@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/dist/main/ipc.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
import { SteamCloudStore } from '../steam/cloudStore.js';
|
|
6
|
+
/**
|
|
7
|
+
* The main-process half of the bridge: a file-backed store, dialogs, focus, and quit.
|
|
8
|
+
*
|
|
9
|
+
* **The store is read once, synchronously, before the window exists.** That is what lets the
|
|
10
|
+
* renderer satisfy `KeyValueStore`'s synchronous contract; see `preload/bridge.ts` for why that
|
|
11
|
+
* contract is not negotiable. Writes are debounced to one file write, because a settings screen
|
|
12
|
+
* with a slider writes on every frame of a drag and a save per frame is a stutter.
|
|
13
|
+
*
|
|
14
|
+
* **Three channels answer synchronously** — the snapshot, fullscreen state and refresh rate — and
|
|
15
|
+
* that is a deliberate cost. A synchronous IPC blocks the renderer, so each is a single value
|
|
16
|
+
* read at boot or from a settings screen, never anything per frame.
|
|
17
|
+
*/
|
|
18
|
+
const storeFile = () => join(app.getPath('userData'), 'store.json');
|
|
19
|
+
/** Milliseconds a write waits for its neighbours. A drag writes every frame; disk should not. */
|
|
20
|
+
const WRITE_DEBOUNCE_MS = 250;
|
|
21
|
+
/** How long a quit waits for a game that asked to be told about it. */
|
|
22
|
+
const QUIT_GRACE_MS = 3000;
|
|
23
|
+
/**
|
|
24
|
+
* What the game starts with, read from wherever this build's saves live.
|
|
25
|
+
*
|
|
26
|
+
* Steam Cloud first where a store answered, because a player who moved machines expects their
|
|
27
|
+
* settings to have travelled — and falling back to the local file when the cloud has nothing is
|
|
28
|
+
* what makes the first launch after enabling Steam keep the saves it already had.
|
|
29
|
+
*/
|
|
30
|
+
export function readStoreSnapshot(steam = null) {
|
|
31
|
+
if (steam !== null) {
|
|
32
|
+
const cloud = cloudSnapshot(steam);
|
|
33
|
+
if (Object.keys(cloud).length > 0)
|
|
34
|
+
return cloud;
|
|
35
|
+
}
|
|
36
|
+
return readLocalSnapshot();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every key Steam Cloud holds for this application.
|
|
40
|
+
*
|
|
41
|
+
* The API lists files rather than keys, and `SteamCloudStore` writes one file per key with a
|
|
42
|
+
* `.txt` suffix — so the mapping is undone here rather than guessed at by a caller.
|
|
43
|
+
*/
|
|
44
|
+
function cloudSnapshot(steam) {
|
|
45
|
+
const out = {};
|
|
46
|
+
try {
|
|
47
|
+
const cloud = steam.cloud;
|
|
48
|
+
for (const file of cloud.listFiles?.() ?? []) {
|
|
49
|
+
if (!file.name.endsWith('.txt'))
|
|
50
|
+
continue;
|
|
51
|
+
const value = cloud.readFile(file.name);
|
|
52
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
53
|
+
out[file.name.slice(0, -'.txt'.length)] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* Cloud storage off for this title or this account. The local file is the answer. */
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function readLocalSnapshot() {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(readFileSync(storeFile(), 'utf8'));
|
|
65
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
66
|
+
return {};
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
69
|
+
if (typeof value === 'string')
|
|
70
|
+
out[key] = value;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* No file yet, or an unreadable one. A fresh store is the right answer to both: a
|
|
76
|
+
preference is never worth failing a boot for. */
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function installIpc(snapshot, windowOf, steam = null) {
|
|
81
|
+
const entries = new Map(Object.entries(snapshot));
|
|
82
|
+
let pending = null;
|
|
83
|
+
/*
|
|
84
|
+
* **Where a save actually lands, decided once, here.** A game asks the same `KeyValueStore` for
|
|
85
|
+
* its preferences whatever platform it is on; whether those bytes go to a file beside the
|
|
86
|
+
* application or into Steam Cloud — and therefore onto the player's other machine — is this
|
|
87
|
+
* process's decision and nothing the renderer can see. That is the whole return on the seam.
|
|
88
|
+
*/
|
|
89
|
+
const cloud = steam === null ? null : new SteamCloudStore(steam.cloud);
|
|
90
|
+
const flush = () => {
|
|
91
|
+
pending = null;
|
|
92
|
+
if (cloud !== null) {
|
|
93
|
+
for (const [key, value] of entries)
|
|
94
|
+
cloud.write(key, value);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
void writeFile(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8').catch((cause) => console.error('[driftengine] could not write the store:', cause));
|
|
98
|
+
};
|
|
99
|
+
const schedule = () => {
|
|
100
|
+
if (pending === null)
|
|
101
|
+
pending = setTimeout(flush, WRITE_DEBOUNCE_MS);
|
|
102
|
+
};
|
|
103
|
+
ipcMain.on('drift:snapshot', (event) => {
|
|
104
|
+
event.returnValue = Object.fromEntries(entries);
|
|
105
|
+
});
|
|
106
|
+
ipcMain.on('drift:write', (_event, key, value) => {
|
|
107
|
+
entries.set(key, value);
|
|
108
|
+
schedule();
|
|
109
|
+
});
|
|
110
|
+
ipcMain.on('drift:remove', (_event, key) => {
|
|
111
|
+
entries.delete(key);
|
|
112
|
+
schedule();
|
|
113
|
+
});
|
|
114
|
+
ipcMain.on('drift:platformAvailable', (event) => {
|
|
115
|
+
event.returnValue = steam !== null;
|
|
116
|
+
});
|
|
117
|
+
ipcMain.on('drift:achievement', (_event, id) => steam?.unlockAchievement(id));
|
|
118
|
+
ipcMain.on('drift:presence', (_event, text) => steam?.setRichPresence(text));
|
|
119
|
+
ipcMain.on('drift:isFullscreen', (event) => {
|
|
120
|
+
event.returnValue = windowOf()?.isFullScreen() ?? false;
|
|
121
|
+
});
|
|
122
|
+
ipcMain.handle('drift:setFullscreen', (_event, on) => {
|
|
123
|
+
windowOf()?.setFullScreen(on);
|
|
124
|
+
});
|
|
125
|
+
ipcMain.on('drift:refreshHz', (event) => {
|
|
126
|
+
event.returnValue = refreshHzFor(windowOf());
|
|
127
|
+
});
|
|
128
|
+
ipcMain.on('drift:mode', (event) => {
|
|
129
|
+
event.returnValue = windowOf()?.isFullScreen() === true ? 'fullscreen' : 'windowed';
|
|
130
|
+
});
|
|
131
|
+
ipcMain.handle('drift:setMode', (_event, mode) => {
|
|
132
|
+
const window = windowOf();
|
|
133
|
+
if (window === null)
|
|
134
|
+
return false;
|
|
135
|
+
window.setFullScreen(mode === 'fullscreen');
|
|
136
|
+
/* Read back rather than assume. A window manager may refuse, and a settings screen that
|
|
137
|
+
believed the request would show a state the desktop disagrees with. */
|
|
138
|
+
return window.isFullScreen() === (mode === 'fullscreen');
|
|
139
|
+
});
|
|
140
|
+
ipcMain.on('drift:size', (event) => {
|
|
141
|
+
const [width = 0, height = 0] = windowOf()?.getContentSize() ?? [];
|
|
142
|
+
event.returnValue = { width, height };
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* Whether a resize would be performed, decided once for the two handlers that need it.
|
|
146
|
+
*
|
|
147
|
+
* **Refused while the window is covering a display**, because there is nothing honest to do:
|
|
148
|
+
* Chromium never takes an exclusive fullscreen, so there is no display mode to change, and
|
|
149
|
+
* resizing the window out from under a fullscreen state would silently leave it. A game that
|
|
150
|
+
* wants fewer pixels while fullscreen changes its render scale, which is the engine's own
|
|
151
|
+
* `RenderQuality`, not the window's business.
|
|
152
|
+
*
|
|
153
|
+
* **One function and not two conditions**, because the two handlers below answer the same
|
|
154
|
+
* question — one by doing it and one by being asked — and a pair that drifted would give a
|
|
155
|
+
* settings screen a greyed control that works, or a live one that does nothing. `AGENTS.md` calls
|
|
156
|
+
* that shape out on its own: two implementations of one decision drift, and they drift invisibly
|
|
157
|
+
* when they start identical.
|
|
158
|
+
*/
|
|
159
|
+
const resizable = () => {
|
|
160
|
+
const window = windowOf();
|
|
161
|
+
return window !== null && !window.isFullScreen();
|
|
162
|
+
};
|
|
163
|
+
/*
|
|
164
|
+
* Synchronous, like `drift:size`: a settings screen asks this while it is drawing a control, and
|
|
165
|
+
* an answer a frame later would draw the control twice.
|
|
166
|
+
*/
|
|
167
|
+
ipcMain.on('drift:canSetSize', (event) => {
|
|
168
|
+
event.returnValue = resizable();
|
|
169
|
+
});
|
|
170
|
+
ipcMain.handle('drift:setSize', (_event, width, height) => {
|
|
171
|
+
if (!resizable())
|
|
172
|
+
return false;
|
|
173
|
+
const window = windowOf();
|
|
174
|
+
if (window === null)
|
|
175
|
+
return false;
|
|
176
|
+
window.setContentSize(Math.round(width), Math.round(height));
|
|
177
|
+
return true;
|
|
178
|
+
});
|
|
179
|
+
ipcMain.on('drift:displays', (event) => {
|
|
180
|
+
const primary = screen.getPrimaryDisplay();
|
|
181
|
+
event.returnValue = screen.getAllDisplays().map((display) => ({
|
|
182
|
+
id: String(display.id),
|
|
183
|
+
/* A platform that has no name for a display gets its size, which is what a person picking
|
|
184
|
+
between two monitors in a list actually recognises. */
|
|
185
|
+
label: typeof display.label === 'string' && display.label.length > 0
|
|
186
|
+
? display.label
|
|
187
|
+
: `${display.size.width}x${display.size.height}`,
|
|
188
|
+
width: display.size.width,
|
|
189
|
+
height: display.size.height,
|
|
190
|
+
scale: display.scaleFactor,
|
|
191
|
+
refreshHz: typeof display.displayFrequency === 'number' && display.displayFrequency > 0
|
|
192
|
+
? display.displayFrequency
|
|
193
|
+
: null,
|
|
194
|
+
primary: display.id === primary.id,
|
|
195
|
+
}));
|
|
196
|
+
});
|
|
197
|
+
/*
|
|
198
|
+
* **Quitting is a two-part protocol and the interest flag is what keeps it cheap.** A game that
|
|
199
|
+
* registers `onQuitRequest` wants a moment to flush a save; a game that does not must close
|
|
200
|
+
* instantly. Without the flag the only correct implementation defers *every* close for a grace
|
|
201
|
+
* period, which is three seconds of nothing on the way out of a game that had nothing to say.
|
|
202
|
+
*/
|
|
203
|
+
let noticeWanted = false;
|
|
204
|
+
let closing = false;
|
|
205
|
+
ipcMain.on('drift:quitInterest', () => {
|
|
206
|
+
noticeWanted = true;
|
|
207
|
+
});
|
|
208
|
+
ipcMain.on('drift:quit', () => {
|
|
209
|
+
closing = true;
|
|
210
|
+
app.quit();
|
|
211
|
+
});
|
|
212
|
+
ipcMain.handle('drift:openFile', async (_event, accept) => {
|
|
213
|
+
const result = await dialog.showOpenDialog({
|
|
214
|
+
properties: ['openFile'],
|
|
215
|
+
filters: [{ name: 'files', extensions: accept.map((entry) => entry.replace(/^\./, '')) }],
|
|
216
|
+
});
|
|
217
|
+
const path = result.filePaths[0];
|
|
218
|
+
if (result.canceled || path === undefined)
|
|
219
|
+
return null;
|
|
220
|
+
return { name: path.split(/[\\/]/).pop() ?? path, bytes: new Uint8Array(await readFile(path)) };
|
|
221
|
+
});
|
|
222
|
+
ipcMain.handle('drift:saveFile', async (_event, name, bytes) => {
|
|
223
|
+
const result = await dialog.showSaveDialog({ defaultPath: name });
|
|
224
|
+
if (result.canceled || result.filePath === undefined)
|
|
225
|
+
return false;
|
|
226
|
+
await writeFile(result.filePath, bytes);
|
|
227
|
+
return true;
|
|
228
|
+
});
|
|
229
|
+
/*
|
|
230
|
+
* **Flushed synchronously on the way out, and the synchronous part is the whole point.**
|
|
231
|
+
*
|
|
232
|
+
* A debounce that loses its last write loses exactly the setting somebody just changed before
|
|
233
|
+
* quitting — and an *asynchronous* flush here loses it too, silently, because `app.quit()`
|
|
234
|
+
* tears the process down without waiting for a promise. Measured: a probe that wrote a key and
|
|
235
|
+
* then asked to quit left no file at all. A blocking write of a few kilobytes on the way out is
|
|
236
|
+
* not a cost anybody can perceive; the alternative is a settings screen that sometimes works.
|
|
237
|
+
*/
|
|
238
|
+
app.on('before-quit', () => {
|
|
239
|
+
if (pending === null)
|
|
240
|
+
return;
|
|
241
|
+
clearTimeout(pending);
|
|
242
|
+
pending = null;
|
|
243
|
+
try {
|
|
244
|
+
if (cloud !== null) {
|
|
245
|
+
for (const [key, value] of entries)
|
|
246
|
+
cloud.write(key, value);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
writeFileSync(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8');
|
|
250
|
+
}
|
|
251
|
+
catch (cause) {
|
|
252
|
+
console.error('[driftengine] could not write the store on the way out:', cause);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
wantsNotice: () => noticeWanted,
|
|
257
|
+
mayClose: () => closing,
|
|
258
|
+
deferClose: (window) => {
|
|
259
|
+
window.webContents.send('drift:quitRequest');
|
|
260
|
+
/* The grace is a ceiling, not a wait: a game that answers with `requestQuit` closes at
|
|
261
|
+
once. Without one, a game that hangs must not make the window unclosable. */
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
closing = true;
|
|
264
|
+
window.close();
|
|
265
|
+
}, QUIT_GRACE_MS);
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The refresh rate of the display this window is on, which the browser implementation of
|
|
271
|
+
* `DisplayControl` has to answer null for.
|
|
272
|
+
*
|
|
273
|
+
* Null rather than a plausible 60 where the platform withholds it, matching the engine's rule:
|
|
274
|
+
* something will divide by this.
|
|
275
|
+
*/
|
|
276
|
+
export function refreshHzFor(window) {
|
|
277
|
+
if (window === null)
|
|
278
|
+
return null;
|
|
279
|
+
const bounds = window.getBounds();
|
|
280
|
+
const display = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y });
|
|
281
|
+
return typeof display.displayFrequency === 'number' && display.displayFrequency > 0
|
|
282
|
+
? display.displayFrequency
|
|
283
|
+
: null;
|
|
284
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function externalTarget(url: string): string | null;
|
|
@@ -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
|
+
export function externalTarget(url) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = new URL(url);
|
|
22
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MenuItemConstructorOptions } from 'electron';
|
|
2
|
+
/**
|
|
3
|
+
* The menu a shipped game has, which on two of three platforms is none.
|
|
4
|
+
*
|
|
5
|
+
* Electron installs a default menu bar with File, Edit, View, Window and Help. Nothing in it
|
|
6
|
+
* belongs to a game — two of its items reload the page and open developer tools — and its presence
|
|
7
|
+
* is the single clearest sign that what you are looking at is a web page in a frame.
|
|
8
|
+
*
|
|
9
|
+
* **macOS is the exception, and not for appearance.** The application menu is where `Cmd+Q`,
|
|
10
|
+
* `Cmd+H` and the services menu live; an application with no menu on that platform is one a person
|
|
11
|
+
* cannot quit with the shortcut every other application answers to. So it keeps the smallest menu
|
|
12
|
+
* that behaves like a Mac application: the app menu, Edit — where copy and paste are roles rather
|
|
13
|
+
* than key handlers a game would have to write — and Window.
|
|
14
|
+
*
|
|
15
|
+
* Pure, so the shape is testable without a running Electron.
|
|
16
|
+
*/
|
|
17
|
+
export declare function appMenuTemplate(productName: string, platform: NodeJS.Platform): MenuItemConstructorOptions[] | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menu a shipped game has, which on two of three platforms is none.
|
|
3
|
+
*
|
|
4
|
+
* Electron installs a default menu bar with File, Edit, View, Window and Help. Nothing in it
|
|
5
|
+
* belongs to a game — two of its items reload the page and open developer tools — and its presence
|
|
6
|
+
* is the single clearest sign that what you are looking at is a web page in a frame.
|
|
7
|
+
*
|
|
8
|
+
* **macOS is the exception, and not for appearance.** The application menu is where `Cmd+Q`,
|
|
9
|
+
* `Cmd+H` and the services menu live; an application with no menu on that platform is one a person
|
|
10
|
+
* cannot quit with the shortcut every other application answers to. So it keeps the smallest menu
|
|
11
|
+
* that behaves like a Mac application: the app menu, Edit — where copy and paste are roles rather
|
|
12
|
+
* than key handlers a game would have to write — and Window.
|
|
13
|
+
*
|
|
14
|
+
* Pure, so the shape is testable without a running Electron.
|
|
15
|
+
*/
|
|
16
|
+
export function appMenuTemplate(productName, platform) {
|
|
17
|
+
if (platform !== 'darwin')
|
|
18
|
+
return null;
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
label: productName,
|
|
22
|
+
submenu: [
|
|
23
|
+
{ role: 'about' },
|
|
24
|
+
{ type: 'separator' },
|
|
25
|
+
{ role: 'hide' },
|
|
26
|
+
{ role: 'hideOthers' },
|
|
27
|
+
{ role: 'unhide' },
|
|
28
|
+
{ type: 'separator' },
|
|
29
|
+
{ role: 'quit' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
label: 'Edit',
|
|
34
|
+
submenu: [
|
|
35
|
+
{ role: 'undo' },
|
|
36
|
+
{ role: 'redo' },
|
|
37
|
+
{ type: 'separator' },
|
|
38
|
+
{ role: 'cut' },
|
|
39
|
+
{ role: 'copy' },
|
|
40
|
+
{ role: 'paste' },
|
|
41
|
+
{ role: 'selectAll' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'Window',
|
|
46
|
+
submenu: [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A path inside the packaged application, or null when it is not inside it.
|
|
3
|
+
*
|
|
4
|
+
* **Separate from the protocol handler so it can be tested at all.** This is the only part of the
|
|
5
|
+
* main process with a security consequence: the renderer runs with `nodeIntegration: false` and
|
|
6
|
+
* `sandbox: true`, so `drift://` is the single filesystem surface it can reach, and a traversal
|
|
7
|
+
* through it hands a compromised renderer the disk.
|
|
8
|
+
*
|
|
9
|
+
* The separator in the comparison is load-bearing. A plain `startsWith(root)` accepts
|
|
10
|
+
* `/app-secrets` for a root of `/app`, which is a sibling directory rather than a child.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveWithinRoot(root: string, pathname: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* A content type, from the extension, for the handful a game serves.
|
|
15
|
+
*
|
|
16
|
+
* **A wrong one is not cosmetic here.** A module served as `text/plain` is refused by the module
|
|
17
|
+
* loader with a message about MIME type, and a `.wasm` served as anything but
|
|
18
|
+
* `application/wasm` cannot be streamed-compiled. Everything unrecognised is
|
|
19
|
+
* `application/octet-stream`, which is correct for the container and for every asset.
|
|
20
|
+
*/
|
|
21
|
+
export declare function contentTypeFor(path: string): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join, normalize, sep } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* A path inside the packaged application, or null when it is not inside it.
|
|
4
|
+
*
|
|
5
|
+
* **Separate from the protocol handler so it can be tested at all.** This is the only part of the
|
|
6
|
+
* main process with a security consequence: the renderer runs with `nodeIntegration: false` and
|
|
7
|
+
* `sandbox: true`, so `drift://` is the single filesystem surface it can reach, and a traversal
|
|
8
|
+
* through it hands a compromised renderer the disk.
|
|
9
|
+
*
|
|
10
|
+
* The separator in the comparison is load-bearing. A plain `startsWith(root)` accepts
|
|
11
|
+
* `/app-secrets` for a root of `/app`, which is a sibling directory rather than a child.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveWithinRoot(root, pathname) {
|
|
14
|
+
const resolved = normalize(join(root, pathname));
|
|
15
|
+
if (resolved === root)
|
|
16
|
+
return resolved;
|
|
17
|
+
return resolved.startsWith(root + sep) ? resolved : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A content type, from the extension, for the handful a game serves.
|
|
21
|
+
*
|
|
22
|
+
* **A wrong one is not cosmetic here.** A module served as `text/plain` is refused by the module
|
|
23
|
+
* loader with a message about MIME type, and a `.wasm` served as anything but
|
|
24
|
+
* `application/wasm` cannot be streamed-compiled. Everything unrecognised is
|
|
25
|
+
* `application/octet-stream`, which is correct for the container and for every asset.
|
|
26
|
+
*/
|
|
27
|
+
export function contentTypeFor(path) {
|
|
28
|
+
const dot = path.lastIndexOf('.');
|
|
29
|
+
switch (dot === -1 ? '' : path.slice(dot + 1).toLowerCase()) {
|
|
30
|
+
case 'html':
|
|
31
|
+
return 'text/html; charset=utf-8';
|
|
32
|
+
case 'js':
|
|
33
|
+
case 'mjs':
|
|
34
|
+
return 'text/javascript; charset=utf-8';
|
|
35
|
+
case 'css':
|
|
36
|
+
return 'text/css; charset=utf-8';
|
|
37
|
+
case 'json':
|
|
38
|
+
return 'application/json; charset=utf-8';
|
|
39
|
+
case 'wasm':
|
|
40
|
+
return 'application/wasm';
|
|
41
|
+
case 'svg':
|
|
42
|
+
/* An `<img>` refuses an SVG served as anything else, and the shell's own badge is one:
|
|
43
|
+
the splash showed a broken-image icon until this case existed. */
|
|
44
|
+
return 'image/svg+xml';
|
|
45
|
+
case 'png':
|
|
46
|
+
return 'image/png';
|
|
47
|
+
case 'jpg':
|
|
48
|
+
case 'jpeg':
|
|
49
|
+
return 'image/jpeg';
|
|
50
|
+
case 'webp':
|
|
51
|
+
return 'image/webp';
|
|
52
|
+
case 'gif':
|
|
53
|
+
return 'image/gif';
|
|
54
|
+
case 'ico':
|
|
55
|
+
return 'image/x-icon';
|
|
56
|
+
case 'txt':
|
|
57
|
+
return 'text/plain; charset=utf-8';
|
|
58
|
+
case 'ktx2':
|
|
59
|
+
return 'image/ktx2';
|
|
60
|
+
case 'ogg':
|
|
61
|
+
return 'audio/ogg';
|
|
62
|
+
case 'mp3':
|
|
63
|
+
return 'audio/mpeg';
|
|
64
|
+
case 'wav':
|
|
65
|
+
return 'audio/wav';
|
|
66
|
+
default:
|
|
67
|
+
return 'application/octet-stream';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
export declare function isBlockedShortcut(input: KeyPress, platform: NodeJS.Platform, development: boolean): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Reload, print, view source, find, and the two zoom directions plus reset. */
|
|
2
|
+
const WITH_MODIFIER = new Set(['r', 'p', 'u', 'f', 'g', '+', '-', '=', '0']);
|
|
3
|
+
/** The inspector, on the two combinations every platform binds it to. */
|
|
4
|
+
const INSPECTOR = new Set(['i', 'j', 'c']);
|
|
5
|
+
export function isBlockedShortcut(input, platform, development) {
|
|
6
|
+
if (development)
|
|
7
|
+
return false;
|
|
8
|
+
if (input.type !== 'keyDown')
|
|
9
|
+
return false;
|
|
10
|
+
const key = input.key.toLowerCase();
|
|
11
|
+
if (key === 'f5' || key === 'f12')
|
|
12
|
+
return true;
|
|
13
|
+
const command = input.control || input.meta;
|
|
14
|
+
if (!command)
|
|
15
|
+
return false;
|
|
16
|
+
if (input.shift && INSPECTOR.has(key))
|
|
17
|
+
return true;
|
|
18
|
+
if (input.meta && input.alt && INSPECTOR.has(key))
|
|
19
|
+
return true;
|
|
20
|
+
if (WITH_MODIFIER.has(key))
|
|
21
|
+
return true;
|
|
22
|
+
/* Closing: a platform gesture on macOS, a browser one everywhere else. */
|
|
23
|
+
if (key === 'w' && input.control && platform !== 'darwin')
|
|
24
|
+
return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BrowserWindow } from 'electron';
|
|
2
|
+
/**
|
|
3
|
+
* The window the engine badge is drawn in.
|
|
4
|
+
*
|
|
5
|
+
* Frameless and fixed, because a splash somebody can resize is a splash somebody has to think
|
|
6
|
+
* about. Opaque rather than transparent: transparency on Linux depends on a compositor that may
|
|
7
|
+
* not be there, and the failure is a black rectangle with the logo cut out of it — worse than
|
|
8
|
+
* having no rounded corners.
|
|
9
|
+
*
|
|
10
|
+
* **Shown on its own first paint, not on creation.** The same rule the main window follows, for
|
|
11
|
+
* the same reason: a window that appears empty and then fills is the thing this page exists to
|
|
12
|
+
* prevent, and a splash that does it is an unusually poor joke.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createSplash(): BrowserWindow;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BrowserWindow } from 'electron';
|
|
2
|
+
/**
|
|
3
|
+
* The window the engine badge is drawn in.
|
|
4
|
+
*
|
|
5
|
+
* Frameless and fixed, because a splash somebody can resize is a splash somebody has to think
|
|
6
|
+
* about. Opaque rather than transparent: transparency on Linux depends on a compositor that may
|
|
7
|
+
* not be there, and the failure is a black rectangle with the logo cut out of it — worse than
|
|
8
|
+
* having no rounded corners.
|
|
9
|
+
*
|
|
10
|
+
* **Shown on its own first paint, not on creation.** The same rule the main window follows, for
|
|
11
|
+
* the same reason: a window that appears empty and then fills is the thing this page exists to
|
|
12
|
+
* prevent, and a splash that does it is an unusually poor joke.
|
|
13
|
+
*/
|
|
14
|
+
export function createSplash() {
|
|
15
|
+
const splash = new BrowserWindow({
|
|
16
|
+
width: 420,
|
|
17
|
+
height: 300,
|
|
18
|
+
frame: false,
|
|
19
|
+
resizable: false,
|
|
20
|
+
movable: false,
|
|
21
|
+
minimizable: false,
|
|
22
|
+
maximizable: false,
|
|
23
|
+
fullscreenable: false,
|
|
24
|
+
skipTaskbar: true,
|
|
25
|
+
/* Above the game's own window, which is hidden until the swap, and above whatever else is
|
|
26
|
+
on screen for the second or two it lives. A splash that can be buried looks like a hang. */
|
|
27
|
+
alwaysOnTop: true,
|
|
28
|
+
show: false,
|
|
29
|
+
backgroundColor: '#0b0b0d',
|
|
30
|
+
webPreferences: {
|
|
31
|
+
/* It runs no script and reaches nothing. There is no bridge here on purpose: the badge has
|
|
32
|
+
no business holding a handle to the filesystem. */
|
|
33
|
+
contextIsolation: true,
|
|
34
|
+
nodeIntegration: false,
|
|
35
|
+
sandbox: true,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
splash.once('ready-to-show', () => splash.show());
|
|
39
|
+
void splash.loadURL('drift://shell/splash.html');
|
|
40
|
+
return splash;
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the badge gives the screen to the game.
|
|
3
|
+
*
|
|
4
|
+
* Two conditions and a cap, and each is there for a failure that has a name:
|
|
5
|
+
*
|
|
6
|
+
* - **The game has painted.** Swapping before that shows a white or black window while the
|
|
7
|
+
* first frame is still being built, which is the thing a splash exists to prevent.
|
|
8
|
+
* - **The minimum has passed *since the badge reached the screen*.** A badge that vanishes the
|
|
9
|
+
* instant a fast machine paints is a flicker rather than a splash, and the machines that boot
|
|
10
|
+
* fastest are the ones a developer tests on. Measured from the badge rather than from process
|
|
11
|
+
* start because the badge's own arrival is an unbounded wait — a second window, a protocol
|
|
12
|
+
* handler and an SVG — and measuring the budget from before it started is how a splash gets
|
|
13
|
+
* closed before it is ever shown. Timed on one machine at `minMs: 900`: the badge reached the
|
|
14
|
+
* screen at 411 ms unpacked and at 1659 ms as an AppImage, where the whole budget had already
|
|
15
|
+
* elapsed. It survived there only because the game happened to paint later still.
|
|
16
|
+
* - **The cap overrides both.** A boot that never finishes must not leave a logo as the whole
|
|
17
|
+
* product; past the cap the window is shown whatever state it is in, because a broken game
|
|
18
|
+
* that can be seen is better than a brand that cannot be dismissed.
|
|
19
|
+
*
|
|
20
|
+
* Pure, so all three are testable without a window: the caller owns the clock.
|
|
21
|
+
*/
|
|
22
|
+
export type SplashDecision = 'hold' | 'swap';
|
|
23
|
+
/**
|
|
24
|
+
* The ceiling on the whole splash, whatever the manifest asked for.
|
|
25
|
+
*
|
|
26
|
+
* Twenty seconds is long enough for a large world to stream in on a slow disk — which is exactly
|
|
27
|
+
* the boot a splash should cover — and short enough that a failure is seen in the same sitting.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SPLASH_HARD_CAP_MS = 20000;
|
|
30
|
+
/**
|
|
31
|
+
* @param sinceStartMs Since the main window was created, which is what the cap is measured against.
|
|
32
|
+
* @param sinceBadgeMs Since the badge reached the screen, or `null` while it has not. A caller that
|
|
33
|
+
* has given up on the badge ever appearing passes `sinceStartMs`, which is the behaviour this had
|
|
34
|
+
* before the badge was waited for at all.
|
|
35
|
+
*/
|
|
36
|
+
export declare function splashDecision(sinceStartMs: number, sinceBadgeMs: number | null, minMs: number, contentReady: boolean, capMs?: number): SplashDecision;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ceiling on the whole splash, whatever the manifest asked for.
|
|
3
|
+
*
|
|
4
|
+
* Twenty seconds is long enough for a large world to stream in on a slow disk — which is exactly
|
|
5
|
+
* the boot a splash should cover — and short enough that a failure is seen in the same sitting.
|
|
6
|
+
*/
|
|
7
|
+
export const SPLASH_HARD_CAP_MS = 20_000;
|
|
8
|
+
/**
|
|
9
|
+
* @param sinceStartMs Since the main window was created, which is what the cap is measured against.
|
|
10
|
+
* @param sinceBadgeMs Since the badge reached the screen, or `null` while it has not. A caller that
|
|
11
|
+
* has given up on the badge ever appearing passes `sinceStartMs`, which is the behaviour this had
|
|
12
|
+
* before the badge was waited for at all.
|
|
13
|
+
*/
|
|
14
|
+
export function splashDecision(sinceStartMs, sinceBadgeMs, minMs, contentReady, capMs = SPLASH_HARD_CAP_MS) {
|
|
15
|
+
if (sinceStartMs >= capMs)
|
|
16
|
+
return 'swap';
|
|
17
|
+
if (!contentReady)
|
|
18
|
+
return 'hold';
|
|
19
|
+
/* The game is ready and the badge is not on screen yet: showing the game now is what makes a
|
|
20
|
+
splash vanish entirely on somebody else's machine. The cap above is what bounds this. */
|
|
21
|
+
if (sinceBadgeMs === null)
|
|
22
|
+
return 'hold';
|
|
23
|
+
return sinceBadgeMs >= minMs ? 'swap' : 'hold';
|
|
24
|
+
}
|