@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/init.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import type { PackageManifest } from './manifest.ts';
6
+ import { buildWindowsScript } from './templates/buildWindows.ts';
7
+ import { packageWrapperScript } from './templates/packageWrapper.ts';
8
+
9
+ /** A file `init` writes, and where it goes relative to the project. */
10
+ export interface InitFile {
11
+ readonly path: string;
12
+ readonly content: string;
13
+ }
14
+
15
+ /**
16
+ * The files a consumer cannot avoid owning, and deliberately only those.
17
+ *
18
+ * **The rule that decides what is here**: if it can be done once inside `drift-package`, it is not
19
+ * allowed to be in a file somebody copies. A Windows build needs a `.ps1` because something has to
20
+ * start Node on that machine and that something cannot live inside a Node program; a project needs
21
+ * a launcher because the engine may be vendored, where `npx drift-package` resolves nothing.
22
+ * Everything else the first three-platform consumer wrote — 421 lines of it — is now `runtime.ts`,
23
+ * `identity.ts`, `outDir.ts` and the deletion at the top of `cli.ts`.
24
+ */
25
+ export function initFiles(manifest: PackageManifest): readonly InitFile[] {
26
+ return [
27
+ { path: 'scripts/build-windows.ps1', content: buildWindowsScript(manifest) },
28
+ { path: 'scripts/package.mjs', content: packageWrapperScript(manifest) },
29
+ ];
30
+ }
31
+
32
+ /**
33
+ * Write them into a project, refusing to overwrite without `--force`.
34
+ *
35
+ * **The refusal names the file rather than the directory**, because a consumer who has edited one
36
+ * of these has edited a specific one and wants to know which is in the way.
37
+ */
38
+ export async function init(cwd: string, manifest: PackageManifest, force: boolean): Promise<void> {
39
+ const files = initFiles(manifest);
40
+ if (!force) {
41
+ const existing = files.map((f) => join(cwd, f.path)).filter((p) => existsSync(p));
42
+ if (existing.length > 0) {
43
+ throw new Error(
44
+ `init would overwrite:\n${existing.map((p) => ` ${p}`).join('\n')}\n\n` +
45
+ 'These are yours once you have edited them. Pass --force to replace them anyway, or ' +
46
+ 'move them aside first and diff.',
47
+ );
48
+ }
49
+ }
50
+ for (const file of files) {
51
+ const full = join(cwd, file.path);
52
+ await mkdir(dirname(full), { recursive: true });
53
+ await writeFile(full, file.content, 'utf8');
54
+ console.log(`ok: ${full}`);
55
+ }
56
+ console.log(
57
+ `\n node scripts/package.mjs doctor\n node scripts/package.mjs build --target=${
58
+ manifest.targets[0] ?? 'linux-x64'
59
+ }\n`,
60
+ );
61
+ }
package/src/ios.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { build as esbuild } from 'esbuild';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { missingReferences } from './mobile/assetCheck.ts';
8
+ import { versionCodeFor } from './mobile/versionCode.ts';
9
+ import type { PackageManifest } from './manifest.ts';
10
+ import { resourceDir } from './resources.ts';
11
+ import type { ResourceOptions } from './resources.ts';
12
+ import { findXcodeGen } from './toolchain.ts';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+
16
+ /**
17
+ * An iOS application, from the same web build every other target uses.
18
+ *
19
+ * **Unsigned unless a team is named, and that is the useful default.** Apple's free tier signs a
20
+ * build for your own device for seven days at a time and does it through Xcode's own UI rather
21
+ * than through anything a script can drive. So this produces an archive and an unsigned `.ipa` —
22
+ * the shape a sideloading tool re-signs — and takes the signed path only when `APPLE_TEAM_ID` is
23
+ * in the environment, where automatic signing has something to work with.
24
+ *
25
+ * **Written, and not yet run.** No Mac has built this: the Swift, the spec and this file were
26
+ * written against Apple's documented behaviour, and the first person to run it should expect to
27
+ * fix something. What is *not* guessed is the shape of the bridge — that is shared with Android
28
+ * and tested — and the containment rule in the scheme handler, which mirrors the desktop shell's.
29
+ *
30
+ * **One question is still open and it gates the whole tier**: whether WebKit treats a custom
31
+ * scheme as a *secure context*. If it does not, `crossOriginIsolated` is false and the answer is a
32
+ * loopback server instead — `SchemeHandler.swift` is the only file that changes.
33
+ * `drift://localhost/__drift/originProbe.html` answers it in one look on a device. **That URL is
34
+ * the answer as much as the page is**: the host has to be `localhost`, because that is the whole
35
+ * of why the origin is trustworthy, and the shell's own assets are served under `__drift/`
36
+ * rather than from a second host, because a second host would not be `localhost`. Every earlier
37
+ * spelling of this in the documentation named the desktop shell's `drift://shell/` form, which
38
+ * on this platform resolves against the game's bundle root and answers with nothing.
39
+ */
40
+ export async function buildIos(
41
+ manifest: PackageManifest,
42
+ projectDir: string,
43
+ outDir: string,
44
+ options: ResourceOptions = {},
45
+ ): Promise<void> {
46
+ if (process.platform !== 'darwin') {
47
+ throw new Error('ios is built on macOS: Xcode is not available anywhere else');
48
+ }
49
+ const xcodegen = await findXcodeGen();
50
+ if (xcodegen === null) {
51
+ throw new Error(
52
+ 'XcodeGen is not here. Run `drift-package bootstrap --target=ios`, which fetches it; ' +
53
+ 'Xcode itself comes from the App Store.',
54
+ );
55
+ }
56
+
57
+ const projectRoot = join(outDir, 'project');
58
+ await rm(projectRoot, { recursive: true, force: true });
59
+ await mkdir(projectRoot, { recursive: true });
60
+ await cp(await resourceDir('ios', options.resourceRoot), projectRoot, { recursive: true });
61
+
62
+ /* The game keeps its own directory here, unlike Android: `SchemeHandler` resolves against a
63
+ `www` root inside the bundle, so absolute references land where they point. */
64
+ const distDir = resolve(projectDir, dirname(manifest.entry));
65
+ await cp(distDir, join(projectRoot, 'www'), { recursive: true });
66
+ await checkReferences(join(projectRoot, 'www'), basename(manifest.entry));
67
+ /* Served under `drift://localhost/__drift/`, so the origin probe and the mark are reachable
68
+ without a second host — which would not be `localhost`, and so would not be secure. */
69
+ await cp(await resourceDir('assets', options.resourceRoot), join(projectRoot, 'shell'), {
70
+ recursive: true,
71
+ });
72
+
73
+ await esbuild({
74
+ stdin: {
75
+ contents: `import { attachIosShim } from ${JSON.stringify(join(HERE, 'mobile', 'iosShim.ts'))};\nattachIosShim('__driftHost');\n`,
76
+ resolveDir: HERE,
77
+ loader: 'ts',
78
+ },
79
+ outfile: join(projectRoot, 'drift-shim.js'),
80
+ bundle: true,
81
+ format: 'iife',
82
+ platform: 'browser',
83
+ target: 'es2022',
84
+ logLevel: 'warning',
85
+ });
86
+
87
+ const version = await consumerVersion(projectDir);
88
+ const team = process.env.APPLE_TEAM_ID ?? '';
89
+ const spec = (await readFile(join(projectRoot, 'project.yml'), 'utf8'))
90
+ .replace(/__DRIFT_APP_NAME__/g, manifest.name)
91
+ .replace(/__DRIFT_APP_ID__/g, manifest.id)
92
+ .replace(/__DRIFT_VERSION_NAME__/g, version)
93
+ .replace(/__DRIFT_VERSION_CODE__/g, String(versionCodeFor(version)))
94
+ .replace(/__DRIFT_TEAM_ID__/g, team);
95
+ await writeFile(join(projectRoot, 'project.yml'), spec, 'utf8');
96
+
97
+ console.log(
98
+ `[drift-package] ios: ${team === '' ? 'unsigned — a sideloading tool re-signs it' : `signing with team ${team}`}`,
99
+ );
100
+
101
+ await runTool(xcodegen, ['generate', '--spec', 'project.yml'], projectRoot);
102
+
103
+ const archive = join(outDir, 'DriftHost.xcarchive');
104
+ await runTool(
105
+ 'xcodebuild',
106
+ [
107
+ '-project',
108
+ 'DriftHost.xcodeproj',
109
+ '-scheme',
110
+ 'DriftHost',
111
+ '-configuration',
112
+ 'Release',
113
+ '-destination',
114
+ 'generic/platform=iOS',
115
+ '-archivePath',
116
+ archive,
117
+ 'archive',
118
+ ...(team === ''
119
+ ? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGN_IDENTITY=']
120
+ : [`DEVELOPMENT_TEAM=${team}`]),
121
+ ],
122
+ projectRoot,
123
+ );
124
+
125
+ /*
126
+ * An `.ipa` is a zip with the app inside a `Payload` directory, which is all `xcodebuild
127
+ * -exportArchive` produces for a development build — and that step needs a provisioning profile
128
+ * this may not have. Built by hand so an unsigned archive still leaves something installable by
129
+ * a tool that signs it afterwards.
130
+ */
131
+ const payload = join(outDir, 'Payload');
132
+ await rm(payload, { recursive: true, force: true });
133
+ await mkdir(payload, { recursive: true });
134
+ await cp(
135
+ join(archive, 'Products', 'Applications', `${manifest.name}.app`),
136
+ join(payload, `${manifest.name}.app`),
137
+ { recursive: true },
138
+ );
139
+ const ipa = join(outDir, `${manifest.name.replace(/[^\w.-]+/g, '-')}-${version}.ipa`);
140
+ await rm(ipa, { force: true });
141
+ await runTool('zip', ['-qry', ipa, 'Payload'], outDir);
142
+ await rm(payload, { recursive: true, force: true });
143
+ console.log(`[drift-package] ios: ${ipa}`);
144
+ }
145
+
146
+ async function checkReferences(root: string, entryName: string): Promise<void> {
147
+ const html = await readFile(join(root, entryName), 'utf8');
148
+ const { readdir } = await import('node:fs/promises');
149
+ const { relative } = await import('node:path');
150
+ const walk = async (dir: string): Promise<string[]> => {
151
+ const out: string[] = [];
152
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
153
+ const full = join(dir, entry.name);
154
+ if (entry.isDirectory()) out.push(...(await walk(full)));
155
+ else out.push(relative(root, full).split('\\').join('/'));
156
+ }
157
+ return out;
158
+ };
159
+ const missing = missingReferences(html, new Set(await walk(root)));
160
+ if (missing.length === 0) return;
161
+ throw new Error(
162
+ `the entry page references ${missing.length} file(s) that are not in the bundle: ` +
163
+ `${missing.slice(0, 5).join(', ')}.`,
164
+ );
165
+ }
166
+
167
+ async function consumerVersion(projectDir: string): Promise<string> {
168
+ try {
169
+ const raw: unknown = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'));
170
+ const version = (raw as { version?: unknown }).version;
171
+ return typeof version === 'string' && version.length > 0 ? version : '0.0.0';
172
+ } catch {
173
+ return '0.0.0';
174
+ }
175
+ }
176
+
177
+ async function runTool(command: string, args: readonly string[], cwd: string): Promise<void> {
178
+ await new Promise<void>((done, fail) => {
179
+ const child = spawn(command, [...args], { stdio: 'inherit', cwd });
180
+ child.on('error', fail);
181
+ child.on('close', (code) =>
182
+ code === 0 ? done() : fail(new Error(`${command} exited with ${String(code)}`)),
183
+ );
184
+ });
185
+ }
@@ -0,0 +1,338 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { open, readdir, rm } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ /**
6
+ * The macOS code signature, applied from a machine that is not a Mac.
7
+ *
8
+ * **Why this exists at all.** electron-builder's own refusal to build a Mac target fires on
9
+ * Windows and nowhere else, so a `.app` packs perfectly well on Linux — and then
10
+ * `isSignAllowed()` returns false off darwin, one warning is logged, and the artifact is finished
11
+ * and unsigned. Apple Silicon's loader refuses an unsigned binary outright, which is not
12
+ * Gatekeeper and which no user gesture works around, so that artifact is one that looks complete
13
+ * and cannot start. `rcodesign` is the one implementation of Apple's signature format that runs
14
+ * anywhere, and this module is the step that puts a signature back on.
15
+ *
16
+ * **Everything here is checked rather than assumed, because nothing on this side can launch the
17
+ * result.** A Mac is the only thing that can prove the app runs; what a Linux host *can* prove is
18
+ * that every binary in the bundle carries a signature, that every nested bundle was sealed, and
19
+ * that the slice inside is the architecture that was asked for. Those are the three ways this step
20
+ * fails silently, and each one below is a refusal rather than a warning.
21
+ */
22
+
23
+ /**
24
+ * The line rcodesign prints when a nested bundle's main executable cannot be resolved.
25
+ *
26
+ * **Measured rather than guessed, and it is not cosmetic.** A stock Electron distribution ships
27
+ * its helper apps with no `CFBundleExecutable` key; Apple's `codesign` infers the executable from
28
+ * the bundle's own name and rcodesign does not. When it cannot resolve one, it signs the Mach-O
29
+ * inside and then leaves that bundle out of the outer app's `CodeResources` — so the app's
30
+ * signature does not cover its own helpers, and nothing about the build says so.
31
+ *
32
+ * electron-builder rewrites every helper plist with a `CFBundleExecutable` before this runs, which
33
+ * is why a real build does not hit this. That is a fact about a dependency's current behaviour,
34
+ * not a guarantee, so it is checked here instead of trusted.
35
+ */
36
+ const UNRESOLVED_NESTED = 'could not find main executable of presumed nested bundle';
37
+
38
+ export interface CrossSignOptions {
39
+ /** The `.app` directory, which is what rcodesign is pointed at. */
40
+ readonly app: string;
41
+ /** The rcodesign binary, from the cache or from the machine. */
42
+ readonly rcodesign: string;
43
+ /** The slice the artifact is supposed to carry, so a silently wrong one is caught here. */
44
+ readonly arch: 'arm64' | 'x64';
45
+ }
46
+
47
+ /**
48
+ * Ad-hoc sign a `.app` in place, then refuse it if the result is not what was asked for.
49
+ *
50
+ * Ad-hoc is the only mode offered: a Developer ID would need a certificate this host cannot use,
51
+ * and `planSigning` refuses that combination before a build gets this far.
52
+ */
53
+ export async function crossSignMacApp(options: CrossSignOptions): Promise<void> {
54
+ /* No identity argument at all is rcodesign's spelling of an ad-hoc signature, and a bundle path
55
+ is signed recursively: nested bundles and loose Mach-O binaries included. */
56
+ const output = await run(options.rcodesign, ['sign', options.app]);
57
+
58
+ if (output.includes(UNRESOLVED_NESTED)) {
59
+ const bundles = output
60
+ .split('\n')
61
+ .filter((line) => line.includes(UNRESOLVED_NESTED))
62
+ .map((line) => line.slice(line.indexOf(UNRESOLVED_NESTED) + UNRESOLVED_NESTED.length + 2))
63
+ .join(', ');
64
+ throw new Error(
65
+ `rcodesign could not resolve the main executable of ${bundles}, so the app's signature ` +
66
+ "does not cover it. That is a bundle whose Info.plist has no CFBundleExecutable: Apple's " +
67
+ 'codesign infers one from the bundle name and rcodesign does not. The artifact would ' +
68
+ 'look signed and fail validation on a Mac.',
69
+ );
70
+ }
71
+
72
+ await assertEverySealed(options.app);
73
+ await assertArch(options.app, options.arch);
74
+ }
75
+
76
+ /**
77
+ * The `.zip` a cross-built Mac artifact ships as, archived here rather than by electron-builder.
78
+ *
79
+ * **electron-builder cannot produce this one correctly off a Mac, and says so in its own source**:
80
+ * its archiver picks 7-Zip everywhere except macOS, guarded by a comment reading *"7zip
81
+ * dereferences symlinks, corrupting .framework structure and breaking codesign"*. The guard is
82
+ * written as `process.platform === "darwin"`, so on Linux it takes the 7-Zip path regardless.
83
+ *
84
+ * **A `.framework` is symlinks by construction.** `Electron Framework` points into
85
+ * `Versions/Current`, which points at `Versions/A`. Dereferenced, the archive carries the
86
+ * framework's 192 MB payload twice and no links at all, and macOS rejects that layout as
87
+ * unrecognised — the signature that was correct on disk no longer validates against it. Measured
88
+ * on this repository before this function existed: a 404 MB archive with zero symlink entries,
89
+ * against 353 MB on disk.
90
+ *
91
+ * `zip -y` stores a symlink as a symlink, `-r` recurses, and Unix permission bits are kept, which
92
+ * every executable inside the bundle depends on.
93
+ */
94
+ export async function zipMacApp(app: string, into: string): Promise<void> {
95
+ /* `zip` updates an existing archive in place rather than replacing it, so a rebuild would
96
+ otherwise leave files from the previous one in the artifact. */
97
+ await rm(into, { force: true });
98
+
99
+ try {
100
+ await run('zip', ['-y', '-r', '-q', into, basename(app)], { cwd: dirname(app) });
101
+ } catch (cause) {
102
+ throw new Error(
103
+ `could not archive the app with \`zip\`: ${String(cause)}. This build needs it because ` +
104
+ "electron-builder's own zip dereferences symlinks off a Mac, which corrupts every " +
105
+ 'framework in the bundle. Install it (`apt install zip`) and build again.',
106
+ );
107
+ }
108
+
109
+ const symlinks = await countSymlinkEntries(into);
110
+ if (symlinks === 0) {
111
+ throw new Error(
112
+ `${into} contains no symlinks, and a macOS app bundle is built out of them — every ` +
113
+ 'framework links its top level into `Versions/Current`. An archive without them holds ' +
114
+ 'each framework twice and is rejected by macOS as an unrecognised bundle format. That ' +
115
+ 'is what `zip` without `-y` produces.',
116
+ );
117
+ }
118
+ console.log(`[drift-package] archived ${into} with ${String(symlinks)} symlinks preserved`);
119
+ }
120
+
121
+ /* The three fields of the zip format this needs, from APPNOTE.TXT. */
122
+ const EOCD_SIGNATURE = 0x06054b50;
123
+ const CENTRAL_SIGNATURE = 0x02014b50;
124
+ /** `S_IFLNK`, in the Unix mode that lives in the high half of the external attributes field. */
125
+ const S_IFMT = 0xf000;
126
+ const S_IFLNK = 0xa000;
127
+
128
+ /**
129
+ * How many entries in an archive are symlinks, read from its central directory.
130
+ *
131
+ * **The archive is inspected rather than the tool trusted**, because the failure this guards
132
+ * against is silent by nature: an archiver that dereferences links produces a larger file that
133
+ * unpacks into something that looks complete. Reading the central directory is exact, needs no
134
+ * `zipinfo` on the host, and answers the one question that matters.
135
+ */
136
+ async function countSymlinkEntries(zip: string): Promise<number> {
137
+ const handle = await open(zip, 'r');
138
+ try {
139
+ const { size } = await handle.stat();
140
+ /* The end-of-central-directory record is last, after a comment of up to 64 KiB. */
141
+ const tailLength = Math.min(size, 0x10000 + 22);
142
+ const tail = Buffer.alloc(tailLength);
143
+ await handle.read(tail, 0, tailLength, size - tailLength);
144
+
145
+ let eocd = -1;
146
+ for (let at = tail.length - 22; at >= 0; at -= 1) {
147
+ if (tail.readUInt32LE(at) === EOCD_SIGNATURE) {
148
+ eocd = at;
149
+ break;
150
+ }
151
+ }
152
+ if (eocd < 0) throw new Error(`${zip} has no end-of-central-directory record`);
153
+
154
+ const entries = tail.readUInt16LE(eocd + 10);
155
+ const directoryLength = tail.readUInt32LE(eocd + 12);
156
+ const directoryStart = tail.readUInt32LE(eocd + 16);
157
+
158
+ const directory = Buffer.alloc(directoryLength);
159
+ await handle.read(directory, 0, directoryLength, directoryStart);
160
+
161
+ let at = 0;
162
+ let symlinks = 0;
163
+ for (let index = 0; index < entries && at + 46 <= directory.length; index += 1) {
164
+ if (directory.readUInt32LE(at) !== CENTRAL_SIGNATURE) break;
165
+ /* The high half of the external attributes is the Unix mode, for an archive written on
166
+ Unix — which is the only kind this function is ever pointed at. */
167
+ const mode = directory.readUInt32LE(at + 38) >>> 16;
168
+ if ((mode & S_IFMT) === S_IFLNK) symlinks += 1;
169
+ at +=
170
+ 46 +
171
+ directory.readUInt16LE(at + 28) +
172
+ directory.readUInt16LE(at + 30) +
173
+ directory.readUInt16LE(at + 32);
174
+ }
175
+ return symlinks;
176
+ } finally {
177
+ await handle.close();
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Every bundle in the artifact carries its own signature.
183
+ *
184
+ * A signed bundle has a `_CodeSignature/CodeResources` beside its contents — the outer app under
185
+ * `Contents/`, a framework under `Versions/A/`. Checking the directory rather than parsing the
186
+ * seal is deliberate: this catches the failure that actually happens, which is a bundle rcodesign
187
+ * walked past, and it does not pretend to be the validation only a Mac can do.
188
+ */
189
+ async function assertEverySealed(app: string): Promise<void> {
190
+ const missing: string[] = [];
191
+
192
+ if (!(await exists(join(app, 'Contents', '_CodeSignature', 'CodeResources')))) {
193
+ missing.push('the app itself');
194
+ }
195
+
196
+ const frameworks = join(app, 'Contents', 'Frameworks');
197
+ for (const entry of await list(frameworks)) {
198
+ const nested = join(frameworks, entry);
199
+ /* A `.app` seals under `Contents/`; a `.framework` under the version it points `Current` at,
200
+ which is `A` for everything Electron ships. */
201
+ const seal = entry.endsWith('.framework')
202
+ ? join(nested, 'Versions', 'A', '_CodeSignature', 'CodeResources')
203
+ : join(nested, 'Contents', '_CodeSignature', 'CodeResources');
204
+ if (entry.endsWith('.app') || entry.endsWith('.framework')) {
205
+ if (!(await exists(seal))) missing.push(entry);
206
+ }
207
+ }
208
+
209
+ if (missing.length > 0) {
210
+ throw new Error(
211
+ `these parts of the app were not signed: ${missing.join(', ')}. An unsigned binary inside ` +
212
+ 'a signed app is refused by the loader at the moment it is needed, which for a helper is ' +
213
+ 'the first window rather than the launch.',
214
+ );
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The artifact carries the slice it was asked for.
220
+ *
221
+ * **The failure this prevents is a build that produced the wrong architecture and said nothing.**
222
+ * electron-builder picks the Electron distribution from the target's arch, so a `mac-x64` asked
223
+ * for should never contain arm64 — but a cache with the wrong download in it, or a default that
224
+ * inherited the host's arch, both end as an app that refuses to start on the machine it was made
225
+ * for, with no error that names the cause.
226
+ */
227
+ async function assertArch(app: string, expected: 'arm64' | 'x64'): Promise<void> {
228
+ const executable = await mainExecutable(app);
229
+ const found = await machoArch(executable);
230
+ if (found === null) {
231
+ throw new Error(`${executable} is not a Mach-O binary; the app did not pack correctly`);
232
+ }
233
+ if (found !== expected) {
234
+ throw new Error(
235
+ `this build asked for ${expected} and the binary inside is ${found}. That artifact does ` +
236
+ 'not run on the machine it was built for, and nothing about it says so until it is opened.',
237
+ );
238
+ }
239
+ }
240
+
241
+ /** The one file under `Contents/MacOS`, which is the app's entry point whatever it is named. */
242
+ async function mainExecutable(app: string): Promise<string> {
243
+ const dir = join(app, 'Contents', 'MacOS');
244
+ const [entry] = await list(dir);
245
+ if (entry === undefined) throw new Error(`${dir} is empty; the app did not pack correctly`);
246
+ return join(dir, entry);
247
+ }
248
+
249
+ /* Mach-O's own constants, from `mach-o/loader.h` and `mach/machine.h`. */
250
+ const MACHO_64_LE = 0xfeedfacf;
251
+ const FAT_BE = 0xcafebabe;
252
+ const CPU_X86_64 = 0x01000007;
253
+ const CPU_ARM64 = 0x0100000c;
254
+
255
+ /**
256
+ * Which architecture a Mach-O binary holds, read from its header.
257
+ *
258
+ * Sixteen bytes of file rather than a tool: `file` and `lipo` both answer this and neither is on
259
+ * a Linux host by default, and the header is four fields that have not moved in twenty years.
260
+ * A universal binary answers with what it contains only when that is unambiguous — one slice.
261
+ */
262
+ async function machoArch(path: string): Promise<'arm64' | 'x64' | null> {
263
+ const handle = await open(path, 'r');
264
+ try {
265
+ const header = Buffer.alloc(8);
266
+ await handle.read(header, 0, 8, 0);
267
+
268
+ if (header.readUInt32LE(0) === MACHO_64_LE) return cpuName(header.readUInt32LE(4));
269
+
270
+ if (header.readUInt32BE(0) === FAT_BE) {
271
+ /* A fat header is a count and then 20-byte entries whose first field is the cpu type. */
272
+ const count = header.readUInt32BE(4);
273
+ const names = new Set<'arm64' | 'x64'>();
274
+ for (let index = 0; index < count; index += 1) {
275
+ const entry = Buffer.alloc(4);
276
+ await handle.read(entry, 0, 4, 8 + index * 20);
277
+ const name = cpuName(entry.readUInt32BE(0));
278
+ if (name !== null) names.add(name);
279
+ }
280
+ return names.size === 1 ? [...names][0] : null;
281
+ }
282
+
283
+ return null;
284
+ } finally {
285
+ await handle.close();
286
+ }
287
+ }
288
+
289
+ function cpuName(cpu: number): 'arm64' | 'x64' | null {
290
+ if (cpu === CPU_ARM64) return 'arm64';
291
+ if (cpu === CPU_X86_64) return 'x64';
292
+ return null;
293
+ }
294
+
295
+ async function list(dir: string): Promise<string[]> {
296
+ try {
297
+ return await readdir(dir);
298
+ } catch {
299
+ return [];
300
+ }
301
+ }
302
+
303
+ async function exists(path: string): Promise<boolean> {
304
+ try {
305
+ await (await import('node:fs/promises')).access(path);
306
+ return true;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Run a command and return everything it said, on both streams.
314
+ *
315
+ * rcodesign narrates to stderr and the warning this module refuses on arrives there, so the two
316
+ * are merged rather than one being watched — a check that read only stdout would pass every time.
317
+ */
318
+ async function run(
319
+ command: string,
320
+ args: readonly string[],
321
+ options: { readonly cwd?: string } = {},
322
+ ): Promise<string> {
323
+ return new Promise((resolve, reject) => {
324
+ const child = spawn(command, [...args], {
325
+ cwd: options.cwd,
326
+ stdio: ['ignore', 'pipe', 'pipe'],
327
+ });
328
+ let output = '';
329
+ child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
330
+ child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
331
+ child.on('error', reject);
332
+ child.on('close', (code) =>
333
+ code === 0
334
+ ? resolve(output)
335
+ : reject(new Error(`${command} exited with ${String(code)}:\n${output}`)),
336
+ );
337
+ });
338
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Reading a renderer's console line off an event whose shape has changed, and deciding whether it
3
+ * is worth forwarding.
4
+ *
5
+ * **Pure, and tested, because the failure of getting it wrong is silence.** A packaged game has no
6
+ * developer tools and no console a player can open, so this forwarding is the only way anything the
7
+ * renderer says reaches a bug report. Reading the wrong argument does not throw and does not warn:
8
+ * it compares `undefined` against a string for every message, forwards none of them, and is
9
+ * indistinguishable from a game that never logged.
10
+ *
11
+ * The two shapes, both of which are `console-message` on `WebContents`:
12
+ *
13
+ * - **Electron 35 and newer.** One object first, carrying `message` and a string `level` of
14
+ * `debug`, `info`, `warning` or `error`. The old positional arguments follow it, deprecated.
15
+ * - **Before that, fully positional**: `(event, level: 0..3, message, line, sourceId)`, where the
16
+ * event itself carries neither. **Its own typings say otherwise** — `electron.d.ts` at 33.4.11
17
+ * declares `(event, messageDetails)` with the details as an object — and reading them is what
18
+ * produced a first version of this module that handled two object shapes and forwarded nothing
19
+ * on the runtime it was written to rescue. The positional form below is what a probe against
20
+ * that binary actually received. The object-second shape is still accepted, because it is what
21
+ * the typings promise and costs one branch to keep.
22
+ */
23
+ export type ConsoleLevel = 'debug' | 'verbose' | 'info' | 'warning' | 'error';
24
+
25
+ export interface ConsoleLine {
26
+ readonly level: ConsoleLevel;
27
+ readonly message: string;
28
+ }
29
+
30
+ /** 0 to 3, as the older event numbers them. */
31
+ const BY_NUMBER: readonly ConsoleLevel[] = ['verbose', 'info', 'warning', 'error'];
32
+
33
+ const NAMED: readonly string[] = ['debug', 'verbose', 'info', 'warning', 'error'];
34
+
35
+ function levelOf(value: unknown): ConsoleLevel | null {
36
+ if (typeof value === 'string' && NAMED.includes(value)) return value as ConsoleLevel;
37
+ if (typeof value === 'number' && Number.isInteger(value)) return BY_NUMBER[value] ?? null;
38
+ return null;
39
+ }
40
+
41
+ function fieldsOf(value: unknown): { level: unknown; message: unknown } | null {
42
+ if (value === null || typeof value !== 'object') return null;
43
+ const record = value as Record<string, unknown>;
44
+ return { level: record.level, message: record.message };
45
+ }
46
+
47
+ /**
48
+ * The line a listener was handed, whichever shape it came in, or `null` when neither carried one.
49
+ *
50
+ * The first argument is preferred when it carries a level, which is the newer shape; the second is
51
+ * read otherwise. The third is the positional message the newer shape also passes, used only when
52
+ * an object carried a level without a message.
53
+ */
54
+ export function consoleLine(first: unknown, second: unknown, third: unknown): ConsoleLine | null {
55
+ /*
56
+ * The positional form first, because it is unambiguous: a number where an object would be is the
57
+ * older event, and no newer one puts a level there without also putting one on its first
58
+ * argument — which the loop below would have taken anyway.
59
+ */
60
+ if (
61
+ typeof second === 'number' &&
62
+ typeof third === 'string' &&
63
+ fieldsOf(first)?.level === undefined
64
+ ) {
65
+ const level = levelOf(second);
66
+ if (level !== null) return { level, message: third };
67
+ }
68
+ for (const candidate of [fieldsOf(first), fieldsOf(second)]) {
69
+ if (candidate === null) continue;
70
+ const level = levelOf(candidate.level);
71
+ if (level === null) continue;
72
+ const message =
73
+ typeof candidate.message === 'string'
74
+ ? candidate.message
75
+ : typeof third === 'string'
76
+ ? third
77
+ : '';
78
+ return { level, message };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Whether a line at this level is forwarded.
85
+ *
86
+ * Errors in both kinds of build: an error nobody can quote is an error nobody can fix. Warnings in
87
+ * development only — they are where a framework's advice and a game's own diagnostics live, and a
88
+ * shipped game printing them to a terminal nobody is watching buries the line that matters.
89
+ */
90
+ export function forwardsConsole(level: ConsoleLevel, development: boolean): boolean {
91
+ if (level === 'error') return true;
92
+ return development && level === 'warning';
93
+ }