@akanjs/devkit 3.0.0-beta.30 → 3.0.0-beta.32
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 +35 -0
- package/akanConfig/akanConfig.ts +45 -4
- package/akanConfig/types.ts +1 -0
- package/mobile/desktopBin.test.ts +5 -0
- package/mobile/desktopBin.ts +9 -8
- package/mobile/desktopServerStage.test.ts +49 -1
- package/mobile/desktopServerStage.ts +45 -5
- package/mobile/nativeApp.test.ts +35 -2
- package/mobile/nativeApp.ts +74 -25
- package/mobile/nativeConfig.test.ts +24 -0
- package/mobile/nativeConfig.ts +3 -5
- package/package.json +2 -2
|
@@ -477,6 +477,18 @@ describe("AkanAppConfig", () => {
|
|
|
477
477
|
).toThrow("unknown basePath");
|
|
478
478
|
});
|
|
479
479
|
|
|
480
|
+
test("a desktop server is true or { omit }, its names trimmed, deduplicated and sorted", () => {
|
|
481
|
+
const resolve = (server: unknown) =>
|
|
482
|
+
new AkanAppConfig(app, [], packageJson, { native: { desktop: { server } } } as never, baseDevEnv).native.targets
|
|
483
|
+
.default.desktop?.server;
|
|
484
|
+
|
|
485
|
+
expect(resolve(true)).toBe(true);
|
|
486
|
+
expect(resolve({ omit: [" rclnodejs", "protobufjs", "rclnodejs"] })).toEqual({ omit: ["protobufjs", "rclnodejs"] });
|
|
487
|
+
expect(resolve({})).toEqual({ omit: [] });
|
|
488
|
+
expect(() => resolve({ omit: "rclnodejs" })).toThrow("native.desktop.server.omit in apps/");
|
|
489
|
+
expect(() => resolve({ exclude: ["rclnodejs"] })).toThrow("native.desktop.server.exclude in apps/");
|
|
490
|
+
});
|
|
491
|
+
|
|
480
492
|
test("an app without basePaths has one target, default, with no basePath and the section's settings", () => {
|
|
481
493
|
const config = new AkanAppConfig(
|
|
482
494
|
app,
|
|
@@ -491,6 +503,29 @@ describe("AkanAppConfig", () => {
|
|
|
491
503
|
expect(config.native.targets.default).toMatchObject({ indexPath: "/explore", desktop: { server: true } });
|
|
492
504
|
});
|
|
493
505
|
|
|
506
|
+
test("a platform section may name its own indexPath, normalized like the section's", () => {
|
|
507
|
+
const config = new AkanAppConfig(
|
|
508
|
+
app,
|
|
509
|
+
[],
|
|
510
|
+
packageJson,
|
|
511
|
+
{
|
|
512
|
+
native: {
|
|
513
|
+
indexPath: "/mobile",
|
|
514
|
+
ios: { indexPath: "cockpit/" },
|
|
515
|
+
desktop: { indexPath: " / ", server: true },
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
baseDevEnv,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
expect(config.native.targets.default).toMatchObject({
|
|
522
|
+
indexPath: "/mobile",
|
|
523
|
+
ios: { indexPath: "/cockpit" },
|
|
524
|
+
desktop: { indexPath: "/", server: true },
|
|
525
|
+
});
|
|
526
|
+
expect(config.native.targets.default.android).toBeUndefined();
|
|
527
|
+
});
|
|
528
|
+
|
|
494
529
|
test("a target takes the native section with its own fields over it: objects merge, lists and values replace", () => {
|
|
495
530
|
const config = new AkanAppConfig(
|
|
496
531
|
app,
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type AkanNativeAppConfig,
|
|
18
18
|
type AkanNativeAppResult,
|
|
19
19
|
type AkanNativeDeepLinks,
|
|
20
|
+
type AkanNativeDesktopConfig,
|
|
20
21
|
type AkanNativeSettings,
|
|
21
22
|
type AkanNativeTarget,
|
|
22
23
|
type AkanNativeUpdatesConfig,
|
|
@@ -293,9 +294,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
293
294
|
} = merged;
|
|
294
295
|
const indexPath = normalizeIndexPath(rawIndexPath);
|
|
295
296
|
const deepLinks = normalizeDeepLinks(rawDeepLinks);
|
|
296
|
-
const { teamId: rawTeamId, ...iosRest } = merged.ios ?? {};
|
|
297
|
+
const { teamId: rawTeamId, ...iosRest } = AkanAppConfig.#platformIndexPath(merged.ios ?? {});
|
|
297
298
|
const teamId = rawTeamId?.trim();
|
|
298
|
-
const { sha256CertFingerprints: rawFingerprints, ...androidRest } =
|
|
299
|
+
const { sha256CertFingerprints: rawFingerprints, ...androidRest } = AkanAppConfig.#platformIndexPath(
|
|
300
|
+
merged.android ?? {},
|
|
301
|
+
);
|
|
299
302
|
const fingerprints = normalizeStringList(rawFingerprints);
|
|
300
303
|
const target: AkanNativeTarget = {
|
|
301
304
|
...rest,
|
|
@@ -307,6 +310,15 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
307
310
|
...(merged.android
|
|
308
311
|
? { android: { ...androidRest, ...(fingerprints ? { sha256CertFingerprints: fingerprints } : {}) } }
|
|
309
312
|
: {}),
|
|
313
|
+
...(merged.desktop
|
|
314
|
+
? {
|
|
315
|
+
desktop: AkanAppConfig.#desktopServer(
|
|
316
|
+
AkanAppConfig.#platformIndexPath(merged.desktop),
|
|
317
|
+
`${where}.desktop.server`,
|
|
318
|
+
configPath,
|
|
319
|
+
),
|
|
320
|
+
}
|
|
321
|
+
: {}),
|
|
310
322
|
appName: merged.appName ?? this.app.name,
|
|
311
323
|
appId: merged.appId ?? appId,
|
|
312
324
|
version: merged.version ?? version,
|
|
@@ -318,6 +330,27 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
318
330
|
);
|
|
319
331
|
return { appName, appId, ...(root.fileName ? { fileName: root.fileName } : {}), version, buildNum, targets };
|
|
320
332
|
}
|
|
333
|
+
//? `true` or `{ omit }`: each omitted name trimmed once, so two targets that omit the same packages stage one server.
|
|
334
|
+
static #desktopServer<T extends { server?: AkanNativeDesktopConfig["server"] }>(
|
|
335
|
+
desktop: T,
|
|
336
|
+
where: string,
|
|
337
|
+
configPath: string,
|
|
338
|
+
): T {
|
|
339
|
+
const { server } = desktop;
|
|
340
|
+
if (server === undefined || typeof server === "boolean") return desktop;
|
|
341
|
+
if (!AkanAppConfig.#isPlainObject(server))
|
|
342
|
+
throw new Error(`${where} in ${configPath} must be true or { omit: string[] }.`);
|
|
343
|
+
const omit = server.omit;
|
|
344
|
+
if (omit !== undefined && (!Array.isArray(omit) || omit.some((name) => typeof name !== "string" || !name.trim())))
|
|
345
|
+
throw new Error(`${where}.omit in ${configPath} must be a list of package names.`);
|
|
346
|
+
const names = [...new Set((omit ?? []).map((name) => name.trim()))].sort();
|
|
347
|
+
return { ...desktop, server: { omit: names } };
|
|
348
|
+
}
|
|
349
|
+
static #platformIndexPath<T extends { indexPath?: string }>(section: T): T {
|
|
350
|
+
const { indexPath: rawIndexPath, ...rest } = section;
|
|
351
|
+
const indexPath = normalizeIndexPath(rawIndexPath);
|
|
352
|
+
return { ...rest, ...(indexPath ? { indexPath } : {}) } as T;
|
|
353
|
+
}
|
|
321
354
|
/** `own` over `base`: plain objects merge key by key, and any other value, a list included, replaces. */
|
|
322
355
|
static #overlay(base: AkanNativeSettings, own: AkanNativeSettings): AkanNativeSettings {
|
|
323
356
|
const merge = (kept: unknown, value: unknown, top: boolean, key: string): unknown => {
|
|
@@ -356,8 +389,9 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
356
389
|
"android",
|
|
357
390
|
"desktop",
|
|
358
391
|
],
|
|
359
|
-
ios: ["teamId", "infoPlist", "entitlements", "privacy", "files"],
|
|
392
|
+
ios: ["indexPath", "teamId", "infoPlist", "entitlements", "privacy", "files"],
|
|
360
393
|
android: [
|
|
394
|
+
"indexPath",
|
|
361
395
|
"sha256CertFingerprints",
|
|
362
396
|
"googleServices",
|
|
363
397
|
"push",
|
|
@@ -367,7 +401,8 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
367
401
|
"activity",
|
|
368
402
|
"files",
|
|
369
403
|
],
|
|
370
|
-
desktop: ["server", "recovery", "window", "screenCapture"],
|
|
404
|
+
desktop: ["indexPath", "server", "recovery", "window", "screenCapture", "entitlements"],
|
|
405
|
+
desktopServer: ["omit"],
|
|
371
406
|
deepLinks: ["schemes", "domains"],
|
|
372
407
|
} as const;
|
|
373
408
|
//* Settings that moved when `mobile` became `native` are named with where they went, instead of "unknown".
|
|
@@ -399,6 +434,12 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
399
434
|
check(settings.ios, AkanAppConfig.#nativeKeys.ios, `${where}.ios`, "ios");
|
|
400
435
|
check(settings.android, AkanAppConfig.#nativeKeys.android, `${where}.android`, "android");
|
|
401
436
|
check(settings.desktop, AkanAppConfig.#nativeKeys.desktop, `${where}.desktop`, "desktop");
|
|
437
|
+
check(
|
|
438
|
+
settings.desktop?.server,
|
|
439
|
+
AkanAppConfig.#nativeKeys.desktopServer,
|
|
440
|
+
`${where}.desktop.server`,
|
|
441
|
+
"desktopServer",
|
|
442
|
+
);
|
|
402
443
|
check(settings.deepLinks, AkanAppConfig.#nativeKeys.deepLinks, `${where}.deepLinks`, "deepLinks");
|
|
403
444
|
}
|
|
404
445
|
static readonly #androidFilePattern = /^(?:res\/[^/]+\/[^/]+|assets\/.+)$/;
|
package/akanConfig/types.ts
CHANGED
|
@@ -48,6 +48,11 @@ afterAll(() => files.stop(true));
|
|
|
48
48
|
const hash = (bytes: Uint8Array | string) => new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
49
49
|
|
|
50
50
|
describe("DesktopBin", () => {
|
|
51
|
+
test("a build carries the file of the CPU it is for", () => {
|
|
52
|
+
expect(DesktopBin.platform("x64")).toBe(`${process.platform}-x64` as BinPlatform);
|
|
53
|
+
expect(DesktopBin.platform("ia32")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
51
56
|
test("carries the app's own entries and those of the libs it depends on, the app's first", () => {
|
|
52
57
|
const own = { ffmpeg: { [here]: { path: "/a/ffmpeg" } } };
|
|
53
58
|
const lib = { ffmpeg: { [here]: { path: "/lib/ffmpeg" } }, ffprobe: { [here]: { path: "/lib/ffprobe" } } };
|
package/mobile/desktopBin.ts
CHANGED
|
@@ -11,18 +11,19 @@ import {
|
|
|
11
11
|
import type { App } from "../commandDecorators";
|
|
12
12
|
|
|
13
13
|
type BinPlatforms = AkanBinConfig[string];
|
|
14
|
+
export type DesktopArch = "arm64" | "x64";
|
|
14
15
|
|
|
15
|
-
//* A desktop app is built on
|
|
16
|
-
//* byte into the folder the app carries as `desktop.bin`: no extended attribute, so
|
|
17
|
-
//* into the bundle.
|
|
16
|
+
//* A desktop app is built on its own OS, for this computer's CPU or the one `--arch` names, so only that platform's file
|
|
17
|
+
//* is fetched. It is copied byte for byte into the folder the app carries as `desktop.bin`: no extended attribute, so
|
|
18
|
+
//* no quarantine flag, travels into the bundle.
|
|
18
19
|
export class DesktopBin {
|
|
19
20
|
constructor(
|
|
20
21
|
readonly app: App,
|
|
21
22
|
readonly config: AkanAppConfig,
|
|
22
23
|
) {}
|
|
23
24
|
|
|
24
|
-
static platform(): BinPlatform | null {
|
|
25
|
-
const platform = `${process.platform}-${
|
|
25
|
+
static platform(arch: string = process.arch): BinPlatform | null {
|
|
26
|
+
const platform = `${process.platform}-${arch}`;
|
|
26
27
|
return (binPlatforms as readonly string[]).includes(platform) ? (platform as BinPlatform) : null;
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -76,14 +77,14 @@ export class DesktopBin {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/** Copies each chosen executable into `dir`, emptied first, and answers the names it carries. */
|
|
79
|
-
async stage(dir: string): Promise<string[]> {
|
|
80
|
+
async stage(dir: string, arch: DesktopArch = process.arch as DesktopArch): Promise<string[]> {
|
|
80
81
|
const scanInfo = this.app.getScanInfo({ allowEmpty: true }) ?? (await this.app.scan({ write: false }));
|
|
81
82
|
const chosen = DesktopBin.select(this.config, scanInfo.getLibs());
|
|
82
83
|
if (!chosen.size) return [];
|
|
83
|
-
const platform = DesktopBin.platform();
|
|
84
|
+
const platform = DesktopBin.platform(arch);
|
|
84
85
|
if (!platform)
|
|
85
86
|
throw new Error(
|
|
86
|
-
`bin: this
|
|
87
|
+
`bin: this desktop app is for ${process.platform}-${arch}, and a bin names only ${binPlatforms.join(", ")}.`,
|
|
87
88
|
);
|
|
88
89
|
await rm(dir, { recursive: true, force: true });
|
|
89
90
|
await mkdir(dir, { recursive: true });
|
|
@@ -23,7 +23,7 @@ const rootPackageJson: PackageJson = {
|
|
|
23
23
|
name: "repo",
|
|
24
24
|
version: "1.0.0",
|
|
25
25
|
description: "repo",
|
|
26
|
-
dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0" },
|
|
26
|
+
dependencies: { "@external/runtime": "2.0.0", pg: "8.0.0", rclnodejs: "1.0.0" },
|
|
27
27
|
};
|
|
28
28
|
const appConfig = (config: object = {}) =>
|
|
29
29
|
new AkanAppConfig({ name: "portal" } as never, [], rootPackageJson, config, baseDevEnv);
|
|
@@ -59,6 +59,17 @@ describe("DesktopServerStage", () => {
|
|
|
59
59
|
for (const driver of drivers) expect(Object.keys(built.dependencies ?? {})).toContain(driver);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
test("leaves out what desktop.server.omit names, an external lib included, while the image keeps it", () => {
|
|
63
|
+
const config = appConfig({ externalLibs: ["rclnodejs", "@external/runtime"] });
|
|
64
|
+
const built = config.getProductionPackageJson();
|
|
65
|
+
|
|
66
|
+
const { dependencies } = DesktopServerStage.packageJson(config, built, ["rclnodejs"]);
|
|
67
|
+
|
|
68
|
+
expect(Object.keys(dependencies ?? {})).toContain("@external/runtime");
|
|
69
|
+
expect(Object.keys(dependencies ?? {})).not.toContain("rclnodejs");
|
|
70
|
+
expect(Object.keys(config.getProductionPackageJson().dependencies ?? {})).toContain("rclnodejs");
|
|
71
|
+
});
|
|
72
|
+
|
|
62
73
|
test("keeps the image's trusted packages, so their install scripts run in the app's server too", () => {
|
|
63
74
|
const config = appConfig({ trustedDependencies: ["@external/runtime"], externalLibs: ["@external/runtime"] });
|
|
64
75
|
expect(DesktopServerStage.packageJson(config, config.getProductionPackageJson()).trustedDependencies).toEqual([
|
|
@@ -148,6 +159,43 @@ describe("DesktopServerStage", () => {
|
|
|
148
159
|
expect(Object.keys(installed.dependencies ?? {}).sort()).toEqual(["react", "react-dom"]);
|
|
149
160
|
});
|
|
150
161
|
|
|
162
|
+
test("an omitted package another dependency still installs stops the stage, naming who needs it", async () => {
|
|
163
|
+
const installing =
|
|
164
|
+
(pulled: boolean) =>
|
|
165
|
+
async (...args: unknown[]) => {
|
|
166
|
+
const nodeModules = path.join((args[2] as { cwd: string }).cwd, "node_modules");
|
|
167
|
+
await write(path.join(nodeModules, "react/package.json"), JSON.stringify({ name: "react" }));
|
|
168
|
+
if (pulled) {
|
|
169
|
+
await write(path.join(nodeModules, "rclnodejs/package.json"), JSON.stringify({ name: "rclnodejs" }));
|
|
170
|
+
await write(
|
|
171
|
+
path.join(nodeModules, "@robot/bridge/package.json"),
|
|
172
|
+
JSON.stringify({ name: "@robot/bridge", dependencies: { rclnodejs: "^1" } }),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return "";
|
|
176
|
+
};
|
|
177
|
+
await (await stageApp(installing(false))).stage.install(undefined, ["rclnodejs"]);
|
|
178
|
+
await expect((await stageApp(installing(true))).stage.install(undefined, ["rclnodejs"])).rejects.toThrow(
|
|
179
|
+
"another dependency still installs: rclnodejs (needed by @robot/bridge)",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("installs another CPU's optional packages for that CPU", () => {
|
|
184
|
+
const other = process.arch === "arm64" ? "x64" : "arm64";
|
|
185
|
+
expect(DesktopServerStage.installArgs()).toEqual(["install", "--production", "--prefer-offline"]);
|
|
186
|
+
expect(DesktopServerStage.installArgs(process.arch as "arm64" | "x64")).toEqual([
|
|
187
|
+
"install",
|
|
188
|
+
"--production",
|
|
189
|
+
"--prefer-offline",
|
|
190
|
+
]);
|
|
191
|
+
expect(DesktopServerStage.installArgs(other)).toEqual([
|
|
192
|
+
"install",
|
|
193
|
+
"--production",
|
|
194
|
+
"--prefer-offline",
|
|
195
|
+
`--cpu=${other}`,
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
151
199
|
test("an install that fails says it needs the registry or a Bun cache that holds the packages", async () => {
|
|
152
200
|
const { stage } = await stageApp(async () => {
|
|
153
201
|
throw new Error("error: GET https://registry.npmjs.org/scheduler - ConnectionRefused");
|
|
@@ -85,7 +85,8 @@ export class DesktopServerStage {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
//? `omit` (the target's `desktop.server.omit`) wins over externalLibs: the image keeps those packages, the app does not.
|
|
89
|
+
static packageJson(config: AkanAppConfig, built: PackageJson, omit: string[] = []): PackageJson {
|
|
89
90
|
const otherDrivers = new Set(
|
|
90
91
|
config.database.modes
|
|
91
92
|
.filter((mode) => mode !== "single")
|
|
@@ -93,24 +94,47 @@ export class DesktopServerStage {
|
|
|
93
94
|
);
|
|
94
95
|
const dependencies = Object.entries(built.dependencies ?? {}).filter(
|
|
95
96
|
([name]) =>
|
|
96
|
-
|
|
97
|
+
!omit.includes(name) &&
|
|
98
|
+
(config.externalLibs.includes(name) || (name !== DesktopServerStage.rscRenderer && !otherDrivers.has(name))),
|
|
97
99
|
);
|
|
98
100
|
return { ...built, dependencies: Object.fromEntries(dependencies) };
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/** The packages in `nodeModules` that depend on `name`: what still pulls an omitted package in. */
|
|
104
|
+
static async dependentsOf(nodeModules: string, name: string): Promise<string[]> {
|
|
105
|
+
const dependents = new Set<string>();
|
|
106
|
+
for await (const manifest of new Bun.Glob("**/package.json").scan({ cwd: nodeModules, onlyFiles: true })) {
|
|
107
|
+
const json = (await Bun.file(path.join(nodeModules, manifest))
|
|
108
|
+
.json()
|
|
109
|
+
.catch(() => null)) as PackageJson | null;
|
|
110
|
+
if (!json?.name || json.name === name) continue;
|
|
111
|
+
const declared = { ...json.dependencies, ...json.optionalDependencies, ...json.peerDependencies };
|
|
112
|
+
if (name in declared) dependents.add(json.name);
|
|
113
|
+
}
|
|
114
|
+
return [...dependents].sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//? `--cpu` installs another CPU's optional packages: an addon's prebuilt binary for the app's CPU, not this computer's.
|
|
118
|
+
static installArgs(arch?: "arm64" | "x64"): string[] {
|
|
119
|
+
return ["install", "--production", "--prefer-offline", ...(arch && arch !== process.arch ? [`--cpu=${arch}`] : [])];
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
//* The packages come from the config alone, so they install before `akan build` runs: a machine that cannot install
|
|
102
123
|
//* them stops at once instead of after the whole build.
|
|
103
|
-
async install() {
|
|
124
|
+
async install(arch?: "arm64" | "x64", omit: string[] = []) {
|
|
104
125
|
const config = await this.app.getConfig();
|
|
105
126
|
DesktopServerStage.assertCarriable(config);
|
|
106
127
|
await rm(this.dir, { recursive: true, force: true });
|
|
107
128
|
await mkdir(this.dir, { recursive: true });
|
|
129
|
+
const built = config.getProductionPackageJson();
|
|
130
|
+
for (const name of omit.filter((name) => !(name in (built.dependencies ?? {}))))
|
|
131
|
+
this.app.logger.warn(`desktop.server.omit names ${name}, which the server does not depend on directly.`);
|
|
108
132
|
await writeFile(
|
|
109
133
|
path.join(this.dir, "package.json"),
|
|
110
|
-
JSON.stringify(DesktopServerStage.packageJson(config,
|
|
134
|
+
JSON.stringify(DesktopServerStage.packageJson(config, built, omit), null, 2),
|
|
111
135
|
);
|
|
112
136
|
try {
|
|
113
|
-
await this.app.spawn(process.execPath,
|
|
137
|
+
await this.app.spawn(process.execPath, DesktopServerStage.installArgs(arch), { cwd: this.dir });
|
|
114
138
|
} catch (error) {
|
|
115
139
|
throw new Error(
|
|
116
140
|
`The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -119,6 +143,22 @@ export class DesktopServerStage {
|
|
|
119
143
|
}
|
|
120
144
|
//? Launcher links only: a link out of the app bundle breaks its signature, and the server runs none of them.
|
|
121
145
|
await rm(path.join(this.dir, "node_modules", ".bin"), { recursive: true, force: true });
|
|
146
|
+
await this.#assertOmitted(omit);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//? Leaving a package out of package.json keeps out only what nothing else needs; another dependency still installs it.
|
|
150
|
+
async #assertOmitted(omit: string[]) {
|
|
151
|
+
const nodeModules = path.join(this.dir, "node_modules");
|
|
152
|
+
const kept: string[] = [];
|
|
153
|
+
for (const name of omit) {
|
|
154
|
+
if (!(await Bun.file(path.join(nodeModules, name, "package.json")).exists())) continue;
|
|
155
|
+
const dependents = await DesktopServerStage.dependentsOf(nodeModules, name);
|
|
156
|
+
kept.push(`${name} (needed by ${dependents.join(", ") || "a package that names it"})`);
|
|
157
|
+
}
|
|
158
|
+
if (kept.length)
|
|
159
|
+
throw new Error(
|
|
160
|
+
`desktop.server.omit leaves out packages another dependency still installs: ${kept.join("; ")}. Omit those too, or keep the package.`,
|
|
161
|
+
);
|
|
122
162
|
}
|
|
123
163
|
|
|
124
164
|
//? Copies the build beside what `install` put in the stage, so it runs after both.
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -86,15 +86,48 @@ describe("NativeApp", () => {
|
|
|
86
86
|
).toMatch(/^portal\/admin ios ready · iPhone 16 · http:\/\/localhost:8283 · 12\.\ds$/);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
test("an installer is what a person downloads, on the desktops that have one", () => {
|
|
90
|
+
expect(() => NativeApp.assertInstaller("windows", true)).not.toThrow();
|
|
91
|
+
expect(() => NativeApp.assertInstaller("macos", true)).not.toThrow();
|
|
92
|
+
expect(() => NativeApp.assertInstaller("linux", true)).not.toThrow();
|
|
93
|
+
expect(() => NativeApp.assertInstaller("ios", true)).toThrow(/not for ios/);
|
|
94
|
+
});
|
|
95
|
+
|
|
89
96
|
test("opens a dev build on its target's home, the CSR shell under the locale", () => {
|
|
90
|
-
expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
|
|
97
|
+
expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en", "ios")).toBe(
|
|
91
98
|
"/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
|
|
92
99
|
);
|
|
93
|
-
expect(new NativeApp(fakeApp(), target({ name: "admin", basePath: "/admin/" })).startPath("ko")).toBe(
|
|
100
|
+
expect(new NativeApp(fakeApp(), target({ name: "admin", basePath: "/admin/" })).startPath("ko", "android")).toBe(
|
|
94
101
|
"/ko/admin?csr=true&akanMobileTarget=admin&akanMobileBasePath=admin",
|
|
95
102
|
);
|
|
96
103
|
});
|
|
97
104
|
|
|
105
|
+
test("a platform section's indexPath wins on that platform, and the others keep the target's", () => {
|
|
106
|
+
const app = new NativeApp(fakeApp(), target({ indexPath: "/mobile", desktop: { indexPath: "/", server: true } }));
|
|
107
|
+
|
|
108
|
+
expect(app.indexPath("ios")).toBe("/mobile");
|
|
109
|
+
expect(app.indexPath("android")).toBe("/mobile");
|
|
110
|
+
expect(app.indexPath("macos")).toBe("/");
|
|
111
|
+
expect(app.indexPath("windows")).toBe("/");
|
|
112
|
+
expect(app.startPath("en", "macos")).toBe("/en?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2F");
|
|
113
|
+
expect(app.startPath("en", "ios")).toBe(
|
|
114
|
+
"/en/mobile?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fmobile",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("assembles each platform's web root with that platform's indexPath", async () => {
|
|
119
|
+
const root = await makeTempRoot();
|
|
120
|
+
await writeText(path.join(root, "dist/csr/index.html"), "<html><head></head><body></body></html>");
|
|
121
|
+
const app = { ...fakeApp(), cwdPath: root, dist: { cwdPath: path.join(root, "dist") } } as unknown as App;
|
|
122
|
+
const nativeApp = new NativeApp(app, target({ indexPath: "/mobile", desktop: { indexPath: "/console" } }));
|
|
123
|
+
const injected = async () => await Bun.file(path.join(nativeApp.web.dir, "index.html")).text();
|
|
124
|
+
|
|
125
|
+
await nativeApp.assembleWeb("linux");
|
|
126
|
+
expect(await injected()).toContain('"indexPath":"/console"');
|
|
127
|
+
await nativeApp.assembleWeb("android");
|
|
128
|
+
expect(await injected()).toContain('"indexPath":"/mobile"');
|
|
129
|
+
});
|
|
130
|
+
|
|
98
131
|
test("builds its config from the app's locales and the plugins' native contributions", async () => {
|
|
99
132
|
const push: AkanPlugin = { name: "push-notification", native: { permission: "push", plugins: ["push"] } };
|
|
100
133
|
const app = new NativeApp(
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
|
3
3
|
import type { NativeEnv } from "akanjs";
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { Executor } from "../executors";
|
|
6
|
-
import { DesktopBin } from "./desktopBin";
|
|
6
|
+
import { type DesktopArch, DesktopBin } from "./desktopBin";
|
|
7
7
|
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
8
8
|
import {
|
|
9
9
|
type DesktopPlatform,
|
|
@@ -38,8 +38,10 @@ export interface NativeBuildOptions {
|
|
|
38
38
|
profile?: "debug" | "release";
|
|
39
39
|
/** The server a desktop app carries (the target's `desktop.server`), staged by DesktopServerStage. */
|
|
40
40
|
server?: DesktopServerBundle;
|
|
41
|
-
/**
|
|
41
|
+
/** What a person downloads beside the app: a Windows NSIS setup program, a macOS dmg, a Linux AppImage. */
|
|
42
42
|
installer?: boolean;
|
|
43
|
+
/** The CPU the desktop app runs on, of the same OS; a macOS app is arm64 only. Default this computer's. */
|
|
44
|
+
arch?: DesktopArch;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export interface NativeDevOptions extends NativeRunOptions {
|
|
@@ -74,14 +76,24 @@ export class NativeApp {
|
|
|
74
76
|
return path.join(this.targetRoot, "dev", platform);
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/** Where the app starts on `platform`: its platform section's `indexPath`, else the target's. */
|
|
80
|
+
indexPath(platform: NativePlatform) {
|
|
81
|
+
const { config } = this.target;
|
|
82
|
+
const section = platform === "ios" ? config.ios : platform === "android" ? config.android : config.desktop;
|
|
83
|
+
return section?.indexPath ?? config.indexPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
/** The web root, from the production build the native commands run first. */
|
|
78
|
-
async assembleWeb() {
|
|
87
|
+
async assembleWeb(platform: NativePlatform) {
|
|
79
88
|
const dist = this.app.dist.cwdPath;
|
|
80
|
-
return await this.web.assemble(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
return await this.web.assemble(
|
|
90
|
+
{ ...this.target.config, indexPath: this.indexPath(platform) },
|
|
91
|
+
{
|
|
92
|
+
html: path.join(dist, "csr", targetHtmlFilename(this.target.config)),
|
|
93
|
+
publicDir: path.join(dist, "public"),
|
|
94
|
+
fontsDir: path.join(dist, ".akan", "artifact", "fonts"),
|
|
95
|
+
},
|
|
96
|
+
);
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
/** Where a desktop build stages the executables it carries (akan.config.ts `bin`). */
|
|
@@ -94,10 +106,12 @@ export class NativeApp {
|
|
|
94
106
|
server,
|
|
95
107
|
platform,
|
|
96
108
|
stageBin = true,
|
|
109
|
+
arch,
|
|
97
110
|
}: {
|
|
98
111
|
server?: DesktopServerBundle;
|
|
99
112
|
platform: NativePlatform;
|
|
100
113
|
stageBin?: boolean;
|
|
114
|
+
arch?: DesktopArch;
|
|
101
115
|
}) {
|
|
102
116
|
const [appConfig, plugins, nativePlugins] = await Promise.all([
|
|
103
117
|
this.app.getConfig(),
|
|
@@ -105,7 +119,7 @@ export class NativeApp {
|
|
|
105
119
|
NativePluginFolders.of(this.app),
|
|
106
120
|
]);
|
|
107
121
|
const desktop = platform === "macos" || platform === "windows" || platform === "linux";
|
|
108
|
-
const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir) : [];
|
|
122
|
+
const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir, arch) : [];
|
|
109
123
|
return NativeConfig.build({
|
|
110
124
|
appPath: this.app.cwdPath,
|
|
111
125
|
target: this.target.config,
|
|
@@ -121,10 +135,10 @@ export class NativeApp {
|
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
/** The API and the config it is about to build, refused here when the runtime would refuse it later. */
|
|
124
|
-
async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
|
|
138
|
+
async prepare(platform: NativePlatform, server?: DesktopServerBundle, arch?: DesktopArch) {
|
|
125
139
|
const [api, { config, warnings }] = await Promise.all([
|
|
126
140
|
NativeApi.load(this.app.cwdPath),
|
|
127
|
-
this.config({ platform, ...(server ? { server } : {}) }),
|
|
141
|
+
this.config({ platform, ...(server ? { server } : {}), ...(arch ? { arch } : {}) }),
|
|
128
142
|
]);
|
|
129
143
|
for (const warning of warnings) this.app.logger.warn(warning);
|
|
130
144
|
this.#assertValid(api, config);
|
|
@@ -165,15 +179,35 @@ export class NativeApp {
|
|
|
165
179
|
};
|
|
166
180
|
}
|
|
167
181
|
|
|
168
|
-
async build(
|
|
182
|
+
async build(
|
|
183
|
+
platform: NativePlatform,
|
|
184
|
+
{ profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
|
|
185
|
+
) {
|
|
169
186
|
NativeApp.#assertServerPlatform(platform, server);
|
|
170
|
-
|
|
171
|
-
await this.assembleWeb();
|
|
172
|
-
const { api, config } = await this.prepare(platform, server);
|
|
187
|
+
NativeApp.assertInstaller(platform, installer);
|
|
188
|
+
await this.assembleWeb(platform);
|
|
189
|
+
const { api, config } = await this.prepare(platform, server, arch);
|
|
173
190
|
return await api.build({
|
|
174
191
|
...this.#task(platform, config),
|
|
175
192
|
profile,
|
|
176
|
-
...(
|
|
193
|
+
...(arch ? { arch } : {}),
|
|
194
|
+
...(platform === "windows"
|
|
195
|
+
? {
|
|
196
|
+
windows: {
|
|
197
|
+
...(installer ? { installer } : {}),
|
|
198
|
+
...(profile === "release" ? NativeApp.#windowsSigning(api) : {}),
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
: {}),
|
|
202
|
+
...(platform === "macos"
|
|
203
|
+
? {
|
|
204
|
+
macos: {
|
|
205
|
+
...(profile === "release" ? api.macosDistributionFromEnv(process.env) : {}),
|
|
206
|
+
...(installer ? { dmg: true } : {}),
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
: {}),
|
|
210
|
+
...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
|
|
177
211
|
});
|
|
178
212
|
}
|
|
179
213
|
|
|
@@ -182,7 +216,7 @@ export class NativeApp {
|
|
|
182
216
|
{ device, teamId, profile = "debug", server }: NativeRunOptions & NativeBuildOptions = {},
|
|
183
217
|
) {
|
|
184
218
|
NativeApp.#assertServerPlatform(platform, server);
|
|
185
|
-
await this.assembleWeb();
|
|
219
|
+
await this.assembleWeb(platform);
|
|
186
220
|
const { api, config } = await this.prepare(platform, server);
|
|
187
221
|
return await api.run({
|
|
188
222
|
...this.#task(platform, config),
|
|
@@ -211,7 +245,7 @@ export class NativeApp {
|
|
|
211
245
|
wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
|
|
212
246
|
reversePorts: [Number(new URL(upstream).port)],
|
|
213
247
|
onLine: this.#appLine,
|
|
214
|
-
startPath: this.startPath(lang),
|
|
248
|
+
startPath: this.startPath(lang, platform),
|
|
215
249
|
...(device ? { device } : {}),
|
|
216
250
|
...(teamId ? { ios: { signing: { teamId } } } : {}),
|
|
217
251
|
});
|
|
@@ -242,7 +276,7 @@ export class NativeApp {
|
|
|
242
276
|
}
|
|
243
277
|
|
|
244
278
|
async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
|
|
245
|
-
await this.assembleWeb();
|
|
279
|
+
await this.assembleWeb("ios");
|
|
246
280
|
const { api, config } = await this.prepare("ios");
|
|
247
281
|
return await api.release({
|
|
248
282
|
...this.#task("ios", config),
|
|
@@ -263,13 +297,16 @@ export class NativeApp {
|
|
|
263
297
|
) {
|
|
264
298
|
NativeApp.#assertServerPlatform(platform, server);
|
|
265
299
|
this.#assertUpdates();
|
|
266
|
-
await this.assembleWeb();
|
|
300
|
+
await this.assembleWeb(platform);
|
|
267
301
|
const { api, config } = await this.prepare(platform, server);
|
|
268
302
|
return await api.publishUpdate({
|
|
269
303
|
...this.#task(platform, config),
|
|
270
304
|
platform,
|
|
271
305
|
out: this.updatesDir,
|
|
272
306
|
...(channel ? { channel } : {}),
|
|
307
|
+
//? The updater keeps the installed app's signature, so a release is signed as the downloaded app was.
|
|
308
|
+
...(platform === "macos" ? { macos: api.macosDistributionFromEnv(process.env) } : {}),
|
|
309
|
+
...(platform === "windows" ? { windows: NativeApp.#windowsSigning(api) } : {}),
|
|
273
310
|
});
|
|
274
311
|
}
|
|
275
312
|
|
|
@@ -324,14 +361,14 @@ export class NativeApp {
|
|
|
324
361
|
|
|
325
362
|
async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
|
|
326
363
|
const signing = NativeApp.androidSigning();
|
|
327
|
-
await this.assembleWeb();
|
|
364
|
+
await this.assembleWeb("android");
|
|
328
365
|
const { api, config } = await this.prepare("android");
|
|
329
366
|
return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
|
|
330
367
|
}
|
|
331
368
|
|
|
332
369
|
/** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
|
|
333
370
|
async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
|
|
334
|
-
await this.assembleWeb();
|
|
371
|
+
await this.assembleWeb(platform);
|
|
335
372
|
const { api, config } = await this.prepare(platform);
|
|
336
373
|
if (!config.updates)
|
|
337
374
|
throw new Error(
|
|
@@ -370,17 +407,29 @@ export class NativeApp {
|
|
|
370
407
|
|
|
371
408
|
/** The first page of a dev build, the target's home as a release bundle opens it; the dev server answers the CSR
|
|
372
409
|
* shell only for `?csr=true`. */
|
|
373
|
-
startPath(lang: string) {
|
|
410
|
+
startPath(lang: string, platform: NativePlatform) {
|
|
374
411
|
const basePath = this.target.config.basePath?.replace(/^\/+|\/+$/g, "");
|
|
412
|
+
const indexPath = this.indexPath(platform);
|
|
375
413
|
const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
|
|
376
414
|
if (basePath) params.set("akanMobileBasePath", basePath);
|
|
377
|
-
if (
|
|
378
|
-
const home = [lang, basePath,
|
|
415
|
+
if (indexPath) params.set("akanMobileIndexPath", indexPath);
|
|
416
|
+
const home = [lang, basePath, indexPath]
|
|
379
417
|
.flatMap((part) => (part ?? "").split("/"))
|
|
380
418
|
.filter((segment) => segment.length > 0);
|
|
381
419
|
return `/${home.join("/")}?${params}`;
|
|
382
420
|
}
|
|
383
421
|
|
|
422
|
+
static #windowsSigning(api: NativeBuildApiModule) {
|
|
423
|
+
const signing = api.windowsSigningFromEnv(process.env);
|
|
424
|
+
return signing ? { signing } : {};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
//* `installer` is the file a person downloads: a setup program on Windows, a disk image on macOS, an AppImage on Linux.
|
|
428
|
+
static assertInstaller(platform: NativePlatform, installer: boolean) {
|
|
429
|
+
if (installer && (platform === "ios" || platform === "android"))
|
|
430
|
+
throw new Error(`An installer is built for a desktop app (Windows, macOS, Linux), not for ${platform}.`);
|
|
431
|
+
}
|
|
432
|
+
|
|
384
433
|
//? A phone runs no Bun, so only a desktop app can carry the server.
|
|
385
434
|
static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
|
|
386
435
|
if (server && (platform === "ios" || platform === "android"))
|
|
@@ -341,6 +341,30 @@ describe("NativeConfig.build", () => {
|
|
|
341
341
|
expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
test("a desktop app's own entitlements reach the macOS build, and the runtime accepts them", async () => {
|
|
345
|
+
const root = await makeTempRoot();
|
|
346
|
+
const entitlements = { "com.apple.security.cs.disable-library-validation": true };
|
|
347
|
+
const { config } = NativeConfig.build({
|
|
348
|
+
appPath: root,
|
|
349
|
+
target: {
|
|
350
|
+
...minimalTarget,
|
|
351
|
+
permissions: [],
|
|
352
|
+
deepLinks: undefined,
|
|
353
|
+
android: undefined,
|
|
354
|
+
desktop: { entitlements },
|
|
355
|
+
},
|
|
356
|
+
webDir: path.join(root, "web"),
|
|
357
|
+
contributions: [],
|
|
358
|
+
locales: ["en"],
|
|
359
|
+
platform: "macos",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(config.native?.macos).toEqual({ entitlements });
|
|
363
|
+
await mkdir(path.join(root, "web"), { recursive: true });
|
|
364
|
+
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
365
|
+
expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
|
|
344
368
|
test("an app with updates ships the updates plugin and its settings", () => {
|
|
345
369
|
const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=`, channel: "pilot" };
|
|
346
370
|
const { config } = NativeConfig.build({
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -161,7 +161,8 @@ export class NativeConfig {
|
|
|
161
161
|
...Object.entries(target.ios?.files ?? {}).map(([to, from]) => ({ from: abs(from), to: `ios/${to}` })),
|
|
162
162
|
...Object.entries(target.android?.files ?? {}).map(([to, from]) => ({ from: abs(from), to: `android/${to}` })),
|
|
163
163
|
];
|
|
164
|
-
const
|
|
164
|
+
const macos = NativeConfig.#compact({ entitlements: target.desktop?.entitlements });
|
|
165
|
+
const native = NativeConfig.#compact({ ios, macos, android, resources });
|
|
165
166
|
const googleServices = target.android?.googleServices;
|
|
166
167
|
const pushAndroid = target.android?.push;
|
|
167
168
|
const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
|
|
@@ -234,10 +235,7 @@ export class NativeConfig {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
//? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
|
|
237
|
-
static #updates(
|
|
238
|
-
target: AkanNativeTarget,
|
|
239
|
-
env?: NativeEnv,
|
|
240
|
-
): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
238
|
+
static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
241
239
|
const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
|
|
242
240
|
return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
|
|
243
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.32",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@earendil-works/pi-tui": "0.86.1",
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
|
-
"akanjs": "3.0.0-beta.
|
|
50
|
+
"akanjs": "3.0.0-beta.32",
|
|
51
51
|
"chalk": "^5.6.2",
|
|
52
52
|
"commander": "^14.0.3",
|
|
53
53
|
"dayjs": "^1.11.20",
|