@downdraft/cli 0.1.0 → 0.1.1
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/package.json +1 -1
- package/scripts/default-icon.png +0 -0
- package/scripts/package-desktop.mjs +931 -0
- package/scripts/package-mobile.mjs +47 -4
- package/scripts/package-native.mjs +25 -3
- package/src/release.ts +98 -40
- package/src/usage.ts +2 -1
package/package.json
CHANGED
|
Binary file
|
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// package-desktop.mjs — linux desktop packaging: staged app dir + formats
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// bun packages/cli/scripts/package-desktop.mjs [flags] <entry.ts> <outdir>
|
|
6
|
+
//
|
|
7
|
+
// --runtime=bun|node|deno JS host embedded in the package (default:
|
|
8
|
+
// package.json build.runtime, else bun)
|
|
9
|
+
// --format=<csv> dir | deb | appimage | flatpak (default:
|
|
10
|
+
// package.json build.linux.target, else dir)
|
|
11
|
+
// --mode=dev|debug|prod bundle mode (default: prod)
|
|
12
|
+
// --product-name=<name> display name (default: pkg.productName)
|
|
13
|
+
// --product-version=<v> version (default: pkg.version)
|
|
14
|
+
// --app-id=<id> reverse-DNS id (default: com.downdraft.<game>)
|
|
15
|
+
// --executable-name=<n> binary name (default: kebab-case productName)
|
|
16
|
+
// --icon=<path> png icon (default: build.linux.icon →
|
|
17
|
+
// build.icon → assets/icon.png → embedded)
|
|
18
|
+
// --node-bin=<path> node binary to ship (default: PATH lookup)
|
|
19
|
+
// --deno-bin=<path> deno binary to ship (default: PATH lookup)
|
|
20
|
+
// --mcp retain the MCP automation endpoint
|
|
21
|
+
//
|
|
22
|
+
// Runtime matrix — what the "binary" actually is per --runtime:
|
|
23
|
+
// bun → `bun build --compile` via package-native.mjs — one self-contained
|
|
24
|
+
// executable embedding the whole bundle (delegated subprocess).
|
|
25
|
+
// node → emitted .mjs bundle tree + a shipped node binary + launcher.
|
|
26
|
+
// deno → emitted .mjs bundle tree + a shipped deno binary + launcher.
|
|
27
|
+
// (deno compile can't embed workers-at-absolute-paths — the bundle
|
|
28
|
+
// tree is also what dd-assets/import.meta resolution anchors on.)
|
|
29
|
+
//
|
|
30
|
+
// App dir layout — identical shape across runtimes so all downstream
|
|
31
|
+
// resolution (lib-paths, dd-assets, workers, game-native .so/.node) is
|
|
32
|
+
// uniform. Everything anchors on dirname(process.execPath) == appdir root:
|
|
33
|
+
//
|
|
34
|
+
// <exe> primary executable — compiled binary (bun) or a
|
|
35
|
+
// relocatable launcher script (node/deno)
|
|
36
|
+
// node|deno runtime binary (emit runtimes only)
|
|
37
|
+
// bundle/ emitted .mjs tree + index.js + package.json
|
|
38
|
+
// bundle/node_modules/ koffi + @koromix/koffi-<plat> prebuild (node only)
|
|
39
|
+
// native/ libdowndraft_*.so + game-native dist/<plat>/*
|
|
40
|
+
// (includes .node addons — napi loads under all
|
|
41
|
+
// three desktop runtimes)
|
|
42
|
+
// dd-assets/ ?url / new URL / import.meta.glob staged assets
|
|
43
|
+
//
|
|
44
|
+
// electron-builder-style config lives in the game's package.json `build`:
|
|
45
|
+
//
|
|
46
|
+
// build: {
|
|
47
|
+
// appId, productName, icon, runtime, executableName,
|
|
48
|
+
// files: [ <paths copied into the appdir> ],
|
|
49
|
+
// linux: { target: ["deb","AppImage","flatpak"], category, maintainer,
|
|
50
|
+
// synopsis, description, icon },
|
|
51
|
+
// deb: { section, priority, depends: [] },
|
|
52
|
+
// flatpak:{ runtime, runtimeVersion, sdk, finishArgs: [] }
|
|
53
|
+
// }
|
|
54
|
+
//
|
|
55
|
+
// Formats:
|
|
56
|
+
// dir — the staged appdir verbatim (what package-native produces)
|
|
57
|
+
// deb — /usr/lib/<exe> + /usr/bin symlink + desktop file + icons,
|
|
58
|
+
// assembled with dpkg-deb
|
|
59
|
+
// appimage — AppDir + AppRun + appimagetool (downloaded+cached on demand)
|
|
60
|
+
// flatpak — generated manifest + flatpak-builder + build-bundle
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
import { spawnSync } from "node:child_process";
|
|
64
|
+
import { createHash } from "node:crypto";
|
|
65
|
+
import {
|
|
66
|
+
chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync,
|
|
67
|
+
readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync,
|
|
68
|
+
} from "node:fs";
|
|
69
|
+
import { tmpdir } from "node:os";
|
|
70
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
71
|
+
import { fileURLToPath } from "node:url";
|
|
72
|
+
|
|
73
|
+
// ── Args ──
|
|
74
|
+
|
|
75
|
+
const argv = process.argv.slice(2);
|
|
76
|
+
const flagValue = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
77
|
+
const hasFlag = (name) => argv.includes(`--${name}`);
|
|
78
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
79
|
+
const [entry, outdirArg] = positional;
|
|
80
|
+
|
|
81
|
+
if (!entry || !outdirArg) {
|
|
82
|
+
console.error(
|
|
83
|
+
"usage: bun package-desktop.mjs [--runtime=bun|node|deno] [--format=dir|deb|appimage|flatpak,...] " +
|
|
84
|
+
"[--mode=...] [--product-name=] [--product-version=] [--app-id=] [--executable-name=] [--icon=] " +
|
|
85
|
+
"[--node-bin=] [--deno-bin=] [--mcp] <entry.ts> <outdir>");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const gameDir = dirname(dirname(resolve(entry))); // src/native-entry.ts → game root
|
|
90
|
+
let pkg = {};
|
|
91
|
+
try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone entry */ }
|
|
92
|
+
const build = pkg.build ?? {};
|
|
93
|
+
const linuxCfg = build.linux ?? {};
|
|
94
|
+
|
|
95
|
+
const runtime = flagValue("runtime") ?? build.runtime ?? "bun";
|
|
96
|
+
if (!["bun", "node", "deno"].includes(runtime)) {
|
|
97
|
+
console.error(`unknown --runtime "${runtime}" — expected bun, node, or deno`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const formats = (flagValue("format") ?? (Array.isArray(linuxCfg.target) ? linuxCfg.target.join(",") : linuxCfg.target) ?? "dir")
|
|
101
|
+
.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
102
|
+
const KNOWN_FORMATS = new Set(["dir", "deb", "appimage", "flatpak"]);
|
|
103
|
+
for (const f of formats) {
|
|
104
|
+
if (!KNOWN_FORMATS.has(f)) {
|
|
105
|
+
console.error(`unknown --format "${f}" — expected dir, deb, appimage, or flatpak`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const mode = flagValue("mode") ?? "prod";
|
|
110
|
+
const retainMcp = hasFlag("mcp");
|
|
111
|
+
|
|
112
|
+
const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
113
|
+
const productName = flagValue("product-name") ?? pkg.productName ?? build.productName ?? titleize(pkg.name ?? basename(gameDir));
|
|
114
|
+
const appId = flagValue("app-id") ?? build.appId ?? `com.downdraft.${basename(gameDir).replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
115
|
+
const version = flagValue("product-version") ?? pkg.version ?? "0.0.1";
|
|
116
|
+
const exeName = (flagValue("executable-name") ?? build.executableName ?? productName)
|
|
117
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || basename(gameDir);
|
|
118
|
+
const maintainer = linuxCfg.maintainer
|
|
119
|
+
?? (typeof pkg.author === "string" ? pkg.author : pkg.author?.name) ?? "DownDraft";
|
|
120
|
+
const category = linuxCfg.category ?? "Game";
|
|
121
|
+
const synopsis = linuxCfg.synopsis ?? pkg.description ?? productName;
|
|
122
|
+
const description = linuxCfg.description ?? pkg.description ?? synopsis;
|
|
123
|
+
|
|
124
|
+
const outdir = resolve(outdirArg);
|
|
125
|
+
const appdir = join(outdir, `${basename(gameDir)}-linux-${runtime}`);
|
|
126
|
+
const platKey = `${process.platform}-${process.arch}`;
|
|
127
|
+
const repoRoot = process.cwd();
|
|
128
|
+
|
|
129
|
+
const run = (cmd, args, opts = {}) => {
|
|
130
|
+
console.log(` $ ${cmd} ${args.join(" ")}`);
|
|
131
|
+
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
|
132
|
+
if (r.status !== 0) throw new Error(`${cmd} failed (exit ${r.status})`);
|
|
133
|
+
return r;
|
|
134
|
+
};
|
|
135
|
+
const runOut = (cmd, args, opts = {}) => {
|
|
136
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
137
|
+
return r.status === 0 ? (r.stdout ?? "").trim() : null;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
mkdirSync(appdir, { recursive: true });
|
|
141
|
+
console.log(`[package-desktop] ${productName} v${version} (${appId}) — runtime=${runtime}, formats=${formats.join(",")}`);
|
|
142
|
+
|
|
143
|
+
// ── Icon resolution ──
|
|
144
|
+
|
|
145
|
+
const DEFAULT_ICON = fileURLToPath(new URL("./default-icon.png", import.meta.url));
|
|
146
|
+
function resolveIcon() {
|
|
147
|
+
const explicit = flagValue("icon") ?? linuxCfg.icon ?? build.icon;
|
|
148
|
+
const candidates = [
|
|
149
|
+
explicit ? resolve(gameDir, explicit) : null,
|
|
150
|
+
join(gameDir, "assets/icon.png"),
|
|
151
|
+
join(gameDir, "assets/icon.ico"),
|
|
152
|
+
join(gameDir, "icon.png"),
|
|
153
|
+
].filter(Boolean);
|
|
154
|
+
return candidates.find((p) => existsSync(p)) ?? (existsSync(DEFAULT_ICON) ? DEFAULT_ICON : null);
|
|
155
|
+
}
|
|
156
|
+
const iconPath = resolveIcon();
|
|
157
|
+
if (!iconPath) console.warn("[package-desktop] no icon found and embedded default missing");
|
|
158
|
+
|
|
159
|
+
// ── Shared bundle machinery (mirrors package-mobile.mjs; the emit-tree
|
|
160
|
+
// flavor. Path anchors are dirname(process.execPath) — the appdir root —
|
|
161
|
+
// for every runtime.) ──
|
|
162
|
+
|
|
163
|
+
const QUERY_RE = /\?(raw|url|json)$/;
|
|
164
|
+
const WORKER_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
|
|
165
|
+
// Rewrite variant — additionally consumes new URL's closing paren so the
|
|
166
|
+
// Worker's own arg list (options object, close paren) stays balanced.
|
|
167
|
+
const WORKER_URL_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)\s*(?:\.href|\.toString\(\s*\))?\s*/g;
|
|
168
|
+
const ASSET_URL_RE = /new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
|
|
169
|
+
const IMPORT_META = /(?:\(\s*import\.meta\s+as\s+any\s*\)|import\.meta(?:\s+as\s+any)?)/;
|
|
170
|
+
const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1`, "g");
|
|
171
|
+
const GLOB_EAGER_RE = new RegExp(
|
|
172
|
+
IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1\s*,\s*(\{[^)]*\beager\s*:\s*true[^)]*\})\s*\)`, "g");
|
|
173
|
+
const STAGE_GLOB_RE = new RegExp(
|
|
174
|
+
`(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
|
|
175
|
+
const STAGE_READ_RE = new RegExp(
|
|
176
|
+
String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
|
|
177
|
+
const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
|
|
178
|
+
const META_URL_RE = new RegExp(IMPORT_META.source + String.raw`\.url\b`, "g");
|
|
179
|
+
const REQUIRE_GLOB_RE = /require\(\s*["'`]@downdraft\/engine\/platform\/glob-polyfill["'`]\s*\)/g;
|
|
180
|
+
const GLOB_CALLEE_RE = new RegExp(IMPORT_META.source + String.raw`\.glob`);
|
|
181
|
+
const SCRIPT_EXT_RE = /\.(ts|tsx|js|jsx|mts|cts|cjs)$/i; // .mjs staged as an asset (execArgv loaders)
|
|
182
|
+
const DYN_IMPORT_RE = /import\(\s*([a-zA-Z_$][\w$]*)\s*\)/g;
|
|
183
|
+
|
|
184
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
185
|
+
|
|
186
|
+
function resolveSpec(spec, importer) {
|
|
187
|
+
try {
|
|
188
|
+
return spec.startsWith(".") || spec.startsWith("/")
|
|
189
|
+
? resolve(dirname(importer), spec)
|
|
190
|
+
: Bun.resolveSync(spec, dirname(importer));
|
|
191
|
+
} catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Repo-relative bundle path for a module, with .mjs extension. Bun's
|
|
195
|
+
* `[dir]` naming escapes out-of-root `..` segments as `_.._`. */
|
|
196
|
+
function bundlePath(absPath) {
|
|
197
|
+
return relative(repoRoot, absPath).replace(/\\/g, "/").replace(/\.[jt]sx?$/, ".mjs")
|
|
198
|
+
.split("/").map((s) => (s === ".." ? "_.._" : s)).join("/");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function assetRel(absPath) {
|
|
202
|
+
const rel = relative(repoRoot, absPath).replace(/\\/g, "/");
|
|
203
|
+
if (!rel.startsWith("..")) return rel;
|
|
204
|
+
const h = createHash("sha1").update(dirname(absPath)).digest("hex").slice(0, 8);
|
|
205
|
+
return `external/${h}/${basename(absPath)}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function walkDir(dir, results = []) {
|
|
209
|
+
let entries;
|
|
210
|
+
try { entries = readdirSync(dir); } catch { return results; }
|
|
211
|
+
for (const e of entries) {
|
|
212
|
+
const full = `${dir}/${e}`;
|
|
213
|
+
let st;
|
|
214
|
+
try { st = statSync(full); } catch { continue; }
|
|
215
|
+
if (st.isDirectory()) walkDir(full, results);
|
|
216
|
+
else results.push(full);
|
|
217
|
+
}
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Mirrors globToRegex in packages/engine/core/src/platform/glob-polyfill.ts
|
|
222
|
+
// — keep in sync.
|
|
223
|
+
function globToRegex(pattern) {
|
|
224
|
+
const regex = pattern
|
|
225
|
+
.replace(/[.+^$()|[\]\\]/g, "\\$&")
|
|
226
|
+
.replace(/\*\*\//g, "\x00")
|
|
227
|
+
.replace(/\*\*/g, "\x01")
|
|
228
|
+
.replace(/\*/g, "[^/]*")
|
|
229
|
+
.replace(/\{([^}]+)\}/g, (_, group) => `(${group.replace(/,/g, "|")})`)
|
|
230
|
+
.replace(/\x00/g, "(?:.*/)?")
|
|
231
|
+
.replace(/\x01/g, ".*");
|
|
232
|
+
return new RegExp(`^${regex}$`, "i");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Expand an import.meta.glob pattern against the real FS at package time. */
|
|
236
|
+
function expandGlob(fromDir, pattern) {
|
|
237
|
+
const rootParts = [];
|
|
238
|
+
for (const p of pattern.split("/")) {
|
|
239
|
+
if (p.includes("*") || p.includes("{")) break;
|
|
240
|
+
rootParts.push(p);
|
|
241
|
+
}
|
|
242
|
+
const searchDir = resolve(fromDir, ...rootParts);
|
|
243
|
+
if (!existsSync(searchDir) || !statSync(searchDir).isDirectory()) return [];
|
|
244
|
+
const matchRegex = globToRegex(pattern.replace(/^\.\//, ""));
|
|
245
|
+
return walkDir(searchDir)
|
|
246
|
+
.map((f) => relative(fromDir, f).replace(/\\/g, "/"))
|
|
247
|
+
.filter((rel) => matchRegex.test(rel) || matchRegex.test(`./${rel}`))
|
|
248
|
+
.sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const workerEntries = new Set();
|
|
252
|
+
const dynImports = new Map();
|
|
253
|
+
const assetFiles = new Set();
|
|
254
|
+
|
|
255
|
+
// Emitted path anchor — every runtime resolves the appdir through
|
|
256
|
+
// dirname(process.execPath): bun → the compiled binary, node → the shipped
|
|
257
|
+
// node binary, deno → the shipped deno binary. All live at the appdir root.
|
|
258
|
+
const APPDIR = "__ddAppDir";
|
|
259
|
+
|
|
260
|
+
const makeEmitPlugin = (collectWorkers) => ({
|
|
261
|
+
name: "downdraft-desktop-emit",
|
|
262
|
+
setup(build_) {
|
|
263
|
+
const resolveImport = (spec, importer) => {
|
|
264
|
+
const base = spec.startsWith(".") || spec.startsWith("/")
|
|
265
|
+
? resolve(dirname(importer), spec)
|
|
266
|
+
: Bun.resolveSync(spec, dirname(importer));
|
|
267
|
+
return existsSync(base) ? base : (existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
|
268
|
+
};
|
|
269
|
+
build_.onResolve({ filter: /\?(raw|url|json)$/ }, (args) => {
|
|
270
|
+
const clean = args.path.replace(QUERY_RE, "");
|
|
271
|
+
const kind = args.path.match(QUERY_RE)[1];
|
|
272
|
+
const resolved = resolveImport(clean, args.importer);
|
|
273
|
+
if (resolved.endsWith(".ts")) return { path: resolved };
|
|
274
|
+
return { path: resolved, namespace: `dd-${kind}` };
|
|
275
|
+
});
|
|
276
|
+
build_.onLoad({ filter: /.*/, namespace: "dd-raw" }, (args) => ({
|
|
277
|
+
contents: `export default ${JSON.stringify(readFileSync(args.path, "utf-8"))};`,
|
|
278
|
+
loader: "js",
|
|
279
|
+
}));
|
|
280
|
+
build_.onLoad({ filter: /.*/, namespace: "dd-json" }, (args) => ({
|
|
281
|
+
contents: `export default ${JSON.stringify(JSON.parse(readFileSync(args.path, "utf-8")))};`,
|
|
282
|
+
loader: "js",
|
|
283
|
+
}));
|
|
284
|
+
// ?url → file:// URL into the staged dd-assets tree (packaged) or the
|
|
285
|
+
// absolute path (scan pass).
|
|
286
|
+
build_.onLoad({ filter: /.*/, namespace: "dd-url" }, (args) => {
|
|
287
|
+
assetFiles.add(args.path);
|
|
288
|
+
if (collectWorkers) {
|
|
289
|
+
return { contents: `export default ${JSON.stringify(args.path)};`, loader: "js" };
|
|
290
|
+
}
|
|
291
|
+
const rel = assetRel(args.path);
|
|
292
|
+
return {
|
|
293
|
+
contents:
|
|
294
|
+
`import { dirname as __ddDirname } from "node:path";` +
|
|
295
|
+
`import { pathToFileURL as __ddP2F } from "node:url";` +
|
|
296
|
+
`export default __ddP2F(__ddDirname(process.execPath) + "/dd-assets/" + ${JSON.stringify(rel)}).href;`,
|
|
297
|
+
loader: "js",
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
["wgsl", "glsl"].forEach((ext) => {
|
|
301
|
+
build_.onResolve({ filter: new RegExp(`\\.${ext}$`) }, (args) => {
|
|
302
|
+
const resolved = resolveImport(args.path, args.importer);
|
|
303
|
+
if (resolved.endsWith(".ts")) return { path: resolved };
|
|
304
|
+
return { path: resolved, namespace: "dd-raw" };
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
build_.onResolve({ filter: /\.css$/ }, (args) => {
|
|
308
|
+
if (args.path.startsWith("dd-css:")) {
|
|
309
|
+
return { path: args.path.slice("dd-css:".length), namespace: "dd-css" };
|
|
310
|
+
}
|
|
311
|
+
let path;
|
|
312
|
+
try { path = resolveImport(args.path, args.importer); }
|
|
313
|
+
catch { path = resolve(dirname(args.importer), args.path); }
|
|
314
|
+
return { path, namespace: "dd-css" };
|
|
315
|
+
});
|
|
316
|
+
build_.onLoad({ filter: /.*/, namespace: "dd-css" }, () => ({
|
|
317
|
+
contents: `export default "";`, loader: "js",
|
|
318
|
+
}));
|
|
319
|
+
build_.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
|
|
320
|
+
const src = readFileSync(args.path, "utf-8");
|
|
321
|
+
const modDir = dirname(args.path);
|
|
322
|
+
const modRel = assetRel(args.path);
|
|
323
|
+
const modBase = basename(modRel).replace(/\.[jt]sx?$/, ".js");
|
|
324
|
+
const stagedDir = dirname(modRel);
|
|
325
|
+
|
|
326
|
+
// Worker entrypoints — needed in both passes (pass 1 collects).
|
|
327
|
+
for (const m of src.matchAll(WORKER_RE)) {
|
|
328
|
+
const p = resolve(modDir, m[1]);
|
|
329
|
+
if (existsSync(p)) workerEntries.add(p);
|
|
330
|
+
}
|
|
331
|
+
// Asset refs: new URL("x.png", import.meta.url) where x isn't a script.
|
|
332
|
+
for (const m of src.matchAll(ASSET_URL_RE)) {
|
|
333
|
+
// Scripts are bundled; .mjs loaders (worker-bootstrap, wgsl-loader)
|
|
334
|
+
// are spawned via execArgv and must exist as real files — stage them.
|
|
335
|
+
if (SCRIPT_EXT_RE.test(m[1])) continue;
|
|
336
|
+
const p = resolve(modDir, m[1]);
|
|
337
|
+
if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
|
|
338
|
+
}
|
|
339
|
+
for (const m of src.matchAll(DYN_IMPORT_RE)) {
|
|
340
|
+
const ident = m[1];
|
|
341
|
+
const bind = src.match(new RegExp("(?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*([\"'`])([^\"'`]+)\\1"));
|
|
342
|
+
if (!bind) continue;
|
|
343
|
+
const spec = bind[2];
|
|
344
|
+
if (!spec || spec.startsWith("node:") || spec.startsWith("bun:")) continue;
|
|
345
|
+
const abs = resolveSpec(spec, args.path);
|
|
346
|
+
if (!abs || !existsSync(abs)) continue;
|
|
347
|
+
workerEntries.add(abs);
|
|
348
|
+
dynImports.set(`${args.path}:${ident}`, { ident, spec, abs });
|
|
349
|
+
}
|
|
350
|
+
for (const m of src.matchAll(STAGE_READ_RE)) {
|
|
351
|
+
if (SCRIPT_EXT_RE.test(m[2])) continue;
|
|
352
|
+
const p = resolve(modDir, m[2]);
|
|
353
|
+
if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
|
|
354
|
+
}
|
|
355
|
+
for (const m of src.matchAll(STAGE_GLOB_RE)) {
|
|
356
|
+
const pattern = m[2] ?? m[4];
|
|
357
|
+
if (!pattern) continue;
|
|
358
|
+
const rootParts = [];
|
|
359
|
+
for (const p of pattern.split("/")) {
|
|
360
|
+
if (p.includes("*") || p.includes("{")) break;
|
|
361
|
+
rootParts.push(p);
|
|
362
|
+
}
|
|
363
|
+
const searchDir = resolve(modDir, ...rootParts);
|
|
364
|
+
if (existsSync(searchDir) && statSync(searchDir).isDirectory()) {
|
|
365
|
+
for (const f of walkDir(searchDir)) assetFiles.add(f);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const loader = { ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", mts: "ts" }[args.path.split(".").pop()] || "ts";
|
|
370
|
+
let needsGlob = false;
|
|
371
|
+
|
|
372
|
+
// 0. Eager module globs → static imports generated at package time.
|
|
373
|
+
const eagerDecls = [];
|
|
374
|
+
let eagerSeq = 0;
|
|
375
|
+
let out = src.replace(GLOB_EAGER_RE, (m, _q, pattern, opts) => {
|
|
376
|
+
if (/\bquery\s*:/.test(opts)) {
|
|
377
|
+
needsGlob = true;
|
|
378
|
+
return m.replace(GLOB_CALLEE_RE, "__ddGlob");
|
|
379
|
+
}
|
|
380
|
+
const spec = /\bimport\s*:\s*["']([A-Za-z_$][\w$]*)["']/.exec(opts)?.[1];
|
|
381
|
+
const name = `__ddEager${eagerSeq++}`;
|
|
382
|
+
const entries = expandGlob(modDir, pattern).map((f, i) => {
|
|
383
|
+
const rel = f.startsWith(".") ? f : "./" + f;
|
|
384
|
+
const ident = `${name}$${i}`;
|
|
385
|
+
eagerDecls.push(`import * as ${ident} from ${JSON.stringify(rel)};`);
|
|
386
|
+
return `${JSON.stringify(rel)}: ${spec ? `${ident}.${spec}` : ident}`;
|
|
387
|
+
});
|
|
388
|
+
eagerDecls.push(`const ${name} = {${entries.length ? "\n " + entries.join(",\n ") + ",\n" : ""}};`);
|
|
389
|
+
return name;
|
|
390
|
+
});
|
|
391
|
+
const eagerPrologue = eagerDecls.length ? eagerDecls.join("\n") + "\n" : "";
|
|
392
|
+
|
|
393
|
+
if (collectWorkers) return eagerPrologue ? { contents: eagerPrologue + out, loader } : undefined;
|
|
394
|
+
|
|
395
|
+
let needsAppDir = false;
|
|
396
|
+
|
|
397
|
+
// 1. Worker URLs → file:// URL into the emitted bundle tree. Deno
|
|
398
|
+
// requires a URL (bare paths throw), node's Worker polyfill accepts
|
|
399
|
+
// URL or path — the file:// URL form serves both.
|
|
400
|
+
out = out.replace(WORKER_URL_RE, (m, spec) => {
|
|
401
|
+
needsAppDir = true;
|
|
402
|
+
const rel = bundlePath(resolve(modDir, spec));
|
|
403
|
+
return `new Worker(new URL("file://" + ${APPDIR} + "/bundle/" + ${JSON.stringify(rel)})`;
|
|
404
|
+
});
|
|
405
|
+
// 1.5 new URL("<asset>", import.meta.url) → absolute file URL into the
|
|
406
|
+
// staged dd-assets tree (see package-mobile for the assetRel note).
|
|
407
|
+
out = out.replace(ASSET_URL_RE, (m, spec) => {
|
|
408
|
+
const p = resolve(modDir, spec);
|
|
409
|
+
if (!existsSync(p)) return m;
|
|
410
|
+
needsAppDir = true;
|
|
411
|
+
return `new URL("file://" + ${APPDIR} + "/dd-assets/" + ${JSON.stringify(assetRel(p))})`;
|
|
412
|
+
});
|
|
413
|
+
// 2. Variable-specifier dynamic imports → file:// URL of the emitted
|
|
414
|
+
// bundle module (file:// because deno rejects bare abs paths).
|
|
415
|
+
let hadDyn = false;
|
|
416
|
+
for (const m of out.matchAll(DYN_IMPORT_RE)) {
|
|
417
|
+
const ident = m[1];
|
|
418
|
+
const info = dynImports.get(`${args.path}:${ident}`);
|
|
419
|
+
if (!info) continue;
|
|
420
|
+
hadDyn = true; needsAppDir = true;
|
|
421
|
+
const decl = new RegExp("((?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*)([\"'`])" + escapeRe(info.spec) + "\\2");
|
|
422
|
+
out = out.replace(decl, `$1$2file://@@APPDIR@@/${bundlePath(info.abs)}$2`);
|
|
423
|
+
}
|
|
424
|
+
// 3. import.meta.glob → createGlob bound to the staged dir.
|
|
425
|
+
out = out.replace(GLOB_RE, (m) => { needsGlob = true; return m.replace(GLOB_CALLEE_RE, "__ddGlob"); });
|
|
426
|
+
if (REQUIRE_GLOB_RE.test(out)) {
|
|
427
|
+
needsGlob = true;
|
|
428
|
+
out = out.replace(REQUIRE_GLOB_RE, "({ createGlob: __ddCreateGlob })");
|
|
429
|
+
}
|
|
430
|
+
// 4. import.meta.dir/.url/__dirname → module's staged dir under
|
|
431
|
+
// dd-assets (real FS).
|
|
432
|
+
const metaOut = out
|
|
433
|
+
.replace(META_DIR_RE, "__ddModDir")
|
|
434
|
+
.replace(META_URL_RE, "__ddModUrl")
|
|
435
|
+
.replace(/(?<![.\w$])__dirname\b/g, "__ddModDir");
|
|
436
|
+
if (metaOut !== out) needsAppDir = true;
|
|
437
|
+
if (metaOut !== out || needsGlob || needsAppDir) {
|
|
438
|
+
out =
|
|
439
|
+
`import { dirname as __ddDirname } from "node:path";\n` +
|
|
440
|
+
`import { pathToFileURL as __ddP2F } from "node:url";\n` +
|
|
441
|
+
`const ${APPDIR} = __ddDirname(process.execPath);\n` +
|
|
442
|
+
`const __ddModDir = ${APPDIR} + "/dd-assets/" + ${JSON.stringify(stagedDir === "." ? "" : stagedDir + "/")}.replace(/\\/$/, "");\n` +
|
|
443
|
+
`const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
|
|
444
|
+
(needsGlob
|
|
445
|
+
? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
|
|
446
|
+
// Lazy — eager construction runs at module-init time and can
|
|
447
|
+
// hit a TDZ on consts declared later in the same module
|
|
448
|
+
// (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
|
|
449
|
+
// comment and crashed exactly this way).
|
|
450
|
+
`const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
|
|
451
|
+
: "") +
|
|
452
|
+
metaOut;
|
|
453
|
+
}
|
|
454
|
+
// @@APPDIR@@ sentinel → appdir expression (post meta rewrite).
|
|
455
|
+
out = out.replaceAll("@@APPDIR@@", `" + ${APPDIR} + "`);
|
|
456
|
+
if (eagerPrologue) out = eagerPrologue + out;
|
|
457
|
+
return out === src ? undefined : { contents: out, loader };
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ── Scan pass: discover Worker entrypoints to a fixpoint ──
|
|
463
|
+
|
|
464
|
+
const scanOutdir = mkdtempSync(join(tmpdir(), "dd-scan-"));
|
|
465
|
+
let prevCount = -1;
|
|
466
|
+
while (workerEntries.size !== prevCount) {
|
|
467
|
+
prevCount = workerEntries.size;
|
|
468
|
+
const scan = await Bun.build({
|
|
469
|
+
entrypoints: [resolve(entry), ...workerEntries],
|
|
470
|
+
target: "node",
|
|
471
|
+
plugins: [makeEmitPlugin(true)],
|
|
472
|
+
splitting: false,
|
|
473
|
+
sourcemap: "none",
|
|
474
|
+
minify: false,
|
|
475
|
+
define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
|
|
476
|
+
outdir: scanOutdir,
|
|
477
|
+
external: ["koffi", "bun:ffi", "bun:test", "bun"],
|
|
478
|
+
});
|
|
479
|
+
if (!scan.success) {
|
|
480
|
+
scan.logs.forEach((msg) => { console.error(msg); });
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const entrypoints = [resolve(entry), ...workerEntries];
|
|
486
|
+
if (workerEntries.size) {
|
|
487
|
+
console.log(`workers: ${[...workerEntries].map((p) => p.split("/").pop()).join(", ")}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── Stage engine + game-native libs into <appdir>/native ──
|
|
491
|
+
|
|
492
|
+
const libExt = platKey.startsWith("win32") ? ".dll" : platKey.startsWith("darwin") ? ".dylib" : ".so";
|
|
493
|
+
|
|
494
|
+
async function pkgRoot(name) {
|
|
495
|
+
try {
|
|
496
|
+
const { createRequire } = await import("node:module");
|
|
497
|
+
const req = createRequire(resolve(entry));
|
|
498
|
+
return dirname(req.resolve(`${name}/package.json`));
|
|
499
|
+
} catch { return null; }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function stageEngineLibs(nativeDir) {
|
|
503
|
+
mkdirSync(nativeDir, { recursive: true });
|
|
504
|
+
// Same probe order as package-native.mjs — platform dir, installed
|
|
505
|
+
// platform-native, per-target npm platform package.
|
|
506
|
+
const platPkg = await pkgRoot("@downdraft/platform-native");
|
|
507
|
+
const platNpm = await pkgRoot(`@downdraft/native-${platKey}`);
|
|
508
|
+
const engineRoot = await pkgRoot("@downdraft/engine");
|
|
509
|
+
const platLibCandidates = [...new Set([
|
|
510
|
+
join(repoRoot, "packages/platform-native/native", platKey),
|
|
511
|
+
platPkg && join(platPkg, "native", platKey),
|
|
512
|
+
platNpm && join(platNpm, "lib"),
|
|
513
|
+
].filter(Boolean))];
|
|
514
|
+
const platStageDir = platLibCandidates.find((d) => existsSync(d));
|
|
515
|
+
if (platStageDir) {
|
|
516
|
+
for (const f of readdirSync(platStageDir)) {
|
|
517
|
+
if (f.endsWith(libExt)) copyFileSync(join(platStageDir, f), join(nativeDir, f));
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
throw new Error(`no libdowndraft_platform build found for ${platKey} — searched: ${platLibCandidates.join(", ")}`);
|
|
521
|
+
}
|
|
522
|
+
// Engine cdylibs via the shared crate registry (same candidates as
|
|
523
|
+
// package-native.mjs).
|
|
524
|
+
let CRATES = [];
|
|
525
|
+
try {
|
|
526
|
+
({ CRATES } = await import(fileURLToPath(new URL("../../../scripts/native-crates.mjs", import.meta.url))));
|
|
527
|
+
} catch { /* published package — probed via node resolution below */ }
|
|
528
|
+
const libFileName = (n) => platKey.startsWith("win32") ? `${n}.dll`
|
|
529
|
+
: platKey.startsWith("darwin") ? `lib${n}.dylib` : `lib${n}.so`;
|
|
530
|
+
for (const crate of CRATES.filter((c) => c.pkg !== "downdraft-platform")) {
|
|
531
|
+
const file = libFileName(crate.lib);
|
|
532
|
+
const pkgRel = crate.dir.replace(/^packages\/engine\//, "");
|
|
533
|
+
const candidates = [
|
|
534
|
+
join(repoRoot, crate.dest, platKey, file),
|
|
535
|
+
join(repoRoot, crate.dest, file),
|
|
536
|
+
join(repoRoot, crate.dir, "target", "release", file),
|
|
537
|
+
join(repoRoot, crate.dir, file),
|
|
538
|
+
...(engineRoot ? [
|
|
539
|
+
join(engineRoot, pkgRel, "dist", platKey, file),
|
|
540
|
+
join(engineRoot, pkgRel, "dist", file),
|
|
541
|
+
] : []),
|
|
542
|
+
];
|
|
543
|
+
for (const p of candidates) {
|
|
544
|
+
if (existsSync(p)) { copyFileSync(p, join(nativeDir, file)); break; }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Game-owned native artifacts: <game>/native/dist/<plat>/ → appdir/native/.
|
|
550
|
+
* Covers .so cdylibs AND .node napi addons (all three desktop runtimes can
|
|
551
|
+
* load napi). The convention game-side loaders resolve — see
|
|
552
|
+
* games/<name>/src/tests/native/native-bridge-lib.ts. */
|
|
553
|
+
function stageGameNative(nativeDir) {
|
|
554
|
+
const dist = join(gameDir, "native", "dist", platKey);
|
|
555
|
+
if (!existsSync(dist)) return;
|
|
556
|
+
mkdirSync(nativeDir, { recursive: true });
|
|
557
|
+
for (const f of readdirSync(dist)) {
|
|
558
|
+
copyFileSync(join(dist, f), join(nativeDir, f));
|
|
559
|
+
console.log(` game-native: ${f}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Runtime dispatch: build the appdir ──
|
|
564
|
+
|
|
565
|
+
const nativeDir = join(appdir, "native");
|
|
566
|
+
|
|
567
|
+
if (runtime === "bun") {
|
|
568
|
+
// Delegate to package-native.mjs — outfile lands at <appdir>/<exe> and its
|
|
569
|
+
// native/ + dd-assets/ siblings land at <appdir>/ exactly.
|
|
570
|
+
const nativeScript = fileURLToPath(new URL("./package-native.mjs", import.meta.url));
|
|
571
|
+
run("bun", [
|
|
572
|
+
nativeScript,
|
|
573
|
+
"--target=linux",
|
|
574
|
+
`--mode=${mode}`,
|
|
575
|
+
`--product-name=${productName}`,
|
|
576
|
+
`--product-version=${version}`,
|
|
577
|
+
...(retainMcp ? ["--mcp"] : []),
|
|
578
|
+
resolve(entry),
|
|
579
|
+
join(appdir, exeName),
|
|
580
|
+
], { cwd: repoRoot });
|
|
581
|
+
} else {
|
|
582
|
+
// Emit the bundle tree into <appdir>/bundle/.
|
|
583
|
+
const bundleDir = join(appdir, "bundle");
|
|
584
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
585
|
+
const buildResult = await Bun.build({
|
|
586
|
+
entrypoints,
|
|
587
|
+
target: "node",
|
|
588
|
+
root: repoRoot,
|
|
589
|
+
naming: { entry: "[dir]/[name].mjs" },
|
|
590
|
+
plugins: [makeEmitPlugin(false)],
|
|
591
|
+
splitting: false,
|
|
592
|
+
sourcemap: "none",
|
|
593
|
+
minify: mode === "prod",
|
|
594
|
+
define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
|
|
595
|
+
external: ["koffi", "bun:ffi", "bun:test", "bun"],
|
|
596
|
+
outdir: bundleDir,
|
|
597
|
+
});
|
|
598
|
+
if (!buildResult.success) {
|
|
599
|
+
buildResult.logs.forEach((msg) => { console.error(msg); });
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Entry shim + type:module marker (same pattern as package-mobile).
|
|
604
|
+
const entryRel = bundlePath(resolve(entry));
|
|
605
|
+
writeFileSync(join(bundleDir, "index.js"), `await import("./${entryRel}");\n`);
|
|
606
|
+
writeFileSync(join(bundleDir, "package.json"), JSON.stringify({ type: "module" }) + "\n");
|
|
607
|
+
|
|
608
|
+
// koffi + platform prebuild — node's FFI fallback needs real files.
|
|
609
|
+
if (runtime === "node") {
|
|
610
|
+
const { createRequire } = await import("node:module");
|
|
611
|
+
const gameRequire = createRequire(resolve(entry));
|
|
612
|
+
let koffiRoot = null;
|
|
613
|
+
try { koffiRoot = dirname(gameRequire.resolve("koffi")); } catch { /* absent */ }
|
|
614
|
+
if (!koffiRoot) {
|
|
615
|
+
console.warn("[package-desktop] koffi not resolvable — FFI will fail under the node runtime");
|
|
616
|
+
} else {
|
|
617
|
+
const nm = join(bundleDir, "node_modules");
|
|
618
|
+
cpSync(koffiRoot, join(nm, "koffi"), { recursive: true });
|
|
619
|
+
const koffiVer = JSON.parse(readFileSync(join(koffiRoot, "package.json"), "utf-8")).version;
|
|
620
|
+
const prebuild = `@koromix/koffi-${platKey}`;
|
|
621
|
+
let src = join(dirname(koffiRoot), prebuild);
|
|
622
|
+
if (!existsSync(join(src, "package.json"))) src = null;
|
|
623
|
+
// bun's .bun store exposes packages through symlinks — cpSync chokes
|
|
624
|
+
// on the link itself (ERR_FS_CP_EINVAL), so copy the real dir.
|
|
625
|
+
if (src) src = realpathSync(src);
|
|
626
|
+
if (!src) src = await fetchKoffiPrebuild(prebuild, koffiVer);
|
|
627
|
+
if (!src) {
|
|
628
|
+
console.warn(`[package-desktop] ${prebuild}@${koffiVer} unavailable — FFI will fail`);
|
|
629
|
+
} else {
|
|
630
|
+
cpSync(src, join(nm, "@koromix", basename(prebuild)), { recursive: true });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Runtime binary + launcher. The runtime binary sits at the appdir root so
|
|
636
|
+
// dirname(process.execPath) anchors native/ + dd-assets/ + bundle/.
|
|
637
|
+
const rtBin = flagValue(`${runtime}-bin`) ?? runOut("sh", ["-c", `command -v ${runtime}`]);
|
|
638
|
+
if (!rtBin) throw new Error(`${runtime} not found on PATH — pass --${runtime}-bin=<path>`);
|
|
639
|
+
copyFileSync(realpathSync(rtBin), join(appdir, runtime));
|
|
640
|
+
chmodSync(join(appdir, runtime), 0o755);
|
|
641
|
+
|
|
642
|
+
const rtArgs = runtime === "deno" ? "run -A --no-config" : "";
|
|
643
|
+
writeFileSync(join(appdir, exeName),
|
|
644
|
+
`#!/bin/sh\n` +
|
|
645
|
+
`# ${productName} — ${runtime} runtime launcher (package-desktop.mjs)\n` +
|
|
646
|
+
`SELF="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"\n` +
|
|
647
|
+
`DIR="$(dirname "$SELF")"\n` +
|
|
648
|
+
`exec "$DIR/${runtime}" ${rtArgs} "$DIR/bundle/index.js" "$@"\n`);
|
|
649
|
+
chmodSync(join(appdir, exeName), 0o755);
|
|
650
|
+
|
|
651
|
+
// Engine cdylibs (bun's path stages these inside package-native).
|
|
652
|
+
await stageEngineLibs(nativeDir);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Game-native artifacts (.so + .node) → appdir/native/.
|
|
656
|
+
stageGameNative(nativeDir);
|
|
657
|
+
|
|
658
|
+
// Follow relative imports inside staged script assets — execArgv loader
|
|
659
|
+
// files (wgsl-loader.mjs, worker-bootstrap.mjs) are staged raw, not
|
|
660
|
+
// bundled, so their own module deps never enter the graph otherwise.
|
|
661
|
+
{
|
|
662
|
+
const ASSET_IMPORT_RE = /(?:from|import|register)\s*\(?\s*(["'`])(\.[^"'`]+)\1/g;
|
|
663
|
+
let grew = true;
|
|
664
|
+
while (grew) {
|
|
665
|
+
grew = false;
|
|
666
|
+
for (const f of [...assetFiles]) {
|
|
667
|
+
if (!/\.(mjs|cjs|js)$/.test(f)) continue;
|
|
668
|
+
let src;
|
|
669
|
+
try { src = readFileSync(f, "utf-8"); } catch { continue; }
|
|
670
|
+
for (const m of src.matchAll(ASSET_IMPORT_RE)) {
|
|
671
|
+
const p = resolve(dirname(f), m[2]);
|
|
672
|
+
if (existsSync(p) && statSync(p).isFile() && !assetFiles.has(p)) {
|
|
673
|
+
assetFiles.add(p);
|
|
674
|
+
grew = true;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// dd-assets — the emit runtimes need them staged; the bun path already staged
|
|
682
|
+
// them inside package-native (assetFiles is populated by the scan pass here
|
|
683
|
+
// either way, and re-copying identical files is harmless but skipped).
|
|
684
|
+
if (runtime !== "bun") {
|
|
685
|
+
const assetRoot = join(appdir, "dd-assets");
|
|
686
|
+
let stagedAssets = 0;
|
|
687
|
+
for (const f of assetFiles.values()) {
|
|
688
|
+
const dest = join(assetRoot, assetRel(f));
|
|
689
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
690
|
+
copyFileSync(f, dest);
|
|
691
|
+
stagedAssets++;
|
|
692
|
+
}
|
|
693
|
+
console.log(`staged: ${nativeDir ? readdirSync(nativeDir).filter((f) => f.endsWith(libExt)).length : 0} native libs, ${stagedAssets} assets`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// build.files — electron-builder-style extra files into the appdir root.
|
|
697
|
+
for (const f of Array.isArray(build.files) ? build.files : []) {
|
|
698
|
+
const src = resolve(gameDir, typeof f === "string" ? f : f.from);
|
|
699
|
+
const dest = join(appdir, typeof f === "string" ? basename(f) : (f.to ?? basename(f.from)));
|
|
700
|
+
if (!existsSync(src)) { console.warn(`[package-desktop] files entry missing: ${src}`); continue; }
|
|
701
|
+
cpSync(src, dest, { recursive: true });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Shared format assets: .desktop + icons + metainfo ──
|
|
705
|
+
|
|
706
|
+
function desktopFile(iconName = exeName) {
|
|
707
|
+
return (
|
|
708
|
+
`[Desktop Entry]\n` +
|
|
709
|
+
`Type=Application\n` +
|
|
710
|
+
`Name=${productName}\n` +
|
|
711
|
+
`Comment=${synopsis}\n` +
|
|
712
|
+
`Exec=${exeName}\n` +
|
|
713
|
+
`Icon=${iconName}\n` +
|
|
714
|
+
`Terminal=false\n` +
|
|
715
|
+
`Categories=${category}\n`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function metainfoXml() {
|
|
719
|
+
return (
|
|
720
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
721
|
+
`<component type="desktop-application">\n` +
|
|
722
|
+
` <id>${appId}</id>\n` +
|
|
723
|
+
` <name>${productName}</name>\n` +
|
|
724
|
+
` <summary>${synopsis}</summary>\n` +
|
|
725
|
+
` <launchable type="desktop-id">${appId}.desktop</launchable>\n` +
|
|
726
|
+
`</component>\n`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Resize the icon into hicolor sizes via jimp (already a cli dep); falls
|
|
730
|
+
* back to copying the source verbatim at 256px when jimp can't load it. */
|
|
731
|
+
async function iconSet(destDir) {
|
|
732
|
+
const sizes = [48, 128, 256];
|
|
733
|
+
const written = {};
|
|
734
|
+
let img = null;
|
|
735
|
+
try {
|
|
736
|
+
const { Jimp } = await import("jimp");
|
|
737
|
+
img = await Jimp.read(iconPath);
|
|
738
|
+
} catch { /* unreadable — verbatim fallback below */ }
|
|
739
|
+
for (const s of sizes) {
|
|
740
|
+
const dir = join(destDir, `${s}x${s}`, "apps");
|
|
741
|
+
mkdirSync(dir, { recursive: true });
|
|
742
|
+
const dest = join(dir, `${exeName}.png`);
|
|
743
|
+
try {
|
|
744
|
+
if (!img) throw new Error("no decoder");
|
|
745
|
+
await img.clone().resize({ w: s, h: s }).write(dest);
|
|
746
|
+
} catch {
|
|
747
|
+
copyFileSync(iconPath, dest);
|
|
748
|
+
}
|
|
749
|
+
written[s] = dest;
|
|
750
|
+
}
|
|
751
|
+
return written;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const artifacts = [];
|
|
755
|
+
const pkgLabel = productName.replace(/\s+/g, "-");
|
|
756
|
+
|
|
757
|
+
// ── Format: dir — the appdir itself ──
|
|
758
|
+
|
|
759
|
+
if (formats.includes("dir")) {
|
|
760
|
+
artifacts.push(`dir ${appdir}`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── Format: deb ──
|
|
764
|
+
|
|
765
|
+
if (formats.includes("deb")) {
|
|
766
|
+
const debCfg = build.deb ?? {};
|
|
767
|
+
const stage = mkdtempSync(join(tmpdir(), "dd-deb-"));
|
|
768
|
+
const root = join(stage, "pkg");
|
|
769
|
+
const libDir = join(root, "usr/lib", exeName);
|
|
770
|
+
mkdirSync(libDir, { recursive: true });
|
|
771
|
+
cpSync(appdir, libDir, { recursive: true, verbatimSymlinks: true });
|
|
772
|
+
|
|
773
|
+
mkdirSync(join(root, "usr/bin"), { recursive: true });
|
|
774
|
+
symlinkSync(`../lib/${exeName}/${exeName}`, join(root, "usr/bin", exeName));
|
|
775
|
+
|
|
776
|
+
mkdirSync(join(root, "usr/share/applications"), { recursive: true });
|
|
777
|
+
writeFileSync(join(root, "usr/share/applications", `${appId}.desktop`), desktopFile());
|
|
778
|
+
if (iconPath) await iconSet(join(root, "usr/share/icons/hicolor"));
|
|
779
|
+
mkdirSync(join(root, "usr/share/metainfo"), { recursive: true });
|
|
780
|
+
writeFileSync(join(root, "usr/share/metainfo", `${appId}.metainfo.xml`), metainfoXml());
|
|
781
|
+
|
|
782
|
+
const sizeKb = parseInt(runOut("du", ["-sk", libDir])?.split("\t")[0] ?? "0", 10);
|
|
783
|
+
const depends = (debCfg.depends ?? ["libc6", "libstdc++6", "libvulkan1"]).join(", ");
|
|
784
|
+
mkdirSync(join(root, "DEBIAN"), { recursive: true });
|
|
785
|
+
writeFileSync(join(root, "DEBIAN/control"),
|
|
786
|
+
`Package: ${exeName}\n` +
|
|
787
|
+
`Version: ${version}\n` +
|
|
788
|
+
`Section: ${debCfg.section ?? "games"}\n` +
|
|
789
|
+
`Priority: ${debCfg.priority ?? "optional"}\n` +
|
|
790
|
+
`Architecture: amd64\n` +
|
|
791
|
+
`Installed-Size: ${sizeKb}\n` +
|
|
792
|
+
`Maintainer: ${maintainer}\n` +
|
|
793
|
+
`Depends: ${depends}\n` +
|
|
794
|
+
`Description: ${synopsis}\n` +
|
|
795
|
+
` ${description.replace(/\n/g, "\n ")}\n`);
|
|
796
|
+
|
|
797
|
+
const out = join(outdir, `${exeName}_${version}+${runtime}_amd64.deb`);
|
|
798
|
+
run("dpkg-deb", ["--build", "--root-owner-group", root, out]);
|
|
799
|
+
artifacts.push(`deb ${out}`);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── Format: appimage ──
|
|
803
|
+
|
|
804
|
+
if (formats.includes("appimage")) {
|
|
805
|
+
const stage = mkdtempSync(join(tmpdir(), "dd-appimage-"));
|
|
806
|
+
const aDir = join(stage, "AppDir");
|
|
807
|
+
mkdirSync(aDir, { recursive: true });
|
|
808
|
+
cpSync(appdir, aDir, { recursive: true, verbatimSymlinks: true });
|
|
809
|
+
|
|
810
|
+
// AppRun — the entrypoint the AppImage runtime execs. Symlink to the exe;
|
|
811
|
+
// the launcher's readlink -f resolves it to the real file at AppDir root.
|
|
812
|
+
symlinkSync(exeName, join(aDir, "AppRun"));
|
|
813
|
+
writeFileSync(join(aDir, `${exeName}.desktop`), desktopFile());
|
|
814
|
+
if (iconPath) {
|
|
815
|
+
copyFileSync(iconPath, join(aDir, `${exeName}.png`));
|
|
816
|
+
symlinkSync(`${exeName}.png`, join(aDir, ".DirIcon"));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const tool = await appimagetool();
|
|
820
|
+
const out = join(outdir, `${pkgLabel}-${version}-linux-${runtime}.AppImage`);
|
|
821
|
+
run(tool, [aDir, out], {
|
|
822
|
+
env: {
|
|
823
|
+
...process.env,
|
|
824
|
+
ARCH: "x86_64",
|
|
825
|
+
// appimagetool is itself an AppImage — extract+run without FUSE.
|
|
826
|
+
APPIMAGE_EXTRACT_AND_RUN: "1",
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
chmodSync(out, 0o755);
|
|
830
|
+
artifacts.push(`appimage ${out}`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Locate or download appimagetool into the tool cache. */
|
|
834
|
+
async function appimagetool() {
|
|
835
|
+
const cacheDir = join(process.env.XDG_CACHE_HOME ?? join(process.env.HOME, ".cache"), "downdraft", "tools");
|
|
836
|
+
const cached = join(cacheDir, "appimagetool-x86_64");
|
|
837
|
+
if (existsSync(cached)) return cached;
|
|
838
|
+
const url = "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage";
|
|
839
|
+
console.log(`[package-desktop] fetching appimagetool → ${cached}`);
|
|
840
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
841
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
842
|
+
if (!res.ok) throw new Error(`appimagetool download failed: ${res.status}`);
|
|
843
|
+
writeFileSync(cached, Buffer.from(await res.arrayBuffer()));
|
|
844
|
+
chmodSync(cached, 0o755);
|
|
845
|
+
return cached;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ── Format: flatpak ──
|
|
849
|
+
|
|
850
|
+
if (formats.includes("flatpak")) {
|
|
851
|
+
const fpCfg = build.flatpak ?? {};
|
|
852
|
+
const stage = mkdtempSync(join(tmpdir(), "dd-flatpak-"));
|
|
853
|
+
const buildDir = join(stage, "build");
|
|
854
|
+
const repoDir = join(stage, "repo");
|
|
855
|
+
|
|
856
|
+
const desktopFilePath = join(stage, `${appId}.desktop`);
|
|
857
|
+
writeFileSync(desktopFilePath, desktopFile(appId));
|
|
858
|
+
// Flatpak only exports icons whose basename matches the app-id.
|
|
859
|
+
const iconStage = join(stage, `${appId}.png`);
|
|
860
|
+
if (iconPath) copyFileSync(iconPath, iconStage);
|
|
861
|
+
|
|
862
|
+
const finishArgs = fpCfg.finishArgs ?? [
|
|
863
|
+
"--socket=wayland", "--socket=fallback-x11", "--share=ipc",
|
|
864
|
+
"--device=dri", "--socket=pulseaudio",
|
|
865
|
+
"--filesystem=home", "--share=network",
|
|
866
|
+
];
|
|
867
|
+
const manifest = {
|
|
868
|
+
"app-id": appId,
|
|
869
|
+
"runtime": fpCfg.runtime ?? "org.freedesktop.Platform",
|
|
870
|
+
"runtime-version": fpCfg.runtimeVersion ?? "25.08",
|
|
871
|
+
"sdk": fpCfg.sdk ?? "org.freedesktop.Sdk",
|
|
872
|
+
"command": exeName,
|
|
873
|
+
"separate-locales": false,
|
|
874
|
+
"finish-args": finishArgs,
|
|
875
|
+
"modules": [{
|
|
876
|
+
"name": exeName,
|
|
877
|
+
"buildsystem": "simple",
|
|
878
|
+
// A `bun build --compile` binary keeps its embedded bundle appended
|
|
879
|
+
// past the ELF sections — strip(1) rewrites the file and silently
|
|
880
|
+
// drops it, turning the app into a bare `bun` CLI. Disable it.
|
|
881
|
+
"build-options": { "strip": false, "no-debuginfo": true },
|
|
882
|
+
"sources": [
|
|
883
|
+
{ "type": "dir", "path": appdir, "dest": "appdir" },
|
|
884
|
+
{ "type": "file", "path": desktopFilePath },
|
|
885
|
+
...(iconPath ? [{ "type": "file", "path": iconStage }] : []),
|
|
886
|
+
],
|
|
887
|
+
"build-commands": [
|
|
888
|
+
`mkdir -p /app/lib/${exeName} /app/bin`,
|
|
889
|
+
`cp -a appdir/. /app/lib/${exeName}/`,
|
|
890
|
+
`ln -s /app/lib/${exeName}/${exeName} /app/bin/${exeName}`,
|
|
891
|
+
`install -Dm644 ${basename(desktopFilePath)} /app/share/applications/${appId}.desktop`,
|
|
892
|
+
...(iconPath ? [`install -Dm644 ${appId}.png /app/share/icons/hicolor/256x256/apps/${appId}.png`] : []),
|
|
893
|
+
],
|
|
894
|
+
}],
|
|
895
|
+
};
|
|
896
|
+
const manifestPath = join(stage, `${appId}.json`);
|
|
897
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
898
|
+
|
|
899
|
+
if (!runOut("sh", ["-c", "command -v flatpak-builder"])) {
|
|
900
|
+
throw new Error("flatpak-builder not installed — required for the flatpak format");
|
|
901
|
+
}
|
|
902
|
+
// --state-dir must share the target's filesystem (flatpak-builder hard-
|
|
903
|
+
// links source files); the default cwd-based dir fails when TMPDIR is a
|
|
904
|
+
// different mount than the checkout.
|
|
905
|
+
run("flatpak-builder", ["--repo", repoDir, "--state-dir", join(stage, "state"), "--force-clean", buildDir, manifestPath]);
|
|
906
|
+
const out = join(outdir, `${pkgLabel}-${version}-linux-${runtime}.flatpak`);
|
|
907
|
+
run("flatpak", ["build-bundle", repoDir, out, appId]);
|
|
908
|
+
artifacts.push(`flatpak ${out}`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ── koffi prebuild fetch (same mechanism as package-mobile) ──
|
|
912
|
+
|
|
913
|
+
async function fetchKoffiPrebuild(pkgName, ver) {
|
|
914
|
+
try {
|
|
915
|
+
const meta = await (await fetch(
|
|
916
|
+
`https://registry.npmjs.org/${pkgName.replace("/", "%2F")}`)).json();
|
|
917
|
+
const tarball = meta?.versions?.[ver]?.dist?.tarball ?? meta?.versions?.[meta["dist-tags"]?.latest]?.dist?.tarball;
|
|
918
|
+
if (!tarball) return null;
|
|
919
|
+
const dir = mkdtempSync(join(tmpdir(), "dd-koffi-"));
|
|
920
|
+
const tgz = join(dir, "pkg.tgz");
|
|
921
|
+
writeFileSync(tgz, Buffer.from(await (await fetch(tarball)).arrayBuffer()));
|
|
922
|
+
run("tar", ["-xzf", tgz, "-C", dir]);
|
|
923
|
+
return join(dir, "package");
|
|
924
|
+
} catch (e) {
|
|
925
|
+
console.warn(`[package-desktop] koffi prebuild fetch failed: ${e?.message ?? e}`);
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
console.log(`[package-desktop] done (${runtime}):`);
|
|
931
|
+
for (const a of artifacts) console.log(` ${a}`);
|
|
@@ -146,7 +146,7 @@ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([
|
|
|
146
146
|
const GLOB_EAGER_RE = new RegExp(
|
|
147
147
|
IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1\s*,\s*(\{[^)]*\beager\s*:\s*true[^)]*\})\s*\)`, "g");
|
|
148
148
|
const STAGE_GLOB_RE = new RegExp(
|
|
149
|
-
`(?:${IMPORT_META.source}\\.glob|_glob)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
|
|
149
|
+
`(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
|
|
150
150
|
const STAGE_READ_RE = new RegExp(
|
|
151
151
|
String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
|
|
152
152
|
const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
|
|
@@ -414,7 +414,11 @@ const makePlugin = (collectWorkers) => ({
|
|
|
414
414
|
`const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
|
|
415
415
|
(needsGlob
|
|
416
416
|
? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
|
|
417
|
-
|
|
417
|
+
// Lazy — eager construction runs at module-init time and can
|
|
418
|
+
// hit a TDZ on consts declared later in the same module
|
|
419
|
+
// (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
|
|
420
|
+
// comment).
|
|
421
|
+
`const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
|
|
418
422
|
: "") +
|
|
419
423
|
metaOut;
|
|
420
424
|
}
|
|
@@ -577,6 +581,31 @@ if (envPairs.length) {
|
|
|
577
581
|
writeFileSync(join(bundleDir, "dd-env.txt"), envPairs.join("\n") + "\n");
|
|
578
582
|
}
|
|
579
583
|
|
|
584
|
+
// Game-native addons — convention: <game>/native/dist/android-<arch>/*.node
|
|
585
|
+
// are napi modules the game built itself. .node files aren't jniLibs (the OS
|
|
586
|
+
// only installs lib*.so), so they ride inside the extracted bundle instead:
|
|
587
|
+
// the shell unpacks them to a real path under DOWNDRAFT_BUNDLE_DIR/native/
|
|
588
|
+
// and require()/process.dlopen resolves napi_* against the RTLD_GLOBAL
|
|
589
|
+
// libnode (the -Wl,-z,global link flag exists for exactly this).
|
|
590
|
+
// Staged BEFORE manifest.txt so the extractor's file list covers them.
|
|
591
|
+
// (.so siblings are handled per-ABI in the native-libs section below.)
|
|
592
|
+
{
|
|
593
|
+
let staged = 0;
|
|
594
|
+
for (const abi of abis) {
|
|
595
|
+
const platDir = join(gameDir, "native", "dist",
|
|
596
|
+
`android-${abi.startsWith("arm64") ? "arm64" : "x64"}`);
|
|
597
|
+
if (!existsSync(platDir)) continue;
|
|
598
|
+
const dest = join(bundleDir, "native", `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`);
|
|
599
|
+
for (const f of readdirSync(platDir)) {
|
|
600
|
+
if (!f.endsWith(".node")) continue;
|
|
601
|
+
mkdirSync(dest, { recursive: true });
|
|
602
|
+
copyFileSync(join(platDir, f), join(dest, f));
|
|
603
|
+
staged++;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (staged) console.log(`[package-mobile] staged ${staged} game-native addon(s) into bundle/native/`);
|
|
607
|
+
}
|
|
608
|
+
|
|
580
609
|
// manifest.txt — the shell's extractor follows this verbatim (AAssetDir
|
|
581
610
|
// can't enumerate subdirectories). aapt2 silently drops asset paths with
|
|
582
611
|
// dot-prefixed segments (.bun, .gitignore), so each line is
|
|
@@ -645,11 +674,11 @@ for (const abi of abis) {
|
|
|
645
674
|
? join(monorepoRoot, `packages/node-mobile/build/dist/android-${nodeFlavor}`, abi, "libnode.so")
|
|
646
675
|
: null,
|
|
647
676
|
join(monorepoRoot, "packages/node-mobile/build/dist/android", abi, "libnode.so"),
|
|
648
|
-
join(monorepoRoot, `packages/
|
|
677
|
+
join(monorepoRoot, `packages/native-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/lib/libnode.so`),
|
|
649
678
|
].filter(Boolean);
|
|
650
679
|
let libnode = libnodeCandidates.find(existsSync);
|
|
651
680
|
if (!libnode) {
|
|
652
|
-
try { libnode = join(dirname(gameRequire.resolve(`@downdraft/
|
|
681
|
+
try { libnode = join(dirname(gameRequire.resolve(`@downdraft/native-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/package.json`)), "lib/libnode.so"); } catch { /* absent */ }
|
|
653
682
|
}
|
|
654
683
|
if (!libnode || !existsSync(libnode)) {
|
|
655
684
|
throw new Error(`libnode.so for ${abi} not found — build via packages/node-mobile/scripts/build-android.mjs`);
|
|
@@ -703,6 +732,20 @@ for (const abi of abis) {
|
|
|
703
732
|
if (existsSync(src)) copyFileSync(src, join(libDir, file));
|
|
704
733
|
}
|
|
705
734
|
|
|
735
|
+
// Game-native cdylibs — <game>/native/dist/android-<arch>/lib*.so ride in
|
|
736
|
+
// jniLibs like the engine crates: the OS installs them to the app's native
|
|
737
|
+
// lib dir, where koffi/Deno.dlopen resolves them by bare soname (same
|
|
738
|
+
// mechanism lib-paths.ts's android branch relies on).
|
|
739
|
+
const gamePlatDir = join(gameDir, "native", "dist", nodePlat);
|
|
740
|
+
if (existsSync(gamePlatDir)) {
|
|
741
|
+
for (const f of readdirSync(gamePlatDir)) {
|
|
742
|
+
if (f.startsWith("lib") && f.endsWith(".so")) {
|
|
743
|
+
copyFileSync(join(gamePlatDir, f), join(libDir, f));
|
|
744
|
+
console.log(`[package-mobile] staged game-native ${f} → lib/${abi}/`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
706
749
|
// Strip staged .so's — debug info and the symtab never map at runtime, so
|
|
707
750
|
// this is pure APK/install size (libnode alone sheds ~25MB). Crash
|
|
708
751
|
// symbolication still works: .dynsym and unwind info are preserved.
|
|
@@ -110,7 +110,7 @@ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([
|
|
|
110
110
|
// Staging scan covers all glob call forms: import.meta.glob("pat"), the
|
|
111
111
|
// common _glob("pat") alias, and createGlob(modDir)("pat").
|
|
112
112
|
const STAGE_GLOB_RE = new RegExp(
|
|
113
|
-
`(?:${IMPORT_META.source}\\.glob|_glob)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
|
|
113
|
+
`(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
|
|
114
114
|
// Module-relative file reads: resolve(__dirname, "./zstd.wasm"),
|
|
115
115
|
// join(import.meta.dir, "data.bin") — stage the referenced file.
|
|
116
116
|
const STAGE_READ_RE = new RegExp(
|
|
@@ -371,7 +371,11 @@ const makePlugin = (collectWorkers) => ({
|
|
|
371
371
|
`const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
|
|
372
372
|
(needsGlob
|
|
373
373
|
? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
|
|
374
|
-
|
|
374
|
+
// Lazy — eager construction runs at module-init time and can
|
|
375
|
+
// hit a TDZ on consts declared later in the same module
|
|
376
|
+
// (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
|
|
377
|
+
// comment).
|
|
378
|
+
`const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
|
|
375
379
|
: "") +
|
|
376
380
|
metaOut;
|
|
377
381
|
}
|
|
@@ -440,8 +444,8 @@ result.outputs.forEach((out) => {
|
|
|
440
444
|
// Metadata precedence: --product-name/--product-version/--icon flags, then
|
|
441
445
|
// the game's package.json (productName / build.productName, version,
|
|
442
446
|
// build.icon or icon.ico|icon.png in the game dir), then the game name.
|
|
447
|
+
const gameDir = dirname(dirname(resolve(entry))); // src/native-entry.ts → game root
|
|
443
448
|
if (target.exe) {
|
|
444
|
-
const gameDir = dirname(dirname(resolve(entry))); // src/native-entry.ts → game root
|
|
445
449
|
let pkg = {};
|
|
446
450
|
try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone entry */ }
|
|
447
451
|
const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
@@ -603,6 +607,24 @@ engineCrates.forEach((crate) => {
|
|
|
603
607
|
}
|
|
604
608
|
});
|
|
605
609
|
|
|
610
|
+
// Game-native libs — convention: <game>/native/dist/<platform>-<arch>/ holds
|
|
611
|
+
// artifacts the game built itself (cdylib .so/.dll/.dylib loaded via the FFI
|
|
612
|
+
// adapter, or .node napi addons loaded via createRequire/process.dlopen).
|
|
613
|
+
// Staged flat into native/ so resolveNativeLibrary's <execDir>/native lookup
|
|
614
|
+
// and execDir-relative .node resolution both hit.
|
|
615
|
+
const gameNativeDir = join(gameDir, "native", "dist", platKey);
|
|
616
|
+
if (existsSync(gameNativeDir)) {
|
|
617
|
+
let gameLibs = 0;
|
|
618
|
+
for (const f of readdirSync(gameNativeDir)) {
|
|
619
|
+
if (f.endsWith(libExt) || f.endsWith(".node")) {
|
|
620
|
+
copyFileSync(join(gameNativeDir, f), join(nativeDir, f));
|
|
621
|
+
staged.add(f);
|
|
622
|
+
gameLibs++;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (gameLibs) console.log(`staged ${gameLibs} game-native lib(s) from ${gameNativeDir}`);
|
|
626
|
+
}
|
|
627
|
+
|
|
606
628
|
// Assets referenced via ?url / new URL / import.meta.glob.
|
|
607
629
|
const assetRoot = join(outdir, "dd-assets");
|
|
608
630
|
let stagedAssets = 0;
|
package/src/release.ts
CHANGED
|
@@ -56,6 +56,8 @@ interface ReleaseArgs {
|
|
|
56
56
|
out: string;
|
|
57
57
|
retainMcp: boolean;
|
|
58
58
|
verbose: boolean;
|
|
59
|
+
runtime: string | null;
|
|
60
|
+
format: string | null;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
// ---------------------------------------------------------------------------
|
|
@@ -84,10 +86,6 @@ function parseReleaseArgs(args: string[]): ReleaseArgs {
|
|
|
84
86
|
if (buildOnly) stage = "build";
|
|
85
87
|
else if (skipBuild) stage = "package";
|
|
86
88
|
|
|
87
|
-
if (parsed.flags.format) {
|
|
88
|
-
log.warn("release", "--format is gone — native packaging produces a single binary + staged tree.");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
89
|
return {
|
|
92
90
|
game,
|
|
93
91
|
games,
|
|
@@ -103,6 +101,8 @@ function parseReleaseArgs(args: string[]): ReleaseArgs {
|
|
|
103
101
|
out: (parsed.flags.out as string) || "release",
|
|
104
102
|
retainMcp: parsed.flags.mcp as boolean,
|
|
105
103
|
verbose: parsed.flags.verbose as boolean,
|
|
104
|
+
runtime: (parsed.flags.runtime as string) || null,
|
|
105
|
+
format: (parsed.flags.format as string) || null,
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -173,20 +173,27 @@ function classifyTargets(target: string): string[] {
|
|
|
173
173
|
// Game info resolution
|
|
174
174
|
// ---------------------------------------------------------------------------
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
interface GameInfo {
|
|
177
|
+
productName: string;
|
|
178
|
+
appId: string;
|
|
179
|
+
version: string;
|
|
180
|
+
/** The electron-builder-style `build` block from the game's package.json. */
|
|
181
|
+
build: Record<string, any>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getGameInfo(gameDir: string, game: string): GameInfo {
|
|
177
185
|
const pkgPath = resolve(gameDir, "package.json");
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
186
|
+
const fallback: GameInfo = { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0", build: {} };
|
|
187
|
+
if (!existsSync(pkgPath)) return fallback;
|
|
181
188
|
try {
|
|
182
189
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
183
190
|
const productName = pkg.productName ?? pkg.build?.productName ??
|
|
184
191
|
String(pkg.name ?? game).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
185
192
|
const appId = pkg.build?.appId ?? `com.downdraft.${game}`;
|
|
186
193
|
const version = pkg.version ?? "0.0.0";
|
|
187
|
-
return { productName, appId, version };
|
|
194
|
+
return { productName, appId, version, build: pkg.build ?? {} };
|
|
188
195
|
} catch {
|
|
189
|
-
return
|
|
196
|
+
return fallback;
|
|
190
197
|
}
|
|
191
198
|
}
|
|
192
199
|
|
|
@@ -205,42 +212,93 @@ function packageNative(
|
|
|
205
212
|
gameDir: string,
|
|
206
213
|
targets: string[],
|
|
207
214
|
opts: ReleaseArgs,
|
|
208
|
-
info:
|
|
215
|
+
info: GameInfo,
|
|
209
216
|
): boolean {
|
|
210
217
|
const entry = resolve(gameDir, "src/native-entry.ts");
|
|
211
218
|
if (!existsSync(entry)) {
|
|
212
219
|
log.error("release:package", `No src/native-entry.ts in ${gameDir} — the game has no native entry.`);
|
|
213
220
|
return false;
|
|
214
221
|
}
|
|
215
|
-
// The packaging
|
|
216
|
-
// standalone game repos resolve
|
|
217
|
-
// engine monorepo.
|
|
218
|
-
const
|
|
222
|
+
// The packaging scripts ship inside @downdraft/cli (files: ["scripts"]) so
|
|
223
|
+
// standalone game repos resolve them through the installed package, not
|
|
224
|
+
// the engine monorepo.
|
|
225
|
+
const scriptNative = fileURLToPath(new URL("../scripts/package-native.mjs", import.meta.url));
|
|
226
|
+
const scriptDesktop = fileURLToPath(new URL("../scripts/package-desktop.mjs", import.meta.url));
|
|
219
227
|
// cwd is the relativization root for staged assets — the monorepo root
|
|
220
228
|
// when present (engine sources relativize cleanly), else the game dir.
|
|
221
229
|
const cwd = findMonorepoRoot(gameDir) ?? gameDir;
|
|
230
|
+
|
|
231
|
+
// Runtime matrix: --runtime flag → build.runtime in package.json → bun.
|
|
232
|
+
// "all" expands to the full matrix (one appdir per runtime).
|
|
233
|
+
const RUNTIMES = ["bun", "node", "deno"];
|
|
234
|
+
const requested = opts.runtime ?? info.build.runtime ?? "bun";
|
|
235
|
+
const runtimes = requested === "all" ? RUNTIMES : [requested];
|
|
236
|
+
for (const r of runtimes) {
|
|
237
|
+
if (!RUNTIMES.includes(r)) {
|
|
238
|
+
log.error("release:package", `Unknown runtime "${r}" — expected bun, node, deno, or all.`);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Non-bun runtimes are linux-only today — the emit-tree packaging hasn't
|
|
243
|
+
// been wired for win/mac.
|
|
244
|
+
const nonBun = runtimes.filter((r) => r !== "bun");
|
|
245
|
+
if (nonBun.length && targets.some((t) => t !== "linux")) {
|
|
246
|
+
log.warn("release:package", `--runtime=${nonBun.join(",")} is linux-only today — win/mac targets still embed bun.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
222
249
|
let ok = true;
|
|
223
250
|
targets.forEach((target) => {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
251
|
+
for (const runtime of runtimes) {
|
|
252
|
+
if (target === "linux") {
|
|
253
|
+
// Linux: package-desktop.mjs owns the format matrix (dir, deb,
|
|
254
|
+
// appimage, flatpak). Formats come from --format → build.linux.target
|
|
255
|
+
// → "dir".
|
|
256
|
+
const format = opts.format
|
|
257
|
+
?? (Array.isArray(info.build.linux?.target) ? info.build.linux.target.join(",") : info.build.linux?.target)
|
|
258
|
+
?? "dir";
|
|
259
|
+
const argv = [
|
|
260
|
+
scriptDesktop,
|
|
261
|
+
`--runtime=${runtime}`,
|
|
262
|
+
`--format=${format}`,
|
|
263
|
+
`--mode=${opts.mode}`,
|
|
264
|
+
`--product-name=${info.productName}`,
|
|
265
|
+
`--product-version=${info.version}`,
|
|
266
|
+
`--app-id=${info.appId}`,
|
|
267
|
+
...(opts.retainMcp ? ["--mcp"] : []),
|
|
268
|
+
entry,
|
|
269
|
+
resolve(opts.out),
|
|
270
|
+
];
|
|
271
|
+
log.info("release:package", `bun package-desktop.mjs --runtime=${runtime} --format=${format} ${basename(entry)}`);
|
|
272
|
+
const result = spawnSync("bun", argv, { cwd, stdio: "inherit", env: process.env });
|
|
273
|
+
if (result.status !== 0) {
|
|
274
|
+
log.error("release:package", `Packaging failed for ${game} (${target}/${runtime})`);
|
|
275
|
+
ok = false;
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// win/mac: single-binary bun compile via package-native.mjs.
|
|
280
|
+
if (runtime !== "bun") continue;
|
|
281
|
+
const outfile = resolve(opts.out, `${game}-${target}`);
|
|
282
|
+
const argv = [
|
|
283
|
+
scriptNative,
|
|
284
|
+
`--target=${target}`,
|
|
285
|
+
`--mode=${opts.mode}`,
|
|
286
|
+
`--product-name=${info.productName}`,
|
|
287
|
+
`--product-version=${info.version}`,
|
|
288
|
+
...(opts.retainMcp ? ["--mcp"] : []),
|
|
289
|
+
entry,
|
|
290
|
+
outfile,
|
|
291
|
+
];
|
|
292
|
+
log.info("release:package", `bun ${argv.map((a) => basename(a)).join(" ")}`);
|
|
293
|
+
const result = spawnSync("bun", argv, {
|
|
294
|
+
cwd,
|
|
295
|
+
stdio: "inherit",
|
|
296
|
+
env: process.env,
|
|
297
|
+
});
|
|
298
|
+
if (result.status !== 0) {
|
|
299
|
+
log.error("release:package", `Native packaging failed for ${game} (${target})`);
|
|
300
|
+
ok = false;
|
|
301
|
+
}
|
|
244
302
|
}
|
|
245
303
|
});
|
|
246
304
|
return ok;
|
|
@@ -259,7 +317,7 @@ function packageAndroid(
|
|
|
259
317
|
game: string,
|
|
260
318
|
gameDir: string,
|
|
261
319
|
opts: ReleaseArgs,
|
|
262
|
-
info:
|
|
320
|
+
info: GameInfo,
|
|
263
321
|
): boolean {
|
|
264
322
|
const entry = resolve(gameDir, "src/native-entry.ts");
|
|
265
323
|
if (!existsSync(entry)) {
|
|
@@ -340,8 +398,8 @@ export async function release(args: string[]): Promise<void> {
|
|
|
340
398
|
|
|
341
399
|
for (let _i = 0, _it = games, _n = _it.length; _i < _n; _i++) { const game = _it[_i];
|
|
342
400
|
const gameDir = resolveGameDir(game) ?? resolve(monorepoRoot ?? process.cwd(), "games", game);
|
|
343
|
-
const
|
|
344
|
-
log.info("release", `Game: ${game} | Product: "${productName}" | AppId: ${appId} | v${version}`);
|
|
401
|
+
const info = getGameInfo(gameDir, game);
|
|
402
|
+
log.info("release", `Game: ${game} | Product: "${info.productName}" | AppId: ${info.appId} | v${info.version}`);
|
|
345
403
|
log.info("release", "");
|
|
346
404
|
|
|
347
405
|
// Native packaging is a single compile step — build/package/release
|
|
@@ -349,8 +407,8 @@ export async function release(args: string[]): Promise<void> {
|
|
|
349
407
|
const isAndroid = targets.every((t) => MOBILE_TARGETS.has(t));
|
|
350
408
|
log.info("release", `[package] Compiling ${game} (${targets.join(", ")})...`);
|
|
351
409
|
const ok = isAndroid
|
|
352
|
-
? packageAndroid(game, gameDir, opts,
|
|
353
|
-
: packageNative(game, gameDir, targets, opts,
|
|
410
|
+
? packageAndroid(game, gameDir, opts, info)
|
|
411
|
+
: packageNative(game, gameDir, targets, opts, info);
|
|
354
412
|
if (!ok) {
|
|
355
413
|
log.error("release", `Packaging failed for ${game} — skipping.`);
|
|
356
414
|
fail = 1;
|
package/src/usage.ts
CHANGED
|
@@ -46,7 +46,8 @@ export const COMMANDS: CommandEntry[] = [
|
|
|
46
46
|
{ name: "game", alias: "g", type: "string", description: "Game to release (games/<game>). For multiple games, use --games. If omitted, infers from the current directory, or defaults to the only game in games/." },
|
|
47
47
|
{ name: "games", type: "string", description: "Comma-separated game names (e.g. sandjongg,to-the-ocean)" },
|
|
48
48
|
{ name: "target", alias: "t", type: "string", default: "all", enum: [...RELEASE_TARGETS], description: "Target platform(s): win, linux, mac, android, or all (android can't mix with desktop targets)" },
|
|
49
|
-
{ name: "format", type: "string", description: "
|
|
49
|
+
{ name: "format", type: "string", description: "Package format(s), comma-separated: dir, deb, appimage, flatpak (linux only; default: build.linux.target in package.json, else dir)" },
|
|
50
|
+
{ name: "runtime", type: "string", enum: ["bun", "node", "deno", "all"], description: "JS runtime embedded in the package: bun (compiled binary), node, deno, or all (default: build.runtime in package.json, else bun). node/deno are linux-only today" },
|
|
50
51
|
{ name: "stage", type: "string", default: "release", enum: [...RELEASE_STAGES], description: "Stage: build, package, or release (all compile the native binary)" },
|
|
51
52
|
{ name: "mode", type: "string", default: "prod", enum: [...BUILD_MODES], description: "Build mode" },
|
|
52
53
|
{ name: "abi", type: "string", default: "arm64-v8a", description: "Android ABI(s): arm64-v8a, x86_64, or all (android target only)" },
|