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