@driftengine/package 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +408 -0
- package/bin/drift-package.mjs +82 -0
- package/dist/android.d.ts +18 -0
- package/dist/android.js +207 -0
- package/dist/bootstrap.d.ts +17 -0
- package/dist/bootstrap.js +326 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +248 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +343 -0
- package/dist/flags.d.ts +57 -0
- package/dist/flags.js +122 -0
- package/dist/identity.d.ts +51 -0
- package/dist/identity.js +75 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +24 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +45 -0
- package/dist/ios.d.ts +27 -0
- package/dist/ios.js +152 -0
- package/dist/macCrossSign.d.ts +34 -0
- package/dist/macCrossSign.js +298 -0
- package/dist/main/consoleForward.d.ts +43 -0
- package/dist/main/consoleForward.js +63 -0
- package/dist/main/csp.d.ts +4 -0
- package/dist/main/csp.js +55 -0
- package/dist/main/entry.d.ts +1 -0
- package/dist/main/entry.js +56 -0
- package/dist/main/ipc.d.ts +28 -0
- package/dist/main/ipc.js +284 -0
- package/dist/main/links.d.ts +1 -0
- package/dist/main/links.js +29 -0
- package/dist/main/menu.d.ts +17 -0
- package/dist/main/menu.js +49 -0
- package/dist/main/serve.d.ts +21 -0
- package/dist/main/serve.js +69 -0
- package/dist/main/shortcuts.d.ts +28 -0
- package/dist/main/shortcuts.js +26 -0
- package/dist/main/splash.d.ts +14 -0
- package/dist/main/splash.js +41 -0
- package/dist/main/splashTiming.d.ts +36 -0
- package/dist/main/splashTiming.js +24 -0
- package/dist/main/window.d.ts +19 -0
- package/dist/main/window.js +297 -0
- package/dist/manifest.d.ts +61 -0
- package/dist/manifest.js +91 -0
- package/dist/mobile/assetCheck.d.ts +1 -0
- package/dist/mobile/assetCheck.js +28 -0
- package/dist/mobile/bridgeShim.d.ts +83 -0
- package/dist/mobile/bridgeShim.js +208 -0
- package/dist/mobile/iosShim.d.ts +42 -0
- package/dist/mobile/iosShim.js +104 -0
- package/dist/mobile/report.d.ts +27 -0
- package/dist/mobile/report.js +122 -0
- package/dist/mobile/versionCode.d.ts +12 -0
- package/dist/mobile/versionCode.js +17 -0
- package/dist/outDir.d.ts +30 -0
- package/dist/outDir.js +32 -0
- package/dist/preload/bridge.d.ts +77 -0
- package/dist/preload/bridge.js +2 -0
- package/dist/preload/entry.d.ts +1 -0
- package/dist/preload/entry.js +77 -0
- package/dist/renderer/fileStore.d.ts +23 -0
- package/dist/renderer/fileStore.js +33 -0
- package/dist/renderer/host.d.ts +45 -0
- package/dist/renderer/host.js +30 -0
- package/dist/renderer/nativeHost.d.ts +83 -0
- package/dist/renderer/nativeHost.js +125 -0
- package/dist/resources.d.ts +71 -0
- package/dist/resources.js +83 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +80 -0
- package/dist/runtimeVersion.d.ts +10 -0
- package/dist/runtimeVersion.js +18 -0
- package/dist/signing.d.ts +49 -0
- package/dist/signing.js +135 -0
- package/dist/stage.d.ts +22 -0
- package/dist/stage.js +138 -0
- package/dist/steam/cloudStore.d.ts +30 -0
- package/dist/steam/cloudStore.js +54 -0
- package/dist/steam/depot.d.ts +29 -0
- package/dist/steam/depot.js +39 -0
- package/dist/steam/main.d.ts +22 -0
- package/dist/steam/main.js +41 -0
- package/dist/templates/buildWindows.d.ts +16 -0
- package/dist/templates/buildWindows.js +161 -0
- package/dist/templates/packageWrapper.d.ts +9 -0
- package/dist/templates/packageWrapper.js +80 -0
- package/dist/toolchain.d.ts +101 -0
- package/dist/toolchain.js +186 -0
- package/package.json +73 -0
- package/src/android.ts +256 -0
- package/src/bootstrap.ts +389 -0
- package/src/build.ts +285 -0
- package/src/cli.ts +367 -0
- package/src/flags.ts +171 -0
- package/src/identity.ts +87 -0
- package/src/index.ts +31 -0
- package/src/init.ts +61 -0
- package/src/ios.ts +185 -0
- package/src/macCrossSign.ts +338 -0
- package/src/main/consoleForward.ts +93 -0
- package/src/main/csp.ts +57 -0
- package/src/main/entry.ts +63 -0
- package/src/main/ipc.ts +311 -0
- package/src/main/links.ts +29 -0
- package/src/main/menu.ts +53 -0
- package/src/main/serve.ts +70 -0
- package/src/main/shortcuts.ts +55 -0
- package/src/main/splash.ts +42 -0
- package/src/main/splashTiming.ts +51 -0
- package/src/main/window.ts +316 -0
- package/src/manifest.ts +157 -0
- package/src/mobile/assetCheck.ts +25 -0
- package/src/mobile/bridgeShim.ts +292 -0
- package/src/mobile/iosShim.ts +148 -0
- package/src/mobile/report.ts +121 -0
- package/src/mobile/versionCode.ts +17 -0
- package/src/outDir.ts +37 -0
- package/src/preload/bridge.ts +73 -0
- package/src/preload/entry.ts +91 -0
- package/src/renderer/fileStore.ts +38 -0
- package/src/renderer/host.ts +96 -0
- package/src/renderer/nativeHost.ts +146 -0
- package/src/resources.ts +150 -0
- package/src/runtime.ts +87 -0
- package/src/runtimeVersion.ts +16 -0
- package/src/signing.ts +206 -0
- package/src/stage.ts +180 -0
- package/src/steam/cloudStore.ts +73 -0
- package/src/steam/depot.ts +69 -0
- package/src/steam/main.ts +69 -0
- package/src/templates/buildWindows.ts +163 -0
- package/src/templates/packageWrapper.ts +82 -0
- package/src/toolchain.ts +210 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { DisplayInfo, WindowMode } from '@driftengine/core';
|
|
2
|
+
/**
|
|
3
|
+
* The single surface the renderer can see, and the only one it ever gets.
|
|
4
|
+
*
|
|
5
|
+
* **This file is a security boundary, and it is deliberately small enough to read in full.**
|
|
6
|
+
* `contextIsolation` is on and `nodeIntegration` is off, so a game — which may run third-party
|
|
7
|
+
* code, a shader from a URL, or a mod — reaches the operating system through exactly these
|
|
8
|
+
* members and nothing else. Adding one is a decision about what a compromised renderer can do.
|
|
9
|
+
*
|
|
10
|
+
* **`storeSnapshot` is a snapshot, not a live view.** `KeyValueStore.read` is synchronous by the
|
|
11
|
+
* engine's contract, because preferences are read during boot and an await there is a visible
|
|
12
|
+
* flicker on every load. IPC is asynchronous. So the main process reads the store from disk once,
|
|
13
|
+
* before the window exists, and hands it over whole; writes go the other way and do not block.
|
|
14
|
+
* **What this costs**: two windows of one application would not see each other's writes, which is
|
|
15
|
+
* why the main process makes the application single-instance.
|
|
16
|
+
*/
|
|
17
|
+
export interface DriftHostBridge {
|
|
18
|
+
readonly storeSnapshot: Readonly<Record<string, string>>;
|
|
19
|
+
writeKey(key: string, value: string): void;
|
|
20
|
+
removeKey(key: string): void;
|
|
21
|
+
setFullscreen(on: boolean): Promise<void>;
|
|
22
|
+
isFullscreen(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* A call rather than a value, unlike the store snapshot beside it.
|
|
25
|
+
*
|
|
26
|
+
* Every other read here is taken once at preload because it cannot change; these can. A window
|
|
27
|
+
* dragged to a second monitor changes its refresh rate, its size and which display it is on, and
|
|
28
|
+
* a settings screen showing the value from boot would be showing the wrong monitor's.
|
|
29
|
+
*/
|
|
30
|
+
refreshHz(): number | null;
|
|
31
|
+
mode(): WindowMode;
|
|
32
|
+
setMode(mode: WindowMode): Promise<boolean>;
|
|
33
|
+
size(): {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Whether `setSize` would work right now.
|
|
39
|
+
*
|
|
40
|
+
* Synchronous like `size()` beside it, and for the same reason: a settings screen asks while it
|
|
41
|
+
* is drawing a control, and an answer that arrived a frame later would draw the control twice.
|
|
42
|
+
* The main process decides it with the same function that decides whether `setSize` refuses —
|
|
43
|
+
* two conditions written twice would drift into a greyed control that works.
|
|
44
|
+
*/
|
|
45
|
+
canSetSize(): boolean;
|
|
46
|
+
setSize(width: number, height: number): Promise<boolean>;
|
|
47
|
+
displays(): readonly DisplayInfo[];
|
|
48
|
+
onFocusChange(handler: (focused: boolean) => void): () => void;
|
|
49
|
+
onQuitRequest(handler: () => void): () => void;
|
|
50
|
+
/** Whether `requestQuit` ends the process here. False on a platform that does not exit. */
|
|
51
|
+
canQuit: boolean;
|
|
52
|
+
requestQuit(): void;
|
|
53
|
+
openFile(accept: readonly string[]): Promise<{
|
|
54
|
+
name: string;
|
|
55
|
+
bytes: Uint8Array;
|
|
56
|
+
} | null>;
|
|
57
|
+
saveFile(name: string, bytes: Uint8Array): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* A store's own features, when there is a store and it answered.
|
|
60
|
+
*
|
|
61
|
+
* **The SDK lives in the main process and can only live there.** `steamworks.js` is a native
|
|
62
|
+
* Node module and the renderer runs sandboxed with no Node at all, so a game reaching for Steam
|
|
63
|
+
* directly is a game reaching for something that is not in its process. It crosses here like
|
|
64
|
+
* every other capability.
|
|
65
|
+
*
|
|
66
|
+
* `available` is false when the manifest named no app, when the module is not installed, or when
|
|
67
|
+
* the copy was launched outside Steam — all three of which are ordinary, and none of which is a
|
|
68
|
+
* reason for a game to refuse to start.
|
|
69
|
+
*/
|
|
70
|
+
readonly platform: {
|
|
71
|
+
readonly available: boolean;
|
|
72
|
+
unlockAchievement(id: string): void;
|
|
73
|
+
setRichPresence(text: string): void;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Where the bridge lands on `globalThis`. One name, so a game can feature-detect the shell. */
|
|
77
|
+
export declare const BRIDGE_KEY = "__driftHost";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { contextBridge, ipcRenderer } from 'electron';
|
|
2
|
+
/**
|
|
3
|
+
* Everything the renderer will ever be able to reach, assembled once.
|
|
4
|
+
*
|
|
5
|
+
* `contextBridge` is what keeps this a boundary rather than a suggestion: the renderer sees the
|
|
6
|
+
* object below and no `require`, no `ipcRenderer`, and no `process`.
|
|
7
|
+
*
|
|
8
|
+
* **Written as an ES module and emitted as CommonJS.** A sandboxed preload is loaded before ES
|
|
9
|
+
* modules exist in that context, so the artifact must be `preload.cjs` — but the module format is
|
|
10
|
+
* the builder's decision rather than the source's, and a `.cts` file cannot use `import` at all
|
|
11
|
+
* under this repository's `verbatimModuleSyntax`.
|
|
12
|
+
*
|
|
13
|
+
* **Three values are read synchronously at load.** The store snapshot is what lets the engine's
|
|
14
|
+
* `KeyValueStore` stay synchronous — see `bridge.ts` — and fullscreen and refresh rate are read
|
|
15
|
+
* once here rather than per call so a settings screen never blocks a frame on IPC.
|
|
16
|
+
*/
|
|
17
|
+
const snapshot = ipcRenderer.sendSync('drift:snapshot');
|
|
18
|
+
const bridge = {
|
|
19
|
+
storeSnapshot: snapshot,
|
|
20
|
+
writeKey: (key, value) => {
|
|
21
|
+
ipcRenderer.send('drift:write', key, value);
|
|
22
|
+
},
|
|
23
|
+
removeKey: (key) => {
|
|
24
|
+
ipcRenderer.send('drift:remove', key);
|
|
25
|
+
},
|
|
26
|
+
setFullscreen: (on) => ipcRenderer.invoke('drift:setFullscreen', on),
|
|
27
|
+
isFullscreen: () => ipcRenderer.sendSync('drift:isFullscreen'),
|
|
28
|
+
/* Read on every call, not once: a window moved to another monitor changes all four of these,
|
|
29
|
+
and a settings screen is exactly where somebody looks after moving it. */
|
|
30
|
+
refreshHz: () => ipcRenderer.sendSync('drift:refreshHz'),
|
|
31
|
+
mode: () => ipcRenderer.sendSync('drift:mode'),
|
|
32
|
+
setMode: (mode) => ipcRenderer.invoke('drift:setMode', mode),
|
|
33
|
+
size: () => ipcRenderer.sendSync('drift:size'),
|
|
34
|
+
canSetSize: () => ipcRenderer.sendSync('drift:canSetSize'),
|
|
35
|
+
setSize: (width, height) => ipcRenderer.invoke('drift:setSize', width, height),
|
|
36
|
+
displays: () => ipcRenderer.sendSync('drift:displays'),
|
|
37
|
+
onFocusChange: (handler) => {
|
|
38
|
+
const listener = (_event, focused) => handler(focused);
|
|
39
|
+
ipcRenderer.on('drift:focus', listener);
|
|
40
|
+
return () => {
|
|
41
|
+
ipcRenderer.removeListener('drift:focus', listener);
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
onQuitRequest: (handler) => {
|
|
45
|
+
/*
|
|
46
|
+
* **Registering interest is what makes a quit fast for everybody else.** The main process
|
|
47
|
+
* defers a window close only for a game that asked to be told; without this signal the only
|
|
48
|
+
* correct implementation waits a grace period on every close, including for the games that
|
|
49
|
+
* had nothing to say.
|
|
50
|
+
*/
|
|
51
|
+
ipcRenderer.send('drift:quitInterest');
|
|
52
|
+
const listener = () => handler();
|
|
53
|
+
ipcRenderer.on('drift:quitRequest', listener);
|
|
54
|
+
return () => {
|
|
55
|
+
ipcRenderer.removeListener('drift:quitRequest', listener);
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
/* A desktop shell can always close its own window, which is most of the point of having one. */
|
|
59
|
+
canQuit: true,
|
|
60
|
+
requestQuit: () => {
|
|
61
|
+
ipcRenderer.send('drift:quit');
|
|
62
|
+
},
|
|
63
|
+
openFile: (accept) => ipcRenderer.invoke('drift:openFile', accept),
|
|
64
|
+
saveFile: (name, bytes) => ipcRenderer.invoke('drift:saveFile', name, bytes),
|
|
65
|
+
platform: {
|
|
66
|
+
/* Read once: whether a store answered is decided before the window exists and does not
|
|
67
|
+
change while the game is running. */
|
|
68
|
+
available: ipcRenderer.sendSync('drift:platformAvailable'),
|
|
69
|
+
unlockAchievement: (id) => {
|
|
70
|
+
ipcRenderer.send('drift:achievement', id);
|
|
71
|
+
},
|
|
72
|
+
setRichPresence: (text) => {
|
|
73
|
+
ipcRenderer.send('drift:presence', text);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
contextBridge.exposeInMainWorld('__driftHost', bridge);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { KeyValueStore } from '@driftengine/core';
|
|
2
|
+
import type { DriftHostBridge } from '../preload/bridge.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The engine's persistence seam, backed by a file the shell owns.
|
|
5
|
+
*
|
|
6
|
+
* Reads come from a snapshot taken before the window opened and from this session's own writes;
|
|
7
|
+
* writes go to the main process and do not block. That is precisely the arrangement
|
|
8
|
+
* `KeyValueStore`'s own doc comment prescribes for an asynchronous backend — hydrate a cache
|
|
9
|
+
* first, make `write` fire-and-forget — and it is why the interface never needed widening for
|
|
10
|
+
* this.
|
|
11
|
+
*
|
|
12
|
+
* **What this costs**: a write that fails on disk is not reported here. The main process logs it;
|
|
13
|
+
* the game continues with the value in memory, which is the same degradation `BrowserStore` makes
|
|
14
|
+
* when `localStorage` throws. A preference is never worth failing a boot for.
|
|
15
|
+
*/
|
|
16
|
+
export declare class FileStore implements KeyValueStore {
|
|
17
|
+
private readonly bridge;
|
|
18
|
+
private readonly entries;
|
|
19
|
+
constructor(bridge: DriftHostBridge);
|
|
20
|
+
read(key: string): string | null;
|
|
21
|
+
write(key: string, value: string): void;
|
|
22
|
+
remove(key: string): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's persistence seam, backed by a file the shell owns.
|
|
3
|
+
*
|
|
4
|
+
* Reads come from a snapshot taken before the window opened and from this session's own writes;
|
|
5
|
+
* writes go to the main process and do not block. That is precisely the arrangement
|
|
6
|
+
* `KeyValueStore`'s own doc comment prescribes for an asynchronous backend — hydrate a cache
|
|
7
|
+
* first, make `write` fire-and-forget — and it is why the interface never needed widening for
|
|
8
|
+
* this.
|
|
9
|
+
*
|
|
10
|
+
* **What this costs**: a write that fails on disk is not reported here. The main process logs it;
|
|
11
|
+
* the game continues with the value in memory, which is the same degradation `BrowserStore` makes
|
|
12
|
+
* when `localStorage` throws. A preference is never worth failing a boot for.
|
|
13
|
+
*/
|
|
14
|
+
export class FileStore {
|
|
15
|
+
bridge;
|
|
16
|
+
entries = new Map();
|
|
17
|
+
constructor(bridge) {
|
|
18
|
+
this.bridge = bridge;
|
|
19
|
+
for (const [key, value] of Object.entries(bridge.storeSnapshot))
|
|
20
|
+
this.entries.set(key, value);
|
|
21
|
+
}
|
|
22
|
+
read(key) {
|
|
23
|
+
return this.entries.get(key) ?? null;
|
|
24
|
+
}
|
|
25
|
+
write(key, value) {
|
|
26
|
+
this.entries.set(key, value);
|
|
27
|
+
this.bridge.writeKey(key, value);
|
|
28
|
+
}
|
|
29
|
+
remove(key) {
|
|
30
|
+
this.entries.delete(key);
|
|
31
|
+
this.bridge.removeKey(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DisplayControl, FileDialogs, KeyValueStore, Lifecycle, PlatformServices, ScreenPresentation } from '@driftengine/core';
|
|
2
|
+
/**
|
|
3
|
+
* Every capability the engine takes, chosen for wherever this build is running.
|
|
4
|
+
*
|
|
5
|
+
* **One call, at boot, and no platform test anywhere else.** A game shipped to a browser and
|
|
6
|
+
* packaged for a desktop is one bundle; the difference between them is which objects these five
|
|
7
|
+
* fields hold, and finding that out is this function's job rather than a game's. What a game
|
|
8
|
+
* writes against is the interfaces — so its own settings menu calls `display.setMode`,
|
|
9
|
+
* `display.setSize` and `display.displays()` the same way in both, and reads `false` from the ones
|
|
10
|
+
* a browser genuinely cannot do rather than discovering nothing happened.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const host = createHost(canvas);
|
|
14
|
+
* const { renderer } = await createRenderer(canvas, quality);
|
|
15
|
+
* const settings = loadPreferences(host.store, SCHEMA);
|
|
16
|
+
* await host.display.setMode(settings.fullscreen ? 'fullscreen' : 'windowed');
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* **`screen` is the browser implementation on every platform including the shell**, and that is
|
|
20
|
+
* not an omission: orientation, safe-area insets and the wake lock are answered by Chromium
|
|
21
|
+
* itself, so a native override would be a second implementation of the same answers. A phone host
|
|
22
|
+
* supplies its own where the WebView cannot answer — see the mobile plan.
|
|
23
|
+
*
|
|
24
|
+
* **`native` is for telling somebody**, not for branching: a build badge, a bug report, a
|
|
25
|
+
* "restart required" line. A game that branches on it is doing what this function exists to
|
|
26
|
+
* prevent.
|
|
27
|
+
*/
|
|
28
|
+
export interface Host {
|
|
29
|
+
/** Whether a shell supplied these, rather than the browser. */
|
|
30
|
+
readonly native: boolean;
|
|
31
|
+
readonly display: DisplayControl;
|
|
32
|
+
readonly lifecycle: Lifecycle;
|
|
33
|
+
readonly files: FileDialogs;
|
|
34
|
+
readonly store: KeyValueStore;
|
|
35
|
+
readonly screen: ScreenPresentation;
|
|
36
|
+
/**
|
|
37
|
+
* A store's own features, or null where there is no store.
|
|
38
|
+
*
|
|
39
|
+
* Null in a browser, null in a shell whose manifest named no app, and null when the copy was
|
|
40
|
+
* launched outside the store — a game checks once and never calls it, rather than calling into
|
|
41
|
+
* something that quietly does nothing.
|
|
42
|
+
*/
|
|
43
|
+
readonly services: PlatformServices | null;
|
|
44
|
+
}
|
|
45
|
+
export declare function createHost(surface: HTMLElement): Host;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BrowserDisplay, BrowserFileDialogs, BrowserLifecycle, BrowserScreenPresentation, BrowserStore, } from '@driftengine/core';
|
|
2
|
+
import { showDeviceReport } from '../mobile/report.js';
|
|
3
|
+
import { FileStore } from './fileStore.js';
|
|
4
|
+
import { NativeDisplay, NativeFileDialogs, NativeLifecycle, NativePlatformServices, hostBridge, } from './nativeHost.js';
|
|
5
|
+
export function createHost(surface) {
|
|
6
|
+
/* `?report=1` draws what the device says about itself over the game, on every platform. It is
|
|
7
|
+
how a shader that compiles here and not on a phone gets attributed to a WebView build. */
|
|
8
|
+
showDeviceReport();
|
|
9
|
+
const bridge = hostBridge();
|
|
10
|
+
if (bridge === null) {
|
|
11
|
+
return {
|
|
12
|
+
native: false,
|
|
13
|
+
display: new BrowserDisplay(surface),
|
|
14
|
+
lifecycle: new BrowserLifecycle(),
|
|
15
|
+
files: new BrowserFileDialogs(),
|
|
16
|
+
store: new BrowserStore(),
|
|
17
|
+
screen: new BrowserScreenPresentation(),
|
|
18
|
+
services: null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
native: true,
|
|
23
|
+
display: new NativeDisplay(bridge),
|
|
24
|
+
lifecycle: new NativeLifecycle(bridge),
|
|
25
|
+
files: new NativeFileDialogs(bridge),
|
|
26
|
+
store: new FileStore(bridge),
|
|
27
|
+
screen: new BrowserScreenPresentation(),
|
|
28
|
+
services: bridge.platform.available ? new NativePlatformServices(bridge) : null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { DisplayControl, DisplayInfo, FileDialogs, Lifecycle, OpenedFile, PlatformServices, WindowMode } from '@driftengine/core';
|
|
2
|
+
import type { DriftHostBridge } from '../preload/bridge.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The engine's capability interfaces, implemented over the shell's one bridge.
|
|
5
|
+
*
|
|
6
|
+
* Each of these is the native half of a pair whose browser half ships in the engine. The pairs
|
|
7
|
+
* differ in exactly the places a browser cannot answer honestly: a refresh rate is a number here
|
|
8
|
+
* and null there, and quitting works here and warns there.
|
|
9
|
+
*
|
|
10
|
+
* **A game is one bundle.** `hostBridge()` answers null in a plain browser rather than throwing,
|
|
11
|
+
* so a consumer picks its implementations at boot and ships the same code to both.
|
|
12
|
+
*/
|
|
13
|
+
export declare function hostBridge(): DriftHostBridge | null;
|
|
14
|
+
/**
|
|
15
|
+
* The video half of a settings screen, on a platform that can actually answer it.
|
|
16
|
+
*
|
|
17
|
+
* Every member here is the same call the browser implementation makes and refuses. That is the
|
|
18
|
+
* point of the pair: a game writes one settings screen against `DisplayControl`, and it is fully
|
|
19
|
+
* populated in a shell and honestly degraded in a tab — a resolution list that reports `false`
|
|
20
|
+
* from `setSize` is a control the game greys out rather than one that silently does nothing.
|
|
21
|
+
*/
|
|
22
|
+
export declare class NativeDisplay implements DisplayControl {
|
|
23
|
+
private readonly bridge;
|
|
24
|
+
constructor(bridge: DriftHostBridge);
|
|
25
|
+
/**
|
|
26
|
+
* A real number, which is the whole reason the browser implementation returns null.
|
|
27
|
+
*
|
|
28
|
+
* Read on each access rather than captured in the constructor: a window moved to another
|
|
29
|
+
* monitor changes it, and a value from boot would be the previous monitor's.
|
|
30
|
+
*/
|
|
31
|
+
get refreshHz(): number | null;
|
|
32
|
+
isFullscreen(): boolean;
|
|
33
|
+
setFullscreen(on: boolean): Promise<void>;
|
|
34
|
+
mode(): WindowMode;
|
|
35
|
+
setMode(mode: WindowMode): Promise<boolean>;
|
|
36
|
+
size(): {
|
|
37
|
+
readonly width: number;
|
|
38
|
+
readonly height: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The question, asked without performing the resize.
|
|
42
|
+
*
|
|
43
|
+
* The shell decides it with the same function that decides whether `setSize` refuses, so the two
|
|
44
|
+
* cannot disagree — see `main/ipc.ts`. Before this existed the only way to learn the answer was
|
|
45
|
+
* to call `setSize`, and a consumer's settings screen probed it at boot by resizing the window to
|
|
46
|
+
* the size it already had.
|
|
47
|
+
*/
|
|
48
|
+
canSetSize(): boolean;
|
|
49
|
+
setSize(width: number, height: number): Promise<boolean>;
|
|
50
|
+
displays(): readonly DisplayInfo[];
|
|
51
|
+
}
|
|
52
|
+
export declare class NativeLifecycle implements Lifecycle {
|
|
53
|
+
private readonly bridge;
|
|
54
|
+
constructor(bridge: DriftHostBridge);
|
|
55
|
+
/** What the shell says, rather than what a desktop assumes: an iOS host answers false. */
|
|
56
|
+
get canQuit(): boolean;
|
|
57
|
+
onFocusChange(handler: (focused: boolean) => void): () => void;
|
|
58
|
+
onQuitRequest(handler: () => void): () => void;
|
|
59
|
+
requestQuit(): void;
|
|
60
|
+
}
|
|
61
|
+
export declare class NativeFileDialogs implements FileDialogs {
|
|
62
|
+
private readonly bridge;
|
|
63
|
+
constructor(bridge: DriftHostBridge);
|
|
64
|
+
openFile(accept: readonly string[]): Promise<OpenedFile | null>;
|
|
65
|
+
saveFile(name: string, bytes: Uint8Array): Promise<boolean>;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A store's achievements and presence, over the bridge.
|
|
69
|
+
*
|
|
70
|
+
* **Only created where a store answered.** `createHost` returns `null` for `services` otherwise,
|
|
71
|
+
* which is the same shape the engine's own interface has: there is no browser implementation of
|
|
72
|
+
* `PlatformServices` because there is nothing honest for one to do, and a game that gets null
|
|
73
|
+
* simply never calls it.
|
|
74
|
+
*
|
|
75
|
+
* Neither method reports failure, deliberately. An achievement that does not unlock is not a
|
|
76
|
+
* reason for a frame to stop, and a store that is offline is an ordinary state.
|
|
77
|
+
*/
|
|
78
|
+
export declare class NativePlatformServices implements PlatformServices {
|
|
79
|
+
private readonly bridge;
|
|
80
|
+
constructor(bridge: DriftHostBridge);
|
|
81
|
+
unlockAchievement(id: string): void;
|
|
82
|
+
setRichPresence(text: string): void;
|
|
83
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { BRIDGE_KEY } from '../preload/bridge.js';
|
|
2
|
+
/**
|
|
3
|
+
* The engine's capability interfaces, implemented over the shell's one bridge.
|
|
4
|
+
*
|
|
5
|
+
* Each of these is the native half of a pair whose browser half ships in the engine. The pairs
|
|
6
|
+
* differ in exactly the places a browser cannot answer honestly: a refresh rate is a number here
|
|
7
|
+
* and null there, and quitting works here and warns there.
|
|
8
|
+
*
|
|
9
|
+
* **A game is one bundle.** `hostBridge()` answers null in a plain browser rather than throwing,
|
|
10
|
+
* so a consumer picks its implementations at boot and ships the same code to both.
|
|
11
|
+
*/
|
|
12
|
+
export function hostBridge() {
|
|
13
|
+
const found = globalThis[BRIDGE_KEY];
|
|
14
|
+
return found === undefined || found === null ? null : found;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The video half of a settings screen, on a platform that can actually answer it.
|
|
18
|
+
*
|
|
19
|
+
* Every member here is the same call the browser implementation makes and refuses. That is the
|
|
20
|
+
* point of the pair: a game writes one settings screen against `DisplayControl`, and it is fully
|
|
21
|
+
* populated in a shell and honestly degraded in a tab — a resolution list that reports `false`
|
|
22
|
+
* from `setSize` is a control the game greys out rather than one that silently does nothing.
|
|
23
|
+
*/
|
|
24
|
+
export class NativeDisplay {
|
|
25
|
+
bridge;
|
|
26
|
+
constructor(bridge) {
|
|
27
|
+
this.bridge = bridge;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A real number, which is the whole reason the browser implementation returns null.
|
|
31
|
+
*
|
|
32
|
+
* Read on each access rather than captured in the constructor: a window moved to another
|
|
33
|
+
* monitor changes it, and a value from boot would be the previous monitor's.
|
|
34
|
+
*/
|
|
35
|
+
get refreshHz() {
|
|
36
|
+
return this.bridge.refreshHz();
|
|
37
|
+
}
|
|
38
|
+
isFullscreen() {
|
|
39
|
+
return this.bridge.isFullscreen();
|
|
40
|
+
}
|
|
41
|
+
async setFullscreen(on) {
|
|
42
|
+
await this.bridge.setFullscreen(on);
|
|
43
|
+
}
|
|
44
|
+
mode() {
|
|
45
|
+
return this.bridge.mode();
|
|
46
|
+
}
|
|
47
|
+
async setMode(mode) {
|
|
48
|
+
return this.bridge.setMode(mode);
|
|
49
|
+
}
|
|
50
|
+
size() {
|
|
51
|
+
return this.bridge.size();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The question, asked without performing the resize.
|
|
55
|
+
*
|
|
56
|
+
* The shell decides it with the same function that decides whether `setSize` refuses, so the two
|
|
57
|
+
* cannot disagree — see `main/ipc.ts`. Before this existed the only way to learn the answer was
|
|
58
|
+
* to call `setSize`, and a consumer's settings screen probed it at boot by resizing the window to
|
|
59
|
+
* the size it already had.
|
|
60
|
+
*/
|
|
61
|
+
canSetSize() {
|
|
62
|
+
return this.bridge.canSetSize();
|
|
63
|
+
}
|
|
64
|
+
async setSize(width, height) {
|
|
65
|
+
return this.bridge.setSize(width, height);
|
|
66
|
+
}
|
|
67
|
+
displays() {
|
|
68
|
+
return this.bridge.displays();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class NativeLifecycle {
|
|
72
|
+
bridge;
|
|
73
|
+
constructor(bridge) {
|
|
74
|
+
this.bridge = bridge;
|
|
75
|
+
}
|
|
76
|
+
/** What the shell says, rather than what a desktop assumes: an iOS host answers false. */
|
|
77
|
+
get canQuit() {
|
|
78
|
+
return this.bridge.canQuit;
|
|
79
|
+
}
|
|
80
|
+
onFocusChange(handler) {
|
|
81
|
+
return this.bridge.onFocusChange(handler);
|
|
82
|
+
}
|
|
83
|
+
onQuitRequest(handler) {
|
|
84
|
+
return this.bridge.onQuitRequest(handler);
|
|
85
|
+
}
|
|
86
|
+
requestQuit() {
|
|
87
|
+
this.bridge.requestQuit();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class NativeFileDialogs {
|
|
91
|
+
bridge;
|
|
92
|
+
constructor(bridge) {
|
|
93
|
+
this.bridge = bridge;
|
|
94
|
+
}
|
|
95
|
+
async openFile(accept) {
|
|
96
|
+
const picked = await this.bridge.openFile(accept);
|
|
97
|
+
return picked === null ? null : { name: picked.name, bytes: picked.bytes };
|
|
98
|
+
}
|
|
99
|
+
async saveFile(name, bytes) {
|
|
100
|
+
return this.bridge.saveFile(name, bytes);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A store's achievements and presence, over the bridge.
|
|
105
|
+
*
|
|
106
|
+
* **Only created where a store answered.** `createHost` returns `null` for `services` otherwise,
|
|
107
|
+
* which is the same shape the engine's own interface has: there is no browser implementation of
|
|
108
|
+
* `PlatformServices` because there is nothing honest for one to do, and a game that gets null
|
|
109
|
+
* simply never calls it.
|
|
110
|
+
*
|
|
111
|
+
* Neither method reports failure, deliberately. An achievement that does not unlock is not a
|
|
112
|
+
* reason for a frame to stop, and a store that is offline is an ordinary state.
|
|
113
|
+
*/
|
|
114
|
+
export class NativePlatformServices {
|
|
115
|
+
bridge;
|
|
116
|
+
constructor(bridge) {
|
|
117
|
+
this.bridge = bridge;
|
|
118
|
+
}
|
|
119
|
+
unlockAchievement(id) {
|
|
120
|
+
this.bridge.platform.unlockAchievement(id);
|
|
121
|
+
}
|
|
122
|
+
setRichPresence(text) {
|
|
123
|
+
this.bridge.platform.setRichPresence(text);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the packager's own resources are: `assets/`, `android/` and `ios/`.
|
|
3
|
+
*
|
|
4
|
+
* **This module exists because four call sites climbed out of `src/` on their own, and a consumer
|
|
5
|
+
* could not tell them where to look instead.** `stage.ts` and `ios.ts` copied `../assets`,
|
|
6
|
+
* `android.ts` copied `../android`, `ios.ts` copied `../ios`, and `build.ts` reached into
|
|
7
|
+
* `../assets/icon.png` for a default icon — each computed from its own module's URL, with no
|
|
8
|
+
* option, flag or variable that moved any of them. The first of those runs on **every** desktop
|
|
9
|
+
* build: the whole assets directory becomes the artifact's `drift://shell/` origin.
|
|
10
|
+
*
|
|
11
|
+
* That is fine inside this repository and a wall outside it. **A consumer that vendors engine
|
|
12
|
+
* source** copies `packages/<name>/src/**` to `<name>/**`
|
|
13
|
+
* — which flattens away exactly the level those four paths climbed to. Reported from outside,
|
|
14
|
+
* where the workaround was a layout exception in the vendor script: this one package kept the
|
|
15
|
+
* engine's own shape while the other ten were copied flat, plus a directory-copying branch so
|
|
16
|
+
* every asset still landed in the integrity hash, plus a per-package entry in the bundler's alias
|
|
17
|
+
* table. Small, and permanently in the way of reading that file.
|
|
18
|
+
*
|
|
19
|
+
* **What it cost before it was understood** is the part worth keeping: copied flat, `cp` threw
|
|
20
|
+
* `ENOENT` on a directory nobody would think to look for, before a single artifact existed, naming
|
|
21
|
+
* a path that is not in the project. `resourceDir` refuses instead, naming both the path it looked
|
|
22
|
+
* in and the way to move it.
|
|
23
|
+
*
|
|
24
|
+
* **Two doors and deliberately not three.** A caller passes `resourceRoot`; the CLI takes
|
|
25
|
+
* `--resources=<dir>` and fills it. There is no environment variable: a build's inputs should be
|
|
26
|
+
* visible in the command that ran it, and an environment variable is the one input that is invisible
|
|
27
|
+
* in a log somebody is reading a week later. **What would change that** is a consumer whose build
|
|
28
|
+
* system cannot pass an argument, which no consumer here has.
|
|
29
|
+
*/
|
|
30
|
+
/** The three directories this package ships beside its sources. */
|
|
31
|
+
export type ResourceName = 'assets' | 'android' | 'ios';
|
|
32
|
+
/**
|
|
33
|
+
* The root the three live under: the given one, or this package's own directory.
|
|
34
|
+
*
|
|
35
|
+
* Resolved against the working directory when it is relative, because a flag typed on a command
|
|
36
|
+
* line is relative to where somebody typed it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resourceRoot(given?: string | undefined): string;
|
|
39
|
+
/**
|
|
40
|
+
* One resource directory, checked before anything is copied out of it.
|
|
41
|
+
*
|
|
42
|
+
* **The check is the point.** Every caller's next move is a recursive `cp`, whose failure names a
|
|
43
|
+
* path a consumer has never seen and says nothing about what would fix it. This says both, once,
|
|
44
|
+
* before the build has done any work — which is what `AGENTS.md` means by failing fast at init with
|
|
45
|
+
* an actionable message.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resourceDir(name: ResourceName, given?: string | undefined): Promise<string>;
|
|
48
|
+
/** One file inside a resource directory, checked the same way. */
|
|
49
|
+
export declare function resourceFile(name: ResourceName, file: string, given?: string | undefined): Promise<string>;
|
|
50
|
+
/** What every entry point takes so a caller can move the three. */
|
|
51
|
+
export interface ResourceOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Where `assets/`, `android/` and `ios/` are, if not beside this package's sources.
|
|
54
|
+
*
|
|
55
|
+
* A vendored engine is the case this is for: a copy that flattened every package's `src` has left
|
|
56
|
+
* the three somewhere this package cannot compute.
|
|
57
|
+
*/
|
|
58
|
+
readonly resourceRoot?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Where builds write, if not `out/` inside the project.
|
|
61
|
+
*
|
|
62
|
+
* **A staging copy that cannot leave the project is a copy that can refuse itself.** `fs.cp`
|
|
63
|
+
* compares `dev` and `ino` up the destination's ancestors, and a shared or network folder can
|
|
64
|
+
* answer the same identity for a file and a directory — at which point the very first copy of a
|
|
65
|
+
* build fails, naming the one thing that is not happening. See `outDir.ts` for the measurement.
|
|
66
|
+
*
|
|
67
|
+
* The same two doors and no third: a caller passes `outRoot`, the CLI takes `--out=<dir>` and
|
|
68
|
+
* fills it. No environment variable, for the reason this module's header already gives.
|
|
69
|
+
*/
|
|
70
|
+
readonly outRoot?: string;
|
|
71
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* A file only the real thing has, one per directory, so a directory is identified and not merely
|
|
6
|
+
* named.
|
|
7
|
+
*
|
|
8
|
+
* **Reported from outside 2026-08-28, the day the root became configurable.** A vendored consumer
|
|
9
|
+
* flattens each package's `src`, so the *default* root — one level above this package's sources —
|
|
10
|
+
* is the vendor root, the directory holding every copied package. One of those packages is
|
|
11
|
+
* `@driftengine/assets`. So `join(root, 'assets')` exists, an existence check passes, and the
|
|
12
|
+
* packager stages a directory of TypeScript source as the shell: verified from this repository,
|
|
13
|
+
* `shell/` came out holding 25 files — `assetPath.ts`, `gltf.ts`, `fbx.ts` — and no `splash.html`,
|
|
14
|
+
* with no error at any stage. The artifact builds, installs and launches, and the badge is a 404
|
|
15
|
+
* nobody sees, because the splash is what would have shown it.
|
|
16
|
+
*
|
|
17
|
+
* **That failure mode is one the old arrangement did not have**, which is the part worth keeping:
|
|
18
|
+
* copied flat with no root given, `cp` threw `ENOENT` and stopped. Making the path configurable
|
|
19
|
+
* made its absence quiet in exactly one layout — the common one.
|
|
20
|
+
*
|
|
21
|
+
* **Each marker is a file the packager itself reads**, so it cannot be deleted without breaking
|
|
22
|
+
* something louder than this check: `main/splash.ts` loads `drift://shell/splash.html`, `ios.ts`
|
|
23
|
+
* reads and rewrites `project.yml`, and Gradle will not open a project without its settings file. A
|
|
24
|
+
* marker nothing used would be a sentinel that could rot while the check went on passing.
|
|
25
|
+
*/
|
|
26
|
+
const MARKER = {
|
|
27
|
+
assets: 'splash.html',
|
|
28
|
+
android: 'settings.gradle.kts',
|
|
29
|
+
ios: 'project.yml',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The root the three live under: the given one, or this package's own directory.
|
|
33
|
+
*
|
|
34
|
+
* Resolved against the working directory when it is relative, because a flag typed on a command
|
|
35
|
+
* line is relative to where somebody typed it.
|
|
36
|
+
*/
|
|
37
|
+
export function resourceRoot(given) {
|
|
38
|
+
if (given !== undefined && given.length > 0)
|
|
39
|
+
return resolve(process.cwd(), given);
|
|
40
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One resource directory, checked before anything is copied out of it.
|
|
44
|
+
*
|
|
45
|
+
* **The check is the point.** Every caller's next move is a recursive `cp`, whose failure names a
|
|
46
|
+
* path a consumer has never seen and says nothing about what would fix it. This says both, once,
|
|
47
|
+
* before the build has done any work — which is what `AGENTS.md` means by failing fast at init with
|
|
48
|
+
* an actionable message.
|
|
49
|
+
*/
|
|
50
|
+
export async function resourceDir(name, given) {
|
|
51
|
+
const root = resourceRoot(given);
|
|
52
|
+
const dir = join(root, name);
|
|
53
|
+
const marker = MARKER[name];
|
|
54
|
+
const move = '— pass `--resources=<dir>` (or `resourceRoot` to the API) naming the directory that holds ' +
|
|
55
|
+
'assets/, android/ and ios/.';
|
|
56
|
+
try {
|
|
57
|
+
await access(dir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(`[drift-package] the packager's own \`${name}\` directory is not at ${dir}. It ships beside ` +
|
|
61
|
+
`this package's sources, so a copy of the engine that flattened them has moved it ${move}`);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await access(join(dir, marker));
|
|
65
|
+
return dir;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/*
|
|
69
|
+
* **The confusing one, so the message explains the coincidence rather than the rule.** A
|
|
70
|
+
* consumer meeting this has a directory of the right name in the right place, and it is
|
|
71
|
+
* somebody else's — `@driftengine/assets` sitting in a vendor root beside the packager.
|
|
72
|
+
*/
|
|
73
|
+
throw new Error(`[drift-package] ${dir} exists and is not the packager's \`${name}\` directory: it has no ` +
|
|
74
|
+
`\`${marker}\`. A vendored engine puts every package in one directory, and one of them is ` +
|
|
75
|
+
`\`@driftengine/assets\` — so a root that looks right can hold a package's source instead ` +
|
|
76
|
+
`of the shell's. Staging that produces an artifact that launches with no splash and no ` +
|
|
77
|
+
`error ${move}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** One file inside a resource directory, checked the same way. */
|
|
81
|
+
export async function resourceFile(name, file, given) {
|
|
82
|
+
return join(await resourceDir(name, given), file);
|
|
83
|
+
}
|