akanjs 3.0.0-beta.28 → 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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/server/akanApp.ts +5 -3
  3. package/server/akanServer.ts +16 -6
  4. package/server/contentEncoding.ts +11 -0
  5. package/server/ops/appInfo.ts +3 -1
  6. package/server/selfExec.ts +12 -4
  7. package/types/server/ops/appInfo.d.ts +1 -0
  8. package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +17 -1
  9. package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +8 -0
  10. package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +2 -0
  11. package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.d.ts +7 -1
  12. package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +21 -6
  13. package/types/vendor/@akanjs/native/packages/desktop/src/plugin.d.ts +19 -5
  14. package/types/vendor/@akanjs/native/packages/desktop/src/relaunch.d.ts +20 -1
  15. package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +18 -0
  16. package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +7 -0
  17. package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +7 -1
  18. package/types/vendor/@akanjs/native/plugins/updates/src/index.d.ts +18 -6
  19. package/vendor/@akanjs/native/packages/cli/src/api.ts +32 -12
  20. package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +9 -2
  21. package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +22 -5
  22. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +2 -0
  23. package/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.ts +51 -26
  24. package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +198 -33
  25. package/vendor/@akanjs/native/packages/desktop/src/dispatcher.ts +9 -2
  26. package/vendor/@akanjs/native/packages/desktop/src/host.ts +21 -9
  27. package/vendor/@akanjs/native/packages/desktop/src/plugin.ts +20 -5
  28. package/vendor/@akanjs/native/packages/desktop/src/relaunch.ts +82 -5
  29. package/vendor/@akanjs/native/packages/desktop/src/server.ts +124 -7
  30. package/vendor/@akanjs/native/plugins/app/native-plugin.json +1 -1
  31. package/vendor/@akanjs/native/plugins/app/src/desktop.ts +5 -0
  32. package/vendor/@akanjs/native/plugins/app/src/index.ts +6 -1
  33. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +410 -111
  34. package/vendor/@akanjs/native/plugins/updates/src/index.ts +18 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.28",
