@driftengine/package 3.63.0 → 4.1.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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The native artifact's `licenses/`: Node's licence, and each package's licence files, with an
3
+ * index saying what every part is under.
4
+ *
5
+ * **Because a bundle drops them.** The desktop target ships Electron, whose own licence files come
6
+ * with it, and a game's web build that its own bundler made. The native target bundles every
7
+ * package the game and the host import into one file, and most of them are MIT or BSD, whose one
8
+ * condition is that the notice goes with the copy — so this build is the one that has to carry it.
9
+ *
10
+ * **Node's licence is the binary's own**, and covers what Node carries inside it: V8, OpenSSL, ICU
11
+ * and the rest. It is read from beside the binary where the release archive puts it, or from `n`'s
12
+ * cache, and otherwise fetched at the exact release that is being shipped. A file there that does
13
+ * not open as Node's does is passed over, because `LICENSE` one directory above `/usr/local/bin` can
14
+ * be anybody's.
15
+ *
16
+ * **The LGPL 3.0 comes with the GPL 3.0**, because it is written as permissions on top of it and
17
+ * asks for both texts; the library's own package carries only the first. And **a copyleft package is
18
+ * named** (`copyleftOf`) so that the build can refuse to bundle one: a dual licence counts as
19
+ * permissive when either half is.
20
+ *
21
+ * What it gives up: a package whose licence is only a word in its manifest has no text to copy, and
22
+ * the index says so for each one rather than inventing one.
23
+ */
24
+ /**
25
+ * Each of `packageDirs` under a copyleft licence, as `name (licence)`. The native build refuses to
26
+ * bundle one: the LGPL lets a work of any licence use its library so long as a person can put a
27
+ * modified copy in its place, which one file with the game in it does not allow, and the others
28
+ * reach further.
29
+ */
30
+ export declare function copyleftOf(packageDirs: Iterable<string>): string[];
31
+ export type FetchText = (url: string) => Promise<string>;
32
+ /** Node's licence for the binary at `execPath`, which is `version`, or a refusal naming both. */
33
+ export declare function nodeLicense(execPath: string, version: string, fetchText: FetchText): Promise<string>;
34
+ /**
35
+ * The licences directory for `packageDirs` and Node, written into `into`. `gpl` is the GPL's text,
36
+ * which goes beside any part under the LGPL 3.0: that licence is written as permissions on top of
37
+ * the GPL, and its section 4 asks for both texts to accompany a work that uses the library.
38
+ */
39
+ export declare function writeLicenses(packageDirs: Iterable<string>, node: {
40
+ readonly version: string;
41
+ readonly text: string;
42
+ }, into: string, gpl: string | null): Promise<void>;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The native artifact's `licenses/`: Node's licence, and each package's licence files, with an
3
+ * index saying what every part is under.
4
+ *
5
+ * **Because a bundle drops them.** The desktop target ships Electron, whose own licence files come
6
+ * with it, and a game's web build that its own bundler made. The native target bundles every
7
+ * package the game and the host import into one file, and most of them are MIT or BSD, whose one
8
+ * condition is that the notice goes with the copy — so this build is the one that has to carry it.
9
+ *
10
+ * **Node's licence is the binary's own**, and covers what Node carries inside it: V8, OpenSSL, ICU
11
+ * and the rest. It is read from beside the binary where the release archive puts it, or from `n`'s
12
+ * cache, and otherwise fetched at the exact release that is being shipped. A file there that does
13
+ * not open as Node's does is passed over, because `LICENSE` one directory above `/usr/local/bin` can
14
+ * be anybody's.
15
+ *
16
+ * **The LGPL 3.0 comes with the GPL 3.0**, because it is written as permissions on top of it and
17
+ * asks for both texts; the library's own package carries only the first. And **a copyleft package is
18
+ * named** (`copyleftOf`) so that the build can refuse to bundle one: a dual licence counts as
19
+ * permissive when either half is.
20
+ *
21
+ * What it gives up: a package whose licence is only a word in its manifest has no text to copy, and
22
+ * the index says so for each one rather than inventing one.
23
+ */
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises';
26
+ import { dirname, join } from 'node:path';
27
+ /** What a package's licence files are called, and its notice, which Apache-2.0 asks to go too. */
28
+ const LICENCE_FILE = /^(licen[cs]e|copying|notice)([.-].*)?$/i;
29
+ const NODE_OPENS = 'Node.js is licensed';
30
+ /** The licences whose terms reach the file a library is bundled into, by their SPDX names. */
31
+ const COPYLEFT = /\b(?:AGPL|LGPL|GPL|MPL|EPL|EUPL|CDDL|OSL)-/i;
32
+ const LGPL3 = /\bLGPL-3\.0/i;
33
+ function manifestOf(dir) {
34
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
35
+ }
36
+ /**
37
+ * Whether `license`, an SPDX expression, holds a work to a copyleft licence: every alternative of
38
+ * an `OR` is copyleft, since otherwise a permissive one can be chosen.
39
+ */
40
+ function copyleft(license) {
41
+ return license
42
+ .replace(/[()]/g, '')
43
+ .split(/\s+OR\s+/i)
44
+ .every((alternative) => COPYLEFT.test(alternative));
45
+ }
46
+ /**
47
+ * Each of `packageDirs` under a copyleft licence, as `name (licence)`. The native build refuses to
48
+ * bundle one: the LGPL lets a work of any licence use its library so long as a person can put a
49
+ * modified copy in its place, which one file with the game in it does not allow, and the others
50
+ * reach further.
51
+ */
52
+ export function copyleftOf(packageDirs) {
53
+ const found = [];
54
+ for (const dir of packageDirs) {
55
+ const { name, license } = manifestOf(dir);
56
+ if (typeof license === 'string' && copyleft(license))
57
+ found.push(`${name} (${license})`);
58
+ }
59
+ return found;
60
+ }
61
+ /** Node's licence for the binary at `execPath`, which is `version`, or a refusal naming both. */
62
+ export async function nodeLicense(execPath, version, fetchText) {
63
+ const prefix = dirname(dirname(execPath));
64
+ for (const candidate of [
65
+ join(prefix, 'LICENSE'),
66
+ join(prefix, 'n', 'versions', 'node', version.replace(/^v/, ''), 'LICENSE'),
67
+ ]) {
68
+ if (!existsSync(candidate))
69
+ continue;
70
+ const text = readFileSync(candidate, 'utf8');
71
+ if (text.startsWith(NODE_OPENS))
72
+ return text;
73
+ }
74
+ const url = `https://raw.githubusercontent.com/nodejs/node/${version}/LICENSE`;
75
+ let text;
76
+ try {
77
+ text = await fetchText(url);
78
+ }
79
+ catch (cause) {
80
+ throw new Error(`the native artifact ships Node ${version} and must carry Node's licence, and there is no ` +
81
+ `copy beside ${execPath} and none could be fetched from ${url}: ${String(cause)}`);
82
+ }
83
+ if (!text.startsWith(NODE_OPENS)) {
84
+ throw new Error(`${url} answered a text that is not Node's licence; nothing was written`);
85
+ }
86
+ return text;
87
+ }
88
+ /**
89
+ * The licences directory for `packageDirs` and Node, written into `into`. `gpl` is the GPL's text,
90
+ * which goes beside any part under the LGPL 3.0: that licence is written as permissions on top of
91
+ * the GPL, and its section 4 asks for both texts to accompany a work that uses the library.
92
+ */
93
+ export async function writeLicenses(packageDirs, node, into, gpl) {
94
+ const packages = [...packageDirs].map((dir) => ({ dir, manifest: manifestOf(dir) }));
95
+ packages.sort((a, b) => (a.manifest.name < b.manifest.name ? -1 : 1));
96
+ const lesser = packages.find(({ manifest }) => typeof manifest.license === 'string' && LGPL3.test(manifest.license));
97
+ if (lesser !== undefined && gpl === null) {
98
+ throw new Error(`${lesser.manifest.name} is ${String(lesser.manifest.license)}, which asks for the GPL's text ` +
99
+ 'to go with it, and none was given to write beside it');
100
+ }
101
+ await mkdir(join(into, 'node'), { recursive: true });
102
+ await writeFile(join(into, 'node', 'LICENSE'), node.text);
103
+ const lines = [`node ${node.version} — node/LICENSE`];
104
+ for (const { dir, manifest } of packages) {
105
+ const folder = manifest.name.replace('/', '+');
106
+ const named = typeof manifest.license === 'string' ? manifest.license : 'no licence';
107
+ const files = readdirSync(dir, { withFileTypes: true })
108
+ .filter((entry) => entry.isFile() && LICENCE_FILE.test(entry.name))
109
+ .map((entry) => entry.name)
110
+ .sort();
111
+ const heading = `${manifest.name} ${manifest.version ?? ''} (${named})`;
112
+ if (files.length === 0) {
113
+ lines.push(`${heading} — the package carries no licence file; its manifest names ${named}`);
114
+ continue;
115
+ }
116
+ await mkdir(join(into, folder), { recursive: true });
117
+ for (const file of files)
118
+ await copyFile(join(dir, file), join(into, folder, file));
119
+ lines.push(`${heading} — ${files.map((file) => `${folder}/${file}`).join(', ')}`);
120
+ }
121
+ if (lesser !== undefined && gpl !== null) {
122
+ await writeFile(join(into, 'GPL-3.0.txt'), gpl);
123
+ lines.push('GPL-3.0 — GPL-3.0.txt, which the LGPL-3.0 above is written against and asks to accompany it');
124
+ }
125
+ await writeFile(join(into, 'INDEX.txt'), [
126
+ 'Everything this application ships, and the licence each part is under.',
127
+ '',
128
+ ...lines,
129
+ '',
130
+ ].join('\n'));
131
+ }
package/dist/signing.js CHANGED
@@ -5,14 +5,14 @@ function has(env, name) {
5
5
  return typeof value === 'string' && value.length > 0;
6
6
  }
