akanjs 3.0.1 → 3.0.2

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 (102) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/common/Logger.ts +10 -0
  5. package/dictionary/base.dictionary.ts +7 -0
  6. package/dictionary/trans.ts +7 -1
  7. package/document/documentQuery.ts +6 -1
  8. package/document/index.ts +1 -0
  9. package/document/into.ts +15 -7
  10. package/document/liveRowScope.ts +79 -0
  11. package/fetch/client/fetchClient.ts +64 -12
  12. package/fetch/client/httpClient.ts +13 -4
  13. package/index.ts +22 -0
  14. package/package.json +1 -1
  15. package/server/akanOption.ts +20 -1
  16. package/server/akanServer.ts +5 -0
  17. package/server/mcp/McpAuth.ts +3 -2
  18. package/server/mcp/McpExecutionContext.ts +15 -14
  19. package/server/mcp/McpRateLimiter.ts +18 -67
  20. package/server/proxy/webProxyRunner.ts +2 -0
  21. package/server/resolver/database.resolver.ts +28 -13
  22. package/server/resolver/signal.resolver.ts +75 -35
  23. package/server/rscWorkerHost.ts +16 -7
  24. package/server/webRouter.ts +3 -0
  25. package/service/index.ts +1 -0
  26. package/service/predefinedAdaptor/index.ts +1 -0
  27. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  28. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  29. package/service/predefinedAdaptor/sql/types.ts +14 -12
  30. package/service/rateLimiter.ts +80 -0
  31. package/signal/base.signal.ts +7 -6
  32. package/signal/endpointRateLimit.ts +159 -0
  33. package/signal/index.ts +1 -0
  34. package/signal/middleware.ts +6 -5
  35. package/signal/serializer/fetch.serializer.ts +15 -4
  36. package/signal/signalContext.ts +148 -67
  37. package/signal/slice.ts +30 -9
  38. package/signal/types.ts +8 -0
  39. package/store/agent/AgentCursor.ts +2 -1
  40. package/test/signalTestRuntime.ts +2 -1
  41. package/types/base/base.d.ts +1 -1
  42. package/types/client/cookie.d.ts +1 -1
  43. package/types/common/Logger.d.ts +2 -0
  44. package/types/dictionary/base.dictionary.d.ts +1 -1
  45. package/types/dictionary/dictionary.d.ts +8 -8
  46. package/types/document/documentQuery.d.ts +5 -1
  47. package/types/document/index.d.ts +1 -0
  48. package/types/document/into.d.ts +7 -7
  49. package/types/document/liveRowScope.d.ts +9 -0
  50. package/types/fetch/client/fetchClient.d.ts +9 -1
  51. package/types/index.d.ts +32 -0
  52. package/types/server/akanOption.d.ts +7 -1
  53. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  54. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  55. package/types/server/rscWorkerHost.d.ts +3 -0
  56. package/types/service/index.d.ts +1 -0
  57. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  58. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  59. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  60. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  61. package/types/service/rateLimiter.d.ts +24 -0
  62. package/types/signal/endpointRateLimit.d.ts +37 -0
  63. package/types/signal/index.d.ts +1 -0
  64. package/types/signal/signalContext.d.ts +33 -15
  65. package/types/signal/slice.d.ts +17 -5
  66. package/types/signal/types.d.ts +8 -0
  67. package/types/test/signalTestRuntime.d.ts +1 -1
  68. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  69. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  70. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  71. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  72. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  73. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  74. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  75. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  76. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  77. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  78. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  79. package/types/webkit/RenderFailure.d.ts +6 -0
  80. package/ui/Input.tsx +7 -1
  81. package/ui/recipe/buttonRecipe.ts +1 -0
  82. package/ui/recipe/inputRecipe.ts +13 -7
  83. package/ui/styles.css +1 -0
  84. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  85. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  86. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  87. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  88. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  89. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  90. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  91. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  92. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  93. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  94. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  95. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  96. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  97. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  98. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  99. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  100. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  101. package/webkit/RenderFailure.tsx +32 -0
  102. package/webkit/RenderLayer.tsx +22 -2
