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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { publishAppUpdate } from "../platforms/desktop-update.ts";
5
+ import { assertSameServer, publishAppUpdate } from "../platforms/desktop-update.ts";
6
6
  import { BUNDLE_FILE, readBundleInfo } from "./compat.ts";
7
7
  import { CliError, log } from "./log.ts";
8
8
  import type { BuildContext } from "./prepare.ts";
@@ -41,6 +41,23 @@ export function nextSequence(dir: string, channel: string, now = Math.floor(Date
41
41
  return previous + 1;
42
42
  }
43
43
 
44
+ /** `<out>/<os>-<arch>` for a desktop release, `<out>/<platform>` for a phone's web bundle. */
45
+ export function releaseDir(out: string, platform: ReleasePlatform): string {
46
+ const desktop = platform === "macos" || platform === "windows" || platform === "linux";
47
+ return join(out, desktop ? `${platform}-${hostArch()}` : platform);
48
+ }
49
+
50
+ /** Refuses before the build a desktop release the apps of its channel would refuse for its server (assertSameServer). */
51
+ export function assertServerOfChannel(
52
+ config: { desktop?: { server?: unknown } },
53
+ platform: ReleasePlatform,
54
+ out: string,
55
+ channel: string,
56
+ ): void {
57
+ if (platform === "macos" || platform === "windows" || platform === "linux")
58
+ assertSameServer(releaseDir(out, platform), channel, !!config.desktop?.server);
59
+ }
60
+
44
61
  /** Refuses before the build a release that could not be signed: no key on this machine, or another app's. */
45
62
  export function assertSigningKey(config: { app: { id: string }; updates?: { publicKey: string } | null }): void {
46
63
  if (!config.updates)
@@ -73,16 +90,16 @@ export async function publishRelease(
73
90
  throw new CliError("akan-native.config.ts has no updates: { url, publicKey } (run `akan-native update keygen`)");
74
91
  const desktop = platform === "macos" || platform === "windows" || platform === "linux" ? platform : null;
75
92
 
76
- const dir = join(out, desktop ? `${desktop}-${hostArch()}` : platform);
93
+ const dir = releaseDir(out, platform);
77
94
  assertChannel(channel);
78
95
  assertSigningKey(config);
79
96
  const keyPath = updateKeyPath(config.app.id);
80
97
  const sequence = nextSequence(dir, channel);
81
98
 
82
99
  let manifest: UpdateManifest;
83
- let superseded: string | null = null;
100
+ let leftovers: string[] = [];
84
101
  if (desktop) {
85
- ({ manifest, superseded } = await publishAppUpdate(ctx, desktop, artifact, dir, channel, sequence));
102
+ ({ manifest, leftovers } = await publishAppUpdate(ctx, desktop, artifact, dir, channel, sequence));
86
103
  } else {
87
104
  const info = readBundleInfo(join(ctx.outDir, BUNDLE_FILE));
88
105
  manifest = webManifest({
@@ -99,6 +116,6 @@ export async function publishRelease(
99
116
  const signature = `${signManifest(bytes, keyPath, config.updates.publicKey)}\n`;
100
117
  mkdirSync(dir, { recursive: true });
101
118
  writeSigned(dir, channel, bytes, signature);
102
- if (superseded) rmSync(superseded, { force: true });
119
+ for (const file of leftovers) rmSync(file, { force: true });
103
120
  return { dir, manifest };
104
121
  }
@@ -58,6 +58,8 @@ export interface UpdateManifest {
58
58
  archive?: { sha256: string; url: string; size: number; gzSha256: string };
59
59
  /** App updates: gzip deltas from earlier releases' tars (`from` = that tar's sha256). */
60
60
  patches?: { from: string; url: string; sha256: string; size: number }[];
61
+ /** App updates: whether the app carries a server; an installed app refuses a release that differs from itself. */
62
+ server?: boolean;
61
63
  }
62
64
 
63
65
  /** Every file of a web bundle as the app serves it: index.html with the init script and CSP, public/, env.runtime.json. */
@@ -1,11 +1,11 @@
1
1
 
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { createDelta } from "../../../desktop/src/delta.ts";
6
6
  import { runtimeVersion } from "../lib/boot.ts";
7
7
  import { execOrThrow } from "../lib/exec.ts";
8
- import { dim, log } from "../lib/log.ts";
8
+ import { CliError, dim, log } from "../lib/log.ts";
9
9
  import type { BuildContext } from "../lib/prepare.ts";
10
10
  import { hostArch, sha256, type UpdateManifest } from "../lib/updates.ts";
11
11
  import { type DesktopOs, resourcesOf } from "./desktop.ts";
@@ -16,6 +16,30 @@ const TAR: Record<DesktopOs, string[]> = {
16
16
  linux: ["tar"],
17
17
  };
18
18
 
19
+ /**
20
+ * An installed app refuses a release whose server presence is not its own (plugins/updates): published, this one would
21
+ * be refused by every app the channel reached, and so would every release after it. A manifest from before the field
22
+ * says nothing and is not compared.
23
+ */
24
+ export function assertSameServer(dir: string, channel: string, server: boolean): void {
25
+ const path = join(dir, `${channel}.json`);
26
+ if (!existsSync(path)) return;
27
+ const previous = JSON.parse(readFileSync(path, "utf8")) as Partial<UpdateManifest>;
28
+ if (typeof previous.server !== "boolean" || previous.server === server) return;
29
+ throw new CliError(
30
+ `the ${channel} release in ${dir} (${previous.bundle}) ${previous.server ? "carries a server and this build does not" : "carries no server and this build does"}, and installed apps refuse a release that differs: publish this build on another channel (updates.channel), or remove ${path} to start the channel over`,
31
+ );
32
+ }
33
+
34
+ /** The previous release's tar from its published archive, when it is still here and still what it says. */
35
+ function previousTar(out: string, previous: UpdateManifest): Uint8Array | null {
36
+ const archive = previous.archive;
37
+ const file = archive ? join(out, archive.url) : "";
38
+ if (!archive || !existsSync(file)) return null;
39
+ const tar = Bun.gunzipSync(readFileSync(file));
40
+ return sha256(tar) === archive.sha256 ? tar : null;
41
+ }
42
+
19
43
  export async function publishAppUpdate(
20
44
  ctx: BuildContext,
21
45
  os: DesktopOs,
@@ -23,40 +47,40 @@ export async function publishAppUpdate(
23
47
  out: string,
24
48
  channel: string,
25
49
  sequence: number,
26
- ): Promise<{ manifest: UpdateManifest; superseded: string | null }> {
50
+ ): Promise<{ manifest: UpdateManifest; leftovers: string[] }> {
27
51
  const { config } = ctx.project;
52
+ const server = existsSync(join(resourcesOf(os, app), "server.json"));
53
+ assertSameServer(out, channel, server);
54
+ const previousPath = join(out, `${channel}.json`);
55
+ const previous = existsSync(previousPath) ? (JSON.parse(readFileSync(previousPath, "utf8")) as UpdateManifest) : null;
28
56
  const dir = join(out, "app");
29
57
  mkdirSync(dir, { recursive: true });
30
58
  const tmp = join(dir, `.build-${sequence}.tar`);
31
59
  await execOrThrow([...TAR[os], "-cf", tmp, "-C", dirname(app), basename(app)], { echo: false });
32
60
  const tar = readFileSync(tmp);
61
+ rmSync(tmp);
33
62
  const tarSha = sha256(tar);
34
- renameSync(tmp, join(dir, `${tarSha}.tar`));
35
63
  const gz = Bun.gzipSync(tar, { level: 9 });
36
64
  writeFileSync(join(dir, `${tarSha}.tar.gz`), gz);
37
65
 
38
66
  const patches: NonNullable<UpdateManifest["patches"]> = [];
39
- let superseded: string | null = null;
40
- const previousPath = join(out, `${channel}.json`);
41
- if (existsSync(previousPath)) {
42
- const previous = JSON.parse(readFileSync(previousPath, "utf8")) as UpdateManifest;
43
- const from = previous.archive?.sha256;
44
- const base = from ? join(dir, `${from}.tar`) : "";
45
- if (from && from !== tarSha && existsSync(base)) {
46
- const started = performance.now();
47
- const delta = Bun.gzipSync(createDelta(readFileSync(base), tar), { level: 9 });
48
- const name = `${from}-${tarSha}.delta.gz`;
49
- writeFileSync(join(dir, name), delta);
50
- patches.push({ from, url: `app/${name}`, sha256: sha256(delta), size: delta.length });
51
- log.info(
52
- dim(
53
- `delta from ${previous.bundle}: ${(delta.length / 1024).toFixed(0)} KiB instead of ${(gz.length / 1024 / 1024).toFixed(1)} MiB (${Math.round(performance.now() - started)} ms)`,
54
- ),
55
- );
56
- }
57
-
58
- if (from && from !== tarSha) superseded = base;
67
+ const from = previous?.archive?.sha256;
68
+ const base = previous && from && from !== tarSha ? previousTar(out, previous) : null;
69
+ if (previous && from && base) {
70
+ const started = performance.now();
71
+ const delta = Bun.gzipSync(createDelta(base, tar), { level: 9 });
72
+ const name = `${from}-${tarSha}.delta.gz`;
73
+ writeFileSync(join(dir, name), delta);
74
+ patches.push({ from, url: `app/${name}`, sha256: sha256(delta), size: delta.length });
75
+ log.info(
76
+ dim(
77
+ `delta from ${previous.bundle}: ${(delta.length / 1024).toFixed(0)} KiB instead of ${(gz.length / 1024 / 1024).toFixed(1)} MiB (${Math.round(performance.now() - started)} ms)`,
78
+ ),
79
+ );
59
80
  }
81
+ const leftovers = readdirSync(dir)
82
+ .filter((name) => /^[0-9a-f]{64}\.tar$/.test(name))
83
+ .map((name) => join(dir, name));
60
84
 
61
85
  const manifest: UpdateManifest = {
62
86
  schema: 1,
@@ -72,9 +96,10 @@ export async function publishAppUpdate(
72
96
  JSON.parse(readFileSync(join(resourcesOf(os, app), "updates.json"), "utf8")) as { embeddedSequence: number }
73
97
  ).embeddedSequence,
74
98
  arch: hostArch(),
75
- files: [{ path: basename(app), sha256: tarSha, size: statSync(join(dir, `${tarSha}.tar`)).size }],
99
+ files: [{ path: basename(app), sha256: tarSha, size: tar.length }],
76
100
  archive: { sha256: tarSha, url: `app/${tarSha}.tar.gz`, size: gz.length, gzSha256: sha256(gz) },
77
101
  patches,
102
+ server,
78
103
  };
79
- return { manifest, superseded };
104
+ return { manifest, leftovers };
80
105
  }
@@ -17,8 +17,11 @@ const WEBVIEW2_CLIENT = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
17
17
  export const WINDOWS_MAX_PATH = 259;
18
18
  /** `C:\Users\` + a 20-character user name + `\AppData\Local\Programs\`. */
19
19
  const INSTALL_ROOT = "C:\\Users\\".length + 20 + "\\AppData\\Local\\Programs\\".length;
20
- /** `.update-<version>-<sequence>\`: an update unpacks next to the app, under the app's name again, before the swap. */
21
- const STAGING = ".update-".length + 24 + 1;
20
+ /** A release's bundle id is `<app.version>-<publish time in seconds>`, ten digits until the year 2286 (lib/publish.ts). */
21
+ const SEQUENCE_DIGITS = 10;
22
+ /** NTFS's default cluster: every file takes whole clusters on disk. */
23
+ const CLUSTER = 4096;
24
+ const MIB = 1024 * 1024;
22
25
 
23
26
  export interface InstallerInput {
24
27
  name: string;
@@ -30,6 +33,10 @@ export interface InstallerInput {
30
33
  folder: string;
31
34
  /** Its top-level files and folders: what an update or uninstall removes. */
32
35
  entries: { name: string; dir: boolean }[];
36
+ /** Bytes the app folder takes on disk, and bytes the setup unpacks in all (the folder and the WebView2 bootstrapper). */
37
+ space: { install: number; unpack: number };
38
+ /** deepLinks.schemes: the uninstaller removes those the app registered for its own executable. */
39
+ schemes: string[];
33
40
  icon?: string;
34
41
  bootstrapper: string;
35
42
  stopScript: string;
@@ -39,14 +46,29 @@ export interface InstallerInput {
39
46
  /** `$` is NSIS's variable sigil; the names that reach a script cannot hold a quote (config.ts app.name). */
40
47
  const nsis = (value: string) => value.replace(/\$/g, "$$$$");
41
48
 
49
+ const mib = (bytes: number) => Math.ceil(bytes / MIB);
50
+
42
51
  const removal = (entries: InstallerInput["entries"]) =>
43
52
  entries
44
- .map(({ name, dir }) => (dir ? ` RMDir /r "$INSTDIR\\${nsis(name)}"` : ` Delete "$INSTDIR\\${nsis(name)}"`))
53
+ .map(({ name, dir }) =>
54
+ dir ? ` !insertmacro RemoveTree "$INSTDIR\\${nsis(name)}"` : ` Delete "$INSTDIR\\${nsis(name)}"`,
55
+ )
45
56
  .join("\n");
46
57
 
47
58
  const moveIn = (entries: InstallerInput["entries"]) =>
48
59
  entries.map(({ name }) => ` Rename "$INSTDIR.setup-new\\${nsis(name)}" "$INSTDIR\\${nsis(name)}"`).join("\n");
49
60
 
61
+ const unlink = (schemes: string[]) =>
62
+ schemes
63
+ .map(
64
+ (scheme) => ` ReadRegStr $0 HKCU "Software\\Classes\\${scheme}\\shell\\open\\command" ""
65
+ \${If} $0 == '"$INSTDIR\\\${EXE}" "%1"'
66
+ DeleteRegKey HKCU "Software\\Classes\\${scheme}"
67
+ \${EndIf}
68
+ `,
69
+ )
70
+ .join("");
71
+
50
72
  export function installerScript(input: InstallerInput): string {
51
73
  const version = windowsVersion(input.version, input.build);
52
74
  const icon = input.icon ? `!define MUI_ICON "${nsis(input.icon)}"\n!define MUI_UNICON "${nsis(input.icon)}"\n` : "";
@@ -66,11 +88,16 @@ RequestExecutionLevel user
66
88
  !define UNINSTALL_KEY "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${input.id}"
67
89
  !define RUN_KEY "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
68
90
  !define STARTUP_APPROVED_KEY "Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run"
91
+ !define RUN_ONCE_KEY "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce"
92
+ !define AUMID_KEY "Software\\Classes\\AppUserModelId\\${input.id}"
69
93
  !define WEBVIEW2_KEY "SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}"
94
+ !define APP_MB ${mib(input.space.install)}
95
+ !define UNPACK_MB ${mib(input.space.unpack)}
70
96
 
71
97
  Name "\${APP_NAME}"
72
98
  OutFile "${nsis(input.outFile)}"
73
99
  InstallDir "$LOCALAPPDATA\\Programs\\\${APP_NAME}"
100
+ InstallDirRegKey HKCU "\${UNINSTALL_KEY}" "InstallLocation"
74
101
  BrandingText "\${APP_NAME} ${nsis(input.version)}"
75
102
  VIProductVersion "${version}"
76
103
  VIAddVersionKey "ProductName" "\${APP_NAME}"
@@ -109,13 +136,50 @@ FunctionEnd
109
136
  Abort
110
137
  !macroend
111
138
 
139
+ !macro Warn MESSAGE
140
+ DetailPrint "\${MESSAGE}"
141
+ \${IfNot} \${Silent}
142
+ MessageBox MB_ICONEXCLAMATION|MB_OK "\${MESSAGE}"
143
+ \${EndIf}
144
+ !macroend
145
+
146
+ ; Local\\, not Global\\: another user's setup installs into that user's own folder.
147
+ !macro OnlyOneSetup
148
+ System::Call 'kernel32::CreateMutex(p 0, i 0, t "Local\\akan-native-setup-\${APP_ID}") p .r0 ?e'
149
+ Pop $1
150
+ \${If} $0 = 0
151
+ \${OrIf} $1 = 183
152
+ \${IfNot} \${Silent}
153
+ MessageBox MB_ICONSTOP|MB_OK "Another setup or uninstall of \${APP_NAME} is running."
154
+ \${EndIf}
155
+ SetErrorLevel 2
156
+ Abort
157
+ \${EndIf}
158
+ !macroend
159
+
160
+ ; RMDir /r walks into a junction and empties its target; rd /s (Vista and later) removes the link itself.
161
+ ; The path reaches cmd through the environment, so % ^ & in it stay literal. rd answers 0 even when a file stays.
162
+ !macro RemoveTree PATH
163
+ StrCpy $R9 "\${PATH}"
164
+ System::Call 'kernel32::SetEnvironmentVariable(t "AKAN_NATIVE_REMOVE", t R9)'
165
+ nsExec::Exec '"$SYSDIR\\cmd.exe" /d /c rmdir /s /q "%AKAN_NATIVE_REMOVE%"'
166
+ Pop $R9
167
+ !macroend
168
+
169
+ !macro RemoveLeftover PATH
170
+ !insertmacro RemoveTree "\${PATH}"
171
+ \${If} \${FileExists} "\${PATH}"
172
+ !insertmacro Fail "\${PATH}, left by an earlier setup, could not be removed. \${APP_NAME} is left as it was."
173
+ \${EndIf}
174
+ !macroend
175
+
112
176
  !macro RemoveEntries
113
177
  ${removal(input.entries)}
114
178
  !macroend
115
179
 
116
180
  !macro RemoveApp
117
181
  \${If} \${FileExists} "$INSTDIR\\\${EXE}"
118
- RMDir /r "$INSTDIR"
182
+ !insertmacro RemoveTree "$INSTDIR"
119
183
  \${Else}
120
184
  !insertmacro RemoveEntries
121
185
  \${EndIf}
@@ -140,12 +204,62 @@ ${removal(input.entries)}
140
204
  !macro RemoveSiblings SUFFIX
141
205
  FindFirst $R0 $R1 "$INSTDIR.\${SUFFIX}*"
142
206
  \${DoWhile} $R1 != ""
143
- RMDir /r "$INSTDIR\\..\\$R1"
207
+ \${If} \${FileExists} "$INSTDIR\\..\\$R1\\*.*"
208
+ !insertmacro RemoveTree "$INSTDIR\\..\\$R1"
209
+ \${Else}
210
+ Delete "$INSTDIR\\..\\$R1"
211
+ \${EndIf}
144
212
  FindNext $R0 $R1
145
213
  \${Loop}
146
214
  FindClose $R0
147
215
  !macroend
148
216
 
217
+ !macro CheckStaged
218
+ \${IfNot} \${FileExists} "$INSTDIR.setup-new\\\${EXE}"
219
+ \${OrIfNot} \${FileExists} "$INSTDIR.setup-new\\resources\\boot.json"
220
+ !insertmacro Fail "The files unpacked into $INSTDIR.setup-new are incomplete: another program removed some, or an antivirus took one. \${APP_NAME} is left as it was."
221
+ \${EndIf}
222
+ !macroend
223
+
224
+ Function .onInit
225
+ !insertmacro OnlyOneSetup
226
+ FunctionEnd
227
+
228
+ ; A full disk ends the section inside File; a file that cannot be created only sets the error flag.
229
+ Function .onInstFailed
230
+ SetOutPath "$LOCALAPPDATA"
231
+ !insertmacro RemoveTree "$INSTDIR.setup-new"
232
+ Delete "$INSTDIR.setup-uninstall.exe"
233
+ FunctionEnd
234
+
235
+ ; /SOLID first unpacks what the setup carries into a file in $TEMP, which grows to all of it.
236
+ Function CheckSpace
237
+ \${GetRoot} "$INSTDIR" $R0
238
+ \${GetRoot} "$TEMP" $R1
239
+ StrCpy $R2 \${APP_MB}
240
+ StrCpy $R3 \${UNPACK_MB}
241
+ \${If} $R0 == $R1
242
+ IntOp $R2 $R2 + $R3
243
+ StrCpy $R3 0
244
+ \${EndIf}
245
+ ClearErrors
246
+ \${DriveSpace} "$R0\\" "/D=F /S=M" $R4
247
+ \${IfNot} \${Errors}
248
+ \${If} $R4 < $R2
249
+ !insertmacro Fail "$R0 has $R4 MB free, and the setup needs $R2 MB there. \${APP_NAME} is left as it was."
250
+ \${EndIf}
251
+ \${EndIf}
252
+ \${If} $R3 > 0
253
+ ClearErrors
254
+ \${DriveSpace} "$R1\\" "/D=F /S=M" $R4
255
+ \${IfNot} \${Errors}
256
+ \${If} $R4 < $R3
257
+ !insertmacro Fail "$R1 has $R4 MB free, and the setup needs $R3 MB there to unpack in $TEMP. \${APP_NAME} is left as it was."
258
+ \${EndIf}
259
+ \${EndIf}
260
+ \${EndIf}
261
+ FunctionEnd
262
+
149
263
  Function WebView2Version
150
264
  SetRegView 64
151
265
  ReadRegStr $0 HKLM "\${WEBVIEW2_KEY}" "pv"
@@ -203,46 +317,56 @@ Section "Install"
203
317
  \${AndIf} \${FileExists} "$INSTDIR.setup-old\\\${EXE}"
204
318
  Rename "$INSTDIR.setup-old" "$INSTDIR"
205
319
  \${EndIf}
206
- RMDir /r "$INSTDIR.setup-new"
320
+ !insertmacro RemoveLeftover "$INSTDIR.setup-new"
207
321
  \${If} \${FileExists} "$INSTDIR\\\${EXE}"
208
322
  \${OrIfNot} \${FileExists} "$INSTDIR.setup-old\\\${EXE}"
209
- RMDir /r "$INSTDIR.setup-old"
323
+ !insertmacro RemoveLeftover "$INSTDIR.setup-old"
210
324
  \${EndIf}
325
+ Delete "$INSTDIR.setup-uninstall.exe"
211
326
  \${DirState} "$INSTDIR" $0
212
327
  \${If} $0 == 1
213
328
  \${AndIfNot} \${FileExists} "$INSTDIR\\\${EXE}"
214
329
  \${AndIf} "$INSTDIR" != "$LOCALAPPDATA\\Programs\\\${APP_NAME}"
215
330
  !insertmacro Fail "$INSTDIR holds other files. Install into an empty folder, or where \${APP_NAME} is installed."
216
331
  \${EndIf}
332
+ Call CheckSpace
217
333
  InitPluginsDir
218
334
  Call EnsureWebView2
219
335
  SetOutPath "$INSTDIR.setup-new"
220
336
  ClearErrors
221
337
  File /r "${nsis(input.folder)}\\*"
222
338
  \${If} \${Errors}
223
- SetOutPath "$LOCALAPPDATA"
224
- RMDir /r "$INSTDIR.setup-new"
225
- !insertmacro Fail "Some files could not be written beside $INSTDIR. Windows refuses a path over 260 characters unless long paths are enabled."
339
+ !insertmacro Fail "Some files could not be written to $INSTDIR.setup-new: the disk is full, this user may not write there, an antivirus holds a file, or a path is over 260 characters and long paths are not enabled."
226
340
  \${EndIf}
227
341
  ; SetOutPath is also the installer's working folder, which Windows does not let anyone rename.
228
342
  SetOutPath "$LOCALAPPDATA"
343
+ !insertmacro CheckStaged
344
+ ClearErrors
345
+ WriteUninstaller "$INSTDIR.setup-uninstall.exe"
346
+ \${If} \${Errors}
347
+ !insertmacro Fail "The uninstaller could not be written to $INSTDIR.setup-uninstall.exe. \${APP_NAME} is left as it was."
348
+ \${EndIf}
229
349
  File "/oname=$PLUGINSDIR\\stop-app.ps1" "${nsis(input.stopScript)}"
230
350
  nsExec::Exec 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\\stop-app.ps1" "$INSTDIR"'
231
351
  Pop $0
232
- StrCpy $AppStopped 1
352
+ \${If} $0 == 0
353
+ StrCpy $AppStopped 1
354
+ \${EndIf}
355
+ !insertmacro CheckStaged
233
356
  \${If} \${FileExists} "$INSTDIR\\\${EXE}"
234
357
  !insertmacro RenameRetry "$INSTDIR" "$INSTDIR.setup-old"
235
358
  \${If} $R2 != "done"
236
- RMDir /r "$INSTDIR.setup-new"
237
359
  !insertmacro Fail "$INSTDIR is in use and could not be replaced; \${APP_NAME} is left as it was."
238
360
  \${EndIf}
239
361
  !insertmacro RenameRetry "$INSTDIR.setup-new" "$INSTDIR"
240
362
  \${If} $R2 != "done"
241
363
  !insertmacro RenameRetry "$INSTDIR.setup-old" "$INSTDIR"
242
- RMDir /r "$INSTDIR.setup-new"
364
+ \${If} $R2 != "done"
365
+ !insertmacro Fail "Neither the new files nor the old ones could be moved back to $INSTDIR: \${APP_NAME} is in $INSTDIR.setup-old, which the next setup puts back."
366
+ \${EndIf}
243
367
  !insertmacro Fail "The new files could not take the place of $INSTDIR; \${APP_NAME} is left as it was."
244
368
  \${EndIf}
245
- RMDir /r "$INSTDIR.setup-old"
369
+ !insertmacro RemoveTree "$INSTDIR.setup-old"
246
370
  \${Else}
247
371
  !insertmacro RemoveEntries
248
372
  RMDir "$INSTDIR"
@@ -253,19 +377,20 @@ ${moveIn(input.entries)}
253
377
  \${If} \${Errors}
254
378
  !insertmacro Fail "Some files could not be moved into $INSTDIR."
255
379
  \${EndIf}
256
- RMDir /r "$INSTDIR.setup-new"
380
+ !insertmacro RemoveTree "$INSTDIR.setup-new"
257
381
  \${Else}
258
382
  !insertmacro RenameRetry "$INSTDIR.setup-new" "$INSTDIR"
259
383
  \${If} $R2 != "done"
260
- RMDir /r "$INSTDIR.setup-new"
261
384
  !insertmacro Fail "$INSTDIR could not be made."
262
385
  \${EndIf}
263
386
  \${EndIf}
264
387
  \${EndIf}
265
388
  ClearErrors
266
- WriteUninstaller "$INSTDIR.uninstall.exe"
267
- \${If} \${Errors}
268
- !insertmacro Fail "$INSTDIR.uninstall.exe could not be written: install into a folder under one this user may write to."
389
+ StrCpy $R0 "$INSTDIR.setup-uninstall.exe"
390
+ StrCpy $R1 "$INSTDIR.uninstall.exe"
391
+ System::Call 'kernel32::MoveFileEx(t R0, t R1, i 1) i .r0'
392
+ \${If} $0 = 0
393
+ SetErrors
269
394
  \${EndIf}
270
395
  CreateShortcut "$SMPROGRAMS\\\${APP_NAME}.lnk" "$INSTDIR\\\${EXE}"
271
396
  WriteRegStr HKCU "\${UNINSTALL_KEY}" "DisplayName" "\${APP_NAME}"
@@ -276,16 +401,22 @@ ${moveIn(input.entries)}
276
401
  WriteRegStr HKCU "\${UNINSTALL_KEY}" "QuietUninstallString" '"$INSTDIR.uninstall.exe" /S'
277
402
  WriteRegDWORD HKCU "\${UNINSTALL_KEY}" "NoModify" 1
278
403
  WriteRegDWORD HKCU "\${UNINSTALL_KEY}" "NoRepair" 1
279
- \${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
280
- WriteRegDWORD HKCU "\${UNINSTALL_KEY}" "EstimatedSize" $0
404
+ WriteRegDWORD HKCU "\${UNINSTALL_KEY}" "EstimatedSize" ${Math.ceil(input.space.install / 1024)}
405
+ \${If} \${Errors}
406
+ !insertmacro Warn "\${APP_NAME} is installed, but its uninstaller, Start menu shortcut or uninstall entry could not all be written."
407
+ \${EndIf}
281
408
  \${If} $RunAfter == 1
282
409
  Call RunApp
283
410
  \${EndIf}
284
411
  SectionEnd
285
412
 
286
413
  Function un.onInit
414
+ !insertmacro OnlyOneSetup
287
415
  ReadRegStr $INSTDIR HKCU "\${UNINSTALL_KEY}" "InstallLocation"
288
- \${If} $INSTDIR == ""
416
+ \${GetRoot} "$INSTDIR" $0
417
+ \${If} $0 == ""
418
+ \${OrIf} $INSTDIR == $0
419
+ \${OrIf} $INSTDIR == "$0\\"
289
420
  \${IfNot} \${Silent}
290
421
  MessageBox MB_ICONSTOP|MB_OK "\${APP_NAME} is not installed for this user."
291
422
  \${EndIf}
@@ -300,33 +431,40 @@ Section "Uninstall"
300
431
  nsExec::Exec 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\\stop-app.ps1" "$INSTDIR"'
301
432
  Pop $0
302
433
  Delete "$SMPROGRAMS\\\${APP_NAME}.lnk"
303
- RMDir /r "$INSTDIR.previous"
434
+ !insertmacro RemoveTree "$INSTDIR.previous"
304
435
  !insertmacro RemoveSiblings "update-"
305
436
  !insertmacro RemoveSiblings "failed-"
306
437
  !insertmacro RemoveSiblings "setup-"
307
438
  !insertmacro RemoveApp
308
439
  RMDir "$INSTDIR"
309
- Delete "$INSTDIR.uninstall.exe"
310
440
  DeleteRegKey HKCU "\${UNINSTALL_KEY}"
311
441
  DeleteRegValue HKCU "\${RUN_KEY}" "\${APP_ID}"
312
442
  DeleteRegValue HKCU "\${STARTUP_APPROVED_KEY}" "\${APP_ID}"
313
- RMDir /r "$LOCALAPPDATA\\\${APP_ID}\\akan-native-updates"
443
+ DeleteRegValue HKCU "\${RUN_ONCE_KEY}" "akan-native-update \${APP_ID}"
444
+ ${unlink(input.schemes)} DeleteRegKey HKCU "\${AUMID_KEY}"
445
+ Delete "$LOCALAPPDATA\\\${APP_ID}\\notification-icon.png"
446
+ !insertmacro RemoveTree "$LOCALAPPDATA\\\${APP_ID}\\akan-native-updates"
447
+ !insertmacro RemoveTree "$LOCALAPPDATA\\\${APP_ID}\\akan-native-updates-debug"
314
448
  Delete "$LOCALAPPDATA\\\${APP_ID}\\akan-native-relaunch.json"
315
449
  RMDir "$LOCALAPPDATA\\\${APP_ID}"
450
+ ; Last: the uninstaller runs as a copy in $TEMP and the one started exits at once, so its file going marks the end.
451
+ Delete "$INSTDIR.uninstall.exe"
316
452
  SectionEnd
317
453
  `;
318
454
  }
319
455
 
320
456
  /**
321
- * Stops every process running from the install folder, and waits (up to 10 s) until they are gone. Through
322
- * WMI: the installer is a 32-bit program, so its PowerShell is too, and a 32-bit process cannot read a
323
- * 64-bit one's path (Get-Process's Path comes back empty).
457
+ * Stops every process running from the install folder, waits (up to 10 s) until they are gone, and exits 1 when
458
+ * one is left or cannot be seen. Through WMI: the installer is a 32-bit program, so its PowerShell is too, and a
459
+ * 32-bit process cannot read a 64-bit one's path (Get-Process's Path comes back empty).
324
460
  */
325
461
  export const STOP_SCRIPT = `param([string]$Dir)
462
+ $ErrorActionPreference = 'Stop'
326
463
  $prefix = [IO.Path]::GetFullPath($Dir).TrimEnd('\\') + '\\'
327
464
  $running = { Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) } }
328
465
  & $running | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
329
466
  for ($i = 0; $i -lt 50 -and (& $running); $i++) { Start-Sleep -Milliseconds 200 }
467
+ if (& $running) { exit 1 }
330
468
  `;
331
469
 
332
470
  /** The longest path under `folder`, relative and with Windows separators. Links are not followed. */
@@ -343,9 +481,32 @@ export function deepestPath(folder: string): string {
343
481
  return deepest;
344
482
  }
345
483
 
346
- /** How long `relative` gets on a typical PC while an update unpacks, the longest it ever is: root, app, staging, app. */
347
- export function installedPathLength(appName: string, relative: string): number {
348
- return INSTALL_ROOT + appName.length + STAGING + appName.length + 1 + relative.length;
484
+ /** The bytes of the files under `folder`, and what they take on disk. Links are not followed. */
485
+ export function folderSize(folder: string): { bytes: number; disk: number } {
486
+ let bytes = 0;
487
+ let disk = 0;
488
+ const walk = (dir: string) => {
489
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
490
+ const path = join(dir, entry.name);
491
+ if (entry.isDirectory()) walk(path);
492
+ else if (entry.isFile()) {
493
+ const { size } = statSync(path);
494
+ bytes += size;
495
+ disk += Math.ceil(size / CLUSTER) * CLUSTER;
496
+ }
497
+ }
498
+ };
499
+ walk(folder);
500
+ return { bytes, disk };
501
+ }
502
+
503
+ /**
504
+ * How long `relative` gets on a typical PC while an update unpacks, the longest it ever is: root, app, then
505
+ * `.update-<bundle>\` and the app again.
506
+ */
507
+ export function installedPathLength(appName: string, version: string, relative: string): number {
508
+ const staging = ".update-".length + version.length + 1 + SEQUENCE_DIGITS + 1;
509
+ return INSTALL_ROOT + appName.length + staging + appName.length + 1 + relative.length;
349
510
  }
350
511
 
351
512
  /** makensis: AKAN_NATIVE_MAKENSIS, PATH, then where the NSIS installer puts it. */
@@ -406,11 +567,13 @@ export async function buildWindowsInstaller(ctx: BuildContext, folder: string):
406
567
  const outFile = join(ctx.outDir, `${config.app.fileName}-${config.app.version}-${hostArch()}-setup.exe`);
407
568
  const script = join(gen, "installer.nsi");
408
569
  const deepest = deepestPath(folder);
409
- const longest = installedPathLength(config.app.name, deepest);
570
+ const longest = installedPathLength(config.app.name, config.app.version, deepest);
410
571
  if (longest > WINDOWS_MAX_PATH - 20)
411
572
  log.warn(
412
573
  `${deepest} reaches about ${longest} characters under %LOCALAPPDATA%\\Programs while an update unpacks: Windows refuses paths over ${WINDOWS_MAX_PATH} unless long paths are enabled, so the installer or an update fails on such a PC. Shorten app.name or that path.`,
413
574
  );
575
+ const bootstrapper = await webview2Bootstrapper();
576
+ const size = folderSize(folder);
414
577
  writeFileSync(
415
578
  script,
416
579
  `${installerScript({
@@ -421,8 +584,10 @@ export async function buildWindowsInstaller(ctx: BuildContext, folder: string):
421
584
  build: config.app.build,
422
585
  folder,
423
586
  entries: readdirSync(folder).map((name) => ({ name, dir: statSync(join(folder, name)).isDirectory() })),
587
+ space: { install: size.disk, unpack: size.bytes + statSync(bootstrapper).size },
588
+ schemes: config.deepLinks.schemes,
424
589
  ...(existsSync(icon) ? { icon } : {}),
425
- bootstrapper: await webview2Bootstrapper(),
590
+ bootstrapper,
426
591
  stopScript,
427
592
  outFile,
428
593
  })}`,
@@ -218,6 +218,7 @@ export function createDispatcher(
218
218
  if (launching) apply();
219
219
  else console.warn(`[akan-native] ${plugin.id}: ctx.launch.${what} after the window was created is ignored`);
220
220
  };
221
+ const quit = (code: number) => (services.lifecycle ? void services.lifecycle.quit(code) : services.quit?.(code));
221
222
  const contexts = new Map<string, DesktopContext>();
222
223
  const context = (plugin: DesktopPlugin): DesktopContext => {
223
224
  let ctx = contexts.get(plugin.id);
@@ -252,7 +253,7 @@ export function createDispatcher(
252
253
  deepLinkSchemes: services.deepLinkSchemes ?? [],
253
254
  externalSchemes: externalSchemes(services.externalSchemes ?? []),
254
255
  openLinks: (args) => services.openLinks?.(args),
255
- quit: (code = 0) => (services.lifecycle ? void services.lifecycle.quit(code) : services.quit?.(code)),
256
+ quit: (code = 0) => quit(code),
256
257
  onQuit: (fn) => services.lifecycle?.onQuit(fn) ?? (() => {}),
257
258
  onDocumentEnd: (fn) => {
258
259
  const entry = { fn };
@@ -265,7 +266,13 @@ export function createDispatcher(
265
266
  services.lifecycle ? void services.lifecycle.closeWindow(window) : services.quit?.(0),
266
267
  launch: {
267
268
  setWindow: (bounds) => duringLaunch(plugin, "setWindow", () => Object.assign(launch.window, bounds)),
268
- exit: (code = 0) => duringLaunch(plugin, "exit", () => (launch.exit ??= code)),
269
+ exit: (code = 0) => {
270
+ if (launching) launch.exit ??= code;
271
+ else {
272
+ console.warn(`[akan-native] ${plugin.id}: ctx.launch.exit after the window was created quits the app`);
273
+ quit(code);
274
+ }
275
+ },
269
276
  },
270
277
  };
271
278
  contexts.set(plugin.id, ctx);