@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,292 @@
1
+ import type { DisplayInfo, WindowMode } from '@driftengine/core';
2
+
3
+ import type { DriftHostBridge } from '../preload/bridge.ts';
4
+ import { showDeviceReport } from './report.ts';
5
+
6
+ /**
7
+ * One bridge shape, assembled from what a mobile WebView is able to hand over.
8
+ *
9
+ * **Android's `@JavascriptInterface` can pass strings, numbers and booleans and nothing else.** It
10
+ * cannot return a promise, an object, an array or a callback. So the native surface is flat,
11
+ * synchronous and string-only — `snapshot()` returns JSON, `displays()` returns JSON — and this
12
+ * file turns it into the same `DriftHostBridge` the desktop shell exposes. The engine then sees
13
+ * **one** seam with three shells behind it rather than three seams, which is the whole design.
14
+ *
15
+ * **Naming them differently is deliberate.** The native object is `__driftHostNative`; the one a
16
+ * game reaches is `__driftHost`. A single half-typed object would hide the adaptation; two names
17
+ * make it a file somebody can read.
18
+ *
19
+ * **Events come the other way through a global**, because a WebView cannot call into the page: the
20
+ * host evaluates `globalThis.__driftHostEvents.focus(false)` when the activity pauses. Installed
21
+ * here rather than in the host, so the subscription bookkeeping is written once in TypeScript.
22
+ */
23
+ export interface NativeShimSurface {
24
+ snapshot(): string;
25
+ write(key: string, value: string): void;
26
+ remove(key: string): void;
27
+ isFullscreen(): boolean;
28
+ setFullscreen(on: boolean): void;
29
+ refreshHz(): number;
30
+ mode(): string;
31
+ setMode(mode: string): boolean;
32
+ size(): string;
33
+ setSize(width: number, height: number): boolean;
34
+ canSetSize(): boolean;
35
+ displays(): string;
36
+ /**
37
+ * Whether this platform lets an application end itself.
38
+ *
39
+ * Android does — `finish()` closes the activity. iOS does not, and Apple's guidance is explicit
40
+ * that an application should not: a game there answers false and draws no exit button.
41
+ */
42
+ canQuit(): boolean;
43
+ /**
44
+ * Writing a file, in three calls, because a bridge that can only carry strings cannot carry a
45
+ * twenty megabyte clip in one.
46
+ *
47
+ * `saveBegin` returns false where the platform refused to start — no space, no permission — and
48
+ * `saveEnd` returns where the file landed, in words a person can be told.
49
+ */
50
+ saveBegin(name: string, mime: string): boolean;
51
+ saveChunk(base64: string): boolean;
52
+ saveEnd(): string;
53
+ saveAbort(): void;
54
+ requestQuit(): void;
55
+ }
56
+
57
+ /** What the host pushes in. Installed on `globalThis` so a WebView can evaluate against it. */
58
+ interface HostEvents {
59
+ focus(on: boolean): void;
60
+ quitRequest(): void;
61
+ /**
62
+ * The back gesture. **True means the game dealt with it and the host must do nothing.**
63
+ *
64
+ * Answered in the order a game would want it answered: a registered quit handler first, because
65
+ * a game that asked to be told about leaving has a confirmation to show; then an Escape key,
66
+ * because a game with no quit handler almost certainly has a pause menu bound to one, and
67
+ * `preventDefault` is the page saying that keystroke was its own. False means neither, and the
68
+ * host is then free to close — after asking twice, which is its own business.
69
+ */
70
+ back(): boolean;
71
+ /**
72
+ * A download the host intercepted, completed here.
73
+ *
74
+ * The host cannot read a `blob:` URL — it belongs to the page — so it hands it back and the page
75
+ * fetches its own blob, then sends the bytes over the same chunked road as `saveFile`.
76
+ */
77
+ saveBlob(url: string, name: string): Promise<boolean>;
78
+ }
79
+
80
+ /**
81
+ * Half a megabyte of bytes a call, which is about seven hundred kilobytes of base64.
82
+ *
83
+ * Large enough that a twenty megabyte clip is forty calls rather than four thousand, and small
84
+ * enough that neither the string nor the argument list is anywhere near a limit.
85
+ */
86
+ export const SAVE_CHUNK_BYTES = 512 * 1024;
87
+
88
+ /**
89
+ * Bytes as base64, built in pieces.
90
+ *
91
+ * `String.fromCharCode(...bytes)` on a whole file overflows the argument list and throws — at a
92
+ * size that depends on the engine, so it works in a test and fails on a real clip.
93
+ */
94
+ export function base64Of(bytes: Uint8Array): string {
95
+ let binary = '';
96
+ const step = 8192;
97
+ for (let at = 0; at < bytes.length; at += step) {
98
+ binary += String.fromCharCode(...bytes.subarray(at, at + step));
99
+ }
100
+ return btoa(binary);
101
+ }
102
+
103
+ /**
104
+ * What the platform files a name under.
105
+ *
106
+ * Android sorts a download by its declared type, so a clip saved as `application/octet-stream`
107
+ * lands somewhere a gallery will not look for it. Wrong is worse than unknown here, so anything
108
+ * unrecognised stays octet-stream rather than being guessed at.
109
+ */
110
+ export function mimeForName(name: string): string {
111
+ const dot = name.lastIndexOf('.');
112
+ switch (dot === -1 ? '' : name.slice(dot + 1).toLowerCase()) {
113
+ case 'webm':
114
+ return 'video/webm';
115
+ case 'mp4':
116
+ return 'video/mp4';
117
+ case 'png':
118
+ return 'image/png';
119
+ case 'jpg':
120
+ case 'jpeg':
121
+ return 'image/jpeg';
122
+ case 'gif':
123
+ return 'image/gif';
124
+ case 'json':
125
+ return 'application/json';
126
+ case 'txt':
127
+ return 'text/plain';
128
+ default:
129
+ return 'application/octet-stream';
130
+ }
131
+ }
132
+
133
+ function parseJson<T>(raw: string, fallback: T): T {
134
+ try {
135
+ const parsed: unknown = JSON.parse(raw);
136
+ return (parsed ?? fallback) as T;
137
+ } catch {
138
+ /* A malformed payload is a host bug, and a game that cannot start because its preferences
139
+ did not parse is a worse one. Empty is the same degradation `BrowserStore` makes. */
140
+ return fallback;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Synthesise the key a pause menu is bound to, and report whether the page took it.
146
+ *
147
+ * **A keydown and a keyup**, because a game that tracks held keys and only ever receives the down
148
+ * ends up with Escape held forever — which is a menu that will not close, from a fix meant to stop
149
+ * one closing too easily.
150
+ *
151
+ * Dispatched at the focused element so it bubbles the way a real keystroke does; a game listening
152
+ * on `window`, on `document` or on its canvas all receive it.
153
+ */
154
+ function pressEscape(): boolean {
155
+ const target =
156
+ (globalThis as { document?: { activeElement?: unknown; body?: unknown } }).document ?? null;
157
+ if (target === null) return false;
158
+ const node = (target.activeElement ?? target.body) as
159
+ { dispatchEvent(event: unknown): boolean } | undefined;
160
+ if (node === undefined || node === null || typeof node.dispatchEvent !== 'function') return false;
161
+
162
+ const make = (type: string): unknown =>
163
+ new KeyboardEvent(type, {
164
+ key: 'Escape',
165
+ code: 'Escape',
166
+ bubbles: true,
167
+ cancelable: true,
168
+ });
169
+ const consumed = !node.dispatchEvent(make('keydown'));
170
+ node.dispatchEvent(make('keyup'));
171
+ return consumed;
172
+ }
173
+
174
+ export function installShim(native: NativeShimSurface): DriftHostBridge {
175
+ const focusHandlers = new Set<(focused: boolean) => void>();
176
+ const quitHandlers = new Set<() => void>();
177
+
178
+ const events: HostEvents = {
179
+ focus: (on) => {
180
+ for (const handler of focusHandlers) handler(on);
181
+ },
182
+ quitRequest: () => {
183
+ for (const handler of quitHandlers) handler();
184
+ },
185
+ saveBlob: async (url, name) => {
186
+ let bytes: Uint8Array;
187
+ try {
188
+ bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
189
+ } catch {
190
+ /* A revoked or cross-origin URL. Nothing is opened, so no empty file is left behind. */
191
+ return false;
192
+ }
193
+ return saveFile(name, bytes);
194
+ },
195
+ back: () => {
196
+ if (quitHandlers.size > 0) {
197
+ for (const handler of quitHandlers) handler();
198
+ return true;
199
+ }
200
+ return pressEscape();
201
+ },
202
+ };
203
+ (globalThis as { __driftHostEvents?: HostEvents }).__driftHostEvents = events;
204
+
205
+ const saveFile = async (name: string, bytes: Uint8Array): Promise<boolean> => {
206
+ if (!native.saveBegin(name, mimeForName(name))) return false;
207
+ try {
208
+ for (let at = 0; at < bytes.length; at += SAVE_CHUNK_BYTES) {
209
+ if (!native.saveChunk(base64Of(bytes.subarray(at, at + SAVE_CHUNK_BYTES)))) {
210
+ native.saveAbort();
211
+ return false;
212
+ }
213
+ }
214
+ native.saveEnd();
215
+ return true;
216
+ } catch {
217
+ /* Half a clip on disk looks exactly like a whole one to a file manager, and the person who
218
+ finds it there has no way to know. Abandoned rather than finished. */
219
+ native.saveAbort();
220
+ return false;
221
+ }
222
+ };
223
+
224
+ const noPicker = (): never => {
225
+ /*
226
+ * A game does not open files; an editor does, and an editor is not a phone application. The
227
+ * refusal is loud rather than a null, for the same reason `BrowserFileDialogs` throws where a
228
+ * browser has no picker: a cancel and an absent capability are different answers.
229
+ */
230
+ throw new Error('[driftengine] no file picker on this platform; a desktop shell has one');
231
+ };
232
+
233
+ return {
234
+ storeSnapshot: parseJson<Record<string, string>>(native.snapshot(), {}),
235
+ writeKey: (key, value) => native.write(key, value),
236
+ removeKey: (key) => native.remove(key),
237
+ isFullscreen: () => native.isFullscreen(),
238
+ setFullscreen: async (on) => {
239
+ native.setFullscreen(on);
240
+ },
241
+ refreshHz: () => {
242
+ const hz = native.refreshHz();
243
+ /* Zero is what a platform reports when it will not say, and a plausible 60 is exactly what
244
+ the engine's rule forbids inventing. */
245
+ return typeof hz === 'number' && hz > 0 ? hz : null;
246
+ },
247
+ mode: () => (native.mode() === 'windowed' ? 'windowed' : 'fullscreen') as WindowMode,
248
+ setMode: async (mode) => native.setMode(mode),
249
+ size: () =>
250
+ parseJson<{ width: number; height: number }>(native.size(), { width: 0, height: 0 }),
251
+ setSize: async (width, height) => native.setSize(width, height),
252
+ /* Asked of the host rather than answered here, so the refusal and the question stay one
253
+ decision: `HostBridge.java` answers both beside each other. */
254
+ canSetSize: () => native.canSetSize(),
255
+ displays: () => parseJson<DisplayInfo[]>(native.displays(), []),
256
+ onFocusChange: (handler) => {
257
+ focusHandlers.add(handler);
258
+ return () => focusHandlers.delete(handler);
259
+ },
260
+ onQuitRequest: (handler) => {
261
+ quitHandlers.add(handler);
262
+ return () => quitHandlers.delete(handler);
263
+ },
264
+ canQuit: native.canQuit(),
265
+ requestQuit: () => native.requestQuit(),
266
+ /* No store on a phone: neither platform's own is integrated, and a game reads `available`
267
+ and never calls the rest. */
268
+ platform: {
269
+ available: false,
270
+ unlockAchievement: () => undefined,
271
+ setRichPresence: () => undefined,
272
+ },
273
+ openFile: async () => noPicker(),
274
+ saveFile,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Wire the native surface into the global the engine's `hostBridge()` looks for.
280
+ *
281
+ * Called by the injected shim script, which the host evaluates before the game's own scripts run —
282
+ * `WebViewCompat.addDocumentStartJavaScript` on Android, a user script on iOS. That timing is what
283
+ * makes this the mobile equivalent of the desktop preload rather than something a game has to
284
+ * remember to import.
285
+ */
286
+ export function attachShim(key: string): void {
287
+ const native = (globalThis as Record<string, unknown>).__driftHostNative as
288
+ NativeShimSurface | undefined;
289
+ if (native === undefined) return;
290
+ (globalThis as Record<string, unknown>)[key] = installShim(native);
291
+ showDeviceReport();
292
+ }
@@ -0,0 +1,148 @@
1
+ import type { DisplayInfo, WindowMode } from '@driftengine/core';
2
+
3
+ import type { DriftHostBridge } from '../preload/bridge.ts';
4
+ import { SAVE_CHUNK_BYTES, base64Of, mimeForName } from './bridgeShim.ts';
5
+ import { showDeviceReport } from './report.ts';
6
+
7
+ /**
8
+ * The same bridge again, from a platform with no synchronous return.
9
+ *
10
+ * **This is why iOS needs its own adapter rather than Android's.** `@JavascriptInterface` on
11
+ * Android returns a value; `WKScriptMessageHandler` does not — it is one-way and asynchronous, and
12
+ * there is no synchronous path from native code into JavaScript on this platform at all. The
13
+ * engine's `KeyValueStore` is synchronous by contract, because preferences are read during boot
14
+ * and an await there is a frame drawn with the wrong settings and corrected afterwards, which is a
15
+ * visible flicker on every load.
16
+ *
17
+ * So the split is: **everything that must be read is injected**, as a literal object written into
18
+ * the page before its first line of script runs, and **everything that changes something is a
19
+ * message**. The host keeps that object current — a rotation replaces its `size` — which is why
20
+ * this reads through it on every call rather than copying the values once.
21
+ *
22
+ * The mutations that report a result ride on `WKScriptMessageHandlerWithReply`, which does hand a
23
+ * promise back, and which is why the deployment target is iOS 15 rather than something older.
24
+ */
25
+ export interface IosState {
26
+ snapshot: Record<string, string>;
27
+ refreshHz: number | null;
28
+ mode: WindowMode;
29
+ size: { width: number; height: number };
30
+ displays: DisplayInfo[];
31
+ fullscreen: boolean;
32
+ canQuit: boolean;
33
+ }
34
+
35
+ /** One message to the host. Resolves with whatever it replied, or rejects if it did not. */
36
+ export type IosCall = (name: string, payload: unknown) => Promise<unknown>;
37
+
38
+ export function installIosShim(state: IosState, call: IosCall): DriftHostBridge {
39
+ const focusHandlers = new Set<(focused: boolean) => void>();
40
+ const quitHandlers = new Set<() => void>();
41
+
42
+ const send = (name: string, payload: unknown): void => {
43
+ void call(name, payload).catch(() => undefined);
44
+ };
45
+
46
+ const ask = async (name: string, payload: unknown): Promise<boolean> => {
47
+ try {
48
+ return (await call(name, payload)) === true;
49
+ } catch {
50
+ /* The host went away, or the handler is not registered. A game must not see an exception
51
+ from a settings screen. */
52
+ return false;
53
+ }
54
+ };
55
+
56
+ (globalThis as { __driftHostEvents?: unknown }).__driftHostEvents = {
57
+ focus: (on: boolean) => {
58
+ for (const handler of focusHandlers) handler(on);
59
+ },
60
+ quitRequest: () => {
61
+ for (const handler of quitHandlers) handler();
62
+ },
63
+ };
64
+
65
+ const noPicker = (): never => {
66
+ throw new Error('[driftengine] no file picker on this platform; a desktop shell has one');
67
+ };
68
+
69
+ return {
70
+ storeSnapshot: state.snapshot,
71
+ writeKey: (key, value) => send('write', { key, value }),
72
+ removeKey: (key) => send('remove', { key }),
73
+ isFullscreen: () => state.fullscreen,
74
+ setFullscreen: async (on) => {
75
+ /* The status bar is the only thing there is to hide, and hiding it is not refused — so this
76
+ is a message rather than a question. */
77
+ send('fullscreen', { on });
78
+ },
79
+ refreshHz: () => state.refreshHz,
80
+ mode: () => state.mode,
81
+ setMode: async (mode) => ask('mode', { mode }),
82
+ size: () => state.size,
83
+ /* Constant rather than a round trip: there is no window on this platform to give a size to. */
84
+ setSize: async () => false,
85
+ /* The question, beside the refusal it must agree with. Both are constants here for the same
86
+ reason, and the day iOS gains a resizable window they move together. */
87
+ canSetSize: () => false,
88
+ displays: () => state.displays,
89
+ onFocusChange: (handler) => {
90
+ focusHandlers.add(handler);
91
+ return () => focusHandlers.delete(handler);
92
+ },
93
+ onQuitRequest: (handler) => {
94
+ quitHandlers.add(handler);
95
+ return () => quitHandlers.delete(handler);
96
+ },
97
+ /** False, always: an iOS application does not exit itself, and Apple asks that it not try. */
98
+ canQuit: false,
99
+ requestQuit: () => {
100
+ console.warn('[driftengine] requestQuit: an iOS application does not exit itself');
101
+ },
102
+ /* No store on a phone: neither platform's own is integrated, and a game reads `available`
103
+ and never calls the rest. */
104
+ platform: {
105
+ available: false,
106
+ unlockAchievement: () => undefined,
107
+ setRichPresence: () => undefined,
108
+ },
109
+ openFile: async () => noPicker(),
110
+ /**
111
+ * Saving goes to the share sheet, which is where a file goes on this platform.
112
+ *
113
+ * The bytes travel base64 in one message rather than in chunks: a reply-capable handler takes
114
+ * a string of whatever size, and iOS has no equivalent of Android's per-call binder limit. The
115
+ * chunk size is still respected for the encode itself, because building one enormous string
116
+ * from a twenty megabyte clip is what overflows an argument list.
117
+ */
118
+ saveFile: async (name, bytes) => {
119
+ const pieces: string[] = [];
120
+ for (let at = 0; at < bytes.length; at += SAVE_CHUNK_BYTES) {
121
+ pieces.push(base64Of(bytes.subarray(at, at + SAVE_CHUNK_BYTES)));
122
+ }
123
+ return ask('save', { name, mime: mimeForName(name), data: pieces });
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Install the bridge from what the host injected.
130
+ *
131
+ * Called by the bundled shim script, which the host evaluates at document start — the same timing
132
+ * as the desktop preload and Android's document-start script.
133
+ */
134
+ export function attachIosShim(key: string): void {
135
+ const holder = globalThis as {
136
+ __driftHostState?: IosState;
137
+ webkit?: {
138
+ messageHandlers?: Record<string, { postMessage(value: unknown): Promise<unknown> }>;
139
+ };
140
+ };
141
+ const state = holder.__driftHostState;
142
+ const handler = holder.webkit?.messageHandlers?.driftHost;
143
+ if (state === undefined || handler === undefined) return;
144
+ (globalThis as Record<string, unknown>)[key] = installIosShim(state, async (name, payload) =>
145
+ handler.postMessage({ name, payload }),
146
+ );
147
+ showDeviceReport();
148
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * What a device says about itself, collected rather than assumed.
3
+ *
4
+ * **A pinned desktop Chromium cannot see the failures this tier exists to catch.** WebKit enforces
5
+ * a sixteen-byte stride for arrays in the uniform address space where Dawn does not, so the shaders
6
+ * that draw everything compiled on every desktop browser and on no iPhone — and nothing in the
7
+ * pipeline could have known. A mobile build that ships without collecting what devices report
8
+ * throws away the only reason mobile is in this delivery at all.
9
+ *
10
+ * **Every value is what the device said**, and a value that could not be read is the empty string.
11
+ * An invented renderer string is what the capability clamp then matches against, which is how a
12
+ * phone ends up in the wrong tier — the same rule `AGENTS.md` sets for a backend that cannot answer.
13
+ *
14
+ * Reached in a packaged build with `?report=1` on the entry URL. Not a hidden gesture: a tester who
15
+ * has to be told a secret is a tester who reports the wrong thing.
16
+ */
17
+ export function deviceReport(): Record<string, string> {
18
+ const agent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
19
+ return {
20
+ userAgent: agent,
21
+ platform: platformOf(agent),
22
+ engine: engineOf(agent),
23
+ webgpu:
24
+ (globalThis as { navigator?: { gpu?: unknown } }).navigator?.gpu === undefined
25
+ ? 'absent'
26
+ : 'present',
27
+ webglRenderer: unmaskedRenderer(),
28
+ secureContext: String(Boolean((globalThis as { isSecureContext?: boolean }).isSecureContext)),
29
+ host: (globalThis as Record<string, unknown>).__driftHost === undefined ? 'absent' : 'present',
30
+ pixelRatio: String((globalThis as { devicePixelRatio?: number }).devicePixelRatio ?? ''),
31
+ };
32
+ }
33
+
34
+ /**
35
+ * The part number the driver reports, which is the only string worth having.
36
+ *
37
+ * `WEBGL_debug_renderer_info` is the extension that gives it. Where a browser withholds it — some
38
+ * do, for fingerprinting — the answer is empty rather than the vendor-neutral placeholder, because
39
+ * an empty string is visibly nothing and `WebKit WebGL` is visibly a device.
40
+ */
41
+ function unmaskedRenderer(): string {
42
+ try {
43
+ const canvas = document.createElement('canvas');
44
+ const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
45
+ if (gl === null) return '';
46
+ const info = (gl as WebGLRenderingContext).getExtension('WEBGL_debug_renderer_info') as {
47
+ UNMASKED_RENDERER_WEBGL: number;
48
+ } | null;
49
+ if (info === null) return '';
50
+ const value: unknown = (gl as WebGLRenderingContext).getParameter(info.UNMASKED_RENDERER_WEBGL);
51
+ return typeof value === 'string' ? value : '';
52
+ } catch {
53
+ return '';
54
+ }
55
+ }
56
+
57
+ /** `iOS 18.2` or `Android 14`, from the agent string, or empty where it says neither. */
58
+ function platformOf(agent: string): string {
59
+ const ios = agent.match(/(?:iPhone|iPad|CPU) OS (\d+)[._](\d+)/);
60
+ if (ios !== null) return `iOS ${ios[1]}.${ios[2]}`;
61
+ const android = agent.match(/Android (\d+(?:\.\d+)?)/);
62
+ if (android !== null) return `Android ${android[1]}`;
63
+ return '';
64
+ }
65
+
66
+ /**
67
+ * Which browser engine build this is.
68
+ *
69
+ * The number that matters when a shader fails to compile: an Android WebView is updated through
70
+ * the store independently of the OS, so two phones on the same Android version can disagree.
71
+ */
72
+ function engineOf(agent: string): string {
73
+ const chrome = agent.match(/Chrome\/([\d.]+)/);
74
+ if (chrome !== null) return `Chrome ${chrome[1]}`;
75
+ const safari = agent.match(/Version\/([\d.]+).*Safari/);
76
+ if (safari !== null) return `Safari ${safari[1]}`;
77
+ return '';
78
+ }
79
+
80
+ /** The query that turns the table on. Documented rather than secret, for the reason above. */
81
+ const REPORT_QUERY = 'report=1';
82
+
83
+ /**
84
+ * Draw the report over whatever is on screen, if this build was asked for it.
85
+ *
86
+ * **Over the game rather than instead of it**, so a tester can see that the game is running behind
87
+ * the numbers they are reading — a report on a blank page cannot distinguish a device that failed
88
+ * to draw from one that was never asked to.
89
+ *
90
+ * Does nothing at all unless the entry URL carries `?report=1`, which is the whole of the switch.
91
+ */
92
+ export function showDeviceReport(): void {
93
+ try {
94
+ if (!globalThis.location?.search?.includes(REPORT_QUERY)) return;
95
+ } catch {
96
+ return;
97
+ }
98
+
99
+ const draw = (): void => {
100
+ try {
101
+ const panel = document.createElement('pre');
102
+ panel.style.cssText =
103
+ 'position:fixed;inset:auto 8px 8px 8px;z-index:2147483647;margin:0;padding:10px;' +
104
+ 'background:rgba(11,11,13,.92);color:#ececf0;font:12px/1.5 ui-monospace,monospace;' +
105
+ 'border:1px solid #26262d;border-radius:6px;white-space:pre-wrap;max-height:60vh;' +
106
+ 'overflow:auto;pointer-events:none';
107
+ panel.textContent = Object.entries(deviceReport())
108
+ .map(([key, value]) => `${key.padEnd(14)} ${value === '' ? '—' : value}`)
109
+ .join('\n');
110
+ document.body?.appendChild(panel);
111
+ } catch {
112
+ /* No DOM yet, or a page that replaced its own body. A diagnostic is never worth a throw. */
113
+ }
114
+ };
115
+
116
+ if (document.readyState === 'loading') {
117
+ document.addEventListener('DOMContentLoaded', draw, { once: true });
118
+ } else {
119
+ draw();
120
+ }
121
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The integer Android orders upgrades by, derived from the version name.
3
+ *
4
+ * **Two digits per part**, so `1.4.2` is `10402`. That holds until a part reaches a hundred, which
5
+ * is stated here rather than discovered on the release that stops installing — at which point the
6
+ * scheme has to widen and every prior code has to stay below the new ones.
7
+ *
8
+ * **Never zero.** A project with no `package.json` has no version, `0.0.0` computes to nothing, and
9
+ * Gradle refuses the build with a message about a positive integer rather than about the missing
10
+ * file. One is the floor, and it is a real version code rather than an error.
11
+ */
12
+ export function versionCodeFor(version: string): number {
13
+ const [major = 0, minor = 0, patch = 0] = version
14
+ .split('.')
15
+ .map((part) => Number.parseInt(part, 10) || 0);
16
+ return Math.max(1, major * 10000 + minor * 100 + patch);
17
+ }
package/src/outDir.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { join, resolve } from 'node:path';
2
+
3
+ import type { Target } from './manifest.ts';
4
+ import type { ResourceOptions } from './resources.ts';
5
+
6
+ /**
7
+ * Where a build writes, which used to be `join(cwd, 'out', target)` in four places and nowhere
8
+ * else.
9
+ *
10
+ * **This is a flag and not tidiness, and the reason is a copy that refuses itself.** Node's
11
+ * `fs.cp` will not copy a thing into itself and decides that by walking the destination's
12
+ * ancestors comparing each one's `dev` and `ino` against the source's. On a filesystem that
13
+ * reports those honestly it never fires. On a shared or network folder it can: measured by a
14
+ * consumer on a Windows VM with the project on a drive mapped to a folder on a Linux machine,
15
+ * `drift.package.json` and `out/` both answered `dev=66313 ino=-112686486700016` — the same
16
+ * identity for a file and for a directory — and the first staging copy failed with
17
+ *
18
+ * cannot copy …\drift.package.json to a subdirectory of self …\out\win-x64\stage\…
19
+ *
20
+ * which is a sentence about the one thing that is not happening. A junction at `out/` closes it,
21
+ * because the walk calls `stat` and not `lstat`, and that is a filesystem trick standing in for
22
+ * this flag.
23
+ *
24
+ * **The layout under the root is unchanged**, so `--out=C:\drift-out` gives
25
+ * `C:\drift-out\win-x64\stage` and nothing that reads `out/<target>/…` has to learn a second
26
+ * shape.
27
+ *
28
+ * **A relative root resolves against the invoking directory and deliberately not against
29
+ * `--project=`.** Those two differ exactly when the flag matters — a packager run from somewhere
30
+ * other than the project it is packaging — and resolving against the project would put the output
31
+ * back inside the thing the flag exists to escape.
32
+ */
33
+ export function outFor(cwd: string, target: Target, options: ResourceOptions): string {
34
+ const root =
35
+ options.outRoot === undefined ? join(cwd, 'out') : resolve(process.cwd(), options.outRoot);
36
+ return join(root, target);
37
+ }