@akanjs/devkit 3.0.0-beta.29 → 3.0.0-beta.30
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/akanConfig/akanConfig.test.ts +109 -92
- package/akanConfig/akanConfig.ts +183 -163
- package/akanConfig/types.ts +12 -6
- package/executors.test.ts +4 -3
- package/executors.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +5 -5
- package/mobile/desktopBin.test.ts +1 -1
- package/mobile/desktopServerStage.ts +4 -4
- package/mobile/mobileTarget.test.ts +10 -10
- package/mobile/mobileTarget.ts +17 -17
- package/mobile/nativeApp.test.ts +7 -7
- package/mobile/nativeApp.ts +11 -11
- package/mobile/nativeConfig.test.ts +47 -44
- package/mobile/nativeConfig.ts +29 -32
- package/mobile/nativeWebDir.test.ts +3 -3
- package/mobile/nativeWebDir.ts +4 -4
- package/package.json +2 -2
- package/workspaceLayout.test.ts +1 -1
- package/workspaceLayout.ts +2 -2
package/mobile/mobileTarget.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { AkanAppConfig,
|
|
1
|
+
import type { AkanNativeAppId } from "akanjs";
|
|
2
|
+
import type { AkanAppConfig, AkanNativeTarget, NativeEnv } from "../akanConfig";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
4
|
|
|
5
5
|
export type MobilePlatform = "ios" | "android";
|
|
@@ -9,26 +9,26 @@ export type MobileTargetSelection = string | "all" | undefined;
|
|
|
9
9
|
|
|
10
10
|
export interface ResolvedMobileTarget {
|
|
11
11
|
name: string;
|
|
12
|
-
config:
|
|
12
|
+
config: AkanNativeTarget;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export const MOBILE_ENVS = ["local", "debug", "develop", "main"] as const satisfies readonly
|
|
15
|
+
export const MOBILE_ENVS = ["local", "debug", "develop", "main"] as const satisfies readonly NativeEnv[];
|
|
16
16
|
|
|
17
17
|
const trimSlashes = (value: string) => value.replace(/^\/+|\/+$/g, "");
|
|
18
18
|
|
|
19
19
|
export const getMobileTargets = async (app: App): Promise<ResolvedMobileTarget[]> => {
|
|
20
20
|
const config = await app.getConfig();
|
|
21
|
-
return Object.entries(config.
|
|
21
|
+
return Object.entries(config.native.targets).map(([name, target]) => ({ name, config: target }));
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
//* An app with basePaths builds a CSR page per basePath and none at its root, so a target naming no basePath opens
|
|
25
25
|
//* nothing on its own there; it is the template each basePath's target is made from.
|
|
26
|
-
const isTemplateOnly = ({ basePaths,
|
|
27
|
-
basePaths.size > 0 && Object.values(
|
|
26
|
+
const isTemplateOnly = ({ basePaths, native }: Pick<AkanAppConfig, "basePaths" | "native">) =>
|
|
27
|
+
basePaths.size > 0 && Object.values(native.targets).every((target) => !target.basePath);
|
|
28
28
|
|
|
29
29
|
export const getMobileTargetChoices = async (app: App): Promise<string[]> => {
|
|
30
30
|
const config = await app.getConfig();
|
|
31
|
-
const targetNames = Object.keys(config.
|
|
31
|
+
const targetNames = Object.keys(config.native.targets);
|
|
32
32
|
if (targetNames.length > 0 && !isTemplateOnly(config)) return targetNames;
|
|
33
33
|
return [...config.basePaths];
|
|
34
34
|
};
|
|
@@ -56,15 +56,15 @@ export const resolveMobileTargets = async (
|
|
|
56
56
|
): Promise<ResolvedMobileTarget[]> => {
|
|
57
57
|
const config = await app.getConfig();
|
|
58
58
|
const targets = await getMobileTargets(app);
|
|
59
|
-
if (targets.length === 0) throw new Error(`No
|
|
59
|
+
if (targets.length === 0) throw new Error(`No native targets configured for ${app.name}`);
|
|
60
60
|
if (!selection && targets.length === 1 && !isTemplateOnly(config)) return targets;
|
|
61
61
|
if (!selection) {
|
|
62
62
|
const choices = await getMobileTargetChoices(app);
|
|
63
63
|
if (choices.length === 1) return resolveMobileTargets(app, choices[0]);
|
|
64
|
-
throw new Error(`Multiple
|
|
64
|
+
throw new Error(`Multiple native targets found for ${app.name}. Pass --target <${choices.join("|")}|all>.`);
|
|
65
65
|
}
|
|
66
66
|
if (selection === "all") {
|
|
67
|
-
if (Object.keys(config.
|
|
67
|
+
if (Object.keys(config.native.targets).length > 1) return targets;
|
|
68
68
|
const basePaths = [...config.basePaths];
|
|
69
69
|
if (basePaths.length > 1 || isTemplateOnly(config)) {
|
|
70
70
|
return basePaths.flatMap((basePath) => {
|
|
@@ -79,20 +79,20 @@ export const resolveMobileTargets = async (
|
|
|
79
79
|
const basePathTarget = resolveMobileTargetByBasePath(targets, selection);
|
|
80
80
|
if (basePathTarget && config.basePaths.has(trimSlashes(selection))) return [basePathTarget];
|
|
81
81
|
const choices = await getMobileTargetChoices(app);
|
|
82
|
-
throw new Error(`
|
|
82
|
+
throw new Error(`Native target '${selection}' was not found. Available: ${choices.join(", ")}`);
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
-
export const resolveAppId = (appId:
|
|
85
|
+
export const resolveAppId = (appId: AkanNativeAppId, platform: NativePlatform): string => {
|
|
86
86
|
if (typeof appId === "string") return appId;
|
|
87
87
|
const id = appId[platform] ?? appId.default;
|
|
88
|
-
if (!id) throw new Error(`
|
|
88
|
+
if (!id) throw new Error(`native.appId names no id for ${platform}; give it ${platform} or default.`);
|
|
89
89
|
return id;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
export const appIdsOf = (appId:
|
|
92
|
+
export const appIdsOf = (appId: AkanNativeAppId): string[] =>
|
|
93
93
|
typeof appId === "string" ? [appId] : Object.values(appId).filter((id): id is string => !!id);
|
|
94
94
|
|
|
95
|
-
export const resolveMobilePath = (target:
|
|
95
|
+
export const resolveMobilePath = (target: AkanNativeTarget, pathname: string) => {
|
|
96
96
|
const basePath = trimSlashes(target.basePath ?? "");
|
|
97
97
|
const normalizedPath = `/${pathname.replace(/^\/+/, "")}`;
|
|
98
98
|
if (!basePath) return normalizedPath;
|
|
@@ -100,7 +100,7 @@ export const resolveMobilePath = (target: AkanMobileTargetConfig, pathname: stri
|
|
|
100
100
|
return `/${basePath}${normalizedPath === "/" ? "" : normalizedPath}`;
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
-
export const targetHtmlFilename = (target:
|
|
103
|
+
export const targetHtmlFilename = (target: AkanNativeTarget) => {
|
|
104
104
|
const basePath = trimSlashes(target.basePath ?? "");
|
|
105
105
|
return basePath ? `${basePath}.html` : "index.html";
|
|
106
106
|
};
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { AkanPlugin } from "akanjs";
|
|
4
|
-
import type {
|
|
4
|
+
import type { AkanNativeTarget } from "../akanConfig";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { tempDirs, writeText } from "../testHelpers";
|
|
7
7
|
import { NativeApp } from "./nativeApp";
|
|
@@ -9,7 +9,7 @@ import { NativeConfig } from "./nativeConfig";
|
|
|
9
9
|
|
|
10
10
|
const makeTempRoot = tempDirs("akan-native-app-");
|
|
11
11
|
|
|
12
|
-
const target = (config: Partial<
|
|
12
|
+
const target = (config: Partial<AkanNativeTarget> = {}) => ({
|
|
13
13
|
name: config.name ?? "default",
|
|
14
14
|
config: {
|
|
15
15
|
name: config.name ?? "default",
|
|
@@ -39,10 +39,10 @@ describe("NativeApp", () => {
|
|
|
39
39
|
test("keeps each target's web root and each platform's output apart", () => {
|
|
40
40
|
const admin = new NativeApp(fakeApp(), target({ name: "admin", basePath: "admin" }));
|
|
41
41
|
|
|
42
|
-
expect(admin.web.dir).toBe(path.join(appDir, ".akan/
|
|
43
|
-
expect(admin.outDir("ios")).toBe(path.join(appDir, ".akan/
|
|
44
|
-
expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/
|
|
45
|
-
expect(admin.devOutDir("macos")).toBe(path.join(appDir, ".akan/
|
|
42
|
+
expect(admin.web.dir).toBe(path.join(appDir, ".akan/native/admin/web"));
|
|
43
|
+
expect(admin.outDir("ios")).toBe(path.join(appDir, ".akan/native/admin/build/ios"));
|
|
44
|
+
expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/native/admin/build/android"));
|
|
45
|
+
expect(admin.devOutDir("macos")).toBe(path.join(appDir, ".akan/native/admin/dev/macos"));
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
|
|
@@ -106,7 +106,7 @@ describe("NativeApp", () => {
|
|
|
106
106
|
|
|
107
107
|
expect(warnings).toEqual([]);
|
|
108
108
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
|
|
109
|
-
expect(config.web.dir).toBe(path.join(appDir, ".akan/
|
|
109
|
+
expect(config.web.dir).toBe(path.join(appDir, ".akan/native/default/web"));
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
3
|
-
import type {
|
|
3
|
+
import type { NativeEnv } from "akanjs";
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { Executor } from "../executors";
|
|
6
6
|
import { DesktopBin } from "./desktopBin";
|
|
@@ -36,7 +36,7 @@ interface NativeDevBoot {
|
|
|
36
36
|
|
|
37
37
|
export interface NativeBuildOptions {
|
|
38
38
|
profile?: "debug" | "release";
|
|
39
|
-
/** The server a desktop app carries (the target's `
|
|
39
|
+
/** The server a desktop app carries (the target's `desktop.server`), staged by DesktopServerStage. */
|
|
40
40
|
server?: DesktopServerBundle;
|
|
41
41
|
/** Windows: an NSIS setup program beside the app folder. */
|
|
42
42
|
installer?: boolean;
|
|
@@ -48,7 +48,7 @@ export interface NativeDevOptions extends NativeRunOptions {
|
|
|
48
48
|
lang: string;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** One
|
|
51
|
+
/** One native target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
|
|
52
52
|
* what it ships, and the calls into the API. */
|
|
53
53
|
export class NativeApp {
|
|
54
54
|
readonly targetRoot: string;
|
|
@@ -59,14 +59,14 @@ export class NativeApp {
|
|
|
59
59
|
constructor(
|
|
60
60
|
readonly app: App,
|
|
61
61
|
readonly target: ResolvedMobileTarget,
|
|
62
|
-
readonly env?:
|
|
62
|
+
readonly env?: NativeEnv,
|
|
63
63
|
) {
|
|
64
|
-
this.targetRoot = path.join(app.cwdPath, ".akan", "
|
|
64
|
+
this.targetRoot = path.join(app.cwdPath, ".akan", "native", target.name);
|
|
65
65
|
this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
outDir(platform: NativePlatform) {
|
|
69
|
-
return path.join(this.targetRoot, "
|
|
69
|
+
return path.join(this.targetRoot, "build", platform);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
//? Apart from the builds: a desktop build empties its folder first, which would take a release and its installer.
|
|
@@ -74,7 +74,7 @@ export class NativeApp {
|
|
|
74
74
|
return path.join(this.targetRoot, "dev", platform);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** The web root, from the production build the
|
|
77
|
+
/** The web root, from the production build the native commands run first. */
|
|
78
78
|
async assembleWeb() {
|
|
79
79
|
const dist = this.app.dist.cwdPath;
|
|
80
80
|
return await this.web.assemble(this.target.config, {
|
|
@@ -135,7 +135,7 @@ export class NativeApp {
|
|
|
135
135
|
const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
|
|
136
136
|
if (problems.length)
|
|
137
137
|
throw new Error(
|
|
138
|
-
`
|
|
138
|
+
`Native target '${this.target.name}' makes an invalid native config:\n- ${problems.join("\n- ")}`,
|
|
139
139
|
);
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -298,7 +298,7 @@ export class NativeApp {
|
|
|
298
298
|
});
|
|
299
299
|
} catch (error) {
|
|
300
300
|
throw new Error(
|
|
301
|
-
`
|
|
301
|
+
`Native target '${this.target.name}': ${error instanceof Error ? error.message : String(error)}`,
|
|
302
302
|
{
|
|
303
303
|
cause: error,
|
|
304
304
|
},
|
|
@@ -309,7 +309,7 @@ export class NativeApp {
|
|
|
309
309
|
#assertUpdates() {
|
|
310
310
|
if (!this.target.config.updates)
|
|
311
311
|
throw new Error(
|
|
312
|
-
`
|
|
312
|
+
`Native target '${this.target.name}' has no updates: add native.updates: { url, publicKey } to akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
|
|
313
313
|
);
|
|
314
314
|
}
|
|
315
315
|
|
|
@@ -335,7 +335,7 @@ export class NativeApp {
|
|
|
335
335
|
const { api, config } = await this.prepare(platform);
|
|
336
336
|
if (!config.updates)
|
|
337
337
|
throw new Error(
|
|
338
|
-
`
|
|
338
|
+
`Native target '${this.target.name}' takes no updates: add native.updates: { url, publicKey } to akan.config.ts first.`,
|
|
339
339
|
);
|
|
340
340
|
const packed = await api.packUpdate({
|
|
341
341
|
...this.#task(platform, config),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type {
|
|
4
|
+
import type { AkanNativeTarget } from "../akanConfig";
|
|
5
5
|
import { tempDirs } from "../testHelpers";
|
|
6
6
|
import { NativeApi } from "./nativeApi";
|
|
7
7
|
import { NativeConfig } from "./nativeConfig";
|
|
@@ -9,7 +9,7 @@ import { NativeConfig } from "./nativeConfig";
|
|
|
9
9
|
const makeTempRoot = tempDirs("akan-native-config-");
|
|
10
10
|
const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
|
|
11
11
|
|
|
12
|
-
const minimalTarget:
|
|
12
|
+
const minimalTarget: AkanNativeTarget = {
|
|
13
13
|
name: "default",
|
|
14
14
|
indexPath: "/explore",
|
|
15
15
|
appName: "minimal",
|
|
@@ -17,16 +17,12 @@ const minimalTarget: AkanMobileTargetConfig = {
|
|
|
17
17
|
version: "0.0.1",
|
|
18
18
|
buildNum: 1,
|
|
19
19
|
permissions: ["push"],
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
domains: ["example.com"],
|
|
24
|
-
ios: { teamId: "TEAMID" },
|
|
25
|
-
android: { sha256CertFingerprints: ["00:11"] },
|
|
26
|
-
},
|
|
20
|
+
deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
|
|
21
|
+
ios: { teamId: "TEAMID" },
|
|
22
|
+
android: { googleServices: "secrets/google-services.json", sha256CertFingerprints: ["00:11"] },
|
|
27
23
|
};
|
|
28
24
|
|
|
29
|
-
const adminTarget:
|
|
25
|
+
const adminTarget: AkanNativeTarget = {
|
|
30
26
|
name: "admin",
|
|
31
27
|
basePath: "admin",
|
|
32
28
|
appName: "Portal Admin",
|
|
@@ -34,14 +30,15 @@ const adminTarget: AkanMobileTargetConfig = {
|
|
|
34
30
|
fileName: "portal-admin",
|
|
35
31
|
version: "2.1.0",
|
|
36
32
|
buildNum: 42,
|
|
37
|
-
|
|
33
|
+
icon: "assets/icon.png",
|
|
34
|
+
splash: "assets/splash.png",
|
|
38
35
|
permissions: ["camera", "location", "contacts", "speech"],
|
|
39
36
|
deepLinks: { domains: ["portal.example"] },
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
plugins: ["iap"],
|
|
38
|
+
ios: { infoPlist: { ITSAppUsesNonExemptEncryption: false } },
|
|
39
|
+
android: {
|
|
40
|
+
manifest: ['<queries><package android:name="com.kakao.talk" /></queries>'],
|
|
41
|
+
files: { "res/raw/chime.mp3": "assets/chime.mp3" },
|
|
45
42
|
},
|
|
46
43
|
};
|
|
47
44
|
|
|
@@ -52,7 +49,7 @@ describe("NativeConfig.build", () => {
|
|
|
52
49
|
const { config, warnings } = NativeConfig.build({
|
|
53
50
|
appPath: "/repo/apps/minimal",
|
|
54
51
|
target: minimalTarget,
|
|
55
|
-
webDir: "/repo/apps/minimal/.akan/
|
|
52
|
+
webDir: "/repo/apps/minimal/.akan/native/default/web",
|
|
56
53
|
contributions: [{ permission: "push", plugins: ["push"] }],
|
|
57
54
|
locales: ["en", "ko"],
|
|
58
55
|
platform: "android",
|
|
@@ -61,7 +58,7 @@ describe("NativeConfig.build", () => {
|
|
|
61
58
|
expect(warnings).toEqual([]);
|
|
62
59
|
expect(config).toEqual({
|
|
63
60
|
app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
|
|
64
|
-
web: { dir: "/repo/apps/minimal/.akan/
|
|
61
|
+
web: { dir: "/repo/apps/minimal/.akan/native/default/web" },
|
|
65
62
|
plugins: [...NativeConfig.basePlugins, "single-instance", "push"],
|
|
66
63
|
capabilities: [
|
|
67
64
|
{
|
|
@@ -83,7 +80,7 @@ describe("NativeConfig.build", () => {
|
|
|
83
80
|
const { config, warnings } = NativeConfig.build({
|
|
84
81
|
appPath: "/repo/apps/portal",
|
|
85
82
|
target: adminTarget,
|
|
86
|
-
webDir: "/repo/apps/portal/.akan/
|
|
83
|
+
webDir: "/repo/apps/portal/.akan/native/admin/web",
|
|
87
84
|
contributions: [],
|
|
88
85
|
locales: ["en", "ko"],
|
|
89
86
|
platform: "android",
|
|
@@ -92,7 +89,7 @@ describe("NativeConfig.build", () => {
|
|
|
92
89
|
expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
|
|
93
90
|
expect(config).toEqual({
|
|
94
91
|
app: { id: "com.portal.admin", name: "Portal Admin", fileName: "portal-admin", version: "2.1.0", build: 42 },
|
|
95
|
-
web: { dir: "/repo/apps/portal/.akan/
|
|
92
|
+
web: { dir: "/repo/apps/portal/.akan/native/admin/web" },
|
|
96
93
|
plugins: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"],
|
|
97
94
|
capabilities: [
|
|
98
95
|
{
|
|
@@ -129,7 +126,15 @@ describe("NativeConfig.build", () => {
|
|
|
129
126
|
test("a plugin that claims a permission replaces the builtin one and adds its Android entries", () => {
|
|
130
127
|
const { config } = NativeConfig.build({
|
|
131
128
|
appPath: "/repo/apps/portal",
|
|
132
|
-
target: {
|
|
129
|
+
target: {
|
|
130
|
+
...adminTarget,
|
|
131
|
+
permissions: ["camera"],
|
|
132
|
+
plugins: undefined,
|
|
133
|
+
ios: undefined,
|
|
134
|
+
android: undefined,
|
|
135
|
+
icon: undefined,
|
|
136
|
+
splash: undefined,
|
|
137
|
+
},
|
|
133
138
|
webDir: "/web",
|
|
134
139
|
contributions: [
|
|
135
140
|
{
|
|
@@ -158,7 +163,7 @@ describe("NativeConfig.build", () => {
|
|
|
158
163
|
});
|
|
159
164
|
|
|
160
165
|
test("updates bring their plugin and follow the binary's backend unless a channel is named", () => {
|
|
161
|
-
const build = (updates:
|
|
166
|
+
const build = (updates: AkanNativeTarget["updates"], env?: "main" | "develop") =>
|
|
162
167
|
NativeConfig.build({
|
|
163
168
|
appPath: "/repo/apps/portal",
|
|
164
169
|
target: { ...adminTarget, updates },
|
|
@@ -190,14 +195,10 @@ describe("NativeConfig.build", () => {
|
|
|
190
195
|
appPath: "/repo/apps/portal",
|
|
191
196
|
target: {
|
|
192
197
|
...adminTarget,
|
|
193
|
-
assets:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
},
|
|
197
|
-
native: {
|
|
198
|
-
push: { android: { smallIcon: "assets/noti.png", color: "#ff5a5f", channel: { id: "chat", name: "Chat" } } },
|
|
199
|
-
privacy: { tracking: false, accessedApis: { DiskSpace: ["E174.1"] } },
|
|
200
|
-
},
|
|
198
|
+
icon: { image: "assets/icon.png", backgroundColor: "#ffffff" },
|
|
199
|
+
splash: { backgroundColor: { light: "#ffffff", dark: "#000000" }, autoHide: false },
|
|
200
|
+
android: { push: { smallIcon: "assets/noti.png", color: "#ff5a5f", channel: { id: "chat", name: "Chat" } } },
|
|
201
|
+
ios: { privacy: { tracking: false, accessedApis: { DiskSpace: ["E174.1"] } } },
|
|
201
202
|
},
|
|
202
203
|
webDir: "/web",
|
|
203
204
|
contributions: [],
|
|
@@ -238,14 +239,11 @@ describe("NativeConfig.build", () => {
|
|
|
238
239
|
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
239
240
|
const api = await NativeApi.load(repoApp);
|
|
240
241
|
|
|
241
|
-
const adminWithEverything:
|
|
242
|
+
const adminWithEverything: AkanNativeTarget = {
|
|
242
243
|
...adminTarget,
|
|
243
244
|
updates: { url: "https://updates.example.com/portal", publicKey: updatesKey },
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
push: { android: { smallIcon: "assets/icon.png", color: "#ff5a5f" } },
|
|
247
|
-
privacy: { tracking: false },
|
|
248
|
-
},
|
|
245
|
+
ios: { ...adminTarget.ios, privacy: { tracking: false } },
|
|
246
|
+
android: { ...adminTarget.android, push: { smallIcon: "assets/icon.png", color: "#ff5a5f" } },
|
|
249
247
|
};
|
|
250
248
|
for (const target of [minimalTarget, adminWithEverything]) {
|
|
251
249
|
const { config } = NativeConfig.build({
|
|
@@ -267,7 +265,13 @@ describe("NativeConfig.build", () => {
|
|
|
267
265
|
await writeManifest(path.join(root, "vendor", "led-panel"), "led-panel");
|
|
268
266
|
const { config } = NativeConfig.build({
|
|
269
267
|
appPath: root,
|
|
270
|
-
target: {
|
|
268
|
+
target: {
|
|
269
|
+
...minimalTarget,
|
|
270
|
+
permissions: [],
|
|
271
|
+
plugins: ["./vendor/led-panel"],
|
|
272
|
+
deepLinks: undefined,
|
|
273
|
+
android: undefined,
|
|
274
|
+
},
|
|
271
275
|
webDir: path.join(root, "web"),
|
|
272
276
|
contributions: [],
|
|
273
277
|
locales: ["en"],
|
|
@@ -293,8 +297,9 @@ describe("NativeConfig.build", () => {
|
|
|
293
297
|
target: {
|
|
294
298
|
...minimalTarget,
|
|
295
299
|
permissions: [],
|
|
296
|
-
|
|
300
|
+
plugins: ["./native/kiosk/", "kiosk", "haptics"],
|
|
297
301
|
deepLinks: undefined,
|
|
302
|
+
android: undefined,
|
|
298
303
|
},
|
|
299
304
|
webDir: path.join(root, "web"),
|
|
300
305
|
contributions: [],
|
|
@@ -314,10 +319,8 @@ describe("NativeConfig.build", () => {
|
|
|
314
319
|
...minimalTarget,
|
|
315
320
|
permissions: [],
|
|
316
321
|
deepLinks: undefined,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
desktop: { recovery: "reload", window: { fullscreen: true, skipTaskbar: true }, screenCapture: "auto" },
|
|
320
|
-
},
|
|
322
|
+
android: { autoplay: true },
|
|
323
|
+
desktop: { recovery: "reload", window: { fullscreen: true, skipTaskbar: true }, screenCapture: "auto" },
|
|
321
324
|
},
|
|
322
325
|
webDir: path.join(root, "web"),
|
|
323
326
|
contributions: [],
|
|
@@ -342,7 +345,7 @@ describe("NativeConfig.build", () => {
|
|
|
342
345
|
const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=`, channel: "pilot" };
|
|
343
346
|
const { config } = NativeConfig.build({
|
|
344
347
|
appPath: "/repo/apps/board",
|
|
345
|
-
target: { ...minimalTarget, permissions: [], deepLinks: undefined,
|
|
348
|
+
target: { ...minimalTarget, permissions: [], deepLinks: undefined, android: undefined, updates },
|
|
346
349
|
webDir: "/web",
|
|
347
350
|
contributions: [],
|
|
348
351
|
locales: ["en"],
|
|
@@ -362,7 +365,7 @@ describe("NativeConfig.build", () => {
|
|
|
362
365
|
...minimalTarget,
|
|
363
366
|
permissions: [],
|
|
364
367
|
deepLinks: undefined,
|
|
365
|
-
|
|
368
|
+
android: undefined,
|
|
366
369
|
updates: { ...updates, ...(channel ? { channel } : {}) },
|
|
367
370
|
},
|
|
368
371
|
webDir: "/web",
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
|
-
import type {
|
|
5
|
-
AkanMobileTargetConfig,
|
|
6
|
-
AkanNativeValue,
|
|
7
|
-
AkanPluginNativeConfig,
|
|
8
|
-
MobileEnv,
|
|
9
|
-
MobilePermission,
|
|
10
|
-
} from "akanjs";
|
|
4
|
+
import type { AkanNativeTarget, AkanNativeValue, AkanPluginNativeConfig, NativeEnv, NativePermission } from "akanjs";
|
|
11
5
|
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
12
6
|
import { type NativePlatform, resolveAppId } from "./mobileTarget";
|
|
13
7
|
import type { NativePluginFolder } from "./nativePluginFolders";
|
|
@@ -16,16 +10,16 @@ import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
|
|
|
16
10
|
export interface NativeConfigInput {
|
|
17
11
|
/** The app folder; every relative path in the target resolves against it. */
|
|
18
12
|
appPath: string;
|
|
19
|
-
target:
|
|
13
|
+
target: AkanNativeTarget;
|
|
20
14
|
/** The assembled web root the app loads. */
|
|
21
15
|
webDir: string;
|
|
22
16
|
/** What the app's and its libs' plugins declare. */
|
|
23
17
|
contributions: AkanPluginNativeConfig[];
|
|
24
18
|
locales: readonly string[];
|
|
25
19
|
/** The backend the binary talks to; an updates channel left unnamed follows it. */
|
|
26
|
-
env?:
|
|
20
|
+
env?: NativeEnv;
|
|
27
21
|
platform: NativePlatform;
|
|
28
|
-
/** A desktop build that carries its server (the target's `
|
|
22
|
+
/** A desktop build that carries its server (the target's `desktop.server`). */
|
|
29
23
|
desktopServer?: DesktopServerBundle;
|
|
30
24
|
/** The plugins in the app's and its libs' `native/` folders. */
|
|
31
25
|
nativePlugins?: NativePluginFolder[];
|
|
@@ -57,7 +51,7 @@ export class NativeConfig {
|
|
|
57
51
|
"haptics",
|
|
58
52
|
] as const;
|
|
59
53
|
//* Used when no plugin of the app or its libs claims the permission; speech has no native plugin yet.
|
|
60
|
-
static readonly builtinContributions: { [permission in
|
|
54
|
+
static readonly builtinContributions: { [permission in NativePermission]: AkanPluginNativeConfig } = {
|
|
61
55
|
camera: {
|
|
62
56
|
permission: "camera",
|
|
63
57
|
plugins: ["camera"],
|
|
@@ -112,7 +106,7 @@ export class NativeConfig {
|
|
|
112
106
|
const folders = new Set(nativePlugins.map((plugin) => path.resolve(plugin.dir)));
|
|
113
107
|
const folderIds = new Set(nativePlugins.map((plugin) => plugin.id));
|
|
114
108
|
//? A `native/<id>` folder ships by itself; listed again, by path or by id, it would reach the runtime twice.
|
|
115
|
-
const listed = (target.
|
|
109
|
+
const listed = (target.plugins ?? []).filter((spec) =>
|
|
116
110
|
NativeConfig.#isId(spec) ? !folderIds.has(spec) : !folders.has(path.resolve(appPath, spec)),
|
|
117
111
|
);
|
|
118
112
|
const plugins = [
|
|
@@ -142,11 +136,11 @@ export class NativeConfig {
|
|
|
142
136
|
const ios = NativeConfig.#compact<NativeIos>({
|
|
143
137
|
infoPlist: NativeConfig.#merged(
|
|
144
138
|
applied.map((c) => c.infoPlist),
|
|
145
|
-
target.
|
|
139
|
+
target.ios?.infoPlist,
|
|
146
140
|
),
|
|
147
141
|
entitlements: NativeConfig.#merged(
|
|
148
142
|
applied.map((c) => c.entitlements),
|
|
149
|
-
target.
|
|
143
|
+
target.ios?.entitlements,
|
|
150
144
|
),
|
|
151
145
|
});
|
|
152
146
|
const manifest = [
|
|
@@ -156,21 +150,24 @@ export class NativeConfig {
|
|
|
156
150
|
...[...new Set(applied.flatMap((c) => c.androidFeatures ?? []))].map(
|
|
157
151
|
(name) => `<uses-feature android:name="${name}" android:required="false" />`,
|
|
158
152
|
),
|
|
159
|
-
...(target.
|
|
153
|
+
...(target.android?.manifest ?? []),
|
|
160
154
|
];
|
|
161
155
|
const android = NativeConfig.#compact({
|
|
162
156
|
manifest,
|
|
163
|
-
application: target.
|
|
164
|
-
activity: target.
|
|
157
|
+
application: target.android?.application ?? [],
|
|
158
|
+
activity: target.android?.activity ?? [],
|
|
165
159
|
});
|
|
166
|
-
const resources =
|
|
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
|
+
];
|
|
167
164
|
const native = NativeConfig.#compact({ ios, android, resources });
|
|
168
|
-
const googleServices = target.
|
|
169
|
-
const pushAndroid = target.
|
|
165
|
+
const googleServices = target.android?.googleServices;
|
|
166
|
+
const pushAndroid = target.android?.push;
|
|
170
167
|
const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
|
|
171
|
-
recovery: target.
|
|
172
|
-
screenCapture: target.
|
|
173
|
-
window: NativeConfig.#compact({ ...target.
|
|
168
|
+
recovery: target.desktop?.recovery,
|
|
169
|
+
screenCapture: target.desktop?.screenCapture,
|
|
170
|
+
window: NativeConfig.#compact({ ...target.desktop?.window }),
|
|
174
171
|
server: desktopServer,
|
|
175
172
|
bin: desktopBin,
|
|
176
173
|
});
|
|
@@ -206,30 +203,30 @@ export class NativeConfig {
|
|
|
206
203
|
},
|
|
207
204
|
}
|
|
208
205
|
: {}),
|
|
209
|
-
...(target.
|
|
206
|
+
...(target.ios?.privacy ? { privacy: target.ios.privacy } : {}),
|
|
210
207
|
//? assetlinks.json vouches for `<appId>.debug` outside main, the suffix a debug build installs under.
|
|
211
208
|
android: {
|
|
212
209
|
debugAppIdSuffix: ".debug",
|
|
213
210
|
...(googleServices ? { googleServices: abs(googleServices) } : {}),
|
|
214
|
-
...(target.
|
|
211
|
+
...(target.android?.autoplay ? { autoplay: true } : {}),
|
|
215
212
|
},
|
|
216
213
|
keyboard: { resize: "none" },
|
|
217
214
|
...(desktop ? { desktop } : {}),
|
|
218
|
-
...(target.
|
|
219
|
-
...(target.
|
|
215
|
+
...(target.icon ? { icon: NativeConfig.#icon(target.icon, abs) } : {}),
|
|
216
|
+
...(target.splash ? { splash: NativeConfig.#splash(target.splash, abs) } : {}),
|
|
220
217
|
};
|
|
221
218
|
return { config, warnings };
|
|
222
219
|
}
|
|
223
220
|
|
|
224
221
|
static #icon(
|
|
225
|
-
icon: NonNullable<
|
|
222
|
+
icon: NonNullable<AkanNativeTarget["icon"]>,
|
|
226
223
|
abs: (relative: string) => string,
|
|
227
224
|
): NonNullable<AkanNativeConfig["icon"]> {
|
|
228
225
|
return typeof icon === "string" ? abs(icon) : { ...icon, image: abs(icon.image) };
|
|
229
226
|
}
|
|
230
227
|
|
|
231
228
|
static #splash(
|
|
232
|
-
splash: NonNullable<
|
|
229
|
+
splash: NonNullable<AkanNativeTarget["splash"]>,
|
|
233
230
|
abs: (relative: string) => string,
|
|
234
231
|
): NonNullable<AkanNativeConfig["splash"]> {
|
|
235
232
|
if (typeof splash === "string") return { image: abs(splash) };
|
|
@@ -238,8 +235,8 @@ export class NativeConfig {
|
|
|
238
235
|
|
|
239
236
|
//? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
|
|
240
237
|
static #updates(
|
|
241
|
-
target:
|
|
242
|
-
env?:
|
|
238
|
+
target: AkanNativeTarget,
|
|
239
|
+
env?: NativeEnv,
|
|
243
240
|
): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
244
241
|
const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
|
|
245
242
|
return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
|
|
@@ -286,7 +283,7 @@ export class NativeConfig {
|
|
|
286
283
|
}
|
|
287
284
|
|
|
288
285
|
//* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
|
|
289
|
-
static #deepLinks(target:
|
|
286
|
+
static #deepLinks(target: AkanNativeTarget, locales: readonly string[]) {
|
|
290
287
|
const basePath = target.basePath?.replace(/^\/+|\/+$/g, "");
|
|
291
288
|
const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
|
|
292
289
|
return {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type {
|
|
4
|
+
import type { AkanNativeTarget } from "../akanConfig";
|
|
5
5
|
import { tempDirs } from "../testHelpers";
|
|
6
6
|
import { NativeWebDir } from "./nativeWebDir";
|
|
7
7
|
|
|
8
8
|
const makeTempRoot = tempDirs("akan-native-web-");
|
|
9
9
|
|
|
10
|
-
const target:
|
|
10
|
+
const target: AkanNativeTarget = {
|
|
11
11
|
name: "admin",
|
|
12
12
|
basePath: "/admin/",
|
|
13
13
|
indexPath: "/home",
|
|
@@ -68,7 +68,7 @@ describe("NativeWebDir", () => {
|
|
|
68
68
|
const root = await makeTempRoot();
|
|
69
69
|
const web = new NativeWebDir(path.join(root, "web"));
|
|
70
70
|
await expect(web.assemble(target, { html: path.join(root, "none.html") })).rejects.toThrow(
|
|
71
|
-
"CSR html for
|
|
71
|
+
"CSR html for native target 'admin' not found",
|
|
72
72
|
);
|
|
73
73
|
|
|
74
74
|
await write(path.join(root, "index.html"), "<html><head></head></html>");
|
package/mobile/nativeWebDir.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type {
|
|
3
|
+
import type { AkanNativeTarget } from "akanjs";
|
|
4
4
|
|
|
5
5
|
export interface NativeWebDirSources {
|
|
6
6
|
/** The target's CSR bundle: one HTML file with its scripts and styles inlined. */
|
|
@@ -19,7 +19,7 @@ export class NativeWebDir {
|
|
|
19
19
|
|
|
20
20
|
//* The bundle is inlined into this page and its own code names the global, so only the tag marks an injection;
|
|
21
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<
|
|
22
|
+
static injectTarget(html: string, target: Pick<AkanNativeTarget, "name" | "basePath" | "indexPath">) {
|
|
23
23
|
if (html.includes(NativeWebDir.#targetTag)) return html;
|
|
24
24
|
const basePath = target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
|
|
25
25
|
const script = `${NativeWebDir.#targetTag}${JSON.stringify({
|
|
@@ -40,9 +40,9 @@ export class NativeWebDir {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** Rebuilds the folder from scratch and answers its files as web paths: relative, `/`-separated and sorted. */
|
|
43
|
-
async assemble(target:
|
|
43
|
+
async assemble(target: AkanNativeTarget, { html, publicDir, fontsDir }: NativeWebDirSources) {
|
|
44
44
|
if (!(await Bun.file(html).exists()))
|
|
45
|
-
throw new Error(`CSR html for
|
|
45
|
+
throw new Error(`CSR html for native target '${target.name}' not found: ${html}`);
|
|
46
46
|
await this.clear();
|
|
47
47
|
if (publicDir && (await NativeWebDir.#isDir(publicDir))) await cp(publicDir, this.dir, { recursive: true });
|
|
48
48
|
if (fontsDir && (await NativeWebDir.#isDir(fontsDir)))
|