@akanjs/devkit 3.0.0-beta.20 → 3.0.0-beta.22

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 (44) hide show
  1. package/README.ko.md +2 -2
  2. package/README.md +2 -2
  3. package/akanConfig/akanConfig.test.ts +58 -38
  4. package/akanConfig/akanConfig.ts +88 -48
  5. package/akanConfig/types.ts +3 -3
  6. package/akanContext.ts +5 -2
  7. package/artifact/implicitRootLayout.test.ts +23 -0
  8. package/artifact/implicitRootLayout.ts +24 -0
  9. package/biome.base.json +26 -0
  10. package/commandDecorators/command.ts +1 -1
  11. package/executors.ts +1 -12
  12. package/incrementalBuilder/buildBatch.proc.ts +7 -1
  13. package/index.ts +0 -2
  14. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  15. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  16. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  17. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  18. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  19. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  20. package/lint/no-document-cookie.grit +29 -0
  21. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  22. package/lint/no-web-storage.grit +30 -0
  23. package/mobile/index.ts +6 -0
  24. package/mobile/mobileTarget.ts +2 -0
  25. package/mobile/nativeApi.test.ts +30 -0
  26. package/mobile/nativeApi.ts +40 -0
  27. package/mobile/nativeApp.test.ts +85 -0
  28. package/mobile/nativeApp.ts +210 -0
  29. package/mobile/nativeConfig.test.ts +200 -0
  30. package/mobile/nativeConfig.ts +190 -0
  31. package/mobile/nativeWebDir.test.ts +80 -0
  32. package/mobile/nativeWebDir.ts +62 -0
  33. package/mobile/placeholderAppId.test.ts +13 -0
  34. package/mobile/placeholderAppId.ts +16 -0
  35. package/mobile/usageDescriptions.test.ts +27 -0
  36. package/mobile/usageDescriptions.ts +24 -0
  37. package/package.json +2 -7
  38. package/qualityScanner.ts +4 -2
  39. package/scanInfo.ts +5 -10
  40. package/workspaceLayout.test.ts +13 -3
  41. package/workspaceLayout.ts +16 -4
  42. package/applicationReleasePackager.ts +0 -189
  43. package/capacitorApp.test.ts +0 -388
  44. package/capacitorApp.ts +0 -1520
