@nwutils/packager 0.5.0 → 0.7.0
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/README.md +96 -5
- package/package.json +3 -3
- package/src/appImage.js +10 -87
- package/src/index.js +8 -6
- package/src/nsis.js +645 -0
- package/src/util.js +214 -6
- package/types/appImage.d.ts +22 -14
- package/types/index.d.ts +16 -5
- package/types/nsis.d.ts +150 -0
- package/types/util.d.ts +119 -36
package/src/nsis.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import child_process from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
|
|
7
|
+
import util from "./util.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} NsisOptions
|
|
11
|
+
* @property {string} appDir Path to a built NW.js Windows application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "win"`.
|
|
12
|
+
* @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the executable is expected at `<appDir>/<appName>.exe`. Must be a valid Windows file name.
|
|
13
|
+
* @property {string} version Version of the application, eg. `"1.2.0"`. Shown in Windows' installed apps list, and converted to `X.X.X.X` for the installer's own version resource.
|
|
14
|
+
* @property {"ia32" | "x64" | "arm64"} [arch] Architecture `appDir` was built for. Defaults to the host architecture. The installer refuses to run on Windows machines that can't run that architecture.
|
|
15
|
+
* @property {string} [outDir] Directory the resulting `<appName>-<arch>-Setup.exe` file is written to. Defaults to the parent directory of `appDir`.
|
|
16
|
+
* @property {string} [icon] Path to a `.ico` icon for the installer and uninstaller. Defaults to NSIS' own icon.
|
|
17
|
+
* @property {string} [appId] Name of the registry key the installation is recorded under. Defaults to `appName`. Keep it the same across releases, so installing a new version replaces the previous one.
|
|
18
|
+
* @property {string} [publisher] Publisher shown in Windows' installed apps list.
|
|
19
|
+
* @property {string} [copyright] Copyright notice written into the installer's version information.
|
|
20
|
+
* @property {string} [license] Path to a `.txt` or `.rtf` license file. When set, the installer shows it on a page the user has to accept.
|
|
21
|
+
* @property {boolean} [desktopShortcut=true] If true, create a desktop shortcut.
|
|
22
|
+
* @property {boolean} [startMenuShortcut=true] If true, create a Start Menu shortcut.
|
|
23
|
+
* @property {string} [makensisPath="makensis"] Path to the NSIS compiler. Defaults to `makensis` on the `PATH`.
|
|
24
|
+
* @property {import("./appImage.js").PublishOptions} [publish] Where releases are published. When set, an `app-update.yml` pointing at it is installed next to the executable and a `latest[-<arch>].yml` update info file is written next to the installer, for `@nwutils/updater` to self update from.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The files and directories of a built application, relative to its root.
|
|
29
|
+
* @typedef {object} AppFiles
|
|
30
|
+
* @property {string[]} files Relative file paths, using the host's path separator.
|
|
31
|
+
* @property {string[]} dirs Relative directory paths, parents before children.
|
|
32
|
+
* @property {number} size Total size of `files` in bytes.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map nw-builder's architecture identifiers to the `x64.nsh` and `WinVer.nsh`
|
|
37
|
+
* checks for whether the Windows machine running the installer can run that
|
|
38
|
+
* architecture. Each inner list is one way to pass, and all of its checks
|
|
39
|
+
* must pass. x64 builds run natively on x64 and, emulated, on ARM64 - but
|
|
40
|
+
* only from Windows 11, as ARM64 Windows 10 only emulates ia32. Every Windows
|
|
41
|
+
* machine runs ia32 builds.
|
|
42
|
+
* @type {Record<"ia32" | "x64" | "arm64", string[][]>}
|
|
43
|
+
*/
|
|
44
|
+
const ARCH_CHECK_KV = {
|
|
45
|
+
ia32: [],
|
|
46
|
+
x64: [["${IsNativeAMD64}"], ["${IsNativeARM64}", "${AtLeastWin11}"]],
|
|
47
|
+
arm64: [["${IsNativeARM64}"]],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long an update waits for the running application to exit.
|
|
52
|
+
* @type {number}
|
|
53
|
+
*/
|
|
54
|
+
const UPDATE_WAIT_SECONDS = 60;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Package a built NW.js Windows application as an NSIS installer.
|
|
58
|
+
*
|
|
59
|
+
* The installer installs for the current user only, into
|
|
60
|
+
* `%LOCALAPPDATA%\Programs\<appName>`, so it never asks for administrator
|
|
61
|
+
* rights. It registers an uninstaller in Windows' installed apps list, and
|
|
62
|
+
* installing a new version first uninstalls the previous one. Both wait for
|
|
63
|
+
* the application to be closed before touching its files.
|
|
64
|
+
*
|
|
65
|
+
* Run with `/S /UPDATE`, as `@nwutils/updater` does, the installer instead
|
|
66
|
+
* waits for the running application to exit, updates it silently and starts
|
|
67
|
+
* the new version.
|
|
68
|
+
*
|
|
69
|
+
* This composes with `@nwutils/builder`: run it against the `outDir` (and
|
|
70
|
+
* `app.name`) that `@nwutils/builder` produced for `platform: "win"`.
|
|
71
|
+
* `makensis` runs on Linux and macOS too, so the installer can be built on
|
|
72
|
+
* any host.
|
|
73
|
+
* @async
|
|
74
|
+
* @function
|
|
75
|
+
* @param {NsisOptions} options
|
|
76
|
+
* @returns {Promise<string>} Path to the resulting installer `.exe` file.
|
|
77
|
+
*/
|
|
78
|
+
async function nsis({
|
|
79
|
+
appDir,
|
|
80
|
+
appName,
|
|
81
|
+
version,
|
|
82
|
+
arch,
|
|
83
|
+
outDir,
|
|
84
|
+
icon,
|
|
85
|
+
appId,
|
|
86
|
+
publisher,
|
|
87
|
+
copyright = "",
|
|
88
|
+
license,
|
|
89
|
+
desktopShortcut = true,
|
|
90
|
+
startMenuShortcut = true,
|
|
91
|
+
makensisPath = "makensis",
|
|
92
|
+
publish,
|
|
93
|
+
}) {
|
|
94
|
+
if (typeof appDir !== "string" || appDir === "") {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Expected "options.appDir" to be a non-empty string. Received: ${JSON.stringify(appDir)}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ((await util.fileExists(appDir)) === false) {
|
|
101
|
+
throw new Error(`"options.appDir" does not exist: ${appDir}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
* `appName` names files inside `appDir`, `outDir` and the install
|
|
106
|
+
* directory, and is written into the installer script.
|
|
107
|
+
*/
|
|
108
|
+
if (
|
|
109
|
+
typeof appName !== "string" ||
|
|
110
|
+
util.isWindowsFileName(appName) === false
|
|
111
|
+
) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Expected "options.appName" to be a valid Windows file name. Received: ${JSON.stringify(appName)}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof version !== "string" || version === "") {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Expected "options.version" to be a non-empty string. Received: ${JSON.stringify(version)}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const fileVersion = util.windowsVersion(version);
|
|
123
|
+
|
|
124
|
+
const resolvedArch =
|
|
125
|
+
/** @type {"ia32" | "x64" | "arm64" | undefined} */ (arch) ??
|
|
126
|
+
/** @type {"ia32" | "x64" | "arm64"} */ (
|
|
127
|
+
/** @type {unknown} */ (process.arch)
|
|
128
|
+
);
|
|
129
|
+
/* `Object.hasOwn` so inherited keys such as "constructor" aren't mistaken for architectures. */
|
|
130
|
+
if (Object.hasOwn(ARCH_CHECK_KV, resolvedArch) === false) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Expected "options.arch" to be "ia32", "x64" or "arm64". Received: ${JSON.stringify(arch)}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const resolvedAppId = appId ?? appName;
|
|
137
|
+
/* The installation is recorded under a registry key named after `appId`, and `\` separates registry keys. */
|
|
138
|
+
if (
|
|
139
|
+
typeof resolvedAppId !== "string" ||
|
|
140
|
+
resolvedAppId === "" ||
|
|
141
|
+
resolvedAppId.includes("\\") ||
|
|
142
|
+
[...resolvedAppId].some((character) => {
|
|
143
|
+
const code = character.charCodeAt(0);
|
|
144
|
+
return code < 0x20 || code === 0x7f;
|
|
145
|
+
})
|
|
146
|
+
) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Expected "options.appId" to be a non-empty string without "\\" or control characters. Received: ${JSON.stringify(appId)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
publisher !== undefined &&
|
|
154
|
+
(typeof publisher !== "string" || publisher === "")
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Expected "options.publisher" to be a non-empty string. Received: ${JSON.stringify(publisher)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof copyright !== "string") {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Expected "options.copyright" to be a string. Received: ${JSON.stringify(copyright)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const [key, value] of Object.entries({
|
|
168
|
+
desktopShortcut,
|
|
169
|
+
startMenuShortcut,
|
|
170
|
+
})) {
|
|
171
|
+
if (typeof value !== "boolean") {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Expected "options.${key}" to be a boolean. Received: ${JSON.stringify(value)}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof makensisPath !== "string" || makensisPath === "") {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Expected "options.makensisPath" to be a non-empty string. Received: ${JSON.stringify(makensisPath)}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (publish !== undefined) {
|
|
185
|
+
util.validatePublish(publish);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const resolvedIconPath =
|
|
189
|
+
icon === undefined
|
|
190
|
+
? undefined
|
|
191
|
+
: await resolveInputFile("icon", icon, [".ico"]);
|
|
192
|
+
const resolvedLicensePath =
|
|
193
|
+
license === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: await resolveInputFile("license", license, [".txt", ".rtf"]);
|
|
196
|
+
|
|
197
|
+
const resolvedAppDir = path.resolve(appDir);
|
|
198
|
+
const executablePath = path.resolve(resolvedAppDir, `${appName}.exe`);
|
|
199
|
+
if ((await util.fileExists(executablePath)) === false) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Expected the NW.js executable to exist at ${executablePath}. Does "options.appName" match the "app.name" used to build "options.appDir" for "platform: \\"win\\""?`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const resolvedOutDir = path.resolve(outDir ?? path.dirname(resolvedAppDir));
|
|
206
|
+
await fs.promises.mkdir(resolvedOutDir, { recursive: true });
|
|
207
|
+
const installerPath = path.resolve(
|
|
208
|
+
resolvedOutDir,
|
|
209
|
+
`${appName}-${resolvedArch}-Setup.exe`,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const appFiles = await listAppFiles(resolvedAppDir);
|
|
213
|
+
|
|
214
|
+
const scriptDir = await fs.promises.mkdtemp(
|
|
215
|
+
path.join(os.tmpdir(), "nwutils-packager-nsis-"),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
/*
|
|
220
|
+
* Installed next to the NW.js executable, ie. at
|
|
221
|
+
* `path.dirname(process.execPath)` at runtime, which is where
|
|
222
|
+
* `@nwutils/updater` looks for it.
|
|
223
|
+
*/
|
|
224
|
+
let appUpdateYamlPath;
|
|
225
|
+
if (publish !== undefined) {
|
|
226
|
+
appUpdateYamlPath = path.resolve(scriptDir, "app-update.yml");
|
|
227
|
+
await fs.promises.writeFile(
|
|
228
|
+
appUpdateYamlPath,
|
|
229
|
+
util.appUpdateYaml(publish),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const script = nsisScript({
|
|
234
|
+
appDir: resolvedAppDir,
|
|
235
|
+
appFiles,
|
|
236
|
+
appName,
|
|
237
|
+
appId: resolvedAppId,
|
|
238
|
+
version,
|
|
239
|
+
fileVersion,
|
|
240
|
+
arch: resolvedArch,
|
|
241
|
+
installerPath,
|
|
242
|
+
iconPath: resolvedIconPath,
|
|
243
|
+
licensePath: resolvedLicensePath,
|
|
244
|
+
publisher,
|
|
245
|
+
copyright,
|
|
246
|
+
desktopShortcut,
|
|
247
|
+
startMenuShortcut,
|
|
248
|
+
appUpdateYamlPath,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const scriptPath = path.resolve(scriptDir, "installer.nsi");
|
|
252
|
+
await fs.promises.writeFile(scriptPath, script, "utf-8");
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
child_process.execFileSync(
|
|
256
|
+
makensisPath,
|
|
257
|
+
["-INPUTCHARSET", "UTF8", "-V2", scriptPath],
|
|
258
|
+
{ stdio: "inherit" },
|
|
259
|
+
);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Could not find the NSIS compiler at ${JSON.stringify(makensisPath)}. Install NSIS (eg. "apt install nsis", "brew install makensis" or "choco install nsis"), or pass its path as "options.makensisPath".`,
|
|
264
|
+
{ cause: error },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (publish !== undefined) {
|
|
271
|
+
await util.writeUpdateInfo({
|
|
272
|
+
filePath: installerPath,
|
|
273
|
+
updateInfoPath: path.resolve(
|
|
274
|
+
resolvedOutDir,
|
|
275
|
+
util.updateInfoFileName(resolvedArch, "win"),
|
|
276
|
+
),
|
|
277
|
+
version,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return installerPath;
|
|
282
|
+
} finally {
|
|
283
|
+
await fs.promises.rm(scriptDir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve an input file option, and throw unless it exists and has one of
|
|
289
|
+
* `extensions`.
|
|
290
|
+
* @async
|
|
291
|
+
* @function
|
|
292
|
+
* @param {string} key Option name, for error messages.
|
|
293
|
+
* @param {unknown} value Option value.
|
|
294
|
+
* @param {string[]} extensions Accepted lowercase extensions, eg. `[".ico"]`.
|
|
295
|
+
* @returns {Promise<string>} Absolute path to the file.
|
|
296
|
+
*/
|
|
297
|
+
async function resolveInputFile(key, value, extensions) {
|
|
298
|
+
if (typeof value !== "string" || value === "") {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Expected "options.${key}" to be a non-empty string. Received: ${JSON.stringify(value)}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const resolvedPath = path.resolve(value);
|
|
304
|
+
if (extensions.includes(path.extname(resolvedPath).toLowerCase()) === false) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Expected "options.${key}" to be a ${extensions.join(" or ")} file. Received: ${resolvedPath}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
if ((await util.fileExists(resolvedPath)) === false) {
|
|
310
|
+
throw new Error(`"options.${key}" does not exist: ${resolvedPath}`);
|
|
311
|
+
}
|
|
312
|
+
return resolvedPath;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* List the files and directories under `appDir`. Symbolic links are followed,
|
|
317
|
+
* since NSIS copies what they point at.
|
|
318
|
+
* @async
|
|
319
|
+
* @function
|
|
320
|
+
* @param {string} appDir Absolute path to the built application.
|
|
321
|
+
* @returns {Promise<AppFiles>}
|
|
322
|
+
*/
|
|
323
|
+
async function listAppFiles(appDir) {
|
|
324
|
+
/** @type {AppFiles} */
|
|
325
|
+
const appFiles = { files: [], dirs: [], size: 0 };
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string} relativeDir
|
|
329
|
+
* @returns {Promise<void>}
|
|
330
|
+
*/
|
|
331
|
+
async function walk(relativeDir) {
|
|
332
|
+
const entries = await fs.promises.readdir(path.join(appDir, relativeDir));
|
|
333
|
+
for (const entry of entries.sort()) {
|
|
334
|
+
const relativePath = path.join(relativeDir, entry);
|
|
335
|
+
const stats = await fs.promises.stat(path.join(appDir, relativePath));
|
|
336
|
+
if (stats.isDirectory()) {
|
|
337
|
+
appFiles.dirs.push(relativePath);
|
|
338
|
+
await walk(relativePath);
|
|
339
|
+
} else {
|
|
340
|
+
appFiles.files.push(relativePath);
|
|
341
|
+
appFiles.size += stats.size;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await walk("");
|
|
347
|
+
return appFiles;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Generate the NSIS script for an installer.
|
|
352
|
+
*
|
|
353
|
+
* Every file is listed explicitly rather than copied with `File /r`, so the
|
|
354
|
+
* uninstaller deletes exactly the files that were installed. The user can
|
|
355
|
+
* choose any install directory - including one such as `C:\` that holds
|
|
356
|
+
* other files - so the uninstaller never deletes directories recursively.
|
|
357
|
+
* @param {object} options
|
|
358
|
+
* @param {string} options.appDir Absolute path to the built application.
|
|
359
|
+
* @param {AppFiles} options.appFiles Files and directories under `appDir`.
|
|
360
|
+
* @param {string} options.appName
|
|
361
|
+
* @param {string} options.appId
|
|
362
|
+
* @param {string} options.version
|
|
363
|
+
* @param {string} options.fileVersion `version` in `X.X.X.X` form.
|
|
364
|
+
* @param {"ia32" | "x64" | "arm64"} options.arch
|
|
365
|
+
* @param {string} options.installerPath Absolute path the installer is written to.
|
|
366
|
+
* @param {string} [options.iconPath] Absolute path to a `.ico` file.
|
|
367
|
+
* @param {string} [options.licensePath] Absolute path to a `.txt` or `.rtf` file.
|
|
368
|
+
* @param {string} [options.publisher]
|
|
369
|
+
* @param {string} options.copyright
|
|
370
|
+
* @param {boolean} options.desktopShortcut
|
|
371
|
+
* @param {boolean} options.startMenuShortcut
|
|
372
|
+
* @param {string} [options.appUpdateYamlPath] Absolute path to an `app-update.yml` file to install next to the executable.
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
function nsisScript({
|
|
376
|
+
appDir,
|
|
377
|
+
appFiles,
|
|
378
|
+
appName,
|
|
379
|
+
appId,
|
|
380
|
+
version,
|
|
381
|
+
fileVersion,
|
|
382
|
+
arch,
|
|
383
|
+
installerPath,
|
|
384
|
+
iconPath,
|
|
385
|
+
licensePath,
|
|
386
|
+
publisher,
|
|
387
|
+
copyright,
|
|
388
|
+
desktopShortcut,
|
|
389
|
+
startMenuShortcut,
|
|
390
|
+
appUpdateYamlPath,
|
|
391
|
+
}) {
|
|
392
|
+
const e = util.nsisEscape;
|
|
393
|
+
const p = util.nsisPath;
|
|
394
|
+
/* Paths inside the install directory always use `\`, whichever host builds the installer. */
|
|
395
|
+
const installed = (/** @type {string} */ relativePath) =>
|
|
396
|
+
`"$INSTDIR\\${e(relativePath.split(path.sep).join("\\"))}"`;
|
|
397
|
+
const uninstallKey = `"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${e(appId)}"`;
|
|
398
|
+
const exe = installed(`${appName}.exe`);
|
|
399
|
+
const archChecks = ARCH_CHECK_KV[arch];
|
|
400
|
+
|
|
401
|
+
/** @type {string[]} */
|
|
402
|
+
const lines = [
|
|
403
|
+
"Unicode true",
|
|
404
|
+
"ManifestDPIAware true",
|
|
405
|
+
"RequestExecutionLevel user",
|
|
406
|
+
"SetCompressor /SOLID lzma",
|
|
407
|
+
"",
|
|
408
|
+
'!include "MUI2.nsh"',
|
|
409
|
+
'!include "LogicLib.nsh"',
|
|
410
|
+
'!include "x64.nsh"',
|
|
411
|
+
'!include "WinVer.nsh"',
|
|
412
|
+
'!include "FileFunc.nsh"',
|
|
413
|
+
"",
|
|
414
|
+
/* "1" when run with `/UPDATE`, ie. by `@nwutils/updater`. */
|
|
415
|
+
"Var IsUpdate",
|
|
416
|
+
"",
|
|
417
|
+
`Name "${e(appName)}"`,
|
|
418
|
+
`OutFile "${p(installerPath)}"`,
|
|
419
|
+
`InstallDir "$LOCALAPPDATA\\Programs\\${e(appName)}"`,
|
|
420
|
+
`InstallDirRegKey HKCU ${uninstallKey} "InstallLocation"`,
|
|
421
|
+
"",
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
if (iconPath !== undefined) {
|
|
425
|
+
lines.push(
|
|
426
|
+
`!define MUI_ICON "${p(iconPath)}"`,
|
|
427
|
+
`!define MUI_UNICON "${p(iconPath)}"`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
lines.push(
|
|
431
|
+
"!define MUI_ABORTWARNING",
|
|
432
|
+
`!define MUI_FINISHPAGE_RUN ${exe}`,
|
|
433
|
+
"",
|
|
434
|
+
"!insertmacro MUI_PAGE_WELCOME",
|
|
435
|
+
);
|
|
436
|
+
if (licensePath !== undefined) {
|
|
437
|
+
lines.push(`!insertmacro MUI_PAGE_LICENSE "${p(licensePath)}"`);
|
|
438
|
+
}
|
|
439
|
+
lines.push(
|
|
440
|
+
"!insertmacro MUI_PAGE_DIRECTORY",
|
|
441
|
+
"!insertmacro MUI_PAGE_INSTFILES",
|
|
442
|
+
"!insertmacro MUI_PAGE_FINISH",
|
|
443
|
+
"!insertmacro MUI_UNPAGE_CONFIRM",
|
|
444
|
+
"!insertmacro MUI_UNPAGE_INSTFILES",
|
|
445
|
+
'!insertmacro MUI_LANGUAGE "English"',
|
|
446
|
+
"",
|
|
447
|
+
`VIProductVersion "${fileVersion}"`,
|
|
448
|
+
`VIFileVersion "${fileVersion}"`,
|
|
449
|
+
`VIAddVersionKey "ProductName" "${e(appName)}"`,
|
|
450
|
+
`VIAddVersionKey "ProductVersion" "${e(version)}"`,
|
|
451
|
+
`VIAddVersionKey "FileVersion" "${e(version)}"`,
|
|
452
|
+
`VIAddVersionKey "FileDescription" "${e(appName)} Setup"`,
|
|
453
|
+
`VIAddVersionKey "LegalCopyright" "${e(copyright)}"`,
|
|
454
|
+
);
|
|
455
|
+
if (publisher !== undefined) {
|
|
456
|
+
lines.push(`VIAddVersionKey "CompanyName" "${e(publisher)}"`);
|
|
457
|
+
}
|
|
458
|
+
lines.push(
|
|
459
|
+
"",
|
|
460
|
+
"Function .onInit",
|
|
461
|
+
" SetShellVarContext current",
|
|
462
|
+
" ${GetParameters} $R0",
|
|
463
|
+
" ClearErrors",
|
|
464
|
+
' ${GetOptions} $R0 "/UPDATE" $R1',
|
|
465
|
+
" ${IfNot} ${Errors}",
|
|
466
|
+
' StrCpy $IsUpdate "1"',
|
|
467
|
+
" ${EndIf}",
|
|
468
|
+
);
|
|
469
|
+
if (archChecks.length > 0) {
|
|
470
|
+
/* Passing branches are left empty: only the final `${Else}` does anything. */
|
|
471
|
+
lines.push(
|
|
472
|
+
...archChecks.flatMap((checks, index) =>
|
|
473
|
+
checks.map(
|
|
474
|
+
(check, checkIndex) =>
|
|
475
|
+
` \${${checkIndex > 0 ? "AndIf" : index === 0 ? "If" : "ElseIf"}} ${check}`,
|
|
476
|
+
),
|
|
477
|
+
),
|
|
478
|
+
" ${Else}",
|
|
479
|
+
` MessageBox MB_ICONSTOP "This installer is for ${arch} Windows, which this computer can't run." /SD IDOK`,
|
|
480
|
+
" Abort",
|
|
481
|
+
" ${EndIf}",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
lines.push(
|
|
485
|
+
"FunctionEnd",
|
|
486
|
+
"",
|
|
487
|
+
'Section "Install"',
|
|
488
|
+
` ReadRegStr $0 HKCU ${uninstallKey} "InstallLocation"`,
|
|
489
|
+
' ${If} $0 != ""',
|
|
490
|
+
...waitForAppToClose(`"$0\\${e(appName)}.exe"`, appName, " "),
|
|
491
|
+
" ${EndIf}",
|
|
492
|
+
...waitForAppToClose(exe, appName, " "),
|
|
493
|
+
/*
|
|
494
|
+
* Uninstall the previous version first, so files it had that this
|
|
495
|
+
* version doesn't are removed. `_?=` runs its uninstaller in place and
|
|
496
|
+
* waits for it, which leaves the uninstaller itself behind.
|
|
497
|
+
*/
|
|
498
|
+
' ${If} $0 != ""',
|
|
499
|
+
' ${AndIf} ${FileExists} "$0\\Uninstall.exe"',
|
|
500
|
+
" ClearErrors",
|
|
501
|
+
` ExecWait '"$0\\Uninstall.exe" /S _?=$0' $1`,
|
|
502
|
+
" ${If} ${Errors}",
|
|
503
|
+
" ${OrIf} $1 != 0",
|
|
504
|
+
` MessageBox MB_ICONSTOP "The previous version of ${e(appName)} couldn't be uninstalled." /SD IDOK`,
|
|
505
|
+
" Abort",
|
|
506
|
+
" ${EndIf}",
|
|
507
|
+
' Delete "$0\\Uninstall.exe"',
|
|
508
|
+
' RMDir "$0"',
|
|
509
|
+
" ${EndIf}",
|
|
510
|
+
"",
|
|
511
|
+
' SetOutPath "$INSTDIR"',
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
/* Group files by directory: `File` writes into the current `SetOutPath`. */
|
|
515
|
+
/** @type {Map<string, string[]>} */
|
|
516
|
+
const filesByDir = new Map([["", []]]);
|
|
517
|
+
for (const dir of appFiles.dirs) {
|
|
518
|
+
filesByDir.set(dir, []);
|
|
519
|
+
}
|
|
520
|
+
for (const file of appFiles.files) {
|
|
521
|
+
/** @type {string[]} */ (
|
|
522
|
+
filesByDir.get(path.dirname(file).replace(/^\.$/, ""))
|
|
523
|
+
).push(file);
|
|
524
|
+
}
|
|
525
|
+
for (const [dir, files] of filesByDir) {
|
|
526
|
+
if (dir !== "") {
|
|
527
|
+
lines.push(` SetOutPath ${installed(dir)}`);
|
|
528
|
+
}
|
|
529
|
+
for (const file of files) {
|
|
530
|
+
lines.push(` File "${p(path.join(appDir, file))}"`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
lines.push(' SetOutPath "$INSTDIR"');
|
|
535
|
+
if (appUpdateYamlPath !== undefined) {
|
|
536
|
+
lines.push(` File "/oname=app-update.yml" "${p(appUpdateYamlPath)}"`);
|
|
537
|
+
}
|
|
538
|
+
lines.push(' WriteUninstaller "$INSTDIR\\Uninstall.exe"', "");
|
|
539
|
+
if (startMenuShortcut) {
|
|
540
|
+
lines.push(` CreateShortcut "$SMPROGRAMS\\${e(appName)}.lnk" ${exe}`);
|
|
541
|
+
}
|
|
542
|
+
if (desktopShortcut) {
|
|
543
|
+
lines.push(` CreateShortcut "$DESKTOP\\${e(appName)}.lnk" ${exe}`);
|
|
544
|
+
}
|
|
545
|
+
lines.push(
|
|
546
|
+
"",
|
|
547
|
+
` WriteRegStr HKCU ${uninstallKey} "DisplayName" "${e(appName)}"`,
|
|
548
|
+
` WriteRegStr HKCU ${uninstallKey} "DisplayVersion" "${e(version)}"`,
|
|
549
|
+
` WriteRegStr HKCU ${uninstallKey} "DisplayIcon" ${exe}`,
|
|
550
|
+
` WriteRegStr HKCU ${uninstallKey} "InstallLocation" "$INSTDIR"`,
|
|
551
|
+
` WriteRegStr HKCU ${uninstallKey} "UninstallString" '"$INSTDIR\\Uninstall.exe"'`,
|
|
552
|
+
` WriteRegStr HKCU ${uninstallKey} "QuietUninstallString" '"$INSTDIR\\Uninstall.exe" /S'`,
|
|
553
|
+
` WriteRegDWORD HKCU ${uninstallKey} "EstimatedSize" ${Math.ceil(appFiles.size / 1024)}`,
|
|
554
|
+
` WriteRegDWORD HKCU ${uninstallKey} "NoModify" 1`,
|
|
555
|
+
` WriteRegDWORD HKCU ${uninstallKey} "NoRepair" 1`,
|
|
556
|
+
);
|
|
557
|
+
if (publisher !== undefined) {
|
|
558
|
+
lines.push(
|
|
559
|
+
` WriteRegStr HKCU ${uninstallKey} "Publisher" "${e(publisher)}"`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
lines.push(
|
|
563
|
+
"",
|
|
564
|
+
/* An update runs silently, so the finish page's "Run" checkbox never shows. */
|
|
565
|
+
' ${If} $IsUpdate == "1"',
|
|
566
|
+
` Exec '${exe}'`,
|
|
567
|
+
" ${EndIf}",
|
|
568
|
+
"SectionEnd",
|
|
569
|
+
"",
|
|
570
|
+
"Function un.onInit",
|
|
571
|
+
" SetShellVarContext current",
|
|
572
|
+
"FunctionEnd",
|
|
573
|
+
"",
|
|
574
|
+
'Section "Uninstall"',
|
|
575
|
+
...waitForAppToClose(exe, appName, " "),
|
|
576
|
+
);
|
|
577
|
+
for (const file of appFiles.files) {
|
|
578
|
+
lines.push(` Delete ${installed(file)}`);
|
|
579
|
+
}
|
|
580
|
+
/* Children before parents, and `RMDir` without `/r` only removes empty directories. */
|
|
581
|
+
for (const dir of [...appFiles.dirs].reverse()) {
|
|
582
|
+
lines.push(` RMDir ${installed(dir)}`);
|
|
583
|
+
}
|
|
584
|
+
if (appUpdateYamlPath !== undefined) {
|
|
585
|
+
lines.push(' Delete "$INSTDIR\\app-update.yml"');
|
|
586
|
+
}
|
|
587
|
+
lines.push(
|
|
588
|
+
' Delete "$INSTDIR\\Uninstall.exe"',
|
|
589
|
+
' RMDir "$INSTDIR"',
|
|
590
|
+
` Delete "$SMPROGRAMS\\${e(appName)}.lnk"`,
|
|
591
|
+
` Delete "$DESKTOP\\${e(appName)}.lnk"`,
|
|
592
|
+
` DeleteRegKey HKCU ${uninstallKey}`,
|
|
593
|
+
"SectionEnd",
|
|
594
|
+
"",
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
return lines.join("\n");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Generate NSIS lines that wait until the application isn't running, asking
|
|
602
|
+
* the user to close it, so its files can be replaced or deleted. Windows
|
|
603
|
+
* refuses to open a running executable for writing, which is how a running
|
|
604
|
+
* copy is detected. Cancelling - which silent mode does straight away -
|
|
605
|
+
* aborts the installer or uninstaller.
|
|
606
|
+
*
|
|
607
|
+
* During an update (`$IsUpdate` is "1") nobody is there to answer the prompt:
|
|
608
|
+
* `@nwutils/updater` starts the installer and then quits the application, so
|
|
609
|
+
* it waits for up to `UPDATE_WAIT_SECONDS` instead, then aborts. Uses `$2`
|
|
610
|
+
* and `$3`.
|
|
611
|
+
* @param {string} exePath Quoted NSIS string with the executable's path, eg. `"$INSTDIR\Demo.exe"`.
|
|
612
|
+
* @param {string} appName Application name, shown in the prompt.
|
|
613
|
+
* @param {string} indent Indentation of the generated lines.
|
|
614
|
+
* @returns {string[]}
|
|
615
|
+
*/
|
|
616
|
+
function waitForAppToClose(exePath, appName, indent) {
|
|
617
|
+
return [
|
|
618
|
+
"StrCpy $3 0",
|
|
619
|
+
"${Do}",
|
|
620
|
+
` \${IfNot} \${FileExists} ${exePath}`,
|
|
621
|
+
" ${Break}",
|
|
622
|
+
" ${EndIf}",
|
|
623
|
+
" ClearErrors",
|
|
624
|
+
` FileOpen $2 ${exePath} a`,
|
|
625
|
+
" ${IfNot} ${Errors}",
|
|
626
|
+
" FileClose $2",
|
|
627
|
+
" ${Break}",
|
|
628
|
+
" ${EndIf}",
|
|
629
|
+
' ${If} $IsUpdate == "1"',
|
|
630
|
+
" IntOp $3 $3 + 1",
|
|
631
|
+
` \${If} $3 > ${UPDATE_WAIT_SECONDS * 2}`,
|
|
632
|
+
" Abort",
|
|
633
|
+
" ${EndIf}",
|
|
634
|
+
" Sleep 500",
|
|
635
|
+
" ${Continue}",
|
|
636
|
+
" ${EndIf}",
|
|
637
|
+
` \${If} \${Cmd} \`MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Close ${util.nsisEscape(appName)}, then select Retry." /SD IDCANCEL IDCANCEL\``,
|
|
638
|
+
" Abort",
|
|
639
|
+
" ${EndIf}",
|
|
640
|
+
"${Loop}",
|
|
641
|
+
].map((line) => `${indent}${line}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export default nsis;
|
|
645
|
+
export { listAppFiles, nsisScript };
|