@@ -0,0 +1,101 @@
1
+
2
+
3
+ export interface AliasTarget {
4
+ /** The HFS+ volume name (the disk image's label). */
5
+ volumeName: string;
6
+ /**
7
+ * The volume header's createDate as it stands, in HFS seconds. HFS+ writes it on the local clock of whatever formatted
8
+ * the volume (TN1150), unlike every other date, so it is read off the image rather than computed here.
9
+ */
10
+ volumeCreated: number;
11
+ /** Where the volume mounts: /Volumes/<name>. */
12
+ volumePath: string;
13
+ kind: "file" | "folder";
14
+ name: string;
15
+ cnid: number;
16
+ created: Date;
17
+ parentName: string;
18
+ parentCnid: number;
19
+ /** The CNIDs of the folders above the target, nearest first, the volume's root left out. */
20
+ cnidPath: number[];
21
+ /** From the volume's root, starting with "/". */
22
+ posixPath: string;
23
+ }
24
+
25
+ /** Seconds since 1904-01-01 UTC, the HFS epoch. */
26
+ export function hfsTime(date: Date): number {
27
+ return Math.max(0, Math.floor(date.getTime() / 1000) + 2_082_844_800) >>> 0;
28
+ }
29
+
30
+ const EJECTABLE_DISK = 5;
31
+
32
+ /** A Carbon name: ':' is the path separator there, so a name spells it '/'. */
33
+ function pascal(text: string, size: number): Uint8Array {
34
+ const bytes = new TextEncoder().encode(text.replaceAll(":", "/")).subarray(0, size - 1);
35
+ const out = new Uint8Array(size);
36
+ out[0] = bytes.length;
37
+ out.set(bytes, 1);
38
+ return out;
39
+ }
40
+
41
+ function utf16WithLength(text: string): Uint8Array {
42
+ const out = new Uint8Array(2 + text.length * 2);
43
+ const view = new DataView(out.buffer);
44
+ view.setUint16(0, text.length);
45
+ for (let i = 0; i < text.length; i++) view.setUint16(2 + i * 2, text.charCodeAt(i));
46
+ return out;
47
+ }
48
+
49
+ export function aliasRecord(target: AliasTarget): Uint8Array {
50
+ const fixed = new Uint8Array(150);
51
+ const view = new DataView(fixed.buffer);
52
+ view.setUint16(6, 2);
53
+ view.setUint16(8, target.kind === "folder" ? 1 : 0);
54
+ fixed.set(pascal(target.volumeName, 28), 10);
55
+
56
+ view.setUint32(38, target.volumeCreated);
57
+ fixed.set(new TextEncoder().encode("H+"), 42);
58
+ view.setUint16(44, EJECTABLE_DISK);
59
+ view.setUint32(46, target.parentCnid);
60
+ fixed.set(pascal(target.name, 64), 50);
61
+ view.setUint32(114, target.cnid);
62
+ view.setUint32(118, hfsTime(target.created));
63
+ view.setInt16(130, -1);
64
+ view.setInt16(132, -1);
65
+
66
+ const encoder = new TextEncoder();
67
+ const cnids = new Uint8Array(target.cnidPath.length * 4);
68
+ for (const [idx, cnid] of target.cnidPath.entries()) new DataView(cnids.buffer).setUint32(idx * 4, cnid);
69
+ const carbonPath = [target.volumeName, ...target.posixPath.split("/").filter(Boolean)]
70
+ .map((part) => part.replaceAll(":", "/"))
71
+ .join(":");
72
+ const extras: [number, Uint8Array][] = [
73
+ [0, encoder.encode(target.parentName.replaceAll(":", "/"))],
74
+ [1, cnids],
75
+ [2, encoder.encode(carbonPath)],
76
+ [14, utf16WithLength(target.name)],
77
+ [15, utf16WithLength(target.volumeName)],
78
+ [18, encoder.encode(target.posixPath)],
79
+ [19, encoder.encode(target.volumePath)],
80
+ ];
81
+ const parts: Uint8Array[] = [fixed];
82
+ for (const [tag, data] of extras) {
83
+ const head = new Uint8Array(4);
84
+ new DataView(head.buffer).setInt16(0, tag);
85
+ new DataView(head.buffer).setUint16(2, data.length);
86
+ parts.push(head, data, ...(data.length % 2 ? [new Uint8Array(1)] : []));
87
+ }
88
+ const end = new Uint8Array(4);
89
+ new DataView(end.buffer).setInt16(0, -1);
90
+ parts.push(end);
91
+
92
+ const length = parts.reduce((sum, part) => sum + part.length, 0);
93
+ const record = new Uint8Array(length);
94
+ let at = 0;
95
+ for (const part of parts) {
96
+ record.set(part, at);
97
+ at += part.length;
98
+ }
99
+ new DataView(record.buffer).setUint16(4, length);
100
+ return record;
101
+ }
@@ -1,7 +1,7 @@
1
1
 
