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