@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
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `drift-package` executable.
4
+ *
5
+ * JavaScript because npm's `bin` runs under plain Node, starting a CLI that is TypeScript because
6
+ * everything else in this repository is. `tsx` is the same bridge `scripts/` uses.
7
+ *
8
+ * **`tsx` is launched with this Node and never through `npx`, and that is not a preference.** On
9
+ * Windows `npx` is `npx.cmd`, and a `.cmd` is not something `CreateProcess` knows how to launch: it
10
+ * needs `cmd.exe`, that is `shell: true`, which reopens quoting on the one platform where paths
11
+ * contain spaces as a matter of course. Without it `spawnSync` fails with `ENOENT`, leaves `status`
12
+ * at `null`, and a bare `status ?? 1` exits 1 **printing nothing at all** — because the reason is in
13
+ * `result.error`, which nothing read. Reported from a Windows VM, where it presented as
14
+ *
15
+ * > node scripts/package.mjs doctor
16
+ * npm run package:doctor failed with exit code 1.
17
+ *
18
+ * and above that line, the nothing that invited you to read. It cost a consumer half a day and is
19
+ * most of why they wrote a launcher of their own instead of using this one.
20
+ *
21
+ * Launching the Node that is already running answers all of it: no shell, no `.cmd`, no npx
22
+ * resolution, and the copy of `tsx` is the one this project locked rather than whichever npx finds.
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { createRequire } from 'node:module';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const cli = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
32
+
33
+ /**
34
+ * `tsx`'s own entry point, read from the `bin` it declares.
35
+ *
36
+ * Not a path written here: a version of tsx that moves its entry would then break this file
37
+ * silently, and this is the layer whose failures are hardest to see.
38
+ */
39
+ function tsxEntry() {
40
+ let manifest;
41
+ try {
42
+ manifest = require.resolve('tsx/package.json');
43
+ } catch {
44
+ console.error(
45
+ 'tsx is not installed. drift-package runs its TypeScript CLI through it; install the ' +
46
+ "packager's dependencies and try again.",
47
+ );
48
+ process.exit(1);
49
+ }
50
+ const bin = JSON.parse(readFileSync(manifest, 'utf8')).bin;
51
+ const relative = typeof bin === 'string' ? bin : bin?.tsx;
52
+ if (typeof relative !== 'string') {
53
+ console.error(`${manifest} declares no \`bin\` that can be launched.`);
54
+ process.exit(1);
55
+ }
56
+ const entry = path.join(path.dirname(manifest), relative);
57
+ if (!existsSync(entry)) {
58
+ console.error(`${entry} is missing, so tsx is installed but incomplete. Reinstall it.`);
59
+ process.exit(1);
60
+ }
61
+ return entry;
62
+ }
63
+
64
+ const result = spawnSync(process.execPath, [tsxEntry(), cli, ...process.argv.slice(2)], {
65
+ stdio: 'inherit',
66
+ });
67
+
68
+ /*
69
+ * **`spawnSync` does not throw**, and leaving this out is what made the bug above invisible. A
70
+ * process that never starts leaves `status` at `null` and puts the reason in `result.error`, so a
71
+ * bare `process.exit(status ?? 1)` turns "I could not launch anything" into "it failed with 1" —
72
+ * the same exit as a real failure, with nothing above it to read.
73
+ */
74
+ if (result.error !== undefined) {
75
+ console.error(`drift-package could not launch ${process.execPath}: ${String(result.error)}`);
76
+ process.exit(1);
77
+ }
78
+ if (result.signal !== null && result.signal !== undefined) {
79
+ console.error(`drift-package was terminated by signal ${result.signal}.`);
80
+ process.exit(1);
81
+ }
82
+ process.exit(result.status ?? 1);
@@ -0,0 +1,18 @@
1
+ import type { PackageManifest } from './manifest.ts';
2
+ import type { ResourceOptions } from './resources.ts';
3
+ /**
4
+ * An installable APK, from the same web build the desktop shell runs.
5
+ *
6
+ * **The Gradle project is a template that is copied, never built in place.** A consumer's game is
7
+ * tens of megabytes of assets; copying it into `packages/package/android/` would put it in this
8
+ * repository's working tree, and a `git status` after one build would be unreadable. So the
9
+ * template is staged into `out/android/project` and the build happens there.
10
+ *
11
+ * **Signed with a throwaway key, on purpose and stated loudly.** Android refuses to install an
12
+ * unsigned APK at all, so there is no "unsigned" option to offer — the choice is between a key
13
+ * this generates and a key a person owns. A generated one installs on a device with developer mode
14
+ * on, which is what this is for; it cannot go to the Play Store, and an upgrade signed with a
15
+ * *different* generated key is refused by the device as an impostor. `DRIFT_ANDROID_KEYSTORE` and
16
+ * its two passwords take over the moment somebody has a real one.
17
+ */
18
+ export declare function buildAndroid(manifest: PackageManifest, projectDir: string, outDir: string, options?: ResourceOptions): Promise<void>;
@@ -0,0 +1,207 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { build as esbuild } from 'esbuild';
4
+ import { basename, dirname, join, relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { resourceDir } from './resources.js';
7
+ import { missingReferences } from './mobile/assetCheck.js';
8
+ import { versionCodeFor } from './mobile/versionCode.js';
9
+ import { androidToolchain, toolchainHome } from './toolchain.js';
10
+ const HERE = dirname(fileURLToPath(import.meta.url));
11
+ /**
12
+ * An installable APK, from the same web build the desktop shell runs.
13
+ *
14
+ * **The Gradle project is a template that is copied, never built in place.** A consumer's game is
15
+ * tens of megabytes of assets; copying it into `packages/package/android/` would put it in this
16
+ * repository's working tree, and a `git status` after one build would be unreadable. So the
17
+ * template is staged into `out/android/project` and the build happens there.
18
+ *
19
+ * **Signed with a throwaway key, on purpose and stated loudly.** Android refuses to install an
20
+ * unsigned APK at all, so there is no "unsigned" option to offer — the choice is between a key
21
+ * this generates and a key a person owns. A generated one installs on a device with developer mode
22
+ * on, which is what this is for; it cannot go to the Play Store, and an upgrade signed with a
23
+ * *different* generated key is refused by the device as an impostor. `DRIFT_ANDROID_KEYSTORE` and
24
+ * its two passwords take over the moment somebody has a real one.
25
+ */
26
+ export async function buildAndroid(manifest, projectDir, outDir, options = {}) {
27
+ const toolchain = await androidToolchain();
28
+ if (toolchain === null) {
29
+ throw new Error('the Android toolchain is not here yet. Run `drift-package bootstrap --target=android`, ' +
30
+ 'which fetches a JDK, the SDK and Gradle into a cache; or set JAVA_HOME and ANDROID_HOME ' +
31
+ 'to ones you already have.');
32
+ }
33
+ const projectRoot = join(outDir, 'project');
34
+ await rm(projectRoot, { recursive: true, force: true });
35
+ await mkdir(projectRoot, { recursive: true });
36
+ await cp(await resourceDir('android', options.resourceRoot), projectRoot, { recursive: true });
37
+ /*
38
+ * **The game goes at the asset root**, because that is where its own absolute references point.
39
+ * See the comment in `MainActivity.java`: a build served from a subdirectory loads its HTML and
40
+ * nothing else.
41
+ */
42
+ const assets = join(projectRoot, 'app', 'src', 'main', 'assets');
43
+ await mkdir(assets, { recursive: true });
44
+ const distDir = resolve(projectDir, dirname(manifest.entry));
45
+ await cp(distDir, assets, { recursive: true });
46
+ await checkAssetReferences(assets, basename(manifest.entry));
47
+ /*
48
+ * The shim is bundled as an IIFE rather than a module: it is evaluated as a document-start
49
+ * script by the WebView, which is a bare script rather than a module context.
50
+ */
51
+ await esbuild({
52
+ stdin: {
53
+ contents: `import { attachShim } from ${JSON.stringify(join(HERE, 'mobile', 'bridgeShim.ts'))};\nattachShim('__driftHost');\n`,
54
+ resolveDir: HERE,
55
+ loader: 'ts',
56
+ },
57
+ outfile: join(assets, 'drift-host', 'shim.js'),
58
+ bundle: true,
59
+ format: 'iife',
60
+ platform: 'browser',
61
+ target: 'es2022',
62
+ logLevel: 'warning',
63
+ });
64
+ if (manifest.icon !== null) {
65
+ /* One PNG at one density: Android scales it for the rest, and a game that wants a full set
66
+ supplies its own `res/` through its own build rather than through this. */
67
+ await cp(resolve(projectDir, manifest.icon), join(projectRoot, 'app', 'src', 'main', 'res', 'mipmap-xxhdpi', 'ic_launcher.png'));
68
+ }
69
+ const keystore = await ensureKeystore(manifest.id, toolchain.javaHome);
70
+ await writeFile(join(projectRoot, 'local.properties'), `sdk.dir=${toolchain.sdkRoot}\n`, 'utf8');
71
+ const version = await consumerVersion(projectDir);
72
+ console.log(`[drift-package] android: building ${manifest.name} ${version}`);
73
+ console.log(`[drift-package] android: signing ${keystore.describe}`);
74
+ await gradle(toolchain, projectRoot, [
75
+ 'assembleRelease',
76
+ `-PdriftAppId=${manifest.id}`,
77
+ `-PdriftAppName=${manifest.name}`,
78
+ `-PdriftVersionName=${version}`,
79
+ `-PdriftVersionCode=${versionCodeFor(version)}`,
80
+ `-PdriftKeystore=${keystore.path}`,
81
+ `-PdriftKeystorePassword=${keystore.password}`,
82
+ `-PdriftKeyAlias=${keystore.alias}`,
83
+ `-PdriftKeyPassword=${keystore.password}`,
84
+ ]);
85
+ const apk = join(projectRoot, 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk');
86
+ const delivered = join(outDir, `${manifest.name.replace(/[^\w.-]+/g, '-')}-${version}.apk`);
87
+ await cp(apk, delivered);
88
+ console.log(`[drift-package] android: ${delivered}`);
89
+ }
90
+ /**
91
+ * A signing key: the one in the environment, or a throwaway generated once and kept.
92
+ *
93
+ * **Kept, and kept outside the project.** Android identifies an application by its signing key: a
94
+ * device refuses to upgrade an installed app whose new copy is signed with a different one, and
95
+ * the only way out on the device is to uninstall and lose the saves. So a generated key lives in
96
+ * the toolchain cache, keyed by application id — where `git clean` cannot reach it, where the
97
+ * build output being gitignored cannot lose it, and where two games on one machine do not share
98
+ * one identity.
99
+ */
100
+ async function ensureKeystore(appId, javaHome) {
101
+ const real = process.env.DRIFT_ANDROID_KEYSTORE;
102
+ if (typeof real === 'string' && real.length > 0) {
103
+ return {
104
+ path: real,
105
+ password: process.env.DRIFT_ANDROID_KEYSTORE_PASSWORD ?? '',
106
+ alias: process.env.DRIFT_ANDROID_KEY_ALIAS ?? 'drift',
107
+ describe: `with the keystore in DRIFT_ANDROID_KEYSTORE`,
108
+ };
109
+ }
110
+ const dir = join(toolchainHome(), 'keystores');
111
+ const path = join(dir, `${appId}.jks`);
112
+ const keystore = {
113
+ path,
114
+ password: 'driftengine',
115
+ alias: 'drift',
116
+ describe: 'with a generated throwaway key, kept in the toolchain cache — installable with developer ' +
117
+ 'mode on, not publishable, and an upgrade signed with a different key is refused by the ' +
118
+ 'device, which is why it is kept rather than made fresh',
119
+ };
120
+ try {
121
+ await readFile(path);
122
+ return keystore;
123
+ }
124
+ catch {
125
+ /* Not there yet. */
126
+ }
127
+ await mkdir(dir, { recursive: true });
128
+ await runTool(join(javaHome, 'bin', process.platform === 'win32' ? 'keytool.exe' : 'keytool'), [
129
+ '-genkeypair',
130
+ '-keystore',
131
+ path,
132
+ '-alias',
133
+ keystore.alias,
134
+ '-storepass',
135
+ keystore.password,
136
+ '-keypass',
137
+ keystore.password,
138
+ '-keyalg',
139
+ 'RSA',
140
+ '-keysize',
141
+ '2048',
142
+ /* Twenty-seven years, which is what Google asks of an upload key, and long enough that a
143
+ throwaway key is never the reason a build stops working. */
144
+ '-validity',
145
+ '10000',
146
+ '-dname',
147
+ 'CN=DriftEngine, OU=Development, O=DriftEngine, C=GB',
148
+ ], {});
149
+ return keystore;
150
+ }
151
+ async function consumerVersion(projectDir) {
152
+ try {
153
+ const raw = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'));
154
+ const version = raw.version;
155
+ return typeof version === 'string' && version.length > 0 ? version : '0.0.0';
156
+ }
157
+ catch {
158
+ return '0.0.0';
159
+ }
160
+ }
161
+ async function gradle(toolchain, projectRoot, args) {
162
+ await runTool(toolchain.gradle, ['--project-dir', projectRoot, '--no-daemon', ...args], {
163
+ JAVA_HOME: toolchain.javaHome,
164
+ ANDROID_HOME: toolchain.sdkRoot,
165
+ ANDROID_SDK_ROOT: toolchain.sdkRoot,
166
+ });
167
+ }
168
+ async function runTool(command, args, env) {
169
+ await new Promise((done, fail) => {
170
+ const child = spawn(command, [...args], {
171
+ stdio: 'inherit',
172
+ env: { ...process.env, ...env },
173
+ });
174
+ child.on('error', fail);
175
+ child.on('close', (code) => code === 0 ? done() : fail(new Error(`${command} exited with ${String(code)}`)));
176
+ });
177
+ }
178
+ /**
179
+ * Refuse a build whose entry page points at files that will not be there.
180
+ *
181
+ * **The check that would have saved a device round trip.** A game whose HTML references
182
+ * `/assets/index-abc.js` served from the wrong root loads its markup and none of its code, and
183
+ * nothing in the build says so — the APK is valid, installs, launches, and shows unstyled text.
184
+ * Here it is a build failure naming the first few references that do not resolve.
185
+ */
186
+ async function checkAssetReferences(assetsRoot, entryName) {
187
+ const html = await readFile(join(assetsRoot, entryName), 'utf8');
188
+ const present = new Set(await listRelative(assetsRoot, assetsRoot));
189
+ const missing = missingReferences(html, present);
190
+ if (missing.length === 0)
191
+ return;
192
+ throw new Error(`the entry page references ${missing.length} file(s) that are not in the bundle: ` +
193
+ `${missing.slice(0, 5).join(', ')}. Those are absolute paths, so they resolve from the root ` +
194
+ 'the game is served at — which is where the packager puts it. A game built with a `base` ' +
195
+ 'other than `/` needs that base to match, or a relative one.');
196
+ }
197
+ async function listRelative(root, dir) {
198
+ const out = [];
199
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
200
+ const full = join(dir, entry.name);
201
+ if (entry.isDirectory())
202
+ out.push(...(await listRelative(root, full)));
203
+ else
204
+ out.push(relative(root, full).split('\\').join('/'));
205
+ }
206
+ return out;
207
+ }
@@ -0,0 +1,17 @@
1
+ import type { Target } from './manifest.ts';
2
+ /**
3
+ * Fetch everything a target needs to be built, so nobody has to install seven things by hand.
4
+ *
5
+ * **This is the command that makes the other ones work on a fresh machine.** A packaging toolchain
6
+ * is a JDK, an Android SDK, a Gradle distribution and an Electron runtime, in pinned versions;
7
+ * every one of those is a download and a licence prompt, and a README that lists them is a README
8
+ * somebody follows wrongly at two in the morning.
9
+ *
10
+ * **Nothing is installed system-wide and nothing needs a password.** Everything lands under a
11
+ * cache directory this owns, and a machine that already has a working toolchain — `JAVA_HOME`,
12
+ * `ANDROID_HOME` — is used as it is rather than duplicated.
13
+ *
14
+ * **Only what is actually downloadable is downloaded.** Xcode is not: it is a sign-in and an App
15
+ * Store install on a Mac, so the iOS branch prints what to get rather than pretending.
16
+ */
17
+ export declare function bootstrap(targets: readonly Target[]): Promise<void>;
@@ -0,0 +1,326 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { chmod, readdir, rename, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { PINS, ensureDir, findAndroidSdk, findGradle, findJavaHome, findRcodesign, findXcodeGen, needsRcodesign, toolchainHome, } from './toolchain.js';
8
+ /**
9
+ * Fetch everything a target needs to be built, so nobody has to install seven things by hand.
10
+ *
11
+ * **This is the command that makes the other ones work on a fresh machine.** A packaging toolchain
12
+ * is a JDK, an Android SDK, a Gradle distribution and an Electron runtime, in pinned versions;
13
+ * every one of those is a download and a licence prompt, and a README that lists them is a README
14
+ * somebody follows wrongly at two in the morning.
15
+ *
16
+ * **Nothing is installed system-wide and nothing needs a password.** Everything lands under a
17
+ * cache directory this owns, and a machine that already has a working toolchain — `JAVA_HOME`,
18
+ * `ANDROID_HOME` — is used as it is rather than duplicated.
19
+ *
20
+ * **Only what is actually downloadable is downloaded.** Xcode is not: it is a sign-in and an App
21
+ * Store install on a Mac, so the iOS branch prints what to get rather than pretending.
22
+ */
23
+ export async function bootstrap(targets) {
24
+ const wantsAndroid = targets.includes('android');
25
+ const wantsIos = targets.includes('ios');
26
+ const wantsDesktop = targets.some((target) => target !== 'android' && target !== 'ios');
27
+ if (wantsDesktop)
28
+ await bootstrapElectron();
29
+ if (targets.some((target) => needsRcodesign(target)))
30
+ await bootstrapRcodesign();
31
+ if (wantsAndroid)
32
+ await bootstrapAndroid();
33
+ if (wantsIos)
34
+ await bootstrapIos();
35
+ }
36
+ /**
37
+ * `rcodesign`, which signs a Mac target on a machine that has no `codesign`.
38
+ *
39
+ * **Fetched only when a Mac target is asked for off a Mac**, because that is the only situation
40
+ * where the platform's own signer is missing. On a Mac this is never downloaded: `codesign` ships
41
+ * with the command line tools and electron-builder drives it directly.
42
+ *
43
+ * A single static musl binary, so unlike the JDK there is nothing to discover in the archive and
44
+ * nothing that cares which distribution it lands on.
45
+ */
46
+ async function bootstrapRcodesign() {
47
+ const existing = await findRcodesign();
48
+ if (existing !== null) {
49
+ console.log(`ok: rcodesign is present at ${existing}`);
50
+ return;
51
+ }
52
+ const asset = rcodesignAsset();
53
+ if (asset === null) {
54
+ throw new Error(`there is no published rcodesign for ${process.platform}/${process.arch}. ` +
55
+ '`cargo install apple-codesign` builds one, and the build will find it in ~/.cargo/bin');
56
+ }
57
+ const home = toolchainHome();
58
+ await ensureDir(home);
59
+ console.log(`fetching ${PINS.rcodesign.label}…`);
60
+ const archive = join(home, asset.archive);
61
+ await download('https://github.com/indygreg/apple-platform-rs/releases/download/' +
62
+ `apple-codesign%2F${PINS.rcodesign.version}/${asset.archive}`, archive);
63
+ const staging = join(home, 'rcodesign-unpack');
64
+ await rm(staging, { recursive: true, force: true });
65
+ await ensureDir(staging);
66
+ await extract(archive, staging);
67
+ /* The archive unpacks one directory named for the triple, holding the binary and its licences.
68
+ The binary is searched for rather than assumed at that path: the name of the wrapper
69
+ directory is the one part of a release asset that has changed before. */
70
+ const found = await findFile(staging, process.platform === 'win32' ? 'rcodesign.exe' : 'rcodesign');
71
+ if (found === null)
72
+ throw new Error('the rcodesign archive did not contain a binary');
73
+ const target = join(home, `rcodesign-${PINS.rcodesign.version}`);
74
+ await rm(target, { recursive: true, force: true });
75
+ await ensureDir(target);
76
+ const binary = join(target, 'rcodesign');
77
+ await rename(found, binary);
78
+ await chmod(binary, 0o755).catch(() => undefined);
79
+ await rm(staging, { recursive: true, force: true });
80
+ await rm(archive, { force: true });
81
+ console.log(`ok: rcodesign is at ${binary}`);
82
+ }
83
+ /** Which published rcodesign asset this machine can run, or null if the release page has none. */
84
+ function rcodesignAsset() {
85
+ const version = PINS.rcodesign.version;
86
+ if (process.platform === 'linux') {
87
+ /* musl rather than gnu: the release publishes only the static build, which is the one that
88
+ does not care about the host's glibc. */
89
+ const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64';
90
+ return { archive: `apple-codesign-${version}-${arch}-unknown-linux-musl.tar.gz` };
91
+ }
92
+ if (process.platform === 'win32') {
93
+ return { archive: `apple-codesign-${version}-x86_64-pc-windows-msvc.zip` };
94
+ }
95
+ return null;
96
+ }
97
+ /** The first file with this name anywhere under a directory, or null. */
98
+ async function findFile(root, name) {
99
+ for (const entry of await readdir(root, { withFileTypes: true })) {
100
+ const full = join(root, entry.name);
101
+ if (entry.isDirectory()) {
102
+ const nested = await findFile(full, name);
103
+ if (nested !== null)
104
+ return nested;
105
+ }
106
+ else if (entry.name === name) {
107
+ return full;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * The Electron runtime, which npm sometimes does not fetch.
114
+ *
115
+ * Its binary arrives through a postinstall script, and an install run with scripts disabled —
116
+ * which several sandboxes, CI images and corporate setups do by default — leaves the package
117
+ * present and the runtime missing. The failure that produces is `Electron failed to install
118
+ * correctly` at the moment somebody first tries to run a game.
119
+ */
120
+ async function bootstrapElectron() {
121
+ const require = (await import('node:module')).createRequire(import.meta.url);
122
+ let installer;
123
+ try {
124
+ installer = require.resolve('electron/install.js');
125
+ }
126
+ catch {
127
+ throw new Error('electron is not installed; run npm install first');
128
+ }
129
+ const dist = join(installer, '..', 'dist');
130
+ if (await pathExists(dist)) {
131
+ console.log('ok: the Electron runtime is present');
132
+ return;
133
+ }
134
+ console.log('fetching the Electron runtime…');
135
+ await run(process.execPath, [installer], {});
136
+ console.log('ok: the Electron runtime is present');
137
+ }
138
+ async function bootstrapAndroid() {
139
+ const home = toolchainHome();
140
+ await ensureDir(home);
141
+ const javaHome = (await findJavaHome()) ?? (await installJdk(home));
142
+ const sdkRoot = (await findAndroidSdk()) ?? (await installAndroidSdk(home, javaHome));
143
+ await installAndroidPackages(sdkRoot, javaHome);
144
+ const gradle = (await findGradle()) ?? (await installGradle(home));
145
+ console.log(`ok: android toolchain ready`);
146
+ console.log(` JDK: ${javaHome}`);
147
+ console.log(` SDK: ${sdkRoot}`);
148
+ console.log(` Gradle: ${gradle}`);
149
+ }
150
+ async function bootstrapIos() {
151
+ if (process.platform !== 'darwin') {
152
+ console.log('ios: this is not a Mac, and an iOS build needs one. Nothing to fetch here.');
153
+ return;
154
+ }
155
+ const home = toolchainHome();
156
+ await ensureDir(home);
157
+ if ((await findXcodeGen()) === null) {
158
+ console.log(`fetching ${PINS.xcodegen.label}…`);
159
+ const archive = join(home, 'xcodegen.zip');
160
+ await download(`https://github.com/yonaskolb/XcodeGen/releases/download/${PINS.xcodegen.version}/xcodegen.zip`, archive);
161
+ const target = join(home, `xcodegen-${PINS.xcodegen.version}`);
162
+ await rm(target, { recursive: true, force: true });
163
+ await ensureDir(target);
164
+ await extract(archive, target);
165
+ /* The archive unpacks a `xcodegen/` directory containing bin and share; flattened so the
166
+ path this file resolves is the path `findXcodeGen` looks for. */
167
+ const [inner] = await readdir(target);
168
+ if (inner !== undefined && inner === 'xcodegen') {
169
+ await rename(join(target, 'xcodegen', 'bin'), join(target, 'bin'));
170
+ await rename(join(target, 'xcodegen', 'share'), join(target, 'share')).catch(() => undefined);
171
+ }
172
+ await chmod(join(target, 'bin', 'xcodegen'), 0o755).catch(() => undefined);
173
+ await rm(archive, { force: true });
174
+ }
175
+ /* Xcode itself cannot be fetched, so it is checked for and named rather than installed. */
176
+ try {
177
+ await run('xcodebuild', ['-version'], { quiet: true });
178
+ console.log('ok: Xcode is here, and so is XcodeGen');
179
+ }
180
+ catch {
181
+ console.log('ios: XcodeGen is ready; Xcode is not.');
182
+ console.log(' - install Xcode from the App Store, then `xcode-select --install`');
183
+ console.log(' - a free Apple ID installs to your own device for seven days at a time;');
184
+ console.log(' the paid Developer Program is what makes a build last and reach TestFlight');
185
+ }
186
+ }
187
+ /** Temurin, from the Adoptium API, which redirects to the current build of the pinned feature. */
188
+ async function installJdk(home) {
189
+ const os = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'mac' : 'linux';
190
+ const arch = process.arch === 'arm64' ? 'aarch64' : 'x64';
191
+ const url = `https://api.adoptium.net/v3/binary/latest/${PINS.jdk.feature}/ga/${os}/${arch}` +
192
+ '/jdk/hotspot/normal/eclipse';
193
+ console.log(`fetching ${PINS.jdk.label}…`);
194
+ const archive = join(home, os === 'windows' ? 'jdk.zip' : 'jdk.tar.gz');
195
+ await download(url, archive);
196
+ const staging = join(home, 'jdk-unpack');
197
+ await rm(staging, { recursive: true, force: true });
198
+ await ensureDir(staging);
199
+ await extract(archive, staging);
200
+ /* Every JDK archive unpacks into one versioned directory; the pin is on the feature release, so
201
+ the exact name changes with each patch and is discovered rather than assumed. */
202
+ const [inner] = await readdir(staging);
203
+ if (inner === undefined)
204
+ throw new Error('the JDK archive unpacked into nothing');
205
+ const target = join(home, 'jdk');
206
+ await rm(target, { recursive: true, force: true });
207
+ await rename(join(staging, inner), target);
208
+ await rm(staging, { recursive: true, force: true });
209
+ await rm(archive, { force: true });
210
+ return target;
211
+ }
212
+ async function installAndroidSdk(home, javaHome) {
213
+ const os = process.platform === 'win32' ? 'win' : process.platform === 'darwin' ? 'mac' : 'linux';
214
+ const url = `https://dl.google.com/android/repository/commandlinetools-${os}-${PINS.androidTools.build}_latest.zip`;
215
+ console.log(`fetching ${PINS.androidTools.label}…`);
216
+ const archive = join(home, 'cmdline-tools.zip');
217
+ await download(url, archive);
218
+ const sdkRoot = join(home, 'android-sdk');
219
+ const staging = join(home, 'cmdline-unpack');
220
+ await rm(staging, { recursive: true, force: true });
221
+ await ensureDir(staging);
222
+ await extract(archive, staging);
223
+ /* sdkmanager insists on living at `cmdline-tools/latest/`, and the archive unpacks a directory
224
+ called `cmdline-tools`. Getting this wrong produces a tool that refuses to run and blames the
225
+ SDK root rather than its own location. */
226
+ await ensureDir(join(sdkRoot, 'cmdline-tools'));
227
+ await rm(join(sdkRoot, 'cmdline-tools', 'latest'), { recursive: true, force: true });
228
+ await rename(join(staging, 'cmdline-tools'), join(sdkRoot, 'cmdline-tools', 'latest'));
229
+ await rm(staging, { recursive: true, force: true });
230
+ await rm(archive, { force: true });
231
+ await acceptLicences(sdkRoot, javaHome);
232
+ return sdkRoot;
233
+ }
234
+ /**
235
+ * Accept the SDK licences, which is a prompt that cannot be skipped and can be answered.
236
+ *
237
+ * Google requires an explicit acceptance per licence before any package installs. Answering it by
238
+ * piping `y` is what every CI image does; it is recorded here rather than hidden because accepting
239
+ * a licence on somebody's behalf is a thing they should be able to see in a diff.
240
+ */
241
+ async function acceptLicences(sdkRoot, javaHome) {
242
+ console.log('accepting the Android SDK licences…');
243
+ await run(sdkManager(sdkRoot), [`--sdk_root=${sdkRoot}`, '--licenses'], {
244
+ env: { JAVA_HOME: javaHome },
245
+ stdin: `${'y\n'.repeat(64)}`,
246
+ quiet: true,
247
+ });
248
+ }
249
+ async function installAndroidPackages(sdkRoot, javaHome) {
250
+ console.log(`installing ${PINS.androidPackages.join(', ')}…`);
251
+ await run(sdkManager(sdkRoot), [`--sdk_root=${sdkRoot}`, ...PINS.androidPackages], {
252
+ env: { JAVA_HOME: javaHome },
253
+ quiet: true,
254
+ });
255
+ }
256
+ async function installGradle(home) {
257
+ console.log(`fetching ${PINS.gradle.label}…`);
258
+ const archive = join(home, 'gradle.zip');
259
+ await download(`https://services.gradle.org/distributions/gradle-${PINS.gradle.version}-bin.zip`, archive);
260
+ await extract(archive, home);
261
+ await rm(archive, { force: true });
262
+ const binary = join(home, `gradle-${PINS.gradle.version}`, 'bin', process.platform === 'win32' ? 'gradle.bat' : 'gradle');
263
+ if (!(await pathExists(binary)))
264
+ throw new Error('the Gradle archive did not contain a launcher');
265
+ return binary;
266
+ }
267
+ function sdkManager(sdkRoot) {
268
+ const name = process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager';
269
+ return join(sdkRoot, 'cmdline-tools', 'latest', 'bin', name);
270
+ }
271
+ async function download(url, to) {
272
+ const response = await fetch(url, { redirect: 'follow' });
273
+ if (!response.ok || response.body === null) {
274
+ throw new Error(`could not download ${url}: ${response.status} ${response.statusText}`);
275
+ }
276
+ /* The DOM's `ReadableStream` and Node's are structurally the same and typed apart; the cast is
277
+ at the one line where the two type worlds meet rather than spread through the signature. */
278
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(to));
279
+ }
280
+ /**
281
+ * Unpack an archive with whatever this machine has.
282
+ *
283
+ * `tar` handles both formats on macOS and on Windows, where it is bsdtar; GNU tar on Linux cannot
284
+ * read a zip, so `unzip` is tried first for those. Two attempts rather than a dependency: an
285
+ * archive extractor is a large amount of code to carry for a step that runs once per machine.
286
+ */
287
+ async function extract(archive, into) {
288
+ if (archive.endsWith('.zip')) {
289
+ try {
290
+ await run('unzip', ['-q', '-o', archive, '-d', into], { quiet: true });
291
+ return;
292
+ }
293
+ catch {
294
+ await run('tar', ['-xf', archive, '-C', into], { quiet: true });
295
+ return;
296
+ }
297
+ }
298
+ await run('tar', ['-xzf', archive, '-C', into], { quiet: true });
299
+ }
300
+ async function pathExists(path) {
301
+ try {
302
+ await (await import('node:fs/promises')).access(path);
303
+ return true;
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ }
309
+ async function run(command, args, options) {
310
+ await new Promise((resolve, reject) => {
311
+ const child = spawn(command, [...args], {
312
+ env: { ...process.env, ...options.env },
313
+ stdio: [
314
+ options.stdin === undefined ? 'ignore' : 'pipe',
315
+ options.quiet === true ? 'ignore' : 'inherit',
316
+ 'inherit',
317
+ ],
318
+ });
319
+ if (options.stdin !== undefined && child.stdin !== null) {
320
+ child.stdin.write(options.stdin);
321
+ child.stdin.end();
322
+ }
323
+ child.on('error', reject);
324
+ child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`${command} exited with ${String(code)}`)));
325
+ });
326
+ }
@@ -0,0 +1,11 @@
1
+ import type { PackageManifest, Target } from './manifest.ts';
2
+ import type { ResourceOptions } from './resources.ts';
3
+ export declare function build(manifest: PackageManifest, cwd: string, targets: readonly Target[], options?: ResourceOptions): Promise<void>;
4
+ /**
5
+ * The Electron the artifact will carry, read from what is installed rather than declared.
6
+ *
7
+ * The staged application has no dependencies of its own — both entry points are bundled — so
8
+ * electron-builder has nothing to infer a version from and would refuse. Reading the installed
9
+ * one keeps the artifact and this repository on the same runtime by construction.
10
+ */
11
+ export declare function installedElectronVersion(): Promise<string>;