@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +408 -0
- package/bin/drift-package.mjs +82 -0
- package/dist/android.d.ts +18 -0
- package/dist/android.js +207 -0
- package/dist/bootstrap.d.ts +17 -0
- package/dist/bootstrap.js +326 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +248 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +343 -0
- package/dist/flags.d.ts +57 -0
- package/dist/flags.js +122 -0
- package/dist/identity.d.ts +51 -0
- package/dist/identity.js +75 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +24 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +45 -0
- package/dist/ios.d.ts +27 -0
- package/dist/ios.js +152 -0
- package/dist/macCrossSign.d.ts +34 -0
- package/dist/macCrossSign.js +298 -0
- package/dist/main/consoleForward.d.ts +43 -0
- package/dist/main/consoleForward.js +63 -0
- package/dist/main/csp.d.ts +4 -0
- package/dist/main/csp.js +55 -0
- package/dist/main/entry.d.ts +1 -0
- package/dist/main/entry.js +56 -0
- package/dist/main/ipc.d.ts +28 -0
- package/dist/main/ipc.js +284 -0
- package/dist/main/links.d.ts +1 -0
- package/dist/main/links.js +29 -0
- package/dist/main/menu.d.ts +17 -0
- package/dist/main/menu.js +49 -0
- package/dist/main/serve.d.ts +21 -0
- package/dist/main/serve.js +69 -0
- package/dist/main/shortcuts.d.ts +28 -0
- package/dist/main/shortcuts.js +26 -0
- package/dist/main/splash.d.ts +14 -0
- package/dist/main/splash.js +41 -0
- package/dist/main/splashTiming.d.ts +36 -0
- package/dist/main/splashTiming.js +24 -0
- package/dist/main/window.d.ts +19 -0
- package/dist/main/window.js +297 -0
- package/dist/manifest.d.ts +61 -0
- package/dist/manifest.js +91 -0
- package/dist/mobile/assetCheck.d.ts +1 -0
- package/dist/mobile/assetCheck.js +28 -0
- package/dist/mobile/bridgeShim.d.ts +83 -0
- package/dist/mobile/bridgeShim.js +208 -0
- package/dist/mobile/iosShim.d.ts +42 -0
- package/dist/mobile/iosShim.js +104 -0
- package/dist/mobile/report.d.ts +27 -0
- package/dist/mobile/report.js +122 -0
- package/dist/mobile/versionCode.d.ts +12 -0
- package/dist/mobile/versionCode.js +17 -0
- package/dist/outDir.d.ts +30 -0
- package/dist/outDir.js +32 -0
- package/dist/preload/bridge.d.ts +77 -0
- package/dist/preload/bridge.js +2 -0
- package/dist/preload/entry.d.ts +1 -0
- package/dist/preload/entry.js +77 -0
- package/dist/renderer/fileStore.d.ts +23 -0
- package/dist/renderer/fileStore.js +33 -0
- package/dist/renderer/host.d.ts +45 -0
- package/dist/renderer/host.js +30 -0
- package/dist/renderer/nativeHost.d.ts +83 -0
- package/dist/renderer/nativeHost.js +125 -0
- package/dist/resources.d.ts +71 -0
- package/dist/resources.js +83 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +80 -0
- package/dist/runtimeVersion.d.ts +10 -0
- package/dist/runtimeVersion.js +18 -0
- package/dist/signing.d.ts +49 -0
- package/dist/signing.js +135 -0
- package/dist/stage.d.ts +22 -0
- package/dist/stage.js +138 -0
- package/dist/steam/cloudStore.d.ts +30 -0
- package/dist/steam/cloudStore.js +54 -0
- package/dist/steam/depot.d.ts +29 -0
- package/dist/steam/depot.js +39 -0
- package/dist/steam/main.d.ts +22 -0
- package/dist/steam/main.js +41 -0
- package/dist/templates/buildWindows.d.ts +16 -0
- package/dist/templates/buildWindows.js +161 -0
- package/dist/templates/packageWrapper.d.ts +9 -0
- package/dist/templates/packageWrapper.js +80 -0
- package/dist/toolchain.d.ts +101 -0
- package/dist/toolchain.js +186 -0
- package/package.json +73 -0
- package/src/android.ts +256 -0
- package/src/bootstrap.ts +389 -0
- package/src/build.ts +285 -0
- package/src/cli.ts +367 -0
- package/src/flags.ts +171 -0
- package/src/identity.ts +87 -0
- package/src/index.ts +31 -0
- package/src/init.ts +61 -0
- package/src/ios.ts +185 -0
- package/src/macCrossSign.ts +338 -0
- package/src/main/consoleForward.ts +93 -0
- package/src/main/csp.ts +57 -0
- package/src/main/entry.ts +63 -0
- package/src/main/ipc.ts +311 -0
- package/src/main/links.ts +29 -0
- package/src/main/menu.ts +53 -0
- package/src/main/serve.ts +70 -0
- package/src/main/shortcuts.ts +55 -0
- package/src/main/splash.ts +42 -0
- package/src/main/splashTiming.ts +51 -0
- package/src/main/window.ts +316 -0
- package/src/manifest.ts +157 -0
- package/src/mobile/assetCheck.ts +25 -0
- package/src/mobile/bridgeShim.ts +292 -0
- package/src/mobile/iosShim.ts +148 -0
- package/src/mobile/report.ts +121 -0
- package/src/mobile/versionCode.ts +17 -0
- package/src/outDir.ts +37 -0
- package/src/preload/bridge.ts +73 -0
- package/src/preload/entry.ts +91 -0
- package/src/renderer/fileStore.ts +38 -0
- package/src/renderer/host.ts +96 -0
- package/src/renderer/nativeHost.ts +146 -0
- package/src/resources.ts +150 -0
- package/src/runtime.ts +87 -0
- package/src/runtimeVersion.ts +16 -0
- package/src/signing.ts +206 -0
- package/src/stage.ts +180 -0
- package/src/steam/cloudStore.ts +73 -0
- package/src/steam/depot.ts +69 -0
- package/src/steam/main.ts +69 -0
- package/src/templates/buildWindows.ts +163 -0
- package/src/templates/packageWrapper.ts +82 -0
- package/src/toolchain.ts +210 -0
|
@@ -0,0 +1,298 @@
|
|
|
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
|
+
* The macOS code signature, applied from a machine that is not a Mac.
|
|
6
|
+
*
|
|
7
|
+
* **Why this exists at all.** electron-builder's own refusal to build a Mac target fires on
|
|
8
|
+
* Windows and nowhere else, so a `.app` packs perfectly well on Linux — and then
|
|
9
|
+
* `isSignAllowed()` returns false off darwin, one warning is logged, and the artifact is finished
|
|
10
|
+
* and unsigned. Apple Silicon's loader refuses an unsigned binary outright, which is not
|
|
11
|
+
* Gatekeeper and which no user gesture works around, so that artifact is one that looks complete
|
|
12
|
+
* and cannot start. `rcodesign` is the one implementation of Apple's signature format that runs
|
|
13
|
+
* anywhere, and this module is the step that puts a signature back on.
|
|
14
|
+
*
|
|
15
|
+
* **Everything here is checked rather than assumed, because nothing on this side can launch the
|
|
16
|
+
* result.** A Mac is the only thing that can prove the app runs; what a Linux host *can* prove is
|
|
17
|
+
* that every binary in the bundle carries a signature, that every nested bundle was sealed, and
|
|
18
|
+
* that the slice inside is the architecture that was asked for. Those are the three ways this step
|
|
19
|
+
* fails silently, and each one below is a refusal rather than a warning.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The line rcodesign prints when a nested bundle's main executable cannot be resolved.
|
|
23
|
+
*
|
|
24
|
+
* **Measured rather than guessed, and it is not cosmetic.** A stock Electron distribution ships
|
|
25
|
+
* its helper apps with no `CFBundleExecutable` key; Apple's `codesign` infers the executable from
|
|
26
|
+
* the bundle's own name and rcodesign does not. When it cannot resolve one, it signs the Mach-O
|
|
27
|
+
* inside and then leaves that bundle out of the outer app's `CodeResources` — so the app's
|
|
28
|
+
* signature does not cover its own helpers, and nothing about the build says so.
|
|
29
|
+
*
|
|
30
|
+
* electron-builder rewrites every helper plist with a `CFBundleExecutable` before this runs, which
|
|
31
|
+
* is why a real build does not hit this. That is a fact about a dependency's current behaviour,
|
|
32
|
+
* not a guarantee, so it is checked here instead of trusted.
|
|
33
|
+
*/
|
|
34
|
+
const UNRESOLVED_NESTED = 'could not find main executable of presumed nested bundle';
|
|
35
|
+
/**
|
|
36
|
+
* Ad-hoc sign a `.app` in place, then refuse it if the result is not what was asked for.
|
|
37
|
+
*
|
|
38
|
+
* Ad-hoc is the only mode offered: a Developer ID would need a certificate this host cannot use,
|
|
39
|
+
* and `planSigning` refuses that combination before a build gets this far.
|
|
40
|
+
*/
|
|
41
|
+
export async function crossSignMacApp(options) {
|
|
42
|
+
/* No identity argument at all is rcodesign's spelling of an ad-hoc signature, and a bundle path
|
|
43
|
+
is signed recursively: nested bundles and loose Mach-O binaries included. */
|
|
44
|
+
const output = await run(options.rcodesign, ['sign', options.app]);
|
|
45
|
+
if (output.includes(UNRESOLVED_NESTED)) {
|
|
46
|
+
const bundles = output
|
|
47
|
+
.split('\n')
|
|
48
|
+
.filter((line) => line.includes(UNRESOLVED_NESTED))
|
|
49
|
+
.map((line) => line.slice(line.indexOf(UNRESOLVED_NESTED) + UNRESOLVED_NESTED.length + 2))
|
|
50
|
+
.join(', ');
|
|
51
|
+
throw new Error(`rcodesign could not resolve the main executable of ${bundles}, so the app's signature ` +
|
|
52
|
+
"does not cover it. That is a bundle whose Info.plist has no CFBundleExecutable: Apple's " +
|
|
53
|
+
'codesign infers one from the bundle name and rcodesign does not. The artifact would ' +
|
|
54
|
+
'look signed and fail validation on a Mac.');
|
|
55
|
+
}
|
|
56
|
+
await assertEverySealed(options.app);
|
|
57
|
+
await assertArch(options.app, options.arch);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The `.zip` a cross-built Mac artifact ships as, archived here rather than by electron-builder.
|
|
61
|
+
*
|
|
62
|
+
* **electron-builder cannot produce this one correctly off a Mac, and says so in its own source**:
|
|
63
|
+
* its archiver picks 7-Zip everywhere except macOS, guarded by a comment reading *"7zip
|
|
64
|
+
* dereferences symlinks, corrupting .framework structure and breaking codesign"*. The guard is
|
|
65
|
+
* written as `process.platform === "darwin"`, so on Linux it takes the 7-Zip path regardless.
|
|
66
|
+
*
|
|
67
|
+
* **A `.framework` is symlinks by construction.** `Electron Framework` points into
|
|
68
|
+
* `Versions/Current`, which points at `Versions/A`. Dereferenced, the archive carries the
|
|
69
|
+
* framework's 192 MB payload twice and no links at all, and macOS rejects that layout as
|
|
70
|
+
* unrecognised — the signature that was correct on disk no longer validates against it. Measured
|
|
71
|
+
* on this repository before this function existed: a 404 MB archive with zero symlink entries,
|
|
72
|
+
* against 353 MB on disk.
|
|
73
|
+
*
|
|
74
|
+
* `zip -y` stores a symlink as a symlink, `-r` recurses, and Unix permission bits are kept, which
|
|
75
|
+
* every executable inside the bundle depends on.
|
|
76
|
+
*/
|
|
77
|
+
export async function zipMacApp(app, into) {
|
|
78
|
+
/* `zip` updates an existing archive in place rather than replacing it, so a rebuild would
|
|
79
|
+
otherwise leave files from the previous one in the artifact. */
|
|
80
|
+
await rm(into, { force: true });
|
|
81
|
+
try {
|
|
82
|
+
await run('zip', ['-y', '-r', '-q', into, basename(app)], { cwd: dirname(app) });
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
throw new Error(`could not archive the app with \`zip\`: ${String(cause)}. This build needs it because ` +
|
|
86
|
+
"electron-builder's own zip dereferences symlinks off a Mac, which corrupts every " +
|
|
87
|
+
'framework in the bundle. Install it (`apt install zip`) and build again.');
|
|
88
|
+
}
|
|
89
|
+
const symlinks = await countSymlinkEntries(into);
|
|
90
|
+
if (symlinks === 0) {
|
|
91
|
+
throw new Error(`${into} contains no symlinks, and a macOS app bundle is built out of them — every ` +
|
|
92
|
+
'framework links its top level into `Versions/Current`. An archive without them holds ' +
|
|
93
|
+
'each framework twice and is rejected by macOS as an unrecognised bundle format. That ' +
|
|
94
|
+
'is what `zip` without `-y` produces.');
|
|
95
|
+
}
|
|
96
|
+
console.log(`[drift-package] archived ${into} with ${String(symlinks)} symlinks preserved`);
|
|
97
|
+
}
|
|
98
|
+
/* The three fields of the zip format this needs, from APPNOTE.TXT. */
|
|
99
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
100
|
+
const CENTRAL_SIGNATURE = 0x02014b50;
|
|
101
|
+
/** `S_IFLNK`, in the Unix mode that lives in the high half of the external attributes field. */
|
|
102
|
+
const S_IFMT = 0xf000;
|
|
103
|
+
const S_IFLNK = 0xa000;
|
|
104
|
+
/**
|
|
105
|
+
* How many entries in an archive are symlinks, read from its central directory.
|
|
106
|
+
*
|
|
107
|
+
* **The archive is inspected rather than the tool trusted**, because the failure this guards
|
|
108
|
+
* against is silent by nature: an archiver that dereferences links produces a larger file that
|
|
109
|
+
* unpacks into something that looks complete. Reading the central directory is exact, needs no
|
|
110
|
+
* `zipinfo` on the host, and answers the one question that matters.
|
|
111
|
+
*/
|
|
112
|
+
async function countSymlinkEntries(zip) {
|
|
113
|
+
const handle = await open(zip, 'r');
|
|
114
|
+
try {
|
|
115
|
+
const { size } = await handle.stat();
|
|
116
|
+
/* The end-of-central-directory record is last, after a comment of up to 64 KiB. */
|
|
117
|
+
const tailLength = Math.min(size, 0x10000 + 22);
|
|
118
|
+
const tail = Buffer.alloc(tailLength);
|
|
119
|
+
await handle.read(tail, 0, tailLength, size - tailLength);
|
|
120
|
+
let eocd = -1;
|
|
121
|
+
for (let at = tail.length - 22; at >= 0; at -= 1) {
|
|
122
|
+
if (tail.readUInt32LE(at) === EOCD_SIGNATURE) {
|
|
123
|
+
eocd = at;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (eocd < 0)
|
|
128
|
+
throw new Error(`${zip} has no end-of-central-directory record`);
|
|
129
|
+
const entries = tail.readUInt16LE(eocd + 10);
|
|
130
|
+
const directoryLength = tail.readUInt32LE(eocd + 12);
|
|
131
|
+
const directoryStart = tail.readUInt32LE(eocd + 16);
|
|
132
|
+
const directory = Buffer.alloc(directoryLength);
|
|
133
|
+
await handle.read(directory, 0, directoryLength, directoryStart);
|
|
134
|
+
let at = 0;
|
|
135
|
+
let symlinks = 0;
|
|
136
|
+
for (let index = 0; index < entries && at + 46 <= directory.length; index += 1) {
|
|
137
|
+
if (directory.readUInt32LE(at) !== CENTRAL_SIGNATURE)
|
|
138
|
+
break;
|
|
139
|
+
/* The high half of the external attributes is the Unix mode, for an archive written on
|
|
140
|
+
Unix — which is the only kind this function is ever pointed at. */
|
|
141
|
+
const mode = directory.readUInt32LE(at + 38) >>> 16;
|
|
142
|
+
if ((mode & S_IFMT) === S_IFLNK)
|
|
143
|
+
symlinks += 1;
|
|
144
|
+
at +=
|
|
145
|
+
46 +
|
|
146
|
+
directory.readUInt16LE(at + 28) +
|
|
147
|
+
directory.readUInt16LE(at + 30) +
|
|
148
|
+
directory.readUInt16LE(at + 32);
|
|
149
|
+
}
|
|
150
|
+
return symlinks;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await handle.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Every bundle in the artifact carries its own signature.
|
|
158
|
+
*
|
|
159
|
+
* A signed bundle has a `_CodeSignature/CodeResources` beside its contents — the outer app under
|
|
160
|
+
* `Contents/`, a framework under `Versions/A/`. Checking the directory rather than parsing the
|
|
161
|
+
* seal is deliberate: this catches the failure that actually happens, which is a bundle rcodesign
|
|
162
|
+
* walked past, and it does not pretend to be the validation only a Mac can do.
|
|
163
|
+
*/
|
|
164
|
+
async function assertEverySealed(app) {
|
|
165
|
+
const missing = [];
|
|
166
|
+
if (!(await exists(join(app, 'Contents', '_CodeSignature', 'CodeResources')))) {
|
|
167
|
+
missing.push('the app itself');
|
|
168
|
+
}
|
|
169
|
+
const frameworks = join(app, 'Contents', 'Frameworks');
|
|
170
|
+
for (const entry of await list(frameworks)) {
|
|
171
|
+
const nested = join(frameworks, entry);
|
|
172
|
+
/* A `.app` seals under `Contents/`; a `.framework` under the version it points `Current` at,
|
|
173
|
+
which is `A` for everything Electron ships. */
|
|
174
|
+
const seal = entry.endsWith('.framework')
|
|
175
|
+
? join(nested, 'Versions', 'A', '_CodeSignature', 'CodeResources')
|
|
176
|
+
: join(nested, 'Contents', '_CodeSignature', 'CodeResources');
|
|
177
|
+
if (entry.endsWith('.app') || entry.endsWith('.framework')) {
|
|
178
|
+
if (!(await exists(seal)))
|
|
179
|
+
missing.push(entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (missing.length > 0) {
|
|
183
|
+
throw new Error(`these parts of the app were not signed: ${missing.join(', ')}. An unsigned binary inside ` +
|
|
184
|
+
'a signed app is refused by the loader at the moment it is needed, which for a helper is ' +
|
|
185
|
+
'the first window rather than the launch.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The artifact carries the slice it was asked for.
|
|
190
|
+
*
|
|
191
|
+
* **The failure this prevents is a build that produced the wrong architecture and said nothing.**
|
|
192
|
+
* electron-builder picks the Electron distribution from the target's arch, so a `mac-x64` asked
|
|
193
|
+
* for should never contain arm64 — but a cache with the wrong download in it, or a default that
|
|
194
|
+
* inherited the host's arch, both end as an app that refuses to start on the machine it was made
|
|
195
|
+
* for, with no error that names the cause.
|
|
196
|
+
*/
|
|
197
|
+
async function assertArch(app, expected) {
|
|
198
|
+
const executable = await mainExecutable(app);
|
|
199
|
+
const found = await machoArch(executable);
|
|
200
|
+
if (found === null) {
|
|
201
|
+
throw new Error(`${executable} is not a Mach-O binary; the app did not pack correctly`);
|
|
202
|
+
}
|
|
203
|
+
if (found !== expected) {
|
|
204
|
+
throw new Error(`this build asked for ${expected} and the binary inside is ${found}. That artifact does ` +
|
|
205
|
+
'not run on the machine it was built for, and nothing about it says so until it is opened.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** The one file under `Contents/MacOS`, which is the app's entry point whatever it is named. */
|
|
209
|
+
async function mainExecutable(app) {
|
|
210
|
+
const dir = join(app, 'Contents', 'MacOS');
|
|
211
|
+
const [entry] = await list(dir);
|
|
212
|
+
if (entry === undefined)
|
|
213
|
+
throw new Error(`${dir} is empty; the app did not pack correctly`);
|
|
214
|
+
return join(dir, entry);
|
|
215
|
+
}
|
|
216
|
+
/* Mach-O's own constants, from `mach-o/loader.h` and `mach/machine.h`. */
|
|
217
|
+
const MACHO_64_LE = 0xfeedfacf;
|
|
218
|
+
const FAT_BE = 0xcafebabe;
|
|
219
|
+
const CPU_X86_64 = 0x01000007;
|
|
220
|
+
const CPU_ARM64 = 0x0100000c;
|
|
221
|
+
/**
|
|
222
|
+
* Which architecture a Mach-O binary holds, read from its header.
|
|
223
|
+
*
|
|
224
|
+
* Sixteen bytes of file rather than a tool: `file` and `lipo` both answer this and neither is on
|
|
225
|
+
* a Linux host by default, and the header is four fields that have not moved in twenty years.
|
|
226
|
+
* A universal binary answers with what it contains only when that is unambiguous — one slice.
|
|
227
|
+
*/
|
|
228
|
+
async function machoArch(path) {
|
|
229
|
+
const handle = await open(path, 'r');
|
|
230
|
+
try {
|
|
231
|
+
const header = Buffer.alloc(8);
|
|
232
|
+
await handle.read(header, 0, 8, 0);
|
|
233
|
+
if (header.readUInt32LE(0) === MACHO_64_LE)
|
|
234
|
+
return cpuName(header.readUInt32LE(4));
|
|
235
|
+
if (header.readUInt32BE(0) === FAT_BE) {
|
|
236
|
+
/* A fat header is a count and then 20-byte entries whose first field is the cpu type. */
|
|
237
|
+
const count = header.readUInt32BE(4);
|
|
238
|
+
const names = new Set();
|
|
239
|
+
for (let index = 0; index < count; index += 1) {
|
|
240
|
+
const entry = Buffer.alloc(4);
|
|
241
|
+
await handle.read(entry, 0, 4, 8 + index * 20);
|
|
242
|
+
const name = cpuName(entry.readUInt32BE(0));
|
|
243
|
+
if (name !== null)
|
|
244
|
+
names.add(name);
|
|
245
|
+
}
|
|
246
|
+
return names.size === 1 ? [...names][0] : null;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await handle.close();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function cpuName(cpu) {
|
|
255
|
+
if (cpu === CPU_ARM64)
|
|
256
|
+
return 'arm64';
|
|
257
|
+
if (cpu === CPU_X86_64)
|
|
258
|
+
return 'x64';
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
async function list(dir) {
|
|
262
|
+
try {
|
|
263
|
+
return await readdir(dir);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function exists(path) {
|
|
270
|
+
try {
|
|
271
|
+
await (await import('node:fs/promises')).access(path);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Run a command and return everything it said, on both streams.
|
|
280
|
+
*
|
|
281
|
+
* rcodesign narrates to stderr and the warning this module refuses on arrives there, so the two
|
|
282
|
+
* are merged rather than one being watched — a check that read only stdout would pass every time.
|
|
283
|
+
*/
|
|
284
|
+
async function run(command, args, options = {}) {
|
|
285
|
+
return new Promise((resolve, reject) => {
|
|
286
|
+
const child = spawn(command, [...args], {
|
|
287
|
+
cwd: options.cwd,
|
|
288
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
289
|
+
});
|
|
290
|
+
let output = '';
|
|
291
|
+
child.stdout.on('data', (chunk) => (output += chunk.toString()));
|
|
292
|
+
child.stderr.on('data', (chunk) => (output += chunk.toString()));
|
|
293
|
+
child.on('error', reject);
|
|
294
|
+
child.on('close', (code) => code === 0
|
|
295
|
+
? resolve(output)
|
|
296
|
+
: reject(new Error(`${command} exited with ${String(code)}:\n${output}`)));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
export interface ConsoleLine {
|
|
25
|
+
readonly level: ConsoleLevel;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The line a listener was handed, whichever shape it came in, or `null` when neither carried one.
|
|
30
|
+
*
|
|
31
|
+
* The first argument is preferred when it carries a level, which is the newer shape; the second is
|
|
32
|
+
* read otherwise. The third is the positional message the newer shape also passes, used only when
|
|
33
|
+
* an object carried a level without a message.
|
|
34
|
+
*/
|
|
35
|
+
export declare function consoleLine(first: unknown, second: unknown, third: unknown): ConsoleLine | null;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a line at this level is forwarded.
|
|
38
|
+
*
|
|
39
|
+
* Errors in both kinds of build: an error nobody can quote is an error nobody can fix. Warnings in
|
|
40
|
+
* development only — they are where a framework's advice and a game's own diagnostics live, and a
|
|
41
|
+
* shipped game printing them to a terminal nobody is watching buries the line that matters.
|
|
42
|
+
*/
|
|
43
|
+
export declare function forwardsConsole(level: ConsoleLevel, development: boolean): boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** 0 to 3, as the older event numbers them. */
|
|
2
|
+
const BY_NUMBER = ['verbose', 'info', 'warning', 'error'];
|
|
3
|
+
const NAMED = ['debug', 'verbose', 'info', 'warning', 'error'];
|
|
4
|
+
function levelOf(value) {
|
|
5
|
+
if (typeof value === 'string' && NAMED.includes(value))
|
|
6
|
+
return value;
|
|
7
|
+
if (typeof value === 'number' && Number.isInteger(value))
|
|
8
|
+
return BY_NUMBER[value] ?? null;
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function fieldsOf(value) {
|
|
12
|
+
if (value === null || typeof value !== 'object')
|
|
13
|
+
return null;
|
|
14
|
+
const record = value;
|
|
15
|
+
return { level: record.level, message: record.message };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The line a listener was handed, whichever shape it came in, or `null` when neither carried one.
|
|
19
|
+
*
|
|
20
|
+
* The first argument is preferred when it carries a level, which is the newer shape; the second is
|
|
21
|
+
* read otherwise. The third is the positional message the newer shape also passes, used only when
|
|
22
|
+
* an object carried a level without a message.
|
|
23
|
+
*/
|
|
24
|
+
export function consoleLine(first, second, third) {
|
|
25
|
+
/*
|
|
26
|
+
* The positional form first, because it is unambiguous: a number where an object would be is the
|
|
27
|
+
* older event, and no newer one puts a level there without also putting one on its first
|
|
28
|
+
* argument — which the loop below would have taken anyway.
|
|
29
|
+
*/
|
|
30
|
+
if (typeof second === 'number' &&
|
|
31
|
+
typeof third === 'string' &&
|
|
32
|
+
fieldsOf(first)?.level === undefined) {
|
|
33
|
+
const level = levelOf(second);
|
|
34
|
+
if (level !== null)
|
|
35
|
+
return { level, message: third };
|
|
36
|
+
}
|
|
37
|
+
for (const candidate of [fieldsOf(first), fieldsOf(second)]) {
|
|
38
|
+
if (candidate === null)
|
|
39
|
+
continue;
|
|
40
|
+
const level = levelOf(candidate.level);
|
|
41
|
+
if (level === null)
|
|
42
|
+
continue;
|
|
43
|
+
const message = typeof candidate.message === 'string'
|
|
44
|
+
? candidate.message
|
|
45
|
+
: typeof third === 'string'
|
|
46
|
+
? third
|
|
47
|
+
: '';
|
|
48
|
+
return { level, message };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether a line at this level is forwarded.
|
|
54
|
+
*
|
|
55
|
+
* Errors in both kinds of build: an error nobody can quote is an error nobody can fix. Warnings in
|
|
56
|
+
* development only — they are where a framework's advice and a game's own diagnostics live, and a
|
|
57
|
+
* shipped game printing them to a terminal nobody is watching buries the line that matters.
|
|
58
|
+
*/
|
|
59
|
+
export function forwardsConsole(level, development) {
|
|
60
|
+
if (level === 'error')
|
|
61
|
+
return true;
|
|
62
|
+
return development && level === 'warning';
|
|
63
|
+
}
|
package/dist/main/csp.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the game is allowed to load, and it is deliberately not strict about very much.
|
|
3
|
+
*
|
|
4
|
+
* **The one thing it forbids is a script from somewhere else.** That is the attack this is for: a
|
|
5
|
+
* renderer that fetches a script over `http:` is a renderer whose network can hand it code, and it
|
|
6
|
+
* runs beside a bridge to the filesystem. Everything a game legitimately does — inline module
|
|
7
|
+
* preloads, a WebAssembly module, a blob worker, a texture from a data URL, a socket to its own
|
|
8
|
+
* server — is allowed, because a policy that breaks an ordinary bundle is a policy somebody
|
|
9
|
+
* switches off.
|
|
10
|
+
*
|
|
11
|
+
* **What would make this wrong** is a game that loads code from a CDN, which this refuses. That is
|
|
12
|
+
* the intended refusal and the answer is to bundle it.
|
|
13
|
+
*
|
|
14
|
+
* **Its own module, and tested, because a directive missing one scheme is invisible.** This lived
|
|
15
|
+
* as a constant in `window.ts`, which is Electron lifecycle and is not unit-tested — and it shipped
|
|
16
|
+
* allowing `wss:` without `ws:`, so a packaged game could not reach a relay on its own network. The
|
|
17
|
+
* shape of that failure is what makes it worth a test rather than a careful read: Chromium blocks
|
|
18
|
+
* the connection before a packet leaves, so the relay logs nothing and the game reports only that
|
|
19
|
+
* it did not connect. The two together point at the network. Seeing the actual line took a remote
|
|
20
|
+
* debugging session against the packaged binary, and a packaged build has no developer tools.
|
|
21
|
+
*/
|
|
22
|
+
const DIRECTIVES = [
|
|
23
|
+
"default-src 'self' drift: blob: data:",
|
|
24
|
+
"script-src 'self' drift: blob: 'unsafe-inline' 'wasm-unsafe-eval'",
|
|
25
|
+
"style-src 'self' drift: 'unsafe-inline'",
|
|
26
|
+
"img-src 'self' drift: blob: data: https:",
|
|
27
|
+
"media-src 'self' drift: blob: data: https:",
|
|
28
|
+
"font-src 'self' drift: data: https:",
|
|
29
|
+
"worker-src 'self' drift: blob:",
|
|
30
|
+
/*
|
|
31
|
+
* **`ws:` beside `wss:`, because a relay on a local network cannot hold a certificate.**
|
|
32
|
+
*
|
|
33
|
+
* `ws://192.168.1.20:8787` is the ordinary case for a desktop game with its own server, and no
|
|
34
|
+
* certificate authority issues for a private address — so the secure scheme is not an
|
|
35
|
+
* alternative there, it is a refusal. A public relay needs `wss:` regardless and is unaffected.
|
|
36
|
+
*
|
|
37
|
+
* **What this widens, stated rather than waved past.** Reaching an arbitrary host was already
|
|
38
|
+
* permitted: `https:` and `wss:` are both open, so a compromised asset could already talk to a
|
|
39
|
+
* server of its choosing. What `ws:` adds is plaintext, which is the developer's own choice about
|
|
40
|
+
* their own traffic, and sockets to services on the machine itself, which `http:` being absent
|
|
41
|
+
* still denies to everything else. Neither is the attack `script-src` is here for.
|
|
42
|
+
*
|
|
43
|
+
* **A manifest field was the other option and is not taken.** `drift.package.json` says nothing
|
|
44
|
+
* about the policy today, and the first thing it said should not be a security surface a consumer
|
|
45
|
+
* has to get right — particularly when the value it would carry is the one this line now has.
|
|
46
|
+
*/
|
|
47
|
+
"connect-src 'self' drift: blob: data: https: wss: ws:",
|
|
48
|
+
];
|
|
49
|
+
/** The header value, as one string. */
|
|
50
|
+
export const CSP = DIRECTIVES.join('; ');
|
|
51
|
+
/** The source list for one directive, or an empty array when the policy does not carry it. */
|
|
52
|
+
export function sourcesFor(directive) {
|
|
53
|
+
const found = DIRECTIVES.find((entry) => entry.startsWith(`${directive} `));
|
|
54
|
+
return found === undefined ? [] : found.slice(directive.length + 1).split(' ');
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { app } from 'electron';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseManifest } from '../manifest.js';
|
|
5
|
+
import { startApp } from './window.js';
|
|
6
|
+
/**
|
|
7
|
+
* What the packaged application actually starts.
|
|
8
|
+
*
|
|
9
|
+
* The layout beside this file is fixed by the builder and is worth stating once, because three
|
|
10
|
+
* files depend on it agreeing:
|
|
11
|
+
*
|
|
12
|
+
* main.cjs this, bundled
|
|
13
|
+
* preload.cjs the bridge, bundled
|
|
14
|
+
* drift.package.json the manifest, copied verbatim from the consumer's project
|
|
15
|
+
* app/ the game's built web bundle, with index.html at its root
|
|
16
|
+
*
|
|
17
|
+
* **The target is derived rather than baked in.** A build produces one artifact per target and
|
|
18
|
+
* each runs on exactly one platform, so asking the process it is running in cannot be wrong —
|
|
19
|
+
* where a value written at build time can be, and would be discovered as the wrong Chromium flags
|
|
20
|
+
* on somebody else's machine.
|
|
21
|
+
*/
|
|
22
|
+
function hostTarget() {
|
|
23
|
+
if (process.platform === 'win32')
|
|
24
|
+
return 'win-x64';
|
|
25
|
+
if (process.platform === 'darwin')
|
|
26
|
+
return process.arch === 'x64' ? 'mac-x64' : 'mac-arm64';
|
|
27
|
+
return 'linux-x64';
|
|
28
|
+
}
|
|
29
|
+
const here = __dirname;
|
|
30
|
+
const manifest = parseManifest(JSON.parse(readFileSync(join(here, 'drift.package.json'), 'utf8')));
|
|
31
|
+
/**
|
|
32
|
+
* Whether this is a development run, which decides what a person can open.
|
|
33
|
+
*
|
|
34
|
+
* **Absent means production, and that direction is the whole point.** A shipped artifact whose
|
|
35
|
+
* marker file was lost must not fall back to exposing developer tools; a development run whose
|
|
36
|
+
* marker is missing merely loses a convenience.
|
|
37
|
+
*/
|
|
38
|
+
function isDevelopment() {
|
|
39
|
+
try {
|
|
40
|
+
const raw = JSON.parse(readFileSync(join(here, 'build.json'), 'utf8'));
|
|
41
|
+
return raw.development === true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
startApp(manifest, join(here, 'app'), hostTarget(), { development: isDevelopment() }).catch((cause) => {
|
|
48
|
+
/*
|
|
49
|
+
* A failure here is a failure to open a window at all, and there is nothing on screen to show
|
|
50
|
+
* it on. The console is what a player can be asked to copy, and a non-zero exit is what a Steam
|
|
51
|
+
* launch reports.
|
|
52
|
+
*/
|
|
53
|
+
console.error('[drift-package] the application could not start:', cause);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
app.quit();
|
|
56
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { BrowserWindow } from 'electron';
|
|
2
|
+
import type { SteamRuntime } from '../steam/main.ts';
|
|
3
|
+
/**
|
|
4
|
+
* What the game starts with, read from wherever this build's saves live.
|
|
5
|
+
*
|
|
6
|
+
* Steam Cloud first where a store answered, because a player who moved machines expects their
|
|
7
|
+
* settings to have travelled — and falling back to the local file when the cloud has nothing is
|
|
8
|
+
* what makes the first launch after enabling Steam keep the saves it already had.
|
|
9
|
+
*/
|
|
10
|
+
export declare function readStoreSnapshot(steam?: SteamRuntime | null): Record<string, string>;
|
|
11
|
+
/** What the window half needs back from here, so quitting is one decision in one place. */
|
|
12
|
+
export interface QuitProtocol {
|
|
13
|
+
/** True once the game has said it wants to be told before the window closes. */
|
|
14
|
+
wantsNotice(): boolean;
|
|
15
|
+
/** True when the close should proceed rather than be deferred. */
|
|
16
|
+
mayClose(): boolean;
|
|
17
|
+
/** Called from the window's `close` handler when it defers, to arm the grace timer. */
|
|
18
|
+
deferClose(window: BrowserWindow): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function installIpc(snapshot: Record<string, string>, windowOf: () => BrowserWindow | null, steam?: SteamRuntime | null): QuitProtocol;
|
|
21
|
+
/**
|
|
22
|
+
* The refresh rate of the display this window is on, which the browser implementation of
|
|
23
|
+
* `DisplayControl` has to answer null for.
|
|
24
|
+
*
|
|
25
|
+
* Null rather than a plausible 60 where the platform withholds it, matching the engine's rule:
|
|
26
|
+
* something will divide by this.
|
|
27
|
+
*/
|
|
28
|
+
export declare function refreshHzFor(window: BrowserWindow | null): number | null;
|