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