@akanjs/devkit 3.0.0-beta.27 → 3.0.0-beta.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanApp/akanApp.host.test.ts +55 -0
- package/akanApp/akanApp.host.ts +24 -2
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +212 -2
- package/akanConfig/akanConfig.ts +93 -26
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +7 -1
- package/applicationBuildRunner.test.ts +36 -2
- package/applicationBuildRunner.ts +21 -6
- package/biome.base.json +3 -1
- package/csrE2e/csrPageStack.e2e.test.ts +65 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
- package/frontendBuild/csrDevModuleCompiler.ts +45 -2
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- package/integration/devStability.integration.test.ts +35 -0
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +204 -0
- package/mobile/desktopServerStage.ts +138 -0
- package/mobile/index.ts +3 -0
- package/mobile/mobileTarget.test.ts +50 -1
- package/mobile/mobileTarget.ts +20 -4
- package/mobile/nativeApp.test.ts +52 -1
- package/mobile/nativeApp.ts +175 -20
- package/mobile/nativeAppLine.test.ts +7 -0
- package/mobile/nativeAppLine.ts +8 -0
- package/mobile/nativeConfig.test.ts +215 -4
- package/mobile/nativeConfig.ts +117 -8
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.ts +6 -2
- package/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- package/workspaceLayout.ts +2 -0
package/mobile/nativeApp.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
3
|
+
import type { MobileEnv } from "akanjs";
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { Executor } from "../executors";
|
|
6
|
+
import { DesktopBin } from "./desktopBin";
|
|
7
|
+
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
6
8
|
import {
|
|
7
9
|
type DesktopPlatform,
|
|
8
10
|
type MobilePlatform,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
|
|
14
16
|
import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
|
|
15
17
|
import { NativeConfig } from "./nativeConfig";
|
|
18
|
+
import { NativePluginFolders } from "./nativePluginFolders";
|
|
16
19
|
import { NativeWebDir } from "./nativeWebDir";
|
|
17
20
|
|
|
18
21
|
type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
@@ -31,6 +34,14 @@ interface NativeDevBoot {
|
|
|
31
34
|
lines: NativeAppLineRead[];
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
export interface NativeBuildOptions {
|
|
38
|
+
profile?: "debug" | "release";
|
|
39
|
+
/** The server a desktop app carries (the target's `native.desktop.server`), staged by DesktopServerStage. */
|
|
40
|
+
server?: DesktopServerBundle;
|
|
41
|
+
/** Windows: an NSIS setup program beside the app folder. */
|
|
42
|
+
installer?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
export interface NativeDevOptions extends NativeRunOptions {
|
|
35
46
|
/** The akan dev server the gateway proxies. */
|
|
36
47
|
upstream: string;
|
|
@@ -48,6 +59,7 @@ export class NativeApp {
|
|
|
48
59
|
constructor(
|
|
49
60
|
readonly app: App,
|
|
50
61
|
readonly target: ResolvedMobileTarget,
|
|
62
|
+
readonly env?: MobileEnv,
|
|
51
63
|
) {
|
|
52
64
|
this.targetRoot = path.join(app.cwdPath, ".akan", "mobile", target.name);
|
|
53
65
|
this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
|
|
@@ -57,6 +69,11 @@ export class NativeApp {
|
|
|
57
69
|
return path.join(this.targetRoot, "native", platform);
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
//? Apart from the builds: a desktop build empties its folder first, which would take a release and its installer.
|
|
73
|
+
devOutDir(platform: NativePlatform) {
|
|
74
|
+
return path.join(this.targetRoot, "dev", platform);
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/** The web root, from the production build the mobile commands run first. */
|
|
61
78
|
async assembleWeb() {
|
|
62
79
|
const dist = this.app.dist.cwdPath;
|
|
@@ -67,27 +84,59 @@ export class NativeApp {
|
|
|
67
84
|
});
|
|
68
85
|
}
|
|
69
86
|
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
/** Where a desktop build stages the executables it carries (akan.config.ts `bin`). */
|
|
88
|
+
get binDir() {
|
|
89
|
+
return path.join(this.targetRoot, "bin");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `stageBin: false` for a config nothing is built from (the update key's): staging downloads the `bin` sources. */
|
|
93
|
+
async config({
|
|
94
|
+
server,
|
|
95
|
+
platform,
|
|
96
|
+
stageBin = true,
|
|
97
|
+
}: {
|
|
98
|
+
server?: DesktopServerBundle;
|
|
99
|
+
platform: NativePlatform;
|
|
100
|
+
stageBin?: boolean;
|
|
101
|
+
}) {
|
|
102
|
+
const [appConfig, plugins, nativePlugins] = await Promise.all([
|
|
103
|
+
this.app.getConfig(),
|
|
104
|
+
this.app.collectPlugins(),
|
|
105
|
+
NativePluginFolders.of(this.app),
|
|
106
|
+
]);
|
|
107
|
+
const desktop = platform === "macos" || platform === "windows" || platform === "linux";
|
|
108
|
+
const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir) : [];
|
|
72
109
|
return NativeConfig.build({
|
|
73
110
|
appPath: this.app.cwdPath,
|
|
74
111
|
target: this.target.config,
|
|
75
112
|
webDir: this.web.dir,
|
|
76
113
|
contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
|
|
77
114
|
locales: appConfig.i18n.locales,
|
|
115
|
+
env: this.env,
|
|
116
|
+
platform,
|
|
117
|
+
nativePlugins,
|
|
118
|
+
...(server ? { desktopServer: server } : {}),
|
|
119
|
+
...(carried.length ? { desktopBin: this.binDir } : {}),
|
|
78
120
|
});
|
|
79
121
|
}
|
|
80
122
|
|
|
81
123
|
/** The API and the config it is about to build, refused here when the runtime would refuse it later. */
|
|
82
|
-
async prepare() {
|
|
83
|
-
const [api, { config, warnings }] = await Promise.all([
|
|
124
|
+
async prepare(platform: NativePlatform, server?: DesktopServerBundle) {
|
|
125
|
+
const [api, { config, warnings }] = await Promise.all([
|
|
126
|
+
NativeApi.load(this.app.cwdPath),
|
|
127
|
+
this.config({ platform, ...(server ? { server } : {}) }),
|
|
128
|
+
]);
|
|
84
129
|
for (const warning of warnings) this.app.logger.warn(warning);
|
|
130
|
+
this.#assertValid(api, config);
|
|
131
|
+
return { api, config };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#assertValid(api: NativeBuildApiModule, config: AkanNativeConfig) {
|
|
85
135
|
const problems = api.validateConfig(config, { appDir: this.app.cwdPath });
|
|
86
136
|
if (problems.length)
|
|
87
137
|
throw new Error(
|
|
88
138
|
`Mobile target '${this.target.name}' makes an invalid native config:\n- ${problems.join("\n- ")}`,
|
|
89
139
|
);
|
|
90
|
-
return { api, config };
|
|
91
140
|
}
|
|
92
141
|
|
|
93
142
|
//? The app's own output: its console (mirrored by a dev build), the simulator log stream or logcat.
|
|
@@ -98,12 +147,12 @@ export class NativeApp {
|
|
|
98
147
|
};
|
|
99
148
|
|
|
100
149
|
//* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
|
|
101
|
-
#task(platform: NativePlatform, config: AkanNativeConfig): TaskOptions {
|
|
150
|
+
#task(platform: NativePlatform, config: AkanNativeConfig, outDir = this.outDir(platform)): TaskOptions {
|
|
102
151
|
return {
|
|
103
152
|
appDir: this.app.cwdPath,
|
|
104
153
|
config,
|
|
105
154
|
platform,
|
|
106
|
-
outDir
|
|
155
|
+
outDir,
|
|
107
156
|
envFiles: false,
|
|
108
157
|
skipWebBuild: true,
|
|
109
158
|
log: (event) => {
|
|
@@ -116,18 +165,25 @@ export class NativeApp {
|
|
|
116
165
|
};
|
|
117
166
|
}
|
|
118
167
|
|
|
119
|
-
async build(platform: NativePlatform, { profile = "release"
|
|
168
|
+
async build(platform: NativePlatform, { profile = "release", server, installer = false }: NativeBuildOptions = {}) {
|
|
169
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
170
|
+
if (installer && platform !== "windows") throw new Error(`An installer is built for Windows, not for ${platform}.`);
|
|
120
171
|
await this.assembleWeb();
|
|
121
|
-
const { api, config } = await this.prepare();
|
|
122
|
-
return await api.build({
|
|
172
|
+
const { api, config } = await this.prepare(platform, server);
|
|
173
|
+
return await api.build({
|
|
174
|
+
...this.#task(platform, config),
|
|
175
|
+
profile,
|
|
176
|
+
...(installer ? { windows: { installer } } : {}),
|
|
177
|
+
});
|
|
123
178
|
}
|
|
124
179
|
|
|
125
180
|
async run(
|
|
126
181
|
platform: NativePlatform,
|
|
127
|
-
{ device, teamId, profile = "debug" }: NativeRunOptions &
|
|
182
|
+
{ device, teamId, profile = "debug", server }: NativeRunOptions & NativeBuildOptions = {},
|
|
128
183
|
) {
|
|
184
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
129
185
|
await this.assembleWeb();
|
|
130
|
-
const { api, config } = await this.prepare();
|
|
186
|
+
const { api, config } = await this.prepare(platform, server);
|
|
131
187
|
return await api.run({
|
|
132
188
|
...this.#task(platform, config),
|
|
133
189
|
profile,
|
|
@@ -137,21 +193,23 @@ export class NativeApp {
|
|
|
137
193
|
});
|
|
138
194
|
}
|
|
139
195
|
|
|
140
|
-
//* The gateway serves the page
|
|
141
|
-
//*
|
|
196
|
+
//* The gateway serves the page, its HMR socket and its API calls on the app origin. The dev server's own port is
|
|
197
|
+
//* reversed too: a browser the app hands off to (auth-session) opens it directly (baseEnv getServerOrigin).
|
|
142
198
|
async dev(platform: NativePlatform, { upstream, lang, device, teamId }: NativeDevOptions) {
|
|
143
199
|
const startedAt = performance.now();
|
|
144
200
|
const boot: NativeDevBoot = { steps: [], lines: [] };
|
|
145
201
|
this.#boot = boot;
|
|
146
202
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
203
|
+
//? The runtime bundles an index.html it finds here as the dev build's page, and a release build leaves one.
|
|
204
|
+
await this.web.clear();
|
|
205
|
+
const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
|
|
149
206
|
const session = await api.dev({
|
|
150
|
-
...this.#task(platform, config),
|
|
207
|
+
...this.#task(platform, config, this.devOutDir(platform)),
|
|
151
208
|
upstream,
|
|
152
209
|
hmrPath: "/_akan/hmr",
|
|
153
210
|
//? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
|
|
154
211
|
wsPaths: [`${routes.prefix}${routes.websocketPrefix}`],
|
|
212
|
+
reversePorts: [Number(new URL(upstream).port)],
|
|
155
213
|
onLine: this.#appLine,
|
|
156
214
|
startPath: this.startPath(lang),
|
|
157
215
|
...(device ? { device } : {}),
|
|
@@ -185,7 +243,7 @@ export class NativeApp {
|
|
|
185
243
|
|
|
186
244
|
async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
|
|
187
245
|
await this.assembleWeb();
|
|
188
|
-
const { api, config } = await this.prepare();
|
|
246
|
+
const { api, config } = await this.prepare("ios");
|
|
189
247
|
return await api.release({
|
|
190
248
|
...this.#task("ios", config),
|
|
191
249
|
platform: "ios",
|
|
@@ -193,13 +251,104 @@ export class NativeApp {
|
|
|
193
251
|
});
|
|
194
252
|
}
|
|
195
253
|
|
|
254
|
+
/** Where publishUpdate writes: upload this folder as the target's updates.url. */
|
|
255
|
+
get updatesDir() {
|
|
256
|
+
return path.join(this.targetRoot, "updates");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
|
|
260
|
+
async publishUpdate(
|
|
261
|
+
platform: NativePlatform,
|
|
262
|
+
{ channel, server }: { channel?: string; server?: DesktopServerBundle } = {},
|
|
263
|
+
) {
|
|
264
|
+
NativeApp.#assertServerPlatform(platform, server);
|
|
265
|
+
this.#assertUpdates();
|
|
266
|
+
await this.assembleWeb();
|
|
267
|
+
const { api, config } = await this.prepare(platform, server);
|
|
268
|
+
return await api.publishUpdate({
|
|
269
|
+
...this.#task(platform, config),
|
|
270
|
+
platform,
|
|
271
|
+
out: this.updatesDir,
|
|
272
|
+
...(channel ? { channel } : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
|
|
278
|
+
* another app's, and a desktop release whose server presence is not its channel's previous release's.
|
|
279
|
+
*/
|
|
280
|
+
async assertPublishable(
|
|
281
|
+
platform: NativePlatform,
|
|
282
|
+
{ channel, server = false }: { channel?: string; server?: boolean } = {},
|
|
283
|
+
) {
|
|
284
|
+
this.#assertUpdates();
|
|
285
|
+
const [api, { config }] = await Promise.all([
|
|
286
|
+
NativeApi.load(this.app.cwdPath),
|
|
287
|
+
this.config({ platform, stageBin: false }),
|
|
288
|
+
]);
|
|
289
|
+
this.#assertValid(api, config);
|
|
290
|
+
try {
|
|
291
|
+
api.checkPublishUpdate({
|
|
292
|
+
appDir: this.app.cwdPath,
|
|
293
|
+
config,
|
|
294
|
+
platform,
|
|
295
|
+
out: this.updatesDir,
|
|
296
|
+
server,
|
|
297
|
+
...(channel ? { channel } : {}),
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`Mobile target '${this.target.name}': ${error instanceof Error ? error.message : String(error)}`,
|
|
302
|
+
{
|
|
303
|
+
cause: error,
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#assertUpdates() {
|
|
310
|
+
if (!this.target.config.updates)
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Mobile target '${this.target.name}' has no updates: { url, publicKey } in akan.config.ts; \`akan update-keygen ${this.app.name}\` prints the key.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The key update releases of this target's app id on `platform` are signed with: made once, then read. */
|
|
317
|
+
async updateKeygen(platform: NativePlatform) {
|
|
318
|
+
const [api, { config }] = await Promise.all([
|
|
319
|
+
NativeApi.load(this.app.cwdPath),
|
|
320
|
+
this.config({ platform, stageBin: false }),
|
|
321
|
+
]);
|
|
322
|
+
return api.updateKeygen({ config });
|
|
323
|
+
}
|
|
324
|
+
|
|
196
325
|
async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
|
|
197
326
|
const signing = NativeApp.androidSigning();
|
|
198
327
|
await this.assembleWeb();
|
|
199
|
-
const { api, config } = await this.prepare();
|
|
328
|
+
const { api, config } = await this.prepare("android");
|
|
200
329
|
return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
|
|
201
330
|
}
|
|
202
331
|
|
|
332
|
+
/** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
|
|
333
|
+
async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
|
|
334
|
+
await this.assembleWeb();
|
|
335
|
+
const { api, config } = await this.prepare(platform);
|
|
336
|
+
if (!config.updates)
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Mobile target '${this.target.name}' takes no updates: add mobile.updates: { url, publicKey } to akan.config.ts first.`,
|
|
339
|
+
);
|
|
340
|
+
const packed = await api.packUpdate({
|
|
341
|
+
...this.#task(platform, config),
|
|
342
|
+
platform,
|
|
343
|
+
out: out ?? path.join(this.targetRoot, "updates", platform),
|
|
344
|
+
});
|
|
345
|
+
return { ...packed, channel: config.updates.channel ?? "production" };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static async compareBundles(appDir: string, shipped: string, bundle: string) {
|
|
349
|
+
return (await NativeApi.load(appDir)).compareBundles(shipped, bundle);
|
|
350
|
+
}
|
|
351
|
+
|
|
203
352
|
//* The names the Gradle build read, so a CI that already holds these secrets keeps working.
|
|
204
353
|
static androidSigning(env: Record<string, string | undefined> = process.env): AndroidSigning {
|
|
205
354
|
const keystore = env.MYAPP_RELEASE_STORE_FILE;
|
|
@@ -232,6 +381,12 @@ export class NativeApp {
|
|
|
232
381
|
return `/${home.join("/")}?${params}`;
|
|
233
382
|
}
|
|
234
383
|
|
|
384
|
+
//? A phone runs no Bun, so only a desktop app can carry the server.
|
|
385
|
+
static #assertServerPlatform(platform: NativePlatform, server: DesktopServerBundle | undefined) {
|
|
386
|
+
if (server && (platform === "ios" || platform === "android"))
|
|
387
|
+
throw new Error(`Only a desktop app carries its server; ${platform} cannot run one.`);
|
|
388
|
+
}
|
|
389
|
+
|
|
235
390
|
//* A desktop app builds only on its own OS, so the platform is this computer's.
|
|
236
391
|
static desktopPlatform(host: NodeJS.Platform = process.platform): DesktopPlatform {
|
|
237
392
|
if (host === "darwin") return "macos";
|
|
@@ -24,6 +24,13 @@ describe("NativeAppLine", () => {
|
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
test("a carried server's line keeps the level its logger wrote, and one without a level stays visible", () => {
|
|
28
|
+
const line = "[server] [minimal] 81 - 09/30/2026, 12:30:06 PM ERROR boom +2ms";
|
|
29
|
+
expect(NativeAppLine.read(line)).toEqual({ level: "error", message: line });
|
|
30
|
+
expect(NativeAppLine.read("[server] \u001b[32m[minimal] 81 -\u001b[39m WARN slow").level).toBe("warn");
|
|
31
|
+
expect(NativeAppLine.read("[server] at run (main.js:1:2)").level).toBe("info");
|
|
32
|
+
});
|
|
33
|
+
|
|
27
34
|
test("a second window is named, the first is not", () => {
|
|
28
35
|
expect(NativeAppLine.read("[page#2 info] [App] ready")).toEqual({ level: "info", message: "[page#2:App] ready" });
|
|
29
36
|
});
|
package/mobile/nativeAppLine.ts
CHANGED
|
@@ -13,6 +13,10 @@ export class NativeAppLine {
|
|
|
13
13
|
static readonly #page = /^\[page(\+)?(?:#(\d+))? (trace|verbose|debug|log|info|warn|error)\] ?(.*)$/;
|
|
14
14
|
static readonly #logcat = /^([VDIWEFA])\/([^(]+?)\s*\(\s*\d+\):\s?(.*)$/;
|
|
15
15
|
static readonly #named = /^\[([^\]\s]+)\] ([\s\S]*)$/;
|
|
16
|
+
//? A server the desktop app carries (host server.ts): `[server] <its own log line>`, whose level word the akan logger
|
|
17
|
+
//? wrote; a line without one (a crash's stack) is still the app's and stays visible.
|
|
18
|
+
static readonly #server = /^\[server\] /;
|
|
19
|
+
static readonly #serverLevel = /\b(TRACE|VERBOSE|DEBUG|INFO|WARN|ERROR)\b/;
|
|
16
20
|
static readonly #logcatLevels: { [priority: string]: LogLevel } = {
|
|
17
21
|
V: "verbose",
|
|
18
22
|
D: "debug",
|
|
@@ -42,6 +46,10 @@ export class NativeAppLine {
|
|
|
42
46
|
});
|
|
43
47
|
}
|
|
44
48
|
if (line.startsWith("[akan-native")) return NativeAppLine.#hostLine(line, "info");
|
|
49
|
+
if (NativeAppLine.#server.test(line)) {
|
|
50
|
+
const word = NativeAppLine.#serverLevel.exec(Bun.stripANSI(line))?.[1];
|
|
51
|
+
return { level: word ? (word.toLowerCase() as LogLevel) : "info", message: line };
|
|
52
|
+
}
|
|
45
53
|
return { level: verbose ? "info" : "debug", message: line };
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -45,26 +45,29 @@ const adminTarget: AkanMobileTargetConfig = {
|
|
|
45
45
|
},
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const updatesKey = Buffer.alloc(32, 7).toString("base64");
|
|
49
|
+
|
|
48
50
|
describe("NativeConfig.build", () => {
|
|
49
|
-
test("turns the minimal app's push target into the runtime's config", () => {
|
|
51
|
+
test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
|
|
50
52
|
const { config, warnings } = NativeConfig.build({
|
|
51
53
|
appPath: "/repo/apps/minimal",
|
|
52
54
|
target: minimalTarget,
|
|
53
55
|
webDir: "/repo/apps/minimal/.akan/mobile/default/web",
|
|
54
56
|
contributions: [{ permission: "push", plugins: ["push"] }],
|
|
55
57
|
locales: ["en", "ko"],
|
|
58
|
+
platform: "android",
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
expect(warnings).toEqual([]);
|
|
59
62
|
expect(config).toEqual({
|
|
60
63
|
app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
|
|
61
64
|
web: { dir: "/repo/apps/minimal/.akan/mobile/default/web" },
|
|
62
|
-
plugins: [...NativeConfig.basePlugins, "push"],
|
|
65
|
+
plugins: [...NativeConfig.basePlugins, "single-instance", "push"],
|
|
63
66
|
capabilities: [
|
|
64
67
|
{
|
|
65
68
|
identifier: "app",
|
|
66
69
|
description: "The plugins minimal ships, each with its default permissions",
|
|
67
|
-
permissions: [...NativeConfig.basePlugins, "push"].map((plugin) => `${plugin}:default`),
|
|
70
|
+
permissions: [...NativeConfig.basePlugins, "single-instance", "push"].map((plugin) => `${plugin}:default`),
|
|
68
71
|
},
|
|
69
72
|
],
|
|
70
73
|
deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
|
|
@@ -83,6 +86,7 @@ describe("NativeConfig.build", () => {
|
|
|
83
86
|
webDir: "/repo/apps/portal/.akan/mobile/admin/web",
|
|
84
87
|
contributions: [],
|
|
85
88
|
locales: ["en", "ko"],
|
|
89
|
+
platform: "android",
|
|
86
90
|
});
|
|
87
91
|
|
|
88
92
|
expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
|
|
@@ -138,6 +142,7 @@ describe("NativeConfig.build", () => {
|
|
|
138
142
|
{ permission: "push", plugins: ["push"] },
|
|
139
143
|
],
|
|
140
144
|
locales: ["en"],
|
|
145
|
+
platform: "android",
|
|
141
146
|
});
|
|
142
147
|
|
|
143
148
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "camera"]);
|
|
@@ -152,6 +157,75 @@ describe("NativeConfig.build", () => {
|
|
|
152
157
|
});
|
|
153
158
|
});
|
|
154
159
|
|
|
160
|
+
test("updates bring their plugin and follow the binary's backend unless a channel is named", () => {
|
|
161
|
+
const build = (updates: AkanMobileTargetConfig["updates"], env?: "main" | "develop") =>
|
|
162
|
+
NativeConfig.build({
|
|
163
|
+
appPath: "/repo/apps/portal",
|
|
164
|
+
target: { ...adminTarget, updates },
|
|
165
|
+
webDir: "/web",
|
|
166
|
+
contributions: [],
|
|
167
|
+
locales: ["en"],
|
|
168
|
+
platform: "android",
|
|
169
|
+
env,
|
|
170
|
+
}).config;
|
|
171
|
+
const url = "https://updates.example.com/portal";
|
|
172
|
+
expect(build({ url, publicKey: updatesKey }, "develop").updates).toEqual({
|
|
173
|
+
url,
|
|
174
|
+
publicKey: updatesKey,
|
|
175
|
+
channel: "develop",
|
|
176
|
+
});
|
|
177
|
+
expect(build({ url, publicKey: updatesKey, channel: "beta", readyTimeout: 15_000 }, "main").updates).toEqual({
|
|
178
|
+
url,
|
|
179
|
+
publicKey: updatesKey,
|
|
180
|
+
channel: "beta",
|
|
181
|
+
readyTimeout: 15_000,
|
|
182
|
+
});
|
|
183
|
+
expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
|
|
184
|
+
expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
|
|
185
|
+
expect(build(undefined, "main").plugins).not.toContain("updates");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("push, privacy and the icon and splash objects reach the runtime, paths from the app folder", () => {
|
|
189
|
+
const { config } = NativeConfig.build({
|
|
190
|
+
appPath: "/repo/apps/portal",
|
|
191
|
+
target: {
|
|
192
|
+
...adminTarget,
|
|
193
|
+
assets: {
|
|
194
|
+
icon: { image: "assets/icon.png", backgroundColor: "#ffffff" },
|
|
195
|
+
splash: { backgroundColor: { light: "#ffffff", dark: "#000000" }, autoHide: false },
|
|
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
|
+
},
|
|
201
|
+
},
|
|
202
|
+
webDir: "/web",
|
|
203
|
+
contributions: [],
|
|
204
|
+
locales: ["en"],
|
|
205
|
+
platform: "android",
|
|
206
|
+
});
|
|
207
|
+
const abs = (relative: string) => path.resolve("/repo/apps/portal", relative);
|
|
208
|
+
expect(config.icon).toEqual({ image: abs("assets/icon.png"), backgroundColor: "#ffffff" });
|
|
209
|
+
expect(config.splash).toEqual({ backgroundColor: { light: "#ffffff", dark: "#000000" }, autoHide: false });
|
|
210
|
+
expect(config.push).toEqual({
|
|
211
|
+
android: { smallIcon: abs("assets/noti.png"), color: "#ff5a5f", channel: { id: "chat", name: "Chat" } },
|
|
212
|
+
});
|
|
213
|
+
expect(config.privacy).toEqual({ tracking: false, accessedApis: { DiskSpace: ["E174.1"] } });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("an app id per platform gives each build its own", () => {
|
|
217
|
+
const idOn = (platform: "ios" | "android") =>
|
|
218
|
+
NativeConfig.build({
|
|
219
|
+
appPath: "/repo/apps/portal",
|
|
220
|
+
target: { ...adminTarget, appId: { ios: "com.puffinplanet.admin", android: "com.portal.admin" } },
|
|
221
|
+
webDir: "/web",
|
|
222
|
+
contributions: [],
|
|
223
|
+
locales: ["en"],
|
|
224
|
+
platform,
|
|
225
|
+
}).config.app.id;
|
|
226
|
+
expect([idOn("ios"), idOn("android")]).toEqual(["com.puffinplanet.admin", "com.portal.admin"]);
|
|
227
|
+
});
|
|
228
|
+
|
|
155
229
|
test("the runtime accepts both configs as they are", async () => {
|
|
156
230
|
const root = await makeTempRoot();
|
|
157
231
|
for (const file of ["secrets/google-services.json", "assets/chime.mp3"])
|
|
@@ -164,18 +238,146 @@ describe("NativeConfig.build", () => {
|
|
|
164
238
|
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
165
239
|
const api = await NativeApi.load(repoApp);
|
|
166
240
|
|
|
167
|
-
|
|
241
|
+
const adminWithEverything: AkanMobileTargetConfig = {
|
|
242
|
+
...adminTarget,
|
|
243
|
+
updates: { url: "https://updates.example.com/portal", publicKey: updatesKey },
|
|
244
|
+
native: {
|
|
245
|
+
...adminTarget.native,
|
|
246
|
+
push: { android: { smallIcon: "assets/icon.png", color: "#ff5a5f" } },
|
|
247
|
+
privacy: { tracking: false },
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
for (const target of [minimalTarget, adminWithEverything]) {
|
|
168
251
|
const { config } = NativeConfig.build({
|
|
169
252
|
appPath: root,
|
|
253
|
+
env: "main",
|
|
170
254
|
target,
|
|
171
255
|
webDir: path.join(root, "web"),
|
|
172
256
|
contributions: [],
|
|
173
257
|
locales: ["en", "ko"],
|
|
258
|
+
platform: "android",
|
|
174
259
|
});
|
|
175
260
|
expect(api.validateConfig(config, { appDir: root })).toEqual([]);
|
|
176
261
|
}
|
|
177
262
|
});
|
|
178
263
|
|
|
264
|
+
test("ships the app's own native plugins by folder and names each by its id in the capability", async () => {
|
|
265
|
+
const root = await makeTempRoot();
|
|
266
|
+
const kiosk = await writeManifest(path.join(root, "native", "kiosk"), "kiosk");
|
|
267
|
+
await writeManifest(path.join(root, "vendor", "led-panel"), "led-panel");
|
|
268
|
+
const { config } = NativeConfig.build({
|
|
269
|
+
appPath: root,
|
|
270
|
+
target: { ...minimalTarget, permissions: [], native: { plugins: ["./vendor/led-panel"] }, deepLinks: undefined },
|
|
271
|
+
webDir: path.join(root, "web"),
|
|
272
|
+
contributions: [],
|
|
273
|
+
locales: ["en"],
|
|
274
|
+
platform: "macos",
|
|
275
|
+
nativePlugins: [{ id: "kiosk", dir: kiosk, owner: "apps/board" }],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "./vendor/led-panel", kiosk]);
|
|
279
|
+
expect(config.capabilities?.[0]?.permissions).toEqual(
|
|
280
|
+
[...NativeConfig.basePlugins, "led-panel", "kiosk"].map((id) => `${id}:default`),
|
|
281
|
+
);
|
|
282
|
+
await mkdir(path.join(root, "web"), { recursive: true });
|
|
283
|
+
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
284
|
+
const api = await NativeApi.load(repoApp);
|
|
285
|
+
expect(api.validateConfig(config, { appDir: root })).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("ships a native/ folder once when the target also lists it by path or by id", async () => {
|
|
289
|
+
const root = await makeTempRoot();
|
|
290
|
+
const kiosk = await writeManifest(path.join(root, "native", "kiosk"), "kiosk");
|
|
291
|
+
const { config } = NativeConfig.build({
|
|
292
|
+
appPath: root,
|
|
293
|
+
target: {
|
|
294
|
+
...minimalTarget,
|
|
295
|
+
permissions: [],
|
|
296
|
+
native: { plugins: ["./native/kiosk/", "kiosk", "haptics"] },
|
|
297
|
+
deepLinks: undefined,
|
|
298
|
+
},
|
|
299
|
+
webDir: path.join(root, "web"),
|
|
300
|
+
contributions: [],
|
|
301
|
+
locales: ["en"],
|
|
302
|
+
platform: "macos",
|
|
303
|
+
nativePlugins: [{ id: "kiosk", dir: kiosk, owner: "apps/board" }],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(config.plugins).toEqual([...NativeConfig.basePlugins, kiosk]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("hands an unattended app's settings on: page recovery, a kiosk window, screen capture and Android autoplay", async () => {
|
|
310
|
+
const root = await makeTempRoot();
|
|
311
|
+
const { config } = NativeConfig.build({
|
|
312
|
+
appPath: root,
|
|
313
|
+
target: {
|
|
314
|
+
...minimalTarget,
|
|
315
|
+
permissions: [],
|
|
316
|
+
deepLinks: undefined,
|
|
317
|
+
native: {
|
|
318
|
+
android: { autoplay: true },
|
|
319
|
+
desktop: { recovery: "reload", window: { fullscreen: true, skipTaskbar: true }, screenCapture: "auto" },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
webDir: path.join(root, "web"),
|
|
323
|
+
contributions: [],
|
|
324
|
+
locales: ["en"],
|
|
325
|
+
platform: "macos",
|
|
326
|
+
desktopBin: root,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(config.desktop).toEqual({
|
|
330
|
+
recovery: "reload",
|
|
331
|
+
window: { fullscreen: true, skipTaskbar: true },
|
|
332
|
+
screenCapture: "auto",
|
|
333
|
+
bin: root,
|
|
334
|
+
});
|
|
335
|
+
expect(config.android).toEqual({ debugAppIdSuffix: ".debug", autoplay: true });
|
|
336
|
+
await mkdir(path.join(root, "web"), { recursive: true });
|
|
337
|
+
await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
|
|
338
|
+
expect((await NativeApi.load(repoApp)).validateConfig(config, { appDir: root })).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("an app with updates ships the updates plugin and its settings", () => {
|
|
342
|
+
const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=`, channel: "pilot" };
|
|
343
|
+
const { config } = NativeConfig.build({
|
|
344
|
+
appPath: "/repo/apps/board",
|
|
345
|
+
target: { ...minimalTarget, permissions: [], deepLinks: undefined, native: undefined, updates },
|
|
346
|
+
webDir: "/web",
|
|
347
|
+
contributions: [],
|
|
348
|
+
locales: ["en"],
|
|
349
|
+
platform: "macos",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "updates"]);
|
|
353
|
+
expect(config.updates).toEqual(updates);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("an updates channel left unnamed is the backend env the binary is built for", () => {
|
|
357
|
+
const updates = { url: "https://releases.example.com/board", publicKey: `${"a".repeat(43)}=` };
|
|
358
|
+
const build = (env?: "debug" | "main", channel?: string) =>
|
|
359
|
+
NativeConfig.build({
|
|
360
|
+
appPath: "/repo/apps/board",
|
|
361
|
+
target: {
|
|
362
|
+
...minimalTarget,
|
|
363
|
+
permissions: [],
|
|
364
|
+
deepLinks: undefined,
|
|
365
|
+
native: undefined,
|
|
366
|
+
updates: { ...updates, ...(channel ? { channel } : {}) },
|
|
367
|
+
},
|
|
368
|
+
webDir: "/web",
|
|
369
|
+
contributions: [],
|
|
370
|
+
locales: ["en"],
|
|
371
|
+
platform: "macos",
|
|
372
|
+
...(env ? { env } : {}),
|
|
373
|
+
}).config.updates;
|
|
374
|
+
|
|
375
|
+
expect(build("debug")).toEqual({ ...updates, channel: "debug" });
|
|
376
|
+
expect(build("main")).toEqual({ ...updates, channel: "main" });
|
|
377
|
+
expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot" });
|
|
378
|
+
expect(build()).toEqual(updates);
|
|
379
|
+
});
|
|
380
|
+
|
|
179
381
|
test("keeps a file name the runtime accepts", () => {
|
|
180
382
|
expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
|
|
181
383
|
expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
|
|
@@ -183,6 +385,15 @@ describe("NativeConfig.build", () => {
|
|
|
183
385
|
});
|
|
184
386
|
});
|
|
185
387
|
|
|
388
|
+
const writeManifest = async (dir: string, id: string) => {
|
|
389
|
+
await mkdir(dir, { recursive: true });
|
|
390
|
+
await writeFile(
|
|
391
|
+
path.join(dir, "native-plugin.json"),
|
|
392
|
+
JSON.stringify({ id, apiVersion: 1, methods: [], events: [], web: null, desktop: null, ios: null, android: null }),
|
|
393
|
+
);
|
|
394
|
+
return dir;
|
|
395
|
+
};
|
|
396
|
+
|
|
186
397
|
const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
|
187
398
|
|
|
188
399
|
const googleServicesJson = JSON.stringify({
|