@driftengine/package 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +408 -0
  4. package/bin/drift-package.mjs +82 -0
  5. package/dist/android.d.ts +18 -0
  6. package/dist/android.js +207 -0
  7. package/dist/bootstrap.d.ts +17 -0
  8. package/dist/bootstrap.js +326 -0
  9. package/dist/build.d.ts +11 -0
  10. package/dist/build.js +248 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +343 -0
  13. package/dist/flags.d.ts +57 -0
  14. package/dist/flags.js +122 -0
  15. package/dist/identity.d.ts +51 -0
  16. package/dist/identity.js +75 -0
  17. package/dist/index.d.ts +19 -0
  18. package/dist/index.js +24 -0
  19. package/dist/init.d.ts +24 -0
  20. package/dist/init.js +45 -0
  21. package/dist/ios.d.ts +27 -0
  22. package/dist/ios.js +152 -0
  23. package/dist/macCrossSign.d.ts +34 -0
  24. package/dist/macCrossSign.js +298 -0
  25. package/dist/main/consoleForward.d.ts +43 -0
  26. package/dist/main/consoleForward.js +63 -0
  27. package/dist/main/csp.d.ts +4 -0
  28. package/dist/main/csp.js +55 -0
  29. package/dist/main/entry.d.ts +1 -0
  30. package/dist/main/entry.js +56 -0
  31. package/dist/main/ipc.d.ts +28 -0
  32. package/dist/main/ipc.js +284 -0
  33. package/dist/main/links.d.ts +1 -0
  34. package/dist/main/links.js +29 -0
  35. package/dist/main/menu.d.ts +17 -0
  36. package/dist/main/menu.js +49 -0
  37. package/dist/main/serve.d.ts +21 -0
  38. package/dist/main/serve.js +69 -0
  39. package/dist/main/shortcuts.d.ts +28 -0
  40. package/dist/main/shortcuts.js +26 -0
  41. package/dist/main/splash.d.ts +14 -0
  42. package/dist/main/splash.js +41 -0
  43. package/dist/main/splashTiming.d.ts +36 -0
  44. package/dist/main/splashTiming.js +24 -0
  45. package/dist/main/window.d.ts +19 -0
  46. package/dist/main/window.js +297 -0
  47. package/dist/manifest.d.ts +61 -0
  48. package/dist/manifest.js +91 -0
  49. package/dist/mobile/assetCheck.d.ts +1 -0
  50. package/dist/mobile/assetCheck.js +28 -0
  51. package/dist/mobile/bridgeShim.d.ts +83 -0
  52. package/dist/mobile/bridgeShim.js +208 -0
  53. package/dist/mobile/iosShim.d.ts +42 -0
  54. package/dist/mobile/iosShim.js +104 -0
  55. package/dist/mobile/report.d.ts +27 -0
  56. package/dist/mobile/report.js +122 -0
  57. package/dist/mobile/versionCode.d.ts +12 -0
  58. package/dist/mobile/versionCode.js +17 -0
  59. package/dist/outDir.d.ts +30 -0
  60. package/dist/outDir.js +32 -0
  61. package/dist/preload/bridge.d.ts +77 -0
  62. package/dist/preload/bridge.js +2 -0
  63. package/dist/preload/entry.d.ts +1 -0
  64. package/dist/preload/entry.js +77 -0
  65. package/dist/renderer/fileStore.d.ts +23 -0
  66. package/dist/renderer/fileStore.js +33 -0
  67. package/dist/renderer/host.d.ts +45 -0
  68. package/dist/renderer/host.js +30 -0
  69. package/dist/renderer/nativeHost.d.ts +83 -0
  70. package/dist/renderer/nativeHost.js +125 -0
  71. package/dist/resources.d.ts +71 -0
  72. package/dist/resources.js +83 -0
  73. package/dist/runtime.d.ts +38 -0
  74. package/dist/runtime.js +80 -0
  75. package/dist/runtimeVersion.d.ts +10 -0
  76. package/dist/runtimeVersion.js +18 -0
  77. package/dist/signing.d.ts +49 -0
  78. package/dist/signing.js +135 -0
  79. package/dist/stage.d.ts +22 -0
  80. package/dist/stage.js +138 -0
  81. package/dist/steam/cloudStore.d.ts +30 -0
  82. package/dist/steam/cloudStore.js +54 -0
  83. package/dist/steam/depot.d.ts +29 -0
  84. package/dist/steam/depot.js +39 -0
  85. package/dist/steam/main.d.ts +22 -0
  86. package/dist/steam/main.js +41 -0
  87. package/dist/templates/buildWindows.d.ts +16 -0
  88. package/dist/templates/buildWindows.js +161 -0
  89. package/dist/templates/packageWrapper.d.ts +9 -0
  90. package/dist/templates/packageWrapper.js +80 -0
  91. package/dist/toolchain.d.ts +101 -0
  92. package/dist/toolchain.js +186 -0
  93. package/package.json +73 -0
  94. package/src/android.ts +256 -0
  95. package/src/bootstrap.ts +389 -0
  96. package/src/build.ts +285 -0
  97. package/src/cli.ts +367 -0
  98. package/src/flags.ts +171 -0
  99. package/src/identity.ts +87 -0
  100. package/src/index.ts +31 -0
  101. package/src/init.ts +61 -0
  102. package/src/ios.ts +185 -0
  103. package/src/macCrossSign.ts +338 -0
  104. package/src/main/consoleForward.ts +93 -0
  105. package/src/main/csp.ts +57 -0
  106. package/src/main/entry.ts +63 -0
  107. package/src/main/ipc.ts +311 -0
  108. package/src/main/links.ts +29 -0
  109. package/src/main/menu.ts +53 -0
  110. package/src/main/serve.ts +70 -0
  111. package/src/main/shortcuts.ts +55 -0
  112. package/src/main/splash.ts +42 -0
  113. package/src/main/splashTiming.ts +51 -0
  114. package/src/main/window.ts +316 -0
  115. package/src/manifest.ts +157 -0
  116. package/src/mobile/assetCheck.ts +25 -0
  117. package/src/mobile/bridgeShim.ts +292 -0
  118. package/src/mobile/iosShim.ts +148 -0
  119. package/src/mobile/report.ts +121 -0
  120. package/src/mobile/versionCode.ts +17 -0
  121. package/src/outDir.ts +37 -0
  122. package/src/preload/bridge.ts +73 -0
  123. package/src/preload/entry.ts +91 -0
  124. package/src/renderer/fileStore.ts +38 -0
  125. package/src/renderer/host.ts +96 -0
  126. package/src/renderer/nativeHost.ts +146 -0
  127. package/src/resources.ts +150 -0
  128. package/src/runtime.ts +87 -0
  129. package/src/runtimeVersion.ts +16 -0
  130. package/src/signing.ts +206 -0
  131. package/src/stage.ts +180 -0
  132. package/src/steam/cloudStore.ts +73 -0
  133. package/src/steam/depot.ts +69 -0
  134. package/src/steam/main.ts +69 -0
  135. package/src/templates/buildWindows.ts +163 -0
  136. package/src/templates/packageWrapper.ts +82 -0
  137. package/src/toolchain.ts +210 -0