@@ -0,0 +1,200 @@
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 { AkanMobileTargetConfig } from "../akanConfig";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { NativeApi } from "./nativeApi";
7
+ import { NativeConfig } from "./nativeConfig";
8
+
9
+ const makeTempRoot = tempDirs("akan-native-config-");
10
+ const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
11
+
12
+ const minimalTarget: AkanMobileTargetConfig = {
13
+ name: "default",
14
+ indexPath: "/explore",
15
+ appName: "minimal",
16
+ appId: "com.minimal.dev.app",
17
+ version: "0.0.1",
18
+ buildNum: 1,
19
+ permissions: ["push"],
20
+ native: { android: { googleServices: "secrets/google-services.json" } },
21
+ deepLinks: {
22
+ schemes: ["minimal"],
23
+ domains: ["example.com"],
24
+ ios: { teamId: "TEAMID" },
25
+ android: { sha256CertFingerprints: ["00:11"] },
26
+ },
27
+ };
28
+
29
+ const adminTarget: AkanMobileTargetConfig = {
30
+ name: "admin",
31
+ basePath: "admin",
32
+ appName: "Portal Admin",
33
+ appId: "com.portal.admin",
34
+ fileName: "portal-admin",
35
+ version: "2.1.0",
36
+ buildNum: 42,
37
+ assets: { icon: "assets/icon.png", splash: "assets/splash.png" },
38
+ permissions: ["camera", "location", "contacts", "speech"],
39
+ deepLinks: { domains: ["portal.example"] },
40
+ files: { "android/res/raw/chime.mp3": "assets/chime.mp3" },
41
+ native: {
42
+ plugins: ["iap"],
43
+ ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
44
+ android: { manifest: ['<queries><package android:name="com.kakao.talk" /></queries>'] },
45
+ },
46
+ };
47
+
48
+ describe("NativeConfig.build", () => {
49
+ test("turns the minimal app's push target into the runtime's config", () => {
50
+ const { config, warnings } = NativeConfig.build({
51
+ appPath: "/repo/apps/minimal",
52
+ target: minimalTarget,
53
+ webDir: "/repo/apps/minimal/.akan/mobile/default/web",
54
+ contributions: [{ permission: "push", plugins: ["push"] }],
55
+ locales: ["en", "ko"],
56
+ });
57
+
58
+ expect(warnings).toEqual([]);
59
+ expect(config).toEqual({
60
+ app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
61
+ web: { dir: "/repo/apps/minimal/.akan/mobile/default/web" },
62
+ plugins: [...NativeConfig.basePlugins, "push"],
63
+ capabilities: [
64
+ {
65
+ identifier: "app",
66
+ description: "The plugins minimal ships, each with its default permissions",
67
+ permissions: [...NativeConfig.basePlugins, "push"].map((plugin) => `${plugin}:default`),
68
+ },
69
+ ],
70
+ deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
71
+ android: {
72
+ debugAppIdSuffix: ".debug",
73
+ googleServices: path.resolve("/repo/apps/minimal", "secrets/google-services.json"),
74
+ },
75
+ keyboard: { resize: "none" },
76
+ });
77
+ });
78
+
79
+ test("falls back to the builtin permission plugins, names the app in their texts, and lists basePath links per locale", () => {
80
+ const { config, warnings } = NativeConfig.build({
81
+ appPath: "/repo/apps/portal",
82
+ target: adminTarget,
83
+ webDir: "/repo/apps/portal/.akan/mobile/admin/web",
84
+ contributions: [],
85
+ locales: ["en", "ko"],
86
+ });
87
+
88
+ expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
89
+ expect(config).toEqual({
90
+ app: { id: "com.portal.admin", name: "Portal Admin", fileName: "portal-admin", version: "2.1.0", build: 42 },
91
+ web: { dir: "/repo/apps/portal/.akan/mobile/admin/web" },
92
+ plugins: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"],
93
+ capabilities: [
94
+ {
95
+ identifier: "app",
96
+ description: "The plugins Portal Admin ships, each with its default permissions",
97
+ permissions: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"].map(
98
+ (plugin) => `${plugin}:default`,
99
+ ),
100
+ },
101
+ ],
102
+ usageDescriptions: {
103
+ NSCameraUsageDescription: "Portal Admin requires access to the camera to take photos.",
104
+ NSPhotoLibraryAddUsageDescription: "Portal Admin requires access to the photo library to take photos.",
105
+ NSPhotoLibraryUsageDescription: "Portal Admin requires access to the photo library to take photos.",
106
+ NSLocationAlwaysAndWhenInUseUsageDescription:
107
+ "Portal Admin requires access to the location to get the user's location.",
108
+ NSLocationAlwaysUsageDescription: "Portal Admin requires access to the location to get the user's location.",
109
+ NSLocationWhenInUseUsageDescription: "Portal Admin requires access to the location to get the user's location.",
110
+ NSContactsUsageDescription: "Portal Admin requires access to the contacts to find people you know.",
111
+ },
112
+ deepLinks: { domains: [{ host: "portal.example", pathPrefixes: ["/en/admin", "/ko/admin"] }] },
113
+ native: {
114
+ ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
115
+ android: { manifest: ['<queries><package android:name="com.kakao.talk" /></queries>'] },
116
+ resources: [{ from: path.resolve("/repo/apps/portal", "assets/chime.mp3"), to: "android/res/raw/chime.mp3" }],
117
+ },
118
+ android: { debugAppIdSuffix: ".debug" },
119
+ keyboard: { resize: "none" },
120
+ icon: path.resolve("/repo/apps/portal", "assets/icon.png"),
121
+ splash: { image: path.resolve("/repo/apps/portal", "assets/splash.png") },
122
+ });
123
+ });
124
+
125
+ test("a plugin that claims a permission replaces the builtin one and adds its Android entries", () => {
126
+ const { config } = NativeConfig.build({
127
+ appPath: "/repo/apps/portal",
128
+ target: { ...adminTarget, permissions: ["camera"], native: undefined, files: undefined, assets: undefined },
129
+ webDir: "/web",
130
+ contributions: [
131
+ {
132
+ permission: "camera",
133
+ plugins: ["camera"],
134
+ usageDescriptions: { cameraUsageDescription: "Scan receipts with $(PRODUCT_NAME)." },
135
+ androidPermissions: ["CAMERA"],
136
+ androidFeatures: ["android.hardware.camera"],
137
+ },
138
+ { permission: "push", plugins: ["push"] },
139
+ ],
140
+ locales: ["en"],
141
+ });
142
+
143
+ expect(config.plugins).toEqual([...NativeConfig.basePlugins, "camera"]);
144
+ expect(config.usageDescriptions).toEqual({ NSCameraUsageDescription: "Scan receipts with Portal Admin." });
145
+ expect(config.native).toEqual({
146
+ android: {
147
+ manifest: [
148
+ '<uses-permission android:name="android.permission.CAMERA" />',
149
+ '<uses-feature android:name="android.hardware.camera" android:required="false" />',
150
+ ],
151
+ },
152
+ });
153
+ });
154
+
155
+ test("the runtime accepts both configs as they are", async () => {
156
+ const root = await makeTempRoot();
157
+ for (const file of ["secrets/google-services.json", "assets/chime.mp3"])
158
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true }).then(() =>
159
+ writeFile(path.join(root, file), file.endsWith(".json") ? googleServicesJson : "x"),
160
+ );
161
+ for (const image of ["assets/icon.png", "assets/splash.png"])
162
+ await writeFile(path.join(root, image), Buffer.from(onePixelPng, "base64"));
163
+ await mkdir(path.join(root, "web"), { recursive: true });
164
+ await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
165
+ const api = await NativeApi.load(repoApp);
166
+
167
+ for (const target of [minimalTarget, adminTarget]) {
168
+ const { config } = NativeConfig.build({
169
+ appPath: root,
170
+ target,
171
+ webDir: path.join(root, "web"),
172
+ contributions: [],
173
+ locales: ["en", "ko"],
174
+ });
175
+ expect(api.validateConfig(config, { appDir: root })).toEqual([]);
176
+ }
177
+ });
178
+
179
+ test("keeps a file name the runtime accepts", () => {
180
+ expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
181
+ expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
182
+ expect(NativeConfig.fileNameOf("...")).toBe("app");
183
+ });
184
+ });
185
+
186
+ const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
187
+
188
+ const googleServicesJson = JSON.stringify({
189
+ project_info: { project_number: "1", project_id: "demo", storage_bucket: "demo.appspot.com" },
190
+ client: [
191
+ {
192
+ client_info: { mobilesdk_app_id: "1:1:android:1", android_client_info: { package_name: "com.minimal.dev.app" } },
193
+ api_key: [{ current_key: "demo-key" }],
194
+ },
195
+ {
196
+ client_info: { mobilesdk_app_id: "1:1:android:2", android_client_info: { package_name: "com.portal.admin" } },
197
+ api_key: [{ current_key: "demo-key" }],
198
+ },
199
+ ],
200
+ });
@@ -0,0 +1,190 @@
1
+ import path from "node:path";
2
+ import type { AkanNativeConfig } from "@akanjs/native/config";
3
+ import type { AkanMobileTargetConfig, AkanNativeValue, AkanPluginNativeConfig, MobilePermission } from "akanjs";
4
+ import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
5
+
6
+ export interface NativeConfigInput {
7
+ /** The app folder; every relative path in the target resolves against it. */
8
+ appPath: string;
9
+ target: AkanMobileTargetConfig;
10
+ /** The assembled web root the app loads. */
11
+ webDir: string;
12
+ /** What the app's and its libs' plugins declare. */
13
+ contributions: AkanPluginNativeConfig[];
14
+ locales: readonly string[];
15
+ }
16
+
17
+ export interface NativeConfigResult {
18
+ config: AkanNativeConfig;
19
+ warnings: string[];
20
+ }
21
+
22
+ type NativeIos = NonNullable<NonNullable<AkanNativeConfig["native"]>["ios"]>;
23
+
24
+ export class NativeConfig {
25
+ //* What every akanjs page may call: routing, lifecycle, links, storage, the device facts the runtime reads, and the
26
+ //* system sheet useCamera asks camera-or-library with.
27
+ static readonly basePlugins = [
28
+ "app",
29
+ "app-state",
30
+ "browser",
31
+ "opener",
32
+ "auth-session",
33
+ "device",
34
+ "dialog",
35
+ "keyboard",
36
+ "preferences",
37
+ "secure-storage",
38
+ "haptics",
39
+ ] as const;
40
+ //* Used when no plugin of the app or its libs claims the permission; speech has no native plugin yet.
41
+ static readonly builtinContributions: { [permission in MobilePermission]: AkanPluginNativeConfig } = {
42
+ camera: {
43
+ permission: "camera",
44
+ plugins: ["camera"],
45
+ usageDescriptions: {
46
+ cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
47
+ photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
48
+ photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
49
+ },
50
+ },
51
+ contacts: {
52
+ permission: "contacts",
53
+ plugins: ["contacts"],
54
+ usageDescriptions: {
55
+ contactsUsageDescription: "$(PRODUCT_NAME) requires access to the contacts to find people you know.",
56
+ },
57
+ },
58
+ location: {
59
+ permission: "location",
60
+ plugins: ["geolocation"],
61
+ usageDescriptions: {
62
+ locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
63
+ locationWhenInUseUsageDescription:
64
+ "$(PRODUCT_NAME) requires access to the location to get the user's location.",
65
+ },
66
+ },
67
+ push: { permission: "push", plugins: ["push"] },
68
+ speech: { permission: "speech" },
69
+ };
70
+
71
+ static build({ appPath, target, webDir, contributions, locales }: NativeConfigInput): NativeConfigResult {
72
+ const warnings: string[] = [];
73
+ const applied = (target.permissions ?? []).flatMap((permission) => {
74
+ const claimed = contributions.filter((contribution) => contribution.permission === permission);
75
+ const used = claimed.length ? claimed : [NativeConfig.builtinContributions[permission]];
76
+ if (!used.some((contribution) => contribution.plugins?.length))
77
+ warnings.push(`Permission '${permission}' has no native plugin yet; the app ships without it.`);
78
+ return used;
79
+ });
80
+ const abs = (relative: string) => path.resolve(appPath, relative);
81
+ const plugins = [
82
+ ...new Set([
83
+ ...NativeConfig.basePlugins,
84
+ ...applied.flatMap((contribution) => contribution.plugins ?? []),
85
+ ...(target.native?.plugins ?? []),
86
+ ]),
87
+ ];
88
+ const usageDescriptions = Object.fromEntries(
89
+ Object.entries(
90
+ toIosInfoPlistUsageDescriptions(
91
+ Object.assign({}, ...applied.map((contribution) => contribution.usageDescriptions ?? {})),
92
+ ),
93
+ ).map(([key, text]) => [key, text.replaceAll("$(PRODUCT_NAME)", target.appName)]),
94
+ );
95
+ const ios = NativeConfig.#compact<NativeIos>({
96
+ infoPlist: NativeConfig.#merged(
97
+ applied.map((c) => c.infoPlist),
98
+ target.native?.ios?.infoPlist,
99
+ ),
100
+ entitlements: NativeConfig.#merged(
101
+ applied.map((c) => c.entitlements),
102
+ target.native?.ios?.entitlements,
103
+ ),
104
+ });
105
+ const manifest = [
106
+ ...[...new Set(applied.flatMap((c) => c.androidPermissions ?? []))].map(
107
+ (name) => `<uses-permission android:name="android.permission.${name}" />`,
108
+ ),
109
+ ...[...new Set(applied.flatMap((c) => c.androidFeatures ?? []))].map(
110
+ (name) => `<uses-feature android:name="${name}" android:required="false" />`,
111
+ ),
112
+ ...(target.native?.android?.manifest ?? []),
113
+ ];
114
+ const android = NativeConfig.#compact({
115
+ manifest,
116
+ application: target.native?.android?.application ?? [],
117
+ activity: target.native?.android?.activity ?? [],
118
+ });
119
+ const resources = Object.entries(target.files ?? {}).map(([to, from]) => ({ from: abs(from), to }));
120
+ const native = NativeConfig.#compact({ ios, android, resources });
121
+ const googleServices = target.native?.android?.googleServices;
122
+ const config: AkanNativeConfig = {
123
+ app: {
124
+ id: target.appId,
125
+ name: target.appName,
126
+ fileName: target.fileName ?? NativeConfig.fileNameOf(path.basename(appPath)),
127
+ version: target.version,
128
+ build: target.buildNum,
129
+ },
130
+ web: { dir: webDir },
131
+ plugins,
132
+ //? PL-11: named, not implied: every plugin with its own default permissions, which is what a build without
133
+ //? capabilities grants too, so a release states what it allows.
134
+ capabilities: [
135
+ {
136
+ identifier: "app",
137
+ description: `The plugins ${target.appName} ships, each with its default permissions`,
138
+ permissions: plugins.map((plugin) => `${plugin}:default`),
139
+ },
140
+ ],
141
+ ...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
142
+ ...(target.deepLinks?.schemes?.length || target.deepLinks?.domains?.length
143
+ ? { deepLinks: NativeConfig.#deepLinks(target, locales) }
144
+ : {}),
145
+ ...(native ? { native } : {}),
146
+ //? assetlinks.json vouches for `<appId>.debug` outside main, the suffix a debug build installs under.
147
+ android: { debugAppIdSuffix: ".debug", ...(googleServices ? { googleServices: abs(googleServices) } : {}) },
148
+ keyboard: { resize: "none" },
149
+ ...(target.assets?.icon ? { icon: abs(target.assets.icon) } : {}),
150
+ ...(target.assets?.splash ? { splash: { image: abs(target.assets.splash) } } : {}),
151
+ };
152
+ return { config, warnings };
153
+ }
154
+
155
+ /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
156
+ static fileNameOf(name: string) {
157
+ return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
158
+ }
159
+
160
+ /** Drops empty arrays and objects, and answers undefined when nothing is left. */
161
+ static #compact<T extends object>(value: { [key: string]: unknown }): T | undefined {
162
+ const kept = Object.entries(value).filter(([, entry]) =>
163
+ Array.isArray(entry)
164
+ ? entry.length > 0
165
+ : entry && typeof entry === "object"
166
+ ? Object.keys(entry).length > 0
167
+ : entry !== undefined,
168
+ );
169
+ return kept.length ? (Object.fromEntries(kept) as T) : undefined;
170
+ }
171
+
172
+ static #merged(
173
+ contributed: (Record<string, AkanNativeValue> | undefined)[],
174
+ own: Record<string, AkanNativeValue> | undefined,
175
+ ): Record<string, AkanNativeValue> {
176
+ return Object.assign({}, ...contributed, own);
177
+ }
178
+
179
+ //* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
180
+ static #deepLinks(target: AkanMobileTargetConfig, locales: readonly string[]) {
181
+ const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
182
+ const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
183
+ return {
184
+ ...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
185
+ ...(target.deepLinks?.domains?.length
186
+ ? { domains: target.deepLinks.domains.map((host) => (pathPrefixes ? { host, pathPrefixes } : host)) }
187
+ : {}),
188
+ };
189
+ }
190
+ }
@@ -0,0 +1,80 @@
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 { AkanMobileTargetConfig } from "../akanConfig";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { NativeWebDir } from "./nativeWebDir";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-web-");
9
+
10
+ const target: AkanMobileTargetConfig = {
11
+ name: "admin",
12
+ basePath: "/admin/",
13
+ indexPath: "/home",
14
+ appName: "Portal",
15
+ appId: "com.portal.admin",
16
+ version: "1.0.0",
17
+ buildNum: 1,
18
+ };
19
+
20
+ const write = async (file: string, content: string) => {
21
+ await mkdir(path.dirname(file), { recursive: true });
22
+ await writeFile(file, content);
23
+ };
24
+
25
+ describe("NativeWebDir", () => {
26
+ test("injects the target global once, first in <head>, even when the inlined bundle names it", () => {
27
+ const bundle = '<script type="module">if(!window.__AKAN_MOBILE_TARGET__)boot("</head>")</script>';
28
+ const html = NativeWebDir.injectTarget(`<html><HEAD lang="en">${bundle}</head><body></body></html>`, target);
29
+
30
+ expect(html).toBe(
31
+ `<html><HEAD lang="en">\n<script>window.__AKAN_MOBILE_TARGET__={"name":"admin","basePath":"admin","indexPath":"/home"};</script>${bundle}</head><body></body></html>`,
32
+ );
33
+ expect(NativeWebDir.injectTarget(html, target)).toBe(html);
34
+ });
35
+
36
+ test("assembles the page, the pruned public folder and the fonts, from scratch every time", async () => {
37
+ const root = await makeTempRoot();
38
+ await write(path.join(root, "dist/csr/admin.html"), "<html><head></head><body>app</body></html>");
39
+ await write(path.join(root, "dist/public/images/logo.png"), "png");
40
+ await write(path.join(root, "dist/public/index.html"), "stale page from public");
41
+ await write(path.join(root, "dist/.akan/artifact/fonts/pretendard/400.woff2"), "font");
42
+ const web = new NativeWebDir(path.join(root, "web"));
43
+ await write(path.join(root, "web/leftover.txt"), "from the last build");
44
+
45
+ const files = await web.assemble(target, {
46
+ html: path.join(root, "dist/csr/admin.html"),
47
+ publicDir: path.join(root, "dist/public"),
48
+ fontsDir: path.join(root, "dist/.akan/artifact/fonts"),
49
+ });
50
+
51
+ expect(files).toEqual(["_akan/fonts/pretendard/400.woff2", "images/logo.png", "index.html"]);
52
+ expect(await Bun.file(path.join(root, "web/index.html")).text()).toContain("window.__AKAN_MOBILE_TARGET__");
53
+ });
54
+
55
+ test("builds without a public folder or fonts", async () => {
56
+ const root = await makeTempRoot();
57
+ await write(path.join(root, "index.html"), "<html><head></head></html>");
58
+
59
+ const files = await new NativeWebDir(path.join(root, "web")).assemble(target, {
60
+ html: path.join(root, "index.html"),
61
+ publicDir: path.join(root, "missing-public"),
62
+ });
63
+
64
+ expect(files).toEqual(["index.html"]);
65
+ });
66
+
67
+ test("refuses a missing bundle and a public folder that takes the runtime's reserved path", async () => {
68
+ const root = await makeTempRoot();
69
+ const web = new NativeWebDir(path.join(root, "web"));
70
+ await expect(web.assemble(target, { html: path.join(root, "none.html") })).rejects.toThrow(
71
+ "CSR html for mobile target 'admin' not found",
72
+ );
73
+
74
+ await write(path.join(root, "index.html"), "<html><head></head></html>");
75
+ await write(path.join(root, "public/__akan_native/x.js"), "x");
76
+ await expect(
77
+ web.assemble(target, { html: path.join(root, "index.html"), publicDir: path.join(root, "public") }),
78
+ ).rejects.toThrow("public/__akan_native is reserved for the native runtime");
79
+ });
80
+ });
@@ -0,0 +1,62 @@
1
+ import { cp, mkdir, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AkanMobileTargetConfig } from "akanjs";
4
+
5
+ export interface NativeWebDirSources {
6
+ /** The target's CSR bundle: one HTML file with its scripts and styles inlined. */
7
+ html: string;
8
+ /** The build's own copy of `public/`, already pruned of fonts nothing reads. */
9
+ publicDir?: string;
10
+ /** Subset fonts, served from `/_akan/fonts` as the page's `<FontFace>` names them. */
11
+ fontsDir?: string;
12
+ }
13
+
14
+ export class NativeWebDir {
15
+ //* The runtime serves its bridge from this prefix and refuses a web root that has one of its own.
16
+ static readonly reservedEntry = "__akan_native";
17
+
18
+ constructor(readonly dir: string) {}
19
+
20
+ //* The bundle is inlined into this page and its own code names the global, so only the tag marks an injection;
21
+ //* it goes first in <head>, ahead of every script, because the page reads it as it boots.
22
+ static injectTarget(html: string, target: Pick<AkanMobileTargetConfig, "name" | "basePath" | "indexPath">) {
23
+ if (html.includes(NativeWebDir.#targetTag)) return html;
24
+ const basePath = target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
25
+ const script = `${NativeWebDir.#targetTag}${JSON.stringify({
26
+ name: target.name,
27
+ basePath,
28
+ indexPath: target.indexPath,
29
+ })};</script>`;
30
+ const head = /<head(?:\s[^>]*)?>/i.exec(html);
31
+ if (!head) return `${script}\n${html}`;
32
+ const at = head.index + head[0].length;
33
+ return `${html.slice(0, at)}\n${script}${html.slice(at)}`;
34
+ }
35
+ static readonly #targetTag = "<script>window.__AKAN_MOBILE_TARGET__=";
36
+
37
+ /** Rebuilds the folder from scratch and answers its files as web paths: relative, `/`-separated and sorted. */
38
+ async assemble(target: AkanMobileTargetConfig, { html, publicDir, fontsDir }: NativeWebDirSources) {
39
+ if (!(await Bun.file(html).exists()))
40
+ throw new Error(`CSR html for mobile target '${target.name}' not found: ${html}`);
41
+ await rm(this.dir, { recursive: true, force: true });
42
+ await mkdir(this.dir, { recursive: true });
43
+ if (publicDir && (await NativeWebDir.#isDir(publicDir))) await cp(publicDir, this.dir, { recursive: true });
44
+ if (fontsDir && (await NativeWebDir.#isDir(fontsDir)))
45
+ await cp(fontsDir, path.join(this.dir, "_akan", "fonts"), { recursive: true });
46
+ if (await NativeWebDir.#isDir(path.join(this.dir, NativeWebDir.reservedEntry)))
47
+ throw new Error(`public/${NativeWebDir.reservedEntry} is reserved for the native runtime.`);
48
+ await Bun.write(path.join(this.dir, "index.html"), NativeWebDir.injectTarget(await Bun.file(html).text(), target));
49
+ const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: this.dir, onlyFiles: true, dot: true }));
50
+ //? Bun.Glob answers with the platform's separator, so a Windows scan reads `_akan\fonts\…`.
51
+ return files.map((file) => file.split(path.sep).join("/")).sort();
52
+ }
53
+
54
+ static async #isDir(dir: string) {
55
+ try {
56
+ return (await stat(dir)).isDirectory();
57
+ } catch {
58
+ // A missing folder is a source the build did not produce, not an error.
59
+ return false;
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isPlaceholderAppId } from "./placeholderAppId";
3
+
4
+ describe("isPlaceholderAppId", () => {
5
+ test("flags placeholder bundle identifiers that Apple's portal already claims", () => {
6
+ for (const appId of ["com.myapp.app", "com.myorg.myapp", "com.example.foo", "com.changeme.app", ""]) {
7
+ expect(isPlaceholderAppId(appId)).toBe(true);
8
+ }
9
+ for (const appId of ["com.minimal.app", "com.nearthlab.leadingflight", "io.akanjs.demo"]) {
10
+ expect(isPlaceholderAppId(appId)).toBe(false);
11
+ }
12
+ });
13
+ });
@@ -0,0 +1,16 @@
1
+ // Placeholder bundle IDs are already claimed on Apple's portal, so device signing fails with "cannot be registered
2
+ // to your development team".
3
+ export const PLACEHOLDER_APP_IDS = [
4
+ "com.myapp.app",
5
+ "com.myorg.myapp",
6
+ "com.example.app",
7
+ "com.example.myapp",
8
+ ] as const;
9
+ const placeholderAppIdSegment = /^(example|examples|myorg|myapp|mycompany|myorganization|changeme|todo|sample|test)$/;
10
+
11
+ export const isPlaceholderAppId = (appId: string | null | undefined): boolean => {
12
+ const normalized = appId?.trim().toLowerCase() ?? "";
13
+ if (!normalized) return true;
14
+ if ((PLACEHOLDER_APP_IDS as readonly string[]).includes(normalized)) return true;
15
+ return normalized.split(".").some((segment) => placeholderAppIdSegment.test(segment));
16
+ };
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
3
+
4
+ describe("toIosInfoPlistUsageDescriptions", () => {
5
+ test("writes Apple's real Info.plist keys, not NS + the description name", () => {
6
+ expect(
7
+ toIosInfoPlistUsageDescriptions({
8
+ cameraUsageDescription: "camera",
9
+ photoAddUsageDescription: "add",
10
+ photoUsageDescription: "read",
11
+ locationAlwaysUsageDescription: "always",
12
+ }),
13
+ ).toEqual({
14
+ NSCameraUsageDescription: "camera",
15
+ NSPhotoLibraryAddUsageDescription: "add",
16
+ NSPhotoLibraryUsageDescription: "read",
17
+ NSLocationAlwaysAndWhenInUseUsageDescription: "always",
18
+ NSLocationAlwaysUsageDescription: "always",
19
+ });
20
+ });
21
+
22
+ test("keeps the NS prefix convention for a key it does not know", () => {
23
+ expect(toIosInfoPlistUsageDescriptions({ bluetoothAlwaysUsageDescription: "ble" })).toEqual({
24
+ NSBluetoothAlwaysUsageDescription: "ble",
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,24 @@
1
+ import { capitalize } from "akanjs/common";
2
+
3
+ //* Apple's Info.plist keys do not follow the description names (photo → NSPhotoLibrary…), so each one is spelled out.
4
+ const iosUsageDescriptionKeys = {
5
+ cameraUsageDescription: ["NSCameraUsageDescription"],
6
+ photoAddUsageDescription: ["NSPhotoLibraryAddUsageDescription"],
7
+ photoUsageDescription: ["NSPhotoLibraryUsageDescription"],
8
+ contactsUsageDescription: ["NSContactsUsageDescription"],
9
+ //? iOS 11+ reads the AlwaysAndWhenInUse key; the Always key only serves iOS 10 and below.
10
+ locationAlwaysUsageDescription: ["NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationAlwaysUsageDescription"],
11
+ locationWhenInUseUsageDescription: ["NSLocationWhenInUseUsageDescription"],
12
+ microphoneUsageDescription: ["NSMicrophoneUsageDescription"],
13
+ speechRecognitionUsageDescription: ["NSSpeechRecognitionUsageDescription"],
14
+ } as const;
15
+
16
+ export const toIosInfoPlistUsageDescriptions = (descriptions: { [key: string]: string }) =>
17
+ Object.fromEntries(
18
+ Object.entries(descriptions).flatMap(([key, value]) => {
19
+ const plistKeys: readonly string[] = Object.hasOwn(iosUsageDescriptionKeys, key)
20
+ ? iosUsageDescriptionKeys[key as keyof typeof iosUsageDescriptionKeys]
21
+ : [`NS${capitalize(key)}`];
22
+ return plistKeys.map((plistKey) => [plistKey, value]);
23
+ }),
24
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.20",
3
+ "version": "3.0.0-beta.22",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,8 +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
- "@trapezedev/project": "^7.1.4",
51
- "akanjs": "3.0.0-beta.20",
50
+ "akanjs": "3.0.0-beta.22",
52
51
  "chalk": "^5.6.2",
53
52
  "commander": "^14.0.3",
54
53
  "dayjs": "^1.11.20",
@@ -64,13 +63,9 @@
64
63
  "typescript": "^6.0.3"
65
64
  },
66
65
  "peerDependencies": {
67
- "@capacitor/cli": "^8.3.4",
68
66
  "react": "19.2.7"
69
67
  },
70
68
  "peerDependenciesMeta": {
71
- "@capacitor/cli": {
72
- "optional": true
73
- },
74
69
  "react": {
75
70
  "optional": true
76
71
  }
package/qualityScanner.ts CHANGED
@@ -7,7 +7,7 @@ import ts from "typescript";
7
7
  import { AbstractDoc } from "./abstractDoc";
8
8
  import { FormSetterScanner } from "./formSetterScanner";
9
9
  import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
10
- import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
10
+ import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles, rootEntryHintOf } from "./workspaceLayout";
11
11
 
12
12
  type QualitySeverity = "warning";
13
13
  type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
@@ -425,7 +425,9 @@ export class AkanQualityScanner {
425
425
  `akan.layout.${type}-root-${kind}`,
426
426
  "layout",
427
427
  sourceFile.file,
428
- `Unexpected ${type} root ${kind} "${name}". Keep ${type} code in conventional ${type} folders.`,
428
+ `Unexpected ${type} root ${kind} "${name}". ${
429
+ rootEntryHintOf(type, name) ?? `Keep ${type} code in conventional ${type} folders.`
430
+ }`,
429
431
  ),
430
432
  );
431
433
  }