@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,51 @@
1
+ /**
2
+ * When the badge gives the screen to the game.
3
+ *
4
+ * Two conditions and a cap, and each is there for a failure that has a name:
5
+ *
6
+ * - **The game has painted.** Swapping before that shows a white or black window while the
7
+ * first frame is still being built, which is the thing a splash exists to prevent.
8
+ * - **The minimum has passed *since the badge reached the screen*.** A badge that vanishes the
9
+ * instant a fast machine paints is a flicker rather than a splash, and the machines that boot
10
+ * fastest are the ones a developer tests on. Measured from the badge rather than from process
11
+ * start because the badge's own arrival is an unbounded wait — a second window, a protocol
12
+ * handler and an SVG — and measuring the budget from before it started is how a splash gets
13
+ * closed before it is ever shown. Timed on one machine at `minMs: 900`: the badge reached the
14
+ * screen at 411 ms unpacked and at 1659 ms as an AppImage, where the whole budget had already
15
+ * elapsed. It survived there only because the game happened to paint later still.
16
+ * - **The cap overrides both.** A boot that never finishes must not leave a logo as the whole
17
+ * product; past the cap the window is shown whatever state it is in, because a broken game
18
+ * that can be seen is better than a brand that cannot be dismissed.
19
+ *
20
+ * Pure, so all three are testable without a window: the caller owns the clock.
21
+ */
22
+ export type SplashDecision = 'hold' | 'swap';
23
+
24
+ /**
25
+ * The ceiling on the whole splash, whatever the manifest asked for.
26
+ *
27
+ * Twenty seconds is long enough for a large world to stream in on a slow disk — which is exactly
28
+ * the boot a splash should cover — and short enough that a failure is seen in the same sitting.
29
+ */
30
+ export const SPLASH_HARD_CAP_MS = 20_000;
31
+
32
+ /**
33
+ * @param sinceStartMs Since the main window was created, which is what the cap is measured against.
34
+ * @param sinceBadgeMs Since the badge reached the screen, or `null` while it has not. A caller that
35
+ * has given up on the badge ever appearing passes `sinceStartMs`, which is the behaviour this had
36
+ * before the badge was waited for at all.
37
+ */
38
+ export function splashDecision(
39
+ sinceStartMs: number,
40
+ sinceBadgeMs: number | null,
41
+ minMs: number,
42
+ contentReady: boolean,
43
+ capMs: number = SPLASH_HARD_CAP_MS,
44
+ ): SplashDecision {
45
+ if (sinceStartMs >= capMs) return 'swap';
46
+ if (!contentReady) return 'hold';
47
+ /* The game is ready and the badge is not on screen yet: showing the game now is what makes a
48
+ splash vanish entirely on somebody else's machine. The cap above is what bounds this. */
49
+ if (sinceBadgeMs === null) return 'hold';
50
+ return sinceBadgeMs >= minMs ? 'swap' : 'hold';
51
+ }
@@ -0,0 +1,316 @@
1
+ import { BrowserWindow, Menu, app, protocol, shell } from 'electron';
2
+ import { createReadStream } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+
6
+ import { planFlags } from '../flags.ts';
7
+ import type { PackageManifest, Target } from '../manifest.ts';
8
+ import { installIpc, readStoreSnapshot } from './ipc.ts';
9
+ import { externalTarget } from './links.ts';
10
+ import { appMenuTemplate } from './menu.ts';
11
+ import { isBlockedShortcut } from './shortcuts.ts';
12
+ import { openSteam } from '../steam/main.ts';
13
+ import { contentTypeFor, resolveWithinRoot } from './serve.ts';
14
+ import { createSplash } from './splash.ts';
15
+ import { splashDecision } from './splashTiming.ts';
16
+ import { consoleLine, forwardsConsole } from './consoleForward.ts';
17
+ import { CSP } from './csp.ts';
18
+ import { electronMajor } from '../runtimeVersion.ts';
19
+
20
+ /** How often the swap condition is re-read. Fine enough not to be seen, coarse enough to cost
21
+ nothing during a boot that is already busy. */
22
+ const SPLASH_POLL_MS = 80;
23
+
24
+ /**
25
+ * The window, the scheme the game is served over, and the switches it launches with.
26
+ *
27
+ * **`drift://` rather than `file://`, and it is not a preference.** ES modules, `fetch` and
28
+ * workers all need an origin, and `file://` is not one — a game served from it fails to load its
29
+ * own modules, which reads as a broken build rather than as a missing origin.
30
+ *
31
+ * **Every switch is appended before `ready`.** Chromium reads its command line once at startup;
32
+ * a switch appended after the app is ready is accepted silently and does nothing, which is the
33
+ * worst available failure mode for a flag whose whole job is invisible.
34
+ *
35
+ * Not unit-tested, deliberately: this is Electron lifecycle and window state, there is no Electron
36
+ * in the test environment, and the two pieces with logic worth checking — the flag plan and the
37
+ * path resolver — are pure modules that are. What proves this file is `drift-package run`.
38
+ */
39
+ export async function startApp(
40
+ manifest: PackageManifest,
41
+ root: string,
42
+ target: Target,
43
+ options: { readonly development: boolean } = { development: false },
44
+ ): Promise<void> {
45
+ /*
46
+ * **The version is read here rather than assumed**, because this is the one place that cannot be
47
+ * wrong about it: `process.versions.electron` is the runtime executing this line, whatever a
48
+ * consumer's install resolved. A switch is only asked for on a runtime it has been seen to work
49
+ * on, and where one is declined the reason is printed rather than left to look like a driver
50
+ * with no adapter.
51
+ */
52
+ const plan = planFlags(manifest, target, electronMajor(process.versions.electron));
53
+ if (plan.refusal !== null) throw new Error(plan.refusal);
54
+ for (const note of plan.notes) console.log(`[drift-package] ${note}`);
55
+ for (const [name, value] of plan.switches) {
56
+ if (value === '') app.commandLine.appendSwitch(name);
57
+ else app.commandLine.appendSwitch(name, value);
58
+ }
59
+
60
+ /*
61
+ * **One instance, because the store is a snapshot.** The renderer reads preferences from a copy
62
+ * taken before the window opened, so two windows would each hold a copy and the last one to
63
+ * write would win silently. A second launch raises the first window instead.
64
+ */
65
+ if (!app.requestSingleInstanceLock()) {
66
+ app.quit();
67
+ return;
68
+ }
69
+
70
+ /*
71
+ * Privileged before `ready`, or the scheme is not a secure context: no modules, no workers,
72
+ * and `crossOriginIsolated` false. Registering it late throws rather than degrading, which is
73
+ * the behaviour to want.
74
+ */
75
+ protocol.registerSchemesAsPrivileged([
76
+ {
77
+ scheme: 'drift',
78
+ privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
79
+ },
80
+ ]);
81
+
82
+ await app.whenReady();
83
+
84
+ /*
85
+ * **The default menu bar goes before the first window exists.** Electron installs File, Edit,
86
+ * View, Window and Help — with Reload and Toggle Developer Tools in it — and a game that ships
87
+ * with that menu is a game that announces what it was built in. macOS keeps a minimal one
88
+ * because that is where Cmd+Q lives; see `menu.ts`.
89
+ */
90
+ const template = appMenuTemplate(manifest.name, process.platform);
91
+ Menu.setApplicationMenu(template === null ? null : Menu.buildFromTemplate(template));
92
+
93
+ /*
94
+ * **Two roots, chosen by host.** `drift://app/` is the game and `drift://shell/` is the
95
+ * packager's own assets — the badge and the mark it draws. One handler with a table rather than
96
+ * two schemes, so the traversal check that guards the game's root guards the shell's by
97
+ * construction; an unknown host is refused rather than defaulted.
98
+ */
99
+ const roots: Readonly<Record<string, string>> = { app: root, shell: join(__dirname, 'shell') };
100
+
101
+ protocol.handle('drift', (request) => {
102
+ const url = new URL(request.url);
103
+ const base = roots[url.host];
104
+ if (base === undefined) return new Response('unknown host', { status: 404 });
105
+ const resolved = resolveWithinRoot(base, decodeURIComponent(url.pathname));
106
+ if (resolved === null) return new Response('forbidden', { status: 403 });
107
+ /* Streamed rather than read whole: a `.drft` world is tens of megabytes and the loader
108
+ consumes it progressively, which is the behaviour the streaming container exists for. */
109
+ return new Response(Readable.toWeb(createReadStream(resolved)) as ReadableStream, {
110
+ headers: { 'content-type': contentTypeFor(resolved), 'content-security-policy': CSP },
111
+ });
112
+ });
113
+
114
+ /*
115
+ * **Steam is opened before the window and before anything reads a save.** Whether the store
116
+ * answered decides where preferences come from, and a game that read a local file first and
117
+ * discovered a cloud second would show one set of settings and then replace them.
118
+ */
119
+ const steam = openSteam(manifest.steam.appId, __dirname);
120
+
121
+ let window: BrowserWindow | null = null;
122
+ const quit = installIpc(readStoreSnapshot(steam), () => window, steam);
123
+
124
+ window = new BrowserWindow({
125
+ width: manifest.window.width,
126
+ height: manifest.window.height,
127
+ resizable: manifest.window.resizable,
128
+ fullscreen: manifest.window.mode === 'fullscreen',
129
+ frame: manifest.window.mode !== 'borderless',
130
+ show: false,
131
+ backgroundColor: '#000000',
132
+ webPreferences: {
133
+ preload: join(__dirname, 'preload.cjs'),
134
+ contextIsolation: true,
135
+ nodeIntegration: false,
136
+ sandbox: true,
137
+ /*
138
+ * **Developer tools exist in a development run and do not exist in a shipped one.** Not
139
+ * hidden behind a shortcut nobody documents: `devTools: false` means the inspector cannot
140
+ * be opened at all, by a menu, an accelerator, or `webContents.openDevTools`. A player
141
+ * who opens one is a player looking at the inside of a game that was not built to be
142
+ * looked at, and a support request about it costs more than the feature is worth.
143
+ */
144
+ devTools: options.development,
145
+ },
146
+ });
147
+
148
+ /*
149
+ * **The badge holds the screen while the game builds its first frame.**
150
+ *
151
+ * Without it the sequence a player sees is: an icon bounces, a black window appears, and
152
+ * several seconds later a game arrives in it — which is indistinguishable from a hang. With it
153
+ * the window stays hidden until it has something to show.
154
+ *
155
+ * The decision itself is in `splashTiming.ts` and is pure, so the three rules that matter — has
156
+ * it painted, has the minimum passed, has the cap been reached — are tested without a window.
157
+ * Polled rather than driven by a timer per condition, because two independent deadlines and an
158
+ * event racing each other is how a splash ends up either flickering or permanent.
159
+ */
160
+ const splash = manifest.splash.show ? createSplash() : null;
161
+ if (splash === null) {
162
+ /* Shown on first paint rather than immediately: a window that appears white and then fills is
163
+ the single most common way a packaged web application looks unfinished. */
164
+ window.once('ready-to-show', () => window?.show());
165
+ } else {
166
+ const startedAt = Date.now();
167
+ let painted = false;
168
+ let badgeShownAt: number | null = null;
169
+ window.once('ready-to-show', () => {
170
+ painted = true;
171
+ });
172
+ /*
173
+ * **The badge's own arrival, which is a second unbounded wait and not the same event.** It has
174
+ * a window to create, a protocol request to answer and an image to paint, and the minimum is
175
+ * the time it is meant to be readable for — so it is counted from here.
176
+ */
177
+ splash.once('show', () => {
178
+ badgeShownAt ??= Date.now();
179
+ });
180
+ /*
181
+ * And a badge that cannot load is not worth holding a game for: counting its minimum from
182
+ * process start is exactly what this did before, which is the right behaviour once there is
183
+ * nothing to wait for.
184
+ */
185
+ splash.webContents.once('did-fail-load', () => {
186
+ badgeShownAt ??= startedAt;
187
+ });
188
+ const poll = setInterval(() => {
189
+ const now = Date.now();
190
+ const sinceBadge = badgeShownAt === null ? null : now - badgeShownAt;
191
+ if (splashDecision(now - startedAt, sinceBadge, manifest.splash.minMs, painted) === 'hold')
192
+ return;
193
+ clearInterval(poll);
194
+ window?.show();
195
+ if (!splash.isDestroyed()) splash.close();
196
+ }, SPLASH_POLL_MS);
197
+ /* A window that is closed before the swap leaves an interval and an always-on-top badge
198
+ behind it, which outlives the application it was branding. */
199
+ window.once('closed', () => {
200
+ clearInterval(poll);
201
+ if (!splash.isDestroyed()) splash.close();
202
+ });
203
+ }
204
+
205
+ /*
206
+ * **The renderer's warnings reach a terminal, because a packaged game has no developer tools.**
207
+ * A player cannot open a console and a bug report cannot quote one, so the two levels worth
208
+ * reading are forwarded to the process's own output where a launcher, a log file or a support
209
+ * request can carry them. Info and debug are not: a game that logs per frame would make its own
210
+ * log useless.
211
+ */
212
+ /*
213
+ * **A link to the outside opens in the person's own browser, and never in a window of this
214
+ * application.** Both routes have to be closed or the other one is the hole: `window.open` and
215
+ * `target="_blank"` come through the open handler, and an ordinary click on an absolute link
216
+ * comes through `will-navigate`. What is refused rather than opened is in `links.ts`.
217
+ */
218
+ const openOutside = (url: string, route: 'window.open' | 'navigation'): void => {
219
+ const target = externalTarget(url);
220
+ /* Named, with the route it took, in a development run: a game that opens a browser on its own
221
+ at startup is doing something its author will want to see rather than guess at, and which
222
+ of the two routes it took is most of the answer. */
223
+ if (options.development) {
224
+ console.log(`[drift-package] opening externally via ${route}: ${url}`);
225
+ }
226
+ if (target !== null) void shell.openExternal(target);
227
+ };
228
+
229
+ window.webContents.setWindowOpenHandler(({ url }) => {
230
+ openOutside(url, 'window.open');
231
+ return { action: 'deny' };
232
+ });
233
+
234
+ window.webContents.on('will-navigate', (event, url) => {
235
+ /* The game navigating within itself is ordinary — a single-page game never does, and one
236
+ that does must not be interrupted. Anything leaving `drift://` is a link to the outside. */
237
+ if (url.startsWith('drift://')) return;
238
+ event.preventDefault();
239
+ openOutside(url, 'navigation');
240
+ });
241
+
242
+ /*
243
+ * **The renderer's own output reaches a terminal, and how much of it depends on the build.**
244
+ *
245
+ * A player cannot open developer tools in a shipped artifact — see `devTools` above — so an
246
+ * error that has nowhere to go is an error nobody can quote in a bug report. Errors are
247
+ * forwarded in both kinds of build for exactly that reason. Warnings are development only:
248
+ * they are where a framework's advice and a game's own diagnostics live, and a shipped game
249
+ * printing them to a terminal nobody is watching is noise that hides the line that matters.
250
+ */
251
+ /*
252
+ * **Both argument shapes, because the event changed and the failure of reading the wrong one is
253
+ * silence.** Electron 35 moved the details into the first argument and made the level a string;
254
+ * before that the first argument was the bare event and the details came second, with a numeric
255
+ * level. Reading only the newer shape on an older runtime compares `undefined` against `'error'`
256
+ * for every message, forwards nothing, and looks exactly like a renderer that never logged —
257
+ * which is how this was found, and why the reading is a pure module with tests rather than a
258
+ * predicate written inline against one version's typings.
259
+ */
260
+ window.webContents.on(
261
+ 'console-message',
262
+ (first: unknown, second: unknown, third: unknown): void => {
263
+ const line = consoleLine(first, second, third);
264
+ if (line === null || !forwardsConsole(line.level, options.development)) return;
265
+ console.log(`[renderer:${line.level}] ${line.message}`);
266
+ },
267
+ );
268
+
269
+ /*
270
+ * **The browser's own key bindings, gone from a shipped build.** Chromium answers Ctrl+R, F5,
271
+ * Ctrl+P, Ctrl+F, the zoom keys and the inspector combinations itself, with no menu item left
272
+ * to remove them — so a player leaning on Ctrl+R loses the run they were in. What is blocked
273
+ * and what is left alone is in `shortcuts.ts`, with a test per rule.
274
+ *
275
+ * In a development run nothing is blocked and F12 opens the inspector, which is most of what a
276
+ * development run is for.
277
+ */
278
+ window.webContents.on('before-input-event', (event, input) => {
279
+ if (isBlockedShortcut(input, process.platform, options.development)) {
280
+ event.preventDefault();
281
+ return;
282
+ }
283
+ if (options.development && input.type === 'keyDown' && input.key === 'F12') {
284
+ window?.webContents.toggleDevTools();
285
+ }
286
+ });
287
+
288
+ /*
289
+ * Pinch-zoom and Ctrl+wheel are the other two ways a page gets scaled, and neither goes through
290
+ * the keyboard. A game draws its own interface at its own scale; a scaled one is half off the
291
+ * screen with no way back, because the gesture that undoes it is a browser gesture.
292
+ */
293
+ window.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined);
294
+ window.webContents.setZoomFactor(1);
295
+
296
+ window.on('focus', () => window?.webContents.send('drift:focus', true));
297
+ window.on('blur', () => window?.webContents.send('drift:focus', false));
298
+
299
+ window.on('close', (event) => {
300
+ const target = window;
301
+ if (target === null || quit.mayClose() || !quit.wantsNotice()) return;
302
+ /* Only a game that registered `onQuitRequest` is waited for. See `installIpc` for why the
303
+ interest flag exists rather than a grace period on every close. */
304
+ event.preventDefault();
305
+ quit.deferClose(target);
306
+ });
307
+
308
+ window.on('closed', () => {
309
+ window = null;
310
+ });
311
+
312
+ /* The manifest's own entry, not a hardcoded `index.html`: a project that builds to
313
+ `dist/game.html` is an ordinary project, and a shell that silently looked for the wrong file
314
+ would fail with a 404 in a window nobody can open developer tools on. */
315
+ await window.loadURL(`drift://app/${basename(manifest.entry)}`);
316
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `drift.package.json`, parsed once and trusted afterwards.
3
+ *
4
+ * **Every rejection names its field.** A packaging manifest is written by hand, usually once, and
5
+ * a wrong value surfaces at signing or at first launch on a machine the author does not have. The
6
+ * cost of validating here is a few dozen lines; the cost of not validating is a build.
7
+ *
8
+ * **What is defaulted and what is not** is the only interesting decision in this file. Window size
9
+ * and features default, because a short manifest is easier to read and a wrong window is visible
10
+ * immediately. The id, the entry, the targets and the backend policy do not, because each of them
11
+ * is silent when wrong: the wrong id writes saves to a directory nobody looks in.
12
+ */
13
+ export type WebGpuPolicy = 'prefer' | 'require' | 'off';
14
+ export type Target = 'win-x64' | 'mac-arm64' | 'mac-x64' | 'linux-x64' | 'android' | 'ios';
15
+
16
+ /** The two that are a phone rather than a desktop, and differ in almost every way that matters. */
17
+ export const MOBILE_TARGETS: readonly Target[] = ['android', 'ios'];
18
+
19
+ const POLICIES: readonly WebGpuPolicy[] = ['prefer', 'require', 'off'];
20
+ const TARGETS: readonly Target[] = [
21
+ 'win-x64',
22
+ 'mac-arm64',
23
+ 'mac-x64',
24
+ 'linux-x64',
25
+ 'android',
26
+ 'ios',
27
+ ];
28
+ /**
29
+ * How long the engine badge holds the screen before the game gets it.
30
+ *
31
+ * **Both ends are clamped rather than trusted.** Long enough to be read is about a second and a
32
+ * half; past six seconds it is a wait somebody resents, and a splash that outlives a failed boot
33
+ * hides the failure behind a logo. Zero is allowed and means the badge shows only for as long as
34
+ * the game takes to paint, which on a fast machine is a flash.
35
+ */
36
+ const SPLASH_DEFAULT_MS = 1400;
37
+ const SPLASH_MAX_MS = 6000;
38
+
39
+ /** Reverse-DNS, which is what all three platforms key an application on. */
40
+ const ID = /^[a-z0-9]+(\.[a-z0-9][a-z0-9-]*)+$/;
41
+
42
+ export interface PackageManifest {
43
+ readonly id: string;
44
+ readonly name: string;
45
+ readonly entry: string;
46
+ readonly window: {
47
+ readonly width: number;
48
+ readonly height: number;
49
+ readonly mode: 'windowed' | 'borderless' | 'fullscreen';
50
+ readonly resizable: boolean;
51
+ };
52
+ readonly backend: { readonly webgpu: WebGpuPolicy; readonly allowSoftwareRenderer: boolean };
53
+ readonly features: { readonly clipExport: boolean; readonly gamepad: boolean };
54
+ readonly targets: readonly Target[];
55
+ readonly steam: { readonly appId: number | null };
56
+ readonly splash: { readonly show: boolean; readonly minMs: number };
57
+ /**
58
+ * Who an installer says this is from.
59
+ *
60
+ * Defaulted from the id's own organisation label rather than demanded, because it is right
61
+ * there and a manifest a person writes by hand should be short — `dev.example.title` gives
62
+ * `example`. It is worth setting properly before a Windows release: it is the name in the
63
+ * installer's header and in Add or Remove Programs.
64
+ */
65
+ readonly publisher: string;
66
+ /**
67
+ * A 1024x1024 PNG for the application, relative to the project. `null` uses the engine's mark.
68
+ *
69
+ * **A default that is a real icon rather than none**, because electron-builder's fallback is
70
+ * Electron's own logo — which ships a game branded as the framework that built it. The engine's
71
+ * mark is at least honest about what made it, and a game replaces it in one line.
72
+ */
73
+ readonly icon: string | null;
74
+ }
75
+
76
+ function requireString(source: Record<string, unknown>, field: string): string {
77
+ const value = source[field];
78
+ if (typeof value !== 'string' || value.length === 0) {
79
+ throw new Error(`drift.package.json: "${field}" must be a non-empty string`);
80
+ }
81
+ return value;
82
+ }
83
+
84
+ export function parseManifest(raw: unknown): PackageManifest {
85
+ if (typeof raw !== 'object' || raw === null) throw new Error('drift.package.json: not an object');
86
+ const source = raw as Record<string, unknown>;
87
+
88
+ const id = requireString(source, 'id');
89
+ if (!ID.test(id)) {
90
+ throw new Error(
91
+ `drift.package.json: "id" must be reverse-DNS, like dev.example.title — got "${id}"`,
92
+ );
93
+ }
94
+
95
+ const backendRaw = (source.backend ?? {}) as Record<string, unknown>;
96
+ const webgpu = backendRaw.webgpu;
97
+ if (typeof webgpu !== 'string' || !POLICIES.includes(webgpu as WebGpuPolicy)) {
98
+ throw new Error(
99
+ `drift.package.json: "backend.webgpu" must be one of ${POLICIES.join(', ')} — got "${String(webgpu)}"`,
100
+ );
101
+ }
102
+
103
+ const targetsRaw = source.targets;
104
+ if (!Array.isArray(targetsRaw) || targetsRaw.length === 0) {
105
+ throw new Error('drift.package.json: "targets" must be a non-empty array');
106
+ }
107
+ for (const target of targetsRaw) {
108
+ if (typeof target !== 'string' || !TARGETS.includes(target as Target)) {
109
+ throw new Error(
110
+ `drift.package.json: unknown target "${String(target)}" — expected one of ${TARGETS.join(', ')}`,
111
+ );
112
+ }
113
+ }
114
+
115
+ const windowRaw = (source.window ?? {}) as Record<string, unknown>;
116
+ const featuresRaw = (source.features ?? {}) as Record<string, unknown>;
117
+ const steamRaw = (source.steam ?? {}) as Record<string, unknown>;
118
+ const splashRaw = (source.splash ?? {}) as Record<string, unknown>;
119
+ const appId = steamRaw.appId;
120
+
121
+ return {
122
+ id,
123
+ name: requireString(source, 'name'),
124
+ entry: requireString(source, 'entry'),
125
+ window: {
126
+ width: typeof windowRaw.width === 'number' ? windowRaw.width : 1280,
127
+ height: typeof windowRaw.height === 'number' ? windowRaw.height : 720,
128
+ mode:
129
+ windowRaw.mode === 'fullscreen' || windowRaw.mode === 'borderless'
130
+ ? windowRaw.mode
131
+ : 'windowed',
132
+ resizable: windowRaw.resizable !== false,
133
+ },
134
+ backend: {
135
+ webgpu: webgpu as WebGpuPolicy,
136
+ allowSoftwareRenderer: backendRaw.allowSoftwareRenderer === true,
137
+ },
138
+ features: {
139
+ clipExport: featuresRaw.clipExport === true,
140
+ gamepad: featuresRaw.gamepad !== false,
141
+ },
142
+ targets: targetsRaw as readonly Target[],
143
+ steam: { appId: typeof appId === 'number' ? appId : null },
144
+ publisher:
145
+ typeof source.publisher === 'string' && source.publisher.length > 0
146
+ ? source.publisher
147
+ : (id.split('.')[1] ?? id),
148
+ icon: typeof source.icon === 'string' && source.icon.length > 0 ? source.icon : null,
149
+ splash: {
150
+ show: splashRaw.show !== false,
151
+ minMs:
152
+ typeof splashRaw.minMs === 'number'
153
+ ? Math.min(SPLASH_MAX_MS, Math.max(0, splashRaw.minMs))
154
+ : SPLASH_DEFAULT_MS,
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Whether a page's own references land on files that are actually there.
3
+ *
4
+ * **This exists because of a failure with no error in it.** A game built by Vite references
5
+ * `/assets/index-abc.js` — absolute, from the site root. Host that game from a subdirectory and
6
+ * every such reference resolves to nothing: the HTML loads and the CSS and the script do not, so
7
+ * the page appears as unstyled text with no console open to say why. Measured on a real APK, which
8
+ * installed, launched, and showed a title in Times New Roman.
9
+ *
10
+ * Checked at build time rather than trusted, because the alternative is finding out from a phone.
11
+ */
12
+ const REFERENCE = /(?:src|href)\s*=\s*"([^"]+)"/g;
13
+
14
+ export function missingReferences(html: string, present: ReadonlySet<string>): string[] {
15
+ const missing: string[] = [];
16
+ for (const [, raw] of html.matchAll(REFERENCE)) {
17
+ if (raw === undefined) continue;
18
+ /* Anything with a scheme, a protocol-relative host, or a fragment belongs to somebody else. */
19
+ if (!raw.startsWith('/') || raw.startsWith('//')) continue;
20
+ const path = raw.slice(1).split(/[?#]/)[0] ?? '';
21
+ if (path.length === 0) continue;
22
+ if (!present.has(path)) missing.push(raw);
23
+ }
24
+ return missing;
25
+ }