@nwutils/packager 0.3.0 → 0.5.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
@@ -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,33 +40,71 @@ 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
+ ```
61
95
 
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.
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>/` |
67
100
 
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.
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.
71
108
 
72
109
  ## API Reference
73
110
 
@@ -84,25 +121,24 @@ Resolves with the path to the resulting packaged artifact.
84
121
 
85
122
  Options
86
123
 
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 |
124
+ | Name | Type | Description |
125
+ | ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | appDir | `string` | Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"` |
127
+ | appName | `string` | Name of the application. Must match the `app.name` value used to build `appDir`. Must not contain `/` or control characters |
128
+ | icon | `string` | Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop` |
129
+ | arch | `"ia32" \| "x64" \| "arm64"` | Target architecture. Defaults to the host architecture |
130
+ | outDir | `string` | Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir` |
131
+ | cacheDir | `string` | Directory used to cache the downloaded `appimagetool` binary. Defaults to `"./cache"` |
132
+ | cache | `boolean` | If true, reuse a cached `appimagetool` binary. Otherwise redownload it. Defaults to `true` |
133
+ | appImageToolUrl | `string` | Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" release. Must be https, except for localhost |
134
+ | sign | `boolean` | If true, embed a GPG signature in the AppImage. Defaults to `false` |
135
+ | signKey | `string` | ID of the GPG key to sign with. Defaults to `gpg`'s default secret key. Requires `sign: true` |
136
+ | version | `string` | Version of the application. Required with `publish` |
137
+ | publish | `PublishOptions` | Where releases are published. Embeds `app-update.yml` and writes `latest-linux[-<arch>].yml` to `outDir` |
138
+ | updateInformation | `boolean \| string` | AppImage zsync update information. `true` derives it from `publish`. Requires `zsyncmake` on the host |
97
139
 
98
140
  Resolves with the path to the resulting `.AppImage` file.
99
141
 
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.
105
-
106
142
  ## Contributing
107
143
 
108
144
  ### External contributor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwutils/packager",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Package NW.js applications for Linux, MacOS and Windows.",
5
5
  "keywords": [
6
6
  "NW.js",
@@ -47,5 +47,16 @@
47
47
  "test:real": "node --test \"../../tests/specs/packager/appImage.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`,
package/src/index.js CHANGED
@@ -10,6 +10,11 @@ import appImage from "./appImage.js";
10
10
  * @typedef {"AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg"} PackageFormat
11
11
  */
12
12
 
13
+ /**
14
+ * Where releases are published, for packagers that support self updating.
15
+ * @typedef {import("./appImage.js").PublishOptions} PublishOptions
16
+ */
17
+
13
18
  /**
14
19
  * Package a built NW.js application.
15
20
  * @async
package/src/util.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
 
3
4
  /**
@@ -71,16 +72,122 @@ function parseDesktopEntry(contents) {
71
72
  function stringifyDesktopEntry(entries) {
72
73
  let fileContent = "[Desktop Entry]\n";
73
74
  for (const key of Object.keys(entries)) {
74
- if (entries[key] !== undefined) {
75
- fileContent += `${key}=${entries[key]}\n`;
75
+ const value = entries[key];
76
+ if (value !== undefined) {
77
+ /* A line break would let the key or value inject further entries, eg. a second `Exec`. */
78
+ if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) {
79
+ throw new Error(
80
+ `Desktop entry keys and values must not contain line breaks. Received: ${JSON.stringify(`${key}=${value}`)}`,
81
+ );
82
+ }
83
+ fileContent += `${key}=${value}\n`;
76
84
  }
77
85
  }
78
86
  return fileContent;
79
87
  }
80
88
 
