@nwutils/packager 0.4.0 → 0.6.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 CHANGED
@@ -12,7 +12,7 @@ Supported output formats, selected via `format`:
12
12
  | `deb` | Linux | Planned |
13
13
  | `rpm` | Linux | Planned |
14
14
  | `msix` | Windows | Planned |
15
- | `nsis` | Windows | Planned |
15
+ | `nsis` | Windows | Implemented |
16
16
  | `dmg` | MacOS | Planned |
17
17
 
18
18
  ## Getting Started
@@ -21,8 +21,7 @@ Supported output formats, selected via `format`:
21
21
 
22
22
  ## Usage
23
23
 
24
- This composes with `@nwutils/builder`: run it against the `outDir` (and `app.name`)
25
- that `@nwutils/builder` produced.
24
+ Build the application.
26
25
 
27
26
  ```js
28
27
  import build from "@nwutils/builder";
@@ -41,42 +40,117 @@ await build({
41
40
  categories: "Utility;",
42
41
  },
43
42
  });
43
+ ```
44
+
45
+ ### AppImage
44
46
 
47
+ ```js
45
48
  const appImagePath = await packager({
46
49
  format: "AppImage",
47
50
  appDir: "./out",
48
51
  appName: "Demo",
49
52
  cacheDir: "./cache",
50
53
  outDir: "./dist",
54
+ sign: true,
55
+ signKey: "ABCDEF1234567890",
51
56
  });
52
57
 
53
58
  console.log(`AppImage written to ${appImagePath}`);
54
59
  ```
55
60
 
