@akanjs/devkit 3.0.0-beta.26 → 3.0.0-beta.28

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 (49) hide show
  1. package/akanApp/akanApp.host.ts +22 -2
  2. package/akanConfig/akanBin.test.ts +62 -0
  3. package/akanConfig/akanBin.ts +78 -0
  4. package/akanConfig/akanConfig.test.ts +212 -2
  5. package/akanConfig/akanConfig.ts +93 -26
  6. package/akanConfig/index.ts +1 -0
  7. package/akanConfig/types.ts +7 -1
  8. package/applicationBuildRunner.test.ts +36 -2
  9. package/applicationBuildRunner.ts +21 -6
  10. package/biome.base.json +3 -1
  11. package/csrE2e/csrE2eHarness.fixture.ts +55 -5
  12. package/csrE2e/csrPageStack.e2e.test.ts +65 -0
  13. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  14. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  15. package/executors.test.ts +43 -0
  16. package/executors.ts +17 -2
  17. package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
  18. package/frontendBuild/csrDevArtifactWriter.ts +4 -0
  19. package/frontendBuild/csrDevBundler.ts +1 -1
  20. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  21. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  22. package/frontendBuild/csrDevModuleCompiler.test.ts +210 -6
  23. package/frontendBuild/csrDevModuleCompiler.ts +133 -19
  24. package/frontendBuild/csrDevPaths.ts +1 -0
  25. package/frontendBuild/csrDevResolver.ts +89 -9
  26. package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
  27. package/frontendBuild/ssrDevBundler.ts +1 -1
  28. package/integration/devStability.integration.test.ts +35 -0
  29. package/mobile/desktopBin.test.ts +177 -0
  30. package/mobile/desktopBin.ts +143 -0
  31. package/mobile/desktopServerStage.test.ts +129 -0
  32. package/mobile/desktopServerStage.ts +120 -0
  33. package/mobile/index.ts +3 -0
  34. package/mobile/mobileTarget.test.ts +50 -1
  35. package/mobile/mobileTarget.ts +20 -4
  36. package/mobile/nativeApp.test.ts +37 -1
  37. package/mobile/nativeApp.ts +176 -27
  38. package/mobile/nativeAppLine.test.ts +69 -0
  39. package/mobile/nativeAppLine.ts +71 -0
  40. package/mobile/nativeConfig.test.ts +215 -4
  41. package/mobile/nativeConfig.ts +117 -8
  42. package/mobile/nativePluginFolders.test.ts +81 -0
  43. package/mobile/nativePluginFolders.ts +60 -0
  44. package/package.json +2 -2
  45. package/transforms/serverEnvPlugin.test.ts +100 -0
  46. package/transforms/serverEnvPlugin.ts +41 -0
  47. package/types.ts +1 -0
  48. package/workspaceLayout.test.ts +5 -0
  49. package/workspaceLayout.ts +2 -0