89
+ /**
90
+ * Quote `value` as a single POSIX shell word. Inside single quotes nothing is
91
+ * expanded, so only single quotes themselves need escaping.
92
+ * @param {string} value
93
+ * @returns {string}
94
+ */
95
+ function shellQuote(value) {
96
+ return `'${value.replaceAll("'", "'\\''")}'`;
97
+ }
98
+
99
+ /**
100
+ * Quote `value` as a single argument of a desktop entry's `Exec` key, per the
101
+ * Desktop Entry Specification: `%` is doubled so it isn't read as a field
102
+ * code, an argument with reserved characters is double quoted with `"`, `` ` ``,
103
+ * `$` and `\` backslash-escaped, and finally backslashes are escaped again as
104
+ * required for every string value.
105
+ * @param {string} value
106
+ * @returns {string}
107
+ */
108
+ function desktopExecArg(value) {
109
+ let arg = value.replaceAll("%", "%%");
110
+ if (/[\s"'\\><~|&;$*?#()`]/.test(arg)) {
111
+ arg = `"${arg.replace(/["`$\\]/g, "\\$&")}"`;
112
+ }
113
+ return arg.replaceAll("\\", "\\\\");
114
+ }
115
+
116
+ /**
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
121
+ * @returns {string}
122
+ */
123
+ function updateInfoFileName(arch) {
124
+ return arch === "x64" ? "latest-linux.yml" : `latest-linux-${arch}.yml`;
125
+ }
126
+
127
+ /**
128
+ * Serialise `value` as a YAML scalar. Strings are written double-quoted via
129
+ * `JSON.stringify` - a JSON string literal is also a valid YAML double-quoted
130
+ * scalar, so this never needs YAML-specific escaping rules.
131
+ * @param {string | number | boolean} value
132
+ * @returns {string}
133
+ */
134
+ function stringifyYamlScalar(value) {
135
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
136
+ }
137
+
138
+ /**
139
+ * Serialise a map of scalars, and lists of maps of scalars, as YAML. This is
140
+ * the only shape the update info and `app-update.yml` files use, so it avoids
141
+ * pulling in a YAML library for a handful of lines.
142
+ * @param {Record<string, string | number | boolean | Record<string, string | number | boolean>[] | undefined>} entries
143
+ * @returns {string}
144
+ */
145
+ function stringifyYaml(entries) {
146
+ let fileContent = "";
147
+ for (const [key, value] of Object.entries(entries)) {
148
+ if (value === undefined) {
149
+ continue;
150
+ }
151
+ if (Array.isArray(value)) {
152
+ fileContent += `${key}:\n`;
153
+ for (const item of value) {
154
+ let prefix = " - ";
155
+ for (const [itemKey, itemValue] of Object.entries(item)) {
156
+ fileContent += `${prefix}${itemKey}: ${stringifyYamlScalar(itemValue)}\n`;
157
+ prefix = " ";
158
+ }
159
+ }
160
+ } else {
161
+ fileContent += `${key}: ${stringifyYamlScalar(value)}\n`;
162
+ }
163
+ }
164
+ return fileContent;
165
+ }
166
+
167
+ /**
168
+ * Compute the base64 encoded SHA-512 digest of the file at `filePath`, the
169
+ * same encoding electron-builder writes into its update info files.
170
+ * @async
171
+ * @function
172
+ * @param {string} filePath
173
+ * @returns {Promise<string>}
174
+ */
175
+ async function sha512(filePath) {
176
+ const hash = crypto.createHash("sha512");
177
+ for await (const chunk of fs.createReadStream(filePath)) {
178
+ hash.update(chunk);
179
+ }
180
+ return hash.digest("base64");
181
+ }
182
+
81
183
  export default {
82
184
  APPIMAGE_ARCH_KV,
185
+ desktopExecArg,
83
186
  fileExists,
84
187
  parseDesktopEntry,
188
+ sha512,
189
+ shellQuote,
85
190
  stringifyDesktopEntry,
191
+ stringifyYaml,
192
+ updateInfoFileName,
86
193
  };
