@driftengine/package 3.62.0 → 4.0.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/NOTICE CHANGED
@@ -7,3 +7,23 @@ This product includes software developed at Drift Technologies
7
7
  Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
8
8
  requires this NOTICE to be reproduced in any derivative work you distribute,
9
9
  in the source, the documentation, or a display generated by the work.
10
+
11
+ Models run by DriftCapture
12
+ --------------------------
13
+ DriftCapture runs the models below. Their weights are not in this repository
14
+ and are not distributed with it: tools/capture-weights/ fetches each at the
15
+ revision its manifest pins, verifies it, and converts it, and a game that
16
+ ships one ships it under that model's own licence. Each model's definition in
17
+ this engine is written from its project's published code.
18
+
19
+ - Depth Anything 3 Small and Depth Anything 3 Base. Apache License 2.0.
20
+ Copyright (c) 2025 ByteDance Ltd. and/or its affiliates.
21
+ - Depth Anything V2 Small. Apache License 2.0. The University of Hong Kong
22
+ and TikTok; converted to the Transformers layout by The HuggingFace Team.
23
+ - MobileSAM. Apache License 2.0. Its image encoder is TinyViT, Copyright (c)
24
+ 2022 Microsoft; its prompt encoder and mask decoder are Segment Anything's,
25
+ Copyright (c) Meta Platforms, Inc. and affiliates.
26
+ - SAM 2.1 tiny. Apache License 2.0. Copyright (c) Meta Platforms, Inc. and
27
+ affiliates.
28
+ - OWLv2 base patch16. Apache License 2.0. Copyright 2023 Google AI and The
29
+ HuggingFace Team.
package/README.md CHANGED
@@ -7,7 +7,7 @@ learning what a shell is.
7
7
  npx drift-package init # writes the two scripts your repository has to own
8
8
  npx drift-package doctor # is this project packageable, and how would it be signed
9
9
  npx drift-package run # the real shell, from source, no installer
10
- npx drift-package build --target=linux-x64
10
+ npx drift-package build --target=native-linux-x64
11
11
  npx drift-package verify --contains=<a string from your diff>
12
12
  ```
13
13
 
@@ -64,6 +64,8 @@ Beside your `package.json`, in the project that produces the web build:
64
64
  ```
65
65
 
66
66
  `id`, `name`, `entry` and `targets` are required; every rejection names the field it is about.
