@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.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.
- package/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +105 -0
- package/akanConfig/akanConfig.ts +21 -1
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +53 -0
- package/applicationBuildRunner.ts +7 -4
- package/artifact/implicitRootLayout.ts +4 -2
- package/biome.base.json +4 -1
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +11 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +43 -0
- package/codeAgent/skills/akan-code/SKILL.md +38 -11
- package/codeAgent/skills/akan-debug/SKILL.md +41 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +39 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -5
- package/executors.ts +59 -4
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- package/integration/ssrMemoryProbe.ts +6 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/linter.test.ts +12 -0
- package/linter.ts +35 -12
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/mobile/desktopRelease.test.ts +1 -1
- package/mobile/desktopRelease.ts +3 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +4 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +23 -82
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/sshHostKeys.ts +59 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +160 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +533 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +82 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/sourceAst.ts +10 -48
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { generateKeyPairSync } from "node:crypto";
|
|
3
3
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { buildCsp, cspHash, parseCsp } from "../../native/packages/cli/src/lib/csp";
|
|
6
|
+
import { rewriteDevHtml } from "../../native/packages/cli/src/lib/hmr";
|
|
5
7
|
import type { AkanNativeTarget } from "../akanConfig";
|
|
6
8
|
import { tempDirs } from "../testHelpers";
|
|
7
9
|
import { NativeApi } from "./nativeApi";
|
|
@@ -43,6 +45,19 @@ const adminTarget: AkanNativeTarget = {
|
|
|
43
45
|
},
|
|
44
46
|
};
|
|
45
47
|
|
|
48
|
+
const cloudEnv = { AKAN_PUBLIC_APP_NAME: "minimal", AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "example.com" };
|
|
49
|
+
const policy = (extra: Record<string, string[]> = {}) =>
|
|
50
|
+
Object.fromEntries(
|
|
51
|
+
Object.entries(NativeConfig.defaultCsp).map(([directive, sources]) => [
|
|
52
|
+
directive,
|
|
53
|
+
[...sources, ...(extra[directive] ?? [])],
|
|
54
|
+
]),
|
|
55
|
+
);
|
|
56
|
+
const permissionOf = (id: string) =>
|
|
57
|
+
id === "opener"
|
|
58
|
+
? { identifier: "opener:default", allow: NativeConfig.defaultOpenUrls.map((url) => ({ url })) }
|
|
59
|
+
: `${id}:default`;
|
|
60
|
+
|
|
46
61
|
const ed25519Key = () =>
|
|
47
62
|
generateKeyPairSync("ed25519").publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
|
|
48
63
|
const updatesKey = ed25519Key();
|
|
@@ -102,6 +117,7 @@ describe("NativeConfig.build", () => {
|
|
|
102
117
|
contributions: [{ permission: "push", plugins: ["push"] }],
|
|
103
118
|
locales: ["en", "ko"],
|
|
104
119
|
platform: "android",
|
|
120
|
+
publicEnv: cloudEnv,
|
|
105
121
|
});
|
|
106
122
|
|
|
107
123
|
expect(warnings).toEqual([]);
|
|
@@ -113,9 +129,10 @@ describe("NativeConfig.build", () => {
|
|
|
113
129
|
{
|
|
114
130
|
identifier: "app",
|
|
115
131
|
description: "The plugins minimal ships, each with its default permissions",
|
|
116
|
-
permissions: [...NativeConfig.basePlugins, "single-instance", "push"].map(
|
|
132
|
+
permissions: [...NativeConfig.basePlugins, "single-instance", "push"].map(permissionOf),
|
|
117
133
|
},
|
|
118
134
|
],
|
|
135
|
+
security: { csp: policy() },
|
|
119
136
|
deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
|
|
120
137
|
android: {
|
|
121
138
|
debugAppIdSuffix: ".debug",
|
|
@@ -144,11 +161,10 @@ describe("NativeConfig.build", () => {
|
|
|
144
161
|
{
|
|
145
162
|
identifier: "app",
|
|
146
163
|
description: "The plugins Portal Admin ships, each with its default permissions",
|
|
147
|
-
permissions: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"].map(
|
|
148
|
-
(plugin) => `${plugin}:default`,
|
|
149
|
-
),
|
|
164
|
+
permissions: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"].map(permissionOf),
|
|
150
165
|
},
|
|
151
166
|
],
|
|
167
|
+
security: { csp: policy() },
|
|
152
168
|
usageDescriptions: {
|
|
153
169
|
NSCameraUsageDescription: "Portal Admin requires access to the camera to take photos.",
|
|
154
170
|
NSPhotoLibraryAddUsageDescription: "Portal Admin requires access to the photo library to take photos.",
|
|
@@ -172,6 +188,22 @@ describe("NativeConfig.build", () => {
|
|
|
172
188
|
});
|
|
173
189
|
});
|
|
174
190
|
|
|
191
|
+
test('lists a basePath link with no locale segment for the default locale under localePrefix "as-needed"', () => {
|
|
192
|
+
const { config } = NativeConfig.build({
|
|
193
|
+
appPath: "/repo/apps/portal",
|
|
194
|
+
target: adminTarget,
|
|
195
|
+
webDir: "/web",
|
|
196
|
+
contributions: [],
|
|
197
|
+
locales: ["en", "ko"],
|
|
198
|
+
i18n: { defaultLocale: "en", localePrefix: "as-needed" },
|
|
199
|
+
platform: "android",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(config.deepLinks).toEqual({
|
|
203
|
+
domains: [{ host: "portal.example", pathPrefixes: ["/admin", "/en/admin", "/ko/admin"] }],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
175
207
|
test("a plugin that claims a permission replaces the builtin one and adds its Android entries", () => {
|
|
176
208
|
const { config } = NativeConfig.build({
|
|
177
209
|
appPath: "/repo/apps/portal",
|
|
@@ -362,7 +394,7 @@ describe("NativeConfig.build", () => {
|
|
|
362
394
|
|
|
363
395
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "./vendor/led-panel", kiosk]);
|
|
364
396
|
expect(config.capabilities?.[0]?.permissions).toEqual(
|
|
365
|
-
[...NativeConfig.basePlugins, "led-panel", "kiosk"].map(
|
|
397
|
+
[...NativeConfig.basePlugins, "led-panel", "kiosk"].map(permissionOf),
|
|
366
398
|
);
|
|
367
399
|
await mkdir(path.join(root, "web"), { recursive: true });
|
|
368
400
|
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
@@ -493,6 +525,178 @@ describe("NativeConfig.build", () => {
|
|
|
493
525
|
expect(build()).toEqual({ ...updates, target });
|
|
494
526
|
});
|
|
495
527
|
|
|
528
|
+
describe("the page's content security policy", () => {
|
|
529
|
+
const build = (
|
|
530
|
+
target: Partial<AkanNativeTarget> = {},
|
|
531
|
+
more: Partial<Parameters<typeof NativeConfig.build>[0]> = {},
|
|
532
|
+
) =>
|
|
533
|
+
NativeConfig.build({
|
|
534
|
+
appPath: "/repo/apps/minimal",
|
|
535
|
+
target: { ...minimalTarget, ...target },
|
|
536
|
+
webDir: "/web",
|
|
537
|
+
contributions: [],
|
|
538
|
+
locales: ["en"],
|
|
539
|
+
platform: "ios",
|
|
540
|
+
publicEnv: cloudEnv,
|
|
541
|
+
...more,
|
|
542
|
+
}).config.security?.csp as Record<string, string[]> | string | undefined;
|
|
543
|
+
|
|
544
|
+
test("lets a local build reach the dev server on any port, and a carried server on loopback", () => {
|
|
545
|
+
const local = build({}, { publicEnv: { AKAN_PUBLIC_ENV: "local" } }) as Record<string, string[]>;
|
|
546
|
+
expect(local["connect-src"]).toEqual([
|
|
547
|
+
...(NativeConfig.defaultCsp["connect-src"] ?? []),
|
|
548
|
+
"http://localhost:*",
|
|
549
|
+
"ws://localhost:*",
|
|
550
|
+
]);
|
|
551
|
+
expect(local["script-src"]).toEqual(["'self'", "'wasm-unsafe-eval'"]);
|
|
552
|
+
const desktopServer = { dir: "/srv", entry: "main.js", env: {} };
|
|
553
|
+
const carried = build({}, { platform: "macos", desktopServer }) as Record<string, string[]>;
|
|
554
|
+
expect(carried["connect-src"]).toContain("http://127.0.0.1:*");
|
|
555
|
+
expect(carried["connect-src"]).toContain("ws://127.0.0.1:*");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a dev page under the default policy reaches the dev gateway's socket, on this computer or over the LAN", () => {
|
|
559
|
+
const csp = build({}, { publicEnv: { AKAN_PUBLIC_ENV: "local" } });
|
|
560
|
+
const devPolicy = (wsOrigin?: string) => {
|
|
561
|
+
const { html } = rewriteDevHtml(
|
|
562
|
+
'<!doctype html><html><head><meta charset="utf-8"></head><body><script type="module" src="/_akan/client/main.js"></script></body></html>',
|
|
563
|
+
{
|
|
564
|
+
platform: "ios",
|
|
565
|
+
port: 4321,
|
|
566
|
+
hmrPath: "/_akan/hmr",
|
|
567
|
+
wsPaths: ["/ws"],
|
|
568
|
+
...(wsOrigin ? { wsOrigin } : {}),
|
|
569
|
+
csp,
|
|
570
|
+
},
|
|
571
|
+
);
|
|
572
|
+
const content = /http-equiv="Content-Security-Policy" content="([^"]*)"/.exec(html)?.[1] ?? "";
|
|
573
|
+
return parseCsp(content.replace(/"/g, '"').replace(/&/g, "&"));
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const loopback = devPolicy();
|
|
577
|
+
expect(loopback.get("connect-src")).toEqual(
|
|
578
|
+
expect.arrayContaining(["'self'", "http://localhost:*", "ws://localhost:*", "ws://127.0.0.1:4321"]),
|
|
579
|
+
);
|
|
580
|
+
expect(loopback.get("script-src")).toEqual(expect.arrayContaining(["'self'", "blob:"]));
|
|
581
|
+
const lan = devPolicy("ws://192.168.0.12:4321");
|
|
582
|
+
expect(lan.get("connect-src")).toEqual(
|
|
583
|
+
expect.arrayContaining(["'self'", "ws://192.168.0.12:4321", "http://192.168.0.12:4321"]),
|
|
584
|
+
);
|
|
585
|
+
expect(lan.get("object-src")).toEqual(["'none'"]);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("reaches any https or wss origin and loads https images, media, fonts and stylesheets, while scripts stay the app's", () => {
|
|
589
|
+
const csp = build() as Record<string, string[]>;
|
|
590
|
+
expect(csp["connect-src"]).toEqual(["'self'", "blob:", "data:", "https:", "wss:"]);
|
|
591
|
+
for (const directive of ["img-src", "media-src", "font-src"]) expect(csp[directive]).toContain("https:");
|
|
592
|
+
expect(csp["style-src"]).toEqual(["'self'", "'unsafe-inline'", "https:"]);
|
|
593
|
+
expect(csp["script-src"]).toEqual(["'self'", "'wasm-unsafe-eval'"]);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("names a pinned AKAN_PUBLIC_SERVER_URL only when it is plain http", () => {
|
|
597
|
+
const connect = (url: string) =>
|
|
598
|
+
(build({}, { publicEnv: { ...cloudEnv, AKAN_PUBLIC_SERVER_URL: url } }) as Record<string, string[]>)[
|
|
599
|
+
"connect-src"
|
|
600
|
+
];
|
|
601
|
+
expect(connect("https://api.example.com/x")).toEqual(["'self'", "blob:", "data:", "https:", "wss:"]);
|
|
602
|
+
expect(connect("http://192.168.0.5:8080/api")).toEqual([
|
|
603
|
+
"'self'",
|
|
604
|
+
"blob:",
|
|
605
|
+
"data:",
|
|
606
|
+
"https:",
|
|
607
|
+
"wss:",
|
|
608
|
+
"http://192.168.0.5:8080",
|
|
609
|
+
"ws://192.168.0.5:8080",
|
|
610
|
+
]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("a directive map adds to the default, a string replaces it, and false ships none", () => {
|
|
614
|
+
const added = build({
|
|
615
|
+
csp: { "script-src": "https://sdk.example.com", "object-src": ["https://embed.example.com"] },
|
|
616
|
+
}) as Record<string, string[]>;
|
|
617
|
+
expect(added["script-src"]).toEqual(["'self'", "'wasm-unsafe-eval'", "https://sdk.example.com"]);
|
|
618
|
+
expect(added["object-src"]).toEqual(["https://embed.example.com"]);
|
|
619
|
+
expect(build({ csp: "strict" })).toBe("strict");
|
|
620
|
+
expect(build({ csp: false })).toBeUndefined();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("a directive the default lacks starts from its CSP fallback, so the bundle and init.js still run", () => {
|
|
624
|
+
const csp = build({
|
|
625
|
+
csp: {
|
|
626
|
+
"script-src": "https://sdk.example.com",
|
|
627
|
+
"script-src-elem": ["https://cdn.example.com"],
|
|
628
|
+
"style-src-attr": "https://styles.example.com",
|
|
629
|
+
"manifest-src": ["https://manifest.example.com"],
|
|
630
|
+
"upgrade-insecure-requests": [],
|
|
631
|
+
},
|
|
632
|
+
}) as Record<string, string[]>;
|
|
633
|
+
expect(csp["script-src-elem"]).toEqual([
|
|
634
|
+
"'self'",
|
|
635
|
+
"'wasm-unsafe-eval'",
|
|
636
|
+
"https://sdk.example.com",
|
|
637
|
+
"https://cdn.example.com",
|
|
638
|
+
]);
|
|
639
|
+
expect(csp["style-src-attr"]).toEqual(["'self'", "'unsafe-inline'", "https:", "https://styles.example.com"]);
|
|
640
|
+
expect(csp["manifest-src"]).toEqual(["'self'", "https://manifest.example.com"]);
|
|
641
|
+
expect(csp["upgrade-insecure-requests"]).toEqual([]);
|
|
642
|
+
|
|
643
|
+
const page =
|
|
644
|
+
'<html><head><meta charset="utf-8"><script src="/__akan_native/init.js"></script></head><body><script type="module">boot()</script></body></html>';
|
|
645
|
+
expect(parseCsp(buildCsp(csp, page).policy).get("script-src-elem")).toEqual(
|
|
646
|
+
expect.arrayContaining(["'self'", cspHash("boot()")]),
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("directive names ignore case, and a default-src entry reaches every fetch directive but object-src", () => {
|
|
651
|
+
const csp = build({
|
|
652
|
+
csp: { "Connect-Src": ["https://upload.example.com"], "default-src": "https://cdn.example.com" },
|
|
653
|
+
}) as Record<string, string[]>;
|
|
654
|
+
expect(Object.keys(csp)).not.toContain("Connect-Src");
|
|
655
|
+
expect(csp["connect-src"]).toEqual([
|
|
656
|
+
"'self'",
|
|
657
|
+
"blob:",
|
|
658
|
+
"data:",
|
|
659
|
+
"https:",
|
|
660
|
+
"wss:",
|
|
661
|
+
"https://cdn.example.com",
|
|
662
|
+
"https://upload.example.com",
|
|
663
|
+
]);
|
|
664
|
+
expect(csp["default-src"]).toEqual(["'self'", "https://cdn.example.com"]);
|
|
665
|
+
for (const directive of ["script-src", "style-src", "img-src", "font-src", "worker-src", "frame-src"])
|
|
666
|
+
expect(csp[directive]).toContain("https://cdn.example.com");
|
|
667
|
+
expect(csp["object-src"]).toEqual(["'none'"]);
|
|
668
|
+
expect(csp["base-uri"]).toEqual(["'self'"]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("refuses an empty policy string and names csp: false", () => {
|
|
672
|
+
expect(() => build({ csp: "" })).toThrow("`csp: false`");
|
|
673
|
+
expect(() => build({ csp: " " })).toThrow("`csp: false`");
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("opener opens https, mailto and tel links unless the app names its own", () => {
|
|
678
|
+
const opener = (openUrls?: string[]) =>
|
|
679
|
+
NativeConfig.build({
|
|
680
|
+
appPath: "/repo/apps/minimal",
|
|
681
|
+
target: { ...minimalTarget, ...(openUrls ? { openUrls } : {}) },
|
|
682
|
+
webDir: "/web",
|
|
683
|
+
contributions: [],
|
|
684
|
+
locales: ["en"],
|
|
685
|
+
platform: "android",
|
|
686
|
+
publicEnv: cloudEnv,
|
|
687
|
+
}).config.capabilities?.[0]?.permissions.find((permission) => JSON.stringify(permission).includes("opener"));
|
|
688
|
+
|
|
689
|
+
expect(opener()).toEqual({
|
|
690
|
+
identifier: "opener:default",
|
|
691
|
+
allow: [{ url: "https://*:*" }, { url: "mailto:*" }, { url: "tel:*" }],
|
|
692
|
+
});
|
|
693
|
+
expect(opener(["https://*.example.com/*"])).toEqual({
|
|
694
|
+
identifier: "opener:default",
|
|
695
|
+
allow: [{ url: "https://*.example.com/*" }],
|
|
696
|
+
});
|
|
697
|
+
expect(opener(["*"])).toBe("opener:default");
|
|
698
|
+
});
|
|
699
|
+
|
|
496
700
|
test("keeps a file name the runtime accepts", () => {
|
|
497
701
|
expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
|
|
498
702
|
expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
NativeEnv,
|
|
9
9
|
NativePermission,
|
|
10
10
|
} from "akanjs";
|
|
11
|
-
import { trimSlashes } from "akanjs/common";
|
|
11
|
+
import { type AkanI18nConfig, LocalePath, trimSlashes } from "akanjs/common";
|
|
12
12
|
import type { AkanNativeConfig } from "akanjs/native/config";
|
|
13
13
|
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
14
14
|
import { type NativePlatform, resolveAppId } from "./mobileTarget";
|
|
@@ -24,6 +24,7 @@ export interface NativeConfigInput {
|
|
|
24
24
|
/** What the app's and its libs' plugins declare. */
|
|
25
25
|
contributions: AkanPluginNativeConfig[];
|
|
26
26
|
locales: readonly string[];
|
|
27
|
+
i18n?: Pick<AkanI18nConfig, "defaultLocale" | "localePrefix">;
|
|
27
28
|
/** The backend the binary talks to; an updates channel left unnamed follows it. */
|
|
28
29
|
env?: NativeEnv;
|
|
29
30
|
platform: NativePlatform;
|
|
@@ -33,6 +34,8 @@ export interface NativeConfigInput {
|
|
|
33
34
|
nativePlugins?: NativePluginFolder[];
|
|
34
35
|
/** A desktop build's staged `bin` executables (DesktopBin). */
|
|
35
36
|
desktopBin?: string;
|
|
37
|
+
/** The `AKAN_PUBLIC_*` the web build inlined, derived keys included. */
|
|
38
|
+
publicEnv?: Record<string, string>;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export interface NativeConfigResult {
|
|
@@ -58,6 +61,42 @@ export class NativeConfig {
|
|
|
58
61
|
"secure-storage",
|
|
59
62
|
"haptics",
|
|
60
63
|
] as const;
|
|
64
|
+
//? Only scripts stay strict ('self', inline ones by hash): injected script must not reach the native bridge.
|
|
65
|
+
static readonly defaultCsp: Readonly<Record<string, readonly string[]>> = {
|
|
66
|
+
"default-src": ["'self'"],
|
|
67
|
+
"script-src": ["'self'", "'wasm-unsafe-eval'"],
|
|
68
|
+
"style-src": ["'self'", "'unsafe-inline'", "https:"],
|
|
69
|
+
"img-src": ["'self'", "blob:", "data:", "https:"],
|
|
70
|
+
"media-src": ["'self'", "blob:", "data:", "https:"],
|
|
71
|
+
"font-src": ["'self'", "data:", "https:"],
|
|
72
|
+
"connect-src": ["'self'", "blob:", "data:", "https:", "wss:"],
|
|
73
|
+
"worker-src": ["'self'", "blob:"],
|
|
74
|
+
"frame-src": ["'self'", "https:"],
|
|
75
|
+
"object-src": ["'none'"],
|
|
76
|
+
"base-uri": ["'self'"],
|
|
77
|
+
"form-action": ["'self'"],
|
|
78
|
+
};
|
|
79
|
+
static readonly #fetchDirectives: readonly string[] = [
|
|
80
|
+
"child-src",
|
|
81
|
+
"connect-src",
|
|
82
|
+
"fenced-frame-src",
|
|
83
|
+
"font-src",
|
|
84
|
+
"frame-src",
|
|
85
|
+
"img-src",
|
|
86
|
+
"manifest-src",
|
|
87
|
+
"media-src",
|
|
88
|
+
"object-src",
|
|
89
|
+
"prefetch-src",
|
|
90
|
+
"script-src",
|
|
91
|
+
"script-src-elem",
|
|
92
|
+
"script-src-attr",
|
|
93
|
+
"style-src",
|
|
94
|
+
"style-src-elem",
|
|
95
|
+
"style-src-attr",
|
|
96
|
+
"worker-src",
|
|
97
|
+
];
|
|
98
|
+
//? Also bounds the links the shell hands to the OS; on mobile, openExternalUrl sends http(s) to the browser plugin.
|
|
99
|
+
static readonly defaultOpenUrls: readonly string[] = ["https://*:*", "mailto:*", "tel:*"];
|
|
61
100
|
//* Used when no plugin of the app or its libs claims the permission; speech has no native plugin yet.
|
|
62
101
|
static readonly builtinContributions: { [permission in NativePermission]: AkanPluginNativeConfig } = {
|
|
63
102
|
camera: {
|
|
@@ -95,11 +134,13 @@ export class NativeConfig {
|
|
|
95
134
|
webDir,
|
|
96
135
|
contributions,
|
|
97
136
|
locales,
|
|
137
|
+
i18n,
|
|
98
138
|
env,
|
|
99
139
|
platform,
|
|
100
140
|
desktopServer,
|
|
101
141
|
nativePlugins = [],
|
|
102
142
|
desktopBin,
|
|
143
|
+
publicEnv = {},
|
|
103
144
|
}: NativeConfigInput): NativeConfigResult {
|
|
104
145
|
const warnings: string[] = [];
|
|
105
146
|
const applied = (target.permissions ?? []).flatMap((permission) => {
|
|
@@ -189,6 +230,8 @@ export class NativeConfig {
|
|
|
189
230
|
backgroundColorDark: typeof background === "string" ? undefined : background?.dark,
|
|
190
231
|
devtools: target.devtools ? true : undefined,
|
|
191
232
|
});
|
|
233
|
+
const csp = NativeConfig.#csp(target, NativeConfig.#plainOrigins(publicEnv, env, !!desktopServer));
|
|
234
|
+
const openUrls = target.openUrls ?? NativeConfig.defaultOpenUrls;
|
|
192
235
|
const config: AkanNativeConfig = {
|
|
193
236
|
app: {
|
|
194
237
|
id: resolveAppId(target.appId, platform),
|
|
@@ -205,12 +248,17 @@ export class NativeConfig {
|
|
|
205
248
|
{
|
|
206
249
|
identifier: "app",
|
|
207
250
|
description: `The plugins ${target.appName} ships, each with its default permissions`,
|
|
208
|
-
permissions: pluginIds.map((id) =>
|
|
251
|
+
permissions: pluginIds.map((id) =>
|
|
252
|
+
id === "opener" && !openUrls.includes("*")
|
|
253
|
+
? { identifier: "opener:default", allow: openUrls.map((url) => ({ url })) }
|
|
254
|
+
: `${id}:default`,
|
|
255
|
+
),
|
|
209
256
|
},
|
|
210
257
|
],
|
|
258
|
+
...(csp ? { security: { csp } } : {}),
|
|
211
259
|
...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
|
|
212
260
|
...(target.deepLinks?.schemes?.length || target.deepLinks?.domains?.length
|
|
213
|
-
? { deepLinks: NativeConfig.#deepLinks(target, locales) }
|
|
261
|
+
? { deepLinks: NativeConfig.#deepLinks(target, locales, i18n) }
|
|
214
262
|
: {}),
|
|
215
263
|
...(native ? { native } : {}),
|
|
216
264
|
...(updates ? { updates } : {}),
|
|
@@ -237,6 +285,69 @@ export class NativeConfig {
|
|
|
237
285
|
return { config, warnings };
|
|
238
286
|
}
|
|
239
287
|
|
|
288
|
+
static #csp({ name, csp: setting }: AkanNativeTarget, plainOrigins: string[]) {
|
|
289
|
+
if (setting === false) return undefined;
|
|
290
|
+
if (typeof setting === "string") {
|
|
291
|
+
if (!setting.trim())
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Native target '${name}': csp "" is no policy; write \`csp: false\` to ship the page without one.`,
|
|
294
|
+
);
|
|
295
|
+
return setting;
|
|
296
|
+
}
|
|
297
|
+
const policy: Record<string, string[]> = Object.fromEntries(
|
|
298
|
+
Object.entries(NativeConfig.defaultCsp).map(([directive, sources]) => [directive, [...sources]]),
|
|
299
|
+
);
|
|
300
|
+
const add = (directive: string, sources: readonly string[]) => {
|
|
301
|
+
const list = policy[directive] ?? [];
|
|
302
|
+
policy[directive] = [...new Set([...list.filter((source) => source !== "'none'"), ...sources])];
|
|
303
|
+
};
|
|
304
|
+
add("connect-src", [...plainOrigins, ...plainOrigins.map((origin) => origin.replace(/^http/, "ws"))]);
|
|
305
|
+
add("img-src", plainOrigins);
|
|
306
|
+
add("media-src", plainOrigins);
|
|
307
|
+
const asked = new Map<string, string[]>();
|
|
308
|
+
for (const [key, sources] of Object.entries(setting ?? {})) {
|
|
309
|
+
const directive = key.toLowerCase();
|
|
310
|
+
const list = typeof sources === "string" ? sources.split(/\s+/).filter(Boolean) : sources;
|
|
311
|
+
asked.set(directive, [...(asked.get(directive) ?? []), ...list]);
|
|
312
|
+
}
|
|
313
|
+
//? Each fetch directive the default names overrides default-src, so a default-src entry has to reach every one.
|
|
314
|
+
const everywhere = asked.get("default-src") ?? [];
|
|
315
|
+
asked.delete("default-src");
|
|
316
|
+
if (everywhere.length)
|
|
317
|
+
for (const directive of Object.keys(policy))
|
|
318
|
+
if (
|
|
319
|
+
directive === "default-src" ||
|
|
320
|
+
(directive !== "object-src" && NativeConfig.#fetchDirectives.includes(directive))
|
|
321
|
+
)
|
|
322
|
+
add(directive, everywhere);
|
|
323
|
+
const variant = /^(script|style)-src-(elem|attr)$/;
|
|
324
|
+
//? A directive the default lacks starts from its CSP fallback, else naming it would drop 'self' and the hashes.
|
|
325
|
+
for (const [directive, sources] of [...asked].sort(
|
|
326
|
+
([a], [b]) => Number(variant.test(a)) - Number(variant.test(b)),
|
|
327
|
+
)) {
|
|
328
|
+
const from = variant.test(directive)
|
|
329
|
+
? directive.replace(/-(elem|attr)$/, "")
|
|
330
|
+
: NativeConfig.#fetchDirectives.includes(directive)
|
|
331
|
+
? "default-src"
|
|
332
|
+
: null;
|
|
333
|
+
policy[directive] ??= [...(from ? (policy[from] ?? []) : [])];
|
|
334
|
+
add(directive, sources);
|
|
335
|
+
}
|
|
336
|
+
return policy;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
//? https: and wss: cover a cloud API, so only plain-http origins are named; a carried server's port comes at launch.
|
|
340
|
+
static #plainOrigins(publicEnv: Record<string, string | undefined>, env: NativeEnv | undefined, carried: boolean) {
|
|
341
|
+
const origins = new Set<string>();
|
|
342
|
+
const pinned = publicEnv.AKAN_PUBLIC_SERVER_URL;
|
|
343
|
+
if (pinned && URL.canParse(pinned) && new URL(pinned).protocol === "http:") origins.add(new URL(pinned).origin);
|
|
344
|
+
const environment = publicEnv.AKAN_PUBLIC_ENV ?? env ?? "debug";
|
|
345
|
+
const mode = publicEnv.AKAN_PUBLIC_OPERATION_MODE ?? (environment === "local" ? "local" : "cloud");
|
|
346
|
+
if (mode === "local") origins.add("http://localhost:*");
|
|
347
|
+
if (carried) origins.add("http://127.0.0.1:*");
|
|
348
|
+
return [...origins];
|
|
349
|
+
}
|
|
350
|
+
|
|
240
351
|
static #icon(
|
|
241
352
|
icon: NonNullable<AkanNativeTarget["icon"]>,
|
|
242
353
|
abs: (relative: string) => string,
|
|
@@ -310,10 +421,20 @@ export class NativeConfig {
|
|
|
310
421
|
return Object.assign({}, ...contributed, own);
|
|
311
422
|
}
|
|
312
423
|
|
|
313
|
-
//*
|
|
314
|
-
static #deepLinks(
|
|
424
|
+
//* An app link limited to a basePath lists it per locale, plus the unprefixed URL `"as-needed"` gives the default one.
|
|
425
|
+
static #deepLinks(
|
|
426
|
+
target: AkanNativeTarget,
|
|
427
|
+
locales: readonly string[],
|
|
428
|
+
i18n: Pick<AkanI18nConfig, "defaultLocale" | "localePrefix"> = { defaultLocale: "" },
|
|
429
|
+
) {
|
|
315
430
|
const basePath = target.basePath ? trimSlashes(target.basePath) : "";
|
|
316
|
-
const pathPrefixes = basePath
|
|
431
|
+
const pathPrefixes = basePath
|
|
432
|
+
? [
|
|
433
|
+
...new Set(
|
|
434
|
+
locales.flatMap((locale) => [LocalePath.join(locale, `/${basePath}`, i18n), `/${locale}/${basePath}`]),
|
|
435
|
+
),
|
|
436
|
+
]
|
|
437
|
+
: undefined;
|
|
317
438
|
return {
|
|
318
439
|
...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
|
|
319
440
|
...(target.deepLinks?.domains?.length
|
|
@@ -43,12 +43,14 @@ describe("NativePublicEnv", () => {
|
|
|
43
43
|
AKAN_PUBLIC_MAP_TOKEN: "x",
|
|
44
44
|
AKAN_PUBLIC_PORT: 8282,
|
|
45
45
|
AKAN_PUBLIC_NOTE: "line\nbreak",
|
|
46
|
+
AKAN_PUBLIC_SIGNER: "-----BEGIN EC PRIVATE KEY-----\\nMHc\\n-----END EC PRIVATE KEY-----",
|
|
46
47
|
}),
|
|
47
48
|
).toEqual([
|
|
48
49
|
"AKAN_PUBLIC_lower is not an AKAN_PUBLIC_[A-Z0-9_] name",
|
|
49
50
|
"AKAN_PUBLIC_MAP_TOKEN names a token, which a page bundle must not carry",
|
|
50
51
|
"AKAN_PUBLIC_PORT is not a string",
|
|
51
52
|
"AKAN_PUBLIC_NOTE holds a control character",
|
|
53
|
+
"AKAN_PUBLIC_SIGNER holds a private key",
|
|
52
54
|
]);
|
|
53
55
|
const many = Object.fromEntries(Array.from({ length: 257 }, (_, idx) => [`AKAN_PUBLIC_K${idx}`, "v"]));
|
|
54
56
|
expect(NativePublicEnv.problems(many)).toEqual(["257 keys, more than 256"]);
|
|
@@ -16,6 +16,7 @@ export class NativePublicEnv {
|
|
|
16
16
|
"AKAN_PUBLIC_RENDER_ENV",
|
|
17
17
|
"AKAN_PUBLIC_DEFAULT_LOCALE",
|
|
18
18
|
"AKAN_PUBLIC_LOCALES",
|
|
19
|
+
"AKAN_PUBLIC_LOCALE_PREFIX",
|
|
19
20
|
];
|
|
20
21
|
//? Akan Cloud's own limits on a recorded env: a build it would refuse fails here, before anything is signed.
|
|
21
22
|
static readonly #keyPattern = /^AKAN_PUBLIC_[A-Z0-9_]{1,128}$/;
|
|
@@ -46,6 +47,7 @@ export class NativePublicEnv {
|
|
|
46
47
|
const word = NativePublicEnv.#secretWords.find((secret) => key.includes(secret));
|
|
47
48
|
if (word) return [`${key} names a ${word.toLowerCase()}, which a page bundle must not carry`];
|
|
48
49
|
if (typeof value !== "string") return [`${key} is not a string`];
|
|
50
|
+
if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(value)) return [`${key} holds a private key`];
|
|
49
51
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is refused
|
|
50
52
|
if (/[\u0000-\u001f\u007f]/.test(value)) return [`${key} holds a control character`];
|
|
51
53
|
return [];
|
|
@@ -71,6 +73,17 @@ export class NativePublicEnv {
|
|
|
71
73
|
return NativePublicEnv.of((await file.json()) as Record<string, unknown>);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/** Every `AKAN_PUBLIC_*` the last `akan build` inlined, derived keys included, else the ones this process has. */
|
|
77
|
+
static async inlined(app: App): Promise<PublicEnv> {
|
|
78
|
+
const file = Bun.file(CsrArtifactBuilder.publicEnvPath(app));
|
|
79
|
+
const env = (await file.exists()) ? ((await file.json()) as Record<string, unknown>) : process.env;
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(env).flatMap(([key, value]) =>
|
|
82
|
+
key.startsWith("AKAN_PUBLIC_") && typeof value === "string" ? [[key, value]] : [],
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
/** A bundle.json's record, or null when it has none (a build from before the record, or no file at all). */
|
|
75
88
|
static async recordedIn(bundleFile: string): Promise<PublicEnv | null> {
|
|
76
89
|
const file = Bun.file(bundleFile);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
"./incrementalBuilder": "./incrementalBuilder/index.ts",
|
|
35
35
|
"./mobile": "./mobile/index.ts",
|
|
36
36
|
"./pipeline": "./pipeline/index.ts",
|
|
37
|
+
"./preview": "./preview/index.ts",
|
|
37
38
|
"./transforms": "./transforms/index.ts",
|
|
38
39
|
"./workflow": "./workflow/index.ts",
|
|
39
40
|
"./*.ts": "./*.ts",
|
|
40
41
|
"./*": "./*.ts"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
44
|
+
"@akanjs/language": "3.1.2-rc.0",
|
|
43
45
|
"@earendil-works/pi-agent-core": "1.3.0",
|
|
44
46
|
"@earendil-works/pi-ai": "1.3.0",
|
|
45
47
|
"@earendil-works/pi-codemode": "1.3.0",
|
|
@@ -48,7 +50,7 @@
|
|
|
48
50
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
51
|
"@inquirer/prompts": "^8.7.3",
|
|
50
52
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.
|
|
53
|
+
"akanjs": "3.1.2-rc.0",
|
|
52
54
|
"chalk": "^6.0.1",
|
|
53
55
|
"commander": "^15.0.0",
|
|
54
56
|
"dayjs": "^1.11.23",
|
|
@@ -40,6 +40,9 @@ export class LinuxDockerTarget extends PlatformTestTarget {
|
|
|
40
40
|
"run",
|
|
41
41
|
"-d",
|
|
42
42
|
"--rm",
|
|
43
|
+
//? An init as PID 1 reaps orphans as a real host does; under `sleep` a killed orphan stays a zombie that still
|
|
44
|
+
//? answers kill(pid, 0).
|
|
45
|
+
"--init",
|
|
43
46
|
"--name",
|
|
44
47
|
container,
|
|
45
48
|
"--cpus",
|
|
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
export type PreviewEngine = "webkit" | "chrome";
|
|
6
|
+
|
|
5
7
|
// Bun.WebView's `webkit` backend is the macOS system framework and throws on every other platform.
|
|
6
8
|
export class PreviewChrome {
|
|
7
9
|
/** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
|
|
@@ -30,7 +32,7 @@ export class PreviewChrome {
|
|
|
30
32
|
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
31
33
|
];
|
|
32
34
|
|
|
33
|
-
static get backend():
|
|
35
|
+
static get backend(): PreviewEngine {
|
|
34
36
|
const declared = process.env[PreviewChrome.backendEnvKey];
|
|
35
37
|
if (declared === "webkit" || declared === "chrome") return declared;
|
|
36
38
|
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
@@ -53,11 +55,15 @@ export class PreviewChrome {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
static get available() {
|
|
56
|
-
return PreviewChrome.
|
|
58
|
+
return PreviewChrome.isAvailable();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
|
|
62
|
+
return engine === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
static backendOption() {
|
|
60
|
-
if (
|
|
65
|
+
static backendOption(engine: PreviewEngine = PreviewChrome.backend) {
|
|
66
|
+
if (engine === "webkit") return "webkit";
|
|
61
67
|
// Docker's default 64MB /dev/shm is below what Chromium's renderer allocates; the crash looks like a hung load.
|
|
62
68
|
const argv = ["--disable-dev-shm-usage"];
|
|
63
69
|
// Chromium refuses uid 0 with its sandbox on, and uid 0 is the ordinary uid in a container.
|
|
@@ -70,9 +76,9 @@ export class PreviewChrome {
|
|
|
70
76
|
"apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
|
|
71
77
|
];
|
|
72
78
|
|
|
73
|
-
static unavailableReason() {
|
|
74
|
-
if (PreviewChrome.
|
|
75
|
-
if (
|
|
79
|
+
static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
|
|
80
|
+
if (PreviewChrome.isAvailable(engine)) return undefined;
|
|
81
|
+
if (engine === "webkit")
|
|
76
82
|
return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
|
|
77
83
|
return [
|
|
78
84
|
"The page preview needs a browser and this machine has none on PATH.",
|