@@ -4,7 +4,7 @@ export type AppImageOptions = {
4
4
  */
5
5
  appDir: string;
6
6
  /**
7
- * 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`.
7
+ * 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.
8
8
  */
9
9
  appName: string;
10
10
  /**
@@ -28,20 +28,83 @@ export type AppImageOptions = {
28
28
  */
29
29
  cache?: boolean;
30
30
  /**
31
- * Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release.
31
+ * Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release. Must be https, except for localhost.
32
32
  */
33
33
  appImageToolUrl?: string;
34
+ /**
35
+ * 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
+ sign?: boolean;
38
+ /**
39
+ * ID of the GPG key to sign with. Defaults to `gpg`'s default secret key. Requires `sign` to be true.
40
+ */
41
+ signKey?: string;
42
+ /**
43
+ * 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
+ version?: string;
46
+ /**
47
+ * 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
+ publish?: PublishOptions;
50
+ /**
51
+ * 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
+ updateInformation?: string | boolean;
54
+ };
55
+ export type GitHubPublishOptions = {
56
+ provider: "github";
57
+ /**
58
+ * Owner of the GitHub repository.
59
+ */
60
+ owner: string;
61
+ /**
62
+ * Name of the GitHub repository.
63
+ */
64
+ repo: string;
65
+ };
66
+ export type GenericPublishOptions = {
67
+ provider: "generic";
68
+ /**
69
+ * Base URL the latest release's files are served from.
70
+ */
71
+ url: string;
34
72
  };
73
+ export type PublishOptions = GitHubPublishOptions | GenericPublishOptions;
35
74
  /**
36
75
  * @typedef {object} AppImageOptions
37
76
  * @property {string} appDir Path to a built NW.js Linux application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "linux"`.
38
- * @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`.
77
+ * @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.
39
78
  * @property {string} [icon] Path to a `.png` or `.svg` icon. Defaults to the `Icon` value read from `<appDir>/<appName>.desktop`.
40
79
  * @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.
41
80
  * @property {string} [outDir] Directory the resulting `.AppImage` file is written to. Defaults to the parent directory of `appDir`.
42
81
  * @property {string} [cacheDir="./cache"] Directory used to cache the downloaded `appimagetool` binary.
43
82
  * @property {boolean} [cache=true] If true, reuse a cached `appimagetool` binary. Otherwise redownload it.
44
- * @property {string} [appImageToolUrl] Base URL `appimagetool-<arch>.AppImage` is downloaded from. Defaults to the `AppImage/appimagetool` "continuous" GitHub release.
83
+ * @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.
84
+ * @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.
85
+ * @property {string} [signKey] ID of the GPG key to sign with. Defaults to `gpg`'s default secret key. Requires `sign` to be true.
86
+ * @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.
87
+ * @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.
88
+ * @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.
89
+ */
90
+ /**
91
+ * Publish to a GitHub repository's releases. The AppImage and update info
92
+ * file must be uploaded as assets of the latest (non draft, non prerelease)
93
+ * release.
94
+ * @typedef {object} GitHubPublishOptions
95
+ * @property {"github"} provider
96
+ * @property {string} owner Owner of the GitHub repository.
97
+ * @property {string} repo Name of the GitHub repository.
98
+ */
99
+ /**
100
+ * Publish to any static file server. The AppImage and update info file must
101
+ * be served from `url`.
102
+ * @typedef {object} GenericPublishOptions
103
+ * @property {"generic"} provider
104
+ * @property {string} url Base URL the latest release's files are served from.
105
+ */
106
+ /**
107
+ * @typedef {GitHubPublishOptions | GenericPublishOptions} PublishOptions
45
108
  */
46
109
  /**
47
110
  * Package a built NW.js Linux application as an AppImage.
@@ -53,7 +116,7 @@ export type AppImageOptions = {
53
116
  * @param {AppImageOptions} options
54
117
  * @returns {Promise<string>} Path to the resulting `.AppImage` file.
55
118
  */
