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