@downdraft/cli 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/package.json +36 -3
- package/scripts/package-mobile.mjs +779 -0
- package/scripts/package-native.mjs +616 -0
- package/src/args.ts +333 -0
- package/src/assets.ts +523 -0
- package/src/blob-store-s3.ts +145 -0
- package/src/build-games.ts +45 -0
- package/src/build.ts +40 -0
- package/src/bun-env.d.ts +6 -0
- package/src/debug.ts +56 -0
- package/src/dev.ts +231 -0
- package/src/dist.ts +36 -0
- package/src/export.ts +38 -0
- package/src/index.ts +131 -0
- package/src/list-games.ts +37 -0
- package/src/mcp.ts +320 -0
- package/src/new.ts +63 -0
- package/src/paths.ts +101 -0
- package/src/plugin-command.ts +214 -0
- package/src/process-utils.ts +179 -0
- package/src/release.ts +374 -0
- package/src/scaffold-plugin.ts +280 -0
- package/src/scaffold.ts +207 -0
- package/src/test.ts +187 -0
- package/src/usage.ts +255 -0
- package/templates/_shared/ai-companion/devin-config.eta +11 -0
- package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
- package/templates/_shared/ai-companion/logging-rule.eta +3 -0
- package/templates/_shared/ai-companion/loops-rule.eta +3 -0
- package/templates/_shared/partials/config-json-fields.eta +3 -0
- package/templates/_shared/partials/dev-deps.eta +8 -0
- package/templates/_shared/partials/package-json-fields.eta +12 -0
- package/templates/_shared/partials/tsconfig.eta +16 -0
- package/templates/_shared/partials/vscode-extensions.eta +11 -0
- package/templates/_shared/partials/vscode-settings.eta +29 -0
- package/templates/_shared/partials/vscode-tasks.eta +93 -0
- package/templates/full/.gitignore.eta +5 -0
- package/templates/full/.vscode/extensions.json.eta +1 -0
- package/templates/full/.vscode/settings.json.eta +1 -0
- package/templates/full/.vscode/tasks.json.eta +1 -0
- package/templates/full/README.md.eta +45 -0
- package/templates/full/assets/shaders/.eta +0 -0
- package/templates/full/downdraft.config.json.eta +10 -0
- package/templates/full/package.json.eta +9 -0
- package/templates/full/src/entities/.eta +0 -0
- package/templates/full/src/game-module.ts.eta +30 -0
- package/templates/full/src/native-entry.ts.eta +14 -0
- package/templates/full/src/sim-host.ts.eta +19 -0
- package/templates/full/src/sim-worker.ts.eta +99 -0
- package/templates/full/src/systems/.eta +0 -0
- package/templates/full/tsconfig.json.eta +1 -0
- package/templates/gamemodule/README.md.eta +32 -0
- package/templates/gamemodule/downdraft.config.json.eta +3 -0
- package/templates/gamemodule/package.json.eta +9 -0
- package/templates/gamemodule/src/game-module.ts.eta +63 -0
- package/templates/gamemodule/src/native-entry.ts.eta +14 -0
- package/templates/gamemodule/src/sim-host.ts.eta +25 -0
- package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
- package/templates/gamemodule/tsconfig.json.eta +1 -0
- package/templates/minimal/.gitignore.eta +5 -0
- package/templates/minimal/.vscode/extensions.json.eta +1 -0
- package/templates/minimal/.vscode/settings.json.eta +1 -0
- package/templates/minimal/.vscode/tasks.json.eta +1 -0
- package/templates/minimal/README.md.eta +32 -0
- package/templates/minimal/downdraft.config.json.eta +3 -0
- package/templates/minimal/package.json.eta +9 -0
- package/templates/minimal/src/game-module.ts.eta +44 -0
- package/templates/minimal/src/native-entry.ts.eta +14 -0
- package/templates/minimal/tsconfig.json.eta +1 -0
- package/templates/physics/.gitignore.eta +5 -0
- package/templates/physics/.vscode/extensions.json.eta +1 -0
- package/templates/physics/.vscode/settings.json.eta +1 -0
- package/templates/physics/.vscode/tasks.json.eta +1 -0
- package/templates/physics/README.md.eta +36 -0
- package/templates/physics/downdraft.config.json.eta +6 -0
- package/templates/physics/package.json.eta +9 -0
- package/templates/physics/src/game-module.ts.eta +29 -0
- package/templates/physics/src/native-entry.ts.eta +14 -0
- package/templates/physics/src/sim-host.ts.eta +19 -0
- package/templates/physics/src/sim-worker.ts.eta +155 -0
- package/templates/physics/tsconfig.json.eta +1 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// package-mobile.mjs — package a game's native entry into an Android APK.
|
|
3
|
+
//
|
|
4
|
+
// bun packages/cli/scripts/package-mobile.mjs [--abi=arm64-v8a|x86_64|all]
|
|
5
|
+
// [--mode=dev|prod] [--app-id=<id>] [--app-name=<name>] [--version=<v>]
|
|
6
|
+
// [--min-sdk=30] [--install] [--launch] [--mcp] <entry.ts> <out.apk>
|
|
7
|
+
//
|
|
8
|
+
// Produces a genuinely native APK — no WebView, no Capacitor:
|
|
9
|
+
// lib/<abi>/libdowndraft_android.so shell (winit loop + shim ABI exports)
|
|
10
|
+
// lib/<abi>/libnode.so embedded Node (packages/node-mobile)
|
|
11
|
+
// lib/<abi>/libdowndraft_*.so optional engine cdylibs
|
|
12
|
+
// assets/bundle/index.js ESM entry shim
|
|
13
|
+
// assets/bundle/<repo-rel>/*.mjs bundled game + worker modules
|
|
14
|
+
// assets/bundle/node_modules/koffi (+ @koromix/koffi-android-*) FFI
|
|
15
|
+
// assets/bundle/dd-assets/ ?url/new URL/import.meta.glob payloads
|
|
16
|
+
// assets/bundle/manifest.txt extraction manifest for the shell
|
|
17
|
+
//
|
|
18
|
+
// Runtime contract (must match packages/android-shell/src/lib.rs):
|
|
19
|
+
// shell extracts assets/bundle/ → internalDataPath/bundle/, sets
|
|
20
|
+
// DOWNDRAFT_BUNDLE_DIR to that dir, then node::Start("index.js").
|
|
21
|
+
// import.meta.dir/.url rewrites resolve under $DOWNDRAFT_BUNDLE_DIR/
|
|
22
|
+
// dd-assets/…; Worker() spawns resolve real extracted files.
|
|
23
|
+
//
|
|
24
|
+
// APK assembly uses Android build-tools directly (aapt2 → zip jniLibs →
|
|
25
|
+
// zipalign → apksigner debug sign) — the app is hasCode=false so no dex or
|
|
26
|
+
// Gradle is required. packages/android-shell/gradle/ is the equivalent
|
|
27
|
+
// Android Studio skeleton.
|
|
28
|
+
//
|
|
29
|
+
// Mirrors the bundling rules in package-native.mjs — keep the regexes and
|
|
30
|
+
// rewrite semantics in sync.
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import {
|
|
36
|
+
copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
|
|
37
|
+
readdirSync, renameSync, statSync, writeFileSync,
|
|
38
|
+
} from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
// ── args ──
|
|
44
|
+
|
|
45
|
+
const argv = process.argv.slice(2);
|
|
46
|
+
const flagValue = (name) =>
|
|
47
|
+
argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
48
|
+
const abiArg = flagValue("abi") ?? "arm64-v8a";
|
|
49
|
+
const modeArg = flagValue("mode") ?? "prod";
|
|
50
|
+
const doInstall = argv.includes("--install") || argv.includes("--launch");
|
|
51
|
+
const doLaunch = argv.includes("--launch");
|
|
52
|
+
const retainMcp = argv.includes("--mcp");
|
|
53
|
+
const minSdk = flagValue("min-sdk") ?? "30";
|
|
54
|
+
// --node-flavor=lite selects build/dist/android-lite/<abi>/libnode.so
|
|
55
|
+
// (NODEJS_MOBILE_FLAVOR=lite from build-android.mjs: no intl/inspector/
|
|
56
|
+
// sqlite/amaro — ~20MB smaller binary, slightly lower PSS).
|
|
57
|
+
const nodeFlavor = flagValue("node-flavor") ?? "full";
|
|
58
|
+
// Optional engine cdylibs stage only when the bundle references their soname
|
|
59
|
+
// ("downdraft_<lib>" token). --libs= forces inclusion, --exclude-libs=
|
|
60
|
+
// forces exclusion (pair with --env=DOWNDRAFT_NO_GAMEPAD=1 so nothing tries
|
|
61
|
+
// to dlopen a lib that isn't shipped).
|
|
62
|
+
const forceLibs = (flagValue("libs") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
63
|
+
const excludeLibs = (flagValue("exclude-libs") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
64
|
+
// Packaged .so's are stripped by default (debug info never maps — pure APK
|
|
65
|
+
// size). --no-strip keeps symbols for on-device native debugging.
|
|
66
|
+
const doStrip = !argv.includes("--no-strip");
|
|
67
|
+
// --env KEY=VALUE (repeatable) → dd-env.txt in the bundle; the shell
|
|
68
|
+
// setenv()s each before node::Start so process.env sees them.
|
|
69
|
+
const envPairs = argv.filter((a) => a.startsWith("--env=")).map((a) => a.slice(6));
|
|
70
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
71
|
+
const [entry, outfileArg] = positional;
|
|
72
|
+
if (!entry || !outfileArg) {
|
|
73
|
+
console.error("usage: bun package-mobile.mjs [--abi=...] [--mode=...] [--app-id=...] [--app-name=...] [--version=...] [--min-sdk=...] [--node-flavor=full|lite] [--libs=a,b] [--exclude-libs=a,b] [--no-strip] [--env KEY=VAL]... [--install|--launch] [--mcp] <entry.ts> <out.apk>");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ABIS = { "arm64-v8a": "aarch64-linux-android", "x86_64": "x86_64-linux-android" };
|
|
78
|
+
const KOFFI_PKGS = { "arm64-v8a": "@koromix/koffi-android-arm64", "x86_64": "@koromix/koffi-android-x64" };
|
|
79
|
+
const abis = abiArg === "all" ? Object.keys(ABIS) : abiArg.split(",").map((s) => s.trim());
|
|
80
|
+
for (const a of abis) {
|
|
81
|
+
if (!ABIS[a]) throw new Error(`unknown --abi "${a}" — expected: ${Object.keys(ABIS).join(", ")}, all`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const repoRoot = process.cwd();
|
|
85
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const monorepoRoot = resolve(scriptDir, "../../..");
|
|
87
|
+
const gameDir = dirname(dirname(resolve(entry)));
|
|
88
|
+
|
|
89
|
+
// Branding: flags → game package.json → defaults.
|
|
90
|
+
let pkg = {};
|
|
91
|
+
try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone */ }
|
|
92
|
+
const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
93
|
+
// Android package ids must be dot-separated Java identifiers — hyphens are
|
|
94
|
+
// rejected by aapt2 ("not a valid Android package name").
|
|
95
|
+
const sanitizeAppId = (id) =>
|
|
96
|
+
id.split(".").map((seg) => {
|
|
97
|
+
let s = seg.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
98
|
+
if (!/^[a-zA-Z_]/.test(s)) s = "_" + s;
|
|
99
|
+
return s;
|
|
100
|
+
}).join(".");
|
|
101
|
+
const rawAppId = flagValue("app-id") ?? pkg.build?.appId ?? `com.downdraft.${basename(gameDir).replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
102
|
+
const appId = sanitizeAppId(rawAppId);
|
|
103
|
+
if (appId !== rawAppId) console.warn(`[package-mobile] app-id "${rawAppId}" sanitized → "${appId}"`);
|
|
104
|
+
const appName = flagValue("app-name") ?? pkg.productName ?? pkg.build?.productName ?? titleize(pkg.name ?? basename(gameDir));
|
|
105
|
+
const versionName = flagValue("version") ?? pkg.version ?? "0.0.1";
|
|
106
|
+
const verParts = (versionName.match(/\d+/g) ?? ["0"]).map(Number);
|
|
107
|
+
const versionCode = Math.max(1, (verParts[0] ?? 0) * 10000 + (verParts[1] ?? 0) * 100 + (verParts[2] ?? 0));
|
|
108
|
+
|
|
109
|
+
// ── Android SDK / build-tools discovery ──
|
|
110
|
+
|
|
111
|
+
const sdkRoot =
|
|
112
|
+
process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ??
|
|
113
|
+
join(process.env.HOME ?? "", "Android/Sdk");
|
|
114
|
+
if (!existsSync(sdkRoot)) throw new Error("Android SDK not found — set ANDROID_HOME");
|
|
115
|
+
const btRoot = join(sdkRoot, "build-tools");
|
|
116
|
+
const btVer = readdirSync(btRoot).sort().pop();
|
|
117
|
+
const bt = join(btRoot, btVer);
|
|
118
|
+
const platformDir = readdirSync(join(sdkRoot, "platforms")).sort().pop();
|
|
119
|
+
const androidJar = join(sdkRoot, "platforms", platformDir, "android.jar");
|
|
120
|
+
for (const tool of ["aapt2", "zipalign", "apksigner"]) {
|
|
121
|
+
if (!existsSync(join(bt, tool))) throw new Error(`missing build-tool ${tool} in ${bt}`);
|
|
122
|
+
}
|
|
123
|
+
const adb = join(sdkRoot, "platform-tools", "adb");
|
|
124
|
+
|
|
125
|
+
const run = (cmd, args, opts = {}) => {
|
|
126
|
+
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
|
127
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args[0]}… failed (exit ${r.status})`);
|
|
128
|
+
return r;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── Bundle (mirrors package-native.mjs rules; paths rebase to the
|
|
132
|
+
// extracted bundle dir via DOWNDRAFT_BUNDLE_DIR) ──
|
|
133
|
+
|
|
134
|
+
const QUERY_RE = /\?(raw|url|json)$/;
|
|
135
|
+
const WORKER_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
|
|
136
|
+
// Rewrite variant — additionally consumes new URL's closing paren so the
|
|
137
|
+
// Worker's own arg list (options object, close paren) stays balanced.
|
|
138
|
+
const WORKER_URL_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)\s*(?:\.href|\.toString\(\s*\))?\s*/g;
|
|
139
|
+
const ASSET_URL_RE = /new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
|
|
140
|
+
const IMPORT_META = /(?:\(\s*import\.meta\s+as\s+any\s*\)|import\.meta(?:\s+as\s+any)?)/;
|
|
141
|
+
const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1`, "g");
|
|
142
|
+
// Full eager call — captured so module globs can be expanded into static
|
|
143
|
+
// imports at package time. The FS glob shim returns file:// URLs only;
|
|
144
|
+
// an eager module glob's side effects (e.g. registerTest()) can never
|
|
145
|
+
// run through it — the modules must be bundled and evaluated.
|
|
146
|
+
const GLOB_EAGER_RE = new RegExp(
|
|
147
|
+
IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1\s*,\s*(\{[^)]*\beager\s*:\s*true[^)]*\})\s*\)`, "g");
|
|
148
|
+
const STAGE_GLOB_RE = new RegExp(
|
|
149
|
+
`(?:${IMPORT_META.source}\\.glob|_glob)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
|
|
150
|
+
const STAGE_READ_RE = new RegExp(
|
|
151
|
+
String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
|
|
152
|
+
const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
|
|
153
|
+
const META_URL_RE = new RegExp(IMPORT_META.source + String.raw`\.url\b`, "g");
|
|
154
|
+
const REQUIRE_GLOB_RE = /require\(\s*["'`]@downdraft\/engine\/platform\/glob-polyfill["'`]\s*\)/g;
|
|
155
|
+
const GLOB_CALLEE_RE = new RegExp(IMPORT_META.source + String.raw`\.glob`);
|
|
156
|
+
const SCRIPT_EXT_RE = /\.(ts|tsx|js|jsx|mts|cts|cjs)$/i; // .mjs staged as an asset (execArgv loaders)
|
|
157
|
+
const DYN_IMPORT_RE = /import\(\s*([a-zA-Z_$][\w$]*)\s*\)/g;
|
|
158
|
+
|
|
159
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
160
|
+
|
|
161
|
+
function resolveSpec(spec, importer) {
|
|
162
|
+
try {
|
|
163
|
+
return spec.startsWith(".") || spec.startsWith("/")
|
|
164
|
+
? resolve(dirname(importer), spec)
|
|
165
|
+
: Bun.resolveSync(spec, dirname(importer));
|
|
166
|
+
} catch { return null; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Repo-relative bundle path for a module, with .mjs extension. Bun's
|
|
170
|
+
* `[dir]` naming escapes out-of-root `..` segments as `_.._` — the staged
|
|
171
|
+
* tree contains `_.._/_.._/packages/...`, so emit the same encoding or
|
|
172
|
+
* spawned Worker paths resolve outside the extracted bundle. */
|
|
173
|
+
function bundlePath(absPath) {
|
|
174
|
+
return relative(repoRoot, absPath).replace(/\\/g, "/").replace(/\.[jt]sx?$/, ".mjs")
|
|
175
|
+
.split("/").map((s) => (s === ".." ? "_.._" : s)).join("/");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assetRel(absPath) {
|
|
179
|
+
const rel = relative(repoRoot, absPath).replace(/\\/g, "/");
|
|
180
|
+
if (!rel.startsWith("..")) return rel;
|
|
181
|
+
const h = createHash("sha1").update(dirname(absPath)).digest("hex").slice(0, 8);
|
|
182
|
+
return `external/${h}/${basename(absPath)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function walkDir(dir, results = []) {
|
|
186
|
+
let entries;
|
|
187
|
+
try { entries = readdirSync(dir); } catch { return results; }
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
const full = `${dir}/${e}`;
|
|
190
|
+
let st;
|
|
191
|
+
try { st = statSync(full); } catch { continue; }
|
|
192
|
+
if (st.isDirectory()) walkDir(full, results);
|
|
193
|
+
else results.push(full);
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Mirrors globToRegex in packages/engine/core/src/platform/glob-polyfill.ts
|
|
199
|
+
// — keep in sync.
|
|
200
|
+
function globToRegex(pattern) {
|
|
201
|
+
const regex = pattern
|
|
202
|
+
.replace(/[.+^$()|[\]\\]/g, "\\$&")
|
|
203
|
+
.replace(/\*\*\//g, "\x00")
|
|
204
|
+
.replace(/\*\*/g, "\x01")
|
|
205
|
+
.replace(/\*/g, "[^/]*")
|
|
206
|
+
.replace(/\{([^}]+)\}/g, (_, group) => `(${group.replace(/,/g, "|")})`)
|
|
207
|
+
.replace(/\x00/g, "(?:.*/)?")
|
|
208
|
+
.replace(/\x01/g, ".*");
|
|
209
|
+
return new RegExp(`^${regex}$`, "i");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Expand an import.meta.glob pattern against the real FS at package time. */
|
|
213
|
+
function expandGlob(fromDir, pattern) {
|
|
214
|
+
const rootParts = [];
|
|
215
|
+
for (const p of pattern.split("/")) {
|
|
216
|
+
if (p.includes("*") || p.includes("{")) break;
|
|
217
|
+
rootParts.push(p);
|
|
218
|
+
}
|
|
219
|
+
const searchDir = resolve(fromDir, ...rootParts);
|
|
220
|
+
if (!existsSync(searchDir) || !statSync(searchDir).isDirectory()) return [];
|
|
221
|
+
const matchRegex = globToRegex(pattern.replace(/^\.\//, ""));
|
|
222
|
+
return walkDir(searchDir)
|
|
223
|
+
.map((f) => relative(fromDir, f).replace(/\\/g, "/"))
|
|
224
|
+
.filter((rel) => matchRegex.test(rel) || matchRegex.test(`./${rel}`))
|
|
225
|
+
.sort();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const workerEntries = new Set();
|
|
229
|
+
const dynImports = new Map();
|
|
230
|
+
const assetFiles = new Set();
|
|
231
|
+
|
|
232
|
+
const makePlugin = (collectWorkers) => ({
|
|
233
|
+
name: "downdraft-mobile-build",
|
|
234
|
+
setup(build) {
|
|
235
|
+
const resolveImport = (spec, importer) => {
|
|
236
|
+
const base = spec.startsWith(".") || spec.startsWith("/")
|
|
237
|
+
? resolve(dirname(importer), spec)
|
|
238
|
+
: Bun.resolveSync(spec, dirname(importer));
|
|
239
|
+
return existsSync(base) ? base : (existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
|
240
|
+
};
|
|
241
|
+
build.onResolve({ filter: /\?(raw|url|json)$/ }, (args) => {
|
|
242
|
+
const clean = args.path.replace(QUERY_RE, "");
|
|
243
|
+
const kind = args.path.match(QUERY_RE)[1];
|
|
244
|
+
const resolved = resolveImport(clean, args.importer);
|
|
245
|
+
if (resolved.endsWith(".ts")) return { path: resolved };
|
|
246
|
+
return { path: resolved, namespace: `dd-${kind}` };
|
|
247
|
+
});
|
|
248
|
+
build.onLoad({ filter: /.*/, namespace: "dd-raw" }, (args) => ({
|
|
249
|
+
contents: `export default ${JSON.stringify(readFileSync(args.path, "utf-8"))};`,
|
|
250
|
+
loader: "js",
|
|
251
|
+
}));
|
|
252
|
+
build.onLoad({ filter: /.*/, namespace: "dd-json" }, (args) => ({
|
|
253
|
+
contents: `export default ${JSON.stringify(JSON.parse(readFileSync(args.path, "utf-8")))};`,
|
|
254
|
+
loader: "js",
|
|
255
|
+
}));
|
|
256
|
+
build.onLoad({ filter: /.*/, namespace: "dd-url" }, (args) => {
|
|
257
|
+
assetFiles.add(args.path);
|
|
258
|
+
if (collectWorkers) {
|
|
259
|
+
return { contents: `export default ${JSON.stringify(args.path)};`, loader: "js" };
|
|
260
|
+
}
|
|
261
|
+
const rel = assetRel(args.path);
|
|
262
|
+
return {
|
|
263
|
+
contents:
|
|
264
|
+
`import { pathToFileURL as __ddP2F } from "node:url";` +
|
|
265
|
+
`export default __ddP2F(process.env.DOWNDRAFT_BUNDLE_DIR + "/dd-assets/" + ${JSON.stringify(rel)}).href;`,
|
|
266
|
+
loader: "js",
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
["wgsl", "glsl"].forEach((ext) => {
|
|
270
|
+
build.onResolve({ filter: new RegExp(`\\.${ext}$`) }, (args) => {
|
|
271
|
+
const resolved = resolveImport(args.path, args.importer);
|
|
272
|
+
if (resolved.endsWith(".ts")) return { path: resolved };
|
|
273
|
+
return { path: resolved, namespace: "dd-raw" };
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
build.onResolve({ filter: /\.css$/ }, (args) => {
|
|
277
|
+
if (args.path.startsWith("dd-css:")) {
|
|
278
|
+
return { path: args.path.slice("dd-css:".length), namespace: "dd-css" };
|
|
279
|
+
}
|
|
280
|
+
let path;
|
|
281
|
+
try { path = resolveImport(args.path, args.importer); }
|
|
282
|
+
catch { path = resolve(dirname(args.importer), args.path); }
|
|
283
|
+
return { path, namespace: "dd-css" };
|
|
284
|
+
});
|
|
285
|
+
build.onLoad({ filter: /.*/, namespace: "dd-css" }, () => ({
|
|
286
|
+
contents: `export default "";`, loader: "js",
|
|
287
|
+
}));
|
|
288
|
+
build.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
|
|
289
|
+
const src = readFileSync(args.path, "utf-8");
|
|
290
|
+
const modDir = dirname(args.path);
|
|
291
|
+
const modRel = assetRel(args.path);
|
|
292
|
+
const modBase = basename(modRel).replace(/\.[jt]sx?$/, ".js");
|
|
293
|
+
const stagedDir = dirname(modRel);
|
|
294
|
+
|
|
295
|
+
for (const m of src.matchAll(WORKER_RE)) {
|
|
296
|
+
const p = resolve(modDir, m[1]);
|
|
297
|
+
if (existsSync(p)) workerEntries.add(p);
|
|
298
|
+
}
|
|
299
|
+
for (const m of src.matchAll(ASSET_URL_RE)) {
|
|
300
|
+
// Scripts are bundled; .mjs loaders (worker-bootstrap, wgsl-loader)
|
|
301
|
+
// are spawned via execArgv and must exist as real files — stage them.
|
|
302
|
+
if (SCRIPT_EXT_RE.test(m[1])) continue;
|
|
303
|
+
const p = resolve(modDir, m[1]);
|
|
304
|
+
if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
|
|
305
|
+
}
|
|
306
|
+
for (const m of src.matchAll(DYN_IMPORT_RE)) {
|
|
307
|
+
const ident = m[1];
|
|
308
|
+
const bind = src.match(new RegExp("(?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*([\"'`])([^\"'`]+)\\1"));
|
|
309
|
+
if (!bind) continue;
|
|
310
|
+
const spec = bind[2];
|
|
311
|
+
if (!spec || spec.startsWith("node:") || spec.startsWith("bun:")) continue;
|
|
312
|
+
const abs = resolveSpec(spec, args.path);
|
|
313
|
+
if (!abs || !existsSync(abs)) continue;
|
|
314
|
+
workerEntries.add(abs);
|
|
315
|
+
dynImports.set(`${args.path}:${ident}`, { ident, spec, abs });
|
|
316
|
+
}
|
|
317
|
+
for (const m of src.matchAll(STAGE_READ_RE)) {
|
|
318
|
+
if (SCRIPT_EXT_RE.test(m[2])) continue;
|
|
319
|
+
const p = resolve(modDir, m[2]);
|
|
320
|
+
if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
|
|
321
|
+
}
|
|
322
|
+
for (const m of src.matchAll(STAGE_GLOB_RE)) {
|
|
323
|
+
const pattern = m[2] ?? m[4];
|
|
324
|
+
if (!pattern) continue;
|
|
325
|
+
const rootParts = [];
|
|
326
|
+
for (const p of pattern.split("/")) {
|
|
327
|
+
if (p.includes("*") || p.includes("{")) break;
|
|
328
|
+
rootParts.push(p);
|
|
329
|
+
}
|
|
330
|
+
const searchDir = resolve(modDir, ...rootParts);
|
|
331
|
+
if (existsSync(searchDir) && statSync(searchDir).isDirectory()) {
|
|
332
|
+
for (const f of walkDir(searchDir)) assetFiles.add(f);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const loader = { ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", mts: "ts" }[args.path.split(".").pop()] || "ts";
|
|
337
|
+
let needsGlob = false;
|
|
338
|
+
|
|
339
|
+
// 0. Eager module globs (no `query:`) → static imports generated
|
|
340
|
+
// at package time. The FS glob shim returns file:// URLs — it
|
|
341
|
+
// can't execute module side effects — so an eager glob's
|
|
342
|
+
// registrations (registerTest(), etc.) must run inside the
|
|
343
|
+
// bundle. Runs in the scan pass too so the worker fixpoint
|
|
344
|
+
// reaches workers declared by eagerly-globbed modules. Eager
|
|
345
|
+
// globs WITH `query:` resolve to asset payloads — the FS
|
|
346
|
+
// shim's file:// URLs are already the right value shape.
|
|
347
|
+
const eagerDecls = [];
|
|
348
|
+
let eagerSeq = 0;
|
|
349
|
+
let out = src.replace(GLOB_EAGER_RE, (m, _q, pattern, opts) => {
|
|
350
|
+
if (/\bquery\s*:/.test(opts)) {
|
|
351
|
+
needsGlob = true;
|
|
352
|
+
return m.replace(GLOB_CALLEE_RE, "__ddGlob");
|
|
353
|
+
}
|
|
354
|
+
const spec = /\bimport\s*:\s*["']([A-Za-z_$][\w$]*)["']/.exec(opts)?.[1];
|
|
355
|
+
const name = `__ddEager${eagerSeq++}`;
|
|
356
|
+
const entries = expandGlob(modDir, pattern).map((f, i) => {
|
|
357
|
+
const rel = f.startsWith(".") ? f : "./" + f;
|
|
358
|
+
const ident = `${name}$${i}`;
|
|
359
|
+
eagerDecls.push(`import * as ${ident} from ${JSON.stringify(rel)};`);
|
|
360
|
+
return `${JSON.stringify(rel)}: ${spec ? `${ident}.${spec}` : ident}`;
|
|
361
|
+
});
|
|
362
|
+
eagerDecls.push(`const ${name} = {${entries.length ? "\n " + entries.join(",\n ") + ",\n" : ""}};`);
|
|
363
|
+
console.log(` eager glob ${pattern} → ${entries.length} module(s) [${relative(repoRoot, args.path)}]`);
|
|
364
|
+
return name;
|
|
365
|
+
});
|
|
366
|
+
const prologue = eagerDecls.length ? eagerDecls.join("\n") + "\n" : "";
|
|
367
|
+
|
|
368
|
+
if (collectWorkers) return prologue ? { contents: prologue + out, loader } : undefined;
|
|
369
|
+
|
|
370
|
+
// 1. Worker URLs → absolute path inside the extracted bundle
|
|
371
|
+
// (worker_threads needs a real file on disk). WORKER_URL_RE eats
|
|
372
|
+
// new URL's close paren so a trailing `, { type: "module" })`
|
|
373
|
+
// remains Worker's second argument.
|
|
374
|
+
out = out.replace(WORKER_URL_RE, (m, spec) => {
|
|
375
|
+
const rel = bundlePath(resolve(modDir, spec));
|
|
376
|
+
return `new Worker(process.env.DOWNDRAFT_BUNDLE_DIR + "/${rel}"`;
|
|
377
|
+
});
|
|
378
|
+
// 1.5 new URL("<asset>", import.meta.url) → absolute file URL into the
|
|
379
|
+
// staged dd-assets tree. assetRel() gives the TARGET's own staged
|
|
380
|
+
// dir — resolving the spec textually against __ddModUrl breaks when
|
|
381
|
+
// the target lives under a different hashed dir (repo-external
|
|
382
|
+
// sources stage at external/<sha1(dir)>/, so "../ffi/x.mjs" would
|
|
383
|
+
// escape into external/ffi/).
|
|
384
|
+
out = out.replace(ASSET_URL_RE, (m, spec) => {
|
|
385
|
+
const p = resolve(modDir, spec);
|
|
386
|
+
if (!existsSync(p)) return m;
|
|
387
|
+
return `new URL("file://" + process.env.DOWNDRAFT_BUNDLE_DIR + "/dd-assets/" + ${JSON.stringify(assetRel(p))})`;
|
|
388
|
+
});
|
|
389
|
+
// 2. Variable-specifier dynamic imports → bundled module path.
|
|
390
|
+
for (const m of out.matchAll(DYN_IMPORT_RE)) {
|
|
391
|
+
const ident = m[1];
|
|
392
|
+
const info = dynImports.get(`${args.path}:${ident}`);
|
|
393
|
+
if (!info) continue;
|
|
394
|
+
const decl = new RegExp("((?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*)([\"'`])" + escapeRe(info.spec) + "\\2");
|
|
395
|
+
out = out.replace(decl, `$1$2@@DD_BUNDLE@@/${bundlePath(info.abs)}$2`);
|
|
396
|
+
}
|
|
397
|
+
// 3. import.meta.glob → createGlob bound to the staged dir.
|
|
398
|
+
out = out.replace(GLOB_RE, (m) => { needsGlob = true; return m.replace(GLOB_CALLEE_RE, "__ddGlob"); });
|
|
399
|
+
if (REQUIRE_GLOB_RE.test(out)) {
|
|
400
|
+
needsGlob = true;
|
|
401
|
+
out = out.replace(REQUIRE_GLOB_RE, "({ createGlob: __ddCreateGlob })");
|
|
402
|
+
}
|
|
403
|
+
// 4. import.meta.dir/.url/__dirname → module's staged dir under
|
|
404
|
+
// dd-assets (real FS after extraction).
|
|
405
|
+
const metaOut = out
|
|
406
|
+
.replace(META_DIR_RE, "__ddModDir")
|
|
407
|
+
.replace(META_URL_RE, "__ddModUrl")
|
|
408
|
+
.replace(/(?<![.\w$])__dirname\b/g, "__ddModDir");
|
|
409
|
+
if (metaOut !== out || needsGlob) {
|
|
410
|
+
out =
|
|
411
|
+
`import { dirname as __ddDirname } from "node:path";\n` +
|
|
412
|
+
`import { pathToFileURL as __ddP2F } from "node:url";\n` +
|
|
413
|
+
`const __ddModDir = process.env.DOWNDRAFT_BUNDLE_DIR + "/dd-assets/" + ${JSON.stringify(stagedDir === "." ? "" : stagedDir + "/")}.replace(/\\/$/, "");\n` +
|
|
414
|
+
`const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
|
|
415
|
+
(needsGlob
|
|
416
|
+
? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
|
|
417
|
+
`const __ddGlob = __ddCreateGlob(__ddModDir);\n`
|
|
418
|
+
: "") +
|
|
419
|
+
metaOut;
|
|
420
|
+
}
|
|
421
|
+
// @@DD_BUNDLE@@ sentinel → env path (post meta rewrite so META_URL
|
|
422
|
+
// doesn't touch it).
|
|
423
|
+
out = out.replaceAll("@@DD_BUNDLE@@", `" + process.env.DOWNDRAFT_BUNDLE_DIR + "`);
|
|
424
|
+
if (prologue) out = prologue + out;
|
|
425
|
+
return out === src ? undefined : { contents: out, loader };
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const stageRoot = mkdtempSync(join(tmpdir(), "dd-android-"));
|
|
431
|
+
const bundleDir = join(stageRoot, "assets", "bundle");
|
|
432
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
433
|
+
|
|
434
|
+
// Pass 1: discover worker entrypoints to a fixpoint.
|
|
435
|
+
const scanOutdir = mkdtempSync(join(tmpdir(), "dd-scan-"));
|
|
436
|
+
let prevCount = -1;
|
|
437
|
+
while (workerEntries.size !== prevCount) {
|
|
438
|
+
prevCount = workerEntries.size;
|
|
439
|
+
const scan = await Bun.build({
|
|
440
|
+
entrypoints: [resolve(entry), ...workerEntries],
|
|
441
|
+
target: "node",
|
|
442
|
+
plugins: [makePlugin(true)],
|
|
443
|
+
splitting: false,
|
|
444
|
+
sourcemap: "none",
|
|
445
|
+
minify: false,
|
|
446
|
+
define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
|
|
447
|
+
outdir: scanOutdir,
|
|
448
|
+
});
|
|
449
|
+
if (!scan.success) {
|
|
450
|
+
scan.logs.forEach((msg) => { console.error(msg); });
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const entrypoints = [resolve(entry), ...workerEntries];
|
|
456
|
+
if (workerEntries.size) {
|
|
457
|
+
console.log(`workers: ${[...workerEntries].map((p) => p.split("/").pop()).join(", ")}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const build = await Bun.build({
|
|
461
|
+
entrypoints,
|
|
462
|
+
// No `compile` — libnode executes the extracted ESM files.
|
|
463
|
+
target: "node",
|
|
464
|
+
root: repoRoot,
|
|
465
|
+
naming: { entry: "[dir]/[name].mjs" },
|
|
466
|
+
plugins: [makePlugin(false)],
|
|
467
|
+
splitting: false,
|
|
468
|
+
sourcemap: "none",
|
|
469
|
+
minify: modeArg === "prod",
|
|
470
|
+
define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
|
|
471
|
+
external: ["koffi", "bun:ffi", "bun:test", "bun"],
|
|
472
|
+
outdir: bundleDir,
|
|
473
|
+
});
|
|
474
|
+
if (!build.success) {
|
|
475
|
+
build.logs.forEach((msg) => { console.error(msg); });
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Entry shim: the shell always runs bundle/index.js (CJS-safe dynamic
|
|
480
|
+
// import — no type:module package.json needed for the .mjs outputs).
|
|
481
|
+
const entryRel = bundlePath(resolve(entry));
|
|
482
|
+
writeFileSync(join(bundleDir, "index.js"),
|
|
483
|
+
`await import("./${entryRel}");\n`);
|
|
484
|
+
writeFileSync(join(bundleDir, "package.json"), JSON.stringify({ type: "module" }) + "\n");
|
|
485
|
+
|
|
486
|
+
// ── node_modules: koffi + the ABI-matched @koromix/koffi-android-* prebuild ──
|
|
487
|
+
|
|
488
|
+
const { createRequire } = await import("node:module");
|
|
489
|
+
const gameRequire = createRequire(resolve(entry));
|
|
490
|
+
// koffi's exports map doesn't expose ./package.json — resolve the entry.
|
|
491
|
+
let koffiRoot = null;
|
|
492
|
+
try { koffiRoot = dirname(gameRequire.resolve("koffi")); } catch { /* not resolvable */ }
|
|
493
|
+
if (!koffiRoot) {
|
|
494
|
+
console.warn("[package-mobile] koffi not resolvable — FFI will fail at runtime");
|
|
495
|
+
} else {
|
|
496
|
+
const nm = join(bundleDir, "node_modules");
|
|
497
|
+
cpSync(koffiRoot, join(nm, "koffi"), { recursive: true });
|
|
498
|
+
const koffiVer = JSON.parse(readFileSync(join(koffiRoot, "package.json"), "utf-8")).version;
|
|
499
|
+
for (const abi of abis) {
|
|
500
|
+
const pkgName = KOFFI_PKGS[abi];
|
|
501
|
+
// The @koromix prebuild pkgs carry no importable entry — probe the dir
|
|
502
|
+
// beside koffi (same layout koffi's loader expects), else fetch npm.
|
|
503
|
+
let src = join(dirname(koffiRoot), pkgName);
|
|
504
|
+
if (!existsSync(join(src, "package.json"))) src = null;
|
|
505
|
+
if (!src) {
|
|
506
|
+
const fetched = await fetchKoffiPrebuild(pkgName, koffiVer);
|
|
507
|
+
src = fetched;
|
|
508
|
+
}
|
|
509
|
+
if (!src) {
|
|
510
|
+
console.warn(`[package-mobile] ${pkgName}@${koffiVer} unavailable — FFI will fail on ${abi}`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
cpSync(src, join(nm, "@koromix", basename(pkgName)), { recursive: true });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Fetch @koromix/koffi-android-<abi>@<ver> from the npm registry → temp dir. */
|
|
518
|
+
async function fetchKoffiPrebuild(pkgName, ver) {
|
|
519
|
+
try {
|
|
520
|
+
const meta = await (await fetch(
|
|
521
|
+
`https://registry.npmjs.org/${pkgName.replace("/", "%2F")}`)).json();
|
|
522
|
+
const tarball = meta?.versions?.[ver]?.dist?.tarball ?? meta?.versions?.[meta["dist-tags"]?.latest]?.dist?.tarball;
|
|
523
|
+
if (!tarball) return null;
|
|
524
|
+
const dir = mkdtempSync(join(tmpdir(), "dd-koffi-"));
|
|
525
|
+
const tgz = join(dir, "pkg.tgz");
|
|
526
|
+
writeFileSync(tgz, Buffer.from(await (await fetch(tarball)).arrayBuffer()));
|
|
527
|
+
run("tar", ["-xzf", tgz, "-C", dir]);
|
|
528
|
+
return join(dir, "package");
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.warn(`[package-mobile] koffi prebuild fetch failed: ${e?.message ?? e}`);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Follow relative imports inside staged script assets — execArgv loader
|
|
536
|
+
// files (wgsl-loader.mjs, worker-bootstrap.mjs) are staged raw, not
|
|
537
|
+
// bundled, so their own module deps never enter the graph otherwise.
|
|
538
|
+
{
|
|
539
|
+
const ASSET_IMPORT_RE = /(?:from|import|register)\s*\(?\s*(["'`])(\.[^"'`]+)\1/g;
|
|
540
|
+
let grew = true;
|
|
541
|
+
while (grew) {
|
|
542
|
+
grew = false;
|
|
543
|
+
for (const f of [...assetFiles]) {
|
|
544
|
+
if (!/\.(mjs|cjs|js)$/.test(f)) continue;
|
|
545
|
+
let src;
|
|
546
|
+
try { src = readFileSync(f, "utf-8"); } catch { continue; }
|
|
547
|
+
for (const m of src.matchAll(ASSET_IMPORT_RE)) {
|
|
548
|
+
const p = resolve(dirname(f), m[2]);
|
|
549
|
+
if (existsSync(p) && statSync(p).isFile() && !assetFiles.has(p)) {
|
|
550
|
+
assetFiles.add(p);
|
|
551
|
+
grew = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── dd-assets staging ──
|
|
559
|
+
|
|
560
|
+
const assetRoot = join(bundleDir, "dd-assets");
|
|
561
|
+
let stagedAssets = 0;
|
|
562
|
+
for (const f of assetFiles.values()) {
|
|
563
|
+
const dest = join(assetRoot, assetRel(f));
|
|
564
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
565
|
+
copyFileSync(f, dest);
|
|
566
|
+
stagedAssets++;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// dd-env.txt — optional process env for the JS thread (debug probes like
|
|
570
|
+
// DD_BENCH_TEST; Android has no launch-env channel).
|
|
571
|
+
if (envPairs.length) {
|
|
572
|
+
for (const p of envPairs) {
|
|
573
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(p)) {
|
|
574
|
+
throw new Error(`--env "${p}" must be KEY=VALUE (KEY: [A-Za-z_][A-Za-z0-9_]*)`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
writeFileSync(join(bundleDir, "dd-env.txt"), envPairs.join("\n") + "\n");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// manifest.txt — the shell's extractor follows this verbatim (AAssetDir
|
|
581
|
+
// can't enumerate subdirectories). aapt2 silently drops asset paths with
|
|
582
|
+
// dot-prefixed segments (.bun, .gitignore), so each line is
|
|
583
|
+
// "asset_path\tdest_path": dot-segments are staged as "_<rest>" while the
|
|
584
|
+
// extractor restores the verbatim destination path.
|
|
585
|
+
function aaptSafePath(rel) {
|
|
586
|
+
return rel.split("/").map((s) => (s.startsWith(".") ? `_${s.slice(1)}` : s)).join("/");
|
|
587
|
+
}
|
|
588
|
+
{
|
|
589
|
+
const seen = new Map();
|
|
590
|
+
const lines = walkDir(bundleDir).map((f) => {
|
|
591
|
+
const dest = relative(bundleDir, f).replace(/\\/g, "/");
|
|
592
|
+
const asset = aaptSafePath(dest);
|
|
593
|
+
const prev = seen.get(asset);
|
|
594
|
+
if (prev && prev !== dest) throw new Error(`asset-path collision: "${dest}" vs "${prev}"`);
|
|
595
|
+
seen.set(asset, dest);
|
|
596
|
+
if (asset !== dest) {
|
|
597
|
+
const target = join(bundleDir, asset);
|
|
598
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
599
|
+
renameSync(f, target);
|
|
600
|
+
}
|
|
601
|
+
return `${asset}\t${dest}`;
|
|
602
|
+
});
|
|
603
|
+
lines.push("manifest.txt\tmanifest.txt");
|
|
604
|
+
writeFileSync(join(bundleDir, "manifest.txt"), lines.sort().join("\n") + "\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── Native libs ──
|
|
608
|
+
|
|
609
|
+
const nativeStage = join(stageRoot, "lib");
|
|
610
|
+
|
|
611
|
+
// NDK discovery — needed for libc++_shared.so fallback and llvm-strip.
|
|
612
|
+
const ndkRoot = process.env.ANDROID_NDK_HOME ?? process.env.ANDROID_NDK ??
|
|
613
|
+
readdirSync(join(sdkRoot, "ndk")).map((v) => join(sdkRoot, "ndk", v))
|
|
614
|
+
.filter((d) => existsSync(join(d, "toolchains"))).sort().pop();
|
|
615
|
+
const stripTool = (() => {
|
|
616
|
+
if (!ndkRoot) return null;
|
|
617
|
+
const prebuiltDir = join(ndkRoot, "toolchains/llvm/prebuilt");
|
|
618
|
+
if (!existsSync(prebuiltDir)) return null;
|
|
619
|
+
for (const host of readdirSync(prebuiltDir)) {
|
|
620
|
+
const p = join(prebuiltDir, host, "bin", process.platform === "win32" ? "llvm-strip.exe" : "llvm-strip");
|
|
621
|
+
if (existsSync(p)) return p;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
})();
|
|
625
|
+
|
|
626
|
+
// Which optional engine cdylibs the bundle references — scan staged .mjs
|
|
627
|
+
// for the crate's base token (dlopen paths derive sonames from
|
|
628
|
+
// "downdraft_<lib>"). Unreferenced libs cost only APK size (never dlopened
|
|
629
|
+
// → never mapped), but pruning them keeps packages honest per game.
|
|
630
|
+
const bundleText = walkDir(bundleDir)
|
|
631
|
+
.filter((f) => f.endsWith(".mjs") || f.endsWith(".js"))
|
|
632
|
+
.map((f) => readFileSync(f, "utf-8"))
|
|
633
|
+
.join("\n");
|
|
634
|
+
|
|
635
|
+
for (const abi of abis) {
|
|
636
|
+
const triple = ABIS[abi];
|
|
637
|
+
const libDir = join(nativeStage, abi);
|
|
638
|
+
mkdirSync(libDir, { recursive: true });
|
|
639
|
+
|
|
640
|
+
// libnode.so — node-mobile build tree or npm platform package. Resolved
|
|
641
|
+
// BEFORE build-native: the shell crate links against it (DT_NEEDED) via
|
|
642
|
+
// DOWNDRAFT_LIBNODE_DIR. --node-flavor=<f> looks in dist/android-<f>/ first.
|
|
643
|
+
const libnodeCandidates = [
|
|
644
|
+
nodeFlavor !== "full"
|
|
645
|
+
? join(monorepoRoot, `packages/node-mobile/build/dist/android-${nodeFlavor}`, abi, "libnode.so")
|
|
646
|
+
: null,
|
|
647
|
+
join(monorepoRoot, "packages/node-mobile/build/dist/android", abi, "libnode.so"),
|
|
648
|
+
join(monorepoRoot, `packages/node-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/lib/libnode.so`),
|
|
649
|
+
].filter(Boolean);
|
|
650
|
+
let libnode = libnodeCandidates.find(existsSync);
|
|
651
|
+
if (!libnode) {
|
|
652
|
+
try { libnode = join(dirname(gameRequire.resolve(`@downdraft/node-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/package.json`)), "lib/libnode.so"); } catch { /* absent */ }
|
|
653
|
+
}
|
|
654
|
+
if (!libnode || !existsSync(libnode)) {
|
|
655
|
+
throw new Error(`libnode.so for ${abi} not found — build via packages/node-mobile/scripts/build-android.mjs`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Ensure the engine crates are built for this target.
|
|
659
|
+
run("node", [join(monorepoRoot, "scripts/build-native.mjs"), `--target=${triple}`, "--profile=release"], {
|
|
660
|
+
cwd: monorepoRoot,
|
|
661
|
+
env: { ...process.env, DOWNDRAFT_LIBNODE_DIR: dirname(libnode) },
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// libdowndraft_android.so — the shell (platform statics inside).
|
|
665
|
+
const shellSo = join(monorepoRoot, "packages/android-shell/dist", `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`, "libdowndraft_android.so");
|
|
666
|
+
if (!existsSync(shellSo)) throw new Error(`shell lib missing: ${shellSo}`);
|
|
667
|
+
copyFileSync(shellSo, join(libDir, "libdowndraft_android.so"));
|
|
668
|
+
|
|
669
|
+
copyFileSync(libnode, join(libDir, "libnode.so"));
|
|
670
|
+
|
|
671
|
+
// libc++_shared.so — libnode.so NEEDs it (Rust crates static-link libc++
|
|
672
|
+
// and don't). Prefer the sibling staged by build-android.mjs — it comes
|
|
673
|
+
// from the same NDK that built libnode (a mismatched libc++ can lack
|
|
674
|
+
// symbols libnode references). Fall back to the NDK sysroot lib dir.
|
|
675
|
+
const cxxCandidates = [join(dirname(libnode), "libc++_shared.so")];
|
|
676
|
+
if (ndkRoot) {
|
|
677
|
+
cxxCandidates.push(join(ndkRoot,
|
|
678
|
+
"toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib", triple, "libc++_shared.so"));
|
|
679
|
+
}
|
|
680
|
+
const cxxShared = cxxCandidates.find(existsSync);
|
|
681
|
+
if (cxxShared) {
|
|
682
|
+
copyFileSync(cxxShared, join(libDir, "libc++_shared.so"));
|
|
683
|
+
} else {
|
|
684
|
+
console.warn("[package-mobile] libc++_shared.so not found — libnode will fail to load");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Optional engine cdylibs — stage only what the bundle references (or
|
|
688
|
+
// --libs= forces). JS resolves them by soname via the app lib dir.
|
|
689
|
+
const crates = await import(fileURLToPath(new URL("../../../scripts/native-crates.mjs", import.meta.url)));
|
|
690
|
+
const nodePlat = `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`;
|
|
691
|
+
for (const crate of crates.CRATES) {
|
|
692
|
+
if (crate.androidOnly || crate.noAndroid) continue;
|
|
693
|
+
const name = crate.lib.replace(/^downdraft_/, "");
|
|
694
|
+
if (excludeLibs.includes(name) || excludeLibs.includes(crate.lib)) continue;
|
|
695
|
+
const wanted = forceLibs.includes(name) || forceLibs.includes(crate.lib) ||
|
|
696
|
+
bundleText.includes(crate.lib);
|
|
697
|
+
if (!wanted) {
|
|
698
|
+
console.log(`[package-mobile] skipping ${crate.lib} — not referenced by bundle`);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const file = `lib${crate.lib}.so`;
|
|
702
|
+
const src = join(monorepoRoot, crate.dest, nodePlat, file);
|
|
703
|
+
if (existsSync(src)) copyFileSync(src, join(libDir, file));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Strip staged .so's — debug info and the symtab never map at runtime, so
|
|
707
|
+
// this is pure APK/install size (libnode alone sheds ~25MB). Crash
|
|
708
|
+
// symbolication still works: .dynsym and unwind info are preserved.
|
|
709
|
+
if (doStrip && stripTool) {
|
|
710
|
+
for (const f of readdirSync(libDir).filter((f) => f.endsWith(".so"))) {
|
|
711
|
+
try { run(stripTool, ["--strip-unneeded", join(libDir, f)]); } catch { /* keep unstripped */ }
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── APK assembly ──
|
|
717
|
+
|
|
718
|
+
const manifestTpl = readFileSync(join(monorepoRoot, "packages/android-shell/AndroidManifest.xml"), "utf-8");
|
|
719
|
+
const manifest = manifestTpl
|
|
720
|
+
.replaceAll("@APP_ID@", appId)
|
|
721
|
+
.replaceAll("@APP_NAME@", appName.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """))
|
|
722
|
+
.replaceAll("@VERSION_CODE@", String(versionCode))
|
|
723
|
+
.replaceAll("@VERSION_NAME@", versionName.replaceAll("&", "&").replaceAll("<", "<"))
|
|
724
|
+
.replaceAll("@MIN_SDK@", minSdk)
|
|
725
|
+
.replaceAll("@DEBUGGABLE@", modeArg === "dev" ? "true" : "false");
|
|
726
|
+
writeFileSync(join(stageRoot, "AndroidManifest.xml"), manifest);
|
|
727
|
+
|
|
728
|
+
const unsigned = join(stageRoot, "unsigned.apk");
|
|
729
|
+
const aligned = join(stageRoot, "aligned.apk");
|
|
730
|
+
const outfile = resolve(outfileArg);
|
|
731
|
+
|
|
732
|
+
run(join(bt, "aapt2"), [
|
|
733
|
+
"link", "-o", unsigned,
|
|
734
|
+
"-I", androidJar,
|
|
735
|
+
"--manifest", join(stageRoot, "AndroidManifest.xml"),
|
|
736
|
+
"-A", join(stageRoot, "assets"),
|
|
737
|
+
"--auto-add-overlay",
|
|
738
|
+
]);
|
|
739
|
+
|
|
740
|
+
// jniLibs → lib/<abi>/*.so inside the APK (extractNativeLibs=true allows
|
|
741
|
+
// compressed storage — plain zip is correct here).
|
|
742
|
+
run("zip", ["-qr", unsigned, "lib"], { cwd: stageRoot });
|
|
743
|
+
run(join(bt, "zipalign"), ["-f", "-p", "4", unsigned, aligned]);
|
|
744
|
+
|
|
745
|
+
// Sign with the standard Android debug key unless a keystore is provided.
|
|
746
|
+
const keystore = flagValue("keystore") ?? join(process.env.HOME ?? "", ".android/debug.keystore");
|
|
747
|
+
const ksPass = flagValue("keystore-pass") ?? "android";
|
|
748
|
+
const ksAlias = flagValue("keystore-alias") ?? "androiddebugkey";
|
|
749
|
+
if (!existsSync(keystore)) {
|
|
750
|
+
console.log("[package-mobile] no debug keystore — generating one");
|
|
751
|
+
mkdirSync(dirname(keystore), { recursive: true });
|
|
752
|
+
run("keytool", [
|
|
753
|
+
"-genkeypair", "-v", "-keystore", keystore,
|
|
754
|
+
"-storepass", ksPass, "-alias", ksAlias, "-keypass", ksPass,
|
|
755
|
+
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
|
756
|
+
"-dname", "CN=Android Debug,O=Downdraft,C=US",
|
|
757
|
+
]);
|
|
758
|
+
}
|
|
759
|
+
mkdirSync(dirname(outfile), { recursive: true });
|
|
760
|
+
run(join(bt, "apksigner"), [
|
|
761
|
+
"sign", "--ks", keystore, "--ks-pass", `pass:${ksPass}`,
|
|
762
|
+
"--ks-key-alias", ksAlias, "--key-pass", `pass:${ksPass}`,
|
|
763
|
+
"--out", outfile, aligned,
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
console.log(`\n[package-mobile] ${outfile}`);
|
|
767
|
+
console.log(` app: ${appName} (${appId}) v${versionName} (${versionCode})`);
|
|
768
|
+
console.log(` abis: ${abis.join(", ")}`);
|
|
769
|
+
console.log(` bundle: ${entrypoints.length} entries, ${stagedAssets} assets`);
|
|
770
|
+
|
|
771
|
+
// ── install / launch ──
|
|
772
|
+
|
|
773
|
+
if (doInstall) {
|
|
774
|
+
run(adb, ["install", "-r", outfile]);
|
|
775
|
+
}
|
|
776
|
+
if (doLaunch) {
|
|
777
|
+
run(adb, ["shell", "am", "start", "-n", `${appId}/android.app.NativeActivity`]);
|
|
778
|
+
console.log(`[package-mobile] launched — logs: adb logcat -s downdraft:D`);
|
|
779
|
+
}
|