@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.
- package/NOTICE +20 -0
- package/README.md +58 -1
- package/android/.gradle/9.2.0/fileHashes/fileHashes.bin +0 -0
- package/android/.gradle/9.2.0/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +1 -1
- package/dist/bootstrap.js +7 -1
- package/dist/build.js +23 -10
- package/dist/cli.js +50 -7
- package/dist/flags.js +16 -0
- package/dist/main/ipc.js +5 -19
- package/dist/main/storeFile.d.ts +30 -0
- package/dist/main/storeFile.js +104 -0
- package/dist/main/window.js +6 -3
- package/dist/manifest.d.ts +8 -1
- package/dist/manifest.js +8 -0
- package/dist/native.d.ts +67 -0
- package/dist/native.js +212 -0
- package/dist/nativeBundle.d.ts +53 -0
- package/dist/nativeBundle.js +177 -0
- package/dist/nativeCopy.d.ts +39 -0
- package/dist/nativeCopy.js +110 -0
- package/dist/nativeLicenses.d.ts +42 -0
- package/dist/nativeLicenses.js +131 -0
- package/dist/signing.js +2 -2
- package/dist/toolchain.js +3 -0
- package/package.json +12 -2
- package/src/bootstrap.ts +11 -1
- package/src/build.ts +25 -13
- package/src/cli.ts +54 -7
- package/src/flags.ts +18 -0
- package/src/main/ipc.ts +6 -17
- package/src/main/storeFile.ts +113 -0
- package/src/main/window.ts +6 -3
- package/src/manifest.ts +19 -1
- package/src/native.ts +263 -0
- package/src/nativeBundle.ts +215 -0
- package/src/nativeCopy.ts +133 -0
- package/src/nativeLicenses.ts +163 -0
- package/src/signing.ts +2 -2
- package/src/toolchain.ts +3 -0
|
@@ -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
|
+
"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": "
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/main/window.ts
CHANGED
|
@@ -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
|
-
|
|
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 ??=
|
|
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 =
|
|
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;
|