@driftengine/package 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +408 -0
- package/bin/drift-package.mjs +82 -0
- package/dist/android.d.ts +18 -0
- package/dist/android.js +207 -0
- package/dist/bootstrap.d.ts +17 -0
- package/dist/bootstrap.js +326 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +248 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +343 -0
- package/dist/flags.d.ts +57 -0
- package/dist/flags.js +122 -0
- package/dist/identity.d.ts +51 -0
- package/dist/identity.js +75 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +24 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +45 -0
- package/dist/ios.d.ts +27 -0
- package/dist/ios.js +152 -0
- package/dist/macCrossSign.d.ts +34 -0
- package/dist/macCrossSign.js +298 -0
- package/dist/main/consoleForward.d.ts +43 -0
- package/dist/main/consoleForward.js +63 -0
- package/dist/main/csp.d.ts +4 -0
- package/dist/main/csp.js +55 -0
- package/dist/main/entry.d.ts +1 -0
- package/dist/main/entry.js +56 -0
- package/dist/main/ipc.d.ts +28 -0
- package/dist/main/ipc.js +284 -0
- package/dist/main/links.d.ts +1 -0
- package/dist/main/links.js +29 -0
- package/dist/main/menu.d.ts +17 -0
- package/dist/main/menu.js +49 -0
- package/dist/main/serve.d.ts +21 -0
- package/dist/main/serve.js +69 -0
- package/dist/main/shortcuts.d.ts +28 -0
- package/dist/main/shortcuts.js +26 -0
- package/dist/main/splash.d.ts +14 -0
- package/dist/main/splash.js +41 -0
- package/dist/main/splashTiming.d.ts +36 -0
- package/dist/main/splashTiming.js +24 -0
- package/dist/main/window.d.ts +19 -0
- package/dist/main/window.js +297 -0
- package/dist/manifest.d.ts +61 -0
- package/dist/manifest.js +91 -0
- package/dist/mobile/assetCheck.d.ts +1 -0
- package/dist/mobile/assetCheck.js +28 -0
- package/dist/mobile/bridgeShim.d.ts +83 -0
- package/dist/mobile/bridgeShim.js +208 -0
- package/dist/mobile/iosShim.d.ts +42 -0
- package/dist/mobile/iosShim.js +104 -0
- package/dist/mobile/report.d.ts +27 -0
- package/dist/mobile/report.js +122 -0
- package/dist/mobile/versionCode.d.ts +12 -0
- package/dist/mobile/versionCode.js +17 -0
- package/dist/outDir.d.ts +30 -0
- package/dist/outDir.js +32 -0
- package/dist/preload/bridge.d.ts +77 -0
- package/dist/preload/bridge.js +2 -0
- package/dist/preload/entry.d.ts +1 -0
- package/dist/preload/entry.js +77 -0
- package/dist/renderer/fileStore.d.ts +23 -0
- package/dist/renderer/fileStore.js +33 -0
- package/dist/renderer/host.d.ts +45 -0
- package/dist/renderer/host.js +30 -0
- package/dist/renderer/nativeHost.d.ts +83 -0
- package/dist/renderer/nativeHost.js +125 -0
- package/dist/resources.d.ts +71 -0
- package/dist/resources.js +83 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +80 -0
- package/dist/runtimeVersion.d.ts +10 -0
- package/dist/runtimeVersion.js +18 -0
- package/dist/signing.d.ts +49 -0
- package/dist/signing.js +135 -0
- package/dist/stage.d.ts +22 -0
- package/dist/stage.js +138 -0
- package/dist/steam/cloudStore.d.ts +30 -0
- package/dist/steam/cloudStore.js +54 -0
- package/dist/steam/depot.d.ts +29 -0
- package/dist/steam/depot.js +39 -0
- package/dist/steam/main.d.ts +22 -0
- package/dist/steam/main.js +41 -0
- package/dist/templates/buildWindows.d.ts +16 -0
- package/dist/templates/buildWindows.js +161 -0
- package/dist/templates/packageWrapper.d.ts +9 -0
- package/dist/templates/packageWrapper.js +80 -0
- package/dist/toolchain.d.ts +101 -0
- package/dist/toolchain.js +186 -0
- package/package.json +73 -0
- package/src/android.ts +256 -0
- package/src/bootstrap.ts +389 -0
- package/src/build.ts +285 -0
- package/src/cli.ts +367 -0
- package/src/flags.ts +171 -0
- package/src/identity.ts +87 -0
- package/src/index.ts +31 -0
- package/src/init.ts +61 -0
- package/src/ios.ts +185 -0
- package/src/macCrossSign.ts +338 -0
- package/src/main/consoleForward.ts +93 -0
- package/src/main/csp.ts +57 -0
- package/src/main/entry.ts +63 -0
- package/src/main/ipc.ts +311 -0
- package/src/main/links.ts +29 -0
- package/src/main/menu.ts +53 -0
- package/src/main/serve.ts +70 -0
- package/src/main/shortcuts.ts +55 -0
- package/src/main/splash.ts +42 -0
- package/src/main/splashTiming.ts +51 -0
- package/src/main/window.ts +316 -0
- package/src/manifest.ts +157 -0
- package/src/mobile/assetCheck.ts +25 -0
- package/src/mobile/bridgeShim.ts +292 -0
- package/src/mobile/iosShim.ts +148 -0
- package/src/mobile/report.ts +121 -0
- package/src/mobile/versionCode.ts +17 -0
- package/src/outDir.ts +37 -0
- package/src/preload/bridge.ts +73 -0
- package/src/preload/entry.ts +91 -0
- package/src/renderer/fileStore.ts +38 -0
- package/src/renderer/host.ts +96 -0
- package/src/renderer/nativeHost.ts +146 -0
- package/src/resources.ts +150 -0
- package/src/runtime.ts +87 -0
- package/src/runtimeVersion.ts +16 -0
- package/src/signing.ts +206 -0
- package/src/stage.ts +180 -0
- package/src/steam/cloudStore.ts +73 -0
- package/src/steam/depot.ts +69 -0
- package/src/steam/main.ts +69 -0
- package/src/templates/buildWindows.ts +163 -0
- package/src/templates/packageWrapper.ts +82 -0
- package/src/toolchain.ts +210 -0
package/src/init.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { PackageManifest } from './manifest.ts';
|
|
6
|
+
import { buildWindowsScript } from './templates/buildWindows.ts';
|
|
7
|
+
import { packageWrapperScript } from './templates/packageWrapper.ts';
|
|
8
|
+
|
|
9
|
+
/** A file `init` writes, and where it goes relative to the project. */
|
|
10
|
+
export interface InitFile {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly content: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The files a consumer cannot avoid owning, and deliberately only those.
|
|
17
|
+
*
|
|
18
|
+
* **The rule that decides what is here**: if it can be done once inside `drift-package`, it is not
|
|
19
|
+
* allowed to be in a file somebody copies. A Windows build needs a `.ps1` because something has to
|
|
20
|
+
* start Node on that machine and that something cannot live inside a Node program; a project needs
|
|
21
|
+
* a launcher because the engine may be vendored, where `npx drift-package` resolves nothing.
|
|
22
|
+
* Everything else the first three-platform consumer wrote — 421 lines of it — is now `runtime.ts`,
|
|
23
|
+
* `identity.ts`, `outDir.ts` and the deletion at the top of `cli.ts`.
|
|
24
|
+
*/
|
|
25
|
+
export function initFiles(manifest: PackageManifest): readonly InitFile[] {
|
|
26
|
+
return [
|
|
27
|
+
{ path: 'scripts/build-windows.ps1', content: buildWindowsScript(manifest) },
|
|
28
|
+
{ path: 'scripts/package.mjs', content: packageWrapperScript(manifest) },
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Write them into a project, refusing to overwrite without `--force`.
|
|
34
|
+
*
|
|
35
|
+
* **The refusal names the file rather than the directory**, because a consumer who has edited one
|
|
36
|
+
* of these has edited a specific one and wants to know which is in the way.
|
|
37
|
+
*/
|
|
38
|
+
export async function init(cwd: string, manifest: PackageManifest, force: boolean): Promise<void> {
|
|
39
|
+
const files = initFiles(manifest);
|
|
40
|
+
if (!force) {
|
|
41
|
+
const existing = files.map((f) => join(cwd, f.path)).filter((p) => existsSync(p));
|
|
42
|
+
if (existing.length > 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`init would overwrite:\n${existing.map((p) => ` ${p}`).join('\n')}\n\n` +
|
|
45
|
+
'These are yours once you have edited them. Pass --force to replace them anyway, or ' +
|
|
46
|
+
'move them aside first and diff.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const full = join(cwd, file.path);
|
|
52
|
+
await mkdir(dirname(full), { recursive: true });
|
|
53
|
+
await writeFile(full, file.content, 'utf8');
|
|
54
|
+
console.log(`ok: ${full}`);
|
|
55
|
+
}
|
|
56
|
+
console.log(
|
|
57
|
+
`\n node scripts/package.mjs doctor\n node scripts/package.mjs build --target=${
|
|
58
|
+
manifest.targets[0] ?? 'linux-x64'
|
|
59
|
+
}\n`,
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/ios.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { build as esbuild } from 'esbuild';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { missingReferences } from './mobile/assetCheck.ts';
|
|
8
|
+
import { versionCodeFor } from './mobile/versionCode.ts';
|
|
9
|
+
import type { PackageManifest } from './manifest.ts';
|
|
10
|
+
import { resourceDir } from './resources.ts';
|
|
11
|
+
import type { ResourceOptions } from './resources.ts';
|
|
12
|
+
import { findXcodeGen } from './toolchain.ts';
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An iOS application, from the same web build every other target uses.
|
|
18
|
+
*
|
|
19
|
+
* **Unsigned unless a team is named, and that is the useful default.** Apple's free tier signs a
|
|
20
|
+
* build for your own device for seven days at a time and does it through Xcode's own UI rather
|
|
21
|
+
* than through anything a script can drive. So this produces an archive and an unsigned `.ipa` —
|
|
22
|
+
* the shape a sideloading tool re-signs — and takes the signed path only when `APPLE_TEAM_ID` is
|
|
23
|
+
* in the environment, where automatic signing has something to work with.
|
|
24
|
+
*
|
|
25
|
+
* **Written, and not yet run.** No Mac has built this: the Swift, the spec and this file were
|
|
26
|
+
* written against Apple's documented behaviour, and the first person to run it should expect to
|
|
27
|
+
* fix something. What is *not* guessed is the shape of the bridge — that is shared with Android
|
|
28
|
+
* and tested — and the containment rule in the scheme handler, which mirrors the desktop shell's.
|
|
29
|
+
*
|
|
30
|
+
* **One question is still open and it gates the whole tier**: whether WebKit treats a custom
|
|
31
|
+
* scheme as a *secure context*. If it does not, `crossOriginIsolated` is false and the answer is a
|
|
32
|
+
* loopback server instead — `SchemeHandler.swift` is the only file that changes.
|
|
33
|
+
* `drift://localhost/__drift/originProbe.html` answers it in one look on a device. **That URL is
|
|
34
|
+
* the answer as much as the page is**: the host has to be `localhost`, because that is the whole
|
|
35
|
+
* of why the origin is trustworthy, and the shell's own assets are served under `__drift/`
|
|
36
|
+
* rather than from a second host, because a second host would not be `localhost`. Every earlier
|
|
37
|
+
* spelling of this in the documentation named the desktop shell's `drift://shell/` form, which
|
|
38
|
+
* on this platform resolves against the game's bundle root and answers with nothing.
|
|
39
|
+
*/
|
|
40
|
+
export async function buildIos(
|
|
41
|
+
manifest: PackageManifest,
|
|
42
|
+
projectDir: string,
|
|
43
|
+
outDir: string,
|
|
44
|
+
options: ResourceOptions = {},
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (process.platform !== 'darwin') {
|
|
47
|
+
throw new Error('ios is built on macOS: Xcode is not available anywhere else');
|
|
48
|
+
}
|
|
49
|
+
const xcodegen = await findXcodeGen();
|
|
50
|
+
if (xcodegen === null) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'XcodeGen is not here. Run `drift-package bootstrap --target=ios`, which fetches it; ' +
|
|
53
|
+
'Xcode itself comes from the App Store.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const projectRoot = join(outDir, 'project');
|
|
58
|
+
await rm(projectRoot, { recursive: true, force: true });
|
|
59
|
+
await mkdir(projectRoot, { recursive: true });
|
|
60
|
+
await cp(await resourceDir('ios', options.resourceRoot), projectRoot, { recursive: true });
|
|
61
|
+
|
|
62
|
+
/* The game keeps its own directory here, unlike Android: `SchemeHandler` resolves against a
|
|
63
|
+
`www` root inside the bundle, so absolute references land where they point. */
|
|
64
|
+
const distDir = resolve(projectDir, dirname(manifest.entry));
|
|
65
|
+
await cp(distDir, join(projectRoot, 'www'), { recursive: true });
|
|
66
|
+
await checkReferences(join(projectRoot, 'www'), basename(manifest.entry));
|
|
67
|
+
/* Served under `drift://localhost/__drift/`, so the origin probe and the mark are reachable
|
|
68
|
+
without a second host — which would not be `localhost`, and so would not be secure. */
|
|
69
|
+
await cp(await resourceDir('assets', options.resourceRoot), join(projectRoot, 'shell'), {
|
|
70
|
+
recursive: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await esbuild({
|
|
74
|
+
stdin: {
|
|
75
|
+
contents: `import { attachIosShim } from ${JSON.stringify(join(HERE, 'mobile', 'iosShim.ts'))};\nattachIosShim('__driftHost');\n`,
|
|
76
|
+
resolveDir: HERE,
|
|
77
|
+
loader: 'ts',
|
|
78
|
+
},
|
|
79
|
+
outfile: join(projectRoot, 'drift-shim.js'),
|
|
80
|
+
bundle: true,
|
|
81
|
+
format: 'iife',
|
|
82
|
+
platform: 'browser',
|
|
83
|
+
target: 'es2022',
|
|
84
|
+
logLevel: 'warning',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const version = await consumerVersion(projectDir);
|
|
88
|
+
const team = process.env.APPLE_TEAM_ID ?? '';
|
|
89
|
+
const spec = (await readFile(join(projectRoot, 'project.yml'), 'utf8'))
|
|
90
|
+
.replace(/__DRIFT_APP_NAME__/g, manifest.name)
|
|
91
|
+
.replace(/__DRIFT_APP_ID__/g, manifest.id)
|
|
92
|
+
.replace(/__DRIFT_VERSION_NAME__/g, version)
|
|
93
|
+
.replace(/__DRIFT_VERSION_CODE__/g, String(versionCodeFor(version)))
|
|
94
|
+
.replace(/__DRIFT_TEAM_ID__/g, team);
|
|
95
|
+
await writeFile(join(projectRoot, 'project.yml'), spec, 'utf8');
|
|
96
|
+
|
|
97
|
+
console.log(
|
|
98
|
+
`[drift-package] ios: ${team === '' ? 'unsigned — a sideloading tool re-signs it' : `signing with team ${team}`}`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await runTool(xcodegen, ['generate', '--spec', 'project.yml'], projectRoot);
|
|
102
|
+
|
|
103
|
+
const archive = join(outDir, 'DriftHost.xcarchive');
|
|
104
|
+
await runTool(
|
|
105
|
+
'xcodebuild',
|
|
106
|
+
[
|
|
107
|
+
'-project',
|
|
108
|
+
'DriftHost.xcodeproj',
|
|
109
|
+
'-scheme',
|
|
110
|
+
'DriftHost',
|
|
111
|
+
'-configuration',
|
|
112
|
+
'Release',
|
|
113
|
+
'-destination',
|
|
114
|
+
'generic/platform=iOS',
|
|
115
|
+
'-archivePath',
|
|
116
|
+
archive,
|
|
117
|
+
'archive',
|
|
118
|
+
...(team === ''
|
|
119
|
+
? ['CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', 'CODE_SIGN_IDENTITY=']
|
|
120
|
+
: [`DEVELOPMENT_TEAM=${team}`]),
|
|
121
|
+
],
|
|
122
|
+
projectRoot,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* An `.ipa` is a zip with the app inside a `Payload` directory, which is all `xcodebuild
|
|
127
|
+
* -exportArchive` produces for a development build — and that step needs a provisioning profile
|
|
128
|
+
* this may not have. Built by hand so an unsigned archive still leaves something installable by
|
|
129
|
+
* a tool that signs it afterwards.
|
|
130
|
+
*/
|
|
131
|
+
const payload = join(outDir, 'Payload');
|
|
132
|
+
await rm(payload, { recursive: true, force: true });
|
|
133
|
+
await mkdir(payload, { recursive: true });
|
|
134
|
+
await cp(
|
|
135
|
+
join(archive, 'Products', 'Applications', `${manifest.name}.app`),
|
|
136
|
+
join(payload, `${manifest.name}.app`),
|
|
137
|
+
{ recursive: true },
|
|
138
|
+
);
|
|
139
|
+
const ipa = join(outDir, `${manifest.name.replace(/[^\w.-]+/g, '-')}-${version}.ipa`);
|
|
140
|
+
await rm(ipa, { force: true });
|
|
141
|
+
await runTool('zip', ['-qry', ipa, 'Payload'], outDir);
|
|
142
|
+
await rm(payload, { recursive: true, force: true });
|
|
143
|
+
console.log(`[drift-package] ios: ${ipa}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function checkReferences(root: string, entryName: string): Promise<void> {
|
|
147
|
+
const html = await readFile(join(root, entryName), 'utf8');
|
|
148
|
+
const { readdir } = await import('node:fs/promises');
|
|
149
|
+
const { relative } = await import('node:path');
|
|
150
|
+
const walk = async (dir: string): Promise<string[]> => {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
153
|
+
const full = join(dir, entry.name);
|
|
154
|
+
if (entry.isDirectory()) out.push(...(await walk(full)));
|
|
155
|
+
else out.push(relative(root, full).split('\\').join('/'));
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
};
|
|
159
|
+
const missing = missingReferences(html, new Set(await walk(root)));
|
|
160
|
+
if (missing.length === 0) return;
|
|
161
|
+
throw new Error(
|
|
162
|
+
`the entry page references ${missing.length} file(s) that are not in the bundle: ` +
|
|
163
|
+
`${missing.slice(0, 5).join(', ')}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function consumerVersion(projectDir: string): Promise<string> {
|
|
168
|
+
try {
|
|
169
|
+
const raw: unknown = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'));
|
|
170
|
+
const version = (raw as { version?: unknown }).version;
|
|
171
|
+
return typeof version === 'string' && version.length > 0 ? version : '0.0.0';
|
|
172
|
+
} catch {
|
|
173
|
+
return '0.0.0';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function runTool(command: string, args: readonly string[], cwd: string): Promise<void> {
|
|
178
|
+
await new Promise<void>((done, fail) => {
|
|
179
|
+
const child = spawn(command, [...args], { stdio: 'inherit', cwd });
|
|
180
|
+
child.on('error', fail);
|
|
181
|
+
child.on('close', (code) =>
|
|
182
|
+
code === 0 ? done() : fail(new Error(`${command} exited with ${String(code)}`)),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { open, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The macOS code signature, applied from a machine that is not a Mac.
|
|
7
|
+
*
|
|
8
|
+
* **Why this exists at all.** electron-builder's own refusal to build a Mac target fires on
|
|
9
|
+
* Windows and nowhere else, so a `.app` packs perfectly well on Linux — and then
|
|
10
|
+
* `isSignAllowed()` returns false off darwin, one warning is logged, and the artifact is finished
|
|
11
|
+
* and unsigned. Apple Silicon's loader refuses an unsigned binary outright, which is not
|
|
12
|
+
* Gatekeeper and which no user gesture works around, so that artifact is one that looks complete
|
|
13
|
+
* and cannot start. `rcodesign` is the one implementation of Apple's signature format that runs
|
|
14
|
+
* anywhere, and this module is the step that puts a signature back on.
|
|
15
|
+
*
|
|
16
|
+
* **Everything here is checked rather than assumed, because nothing on this side can launch the
|
|
17
|
+
* result.** A Mac is the only thing that can prove the app runs; what a Linux host *can* prove is
|
|
18
|
+
* that every binary in the bundle carries a signature, that every nested bundle was sealed, and
|
|
19
|
+
* that the slice inside is the architecture that was asked for. Those are the three ways this step
|
|
20
|
+
* fails silently, and each one below is a refusal rather than a warning.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The line rcodesign prints when a nested bundle's main executable cannot be resolved.
|
|
25
|
+
*
|
|
26
|
+
* **Measured rather than guessed, and it is not cosmetic.** A stock Electron distribution ships
|
|
27
|
+
* its helper apps with no `CFBundleExecutable` key; Apple's `codesign` infers the executable from
|
|
28
|
+
* the bundle's own name and rcodesign does not. When it cannot resolve one, it signs the Mach-O
|
|
29
|
+
* inside and then leaves that bundle out of the outer app's `CodeResources` — so the app's
|
|
30
|
+
* signature does not cover its own helpers, and nothing about the build says so.
|
|
31
|
+
*
|
|
32
|
+
* electron-builder rewrites every helper plist with a `CFBundleExecutable` before this runs, which
|
|
33
|
+
* is why a real build does not hit this. That is a fact about a dependency's current behaviour,
|
|
34
|
+
* not a guarantee, so it is checked here instead of trusted.
|
|
35
|
+
*/
|
|
36
|
+
const UNRESOLVED_NESTED = 'could not find main executable of presumed nested bundle';
|
|
37
|
+
|
|
38
|
+
export interface CrossSignOptions {
|
|
39
|
+
/** The `.app` directory, which is what rcodesign is pointed at. */
|
|
40
|
+
readonly app: string;
|
|
41
|
+
/** The rcodesign binary, from the cache or from the machine. */
|
|
42
|
+
readonly rcodesign: string;
|
|
43
|
+
/** The slice the artifact is supposed to carry, so a silently wrong one is caught here. */
|
|
44
|
+
readonly arch: 'arm64' | 'x64';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ad-hoc sign a `.app` in place, then refuse it if the result is not what was asked for.
|
|
49
|
+
*
|
|
50
|
+
* Ad-hoc is the only mode offered: a Developer ID would need a certificate this host cannot use,
|
|
51
|
+
* and `planSigning` refuses that combination before a build gets this far.
|
|
52
|
+
*/
|
|
53
|
+
export async function crossSignMacApp(options: CrossSignOptions): Promise<void> {
|
|
54
|
+
/* No identity argument at all is rcodesign's spelling of an ad-hoc signature, and a bundle path
|
|
55
|
+
is signed recursively: nested bundles and loose Mach-O binaries included. */
|
|
56
|
+
const output = await run(options.rcodesign, ['sign', options.app]);
|
|
57
|
+
|
|
58
|
+
if (output.includes(UNRESOLVED_NESTED)) {
|
|
59
|
+
const bundles = output
|
|
60
|
+
.split('\n')
|
|
61
|
+
.filter((line) => line.includes(UNRESOLVED_NESTED))
|
|
62
|
+
.map((line) => line.slice(line.indexOf(UNRESOLVED_NESTED) + UNRESOLVED_NESTED.length + 2))
|
|
63
|
+
.join(', ');
|
|
64
|
+
throw new Error(
|
|
65
|
+
`rcodesign could not resolve the main executable of ${bundles}, so the app's signature ` +
|
|
66
|
+
"does not cover it. That is a bundle whose Info.plist has no CFBundleExecutable: Apple's " +
|
|
67
|
+
'codesign infers one from the bundle name and rcodesign does not. The artifact would ' +
|
|
68
|
+
'look signed and fail validation on a Mac.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await assertEverySealed(options.app);
|
|
73
|
+
await assertArch(options.app, options.arch);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `.zip` a cross-built Mac artifact ships as, archived here rather than by electron-builder.
|
|
78
|
+
*
|
|
79
|
+
* **electron-builder cannot produce this one correctly off a Mac, and says so in its own source**:
|
|
80
|
+
* its archiver picks 7-Zip everywhere except macOS, guarded by a comment reading *"7zip
|
|
81
|
+
* dereferences symlinks, corrupting .framework structure and breaking codesign"*. The guard is
|
|
82
|
+
* written as `process.platform === "darwin"`, so on Linux it takes the 7-Zip path regardless.
|
|
83
|
+
*
|
|
84
|
+
* **A `.framework` is symlinks by construction.** `Electron Framework` points into
|
|
85
|
+
* `Versions/Current`, which points at `Versions/A`. Dereferenced, the archive carries the
|
|
86
|
+
* framework's 192 MB payload twice and no links at all, and macOS rejects that layout as
|
|
87
|
+
* unrecognised — the signature that was correct on disk no longer validates against it. Measured
|
|
88
|
+
* on this repository before this function existed: a 404 MB archive with zero symlink entries,
|
|
89
|
+
* against 353 MB on disk.
|
|
90
|
+
*
|
|
91
|
+
* `zip -y` stores a symlink as a symlink, `-r` recurses, and Unix permission bits are kept, which
|
|
92
|
+
* every executable inside the bundle depends on.
|
|
93
|
+
*/
|
|
94
|
+
export async function zipMacApp(app: string, into: string): Promise<void> {
|
|
95
|
+
/* `zip` updates an existing archive in place rather than replacing it, so a rebuild would
|
|
96
|
+
otherwise leave files from the previous one in the artifact. */
|
|
97
|
+
await rm(into, { force: true });
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await run('zip', ['-y', '-r', '-q', into, basename(app)], { cwd: dirname(app) });
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`could not archive the app with \`zip\`: ${String(cause)}. This build needs it because ` +
|
|
104
|
+
"electron-builder's own zip dereferences symlinks off a Mac, which corrupts every " +
|
|
105
|
+
'framework in the bundle. Install it (`apt install zip`) and build again.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const symlinks = await countSymlinkEntries(into);
|
|
110
|
+
if (symlinks === 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${into} contains no symlinks, and a macOS app bundle is built out of them — every ` +
|
|
113
|
+
'framework links its top level into `Versions/Current`. An archive without them holds ' +
|
|
114
|
+
'each framework twice and is rejected by macOS as an unrecognised bundle format. That ' +
|
|
115
|
+
'is what `zip` without `-y` produces.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
console.log(`[drift-package] archived ${into} with ${String(symlinks)} symlinks preserved`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* The three fields of the zip format this needs, from APPNOTE.TXT. */
|
|
122
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
123
|
+
const CENTRAL_SIGNATURE = 0x02014b50;
|
|
124
|
+
/** `S_IFLNK`, in the Unix mode that lives in the high half of the external attributes field. */
|
|
125
|
+
const S_IFMT = 0xf000;
|
|
126
|
+
const S_IFLNK = 0xa000;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How many entries in an archive are symlinks, read from its central directory.
|
|
130
|
+
*
|
|
131
|
+
* **The archive is inspected rather than the tool trusted**, because the failure this guards
|
|
132
|
+
* against is silent by nature: an archiver that dereferences links produces a larger file that
|
|
133
|
+
* unpacks into something that looks complete. Reading the central directory is exact, needs no
|
|
134
|
+
* `zipinfo` on the host, and answers the one question that matters.
|
|
135
|
+
*/
|
|
136
|
+
async function countSymlinkEntries(zip: string): Promise<number> {
|
|
137
|
+
const handle = await open(zip, 'r');
|
|
138
|
+
try {
|
|
139
|
+
const { size } = await handle.stat();
|
|
140
|
+
/* The end-of-central-directory record is last, after a comment of up to 64 KiB. */
|
|
141
|
+
const tailLength = Math.min(size, 0x10000 + 22);
|
|
142
|
+
const tail = Buffer.alloc(tailLength);
|
|
143
|
+
await handle.read(tail, 0, tailLength, size - tailLength);
|
|
144
|
+
|
|
145
|
+
let eocd = -1;
|
|
146
|
+
for (let at = tail.length - 22; at >= 0; at -= 1) {
|
|
147
|
+
if (tail.readUInt32LE(at) === EOCD_SIGNATURE) {
|
|
148
|
+
eocd = at;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (eocd < 0) throw new Error(`${zip} has no end-of-central-directory record`);
|
|
153
|
+
|
|
154
|
+
const entries = tail.readUInt16LE(eocd + 10);
|
|
155
|
+
const directoryLength = tail.readUInt32LE(eocd + 12);
|
|
156
|
+
const directoryStart = tail.readUInt32LE(eocd + 16);
|
|
157
|
+
|
|
158
|
+
const directory = Buffer.alloc(directoryLength);
|
|
159
|
+
await handle.read(directory, 0, directoryLength, directoryStart);
|
|
160
|
+
|
|
161
|
+
let at = 0;
|
|
162
|
+
let symlinks = 0;
|
|
163
|
+
for (let index = 0; index < entries && at + 46 <= directory.length; index += 1) {
|
|
164
|
+
if (directory.readUInt32LE(at) !== CENTRAL_SIGNATURE) break;
|
|
165
|
+
/* The high half of the external attributes is the Unix mode, for an archive written on
|
|
166
|
+
Unix — which is the only kind this function is ever pointed at. */
|
|
167
|
+
const mode = directory.readUInt32LE(at + 38) >>> 16;
|
|
168
|
+
if ((mode & S_IFMT) === S_IFLNK) symlinks += 1;
|
|
169
|
+
at +=
|
|
170
|
+
46 +
|
|
171
|
+
directory.readUInt16LE(at + 28) +
|
|
172
|
+
directory.readUInt16LE(at + 30) +
|
|
173
|
+
directory.readUInt16LE(at + 32);
|
|
174
|
+
}
|
|
175
|
+
return symlinks;
|
|
176
|
+
} finally {
|
|
177
|
+
await handle.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Every bundle in the artifact carries its own signature.
|
|
183
|
+
*
|
|
184
|
+
* A signed bundle has a `_CodeSignature/CodeResources` beside its contents — the outer app under
|
|
185
|
+
* `Contents/`, a framework under `Versions/A/`. Checking the directory rather than parsing the
|
|
186
|
+
* seal is deliberate: this catches the failure that actually happens, which is a bundle rcodesign
|
|
187
|
+
* walked past, and it does not pretend to be the validation only a Mac can do.
|
|
188
|
+
*/
|
|
189
|
+
async function assertEverySealed(app: string): Promise<void> {
|
|
190
|
+
const missing: string[] = [];
|
|
191
|
+
|
|
192
|
+
if (!(await exists(join(app, 'Contents', '_CodeSignature', 'CodeResources')))) {
|
|
193
|
+
missing.push('the app itself');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const frameworks = join(app, 'Contents', 'Frameworks');
|
|
197
|
+
for (const entry of await list(frameworks)) {
|
|
198
|
+
const nested = join(frameworks, entry);
|
|
199
|
+
/* A `.app` seals under `Contents/`; a `.framework` under the version it points `Current` at,
|
|
200
|
+
which is `A` for everything Electron ships. */
|
|
201
|
+
const seal = entry.endsWith('.framework')
|
|
202
|
+
? join(nested, 'Versions', 'A', '_CodeSignature', 'CodeResources')
|
|
203
|
+
: join(nested, 'Contents', '_CodeSignature', 'CodeResources');
|
|
204
|
+
if (entry.endsWith('.app') || entry.endsWith('.framework')) {
|
|
205
|
+
if (!(await exists(seal))) missing.push(entry);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (missing.length > 0) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`these parts of the app were not signed: ${missing.join(', ')}. An unsigned binary inside ` +
|
|
212
|
+
'a signed app is refused by the loader at the moment it is needed, which for a helper is ' +
|
|
213
|
+
'the first window rather than the launch.',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The artifact carries the slice it was asked for.
|
|
220
|
+
*
|
|
221
|
+
* **The failure this prevents is a build that produced the wrong architecture and said nothing.**
|
|
222
|
+
* electron-builder picks the Electron distribution from the target's arch, so a `mac-x64` asked
|
|
223
|
+
* for should never contain arm64 — but a cache with the wrong download in it, or a default that
|
|
224
|
+
* inherited the host's arch, both end as an app that refuses to start on the machine it was made
|
|
225
|
+
* for, with no error that names the cause.
|
|
226
|
+
*/
|
|
227
|
+
async function assertArch(app: string, expected: 'arm64' | 'x64'): Promise<void> {
|
|
228
|
+
const executable = await mainExecutable(app);
|
|
229
|
+
const found = await machoArch(executable);
|
|
230
|
+
if (found === null) {
|
|
231
|
+
throw new Error(`${executable} is not a Mach-O binary; the app did not pack correctly`);
|
|
232
|
+
}
|
|
233
|
+
if (found !== expected) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`this build asked for ${expected} and the binary inside is ${found}. That artifact does ` +
|
|
236
|
+
'not run on the machine it was built for, and nothing about it says so until it is opened.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The one file under `Contents/MacOS`, which is the app's entry point whatever it is named. */
|
|
242
|
+
async function mainExecutable(app: string): Promise<string> {
|
|
243
|
+
const dir = join(app, 'Contents', 'MacOS');
|
|
244
|
+
const [entry] = await list(dir);
|
|
245
|
+
if (entry === undefined) throw new Error(`${dir} is empty; the app did not pack correctly`);
|
|
246
|
+
return join(dir, entry);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* Mach-O's own constants, from `mach-o/loader.h` and `mach/machine.h`. */
|
|
250
|
+
const MACHO_64_LE = 0xfeedfacf;
|
|
251
|
+
const FAT_BE = 0xcafebabe;
|
|
252
|
+
const CPU_X86_64 = 0x01000007;
|
|
253
|
+
const CPU_ARM64 = 0x0100000c;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Which architecture a Mach-O binary holds, read from its header.
|
|
257
|
+
*
|
|
258
|
+
* Sixteen bytes of file rather than a tool: `file` and `lipo` both answer this and neither is on
|
|
259
|
+
* a Linux host by default, and the header is four fields that have not moved in twenty years.
|
|
260
|
+
* A universal binary answers with what it contains only when that is unambiguous — one slice.
|
|
261
|
+
*/
|
|
262
|
+
async function machoArch(path: string): Promise<'arm64' | 'x64' | null> {
|
|
263
|
+
const handle = await open(path, 'r');
|
|
264
|
+
try {
|
|
265
|
+
const header = Buffer.alloc(8);
|
|
266
|
+
await handle.read(header, 0, 8, 0);
|
|
267
|
+
|
|
268
|
+
if (header.readUInt32LE(0) === MACHO_64_LE) return cpuName(header.readUInt32LE(4));
|
|
269
|
+
|
|
270
|
+
if (header.readUInt32BE(0) === FAT_BE) {
|
|
271
|
+
/* A fat header is a count and then 20-byte entries whose first field is the cpu type. */
|
|
272
|
+
const count = header.readUInt32BE(4);
|
|
273
|
+
const names = new Set<'arm64' | 'x64'>();
|
|
274
|
+
for (let index = 0; index < count; index += 1) {
|
|
275
|
+
const entry = Buffer.alloc(4);
|
|
276
|
+
await handle.read(entry, 0, 4, 8 + index * 20);
|
|
277
|
+
const name = cpuName(entry.readUInt32BE(0));
|
|
278
|
+
if (name !== null) names.add(name);
|
|
279
|
+
}
|
|
280
|
+
return names.size === 1 ? [...names][0] : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
} finally {
|
|
285
|
+
await handle.close();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cpuName(cpu: number): 'arm64' | 'x64' | null {
|
|
290
|
+
if (cpu === CPU_ARM64) return 'arm64';
|
|
291
|
+
if (cpu === CPU_X86_64) return 'x64';
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function list(dir: string): Promise<string[]> {
|
|
296
|
+
try {
|
|
297
|
+
return await readdir(dir);
|
|
298
|
+
} catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function exists(path: string): Promise<boolean> {
|
|
304
|
+
try {
|
|
305
|
+
await (await import('node:fs/promises')).access(path);
|
|
306
|
+
return true;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Run a command and return everything it said, on both streams.
|
|
314
|
+
*
|
|
315
|
+
* rcodesign narrates to stderr and the warning this module refuses on arrives there, so the two
|
|
316
|
+
* are merged rather than one being watched — a check that read only stdout would pass every time.
|
|
317
|
+
*/
|
|
318
|
+
async function run(
|
|
319
|
+
command: string,
|
|
320
|
+
args: readonly string[],
|
|
321
|
+
options: { readonly cwd?: string } = {},
|
|
322
|
+
): Promise<string> {
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
const child = spawn(command, [...args], {
|
|
325
|
+
cwd: options.cwd,
|
|
326
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
327
|
+
});
|
|
328
|
+
let output = '';
|
|
329
|
+
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
330
|
+
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
331
|
+
child.on('error', reject);
|
|
332
|
+
child.on('close', (code) =>
|
|
333
|
+
code === 0
|
|
334
|
+
? resolve(output)
|
|
335
|
+
: reject(new Error(`${command} exited with ${String(code)}:\n${output}`)),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a renderer's console line off an event whose shape has changed, and deciding whether it
|
|
3
|
+
* is worth forwarding.
|
|
4
|
+
*
|
|
5
|
+
* **Pure, and tested, because the failure of getting it wrong is silence.** A packaged game has no
|
|
6
|
+
* developer tools and no console a player can open, so this forwarding is the only way anything the
|
|
7
|
+
* renderer says reaches a bug report. Reading the wrong argument does not throw and does not warn:
|
|
8
|
+
* it compares `undefined` against a string for every message, forwards none of them, and is
|
|
9
|
+
* indistinguishable from a game that never logged.
|
|
10
|
+
*
|
|
11
|
+
* The two shapes, both of which are `console-message` on `WebContents`:
|
|
12
|
+
*
|
|
13
|
+
* - **Electron 35 and newer.** One object first, carrying `message` and a string `level` of
|
|
14
|
+
* `debug`, `info`, `warning` or `error`. The old positional arguments follow it, deprecated.
|
|
15
|
+
* - **Before that, fully positional**: `(event, level: 0..3, message, line, sourceId)`, where the
|
|
16
|
+
* event itself carries neither. **Its own typings say otherwise** — `electron.d.ts` at 33.4.11
|
|
17
|
+
* declares `(event, messageDetails)` with the details as an object — and reading them is what
|
|
18
|
+
* produced a first version of this module that handled two object shapes and forwarded nothing
|
|
19
|
+
* on the runtime it was written to rescue. The positional form below is what a probe against
|
|
20
|
+
* that binary actually received. The object-second shape is still accepted, because it is what
|
|
21
|
+
* the typings promise and costs one branch to keep.
|
|
22
|
+
*/
|
|
23
|
+
export type ConsoleLevel = 'debug' | 'verbose' | 'info' | 'warning' | 'error';
|
|
24
|
+
|
|
25
|
+
export interface ConsoleLine {
|
|
26
|
+
readonly level: ConsoleLevel;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 0 to 3, as the older event numbers them. */
|
|
31
|
+
const BY_NUMBER: readonly ConsoleLevel[] = ['verbose', 'info', 'warning', 'error'];
|
|
32
|
+
|
|
33
|
+
const NAMED: readonly string[] = ['debug', 'verbose', 'info', 'warning', 'error'];
|
|
34
|
+
|
|
35
|
+
function levelOf(value: unknown): ConsoleLevel | null {
|
|
36
|
+
if (typeof value === 'string' && NAMED.includes(value)) return value as ConsoleLevel;
|
|
37
|
+
if (typeof value === 'number' && Number.isInteger(value)) return BY_NUMBER[value] ?? null;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fieldsOf(value: unknown): { level: unknown; message: unknown } | null {
|
|
42
|
+
if (value === null || typeof value !== 'object') return null;
|
|
43
|
+
const record = value as Record<string, unknown>;
|
|
44
|
+
return { level: record.level, message: record.message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The line a listener was handed, whichever shape it came in, or `null` when neither carried one.
|
|
49
|
+
*
|
|
50
|
+
* The first argument is preferred when it carries a level, which is the newer shape; the second is
|
|
51
|
+
* read otherwise. The third is the positional message the newer shape also passes, used only when
|
|
52
|
+
* an object carried a level without a message.
|
|
53
|
+
*/
|
|
54
|
+
export function consoleLine(first: unknown, second: unknown, third: unknown): ConsoleLine | null {
|
|
55
|
+
/*
|
|
56
|
+
* The positional form first, because it is unambiguous: a number where an object would be is the
|
|
57
|
+
* older event, and no newer one puts a level there without also putting one on its first
|
|
58
|
+
* argument — which the loop below would have taken anyway.
|
|
59
|
+
*/
|
|
60
|
+
if (
|
|
61
|
+
typeof second === 'number' &&
|
|
62
|
+
typeof third === 'string' &&
|
|
63
|
+
fieldsOf(first)?.level === undefined
|
|
64
|
+
) {
|
|
65
|
+
const level = levelOf(second);
|
|
66
|
+
if (level !== null) return { level, message: third };
|
|
67
|
+
}
|
|
68
|
+
for (const candidate of [fieldsOf(first), fieldsOf(second)]) {
|
|
69
|
+
if (candidate === null) continue;
|
|
70
|
+
const level = levelOf(candidate.level);
|
|
71
|
+
if (level === null) continue;
|
|
72
|
+
const message =
|
|
73
|
+
typeof candidate.message === 'string'
|
|
74
|
+
? candidate.message
|
|
75
|
+
: typeof third === 'string'
|
|
76
|
+
? third
|
|
77
|
+
: '';
|
|
78
|
+
return { level, message };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a line at this level is forwarded.
|
|
85
|
+
*
|
|
86
|
+
* Errors in both kinds of build: an error nobody can quote is an error nobody can fix. Warnings in
|
|
87
|
+
* development only — they are where a framework's advice and a game's own diagnostics live, and a
|
|
88
|
+
* shipped game printing them to a terminal nobody is watching buries the line that matters.
|
|
89
|
+
*/
|
|
90
|
+
export function forwardsConsole(level: ConsoleLevel, development: boolean): boolean {
|
|
91
|
+
if (level === 'error') return true;
|
|
92
|
+
return development && level === 'warning';
|
|
93
|
+
}
|