@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,57 @@
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
+
50
+ /** The header value, as one string. */
51
+ export const CSP = DIRECTIVES.join('; ');
52
+
53
+ /** The source list for one directive, or an empty array when the policy does not carry it. */
54
+ export function sourcesFor(directive: string): readonly string[] {
55
+ const found = DIRECTIVES.find((entry) => entry.startsWith(`${directive} `));
56
+ return found === undefined ? [] : found.slice(directive.length + 1).split(' ');
57
+ }
@@ -0,0 +1,63 @@
1
+ import { app } from 'electron';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { parseManifest } from '../manifest.ts';
6
+ import type { Target } from '../manifest.ts';
7
+ import { startApp } from './window.ts';
8
+
9
+ /**
10
+ * What the packaged application actually starts.
11
+ *
12
+ * The layout beside this file is fixed by the builder and is worth stating once, because three
13
+ * files depend on it agreeing:
14
+ *
15
+ * main.cjs this, bundled
16
+ * preload.cjs the bridge, bundled
17
+ * drift.package.json the manifest, copied verbatim from the consumer's project
18
+ * app/ the game's built web bundle, with index.html at its root
19
+ *
20
+ * **The target is derived rather than baked in.** A build produces one artifact per target and
21
+ * each runs on exactly one platform, so asking the process it is running in cannot be wrong —
22
+ * where a value written at build time can be, and would be discovered as the wrong Chromium flags
23
+ * on somebody else's machine.
24
+ */
25
+ function hostTarget(): Target {
26
+ if (process.platform === 'win32') return 'win-x64';
27
+ if (process.platform === 'darwin') return process.arch === 'x64' ? 'mac-x64' : 'mac-arm64';
28
+ return 'linux-x64';
29
+ }
30
+
31
+ const here = __dirname;
32
+ const manifest = parseManifest(
33
+ JSON.parse(readFileSync(join(here, 'drift.package.json'), 'utf8')) as unknown,
34
+ );
35
+
36
+ /**
37
+ * Whether this is a development run, which decides what a person can open.
38
+ *
39
+ * **Absent means production, and that direction is the whole point.** A shipped artifact whose
40
+ * marker file was lost must not fall back to exposing developer tools; a development run whose
41
+ * marker is missing merely loses a convenience.
42
+ */
43
+ function isDevelopment(): boolean {
44
+ try {
45
+ const raw: unknown = JSON.parse(readFileSync(join(here, 'build.json'), 'utf8'));
46
+ return (raw as { development?: unknown }).development === true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ startApp(manifest, join(here, 'app'), hostTarget(), { development: isDevelopment() }).catch(
53
+ (cause: unknown) => {
54
+ /*
55
+ * A failure here is a failure to open a window at all, and there is nothing on screen to show
56
+ * it on. The console is what a player can be asked to copy, and a non-zero exit is what a Steam
57
+ * launch reports.
58
+ */
59
+ console.error('[drift-package] the application could not start:', cause);
60
+ process.exitCode = 1;
61
+ app.quit();
62
+ },
63
+ );
@@ -0,0 +1,311 @@
1
+ import { BrowserWindow, app, dialog, ipcMain, screen } from 'electron';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+
6
+ import type { WindowMode } from '@driftengine/core';
7
+
8
+ import { SteamCloudStore } from '../steam/cloudStore.ts';
9
+ import type { SteamRuntime } from '../steam/main.ts';
10
+
11
+ /**
12
+ * The main-process half of the bridge: a file-backed store, dialogs, focus, and quit.
13
+ *
14
+ * **The store is read once, synchronously, before the window exists.** That is what lets the
15
+ * renderer satisfy `KeyValueStore`'s synchronous contract; see `preload/bridge.ts` for why that
16
+ * contract is not negotiable. Writes are debounced to one file write, because a settings screen
17
+ * with a slider writes on every frame of a drag and a save per frame is a stutter.
18
+ *
19
+ * **Three channels answer synchronously** — the snapshot, fullscreen state and refresh rate — and
20
+ * that is a deliberate cost. A synchronous IPC blocks the renderer, so each is a single value
21
+ * read at boot or from a settings screen, never anything per frame.
22
+ */
23
+ const storeFile = (): string => join(app.getPath('userData'), 'store.json');
24
+
25
+ /** Milliseconds a write waits for its neighbours. A drag writes every frame; disk should not. */
26
+ const WRITE_DEBOUNCE_MS = 250;
27
+
28
+ /** How long a quit waits for a game that asked to be told about it. */
29
+ const QUIT_GRACE_MS = 3000;
30
+
31
+ /**
32
+ * What the game starts with, read from wherever this build's saves live.
33
+ *
34
+ * Steam Cloud first where a store answered, because a player who moved machines expects their
35
+ * settings to have travelled — and falling back to the local file when the cloud has nothing is
36
+ * what makes the first launch after enabling Steam keep the saves it already had.
37
+ */
38
+ export function readStoreSnapshot(steam: SteamRuntime | null = null): Record<string, string> {
39
+ if (steam !== null) {
40
+ const cloud = cloudSnapshot(steam);
41
+ if (Object.keys(cloud).length > 0) return cloud;
42
+ }
43
+ return readLocalSnapshot();
44
+ }
45
+
46
+ /**
47
+ * Every key Steam Cloud holds for this application.
48
+ *
49
+ * The API lists files rather than keys, and `SteamCloudStore` writes one file per key with a
50
+ * `.txt` suffix — so the mapping is undone here rather than guessed at by a caller.
51
+ */
52
+ function cloudSnapshot(steam: SteamRuntime): Record<string, string> {
53
+ const out: Record<string, string> = {};
54
+ try {
55
+ const cloud = steam.cloud as SteamRuntime['cloud'] & { listFiles?(): { name: string }[] };
56
+ for (const file of cloud.listFiles?.() ?? []) {
57
+ if (!file.name.endsWith('.txt')) continue;
58
+ const value = cloud.readFile(file.name);
59
+ if (typeof value === 'string' && value.length > 0) {
60
+ out[file.name.slice(0, -'.txt'.length)] = value;
61
+ }
62
+ }
63
+ } catch {
64
+ /* Cloud storage off for this title or this account. The local file is the answer. */
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function readLocalSnapshot(): Record<string, string> {
70
+ try {
71
+ const parsed: unknown = JSON.parse(readFileSync(storeFile(), 'utf8'));
72
+ if (typeof parsed !== 'object' || parsed === null) return {};
73
+ const out: Record<string, string> = {};
74
+ for (const [key, value] of Object.entries(parsed)) {
75
+ if (typeof value === 'string') out[key] = value;
76
+ }
77
+ return out;
78
+ } catch {
79
+ /* No file yet, or an unreadable one. A fresh store is the right answer to both: a
80
+ preference is never worth failing a boot for. */
81
+ return {};
82
+ }
83
+ }
84
+
85
+ /** What the window half needs back from here, so quitting is one decision in one place. */
86
+ export interface QuitProtocol {
87
+ /** True once the game has said it wants to be told before the window closes. */
88
+ wantsNotice(): boolean;
89
+ /** True when the close should proceed rather than be deferred. */
90
+ mayClose(): boolean;
91
+ /** Called from the window's `close` handler when it defers, to arm the grace timer. */
92
+ deferClose(window: BrowserWindow): void;
93
+ }
94
+
95
+ export function installIpc(
96
+ snapshot: Record<string, string>,
97
+ windowOf: () => BrowserWindow | null,
98
+ steam: SteamRuntime | null = null,
99
+ ): QuitProtocol {
100
+ const entries = new Map(Object.entries(snapshot));
101
+ let pending: NodeJS.Timeout | null = null;
102
+
103
+ /*
104
+ * **Where a save actually lands, decided once, here.** A game asks the same `KeyValueStore` for
105
+ * its preferences whatever platform it is on; whether those bytes go to a file beside the
106
+ * application or into Steam Cloud — and therefore onto the player's other machine — is this
107
+ * process's decision and nothing the renderer can see. That is the whole return on the seam.
108
+ */
109
+ const cloud = steam === null ? null : new SteamCloudStore(steam.cloud);
110
+
111
+ const flush = (): void => {
112
+ pending = null;
113
+ if (cloud !== null) {
114
+ for (const [key, value] of entries) cloud.write(key, value);
115
+ return;
116
+ }
117
+ void writeFile(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8').catch(
118
+ (cause: unknown) => console.error('[driftengine] could not write the store:', cause),
119
+ );
120
+ };
121
+ const schedule = (): void => {
122
+ if (pending === null) pending = setTimeout(flush, WRITE_DEBOUNCE_MS);
123
+ };
124
+
125
+ ipcMain.on('drift:snapshot', (event) => {
126
+ event.returnValue = Object.fromEntries(entries);
127
+ });
128
+ ipcMain.on('drift:write', (_event, key: string, value: string) => {
129
+ entries.set(key, value);
130
+ schedule();
131
+ });
132
+ ipcMain.on('drift:remove', (_event, key: string) => {
133
+ entries.delete(key);
134
+ schedule();
135
+ });
136
+
137
+ ipcMain.on('drift:platformAvailable', (event) => {
138
+ event.returnValue = steam !== null;
139
+ });
140
+ ipcMain.on('drift:achievement', (_event, id: string) => steam?.unlockAchievement(id));
141
+ ipcMain.on('drift:presence', (_event, text: string) => steam?.setRichPresence(text));
142
+
143
+ ipcMain.on('drift:isFullscreen', (event) => {
144
+ event.returnValue = windowOf()?.isFullScreen() ?? false;
145
+ });
146
+ ipcMain.handle('drift:setFullscreen', (_event, on: boolean) => {
147
+ windowOf()?.setFullScreen(on);
148
+ });
149
+ ipcMain.on('drift:refreshHz', (event) => {
150
+ event.returnValue = refreshHzFor(windowOf());
151
+ });
152
+
153
+ ipcMain.on('drift:mode', (event) => {
154
+ event.returnValue = windowOf()?.isFullScreen() === true ? 'fullscreen' : 'windowed';
155
+ });
156
+ ipcMain.handle('drift:setMode', (_event, mode: WindowMode) => {
157
+ const window = windowOf();
158
+ if (window === null) return false;
159
+ window.setFullScreen(mode === 'fullscreen');
160
+ /* Read back rather than assume. A window manager may refuse, and a settings screen that
161
+ believed the request would show a state the desktop disagrees with. */
162
+ return window.isFullScreen() === (mode === 'fullscreen');
163
+ });
164
+
165
+ ipcMain.on('drift:size', (event) => {
166
+ const [width = 0, height = 0] = windowOf()?.getContentSize() ?? [];
167
+ event.returnValue = { width, height };
168
+ });
169
+ /**
170
+ * Whether a resize would be performed, decided once for the two handlers that need it.
171
+ *
172
+ * **Refused while the window is covering a display**, because there is nothing honest to do:
173
+ * Chromium never takes an exclusive fullscreen, so there is no display mode to change, and
174
+ * resizing the window out from under a fullscreen state would silently leave it. A game that
175
+ * wants fewer pixels while fullscreen changes its render scale, which is the engine's own
176
+ * `RenderQuality`, not the window's business.
177
+ *
178
+ * **One function and not two conditions**, because the two handlers below answer the same
179
+ * question — one by doing it and one by being asked — and a pair that drifted would give a
180
+ * settings screen a greyed control that works, or a live one that does nothing. `AGENTS.md` calls
181
+ * that shape out on its own: two implementations of one decision drift, and they drift invisibly
182
+ * when they start identical.
183
+ */
184
+ const resizable = (): boolean => {
185
+ const window = windowOf();
186
+ return window !== null && !window.isFullScreen();
187
+ };
188
+
189
+ /*
190
+ * Synchronous, like `drift:size`: a settings screen asks this while it is drawing a control, and
191
+ * an answer a frame later would draw the control twice.
192
+ */
193
+ ipcMain.on('drift:canSetSize', (event) => {
194
+ event.returnValue = resizable();
195
+ });
196
+ ipcMain.handle('drift:setSize', (_event, width: number, height: number) => {
197
+ if (!resizable()) return false;
198
+ const window = windowOf();
199
+ if (window === null) return false;
200
+ window.setContentSize(Math.round(width), Math.round(height));
201
+ return true;
202
+ });
203
+
204
+ ipcMain.on('drift:displays', (event) => {
205
+ const primary = screen.getPrimaryDisplay();
206
+ event.returnValue = screen.getAllDisplays().map((display) => ({
207
+ id: String(display.id),
208
+ /* A platform that has no name for a display gets its size, which is what a person picking
209
+ between two monitors in a list actually recognises. */
210
+ label:
211
+ typeof display.label === 'string' && display.label.length > 0
212
+ ? display.label
213
+ : `${display.size.width}x${display.size.height}`,
214
+ width: display.size.width,
215
+ height: display.size.height,
216
+ scale: display.scaleFactor,
217
+ refreshHz:
218
+ typeof display.displayFrequency === 'number' && display.displayFrequency > 0
219
+ ? display.displayFrequency
220
+ : null,
221
+ primary: display.id === primary.id,
222
+ }));
223
+ });
224
+
225
+ /*
226
+ * **Quitting is a two-part protocol and the interest flag is what keeps it cheap.** A game that
227
+ * registers `onQuitRequest` wants a moment to flush a save; a game that does not must close
228
+ * instantly. Without the flag the only correct implementation defers *every* close for a grace
229
+ * period, which is three seconds of nothing on the way out of a game that had nothing to say.
230
+ */
231
+ let noticeWanted = false;
232
+ let closing = false;
233
+ ipcMain.on('drift:quitInterest', () => {
234
+ noticeWanted = true;
235
+ });
236
+ ipcMain.on('drift:quit', () => {
237
+ closing = true;
238
+ app.quit();
239
+ });
240
+
241
+ ipcMain.handle('drift:openFile', async (_event, accept: readonly string[]) => {
242
+ const result = await dialog.showOpenDialog({
243
+ properties: ['openFile'],
244
+ filters: [{ name: 'files', extensions: accept.map((entry) => entry.replace(/^\./, '')) }],
245
+ });
246
+ const path = result.filePaths[0];
247
+ if (result.canceled || path === undefined) return null;
248
+ return { name: path.split(/[\\/]/).pop() ?? path, bytes: new Uint8Array(await readFile(path)) };
249
+ });
250
+
251
+ ipcMain.handle('drift:saveFile', async (_event, name: string, bytes: Uint8Array) => {
252
+ const result = await dialog.showSaveDialog({ defaultPath: name });
253
+ if (result.canceled || result.filePath === undefined) return false;
254
+ await writeFile(result.filePath, bytes);
255
+ return true;
256
+ });
257
+
258
+ /*
259
+ * **Flushed synchronously on the way out, and the synchronous part is the whole point.**
260
+ *
261
+ * A debounce that loses its last write loses exactly the setting somebody just changed before
262
+ * quitting — and an *asynchronous* flush here loses it too, silently, because `app.quit()`
263
+ * tears the process down without waiting for a promise. Measured: a probe that wrote a key and
264
+ * then asked to quit left no file at all. A blocking write of a few kilobytes on the way out is
265
+ * not a cost anybody can perceive; the alternative is a settings screen that sometimes works.
266
+ */
267
+ app.on('before-quit', () => {
268
+ if (pending === null) return;
269
+ clearTimeout(pending);
270
+ pending = null;
271
+ try {
272
+ if (cloud !== null) {
273
+ for (const [key, value] of entries) cloud.write(key, value);
274
+ return;
275
+ }
276
+ writeFileSync(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8');
277
+ } catch (cause) {
278
+ console.error('[driftengine] could not write the store on the way out:', cause);
279
+ }
280
+ });
281
+
282
+ return {
283
+ wantsNotice: () => noticeWanted,
284
+ mayClose: () => closing,
285
+ deferClose: (window) => {
286
+ window.webContents.send('drift:quitRequest');
287
+ /* The grace is a ceiling, not a wait: a game that answers with `requestQuit` closes at
288
+ once. Without one, a game that hangs must not make the window unclosable. */
289
+ setTimeout(() => {
290
+ closing = true;
291
+ window.close();
292
+ }, QUIT_GRACE_MS);
293
+ },
294
+ };
295
+ }
296
+
297
+ /**
298
+ * The refresh rate of the display this window is on, which the browser implementation of
299
+ * `DisplayControl` has to answer null for.
300
+ *
301
+ * Null rather than a plausible 60 where the platform withholds it, matching the engine's rule:
302
+ * something will divide by this.
303
+ */
304
+ export function refreshHzFor(window: BrowserWindow | null): number | null {
305
+ if (window === null) return null;
306
+ const bounds = window.getBounds();
307
+ const display = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y });
308
+ return typeof display.displayFrequency === 'number' && display.displayFrequency > 0
309
+ ? display.displayFrequency
310
+ : null;
311
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Which links leave the application, and which are refused outright.
3
+ *
4
+ * **A link to the web opens in the person's own browser.** That is what a packaged application is
5
+ * expected to do — their bookmarks, their session, their extensions, an address bar and a way back
6
+ * — and it is also the safer of the two: a second Electron window is a browser with no address bar
7
+ * that nobody is maintaining, showing a page that can then see how it was opened.
8
+ *
9
+ * **Everything else is refused, and that list is short on purpose.** `shell.openExternal` hands a
10
+ * string to the operating system: `file:` opens whatever it names, and a handler-registered scheme
11
+ * can launch another application with an argument. A page that controls a link would then control
12
+ * that. So three schemes leave — `http`, `https` and `mailto` — and everything else, including the
13
+ * game's own `drift://`, answers null.
14
+ *
15
+ * Pure, so every one of those refusals is a test rather than a comment.
16
+ */
17
+ const ALLOWED = new Set(['http:', 'https:', 'mailto:']);
18
+
19
+ export function externalTarget(url: string): string | null {
20
+ let parsed: URL;
21
+ try {
22
+ parsed = new URL(url);
23
+ } catch {
24
+ /* Not a URL at all. A relative link inside the game never reaches here — it resolves against
25
+ drift:// before anything asks — so this is a malformed one, and nothing opens it. */
26
+ return null;
27
+ }
28
+ return ALLOWED.has(parsed.protocol) ? url : null;
29
+ }
@@ -0,0 +1,53 @@
1
+ import type { MenuItemConstructorOptions } from 'electron';
2
+
3
+ /**
4
+ * The menu a shipped game has, which on two of three platforms is none.
5
+ *
6
+ * Electron installs a default menu bar with File, Edit, View, Window and Help. Nothing in it
7
+ * belongs to a game — two of its items reload the page and open developer tools — and its presence
8
+ * is the single clearest sign that what you are looking at is a web page in a frame.
9
+ *
10
+ * **macOS is the exception, and not for appearance.** The application menu is where `Cmd+Q`,
11
+ * `Cmd+H` and the services menu live; an application with no menu on that platform is one a person
12
+ * cannot quit with the shortcut every other application answers to. So it keeps the smallest menu
13
+ * that behaves like a Mac application: the app menu, Edit — where copy and paste are roles rather
14
+ * than key handlers a game would have to write — and Window.
15
+ *
16
+ * Pure, so the shape is testable without a running Electron.
17
+ */
18
+ export function appMenuTemplate(
19
+ productName: string,
20
+ platform: NodeJS.Platform,
21
+ ): MenuItemConstructorOptions[] | null {
22
+ if (platform !== 'darwin') return null;
23
+ return [
24
+ {
25
+ label: productName,
26
+ submenu: [
27
+ { role: 'about' },
28
+ { type: 'separator' },
29
+ { role: 'hide' },
30
+ { role: 'hideOthers' },
31
+ { role: 'unhide' },
32
+ { type: 'separator' },
33
+ { role: 'quit' },
34
+ ],
35
+ },
36
+ {
37
+ label: 'Edit',
38
+ submenu: [
39
+ { role: 'undo' },
40
+ { role: 'redo' },
41
+ { type: 'separator' },
42
+ { role: 'cut' },
43
+ { role: 'copy' },
44
+ { role: 'paste' },
45
+ { role: 'selectAll' },
46
+ ],
47
+ },
48
+ {
49
+ label: 'Window',
50
+ submenu: [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }],
51
+ },
52
+ ];
53
+ }
@@ -0,0 +1,70 @@
1
+ import { join, normalize, sep } from 'node:path';
2
+
3
+ /**
4
+ * A path inside the packaged application, or null when it is not inside it.
5
+ *
6
+ * **Separate from the protocol handler so it can be tested at all.** This is the only part of the
7
+ * main process with a security consequence: the renderer runs with `nodeIntegration: false` and
8
+ * `sandbox: true`, so `drift://` is the single filesystem surface it can reach, and a traversal
9
+ * through it hands a compromised renderer the disk.
10
+ *
11
+ * The separator in the comparison is load-bearing. A plain `startsWith(root)` accepts
12
+ * `/app-secrets` for a root of `/app`, which is a sibling directory rather than a child.
13
+ */
14
+ export function resolveWithinRoot(root: string, pathname: string): string | null {
15
+ const resolved = normalize(join(root, pathname));
16
+ if (resolved === root) return resolved;
17
+ return resolved.startsWith(root + sep) ? resolved : null;
18
+ }
19
+
20
+ /**
21
+ * A content type, from the extension, for the handful a game serves.
22
+ *
23
+ * **A wrong one is not cosmetic here.** A module served as `text/plain` is refused by the module
24
+ * loader with a message about MIME type, and a `.wasm` served as anything but
25
+ * `application/wasm` cannot be streamed-compiled. Everything unrecognised is
26
+ * `application/octet-stream`, which is correct for the container and for every asset.
27
+ */
28
+ export function contentTypeFor(path: string): string {
29
+ const dot = path.lastIndexOf('.');
30
+ switch (dot === -1 ? '' : path.slice(dot + 1).toLowerCase()) {
31
+ case 'html':
32
+ return 'text/html; charset=utf-8';
33
+ case 'js':
34
+ case 'mjs':
35
+ return 'text/javascript; charset=utf-8';
36
+ case 'css':
37
+ return 'text/css; charset=utf-8';
38
+ case 'json':
39
+ return 'application/json; charset=utf-8';
40
+ case 'wasm':
41
+ return 'application/wasm';
42
+ case 'svg':
43
+ /* An `<img>` refuses an SVG served as anything else, and the shell's own badge is one:
44
+ the splash showed a broken-image icon until this case existed. */
45
+ return 'image/svg+xml';
46
+ case 'png':
47
+ return 'image/png';
48
+ case 'jpg':
49
+ case 'jpeg':
50
+ return 'image/jpeg';
51
+ case 'webp':
52
+ return 'image/webp';
53
+ case 'gif':
54
+ return 'image/gif';
55
+ case 'ico':
56
+ return 'image/x-icon';
57
+ case 'txt':
58
+ return 'text/plain; charset=utf-8';
59
+ case 'ktx2':
60
+ return 'image/ktx2';
61
+ case 'ogg':
62
+ return 'audio/ogg';
63
+ case 'mp3':
64
+ return 'audio/mpeg';
65
+ case 'wav':
66
+ return 'audio/wav';
67
+ default:
68
+ return 'application/octet-stream';
69
+ }
70
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The browser's own key bindings, taken away from a shipped game.
3
+ *
4
+ * **Chromium answers these itself and there is no menu item to remove.** With the menu bar gone a
5
+ * player still has Ctrl+R and F5 reloading the game mid-run, Ctrl+P offering to print it, Ctrl+F
6
+ * opening a find bar over it, Ctrl+minus shrinking the whole interface, and Ctrl+Shift+I opening
7
+ * an inspector on it. None of those belong to a game, and every one of them is somebody's bug
8
+ * report about a run they lost.
9
+ *
10
+ * **In a development run nothing is blocked**, because those are exactly the tools a developer
11
+ * wants. The build kind is decided by `build.json`, written by the packager — never by an
12
+ * environment variable a player could set.
13
+ *
14
+ * **Quit is left alone on macOS**, and so is Cmd+W: they are what every application on that
15
+ * platform answers to, and a game that swallowed them would be the one a person cannot get out
16
+ * of. Ctrl+W elsewhere is a browser habit rather than a platform one, and goes.
17
+ *
18
+ * Pure, and every rule above is a test.
19
+ */
20
+ export interface KeyPress {
21
+ readonly type: string;
22
+ readonly key: string;
23
+ readonly control: boolean;
24
+ readonly meta: boolean;
25
+ readonly shift: boolean;
26
+ readonly alt: boolean;
27
+ }
28
+
29
+ /** Reload, print, view source, find, and the two zoom directions plus reset. */
30
+ const WITH_MODIFIER = new Set(['r', 'p', 'u', 'f', 'g', '+', '-', '=', '0']);
31
+ /** The inspector, on the two combinations every platform binds it to. */
32
+ const INSPECTOR = new Set(['i', 'j', 'c']);
33
+
34
+ export function isBlockedShortcut(
35
+ input: KeyPress,
36
+ platform: NodeJS.Platform,
37
+ development: boolean,
38
+ ): boolean {
39
+ if (development) return false;
40
+ if (input.type !== 'keyDown') return false;
41
+
42
+ const key = input.key.toLowerCase();
43
+ if (key === 'f5' || key === 'f12') return true;
44
+
45
+ const command = input.control || input.meta;
46
+ if (!command) return false;
47
+
48
+ if (input.shift && INSPECTOR.has(key)) return true;
49
+ if (input.meta && input.alt && INSPECTOR.has(key)) return true;
50
+ if (WITH_MODIFIER.has(key)) return true;
51
+ /* Closing: a platform gesture on macOS, a browser one everywhere else. */
52
+ if (key === 'w' && input.control && platform !== 'darwin') return true;
53
+
54
+ return false;
55
+ }
@@ -0,0 +1,42 @@
1
+ import { BrowserWindow } from 'electron';
2
+
3
+ /**
4
+ * The window the engine badge is drawn in.
5
+ *
6
+ * Frameless and fixed, because a splash somebody can resize is a splash somebody has to think
7
+ * about. Opaque rather than transparent: transparency on Linux depends on a compositor that may
8
+ * not be there, and the failure is a black rectangle with the logo cut out of it — worse than
9
+ * having no rounded corners.
10
+ *
11
+ * **Shown on its own first paint, not on creation.** The same rule the main window follows, for
12
+ * the same reason: a window that appears empty and then fills is the thing this page exists to
13
+ * prevent, and a splash that does it is an unusually poor joke.
14
+ */
15
+ export function createSplash(): BrowserWindow {
16
+ const splash = new BrowserWindow({
17
+ width: 420,
18
+ height: 300,
19
+ frame: false,
20
+ resizable: false,
21
+ movable: false,
22
+ minimizable: false,
23
+ maximizable: false,
24
+ fullscreenable: false,
25
+ skipTaskbar: true,
26
+ /* Above the game's own window, which is hidden until the swap, and above whatever else is
27
+ on screen for the second or two it lives. A splash that can be buried looks like a hang. */
28
+ alwaysOnTop: true,
29
+ show: false,
30
+ backgroundColor: '#0b0b0d',
31
+ webPreferences: {
32
+ /* It runs no script and reaches nothing. There is no bridge here on purpose: the badge has
33
+ no business holding a handle to the filesystem. */
34
+ contextIsolation: true,
35
+ nodeIntegration: false,
36
+ sandbox: true,
37
+ },
38
+ });
39
+ splash.once('ready-to-show', () => splash.show());
40
+ void splash.loadURL('drift://shell/splash.html');
41
+ return splash;
42
+ }