@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,294 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { AkanNativeConfig } from "@akanjs/native/config";
4
+ import type { AkanNativeTarget, AkanNativeValue, AkanPluginNativeConfig, NativeEnv, NativePermission } from "akanjs";
5
+ import type { DesktopServerBundle } from "./desktopServerStage";
6
+ import { type NativePlatform, resolveAppId } from "./mobileTarget";
7
+ import type { NativePluginFolder } from "./nativePluginFolders";
8
+ import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
9
+
10
+ export interface NativeConfigInput {
11
+ /** The app folder; every relative path in the target resolves against it. */
12
+ appPath: string;
13
+ target: AkanNativeTarget;
14
+ /** The assembled web root the app loads. */
15
+ webDir: string;
16
+ /** What the app's and its libs' plugins declare. */
17
+ contributions: AkanPluginNativeConfig[];
18
+ locales: readonly string[];
19
+ /** The backend the binary talks to; an updates channel left unnamed follows it. */
20
+ env?: NativeEnv;
21
+ platform: NativePlatform;
22
+ /** A desktop build that carries its server (the target's `desktop.server`). */
23
+ desktopServer?: DesktopServerBundle;
24
+ /** The plugins in the app's and its libs' `native/` folders. */
25
+ nativePlugins?: NativePluginFolder[];
26
+ /** A desktop build's staged `bin` executables (DesktopBin). */
27
+ desktopBin?: string;
28
+ }
29
+
30
+ export interface NativeConfigResult {
31
+ config: AkanNativeConfig;
32
+ warnings: string[];
33
+ }
34
+
35
+ type NativeIos = NonNullable<NonNullable<AkanNativeConfig["native"]>["ios"]>;
36
+
37
+ export class NativeConfig {
38
+ //* What every akanjs page may call: routing, lifecycle, links, storage, the device facts the runtime reads, and the
39
+ //* system sheet useCamera asks camera-or-library with.
40
+ static readonly basePlugins = [
41
+ "app",
42
+ "app-state",
43
+ "browser",
44
+ "opener",
45
+ "auth-session",
46
+ "device",
47
+ "dialog",
48
+ "keyboard",
49
+ "preferences",
50
+ "secure-storage",
51
+ "haptics",
52
+ ] as const;
53
+ //* Used when no plugin of the app or its libs claims the permission; speech has no native plugin yet.
54
+ static readonly builtinContributions: { [permission in NativePermission]: AkanPluginNativeConfig } = {
55
+ camera: {
56
+ permission: "camera",
57
+ plugins: ["camera"],
58
+ usageDescriptions: {
59
+ cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
60
+ photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
61
+ photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
62
+ },
63
+ },
64
+ contacts: {
65
+ permission: "contacts",
66
+ plugins: ["contacts"],
67
+ usageDescriptions: {
68
+ contactsUsageDescription: "$(PRODUCT_NAME) requires access to the contacts to find people you know.",
69
+ },
70
+ },
71
+ location: {
72
+ permission: "location",
73
+ plugins: ["geolocation"],
74
+ usageDescriptions: {
75
+ locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
76
+ locationWhenInUseUsageDescription:
77
+ "$(PRODUCT_NAME) requires access to the location to get the user's location.",
78
+ },
79
+ },
80
+ push: { permission: "push", plugins: ["push"] },
81
+ speech: { permission: "speech" },
82
+ };
83
+
84
+ static build({
85
+ appPath,
86
+ target,
87
+ webDir,
88
+ contributions,
89
+ locales,
90
+ env,
91
+ platform,
92
+ desktopServer,
93
+ nativePlugins = [],
94
+ desktopBin,
95
+ }: NativeConfigInput): NativeConfigResult {
96
+ const warnings: string[] = [];
97
+ const applied = (target.permissions ?? []).flatMap((permission) => {
98
+ const claimed = contributions.filter((contribution) => contribution.permission === permission);
99
+ const used = claimed.length ? claimed : [NativeConfig.builtinContributions[permission]];
100
+ if (!used.some((contribution) => contribution.plugins?.length))
101
+ warnings.push(`Permission '${permission}' has no native plugin yet; the app ships without it.`);
102
+ return used;
103
+ });
104
+ const abs = (relative: string) => path.resolve(appPath, relative);
105
+ const updates = NativeConfig.#updates(target, env);
106
+ const folders = new Set(nativePlugins.map((plugin) => path.resolve(plugin.dir)));
107
+ const folderIds = new Set(nativePlugins.map((plugin) => plugin.id));
108
+ //? A `native/<id>` folder ships by itself; listed again, by path or by id, it would reach the runtime twice.
109
+ const listed = (target.plugins ?? []).filter((spec) =>
110
+ NativeConfig.#isId(spec) ? !folderIds.has(spec) : !folders.has(path.resolve(appPath, spec)),
111
+ );
112
+ const plugins = [
113
+ ...new Set([
114
+ ...NativeConfig.basePlugins,
115
+ //? Windows and Linux open a link by starting the app again; without the hand-over it runs as a second app and
116
+ //? the instance whose auth-session start() waits for the callback never hears it.
117
+ ...(target.deepLinks?.schemes?.length ? ["single-instance"] : []),
118
+ ...applied.flatMap((contribution) => contribution.plugins ?? []),
119
+ //? A second copy of the app would start a second server on the same data.
120
+ ...(desktopServer ? ["single-instance"] : []),
121
+ ...(updates ? ["updates"] : []),
122
+ ...listed,
123
+ ...nativePlugins.map((plugin) => plugin.dir),
124
+ ]),
125
+ ];
126
+ const pluginIds = plugins.map(
127
+ (spec) => nativePlugins.find((plugin) => plugin.dir === spec)?.id ?? NativeConfig.#pluginId(appPath, spec),
128
+ );
129
+ const usageDescriptions = Object.fromEntries(
130
+ Object.entries(
131
+ toIosInfoPlistUsageDescriptions(
132
+ Object.assign({}, ...applied.map((contribution) => contribution.usageDescriptions ?? {})),
133
+ ),
134
+ ).map(([key, text]) => [key, text.replaceAll("$(PRODUCT_NAME)", target.appName)]),
135
+ );
136
+ const ios = NativeConfig.#compact<NativeIos>({
137
+ infoPlist: NativeConfig.#merged(
138
+ applied.map((c) => c.infoPlist),
139
+ target.ios?.infoPlist,
140
+ ),
141
+ entitlements: NativeConfig.#merged(
142
+ applied.map((c) => c.entitlements),
143
+ target.ios?.entitlements,
144
+ ),
145
+ });
146
+ const manifest = [
147
+ ...[...new Set(applied.flatMap((c) => c.androidPermissions ?? []))].map(
148
+ (name) => `<uses-permission android:name="android.permission.${name}" />`,
149
+ ),
150
+ ...[...new Set(applied.flatMap((c) => c.androidFeatures ?? []))].map(
151
+ (name) => `<uses-feature android:name="${name}" android:required="false" />`,
152
+ ),
153
+ ...(target.android?.manifest ?? []),
154
+ ];
155
+ const android = NativeConfig.#compact({
156
+ manifest,
157
+ application: target.android?.application ?? [],
158
+ activity: target.android?.activity ?? [],
159
+ });
160
+ const resources = [
161
+ ...Object.entries(target.ios?.files ?? {}).map(([to, from]) => ({ from: abs(from), to: `ios/${to}` })),
162
+ ...Object.entries(target.android?.files ?? {}).map(([to, from]) => ({ from: abs(from), to: `android/${to}` })),
163
+ ];
164
+ const macos = NativeConfig.#compact({ entitlements: target.desktop?.entitlements });
165
+ const native = NativeConfig.#compact({ ios, macos, android, resources });
166
+ const googleServices = target.android?.googleServices;
167
+ const pushAndroid = target.android?.push;
168
+ const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
169
+ recovery: target.desktop?.recovery,
170
+ screenCapture: target.desktop?.screenCapture,
171
+ window: NativeConfig.#compact({ ...target.desktop?.window }),
172
+ server: desktopServer,
173
+ bin: desktopBin,
174
+ });
175
+ const config: AkanNativeConfig = {
176
+ app: {
177
+ id: resolveAppId(target.appId, platform),
178
+ name: target.appName,
179
+ fileName: target.fileName ?? NativeConfig.fileNameOf(path.basename(appPath)),
180
+ version: target.version,
181
+ build: target.buildNum,
182
+ },
183
+ web: { dir: webDir },
184
+ plugins,
185
+ //? PL-11: named, not implied: every plugin with its own default permissions, which is what a build without
186
+ //? capabilities grants too, so a release states what it allows.
187
+ capabilities: [
188
+ {
189
+ identifier: "app",
190
+ description: `The plugins ${target.appName} ships, each with its default permissions`,
191
+ permissions: pluginIds.map((id) => `${id}:default`),
192
+ },
193
+ ],
194
+ ...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
195
+ ...(target.deepLinks?.schemes?.length || target.deepLinks?.domains?.length
196
+ ? { deepLinks: NativeConfig.#deepLinks(target, locales) }
197
+ : {}),
198
+ ...(native ? { native } : {}),
199
+ ...(updates ? { updates } : {}),
200
+ ...(pushAndroid
201
+ ? {
202
+ push: {
203
+ android: { ...pushAndroid, ...(pushAndroid.smallIcon ? { smallIcon: abs(pushAndroid.smallIcon) } : {}) },
204
+ },
205
+ }
206
+ : {}),
207
+ ...(target.ios?.privacy ? { privacy: target.ios.privacy } : {}),
208
+ //? assetlinks.json vouches for `<appId>.debug` outside main, the suffix a debug build installs under.
209
+ android: {
210
+ debugAppIdSuffix: ".debug",
211
+ ...(googleServices ? { googleServices: abs(googleServices) } : {}),
212
+ ...(target.android?.autoplay ? { autoplay: true } : {}),
213
+ },
214
+ keyboard: { resize: "none" },
215
+ ...(desktop ? { desktop } : {}),
216
+ ...(target.icon ? { icon: NativeConfig.#icon(target.icon, abs) } : {}),
217
+ ...(target.splash ? { splash: NativeConfig.#splash(target.splash, abs) } : {}),
218
+ };
219
+ return { config, warnings };
220
+ }
221
+
222
+ static #icon(
223
+ icon: NonNullable<AkanNativeTarget["icon"]>,
224
+ abs: (relative: string) => string,
225
+ ): NonNullable<AkanNativeConfig["icon"]> {
226
+ return typeof icon === "string" ? abs(icon) : { ...icon, image: abs(icon.image) };
227
+ }
228
+
229
+ static #splash(
230
+ splash: NonNullable<AkanNativeTarget["splash"]>,
231
+ abs: (relative: string) => string,
232
+ ): NonNullable<AkanNativeConfig["splash"]> {
233
+ if (typeof splash === "string") return { image: abs(splash) };
234
+ return { ...splash, ...(splash.image ? { image: abs(splash.image) } : {}) };
235
+ }
236
+
237
+ //? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
238
+ static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
239
+ const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
240
+ return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
241
+ }
242
+
243
+ /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
244
+ static fileNameOf(name: string) {
245
+ return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
246
+ }
247
+
248
+ //? A permission names a plugin by its id, and a plugin given by folder is named in its manifest; a spec the runtime
249
+ //? cannot read is left as it is for the runtime to report.
250
+ static #pluginId(appPath: string, spec: string) {
251
+ if (NativeConfig.#isId(spec)) return spec;
252
+ try {
253
+ const { id } = JSON.parse(readFileSync(path.join(path.resolve(appPath, spec), "native-plugin.json"), "utf8"));
254
+ return typeof id === "string" ? id : spec;
255
+ } catch {
256
+ return spec;
257
+ }
258
+ }
259
+
260
+ static #isId(spec: string) {
261
+ return !spec.startsWith(".") && !path.isAbsolute(spec);
262
+ }
263
+
264
+ /** Drops empty arrays and objects, and answers undefined when nothing is left. */
265
+ static #compact<T extends object>(value: { [key: string]: unknown }): T | undefined {
266
+ const kept = Object.entries(value).filter(([, entry]) =>
267
+ Array.isArray(entry)
268
+ ? entry.length > 0
269
+ : entry && typeof entry === "object"
270
+ ? Object.keys(entry).length > 0
271
+ : entry !== undefined,
272
+ );
273
+ return kept.length ? (Object.fromEntries(kept) as T) : undefined;
274
+ }
275
+
276
+ static #merged(
277
+ contributed: (Record<string, AkanNativeValue> | undefined)[],
278
+ own: Record<string, AkanNativeValue> | undefined,
279
+ ): Record<string, AkanNativeValue> {
280
+ return Object.assign({}, ...contributed, own);
281
+ }
282
+
283
+ //* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
284
+ static #deepLinks(target: AkanNativeTarget, locales: readonly string[]) {
285
+ const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
286
+ const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
287
+ return {
288
+ ...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
289
+ ...(target.deepLinks?.domains?.length
290
+ ? { domains: target.deepLinks.domains.map((host) => (pathPrefixes ? { host, pathPrefixes } : host)) }
291
+ : {}),
292
+ };
293
+ }
294
+ }
@@ -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
+ }
@@ -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 { AkanNativeTarget } from "../akanConfig";
5
+ import { tempDirs } from "../testHelpers";
6
+ import { NativeWebDir } from "./nativeWebDir";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-web-");
9
+
10
+ const target: AkanNativeTarget = {
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 native 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,66 @@
1
+ import { cp, mkdir, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { AkanNativeTarget } 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<AkanNativeTarget, "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
+ async clear() {
38
+ await rm(this.dir, { recursive: true, force: true });
39
+ await mkdir(this.dir, { recursive: true });
40
+ }
41
+
42
+ /** Rebuilds the folder from scratch and answers its files as web paths: relative, `/`-separated and sorted. */
43
+ async assemble(target: AkanNativeTarget, { html, publicDir, fontsDir }: NativeWebDirSources) {
44
+ if (!(await Bun.file(html).exists()))
45
+ throw new Error(`CSR html for native target '${target.name}' not found: ${html}`);
46
+ await this.clear();
47
+ if (publicDir && (await NativeWebDir.#isDir(publicDir))) await cp(publicDir, this.dir, { recursive: true });
48
+ if (fontsDir && (await NativeWebDir.#isDir(fontsDir)))
49
+ await cp(fontsDir, path.join(this.dir, "_akan", "fonts"), { recursive: true });
50
+ if (await NativeWebDir.#isDir(path.join(this.dir, NativeWebDir.reservedEntry)))
51
+ throw new Error(`public/${NativeWebDir.reservedEntry} is reserved for the native runtime.`);
52
+ await Bun.write(path.join(this.dir, "index.html"), NativeWebDir.injectTarget(await Bun.file(html).text(), target));
53
+ const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: this.dir, onlyFiles: true, dot: true }));
54
+ //? Bun.Glob answers with the platform's separator, so a Windows scan reads `_akan\fonts\…`.
55
+ return files.map((file) => file.split(path.sep).join("/")).sort();
56
+ }
57
+
58
+ static async #isDir(dir: string) {
59
+ try {
60
+ return (await stat(dir)).isDirectory();
61
+ } catch {
62
+ // A missing folder is a source the build did not produce, not an error.
63
+ return false;
64
+ }
65
+ }
66
+ }
@@ -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
+ );