@nativedesktop/cli 0.2.0 → 0.3.1

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.
@@ -6,7 +6,9 @@ import { $ } from "bun";
6
6
  import { chmodSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
8
  import { resolveHostBinary } from "@nativedesktop/host";
9
- import type { NativeDesktopConfig } from "../config.ts";
9
+ import { cefPlatformKey } from "@nativedesktop/host/cef";
10
+ import { type NativeDesktopConfig, resolveCefSchemes, resolveWebViewEngine } from "../config.ts";
11
+ import { applyCefLinuxPlan, cefVersionFor, ensureCefDist, planCefLinux } from "./cef.ts";
10
12
  import { installLinuxIcon } from "./icons.ts";
11
13
  import { buildAppRun, buildDesktopEntry, buildMimeInfoXml, type ResolvedIdentity } from "./identity.ts";
12
14
  import { assemblePayload } from "./payload.ts";
@@ -28,11 +30,15 @@ export async function packageLinuxApp(
28
30
  }
29
31
  mkdirSync(join(appdir, "usr", "bin"), { recursive: true });
30
32
 
33
+ const engine = resolveWebViewEngine(config, "linux");
34
+ const schemes = resolveCefSchemes(config);
31
35
  const payload = await assemblePayload({
32
36
  appDir,
33
37
  config,
34
38
  identity,
35
39
  appRoot: join(appdir, "app"),
40
+ engine,
41
+ schemes,
36
42
  entry: options.entry,
37
43
  compile: options.compile,
38
44
  });
@@ -43,6 +49,8 @@ export async function packageLinuxApp(
43
49
  slug: identity.slug,
44
50
  appId: identity.id,
45
51
  pluginPaths: payload.pluginPaths,
52
+ engine,
53
+ schemes,
46
54
  }));
47
55
  chmodSync(join(appdir, "AppRun"), 0o755);
48
56
  writeFileSync(join(appdir, `${identity.slug}.desktop`), buildDesktopEntry(identity, linux));