3
+ "version": "3.0.0-beta.29",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -242,7 +242,7 @@ export class AkanApp {
242
242
  if (this.#solo) return await this.#startSolo();
243
243
  if (SelfExec.carried)
244
244
  throw new Error(
245
- "A desktop app's server runs in one process, and this main.ts asks for replicas (replica, solo: false or AKAN_SOLO=false), which would start with the `bun` on the user's PATH. Leave them out of the app the desktop build carries.",
245
+ "A desktop app's server runs in one process, and this one was asked for a gateway and replicas, which would start with the `bun` on the user's PATH: by `replica` or `solo: false` in main.ts, or by AKAN_SOLO=false, an AKAN_REPLICA other than one traffic replica, or AKAN_COMMAND_TYPE=start in its env. Leave them out of the app the desktop build carries.",
246
246
  );
247
247
  Logger.role = "gateway";
248
248
  await this.#prepareRuntimeDir();
@@ -943,8 +943,10 @@ export class AkanApp {
943
943
  async #proxyResponse(req: Request, upstreamRes: Response): Promise<Response> {
944
944
  const headers = new Headers(upstreamRes.headers);
945
945
 
946
- headers.delete("content-encoding");
947
- headers.delete("content-length");
946
+ if (headers.has("content-encoding")) {
947
+ headers.delete("content-encoding");
948
+ headers.delete("content-length");
949
+ }
948
950
  this.#rewriteInternalLocation(headers);
949
951
  const proxied = new Response(upstreamRes.body, {
950
952
  status: upstreamRes.status,
@@ -170,6 +170,7 @@ export class AkanServer {
170
170
  #ops: OpsRoute | null | undefined;
171
171
  #lastMetrics: AkanMetricsReport = {};
172
172
  #stopping: Promise<void> | null = null;
173
+ #parentGone: boolean = false;
173
174
  #orphanExiting = false;
174
175
  constructor(
175
176
  name = "AkanServer",
@@ -472,7 +473,7 @@ export class AkanServer {
472
473
  this.#di.registerSchedule(this.serverMode);
473
474
  this.logger.verbose(`🚀 ${this.name} is running on ${unix ? `unix://${unix}` : `port ${port}`}`);
474
475
  const wsPort = this.#wsServer?.port;
475
- process.send?.({
476
+ this.#sendReady({
476
477
  type: "ready",
477
478
  pid: process.pid,
478
479
  replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
@@ -481,7 +482,7 @@ export class AkanServer {
481
482
  wsUpstream: typeof wsPort === "number" ? { type: "tcp", host: "127.0.0.1", port: wsPort } : undefined,
482
483
  healthPath: "/_akan/app/child-health",
483
484
  crossSite: CrossSiteGuard.option(),
484
- } satisfies AkanIpcMessage);
485
+ });
485
486
  await this.#di.runSchedulerInit();
486
487
  ShutdownManager.register(this.logger, () => this.stop());
487
488
  return this;
@@ -490,7 +491,10 @@ export class AkanServer {
490
491
  async start({ listen, web }: { listen?: boolean; web?: AkanWebOption } = {}) {
491
492
  const isNoListenCommand = process.env.AKAN_COMMAND_TYPE === "script" || process.env.AKAN_COMMAND_TYPE === "console";
492
493
  const shouldListen = (listen ?? !isNoListenCommand) && this.serverMode !== "batch";
494
+
495
+ if (shouldListen || !isNoListenCommand) this.#registerParentIpc();
493
496
  await this.init({ routes: shouldListen, web });
497
+ if (this.#parentGone) return this;
494
498
  if (!shouldListen) {
495
499
  const websocket = this.#di.getWebsocketAdaptor();
496
500
  if (websocket)
@@ -500,20 +504,18 @@ export class AkanServer {
500
504
  Logger.role = this.serverMode;
501
505
  this.#metricsTimer ??= ProcessMetricsCollector.startReporting(() => this.#reportMetrics());
502
506
  this.#di.registerSchedule(this.serverMode);
503
- this.#registerParentIpc();
504
507
  await this.#startLogTransport();
505
- process.send?.({
508
+ this.#sendReady({
506
509
  type: "ready",
507
510
  pid: process.pid,
508
511
  replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
509
512
  role: this.serverMode,
510
- } satisfies AkanIpcMessage);
513
+ });
511
514
  await this.#di.runSchedulerInit();
512
515
  ShutdownManager.register(this.logger, () => this.stop());
513
516
  }
514
517
  return this;
515
518
  }
516
- this.#registerParentIpc();
517
519
  return this.listen();
518
520
  }
519
521
  stop(): Promise<void> {
@@ -560,9 +562,17 @@ export class AkanServer {
560
562
  #registerParentIpc() {
561
563
  process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
562
564
  process.on("disconnect", () => this.#handleParentDisconnect());
565
+
566
+ if (process.send && !process.connected) this.#handleParentDisconnect();
567
+ }
568
+
569
+ #sendReady(message: AkanIpcMessage) {
570
+ if (process.send?.(message) === false) this.#handleParentDisconnect();
563
571
  }
564
572
 
565
573
  #handleParentDisconnect() {
574
+ if (this.#parentGone) return;
575
+ this.#parentGone = true;
566
576
  this.logger.warn("Parent IPC channel closed; shutting down to avoid an orphaned replica");
567
577
  setTimeout(() => this.#exitOrphaned(1), this.shutdownTimeoutMs + 1_000);
568
578
  void this.stop()
@@ -32,6 +32,8 @@ const GZIP_LEVEL = 6;
32
32
  /** Under a KB the framing bytes and the call cost more than the repetition they remove. */
33
33
  const MIN_COMPRESS_BYTES = 1024;
34
34
 
35
+ const MAX_COMPRESS_BYTES = 4 * 1024 * 1024;
36
+
35
37
  const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
36
38
  encoding === "br"
37
39
  ? brotliCompressSync(bytes, {
@@ -42,8 +44,17 @@ const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
42
44
  })
43
45
  : gzipSync(bytes, { level: GZIP_LEVEL });
44
46
 
47
+ const isWholeBody = (req: Request, response: Response) =>
48
+ !req.headers.has("range") &&
49
+ !response.headers.has("content-range") &&
50
+ response.status >= 200 &&
51
+ response.status < 300 &&
52
+ response.status !== 206;
53
+
45
54
  export const compressResponse = async (req: Request, response: Response): Promise<Response> => {
46
55
  if (response.headers.has("content-encoding") || !response.body) return response;
56
+ if (!isWholeBody(req, response)) return response;
57
+ if (Number(response.headers.get("content-length")) > MAX_COMPRESS_BYTES) return response;
47
58
  if (!isCompressibleContentType(response.headers.get("content-type") ?? "")) return response;
48
59
  if (process.env.AKAN_HTTP_COMPRESS === "false" || process.env.AKAN_HTTP_COMPRESS === "0") return response;
49
60
  const acceptEncoding = req.headers.get("accept-encoding") ?? "";
@@ -7,6 +7,7 @@ export interface AppPublicInfo {
7
7
  repoName: string;
8
8
  environment: string;
9
9
  operationMode: string;
10
+ workspaceRoot?: string;
10
11
  }
11
12
 
12
13
  export interface AppDetailInfo extends AppPublicInfo {
@@ -39,7 +40,8 @@ export class AppInfo {
39
40
 
40
41
  static public(): AppPublicInfo {
41
42
  const { appName, repoName, environment, operationMode } = getEnv();
42
- return { appName, repoName, environment, operationMode };
43
+ const workspaceRoot = operationMode === "local" ? process.env.AKAN_WORKSPACE_ROOT : undefined;
44
+ return { appName, repoName, environment, operationMode, ...(workspaceRoot ? { workspaceRoot } : {}) };
43
45
  }
44
46
 
45
47
  static detail(proc: AppProcessInfo): AppDetailInfo {
@@ -1,12 +1,19 @@
1
-
1
+ interface SelfExecGlobal {
2
+ [key: symbol]: string | undefined;
3
+ }
2
4
 
3
5
  export class SelfExec {
4
- static #beBun: string | undefined;
6
+
7
+ static readonly #key = Symbol.for("akan.selfExec.beBun");
8
+
9
+ static get #beBun(): string | undefined {
10
+ return (globalThis as unknown as SelfExecGlobal)[SelfExec.#key];
11
+ }
5
12
 
6
13
  static adopt() {
7
14
  const value = process.env.BUN_BE_BUN;
8
15
  if (value === undefined) return;
9
- SelfExec.#beBun = value;
16
+ (globalThis as unknown as SelfExecGlobal)[SelfExec.#key] = value;
10
17
  delete process.env.BUN_BE_BUN;
11
18
  }
12
19
 
@@ -16,7 +23,8 @@ export class SelfExec {
16
23
  }
17
24
 
18
25
  static env(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
19
- return SelfExec.#beBun === undefined ? env : { ...env, BUN_BE_BUN: SelfExec.#beBun };
26
+ const beBun = SelfExec.#beBun;
27
+ return beBun === undefined ? env : { ...env, BUN_BE_BUN: beBun };
20
28
  }
21
29
 
22
30
  static command(script: string, ...args: string[]): string[] {
@@ -4,6 +4,7 @@ export interface AppPublicInfo {
4
4
  repoName: string;
5
5
  environment: string;
6
6
  operationMode: string;
7
+ workspaceRoot?: string;
7
8
  }
8
9
  export interface AppDetailInfo extends AppPublicInfo {
9
10
  akanVersion: string | null;
@@ -7,7 +7,7 @@ import { type UpdateManifest } from "./lib/updates.ts";
7
7
  import { type TargetPlatform } from "./platforms/index.ts";
8
8
  export type { AkanNativeConfig, AndroidSigning, Device, DeviceSelector, DoctorCheck, IosBuild, IosSigning, IosSigningResult, LogEvent, TargetPlatform, WindowsBuild, };
9
9
  /** semver of this API. A caller checks the major before it relies on anything here. */
10
- export declare const API_VERSION = "0.8.0";
10
+ export declare const API_VERSION = "0.9.0";
11
11
  export type AkanNativeErrorCode = "CONFIG_INVALID" | "TOOLCHAIN_MISSING" | "WEB_INPUT_INVALID" | "WEB_BUILD_FAILED" | "NATIVE_BUILD_FAILED" | "SIGNING_FAILED" | "DEVICE_FAILED" | "CANCELLED" | "INTERNAL";
12
12
  export declare class AkanNativeError extends Error {
13
13
  readonly code: AkanNativeErrorCode;
@@ -126,6 +126,22 @@ export interface PublishResult {
126
126
  /** Bytes of the files the manifest names. */
127
127
  size: number;
128
128
  }
129
+ export interface PublishCheckOptions extends Pick<TaskOptions, "appDir" | "config"> {
130
+ platform: Exclude<TargetPlatform, "web">;
131
+ channel?: string;
132
+ out?: string;
133
+ /** Whether the release will carry a server; the config's `desktop.server` by default. */
134
+ server?: boolean;
135
+ }
136
+ /**
137
+ * What publishUpdate() refuses before it builds, without building (CONFIG_INVALID): no updates settings, a channel no
138
+ * app could be configured for, no signing key on this machine or another app's, and a desktop release whose server
139
+ * presence is not the channel's previous release's. For a caller with a long build of its own before publishUpdate.
140
+ */
141
+ export declare function checkPublishUpdate(options: PublishCheckOptions): {
142
+ channel: string;
143
+ out: string;
144
+ };
129
145
  /**
130
146
  * A signed release for the updates plugin (UP-1, UP-2): a release build, then `<channel>.json`, its signature and
131
147
  * its files under `out` (default <appDir>/.akan/native/updates), signed with the key updateKeygen() made.
@@ -11,6 +11,14 @@ export interface PublishedRelease {
11
11
  * sequence, so a clock behind the last publisher's would make this release look older to every one of them.
12
12
  */
13
13
  export declare function nextSequence(dir: string, channel: string, now?: number): number;
14
+ /** `<out>/<os>-<arch>` for a desktop release, `<out>/<platform>` for a phone's web bundle. */
15
+ export declare function releaseDir(out: string, platform: ReleasePlatform): string;
16
+ /** Refuses before the build a desktop release the apps of its channel would refuse for its server (assertSameServer). */
17
+ export declare function assertServerOfChannel(config: {
18
+ desktop?: {
19
+ server?: unknown;
20
+ };
21
+ }, platform: ReleasePlatform, out: string, channel: string): void;
14
22
  /** Refuses before the build a release that could not be signed: no key on this machine, or another app's. */
15
23
  export declare function assertSigningKey(config: {
16
24
  app: {
@@ -52,6 +52,8 @@ export interface UpdateManifest {
52
52
  sha256: string;
53
53
  size: number;
54
54
  }[];
55
+ /** App updates: whether the app carries a server; an installed app refuses a release that differs from itself. */
56
+ server?: boolean;
55
57
  }
56
58
  /** Every file of a web bundle as the app serves it: index.html with the init script and CSP, public/, env.runtime.json. */
57
59
  export declare function webFiles(webDir: string, html: string, env: Record<string, string>): {
@@ -1,7 +1,13 @@
1
1
  import type { BuildContext } from "../lib/prepare.ts";
2
2
  import { type UpdateManifest } from "../lib/updates.ts";
3
3
  import { type DesktopOs } from "./desktop.ts";
4
+ /**
5
+ * An installed app refuses a release whose server presence is not its own (plugins/updates): published, this one would
6
+ * be refused by every app the channel reached, and so would every release after it. A manifest from before the field
7
+ * says nothing and is not compared.
8
+ */
9
+ export declare function assertSameServer(dir: string, channel: string, server: boolean): void;
4
10
  export declare function publishAppUpdate(ctx: BuildContext, os: DesktopOs, app: string, out: string, channel: string, sequence: number): Promise<{
5
11
  manifest: UpdateManifest;
6
- superseded: string | null;
12
+ leftovers: string[];
7
13
  }>;
@@ -16,6 +16,13 @@ export interface InstallerInput {
16
16
  name: string;
17
17
  dir: boolean;
18
18
  }[];
19
+ /** Bytes the app folder takes on disk, and bytes the setup unpacks in all (the folder and the WebView2 bootstrapper). */
20
+ space: {
21
+ install: number;
22
+ unpack: number;
23
+ };
24
+ /** deepLinks.schemes: the uninstaller removes those the app registered for its own executable. */
25
+ schemes: string[];
19
26
  icon?: string;
20
27
  bootstrapper: string;
21
28
  stopScript: string;
@@ -23,15 +30,23 @@ export interface InstallerInput {
23
30
  }
24
31
  export declare function installerScript(input: InstallerInput): string;
25
32
  /**
26
- * Stops every process running from the install folder, and waits (up to 10 s) until they are gone. Through
27
- * WMI: the installer is a 32-bit program, so its PowerShell is too, and a 32-bit process cannot read a
28
- * 64-bit one's path (Get-Process's Path comes back empty).
33
+ * Stops every process running from the install folder, waits (up to 10 s) until they are gone, and exits 1 when
34
+ * one is left or cannot be seen. Through WMI: the installer is a 32-bit program, so its PowerShell is too, and a
35
+ * 32-bit process cannot read a 64-bit one's path (Get-Process's Path comes back empty).
29
36
  */
30
- export declare const STOP_SCRIPT = "param([string]$Dir)\n$prefix = [IO.Path]::GetFullPath($Dir).TrimEnd('\\') + '\\'\n$running = { Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) } }\n& $running | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }\nfor ($i = 0; $i -lt 50 -and (& $running); $i++) { Start-Sleep -Milliseconds 200 }\n";
37
+ export declare const STOP_SCRIPT = "param([string]$Dir)\n$ErrorActionPreference = 'Stop'\n$prefix = [IO.Path]::GetFullPath($Dir).TrimEnd('\\') + '\\'\n$running = { Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) } }\n& $running | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }\nfor ($i = 0; $i -lt 50 -and (& $running); $i++) { Start-Sleep -Milliseconds 200 }\nif (& $running) { exit 1 }\n";
31
38
  /** The longest path under `folder`, relative and with Windows separators. Links are not followed. */
32
39
  export declare function deepestPath(folder: string): string;
33
- /** How long `relative` gets on a typical PC while an update unpacks, the longest it ever is: root, app, staging, app. */
34
- export declare function installedPathLength(appName: string, relative: string): number;
40
+ /** The bytes of the files under `folder`, and what they take on disk. Links are not followed. */
41
+ export declare function folderSize(folder: string): {
42
+ bytes: number;
43
+ disk: number;
44
+ };
45
+ /**
46
+ * How long `relative` gets on a typical PC while an update unpacks, the longest it ever is: root, app, then
47
+ * `.update-<bundle>\` and the app again.
48
+ */
49
+ export declare function installedPathLength(appName: string, version: string, relative: string): number;
35
50
  /** makensis: AKAN_NATIVE_MAKENSIS, PATH, then where the NSIS installer puts it. */
36
51
  export declare function findMakensis(env?: Record<string, string | undefined>): string | null;
37
52
  /** Writes the setup program next to the app folder and answers its path. */
@@ -12,7 +12,10 @@ export type EmitTarget = {
12
12
  } | "focused";
13
13
  export interface DesktopContext {
14
14
  app: AppInfo;
15
- /** A dev build (boot.json `dev`): its pages come from a dev server the shell did not start. */
15
+ /**
16
+ * A debug build (boot.json `dev`: `--debug`, and every dev build, whose pages come from a dev server the shell did not
17
+ * start). It has the release app's id, so what it keeps apart goes in folders of its own.
18
+ */
16
19
  readonly dev: boolean;
17
20
  /**
18
21
  * The window whose page made this call (SH-6; 1 = the window the app opened). Only in method
@@ -51,7 +54,7 @@ export interface DesktopContext {
51
54
  readonly binDir: string | null;
52
55
  /**
53
56
  * The server the app carries (desktop.server), or null. `ready` settles once per session: true when the server
54
- * first answered ready, false when it could not start or gave up before that.
57
+ * first answered ready, false when it could not start or gave up before that; `state` and `onState` follow it after.
55
58
  */
56
59
  readonly server: DesktopServerStatus | null;
57
60
  /** Pushes an event of this plugin to the pages that listen. Returns the windows reached. */
@@ -108,18 +111,29 @@ export interface DesktopContext {
108
111
  closeWindow(window?: number): void;
109
112
  /**
110
113
  * Launch phase: only while setup runs, before the window exists. The host waits for every
111
- * plugin's setup (async allowed, at most 3 s each) before it creates the window. Calls after
112
- * that are ignored with a warning.
114
+ * plugin's setup (async allowed, at most 3 s each) before it creates the window. setWindow after
115
+ * that is ignored with a warning.
113
116
  */
114
117
  readonly launch: {
115
118
  /** Creates the window at these bounds (logical points) instead of the configured size (window-state). */
116
119
  setWindow(bounds: LaunchWindow): void;
117
- /** Ends the app before a window is created; onQuit hooks do not run (single-instance). */
120
+ /**
121
+ * Ends the app before a window is created; onQuit hooks do not run (single-instance). A setup that ran past its
122
+ * time quits the app instead, since its window exists by then.
123
+ */
118
124
  exit(code?: number): void;
119
125
  };
120
126
  }
127
+ /**
128
+ * "starting" until the server answers ready (again after a crash before that), "up" while it answers, "restarting"
129
+ * after a crash once it had been up, "gaveUp" when it is not started again this session, "stopped" as the app quits.
130
+ */
131
+ export type DesktopServerState = "starting" | "up" | "restarting" | "gaveUp" | "stopped";
121
132
  export interface DesktopServerStatus {
122
133
  readonly ready: Promise<boolean>;
134
+ readonly state: DesktopServerState;
135
+ /** Every later change of `state`. Returns an unsubscribe function. */
136
+ onState(listener: (state: DesktopServerState) => void): () => void;
123
137
  }
124
138
  /** Which page load ended. `id` is "" for a page that sends no document id (v1 callers, tests). */
125
139
  export interface DocumentInfo {
@@ -1,14 +1,33 @@
1
1
  export interface RelaunchOptions {
2
2
  /** How long the helper waits after this process exited before it starts the app. */
3
3
  delayMs?: number;
4
+ /**
5
+ * Windows, a swap: while the helper moves the folders, a RunOnce value named `name` runs `script` (written here) at
6
+ * the next logon, which puts back what a shutdown or a killed helper left half moved and starts the app. The helper
7
+ * removes the value once the moves are done or undone.
8
+ */
9
+ recover?: {
10
+ script: string;
11
+ name: string;
12
+ };
4
13
  }
14
+ /**
15
+ * The recovery `recover.script` holds: when `exe` is missing, every move whose source is gone and whose target is
16
+ * there goes back, newest first, and the app starts. An app that is there was never half moved: it is left alone.
17
+ */
18
+ export declare function recoveryScript(exe: string, moves: [string, string][]): string;
19
+ /**
20
+ * The RunOnce command for a recovery script, or null past the 260 characters Windows runs from there. The script is
21
+ * read as text: an execution policy that refuses .ps1 files does not apply to it, as it does not to -EncodedCommand.
22
+ */
23
+ export declare function recoveryCommand(script: string): string | null;
5
24
  /**
6
25
  * Once this process has exited: renames `moves` in order and starts `exe` with this environment. macOS and
7
26
  * Linux rename a running app's folder right away, so only the start waits; Windows refuses that, so a helper
8
27
  * does the renames too (and on a failed rename puts back what it moved, so there is always an app to start).
9
28
  * Only the first call of a process starts a helper; later ones answer the same promise.
10
29
  */
11
- export declare function relaunchAfterExit(exe: string, moves?: [string, string][], { delayMs }?: RelaunchOptions): Promise<void>;
30
+ export declare function relaunchAfterExit(exe: string, moves?: [string, string][], { delayMs, recover }?: RelaunchOptions): Promise<void>;
12
31
  /** Relaunches after the browser process ended, in a row: each one's app ran less than RELAUNCH_HEALTHY. */
13
32
  export declare const RELAUNCH_LIMIT = 10;
14
33
  export declare const RELAUNCH_HEALTHY = 60000;
@@ -1,3 +1,4 @@
1
+ import type { DesktopServerState, DesktopServerStatus } from "./plugin.ts";
1
2
  export interface ServerManifest {
2
3
  /** The file in resources/server that starts the server. */
3
4
  entry: string;
@@ -14,6 +15,8 @@ export declare const MAX_FAILURES = 5;
14
15
  export declare const BOOT_RESTART_DELAY = 250;
15
16
  /** A run that stayed up this long was healthy: its crash starts the count again. */
16
17
  export declare const HEALTHY_RUN = 60000;
18
+ /** server-output.log moves to server-output.log.1 past this size. */
19
+ export declare const OUTPUT_LOG_LIMIT: number;
17
20
  export interface ServerProcess {
18
21
  readonly exited: Promise<number | null>;
19
22
  send(message: unknown): void;
@@ -41,6 +44,8 @@ export interface DesktopServerOptions {
41
44
  freePort?(preferred?: number): Promise<number>;
42
45
  /** The server gave up: MAX_FAILURES crashes in a row, or it could not start at all. */
43
46
  onGiveUp?(message: string): void;
47
+ /** Every change of `state`. */
48
+ onState?(state: DesktopServerState): void;
44
49
  readyTimeout?: number;
45
50
  stopGrace?: number;
46
51
  restartDelay?(failures: number, everReady: boolean): number;
@@ -57,10 +62,23 @@ export interface DesktopServer {
57
62
  }>;
58
63
  /** Settles once: true at the first ready, false when the server gave up or stopped before that. */
59
64
  readonly ready: Promise<boolean>;
65
+ /** Where the server is now; `ready` only tells the first answer. */
66
+ readonly state: DesktopServerState;
60
67
  stop(): Promise<void>;
61
68
  }
69
+ /** What plugins see of the server (ctx.server) from their setup on: the plugin host starts it only after that. */
70
+ export declare function createServerStatus(): {
71
+ status: DesktopServerStatus;
72
+ settle: (value: boolean) => void;
73
+ setState: (next: DesktopServerState) => void;
74
+ };
62
75
  export declare function readServerManifest(resources: string): ServerManifest | null;
63
76
  export declare function serverArgv(execPath: string, resources: string, entry: string): string[];
77
+ /**
78
+ * `key`'s value in `env`. Windows names a variable in any case (`Path`), and the plugin host's copy of process.env
79
+ * matches a name only in the case it has, where the process's own env ignores case.
80
+ */
81
+ export declare function envValue(env: Record<string, string | undefined>, key: string, platform?: NodeJS.Platform): string | undefined;
64
82
  /** `dir` first on a PATH, once: a relaunched app inherits the PATH its predecessor already extended. */
65
83
  export declare function pathWithFirst(dir: string, path: string | undefined, platform?: NodeJS.Platform): string;
66
84
  export declare function serverEnv(manifest: ServerManifest, { port, secret, dataDir, env, binDir, platform, }: {
@@ -62,6 +62,13 @@ export interface AppEvents {
62
62
  progress: number;
63
63
  swipeEdge: "left" | "right";
64
64
  };
65
+ /**
66
+ * Desktop, an app that carries a server (desktop.server): where it is, at every change and when the first page
67
+ * starts listening. "gaveUp" means it is not started again until the app is.
68
+ */
69
+ serverState: {
70
+ state: "starting" | "up" | "restarting" | "gaveUp" | "stopped";
71
+ };
65
72
  }
66
73
  export declare const app: import("../../../packages/core/src/plugin.ts").Plugin<AppApi, AppEvents>;
67
74
  /** Calls `handler` for every deep link while mounted. */
@@ -1,4 +1,5 @@
1
1
  import { type DesktopPlugin } from "../../../packages/desktop/src/plugin.ts";
2
+ import { type RelaunchOptions } from "../../../packages/desktop/src/relaunch.ts";
2
3
  import type { UpdatesApi, UpdatesEvents } from "./index.ts";
3
4
  export interface UpdatesConfig {
4
5
  app: string;
@@ -16,6 +17,10 @@ export interface UpdatesConfig {
16
17
  * launcher's own wait (READY_TIMEOUT, 8 s) only decides when the window opens.
17
18
  */
18
19
  export declare const SERVER_BOOT_ALLOWANCE = 120000;
20
+ /** A server whose init throws answered ready first: its release is confirmed only once it stayed up this long. */
21
+ export declare const SERVER_SETTLE = 5000;
22
+ /** Tries of a release whose server did not come up, or whose Windows swap did not happen, before it is failed. */
23
+ export declare const MAX_STRIKES = 3;
19
24
  export interface DesktopUpdatesOptions {
20
25
  platform?: NodeJS.Platform;
21
26
  /** The installed app; the running one by default. */
@@ -24,8 +29,9 @@ export interface DesktopUpdatesOptions {
24
29
  config?: UpdatesConfig | null;
25
30
  /** The executable's file name inside an app; the running one's by default. */
26
31
  exeName?: string;
27
- relaunch?(exe: string, moves: [string, string][]): Promise<void>;
32
+ relaunch?(exe: string, moves: [string, string][], recover?: RelaunchOptions["recover"]): Promise<void>;
28
33
  serverBootAllowance?: number;
34
+ serverSettle?: number;
29
35
  }
30
36
  export declare function createDesktopUpdates(options?: DesktopUpdatesOptions): DesktopPlugin<UpdatesApi, UpdatesEvents>;
31
37
  declare const _default: DesktopPlugin<UpdatesApi, UpdatesEvents>;
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Over-the-air updates. On iOS and Android they replace the web bundle (UP-2); the native code
3
- * stays, so a bundle only runs in a binary with the same native API (UP-3, `nativeApi`). Releases
4
- * are made with `akan-native update publish <platform>` and signed with the key of `akan-native update keygen`.
3
+ * stays, so a bundle only runs in a binary with the same native API (UP-3, `nativeApi`). On macOS,
4
+ * Windows and Linux they replace the whole app (UP-1). Releases are made with
5
+ * `akan-native update publish <platform>` and signed with the key of `akan-native update keygen`.
5
6
  *
6
7
  * A new bundle first runs on trial: the app calls notifyReady() once it works. A trial that does
7
8
  * not confirm within `updates.readyTimeout` (akan-native.config.ts, default 10 s), or that crashes and is
8
- * launched again, is rolled back to the previous bundle.
9
+ * launched again, is rolled back to the previous bundle. On a desktop app that carries a server, the
10
+ * trial also waits for that server to answer and stay up.
9
11
  */
10
12
  export interface UpdateState {
11
13
  /** The downloaded bundle running now; null = the one inside the app. */
@@ -24,7 +26,10 @@ export interface UpdateState {
24
26
  nativeApi: string | null;
25
27
  }
26
28
  export interface UpdateCheck {
27
- /** A newer bundle for this binary exists (not rolled back before). */
29
+ /**
30
+ * A newer bundle for this binary exists (not rolled back before). Desktop: never the release on trial, nor one whose
31
+ * server presence differs from this app's.
32
+ */
28
33
  available: boolean;
29
34
  bundle: string | null;
30
35
  /** The app version it was published with. */
@@ -45,11 +50,18 @@ export interface UpdatesApi {
45
50
  download(): Promise<{
46
51
  bundle: string;
47
52
  }>;
48
- /** Reloads the page into the downloaded bundle now (on trial). Rejects NOT_FOUND without one. */
53
+ /**
54
+ * Phones: reloads the page into the downloaded bundle now (on trial). Desktop: replaces the app with the download and
55
+ * relaunches it (on trial); rejects NOT_ALLOWED while the running release is still on trial. Rejects NOT_FOUND
56
+ * without a download.
57
+ */
49
58
  apply(): Promise<void>;
50
59
  /** The running bundle works: keep it. Call it once the app has started; harmless otherwise. */
51
60
  notifyReady(): Promise<void>;
52
- /** Back to the app's own bundle from the next launch on. */
61
+ /**
62
+ * Phones: back to the app's own bundle from the next launch on. Desktop: drops the download and forgets the refused
63
+ * releases; the running app stays, and a trial keeps its record.
64
+ */
53
65
  reset(): Promise<void>;
54
66
  }
55
67
  export interface UpdatesEvents {
@@ -25,7 +25,7 @@ import {
25
25
  type WindowsBuild,
26
26
  } from "./lib/prepare.ts";
27
27
  import { ConfigError, type Project, projectFromConfig } from "./lib/project.ts";
28
- import { assertSigningKey, publishRelease } from "./lib/publish.ts";
28
+ import { assertServerOfChannel, assertSigningKey, publishRelease } from "./lib/publish.ts";
29
29
  import {
30
30
  assertChannel,
31
31
  generateUpdateKey,
@@ -53,7 +53,7 @@ export type {
53
53
  };
54
54
 
55
55
  /** semver of this API. A caller checks the major before it relies on anything here. */
56
- export const API_VERSION = "0.8.0";
56
+ export const API_VERSION = "0.9.0";
57
57
 
58
58
  export type AkanNativeErrorCode =
59
59
  | "CONFIG_INVALID"
@@ -255,6 +255,35 @@ export interface PublishResult {
255
255
  size: number;
256
256
  }
257
257
 
258
+ export interface PublishCheckOptions extends Pick<TaskOptions, "appDir" | "config"> {
259
+ platform: Exclude<TargetPlatform, "web">;
260
+ channel?: string;
261
+ out?: string;
262
+ /** Whether the release will carry a server; the config's `desktop.server` by default. */
263
+ server?: boolean;
264
+ }
265
+
266
+ /**
267
+ * What publishUpdate() refuses before it builds, without building (CONFIG_INVALID): no updates settings, a channel no
268
+ * app could be configured for, no signing key on this machine or another app's, and a desktop release whose server
269
+ * presence is not the channel's previous release's. For a caller with a long build of its own before publishUpdate.
270
+ */
271
+ export function checkPublishUpdate(options: PublishCheckOptions): { channel: string; out: string } {
272
+ if (!options.config.updates)
273
+ throw new AkanNativeError("CONFIG_INVALID", "the config has no updates: { url, publicKey } to publish for");
274
+ const channel = options.channel ?? options.config.updates.channel ?? "production";
275
+ const out = resolve(options.out ?? resolve(options.appDir, ".akan", "native", "updates"));
276
+ try {
277
+ if (options.channel !== undefined) assertChannel(options.channel);
278
+ assertSigningKey(options.config);
279
+ const server = options.server ?? !!options.config.desktop?.server;
280
+ assertServerOfChannel({ desktop: server ? { server } : {} }, options.platform, out, channel);
281
+ } catch (error) {
282
+ throw toAkanNativeError(error, "CONFIG_INVALID");
283
+ }
284
+ return { channel, out };
285
+ }
286
+
258
287
  /**
259
288
  * A signed release for the updates plugin (UP-1, UP-2): a release build, then `<channel>.json`, its signature and
260
289
  * its files under `out` (default <appDir>/.akan/native/updates), signed with the key updateKeygen() made.
@@ -263,17 +292,8 @@ export function publishUpdate(
263
292
  options: TaskOptions & { platform: Exclude<TargetPlatform, "web">; channel?: string; out?: string },
264
293
  ): Promise<PublishResult> {
265
294
  return task(options, async (warnings) => {
266
- if (!options.config.updates)
267
- throw new AkanNativeError("CONFIG_INVALID", "the config has no updates: { url, publicKey } to publish for");
268
- try {
269
- if (options.channel !== undefined) assertChannel(options.channel);
270
- assertSigningKey(options.config);
271
- } catch (error) {
272
- throw toAkanNativeError(error, "CONFIG_INVALID");
273
- }
295
+ const { channel, out } = checkPublishUpdate(options);
274
296
  const { ctx, artifact, result } = await buildIn(options, "release", options.mode ?? "production", warnings);
275
- const channel = options.channel ?? ctx.project.config.updates?.channel ?? "production";
276
- const out = resolve(options.out ?? resolve(options.appDir, ".akan", "native", "updates"));
277
297
  const { dir, manifest } = await publishRelease(ctx, options.platform, artifact, out, channel).catch((error) => {
278
298
  throw toAkanNativeError(error, "CONFIG_INVALID");
279
299
  });
@@ -5,7 +5,7 @@ import { join, relative, resolve, sep } from "node:path";
5
5
  import { checkFlags, type ParsedArgs, parseArgs, stringFlag } from "../lib/args.ts";
6
6
  import { bold, CliError, dim, log } from "../lib/log.ts";
7
7
  import { findAppDir, loadProject } from "../lib/project.ts";
8
- import { assertSigningKey, publishRelease } from "../lib/publish.ts";
8
+ import { assertServerOfChannel, assertSigningKey, publishRelease } from "../lib/publish.ts";
9
9
  import { assertChannel, generateUpdateKey, updateKeyPath } from "../lib/updates.ts";
10
10
  import { BOOLEAN_FLAGS, buildFromArgs } from "./build.ts";
11
11
 
@@ -39,7 +39,14 @@ async function publish(args: ParsedArgs): Promise<number> {
39
39
  const platform = desktop ?? (arg as "ios" | "android");
40
40
  const requested = stringFlag(args, "channel");
41
41
  if (requested !== undefined) assertChannel(requested);
42
- assertSigningKey((await loadProject(findAppDir(stringFlag(args, "app") ?? process.cwd()))).config);
42
+ const project = await loadProject(findAppDir(stringFlag(args, "app") ?? process.cwd()));
43
+ assertSigningKey(project.config);
44
+ assertServerOfChannel(
45
+ project.config,
46
+ platform,
47
+ outDir(args, project.appDir),
48
+ requested ?? project.config.updates?.channel ?? "production",
49
+ );
43
50
  const { ctx, artifact } = await buildFromArgs(
44
51
  { positional: [platform], flags: args.flags },
45
52
  { mode: "production", profile: "release" },