@akanjs/devkit 3.0.0-beta.31 → 3.0.0-beta.32

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.
@@ -477,6 +477,18 @@ describe("AkanAppConfig", () => {
477
477
  ).toThrow("unknown basePath");
478
478
  });
479
479
 
480
+ test("a desktop server is true or { omit }, its names trimmed, deduplicated and sorted", () => {
481
+ const resolve = (server: unknown) =>
482
+ new AkanAppConfig(app, [], packageJson, { native: { desktop: { server } } } as never, baseDevEnv).native.targets
483
+ .default.desktop?.server;
484
+
485
+ expect(resolve(true)).toBe(true);
486
+ expect(resolve({ omit: [" rclnodejs", "protobufjs", "rclnodejs"] })).toEqual({ omit: ["protobufjs", "rclnodejs"] });
487
+ expect(resolve({})).toEqual({ omit: [] });
488
+ expect(() => resolve({ omit: "rclnodejs" })).toThrow("native.desktop.server.omit in apps/");
489
+ expect(() => resolve({ exclude: ["rclnodejs"] })).toThrow("native.desktop.server.exclude in apps/");
490
+ });
491
+
480
492
  test("an app without basePaths has one target, default, with no basePath and the section's settings", () => {
481
493
  const config = new AkanAppConfig(
482
494
  app,
@@ -17,6 +17,7 @@ import {
17
17
  type AkanNativeAppConfig,
18
18
  type AkanNativeAppResult,
19
19
  type AkanNativeDeepLinks,
20
+ type AkanNativeDesktopConfig,
20
21
  type AkanNativeSettings,
21
22
  type AkanNativeTarget,
22
23
  type AkanNativeUpdatesConfig,
@@ -309,7 +310,15 @@ export class AkanAppConfig implements AppConfigResult {
309
310
  ...(merged.android
310
311
  ? { android: { ...androidRest, ...(fingerprints ? { sha256CertFingerprints: fingerprints } : {}) } }
311
312
  : {}),
312
- ...(merged.desktop ? { desktop: AkanAppConfig.#platformIndexPath(merged.desktop) } : {}),
313
+ ...(merged.desktop
314
+ ? {
315
+ desktop: AkanAppConfig.#desktopServer(
316
+ AkanAppConfig.#platformIndexPath(merged.desktop),
317
+ `${where}.desktop.server`,
318
+ configPath,
319
+ ),
320
+ }
321
+ : {}),
313
322
  appName: merged.appName ?? this.app.name,
314
323
  appId: merged.appId ?? appId,
315
324
  version: merged.version ?? version,
@@ -321,6 +330,22 @@ export class AkanAppConfig implements AppConfigResult {
321
330
  );
322
331
  return { appName, appId, ...(root.fileName ? { fileName: root.fileName } : {}), version, buildNum, targets };
323
332
  }
333
+ //? `true` or `{ omit }`: each omitted name trimmed once, so two targets that omit the same packages stage one server.
334
+ static #desktopServer<T extends { server?: AkanNativeDesktopConfig["server"] }>(
335
+ desktop: T,
336
+ where: string,
337
+ configPath: string,
338
+ ): T {
339
+ const { server } = desktop;
340
+ if (server === undefined || typeof server === "boolean") return desktop;
341
+ if (!AkanAppConfig.#isPlainObject(server))
342
+ throw new Error(`${where} in ${configPath} must be true or { omit: string[] }.`);
343
+ const omit = server.omit;
344
+ if (omit !== undefined && (!Array.isArray(omit) || omit.some((name) => typeof name !== "string" || !name.trim())))
345
+ throw new Error(`${where}.omit in ${configPath} must be a list of package names.`);
346
+ const names = [...new Set((omit ?? []).map((name) => name.trim()))].sort();
347
+ return { ...desktop, server: { omit: names } };
348
+ }
324
349
  static #platformIndexPath<T extends { indexPath?: string }>(section: T): T {
325
350
  const { indexPath: rawIndexPath, ...rest } = section;
326
351
  const indexPath = normalizeIndexPath(rawIndexPath);
@@ -376,7 +401,8 @@ export class AkanAppConfig implements AppConfigResult {
376
401
  "activity",
377
402
  "files",
378
403
  ],
379
- desktop: ["indexPath", "server", "recovery", "window", "screenCapture"],
404
+ desktop: ["indexPath", "server", "recovery", "window", "screenCapture", "entitlements"],
405
+ desktopServer: ["omit"],
380
406
  deepLinks: ["schemes", "domains"],
381
407
  } as const;
382
408
  //* Settings that moved when `mobile` became `native` are named with where they went, instead of "unknown".
@@ -408,6 +434,12 @@ export class AkanAppConfig implements AppConfigResult {
408
434
  check(settings.ios, AkanAppConfig.#nativeKeys.ios, `${where}.ios`, "ios");
409
435
  check(settings.android, AkanAppConfig.#nativeKeys.android, `${where}.android`, "android");
410
436
  check(settings.desktop, AkanAppConfig.#nativeKeys.desktop, `${where}.desktop`, "desktop");
437
+ check(
438
+ settings.desktop?.server,
439
+ AkanAppConfig.#nativeKeys.desktopServer,
440
+ `${where}.desktop.server`,
441
+ "desktopServer",
442
+ );
411
443
  check(settings.deepLinks, AkanAppConfig.#nativeKeys.deepLinks, `${where}.deepLinks`, "deepLinks");
412
444
  }
413
445
  static readonly #androidFilePattern = /^(?:res\/[^/]+\/[^/]+|assets\/.+)$/;
@@ -14,6 +14,7 @@ export type {
14
14
  AkanNativeAppResult,
15
15
  AkanNativeDeepLinks,
16
16
  AkanNativeDesktopConfig,
17
+ AkanNativeDesktopServerConfig,
17
18
  AkanNativeIosConfig,
18
19
  AkanNativeSettings,
19
20
  AkanNativeTarget,
@@ -48,6 +48,11 @@ afterAll(() => files.stop(true));
48
48
  const hash = (bytes: Uint8Array | string) => new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
49
49
 
50
50
  describe("DesktopBin", () => {
51
+ test("a build carries the file of the CPU it is for", () => {
52
+ expect(DesktopBin.platform("x64")).toBe(`${process.platform}-x64` as BinPlatform);
53
+ expect(DesktopBin.platform("ia32")).toBeNull();
54
+ });
55
+
51
56
  test("carries the app's own entries and those of the libs it depends on, the app's first", () => {
52
57
  const own = { ffmpeg: { [here]: { path: "/a/ffmpeg" } } };
53
58
  const lib = { ffmpeg: { [here]: { path: "/lib/ffmpeg" } }, ffprobe: { [here]: { path: "/lib/ffprobe" } } };
@@ -11,18 +11,19 @@ import {
11
11
  import type { App } from "../commandDecorators";
12
12
 
13
13
  type BinPlatforms = AkanBinConfig[string];
14
+ export type DesktopArch = "arm64" | "x64";
14
15
 
15
- //* A desktop app is built on the computer it runs on, so only that platform's file is fetched. It is copied byte for
16
- //* byte into the folder the app carries as `desktop.bin`: no extended attribute, so no quarantine flag, travels
17
- //* into the bundle.
16
+ //* A desktop app is built on its own OS, for this computer's CPU or the one `--arch` names, so only that platform's file
17
+ //* is fetched. It is copied byte for byte into the folder the app carries as `desktop.bin`: no extended attribute, so
18
+ //* no quarantine flag, travels into the bundle.
18
19
  export class DesktopBin {
19
20
  constructor(
20
21
  readonly app: App,
21
22
  readonly config: AkanAppConfig,
22
23
  ) {}
23
24
 
24
- static platform(): BinPlatform | null {
25
- const platform = `${process.platform}-${process.arch}`;
25
+ static platform(arch: string = process.arch): BinPlatform | null {
26
+ const platform = `${process.platform}-${arch}`;
26
27
  return (binPlatforms as readonly string[]).includes(platform) ? (platform as BinPlatform) : null;
27
28
  }
28
29
 
@@ -76,14 +77,14 @@ export class DesktopBin {
76
77
  }
77
78
 
78
79
  /** Copies each chosen executable into `dir`, emptied first, and answers the names it carries. */
79
- async stage(dir: string): Promise<string[]> {
80
+ async stage(dir: string, arch: DesktopArch = process.arch as DesktopArch): Promise<string[]> {
80
81
  const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
81
82
  const chosen = DesktopBin.select(this.config, scanInfo.getLibs());
82
83
  if (!chosen.size) return [];
83
- const platform = DesktopBin.platform();
84
+ const platform = DesktopBin.platform(arch);
84
85
  if (!platform)
85
86
  throw new Error(
86
- `bin: this computer is ${process.platform}-${process.arch}, and a bin names only ${binPlatforms.join(", ")}.`,
87
+ `bin: this desktop app is for ${process.platform}-${arch}, and a bin names only ${binPlatforms.join(", ")}.`,
87
88
  );
88
89
  await rm(dir, { recursive: true, force: true });
89
90
  await mkdir(dir, { recursive: true });
@@ -23,7 +23,7 @@ const rootPackageJson: PackageJson = {
23
23
  name: "repo",
24
24
  version: "1.0.0",
25
25
  description: "repo",
26
- dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0" },
26
+ dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0", rclnodejs: "1.0.0" },
27
27
  };
28
28
  const appConfig = (config: object = {}) =>
29
29
  new AkanAppConfig({ name: "portal" } as never, [], rootPackageJson, config, baseDevEnv);
@@ -59,6 +59,17 @@ describe("DesktopServerStage", () => {
59
59
  for (const driver of drivers) expect(Object.keys(built.dependencies ?? {})).toContain(driver);
60
60
  });
61
61
 
62
+ test("leaves out what desktop.server.omit names, an external lib included, while the image keeps it", () => {
63
+ const config = appConfig({ externalLibs: ["rclnodejs", "@external/runtime"] });
64
+ const built = config.getProductionPackageJson();
65
+
66
+ const { dependencies } = DesktopServerStage.packageJson(config, built, ["rclnodejs"]);
67
+
68
+ expect(Object.keys(dependencies ?? {})).toContain("@external/runtime");
69
+ expect(Object.keys(dependencies ?? {})).not.toContain("rclnodejs");
70
+ expect(Object.keys(config.getProductionPackageJson().dependencies ?? {})).toContain("rclnodejs");
71
+ });
72
+
62
73
  test("keeps the image's trusted packages, so their install scripts run in the app's server too", () => {
63
74
  const config = appConfig({ trustedDependencies: ["@external/runtime"], externalLibs: ["@external/runtime"] });
64
75
  expect(DesktopServerStage.packageJson(config, config.getProductionPackageJson()).trustedDependencies).toEqual([
@@ -148,6 +159,43 @@ describe("DesktopServerStage", () => {
148
159
  expect(Object.keys(installed.dependencies ?? {}).sort()).toEqual(["react", "react-dom"]);
149
160
  });
150
161
 
162
+ test("an omitted package another dependency still installs stops the stage, naming who needs it", async () => {
163
+ const installing =
164
+ (pulled: boolean) =>
165
+ async (...args: unknown[]) => {
166
+ const nodeModules = path.join((args[2] as { cwd: string }).cwd, "node_modules");
167
+ await write(path.join(nodeModules, "react/package.json"), JSON.stringify({ name: "react" }));
168
+ if (pulled) {
169
+ await write(path.join(nodeModules, "rclnodejs/package.json"), JSON.stringify({ name: "rclnodejs" }));
170
+ await write(
171
+ path.join(nodeModules, "@robot/bridge/package.json"),
172
+ JSON.stringify({ name: "@robot/bridge", dependencies: { rclnodejs: "^1" } }),
173
+ );
174
+ }
175
+ return "";
176
+ };
177
+ await (await stageApp(installing(false))).stage.install(undefined, ["rclnodejs"]);
178
+ await expect((await stageApp(installing(true))).stage.install(undefined, ["rclnodejs"])).rejects.toThrow(
179
+ "another dependency still installs: rclnodejs (needed by @robot/bridge)",
180
+ );
181
+ });
182
+
183
+ test("installs another CPU's optional packages for that CPU", () => {
184
+ const other = process.arch === "arm64" ? "x64" : "arm64";
185
+ expect(DesktopServerStage.installArgs()).toEqual(["install", "--production", "--prefer-offline"]);
186
+ expect(DesktopServerStage.installArgs(process.arch as "arm64" | "x64")).toEqual([
187
+ "install",
188
+ "--production",
189
+ "--prefer-offline",
190
+ ]);
191
+ expect(DesktopServerStage.installArgs(other)).toEqual([
192
+ "install",
193
+ "--production",
194
+ "--prefer-offline",
195
+ `--cpu=${other}`,
196
+ ]);
197
+ });
198
+
151
199
  test("an install that fails says it needs the registry or a Bun cache that holds the packages", async () => {
152
200
  const { stage } = await stageApp(async () => {
153
201
  throw new Error("error: GET https://registry.npmjs.org/scheduler - ConnectionRefused");
@@ -85,7 +85,8 @@ export class DesktopServerStage {
85
85
  };
86
86
  }
87
87
 
88
- static packageJson(config: AkanAppConfig, built: PackageJson): PackageJson {
88
+ //? `omit` (the target's `desktop.server.omit`) wins over externalLibs: the image keeps those packages, the app does not.
89
+ static packageJson(config: AkanAppConfig, built: PackageJson, omit: string[] = []): PackageJson {
89
90
  const otherDrivers = new Set(
90
91
  config.database.modes
91
92
  .filter((mode) => mode !== "single")
@@ -93,24 +94,47 @@ export class DesktopServerStage {
93
94
  );
94
95
  const dependencies = Object.entries(built.dependencies ?? {}).filter(
95
96
  ([name]) =>
96
- config.externalLibs.includes(name) || (name !== DesktopServerStage.rscRenderer && !otherDrivers.has(name)),
97
+ !omit.includes(name) &&
98
+ (config.externalLibs.includes(name) || (name !== DesktopServerStage.rscRenderer && !otherDrivers.has(name))),
97
99
  );
98
100
  return { ...built, dependencies: Object.fromEntries(dependencies) };
99
101
  }
100
102
 
103
+ /** The packages in `nodeModules` that depend on `name`: what still pulls an omitted package in. */
104
+ static async dependentsOf(nodeModules: string, name: string): Promise<string[]> {
105
+ const dependents = new Set<string>();
106
+ for await (const manifest of new Bun.Glob("**/package.json").scan({ cwd: nodeModules, onlyFiles: true })) {
107
+ const json = (await Bun.file(path.join(nodeModules, manifest))
108
+ .json()
109
+ .catch(() => null)) as PackageJson | null;
110
+ if (!json?.name || json.name === name) continue;
111
+ const declared = { ...json.dependencies, ...json.optionalDependencies, ...json.peerDependencies };
112
+ if (name in declared) dependents.add(json.name);
113
+ }
114
+ return [...dependents].sort();
115
+ }
116
+
117
+ //? `--cpu` installs another CPU's optional packages: an addon's prebuilt binary for the app's CPU, not this computer's.
118
+ static installArgs(arch?: "arm64" | "x64"): string[] {
119
+ return ["install", "--production", "--prefer-offline", ...(arch && arch !== process.arch ? [`--cpu=${arch}`] : [])];
120
+ }
121
+
101
122
  //* The packages come from the config alone, so they install before `akan build` runs: a machine that cannot install
102
123
  //* them stops at once instead of after the whole build.
103
- async install() {
124
+ async install(arch?: "arm64" | "x64", omit: string[] = []) {
104
125
  const config = await this.app.getConfig();
105
126
  DesktopServerStage.assertCarriable(config);
106
127
  await rm(this.dir, { recursive: true, force: true });
107
128
  await mkdir(this.dir, { recursive: true });
129
+ const built = config.getProductionPackageJson();
130
+ for (const name of omit.filter((name) => !(name in (built.dependencies ?? {}))))
131
+ this.app.logger.warn(`desktop.server.omit names ${name}, which the server does not depend on directly.`);
108
132
  await writeFile(
109
133
  path.join(this.dir, "package.json"),
110
- JSON.stringify(DesktopServerStage.packageJson(config, config.getProductionPackageJson()), null, 2),
134
+ JSON.stringify(DesktopServerStage.packageJson(config, built, omit), null, 2),
111
135
  );
112
136
  try {
113
- await this.app.spawn(process.execPath, ["install", "--production", "--prefer-offline"], { cwd: this.dir });
137
+ await this.app.spawn(process.execPath, DesktopServerStage.installArgs(arch), { cwd: this.dir });
114
138
  } catch (error) {
115
139
  throw new Error(
116
140
  `The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${error instanceof Error ? error.message : String(error)}`,
@@ -119,6 +143,22 @@ export class DesktopServerStage {
119
143
  }
120
144
  //? Launcher links only: a link out of the app bundle breaks its signature, and the server runs none of them.
121
145
  await rm(path.join(this.dir, "node_modules", ".bin"), { recursive: true, force: true });
146
+ await this.#assertOmitted(omit);
147
+ }
148
+
149
+ //? Leaving a package out of package.json keeps out only what nothing else needs; another dependency still installs it.
150
+ async #assertOmitted(omit: string[]) {
151
+ const nodeModules = path.join(this.dir, "node_modules");
152
+ const kept: string[] = [];
153
+ for (const name of omit) {
154
+ if (!(await Bun.file(path.join(nodeModules, name, "package.json")).exists())) continue;
155
+ const dependents = await DesktopServerStage.dependentsOf(nodeModules, name);
156
+ kept.push(`${name} (needed by ${dependents.join(", ") || "a package that names it"})`);
157
+ }
158
+ if (kept.length)
159
+ throw new Error(
160
+ `desktop.server.omit leaves out packages another dependency still installs: ${kept.join("; ")}. Omit those too, or keep the package.`,
161
+ );
122
162
  }
123
163
 
124
164
  //? Copies the build beside what `install` put in the stage, so it runs after both.
@@ -86,6 +86,13 @@ describe("NativeApp", () => {
86
86
  ).toMatch(/^portal\/admin ios ready · iPhone 16 · http:\/\/localhost:8283 · 12\.\ds$/);
87
87
  });
88
88
 
89
+ test("an installer is what a person downloads, on the desktops that have one", () => {
90
+ expect(() => NativeApp.assertInstaller("windows", true)).not.toThrow();
91
+ expect(() => NativeApp.assertInstaller("macos", true)).not.toThrow();
92
+ expect(() => NativeApp.assertInstaller("linux", true)).not.toThrow();
93
+ expect(() => NativeApp.assertInstaller("ios", true)).toThrow(/not for ios/);
94
+ });
95
+
89
96
  test("opens a dev build on its target's home, the CSR shell under the locale", () => {
90
97
  expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en", "ios")).toBe(
91
98
  "/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
@@ -3,7 +3,7 @@ import type { AkanNativeConfig } from "@akanjs/native/config";
3
3
  import type { NativeEnv } from "akanjs";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { Executor } from "../executors";
6
- import { DesktopBin } from "./desktopBin";
6
+ import { type DesktopArch, DesktopBin } from "./desktopBin";
7
7
  import type { DesktopServerBundle } from "./desktopServerStage";
8
8
  import {
9
9
  type DesktopPlatform,
@@ -38,8 +38,10 @@ export interface NativeBuildOptions {
38
38
  profile?: "debug" | "release";
39
39
  /** The server a desktop app carries (the target's `desktop.server`), staged by DesktopServerStage. */
40
40
  server?: DesktopServerBundle;
41
- /** Windows: an NSIS setup program beside the app folder. */
41
+ /** What a person downloads beside the app: a Windows NSIS setup program, a macOS dmg, a Linux AppImage. */
42
42
  installer?: boolean;
43
+ /** The CPU the desktop app runs on, of the same OS; a macOS app is arm64 only. Default this computer's. */
44
+ arch?: DesktopArch;
43
45
  }
44
46
 
45
47
  export interface NativeDevOptions extends NativeRunOptions {
@@ -104,10 +106,12 @@ export class NativeApp {
104
106
  server,
105
107
  platform,
106
108
  stageBin = true,
109
+ arch,
107
110
  }: {
108
111
  server?: DesktopServerBundle;
109
112
  platform: NativePlatform;
110
113
  stageBin?: boolean;
114
+ arch?: DesktopArch;
111
115
  }) {
112
116
  const [appConfig, plugins, nativePlugins] = await Promise.all([
113
117
  this.app.getConfig(),
@@ -115,7 +119,7 @@ export class NativeApp {
115
119
  NativePluginFolders.of(this.app),
116
120
  ]);
117
121
  const desktop = platform === "macos" || platform === "windows" || platform === "linux";
118
- const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir) : [];
122
+ const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir, arch) : [];
119
123
  return NativeConfig.build({
120
124
  appPath: this.app.cwdPath,
121
125
  target: this.target.config,
@@ -131,10 +135,10 @@ export class NativeApp {
131
135
  }
132
136
 
133
137
  /** The API and the config it is about to build, refused here when the runtime would refuse it later. */
134
- async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
138
+ async prepare(platform: NativePlatform, server?: DesktopServerBundle, arch?: DesktopArch) {
135
139
  const [api, { config, warnings }] = await Promise.all([
136
140
  NativeApi.load(this.app.cwdPath),
137
- this.config({ platform, ...(server ? { server } : {}) }),
141
+ this.config({ platform, ...(server ? { server } : {}), ...(arch ? { arch } : {}) }),
138
142
  ]);
139
143
  for (const warning of warnings) this.app.logger.warn(warning);
140
144
  this.#assertValid(api, config);
@@ -175,15 +179,35 @@ export class NativeApp {
175
179
  };
176
180
  }
177
181
 
178
- async build(platform: NativePlatform, { profile = "release", server, installer = false }: NativeBuildOptions = {}) {
182
+ async build(
183
+ platform: NativePlatform,
184
+ { profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
185
+ ) {
179
186
  NativeApp.#assertServerPlatform(platform, server);
180
- if (installer && platform !== "windows") throw new Error(`An installer is built for Windows, not for ${platform}.`);
187
+ NativeApp.assertInstaller(platform, installer);
181
188
  await this.assembleWeb(platform);
182
- const { api, config } = await this.prepare(platform, server);
189
+ const { api, config } = await this.prepare(platform, server, arch);
183
190
  return await api.build({
184
191
  ...this.#task(platform, config),
185
192
  profile,
186
- ...(installer ? { windows: { installer } } : {}),
193
+ ...(arch ? { arch } : {}),
194
+ ...(platform === "windows"
195
+ ? {
196
+ windows: {
197
+ ...(installer ? { installer } : {}),
198
+ ...(profile === "release" ? NativeApp.#windowsSigning(api) : {}),
199
+ },
200
+ }
201
+ : {}),
202
+ ...(platform === "macos"
203
+ ? {
204
+ macos: {
205
+ ...(profile === "release" ? api.macosDistributionFromEnv(process.env) : {}),
206
+ ...(installer ? { dmg: true } : {}),
207
+ },
208
+ }
209
+ : {}),
210
+ ...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
187
211
  });
188
212
  }
189
213
 
@@ -280,6 +304,9 @@ export class NativeApp {
280
304
  platform,
281
305
  out: this.updatesDir,
282
306
  ...(channel ? { channel } : {}),
307
+ //? The updater keeps the installed app's signature, so a release is signed as the downloaded app was.
308
+ ...(platform === "macos" ? { macos: api.macosDistributionFromEnv(process.env) } : {}),
309
+ ...(platform === "windows" ? { windows: NativeApp.#windowsSigning(api) } : {}),
283
310
  });
284
311
  }
285
312
 
@@ -392,6 +419,17 @@ export class NativeApp {
392
419
  return `/${home.join("/")}?${params}`;
393
420
  }
394
421
 
422
+ static #windowsSigning(api: NativeBuildApiModule) {
423
+ const signing = api.windowsSigningFromEnv(process.env);
424
+ return signing ? { signing } : {};
425
+ }
426
+
427
+ //* `installer` is the file a person downloads: a setup program on Windows, a disk image on macOS, an AppImage on Linux.
428
+ static assertInstaller(platform: NativePlatform, installer: boolean) {
429
+ if (installer && (platform === "ios" || platform === "android"))
430
+ throw new Error(`An installer is built for a desktop app (Windows, macOS, Linux), not for ${platform}.`);
431
+ }
432
+
395
433
  //? A phone runs no Bun, so only a desktop app can carry the server.
396
434
  static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
397
435
  if (server && (platform === "ios" || platform === "android"))
@@ -341,6 +341,30 @@ describe("NativeConfig.build", () => {
341
341
  expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
342
342
  });
343
343
 
344
+ test("a desktop app's own entitlements reach the macOS build, and the runtime accepts them", async () => {
345
+ const root = await makeTempRoot();
346
+ const entitlements = { "com.apple.security.cs.disable-library-validation": true };
347
+ const { config } = NativeConfig.build({
348
+ appPath: root,
349
+ target: {
350
+ ...minimalTarget,
351
+ permissions: [],
352
+ deepLinks: undefined,
353
+ android: undefined,
354
+ desktop: { entitlements },
355
+ },
356
+ webDir: path.join(root, "web"),
357
+ contributions: [],
358
+ locales: ["en"],
359
+ platform: "macos",
360
+ });
361
+
362
+ expect(config.native?.macos).toEqual({ entitlements });
363
+ await mkdir(path.join(root, "web"), { recursive: true });
364
+ await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
365
+ expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
366
+ });
367
+
344
368
  test("an app with updates ships the updates plugin and its settings", () => {
345
369
  const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=`, channel: "pilot" };
346
370
  const { config } = NativeConfig.build({
@@ -161,7 +161,8 @@ export class NativeConfig {
161
161
  ...Object.entries(target.ios?.files ?? {}).map(([to, from]) => ({ from: abs(from), to: `ios/${to}` })),
162
162
  ...Object.entries(target.android?.files ?? {}).map(([to, from]) => ({ from: abs(from), to: `android/${to}` })),
163
163
  ];
164
- const native = NativeConfig.#compact({ ios, android, resources });
164
+ const macos = NativeConfig.#compact({ entitlements: target.desktop?.entitlements });
165
+ const native = NativeConfig.#compact({ ios, macos, android, resources });
165
166
  const googleServices = target.android?.googleServices;
166
167
  const pushAndroid = target.android?.push;
167
168
  const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.31",
3
+ "version": "3.0.0-beta.32",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "@earendil-works/pi-tui": "0.86.1",
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
- "akanjs": "3.0.0-beta.31",
50
+ "akanjs": "3.0.0-beta.32",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",