56
- `packager({ format, ...options })` dispatches to the packager for `format` -
57
- `options` is whatever that packager expects (see `appImage(options)` below).
58
- Passing a `format` that isn't `"AppImage"` yet throws a clear "not implemented
59
- yet" error rather than doing nothing. `appImage` is also exported directly, if
60
- you'd rather skip the dispatch and know you only ever target AppImage.
61
+ Signing needs `gpg` installed on the host and the secret key imported into its
62
+ keyring. If the key is protected by a passphrase, set it in the
63
+ `APPIMAGETOOL_SIGN_PASSPHRASE` environment variable
64
+
65
+ ### Self updating AppImages
66
+
67
+ Set `publish` to where releases are uploaded, and `version` to the app's
68
+ version. This follows electron-builder's approach:
69
+
70
+ - an `app-update.yml` describing `publish` is embedded next to the NW.js
71
+ executable inside the AppImage, so the running app knows where to look
72
+ - a `latest-linux.yml` update info file (`latest-linux-<arch>.yml` for
73
+ architectures other than `x64`) with the version, file name, size and SHA-512
74
+ of the AppImage is written next to it in `outDir`
75
+
76
+ Upload both the AppImage and the update info file to every release.
77
+ [`@nwutils/updater`](https://www.npmjs.com/package/@nwutils/updater) reads them
78
+ at runtime to check for, download and install updates.
79
+
80
+ ```js
81
+ await packager({
82
+ format: "AppImage",
83
+ appDir: "./out",
84
+ appName: "Demo",
85
+ outDir: "./dist",
86
+ version: "1.2.0",
87
+ publish: { provider: "github", owner: "nwutils", repo: "demo" },
88
+ // Optional: also support zsync based updaters, see below.
89
+ updateInformation: true,
90
+ });
91
+ // ./dist/Demo-x86_64.AppImage
92
+ // ./dist/latest-linux.yml
93
+ // ./dist/Demo-x86_64.AppImage.zsync (only with updateInformation)
94
+ ```
95
+
96
+ | `publish.provider` | Other keys | The latest release is looked up at |
97
+ | ------------------ | --------------- | ---------------------------------------------------------------- |
98
+ | `"github"` | `owner`, `repo` | The latest (non draft, non prerelease) GitHub release's assets |
99
+ | `"generic"` | `url` | `<url>/latest-linux.yml`, with the AppImage served from `<url>/` |
61
100
 
62
- The first time it runs for a given architecture, `appImage()` downloads
63
- [`appimagetool`](https://github.com/AppImage/appimagetool) into `cacheDir` and
64
- reuses it on subsequent calls. `appimagetool` is a native Linux binary, so
65
- `appImage()` only runs on a Linux host, targeting the host's own architecture
66
- by default.
101
+ `updateInformation` additionally embeds AppImage update information, for zsync
102
+ based updaters such as AppImageUpdate, `appimageupdatetool` and AppImage
103
+ managers. `true` derives it from `publish`
104
+ (`gh-releases-zsync|<owner>|<repo>|latest|<AppImage>.zsync` or
105
+ `zsync|<url>/<AppImage>.zsync`); a string is embedded as-is. It needs
106
+ `zsyncmake` (usually packaged as `zsync`) installed on the host, and the
107
+ `.zsync` file must be uploaded alongside the AppImage.
67
108
 
68
- `appimagetool` is invoked with `APPIMAGE_EXTRACT_AND_RUN=1`, so a FUSE mount is
69
- never required - useful in containers, CI runners and other sandboxes where
70
- FUSE is typically unavailable.
109
+ ### NSIS installer
110
+
111
+ Build the application with `platform: "win"` first, then:
112
+
113
+ ```js
114
+ const installerPath = await packager({
115
+ format: "nsis",
116
+ appDir: "./out",
117
+ appName: "Demo",
118
+ version: "1.2.0",
119
+ outDir: "./dist",
120
+ icon: "./icon.ico",
121
+ publisher: "ACME",
122
+ license: "./LICENSE.txt",
123
+ });
124
+ // ./dist/Demo-x64-Setup.exe
125
+ ```
126
+
127
+ The installer walks the user through a wizard and installs for the current
128
+ user only, into `%LOCALAPPDATA%\Programs\<appName>`, so it never asks for
129
+ administrator rights. It adds Start Menu and desktop shortcuts, and registers
130
+ an uninstaller in Windows' installed apps list. The uninstaller removes exactly
131
+ the files that were installed. Installing a new version first uninstalls the
132
+ previous one, matched by `appId`, and stops if that fails. If the application
133
+ is running, the installer and uninstaller ask the user to close it first;
134
+ in silent mode (`/S`) they exit with an error instead.
135
+
136
+ The installer refuses to run on Windows machines that can't run `arch`: x64
137
+ builds need x64 Windows, or ARM64 Windows 11, which emulates x64.
138
+
139
+ Run the installer with `/S` to install silently, and `/D=<dir>` (last, unquoted)
140
+ to choose the install directory.
141
+
142
+ It needs the NSIS compiler, `makensis`, installed on the host. It runs on
143
+ Linux (`apt install nsis`) and MacOS (`brew install makensis`) too, so a Windows
144
+ installer can be built from any platform. On Windows, `choco install nsis`.
71
145
 
72
146
  ## API Reference
73
147
 
74
148
  ### `packager({ format, ...options })`
75
149
 
76
- | Name | Type | Description |
77
- | ------ | -------------------------------------------------- | ---------------------------------------------------------------------------- |
78
- | format | `"AppImage" \| "deb" \| "rpm" \| "MSIX" \| "NSIS"` | Which packager to run. Only `"AppImage"` is implemented today. |
79
- | ... | - | The remaining options are passed through to the packager for `format` as-is. |
150
+ | Name | Type | Description |
151
+ | ------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
152
+ | format | `"AppImage" \| "deb" \| "rpm" \| "msix" \| "nsis" \| "dmg"` | Which packager to run. `"AppImage"` and `"nsis"` are implemented today. |
153
+ | ... | - | The remaining options are passed through to the packager for `format` as-is. |
80
154
 
81
155
  Resolves with the path to the resulting packaged artifact.
82
156
 
@@ -84,24 +158,45 @@ Resolves with the path to the resulting packaged artifact.
84
158
 
85
159
  Options
86
160
 
87
- | Name | Type | Description |
88
- | --------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
89
- | appDir | `string` | Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"` |
90
- | appName | `string` | Name of the application. Must match the `app.name` value used to build `appDir` |
91
- | icon | `string` | Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop` |
92
- | arch | `"ia32" \| "x64" \| "arm64"` | Target architecture. Defaults to the host architecture |
93
- | outDir | `string` | Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir` |
94
- | cacheDir | `string` | Directory used to cache the downloaded `appimagetool` binary. Defaults to `"./cache"` |
95
- | cache | `boolean` | If true, reuse a cached `appimagetool` binary. Otherwise redownload it. Defaults to `true` |
96
- | appImageToolUrl | `string` | Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" release |
161
+ | Name | Type | Description |
162
+ | ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
163
+ | appDir | `string` | Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"` |
164
+ | appName | `string` | Name of the application. Must match the `app.name` value used to build `appDir`. Must not contain `/` or control characters |
165
+ | icon | `string` | Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop` |
166
+ | arch | `"ia32" \| "x64" \| "arm64"` | Target architecture. Defaults to the host architecture |
167
+ | outDir | `string` | Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir` |
168
+ | cacheDir | `string` | Directory used to cache the downloaded `appimagetool` binary. Defaults to `"./cache"` |
169
+ | cache | `boolean` | If true, reuse a cached `appimagetool` binary. Otherwise redownload it. Defaults to `true` |
170
+ | appImageToolUrl | `string` | Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" release. Must be https, except for localhost |
171
+ | sign | `boolean` | If true, embed a GPG signature in the AppImage. Defaults to `false` |
172
+ | signKey | `string` | ID of the GPG key to sign with. Defaults to `gpg`'s default secret key. Requires `sign: true` |
173
+ | version | `string` | Version of the application. Required with `publish` |
174
+ | publish | `PublishOptions` | Where releases are published. Embeds `app-update.yml` and writes `latest-linux[-<arch>].yml` to `outDir` |
175
+ | updateInformation | `boolean \| string` | AppImage zsync update information. `true` derives it from `publish`. Requires `zsyncmake` on the host |
97
176
 
98
177
  Resolves with the path to the resulting `.AppImage` file.
99
178
 
100
- The application's desktop entry file (`<appDir>/<appName>.desktop`, as written
101
- by `@nwutils/builder`) is copied into the AppImage with its `Exec` and `Icon`
102
- keys rewritten to match the AppImage's own layout, and a `Categories` key
103
- added (defaulting to `Utility;`) if one isn't already present, since
104
- `appimagetool` refuses to build an AppImage without one.
179
+ ### `nsis(options)`
180
+
181
+ Options
182
+
183
+ | Name | Type | Description |
184
+ | ----------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
185
+ | appDir | `string` | Path to a built NW.js Windows application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "win"` |
186
+ | appName | `string` | Name of the application. Must match the `app.name` value used to build `appDir`. Must be a valid Windows file name |
187
+ | version | `string` | Version of the application, eg. `"1.2.0"`. Required |
188
+ | arch | `"ia32" \| "x64" \| "arm64"` | Architecture `appDir` was built for. Defaults to the host architecture |
189
+ | outDir | `string` | Directory the resulting `<appName>-<arch>-Setup.exe` file is written to. Defaults to the parent directory of `appDir` |
190
+ | icon | `string` | Path to a `.ico` icon for the installer and uninstaller. Defaults to NSIS' own icon |
191
+ | appId | `string` | Name of the registry key the installation is recorded under. Defaults to `appName`. Keep it the same across releases |
192
+ | publisher | `string` | Publisher shown in Windows' installed apps list |
193
+ | copyright | `string` | Copyright notice written into the installer's version information |
194
+ | license | `string` | Path to a `.txt` or `.rtf` license file the user has to accept |
195
+ | desktopShortcut | `boolean` | If true, create a desktop shortcut. Defaults to `true` |
196
+ | startMenuShortcut | `boolean` | If true, create a Start Menu shortcut. Defaults to `true` |
197
+ | makensisPath | `string` | Path to the NSIS compiler. Defaults to `makensis` on the `PATH` |
198
+
199
+ Resolves with the path to the resulting installer `.exe` file.
105
200
 
106
201
  ## Contributing
107
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwutils/packager",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Package NW.js applications for Linux, MacOS and Windows.",
5
5
  "keywords": [
6
6
  "NW.js",
@@ -43,9 +43,20 @@
43
43
  "lint": "eslint . ../../tests/specs/packager ../../tests/fixtures/packager",
44
44
  "lint:fix": "eslint . ../../tests/specs/packager ../../tests/fixtures/packager --fix",
45
45
  "type": "tsc",
46
- "test": "node --test --test-concurrency=1 \"../../tests/specs/packager/appImage.test.js\" \"../../tests/specs/packager/request.test.js\" \"../../tests/specs/packager/util.test.js\"",
47
- "test:real": "node --test \"../../tests/specs/packager/appImage.real.test.js\""
46
+ "test": "node --test --test-concurrency=1 \"../../tests/specs/packager/appImage.test.js\" \"../../tests/specs/packager/nsis.test.js\" \"../../tests/specs/packager/request.test.js\" \"../../tests/specs/packager/util.test.js\"",
47
+ "test:real": "node --test --test-concurrency=1 \"../../tests/specs/packager/appImage.real.test.js\" \"../../tests/specs/packager/nsis.real.test.js\""
48
48
  },
49
49
  "dependencies": {},
50
- "devDependencies": {}
50
+ "devEngines": {
51
+ "runtime": {
52
+ "name": "node",
53
+ "version": "26.7.0",
54
+ "onFail": "warn"
55
+ },
56
+ "packageManager": {
57
+ "name": "npm",
58
+ "version": "12.0.2",
59
+ "onFail": "warn"
60
+ }
61
+ }
51
62
  }
package/src/appImage.js CHANGED
@@ -19,13 +19,40 @@ const APPIMAGETOOL_URL =
19
19
  /**
20
20
  * @typedef {object} AppImageOptions
21
21
  * @property {string} appDir Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"`.
22
- * @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the executable and desktop entry are expected at `<appDir>/<appName>` and `<appDir>/<appName>.desktop`.
22
+ * @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the executable and desktop entry are expected at `<appDir>/<appName>` and `<appDir>/<appName>.desktop`. Must not contain `/` or control characters.
23
23
  * @property {string} [icon] Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop`.
24
24
  * @property {"ia32" | "x64" | "arm64"} [arch] 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.
25
25
  * @property {string} [outDir] Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir`.
26
26
  * @property {string} [cacheDir="./cache"] Directory used to cache the downloaded `appimagetool` binary.
27
27
  * @property {boolean} [cache=true] If true, reuse a cached `appimagetool` binary. Otherwise redownload it.
28
- * @property {string} [appImageToolUrl] Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release.
28
+ * @property {string} [appImageToolUrl] Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release. Must be https, except for localhost.
29
+ * @property {boolean} [sign=false] 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.
30
+ * @property {string} [signKey] ID of the GPG key to sign with. Defaults to `gpg`'s default secret key. Requires `sign` to be true.
31
+ * @property {string} [version] 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.
32
+ * @property {PublishOptions} [publish] 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.
33
+ * @property {string | boolean} [updateInformation] 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.
34
+ */
35
+
36
+ /**
37
+ * Publish to a GitHub repository's releases. The AppImage and update info
38
+ * file must be uploaded as assets of the latest (non draft, non prerelease)
39
+ * release.
40
+ * @typedef {object} GitHubPublishOptions
41
+ * @property {"github"} provider
42
+ * @property {string} owner Owner of the GitHub repository.
43
+ * @property {string} repo Name of the GitHub repository.
44
+ */
45
+
46
+ /**
47
+ * Publish to any static file server. The AppImage and update info file must
48
+ * be served from `url`.
49
+ * @typedef {object} GenericPublishOptions
50
+ * @property {"generic"} provider
51
+ * @property {string} url Base URL the latest release's files are served from.
52
+ */
53
+
54
+ /**
55
+ * @typedef {GitHubPublishOptions | GenericPublishOptions} PublishOptions
29
56
  */
30
57
 
31
58
  /**
@@ -47,6 +74,11 @@ async function appImage({
47
74
  cacheDir = "./cache",
48
75
  cache = true,
49
76
  appImageToolUrl = APPIMAGETOOL_URL,
77
+ sign = false,
78
+ signKey,
79
+ version,
80
+ publish,
81
+ updateInformation,
50
82
  }) {
51
83
  if (process.platform !== "linux") {
52
84
  throw new Error(
@@ -69,6 +101,68 @@ async function appImage({
69
101
  `Expected "options.appName" to be a non-empty string. Received: ${JSON.stringify(appName)}`,
70
102
  );
71
103
  }
104
+ /*
105
+ * `appName` names files inside `appDir` and `outDir`, and is written into
106
+ * the desktop entry and `AppRun`: a path separator or `..` would reach
107
+ * outside those directories, and a line break would inject desktop entries.
108
+ */
109
+ const hasControlCharacter = [...appName].some((character) => {
110
+ const code = character.charCodeAt(0);
111
+ return code < 0x20 || code === 0x7f;
112
+ });
113
+ if (
114
+ appName.includes("/") ||
115
+ hasControlCharacter ||
116
+ appName === "." ||
117
+ appName === ".."
118
+ ) {
119
+ throw new Error(
120
+ `Expected "options.appName" to be a file name without "/" or control characters. Received: ${JSON.stringify(appName)}`,
121
+ );
122
+ }
123
+
124
+ if (typeof sign !== "boolean") {
125
+ throw new Error(
126
+ `Expected "options.sign" to be a boolean. Received: ${JSON.stringify(sign)}`,
127
+ );
128
+ }
129
+
130
+ if (signKey !== undefined) {
131
+ if (typeof signKey !== "string" || signKey === "") {
132
+ throw new Error(
133
+ `Expected "options.signKey" to be a non-empty string. Received: ${JSON.stringify(signKey)}`,
134
+ );
135
+ }
136
+ if (sign === false) {
137
+ throw new Error(
138
+ '"options.signKey" was passed but "options.sign" is false. Set "options.sign" to true to sign the AppImage.',
139
+ );
140
+ }
141
+ }
142
+
143
+ if (publish !== undefined) {
144
+ validatePublish(publish);
145
+ if (typeof version !== "string" || version === "") {
146
+ throw new Error(
147
+ `Expected "options.version" to be a non-empty string when "options.publish" is set. Received: ${JSON.stringify(version)}`,
148
+ );
149
+ }
150
+ }
151
+
152
+ if (
153
+ updateInformation !== undefined &&
154
+ typeof updateInformation !== "boolean" &&
155
+ (typeof updateInformation !== "string" || updateInformation === "")
156
+ ) {
157
+ throw new Error(
158
+ `Expected "options.updateInformation" to be a boolean or a non-empty string. Received: ${JSON.stringify(updateInformation)}`,
159
+ );
160
+ }
161
+ if (updateInformation === true && publish === undefined) {
162
+ throw new Error(
163
+ '"options.updateInformation" is true but "options.publish" is not set. Set "options.publish" to derive it, or pass the update information string directly.',
164
+ );
165
+ }
72
166
 
73
167
  const resolvedArch =
74
168
  /** @type {"ia32" | "x64" | "arm64" | undefined} */ (arch) ??
@@ -76,7 +170,10 @@ async function appImage({
76
170
  /** @type {unknown} */ (process.arch)
77
171
  );
78
172
 
79
- const appImageToolArch = util.APPIMAGE_ARCH_KV[resolvedArch];
173
+ /* `Object.hasOwn` so inherited keys such as "constructor" aren't mistaken for architectures. */
174
+ const appImageToolArch = Object.hasOwn(util.APPIMAGE_ARCH_KV, resolvedArch)
175
+ ? util.APPIMAGE_ARCH_KV[resolvedArch]
176
+ : undefined;
80
177
  if (appImageToolArch === undefined) {
81
178
  throw new Error(
82
179
  `Expected "options.arch" to be "ia32", "x64" or "arm64". Received: ${JSON.stringify(arch)}`,
@@ -153,7 +250,7 @@ async function appImage({
153
250
  const appImageDesktopEntry = {
154
251
  ...desktopEntry,
155
252
  Name: desktopEntry.Name ?? appName,
156
- Exec: appName,
253
+ Exec: util.desktopExecArg(appName),
157
254
  Icon: path.basename(iconFileName, path.extname(iconFileName)),
158
255
  Categories: desktopEntry.Categories || "Utility;",
159
256
  };
@@ -168,21 +265,79 @@ async function appImage({
168
265
  [
169
266
  "#!/bin/sh",
170
267
  'HERE="$(dirname "$(readlink -f "${0}")")"',
171
- `exec "\${HERE}/${appName}" "$@"`,
268
+ /* Single quoted, so the app name is never expanded or run by the shell. */
269
+ `exec "\${HERE}"/${util.shellQuote(appName)} "$@"`,
172
270
  "",
173
271
  ].join("\n"),
174
272
  );
175
273
  await fs.promises.chmod(appRunPath, 0o755);
176
274
 
177
- const appImageFilePath = path.resolve(
178
- resolvedOutDir,
179
- `${appName}-${appImageToolArch}.AppImage`,
180
- );
275
+ /*
276
+ * Sits next to the NW.js executable, ie. at
277
+ * `path.dirname(process.execPath)` at runtime, which is where
278
+ * `@nwutils/updater` looks for it.
279
+ */
280
+ if (publish !== undefined) {
281
+ await fs.promises.writeFile(
282
+ path.resolve(appImageDir, "app-update.yml"),
283
+ util.stringifyYaml(
284
+ publish.provider === "github"
285
+ ? {
286
+ provider: publish.provider,
287
+ owner: publish.owner,
288
+ repo: publish.repo,
289
+ }
290
+ : { provider: publish.provider, url: publish.url },
291
+ ),
292
+ );
293
+ }
294
+
295
+ const appImageFileName = `${appName}-${appImageToolArch}.AppImage`;
296
+ const appImageFilePath = path.resolve(resolvedOutDir, appImageFileName);
297
+ const zsyncFilePath = `${appImageFilePath}.zsync`;
298
+
299
+ const resolvedUpdateInformation =
300
+ updateInformation === true
301
+ ? deriveUpdateInformation(
302
+ /** @type {PublishOptions} */ (publish),
303
+ path.basename(zsyncFilePath),
304
+ )
305
+ : updateInformation || undefined;
306
+
307
+ /*
308
+ * Signing happens inside the same `appimagetool` run that builds the
309
+ * file: an embedded signature can't be added to an existing AppImage.
310
+ */
311
+ const signArgs = sign
312
+ ? ["--sign", ...(signKey === undefined ? [] : ["--sign-key", signKey])]
313
+ : [];
314
+
315
+ const updateInformationArgs =
316
+ resolvedUpdateInformation === undefined
317
+ ? []
318
+ : ["--updateinformation", resolvedUpdateInformation];
319
+
320
+ if (resolvedUpdateInformation !== undefined) {
321
+ /* Don't let a `.zsync` file left over from a previous build pass the check below. */
322
+ await fs.promises.rm(zsyncFilePath, { force: true });
323
+ }
181
324
 
182
325
  child_process.execFileSync(
183
326
  appImageToolPath,
184
- ["--no-appstream", appImageDir, appImageFilePath],
327
+ [
328
+ "--no-appstream",
329
+ ...signArgs,
330
+ ...updateInformationArgs,
331
+ appImageDir,
332
+ appImageFilePath,
333
+ ],
185
334
  {
335
+ /*
336
+ * `appimagetool` runs `zsyncmake` without an output path, which
337
+ * writes the `.zsync` file to the working directory - run from
338
+ * `outDir` so it lands next to the AppImage.
339
+ */
340
+ cwd: resolvedOutDir,
186
341
  env: {
187
342
  ...process.env,
188
343
  ARCH: appImageToolArch,
@@ -197,12 +352,152 @@ async function appImage({
197
352
  },
198
353
  );
199
354
 
355
+ /*
356
+ * `appimagetool` only warns, and still exits successfully, when it
357
+ * can't find `zsyncmake` - leaving an AppImage that advertises a
358
+ * `.zsync` file which was never produced.
359
+ */
360
+ if (
361
+ resolvedUpdateInformation !== undefined &&
362
+ (await util.fileExists(zsyncFilePath)) === false
363
+ ) {
364
+ throw new Error(
365
+ `"appimagetool" did not produce ${zsyncFilePath}. Install "zsyncmake" (usually packaged as "zsync") on the host to build AppImages with "options.updateInformation".`,
366
+ );
367
+ }
368
+
369
+ if (publish !== undefined) {
370
+ const appImageSha512 = await util.sha512(appImageFilePath);
371
+ const { size } = await fs.promises.stat(appImageFilePath);
372
+ await fs.promises.writeFile(
373
+ path.resolve(resolvedOutDir, util.updateInfoFileName(resolvedArch)),
374
+ util.stringifyYaml({
375
+ version: /** @type {string} */ (version),
376
+ files: [{ url: appImageFileName, sha512: appImageSha512, size }],
377
+ path: appImageFileName,
378
+ sha512: appImageSha512,
379
+ releaseDate: new Date().toISOString(),
380
+ }),
381
+ );
382
+ }
383
+
200
384
  return appImageFilePath;
201
385
  } finally {
202
386
  await fs.promises.rm(appImageDir, { recursive: true, force: true });
203
387
  }
204
388
  }
205
389
 
390
+ /**
391
+ * Throw if `publish` isn't a valid `PublishOptions` object.
392
+ * @param {unknown} publish
393
+ * @returns {asserts publish is PublishOptions}
394
+ */
395
+ function validatePublish(publish) {
396
+ if (typeof publish !== "object" || publish === null) {
397
+ throw new Error(
398
+ `Expected "options.publish" to be an object. Received: ${JSON.stringify(publish)}`,
399
+ );
400
+ }
401
+
402
+ const { provider } = /** @type {{provider?: unknown}} */ (publish);
403
+ /** @type {string[]} */
404
+ let requiredKeys;
405
+ if (provider === "github") {
406
+ requiredKeys = ["owner", "repo"];
407
+ } else if (provider === "generic") {
408
+ requiredKeys = ["url"];
409
+ } else {
410
+ throw new Error(
411
+ `Expected "options.publish.provider" to be "github" or "generic". Received: ${JSON.stringify(provider)}`,
412
+ );
413
+ }
414
+
415
+ for (const key of requiredKeys) {
416
+ const value = /** @type {Record<string, unknown>} */ (publish)[key];
417
+ if (typeof value !== "string" || value === "") {
418
+ throw new Error(
419
+ `Expected "options.publish.${key}" to be a non-empty string for provider "${provider}". Received: ${JSON.stringify(value)}`,
420
+ );
421
+ }
422
+ }
423
+
424
+ if (provider === "github") {
425
+ /*
426
+ * The characters GitHub allows in owner and repository names. Anything
427
+ * else - eg. "|", the update information's field separator - is rejected.
428
+ */
429
+ for (const key of requiredKeys) {
430
+ const value = /** @type {Record<string, string>} */ (publish)[key];
431
+ if (/^[A-Za-z0-9._-]+$/.test(value) === false) {
432
+ throw new Error(
433
+ `Expected "options.publish.${key}" to be a GitHub ${key} name (letters, digits, ".", "-" and "_"). Received: ${JSON.stringify(value)}`,
434
+ );
435
+ }
436
+ }
437
+ }
438
+
439
+ if (provider === "generic") {
440
+ const { url } = /** @type {GenericPublishOptions} */ (publish);
441
+ let protocol;
442
+ try {
443
+ protocol = new URL(url).protocol;
444
+ } catch {
445
+ protocol = undefined;
446
+ }
447
+ if (protocol !== "https:" && protocol !== "http:") {
448
+ throw new Error(
449
+ `Expected "options.publish.url" to be an http(s) URL. Received: ${JSON.stringify(url)}`,
450
+ );
451
+ }
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Build the AppImage update information string for `publish`, in the format
457
+ * zsync based AppImage updaters understand.
458
+ * @param {PublishOptions} publish
459
+ * @param {string} zsyncFileName Name the `.zsync` file is published under.
460
+ * @returns {string}
461
+ */
462
+ function deriveUpdateInformation(publish, zsyncFileName) {
463
+ if (publish.provider === "github") {
464
+ return `gh-releases-zsync|${publish.owner}|${publish.repo}|latest|${zsyncFileName}`;
465
+ }
466
+ /* Trim trailing slashes with a linear scan rather than a backtracking regular expression. */
467
+ let end = publish.url.length;
468
+ while (end > 0 && publish.url[end - 1] === "/") {
469
+ end--;
470
+ }
471
+ return `zsync|${publish.url.slice(0, end)}/${zsyncFileName}`;
472
+ }
473
+
474
+ /**
475
+ * Throw unless `appImageToolUrl` is an https URL. The downloaded binary is
476
+ * executed, so a plain http download would let anyone on the network path
477
+ * replace it. http is only accepted for loopback hosts, eg. a local mirror.
478
+ * @param {string} appImageToolUrl
479
+ * @returns {void}
480
+ */
481
+ function validateAppImageToolUrl(appImageToolUrl) {
482
+ let url;
483
+ try {
484
+ url = new URL(appImageToolUrl);
485
+ } catch {
486
+ url = undefined;
487
+ }
488
+ const isLoopback =
489
+ url !== undefined &&
490
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
491
+ if (
492
+ url === undefined ||
493
+ (url.protocol !== "https:" && (url.protocol !== "http:" || !isLoopback))
494
+ ) {
495
+ throw new Error(
496
+ `Expected "options.appImageToolUrl" to be an https URL (http is only allowed for localhost). Received: ${JSON.stringify(appImageToolUrl)}`,
497
+ );
498
+ }
499
+ }
500
+
206
501
  /**
207
502
  * Download (or reuse a cached) `appimagetool` binary for `appImageToolArch`.
208
503
  * @async
@@ -220,6 +515,8 @@ async function getAppImageTool({
220
515
  cacheDir,
221
516
  cache,
222
517
  }) {
518
+ validateAppImageToolUrl(appImageToolUrl);
519
+
223
520
  const appImageToolPath = path.resolve(
224
521
  cacheDir,
225
522
  `appimagetool-${appImageToolArch}.AppImage`,