@akanjs/devkit 3.0.0-beta.20 → 3.0.0-beta.21
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/README.ko.md +2 -2
- package/README.md +2 -2
- package/akanConfig/akanConfig.test.ts +58 -38
- package/akanConfig/akanConfig.ts +88 -48
- package/akanConfig/types.ts +3 -3
- package/akanContext.ts +5 -2
- package/artifact/implicitRootLayout.test.ts +23 -0
- package/artifact/implicitRootLayout.ts +24 -0
- package/biome.base.json +26 -0
- package/commandDecorators/command.ts +1 -1
- package/executors.ts +1 -12
- package/incrementalBuilder/buildBatch.proc.ts +7 -1
- package/index.ts +0 -2
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/mobile/index.ts +6 -0
- package/mobile/mobileTarget.ts +2 -0
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +85 -0
- package/mobile/nativeApp.ts +210 -0
- package/mobile/nativeConfig.test.ts +199 -0
- package/mobile/nativeConfig.ts +184 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +62 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +2 -7
- package/qualityScanner.ts +4 -2
- package/scanInfo.ts +5 -10
- package/workspaceLayout.test.ts +13 -3
- package/workspaceLayout.ts +16 -4
- package/applicationReleasePackager.ts +0 -189
- package/capacitorApp.test.ts +0 -388
- package/capacitorApp.ts +0 -1520
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// A native shell serves the page from app://localhost on iOS, macOS and Linux, a custom scheme WebKit keeps no
|
|
5
|
+
// cookie jar for: `document.cookie` reads "" and a write is dropped without an error, so the value exists in the
|
|
6
|
+
// browser and silently vanishes in the app. akanjs/client's cookie helpers keep an app:// page's cookies in a
|
|
7
|
+
// storage-backed jar, which is the only spelling that works in every shell.
|
|
8
|
+
//
|
|
9
|
+
// Pinned to the member nodes: a bare snippet also matches JSX text that spells the same words (a docs page), and a
|
|
10
|
+
// write is its own node (JsStaticMemberAssignment) that the expression form does not match.
|
|
11
|
+
or {
|
|
12
|
+
JsStaticMemberExpression() as $access where {
|
|
13
|
+
$access <: or { `document.cookie`, `window.document.cookie`, `globalThis.document.cookie`, `self.document.cookie` },
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $access,
|
|
16
|
+
message = "`document.cookie` is always empty on a native app page (app://localhost keeps no cookies), so this value vanishes in the app. Use `getCookie` / `setCookie` / `removeCookie` from `akanjs/client`.",
|
|
17
|
+
severity = "error"
|
|
18
|
+
)
|
|
19
|
+
},
|
|
20
|
+
JsStaticMemberAssignment(object = $object, member = $member) as $access where {
|
|
21
|
+
$member <: `cookie`,
|
|
22
|
+
$object <: or { `document`, `window.document`, `globalThis.document`, `self.document` },
|
|
23
|
+
register_diagnostic(
|
|
24
|
+
span = $access,
|
|
25
|
+
message = "`document.cookie` is always empty on a native app page (app://localhost keeps no cookies), so this value vanishes in the app. Use `getCookie` / `setCookie` / `removeCookie` from `akanjs/client`.",
|
|
26
|
+
severity = "error"
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Browser APIs a native shell's WebView lacks: Android's WebView has no Web Share, an app:// page on iOS has no
|
|
5
|
+
// service worker, no WebView has the Notification API, and iOS has no vibration. They stay legal on the web, so the
|
|
6
|
+
// rule does not ban them — it keeps them in `webkit/`, the layer whose hooks branch on the platform (and is
|
|
7
|
+
// excluded from this rule's scope in biome.base.json), and names the native side where one exists.
|
|
8
|
+
//
|
|
9
|
+
// Pinned to member access, so JSX text naming an API does not match; `Notification` only through its static entry
|
|
10
|
+
// points, because the name is also the libs/shared model (`Notification.View`, `cnst.Notification`).
|
|
11
|
+
or {
|
|
12
|
+
JsStaticMemberExpression() as $access where {
|
|
13
|
+
$access <: or { `navigator.share`, `navigator.canShare` },
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $access,
|
|
16
|
+
message = "Android's WebView has no Web Share, so this does nothing in the app. Move it into a `webkit/` hook that branches on `isNativeApp()` from `akanjs/client/native`; the runtime's `share` plugin is the app side.",
|
|
17
|
+
severity = "warn"
|
|
18
|
+
)
|
|
19
|
+
},
|
|
20
|
+
JsStaticMemberExpression() as $access where {
|
|
21
|
+
$access <: or { `navigator.serviceWorker`, `Notification.requestPermission`, `Notification.permission` },
|
|
22
|
+
register_diagnostic(
|
|
23
|
+
span = $access,
|
|
24
|
+
message = "A native app page has no service worker or Notification API. Keep this in a `webkit/` hook behind `isNativeApp()`; native push is `push` from `akanjs/client/native`, and `usePushNotification` in libs/util wraps both.",
|
|
25
|
+
severity = "warn"
|
|
26
|
+
)
|
|
27
|
+
},
|
|
28
|
+
JsStaticMemberExpression() as $access where {
|
|
29
|
+
$access <: `navigator.geolocation`,
|
|
30
|
+
register_diagnostic(
|
|
31
|
+
span = $access,
|
|
32
|
+
message = "`navigator.geolocation` skips the app's location plugin and its permission flow. Use `useGeoLocation` from `akanjs/webkit`.",
|
|
33
|
+
severity = "warn"
|
|
34
|
+
)
|
|
35
|
+
},
|
|
36
|
+
JsStaticMemberExpression() as $access where {
|
|
37
|
+
$access <: `navigator.vibrate`,
|
|
38
|
+
register_diagnostic(
|
|
39
|
+
span = $access,
|
|
40
|
+
message = "iOS has no `navigator.vibrate`. Use `haptics` from `akanjs/client/native` in a `webkit/` hook.",
|
|
41
|
+
severity = "warn"
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Web Storage works inside the native shells, so this is not about a crash: akanjs/client's `storage` is where the
|
|
5
|
+
// backend is chosen per platform (the shell's Preferences — UserDefaults, SharedPreferences, a JSON file on the
|
|
6
|
+
// desktop — in an app, localStorage on the web), `secretStorage` keeps credentials in the Keychain/Keystore and
|
|
7
|
+
// clears them on a reinstall, and both answer nothing on the server where a bare `localStorage` read during SSR
|
|
8
|
+
// throws.
|
|
9
|
+
//
|
|
10
|
+
// Only the global is matched — a reference, or a member of window/globalThis/self — pinned to those node kinds: a
|
|
11
|
+
// bare snippet matches the name in any position, a field, an object key or JSX text included.
|
|
12
|
+
or {
|
|
13
|
+
JsIdentifierExpression() as $access where {
|
|
14
|
+
$access <: or { `localStorage`, `sessionStorage` },
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $access,
|
|
17
|
+
message = "Web Storage bypasses the store akanjs picks per platform (the shell's Preferences in an app) and throws during SSR. Use `storage` from `akanjs/client`, or `secretStorage` for tokens and credentials; a value that may be lost with the tab belongs in component state or the store.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
JsStaticMemberExpression(object = $root, member = $name) as $access where {
|
|
22
|
+
$root <: or { `window`, `globalThis`, `self` },
|
|
23
|
+
$name <: or { `localStorage`, `sessionStorage` },
|
|
24
|
+
register_diagnostic(
|
|
25
|
+
span = $access,
|
|
26
|
+
message = "Web Storage bypasses the store akanjs picks per platform (the shell's Preferences in an app) and throws during SSR. Use `storage` from `akanjs/client`, or `secretStorage` for tokens and credentials; a value that may be lost with the tab belongs in component state or the store.",
|
|
27
|
+
severity = "error"
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
}
|
package/mobile/index.ts
CHANGED
package/mobile/mobileTarget.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { AkanMobileTargetConfig, MobileEnv } from "../akanConfig";
|
|
|
2
2
|
import type { App } from "../commandDecorators";
|
|
3
3
|
|
|
4
4
|
export type MobilePlatform = "ios" | "android";
|
|
5
|
+
export type DesktopPlatform = "macos" | "windows" | "linux";
|
|
6
|
+
export type NativePlatform = MobilePlatform | DesktopPlatform;
|
|
5
7
|
export type MobileTargetSelection = string | "all" | undefined;
|
|
6
8
|
|
|
7
9
|
export interface ResolvedMobileTarget {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tempDirs } from "../testHelpers";
|
|
5
|
+
import { NativeApi } from "./nativeApi";
|
|
6
|
+
|
|
7
|
+
const makeTempRoot = tempDirs("akan-native-api-");
|
|
8
|
+
const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
|
|
9
|
+
|
|
10
|
+
describe("NativeApi", () => {
|
|
11
|
+
test("inside this repo, loads the workspace package and checks its major", async () => {
|
|
12
|
+
expect(NativeApi.entryOf(repoApp)).toBe(path.resolve(import.meta.dir, "../../native/packages/cli/src/api.ts"));
|
|
13
|
+
const api = await NativeApi.load(repoApp);
|
|
14
|
+
expect(api.API_VERSION.split(".")[0]).toBe(String(NativeApi.supportedMajor));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("elsewhere, looks for the copy akanjs vendors, and says so when it is missing", async () => {
|
|
18
|
+
const root = await realpath(await makeTempRoot());
|
|
19
|
+
await mkdir(path.join(root, "node_modules/akanjs"), { recursive: true });
|
|
20
|
+
await writeFile(
|
|
21
|
+
path.join(root, "node_modules/akanjs/package.json"),
|
|
22
|
+
JSON.stringify({ name: "akanjs", exports: { "./package.json": "./package.json" } }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(NativeApi.entryOf(root)).toBe(
|
|
26
|
+
path.join(root, "node_modules/akanjs/vendor/@akanjs/native/packages/cli/src/api.ts"),
|
|
27
|
+
);
|
|
28
|
+
await expect(NativeApi.load(root)).rejects.toThrow("The installed akanjs has no native build");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type * as NativeBuildApi from "@akanjs/native/api";
|
|
3
|
+
|
|
4
|
+
export type NativeBuildApiModule = typeof NativeBuildApi;
|
|
5
|
+
|
|
6
|
+
export class NativeApi {
|
|
7
|
+
//* The build API versions itself; a major this devkit was not written against is refused before anything builds.
|
|
8
|
+
static readonly supportedMajor = 0;
|
|
9
|
+
|
|
10
|
+
/** The API of the akanjs the app installed: the workspace package inside this repo, akanjs's vendored copy elsewhere. */
|
|
11
|
+
static entryOf(appDir: string): string {
|
|
12
|
+
const workspaceEntry = NativeApi.#resolve("@akanjs/native/api", appDir);
|
|
13
|
+
if (workspaceEntry) return workspaceEntry;
|
|
14
|
+
const akanjsPackage = NativeApi.#resolve("akanjs/package.json", appDir);
|
|
15
|
+
if (!akanjsPackage) throw new Error(`akanjs is not installed for ${appDir}; the native build ships inside it.`);
|
|
16
|
+
return path.join(path.dirname(akanjsPackage), "vendor", "@akanjs", "native", "packages", "cli", "src", "api.ts");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async load(appDir: string): Promise<NativeBuildApiModule> {
|
|
20
|
+
const entry = NativeApi.entryOf(appDir);
|
|
21
|
+
if (!(await Bun.file(entry).exists()))
|
|
22
|
+
throw new Error(`The installed akanjs has no native build at ${entry}; update akanjs.`);
|
|
23
|
+
const api = (await import(entry)) as NativeBuildApiModule;
|
|
24
|
+
const major = Number(api.API_VERSION.split(".")[0]);
|
|
25
|
+
if (major !== NativeApi.supportedMajor)
|
|
26
|
+
throw new Error(
|
|
27
|
+
`The native build API is ${api.API_VERSION}, and this akan CLI speaks ${NativeApi.supportedMajor}.x; install matching versions of akanjs and @akanjs/cli.`,
|
|
28
|
+
);
|
|
29
|
+
return api;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static #resolve(specifier: string, from: string) {
|
|
33
|
+
try {
|
|
34
|
+
return Bun.resolveSync(specifier, from);
|
|
35
|
+
} catch {
|
|
36
|
+
// Not installed where the app can reach it, which the caller answers with the next place to look.
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AkanPlugin } from "akanjs";
|
|
4
|
+
import type { AkanMobileTargetConfig } from "../akanConfig";
|
|
5
|
+
import type { App } from "../commandDecorators";
|
|
6
|
+
import { NativeApp } from "./nativeApp";
|
|
7
|
+
import { NativeConfig } from "./nativeConfig";
|
|
8
|
+
|
|
9
|
+
const target = (config: Partial<AkanMobileTargetConfig> = {}) => ({
|
|
10
|
+
name: config.name ?? "default",
|
|
11
|
+
config: {
|
|
12
|
+
name: config.name ?? "default",
|
|
13
|
+
appName: "Portal",
|
|
14
|
+
appId: "com.portal.app",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
buildNum: 1,
|
|
17
|
+
...config,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const appDir = "/repo/apps/portal";
|
|
22
|
+
|
|
23
|
+
const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
24
|
+
({
|
|
25
|
+
cwdPath: appDir,
|
|
26
|
+
dist: { cwdPath: "/repo/dist/apps/portal" },
|
|
27
|
+
getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
|
|
28
|
+
collectPlugins: async () => plugins,
|
|
29
|
+
logger: { warn: () => undefined },
|
|
30
|
+
}) as unknown as App;
|
|
31
|
+
|
|
32
|
+
describe("NativeApp", () => {
|
|
33
|
+
test("keeps each target's web root and each platform's output apart", () => {
|
|
34
|
+
const admin = new NativeApp(fakeApp(), target({ name: "admin", basePath: "admin" }));
|
|
35
|
+
|
|
36
|
+
expect(admin.web.dir).toBe(path.join(appDir, ".akan/mobile/admin/web"));
|
|
37
|
+
expect(admin.outDir("ios")).toBe(path.join(appDir, ".akan/mobile/admin/native/ios"));
|
|
38
|
+
expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("opens a dev build on its target's home, the CSR shell under the locale", () => {
|
|
42
|
+
expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
|
|
43
|
+
"/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
|
|
44
|
+
);
|
|
45
|
+
expect(new NativeApp(fakeApp(), target({ name: "admin", basePath: "/admin/" })).startPath("ko")).toBe(
|
|
46
|
+
"/ko/admin?csr=true&akanMobileTarget=admin&akanMobileBasePath=admin",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("builds its config from the app's locales and the plugins' native contributions", async () => {
|
|
51
|
+
const push: AkanPlugin = { name: "push-notification", native: { permission: "push", plugins: ["push"] } };
|
|
52
|
+
const app = new NativeApp(
|
|
53
|
+
fakeApp([push, { name: "other" }]),
|
|
54
|
+
target({ basePath: undefined, permissions: ["push"] }),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const { config, warnings } = await app.config();
|
|
58
|
+
|
|
59
|
+
expect(warnings).toEqual([]);
|
|
60
|
+
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
|
|
61
|
+
expect(config.web.dir).toBe(path.join(appDir, ".akan/mobile/default/web"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a desktop app is this computer's platform", () => {
|
|
65
|
+
expect(["darwin", "win32", "linux"].map((host) => NativeApp.desktopPlatform(host as NodeJS.Platform))).toEqual([
|
|
66
|
+
"macos",
|
|
67
|
+
"windows",
|
|
68
|
+
"linux",
|
|
69
|
+
]);
|
|
70
|
+
expect(() => NativeApp.desktopPlatform("freebsd")).toThrow("A desktop app builds on macOS, Windows or Linux");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("signs an Android release with the upload key the environment names, and says which part is missing", () => {
|
|
74
|
+
expect(
|
|
75
|
+
NativeApp.androidSigning({
|
|
76
|
+
MYAPP_RELEASE_STORE_FILE: "keys/upload.jks",
|
|
77
|
+
MYAPP_RELEASE_STORE_PASSWORD: "store",
|
|
78
|
+
MYAPP_RELEASE_KEY_ALIAS: "upload",
|
|
79
|
+
}),
|
|
80
|
+
).toEqual({ keystore: "keys/upload.jks", storePassword: "store", alias: "upload" });
|
|
81
|
+
expect(() => NativeApp.androidSigning({ MYAPP_RELEASE_STORE_FILE: "keys/upload.jks" })).toThrow(
|
|
82
|
+
"set MYAPP_RELEASE_STORE_PASSWORD, MYAPP_RELEASE_KEY_ALIAS in the environment",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import {
|
|
6
|
+
type DesktopPlatform,
|
|
7
|
+
type MobilePlatform,
|
|
8
|
+
type NativePlatform,
|
|
9
|
+
type ResolvedMobileTarget,
|
|
10
|
+
targetHtmlFilename,
|
|
11
|
+
} from "./mobileTarget";
|
|
12
|
+
import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
|
|
13
|
+
import { NativeConfig } from "./nativeConfig";
|
|
14
|
+
import { NativeWebDir } from "./nativeWebDir";
|
|
15
|
+
|
|
16
|
+
type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
17
|
+
type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
|
|
18
|
+
type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
|
|
19
|
+
type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
|
|
20
|
+
|
|
21
|
+
export interface NativeRunOptions {
|
|
22
|
+
device?: DeviceSelector;
|
|
23
|
+
/** Narrows the iPhone signing to one Apple team. */
|
|
24
|
+
teamId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NativeDevOptions extends NativeRunOptions {
|
|
28
|
+
/** The akan dev server the gateway proxies. */
|
|
29
|
+
upstream: string;
|
|
30
|
+
lang: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One mobile target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
|
|
34
|
+
* what it ships, and the calls into the API. */
|
|
35
|
+
export class NativeApp {
|
|
36
|
+
readonly targetRoot: string;
|
|
37
|
+
readonly web: NativeWebDir;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
readonly app: App,
|
|
41
|
+
readonly target: ResolvedMobileTarget,
|
|
42
|
+
) {
|
|
43
|
+
this.targetRoot = path.join(app.cwdPath, ".akan", "mobile", target.name);
|
|
44
|
+
this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
outDir(platform: NativePlatform) {
|
|
48
|
+
return path.join(this.targetRoot, "native", platform);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The web root, from the production build the mobile commands run first. */
|
|
52
|
+
async assembleWeb() {
|
|
53
|
+
const dist = this.app.dist.cwdPath;
|
|
54
|
+
return await this.web.assemble(this.target.config, {
|
|
55
|
+
html: path.join(dist, "csr", targetHtmlFilename(this.target.config)),
|
|
56
|
+
publicDir: path.join(dist, "public"),
|
|
57
|
+
fontsDir: path.join(dist, ".akan", "artifact", "fonts"),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async config() {
|
|
62
|
+
const [appConfig, plugins] = await Promise.all([this.app.getConfig(), this.app.collectPlugins()]);
|
|
63
|
+
return NativeConfig.build({
|
|
64
|
+
appPath: this.app.cwdPath,
|
|
65
|
+
target: this.target.config,
|
|
66
|
+
webDir: this.web.dir,
|
|
67
|
+
contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
|
|
68
|
+
locales: appConfig.i18n.locales,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 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([NativeApi.load(this.app.cwdPath), this.config()]);
|
|
75
|
+
for (const warning of warnings) this.app.logger.warn(warning);
|
|
76
|
+
const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
|
|
77
|
+
if (problems.length)
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Mobile target '${this.target.name}' makes an invalid native config:\n- ${problems.join("\n- ")}`,
|
|
80
|
+
);
|
|
81
|
+
return { api, config };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
|
|
85
|
+
#appLine = (line: string) => {
|
|
86
|
+
this.app.logger.info(`[${this.target.name}] ${line}`);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
//* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
|
|
90
|
+
#task(platform: NativePlatform, config: AkanNativeConfig): TaskOptions {
|
|
91
|
+
return {
|
|
92
|
+
appDir: this.app.cwdPath,
|
|
93
|
+
config,
|
|
94
|
+
platform,
|
|
95
|
+
outDir: this.outDir(platform),
|
|
96
|
+
envFiles: false,
|
|
97
|
+
skipWebBuild: true,
|
|
98
|
+
log: (event) => {
|
|
99
|
+
if (event.level === "error") this.app.logger.error(event.message);
|
|
100
|
+
else if (event.level === "warn") this.app.logger.warn(event.message);
|
|
101
|
+
else if (event.level === "tool") this.app.logger.verbose(event.message);
|
|
102
|
+
else this.app.logger.info(event.message);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async build(platform: NativePlatform, { profile = "release" }: { profile?: "debug" | "release" } = {}) {
|
|
108
|
+
await this.assembleWeb();
|
|
109
|
+
const { api, config } = await this.prepare();
|
|
110
|
+
return await api.build({ ...this.#task(platform, config), profile });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async run(
|
|
114
|
+
platform: NativePlatform,
|
|
115
|
+
{ device, teamId, profile = "debug" }: NativeRunOptions & { profile?: "debug" | "release" } = {},
|
|
116
|
+
) {
|
|
117
|
+
await this.assembleWeb();
|
|
118
|
+
const { api, config } = await this.prepare();
|
|
119
|
+
return await api.run({
|
|
120
|
+
...this.#task(platform, config),
|
|
121
|
+
profile,
|
|
122
|
+
onLine: this.#appLine,
|
|
123
|
+
...(device ? { device } : {}),
|
|
124
|
+
...(teamId ? { ios: { signing: { teamId } } } : {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
//* The gateway serves the page and its HMR socket on the app origin; API calls go to the dev server itself (baseEnv),
|
|
129
|
+
//* which an Android device reaches through the reversed port.
|
|
130
|
+
async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
|
|
131
|
+
await mkdir(this.web.dir, { recursive: true });
|
|
132
|
+
const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(), this.app.getConfig()]);
|
|
133
|
+
return await api.dev({
|
|
134
|
+
...this.#task(platform, config),
|
|
135
|
+
upstream,
|
|
136
|
+
hmrPath: "/_akan/hmr",
|
|
137
|
+
//? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
|
|
138
|
+
wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
|
|
139
|
+
onLine: this.#appLine,
|
|
140
|
+
startPath: this.startPath(lang),
|
|
141
|
+
...(device ? { device } : {}),
|
|
142
|
+
...(teamId ? { ios: { signing: { teamId } } } : {}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
|
|
147
|
+
await this.assembleWeb();
|
|
148
|
+
const { api, config } = await this.prepare();
|
|
149
|
+
return await api.release({
|
|
150
|
+
...this.#task("ios", config),
|
|
151
|
+
platform: "ios",
|
|
152
|
+
signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
|
|
157
|
+
const signing = NativeApp.androidSigning();
|
|
158
|
+
await this.assembleWeb();
|
|
159
|
+
const { api, config } = await this.prepare();
|
|
160
|
+
return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//* The names the Gradle build read, so a CI that already holds these secrets keeps working.
|
|
164
|
+
static androidSigning(env: Record<string, string | undefined> = process.env): AndroidSigning {
|
|
165
|
+
const keystore = env.MYAPP_RELEASE_STORE_FILE;
|
|
166
|
+
const storePassword = env.MYAPP_RELEASE_STORE_PASSWORD;
|
|
167
|
+
const alias = env.MYAPP_RELEASE_KEY_ALIAS;
|
|
168
|
+
const keyPassword = env.MYAPP_RELEASE_KEY_PASSWORD;
|
|
169
|
+
if (!keystore || !storePassword || !alias) {
|
|
170
|
+
const missing = Object.entries({
|
|
171
|
+
MYAPP_RELEASE_STORE_FILE: keystore,
|
|
172
|
+
MYAPP_RELEASE_STORE_PASSWORD: storePassword,
|
|
173
|
+
MYAPP_RELEASE_KEY_ALIAS: alias,
|
|
174
|
+
}).flatMap(([key, value]) => (value ? [] : [key]));
|
|
175
|
+
throw new Error(
|
|
176
|
+
`An Android release is signed with the upload key; set ${missing.join(", ")} in the environment.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return { keystore, storePassword, alias, ...(keyPassword ? { keyPassword } : {}) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The first page of a dev build, the target's home as a release bundle opens it; the dev server answers the CSR
|
|
183
|
+
* shell only for `?csr=true`. */
|
|
184
|
+
startPath(lang: string) {
|
|
185
|
+
const basePath = this.target.config.basePath?.replace(/^\/+|\/+$/g, "");
|
|
186
|
+
const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
|
|
187
|
+
if (basePath) params.set("akanMobileBasePath", basePath);
|
|
188
|
+
if (this.target.config.indexPath) params.set("akanMobileIndexPath", this.target.config.indexPath);
|
|
189
|
+
const home = [lang, basePath, this.target.config.indexPath]
|
|
190
|
+
.flatMap((part) => (part ?? "").split("/"))
|
|
191
|
+
.filter((segment) => segment.length > 0);
|
|
192
|
+
return `/${home.join("/")}?${params}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
//* A desktop app builds only on its own OS, so the platform is this computer's.
|
|
196
|
+
static desktopPlatform(host: NodeJS.Platform = process.platform): DesktopPlatform {
|
|
197
|
+
if (host === "darwin") return "macos";
|
|
198
|
+
if (host === "win32") return "windows";
|
|
199
|
+
if (host === "linux") return "linux";
|
|
200
|
+
throw new Error(`A desktop app builds on macOS, Windows or Linux, not on ${host}.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
static async devices(appDir: string, platform: MobilePlatform) {
|
|
204
|
+
return await (await NativeApi.load(appDir)).devices({ platform });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
static async doctor(appDir: string, platforms: NativePlatform[]) {
|
|
208
|
+
return await (await NativeApi.load(appDir)).doctor({ platforms });
|
|
209
|
+
}
|
|
210
|
+
}
|