@@ -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({
@@ -1,6 +1,16 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  import type { AkanNativeConfig } from "@akanjs/native/config";
3
- import type { AkanMobileTargetConfig, AkanNativeValue, AkanPluginNativeConfig, MobilePermission } from "akanjs";
4
+ import type {
5
+ AkanMobileTargetConfig,
6
+ AkanNativeValue,
7
+ AkanPluginNativeConfig,
8
+ MobileEnv,
9
+ MobilePermission,
10
+ } from "akanjs";
11
+ import type { DesktopServerBundle } from "./desktopServerStage";
12
+ import { type NativePlatform, resolveAppId } from "./mobileTarget";
13
+ import type { NativePluginFolder } from "./nativePluginFolders";
4
14
  import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
5
15
 
6
16
  export interface NativeConfigInput {
@@ -12,6 +22,15 @@ export interface NativeConfigInput {
12
22
  /** What the app's and its libs' plugins declare. */
13
23
  contributions: AkanPluginNativeConfig[];
14
24
  locales: readonly string[];
25
+ /** The backend the binary talks to; an updates channel left unnamed follows it. */
26
+ env?: MobileEnv;
27
+ platform: NativePlatform;
28
+ /** A desktop build that carries its server (the target's `native.desktop.server`). */
29
+ desktopServer?: DesktopServerBundle;
30
+ /** The plugins in the app's and its libs' `native/` folders. */
31
+ nativePlugins?: NativePluginFolder[];
32
+ /** A desktop build's staged `bin` executables (DesktopBin). */
33
+ desktopBin?: string;
15
34
  }
16
35
 
17
36
  export interface NativeConfigResult {
@@ -68,7 +87,18 @@ export class NativeConfig {
68
87
  speech: { permission: "speech" },
69
88
  };
70
89
 
71
- static build({ appPath, target, webDir, contributions, locales }: NativeConfigInput): NativeConfigResult {
90
+ static build({
91
+ appPath,
92
+ target,
93
+ webDir,
94
+ contributions,
95
+ locales,
96
+ env,
97
+ platform,
98
+ desktopServer,
99
+ nativePlugins = [],
100
+ desktopBin,
101
+ }: NativeConfigInput): NativeConfigResult {
72
102
  const warnings: string[] = [];
73
103
  const applied = (target.permissions ?? []).flatMap((permission) => {
74
104
  const claimed = contributions.filter((contribution) => contribution.permission === permission);
@@ -78,13 +108,30 @@ export class NativeConfig {
78
108
  return used;
79
109
  });
80
110
  const abs = (relative: string) => path.resolve(appPath, relative);
111
+ const updates = NativeConfig.#updates(target, env);
112
+ const folders = new Set(nativePlugins.map((plugin) => path.resolve(plugin.dir)));
113
+ const folderIds = new Set(nativePlugins.map((plugin) => plugin.id));
114
+ //? A `native/<id>` folder ships by itself; listed again, by path or by id, it would reach the runtime twice.
115
+ const listed = (target.native?.plugins ?? []).filter((spec) =>
116
+ NativeConfig.#isId(spec) ? !folderIds.has(spec) : !folders.has(path.resolve(appPath, spec)),
117
+ );
81
118
  const plugins = [
82
119
  ...new Set([
83
120
  ...NativeConfig.basePlugins,
121
+ //? Windows and Linux open a link by starting the app again; without the hand-over it runs as a second app and
122
+ //? the instance whose auth-session start() waits for the callback never hears it.
123
+ ...(target.deepLinks?.schemes?.length ? ["single-instance"] : []),
84
124
  ...applied.flatMap((contribution) => contribution.plugins ?? []),
85
- ...(target.native?.plugins ?? []),
125
+ //? A second copy of the app would start a second server on the same data.
126
+ ...(desktopServer ? ["single-instance"] : []),
127
+ ...(updates ? ["updates"] : []),
128
+ ...listed,
129
+ ...nativePlugins.map((plugin) => plugin.dir),
86
130
  ]),
87
131
  ];
132
+ const pluginIds = plugins.map(
133
+ (spec) => nativePlugins.find((plugin) => plugin.dir === spec)?.id ?? NativeConfig.#pluginId(appPath, spec),
134
+ );
88
135
  const usageDescriptions = Object.fromEntries(
89
136
  Object.entries(
90
137
  toIosInfoPlistUsageDescriptions(
@@ -119,9 +166,17 @@ export class NativeConfig {
119
166
  const resources = Object.entries(target.files ?? {}).map(([to, from]) => ({ from: abs(from), to }));
120
167
  const native = NativeConfig.#compact({ ios, android, resources });
121
168
  const googleServices = target.native?.android?.googleServices;
169
+ const pushAndroid = target.native?.push?.android;
170
+ const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
171
+ recovery: target.native?.desktop?.recovery,
172
+ screenCapture: target.native?.desktop?.screenCapture,
173
+ window: NativeConfig.#compact({ ...target.native?.desktop?.window }),
174
+ server: desktopServer,
175
+ bin: desktopBin,
176
+ });
122
177
  const config: AkanNativeConfig = {
123
178
  app: {
124
- id: target.appId,
179
+ id: resolveAppId(target.appId, platform),
125
180
  name: target.appName,
126
181
  fileName: target.fileName ?? NativeConfig.fileNameOf(path.basename(appPath)),
127
182
  version: target.version,
@@ -135,7 +190,7 @@ export class NativeConfig {
135
190
  {
136
191
  identifier: "app",
137
192
  description: `The plugins ${target.appName} ships, each with its default permissions`,
138
- permissions: plugins.map((plugin) => `${plugin}:default`),
193
+ permissions: pluginIds.map((id) => `${id}:default`),
139
194
  },
140
195
  ],
141
196
  ...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
@@ -143,20 +198,74 @@ export class NativeConfig {
143
198
  ? { deepLinks: NativeConfig.#deepLinks(target, locales) }
144
199
  : {}),
145
200
  ...(native ? { native } : {}),
201
+ ...(updates ? { updates } : {}),
202
+ ...(pushAndroid
203
+ ? {
204
+ push: {
205
+ android: { ...pushAndroid, ...(pushAndroid.smallIcon ? { smallIcon: abs(pushAndroid.smallIcon) } : {}) },
206
+ },
207
+ }
208
+ : {}),
209
+ ...(target.native?.privacy ? { privacy: target.native.privacy } : {}),
146
210
  //? assetlinks.json vouches for `<appId>.debug` outside main, the suffix a debug build installs under.
147
- android: { debugAppIdSuffix: ".debug", ...(googleServices ? { googleServices: abs(googleServices) } : {}) },
211
+ android: {
212
+ debugAppIdSuffix: ".debug",
213
+ ...(googleServices ? { googleServices: abs(googleServices) } : {}),
214
+ ...(target.native?.android?.autoplay ? { autoplay: true } : {}),
215
+ },
148
216
  keyboard: { resize: "none" },
149
- ...(target.assets?.icon ? { icon: abs(target.assets.icon) } : {}),
150
- ...(target.assets?.splash ? { splash: { image: abs(target.assets.splash) } } : {}),
217
+ ...(desktop ? { desktop } : {}),
218
+ ...(target.assets?.icon ? { icon: NativeConfig.#icon(target.assets.icon, abs) } : {}),
219
+ ...(target.assets?.splash ? { splash: NativeConfig.#splash(target.assets.splash, abs) } : {}),
151
220
  };
152
221
  return { config, warnings };
153
222
  }
154
223
 
224
+ static #icon(
225
+ icon: NonNullable<NonNullable<AkanMobileTargetConfig["assets"]>["icon"]>,
226
+ abs: (relative: string) => string,
227
+ ): NonNullable<AkanNativeConfig["icon"]> {
228
+ return typeof icon === "string" ? abs(icon) : { ...icon, image: abs(icon.image) };
229
+ }
230
+
231
+ static #splash(
232
+ splash: NonNullable<NonNullable<AkanMobileTargetConfig["assets"]>["splash"]>,
233
+ abs: (relative: string) => string,
234
+ ): NonNullable<AkanNativeConfig["splash"]> {
235
+ if (typeof splash === "string") return { image: abs(splash) };
236
+ return { ...splash, ...(splash.image ? { image: abs(splash.image) } : {}) };
237
+ }
238
+
239
+ //? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
240
+ static #updates(
241
+ target: AkanMobileTargetConfig,
242
+ env?: MobileEnv,
243
+ ): NonNullable<AkanNativeConfig["updates"]> | undefined {
244
+ const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
245
+ return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
246
+ }
247
+
155
248
  /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
156
249
  static fileNameOf(name: string) {
157
250
  return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
158
251
  }
159
252
 
253
+ //? A permission names a plugin by its id, and a plugin given by folder is named in its manifest; a spec the runtime
254
+ //? cannot read is left as it is for the runtime to report.
255
+ static #pluginId(appPath: string, spec: string) {
256
+ if (NativeConfig.#isId(spec)) return spec;
257
+ try {
258
+ const { id } = JSON.parse(readFileSync(path.join(path.resolve(appPath, spec), "native-plugin.json"), "utf8"));
259
+ return typeof id === "string" ? id : spec;
260
+ } catch {
261
+ return spec;
262
+ }
263
+ }
264
+
265
+ static #isId(spec: string) {
266
+ return !spec.startsWith(".") && !path.isAbsolute(spec);
267
+ }
268
+
160
269
  /** Drops empty arrays and objects, and answers undefined when nothing is left. */
161
270
  static #compact<T extends object>(value: { [key: string]: unknown }): T | undefined {
162
271
  const kept = Object.entries(value).filter(([, entry]) =>
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { App } from "../commandDecorators";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { NativePluginFolders } from "./nativePluginFolders";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-plugin-folders-");
9
+
10
+ const writePlugin = async (root: string, folder: string, id: unknown = folder) => {
11
+ const dir = path.join(root, "native", folder);
12
+ await mkdir(dir, { recursive: true });
13
+ await writeFile(path.join(dir, "native-plugin.json"), JSON.stringify({ id, apiVersion: 1, methods: [] }));
14
+ return dir;
15
+ };
16
+
17
+ const fakeApp = (root: string, libs: string[]) =>
18
+ ({
19
+ name: "board",
20
+ cwdPath: path.join(root, "apps", "board"),
21
+ workspace: { workspaceRoot: root },
22
+ getScanInfo: () => ({ getLibs: () => libs }),
23
+ }) as unknown as App;
24
+
25
+ describe("NativePluginFolders", () => {
26
+ test("finds each plugin folder in native/, by name", async () => {
27
+ const root = await makeTempRoot();
28
+ const kiosk = await writePlugin(root, "kiosk");
29
+ const audio = await writePlugin(root, "audio-output");
30
+ await writeFile(path.join(root, "native", "README.md"), "notes");
31
+
32
+ expect(await NativePluginFolders.in(root, "apps/board")).toEqual([
33
+ { id: "audio-output", dir: audio, owner: "apps/board" },
34
+ { id: "kiosk", dir: kiosk, owner: "apps/board" },
35
+ ]);
36
+ });
37
+
38
+ test("answers nothing for an app with no native folder", async () => {
39
+ expect(await NativePluginFolders.in(await makeTempRoot(), "apps/board")).toEqual([]);
40
+ });
41
+
42
+ test("refuses a folder without a manifest, or one named apart from its id", async () => {
43
+ const root = await makeTempRoot();
44
+ await mkdir(path.join(root, "native", "helpers"), { recursive: true });
45
+ await expect(NativePluginFolders.in(root, "apps/board")).rejects.toThrow(
46
+ "apps/board/native/helpers has no native-plugin.json",
47
+ );
48
+
49
+ const other = await makeTempRoot();
50
+ await writePlugin(other, "kiosk-tools", "kiosk");
51
+ await expect(NativePluginFolders.in(other, "libs/kiosk")).rejects.toThrow(
52
+ 'libs/kiosk/native/kiosk-tools/native-plugin.json has id "kiosk"; name the folder after its id.',
53
+ );
54
+ });
55
+
56
+ test("the app's own plugin wins an id, and two libs may not both hold one", () => {
57
+ const app = { id: "kiosk", dir: "/w/apps/board/native/kiosk", owner: "apps/board" };
58
+ const libKiosk = { id: "kiosk", dir: "/w/libs/signage/native/kiosk", owner: "libs/signage" };
59
+ const libAudio = { id: "audio", dir: "/w/libs/signage/native/audio", owner: "libs/signage" };
60
+
61
+ expect(NativePluginFolders.select([app], [libKiosk, libAudio])).toEqual([app, libAudio]);
62
+ expect(() =>
63
+ NativePluginFolders.select(
64
+ [],
65
+ [libKiosk, { ...libKiosk, dir: "/w/libs/venue/native/kiosk", owner: "libs/venue" }],
66
+ ),
67
+ ).toThrow('libs/signage and libs/venue both hold native plugin "kiosk"; give the app its own native/kiosk');
68
+ });
69
+
70
+ test("an app ships its own plugins and those of the libs it depends on, not of other libs", async () => {
71
+ const root = await makeTempRoot();
72
+ const own = await writePlugin(path.join(root, "apps", "board"), "kiosk");
73
+ const used = await writePlugin(path.join(root, "libs", "signage"), "audio");
74
+ await writePlugin(path.join(root, "libs", "unused"), "camera-rig");
75
+
76
+ expect(await NativePluginFolders.of(fakeApp(root, ["signage"]))).toEqual([
77
+ { id: "kiosk", dir: own, owner: "apps/board" },
78
+ { id: "audio", dir: used, owner: "libs/signage" },
79
+ ]);
80
+ });
81
+ });
@@ -0,0 +1,60 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { App } from "../commandDecorators";
4
+
5
+ export interface NativePluginFolder {
6
+ id: string;
7
+ /** Absolute: the native build takes a plugin that is not builtin by its folder. */
8
+ dir: string;
9
+ /** Whose `native/` holds it: `apps/<app>` or `libs/<lib>`. */
10
+ owner: string;
11
+ }
12
+
13
+ //* An app's and its libs' own native plugins, each in `native/<id>/` beside its `native-plugin.json`. Every mobile and
14
+ //* desktop target of the app ships them (the manifest says per platform what runs there); a lib's reach only the apps
15
+ //* that depend on it.
16
+ export class NativePluginFolders {
17
+ static readonly folder = "native";
18
+
19
+ static async in(root: string, owner: string): Promise<NativePluginFolder[]> {
20
+ const base = path.join(root, NativePluginFolders.folder);
21
+ const entries = await readdir(base, { withFileTypes: true }).catch(() => []);
22
+ const folders = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
23
+ return await Promise.all(
24
+ folders.sort().map(async (name) => {
25
+ const where = `${owner}/${NativePluginFolders.folder}/${name}`;
26
+ const manifest = Bun.file(path.join(base, name, "native-plugin.json"));
27
+ if (!(await manifest.exists()))
28
+ throw new Error(`${where} has no native-plugin.json; a folder in native/ is one native plugin.`);
29
+ const { id } = (await manifest.json()) as { id?: unknown };
30
+ if (id !== name)
31
+ throw new Error(`${where}/native-plugin.json has id ${JSON.stringify(id)}; name the folder after its id.`);
32
+ return { id, dir: path.join(base, name), owner };
33
+ }),
34
+ );
35
+ }
36
+
37
+ /** The app's own plugin wins an id; two libs may not both claim one. */
38
+ static select(own: NativePluginFolder[], fromLibs: NativePluginFolder[]): NativePluginFolder[] {
39
+ const chosen = new Map(own.map((plugin) => [plugin.id, plugin]));
40
+ for (const plugin of fromLibs) {
41
+ const held = chosen.get(plugin.id);
42
+ if (!held) chosen.set(plugin.id, plugin);
43
+ else if (held.owner.startsWith("libs/"))
44
+ throw new Error(
45
+ `${held.owner} and ${plugin.owner} both hold native plugin "${plugin.id}"; give the app its own native/${plugin.id} to pick one.`,
46
+ );
47
+ }
48
+ return [...chosen.values()];
49
+ }
50
+
51
+ static async of(app: App): Promise<NativePluginFolder[]> {
52
+ const scanInfo = app.getScanInfo({ allowEmpty: true }) ?? (await app.scan({ write: false }));
53
+ const root = app.workspace.workspaceRoot;
54
+ const [own, ...fromLibs] = await Promise.all([
55
+ NativePluginFolders.in(app.cwdPath, `apps/${app.name}`),
56
+ ...scanInfo.getLibs().map((lib) => NativePluginFolders.in(path.join(root, "libs", lib), `libs/${lib}`)),
57
+ ]);
58
+ return NativePluginFolders.select(own ?? [], fromLibs.flat());
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.26",
3
+ "version": "3.0.0-beta.28",
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.26",
50
+ "akanjs": "3.0.0-beta.28",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",