@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.
@@ -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
+ });
@@ -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 { loadConfig, type NativeDesktopConfig } from "../config.ts";
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
  });