@nativedesktop/cli 0.2.0 → 0.3.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/package.json +2 -2
- package/src/config.test.ts +54 -0
- package/src/config.ts +87 -0
- package/src/index.ts +25 -4
- package/src/package/cef.test.ts +332 -0
- package/src/package/cef.ts +454 -0
- package/src/package/doctor.test.ts +84 -0
- package/src/package/doctor.ts +91 -3
- package/src/package/identity.test.ts +14 -0
- package/src/package/linux.ts +17 -1
- package/src/package/mac.ts +61 -3
- package/src/package/payload.test.ts +32 -1
- package/src/package/payload.ts +19 -3
- package/src/package/templates.ts +47 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// CEF acquisition and staging for `nd package`. Nothing here runs unless the
|
|
2
|
+
// target's `webview.engine` is "chromium": with the default engine the app ships
|
|
3
|
+
// zero Chromium bytes, which is a product claim the doctor's bundle audit
|
|
4
|
+
// (cefBundleAudit, below) proves.
|
|
5
|
+
//
|
|
6
|
+
// Acquisition: the pinned version is resolved against the official build index,
|
|
7
|
+
// the "minimal" dist is downloaded, its sha1 from the index is verified, and the
|
|
8
|
+
// tarball plus its extraction are cached under ~/.cache/nativedesktop/cef/ so a
|
|
9
|
+
// second package run and `nd dev` share one copy.
|
|
10
|
+
//
|
|
11
|
+
// Staging is split into a pure plan and an executor. The plan is what the tests
|
|
12
|
+
// assert, so both platforms' layouts are covered from one cached dist without
|
|
13
|
+
// writing a bundle.
|
|
14
|
+
import { $ } from "bun";
|
|
15
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { basename, join } from "node:path";
|
|
17
|
+
import { buildCefHelper } from "@nativedesktop/host";
|
|
18
|
+
import {
|
|
19
|
+
CEF_ARTIFACT_NAMES,
|
|
20
|
+
CEF_HELPER_BINARY_NAME,
|
|
21
|
+
type CefPlatform,
|
|
22
|
+
cefCacheRoot,
|
|
23
|
+
cefDistDir,
|
|
24
|
+
DEFAULT_CEF_VERSION,
|
|
25
|
+
isCefRoot,
|
|
26
|
+
} from "@nativedesktop/host/cef";
|
|
27
|
+
import type { CefConfig } from "../config.ts";
|
|
28
|
+
import { cefHelperEntitlements, cefHelperPlist } from "./templates.ts";
|
|
29
|
+
|
|
30
|
+
const CEF_INDEX_URL = "https://cef-builds.spotifycdn.com/index.json";
|
|
31
|
+
const CEF_DOWNLOAD_BASE = "https://cef-builds.spotifycdn.com/";
|
|
32
|
+
|
|
33
|
+
/** Locales staged when the config names none. */
|
|
34
|
+
export const DEFAULT_CEF_LOCALES: readonly string[] = ["en-US"];
|
|
35
|
+
|
|
36
|
+
export { CEF_HELPER_BINARY_NAME } from "@nativedesktop/host/cef";
|
|
37
|
+
|
|
38
|
+
/** Helper bundle suffixes CEF requires on macOS, in the order they are signed.
|
|
39
|
+
* The names are fixed by CEF: it derives each helper path from the main bundle
|
|
40
|
+
* name plus exactly these suffixes. */
|
|
41
|
+
export const CEF_HELPER_SUFFIXES: readonly string[] = ["", " (Alerts)", " (GPU)", " (Plugin)", " (Renderer)"];
|
|
42
|
+
|
|
43
|
+
/** Suffix to bundle-id tail, e.g. " (Renderer)" to ".renderer". */
|
|
44
|
+
function helperIdSuffix(suffix: string): string {
|
|
45
|
+
return suffix ? `.${suffix.trim().replace(/[()]/g, "").toLowerCase()}` : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Acquisition
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
interface CefIndexFile {
|
|
53
|
+
type: string;
|
|
54
|
+
name: string;
|
|
55
|
+
sha1: string;
|
|
56
|
+
size: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface CefIndexVersion {
|
|
60
|
+
cef_version: string;
|
|
61
|
+
channel: string;
|
|
62
|
+
files: CefIndexFile[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CefIndex {
|
|
66
|
+
[platform: string]: { versions: CefIndexVersion[] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CefBuild {
|
|
70
|
+
/** Full version as the index spells it, e.g. "151.3.23+gd211df0+chromium-151.0.7922.170". */
|
|
71
|
+
cefVersion: string;
|
|
72
|
+
name: string;
|
|
73
|
+
sha1: string;
|
|
74
|
+
size: number;
|
|
75
|
+
url: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The minimal dist for a pinned version. The pin is the short release number
|
|
80
|
+
* ("151.3.23"); the index keys on the full build string, so it matches on the
|
|
81
|
+
* "+" boundary rather than a bare prefix (151.3.2 must not match 151.3.23).
|
|
82
|
+
*/
|
|
83
|
+
export function selectCefBuild(index: CefIndex, cefPlatform: CefPlatform, version: string): CefBuild {
|
|
84
|
+
const versions = index[cefPlatform]?.versions;
|
|
85
|
+
if (!versions?.length) throw new Error(`nd: the CEF build index lists no builds for "${cefPlatform}"`);
|
|
86
|
+
const entry = versions.find((v) => v.cef_version === version || v.cef_version.startsWith(`${version}+`));
|
|
87
|
+
if (!entry) {
|
|
88
|
+
throw new Error(`nd: CEF ${version} has no ${cefPlatform} build in the index (newest is ${versions[0]!.cef_version})`);
|
|
89
|
+
}
|
|
90
|
+
const file = entry.files.find((f) => f.type === "minimal");
|
|
91
|
+
if (!file) throw new Error(`nd: CEF ${entry.cef_version} ships no minimal ${cefPlatform} dist`);
|
|
92
|
+
return {
|
|
93
|
+
cefVersion: entry.cef_version,
|
|
94
|
+
name: file.name,
|
|
95
|
+
sha1: file.sha1,
|
|
96
|
+
size: file.size,
|
|
97
|
+
url: CEF_DOWNLOAD_BASE + encodeURIComponent(file.name),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The build index. ND_CEF_INDEX points at a local copy (offline builds, tests). */
|
|
102
|
+
export async function fetchCefIndex(env: Record<string, string | undefined> = process.env): Promise<CefIndex> {
|
|
103
|
+
const local = env.ND_CEF_INDEX;
|
|
104
|
+
if (local) return (await Bun.file(local).json()) as CefIndex;
|
|
105
|
+
const response = await fetch(CEF_INDEX_URL);
|
|
106
|
+
if (!response.ok) throw new Error(`nd: CEF build index request failed (${response.status} ${response.statusText})`);
|
|
107
|
+
return (await response.json()) as CefIndex;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function sha1OfFile(path: string): Promise<string> {
|
|
111
|
+
const hasher = new Bun.CryptoHasher("sha1");
|
|
112
|
+
for await (const chunk of Bun.file(path).stream()) hasher.update(chunk);
|
|
113
|
+
return hasher.digest("hex");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface EnsureCefDistOptions {
|
|
117
|
+
cefPlatform: CefPlatform;
|
|
118
|
+
version?: string;
|
|
119
|
+
env?: Record<string, string | undefined>;
|
|
120
|
+
/** Refuse to touch the network: the dist must already be cached. */
|
|
121
|
+
offline?: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Absolute path to an extracted dist, downloading and verifying it first when
|
|
126
|
+
* the cache has none. An extraction counts as complete only once the platform's
|
|
127
|
+
* loadable is in place, so a killed run re-extracts instead of staging a
|
|
128
|
+
* half-written tree.
|
|
129
|
+
*/
|
|
130
|
+
export async function ensureCefDist(opts: EnsureCefDistOptions): Promise<string> {
|
|
131
|
+
const env = opts.env ?? process.env;
|
|
132
|
+
const version = opts.version ?? DEFAULT_CEF_VERSION;
|
|
133
|
+
const dist = cefDistDir(version, opts.cefPlatform, env);
|
|
134
|
+
if (isCefRoot(join(dist, "Release"), opts.cefPlatform)) return dist;
|
|
135
|
+
if (opts.offline) {
|
|
136
|
+
throw new Error(`nd: CEF ${version} for ${opts.cefPlatform} is not in the cache (${dist}) and downloads are disabled`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const build = selectCefBuild(await fetchCefIndex(env), opts.cefPlatform, version);
|
|
140
|
+
const cache = cefCacheRoot(env);
|
|
141
|
+
mkdirSync(cache, { recursive: true });
|
|
142
|
+
const tarball = join(cache, `cef_${version}-${opts.cefPlatform}_minimal.tar.bz2`);
|
|
143
|
+
if (!existsSync(tarball) || (await sha1OfFile(tarball)) !== build.sha1) {
|
|
144
|
+
console.error(`nd: downloading CEF ${build.cefVersion} for ${opts.cefPlatform} (${(build.size / 1e6).toFixed(0)} MB)`);
|
|
145
|
+
const response = await fetch(build.url);
|
|
146
|
+
if (!response.ok) throw new Error(`nd: CEF download failed (${response.status} ${response.statusText}) for ${build.url}`);
|
|
147
|
+
await Bun.write(tarball, response);
|
|
148
|
+
const got = await sha1OfFile(tarball);
|
|
149
|
+
if (got !== build.sha1) {
|
|
150
|
+
rmSync(tarball, { force: true });
|
|
151
|
+
throw new Error(`nd: CEF download sha1 mismatch for ${build.name} (index ${build.sha1}, got ${got})`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Extract beside the target and rename, so an interrupted run cannot leave a
|
|
156
|
+
// partial tree that later passes the completeness check. --strip-components
|
|
157
|
+
// drops the tarball's cef_binary_<version>_<platform>_minimal/ wrapper, and
|
|
158
|
+
// tar keeps symlinks as symlinks, which the macOS framework layout needs for
|
|
159
|
+
// codesign to accept it.
|
|
160
|
+
const staging = `${dist}.partial`;
|
|
161
|
+
rmSync(staging, { recursive: true, force: true });
|
|
162
|
+
mkdirSync(staging, { recursive: true });
|
|
163
|
+
await $`tar -xjf ${tarball} -C ${staging} --strip-components=1`;
|
|
164
|
+
rmSync(dist, { recursive: true, force: true });
|
|
165
|
+
renameSync(staging, dist);
|
|
166
|
+
return dist;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// macOS staging
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export interface CefHelperPlan {
|
|
174
|
+
/** "" for the base helper, " (Renderer)" and friends for the rest. */
|
|
175
|
+
suffix: string;
|
|
176
|
+
appPath: string;
|
|
177
|
+
executablePath: string;
|
|
178
|
+
bundleId: string;
|
|
179
|
+
plist: string;
|
|
180
|
+
/** Renderer and GPU run JIT-compiled code and need the entitlements for it. */
|
|
181
|
+
jit: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface CefMacPlan {
|
|
185
|
+
framework: { from: string; to: string };
|
|
186
|
+
/** Mach-O inside the framework that codesign has to seal before the bundle. */
|
|
187
|
+
frameworkLibraries: string[];
|
|
188
|
+
helpers: CefHelperPlan[];
|
|
189
|
+
helperBinary: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface CefMacPlanInput {
|
|
193
|
+
distRoot: string;
|
|
194
|
+
/** The .app's Contents directory. */
|
|
195
|
+
contents: string;
|
|
196
|
+
appName: string;
|
|
197
|
+
appId: string;
|
|
198
|
+
version: string;
|
|
199
|
+
minimumSystemVersion: string;
|
|
200
|
+
helperBinary: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const FRAMEWORK_NAME = "Chromium Embedded Framework.framework";
|
|
204
|
+
|
|
205
|
+
/** What `nd package mac` will put in Contents/Frameworks, without writing it. */
|
|
206
|
+
export function planCefMac(input: CefMacPlanInput): CefMacPlan {
|
|
207
|
+
const from = join(input.distRoot, "Release", FRAMEWORK_NAME);
|
|
208
|
+
if (!existsSync(join(from, "Chromium Embedded Framework"))) {
|
|
209
|
+
throw new Error(`nd: the CEF dist at ${input.distRoot} has no ${FRAMEWORK_NAME}`);
|
|
210
|
+
}
|
|
211
|
+
const frameworks = join(input.contents, "Frameworks");
|
|
212
|
+
const to = join(frameworks, FRAMEWORK_NAME);
|
|
213
|
+
const librariesDir = join(from, "Libraries");
|
|
214
|
+
const frameworkLibraries = existsSync(librariesDir)
|
|
215
|
+
? readdirSync(librariesDir).filter((f) => f.endsWith(".dylib")).sort().map((f) => join(to, "Libraries", f))
|
|
216
|
+
: [];
|
|
217
|
+
|
|
218
|
+
const helpers = CEF_HELPER_SUFFIXES.map((suffix) => {
|
|
219
|
+
const name = `${input.appName} Helper${suffix}`;
|
|
220
|
+
const appPath = join(frameworks, `${name}.app`);
|
|
221
|
+
const bundleId = `${input.appId}.helper${helperIdSuffix(suffix)}`;
|
|
222
|
+
return {
|
|
223
|
+
suffix,
|
|
224
|
+
appPath,
|
|
225
|
+
executablePath: join(appPath, "Contents", "MacOS", name),
|
|
226
|
+
bundleId,
|
|
227
|
+
plist: cefHelperPlist({
|
|
228
|
+
name,
|
|
229
|
+
id: bundleId,
|
|
230
|
+
version: input.version,
|
|
231
|
+
minimumSystemVersion: input.minimumSystemVersion,
|
|
232
|
+
}),
|
|
233
|
+
jit: suffix === " (Renderer)" || suffix === " (GPU)",
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return { framework: { from, to }, frameworkLibraries, helpers, helperBinary: input.helperBinary };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Inside-out codesign order for the staged CEF payload: the framework's own
|
|
241
|
+
* Mach-O, the framework, then each helper executable and its bundle. Everything
|
|
242
|
+
* here is signed before the outer .app. */
|
|
243
|
+
export function cefMacSignTargets(plan: CefMacPlan): { path: string; jit: boolean }[] {
|
|
244
|
+
const targets = plan.frameworkLibraries.map((path) => ({ path, jit: false }));
|
|
245
|
+
targets.push({ path: plan.framework.to, jit: false });
|
|
246
|
+
for (const helper of plan.helpers) {
|
|
247
|
+
targets.push({ path: helper.executablePath, jit: helper.jit });
|
|
248
|
+
targets.push({ path: helper.appPath, jit: helper.jit });
|
|
249
|
+
}
|
|
250
|
+
return targets;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Writes a mac plan: the framework tree and the five helper bundles. */
|
|
254
|
+
export function applyCefMacPlan(plan: CefMacPlan): void {
|
|
255
|
+
if (!existsSync(plan.helperBinary)) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`nd: the CEF helper executable is missing (${plan.helperBinary}). ` +
|
|
258
|
+
`engine "chromium" needs ${CEF_HELPER_BINARY_NAME} from the AppKit host build; ` +
|
|
259
|
+
`point ND_CEF_HELPER at one, or package with engine "system".`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
mkdirSync(join(plan.framework.to, ".."), { recursive: true });
|
|
263
|
+
rmSync(plan.framework.to, { recursive: true, force: true });
|
|
264
|
+
// verbatimSymlinks keeps the framework's internal links as links. Resolving
|
|
265
|
+
// them duplicates the 224 MB binary and makes codesign reject the bundle.
|
|
266
|
+
cpSync(plan.framework.from, plan.framework.to, { recursive: true, verbatimSymlinks: true });
|
|
267
|
+
for (const helper of plan.helpers) {
|
|
268
|
+
mkdirSync(join(helper.appPath, "Contents", "MacOS"), { recursive: true });
|
|
269
|
+
cpSync(plan.helperBinary, helper.executablePath, { dereference: true });
|
|
270
|
+
chmodSync(helper.executablePath, 0o755);
|
|
271
|
+
writeFileSync(join(helper.appPath, "Contents", "Info.plist"), helper.plist);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Entitlements file for the JIT helpers, written next to the app's own. */
|
|
276
|
+
export function writeCefHelperEntitlements(dir: string): string {
|
|
277
|
+
const path = join(dir, "cef-helper-entitlements.plist");
|
|
278
|
+
writeFileSync(path, cefHelperEntitlements());
|
|
279
|
+
return path;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Linux staging
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** Release/ payload, from the dist's own cmake/cef_variables.cmake list.
|
|
287
|
+
* libvulkan.so.1 belongs to the swiftshader set: dropping it leaves the
|
|
288
|
+
* software renderer unable to load. */
|
|
289
|
+
const LINUX_BINARIES: readonly string[] = [
|
|
290
|
+
"libcef.so",
|
|
291
|
+
"libEGL.so",
|
|
292
|
+
"libGLESv2.so",
|
|
293
|
+
"libvk_swiftshader.so",
|
|
294
|
+
"libvulkan.so.1",
|
|
295
|
+
"vk_swiftshader_icd.json",
|
|
296
|
+
"v8_context_snapshot.bin",
|
|
297
|
+
"chrome-sandbox",
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
/** Resources/ payload, minus locales/, which is trimmed to the config list. */
|
|
301
|
+
const LINUX_RESOURCES: readonly string[] = [
|
|
302
|
+
"icudtl.dat",
|
|
303
|
+
"resources.pak",
|
|
304
|
+
"chrome_100_percent.pak",
|
|
305
|
+
"chrome_200_percent.pak",
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
export interface CefLinuxFile {
|
|
309
|
+
from: string;
|
|
310
|
+
to: string;
|
|
311
|
+
/** libcef.so ships with full debug info: 1.43 GB unstripped, ~269 MB stripped. */
|
|
312
|
+
strip?: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface CefLinuxPlan {
|
|
316
|
+
root: string;
|
|
317
|
+
files: CefLinuxFile[];
|
|
318
|
+
locales: CefLinuxFile[];
|
|
319
|
+
/** Dist entries the plan wanted and could not find. A non-empty list is fatal. */
|
|
320
|
+
missing: string[];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface CefLinuxPlanInput {
|
|
324
|
+
distRoot: string;
|
|
325
|
+
/** The AppDir root. */
|
|
326
|
+
appDir: string;
|
|
327
|
+
locales?: string[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What `nd package linux` will put in <AppDir>/lib/cef, without writing it. */
|
|
331
|
+
export function planCefLinux(input: CefLinuxPlanInput): CefLinuxPlan {
|
|
332
|
+
const root = join(input.appDir, "lib", "cef");
|
|
333
|
+
const release = join(input.distRoot, "Release");
|
|
334
|
+
const resources = join(input.distRoot, "Resources");
|
|
335
|
+
const missing: string[] = [];
|
|
336
|
+
const files: CefLinuxFile[] = [];
|
|
337
|
+
|
|
338
|
+
for (const name of LINUX_BINARIES) {
|
|
339
|
+
const from = join(release, name);
|
|
340
|
+
if (!existsSync(from)) missing.push(`Release/${name}`);
|
|
341
|
+
files.push({ from, to: join(root, name), strip: name === "libcef.so" });
|
|
342
|
+
}
|
|
343
|
+
for (const name of LINUX_RESOURCES) {
|
|
344
|
+
const from = join(resources, name);
|
|
345
|
+
if (!existsSync(from)) missing.push(`Resources/${name}`);
|
|
346
|
+
files.push({ from, to: join(root, name) });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const wanted = input.locales?.length ? input.locales : DEFAULT_CEF_LOCALES;
|
|
350
|
+
const locales = wanted.map((locale) => {
|
|
351
|
+
const from = join(resources, "locales", `${locale}.pak`);
|
|
352
|
+
if (!existsSync(from)) missing.push(`Resources/locales/${locale}.pak`);
|
|
353
|
+
return { from, to: join(root, "locales", `${locale}.pak`) };
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return { root, files, locales, missing };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Writes a linux plan, preserving each file's mode from the dist. chrome-sandbox
|
|
360
|
+
* ships mode 0755 and needs 4755 root-owned to use the setuid sandbox; that is a
|
|
361
|
+
* post-install step for whoever installs the AppImage, not something a packaging
|
|
362
|
+
* run can do for them (see the doctor's cef note). The userns sandbox is the
|
|
363
|
+
* automatic fallback, so `--no-sandbox` is never the answer. */
|
|
364
|
+
export async function applyCefLinuxPlan(plan: CefLinuxPlan): Promise<void> {
|
|
365
|
+
if (plan.missing.length) {
|
|
366
|
+
throw new Error(`nd: the CEF dist is missing ${plan.missing.length} staged file(s): ${plan.missing.join(", ")}`);
|
|
367
|
+
}
|
|
368
|
+
mkdirSync(join(plan.root, "locales"), { recursive: true });
|
|
369
|
+
for (const file of [...plan.files, ...plan.locales]) {
|
|
370
|
+
cpSync(file.from, file.to, { dereference: true });
|
|
371
|
+
chmodSync(file.to, statSync(file.from).mode & 0o7777);
|
|
372
|
+
}
|
|
373
|
+
const strip = plan.files.find((f) => f.strip);
|
|
374
|
+
if (strip) await stripInPlace(strip.to);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** `strip` on the staged libcef.so. Cross-packaging from macOS has no ELF strip,
|
|
378
|
+
* so a failure warns and ships the unstripped copy rather than failing the run. */
|
|
379
|
+
async function stripInPlace(path: string): Promise<void> {
|
|
380
|
+
const before = statSync(path).size;
|
|
381
|
+
const result = await $`strip ${path}`.quiet().nothrow();
|
|
382
|
+
if (result.exitCode !== 0) {
|
|
383
|
+
console.error(`ND_WARN nd: strip failed on ${basename(path)}; shipping it unstripped (${(before / 1e9).toFixed(2)} GB)`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const after = statSync(path).size;
|
|
387
|
+
console.error(`ND_PACKAGE_CEF_STRIP ${basename(path)} ${(before / 1e6).toFixed(0)}MB -> ${(after / 1e6).toFixed(0)}MB`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// The zero-bytes claim
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Every CEF artifact found in a bundle, bundle-relative. An engine="system"
|
|
396
|
+
* build must return an empty list: that is the "zero Chromium bytes unless the
|
|
397
|
+
* config asks for it" claim, checked rather than asserted.
|
|
398
|
+
*/
|
|
399
|
+
export function cefBundleAudit(bundleRoot: string): string[] {
|
|
400
|
+
const names = new Set(CEF_ARTIFACT_NAMES);
|
|
401
|
+
const found: string[] = [];
|
|
402
|
+
const walk = (dir: string, prefix: string): void => {
|
|
403
|
+
let entries;
|
|
404
|
+
try {
|
|
405
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
406
|
+
} catch {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const entry of entries) {
|
|
410
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
411
|
+
if (names.has(entry.name)) {
|
|
412
|
+
found.push(rel);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) walk(join(dir, entry.name), rel);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
walk(bundleRoot, "");
|
|
419
|
+
return found.sort();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The pinned CEF release for a config. */
|
|
423
|
+
export function cefVersionFor(cef: CefConfig | undefined): string {
|
|
424
|
+
return cef?.version ?? DEFAULT_CEF_VERSION;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The helper executable: ND_CEF_HELPER, else beside the host binary. */
|
|
428
|
+
export function resolveCefHelperBinary(
|
|
429
|
+
hostBinary: string,
|
|
430
|
+
env: Record<string, string | undefined> = process.env,
|
|
431
|
+
): string {
|
|
432
|
+
return env.ND_CEF_HELPER ?? join(hostBinary, "..", CEF_HELPER_BINARY_NAME);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The helper executable, built from the framework checkout when the host is a
|
|
436
|
+
* source build and the helper product has never been built. Same resolution the
|
|
437
|
+
* host binary itself gets: use what is there, otherwise build it, and only then
|
|
438
|
+
* give up. */
|
|
439
|
+
export async function ensureCefHelperBinary(
|
|
440
|
+
hostBinary: string,
|
|
441
|
+
env: Record<string, string | undefined> = process.env,
|
|
442
|
+
): Promise<string> {
|
|
443
|
+
const path = resolveCefHelperBinary(hostBinary, env);
|
|
444
|
+
if (existsSync(path)) return path;
|
|
445
|
+
if (!env.ND_CEF_HELPER) {
|
|
446
|
+
const built = await buildCefHelper(hostBinary);
|
|
447
|
+
if (built) return built;
|
|
448
|
+
}
|
|
449
|
+
throw new Error(
|
|
450
|
+
`nd: the CEF helper executable is missing (${path}). ` +
|
|
451
|
+
`engine "chromium" needs ${CEF_HELPER_BINARY_NAME} from the AppKit host build; ` +
|
|
452
|
+
"point ND_CEF_HELPER at one, or package with engine \"system\".",
|
|
453
|
+
);
|
|
454
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The two CEF doctor checks: a config that asks for chromium with no dist
|
|
2
|
+
// resolvable, and the zero-Chromium-bytes claim an engine="system" bundle makes.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { NativeDesktopConfig } from "../config.ts";
|
|
8
|
+
import { builtBundles, type Check, webviewChecks } from "./doctor.ts";
|
|
9
|
+
|
|
10
|
+
const FRAMEWORK = "Chromium Embedded Framework.framework";
|
|
11
|
+
const target = process.platform === "darwin" ? "mac" : "linux";
|
|
12
|
+
|
|
13
|
+
function tempApp(): string {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), "nd-doctor-"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function find(checks: Check[], name: string): Check[] {
|
|
18
|
+
return checks.filter((c) => c.name === name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stage a packaged bundle of the host platform's shape under <app>/dist. */
|
|
22
|
+
function stageBundle(app: string, withCef: boolean): string {
|
|
23
|
+
if (process.platform === "darwin") {
|
|
24
|
+
const bundle = join(app, "dist", "mac", "Demo.app");
|
|
25
|
+
mkdirSync(join(bundle, "Contents", "MacOS"), { recursive: true });
|
|
26
|
+
if (withCef) mkdirSync(join(bundle, "Contents", "Frameworks", FRAMEWORK), { recursive: true });
|
|
27
|
+
return bundle;
|
|
28
|
+
}
|
|
29
|
+
const bundle = join(app, "dist", "linux", "AppDir");
|
|
30
|
+
mkdirSync(join(bundle, "usr", "bin"), { recursive: true });
|
|
31
|
+
if (withCef) {
|
|
32
|
+
mkdirSync(join(bundle, "lib", "cef"), { recursive: true });
|
|
33
|
+
writeFileSync(join(bundle, "lib", "cef", "libcef.so"), "");
|
|
34
|
+
}
|
|
35
|
+
return bundle;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("builtBundles", () => {
|
|
39
|
+
test("finds what a previous package run left in the output dir", () => {
|
|
40
|
+
const app = tempApp();
|
|
41
|
+
const bundle = stageBundle(app, false);
|
|
42
|
+
expect(builtBundles({}, app)).toEqual([bundle]);
|
|
43
|
+
expect(builtBundles({}, tempApp())).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("webviewChecks", () => {
|
|
48
|
+
test("reports the resolved engine for this platform", () => {
|
|
49
|
+
const checks = webviewChecks({}, tempApp());
|
|
50
|
+
expect(find(checks, "webview")[0]!.detail).toBe(`engine=system (${target})`);
|
|
51
|
+
expect(find(checks, "cef")).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("chromium with no resolvable dist is an error naming the cache path", () => {
|
|
55
|
+
const config: NativeDesktopConfig = {
|
|
56
|
+
webview: { engine: { [target]: "chromium" }, cef: { version: "0.0.0-absent" } },
|
|
57
|
+
};
|
|
58
|
+
const cef = find(webviewChecks(config, tempApp()), "cef");
|
|
59
|
+
const failure = cef.find((c) => c.status === "error");
|
|
60
|
+
expect(failure).toBeDefined();
|
|
61
|
+
expect(failure!.detail).toContain("0.0.0-absent");
|
|
62
|
+
expect(failure!.detail).toContain("ND_CEF_ROOT");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("an engine=system bundle carrying CEF fails the zero-bytes claim", () => {
|
|
66
|
+
const clean = webviewChecks({}, (() => { const a = tempApp(); stageBundle(a, false); return a; })());
|
|
67
|
+
expect(find(clean, "cef-bytes")[0]!.status).toBe("ok");
|
|
68
|
+
|
|
69
|
+
const dirty = webviewChecks({}, (() => { const a = tempApp(); stageBundle(a, true); return a; })());
|
|
70
|
+
expect(find(dirty, "cef-bytes")[0]!.status).toBe("error");
|
|
71
|
+
expect(find(dirty, "cef-bytes")[0]!.detail).toContain('engine "system"');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("with chromium configured the same bundle is what the check wants to see", () => {
|
|
75
|
+
const config: NativeDesktopConfig = { webview: { engine: { [target]: "chromium" } } };
|
|
76
|
+
const app = tempApp();
|
|
77
|
+
stageBundle(app, true);
|
|
78
|
+
expect(find(webviewChecks(config, app), "cef-bytes")[0]!.status).toBe("ok");
|
|
79
|
+
|
|
80
|
+
const empty = tempApp();
|
|
81
|
+
stageBundle(empty, false);
|
|
82
|
+
expect(find(webviewChecks(config, empty), "cef-bytes")[0]!.status).toBe("error");
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/package/doctor.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
// `nd doctor`: checks the current directory's packaging readiness and the
|
|
2
2
|
// host toolchain. Warnings are advisory; only real gaps (a config that names
|
|
3
3
|
// missing files, an unresolvable host binary) exit non-zero.
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
6
|
import { hostBinaryCandidates, prebuiltHostBinary, resolveBackend } from "@nativedesktop/host";
|
|
7
|
-
import {
|
|
7
|
+
import { cefDistDir, cefPlatformKey, resolveCefRoot } from "@nativedesktop/host/cef";
|
|
8
|
+
import {
|
|
9
|
+
engineTargetFor,
|
|
10
|
+
loadConfig,
|
|
11
|
+
type NativeDesktopConfig,
|
|
12
|
+
resolveCefSchemes,
|
|
13
|
+
resolveWebViewEngine,
|
|
14
|
+
type WebViewEngine,
|
|
15
|
+
} from "../config.ts";
|
|
16
|
+
import { cefBundleAudit, cefVersionFor } from "./cef.ts";
|
|
8
17
|
import { DEFAULT_ENTRY } from "./payload.ts";
|
|
9
18
|
|
|
10
19
|
export type CheckStatus = "ok" | "warn" | "error";
|
|
@@ -44,6 +53,83 @@ function macIconChecks(config: NativeDesktopConfig): Check[] {
|
|
|
44
53
|
return [];
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
/** Bundles a previous `nd package` run left in the output dir, which is what the
|
|
57
|
+
* zero-CEF-bytes claim can be checked against. */
|
|
58
|
+
export function builtBundles(config: NativeDesktopConfig, cwd: string): string[] {
|
|
59
|
+
const outDir = resolve(cwd, config.package?.workspaceRoot ?? ".", config.package?.outDir ?? "dist");
|
|
60
|
+
const bundles: string[] = [];
|
|
61
|
+
const macDir = join(outDir, "mac");
|
|
62
|
+
if (existsSync(macDir)) {
|
|
63
|
+
for (const entry of readdirSync(macDir)) if (entry.endsWith(".app")) bundles.push(join(macDir, entry));
|
|
64
|
+
}
|
|
65
|
+
const appDir = join(outDir, "linux", "AppDir");
|
|
66
|
+
if (existsSync(appDir)) bundles.push(appDir);
|
|
67
|
+
return bundles;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The engine the current platform resolves to, whether CEF can be found when it
|
|
72
|
+
* asks for one, and whether the last packaged bundle matches: an engine="system"
|
|
73
|
+
* bundle carrying Chromium bytes breaks the framework's central claim about the
|
|
74
|
+
* default path, so it fails rather than warns.
|
|
75
|
+
*/
|
|
76
|
+
export function webviewChecks(config: NativeDesktopConfig, cwd: string): Check[] {
|
|
77
|
+
const target = engineTargetFor();
|
|
78
|
+
if (!target) return [];
|
|
79
|
+
let engine: WebViewEngine;
|
|
80
|
+
let schemes: string[];
|
|
81
|
+
try {
|
|
82
|
+
engine = resolveWebViewEngine(config, target);
|
|
83
|
+
schemes = resolveCefSchemes(config);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return [{ name: "webview", status: "error", detail: String(err) }];
|
|
86
|
+
}
|
|
87
|
+
const checks: Check[] = [{
|
|
88
|
+
name: "webview",
|
|
89
|
+
status: "ok",
|
|
90
|
+
detail: `engine=${engine} (${target})${process.env.ND_WEBVIEW_ENGINE ? " from ND_WEBVIEW_ENGINE" : ""}` +
|
|
91
|
+
(schemes.length ? ` schemes=${schemes.join(",")}` : ""),
|
|
92
|
+
}];
|
|
93
|
+
|
|
94
|
+
const version = cefVersionFor(config.webview?.cef);
|
|
95
|
+
if (engine === "chromium") {
|
|
96
|
+
try {
|
|
97
|
+
const cefPlatform = cefPlatformKey();
|
|
98
|
+
const root = resolveCefRoot({ cefPlatform, version });
|
|
99
|
+
checks.push(root
|
|
100
|
+
? { name: "cef", status: "ok", detail: `CEF ${version} at ${root}` }
|
|
101
|
+
: {
|
|
102
|
+
name: "cef",
|
|
103
|
+
status: "error",
|
|
104
|
+
detail: `engine "chromium" but no CEF ${version} dist found (ND_CEF_ROOT, the app bundle, or ${cefDistDir(version, cefPlatform)}); \`nd package\` downloads it`,
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
checks.push({ name: "cef", status: "error", detail: String(err) });
|
|
108
|
+
}
|
|
109
|
+
if (target === "linux") {
|
|
110
|
+
checks.push({
|
|
111
|
+
name: "cef",
|
|
112
|
+
status: "warn",
|
|
113
|
+
detail: "chrome-sandbox ships with the dist's own mode; the setuid sandbox wants root:root 4755 at install time (the userns sandbox is the automatic fallback, never --no-sandbox)",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const bundle of builtBundles(config, cwd)) {
|
|
119
|
+
const found = cefBundleAudit(bundle);
|
|
120
|
+
if (engine === "chromium") {
|
|
121
|
+
checks.push(found.length
|
|
122
|
+
? { name: "cef-bytes", status: "ok", detail: `${bundle}: ${found.length} CEF artifact(s) staged` }
|
|
123
|
+
: { name: "cef-bytes", status: "error", detail: `${bundle} carries no CEF payload but engine is "chromium" (repackage)` });
|
|
124
|
+
} else {
|
|
125
|
+
checks.push(found.length
|
|
126
|
+
? { name: "cef-bytes", status: "error", detail: `${bundle} ships CEF with engine "system": ${found.slice(0, 3).join(", ")}` }
|
|
127
|
+
: { name: "cef-bytes", status: "ok", detail: `${bundle}: no CEF bytes` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return checks;
|
|
131
|
+
}
|
|
132
|
+
|
|
47
133
|
export async function collectChecks(cwd: string): Promise<Check[]> {
|
|
48
134
|
const checks: Check[] = [];
|
|
49
135
|
const configPath = resolve(cwd, "nativedesktop.config.ts");
|
|
@@ -114,6 +200,8 @@ export async function collectChecks(cwd: string): Promise<Check[]> {
|
|
|
114
200
|
: { name: "appimage", status: "warn", detail: "neither appimagetool nor mksquashfs on PATH (only the raw AppDir can be produced)" });
|
|
115
201
|
}
|
|
116
202
|
|
|
203
|
+
checks.push(...webviewChecks(config, cwd));
|
|
204
|
+
|
|
117
205
|
if (config.package?.updates) {
|
|
118
206
|
checks.push(which("minisign")
|
|
119
207
|
? { name: "updates", status: "ok", detail: "updates configured, minisign available" }
|
|
@@ -205,5 +205,19 @@ describe("buildDesktopEntry / appRunTemplate", () => {
|
|
|
205
205
|
expect(script).toContain('export ND_APP_ID="com.nativedesktop.gallery"');
|
|
206
206
|
expect(script).toContain('export ND_PLUGIN_PATHS="$HERE/app/native/libdemo.so"');
|
|
207
207
|
expect(script).toContain('exec "$HERE/usr/bin/gallery" "$@"');
|
|
208
|
+
expect(script).not.toContain("ND_WEBVIEW_ENGINE");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("AppRun exports the engine and its schemes, behind an override", () => {
|
|
212
|
+
const script = appRunTemplate({
|
|
213
|
+
entry: "src/main.tsx",
|
|
214
|
+
cwd: ".",
|
|
215
|
+
slug: "gallery",
|
|
216
|
+
pluginPaths: [],
|
|
217
|
+
engine: "chromium",
|
|
218
|
+
schemes: ["nbext", "nbint"],
|
|
219
|
+
});
|
|
220
|
+
expect(script).toContain('export ND_WEBVIEW_ENGINE="${ND_WEBVIEW_ENGINE:-chromium}"');
|
|
221
|
+
expect(script).toContain('export ND_CEF_SCHEMES="${ND_CEF_SCHEMES:-nbext,nbint}"');
|
|
208
222
|
});
|
|
209
223
|
});
|