import { values, source } from "../lib/args" import { bundleFlux, bundleSolid, compileToBytecode, engineFingerprint, findFluxIsolates } from "../bundle/bundler" import { STOCK_FONT_ROLES } from "../lib/fonts" import { projectRuntime, type ProjectRuntime } from "../lib/runtime" import { fail } from "../lib/fail" import { ANDROID_APP_ID, ANDROID_APP_ID_HINT, loadAppIdentity, loadProject, resolveCapabilities, type Capability, type ManifestFont, type Project } from "../lib/project" import { resolveMode, type Mode } from "../lib/mode" import { packApp, packFlux, packSolid } from "./trailer" import { buildPackFolder, writePackFolder } from "./layout" import { patchApk } from "./android/apk" import { isPng } from "./android/icon" import { requireBinary } from "../lib/util" import { resolveRunnerApk, ANDROID_PKG_MAP, DEFAULT_ANDROID_ABI } from "../lib/artifacts" import { ensureTargets } from "../lib/android-targets" import { existsSync, readFileSync } from "node:fs" import { basename, dirname, join, resolve } from "node:path" // Where an unconfigured APK starts on Android's update ordering. const DEFAULT_VERSION_CODE = 1 // Display version when package.json declares none (matches the runner base). const DEFAULT_VERSION_NAME = "1.0" // Adaptive-icon background when the project sets no iconBackground: the // near-black ground of the unbranded grayscale scaffold icon. const DEFAULT_ICON_BACKGROUND = "#1a1a1a" // The Android permission each capability declares. The runner declares none // itself; SDL requests the runtime grant when a feature opens, which only // works for a declared permission. const ANDROID_CAPABILITY_PERMISSIONS: Record = { camera: "android.permission.CAMERA", microphone: "android.permission.RECORD_AUDIO", vibration: "android.permission.VIBRATE", internet: "android.permission.INTERNET", } // The app's Android launcher icon as PNG bytes: the `icon` config key when it // names a .png, else the assets/icon.png convention. An SVG icon (the desktop // and player-tile source) cannot be rasterized at pack time, so with no PNG // beside it the runner's placeholder stays and a note says so. function resolveLauncherIcon(project: Project | null): Buffer | undefined { if (!project) return undefined let declared = project.config.icon let path = declared?.endsWith(".png") ? resolve(project.dir, declared) : join(project.dir, "assets", "icon.png") if (!existsSync(path)) { if (declared?.endsWith(".png")) { console.error(`"solidrt": "icon" names ${declared}, which does not exist`) process.exit(1) } // Declared SVG or the conventional assets/icon.svg: the app has icon // artwork, just not in a form the patcher can use. if (declared || existsSync(join(project.dir, "assets", "icon.svg"))) { console.log(">> note: an SVG icon cannot be rasterized at pack time; add assets/icon.png for the launcher icon") } return undefined } let bytes = readFileSync(path) if (!isPng(bytes)) { console.error(`${path} is not a PNG file`) process.exit(1) } return bytes } // Windows executables need the suffix; a user-given --output may already // carry it. function exeName(outfile: string): string { return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe") ? outfile + ".exe" : outfile } // Write the packed executable, mark it runnable, and report its size. async function writeExecutable(packed: Buffer, outfile: string) { await Bun.write(outfile, packed) if (process.platform !== "win32") { Bun.spawnSync(["chmod", "+x", outfile]) } console.log(`>> wrote ${packed.length} bytes to ${outfile}`) } // A per-target APK path: the ABI goes in before the extension, so the // targets land side by side (dist/-arm64-v8a.apk). function apkPath(path: string, abi: string): string { return path.endsWith(".apk") ? `${path.slice(0, -".apk".length)}-${abi}.apk` : `${path}-${abi}.apk` } // The app being packed and where its outputs go, reported first by every // solidrt pack. // // One output rule: every deliverable defaults into the gitignored dist/ // build root (okf/backlog/build-output-dirs.md) - files in the root named // by the appId's last segment, flow dirs (pack/, render/, bundle/) below // it - never next to the sources. --output overrides. function packTarget() { let mode = resolveMode() let identity = loadAppIdentity(mode.entry, mode.projectDir) console.log(`>> app: ${identity.appId} (${identity.org} / ${identity.displayName})`) if (identity.defaulted) { console.warn('>> warning: no "solidrt.appId" in package.json; set a stable reverse-DNS id before distributing') } let distRoot = join(mode.projectDir ?? dirname(mode.entry), "dist") let baseName = identity.appId.split(".").pop()! return { mode, identity, distRoot, baseName } } // All solidrt outputs are the same canonical pack: manifest + bundle.fluxbc + // assets (the app's own fonts included). --folder writes it as a flat folder next to a // bare runner; --app writes it alone as one .solapp for a runner to load; // --apk patches it into APKs; the default single-file exe carries it as // trailer sections. // What the pack does about fonts, for the report: what the app binds or // drops, then what the runtime it is packed for carries (`runtime` is its // listed set; null when the project's runtime has not been built here). function describeFonts(fonts: ManifestFont[], runtime: string[] | null): string { let claimed = fonts.map((f) => f.alias) let rest = runtime?.filter((alias) => !claimed.includes(alias)) let runtimes = rest === undefined ? "the runtime's" : `the runtime's (${rest.length ? rest.join(", ") : "none"})` if (fonts.length === 0) return runtimes let own = fonts.map((f) => (f.path ? `${f.alias} from ${f.path}` : `${f.alias} dropped`)) return `${own.join(", ")}; the rest ${runtimes}` } async function buildApp(mode: Mode) { let bundled = await bundleSolid(mode) let bytecode = await compileToBytecode(bundled.code) let isolates = [] for (let i of bundled.isolates) isolates.push({ id: i.id, bytecode: await compileToBytecode(i.code, i.id) }) if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`) // A module only the project's runtime has: the runner packed must be the // project's, so a target it did not build is an error (lib/runtime.ts). let needed = bundled.projectModules[0] ?? null let folder = buildPackFolder(mode, bytecode, isolates) let runtime = projectRuntime() if (runtime) await checkEngine(runtime) console.log(`>> fonts: ${describeFonts(folder.fonts, runtime ? runtime.fonts() : STOCK_FONT_ROLES)}`) return { bytecode, folder, needed } } // The bytecode is compiled by sol's own fluxc, and the runtime packed must // load it: a project runtime's engine (engine.txt, listed by its host build // beside fonts.txt) is compared with the compiler's before anything is // written, since a runtime refuses bytecode from another engine and nothing // else fails before the packed app starts. A runtime whose build listed no // engine (an older runtime.mk) is said, not checked. async function checkEngine(runtime: ProjectRuntime) { let listed = runtime.engine() if (listed === null) { console.log(`>> note: ${runtime.dir} lists no engine (no engine.txt); rebuild the runtime with this release to have its bytecode format checked`) return } if (listed !== (await engineFingerprint())) { fail( `The runtime in ${runtime.dir} runs another JavaScript engine than sol's bytecode compiler, so the app packed here would not load in it. Rebuild the runtime at this release (make -C runtime) or bring @solidrt/cli to the release its lattice tag names.`, ) } } // --apk patches the app into one installable Android APK per installed // target (android-targets.ts; the picker runs when there is none): // application id and label rewritten, permissions declared, the .solapp // payload added as a stored asset, re-aligned and re-signed - pure // TypeScript, no Android SDK (okf/done/standalone-android-apk.md). A // target's base is its runner APK, which boots the payload. Every // @solidrt/android- package carries one; a checkout has one where `make // android-runtime` staged it, and a target without one is skipped with a // note. Returns the APK written per ABI. export async function packApks(): Promise> { let { mode, identity, distRoot, baseName } = packTarget() if (!ANDROID_APP_ID.test(identity.appId)) { console.error(`"solidrt": "appId" ("${identity.appId}") is not a valid Android application id: use ${ANDROID_APP_ID_HINT}`) process.exit(1) } let { bytecode, folder, needed } = await buildApp(mode) let targets: string[] = [] for (let abi of await ensureTargets([], mode.projectDir)) { if (resolveRunnerApk(abi, needed)) targets.push(abi) else console.log(`>> note: no ${abi} runner APK; skipped (in a checkout: make android-runtime ANDROID_ABI=${abi})`) } if (targets.length === 0) { console.error(`Could not find a runner APK; add the ${ANDROID_PKG_MAP[DEFAULT_ANDROID_ABI]} dev dependency, or in a checkout run make android-runtime`) process.exit(1) } let project = loadProject(mode.projectDir) let config = project?.config ?? {} let android = config.android ?? {} let icon = resolveLauncherIcon(project) let permissions = [ ...resolveCapabilities(config).map((name) => ANDROID_CAPABILITY_PERMISSIONS[name]), ...(android.permissions ?? []), ] let backup = config.backup ?? false let payload = packApp(folder, bytecode) console.log(`>> icon: ${icon ? "from project" : "placeholder"}`) console.log(`>> permissions: ${permissions.length ? permissions.join(", ") : "none"}`) console.log(`>> backup: ${backup ? "data/ folder" : "off"}`) console.log(">> signed with the shared development key (fine for sideloading; distribution signing pending)") let written = new Map() for (let abi of targets) { let base = resolveRunnerApk(abi, needed)! console.log(`>> base (${abi}): ${base}`) let apk: Buffer try { apk = patchApk(readFileSync(base), { appId: identity.appId, label: identity.displayName, payload, versionCode: android.versionCode ?? DEFAULT_VERSION_CODE, versionName: project?.version ?? DEFAULT_VERSION_NAME, icon, iconBackground: config.iconBackground ?? DEFAULT_ICON_BACKGROUND, permissions, backup, }) } catch (e) { console.error(`Could not patch ${base}: ${e instanceof Error ? e.message : e}`) process.exit(1) } let outfile = apkPath(values.output ?? join(distRoot, baseName + ".apk"), abi) await Bun.write(outfile, apk) console.log(`>> wrote ${apk.length} bytes to ${outfile}`) written.set(abi, outfile) } return written } export async function main() { if (values.flux) { if (values.folder || values.app || values.apk) { console.error("--folder, --app and --apk are for app packs; flux scripts have no folder, .solapp or APK output") process.exit(1) } let outfile = exeName(values.output ?? join(dirname(resolve(source!)), "dist", basename(source!).replace(/\.[jt]s$/, ""))) // The entry's isolate modules ride along as isolates/.fluxbc sections // (module name = id, for stack attribution). let isolates = [] for (let module of findFluxIsolates(dirname(resolve(source!)))) { isolates.push({ id: module.id, bytecode: await compileToBytecode(await bundleFlux(module.path), module.id) }) } if (isolates.length) console.log(`>> isolates: ${isolates.map((i) => i.id).join(", ")}`) await writeExecutable(packFlux(await compileToBytecode(await bundleFlux(source!)), isolates), outfile) process.exit() } if (values.apk) { await packApks() process.exit() } let { mode, distRoot, baseName } = packTarget() let { bytecode, folder, needed } = await buildApp(mode) if (values.folder) { let outDir = values.output ?? join(distRoot, "pack") writePackFolder(outDir, requireBinary("solidrt", needed), bytecode, folder) console.log(`>> wrote pack folder to ${resolve(outDir)}`) process.exit() } if (values.app) { let outfile = values.output ?? join(distRoot, baseName + ".solapp") let packed = packApp(folder, bytecode) await Bun.write(outfile, packed) console.log(`>> wrote ${packed.length} bytes to ${outfile}`) process.exit() } let outfile = exeName(values.output ?? join(distRoot, baseName)) await writeExecutable(packSolid(folder, bytecode, needed), outfile) process.exit() }