@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.
- package/akanApp/akanApp.host.ts +22 -2
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +212 -2
- package/akanConfig/akanConfig.ts +93 -26
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +7 -1
- package/applicationBuildRunner.test.ts +36 -2
- package/applicationBuildRunner.ts +21 -6
- package/biome.base.json +3 -1
- package/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrPageStack.e2e.test.ts +65 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +210 -6
- package/frontendBuild/csrDevModuleCompiler.ts +133 -19
- package/frontendBuild/csrDevPaths.ts +1 -0
- package/frontendBuild/csrDevResolver.ts +89 -9
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- package/frontendBuild/ssrDevBundler.ts +1 -1
- package/integration/devStability.integration.test.ts +35 -0
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +129 -0
- package/mobile/desktopServerStage.ts +120 -0
- package/mobile/index.ts +3 -0
- package/mobile/mobileTarget.test.ts +50 -1
- package/mobile/mobileTarget.ts +20 -4
- package/mobile/nativeApp.test.ts +37 -1
- package/mobile/nativeApp.ts +176 -27
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +215 -4
- package/mobile/nativeConfig.ts +117 -8
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +100 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- 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
|
-
|
|
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({
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -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 {
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
211
|
+
android: {
|
|
212
|
+
debugAppIdSuffix: ".debug",
|
|
213
|
+
...(googleServices ? { googleServices: abs(googleServices) } : {}),
|
|
214
|
+
...(target.native?.android?.autoplay ? { autoplay: true } : {}),
|
|
215
|
+
},
|
|
148
216
|
keyboard: { resize: "none" },
|
|
149
|
-
...(
|
|
150
|
-
...(target.assets?.
|
|
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.
|
|
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.
|
|
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",
|