@akanjs/devkit 3.0.0-beta.26 → 3.0.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanApp/akanApp.host.ts +22 -2
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +212 -2
- package/akanConfig/akanConfig.ts +93 -26
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +7 -1
- package/applicationBuildRunner.test.ts +36 -2
- package/applicationBuildRunner.ts +21 -6
- package/biome.base.json +3 -1
- package/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrPageStack.e2e.test.ts +65 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +210 -6
- package/frontendBuild/csrDevModuleCompiler.ts +133 -19
- package/frontendBuild/csrDevPaths.ts +1 -0
- package/frontendBuild/csrDevResolver.ts +89 -9
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- package/frontendBuild/ssrDevBundler.ts +1 -1
- package/integration/devStability.integration.test.ts +35 -0
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +129 -0
- package/mobile/desktopServerStage.ts +120 -0
- package/mobile/index.ts +3 -0
- package/mobile/mobileTarget.test.ts +50 -1
- package/mobile/mobileTarget.ts +20 -4
- package/mobile/nativeApp.test.ts +37 -1
- package/mobile/nativeApp.ts +176 -27
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +215 -4
- package/mobile/nativeConfig.ts +117 -8
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +100 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- package/workspaceLayout.ts +2 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { AkanMobileTargetConfig } from "../akanConfig";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
appIdsOf,
|
|
6
|
+
getMobileTargetChoices,
|
|
7
|
+
resolveAppId,
|
|
8
|
+
resolveMobilePath,
|
|
9
|
+
resolveMobileTargets,
|
|
10
|
+
targetHtmlFilename,
|
|
11
|
+
} from "./mobileTarget";
|
|
5
12
|
|
|
6
13
|
const target = {
|
|
7
14
|
name: "akanjs",
|
|
@@ -19,6 +26,17 @@ describe("mobile target helpers", () => {
|
|
|
19
26
|
expect(resolveMobilePath({ ...target, basePath: undefined }, "/order/123")).toBe("/order/123");
|
|
20
27
|
});
|
|
21
28
|
|
|
29
|
+
test("an app id per platform falls back to default, and a platform with neither is refused", () => {
|
|
30
|
+
const appId = { default: "com.yeollege", ios: "com.puffinplanet.yeollege" };
|
|
31
|
+
expect(resolveAppId(appId, "ios")).toBe("com.puffinplanet.yeollege");
|
|
32
|
+
expect(resolveAppId(appId, "android")).toBe("com.yeollege");
|
|
33
|
+
expect(resolveAppId("com.one.app", "macos")).toBe("com.one.app");
|
|
34
|
+
expect(() => resolveAppId({ ios: "com.puffinplanet.yeollege" }, "android")).toThrow(
|
|
35
|
+
"mobile.appId names no id for android",
|
|
36
|
+
);
|
|
37
|
+
expect(appIdsOf(appId)).toEqual(["com.yeollege", "com.puffinplanet.yeollege"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
22
40
|
test("selects the self-contained html file for a target", () => {
|
|
23
41
|
expect(targetHtmlFilename(target)).toBe("akanjs.html");
|
|
24
42
|
expect(targetHtmlFilename({ ...target, basePath: undefined })).toBe("index.html");
|
|
@@ -36,6 +54,37 @@ describe("mobile target helpers", () => {
|
|
|
36
54
|
await expect(resolveMobileTargets(app, undefined)).resolves.toEqual([{ name: "akanjs", config: target }]);
|
|
37
55
|
});
|
|
38
56
|
|
|
57
|
+
test("a target naming no basePath is a template in an app with basePaths, never a page of its own", async () => {
|
|
58
|
+
const template = { ...target, name: "default", basePath: undefined };
|
|
59
|
+
const appWith = (basePaths: string[]) =>
|
|
60
|
+
({
|
|
61
|
+
name: "angelo",
|
|
62
|
+
getConfig: async () => ({ basePaths: new Set(basePaths), mobile: { targets: { default: template } } }),
|
|
63
|
+
}) as unknown as App;
|
|
64
|
+
|
|
65
|
+
const two = appWith(["soft", "office"]);
|
|
66
|
+
await expect(getMobileTargetChoices(two)).resolves.toEqual(["soft", "office"]);
|
|
67
|
+
await expect(resolveMobileTargets(two, undefined)).rejects.toThrow(
|
|
68
|
+
"Multiple mobile targets found for angelo. Pass --target <soft|office|all>.",
|
|
69
|
+
);
|
|
70
|
+
await expect(resolveMobileTargets(two, "office")).resolves.toEqual([
|
|
71
|
+
{ name: "office", config: { ...template, name: "office", basePath: "office" } },
|
|
72
|
+
]);
|
|
73
|
+
await expect(resolveMobileTargets(two, "all")).resolves.toEqual([
|
|
74
|
+
{ name: "soft", config: { ...template, name: "soft", basePath: "soft" } },
|
|
75
|
+
{ name: "office", config: { ...template, name: "office", basePath: "office" } },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const one = appWith(["soft"]);
|
|
79
|
+
const soft = [{ name: "soft", config: { ...template, name: "soft", basePath: "soft" } }];
|
|
80
|
+
await expect(resolveMobileTargets(one, undefined)).resolves.toEqual(soft);
|
|
81
|
+
await expect(resolveMobileTargets(one, "all")).resolves.toEqual(soft);
|
|
82
|
+
|
|
83
|
+
await expect(resolveMobileTargets(appWith([]), undefined)).resolves.toEqual([
|
|
84
|
+
{ name: "default", config: template },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
39
88
|
test("resolves a route base path onto the default mobile target config", async () => {
|
|
40
89
|
const app = {
|
|
41
90
|
getConfig: async () => ({
|
package/mobile/mobileTarget.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AkanMobileAppId } from "akanjs";
|
|
2
|
+
import type { AkanAppConfig, AkanMobileTargetConfig, MobileEnv } from "../akanConfig";
|
|
2
3
|
import type { App } from "../commandDecorators";
|
|
3
4
|
|
|
4
5
|
export type MobilePlatform = "ios" | "android";
|
|
@@ -20,10 +21,15 @@ export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]
|
|
|
20
21
|
return Object.entries(config.mobile.targets).map(([name, target]) => ({ name, config: target }));
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
//* An app with basePaths builds a CSR page per basePath and none at its root, so a target naming no basePath opens
|
|
25
|
+
//* nothing on its own there; it is the template each basePath's target is made from.
|
|
26
|
+
const isTemplateOnly = ({ basePaths, mobile }: Pick<AkanAppConfig, "basePaths" | "mobile">) =>
|
|
27
|
+
basePaths.size > 0 && Object.values(mobile.targets).every((target) => !target.basePath);
|
|
28
|
+
|
|
23
29
|
export const getMobileTargetChoices = async (app: App): Promise<string[]> => {
|
|
24
30
|
const config = await app.getConfig();
|
|
25
31
|
const targetNames = Object.keys(config.mobile.targets);
|
|
26
|
-
if (targetNames.length > 0) return targetNames;
|
|
32
|
+
if (targetNames.length > 0 && !isTemplateOnly(config)) return targetNames;
|
|
27
33
|
return [...config.basePaths];
|
|
28
34
|
};
|
|
29
35
|
|
|
@@ -51,7 +57,7 @@ export const resolveMobileTargets = async (
|
|
|
51
57
|
const config = await app.getConfig();
|
|
52
58
|
const targets = await getMobileTargets(app);
|
|
53
59
|
if (targets.length === 0) throw new Error(`No mobile targets configured for ${app.name}`);
|
|
54
|
-
if (!selection && targets.length === 1) return targets;
|
|
60
|
+
if (!selection && targets.length === 1 && !isTemplateOnly(config)) return targets;
|
|
55
61
|
if (!selection) {
|
|
56
62
|
const choices = await getMobileTargetChoices(app);
|
|
57
63
|
if (choices.length === 1) return resolveMobileTargets(app, choices[0]);
|
|
@@ -60,7 +66,7 @@ export const resolveMobileTargets = async (
|
|
|
60
66
|
if (selection === "all") {
|
|
61
67
|
if (Object.keys(config.mobile.targets).length > 1) return targets;
|
|
62
68
|
const basePaths = [...config.basePaths];
|
|
63
|
-
if (basePaths.length > 1) {
|
|
69
|
+
if (basePaths.length > 1 || isTemplateOnly(config)) {
|
|
64
70
|
return basePaths.flatMap((basePath) => {
|
|
65
71
|
const resolved = resolveMobileTargetByBasePath(targets, basePath);
|
|
66
72
|
return resolved ? [resolved] : [];
|
|
@@ -76,6 +82,16 @@ export const resolveMobileTargets = async (
|
|
|
76
82
|
throw new Error(`Mobile target '${selection}' was not found. Available: ${choices.join(", ")}`);
|
|
77
83
|
};
|
|
78
84
|
|
|
85
|
+
export const resolveAppId = (appId: AkanMobileAppId, platform: NativePlatform): string => {
|
|
86
|
+
if (typeof appId === "string") return appId;
|
|
87
|
+
const id = appId[platform] ?? appId.default;
|
|
88
|
+
if (!id) throw new Error(`mobile.appId names no id for ${platform}; give it ${platform} or default.`);
|
|
89
|
+
return id;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const appIdsOf = (appId: AkanMobileAppId): string[] =>
|
|
93
|
+
typeof appId === "string" ? [appId] : Object.values(appId).filter((id): id is string => !!id);
|
|
94
|
+
|
|
79
95
|
export const resolveMobilePath = (target: AkanMobileTargetConfig, pathname: string) => {
|
|
80
96
|
const basePath = trimSlashes(target.basePath ?? "");
|
|
81
97
|
const normalizedPath = `/${pathname.replace(/^\/+/, "")}`;
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -22,7 +22,10 @@ const appDir = "/repo/apps/portal";
|
|
|
22
22
|
|
|
23
23
|
const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
24
24
|
({
|
|
25
|
+
name: "portal",
|
|
25
26
|
cwdPath: appDir,
|
|
27
|
+
workspace: { workspaceRoot: "/repo" },
|
|
28
|
+
getScanInfo: () => ({ getLibs: () => [] }),
|
|
26
29
|
dist: { cwdPath: "/repo/dist/apps/portal" },
|
|
27
30
|
getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
|
|
28
31
|
collectPlugins: async () => plugins,
|
|
@@ -38,6 +41,21 @@ describe("NativeApp", () => {
|
|
|
38
41
|
expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
|
|
39
42
|
});
|
|
40
43
|
|
|
44
|
+
test("a dev boot that came up says so in one line: platform, device, dev server, time", () => {
|
|
45
|
+
const app = { ...fakeApp(), name: "portal" } as unknown as App;
|
|
46
|
+
const startedAt = performance.now() - 12_400;
|
|
47
|
+
expect(new NativeApp(app, target()).readyLine("macos", { upstream: "http://localhost:8283", startedAt })).toMatch(
|
|
48
|
+
/^portal desktop ready · http:\/\/localhost:8283 · 12\.\ds$/,
|
|
49
|
+
);
|
|
50
|
+
expect(
|
|
51
|
+
new NativeApp(app, target({ name: "admin" })).readyLine("ios", {
|
|
52
|
+
upstream: "http://localhost:8283",
|
|
53
|
+
device: { name: "iPhone 16" },
|
|
54
|
+
startedAt,
|
|
55
|
+
}),
|
|
56
|
+
).toMatch(/^portal\/admin ios ready · iPhone 16 · http:\/\/localhost:8283 · 12\.\ds$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
41
59
|
test("opens a dev build on its target's home, the CSR shell under the locale", () => {
|
|
42
60
|
expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
|
|
43
61
|
"/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
|
|
@@ -54,13 +72,31 @@ describe("NativeApp", () => {
|
|
|
54
72
|
target({ basePath: undefined, permissions: ["push"] }),
|
|
55
73
|
);
|
|
56
74
|
|
|
57
|
-
const { config, warnings } = await app.config();
|
|
75
|
+
const { config, warnings } = await app.config({ platform: "android" });
|
|
58
76
|
|
|
59
77
|
expect(warnings).toEqual([]);
|
|
60
78
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
|
|
61
79
|
expect(config.web.dir).toBe(path.join(appDir, ".akan/mobile/default/web"));
|
|
62
80
|
});
|
|
63
81
|
|
|
82
|
+
test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
|
|
83
|
+
const { config } = await new NativeApp(fakeApp(), target({ basePath: undefined })).config({
|
|
84
|
+
platform: "macos",
|
|
85
|
+
stageBin: false,
|
|
86
|
+
});
|
|
87
|
+
expect(config.desktop?.bin).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("follows the updates channel of the backend env it is built for", async () => {
|
|
91
|
+
const updates = { url: "https://releases.example.com/portal", publicKey: `${"a".repeat(43)}=` };
|
|
92
|
+
const built = async (env?: "debug" | "main") =>
|
|
93
|
+
(await new NativeApp(fakeApp(), target({ basePath: undefined, updates }), env).config({ platform: "android" }))
|
|
94
|
+
.config.updates;
|
|
95
|
+
|
|
96
|
+
expect(await built("main")).toEqual({ ...updates, channel: "main" });
|
|
97
|
+
expect(await built("debug")).toEqual({ ...updates, channel: "debug" });
|
|
98
|
+
});
|
|
99
|
+
|
|
64
100
|
test("a desktop app is this computer's platform", () => {
|
|
65
101
|
expect(["darwin", "win32", "linux"].map((host) => NativeApp.desktopPlatform(host as NodeJS.Platform))).toEqual([
|
|
66
102
|
"macos",
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
|
+
import type { MobileEnv } from "akanjs";
|
|
4
5
|
import type { App } from "../commandDecorators";
|
|
6
|
+
import { Executor } from "../executors";
|
|
7
|
+
import { DesktopBin } from "./desktopBin";
|
|
8
|
+
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
5
9
|
import {
|
|
6
10
|
type DesktopPlatform,
|
|
7
11
|
type MobilePlatform,
|
|
@@ -10,7 +14,9 @@ import {
|
|
|
10
14
|
targetHtmlFilename,
|
|
11
15
|
} from "./mobileTarget";
|
|
12
16
|
import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
|
|
17
|
+
import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
|
|
13
18
|
import { NativeConfig } from "./nativeConfig";
|
|
19
|
+
import { NativePluginFolders } from "./nativePluginFolders";
|
|
14
20
|
import { NativeWebDir } from "./nativeWebDir";
|
|
15
21
|
|
|
16
22
|
type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
@@ -24,6 +30,19 @@ export interface NativeRunOptions {
|
|
|
24
30
|
teamId?: string;
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
interface NativeDevBoot {
|
|
34
|
+
steps: string[];
|
|
35
|
+
lines: NativeAppLineRead[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NativeBuildOptions {
|
|
39
|
+
profile?: "debug" | "release";
|
|
40
|
+
/** The server a desktop app carries (the target's `native.desktop.server`), staged by DesktopServerStage. */
|
|
41
|
+
server?: DesktopServerBundle;
|
|
42
|
+
/** Windows: an NSIS setup program beside the app folder. */
|
|
43
|
+
installer?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
export interface NativeDevOptions extends NativeRunOptions {
|
|
28
47
|
/** The akan dev server the gateway proxies. */
|
|
29
48
|
upstream: string;
|
|
@@ -35,10 +54,13 @@ export interface NativeDevOptions extends NativeRunOptions {
|
|
|
35
54
|
export class NativeApp {
|
|
36
55
|
readonly targetRoot: string;
|
|
37
56
|
readonly web: NativeWebDir;
|
|
57
|
+
//? A dev boot holds its steps and the app's first lines back: one line says it is up, all of it when it failed.
|
|
58
|
+
#boot: NativeDevBoot | null = null;
|
|
38
59
|
|
|
39
60
|
constructor(
|
|
40
61
|
readonly app: App,
|
|
41
62
|
readonly target: ResolvedMobileTarget,
|
|
63
|
+
readonly env?: MobileEnv,
|
|
42
64
|
) {
|
|
43
65
|
this.targetRoot = path.join(app.cwdPath, ".akan", "mobile", target.name);
|
|
44
66
|
this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
|
|
@@ -58,20 +80,48 @@ export class NativeApp {
|
|
|
58
80
|
});
|
|
59
81
|
}
|
|
60
82
|
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
/** Where a desktop build stages the executables it carries (akan.config.ts `bin`). */
|
|
84
|
+
get binDir() {
|
|
85
|
+
return path.join(this.targetRoot, "bin");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `stageBin: false` for a config nothing is built from (the update key's): staging downloads the `bin` sources. */
|
|
89
|
+
async config({
|
|
90
|
+
server,
|
|
91
|
+
platform,
|
|
92
|
+
stageBin = true,
|
|
93
|
+
}: {
|
|
94
|
+
server?: DesktopServerBundle;
|
|
95
|
+
platform: NativePlatform;
|
|
96
|
+
stageBin?: boolean;
|
|
97
|
+
}) {
|
|
98
|
+
const [appConfig, plugins, nativePlugins] = await Promise.all([
|
|
99
|
+
this.app.getConfig(),
|
|
100
|
+
this.app.collectPlugins(),
|
|
101
|
+
NativePluginFolders.of(this.app),
|
|
102
|
+
]);
|
|
103
|
+
const desktop = platform === "macos" || platform === "windows" || platform === "linux";
|
|
104
|
+
const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir) : [];
|
|
63
105
|
return NativeConfig.build({
|
|
64
106
|
appPath: this.app.cwdPath,
|
|
65
107
|
target: this.target.config,
|
|
66
108
|
webDir: this.web.dir,
|
|
67
109
|
contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
|
|
68
110
|
locales: appConfig.i18n.locales,
|
|
111
|
+
env: this.env,
|
|
112
|
+
platform,
|
|
113
|
+
nativePlugins,
|
|
114
|
+
...(server ? { desktopServer: server } : {}),
|
|
115
|
+
...(carried.length ? { desktopBin: this.binDir } : {}),
|
|
69
116
|
});
|
|
70
117
|
}
|
|
71
118
|
|
|
72
119
|
/** The API and the config it is about to build, refused here when the runtime would refuse it later. */
|
|
73
|
-
async prepare() {
|
|
74
|
-
const [api, { config, warnings }] = await Promise.all([
|
|
120
|
+
async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
|
|
121
|
+
const [api, { config, warnings }] = await Promise.all([
|
|
122
|
+
NativeApi.load(this.app.cwdPath),
|
|
123
|
+
this.config({ platform, ...(server ? { server } : {}) }),
|
|
124
|
+
]);
|
|
75
125
|
for (const warning of warnings) this.app.logger.warn(warning);
|
|
76
126
|
const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
|
|
77
127
|
if (problems.length)
|
|
@@ -83,7 +133,9 @@ export class NativeApp {
|
|
|
83
133
|
|
|
84
134
|
//? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
|
|
85
135
|
#appLine = (line: string) => {
|
|
86
|
-
|
|
136
|
+
const read = NativeAppLine.read(line, { verbose: Executor.verbose });
|
|
137
|
+
if (this.#boot) this.#boot.lines.push(read);
|
|
138
|
+
else this.app.logger[read.level](read.message);
|
|
87
139
|
};
|
|
88
140
|
|
|
89
141
|
//* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
|
|
@@ -98,24 +150,32 @@ export class NativeApp {
|
|
|
98
150
|
log: (event) => {
|
|
99
151
|
if (event.level === "error") this.app.logger.error(event.message);
|
|
100
152
|
else if (event.level === "warn") this.app.logger.warn(event.message);
|
|
153
|
+
else if (this.#boot) this.#boot.steps.push(event.message);
|
|
101
154
|
else if (event.level === "tool") this.app.logger.verbose(event.message);
|
|
102
155
|
else this.app.logger.info(event.message);
|
|
103
156
|
},
|
|
104
157
|
};
|
|
105
158
|
}
|
|
106
159
|
|
|
107
|
-
async build(platform: NativePlatform, { profile = "release"
|
|
160
|
+
async build(platform: NativePlatform, { profile = "release", server, installer = false }: NativeBuildOptions = {}) {
|
|
161
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
162
|
+
if (installer && platform !== "windows") throw new Error(`An installer is built for Windows, not for ${platform}.`);
|
|
108
163
|
await this.assembleWeb();
|
|
109
|
-
const { api, config } = await this.prepare();
|
|
110
|
-
return await api.build({
|
|
164
|
+
const { api, config } = await this.prepare(platform, server);
|
|
165
|
+
return await api.build({
|
|
166
|
+
...this.#task(platform, config),
|
|
167
|
+
profile,
|
|
168
|
+
...(installer ? { windows: { installer } } : {}),
|
|
169
|
+
});
|
|
111
170
|
}
|
|
112
171
|
|
|
113
172
|
async run(
|
|
114
173
|
platform: NativePlatform,
|
|
115
|
-
{ device, teamId, profile = "debug" }: NativeRunOptions &
|
|
174
|
+
{ device, teamId, profile = "debug", server }: NativeRunOptions & NativeBuildOptions = {},
|
|
116
175
|
) {
|
|
176
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
117
177
|
await this.assembleWeb();
|
|
118
|
-
const { api, config } = await this.prepare();
|
|
178
|
+
const { api, config } = await this.prepare(platform, server);
|
|
119
179
|
return await api.run({
|
|
120
180
|
...this.#task(platform, config),
|
|
121
181
|
profile,
|
|
@@ -125,27 +185,56 @@ export class NativeApp {
|
|
|
125
185
|
});
|
|
126
186
|
}
|
|
127
187
|
|
|
128
|
-
//* The gateway serves the page
|
|
129
|
-
//*
|
|
188
|
+
//* The gateway serves the page, its HMR socket and its API calls on the app origin. The dev server's own port is
|
|
189
|
+
//* reversed too: a browser the app hands off to (auth-session) opens it directly (baseEnv getServerOrigin).
|
|
130
190
|
async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
191
|
+
const startedAt = performance.now();
|
|
192
|
+
const boot: NativeDevBoot = { steps: [], lines: [] };
|
|
193
|
+
this.#boot = boot;
|
|
194
|
+
try {
|
|
195
|
+
await mkdir(this.web.dir, { recursive: true });
|
|
196
|
+
const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
|
|
197
|
+
const session = await api.dev({
|
|
198
|
+
...this.#task(platform, config),
|
|
199
|
+
upstream,
|
|
200
|
+
hmrPath: "/_akan/hmr",
|
|
201
|
+
//? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
|
|
202
|
+
wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
|
|
203
|
+
reversePorts: [Number(new URL(upstream).port)],
|
|
204
|
+
onLine: this.#appLine,
|
|
205
|
+
startPath: this.startPath(lang),
|
|
206
|
+
...(device ? { device } : {}),
|
|
207
|
+
...(teamId ? { ios: { signing: { teamId } } } : {}),
|
|
208
|
+
});
|
|
209
|
+
this.#boot = null;
|
|
210
|
+
this.app.logger.info(this.readyLine(platform, { upstream, device: session.device, startedAt }));
|
|
211
|
+
for (const step of [...boot.steps, `pages through ${session.gateway}`]) this.app.logger.debug(step);
|
|
212
|
+
for (const line of boot.lines) this.app.logger[line.level](line.message);
|
|
213
|
+
return session;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
this.#boot = null;
|
|
216
|
+
for (const step of boot.steps) this.app.logger.info(step);
|
|
217
|
+
for (const line of boot.lines) this.app.logger[line.level === "debug" ? "info" : line.level](line.message);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What a dev boot that came up says, and all it says: `cmdc ios ready · iPhone 16 · http://localhost:8283 · 12.4s`. */
|
|
223
|
+
readyLine(
|
|
224
|
+
platform: NativePlatform,
|
|
225
|
+
{ upstream, device, startedAt }: { upstream: string; device?: { name: string }; startedAt: number },
|
|
226
|
+
) {
|
|
227
|
+
const label = platform === "ios" || platform === "android" ? platform : "desktop";
|
|
228
|
+
const target = this.target.name === "default" ? "" : `/${this.target.name}`;
|
|
229
|
+
const seconds = ((performance.now() - startedAt) / 1000).toFixed(1);
|
|
230
|
+
return [`${this.app.name}${target} ${label} ready`, ...(device ? [device.name] : []), upstream, `${seconds}s`].join(
|
|
231
|
+
" · ",
|
|
232
|
+
);
|
|
144
233
|
}
|
|
145
234
|
|
|
146
235
|
async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
|
|
147
236
|
await this.assembleWeb();
|
|
148
|
-
const { api, config } = await this.prepare();
|
|
237
|
+
const { api, config } = await this.prepare("ios");
|
|
149
238
|
return await api.release({
|
|
150
239
|
...this.#task("ios", config),
|
|
151
240
|
platform: "ios",
|
|
@@ -153,13 +242,67 @@ export class NativeApp {
|
|
|
153
242
|
});
|
|
154
243
|
}
|
|
155
244
|
|
|
245
|
+
/** Where publishUpdate writes: upload this folder as the target's updates.url. */
|
|
246
|
+
get updatesDir() {
|
|
247
|
+
return path.join(this.targetRoot, "updates");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
|
|
251
|
+
async publishUpdate(
|
|
252
|
+
platform: NativePlatform,
|
|
253
|
+
{ channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
|
|
254
|
+
) {
|
|
255
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
256
|
+
if (!this.target.config.updates)
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Mobile target '${this.target.name}' has no updates: { url, publicKey } in akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
|
|
259
|
+
);
|
|
260
|
+
await this.assembleWeb();
|
|
261
|
+
const { api, config } = await this.prepare(platform, server);
|
|
262
|
+
return await api.publishUpdate({
|
|
263
|
+
...this.#task(platform, config),
|
|
264
|
+
platform,
|
|
265
|
+
out: this.updatesDir,
|
|
266
|
+
...(channel ? { channel } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The key update releases of this target's app id on `platform` are signed with: made once, then read. */
|
|
271
|
+
async updateKeygen(platform: NativePlatform) {
|
|
272
|
+
const [api, { config }] = await Promise.all([
|
|
273
|
+
NativeApi.load(this.app.cwdPath),
|
|
274
|
+
this.config({ platform, stageBin: false }),
|
|
275
|
+
]);
|
|
276
|
+
return api.updateKeygen({ config });
|
|
277
|
+
}
|
|
278
|
+
|
|
156
279
|
async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
|
|
157
280
|
const signing = NativeApp.androidSigning();
|
|
158
281
|
await this.assembleWeb();
|
|
159
|
-
const { api, config } = await this.prepare();
|
|
282
|
+
const { api, config } = await this.prepare("android");
|
|
160
283
|
return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
|
|
161
284
|
}
|
|
162
285
|
|
|
286
|
+
/** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
|
|
287
|
+
async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
|
|
288
|
+
await this.assembleWeb();
|
|
289
|
+
const { api, config } = await this.prepare(platform);
|
|
290
|
+
if (!config.updates)
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Mobile target '${this.target.name}' takes no updates: add mobile.updates: { url, publicKey } to akan.config.ts first.`,
|
|
293
|
+
);
|
|
294
|
+
const packed = await api.packUpdate({
|
|
295
|
+
...this.#task(platform, config),
|
|
296
|
+
platform,
|
|
297
|
+
out: out ?? path.join(this.targetRoot, "updates", platform),
|
|
298
|
+
});
|
|
299
|
+
return { ...packed, channel: config.updates.channel ?? "production" };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
|
303
|
+
return (await NativeApi.load(appDir)).compareBundles(shipped, bundle);
|
|
304
|
+
}
|
|
305
|
+
|
|
163
306
|
//* The names the Gradle build read, so a CI that already holds these secrets keeps working.
|
|
164
307
|
static androidSigning(env: Record<string, string | undefined> = process.env): AndroidSigning {
|
|
165
308
|
const keystore = env.MYAPP_RELEASE_STORE_FILE;
|
|
@@ -192,6 +335,12 @@ export class NativeApp {
|
|
|
192
335
|
return `/${home.join("/")}?${params}`;
|
|
193
336
|
}
|
|
194
337
|
|
|
338
|
+
//? A phone runs no Bun, so only a desktop app can carry the server.
|
|
339
|
+
static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
|
|
340
|
+
if (server && (platform === "ios" || platform === "android"))
|
|
341
|
+
throw new Error(`Only a desktop app carries its server; ${platform} cannot run one.`);
|
|
342
|
+
}
|
|
343
|
+
|
|
195
344
|
//* A desktop app builds only on its own OS, so the platform is this computer's.
|
|
196
345
|
static desktopPlatform(host: NodeJS.Platform = process.platform): DesktopPlatform {
|
|
197
346
|
if (host === "darwin") return "macos";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { NativeAppLine } from "./nativeAppLine";
|
|
3
|
+
|
|
4
|
+
describe("NativeAppLine", () => {
|
|
5
|
+
test("a page logger line keeps its level and its logger's name", () => {
|
|
6
|
+
expect(NativeAppLine.read("[page warn] [WsClient] WebSocket message process failed")).toEqual({
|
|
7
|
+
level: "warn",
|
|
8
|
+
message: "[page:WsClient] WebSocket message process failed",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("a bare console call keeps its level, `log` reading as info", () => {
|
|
13
|
+
expect(NativeAppLine.read("[page log] hello")).toEqual({ level: "info", message: "[page] hello" });
|
|
14
|
+
expect(NativeAppLine.read("[page debug] [akan:frame:x:1] csr.render {}")).toEqual({
|
|
15
|
+
level: "debug",
|
|
16
|
+
message: "[page:akan:frame:x:1] csr.render {}",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("a continuation line follows its message's level without a prefix of its own", () => {
|
|
21
|
+
expect(NativeAppLine.read("[page+ error] at run (app.js:1:2)")).toEqual({
|
|
22
|
+
level: "error",
|
|
23
|
+
message: " at run (app.js:1:2)",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a carried server's line keeps the level its logger wrote, and one without a level stays visible", () => {
|
|
28
|
+
const line = "[server] [minimal] 81 - 09/30/2026, 12:30:06 PM ERROR boom +2ms";
|
|
29
|
+
expect(NativeAppLine.read(line)).toEqual({ level: "error", message: line });
|
|
30
|
+
expect(NativeAppLine.read("[server] \u001b[32m[minimal] 81 -\u001b[39m WARN slow").level).toBe("warn");
|
|
31
|
+
expect(NativeAppLine.read("[server] at run (main.js:1:2)").level).toBe("info");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a second window is named, the first is not", () => {
|
|
35
|
+
expect(NativeAppLine.read("[page#2 info] [App] ready")).toEqual({ level: "info", message: "[page#2:App] ready" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("logcat lines read by tag and priority", () => {
|
|
39
|
+
expect(NativeAppLine.read("W/AkanNativeConsole( 1234): [WsClient] failed")).toEqual({
|
|
40
|
+
level: "warn",
|
|
41
|
+
message: "[page:WsClient] failed",
|
|
42
|
+
});
|
|
43
|
+
expect(NativeAppLine.read("E/AndroidRuntime( 1234): FATAL EXCEPTION: main")).toEqual({
|
|
44
|
+
level: "error",
|
|
45
|
+
message: "[AndroidRuntime] FATAL EXCEPTION: main",
|
|
46
|
+
});
|
|
47
|
+
expect(
|
|
48
|
+
NativeAppLine.read("I/AkanNative( 1234): pages from http://10.0.2.2:5000 (akan-native dev --hmr)").level,
|
|
49
|
+
).toBe("debug");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the host's own lines stay visible but for where its pages come from", () => {
|
|
53
|
+
expect(NativeAppLine.read("[akan-native] opened in the browser: https://x.test")).toEqual({
|
|
54
|
+
level: "info",
|
|
55
|
+
message: "[akan-native] opened in the browser: https://x.test",
|
|
56
|
+
});
|
|
57
|
+
expect(
|
|
58
|
+
NativeAppLine.read("[akan-native native] pages from http://127.0.0.1:6000 (akan-native dev --hmr)").level,
|
|
59
|
+
).toBe("debug");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a line no page or host wrote is kept for debug, or for info when verbose", () => {
|
|
63
|
+
expect(NativeAppLine.read("WebContent[123] Could not signal service")).toEqual({
|
|
64
|
+
level: "debug",
|
|
65
|
+
message: "WebContent[123] Could not signal service",
|
|
66
|
+
});
|
|
67
|
+
expect(NativeAppLine.read("WebContent[123] Could not signal service", { verbose: true }).level).toBe("info");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { LogLevel } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
export interface NativeAppLineRead {
|
|
4
|
+
level: LogLevel;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
//* A line a native dev build prints — the page console its host mirrors, the simulator's console, logcat — read back
|
|
9
|
+
//* at the level the page logged it, so the terminal shows it once, stamped by the app's own logger.
|
|
10
|
+
export class NativeAppLine {
|
|
11
|
+
//? The host's grammar (desktop host.ts, iOS AkanNativeShell.swift): `[page<+><#window> <level>] text`, where `+`
|
|
12
|
+
//? marks a continuation line of a multi-line message and `#n` any window but the first.
|
|
13
|
+
static readonly #page = /^\[page(\+)?(?:#(\d+))? (trace|verbose|debug|log|info|warn|error)\] ?(.*)$/;
|
|
14
|
+
static readonly #logcat = /^([VDIWEFA])\/([^(]+?)\s*\(\s*\d+\):\s?(.*)$/;
|
|
15
|
+
static readonly #named = /^\[([^\]\s]+)\] ([\s\S]*)$/;
|
|
16
|
+
//? A server the desktop app carries (host server.ts): `[server] <its own log line>`, whose level word the akan logger
|
|
17
|
+
//? wrote; a line without one (a crash's stack) is still the app's and stays visible.
|
|
18
|
+
static readonly #server = /^\[server\] /;
|
|
19
|
+
static readonly #serverLevel = /\b(TRACE|VERBOSE|DEBUG|INFO|WARN|ERROR)\b/;
|
|
20
|
+
static readonly #logcatLevels: { [priority: string]: LogLevel } = {
|
|
21
|
+
V: "verbose",
|
|
22
|
+
D: "debug",
|
|
23
|
+
I: "info",
|
|
24
|
+
W: "warn",
|
|
25
|
+
E: "error",
|
|
26
|
+
F: "error",
|
|
27
|
+
A: "error",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `verbose` keeps the lines no page or host wrote (WebKit, the simulator) at info instead of debug. */
|
|
31
|
+
static read(line: string, { verbose = false }: { verbose?: boolean } = {}): NativeAppLineRead {
|
|
32
|
+
const logcat = NativeAppLine.#logcat.exec(line);
|
|
33
|
+
if (logcat) {
|
|
34
|
+
const [, priority = "I", tag = "", text = ""] = logcat;
|
|
35
|
+
const level = NativeAppLine.#logcatLevels[priority] ?? "info";
|
|
36
|
+
if (tag === "AkanNativeConsole") return NativeAppLine.#pageLine(level, text, { continuation: false });
|
|
37
|
+
if (tag === "AkanNative") return NativeAppLine.#hostLine(`[akan-native] ${text}`, level);
|
|
38
|
+
return { level, message: `[${tag}] ${text}` };
|
|
39
|
+
}
|
|
40
|
+
const page = NativeAppLine.#page.exec(line);
|
|
41
|
+
if (page) {
|
|
42
|
+
const [, continuation, window, level = "info", text = ""] = page;
|
|
43
|
+
return NativeAppLine.#pageLine(level === "log" ? "info" : (level as LogLevel), text, {
|
|
44
|
+
continuation: continuation === "+",
|
|
45
|
+
window,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (line.startsWith("[akan-native")) return NativeAppLine.#hostLine(line, "info");
|
|
49
|
+
if (NativeAppLine.#server.test(line)) {
|
|
50
|
+
const word = NativeAppLine.#serverLevel.exec(Bun.stripANSI(line))?.[1];
|
|
51
|
+
return { level: word ? (word.toLowerCase() as LogLevel) : "info", message: line };
|
|
52
|
+
}
|
|
53
|
+
return { level: verbose ? "info" : "debug", message: line };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static #pageLine(
|
|
57
|
+
level: LogLevel,
|
|
58
|
+
text: string,
|
|
59
|
+
{ continuation, window }: { continuation: boolean; window?: string },
|
|
60
|
+
): NativeAppLineRead {
|
|
61
|
+
if (continuation) return { level, message: ` ${text}` };
|
|
62
|
+
const named = NativeAppLine.#named.exec(text);
|
|
63
|
+
const scope = `page${window ? `#${window}` : ""}${named ? `:${named[1]}` : ""}`;
|
|
64
|
+
return { level, message: `[${scope}] ${named ? named[2] : text}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//? The host's own lines are about the host, not the app: where its pages come from is boot detail.
|
|
68
|
+
static #hostLine(line: string, level: LogLevel): NativeAppLineRead {
|
|
69
|
+
return { level: /\bpages from\b/.test(line) ? "debug" : level, message: line };
|
|
70
|
+
}
|
|
71
|
+
}
|