import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path" import { fail } from "./fail" import { checkFontsMap, STOCK_FONT_ROLES } from "./fonts" // Project configuration lives in the `solidrt` key of the project's // package.json (okf/plans/client-storage-updates.md): // // "solidrt": { // "appId": "com.example.app", // stable identity: storage dir, Android package id // "org": "Example", // optional display metadata (publisher) // "displayName": "Example App", // optional display metadata (player/window) // "fonts": { ... }, // see fonts.ts // "textures": { ... }, // how textures are compressed at bake // // time (@solidrt/core/textures) // "icon": "./assets/icon.svg", // optional app icon (SVG, under assets/); // // an undeclared assets/icon.svg is picked // // up by convention. A .png also feeds the // // Android launcher icon (pack --apk) // "iconBackground": "#ffffff", // the ground behind the icon's transparent // // foreground (Android adaptive icon; other // // packers as they compose icons) // "capabilities": { // what the app may ask the user for; all // "camera": true, // on by default (a packed app then works // "microphone": true, // like it did in the player), false drops // "vibration": true, // one; each packer maps the names // "internet": true // }, // "backup": false, // true lets the OS back up the app's data/ // // folder; off keeps it on the device // "android": { // pack --apk (and, later, --aab) // "versionCode": 1, // update ordering; versionName comes from // // the package "version" field // "permissions": ["com.android.vending.BILLING"] // // names beyond what // // the capabilities imply, fully qualified // }, // "runtime": "runtime/dist" // the project's own runtime (a cargo // // project over lattice with the project's // // modules), a directory in the // // SOLIDRT_HOME/dist layout; lib/runtime.ts // } // // The top level holds what the app is and intends, in platform-neutral // terms; whether a platform implements an intention (backup) is that // packer's business. Platform-specific knobs are grouped per target // (android, later windows, macos, flatpak, steam, ...), never nested by // platform (okf/backlog/play-store-aab.md). An unknown key fails, at the // top level and inside a group alike, since a misspelled knob would // otherwise silently ship a default. // // Everything defaults from the package name (or the entry filename when there // is no project) so a dev project needs zero config; `sol pack` warns // when appId is defaulted, since a distributed app should pin its identity. // // A null value means "unset": the scaffold ships the keys as visible nulls // (JSON has no comments, so the file itself is the discovery surface) and a // null behaves exactly like the absent key - defaults, and the defaulted- // appId warning, included. // // The key is read in one place, loadProject, which checks every field's // shape (throw-in-dev policy: a bad value fails the command); the readers // (mode.ts, and the identity and asset collectors below) take // their fields from the result and only check what is specific to them. // // Which project an entry belongs to is the caller's decision (mode.ts: the // cwd, never a search); the one exception is `sol check`, which verifies // trees of entries from one cwd and walks up from each (findProject). /** The `solidrt` key: every field optional, every present field shape-checked. */ export type ProjectConfig = { entry?: string appId?: string org?: string displayName?: string /** * alias -> font file path (the app's own face under that alias, replacing * the runtime's), false to drop the runtime's font under it, or true for * the runtime's (the scaffold's explicit "on", the same as leaving the * key out). The runtime owns the defaults: the stock one binds the three * roles, a custom runtime whatever its lib.rs says (fonts.ts). */ fonts?: Record /** How textures are compressed at bake time; read and checked by * textureSettings in @solidrt/core/textures, not here. */ textures?: Record icon?: string /** The ground behind the icon's transparent foreground, "#rrggbb". */ iconBackground?: string /** Per-capability switches (CAPABILITIES); absent means on. */ capabilities?: Partial> /** Let the OS back up the app's data/ folder; off keeps it on the device. */ backup?: boolean android?: AndroidConfig /** * The project's own runtime binaries (lib/runtime.ts): a directory in the * SOLIDRT_HOME/dist layout, relative to the project. The app runtime * (solidrt, solidrt-go, the APKs) resolves there first. */ runtime?: string } /** The `android` packaging group, every field optional and shape-checked. */ export type AndroidConfig = { /** Android versionCode: a positive integer that must only ever grow. */ versionCode?: number /** * Permissions the packed app declares beyond what its capabilities imply, * fully qualified ("com.android.vending.BILLING"). */ permissions?: string[] } // The capability vocabulary: everything the runtime can ask the user for, in // platform-neutral terms. Each packer maps a name to its platform's // permission (pack/main.ts for Android); an unknown name fails here. All are // on unless the project switches one off, so a packed app can do what it did // in the player; the scaffold lists them all so switching off is a one-line // edit. export const CAPABILITIES = ["camera", "microphone", "vibration", "internet"] as const export type Capability = (typeof CAPABILITIES)[number] /** The capabilities a project keeps: every one not switched off. */ export function resolveCapabilities(config: ProjectConfig): Capability[] { return CAPABILITIES.filter((name) => config.capabilities?.[name] !== false) } // A fully qualified permission name: at least two identifier segments // joined by dots. Every Android permission carries its namespace // (android.permission.*, com.android.vending.*); a bare name is a mistake. const ANDROID_PERMISSION_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/ function parseAndroidConfig(raw: unknown): AndroidConfig { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) fail('"solidrt": "android" must be an object') let group = raw as Record for (let key of Object.keys(group)) { if (!["versionCode", "permissions"].includes(key)) fail(`"solidrt.android": unknown key "${key}"`) if (group[key] === null) delete group[key] } // Android caps versionCode at 2100000000; enforcing it here keeps every // packed artifact installable. if ("versionCode" in group) { let code = group.versionCode if (typeof code !== "number" || !Number.isInteger(code) || code < 1 || code > 2100000000) { fail('"solidrt.android": "versionCode" must be an integer between 1 and 2100000000') } } if ("permissions" in group) { let list = group.permissions if (!Array.isArray(list) || !list.every((p) => typeof p === "string")) { fail('"solidrt.android": "permissions" must be an array of permission names') } for (let name of list as string[]) { if (!ANDROID_PERMISSION_NAME.test(name)) { fail(`"solidrt.android": "permissions" entry "${name}" is not a fully qualified permission name`) } } } return group as AndroidConfig } export type Project = { dir: string; name: string | undefined; version: string | undefined; config: ProjectConfig } // Every key the `solidrt` object accepts (the ProjectConfig fields). const PROJECT_KEYS = ["entry", "appId", "org", "displayName", "icon", "iconBackground", "capabilities", "backup", "fonts", "textures", "android", "runtime"] /** * The font aliases a runtime binds, as its build listed them beside its * binaries (`/fonts.txt`, one alias per line, written by * runtime.mk from `solidrt --fonts`); null when the listing is not there * (the runtime was not built on this host). */ export function runtimeFontAliases(runtimeDir: string): string[] | null { let path = resolve(runtimeDir, "fonts.txt") if (!existsSync(path)) return null return readFileSync(path, "utf8") .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) } /** * The engine fingerprint a runtime's host build listed (`engine.txt`, * `solidrt --engine`); null when the runtime has not been built here, or * was built by a runtime.mk from before the listing. */ export function runtimeEngine(runtimeDir: string): string | null { let path = resolve(runtimeDir, "engine.txt") return existsSync(path) ? readFileSync(path, "utf8").trim() : null } // `dir` is the project's, for the listing of its runtime's fonts. function parseProjectConfig(raw: unknown, dir: string): ProjectConfig { if (raw === undefined) return {} if (typeof raw !== "object" || raw === null || Array.isArray(raw)) fail('"solidrt" in package.json must be an object') let config = raw as Record // Null = unset (see the header note): stripped here so every reader sees // the same absent key it would for an omitted one. for (let key of Object.keys(config)) { if (!PROJECT_KEYS.includes(key)) fail(`"solidrt": unknown key "${key}"`) if (config[key] === null) delete config[key] } for (let key of ["entry", "appId", "org", "displayName", "icon", "runtime"]) { if (key in config && typeof config[key] !== "string") fail(`"solidrt": "${key}" must be a string`) } if ("iconBackground" in config && !(typeof config.iconBackground === "string" && /^#[0-9a-fA-F]{6}$/.test(config.iconBackground))) { fail('"solidrt": "iconBackground" must be a "#rrggbb" color') } if ("capabilities" in config) { let map = config.capabilities if (typeof map !== "object" || map === null || Array.isArray(map)) { fail('"solidrt": "capabilities" must be a map of capability name to true/false') } for (let [name, value] of Object.entries(map)) { if (!(CAPABILITIES as readonly string[]).includes(name)) { fail(`"solidrt.capabilities": "${name}" is not one of ${CAPABILITIES.join(", ")}`) } if (typeof value !== "boolean") fail(`"solidrt.capabilities": "${name}" must be true or false`) } } if ("backup" in config && typeof config.backup !== "boolean") fail('"solidrt": "backup" must be true or false') // What is in the group is its reader's to check: textureSettings in // @solidrt/core/textures, which every texture bake runs. if ("textures" in config && (typeof config.textures !== "object" || Array.isArray(config.textures))) { fail('"solidrt": "textures" must be an object') } if ("android" in config) config.android = parseAndroidConfig(config.android) if ("fonts" in config) { let runtime = typeof config.runtime === "string" ? runtimeFontAliases(resolve(dir, config.runtime)) : STOCK_FONT_ROLES let error = checkFontsMap(config.fonts, runtime) if (error) fail(error) } return config as ProjectConfig } // The project at `dir`: its package name and validated `solidrt` config. A // dir without a package.json is an empty project; null (file mode: the entry // stands alone) stays null. export function loadProject(dir: string | null): Project | null { if (dir === null) return null let pkgPath = resolve(dir, "package.json") let pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")) : {} return { dir, name: typeof pkg.name === "string" ? pkg.name : undefined, version: typeof pkg.version === "string" ? pkg.version : undefined, config: parseProjectConfig(pkg.solidrt, dir), } } // The nearest project above a source file (see the note on searching above). export function findProject(sourcePath: string): Project | null { let dir = resolve(dirname(sourcePath)) while (true) { if (existsSync(resolve(dir, "package.json"))) return loadProject(dir) let parent = dirname(dir) if (parent === dir) return null dir = parent } } export type AppIdentity = { appId: string; org: string; displayName: string; defaulted: boolean } /** * An Android application id, stricter than a general appId: at least two * dot-separated segments, each starting with a letter. What a packed APK * (pack/main.ts) and a custom runtime (lib/runtime.ts, its Player is named * after the app) need the appId to be. */ export const ANDROID_APP_ID = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/ export const ANDROID_APP_ID_HINT = 'reverse-DNS with at least two dot-separated segments, each starting with a letter (e.g. "com.example.app")' // Storage directory component (matches the runtime's safe_component check). let APP_ID_PATTERN = /^[A-Za-z0-9._-]+$/ // Derived values are sanitized into a valid appId; explicit config must // already be valid (throw-in-dev policy: a bad value fails the command). function sanitizeAppId(name: string): string { let id = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "") return id === "" || id === "." || id === ".." ? "app" : id } function checkField(value: string, what: string) { if (Buffer.byteLength(value, "utf8") > 255) fail(`"solidrt": ${what} is too long (max 255 bytes)`) if (/[/\\]/.test(value)) fail(`"solidrt": ${what} must not contain path separators`) if (value === "") fail(`"solidrt": ${what} must not be empty`) } // The version manifest for a bundle (okf/plans/client-storage-updates.md, // stages 2 + 3): appId + runtimeVersion + the bundle entry + the collected // assets/ tree + font annotations. The returned JSON string is canonical - it // travels verbatim to clients and its sha256 is the version id, so it must // never be re-serialized along the way. runtimeVersion is the constant 1 // until the derivation question is settled (see plan). // The manifest's runtimeVersion: a manually bumped constant until the // derivation question is settled (see plan). export const RUNTIME_VERSION = 1 // This CLI's version, the one value every "which sol is this" answer comes // from (--version, the manifest stamp, the MCP server). A published CLI // carries the real version in its package.json; in-repo that is the 0.0.0 // placeholder (see CLAUDE.md, "Versioning"), so a checkout reports the same // git describe the runtime builds stamp themselves with (lattice/Makefile, // flux/build.rs). Falls back to the placeholder when git cannot answer. function cliVersion(): string { let pkgVersion = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")).version if (pkgVersion !== "0.0.0") return pkgVersion let git = Bun.spawnSync({ cmd: ["git", "describe", "--tags", "--always", "--dirty"], cwd: import.meta.dir, stdout: "pipe", stderr: "ignore", }) return git.success ? git.stdout.toString().trim().replace(/^v/, "") : pkgVersion } export const CLI_VERSION: string = cliVersion() // The manifest's solidrtVersion: provenance, not a compat gate like // runtimeVersion - the CLI release (or checkout) that built the version. The // packed runner warns when it differs from its own (lattice/src/main.rs). export const SOLIDRT_VERSION: string = CLI_VERSION // `extra` are build outputs that ship as assets too (isolate bundles); they // follow the assets/ tree in the list, in the order given. export function buildManifest(code: string, entry: string, extra: ManifestAsset[], projectDir: string | null): string { let identity = loadAppIdentity(entry, projectDir) let sha256 = new Bun.CryptoHasher("sha256").update(code).digest("hex") let { assets, fonts, icon } = collectAssets(projectDir) assets.push(...extra) return JSON.stringify({ appId: identity.appId, displayName: identity.displayName, runtimeVersion: RUNTIME_VERSION, solidrtVersion: SOLIDRT_VERSION, ...(icon ? { icon } : {}), bundle: { path: "bundle.js", sha256, size: Buffer.byteLength(code, "utf8") }, ...(assets.length ? { assets } : {}), ...(fonts.length ? { fonts } : {}), }) } export type ManifestAsset = { path: string; sha256: string; size: number } /** A font annotation: an alias bound to an assets/ file, or to nothing (no * path) when the app drops the runner's default for that role. */ export type ManifestFont = { alias: string; path?: string } /** The manifest entry for in-memory asset bytes at `path`. */ export function manifestAssetFor(path: string, bytes: Uint8Array): ManifestAsset { return { path, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), size: bytes.length } } // The manifest asset path for an absolute file inside the project's assets/ // dir, or null when it lies outside it. export function assetPathFor(projectDir: string, abs: string): string | null { let rel = relative(resolve(projectDir, "assets"), abs) if (rel.startsWith("..") || isAbsolute(rel)) return null return "assets/" + rel.split(sep).join("/") } function walkAssets(assetsDir: string, dir: string, out: ManifestAsset[]) { for (let entry of readdirSync(dir, { withFileTypes: true })) { // Dotfiles (.DS_Store and friends) are tooling noise, not app assets. if (entry.name.startsWith(".")) continue let abs = join(dir, entry.name) if (entry.isDirectory()) { walkAssets(assetsDir, abs, out) } else if (entry.isFile()) { let bytes = readFileSync(abs) let path = "assets/" + relative(assetsDir, abs).split(sep).join("/") out.push({ path, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), size: bytes.length }) } } } // The convention-first asset set: everything under the project's assets/ // folder (next to package.json), collected wholesale in sorted order so the // manifest bytes are deterministic. Fonts and the icon are annotations // pointing into that set: `solidrt.fonts` path entries and `solidrt.icon` // must live under assets/ so they reach dev clients and the version store. // A `false` font entry is an annotation without a path: the alias is bound // to nothing, so the runtime (the dev client, render, a packed app alike) // drops its own font under it; `true` is the runtime's font and needs no // entry. The icon is // SVG-only for now: the player renders SVG natively, // and the raster surfaces (window icon, OS embedding) come with later stages // (okf/backlog/app-icons.md). An undeclared assets/icon.svg is picked up by // convention. export function collectAssets(dir: string | null): { assets: ManifestAsset[] fonts: ManifestFont[] icon: string | null } { let project = loadProject(dir) // No project (file mode): the entry stands alone, so no assets at all. if (!project) return { assets: [], fonts: [], icon: null } let projectDir = project.dir let assetsDir = resolve(projectDir, "assets") let assets: ManifestAsset[] = [] if (existsSync(assetsDir) && statSync(assetsDir).isDirectory()) { walkAssets(assetsDir, assetsDir, assets) assets.sort((a, b) => (a.path < b.path ? -1 : 1)) } let fonts: ManifestFont[] = [] for (let [alias, value] of Object.entries(project.config.fonts ?? {})) { // Booleans toggle the runtime's fonts, which are not project assets. if (value === true) continue if (value === false) { fonts.push({ alias }) continue } let path = assetPathFor(projectDir, resolve(projectDir, value)) if (!path) { fail(`"solidrt.fonts": "${alias}": ${value} must live under assets/ (fonts ship as version assets)`) } if (!assets.some((a) => a.path === path)) { fail(`"solidrt.fonts": "${alias}": no such file: ${resolve(projectDir, value)}`) } fonts.push({ path, alias }) } let icon: string | null = null let declared = project.config.icon if (declared !== undefined) { let path = assetPathFor(projectDir, resolve(projectDir, declared)) if (!path) { fail(`"solidrt.icon": ${declared} must live under assets/ (the icon ships as a version asset)`) } if (!path.toLowerCase().endsWith(".svg")) { fail(`"solidrt.icon": ${declared} must be an .svg file`) } if (!assets.some((a) => a.path === path)) { fail(`"solidrt.icon": no such file: ${resolve(projectDir, declared)}`) } icon = path } else if (assets.some((a) => a.path === "assets/icon.svg")) { icon = "assets/icon.svg" } return { assets, fonts, icon } } // Resolve the app identity for a pack. All three fields are guaranteed // non-empty and 255 bytes max (the trailer encoding's length prefix). export function loadAppIdentity(sourcePath: string, projectDir: string | null): AppIdentity { let project = loadProject(projectDir) let config = project?.config ?? {} // A scoped package name (@org/name) defaults to its last segment: identity // fields reject path separators, and derived defaults must never fail that. let fallbackName = (project?.name ?? basename(sourcePath).replace(/\.[jt]sx?$/, "")).split("/").pop()! let defaulted = config.appId === undefined let appId = config.appId ?? sanitizeAppId(fallbackName) if (!defaulted && (!APP_ID_PATTERN.test(appId) || appId === "." || appId === "..")) { fail(`"solidrt": "appId" must match ${APP_ID_PATTERN} (reverse-DNS recommended, e.g. "com.example.app")`) } let displayName = config.displayName ?? fallbackName let org = config.org ?? displayName checkField(appId, '"appId"') checkField(displayName, '"displayName"') checkField(org, '"org"') return { appId, org, displayName, defaulted } }