2
2
 
3
3
  import { randomBytes } from "node:crypto";
4
- import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { basename, join } from "node:path";
7
7
  import { exec, execOrThrow } from "./exec.ts";
@@ -283,33 +283,6 @@ export async function zipForNotary(appPath: string, zip: string): Promise<void>
283
283
  }
284
284
 
285
285
  /** A compressed disk image of the app beside a link to /Applications, the window people drag it into. */
286
- export async function buildDmg(appPath: string, dmgPath: string, volumeName: string, workDir: string): Promise<void> {
287
- const staging = join(workDir, "dmg");
288
- rmSync(staging, { recursive: true, force: true });
289
- mkdirSync(staging, { recursive: true });
290
- await execOrThrow(["ditto", appPath, join(staging, basename(appPath))], { echo: false });
291
- symlinkSync("/Applications", join(staging, "Applications"));
292
- rmSync(dmgPath, { force: true });
293
- await execOrThrow(
294
- [
295
- "hdiutil",
296
- "create",
297
- "-volname",
298
- volumeName,
299
- "-srcfolder",
300
- staging,
301
- "-ov",
302
- "-fs",
303
- "HFS+",
304
- "-format",
305
- "UDZO",
306
- dmgPath,
307
- ],
308
- { echo: false },
309
- );
310
- rmSync(staging, { recursive: true, force: true });
311
- }
312
-
313
286
  /** What Gatekeeper says of a notarized app or disk image, as a downloaded copy would be judged. */