56
- declare function appImage({ appDir, appName, icon, arch, outDir, cacheDir, cache, appImageToolUrl, }: AppImageOptions): Promise<string>;
119
+ declare function appImage({ appDir, appName, icon, arch, outDir, cacheDir, cache, appImageToolUrl, sign, signKey, version, publish, updateInformation, }: AppImageOptions): Promise<string>;
57
120
  /**
58
121
  * Download (or reuse a cached) `appimagetool` binary for `appImageToolArch`.
59
122
  * @async
package/types/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type PackageFormat = "AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg";
2
+ export type PublishOptions = import("./appImage.js").PublishOptions;
2
3
  /**
3
4
  * Supported packaged output formats.
4
5
  *
@@ -8,6 +9,10 @@ export type PackageFormat = "AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg"
8
9
  * land.
9
10
  * @typedef {"AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg"} PackageFormat
10
11
  */
12
+ /**
13
+ * Where releases are published, for packagers that support self updating.
14
+ * @typedef {import("./appImage.js").PublishOptions} PublishOptions
15
+ */
11
16
  /**
12
17
  * Package a built NW.js application.
13
18
  * @async
package/types/util.d.ts CHANGED
@@ -19,10 +19,57 @@ declare function parseDesktopEntry(contents: string): Record<string, string>;
19
19
  * @returns {string}
20
20
  */
21
21
  declare function stringifyDesktopEntry(entries: Record<string, string | undefined>): string;
22
+ /**
23
+ * Quote `value` as a single POSIX shell word. Inside single quotes nothing is
24
+ * expanded, so only single quotes themselves need escaping.
25
+ * @param {string} value
26
+ * @returns {string}
27
+ */
28
+ declare function shellQuote(value: string): string;
29
+ /**
30
+ * Quote `value` as a single argument of a desktop entry's `Exec` key, per the
31
+ * Desktop Entry Specification: `%` is doubled so it isn't read as a field
32
+ * code, an argument with reserved characters is double quoted with `"`, `` ` ``,
33
+ * `$` and `\` backslash-escaped, and finally backslashes are escaped again as
34
+ * required for every string value.
35
+ * @param {string} value
36
+ * @returns {string}
37
+ */
38
+ declare function desktopExecArg(value: string): string;
39
+ /**
40
+ * Name of the update info file `@nwutils/updater` downloads to find the
41
+ * latest release, following electron-builder's naming: `x64` gets the bare
42
+ * `latest-linux.yml`, every other architecture gets an `-<arch>` suffix.
43
+ * @param {"ia32" | "x64" | "arm64"} arch
44
+ * @returns {string}
45
+ */
46
+ declare function updateInfoFileName(arch: "ia32" | "x64" | "arm64"): string;
47
+ /**
48
+ * Serialise a map of scalars, and lists of maps of scalars, as YAML. This is
49
+ * the only shape the update info and `app-update.yml` files use, so it avoids
50
+ * pulling in a YAML library for a handful of lines.
51
+ * @param {Record<string, string | number | boolean | Record<string, string | number | boolean>[] | undefined>} entries
52
+ * @returns {string}
53
+ */
54
+ declare function stringifyYaml(entries: Record<string, string | number | boolean | Record<string, string | number | boolean>[] | undefined>): string;
55
+ /**
56
+ * Compute the base64 encoded SHA-512 digest of the file at `filePath`, the
57
+ * same encoding electron-builder writes into its update info files.
58
+ * @async
59
+ * @function
60
+ * @param {string} filePath
61
+ * @returns {Promise<string>}
62
+ */
63
+ declare function sha512(filePath: string): Promise<string>;
22
64
  declare const _default: {
23
65
  APPIMAGE_ARCH_KV: Record<"arm64" | "ia32" | "x64", string>;
66
+ desktopExecArg: typeof desktopExecArg;
24
67
  fileExists: typeof fileExists;
25
68
  parseDesktopEntry: typeof parseDesktopEntry;
69
+ sha512: typeof sha512;
70
+ shellQuote: typeof shellQuote;
26
71
  stringifyDesktopEntry: typeof stringifyDesktopEntry;
72
+ stringifyYaml: typeof stringifyYaml;
73
+ updateInfoFileName: typeof updateInfoFileName;
27
74
  };
28
75
  export default _default;