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.
- package/package.json +1 -1
- package/server/akanApp.ts +5 -3
- package/server/akanServer.ts +16 -6
- package/server/contentEncoding.ts +11 -0
- package/server/ops/appInfo.ts +3 -1
- package/server/selfExec.ts +12 -4
- package/types/server/ops/appInfo.d.ts +1 -0
- package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +17 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +8 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +2 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.d.ts +7 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +21 -6
- package/types/vendor/@akanjs/native/packages/desktop/src/plugin.d.ts +19 -5
- package/types/vendor/@akanjs/native/packages/desktop/src/relaunch.d.ts +20 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +18 -0
- package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +7 -0
- package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +7 -1
- package/types/vendor/@akanjs/native/plugins/updates/src/index.d.ts +18 -6
- package/vendor/@akanjs/native/packages/cli/src/api.ts +32 -12
- package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +9 -2
- package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +22 -5
- package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +2 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.ts +51 -26
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +198 -33
- package/vendor/@akanjs/native/packages/desktop/src/dispatcher.ts +9 -2
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +21 -9
- package/vendor/@akanjs/native/packages/desktop/src/plugin.ts +20 -5
- package/vendor/@akanjs/native/packages/desktop/src/relaunch.ts +82 -5
- package/vendor/@akanjs/native/packages/desktop/src/server.ts +124 -7
- package/vendor/@akanjs/native/plugins/app/native-plugin.json +1 -1
- package/vendor/@akanjs/native/plugins/app/src/desktop.ts +5 -0
- package/vendor/@akanjs/native/plugins/app/src/index.ts +6 -1
- package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +410 -111
- 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 =
|
|
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
|
|
100
|
+
let leftovers: string[] = [];
|
|
84
101
|
if (desktop) {
|
|
85
|
-
({ manifest,
|
|
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
|
-
|
|
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,
|
|
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;
|
|
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
|
-
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
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,
|
|
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
|
-
/**
|
|
21
|
-
const
|
|
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 }) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
!insertmacro RemoveLeftover "$INSTDIR.setup-new"
|
|
207
321
|
\${If} \${FileExists} "$INSTDIR\\\${EXE}"
|
|
208
322
|
\${OrIfNot} \${FileExists} "$INSTDIR.setup-old\\\${EXE}"
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
\${
|
|
280
|
-
|
|
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
|
-
\${
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
322
|
-
* WMI: the installer is a 32-bit program, so its PowerShell is too, and 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
|
-
/**
|
|
347
|
-
export function
|
|
348
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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);
|