@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/src/util.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
3
4
 
4
5
  /**
5
6
  * Map nw-builder's architecture identifiers to the identifiers used in
@@ -114,14 +115,17 @@ function desktopExecArg(value) {
114
115
  }
115
116
 
116
117
  /**
117
- * Name of the update info file `@nwutils/updater` downloads to find the
118
- * latest release, following electron-builder's naming: `x64` gets the bare
119
- * `latest-linux.yml`, every other architecture gets an `-<arch>` suffix.
120
- * @param {"ia32" | "x64" | "arm64"} arch
118
+ * Name of the update info file `@nwutils/updater` downloads for `arch` on
119
+ * `platform`, matching electron-builder: `x64` gets the bare
120
+ * `latest-linux.yml` (Linux) or `latest.yml` (Windows), every other
121
+ * architecture gets an `-<arch>` suffix.
122
+ * @param {string} arch
123
+ * @param {"linux" | "win"} [platform="linux"]
121
124
  * @returns {string}
122
125
  */
123
- function updateInfoFileName(arch) {
124
- return arch === "x64" ? "latest-linux.yml" : `latest-linux-${arch}.yml`;
126
+ function updateInfoFileName(arch, platform = "linux") {
127
+ const base = platform === "win" ? "latest" : "latest-linux";
128
+ return arch === "x64" ? `${base}.yml` : `${base}-${arch}.yml`;
125
129
  }
126
130
 
127
131
  /**
@@ -180,14 +184,218 @@ async function sha512(filePath) {
180
184
  return hash.digest("base64");
181
185
  }
182
186
 
187
+ /**
188
+ * Escape `value` for use inside a double quoted NSIS string. NSIS expands `$`
189
+ * variables and `$\` escapes there, so `$` is doubled and `"` is escaped.
190
+ * Line breaks are rejected rather than escaped: a raw one would end the
191
+ * command and let the value inject further script lines. The result is not
192
+ * quoted, so it can be joined with NSIS variables, eg. `"$INSTDIR\<value>"`.
193
+ * @param {string} value
194
+ * @returns {string}
195
+ */
196
+ function nsisEscape(value) {
197
+ if (/[\r\n]/.test(value)) {
198
+ throw new Error(
199
+ `NSIS strings must not contain line breaks. Received: ${JSON.stringify(value)}`,
200
+ );
201
+ }
202
+ /* In a replacement string `$$` is a literal `$`, so "$$$$" writes "$$". */
203
+ return value.replaceAll("$", "$$$$").replaceAll('"', '$\\"');
204
+ }
205
+
206
+ /**
207
+ * Check a path on the host for use inside a double quoted NSIS string that
208
+ * `makensis` reads at compile time, eg. a `File` source or `OutFile`. Unlike
209
+ * runtime strings, those keep `$` literally, so nothing is escaped. Instead,
210
+ * the sequences `makensis` would still interpret are rejected: `${define}`
211
+ * expansions and `$\` escapes, and `"` - an escaped `$\"` doesn't survive
212
+ * being passed through a `!define`, as the icon and license paths are.
213
+ * @param {string} value
214
+ * @returns {string} `value`, unchanged.
215
+ */
216
+ function nsisPath(value) {
217
+ if (/[\r\n"]/.test(value) || value.includes("${") || value.includes("$\\")) {
218
+ throw new Error(
219
+ `Paths passed to NSIS must not contain line breaks, '"', "\${" or "$\\". Received: ${JSON.stringify(value)}`,
220
+ );
221
+ }
222
+ return value;
223
+ }
224
+
225
+ /**
226
+ * Names Windows reserves for devices, with or without an extension.
227
+ * @type {RegExp}
228
+ */
229
+ const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
230
+
231
+ /**
232
+ * Check that `value` can be used as a single Windows file name: no path
233
+ * separators, characters Windows forbids in file names, control characters,
234
+ * trailing dots or spaces, or reserved device names.
235
+ * @param {string} value
236
+ * @returns {boolean}
237
+ */
238
+ function isWindowsFileName(value) {
239
+ const hasControlCharacter = [...value].some((character) => {
240
+ const code = character.charCodeAt(0);
241
+ return code < 0x20 || code === 0x7f;
242
+ });
243
+ return (
244
+ value !== "" &&
245
+ /[<>:"/\\|?*]/.test(value) === false &&
246
+ hasControlCharacter === false &&
247
+ /[. ]$/.test(value) === false &&
248
+ WINDOWS_RESERVED_NAME.test(value) === false
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Convert a version such as `"1.2.3"` or `"1.2.3-beta.1"` into the
254
+ * `X.X.X.X` form Windows version resources (and NSIS' `VIProductVersion`)
255
+ * require. Up to four leading numeric parts are kept, missing ones are
256
+ * filled with `0`, and any prerelease or build suffix is dropped.
257
+ * @param {string} version
258
+ * @returns {string}
259
+ */
260
+ function windowsVersion(version) {
261
+ const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?/);
262
+ if (match === null) {
263
+ throw new Error(
264
+ `Expected a version starting with a number, eg. "1.2.3". Received: ${JSON.stringify(version)}`,
265
+ );
266
+ }
267
+ const parts = match.slice(1).map((part) => Number(part ?? 0));
268
+ if (parts.some((part) => part > 65535)) {
269
+ throw new Error(
270
+ `Expected each part of the version to be at most 65535. Received: ${JSON.stringify(version)}`,
271
+ );
272
+ }
273
+ return parts.join(".");
274
+ }
275
+
276
+ /**
277
+ * Throw if `publish` isn't a valid `PublishOptions` object.
278
+ * @param {unknown} publish
279
+ * @returns {void}
280
+ */
281
+ function validatePublish(publish) {
282
+ if (typeof publish !== "object" || publish === null) {
283
+ throw new Error(
284
+ `Expected "options.publish" to be an object. Received: ${JSON.stringify(publish)}`,
285
+ );
286
+ }
287
+
288
+ const { provider } = /** @type {{provider?: unknown}} */ (publish);
289
+ /** @type {string[]} */
290
+ let requiredKeys;
291
+ if (provider === "github") {
292
+ requiredKeys = ["owner", "repo"];
293
+ } else if (provider === "generic") {
294
+ requiredKeys = ["url"];
295
+ } else {
296
+ throw new Error(
297
+ `Expected "options.publish.provider" to be "github" or "generic". Received: ${JSON.stringify(provider)}`,
298
+ );
299
+ }
300
+
301
+ for (const key of requiredKeys) {
302
+ const value = /** @type {Record<string, unknown>} */ (publish)[key];
303
+ if (typeof value !== "string" || value === "") {
304
+ throw new Error(
305
+ `Expected "options.publish.${key}" to be a non-empty string for provider "${provider}". Received: ${JSON.stringify(value)}`,
306
+ );
307
+ }
308
+ }
309
+
310
+ if (provider === "github") {
311
+ /*
312
+ * The characters GitHub allows in owner and repository names. Anything
313
+ * else - eg. "|", the update information's field separator - is rejected.
314
+ */
315
+ for (const key of requiredKeys) {
316
+ const value = /** @type {Record<string, string>} */ (publish)[key];
317
+ if (/^[A-Za-z0-9._-]+$/.test(value) === false) {
318
+ throw new Error(
319
+ `Expected "options.publish.${key}" to be a GitHub ${key} name (letters, digits, ".", "-" and "_"). Received: ${JSON.stringify(value)}`,
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ if (provider === "generic") {
326
+ const { url } =
327
+ /** @type {import("./appImage.js").GenericPublishOptions} */ (publish);
328
+ let protocol;
329
+ try {
330
+ protocol = new URL(url).protocol;
331
+ } catch {
332
+ protocol = undefined;
333
+ }
334
+ if (protocol !== "https:" && protocol !== "http:") {
335
+ throw new Error(
336
+ `Expected "options.publish.url" to be an http(s) URL. Received: ${JSON.stringify(url)}`,
337
+ );
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Contents of the `app-update.yml` file `@nwutils/updater` reads to find out
344
+ * where releases are published. It is shipped next to the NW.js executable.
345
+ * @param {import("./appImage.js").PublishOptions} publish
346
+ * @returns {string}
347
+ */
348
+ function appUpdateYaml(publish) {
349
+ return stringifyYaml(
350
+ publish.provider === "github"
351
+ ? { provider: publish.provider, owner: publish.owner, repo: publish.repo }
352
+ : { provider: publish.provider, url: publish.url },
353
+ );
354
+ }
355
+
356
+ /**
357
+ * Write the update info file `@nwutils/updater` compares the running version
358
+ * against, describing the packaged artifact at `filePath`. It must be uploaded
359
+ * to every release next to the artifact.
360
+ * @async
361
+ * @function
362
+ * @param {object} options
363
+ * @param {string} options.filePath Path to the packaged artifact, eg. the AppImage or installer.
364
+ * @param {string} options.updateInfoPath Path the update info file is written to.
365
+ * @param {string} options.version Version of the application.
366
+ * @returns {Promise<void>}
367
+ */
368
+ async function writeUpdateInfo({ filePath, updateInfoPath, version }) {
369
+ const fileName = path.basename(filePath);
370
+ const sha512Digest = await sha512(filePath);
371
+ const { size } = await fs.promises.stat(filePath);
372
+ await fs.promises.writeFile(
373
+ updateInfoPath,
374
+ stringifyYaml({
375
+ version,
376
+ files: [{ url: fileName, sha512: sha512Digest, size }],
377
+ path: fileName,
378
+ sha512: sha512Digest,
379
+ releaseDate: new Date().toISOString(),
380
+ }),
381
+ );
382
+ }
383
+
183
384
  export default {
184
385
  APPIMAGE_ARCH_KV,
386
+ appUpdateYaml,
185
387
  desktopExecArg,
186
388
  fileExists,
389
+ isWindowsFileName,
390
+ nsisEscape,
391
+ nsisPath,
187
392
  parseDesktopEntry,
188
393
  sha512,
189
394
  shellQuote,
190
395
  stringifyDesktopEntry,
191
396
  stringifyYaml,
192
397
  updateInfoFileName,
398
+ validatePublish,
399
+ windowsVersion,
400
+ writeUpdateInfo,
193
401
  };
@@ -1,3 +1,4 @@
1
+ export default appImage;
1
2
  export type AppImageOptions = {
2
3
  /**
3
4
  * Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"`.
@@ -10,48 +11,53 @@ export type AppImageOptions = {
10
11
  /**
11
12
  * Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop`.
12
13
  */
13
- icon?: string;
14
+ icon?: string | undefined;
14
15
  /**
15
16
  * Target architecture. Defaults to the host architecture. `appimagetool` runs as a native binary, so this must match the host unless the host has emulation (eg. QEMU/binfmt) configured for the target architecture.
16
17
  */
17
- arch?: "ia32" | "x64" | "arm64";
18
+ arch?: "ia32" | "x64" | "arm64" | undefined;
18
19
  /**
19
20
  * Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir`.
20
21
  */
21
- outDir?: string;
22
+ outDir?: string | undefined;
22
23
  /**
23
24
  * Directory used to cache the downloaded `appimagetool` binary.
24
25
  */
25
- cacheDir?: string;
26
+ cacheDir?: string | undefined;
26
27
  /**
27
28
  * If true, reuse a cached `appimagetool` binary. Otherwise redownload it.
28
29
  */
29
- cache?: boolean;
30
+ cache?: boolean | undefined;
30
31
  /**
31
32
  * Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release. Must be https, except for localhost.
32
33
  */
33
- appImageToolUrl?: string;
34
+ appImageToolUrl?: string | undefined;
34
35
  /**
35
36
  * If true, embed a GPG signature in the AppImage. Requires `gpg` and a usable secret key on the host. A passphrase-protected key reads its passphrase from the `APPIMAGETOOL_SIGN_PASSPHRASE` environment variable.
36
37
  */
37
- sign?: boolean;
38
+ sign?: boolean | undefined;
38
39
  /**
39
40
  * ID of the GPG key to sign with. Defaults to `gpg`'s default secret key. Requires `sign` to be true.
40
41
  */
41
- signKey?: string;
42
+ signKey?: string | undefined;
42
43
  /**
43
44
  * Version of the application, eg. `"1.2.0"`. Required when `publish` is set: it is written to the update info file `@nwutils/updater` compares against.
44
45
  */
45
- version?: string;
46
+ version?: string | undefined;
46
47
  /**
47
48
  * Where releases are published. When set, an `app-update.yml` pointing at it is embedded in the AppImage and a `latest-linux[-<arch>].yml` update info file is written next to it, for `@nwutils/updater` to self update from.
48
49
  */
49
- publish?: PublishOptions;
50
+ publish?: PublishOptions | undefined;
50
51
  /**
51
52
  * AppImage update information for zsync based updaters (eg. AppImageUpdate, `appimageupdatetool`, AppImage managers). `true` derives it from `publish`. A string is embedded as-is, eg. `"gh-releases-zsync|owner|repo|latest|Demo-x86_64.AppImage.zsync"`. Requires `zsyncmake` on the host to produce the `.zsync` file.
52
53
  */
53
- updateInformation?: string | boolean;
54
+ updateInformation?: string | boolean | undefined;
54
55
  };
56
+ /**
57
+ * Publish to a GitHub repository's releases. The AppImage and update info
58
+ * file must be uploaded as assets of the latest (non draft, non prerelease)
59
+ * release.
60
+ */
55
61
  export type GitHubPublishOptions = {
56
62
  provider: "github";
57
63
  /**
@@ -63,6 +69,10 @@ export type GitHubPublishOptions = {
63
69
  */
64
70
  repo: string;
65
71
  };
72
+ /**
73
+ * Publish to any static file server. The AppImage and update info file must
74
+ * be served from `url`.
75
+ */
66
76
  export type GenericPublishOptions = {
67
77
  provider: "generic";
68
78
  /**
@@ -128,11 +138,9 @@ declare function appImage({ appDir, appName, icon, arch, outDir, cacheDir, cache
128
138
  * @param {boolean} options.cache If true, reuse a cached binary. Otherwise redownload it.
129
139
  * @returns {Promise<string>} Path to an executable `appimagetool` binary.
130
140
  */
131
- declare function getAppImageTool({ appImageToolArch, appImageToolUrl, cacheDir, cache, }: {
141
+ export function getAppImageTool({ appImageToolArch, appImageToolUrl, cacheDir, cache, }: {
132
142
  appImageToolArch: string;
133
143
  appImageToolUrl: string;
134
144
  cacheDir: string;
135
145
  cache: boolean;
136
146
  }): Promise<string>;
137
- export default appImage;
138
- export { getAppImageTool };
package/types/index.d.ts CHANGED
@@ -1,12 +1,24 @@
1
+ export default packager;
2
+ /**
3
+ * Supported packaged output formats.
4
+ *
5
+ * `AppImage`, `deb` and `rpm` package a Linux build. `MSIX` and `NSIS`
6
+ * package a Windows build. `"AppImage"` and `"nsis"` are implemented today -
7
+ * the others are reserved so this option doesn't need to change shape once
8
+ * they land.
9
+ */
1
10
  export type PackageFormat = "AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg";
11
+ /**
12
+ * Where releases are published, for packagers that support self updating.
13
+ */
2
14
  export type PublishOptions = import("./appImage.js").PublishOptions;
3
15
  /**
4
16
  * Supported packaged output formats.
5
17
  *
6
18
  * `AppImage`, `deb` and `rpm` package a Linux build. `MSIX` and `NSIS`
7
- * package a Windows build. Only `"AppImage"` is implemented today - the
8
- * others are reserved so this option doesn't need to change shape once they
9
- * land.
19
+ * package a Windows build. `"AppImage"` and `"nsis"` are implemented today -
20
+ * the others are reserved so this option doesn't need to change shape once
21
+ * they land.
10
22
  * @typedef {"AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg"} PackageFormat
11
23
  */
12
24
  /**
@@ -17,10 +29,9 @@ export type PublishOptions = import("./appImage.js").PublishOptions;
17
29
  * Package a built NW.js application.
18
30
  * @async
19
31
  * @function
20
- * @param {{format: PackageFormat} & Record<string, any>} options `format`, plus the options the matching packager (eg. `appImage`) expects.
32
+ * @param {{format: PackageFormat} & Record<string, any>} options `format`, plus the options the matching packager (eg. `appImage` or `nsis`) expects.
21
33
  * @returns {Promise<string>} Path to the resulting packaged artifact.
22
34
  */
23
35
  declare function packager({ format, ...options }: {
24
36
  format: PackageFormat;
25
37
  } & Record<string, any>): Promise<string>;
26
- export default packager;
@@ -0,0 +1,150 @@
1
+ export default nsis;
2
+ export type NsisOptions = {
3
+ /**
4
+ * Path to a built NW.js Windows application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "win"`.
5
+ */
6
+ appDir: string;
7
+ /**
8
+ * 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.
9
+ */
10
+ appName: string;
11
+ /**
12
+ * 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.
13
+ */
14
+ version: string;
15
+ /**
16
+ * Architecture `appDir` was built for. Defaults to the host architecture. The installer refuses to run on Windows machines that can't run that architecture.
17
+ */
18
+ arch?: "ia32" | "x64" | "arm64" | undefined;
19
+ /**
20
+ * Directory the resulting `<appName>-<arch>-Setup.exe` file is written to. Defaults to the parent directory of `appDir`.
21
+ */
22
+ outDir?: string | undefined;
23
+ /**
24
+ * Path to a `.ico` icon for the installer and uninstaller. Defaults to NSIS' own icon.
25
+ */
26
+ icon?: string | undefined;
27
+ /**
28
+ * 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.
29
+ */
30
+ appId?: string | undefined;
31
+ /**
32
+ * Publisher shown in Windows' installed apps list.
33
+ */
34
+ publisher?: string | undefined;
35
+ /**
36
+ * Copyright notice written into the installer's version information.
37
+ */
38
+ copyright?: string | undefined;
39
+ /**
40
+ * Path to a `.txt` or `.rtf` license file. When set, the installer shows it on a page the user has to accept.
41
+ */
42
+ license?: string | undefined;
43
+ /**
44
+ * If true, create a desktop shortcut.
45
+ */
46
+ desktopShortcut?: boolean | undefined;
47
+ /**
48
+ * If true, create a Start Menu shortcut.
49
+ */
50
+ startMenuShortcut?: boolean | undefined;
51
+ /**
52
+ * Path to the NSIS compiler. Defaults to `makensis` on the `PATH`.
53
+ */
54
+ makensisPath?: string | undefined;
55
+ /**
56
+ * 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.
57
+ */
58
+ publish?: import("./appImage.js").PublishOptions | undefined;
59
+ };
60
+ /**
61
+ * The files and directories of a built application, relative to its root.
62
+ */
63
+ export type AppFiles = {
64
+ /**
65
+ * Relative file paths, using the host's path separator.
66
+ */
67
+ files: string[];
68
+ /**
69
+ * Relative directory paths, parents before children.
70
+ */
71
+ dirs: string[];
72
+ /**
73
+ * Total size of `files` in bytes.
74
+ */
75
+ size: number;
76
+ };
77
+ /**
78
+ * Package a built NW.js Windows application as an NSIS installer.
79
+ *
80
+ * The installer installs for the current user only, into
81
+ * `%LOCALAPPDATA%\Programs\<appName>`, so it never asks for administrator
82
+ * rights. It registers an uninstaller in Windows' installed apps list, and
83
+ * installing a new version first uninstalls the previous one. Both wait for
84
+ * the application to be closed before touching its files.
85
+ *
86
+ * Run with `/S /UPDATE`, as `@nwutils/updater` does, the installer instead
87
+ * waits for the running application to exit, updates it silently and starts
88
+ * the new version.
89
+ *
90
+ * This composes with `@nwutils/builder`: run it against the `outDir` (and
91
+ * `app.name`) that `@nwutils/builder` produced for `platform: "win"`.
92
+ * `makensis` runs on Linux and macOS too, so the installer can be built on
93
+ * any host.
94
+ * @async
95
+ * @function
96
+ * @param {NsisOptions} options
97
+ * @returns {Promise<string>} Path to the resulting installer `.exe` file.
98
+ */
99
+ declare function nsis({ appDir, appName, version, arch, outDir, icon, appId, publisher, copyright, license, desktopShortcut, startMenuShortcut, makensisPath, publish, }: NsisOptions): Promise<string>;
100
+ /**
101
+ * List the files and directories under `appDir`. Symbolic links are followed,
102
+ * since NSIS copies what they point at.
103
+ * @async
104
+ * @function
105
+ * @param {string} appDir Absolute path to the built application.
106
+ * @returns {Promise<AppFiles>}
107
+ */
108
+ export function listAppFiles(appDir: string): Promise<AppFiles>;
109
+ /**
110
+ * Generate the NSIS script for an installer.
111
+ *
112
+ * Every file is listed explicitly rather than copied with `File /r`, so the
113
+ * uninstaller deletes exactly the files that were installed. The user can
114
+ * choose any install directory - including one such as `C:\` that holds
115
+ * other files - so the uninstaller never deletes directories recursively.
116
+ * @param {object} options
117
+ * @param {string} options.appDir Absolute path to the built application.
118
+ * @param {AppFiles} options.appFiles Files and directories under `appDir`.
119
+ * @param {string} options.appName
120
+ * @param {string} options.appId
121
+ * @param {string} options.version
122
+ * @param {string} options.fileVersion `version` in `X.X.X.X` form.
123
+ * @param {"ia32" | "x64" | "arm64"} options.arch
124
+ * @param {string} options.installerPath Absolute path the installer is written to.
125
+ * @param {string} [options.iconPath] Absolute path to a `.ico` file.
126
+ * @param {string} [options.licensePath] Absolute path to a `.txt` or `.rtf` file.
127
+ * @param {string} [options.publisher]
128
+ * @param {string} options.copyright
129
+ * @param {boolean} options.desktopShortcut
130
+ * @param {boolean} options.startMenuShortcut
131
+ * @param {string} [options.appUpdateYamlPath] Absolute path to an `app-update.yml` file to install next to the executable.
132
+ * @returns {string}
133
+ */
134
+ export function nsisScript({ appDir, appFiles, appName, appId, version, fileVersion, arch, installerPath, iconPath, licensePath, publisher, copyright, desktopShortcut, startMenuShortcut, appUpdateYamlPath, }: {
135
+ appDir: string;
136
+ appFiles: AppFiles;
137
+ appName: string;
138
+ appId: string;
139
+ version: string;
140
+ fileVersion: string;
141
+ arch: "ia32" | "x64" | "arm64";
142
+ installerPath: string;
143
+ iconPath?: string | undefined;
144
+ licensePath?: string | undefined;
145
+ publisher?: string | undefined;
146
+ copyright: string;
147
+ desktopShortcut: boolean;
148
+ startMenuShortcut: boolean;
149
+ appUpdateYamlPath?: string | undefined;
150
+ }): string;