67
+ `native.entry` is required when `targets` names `native-linux-x64`, and nowhere else — see
68
+ [the native target](#the-native-target-and-electron-as-the-compatibility-target).
67
69
  `icon` is a 1024x1024 PNG and defaults to the engine's own mark, because the alternative default is
68
70
  Electron's logo. `window.mode` is what the window is _created_ as — `borderless` makes it
69
71
  frameless — and is a different thing from the runtime mode a settings screen changes.
@@ -327,6 +329,60 @@ which is committed.
327
329
  mobile build is 20 MB rather than 200, and it is also why WebGL2 is the baseline there: what the
328
330
  device offers is what the game gets, and the acceptance probe decides.
329
331
 
332
+ ## The native target, and Electron as the compatibility target
333
+
334
+ **On Linux x64, `native-linux-x64` is the target to ship.** It runs the game on
335
+ [`@driftengine/native-host`](../native-host/README.md) — Node, Dawn and SDL, with no browser — and
336
+ the archive carries everything that runs it:
337
+
338
+ startup/ what the archive holds, and what a Steam depot would upload
339
+ startup the launcher: the Node beside it, running the game
340
+ runtime/node the Node that ran the build
341
+ app/game.mjs the game, the host and the engine, bundled for Node
342
+ app/modules/ each module the bundle starts by URL: workers, the thread bootstrap
343
+ app/node_modules/ the modules that cannot be bundled, and what they load
344
+ app/public/ the game's web build, which its `fetch` of a relative path reads
345
+ licenses/ Node's licence and every package's, with INDEX.txt
346
+ Startup-1.0.0-linux-x64.tar.gz
347
+
348
+ Two things it needs of the game. **The host**, installed beside it at the packager's version —
349
+ `npm install @driftengine/native-host@<version>`; `doctor` names the command when it is missing, and
350
+ a build refuses a host from another release. **A native entry**, `native.entry` in the manifest: a
351
+ module exporting `mount(canvas)`, since there is no page there for a game to find its canvas in.
352
+ The game's page still builds as before, and its files are what the host serves to the game's
353
+ `fetch`. Saves are kept where the desktop target keeps them, under the same name, so a player
354
+ moving between the two builds keeps theirs.
355
+
356
+ **Measured 2026-09-19**, one small program — the starter's lit cube — packaged both ways on one
357
+ machine (an RX 9070 XT under COSMIC), with `npm run native:startup -- --electron` in the engine's
358
+ repository:
359
+
360
+ | | `native-linux-x64` | `linux-x64` (Electron 43.4.1) |
361
+ | -------------------- | ----------------------------- | ------------------------------------- |
362
+ | archive | 61.3 MB | 121.7 MB tarball, 128.5 MB AppImage |
363
+ | unpacked | 181.2 MB, of which Node 124.8 | 330.6 MB |
364
+ | spawn to first frame | 750 ms, WebGPU | 380 ms, WebGL2 — it offered no WebGPU |
365
+ | build | 7 s | 3 min 13 s |
366
+
367
+ The native start is mostly the renderer's pipelines, compiled on every launch because the binding
368
+ exposes Dawn's cache to nothing: 355 ms of the 750, where Node's own start is 17. **On that
369
+ machine's Wayland session the Electron build did not start at all** — its main thread waits on the
370
+ compositor's socket before `ready` — so it was timed under XWayland, where the native host's window
371
+ is too.
372
+
373
+ **The Electron targets are kept, as the compatibility target**, and nothing that shipped stops
374
+ shipping: they are the desktop build on Windows and macOS, where there is no native host yet — it
375
+ reaches both after 4.0.0, and the three bindings already publish binaries for them — and on
376
+ Linux the build for a game with no native entry yet, or one that needs what the host lacks — WebGL2
377
+ beneath WebGPU on a machine with no Vulkan device, a DOM beyond the canvas, the engine's badge, and
378
+ a store (`steam.appId` does nothing on the host, and `doctor` says so).
379
+
380
+ **No copyleft library is bundled.** The Opus decoder's Ogg parser is LGPL-3.0, so it ships as a
381
+ file of its own in `app/node_modules`, which a person can replace, with the GPL's text beside it in
382
+ `licenses/`; any other that would land in the bundle stops the build, naming it. `verify
383
+ --target=native-linux-x64` looks in the native bundle, which is built from the game's source rather
384
+ than copied from its web build.
385
+
330
386
  ## Where each artifact is built
331
387
 
332
388
  An artifact is built on the platform it targets, with one exception, and `build` refuses the ones
@@ -334,6 +390,7 @@ this machine cannot honestly produce.
334
390
 
335
391
  | target | built on | how |
336
392
  | ---------------------- | ------------------------------- | --------------------------------------------------------------------------------------- |
393
+ | `native-linux-x64` | Linux on x64 | `npx drift-package build --target=native-linux-x64` |
337
394
  | `linux-x64` | Linux | `npx drift-package build --target=linux-x64` |
338
395
  | `win-x64` | Windows, a VM is fine | `drift-package init` writes `scripts/build-windows.ps1` into your project; run it there |
339
396
  | `mac-arm64`, `mac-x64` | macOS, **or Linux** | [`scripts/build-macos.sh`](scripts/build-macos.sh), or `build --target=mac-arm64` |
@@ -1,2 +1,2 @@
1
- #Tue Sep 01 09:58:03 CEST 2026
1
+ #Sun Sep 20 15:44:54 CEST 2026
2
2
  gradle.version=8.9
package/dist/bootstrap.js CHANGED
@@ -23,9 +23,15 @@ import { PINS, ensureDir, findAndroidSdk, findGradle, findJavaHome, findRcodesig
23
23
  export async function bootstrap(targets) {
24
24
  const wantsAndroid = targets.includes('android');
25
25
  const wantsIos = targets.includes('ios');
26
- const wantsDesktop = targets.some((target) => target !== 'android' && target !== 'ios');
26
+ const wantsDesktop = targets.some((target) => target !== 'android' && target !== 'ios' && target !== 'native-linux-x64');
27
27
  if (wantsDesktop)
28
28
  await bootstrapElectron();
29
+ if (targets.includes('native-linux-x64')) {
30
+ /* A machine is ready for it as it is: the artifact carries the Node running the build, and the
31
+ host is a dependency of the game, which `npm install` there fetches. */
32
+ console.log('ok: native-linux-x64 needs nothing fetched for this machine — it ships the Node running the ' +
33
+ "build — and the game needs @driftengine/native-host installed, at the packager's version");
34
+ }
29
35
  if (targets.some((target) => needsRcodesign(target)))
30
36
  await bootstrapRcodesign();
31
37
  if (wantsAndroid)
package/dist/build.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { buildAndroid } from './android.js';
6
6
  import { buildIos } from './ios.js';
7
7
  import { crossSignMacApp, zipMacApp } from './macCrossSign.js';
8
+ import { buildNative } from './native.js';
8
9
  import { outFor } from './outDir.js';
9
10
  import { resourceFile } from './resources.js';
10
11
  import { ensureElectronRuntime } from './runtime.js';
@@ -41,6 +42,9 @@ const HOST_FOR = {
41
42
  'mac-arm64': 'darwin',
42
43
  'mac-x64': 'darwin',
43
44
  'linux-x64': 'linux',
45
+ /* The native host ships the Node that runs the build and the binaries installed beside it;
46
+ `native.ts` refuses the machine itself, architecture included. */
47
+ 'native-linux-x64': 'linux',
44
48
  /* Android's toolchain is Java and it runs anywhere; iOS needs Xcode, which is a Mac. */
45
49
  android: null,
46
50
  ios: 'darwin',
@@ -68,16 +72,6 @@ function refuseHost(target, platform) {
68
72
  'Wine cannot reach a certificate store, which is the one platform where that matters.');
69
73
  }
70
74
  export async function build(manifest, cwd, targets, options = {}) {
71
- const { build: run, Arch, Platform } = await import('electron-builder');
72
- /* One artifact per target, and the architecture is named rather than inherited: a build on an
73
- Apple Silicon machine defaults to arm64, so `mac-x64` asked for on one would quietly produce
74
- the wrong slice. */
75
- const targetFor = (name) => name === 'win-x64'
76
- ? Platform.WINDOWS.createTarget(undefined, Arch.x64)
77
- : name === 'linux-x64'
78
- ? Platform.LINUX.createTarget(undefined, Arch.x64)
79
- : Platform.MAC.createTarget(undefined, name === 'mac-x64' ? Arch.x64 : Arch.arm64);
80
- const electronVersion = await installedElectronVersion();
81
75
  for (const target of targets) {
82
76
  if (target === 'android') {
83
77
  await buildAndroid(manifest, cwd, outFor(cwd, target, options), options);
@@ -87,9 +81,28 @@ export async function build(manifest, cwd, targets, options = {}) {
87
81
  await buildIos(manifest, cwd, outFor(cwd, target, options), options);
88
82
  continue;
89
83
  }
84
+ if (target === 'native-linux-x64') {
85
+ await buildNative(manifest, cwd, outFor(cwd, target, options));
86
+ continue;
87
+ }
90
88
  const refusal = refuseHost(target, process.platform);
91
89
  if (refusal !== null)
92
90
  throw new Error(refusal);
91
+ /*
92
+ * **Electron's builder and version are read here, for an Electron target**, rather than before
93
+ * the loop: a build of the native target alone needs neither.
94
+ *
95
+ * One artifact per target, and the architecture is named rather than inherited: a build on an
96
+ * Apple Silicon machine defaults to arm64, so `mac-x64` asked for on one would quietly produce
97
+ * the wrong slice.
98
+ */
99
+ const { build: run, Arch, Platform } = await import('electron-builder');
100
+ const targetFor = (name) => name === 'win-x64'
101
+ ? Platform.WINDOWS.createTarget(undefined, Arch.x64)
102
+ : name === 'linux-x64'
103
+ ? Platform.LINUX.createTarget(undefined, Arch.x64)
104
+ : Platform.MAC.createTarget(undefined, name === 'mac-x64' ? Arch.x64 : Arch.arm64);
105
+ const electronVersion = await installedElectronVersion();
93
106
  /*
94
107
  * **The runtime, before anything is staged.** `installedElectronVersion` below reads a version
95
108
  * out of `electron/package.json`, which is in the npm tarball — so it answers even when the
package/dist/cli.js 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';
@@ -11,6 +11,7 @@ import { refuseLyingFilesystem } from './identity.js';
11
11
  import { init } from './init.js';
12
12
  import { outFor } from './outDir.js';
13
13
  import { parseManifest } from './manifest.js';
14
+ import { executableName, nativeHost, nativeNotes, packagerVersion } from './native.js';
14
15
  import { planSigning } from './signing.js';
15
16
  import { electronMajor } from './runtimeVersion.js';
16
17
  import { depotScripts } from './steam/depot.js';
@@ -35,7 +36,7 @@ delete process.env.ELECTRON_RUN_AS_NODE;
35
36
  /**
36
37
  * `drift-package` — the command a consumer runs to get an installable application.
37
38
  *
38
- * Four subcommands, and `verify` is the one that is not obvious. An artifact that was never
39
+ * Seven subcommands, and `verify` is the one that is not obvious. An artifact that was never
39
40
  * rebuilt is byte-identical in every respect a build log can see, so the only honest check is to
40
41
  * look inside the shipped thing for a string from the change. This repository already learned that
41
42
  * about deploys: an asset hash proves nothing.
@@ -121,6 +122,22 @@ async function doctor(cwd, options = {}) {
121
122
  * arrive twenty minutes later from Gradle.
122
123
  */
123
124
  for (const target of manifest.targets) {
125
+ /*
126
+ * **The native target asks nothing of the machine and one thing of the game**: it ships the
127
+ * Node running this, and it needs the host installed beside the game, which `bootstrap` cannot
128
+ * do because it prepares a machine rather than a project.
129
+ */
130
+ if (target === 'native-linux-x64') {
131
+ for (const note of nativeNotes(manifest))
132
+ console.log(` ${target}: ${note}`);
133
+ try {
134
+ nativeHost(cwd, packagerVersion());
135
+ }
136
+ catch (cause) {
137
+ console.log(` ${target}: ${cause instanceof Error ? cause.message : String(cause)}`);
138
+ }
139
+ continue;
140
+ }
124
141
  const ready = await toolchainReady(target);
125
142
  if (ready)
126
143
  continue;
@@ -129,10 +146,28 @@ async function doctor(cwd, options = {}) {
129
146
  }
130
147
  return manifest;
131
148
  }
132
- function verify(cwd, needle) {
149
+ /**
150
+ * **The native target is looked at in its own bundle**, because the build makes that from the game's
151
+ * source rather than copying the web build: the web build inside it, `public/`, and the copied
152
+ * modules are left out, so a fresh web build cannot vouch for a stale native one.
153
+ */
154
+ function verify(cwd, needle, target, options) {
133
155
  const manifest = loadManifest(cwd);
134
- const root = join(cwd, dirname(manifest.entry));
135
- const found = walk(root).some((file) => readFileSync(file, 'utf8').includes(needle));
156
+ let root = join(cwd, dirname(manifest.entry));
157
+ let files;
158
+ if (target === 'native-linux-x64') {
159
+ root = join(outFor(cwd, target, options), executableName(manifest), 'app');
160
+ if (!existsSync(root)) {
161
+ throw new Error(`verify: there is no native build at ${root}; \`drift-package build ` +
162
+ '--target=native-linux-x64` makes one.');
163
+ }
164
+ const skip = new Set([join(root, 'public'), join(root, 'node_modules')]);
165
+ files = walk(root).filter((file) => ![...skip].some((dir) => file.startsWith(`${dir}/`)));
166
+ }
167
+ else {
168
+ files = walk(root);
169
+ }
170
+ const found = files.some((file) => readFileSync(file, 'utf8').includes(needle));
136
171
  if (!found) {
137
172
  throw new Error(`verify: "${needle}" is in no file under ${root}. That is what a build that did not rerun ` +
138
173
  'looks like — the artifact exists and is stale.');
@@ -223,7 +258,13 @@ async function writeSteamScripts(cwd, args, options) {
223
258
  const depotId = depotArg === undefined ? manifest.steam.appId + 1 : Number.parseInt(depotArg.slice(8), 10);
224
259
  const branchArg = args.find((entry) => entry.startsWith('--branch='));
225
260
  const outDir = outFor(cwd, target, options);
226
- const content = target === 'linux-x64' ? 'linux-unpacked' : target === 'win-x64' ? 'win-unpacked' : 'mac';
261
+ const content = target === 'native-linux-x64'
262
+ ? executableName(manifest)
263
+ : target === 'linux-x64'
264
+ ? 'linux-unpacked'
265
+ : target === 'win-x64'
266
+ ? 'win-unpacked'
267
+ : 'mac';
227
268
  const scripts = depotScripts({
228
269
  appId: manifest.steam.appId,
229
270
  depotId,
@@ -305,7 +346,9 @@ try {
305
346
  const arg = rest.find((entry) => entry.startsWith('--contains='));
306
347
  if (arg === undefined)
307
348
  throw new Error('verify needs --contains=<string>');
308
- verify(cwd, arg.slice('--contains='.length));
349
+ const asked = rest.find((entry) => entry.startsWith('--target='));
350
+ const target = asked === undefined ? null : asked.slice('--target='.length);
351
+ verify(cwd, arg.slice('--contains='.length), target, resources);
309
352
  }
310
353
  else if (command === 'run') {
311
354
  process.exitCode = await run(cwd, resources);
package/dist/flags.js CHANGED
@@ -25,6 +25,22 @@ export function planFlags(manifest, target, electronMajor = null) {
25
25
  */
26
26
  if (target === 'android' || target === 'ios')
27
27
  return { switches: [], refusal: null, notes: [] };
28
+ /*
29
+ * **The native host has no Chromium either, and no WebGL2 beneath its WebGPU**: Dawn is its whole
30
+ * renderer. So a switch is nothing there, and a manifest that turns WebGPU off asks for the one
31
+ * backend that target does not have — refused, rather than shipped as a window that cannot draw.
32
+ */
33
+ if (target === 'native-linux-x64') {
34
+ return {
35
+ switches: [],
36
+ notes: [],
37
+ refusal: manifest.backend.webgpu === 'off'
38
+ ? "native-linux-x64 draws with WebGPU alone — Dawn is the host's renderer and there is " +
39
+ 'no WebGL2 beneath it — so backend.webgpu "off" cannot be honoured there. Set it to ' +
40
+ '"prefer" or "require", or build a desktop target for WebGL2.'
41
+ : null,
42
+ };
43
+ }
28
44
  const switches = [];
29
45
  const notes = [];
30
46
  const wantsWebGpu = manifest.backend.webgpu !== 'off';
package/dist/main/ipc.js CHANGED
@@ -1,8 +1,8 @@
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
  import { SteamCloudStore } from '../steam/cloudStore.js';
5
+ import { readStoreFile, writeStoreFile, writeStoreFileSync } from './storeFile.js';
6
6
  /**
7
7
  * The main-process half of the bridge: a file-backed store, dialogs, focus, and quit.
8
8
  *
@@ -60,22 +60,8 @@ function cloudSnapshot(steam) {
60
60
  return out;
61
61
  }
62
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
- }
63
+ /* No file yet, or an unreadable one, reads as a fresh store: see `storeFile.ts`. */
64
+ return readStoreFile(storeFile());
79
65
  }
80
66
  export function installIpc(snapshot, windowOf, steam = null) {
81
67
  const entries = new Map(Object.entries(snapshot));
@@ -94,7 +80,7 @@ export function installIpc(snapshot, windowOf, steam = null) {
94
80
  cloud.write(key, value);
95
81
  return;
96
82
  }
97
- void writeFile(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8').catch((cause) => console.error('[driftengine] could not write the store:', cause));
83
+ void writeStoreFile(storeFile(), entries).catch((cause) => console.error('[driftengine] could not write the store:', cause));
98
84
  };
99
85
  const schedule = () => {
100
86
  if (pending === null)
@@ -246,7 +232,7 @@ export function installIpc(snapshot, windowOf, steam = null) {
246
232
  cloud.write(key, value);
247
233
  return;
248
234
  }
249
- writeFileSync(storeFile(), JSON.stringify(Object.fromEntries(entries)), 'utf8');
235
+ writeStoreFileSync(storeFile(), entries);
250
236
  }
251
237
  catch (cause) {
252
238
  console.error('[driftengine] could not write the store on the way out:', cause);
@@ -0,0 +1,30 @@
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
+ type Entries = Iterable<readonly [string, string]>;
21
+ /**
22
+ * The store, or an empty one when there is no file or the file is not a store. A value that is not a
23
+ * string is dropped. A preference is never worth failing a boot for.
24
+ */
25
+ export declare function readStoreFile(path: string): Record<string, string>;
26
+ /** Replace the store now, for the way out, where there is no later to wait for. */
27
+ export declare function writeStoreFileSync(path: string, entries: Entries): void;
28
+ /** Replace the store after every write already asked for, with `entries` as they are now. */
29
+ export declare function writeStoreFile(path: string, entries: Entries): Promise<void>;
30
+ export {};
@@ -0,0 +1,104 @@
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
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeSync, } from 'node:fs';
21
+ import { open, rename, rm } from 'node:fs/promises';
22
+ /**
23
+ * The store, or an empty one when there is no file or the file is not a store. A value that is not a
24
+ * string is dropped. A preference is never worth failing a boot for.
25
+ */
26
+ export function readStoreFile(path) {
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
29
+ if (typeof parsed !== 'object' || parsed === null)
30
+ return {};
31
+ const out = {};
32
+ for (const [key, value] of Object.entries(parsed)) {
33
+ if (typeof value === 'string')
34
+ out[key] = value;
35
+ }
36
+ return out;
37
+ }
38
+ catch {
39
+ return {};
40
+ }
41
+ }
42
+ let temporaries = 0;
43
+ /** One name per write, so two writes in flight never share a half-written file. */
44
+ function temporaryFor(path) {
45
+ temporaries += 1;
46
+ return `${path}.${process.pid}.${temporaries}.tmp`;
47
+ }
48
+ function textOf(entries) {
49
+ return JSON.stringify(Object.fromEntries(entries));
50
+ }
51
+ /** Replace the store now, for the way out, where there is no later to wait for. */
52
+ export function writeStoreFileSync(path, entries) {
53
+ const temporary = temporaryFor(path);
54
+ try {
55
+ const file = openSync(temporary, 'w');
56
+ try {
57
+ writeSync(file, textOf(entries));
58
+ fsyncSync(file);
59
+ }
60
+ finally {
61
+ closeSync(file);
62
+ }
63
+ renameSync(temporary, path);
64
+ }
65
+ catch (cause) {
66
+ rmSync(temporary, { force: true });
67
+ throw cause;
68
+ }
69
+ }
70
+ const queues = new Map();
71
+ /** Replace the store after every write already asked for, with `entries` as they are now. */
72
+ export function writeStoreFile(path, entries) {
73
+ const text = textOf(entries);
74
+ const before = queues.get(path) ?? Promise.resolve();
75
+ const next = before
76
+ .catch(() => undefined)
77
+ .then(async () => {
78
+ const temporary = temporaryFor(path);
79
+ try {
80
+ const file = await open(temporary, 'w');
81
+ try {
82
+ await file.writeFile(text, 'utf8');
83
+ await file.sync();
84
+ }
85
+ finally {
86
+ await file.close();
87
+ }
88
+ await rename(temporary, path);
89
+ }
90
+ catch (cause) {
91
+ await rm(temporary, { force: true });
92
+ throw cause;
93
+ }
94
+ });
95
+ queues.set(path, next);
96
+ /* Forgotten once it is the last, so the map holds nothing for a store no longer written. */
97
+ void next
98
+ .catch(() => undefined)
99
+ .then(() => {
100
+ if (queues.get(path) === next)
101
+ queues.delete(path);
102
+ });
103
+ return next;
104
+ }
@@ -151,7 +151,10 @@ export async function startApp(manifest, root, target, options = { development:
151
151
  window.once('ready-to-show', () => window?.show());
152
152
  }
153
153
  else {
154
- const startedAt = Date.now();
154
+ /* Monotonic. Every reading here is subtracted from another and compared against a minimum,
155
+ and a wall clock that steps backwards holds the badge forever while one that steps forwards
156
+ flashes it and shows a white window. */
157
+ const startedAt = performance.now();
155
158
  let painted = false;
156
159
  let badgeShownAt = null;
157
160
  window.once('ready-to-show', () => {
@@ -163,7 +166,7 @@ export async function startApp(manifest, root, target, options = { development:
163
166
  * the time it is meant to be readable for — so it is counted from here.
164
167
  */
165
168
  splash.once('show', () => {
166
- badgeShownAt ??= Date.now();
169
+ badgeShownAt ??= performance.now();
167
170
  });
168
171
  /*
169
172
  * And a badge that cannot load is not worth holding a game for: counting its minimum from
@@ -174,7 +177,7 @@ export async function startApp(manifest, root, target, options = { development:
174
177
  badgeShownAt ??= startedAt;
175
178
  });
176
179
  const poll = setInterval(() => {
177
- const now = Date.now();
180
+ const now = performance.now();
178
181
  const sinceBadge = badgeShownAt === null ? null : now - badgeShownAt;
179
182
  if (splashDecision(now - startedAt, sinceBadge, manifest.splash.minMs, painted) === 'hold')
180
183
  return;
@@ -1,5 +1,5 @@
1
1
  export type WebGpuPolicy = 'prefer' | 'require' | 'off';
2
- export type Target = 'win-x64' | 'mac-arm64' | 'mac-x64' | 'linux-x64' | 'android' | 'ios';
2
+ export type Target = 'win-x64' | 'mac-arm64' | 'mac-x64' | 'linux-x64' | 'native-linux-x64' | 'android' | 'ios';
3
3
  /** The two that are a phone rather than a desktop, and differ in almost every way that matters. */
4
4
  export declare const MOBILE_TARGETS: readonly Target[];
5
5
  export interface PackageManifest {
@@ -21,6 +21,13 @@ export interface PackageManifest {
21
21
  readonly gamepad: boolean;
22
22
  };
23
23
  readonly targets: readonly Target[];
24
+ /**
25
+ * The module a native target mounts: it exports `mount(canvas)`, which the native host calls with
26
+ * its canvas, since there is no page there to find one in. Null unless a native target is built.
27
+ */
28
+ readonly native: {
29
+ readonly entry: string;
30
+ } | null;
24
31
  readonly steam: {
25
32
  readonly appId: number | null;
26
33
  };
package/dist/manifest.js CHANGED
@@ -19,6 +19,7 @@ const TARGETS = [
19
19
  'mac-arm64',
20
20
  'mac-x64',
21
21
  'linux-x64',
22
+ 'native-linux-x64',
22
23
  'android',
23
24
  'ios',
24
25
  ];
@@ -63,6 +64,12 @@ export function parseManifest(raw) {
63
64
  throw new Error(`drift.package.json: unknown target "${String(target)}" — expected one of ${TARGETS.join(', ')}`);
64
65
  }
65
66
  }
67
+ const nativeRaw = (source.native ?? {});
68
+ const nativeEntry = typeof nativeRaw.entry === 'string' && nativeRaw.entry.length > 0 ? nativeRaw.entry : null;
69
+ if (targetsRaw.includes('native-linux-x64') && nativeEntry === null) {
70
+ throw new Error('drift.package.json: the native-linux-x64 target needs "native.entry", the module whose ' +
71
+ '`mount(canvas)` the native host calls — there is no page there to find a canvas in');
72
+ }
66
73
  const windowRaw = (source.window ?? {});
67
74
  const featuresRaw = (source.features ?? {});
68
75
  const steamRaw = (source.steam ?? {});
@@ -101,6 +108,7 @@ export function parseManifest(raw) {
101
108
  gamepad: featuresRaw.gamepad !== false,
102
109
  },
103
110
  targets: targetsRaw,
111
+ native: nativeEntry === null ? null : { entry: nativeEntry },
104
112
  steam: { appId: typeof appId === 'number' ? appId : null },
105
113
  publisher: typeof source.publisher === 'string' && source.publisher.length > 0
106
114
  ? source.publisher