package/src/cli.ts ADDED
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
4
+ import { writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+
8
+ import { bootstrap } from './bootstrap.ts';
9
+ import { build, installedElectronVersion } from './build.ts';
10
+ import { planFlags } from './flags.ts';
11
+ import { refuseLyingFilesystem } from './identity.ts';
12
+ import { init } from './init.ts';
13
+ import { outFor } from './outDir.ts';
14
+ import { parseManifest } from './manifest.ts';
15
+ import type { PackageManifest, Target } from './manifest.ts';
16
+ import { planSigning } from './signing.ts';
17
+ import { electronMajor } from './runtimeVersion.ts';
18
+ import { depotScripts } from './steam/depot.ts';
19
+ import { androidToolchain, findRcodesign, needsRcodesign, requirementsFor } from './toolchain.ts';
20
+ import { stageApp } from './stage.ts';
21
+ import { resourceDir } from './resources.ts';
22
+ import type { ResourceOptions } from './resources.ts';
23
+
24
+ /*
25
+ * **`ELECTRON_RUN_AS_NODE` is removed from this process before anything else happens.**
26
+ *
27
+ * Any terminal that is itself an Electron application — an editor's built-in one is the ordinary
28
+ * case, and a developer's own shell had it set — exports the variable to everything it spawns.
29
+ * Inherited, it turns Electron into plain Node: a packaged build dies on `Cannot find module
30
+ * 'electron'` under a Node banner, which looks nothing like its cause, and in that mode the
31
+ * built-in module genuinely is not there.
32
+ *
33
+ * **It used to be removed for `run` alone**, in `launchEnv` below, which left `build` and every
34
+ * other subcommand inheriting it — and `build` is the one that spends twenty minutes before
35
+ * anything can go wrong. Deleted rather than set empty: Electron treats the variable as set
36
+ * whatever its value.
37
+ */
38
+ delete process.env.ELECTRON_RUN_AS_NODE;
39
+
40
+ /**
41
+ * `drift-package` — the command a consumer runs to get an installable application.
42
+ *
43
+ * Four subcommands, and `verify` is the one that is not obvious. An artifact that was never
44
+ * rebuilt is byte-identical in every respect a build log can see, so the only honest check is to
45
+ * look inside the shipped thing for a string from the change. This repository already learned that
46
+ * about deploys: an asset hash proves nothing.
47
+ *
48
+ * Every failure exits non-zero with the reason on stderr. A packaging command that prints a
49
+ * complaint and exits 0 is a command that passes in a script.
50
+ */
51
+ function loadManifest(cwd: string): PackageManifest {
52
+ const path = join(cwd, 'drift.package.json');
53
+ try {
54
+ return parseManifest(JSON.parse(readFileSync(path, 'utf8')) as unknown);
55
+ } catch (cause) {
56
+ throw new Error(`could not read ${path}: ${String(cause)}`);
57
+ }
58
+ }
59
+
60
+ function walk(dir: string, out: string[] = []): string[] {
61
+ for (const entry of readdirSync(dir)) {
62
+ const full = join(dir, entry);
63
+ if (statSync(full).isDirectory()) walk(full, out);
64
+ else out.push(full);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ async function doctor(cwd: string, options: ResourceOptions = {}): Promise<PackageManifest> {
70
+ /*
71
+ * **Before anything is read, downloaded or staged**, because the failure it replaces arrives ten
72
+ * minutes in and talks about copying a file into a subdirectory of itself. See `identity.ts`:
73
+ * a share that invents a file index makes Node refuse a copy that is perfectly legitimate.
74
+ */
75
+ refuseLyingFilesystem(cwd, options.outRoot);
76
+ const manifest = loadManifest(cwd);
77
+ /*
78
+ * **The packager's own three directories, checked here rather than met mid-build.** `assets/`
79
+ * becomes every desktop artifact's `drift://shell/` origin, and a copy of the engine that
80
+ * flattened its packages has moved all three out of reach — which used to arrive as `ENOENT` on a
81
+ * path that is not in the project, after esbuild had already run. `doctor` is where this file
82
+ * already refuses what a build cannot do, before anything is downloaded or staged.
83
+ *
84
+ * `assets` alone: it is the one every target needs, and naming `android/` or `ios/` on a machine
85
+ * building for Linux would refuse a build that was never going to touch them. The two mobile
86
+ * builders check their own.
87
+ */
88
+ await resourceDir('assets', options.resourceRoot);
89
+ /*
90
+ * **Against the Electron this machine would actually package**, not the one the package declares.
91
+ * A switch written for one Chromium and applied to another is ignored without a word, so doctor
92
+ * is where a runtime too old for it is named — before a build spends its minutes and ships a
93
+ * game that silently has no WebGPU.
94
+ */
95
+ const electron = electronMajor(await installedElectronVersion().catch(() => null));
96
+ for (const target of manifest.targets) {
97
+ const plan = planFlags(manifest, target, electron);
98
+ if (plan.refusal !== null) throw new Error(`${target}: ${plan.refusal}`);
99
+ for (const note of plan.notes) console.log(` ${target}: ${note}`);
100
+ }
101
+ console.log(`ok: ${manifest.name} (${manifest.id}) for ${manifest.targets.join(', ')}`);
102
+ for (const target of manifest.targets) {
103
+ const signing = planSigning(target, process.env, process.platform);
104
+ console.log(` ${target}: signing ${signing.mode} — ${signing.reason}`);
105
+ }
106
+ console.log(
107
+ ` splash: ${manifest.splash.show ? `the engine badge, held ${manifest.splash.minMs} ms` : 'off'}`,
108
+ );
109
+
110
+ /*
111
+ * **What each target still needs, and the command that fetches it.** A `doctor` that only
112
+ * validated a manifest would pass on a machine with no Java and no SDK, and the failure would
113
+ * arrive twenty minutes later from Gradle.
114
+ */
115
+ for (const target of manifest.targets) {
116
+ const ready = await toolchainReady(target);
117
+ if (ready) continue;
118
+ console.log(
119
+ ` ${target}: needs ${requirementsFor(target).join(', ')} — ` +
120
+ `run \`drift-package bootstrap --target=${target}\``,
121
+ );
122
+ }
123
+ return manifest;
124
+ }
125
+
126
+ function verify(cwd: string, needle: string): void {
127
+ const manifest = loadManifest(cwd);
128
+ const root = join(cwd, dirname(manifest.entry));
129
+ const found = walk(root).some((file) => readFileSync(file, 'utf8').includes(needle));
130
+ if (!found) {
131
+ throw new Error(
132
+ `verify: "${needle}" is in no file under ${root}. That is what a build that did not rerun ` +
133
+ 'looks like — the artifact exists and is stale.',
134
+ );
135
+ }
136
+ console.log(`ok: "${needle}" is in the built bundle`);
137
+ }
138
+
139
+ /**
140
+ * The packaged application, run from source without producing an installer.
141
+ *
142
+ * **The whole point is that it is the same code path.** It stages exactly what a build stages and
143
+ * starts Electron on it, so the window, the flags, the `drift://` origin and the badge are the
144
+ * ones that will ship. A `run` that took a shortcut would be a rehearsal of something else.
145
+ */
146
+ async function run(cwd: string, options: ResourceOptions): Promise<number> {
147
+ const manifest = await doctor(cwd, options);
148
+ const stageDir = mkdtempSync(join(tmpdir(), 'drift-run-'));
149
+ /* A development run: developer tools open, warnings forwarded. `build` stages the other kind. */
150
+ await stageApp(manifest, cwd, stageDir, { development: true, ...options });
151
+ const electron = await import('electron');
152
+ const binary = electron.default as unknown as string;
153
+ console.log(`[drift-package] running from ${stageDir}`);
154
+ return new Promise((resolve) => {
155
+ const child = spawn(binary, [stageDir], { stdio: 'inherit', env: launchEnv() });
156
+ child.on('close', (code) => {
157
+ /* The staging directory holds a whole copy of the game's build — tens of megabytes for a
158
+ real one — and a `run` per iteration would leave one of each in the temp directory. */
159
+ rmSync(stageDir, { recursive: true, force: true });
160
+ resolve(code ?? 0);
161
+ });
162
+ });
163
+ }
164
+
165
+ /**
166
+ * The environment Electron is started in, with one variable removed.
167
+ *
168
+ * **`ELECTRON_RUN_AS_NODE` turns Electron into plain Node**, and any Electron-based terminal sets
169
+ * it for the processes it spawns — an editor's integrated terminal is the common case, and this
170
+ * workspace's own shell had it set. Inherited, it produces a failure that looks nothing like its
171
+ * cause: the binary starts, prints a Node banner and dies on `Cannot find module 'electron'`,
172
+ * because in that mode the built-in module genuinely is not there.
173
+ *
174
+ * Deleted rather than overridden with an empty string: Electron treats the variable as set
175
+ * whatever its value.
176
+ *
177
+ * **This is now the second place it is removed** and it stays: the top of this file clears it for
178
+ * the whole process, and this clears it for the child. The reason is written twice because it is
179
+ * the reason somebody will come here looking for, and a copy of `process.env` taken elsewhere
180
+ * would not inherit the first deletion.
181
+ */
182
+ function launchEnv(): NodeJS.ProcessEnv {
183
+ const env = { ...process.env };
184
+ delete env.ELECTRON_RUN_AS_NODE;
185
+ return env;
186
+ }
187
+
188
+ /**
189
+ * Whether this machine can build that target today.
190
+ *
191
+ * Desktop targets need the Electron runtime, which is present unless an install skipped its
192
+ * postinstall; Android needs a whole toolchain; iOS needs Xcode, which nothing here can check for
193
+ * beyond the platform it would have to be on.
194
+ */
195
+ async function toolchainReady(target: Target): Promise<boolean> {
196
+ if (target === 'android') return (await androidToolchain()) !== null;
197
+ if (target === 'ios') return process.platform === 'darwin';
198
+ /* A Mac target built anywhere else is signed by rcodesign, and an unsigned .app will not
199
+ launch — so a machine without it is not ready for that target, however well it packs. */
200
+ if (needsRcodesign(target)) return (await findRcodesign()) !== null;
201
+ return true;
202
+ }
203
+
204
+ /**
205
+ * The two files `steamcmd` uploads a build with, written beside the artifacts they describe.
206
+ *
207
+ * A separate command rather than part of `build`, because uploading is a decision: the scripts
208
+ * name an app id that costs money to get wrong, and a build is often made several times before one
209
+ * of them is the one that ships.
210
+ */
211
+ async function writeSteamScripts(
212
+ cwd: string,
213
+ args: readonly string[],
214
+ options: ResourceOptions,
215
+ ): Promise<void> {
216
+ const manifest = loadManifest(cwd);
217
+ if (manifest.steam.appId === null) {
218
+ throw new Error(
219
+ 'steam: `steam.appId` is null in drift.package.json. Set it to the app id Valve gave you; ' +
220
+ "a depot script with the wrong one uploads into somebody else's game.",
221
+ );
222
+ }
223
+ const target = (
224
+ args.find((entry) => entry.startsWith('--target=')) ?? '--target=linux-x64'
225
+ ).slice('--target='.length) as Target;
226
+ const depotArg = args.find((entry) => entry.startsWith('--depot='));
227
+ /* A first depot is conventionally the app id plus one, which is how Valve numbers them. */
228
+ const depotId =
229
+ depotArg === undefined ? manifest.steam.appId + 1 : Number.parseInt(depotArg.slice(8), 10);
230
+ const branchArg = args.find((entry) => entry.startsWith('--branch='));
231
+
232
+ const outDir = outFor(cwd, target, options);
233
+ const content =
234
+ target === 'linux-x64' ? 'linux-unpacked' : target === 'win-x64' ? 'win-unpacked' : 'mac';
235
+ const scripts = depotScripts({
236
+ appId: manifest.steam.appId,
237
+ depotId,
238
+ description: `${manifest.name} ${target}`,
239
+ contentRoot: content,
240
+ branch: branchArg === undefined ? null : branchArg.slice('--branch='.length),
241
+ });
242
+
243
+ await writeFile(join(outDir, scripts.appFileName), scripts.app, 'utf8');
244
+ await writeFile(join(outDir, scripts.depotFileName), scripts.depot, 'utf8');
245
+ console.log(`ok: ${join(outDir, scripts.appFileName)}`);
246
+ console.log(`ok: ${join(outDir, scripts.depotFileName)}`);
247
+ console.log(
248
+ ` upload with: steamcmd +login <user> +run_app_build "${join(outDir, scripts.appFileName)}" +quit`,
249
+ );
250
+ console.log(" nothing is set live: the build waits in Steam's admin for a person");
251
+ }
252
+
253
+ /** What this machine builds for itself, used when nothing has said otherwise. */
254
+ function hostTarget(): Target {
255
+ if (process.platform === 'win32') return 'win-x64';
256
+ if (process.platform === 'darwin') return process.arch === 'x64' ? 'mac-x64' : 'mac-arm64';
257
+ return 'linux-x64';
258
+ }
259
+
260
+ function targetsFrom(manifest: PackageManifest, args: readonly string[]): readonly Target[] {
261
+ const asked = args.find((entry) => entry.startsWith('--target='));
262
+ if (asked === undefined) return manifest.targets;
263
+ const name = asked.slice('--target='.length);
264
+ const found = manifest.targets.find((target) => target === name);
265
+ if (found === undefined) {
266
+ throw new Error(
267
+ `--target=${name} is not in this manifest's targets (${manifest.targets.join(', ')})`,
268
+ );
269
+ }
270
+ return [found];
271
+ }
272
+
273
+ const [command, ...rest] = process.argv.slice(2);
274
+
275
+ /**
276
+ * The project being packaged, which is the working directory unless `--project=` says otherwise.
277
+ *
278
+ * **Because the packager and the game are often not the same repository.** A game that keeps its
279
+ * `drift.package.json` and nothing else — no Electron, no electron-builder, no Android SDK in its
280
+ * own `node_modules` — is packaged by pointing this at it from wherever the tool lives, and that
281
+ * is the arrangement most consumers actually want: half a gigabyte of build tooling does not
282
+ * belong in a game's dependency tree.
283
+ */
284
+ const projectFlag = rest.find((entry) => entry.startsWith('--project='));
285
+ const cwd =
286
+ projectFlag === undefined
287
+ ? process.cwd()
288
+ : resolve(process.cwd(), projectFlag.slice('--project='.length));
289
+
290
+ /**
291
+ * Where the packager's own `assets/`, `android/` and `ios/` are, if not beside its sources.
292
+ *
293
+ * **For a consumer that vendors engine source** rather than installing it: such a vendor copies
294
+ * `packages/<name>/src/**` to `<name>/**` and the flattening moves
295
+ * the three out of reach of anything this package can compute. Reported from outside, where the
296
+ * workaround was a layout exception in the vendor script for this one package. See `resources.ts`
297
+ * for why this is a flag rather than an environment variable.
298
+ */
299
+ const resourcesFlag = rest.find((entry) => entry.startsWith('--resources='));
300
+
301
+ /**
302
+ * Where builds write, if not `out/` inside the project.
303
+ *
304
+ * **For a project on a filesystem that misreports file identity**, where the first staging copy
305
+ * of a build refuses itself: `fs.cp` compares `dev` and `ino` up the destination's ancestors, and
306
+ * a shared or network folder can answer the same pair for a file and a directory. `outDir.ts`
307
+ * carries the measurement. Absolute here, or relative to wherever the command was run, which is
308
+ * not necessarily `--project=`.
309
+ */
310
+ const outFlag = rest.find((entry) => entry.startsWith('--out='));
311
+ const resources: ResourceOptions = {
312
+ ...(resourcesFlag === undefined
313
+ ? {}
314
+ : { resourceRoot: resourcesFlag.slice('--resources='.length) }),
315
+ ...(outFlag === undefined ? {} : { outRoot: outFlag.slice('--out='.length) }),
316
+ };
317
+
318
+ try {
319
+ if (command === 'doctor') {
320
+ await doctor(cwd, resources);
321
+ } else if (command === 'verify') {
322
+ const arg = rest.find((entry) => entry.startsWith('--contains='));
323
+ if (arg === undefined) throw new Error('verify needs --contains=<string>');
324
+ verify(cwd, arg.slice('--contains='.length));
325
+ } else if (command === 'run') {
326
+ process.exitCode = await run(cwd, resources);
327
+ } else if (command === 'bootstrap') {
328
+ /* Deliberately does not need a manifest: a machine is prepared before a project exists on it,
329
+ and `--target=` names what to prepare for. */
330
+ const asked = rest.find((entry) => entry.startsWith('--target='));
331
+ const targets =
332
+ asked === undefined
333
+ ? ((): readonly Target[] => {
334
+ try {
335
+ return loadManifest(cwd).targets;
336
+ } catch {
337
+ /* No manifest here: prepare for this desktop, which is the useful default on a
338
+ machine somebody is setting up. */
339
+ return [hostTarget()];
340
+ }
341
+ })()
342
+ : [asked.slice('--target='.length) as Target];
343
+ await bootstrap(targets);
344
+ } else if (command === 'init') {
345
+ /*
346
+ * Scaffolds a *project*, where `bootstrap` prepares a *machine*. Two commands because a
347
+ * machine is prepared once and a project once per project, and conflating them would make the
348
+ * one that writes files into your repository the one you run to install a JDK.
349
+ */
350
+ await init(cwd, loadManifest(cwd), rest.includes('--force'));
351
+ } else if (command === 'steam') {
352
+ await writeSteamScripts(cwd, rest, resources);
353
+ } else if (command === 'build') {
354
+ /* Checked before anything is downloaded or staged: a build that would be refused must be
355
+ refused first, not after twenty minutes of Electron. */
356
+ const manifest = await doctor(cwd, resources);
357
+ await build(manifest, cwd, targetsFrom(manifest, rest), resources);
358
+ } else {
359
+ throw new Error(
360
+ 'usage: drift-package <init|bootstrap|doctor|build|run|verify|steam> [--project=<dir>] ' +
361
+ `[--target=<target>] [--resources=<dir>] [--out=<dir>] [--force] — got "${String(command)}"`,
362
+ );
363
+ }
364
+ } catch (cause) {
365
+ console.error(String(cause instanceof Error ? cause.message : cause));
366
+ process.exit(1);
367
+ }
package/src/flags.ts ADDED
@@ -0,0 +1,171 @@
1
+ import type { PackageManifest, Target } from './manifest.ts';
2
+
3
+ /**
4
+ * The Chromium switches a shipped build launches with.
5
+ *
6
+ * **This is most of why the packager exists.** Every switch below was paid for once already, in
7
+ * this repository or by a consumer, and a game that wraps Electron by hand rediscovers each of
8
+ * them — two of them only by measuring a frame nobody thought to measure.
9
+ *
10
+ * **What this costs**: the flags are ours rather than the platform's defaults, so a Chromium
11
+ * change can invalidate one and nothing will say so. **What would make it wrong**: Electron
12
+ * enabling Vulkan on Linux by default, at which point the first block below becomes a no-op worth
13
+ * deleting rather than a fix worth keeping.
14
+ *
15
+ * **Checked 2026-09-09 and still needed**, on Electron 43.4.1 / Chromium 150.0.7871.224 with an
16
+ * RX 9070 XT: launched with no switches at all, `navigator.gpu` is present and `requestAdapter`
17
+ * answers **null**. With the interop feature it hands back an `amd`/`rdna-4` adapter and a device.
18
+ * So the block below is doing the work rather than riding along, six Chromium majors after it was
19
+ * written.
20
+ *
21
+ * **And a switch is now only asked for on a runtime it has been seen to work on**, because the
22
+ * other half of "nothing will say so" is a name Chromium does not recognise: unknown features in
23
+ * `--enable-features` are dropped in silence, and the build that results is indistinguishable from
24
+ * a machine with no adapter to offer. See `WEBGPU_INTEROP_ELECTRON`.
25
+ */
26
+ export interface FlagPlan {
27
+ readonly switches: readonly (readonly [string, string])[];
28
+ /** Non-null means refuse to build, and this is what to print. */
29
+ readonly refusal: string | null;
30
+ /**
31
+ * What a caller must print, having decided nothing.
32
+ *
33
+ * **A switch that names a feature the runtime has never heard of is worse than no switch**, and
34
+ * that is what these exist for: Chromium ignores an unknown name in `--enable-features` without a
35
+ * word, so a build that cannot deliver WebGPU looks exactly like a machine whose driver had no
36
+ * adapter to offer. Where this plan declines to ask for something, it says so out loud.
37
+ */
38
+ readonly notes: readonly string[];
39
+ }
40
+
41
+ /**
42
+ * The Electron the Linux WebGPU switch below is written against, and measured on.
43
+ *
44
+ * **Not the version the feature was introduced in, which nobody here has established.** It is the
45
+ * version this repository has run the switch against and watched WebGPU come up: Electron 43.4.1,
46
+ * Chromium 150.0.7871.224, an RX 9070 XT, where `requestAdapter` answers null without the switch
47
+ * and hands back an `amd`/`rdna-4` adapter with it. Reported from outside against Electron 33.4.11,
48
+ * Chromium 130: the feature name is not in that binary at all, and the switch is inert there.
49
+ *
50
+ * So a runtime older than this is *unverified*, not known-broken — and the plan declines to ship an
51
+ * unverified switch rather than shipping one that may do nothing silently.
52
+ */
53
+ export const WEBGPU_INTEROP_ELECTRON = 43;
54
+
55
+ /**
56
+ * @param electronMajor The major version of the Electron these switches will be applied to, or
57
+ * `null` where a caller genuinely cannot know. Both callers in this package can: the build reads
58
+ * it out of the installed package, and the main process reads it out of `process.versions`.
59
+ */
60
+ export function planFlags(
61
+ manifest: PackageManifest,
62
+ target: Target,
63
+ electronMajor: number | null = null,
64
+ ): FlagPlan {
65
+ /*
66
+ * **A mobile host has no command line to pass switches on.** The renderer is the platform's own
67
+ * WebView, updated by the store rather than by us, and every decision below is about a Chromium
68
+ * this package ships. Returning nothing is the honest answer rather than switches that would be
69
+ * silently dropped — and it is why the Linux refusal further down cannot fire on a phone.
70
+ */
71
+ if (target === 'android' || target === 'ios') return { switches: [], refusal: null, notes: [] };
72
+
73
+ const switches: (readonly [string, string])[] = [];
74
+ const notes: string[] = [];
75
+ const wantsWebGpu = manifest.backend.webgpu !== 'off';
76
+ const linux = target === 'linux-x64';
77
+
78
+ /*
79
+ * **A software rasteriser is never chosen silently.** Plain headless Chromium selects
80
+ * SwiftShader on a development machine, measured, and says nothing about it — every
81
+ * frame it produces is a lie about what a player sees and it saturates the CPU producing it.
82
+ * `requireHardwareGpu()` refuses it in the capture harness; this refuses it in the product.
83
+ */
84
+ if (!manifest.backend.allowSoftwareRenderer) {
85
+ switches.push(['disable-software-rasterizer', '']);
86
+ }
87
+
88
+ if (linux && wantsWebGpu) {
89
+ /*
90
+ * **Electron does not enable WebGPU on Linux although Chrome does** (`electron#40929`), so
91
+ * without this there is no WebGPU on that target at all — `requestAdapter` simply answers null
92
+ * and the engine falls back, correctly and invisibly.
93
+ *
94
+ * **The interop feature, and deliberately not `--use-angle=vulkan --enable-features=Vulkan`.**
95
+ * Both yield WebGPU. The second also breaks video encoding under Wayland: measured on an
96
+ * RX 9070 XT, canvas frames encode as all-zero YUV — a green clip — with nothing reporting it,
97
+ * since `VideoEncoder.error` never fires and the muxer finalises a file of the expected size.
98
+ * `ForceEnableWebGpuInterop` composites WebGPU through GL instead of moving ANGLE onto Vulkan,
99
+ * and both survive: measured in a packaged build on this machine, WebGPU drew and a vp8 round
100
+ * trip came back magenta rather than green.
101
+ *
102
+ * **This is why clip export and WebGPU are no longer refused together.** They were, for as
103
+ * long as the only switch known to work was the one that broke encoding.
104
+ *
105
+ * **What would make it wrong** is a Chromium that retires the feature, at which point WebGPU
106
+ * quietly stops being offered on Linux and the acceptance probe reports the fallback — which
107
+ * is the failure mode to watch for rather than a green clip.
108
+ *
109
+ * **Reported from outside as hanging a Wayland session, and it does not do that here.** The
110
+ * report is a packaged game that never paints on AMD under Wayland: an adapter is offered and
111
+ * the renderer's creation never returns. Chromium does print
112
+ * `'--ozone-platform=wayland' is not compatible with Vulkan` on stderr while this switch is
113
+ * applied, on this machine too. But run against the engine's own starter — an RX 9070 XT on a
114
+ * Wayland session under COSMIC, Electron 43.4.1, Chromium 150 — the acquisition completes in
115
+ * **153 ms, three runs of three**, and the example draws with WebGPU. The bare path completes
116
+ * as well: adapter, device, canvas context, swapchain, submit, and a one-pixel readback that
117
+ * comes back magenta.
118
+ *
119
+ * **Chromium's own advice is worse here.** Pairing this with `--ozone-platform=x11`, which is
120
+ * what that message suggests, takes the GPU process down on this machine: `exit_code=139`, and
121
+ * a screenshot request answers `UnknownVizError`. So it is not offered.
122
+ *
123
+ * The switch is therefore kept, because withdrawing it on every Wayland session would take a
124
+ * measured-working backend away from every machine like this one. What the report is right
125
+ * about is that a stall had no floor under it, and that is closed where it belongs: see
126
+ * `BACKEND_TIMEOUT_MS` in the engine's backend selection, which answers WebGL2 rather than
127
+ * waiting for a device that is not coming.
128
+ */
129
+ /*
130
+ * **And it is only asked for on a runtime it has been seen to work on.** The switch was
131
+ * verified once and then travelled, unchecked, into whatever Electron a consumer's install
132
+ * happened to resolve — which is how a build reached a Chromium whose binary does not contain
133
+ * the feature name at all. Nothing reported it, because nothing could: an ignored feature name
134
+ * and an absent adapter produce the same product.
135
+ */
136
+ /*
137
+ * **And there is no second-best route on an older runtime, deliberately.**
138
+ *
139
+ * There is one that works: measured on Electron 33.4.11 against this machine, WebGPU comes up
140
+ * under `--enable-unsafe-webgpu` *and* `--enable-features=Vulkan` together — neither alone
141
+ * produces an adapter, nor does `--use-angle=vulkan` beside them. It is not offered, for two
142
+ * reasons that hold together. This package declares the Electron it is written against, so a
143
+ * build resolving an older one has an install to fix rather than a workaround to adopt; and the
144
+ * route costs video encoding under Wayland, where canvas frames encode as all-zero YUV — a
145
+ * green clip, with `VideoEncoder.error` never firing and the muxer finalising a file of the
146
+ * expected size. Shipping an unsafe flag and that trade, to a configuration this package says
147
+ * it does not support, is a worse answer than saying so.
148
+ */
149
+ if (electronMajor === null || electronMajor >= WEBGPU_INTEROP_ELECTRON) {
150
+ switches.push(['enable-features', 'ForceEnableWebGpuInterop']);
151
+ } else if (manifest.backend.webgpu === 'require') {
152
+ return {
153
+ switches,
154
+ notes,
155
+ refusal:
156
+ `backend.webgpu is "require" and Electron ${electronMajor} cannot deliver it on Linux. ` +
157
+ `This package declares Electron ${WEBGPU_INTEROP_ELECTRON}; an install resolving an older ` +
158
+ 'one is the thing to fix. Otherwise set backend.webgpu to "prefer" or "off" to ship ' +
159
+ 'WebGL2 on this target deliberately.',
160
+ };
161
+ } else {
162
+ notes.push(
163
+ `no WebGPU on Linux: Electron ${electronMajor} predates the interop switch this package is ` +
164
+ `written against (${WEBGPU_INTEROP_ELECTRON}), so this build falls back to WebGL2 there. ` +
165
+ 'Install the Electron this package declares to get it back.',
166
+ );
167
+ }
168
+ }
169
+
170
+ return { switches, refusal: null, notes };
171
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ /** The two fields Node compares to decide whether two paths are the same thing. */
5
+ export interface Identity {
6
+ readonly dev: bigint;
7
+ readonly ino: bigint;
8
+ }
9
+
10
+ /**
11
+ * Node's own `areIdentical`, which is what decides whether a copy is refused.
12
+ *
13
+ * The two zeros are excluded there and here: a filesystem that hands out no index answers 0, and
14
+ * Node declines to believe it.
15
+ *
16
+ * **`bigint` and not `number`.** A Windows file index is 64 bits and loses precision as a double
17
+ * exactly in the high half where those values live, so two different indices can arrive equal and
18
+ * two equal ones different — the check would be wrong in both directions on precisely the platform
19
+ * it exists for.
20
+ */
21
+ export function sameIdentity(a: Identity, b: Identity): boolean {
22
+ return a.ino !== 0n && a.dev !== 0n && a.ino === b.ino && a.dev === b.dev;
23
+ }
24
+
25
+ /**
26
+ * Refuse a filesystem that cannot tell a file from a directory, before a build spends its minutes.
27
+ *
28
+ * **The failure this replaces is a sentence about the one thing that is not happening.** `stage.ts`
29
+ * copies `drift.package.json` into the staging directory, and Node's `fs.cp` refuses to copy a
30
+ * thing into itself — deciding that by walking the destination's ancestors and comparing each
31
+ * one's `dev` and `ino` against the source's. On a filesystem that reports those honestly it never
32
+ * fires. On a network share it can, and what comes out is
33
+ *
34
+ * cannot copy …\drift.package.json to a subdirectory of self …\out\win-x64\stage\…
35
+ *
36
+ * ten minutes into a build, about the thing furthest from the cause you could imagine. Reported
37
+ * from a Windows VM building over a mapped drive, where a file and a directory both answered
38
+ * `dev=66313 ino=-112686486700016`: a redirector that gets no file index from the server invents a
39
+ * constant one, and on Windows `dev` is already the volume serial number.
40
+ *
41
+ * **The witnesses are not the directories Node will really compare, and that is fine.**
42
+ * `checkParentPaths` walks up from `out/<target>/stage` and stops at the source's parent, so it
43
+ * compares `stage`, `<target>` and `out` — which on a first build do not exist yet and cannot be
44
+ * stat'ed here. What is being established is not *which* directory collides but that this volume
45
+ * gives a file and a directory the same identity, which is a property of the volume. The project
46
+ * root and `out/` are witnesses as good as any.
47
+ *
48
+ * **The remedy it names is `--out=<dir>`, and that is what changed.** Until that flag existed the
49
+ * only answer was to clone onto a local disk, which for a project with tens of gigabytes of assets
50
+ * is a real cost. Now the output can simply be sent somewhere that tells the truth, and the copy is
51
+ * the second suggestion rather than the first.
52
+ *
53
+ * **`look` is a seam and exists for one reason**: the condition cannot be reproduced on any
54
+ * filesystem this suite can create, so without it the refusal — the whole point of the function —
55
+ * would ship having never been seen to fire. The default is `statSync` and no caller passes
56
+ * anything else.
57
+ */
58
+ export function refuseLyingFilesystem(
59
+ cwd: string,
60
+ outRoot: string | undefined,
61
+ look: (path: string) => Identity = (path) => statSync(path, { bigint: true }),
62
+ ): void {
63
+ /* Already pointed off this volume: whatever it answers about itself no longer decides a copy. */
64
+ if (outRoot !== undefined && !resolve(process.cwd(), outRoot).startsWith(resolve(cwd))) return;
65
+
66
+ const manifest = join(cwd, 'drift.package.json');
67
+ if (!existsSync(manifest)) return;
68
+ const its = look(manifest);
69
+
70
+ for (const witness of [cwd, join(cwd, 'out')]) {
71
+ if (!existsSync(witness)) continue;
72
+ const theirs = look(witness);
73
+ if (!sameIdentity(its, theirs)) continue;
74
+ throw new Error(
75
+ `${cwd} is on a filesystem that gives a file and a directory the same identity.\n\n` +
76
+ ` ${manifest}\n dev=${its.dev} ino=${its.ino}\n` +
77
+ ` ${witness}\n dev=${theirs.dev} ino=${theirs.ino}\n\n` +
78
+ 'Node uses those two fields to avoid copying something into itself, so the first copy of ' +
79
+ 'the staging step fails with "cannot copy … to a subdirectory of self …" — a message ' +
80
+ 'about the thing furthest from the cause, ten minutes into a build. It is what a network ' +
81
+ 'share does: the redirector invents a constant file index when the server gives it none.\n\n' +
82
+ 'Send the output somewhere that tells the truth:\n\n' +
83
+ ' drift-package build --out=/a/local/disk\n\n' +
84
+ 'Or build from a local copy of the project, if the assets are small enough to move.',
85
+ );
86
+ }
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * What a game imports from the packager.
4
+ *
5
+ * **The renderer half only.** The main process, the CLI, the builder and the staging step are
6
+ * Node and are reached through the `drift-package` command rather than through this barrel — a
7
+ * game bundling `electron` or `esbuild` into its own payload is the failure this boundary exists
8
+ * to prevent.
9
+ *
10
+ * `createHost` is the one call most consumers need; the classes beside it are for a game that
11
+ * assembles its capabilities itself.
12
+ */
13
+ export { createHost } from './renderer/host.ts';
14
+ export type { Host } from './renderer/host.ts';
15
+ export { FileStore } from './renderer/fileStore.ts';
16
+ export {
17
+ NativeDisplay,
18
+ NativeFileDialogs,
19
+ NativeLifecycle,
20
+ hostBridge,
21
+ } from './renderer/nativeHost.ts';
22
+ /*
23
+ * A store's own features, when a store answered.
24
+ *
25
+ * `createHost().services` is how a game reaches these — null in a browser, null where the manifest
26
+ * named no app, and null when the copy was launched outside the store. The SDK itself is not here:
27
+ * it is a native Node module held by the main process, because the renderer has no Node at all.
28
+ */
29
+ export { NativePlatformServices } from './renderer/nativeHost.ts';
30
+ export { BRIDGE_KEY } from './preload/bridge.ts';
31
+ export type { DriftHostBridge } from './preload/bridge.ts';