7
7
  export function planSigning(target, env, platform) {
8
- if (target === 'linux-x64') {
8
+ if (target === 'linux-x64' || target === 'native-linux-x64') {
9
9
  return {
10
10
  ...HOST_SIGNED,
11
11
  mode: 'none',
12
12
  notarise: false,
13
13
  /* No signature is expected of a Linux artifact by anything that runs one. An AppImage can
14
14
  carry a detached GPG signature and nothing checks it by default, so producing one would be
15
- ceremony rather than protection. */
15
+ ceremony rather than protection. The native host's archive is the same case. */
16
16
  reason: 'Linux artifacts are not signed; nothing on the platform checks one',
17
17
  };
18
18
  }
package/dist/toolchain.js CHANGED
@@ -167,6 +167,9 @@ export function requirementsFor(target) {
167
167
  /* XcodeGen is downloadable; Xcode is not, and pretending otherwise would be the lie this
168
168
  file exists to avoid — it is a sign-in and an App Store download, and only on a Mac. */
169
169
  return ['macOS', 'Xcode with the iOS SDK', PINS.xcodegen.label];
170
+ case 'native-linux-x64':
171
+ /* Nothing of the machine: it ships the Node that runs the build. The host is the game's. */
172
+ return ["@driftengine/native-host at the packager's version, installed in the game"];
170
173
  default:
171
174
  return [
172
175
  'the Electron runtime for this platform',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/package",
3
- "version": "3.63.0",
3
+ "version": "4.1.0",
4
4
  "description": "Builds an installable application from a game built on the engine",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -12,6 +12,16 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "default": "./dist/index.js"
14
14
  },
15
+ "./bridge": {
16
+ "drift-source": "./src/preload/bridge.ts",
17
+ "types": "./dist/preload/bridge.d.ts",
18
+ "default": "./dist/preload/bridge.js"
19
+ },
20
+ "./store": {
21
+ "drift-source": "./src/main/storeFile.ts",
22
+ "types": "./dist/main/storeFile.d.ts",
23
+ "default": "./dist/main/storeFile.js"
24
+ },
15
25
  "./package.json": "./package.json",
16
26
  "./*": "./*"
17
27
  },
@@ -35,7 +45,7 @@
35
45
  "drift-package": "bin/drift-package.mjs"
36
46
  },
37
47
  "peerDependencies": {
38
- "@driftengine/core": "3.63.0",
48
+ "@driftengine/core": "4.1.0",
39
49
  "steamworks.js": ">=0.4.0"
40
50
  },
41
51
  "dependencies": {
package/src/bootstrap.ts CHANGED
@@ -36,9 +36,19 @@ import {
36
36
  export async function bootstrap(targets: readonly Target[]): Promise<void> {
37
37
  const wantsAndroid = targets.includes('android');
38
38
  const wantsIos = targets.includes('ios');
39
- const wantsDesktop = targets.some((target) => target !== 'android' && target !== 'ios');
39
+ const wantsDesktop = targets.some(
40
+ (target) => target !== 'android' && target !== 'ios' && target !== 'native-linux-x64',
41
+ );
40
42
 
41
43
  if (wantsDesktop) await bootstrapElectron();
44
+ if (targets.includes('native-linux-x64')) {
45
+ /* A machine is ready for it as it is: the artifact carries the Node running the build, and the
46
+ host is a dependency of the game, which `npm install` there fetches. */
47
+ console.log(
48
+ 'ok: native-linux-x64 needs nothing fetched for this machine — it ships the Node running the ' +
49
+ "build — and the game needs @driftengine/native-host installed, at the packager's version",
50
+ );
51
+ }
42
52
  if (targets.some((target) => needsRcodesign(target))) await bootstrapRcodesign();
43
53
  if (wantsAndroid) await bootstrapAndroid();
44
54
  if (wantsIos) await bootstrapIos();
package/src/build.ts CHANGED
@@ -7,6 +7,7 @@ import { buildAndroid } from './android.ts';
7
7
  import { buildIos } from './ios.ts';
8
8
  import { crossSignMacApp, zipMacApp } from './macCrossSign.ts';
9
9
  import type { PackageManifest, Target } from './manifest.ts';
10
+ import { buildNative } from './native.ts';
10
11
  import { outFor } from './outDir.ts';
11
12
  import { resourceFile } from './resources.ts';
12
13
  import { ensureElectronRuntime } from './runtime.ts';
@@ -45,6 +46,9 @@ const HOST_FOR: Readonly<Record<Target, NodeJS.Platform | null>> = {
45
46
  'mac-arm64': 'darwin',
46
47
  'mac-x64': 'darwin',
47
48
  'linux-x64': 'linux',
49
+ /* The native host ships the Node that runs the build and the binaries installed beside it;
50
+ `native.ts` refuses the machine itself, architecture included. */
51
+ 'native-linux-x64': 'linux',
48
52
  /* Android's toolchain is Java and it runs anywhere; iOS needs Xcode, which is a Mac. */
49
53
  android: null,
50
54
  ios: 'darwin',
@@ -84,19 +88,6 @@ export async function build(
84
88
  targets: readonly Target[],
85
89
  options: ResourceOptions = {},
86
90
  ): Promise<void> {
87
- const { build: run, Arch, Platform } = await import('electron-builder');
88
-
89
- /* One artifact per target, and the architecture is named rather than inherited: a build on an
90
- Apple Silicon machine defaults to arm64, so `mac-x64` asked for on one would quietly produce
91
- the wrong slice. */
92
- const targetFor = (name: Target): ReturnType<typeof Platform.LINUX.createTarget> =>
93
- name === 'win-x64'
94
- ? Platform.WINDOWS.createTarget(undefined, Arch.x64)
95
- : name === 'linux-x64'
96
- ? Platform.LINUX.createTarget(undefined, Arch.x64)
97
- : Platform.MAC.createTarget(undefined, name === 'mac-x64' ? Arch.x64 : Arch.arm64);
98
- const electronVersion = await installedElectronVersion();
99
-
100
91
  for (const target of targets) {
101
92
  if (target === 'android') {
102
93
  await buildAndroid(manifest, cwd, outFor(cwd, target, options), options);
@@ -106,9 +97,30 @@ export async function build(
106
97
  await buildIos(manifest, cwd, outFor(cwd, target, options), options);
107
98
  continue;
108
99
  }
100
+ if (target === 'native-linux-x64') {
101
+ await buildNative(manifest, cwd, outFor(cwd, target, options));
102
+ continue;
103
+ }
109
104
  const refusal = refuseHost(target, process.platform);
110
105
  if (refusal !== null) throw new Error(refusal);
111
106
 
107
+ /*
108
+ * **Electron's builder and version are read here, for an Electron target**, rather than before
109
+ * the loop: a build of the native target alone needs neither.
110
+ *
111
+ * One artifact per target, and the architecture is named rather than inherited: a build on an
112
+ * Apple Silicon machine defaults to arm64, so `mac-x64` asked for on one would quietly produce
113
+ * the wrong slice.
114
+ */
115
+ const { build: run, Arch, Platform } = await import('electron-builder');
116
+ const targetFor = (name: Target): ReturnType<typeof Platform.LINUX.createTarget> =>
117
+ name === 'win-x64'
118
+ ? Platform.WINDOWS.createTarget(undefined, Arch.x64)
119
+ : name === 'linux-x64'
120
+ ? Platform.LINUX.createTarget(undefined, Arch.x64)
121
+ : Platform.MAC.createTarget(undefined, name === 'mac-x64' ? Arch.x64 : Arch.arm64);
122
+ const electronVersion = await installedElectronVersion();
123
+
112
124
  /*
113
125
  * **The runtime, before anything is staged.** `installedElectronVersion` below reads a version
114
126
  * out of `electron/package.json`, which is in the npm tarball — so it answers even when the
package/src/cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
4
4
  import { writeFile } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
@@ -13,6 +13,7 @@ import { init } from './init.ts';
13
13
  import { outFor } from './outDir.ts';
14
14
  import { parseManifest } from './manifest.ts';
15
15
  import type { PackageManifest, Target } from './manifest.ts';
16
+ import { executableName, nativeHost, nativeNotes, packagerVersion } from './native.ts';
16
17
  import { planSigning } from './signing.ts';
17
18
  import { electronMajor } from './runtimeVersion.ts';
18
19
  import { depotScripts } from './steam/depot.ts';
@@ -40,7 +41,7 @@ delete process.env.ELECTRON_RUN_AS_NODE;
40
41
  /**
41
42
  * `drift-package` — the command a consumer runs to get an installable application.
42
43
  *
43
- * Four subcommands, and `verify` is the one that is not obvious. An artifact that was never
44
+ * Seven subcommands, and `verify` is the one that is not obvious. An artifact that was never
44
45
  * rebuilt is byte-identical in every respect a build log can see, so the only honest check is to
45
46
  * look inside the shipped thing for a string from the change. This repository already learned that
46
47
  * about deploys: an asset hash proves nothing.
@@ -132,6 +133,20 @@ async function doctor(cwd: string, options: ResourceOptions = {}): Promise<Packa
132
133
  * arrive twenty minutes later from Gradle.
133
134
  */
134
135
  for (const target of manifest.targets) {
136
+ /*
137
+ * **The native target asks nothing of the machine and one thing of the game**: it ships the
138
+ * Node running this, and it needs the host installed beside the game, which `bootstrap` cannot
139
+ * do because it prepares a machine rather than a project.
140
+ */
141
+ if (target === 'native-linux-x64') {
142
+ for (const note of nativeNotes(manifest)) console.log(` ${target}: ${note}`);
143
+ try {
144
+ nativeHost(cwd, packagerVersion());
145
+ } catch (cause) {
146
+ console.log(` ${target}: ${cause instanceof Error ? cause.message : String(cause)}`);
147
+ }
148
+ continue;
149
+ }
135
150
  const ready = await toolchainReady(target);
136
151
  if (ready) continue;
137
152
  console.log(
@@ -142,10 +157,34 @@ async function doctor(cwd: string, options: ResourceOptions = {}): Promise<Packa
142
157
  return manifest;
143
158
  }
144
159
 
145
- function verify(cwd: string, needle: string): void {
160
+ /**
161
+ * **The native target is looked at in its own bundle**, because the build makes that from the game's
162
+ * source rather than copying the web build: the web build inside it, `public/`, and the copied
163
+ * modules are left out, so a fresh web build cannot vouch for a stale native one.
164
+ */
165
+ function verify(
166
+ cwd: string,
167
+ needle: string,
168
+ target: Target | null,
169
+ options: ResourceOptions,
170
+ ): void {
146
171
  const manifest = loadManifest(cwd);
147
- const root = join(cwd, dirname(manifest.entry));
148
- const found = walk(root).some((file) => readFileSync(file, 'utf8').includes(needle));
172
+ let root = join(cwd, dirname(manifest.entry));
173
+ let files: string[];
174
+ if (target === 'native-linux-x64') {
175
+ root = join(outFor(cwd, target, options), executableName(manifest), 'app');
176
+ if (!existsSync(root)) {
177
+ throw new Error(
178
+ `verify: there is no native build at ${root}; \`drift-package build ` +
179
+ '--target=native-linux-x64` makes one.',
180
+ );
181
+ }
182
+ const skip = new Set([join(root, 'public'), join(root, 'node_modules')]);
183
+ files = walk(root).filter((file) => ![...skip].some((dir) => file.startsWith(`${dir}/`)));
184
+ } else {
185
+ files = walk(root);
186
+ }
187
+ const found = files.some((file) => readFileSync(file, 'utf8').includes(needle));
149
188
  if (!found) {
150
189
  throw new Error(
151
190
  `verify: "${needle}" is in no file under ${root}. That is what a build that did not rerun ` +
@@ -250,7 +289,13 @@ async function writeSteamScripts(
250
289
 
251
290
  const outDir = outFor(cwd, target, options);
252
291
  const content =
253
- target === 'linux-x64' ? 'linux-unpacked' : target === 'win-x64' ? 'win-unpacked' : 'mac';
292
+ target === 'native-linux-x64'
293
+ ? executableName(manifest)
294
+ : target === 'linux-x64'
295
+ ? 'linux-unpacked'
296
+ : target === 'win-x64'
297
+ ? 'win-unpacked'
298
+ : 'mac';
254
299
  const scripts = depotScripts({
255
300
  appId: manifest.steam.appId,
256
301
  depotId,
@@ -340,7 +385,9 @@ try {
340
385
  } else if (command === 'verify') {
341
386
  const arg = rest.find((entry) => entry.startsWith('--contains='));
342
387
  if (arg === undefined) throw new Error('verify needs --contains=<string>');
343
- verify(cwd, arg.slice('--contains='.length));
388
+ const asked = rest.find((entry) => entry.startsWith('--target='));
389
+ const target = asked === undefined ? null : (asked.slice('--target='.length) as Target);
390
+ verify(cwd, arg.slice('--contains='.length), target, resources);
344
391
  } else if (command === 'run') {
345
392
  process.exitCode = await run(cwd, resources);
346
393
  } else if (command === 'bootstrap') {
package/src/flags.ts CHANGED
@@ -70,6 +70,24 @@ export function planFlags(
70
70
  */
71
71
  if (target === 'android' || target === 'ios') return { switches: [], refusal: null, notes: [] };
72
72
 
73
+ /*
74
+ * **The native host has no Chromium either, and no WebGL2 beneath its WebGPU**: Dawn is its whole
75
+ * renderer. So a switch is nothing there, and a manifest that turns WebGPU off asks for the one
76
+ * backend that target does not have — refused, rather than shipped as a window that cannot draw.
77
+ */
78
+ if (target === 'native-linux-x64') {
79
+ return {
80
+ switches: [],
81
+ notes: [],
82
+ refusal:
83
+ manifest.backend.webgpu === 'off'
84
+ ? "native-linux-x64 draws with WebGPU alone — Dawn is the host's renderer and there is " +
85
+ 'no WebGL2 beneath it — so backend.webgpu "off" cannot be honoured there. Set it to ' +
86
+ '"prefer" or "require", or build a desktop target for WebGL2.'
87
+ : null,
88
+ };
89
+ }
90
+
73
91
  const switches: (readonly [string, string])[] = [];
74
92
  const notes: string[] = [];
75
93
  const wantsWebGpu = manifest.backend.webgpu !== 'off';
package/src/main/ipc.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { BrowserWindow, app, dialog, ipcMain, screen } from 'electron';
2
- import { readFileSync, writeFileSync } from 'node:fs';
3
2
  import { readFile, writeFile } from 'node:fs/promises';
4
3
  import { join } from 'node:path';
5
4
 
6
5
  import type { WindowMode } from '@driftengine/core';
7
6
 
8
7
  import { SteamCloudStore } from '../steam/cloudStore.ts';
8
+ import { readStoreFile, writeStoreFile, writeStoreFileSync } from './storeFile.ts';
9
9
  import type { SteamRuntime } from '../steam/main.ts';
10
10
 
11
11
  /**
@@ -67,19 +67,8 @@ function cloudSnapshot(steam: SteamRuntime): Record<string, string> {
67
67
  }
68
68
 
69
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
- }
70
+ /* No file yet, or an unreadable one, reads as a fresh store: see `storeFile.ts`. */
71
+ return readStoreFile(storeFile());
83
72
  }
84
73
 
85
74
  /** What the window half needs back from here, so quitting is one decision in one place. */
@@ -114,8 +103,8 @@ export function installIpc(
114
103
  for (const [key, value] of entries) cloud.write(key, value);
115
104
  return;
116
105
  }
117
- void writeFile(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8').catch(
118
- (cause: unknown) => console.error('[driftengine] could not write the store:', cause),
106
+ void writeStoreFile(storeFile(), entries).catch((cause: unknown) =>
107
+ console.error('[driftengine] could not write the store:', cause),
119
108
  );
120
109
  };
121
110
  const schedule = (): void => {
@@ -273,7 +262,7 @@ export function installIpc(
273
262
  for (const [key, value] of entries) cloud.write(key, value);
274
263
  return;
275
264
  }
276
- writeFileSync(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8');
265
+ writeStoreFileSync(storeFile(), entries);
277
266
  } catch (cause) {
278
267
  console.error('[driftengine] could not write the store on the way out:', cause);
279
268
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A shell's key-value store as one JSON file, replaced whole so that nothing ever sees half of it.
3
+ *
4
+ * **Written beside the store and renamed over it.** A rename replaces a file in one step, so a
5
+ * reader — or the next boot, after a crash at any instant — finds the previous store or the next
6
+ * one and never a mixture. Written in place, as this was until 2026-09-19, the file is empty from
7
+ * the moment it is opened until the write lands: a test reading it during rewrites found it torn on
8
+ * 72 reads of 119, and a store that does not parse is read as an empty one, so a crash then costs
9
+ * the player every setting they ever made, with nothing said. **Synced before the rename**, so the
10
+ * replacement carries its bytes to the disk before the name moves: without it a power cut can
11
+ * leave the new name on a file whose contents never arrived.
12
+ *
13
+ * **Asynchronous writes land in the order they were asked for.** Each waits for the one before it,
14
+ * so a large early write finishing after a small late one cannot put the older store back.
15
+ *
16
+ * What it gives up: the directory is not synced, so a power cut straight after a rename may bring
17
+ * back the previous store rather than the new one — a lost second of settings, not a lost store.
18
+ * Both shells use this: the desktop shell's main process and the native host.
19
+ */
20
+
21
+ import {
22
+ closeSync,
23
+ fsyncSync,
24
+ openSync,
25
+ readFileSync,
26
+ renameSync,
27
+ rmSync,
28
+ writeSync,
29
+ } from 'node:fs';
30
+ import { open, rename, rm } from 'node:fs/promises';
31
+
32
+ type Entries = Iterable<readonly [string, string]>;
33
+
34
+ /**
35
+ * The store, or an empty one when there is no file or the file is not a store. A value that is not a
36
+ * string is dropped. A preference is never worth failing a boot for.
37
+ */
38
+ export function readStoreFile(path: string): Record<string, string> {
39
+ try {
40
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
41
+ if (typeof parsed !== 'object' || parsed === null) return {};
42
+ const out: Record<string, string> = {};
43
+ for (const [key, value] of Object.entries(parsed)) {
44
+ if (typeof value === 'string') out[key] = value;
45
+ }
46
+ return out;
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ let temporaries = 0;
53
+ /** One name per write, so two writes in flight never share a half-written file. */
54
+ function temporaryFor(path: string): string {
55
+ temporaries += 1;
56
+ return `${path}.${process.pid}.${temporaries}.tmp`;
57
+ }
58
+
59
+ function textOf(entries: Entries): string {
60
+ return JSON.stringify(Object.fromEntries(entries));
61
+ }
62
+
63
+ /** Replace the store now, for the way out, where there is no later to wait for. */
64
+ export function writeStoreFileSync(path: string, entries: Entries): void {
65
+ const temporary = temporaryFor(path);
66
+ try {
67
+ const file = openSync(temporary, 'w');
68
+ try {
69
+ writeSync(file, textOf(entries));
70
+ fsyncSync(file);
71
+ } finally {
72
+ closeSync(file);
73
+ }
74
+ renameSync(temporary, path);
75
+ } catch (cause) {
76
+ rmSync(temporary, { force: true });
77
+ throw cause;
78
+ }
79
+ }
80
+
81
+ const queues = new Map<string, Promise<void>>();
82
+
83
+ /** Replace the store after every write already asked for, with `entries` as they are now. */
84
+ export function writeStoreFile(path: string, entries: Entries): Promise<void> {
85
+ const text = textOf(entries);
86
+ const before = queues.get(path) ?? Promise.resolve();
87
+ const next = before
88
+ .catch(() => undefined)
89
+ .then(async () => {
90
+ const temporary = temporaryFor(path);
91
+ try {
92
+ const file = await open(temporary, 'w');
93
+ try {
94
+ await file.writeFile(text, 'utf8');
95
+ await file.sync();
96
+ } finally {
97
+ await file.close();
98
+ }
99
+ await rename(temporary, path);
100
+ } catch (cause) {
101
+ await rm(temporary, { force: true });
102
+ throw cause;
103
+ }
104
+ });
105
+ queues.set(path, next);
106
+ /* Forgotten once it is the last, so the map holds nothing for a store no longer written. */
107
+ void next
108
+ .catch(() => undefined)
109
+ .then(() => {
110
+ if (queues.get(path) === next) queues.delete(path);
111
+ });
112
+ return next;
113
+ }
@@ -163,7 +163,10 @@ export async function startApp(
163
163
  the single most common way a packaged web application looks unfinished. */
164
164
  window.once('ready-to-show', () => window?.show());
165
165
  } else {
166
- const startedAt = Date.now();
166
+ /* Monotonic. Every reading here is subtracted from another and compared against a minimum,
167
+ and a wall clock that steps backwards holds the badge forever while one that steps forwards
168
+ flashes it and shows a white window. */
169
+ const startedAt = performance.now();
167
170
  let painted = false;
168
171
  let badgeShownAt: number | null = null;
169
172
  window.once('ready-to-show', () => {
@@ -175,7 +178,7 @@ export async function startApp(
175
178
  * the time it is meant to be readable for — so it is counted from here.
176
179
  */
177
180
  splash.once('show', () => {
178
- badgeShownAt ??= Date.now();
181
+ badgeShownAt ??= performance.now();
179
182
  });
180
183
  /*
181
184
  * And a badge that cannot load is not worth holding a game for: counting its minimum from
@@ -186,7 +189,7 @@ export async function startApp(
186
189
  badgeShownAt ??= startedAt;
187
190
  });
188
191
  const poll = setInterval(() => {
189
- const now = Date.now();
192
+ const now = performance.now();
190
193
  const sinceBadge = badgeShownAt === null ? null : now - badgeShownAt;
191
194
  if (splashDecision(now - startedAt, sinceBadge, manifest.splash.minMs, painted) === 'hold')
192
195
  return;