@@ -64,6 +72,14 @@ export async function packageLinuxApp(
64
72
  cpSync(bunPath, join(appdir, "usr", "bin", "bun"), { dereference: true });
65
73
  chmodSync(join(appdir, "usr", "bin", "bun"), 0o755);
66
74
 
75
+ if (engine === "chromium") {
76
+ const version = cefVersionFor(config.webview?.cef);
77
+ const distRoot = await ensureCefDist({ cefPlatform: cefPlatformKey("linux", process.arch), version });
78
+ const plan = planCefLinux({ distRoot, appDir: appdir, locales: config.webview?.cef?.locales });
79
+ await applyCefLinuxPlan(plan);
80
+ console.error(`ND_PACKAGE_CEF ${plan.root} version=${version} locales=${plan.locales.length}`);
81
+ }
82
+
67
83
  let bundlePath = appdir;
68
84
  const format = options.format ?? linux?.format ?? "appimage";
69
85
  if (format === "appimage") {
@@ -6,7 +6,18 @@ import { $ } from "bun";
6
6
  import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
8
  import { resolveHostBinary } from "@nativedesktop/host";
9
- import type { NativeDesktopConfig } from "../config.ts";
9
+ import { cefPlatformKey } from "@nativedesktop/host/cef";
10
+ import { type NativeDesktopConfig, resolveCefSchemes, resolveWebViewEngine } from "../config.ts";
11
+ import {
12
+ applyCefMacPlan,
13
+ type CefMacPlan,
14
+ cefMacSignTargets,
15
+ cefVersionFor,
16
+ ensureCefDist,
17
+ ensureCefHelperBinary,
18
+ planCefMac,
19
+ writeCefHelperEntitlements,
20
+ } from "./cef.ts";
10
21
  import { installMacIcon } from "./icons.ts";
11
22
  import { buildInfoPlist, type ResolvedIdentity } from "./identity.ts";
12
23
  import { assemblePayload } from "./payload.ts";
@@ -14,6 +25,33 @@ import { ENTITLEMENTS_PLIST } from "./templates.ts";
14
25
  import { maybePublishUpdate } from "./updates.ts";
15
26
  import type { PackageOptions, PackageResult } from "./index.ts";
16
27
 
28
+ /** Downloads (or reuses) the pinned CEF dist and stages the framework plus the
29
+ * five helper .apps into Contents/Frameworks. Returns the plan so the signing
30
+ * pass below can walk the same targets. */
31
+ async function stageCefMac(
32
+ config: NativeDesktopConfig,
33
+ identity: ResolvedIdentity,
34
+ contents: string,
35
+ hostBinary: string,
36
+ minimumSystemVersion: string,
37
+ ): Promise<CefMacPlan> {
38
+ if (!identity.id) throw new Error('nd: webview engine "chromium" needs app.id (the CEF helper bundle ids derive from it)');
39
+ const version = cefVersionFor(config.webview?.cef);
40
+ const distRoot = await ensureCefDist({ cefPlatform: cefPlatformKey("darwin", process.arch), version });
41
+ const plan = planCefMac({
42
+ distRoot,
43
+ contents,
44
+ appName: identity.name,
45
+ appId: identity.id,
46
+ version: identity.version,
47
+ minimumSystemVersion,
48
+ helperBinary: await ensureCefHelperBinary(hostBinary),
49
+ });
50
+ applyCefMacPlan(plan);
51
+ console.error(`ND_PACKAGE_CEF ${plan.framework.to} version=${version}`);
52
+ return plan;
53
+ }
54
+
17
55
  /** Notarization decision: an explicit flag/config wins; the auto default needs credentials AND a signed bundle. */
18
56
  export function resolveNotarize(explicit: boolean | undefined, creds: boolean, signed: boolean): boolean {
19
57
  return explicit ?? (creds && signed);
@@ -34,11 +72,14 @@ export async function packageMacApp(
34
72
  mkdirSync(join(c, "Resources"), { recursive: true });
35
73
  mkdirSync(join(c, "Frameworks"), { recursive: true });
36
74
 
75
+ const engine = resolveWebViewEngine(config, "mac");
37
76
  await assemblePayload({
38
77
  appDir,
39
78
  config,
40
79
  identity,
41
80
  appRoot: join(c, "Resources", "app"),
81
+ engine,
82
+ schemes: resolveCefSchemes(config),
42
83
  entry: options.entry,
43
84
  compile: options.compile,
44
85
  });
@@ -55,7 +96,12 @@ export async function packageMacApp(
55
96
  cpSync(bunPath, join(c, "MacOS", "bun"), { dereference: true });
56
97
  chmodSync(join(c, "MacOS", "bun"), 0o755);
57
98
 
58
- const iconFile = await installMacIcon(identity, appDir, join(c, "Resources"), mac?.minimumSystemVersion ?? "26.0");
99
+ const minimumSystemVersion = mac?.minimumSystemVersion ?? "26.0";
100
+ const cefPlan = engine === "chromium"
101
+ ? await stageCefMac(config, identity, c, hostBinary, minimumSystemVersion)
102
+ : undefined;
103
+
104
+ const iconFile = await installMacIcon(identity, appDir, join(c, "Resources"), minimumSystemVersion);
59
105
  const customXml = mac?.infoPlist ? readFileSync(resolve(appDir, mac.infoPlist), "utf8") : undefined;
60
106
  writeFileSync(join(c, "Info.plist"), buildInfoPlist(identity, mac, customXml, iconFile));
61
107
 
@@ -73,10 +119,22 @@ export async function packageMacApp(
73
119
  const plugins = existsSync(nativeDir)
74
120
  ? [...new Bun.Glob("*.dylib").scanSync({ cwd: nativeDir, absolute: true })].sort()
75
121
  : [];
122
+ if (cefPlan) {
123
+ // The renderer and GPU helpers carry entitlements the app's own do not,
124
+ // and `--deep` re-signs nested code with whatever it was handed, which
125
+ // would drop them. So the CEF payload is enumerated and signed
126
+ // inside-out here, and the outer sign below drops --deep.
127
+ const helperEnt = writeCefHelperEntitlements(dist);
128
+ for (const target of cefMacSignTargets(cefPlan)) {
129
+ if (target.jit) await $`codesign --force --options runtime --entitlements ${helperEnt} --sign ${sign} ${target.path}`;
130
+ else await $`codesign --force --options runtime --sign ${sign} ${target.path}`;
131
+ }
132
+ }
76
133
  for (const nested of [join(c, "MacOS", "bun"), exe, ...plugins]) {
77
134
  await $`codesign --force --options runtime --entitlements ${ent} --sign ${sign} ${nested}`;
78
135
  }
79
- await $`codesign --force --deep --options runtime --entitlements ${ent} --sign ${sign} ${app}`;
136
+ if (cefPlan) await $`codesign --force --options runtime --entitlements ${ent} --sign ${sign} ${app}`;
137
+ else await $`codesign --force --deep --options runtime --entitlements ${ent} --sign ${sign} ${app}`;
80
138
  await $`codesign --verify --strict ${app}`;
81
139
  console.error(`ND_PACKAGE_APP_SIGNED ${app} identity=${sign === "-" ? "adhoc" : "developer-id"}`);
82
140
  }
@@ -82,6 +82,8 @@ describe("assemblePayload", () => {
82
82
  config: { package: { workspaceRoot: "../..", include: ["shared"], compile: false } },
83
83
  identity,
84
84
  appRoot,
85
+ engine: "chromium",
86
+ schemes: ["nbext"],
85
87
  });
86
88
 
87
89
  expect(result).toEqual({ entry: "apps/fixture/src/main.tsx", cwd: "apps/fixture", pluginPaths: [] });
@@ -96,6 +98,8 @@ describe("assemblePayload", () => {
96
98
  entry: "apps/fixture/src/main.tsx",
97
99
  cwd: "apps/fixture",
98
100
  pluginPaths: [],
101
+ engine: "chromium",
102
+ schemes: ["nbext"],
99
103
  });
100
104
  });
101
105
 
@@ -117,7 +121,7 @@ describe("assemblePayload", () => {
117
121
  for (const dir of ["MacOS", "Resources", "Frameworks"]) mkdirSync(join(contents, dir), { recursive: true });
118
122
  const appRoot = join(contents, "Resources", "app");
119
123
 
120
- const result = await assemblePayload({ appDir, config: {}, identity, appRoot });
124
+ const result = await assemblePayload({ appDir, config: {}, identity, appRoot, engine: "system", schemes: [] });
121
125
 
122
126
  expect(result).toEqual({ entry: "dist/main.tsx", cwd: ".", pluginPaths: [] });
123
127
  expect(existsSync(join(appRoot, "dist/main.tsx"))).toBe(true);
@@ -125,6 +129,31 @@ describe("assemblePayload", () => {
125
129
  expect(existsSync(join(appRoot, "dist", "mac"))).toBe(false);
126
130
  });
127
131
 
132
+ test("--out elsewhere: a previous run's bundle is left out of the payload", async () => {
133
+ const appDir = mkdtempSync(join(tmpdir(), "nd-payload-out-"));
134
+ mkdirSync(join(appDir, "src"), { recursive: true });
135
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" }));
136
+ writeFileSync(join(appDir, "src", "main.tsx"), "export {};\n");
137
+ mkdirSync(join(appDir, "dist"), { recursive: true });
138
+ writeFileSync(join(appDir, "dist", "main.tsx"), "export {};\n");
139
+ // What `nd package mac` wrote last time, in the default output dir.
140
+ mkdirSync(join(appDir, "dist", "mac", "Fixture.app", "Contents", "Frameworks"), { recursive: true });
141
+ writeFileSync(join(appDir, "dist", "mac", "Fixture.app", "Contents", "Frameworks", "big.bin"), "x");
142
+
143
+ const appRoot = join(mkdtempSync(join(tmpdir(), "nd-payload-out2-")), "mac", "Fixture.app", "Contents", "Resources", "app");
144
+ await assemblePayload({
145
+ appDir,
146
+ config: { package: { compile: false, entry: "dist/main.tsx" } },
147
+ identity,
148
+ appRoot,
149
+ engine: "system",
150
+ schemes: [],
151
+ });
152
+
153
+ expect(existsSync(join(appRoot, "dist/main.tsx"))).toBe(true);
154
+ expect(existsSync(join(appRoot, "dist", "mac"))).toBe(false);
155
+ });
156
+
128
157
  test("root-level entry ships the whole app dir minus node_modules", async () => {
129
158
  const root = mkdtempSync(join(tmpdir(), "nd-payload-"));
130
159
  const appDir = join(root, "app");
@@ -140,6 +169,8 @@ describe("assemblePayload", () => {
140
169
  config: { package: { entry: "main.tsx", compile: false } },
141
170
  identity,
142
171
  appRoot,
172
+ engine: "system",
173
+ schemes: [],
143
174
  });
144
175
 
145
176
  expect(result).toEqual({ entry: "main.tsx", cwd: ".", pluginPaths: [] });
@@ -4,7 +4,7 @@
4
4
  // workspace-relative path and relative imports keep resolving packaged.
5
5
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { basename, join, relative, resolve, sep } from "node:path";
7
- import { buildNativePlugins, type NativeDesktopConfig, type PackageConfig } from "../config.ts";
7
+ import { buildNativePlugins, type NativeDesktopConfig, type PackageConfig, type WebViewEngine } from "../config.ts";
8
8
  import type { ResolvedIdentity } from "./identity.ts";
9
9
  import { assertResolvableEntries, flattenRuntimeModules } from "./modules.ts";
10
10
 
@@ -40,6 +40,12 @@ export interface PayloadOptions {
40
40
  identity: ResolvedIdentity;
41
41
  /** The bundle's app root (Resources/app or AppDir/app). */
42
42
  appRoot: string;
43
+ /** Engine this bundle was staged for. Recorded in the manifest so the
44
+ * packaged launch path hands the host what `nd dev` hands it, with no shell
45
+ * in between. */
46
+ engine: WebViewEngine;
47
+ /** Launch-declared CEF schemes, recorded for the same reason. */
48
+ schemes: string[];
43
49
  /** CLI --entry override (app-relative source entry). */
44
50
  entry?: string;
45
51
  /** false forces raw source (--no-compile). */
@@ -108,14 +114,22 @@ export async function assemblePayload(o: PayloadOptions): Promise<PayloadResult>
108
114
  }
109
115
  }
110
116
 
117
+ // Bundles a previous run left in the output dir. Under the default config
118
+ // they sit inside the compile outDir ("dist/mac", "dist/linux" beside
119
+ // "dist/main.tsx"), so without this a run whose own output lands elsewhere
120
+ // (`--out`) ships the last .app inside the new one, CEF payload and all.
121
+ const bundleOut = resolve(workspaceRoot, pkg.outDir ?? "dist");
122
+ const platformOutputs = new Set([join(bundleOut, "mac"), join(bundleOut, "linux")]);
123
+ const skipBuiltBundles = (src: string) => !platformOutputs.has(resolve(src));
124
+
111
125
  mkdirSync(destAppDir, { recursive: true });
112
126
  const entryDir = entry.includes("/") ? entry.split("/")[0]! : null;
113
127
  if (entryDir) {
114
- copyPayloadTree(join(appDir, entryDir), join(destAppDir, entryDir), o.appRoot, skipNodeModules);
128
+ const skip = (src: string) => skipNodeModules(src) && skipBuiltBundles(src);
129
+ copyPayloadTree(join(appDir, entryDir), join(destAppDir, entryDir), o.appRoot, skip);
115
130
  } else {
116
131
  // Root-level entry: ship the whole app dir (minus node_modules and any
117
132
  // build output nested inside it, which would recurse into the copy).
118
- const bundleOut = resolve(workspaceRoot, pkg.outDir ?? "dist");
119
133
  const skip = (src: string) => skipNodeModules(src) && resolve(src) !== bundleOut;
120
134
  copyPayloadTree(appDir, destAppDir, o.appRoot, skip);
121
135
  }
@@ -154,6 +168,8 @@ export async function assemblePayload(o: PayloadOptions): Promise<PayloadResult>
154
168
  entry: appEntry,
155
169
  cwd,
156
170
  pluginPaths,
171
+ engine: o.engine,
172
+ schemes: o.schemes,
157
173
  }, null, 2)}\n`);
158
174
 
159
175
  return { entry: appEntry, cwd, pluginPaths };
@@ -47,6 +47,44 @@ export const ENTITLEMENTS_PLIST =
47
47
  " <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n <true/>\n" +
48
48
  "</dict>\n</plist>\n";
49
49
 
50
+ export interface CefHelperPlistFields {
51
+ /** "<App> Helper (Renderer)" and friends. Also the executable name. */
52
+ name: string;
53
+ id: string;
54
+ version: string;
55
+ minimumSystemVersion: string;
56
+ }
57
+
58
+ /** Info.plist for one CEF helper .app. LSUIElement keeps the five subprocesses
59
+ * out of the Dock and the app switcher; without it every renderer launch would
60
+ * flash a second icon. */
61
+ export function cefHelperPlist(f: CefHelperPlistFields): string {
62
+ return infoPlistSkeleton({
63
+ name: f.name,
64
+ displayName: f.name,
65
+ id: f.id,
66
+ executable: f.name,
67
+ version: f.version,
68
+ minimumSystemVersion: f.minimumSystemVersion,
69
+ }).replace("</dict>\n</plist>", " <key>LSUIElement</key><string>1</string>\n</dict>\n</plist>");
70
+ }
71
+
72
+ /** Entitlements for the renderer and GPU helpers. V8 and ANGLE map executable
73
+ * pages the hardened runtime refuses without these, and the renderer loads the
74
+ * CEF framework from the outer app's signature, which library validation would
75
+ * reject. CEF's own sample app ships this exact set. */
76
+ export function cefHelperEntitlements(): string {
77
+ return (
78
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
79
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
80
+ '<plist version="1.0">\n<dict>\n' +
81
+ " <key>com.apple.security.cs.allow-jit</key>\n <true/>\n" +
82
+ " <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n <true/>\n" +
83
+ " <key>com.apple.security.cs.disable-library-validation</key>\n <true/>\n" +
84
+ "</dict>\n</plist>\n"
85
+ );
86
+ }
87
+
50
88
  export interface AppRunSpec {
51
89
  /** App-root-relative script the host boots. */
52
90
  entry: string;
@@ -57,6 +95,10 @@ export interface AppRunSpec {
57
95
  appId?: string;
58
96
  /** App-root-relative native plugin paths. */
59
97
  pluginPaths: string[];
98
+ /** Engine the AppDir was staged for. */
99
+ engine?: string;
100
+ /** Launch-declared CEF schemes. */
101
+ schemes?: string[];
60
102
  }
61
103
 
62
104
  export function appRunTemplate(s: AppRunSpec): string {
@@ -66,6 +108,11 @@ export function appRunTemplate(s: AppRunSpec): string {
66
108
  `export ND_SCRIPT="$HERE/app/${s.entry}"\n` +
67
109
  'export PATH="$HERE/usr/bin:$PATH"\n';
68
110
  if (s.appId) script += `export ND_APP_ID="${s.appId}"\n`;
111
+ // Every CEF process reads both of these, and both have to be settled before
112
+ // cef_initialize. The :- form keeps an explicit override ahead of the bundle,
113
+ // matching how the mac bootstrap defers to an already-set variable.
114
+ if (s.engine) script += `export ND_WEBVIEW_ENGINE="\${ND_WEBVIEW_ENGINE:-${s.engine}}"\n`;
115
+ if (s.schemes?.length) script += `export ND_CEF_SCHEMES="\${ND_CEF_SCHEMES:-${s.schemes.join(",")}}"\n`;
69
116
  if (s.pluginPaths.length) {
70
117
  script += 'export ND_PLUGINS="1"\n';
71
118
  script += `export ND_PLUGIN_PATHS="${s.pluginPaths.map((p) => `$HERE/app/${p}`).join(":")}"\n`;