@akanjs/devkit 3.0.0-beta.30 → 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,
@@ -491,6 +503,29 @@ describe("AkanAppConfig", () => {
491
503
  expect(config.native.targets.default).toMatchObject({ indexPath: "/explore", desktop: { server: true } });
492
504
  });
493
505
 
506
+ test("a platform section may name its own indexPath, normalized like the section's", () => {
507
+ const config = new AkanAppConfig(
508
+ app,
509
+ [],
510
+ packageJson,
511
+ {
512
+ native: {
513
+ indexPath: "/mobile",
514
+ ios: { indexPath: "cockpit/" },
515
+ desktop: { indexPath: " / ", server: true },
516
+ },
517
+ },
518
+ baseDevEnv,
519
+ );
520
+
521
+ expect(config.native.targets.default).toMatchObject({
522
+ indexPath: "/mobile",
523
+ ios: { indexPath: "/cockpit" },
524
+ desktop: { indexPath: "/", server: true },
525
+ });
526
+ expect(config.native.targets.default.android).toBeUndefined();
527
+ });
528
+
494
529
  test("a target takes the native section with its own fields over it: objects merge, lists and values replace", () => {
495
530
  const config = new AkanAppConfig(
496
531
  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,
@@ -293,9 +294,11 @@ export class AkanAppConfig implements AppConfigResult {
293
294
  } = merged;
294
295
  const indexPath = normalizeIndexPath(rawIndexPath);
295
296
  const deepLinks = normalizeDeepLinks(rawDeepLinks);
296
- const { teamId: rawTeamId, ...iosRest } = merged.ios ?? {};
297
+ const { teamId: rawTeamId, ...iosRest } = AkanAppConfig.#platformIndexPath(merged.ios ?? {});
297
298
  const teamId = rawTeamId?.trim();
298
- const { sha256CertFingerprints: rawFingerprints, ...androidRest } = merged.android ?? {};
299
+ const { sha256CertFingerprints: rawFingerprints, ...androidRest } = AkanAppConfig.#platformIndexPath(
300
+ merged.android ?? {},
301
+ );
299
302
  const fingerprints = normalizeStringList(rawFingerprints);
300
303
  const target: AkanNativeTarget = {
301
304
  ...rest,
@@ -307,6 +310,15 @@ export class AkanAppConfig implements AppConfigResult {
307
310
  ...(merged.android
308
311
  ? { android: { ...androidRest, ...(fingerprints ? { sha256CertFingerprints: fingerprints } : {}) } }
309
312
  : {}),
313
+ ...(merged.desktop
314
+ ? {
315
+ desktop: AkanAppConfig.#desktopServer(
316
+ AkanAppConfig.#platformIndexPath(merged.desktop),
317
+ `${where}.desktop.server`,
318
+ configPath,
319
+ ),
320
+ }
321
+ : {}),
310
322
  appName: merged.appName ?? this.app.name,
311
323
  appId: merged.appId ?? appId,
312
324
  version: merged.version ?? version,
@@ -318,6 +330,27 @@ export class AkanAppConfig implements AppConfigResult {
318
330
  );
319
331
  return { appName, appId, ...(root.fileName ? { fileName: root.fileName } : {}), version, buildNum, targets };
320
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
+ }
349
+ static #platformIndexPath<T extends { indexPath?: string }>(section: T): T {
350
+ const { indexPath: rawIndexPath, ...rest } = section;
351
+ const indexPath = normalizeIndexPath(rawIndexPath);
352
+ return { ...rest, ...(indexPath ? { indexPath } : {}) } as T;
353
+ }
321
354
  /** `own` over `base`: plain objects merge key by key, and any other value, a list included, replaces. */
322
355
  static #overlay(base: AkanNativeSettings, own: AkanNativeSettings): AkanNativeSettings {
323
356
  const merge = (kept: unknown, value: unknown, top: boolean, key: string): unknown => {
@@ -356,8 +389,9 @@ export class AkanAppConfig implements AppConfigResult {
356
389
  "android",
357
390
  "desktop",
358
391
  ],
359
- ios: ["teamId", "infoPlist", "entitlements", "privacy", "files"],
392
+ ios: ["indexPath", "teamId", "infoPlist", "entitlements", "privacy", "files"],
360
393
  android: [
394
+ "indexPath",
361
395
  "sha256CertFingerprints",
362
396
  "googleServices",
363
397
  "push",
@@ -367,7 +401,8 @@ export class AkanAppConfig implements AppConfigResult {
367
401
  "activity",
368
402
  "files",
369
403
  ],
370
- desktop: ["server", "recovery", "window", "screenCapture"],
404
+ desktop: ["indexPath", "server", "recovery", "window", "screenCapture", "entitlements"],
405
+ desktopServer: ["omit"],
371
406
  deepLinks: ["schemes", "domains"],
372
407
  } as const;
373
408
  //* Settings that moved when `mobile` became `native` are named with where they went, instead of "unknown".
@@ -399,6 +434,12 @@ export class AkanAppConfig implements AppConfigResult {
399
434
  check(settings.ios, AkanAppConfig.#nativeKeys.ios, `${where}.ios`, "ios");
400
435
  check(settings.android, AkanAppConfig.#nativeKeys.android, `${where}.android`, "android");
401
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
+ );
402
443
  check(settings.deepLinks, AkanAppConfig.#nativeKeys.deepLinks, `${where}.deepLinks`, "deepLinks");
403
444
  }
404
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,15 +86,48 @@ 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
- expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
97
+ expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en", "ios")).toBe(
91
98
  "/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
92
99
  );
93
- expect(new NativeApp(fakeApp(), target({ name: "admin", basePath: "/admin/" })).startPath("ko")).toBe(
100
+ expect(new NativeApp(fakeApp(), target({ name: "admin", basePath: "/admin/" })).startPath("ko", "android")).toBe(
94
101
  "/ko/admin?csr=true&akanMobileTarget=admin&akanMobileBasePath=admin",
95
102
  );
96
103
  });
97
104
 
105
+ test("a platform section's indexPath wins on that platform, and the others keep the target's", () => {
106
+ const app = new NativeApp(fakeApp(), target({ indexPath: "/mobile", desktop: { indexPath: "/", server: true } }));
107
+
108
+ expect(app.indexPath("ios")).toBe("/mobile");
109
+ expect(app.indexPath("android")).toBe("/mobile");
110
+ expect(app.indexPath("macos")).toBe("/");
111
+ expect(app.indexPath("windows")).toBe("/");
112
+ expect(app.startPath("en", "macos")).toBe("/en?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2F");
113
+ expect(app.startPath("en", "ios")).toBe(
114
+ "/en/mobile?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fmobile",
115
+ );
116
+ });
117
+
118
+ test("assembles each platform's web root with that platform's indexPath", async () => {
119
+ const root = await makeTempRoot();
120
+ await writeText(path.join(root, "dist/csr/index.html"), "<html><head></head><body></body></html>");
121
+ const app = { ...fakeApp(), cwdPath: root, dist: { cwdPath: path.join(root, "dist") } } as unknown as App;
122
+ const nativeApp = new NativeApp(app, target({ indexPath: "/mobile", desktop: { indexPath: "/console" } }));
123
+ const injected = async () => await Bun.file(path.join(nativeApp.web.dir, "index.html")).text();
124
+
125
+ await nativeApp.assembleWeb("linux");
126
+ expect(await injected()).toContain('"indexPath":"/console"');
127
+ await nativeApp.assembleWeb("android");
128
+ expect(await injected()).toContain('"indexPath":"/mobile"');
129
+ });
130
+
98
131
  test("builds its config from the app's locales and the plugins' native contributions", async () => {
99
132
  const push: AkanPlugin = { name: "push-notification", native: { permission: "push", plugins: ["push"] } };
100
133
  const app = new NativeApp(
@@ -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 {
@@ -74,14 +76,24 @@ export class NativeApp {
74
76
  return path.join(this.targetRoot, "dev", platform);
75
77
  }
76
78
 
79
+ /** Where the app starts on `platform`: its platform section's `indexPath`, else the target's. */
80
+ indexPath(platform: NativePlatform) {
81
+ const { config } = this.target;
82
+ const section = platform === "ios" ? config.ios : platform === "android" ? config.android : config.desktop;
83
+ return section?.indexPath ?? config.indexPath;
84
+ }
85
+
77
86
  /** The web root, from the production build the native commands run first. */
78
- async assembleWeb() {
87
+ async assembleWeb(platform: NativePlatform) {
79
88
  const dist = this.app.dist.cwdPath;
80
- return await this.web.assemble(this.target.config, {
81
- html: path.join(dist, "csr", targetHtmlFilename(this.target.config)),
82
- publicDir: path.join(dist, "public"),
83
- fontsDir: path.join(dist, ".akan", "artifact", "fonts"),
84
- });
89
+ return await this.web.assemble(
90
+ { ...this.target.config, indexPath: this.indexPath(platform) },
91
+ {
92
+ html: path.join(dist, "csr", targetHtmlFilename(this.target.config)),
93
+ publicDir: path.join(dist, "public"),
94
+ fontsDir: path.join(dist, ".akan", "artifact", "fonts"),
95
+ },
96
+ );
85
97
  }
86
98
 
87
99
  /** Where a desktop build stages the executables it carries (akan.config.ts `bin`). */
@@ -94,10 +106,12 @@ export class NativeApp {
94
106
  server,
95
107
  platform,
96
108
  stageBin = true,
109
+ arch,
97
110
  }: {
98
111
  server?: DesktopServerBundle;
99
112
  platform: NativePlatform;
100
113
  stageBin?: boolean;
114
+ arch?: DesktopArch;
101
115
  }) {
102
116
  const [appConfig, plugins, nativePlugins] = await Promise.all([
103
117
  this.app.getConfig(),
@@ -105,7 +119,7 @@ export class NativeApp {
105
119
  NativePluginFolders.of(this.app),
106
120
  ]);
107
121
  const desktop = platform === "macos" || platform === "windows" || platform === "linux";
108
- 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) : [];
109
123
  return NativeConfig.build({
110
124
  appPath: this.app.cwdPath,
111
125
  target: this.target.config,
@@ -121,10 +135,10 @@ export class NativeApp {
121
135
  }
122
136
 
123
137
  /** The API and the config it is about to build, refused here when the runtime would refuse it later. */
124
- async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
138
+ async prepare(platform: NativePlatform, server?: DesktopServerBundle, arch?: DesktopArch) {
125
139
  const [api, { config, warnings }] = await Promise.all([
126
140
  NativeApi.load(this.app.cwdPath),
127
- this.config({ platform, ...(server ? { server } : {}) }),
141
+ this.config({ platform, ...(server ? { server } : {}), ...(arch ? { arch } : {}) }),
128
142
  ]);
129
143
  for (const warning of warnings) this.app.logger.warn(warning);
130
144
  this.#assertValid(api, config);
@@ -165,15 +179,35 @@ export class NativeApp {
165
179
  };
166
180
  }
167
181
 
168
- 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
+ ) {
169
186
  NativeApp.#assertServerPlatform(platform, server);
170
- if (installer && platform !== "windows") throw new Error(`An installer is built for Windows, not for ${platform}.`);
171
- await this.assembleWeb();
172
- const { api, config } = await this.prepare(platform, server);
187
+ NativeApp.assertInstaller(platform, installer);
188
+ await this.assembleWeb(platform);
189
+ const { api, config } = await this.prepare(platform, server, arch);
173
190
  return await api.build({
174
191
  ...this.#task(platform, config),
175
192
  profile,
176
- ...(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 } } : {}),
177
211
  });
178
212
  }
179
213
 
@@ -182,7 +216,7 @@ export class NativeApp {
182
216
  { device, teamId, profile = "debug", server }: NativeRunOptions & NativeBuildOptions = {},
183
217
  ) {
184
218
  NativeApp.#assertServerPlatform(platform, server);
185
- await this.assembleWeb();
219
+ await this.assembleWeb(platform);
186
220
  const { api, config } = await this.prepare(platform, server);
187
221
  return await api.run({
188
222
  ...this.#task(platform, config),
@@ -211,7 +245,7 @@ export class NativeApp {
211
245
  wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
212
246
  reversePorts: [Number(new URL(upstream).port)],
213
247
  onLine: this.#appLine,
214
- startPath: this.startPath(lang),
248
+ startPath: this.startPath(lang, platform),
215
249
  ...(device ? { device } : {}),
216
250
  ...(teamId ? { ios: { signing: { teamId } } } : {}),
217
251
  });
@@ -242,7 +276,7 @@ export class NativeApp {
242
276
  }
243
277
 
244
278
  async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
245
- await this.assembleWeb();
279
+ await this.assembleWeb("ios");
246
280
  const { api, config } = await this.prepare("ios");
247
281
  return await api.release({
248
282
  ...this.#task("ios", config),
@@ -263,13 +297,16 @@ export class NativeApp {
263
297
  ) {
264
298
  NativeApp.#assertServerPlatform(platform, server);
265
299
  this.#assertUpdates();
266
- await this.assembleWeb();
300
+ await this.assembleWeb(platform);
267
301
  const { api, config } = await this.prepare(platform, server);
268
302
  return await api.publishUpdate({
269
303
  ...this.#task(platform, config),
270
304
  platform,
271
305
  out: this.updatesDir,
272
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) } : {}),
273
310
  });
274
311
  }
275
312
 
@@ -324,14 +361,14 @@ export class NativeApp {
324
361
 
325
362
  async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
326
363
  const signing = NativeApp.androidSigning();
327
- await this.assembleWeb();
364
+ await this.assembleWeb("android");
328
365
  const { api, config } = await this.prepare("android");
329
366
  return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
330
367
  }
331
368
 
332
369
  /** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
333
370
  async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
334
- await this.assembleWeb();
371
+ await this.assembleWeb(platform);
335
372
  const { api, config } = await this.prepare(platform);
336
373
  if (!config.updates)
337
374
  throw new Error(
@@ -370,17 +407,29 @@ export class NativeApp {
370
407
 
371
408
  /** The first page of a dev build, the target's home as a release bundle opens it; the dev server answers the CSR
372
409
  * shell only for `?csr=true`. */
373
- startPath(lang: string) {
410
+ startPath(lang: string, platform: NativePlatform) {
374
411
  const basePath = this.target.config.basePath?.replace(/^\/+|\/+$/g, "");
412
+ const indexPath = this.indexPath(platform);
375
413
  const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
376
414
  if (basePath) params.set("akanMobileBasePath", basePath);
377
- if (this.target.config.indexPath) params.set("akanMobileIndexPath", this.target.config.indexPath);
378
- const home = [lang, basePath, this.target.config.indexPath]
415
+ if (indexPath) params.set("akanMobileIndexPath", indexPath);
416
+ const home = [lang, basePath, indexPath]
379
417
  .flatMap((part) => (part ?? "").split("/"))
380
418
  .filter((segment) => segment.length > 0);
381
419
  return `/${home.join("/")}?${params}`;
382
420
  }
383
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
+
384
433
  //? A phone runs no Bun, so only a desktop app can carry the server.
385
434
  static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
386
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"]>>({
@@ -234,10 +235,7 @@ export class NativeConfig {
234
235
  }
235
236
 
236
237
  //? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
237
- static #updates(
238
- target: AkanNativeTarget,
239
- env?: NativeEnv,
240
- ): NonNullable<AkanNativeConfig["updates"]> | undefined {
238
+ static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
241
239
  const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
242
240
  return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
243
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.30",
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.30",
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",