314
287
  export async function assessGatekeeper(file: string, type: "execute" | "open"): Promise<void> {
315
288
  const args =
@@ -8,6 +8,7 @@ import { manifestPermissionProblems, type PermissionSet, resolveAcl } from "./ac
8
8
  import { unknownConfigKeys } from "./configkeys.ts";
9
9
  import { validateCsp, validateExternalSchemes } from "./csp.ts";
10
10
  import { type DesktopServerConfig, validateDesktopServer } from "./desktop-server.ts";
11
+ import { type ResolvedDmg, validateDmg } from "./dmg.ts";
11
12
  import { type EnvConfig, validateEnvConfig } from "./env.ts";
12
13
  import { CliError } from "./log.ts";
13
14
  import { type PermissionsConfig, validatePermissions } from "./permissions.ts";
@@ -18,7 +19,8 @@ import { type UpdatesConfig, validateUpdates } from "./updates.ts";
18
19
 
19
20
  const CONFIG_FILES = ["akan-native.config.ts", "akan-native.config.js", "akan-native.config.mjs"];
20
21
 
21
- export interface ResolvedConfig extends Omit<AkanNativeConfig, "icon" | "splash" | "permissions" | "updates"> {
22
+ export interface ResolvedConfig
23
+ extends Omit<AkanNativeConfig, "icon" | "splash" | "permissions" | "updates" | "desktop"> {
22
24
  app: Required<AkanNativeConfig["app"]>;
23
25
  web: Required<Pick<AkanNativeConfig["web"], "dir" | "base">> & { build?: string; devEntry?: string };
24
26
  plugins: string[];
@@ -35,6 +37,7 @@ export interface ResolvedConfig extends Omit<AkanNativeConfig, "icon" | "splash"
35
37
  server?: DesktopServerConfig;
36
38
  /** Absolute. */
37
39
  bin?: string;
40
+ dmg: ResolvedDmg;
38
41
  };
39
42
  updates: UpdatesConfig | null;
40
43
  /** Absolute image path. */
@@ -212,6 +215,7 @@ export function projectFromConfig(raw: AkanNativeConfig, appDir: string, options
212
215
  if (bin !== undefined && !(binDir && existsSync(binDir) && statSync(binDir).isDirectory()))
213
216
  problems.push(`desktop.bin must be a folder (got ${JSON.stringify(bin)})`);
214
217
  const desktopServer = validateDesktopServer(raw.desktop, appDir, problems);
218
+ const dmg = validateDmg(raw.desktop?.dmg, appDir, problems);
215
219
  if (raw.keyboard?.resize !== undefined && raw.keyboard.resize !== "resize" && raw.keyboard.resize !== "none") {
216
220
  problems.push(`keyboard.resize must be "resize" or "none" (got ${JSON.stringify(raw.keyboard.resize)})`);
217
221
  }
@@ -281,6 +285,7 @@ export function projectFromConfig(raw: AkanNativeConfig, appDir: string, options
281
285
  },
282
286
  ...(desktopServer ? { server: desktopServer } : {}),
283
287
  ...(binDir ? { bin: binDir } : {}),
288
+ dmg,
284
289
  },
285
290
  updates,
286
291
  icon: icon ? { ...icon, image: resolve(appDir, icon.image) } : null,
@@ -11,15 +11,20 @@ import type { BuildContext } from "../lib/prepare.ts";
11
11
  import { ensureInstalled, TOOLCHAIN } from "../lib/toolchains.ts";
12
12
  import { targetArch } from "./desktop.ts";
13
13
 
14
- /** The launcher entry (freedesktop Desktop Entry): what a menu shows and which link schemes open the app. */
15
- export function desktopEntry(input: { name: string; fileName: string; schemes: string[] }): string {
14
+ /**
15
+ * The launcher entry (freedesktop Desktop Entry): what a menu shows and which link schemes open the app. Named and
16
+ * matched by the app id, the window's program name (native/desktop lib.rs): AppImageLauncher and appimaged rename the
17
+ * file they install, so StartupWMClass is what still ties the window to it.
18
+ */
19
+ export function desktopEntry(input: { id: string; name: string; fileName: string; schemes: string[] }): string {
16
20
  const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
17
21
  return [
18
22
  "[Desktop Entry]",
19
23
  "Type=Application",
20
24
  `Name=${escape(input.name)}`,
21
25
  `Exec=${input.fileName} %u`,
22
- `Icon=${input.fileName}`,
26
+ `Icon=${input.id}`,
27
+ `StartupWMClass=${input.id}`,
23
28
  "Terminal=false",
24
29
  "Categories=Utility;",
25
30
  ...(input.schemes.length ? [`MimeType=${input.schemes.map((s) => `x-scheme-handler/${s};`).join("")}`] : []),
@@ -59,13 +64,18 @@ export async function buildAppImage(ctx: BuildContext, folder: string): Promise<
59
64
  writeFileSync(join(appDir, "AppRun"), appRun(config.app.fileName));
60
65
  chmodSync(join(appDir, "AppRun"), 0o755);
61
66
  writeFileSync(
62
- join(appDir, `${config.app.fileName}.desktop`),
63
- desktopEntry({ name: config.app.name, fileName: config.app.fileName, schemes: config.deepLinks.schemes }),
67
+ join(appDir, `${config.app.id}.desktop`),
68
+ desktopEntry({
69
+ id: config.app.id,
70
+ name: config.app.name,
71
+ fileName: config.app.fileName,
72
+ schemes: config.deepLinks.schemes,
73
+ }),
64
74
  );
65
75
  const art = iconArt(config);
66
76
  if (art) {
67
- writeFileSync(join(appDir, `${config.app.fileName}.png`), encodePng(resize(art.master, 256, 256)));
68
- symlinkSync(`${config.app.fileName}.png`, join(appDir, ".DirIcon"));
77
+ writeFileSync(join(appDir, `${config.app.id}.png`), encodePng(resize(art.master, 256, 256)));
78
+ symlinkSync(`${config.app.id}.png`, join(appDir, ".DirIcon"));
69
79
  }
70
80
 
71
81
  log.step("installer: AppImage");
@@ -4,8 +4,10 @@ import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import type { ParsedArgs } from "../lib/args.ts";
6
6
  import { iconArt, windowIcon } from "../lib/icons.ts";
7
+ import { resize } from "../lib/image.ts";
7
8
  import type { Launched, LaunchOptions } from "../lib/launch.ts";
8
9
  import { dim, log } from "../lib/log.ts";
10
+ import { encodePng } from "../lib/png.ts";
9
11
  import type { BuildContext } from "../lib/prepare.ts";
10
12
  import {
11
13
  buildNativeLibrary,
@@ -39,7 +41,10 @@ export async function buildLinux(ctx: BuildContext): Promise<string> {
39
41
  const resources = resourcesOf("linux", dir);
40
42
  writeDesktopResources(ctx, resources, "linux");
41
43
  const art = iconArt(config);
42
- if (art) writeFileSync(join(resources, "icon.rgba"), windowIcon(art.master));
44
+ if (art) {
45
+ writeFileSync(join(resources, "icon.rgba"), windowIcon(art.master));
46
+ writeFileSync(join(resources, "icon.png"), encodePng(resize(art.master, 256, 256)));
47
+ }
43
48
  if (ctx.linux?.appImage) ctx.artifacts.push({ kind: "installer", path: await buildAppImage(ctx, dir) });
44
49
  const size = await folderSize(dir);
45
50
  if (size) log.info(dim(`size ${size}`));
@@ -14,6 +14,7 @@ import {
14
14
  } from "node:fs";
15
15
  import { basename, join } from "node:path";
16
16
  import type { ParsedArgs } from "../lib/args.ts";
17
+ import { buildDmg } from "../lib/dmg.ts";
17
18
  import { execOrThrow } from "../lib/exec.ts";
18
19
  import { icns, iconArt, macosIconImage } from "../lib/icons.ts";
19
20
  import type { Launched, LaunchOptions } from "../lib/launch.ts";
@@ -21,7 +22,6 @@ import { dim, log } from "../lib/log.ts";
21
22
  import {
22
23
  appEntitlements,
23
24
  assessGatekeeper,
24
- buildDmg,
25
25
  codesign,
26
26
  notarize,
27
27
  type ResolvedMacosIdentity,
@@ -144,7 +144,10 @@ export async function buildMacos(ctx: BuildContext): Promise<string> {
144
144
  if (ctx.macos?.dmg) {
145
145
  const dmg = join(outDir, `${config.app.fileName}-${config.app.version}-${ctx.arch ?? hostArch()}.dmg`);
146
146
  log.step(`dmg: ${basename(dmg)}`);
147
- await buildDmg(appPath, dmg, config.app.name, join(outDir, "gen"));
147
+ await buildDmg(appPath, dmg, config.app.name, join(outDir, "gen"), {
148
+ layout: config.desktop.dmg,
149
+ volumeIcon: join(resources, "AppIcon.icns"),
150
+ });
148
151
 
149
152
  if (identity.sign !== "-") await codesign(identity, dmg, { hardened: false, timestamp: team });
150
153
  if (notarization) {
@@ -25,12 +25,19 @@ export function desktopExec(exe: string): string {
25
25
  return `"${exe.replace(/[\\"`$]/g, (c) => `\\${c}`)}" %u`;
26
26
  }
27
27
 
28
- export function desktopEntry(name: string, exe: string, schemes: readonly string[]): string {
28
+ export function desktopEntry(
29
+ name: string,
30
+ exe: string,
31
+ schemes: readonly string[],
32
+ { id, icon = false }: { id?: string; icon?: boolean } = {},
33
+ ): string {
29
34
  return [
30
35
  "[Desktop Entry]",
31
36
  "Type=Application",
32
37
  `Name=${name.replace(/[\r\n]/g, " ")}`,
33
38
  `Exec=${desktopExec(exe)}`,
39
+ ...(id && icon ? [`Icon=${id}`] : []),
40
+ ...(id ? [`StartupWMClass=${id}`] : []),
34
41
  "Terminal=false",
35
42
 
36
43
  "NoDisplay=true",
@@ -69,17 +76,28 @@ function writeIfChanged(path: string, text: string): boolean {
69
76
  return true;
70
77
  }
71
78
 
79
+ /** Copies the app's icon into the user's hicolor theme as <app id>.png; false without one. */
80
+ function installIcon(dataHome: string, id: string, icon: string | undefined): boolean {
81
+ if (!icon || !existsSync(icon)) return false;
82
+ const target = join(dataHome, "icons", "hicolor", "256x256", "apps", `${id}.png`);
83
+ const bytes = readFileSync(icon);
84
+ if (existsSync(target) && readFileSync(target).equals(bytes)) return true;
85
+ mkdirSync(dirname(target), { recursive: true });
86
+ writeFileSync(target, bytes);
87
+ return true;
88
+ }
89
+
72
90
  async function registerLinux(
73
91
  app: { id: string; name: string },
74
92
  exe: string,
75
93
  schemes: readonly string[],
76
- env: Env,
77
- home: string,
78
- runner: Runner,
94
+ { env, home, runner, icon }: { env: Env; home: string; runner: Runner; icon?: string },
79
95
  ): Promise<void> {
80
96
  const file = `${app.id}.desktop`;
81
- const applications = join(xdg(env.XDG_DATA_HOME, join(home, ".local", "share")), "applications");
82
- const entryChanged = writeIfChanged(join(applications, file), desktopEntry(app.name, exe, schemes));
97
+ const dataHome = xdg(env.XDG_DATA_HOME, join(home, ".local", "share"));
98
+ const applications = join(dataHome, "applications");
99
+ const entry = desktopEntry(app.name, exe, schemes, { id: app.id, icon: installIcon(dataHome, app.id, icon) });
100
+ const entryChanged = writeIfChanged(join(applications, file), entry);
83
101
  const listPath = join(xdg(env.XDG_CONFIG_HOME, join(home, ".config")), "mimeapps.list");
84
102
  const list = existsSync(listPath) ? readFileSync(listPath, "utf8") : "";
85
103
  writeIfChanged(listPath, withDefaults(list, file, schemes));
@@ -128,16 +146,21 @@ async function registerWindows(
128
146
  }
129
147
  }
130
148
 
131
- /** Makes this executable the current user's handler of the app's schemes (Windows, Linux). */
149
+ /**
150
+ * Makes this executable the current user's handler of the app's schemes (Windows, Linux). `icon`: a 256 px PNG the
151
+ * Linux entry shows (resources/icon.png).
152
+ */
132
153
  export async function registerDeepLinks(
133
154
  app: { id: string; name: string },
134
155
  schemes: readonly string[],
135
- options: { exe?: string; platform?: NodeJS.Platform; env?: Env; home?: string; run?: Runner } = {},
156
+ options: { exe?: string; platform?: NodeJS.Platform; env?: Env; home?: string; run?: Runner; icon?: string } = {},
136
157
  ): Promise<void> {
137
158
  const platform = options.platform ?? process.platform;
138
159
  if (!schemes.length || (platform !== "win32" && platform !== "linux")) return;
139
- const exe = options.exe ?? process.execPath;
160
+ const env = options.env ?? process.env;
161
+
162
+ const exe = options.exe ?? (env.APPIMAGE || process.execPath);
140
163
  const runner = options.run ?? run;
141
164
  if (platform === "win32") await registerWindows(app, exe, schemes, runner);
142
- else await registerLinux(app, exe, schemes, options.env ?? process.env, options.home ?? homedir(), runner);
165
+ else await registerLinux(app, exe, schemes, { env, home: options.home ?? homedir(), runner, icon: options.icon });
143
166
  }
@@ -447,7 +447,7 @@ export function startHost(plugins: DesktopPlugin[]): void {
447
447
  if (typeof launch.exit === "number") return;
448
448
 
449
449
  openLinks(process.argv.slice(2));
450
- registerDeepLinks(boot.app, deepLinkSchemes).catch((error) =>
450
+ registerDeepLinks(boot.app, deepLinkSchemes, { icon: join(paths.resources, "icon.png") }).catch((error) =>
451
451
  console.warn("[akan-native] cannot register the deep link schemes", error),
452
452
  );
453
453
  })
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { Translator } from "akanjs/client";
3
+ import { parseAkanI18nEnv } from "akanjs/common";
4
+
5
+ const errorKeyPattern = /^[a-zA-Z][A-Za-z0-9]*\.error\.[A-Za-z0-9_]+$/;
6
+
7
+ const messageOf = (error: unknown, lang: string) => {
8
+ if (!(error instanceof Error) || !errorKeyPattern.test(error.message))
9
+ return Translator.translateByLocale(lang, "base.pageLoadFailed");
10
+ const { data } = error as { data?: Record<string, string | number> };
11
+ return Translator.translateByLocale(lang, error.message, data);
12
+ };
13
+
14
+ interface RenderFailureProps {
15
+ error: unknown;
16
+ onRetry: () => void;
17
+ }
18
+ export const RenderFailure = ({ error, onRetry }: RenderFailureProps) => {
19
+ const lang = Translator.getActiveLocale() ?? parseAkanI18nEnv().defaultLocale;
20
+ return (
21
+ <div role="alert" className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-6 text-center">
22
+ <p className="max-w-sm text-foreground/70 text-sm">{messageOf(error, lang)}</p>
23
+ <button
24
+ type="button"
25
+ onClick={onRetry}
26
+ className="rounded-field bg-primary px-4 py-2 font-medium text-primary-foreground text-sm"
27
+ >
28
+ {Translator.translateByLocale(lang, "base.retry")}
29
+ </button>
30
+ </div>
31
+ );
32
+ };
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { debugFrame, RouteDefinition, type RouteRender, router, usePathCtx } from "akanjs/client";
3
3
  import { isThenable, Logger } from "akanjs/common";
4
- import { createContext, createElement, memo, type ReactNode, useContext, useEffect, useRef } from "react";
4
+ import { createContext, createElement, memo, type ReactNode, useContext, useEffect, useRef, useState } from "react";
5
5
  import { CsrFrameDump } from "./csrFrameDump";
6
+ import { RenderFailure } from "./RenderFailure";
6
7
  import { useFetch } from "./useFetch";
7
8
  import { usePageActivity } from "./usePageActivity";
8
9
 
@@ -17,6 +18,8 @@ interface RenderedRoute {
17
18
  record: ReturnType<typeof CsrFrameDump.track> | null;
18
19
  /** A redirect was issued while the render ran, so what it drew is not the page. */
19
20
  redirected: boolean;
21
+ /** The render rejected while its page stayed: what the layer draws instead, with a retry. */
22
+ failure: { error: unknown } | null;
20
23
  }
21
24
 
22
25
  interface RenderLayerProps {
@@ -36,6 +39,7 @@ export const RenderLayer = memo(({ renders, index, params, searchParams, leaf =
36
39
  const routeRender = renders[index];
37
40
  const isAsyncRender = isAsyncRouteRender(routeRender);
38
41
  const renderedRef = useRef<RenderedRoute | null>(null);
42
+ const [, setFailures] = useState(0);
39
43
  const activity = usePageActivity();
40
44
 
41
45
  const onScreenRef = useRef(activity === "current");
@@ -75,13 +79,18 @@ export const RenderLayer = memo(({ renders, index, params, searchParams, leaf =
75
79
  result,
76
80
  record: isThenable(result) ? CsrFrameDump.track(layer, result as Promise<ReactNode>) : null,
77
81
  redirected: false,
82
+ failure: null,
78
83
  };
79
84
  if (isThenable(result))
80
85
  (result as Promise<ReactNode>).then(
81
86
  () => {
82
87
  next.redirected = router.redirectCount() !== redirectsBefore;
83
88
  },
84
- () => undefined,
89
+ (error: unknown) => {
90
+ if (router.redirectCount() !== redirectsBefore || renderedRef.current !== next) return;
91
+ next.failure = { error };
92
+ setFailures((count) => count + 1);
93
+ },
85
94
  );
86
95
  renderedRef.current = next;
87
96
  }
@@ -98,6 +107,17 @@ export const RenderLayer = memo(({ renders, index, params, searchParams, leaf =
98
107
  }, [record]);
99
108
  if (!routeRender) return null;
100
109
  if (!isAsyncRender) return createElement(routeRender.render as never, { children, params, searchParams } as never);
110
+ const failure = renderedRef.current?.failure;
111
+ if (failure)
112
+ return (
113
+ <RenderFailure
114
+ error={failure.error}
115
+ onRetry={() => {
116
+ renderedRef.current = null;
117
+ setFailures((count) => count + 1);
118
+ }}
119
+ />
120
+ );
101
121
  if (!value) return <>{composeLoadingFallback(renders.slice(index), params)}</>;
102
122
  return <layerChildContext.Provider value={children}>{value}</layerChildContext.Provider>;
103
123
  });