@akanjs/devkit 3.0.0-beta.27 → 3.0.0-beta.29

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.
Files changed (40) hide show
  1. package/akanApp/akanApp.host.test.ts +55 -0
  2. package/akanApp/akanApp.host.ts +24 -2
  3. package/akanConfig/akanBin.test.ts +62 -0
  4. package/akanConfig/akanBin.ts +78 -0
  5. package/akanConfig/akanConfig.test.ts +212 -2
  6. package/akanConfig/akanConfig.ts +93 -26
  7. package/akanConfig/index.ts +1 -0
  8. package/akanConfig/types.ts +7 -1
  9. package/applicationBuildRunner.test.ts +36 -2
  10. package/applicationBuildRunner.ts +21 -6
  11. package/biome.base.json +3 -1
  12. package/csrE2e/csrPageStack.e2e.test.ts +65 -0
  13. package/executors.test.ts +43 -0
  14. package/executors.ts +17 -2
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +45 -2
  17. package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
  18. package/integration/devStability.integration.test.ts +35 -0
  19. package/mobile/desktopBin.test.ts +177 -0
  20. package/mobile/desktopBin.ts +143 -0
  21. package/mobile/desktopServerStage.test.ts +204 -0
  22. package/mobile/desktopServerStage.ts +138 -0
  23. package/mobile/index.ts +3 -0
  24. package/mobile/mobileTarget.test.ts +50 -1
  25. package/mobile/mobileTarget.ts +20 -4
  26. package/mobile/nativeApp.test.ts +52 -1
  27. package/mobile/nativeApp.ts +175 -20
  28. package/mobile/nativeAppLine.test.ts +7 -0
  29. package/mobile/nativeAppLine.ts +8 -0
  30. package/mobile/nativeConfig.test.ts +215 -4
  31. package/mobile/nativeConfig.ts +117 -8
  32. package/mobile/nativePluginFolders.test.ts +81 -0
  33. package/mobile/nativePluginFolders.ts +60 -0
  34. package/mobile/nativeWebDir.ts +6 -2
  35. package/package.json +2 -2
  36. package/transforms/serverEnvPlugin.test.ts +103 -0
  37. package/transforms/serverEnvPlugin.ts +41 -0
  38. package/types.ts +1 -0
  39. package/workspaceLayout.test.ts +5 -0
  40. package/workspaceLayout.ts +2 -0
@@ -1,8 +1,10 @@
1
- import { mkdir } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import type { AkanNativeConfig } from "@akanjs/native/config";
3
+ import type { MobileEnv } from "akanjs";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { Executor } from "../executors";
6
+ import { DesktopBin } from "./desktopBin";
7
+ import type { DesktopServerBundle } from "./desktopServerStage";
6
8
  import {
7
9
  type DesktopPlatform,
8
10
  type MobilePlatform,
@@ -13,6 +15,7 @@ import {
13
15
  import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
14
16
  import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
15
17
  import { NativeConfig } from "./nativeConfig";
18
+ import { NativePluginFolders } from "./nativePluginFolders";
16
19
  import { NativeWebDir } from "./nativeWebDir";
17
20
 
18
21
  type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
@@ -31,6 +34,14 @@ interface NativeDevBoot {
31
34
  lines: NativeAppLineRead[];
32
35
  }
33
36
 
37
+ export interface NativeBuildOptions {
38
+ profile?: "debug" | "release";
39
+ /** The server a desktop app carries (the target's `native.desktop.server`), staged by DesktopServerStage. */
40
+ server?: DesktopServerBundle;
41
+ /** Windows: an NSIS setup program beside the app folder. */
42
+ installer?: boolean;
43
+ }
44
+
34
45
  export interface NativeDevOptions extends NativeRunOptions {
35
46
  /** The akan dev server the gateway proxies. */
36
47
  upstream: string;
@@ -48,6 +59,7 @@ export class NativeApp {
48
59
  constructor(
49
60
  readonly app: App,
50
61
  readonly target: ResolvedMobileTarget,
62
+ readonly env?: MobileEnv,
51
63
  ) {
52
64
  this.targetRoot = path.join(app.cwdPath, ".akan", "mobile", target.name);
53
65
  this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
@@ -57,6 +69,11 @@ export class NativeApp {
57
69
  return path.join(this.targetRoot, "native", platform);
58
70
  }
59
71
 
72
+ //? Apart from the builds: a desktop build empties its folder first, which would take a release and its installer.
73
+ devOutDir(platform: NativePlatform) {
74
+ return path.join(this.targetRoot, "dev", platform);
75
+ }
76
+
60
77
  /** The web root, from the production build the mobile commands run first. */
61
78
  async assembleWeb() {
62
79
  const dist = this.app.dist.cwdPath;
@@ -67,27 +84,59 @@ export class NativeApp {
67
84
  });
68
85
  }
69
86
 
70
- async config() {
71
- const [appConfig, plugins] = await Promise.all([this.app.getConfig(), this.app.collectPlugins()]);
87
+ /** Where a desktop build stages the executables it carries (akan.config.ts `bin`). */
88
+ get binDir() {
89
+ return path.join(this.targetRoot, "bin");
90
+ }
91
+
92
+ /** `stageBin: false` for a config nothing is built from (the update key's): staging downloads the `bin` sources. */
93
+ async config({
94
+ server,
95
+ platform,
96
+ stageBin = true,
97
+ }: {
98
+ server?: DesktopServerBundle;
99
+ platform: NativePlatform;
100
+ stageBin?: boolean;
101
+ }) {
102
+ const [appConfig, plugins, nativePlugins] = await Promise.all([
103
+ this.app.getConfig(),
104
+ this.app.collectPlugins(),
105
+ NativePluginFolders.of(this.app),
106
+ ]);
107
+ const desktop = platform === "macos" || platform === "windows" || platform === "linux";
108
+ const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir) : [];
72
109
  return NativeConfig.build({
73
110
  appPath: this.app.cwdPath,
74
111
  target: this.target.config,
75
112
  webDir: this.web.dir,
76
113
  contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
77
114
  locales: appConfig.i18n.locales,
115
+ env: this.env,
116
+ platform,
117
+ nativePlugins,
118
+ ...(server ? { desktopServer: server } : {}),
119
+ ...(carried.length ? { desktopBin: this.binDir } : {}),
78
120
  });
79
121
  }
80
122
 
81
123
  /** The API and the config it is about to build, refused here when the runtime would refuse it later. */
82
- async prepare() {
83
- const [api, { config, warnings }] = await Promise.all([NativeApi.load(this.app.cwdPath), this.config()]);
124
+ async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
125
+ const [api, { config, warnings }] = await Promise.all([
126
+ NativeApi.load(this.app.cwdPath),
127
+ this.config({ platform, ...(server ? { server } : {}) }),
128
+ ]);
84
129
  for (const warning of warnings) this.app.logger.warn(warning);
130
+ this.#assertValid(api, config);
131
+ return { api, config };
132
+ }
133
+
134
+ #assertValid(api: NativeBuildApiModule, config: AkanNativeConfig) {
85
135
  const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
86
136
  if (problems.length)
87
137
  throw new Error(
88
138
  `Mobile target '${this.target.name}' makes an invalid native config:\n- ${problems.join("\n- ")}`,
89
139
  );
90
- return { api, config };
91
140
  }
92
141
 
93
142
  //? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
@@ -98,12 +147,12 @@ export class NativeApp {
98
147
  };
99
148
 
100
149
  //* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
101
- #task(platform: NativePlatform, config: AkanNativeConfig): TaskOptions {
150
+ #task(platform: NativePlatform, config: AkanNativeConfig, outDir = this.outDir(platform)): TaskOptions {
102
151
  return {
103
152
  appDir: this.app.cwdPath,
104
153
  config,
105
154
  platform,
106
- outDir: this.outDir(platform),
155
+ outDir,
107
156
  envFiles: false,
108
157
  skipWebBuild: true,
109
158
  log: (event) => {
@@ -116,18 +165,25 @@ export class NativeApp {
116
165
  };
117
166
  }
118
167
 
119
- async build(platform: NativePlatform, { profile = "release" }: { profile?: "debug" | "release" } = {}) {
168
+ async build(platform: NativePlatform, { profile = "release", server, installer = false }: NativeBuildOptions = {}) {
169
+ NativeApp.#assertServerPlatform(platform, server);
170
+ if (installer && platform !== "windows") throw new Error(`An installer is built for Windows, not for ${platform}.`);
120
171
  await this.assembleWeb();
121
- const { api, config } = await this.prepare();
122
- return await api.build({ ...this.#task(platform, config), profile });
172
+ const { api, config } = await this.prepare(platform, server);
173
+ return await api.build({
174
+ ...this.#task(platform, config),
175
+ profile,
176
+ ...(installer ? { windows: { installer } } : {}),
177
+ });
123
178
  }
124
179
 
125
180
  async run(
126
181
  platform: NativePlatform,
127
- { device, teamId, profile = "debug" }: NativeRunOptions & { profile?: "debug" | "release" } = {},
182
+ { device, teamId, profile = "debug", server }: NativeRunOptions & NativeBuildOptions = {},
128
183
  ) {
184
+ NativeApp.#assertServerPlatform(platform, server);
129
185
  await this.assembleWeb();
130
- const { api, config } = await this.prepare();
186
+ const { api, config } = await this.prepare(platform, server);
131
187
  return await api.run({
132
188
  ...this.#task(platform, config),
133
189
  profile,
@@ -137,21 +193,23 @@ export class NativeApp {
137
193
  });
138
194
  }
139
195
 
140
- //* The gateway serves the page and its HMR socket on the app origin; API calls go to the dev server itself (baseEnv),
141
- //* which an Android device reaches through the reversed port.
196
+ //* The gateway serves the page, its HMR socket and its API calls on the app origin. The dev server's own port is
197
+ //* reversed too: a browser the app hands off to (auth-session) opens it directly (baseEnv getServerOrigin).
142
198
  async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
143
199
  const startedAt = performance.now();
144
200
  const boot: NativeDevBoot = { steps: [], lines: [] };
145
201
  this.#boot = boot;
146
202
  try {
147
- await mkdir(this.web.dir, { recursive: true });
148
- const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(), this.app.getConfig()]);
203
+ //? The runtime bundles an index.html it finds here as the dev build's page, and a release build leaves one.
204
+ await this.web.clear();
205
+ const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
149
206
  const session = await api.dev({
150
- ...this.#task(platform, config),
207
+ ...this.#task(platform, config, this.devOutDir(platform)),
151
208
  upstream,
152
209
  hmrPath: "/_akan/hmr",
153
210
  //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
154
211
  wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
212
+ reversePorts: [Number(new URL(upstream).port)],
155
213
  onLine: this.#appLine,
156
214
  startPath: this.startPath(lang),
157
215
  ...(device ? { device } : {}),
@@ -185,7 +243,7 @@ export class NativeApp {
185
243
 
186
244
  async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
187
245
  await this.assembleWeb();
188
- const { api, config } = await this.prepare();
246
+ const { api, config } = await this.prepare("ios");
189
247
  return await api.release({
190
248
  ...this.#task("ios", config),
191
249
  platform: "ios",
@@ -193,13 +251,104 @@ export class NativeApp {
193
251
  });
194
252
  }
195
253
 
254
+ /** Where publishUpdate writes: upload this folder as the target's updates.url. */
255
+ get updatesDir() {
256
+ return path.join(this.targetRoot, "updates");
257
+ }
258
+
259
+ /** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
260
+ async publishUpdate(
261
+ platform: NativePlatform,
262
+ { channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
263
+ ) {
264
+ NativeApp.#assertServerPlatform(platform, server);
265
+ this.#assertUpdates();
266
+ await this.assembleWeb();
267
+ const { api, config } = await this.prepare(platform, server);
268
+ return await api.publishUpdate({
269
+ ...this.#task(platform, config),
270
+ platform,
271
+ out: this.updatesDir,
272
+ ...(channel ? { channel } : {}),
273
+ });
274
+ }
275
+
276
+ /**
277
+ * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
278
+ * another app's, and a desktop release whose server presence is not its channel's previous release's.
279
+ */
280
+ async assertPublishable(
281
+ platform: NativePlatform,
282
+ { channel, server = false }: { channel?: string; server?: boolean } = {},
283
+ ) {
284
+ this.#assertUpdates();
285
+ const [api, { config }] = await Promise.all([
286
+ NativeApi.load(this.app.cwdPath),
287
+ this.config({ platform, stageBin: false }),
288
+ ]);
289
+ this.#assertValid(api, config);
290
+ try {
291
+ api.checkPublishUpdate({
292
+ appDir: this.app.cwdPath,
293
+ config,
294
+ platform,
295
+ out: this.updatesDir,
296
+ server,
297
+ ...(channel ? { channel } : {}),
298
+ });
299
+ } catch (error) {
300
+ throw new Error(
301
+ `Mobile target '${this.target.name}': ${error instanceof Error ? error.message : String(error)}`,
302
+ {
303
+ cause: error,
304
+ },
305
+ );
306
+ }
307
+ }
308
+
309
+ #assertUpdates() {
310
+ if (!this.target.config.updates)
311
+ throw new Error(
312
+ `Mobile target '${this.target.name}' has no updates: { url, publicKey } in akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
313
+ );
314
+ }
315
+
316
+ /** The key update releases of this target's app id on `platform` are signed with: made once, then read. */
317
+ async updateKeygen(platform: NativePlatform) {
318
+ const [api, { config }] = await Promise.all([
319
+ NativeApi.load(this.app.cwdPath),
320
+ this.config({ platform, stageBin: false }),
321
+ ]);
322
+ return api.updateKeygen({ config });
323
+ }
324
+
196
325
  async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
197
326
  const signing = NativeApp.androidSigning();
198
327
  await this.assembleWeb();
199
- const { api, config } = await this.prepare();
328
+ const { api, config } = await this.prepare("android");
200
329
  return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
201
330
  }
202
331
 
332
+ /** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
333
+ async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
334
+ await this.assembleWeb();
335
+ const { api, config } = await this.prepare(platform);
336
+ if (!config.updates)
337
+ throw new Error(
338
+ `Mobile target '${this.target.name}' takes no updates: add mobile.updates: { url, publicKey } to akan.config.ts first.`,
339
+ );
340
+ const packed = await api.packUpdate({
341
+ ...this.#task(platform, config),
342
+ platform,
343
+ out: out ?? path.join(this.targetRoot, "updates", platform),
344
+ });
345
+ return { ...packed, channel: config.updates.channel ?? "production" };
346
+ }
347
+
348
+ static async compareBundles(appDir: string, shipped: string, bundle: string) {
349
+ return (await NativeApi.load(appDir)).compareBundles(shipped, bundle);
350
+ }
351
+
203
352
  //* The names the Gradle build read, so a CI that already holds these secrets keeps working.
204
353
  static androidSigning(env: Record<string, string | undefined> = process.env): AndroidSigning {
205
354
  const keystore = env.MYAPP_RELEASE_STORE_FILE;
@@ -232,6 +381,12 @@ export class NativeApp {
232
381
  return `/${home.join("/")}?${params}`;
233
382
  }
234
383
 
384
+ //? A phone runs no Bun, so only a desktop app can carry the server.
385
+ static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
386
+ if (server && (platform === "ios" || platform === "android"))
387
+ throw new Error(`Only a desktop app carries its server; ${platform} cannot run one.`);
388
+ }
389
+
235
390
  //* A desktop app builds only on its own OS, so the platform is this computer's.
236
391
  static desktopPlatform(host: NodeJS.Platform = process.platform): DesktopPlatform {
237
392
  if (host === "darwin") return "macos";
@@ -24,6 +24,13 @@ describe("NativeAppLine", () => {
24
24
  });
25
25
  });
26
26
 
27
+ test("a carried server's line keeps the level its logger wrote, and one without a level stays visible", () => {
28
+ const line = "[server] [minimal] 81 - 09/30/2026, 12:30:06 PM ERROR boom +2ms";
29
+ expect(NativeAppLine.read(line)).toEqual({ level: "error", message: line });
30
+ expect(NativeAppLine.read("[server] \u001b[32m[minimal] 81 -\u001b[39m WARN slow").level).toBe("warn");
31
+ expect(NativeAppLine.read("[server] at run (main.js:1:2)").level).toBe("info");
32
+ });
33
+
27
34
  test("a second window is named, the first is not", () => {
28
35
  expect(NativeAppLine.read("[page#2 info] [App] ready")).toEqual({ level: "info", message: "[page#2:App] ready" });
29
36
  });
@@ -13,6 +13,10 @@ export class NativeAppLine {
13
13
  static readonly #page = /^\[page(\+)?(?:#(\d+))? (trace|verbose|debug|log|info|warn|error)\] ?(.*)$/;
14
14
  static readonly #logcat = /^([VDIWEFA])\/([^(]+?)\s*\(\s*\d+\):\s?(.*)$/;
15
15
  static readonly #named = /^\[([^\]\s]+)\] ([\s\S]*)$/;
16
+ //? A server the desktop app carries (host server.ts): `[server] <its own log line>`, whose level word the akan logger
17
+ //? wrote; a line without one (a crash's stack) is still the app's and stays visible.
18
+ static readonly #server = /^\[server\] /;
19
+ static readonly #serverLevel = /\b(TRACE|VERBOSE|DEBUG|INFO|WARN|ERROR)\b/;
16
20
  static readonly #logcatLevels: { [priority: string]: LogLevel } = {
17
21
  V: "verbose",
18
22
  D: "debug",
@@ -42,6 +46,10 @@ export class NativeAppLine {
42
46
  });
43
47
  }
44
48
  if (line.startsWith("[akan-native")) return NativeAppLine.#hostLine(line, "info");
49
+ if (NativeAppLine.#server.test(line)) {
50
+ const word = NativeAppLine.#serverLevel.exec(Bun.stripANSI(line))?.[1];
51
+ return { level: word ? (word.toLowerCase() as LogLevel) : "info", message: line };
52
+ }
45
53
  return { level: verbose ? "info" : "debug", message: line };
46
54
  }
47
55
 
@@ -45,26 +45,29 @@ const adminTarget: AkanMobileTargetConfig = {
45
45
  },
46
46
  };
47
47
 
48
+ const updatesKey = Buffer.alloc(32, 7).toString("base64");
49
+
48
50
  describe("NativeConfig.build", () => {
49
- test("turns the minimal app's push target into the runtime's config", () => {
51
+ test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
50
52
  const { config, warnings } = NativeConfig.build({
51
53
  appPath: "/repo/apps/minimal",
52
54
  target: minimalTarget,
53
55
  webDir: "/repo/apps/minimal/.akan/mobile/default/web",
54
56
  contributions: [{ permission: "push", plugins: ["push"] }],
55
57
  locales: ["en", "ko"],
58
+ platform: "android",
56
59
  });
57
60
 
58
61
  expect(warnings).toEqual([]);
59
62
  expect(config).toEqual({
60
63
  app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
61
64
  web: { dir: "/repo/apps/minimal/.akan/mobile/default/web" },
62
- plugins: [...NativeConfig.basePlugins, "push"],
65
+ plugins: [...NativeConfig.basePlugins, "single-instance", "push"],
63
66
  capabilities: [
64
67
  {
65
68
  identifier: "app",
66
69
  description: "The plugins minimal ships, each with its default permissions",
67
- permissions: [...NativeConfig.basePlugins, "push"].map((plugin) => `${plugin}:default`),
70
+ permissions: [...NativeConfig.basePlugins, "single-instance", "push"].map((plugin) => `${plugin}:default`),
68
71
  },
69
72
  ],
70
73
  deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
@@ -83,6 +86,7 @@ describe("NativeConfig.build", () => {
83
86
  webDir: "/repo/apps/portal/.akan/mobile/admin/web",
84
87
  contributions: [],
85
88
  locales: ["en", "ko"],
89
+ platform: "android",
86
90
  });
87
91
 
88
92
  expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
@@ -138,6 +142,7 @@ describe("NativeConfig.build", () => {
138
142
  { permission: "push", plugins: ["push"] },
139
143
  ],
140
144
  locales: ["en"],
145
+ platform: "android",
141
146
  });
142
147
 
143
148
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "camera"]);
@@ -152,6 +157,75 @@ describe("NativeConfig.build", () => {
152
157
  });
153
158
  });
154
159
 
160
+ test("updates bring their plugin and follow the binary's backend unless a channel is named", () => {
161
+ const build = (updates: AkanMobileTargetConfig["updates"], env?: "main" | "develop") =>
162
+ NativeConfig.build({
163
+ appPath: "/repo/apps/portal",
164
+ target: { ...adminTarget, updates },
165
+ webDir: "/web",
166
+ contributions: [],
167
+ locales: ["en"],
168
+ platform: "android",
169
+ env,
170
+ }).config;
171
+ const url = "https://updates.example.com/portal";
172
+ expect(build({ url, publicKey: updatesKey }, "develop").updates).toEqual({
173
+ url,
174
+ publicKey: updatesKey,
175
+ channel: "develop",
176
+ });
177
+ expect(build({ url, publicKey: updatesKey, channel: "beta", readyTimeout: 15_000 }, "main").updates).toEqual({
178
+ url,
179
+ publicKey: updatesKey,
180
+ channel: "beta",
181
+ readyTimeout: 15_000,
182
+ });
183
+ expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
184
+ expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
185
+ expect(build(undefined, "main").plugins).not.toContain("updates");
186
+ });
187
+
188
+ test("push, privacy and the icon and splash objects reach the runtime, paths from the app folder", () => {
189
+ const { config } = NativeConfig.build({
190
+ appPath: "/repo/apps/portal",
191
+ target: {
192
+ ...adminTarget,
193
+ assets: {
194
+ icon: { image: "assets/icon.png", backgroundColor: "#ffffff" },
195
+ splash: { backgroundColor: { light: "#ffffff", dark: "#000000" }, autoHide: false },
196
+ },
197
+ native: {
198
+ push: { android: { smallIcon: "assets/noti.png", color: "#ff5a5f", channel: { id: "chat", name: "Chat" } } },
199
+ privacy: { tracking: false, accessedApis: { DiskSpace: ["E174.1"] } },
200
+ },
201
+ },
202
+ webDir: "/web",
203
+ contributions: [],
204
+ locales: ["en"],
205
+ platform: "android",
206
+ });
207
+ const abs = (relative: string) => path.resolve("/repo/apps/portal", relative);
208
+ expect(config.icon).toEqual({ image: abs("assets/icon.png"), backgroundColor: "#ffffff" });
209
+ expect(config.splash).toEqual({ backgroundColor: { light: "#ffffff", dark: "#000000" }, autoHide: false });
210
+ expect(config.push).toEqual({
211
+ android: { smallIcon: abs("assets/noti.png"), color: "#ff5a5f", channel: { id: "chat", name: "Chat" } },
212
+ });
213
+ expect(config.privacy).toEqual({ tracking: false, accessedApis: { DiskSpace: ["E174.1"] } });
214
+ });
215
+
216
+ test("an app id per platform gives each build its own", () => {
217
+ const idOn = (platform: "ios" | "android") =>
218
+ NativeConfig.build({
219
+ appPath: "/repo/apps/portal",
220
+ target: { ...adminTarget, appId: { ios: "com.puffinplanet.admin", android: "com.portal.admin" } },
221
+ webDir: "/web",
222
+ contributions: [],
223
+ locales: ["en"],
224
+ platform,
225
+ }).config.app.id;
226
+ expect([idOn("ios"), idOn("android")]).toEqual(["com.puffinplanet.admin", "com.portal.admin"]);
227
+ });
228
+
155
229
  test("the runtime accepts both configs as they are", async () => {
156
230
  const root = await makeTempRoot();
157
231
  for (const file of ["secrets/google-services.json", "assets/chime.mp3"])
@@ -164,18 +238,146 @@ describe("NativeConfig.build", () => {
164
238
  await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
165
239
  const api = await NativeApi.load(repoApp);
166
240
 
167
- for (const target of [minimalTarget, adminTarget]) {
241
+ const adminWithEverything: AkanMobileTargetConfig = {
242
+ ...adminTarget,
243
+ updates: { url: "https://updates.example.com/portal", publicKey: updatesKey },
244
+ native: {
245
+ ...adminTarget.native,
246
+ push: { android: { smallIcon: "assets/icon.png", color: "#ff5a5f" } },
247
+ privacy: { tracking: false },
248
+ },
249
+ };
250
+ for (const target of [minimalTarget, adminWithEverything]) {
168
251
  const { config } = NativeConfig.build({
169
252
  appPath: root,
253
+ env: "main",
170
254
  target,
171
255
  webDir: path.join(root, "web"),
172
256
  contributions: [],
173
257
  locales: ["en", "ko"],
258
+ platform: "android",
174
259
  });
175
260
  expect(api.validateConfig(config, { appDir: root })).toEqual([]);
176
261
  }
177
262
  });
178
263
 
264
+ test("ships the app's own native plugins by folder and names each by its id in the capability", async () => {
265
+ const root = await makeTempRoot();
266
+ const kiosk = await writeManifest(path.join(root, "native", "kiosk"), "kiosk");
267
+ await writeManifest(path.join(root, "vendor", "led-panel"), "led-panel");
268
+ const { config } = NativeConfig.build({
269
+ appPath: root,
270
+ target: { ...minimalTarget, permissions: [], native: { plugins: ["./vendor/led-panel"] }, deepLinks: undefined },
271
+ webDir: path.join(root, "web"),
272
+ contributions: [],
273
+ locales: ["en"],
274
+ platform: "macos",
275
+ nativePlugins: [{ id: "kiosk", dir: kiosk, owner: "apps/board" }],
276
+ });
277
+
278
+ expect(config.plugins).toEqual([...NativeConfig.basePlugins, "./vendor/led-panel", kiosk]);
279
+ expect(config.capabilities?.[0]?.permissions).toEqual(
280
+ [...NativeConfig.basePlugins, "led-panel", "kiosk"].map((id) => `${id}:default`),
281
+ );
282
+ await mkdir(path.join(root, "web"), { recursive: true });
283
+ await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
284
+ const api = await NativeApi.load(repoApp);
285
+ expect(api.validateConfig(config, { appDir: root })).toEqual([]);
286
+ });
287
+
288
+ test("ships a native/ folder once when the target also lists it by path or by id", async () => {
289
+ const root = await makeTempRoot();
290
+ const kiosk = await writeManifest(path.join(root, "native", "kiosk"), "kiosk");
291
+ const { config } = NativeConfig.build({
292
+ appPath: root,
293
+ target: {
294
+ ...minimalTarget,
295
+ permissions: [],
296
+ native: { plugins: ["./native/kiosk/", "kiosk", "haptics"] },
297
+ deepLinks: undefined,
298
+ },
299
+ webDir: path.join(root, "web"),
300
+ contributions: [],
301
+ locales: ["en"],
302
+ platform: "macos",
303
+ nativePlugins: [{ id: "kiosk", dir: kiosk, owner: "apps/board" }],
304
+ });
305
+
306
+ expect(config.plugins).toEqual([...NativeConfig.basePlugins, kiosk]);
307
+ });
308
+
309
+ test("hands an unattended app's settings on: page recovery, a kiosk window, screen capture and Android autoplay", async () => {
310
+ const root = await makeTempRoot();
311
+ const { config } = NativeConfig.build({
312
+ appPath: root,
313
+ target: {
314
+ ...minimalTarget,
315
+ permissions: [],
316
+ deepLinks: undefined,
317
+ native: {
318
+ android: { autoplay: true },
319
+ desktop: { recovery: "reload", window: { fullscreen: true, skipTaskbar: true }, screenCapture: "auto" },
320
+ },
321
+ },
322
+ webDir: path.join(root, "web"),
323
+ contributions: [],
324
+ locales: ["en"],
325
+ platform: "macos",
326
+ desktopBin: root,
327
+ });
328
+
329
+ expect(config.desktop).toEqual({
330
+ recovery: "reload",
331
+ window: { fullscreen: true, skipTaskbar: true },
332
+ screenCapture: "auto",
333
+ bin: root,
334
+ });
335
+ expect(config.android).toEqual({ debugAppIdSuffix: ".debug", autoplay: true });
336
+ await mkdir(path.join(root, "web"), { recursive: true });
337
+ await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
338
+ expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
339
+ });
340
+
341
+ test("an app with updates ships the updates plugin and its settings", () => {
342
+ const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=`, channel: "pilot" };
343
+ const { config } = NativeConfig.build({
344
+ appPath: "/repo/apps/board",
345
+ target: { ...minimalTarget, permissions: [], deepLinks: undefined, native: undefined, updates },
346
+ webDir: "/web",
347
+ contributions: [],
348
+ locales: ["en"],
349
+ platform: "macos",
350
+ });
351
+
352
+ expect(config.plugins).toEqual([...NativeConfig.basePlugins, "updates"]);
353
+ expect(config.updates).toEqual(updates);
354
+ });
355
+
356
+ test("an updates channel left unnamed is the backend env the binary is built for", () => {
357
+ const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=` };
358
+ const build = (env?: "debug" | "main", channel?: string) =>
359
+ NativeConfig.build({
360
+ appPath: "/repo/apps/board",
361
+ target: {
362
+ ...minimalTarget,
363
+ permissions: [],
364
+ deepLinks: undefined,
365
+ native: undefined,
366
+ updates: { ...updates, ...(channel ? { channel } : {}) },
367
+ },
368
+ webDir: "/web",
369
+ contributions: [],
370
+ locales: ["en"],
371
+ platform: "macos",
372
+ ...(env ? { env } : {}),
373
+ }).config.updates;
374
+
375
+ expect(build("debug")).toEqual({ ...updates, channel: "debug" });
376
+ expect(build("main")).toEqual({ ...updates, channel: "main" });
377
+ expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot" });
378
+ expect(build()).toEqual(updates);
379
+ });
380
+
179
381
  test("keeps a file name the runtime accepts", () => {
180
382
  expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
181
383
  expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
@@ -183,6 +385,15 @@ describe("NativeConfig.build", () => {
183
385
  });
184
386
  });
185
387
 
388
+ const writeManifest = async (dir: string, id: string) => {
389
+ await mkdir(dir, { recursive: true });
390
+ await writeFile(
391
+ path.join(dir, "native-plugin.json"),
392
+ JSON.stringify({ id, apiVersion: 1, methods: [], events: [], web: null, desktop: null, ios: null, android: null }),
393
+ );
394
+ return dir;
395
+ };
396
+
186
397
  const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
187
398
 
188
399
  const googleServicesJson = JSON.stringify({