@nwutils/packager 0.6.0 → 0.8.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
@@ -13,7 +13,7 @@ Supported output formats, selected via `format`:
13
13
  | `rpm` | Linux | Planned |
14
14
  | `msix` | Windows | Planned |
15
15
  | `nsis` | Windows | Implemented |
16
- | `dmg` | MacOS | Planned |
16
+ | `dmg` | MacOS | Implemented |
17
17
 
18
18
  ## Getting Started
19
19
 
@@ -65,7 +65,7 @@ keyring. If the key is protected by a passphrase, set it in the
65
65
  ### Self updating AppImages
66
66
 
67
67
  Set `publish` to where releases are uploaded, and `version` to the app's
68
- version. This follows electron-builder's approach:
68
+ version.
69
69
 
70
70
  - an `app-update.yml` describing `publish` is embedded next to the NW.js
71
71
  executable inside the AppImage, so the running app knows where to look
@@ -143,14 +143,102 @@ It needs the NSIS compiler, `makensis`, installed on the host. It runs on
143
143
  Linux (`apt install nsis`) and MacOS (`brew install makensis`) too, so a Windows
144
144
  installer can be built from any platform. On Windows, `choco install nsis`.
145
145
 
146
+ ### Self updating NSIS installs
147
+
148
+ Set `publish`, the same way as for [AppImages](#self-updating-appimages):
149
+
150
+ - an `app-update.yml` describing `publish` is installed next to
151
+ `<appName>.exe`, and removed by the uninstaller
152
+ - a `latest.yml` update info file (`latest-<arch>.yml` for architectures other
153
+ than `x64`) with the version, file name, size and SHA-512 of the installer is
154
+ written next to it in `outDir`
155
+
156
+ ```js
157
+ await packager({
158
+ format: "nsis",
159
+ appDir: "./out",
160
+ appName: "Demo",
161
+ version: "1.2.0",
162
+ outDir: "./dist",
163
+ publish: { provider: "github", owner: "nwutils", repo: "demo" },
164
+ });
165
+ // ./dist/Demo-x64-Setup.exe
166
+ // ./dist/latest.yml
167
+ ```
168
+
169
+ Upload both the installer and `latest.yml` to every release.
170
+ [`@nwutils/updater`](https://www.npmjs.com/package/@nwutils/updater)'s
171
+ `NsisUpdater` downloads the new installer, checks it, and runs it with
172
+ `/S /UPDATE`. With `/UPDATE`, the installer waits up to 60 seconds for the
173
+ running application to exit instead of asking the user to close it, updates it
174
+ silently, and starts the new version. Because it installs for the current user,
175
+ updating never asks for administrator rights.
176
+
177
+ ### DMG
178
+
179
+ Build the application with `platform: "osx"` first, then, on a MacOS host:
180
+
181
+ ```js
182
+ const dmgPath = await packager({
183
+ format: "dmg",
184
+ appDir: "./out",
185
+ appName: "Demo",
186
+ arch: "arm64",
187
+ outDir: "./dist",
188
+ signIdentity: "Developer ID Application: ACME (TEAMID)",
189
+ });
190
+ // ./dist/Demo-arm64.dmg
191
+ ```
192
+
193
+ The DMG holds the app next to a link to `/Applications`, so users install it by
194
+ dragging the app onto the link. It is made with `hdiutil`, and the app is signed
195
+ with `codesign` when `signIdentity` is set (`"-"` signs ad-hoc). Both are part
196
+ of MacOS, so DMGs are built on a MacOS host. Notarize the DMG afterwards, eg.
197
+ with `xcrun notarytool submit --wait` and `xcrun stapler staple`, so Gatekeeper
198
+ lets users open it.
199
+
200
+ ### Self updating DMGs
201
+
202
+ Set `publish` and `version`. Updates don't use the DMG:
203
+
204
+ - an `app-update.yml` describing `publish` is added to the app's
205
+ `Contents/Resources`
206
+ - a `<appName>-<arch>-mac.zip` of the app, made with `ditto` so permissions,
207
+ symbolic links and the signature survive, is written next to the DMG
208
+ - a `latest-mac.yml` update info file (`latest-mac-<arch>.yml` for
209
+ architectures other than `x64`) describing the zip is written next to it
210
+
211
+ ```js
212
+ await packager({
213
+ format: "dmg",
214
+ appDir: "./out",
215
+ appName: "Demo",
216
+ arch: "arm64",
217
+ outDir: "./dist",
218
+ version: "1.2.0",
219
+ signIdentity: "Developer ID Application: ACME (TEAMID)",
220
+ publish: { provider: "github", owner: "nwutils", repo: "demo" },
221
+ });
222
+ // ./dist/Demo-arm64.dmg
223
+ // ./dist/Demo-arm64-mac.zip
224
+ // ./dist/latest-mac-arm64.yml
225
+ ```
226
+
227
+ Upload all three to every release.
228
+ [`@nwutils/updater`](https://www.npmjs.com/package/@nwutils/updater)'s
229
+ `MacUpdater` downloads the zip, checks it, swaps it in for the running app and
230
+ restarts it. Adding `app-update.yml` changes the app, so sign it here with
231
+ `signIdentity` rather than before packaging. A signed app only accepts updates
232
+ signed by the same team.
233
+
146
234
  ## API Reference
147
235
 
148
236
  ### `packager({ format, ...options })`
149
237
 
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. |
238
+ | Name | Type | Description |
239
+ | ------ | ----------------------------------------------------------- | -------------------------------------------------------------------------------- |
240
+ | format | `"AppImage" \| "deb" \| "rpm" \| "msix" \| "nsis" \| "dmg"` | Which packager to run. `"AppImage"`, `"nsis"` and `"dmg"` are implemented today. |
241
+ | ... | - | The remaining options are passed through to the packager for `format` as-is. |
154
242
 
155
243
  Resolves with the path to the resulting packaged artifact.
156
244
 
@@ -195,9 +283,30 @@ Options
195
283
  | desktopShortcut | `boolean` | If true, create a desktop shortcut. Defaults to `true` |
196
284
  | startMenuShortcut | `boolean` | If true, create a Start Menu shortcut. Defaults to `true` |
197
285
  | makensisPath | `string` | Path to the NSIS compiler. Defaults to `makensis` on the `PATH` |
286
+ | publish | `PublishOptions` | Where releases are published. Installs `app-update.yml` and writes `latest[-<arch>].yml` to `outDir` |
198
287
 
199
288
  Resolves with the path to the resulting installer `.exe` file.
200
289
 
290
+ ### `dmg(options)`
291
+
292
+ Options
293
+
294
+ | Name | Type | Description |
295
+ | ------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
296
+ | appDir | `string` | Path to a built NW.js MacOS application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "osx"` |
297
+ | appName | `string` | Name of the application. Must match the `app.name` value used to build `appDir`: the bundle is expected at `<appDir>/<appName>.app` |
298
+ | arch | `"x64" \| "arm64"` | Architecture `appDir` was built for. Defaults to the host architecture |
299
+ | outDir | `string` | Directory the resulting `<appName>-<arch>.dmg` file is written to. Defaults to the parent directory of `appDir` |
300
+ | volumeName | `string` | Name of the volume the DMG mounts as. Defaults to `appName` |
301
+ | signIdentity | `string` | Code signing identity to sign the app with, or `"-"` to sign ad-hoc. Not signed by default |
302
+ | version | `string` | Version of the application. Required with `publish` |
303
+ | publish | `PublishOptions` | Where releases are published. Adds `app-update.yml` and writes `<appName>-<arch>-mac.zip` and `latest-mac[-<arch>].yml` to `outDir` |
304
+ | hdiutilPath | `string` | Path to `hdiutil`. Defaults to `hdiutil` on the `PATH` |
305
+ | dittoPath | `string` | Path to `ditto`. Defaults to `ditto` on the `PATH` |
306
+ | codesignPath | `string` | Path to `codesign`. Defaults to `codesign` on the `PATH` |
307
+
308
+ Resolves with the path to the resulting `.dmg` file.
309
+
201
310
  ## Contributing
202
311
 
203
312
  ### External contributor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwutils/packager",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Package NW.js applications for Linux, MacOS and Windows.",
5
5
  "keywords": [
6
6
  "NW.js",
@@ -43,8 +43,8 @@
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/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\""
46
+ "test": "node --test --test-concurrency=1 \"../../tests/specs/packager/appImage.test.js\" \"../../tests/specs/packager/dmg.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/dmg.real.test.js\" \"../../tests/specs/packager/nsis.real.test.js\""
48
48
  },
49
49
  "dependencies": {},
50
50
  "devEngines": {
package/src/appImage.js CHANGED
@@ -141,7 +141,7 @@ async function appImage({
141
141
  }
142
142
 
143
143
  if (publish !== undefined) {
144
- validatePublish(publish);
144
+ util.validatePublish(publish);
145
145
  if (typeof version !== "string" || version === "") {
146
146
  throw new Error(
147
147
  `Expected "options.version" to be a non-empty string when "options.publish" is set. Received: ${JSON.stringify(version)}`,
@@ -280,15 +280,7 @@ async function appImage({
280
280
  if (publish !== undefined) {
281
281
  await fs.promises.writeFile(
282
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
- ),
283
+ util.appUpdateYaml(publish),
292
284
  );
293
285
  }
294
286
 
@@ -367,18 +359,14 @@ async function appImage({
367
359
  }
368
360
 
369
361
  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
- );
362
+ await util.writeUpdateInfo({
363
+ filePath: appImageFilePath,
364
+ updateInfoPath: path.resolve(
365
+ resolvedOutDir,
366
+ util.updateInfoFileName(resolvedArch, "linux"),
367
+ ),
368
+ version: /** @type {string} */ (version),
369
+ });
382
370
  }
383
371
 
384
372
  return appImageFilePath;
@@ -387,71 +375,6 @@ async function appImage({
387
375
  }
388
376
  }
389
377
 
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
378
  /**
456
379
  * Build the AppImage update information string for `publish`, in the format
457
380
  * zsync based AppImage updaters understand.
package/src/dmg.js ADDED
@@ -0,0 +1,277 @@
1
+ import child_process from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ import util from "./util.js";
8
+
9
+ /**
10
+ * @typedef {object} DmgOptions
11
+ * @property {string} appDir Path to a built NW.js MacOS application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "osx"`.
12
+ * @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the bundle is expected at `<appDir>/<appName>.app`.
13
+ * @property {"x64" | "arm64"} [arch] Architecture `appDir` was built for. Defaults to the host architecture.
14
+ * @property {string} [outDir] Directory the resulting `<appName>-<arch>.dmg` file is written to. Defaults to the parent directory of `appDir`.
15
+ * @property {string} [volumeName] Name of the volume the DMG mounts as. Defaults to `appName`.
16
+ * @property {string} [signIdentity] Code signing identity, eg. `"Developer ID Application: ACME (TEAMID)"`, or `"-"` to sign ad-hoc. When set, the app is signed with `codesign` before it is put in the DMG. Sign here rather than before packaging when `publish` is set, since adding `app-update.yml` changes the bundle.
17
+ * @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.
18
+ * @property {import("./appImage.js").PublishOptions} [publish] Where releases are published. When set, an `app-update.yml` pointing at it is added to the app's `Contents/Resources`, and a `<appName>-<arch>-mac.zip` update archive and a `latest-mac[-<arch>].yml` update info file are written next to the DMG, for `@nwutils/updater` to self update from.
19
+ * @property {string} [hdiutilPath="hdiutil"] Path to `hdiutil`, which creates the DMG.
20
+ * @property {string} [dittoPath="ditto"] Path to `ditto`, which creates the update archive.
21
+ * @property {string} [codesignPath="codesign"] Path to `codesign`, which signs the app.
22
+ */
23
+
24
+ /**
25
+ * Package a built NW.js MacOS application as a DMG disk image.
26
+ *
27
+ * The DMG holds the app next to a link to `/Applications`, so users install it
28
+ * by dragging the app onto the link. Updates don't use the DMG: with
29
+ * `publish` set, a zip of the app is written next to it, which
30
+ * `@nwutils/updater`'s `MacUpdater` downloads and swaps in for the running
31
+ * app.
32
+ *
33
+ * This composes with `@nwutils/builder`: run it against the `outDir` (and
34
+ * `app.name`) that `@nwutils/builder` produced for `platform: "osx"`.
35
+ * `hdiutil`, `ditto` and `codesign` are part of MacOS, so this runs on a MacOS
36
+ * host.
37
+ * @async
38
+ * @function
39
+ * @param {DmgOptions} options
40
+ * @returns {Promise<string>} Path to the resulting `.dmg` file.
41
+ */
42
+ async function dmg({
43
+ appDir,
44
+ appName,
45
+ arch,
46
+ outDir,
47
+ volumeName,
48
+ signIdentity,
49
+ version,
50
+ publish,
51
+ hdiutilPath = "hdiutil",
52
+ dittoPath = "ditto",
53
+ codesignPath = "codesign",
54
+ }) {
55
+ if (typeof appDir !== "string" || appDir === "") {
56
+ throw new Error(
57
+ `Expected "options.appDir" to be a non-empty string. Received: ${JSON.stringify(appDir)}`,
58
+ );
59
+ }
60
+
61
+ if ((await util.fileExists(appDir)) === false) {
62
+ throw new Error(`"options.appDir" does not exist: ${appDir}`);
63
+ }
64
+
65
+ /* `appName` names the bundle and the files written to `outDir`. */
66
+ if (
67
+ typeof appName !== "string" ||
68
+ appName === "" ||
69
+ appName.includes("/") ||
70
+ appName.startsWith(".") ||
71
+ hasControlCharacter(appName)
72
+ ) {
73
+ throw new Error(
74
+ `Expected "options.appName" to be a non-empty file name without "/" or control characters, not starting with ".". Received: ${JSON.stringify(appName)}`,
75
+ );
76
+ }
77
+
78
+ const resolvedArch =
79
+ /** @type {"x64" | "arm64" | undefined} */ (arch) ??
80
+ /** @type {"x64" | "arm64"} */ (/** @type {unknown} */ (process.arch));
81
+ if (resolvedArch !== "x64" && resolvedArch !== "arm64") {
82
+ throw new Error(
83
+ `Expected "options.arch" to be "x64" or "arm64". Received: ${JSON.stringify(arch ?? process.arch)}`,
84
+ );
85
+ }
86
+
87
+ const resolvedVolumeName = volumeName ?? appName;
88
+ if (
89
+ typeof resolvedVolumeName !== "string" ||
90
+ resolvedVolumeName === "" ||
91
+ hasControlCharacter(resolvedVolumeName)
92
+ ) {
93
+ throw new Error(
94
+ `Expected "options.volumeName" to be a non-empty string without control characters. Received: ${JSON.stringify(volumeName)}`,
95
+ );
96
+ }
97
+
98
+ if (
99
+ signIdentity !== undefined &&
100
+ (typeof signIdentity !== "string" || signIdentity === "")
101
+ ) {
102
+ throw new Error(
103
+ `Expected "options.signIdentity" to be a non-empty string. Received: ${JSON.stringify(signIdentity)}`,
104
+ );
105
+ }
106
+
107
+ if (publish !== undefined) {
108
+ util.validatePublish(publish);
109
+ if (typeof version !== "string" || version === "") {
110
+ throw new Error(
111
+ `Expected "options.version" to be a non-empty string when "options.publish" is set. Received: ${JSON.stringify(version)}`,
112
+ );
113
+ }
114
+ }
115
+
116
+ for (const [key, value] of Object.entries({
117
+ hdiutilPath,
118
+ dittoPath,
119
+ codesignPath,
120
+ })) {
121
+ if (typeof value !== "string" || value === "") {
122
+ throw new Error(
123
+ `Expected "options.${key}" to be a non-empty string. Received: ${JSON.stringify(value)}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ const resolvedAppDir = path.resolve(appDir);
129
+ const appBundleName = `${appName}.app`;
130
+ const appBundlePath = path.resolve(resolvedAppDir, appBundleName);
131
+ if ((await util.fileExists(appBundlePath)) === false) {
132
+ throw new Error(
133
+ `Expected the app bundle to exist at ${appBundlePath}. Does "options.appName" match the "app.name" used to build "options.appDir" for "platform: \\"osx\\""?`,
134
+ );
135
+ }
136
+
137
+ const resolvedOutDir = path.resolve(outDir ?? path.dirname(resolvedAppDir));
138
+ await fs.promises.mkdir(resolvedOutDir, { recursive: true });
139
+ const dmgPath = path.resolve(
140
+ resolvedOutDir,
141
+ `${appName}-${resolvedArch}.dmg`,
142
+ );
143
+
144
+ const stagingDir = await fs.promises.mkdtemp(
145
+ path.join(os.tmpdir(), "nwutils-packager-dmg-"),
146
+ );
147
+
148
+ try {
149
+ /* NW.js' frameworks are full of symbolic links, which must stay links for the signature to hold. */
150
+ const stagedAppPath = path.resolve(stagingDir, appBundleName);
151
+ await fs.promises.cp(appBundlePath, stagedAppPath, {
152
+ recursive: true,
153
+ verbatimSymlinks: true,
154
+ });
155
+
156
+ /*
157
+ * `Contents/Resources` is where `@nwutils/updater` looks for it.
158
+ * It's inside the signed bundle, so
159
+ * signing comes after.
160
+ */
161
+ if (publish !== undefined) {
162
+ await fs.promises.writeFile(
163
+ path.resolve(stagedAppPath, "Contents", "Resources", "app-update.yml"),
164
+ util.appUpdateYaml(publish),
165
+ );
166
+ }
167
+
168
+ if (signIdentity !== undefined) {
169
+ run(
170
+ codesignPath,
171
+ [
172
+ "--force",
173
+ "--deep",
174
+ "--sign",
175
+ signIdentity,
176
+ /* Notarization requires the hardened runtime and a secure timestamp, which ad-hoc signatures can't have. */
177
+ ...(signIdentity === "-"
178
+ ? []
179
+ : ["--options", "runtime", "--timestamp"]),
180
+ stagedAppPath,
181
+ ],
182
+ "codesign",
183
+ "codesignPath",
184
+ );
185
+ }
186
+
187
+ /* Lets users install by dragging the app onto it. */
188
+ await fs.promises.symlink(
189
+ "/Applications",
190
+ path.resolve(stagingDir, "Applications"),
191
+ );
192
+
193
+ run(
194
+ hdiutilPath,
195
+ [
196
+ "create",
197
+ "-volname",
198
+ resolvedVolumeName,
199
+ "-srcfolder",
200
+ stagingDir,
201
+ "-fs",
202
+ "HFS+",
203
+ "-format",
204
+ "UDZO",
205
+ "-ov",
206
+ dmgPath,
207
+ ],
208
+ "hdiutil",
209
+ "hdiutilPath",
210
+ );
211
+
212
+ if (publish !== undefined) {
213
+ /*
214
+ * `ditto` keeps the permissions, symbolic links and extended attributes
215
+ * a signed app bundle needs, unlike most zip tools. `--keepParent` puts
216
+ * `<appName>.app` itself at the archive's root.
217
+ */
218
+ const zipPath = path.resolve(
219
+ resolvedOutDir,
220
+ `${appName}-${resolvedArch}-mac.zip`,
221
+ );
222
+ run(
223
+ dittoPath,
224
+ ["-c", "-k", "--sequesterRsrc", "--keepParent", stagedAppPath, zipPath],
225
+ "ditto",
226
+ "dittoPath",
227
+ );
228
+ await util.writeUpdateInfo({
229
+ filePath: zipPath,
230
+ updateInfoPath: path.resolve(
231
+ resolvedOutDir,
232
+ util.updateInfoFileName(resolvedArch, "osx"),
233
+ ),
234
+ version: /** @type {string} */ (version),
235
+ });
236
+ }
237
+
238
+ return dmgPath;
239
+ } finally {
240
+ await fs.promises.rm(stagingDir, { recursive: true, force: true });
241
+ }
242
+ }
243
+
244
+ /**
245
+ * @param {string} value
246
+ * @returns {boolean}
247
+ */
248
+ function hasControlCharacter(value) {
249
+ return [...value].some((character) => {
250
+ const code = character.charCodeAt(0);
251
+ return code < 0x20 || code === 0x7f;
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Run a MacOS tool, turning a missing tool into an error that says so.
257
+ * @param {string} toolPath Path or name of the tool.
258
+ * @param {string[]} args
259
+ * @param {string} toolName eg. `"hdiutil"`.
260
+ * @param {string} optionName Option that overrides `toolPath`, eg. `"hdiutilPath"`.
261
+ * @returns {void}
262
+ */
263
+ function run(toolPath, args, toolName, optionName) {
264
+ try {
265
+ child_process.execFileSync(toolPath, args, { stdio: "inherit" });
266
+ } catch (error) {
267
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") {
268
+ throw new Error(
269
+ `Could not find ${JSON.stringify(toolPath)}. "${toolName}" is part of MacOS, so DMGs are built on a MacOS host. Otherwise pass its path as "options.${optionName}".`,
270
+ { cause: error },
271
+ );
272
+ }
273
+ throw error;
274
+ }
275
+ }
276
+
277
+ export default dmg;
package/src/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import appImage from "./appImage.js";
2
+ import dmg from "./dmg.js";
2
3
  import nsis from "./nsis.js";
3
4
 
4
5
  /**
5
6
  * Supported packaged output formats.
6
7
  *
7
8
  * `AppImage`, `deb` and `rpm` package a Linux build. `MSIX` and `NSIS`
8
- * package a Windows build. `"AppImage"` and `"nsis"` are implemented today -
9
- * the others are reserved so this option doesn't need to change shape once
10
- * they land.
9
+ * package a Windows build. `dmg` packages a MacOS build. `"AppImage"`,
10
+ * `"nsis"` and `"dmg"` are implemented today - the others are reserved so
11
+ * this option doesn't need to change shape once they land.
11
12
  * @typedef {"AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg"} PackageFormat
12
13
  */
13
14
 
@@ -20,7 +21,7 @@ import nsis from "./nsis.js";
20
21
  * Package a built NW.js application.
21
22
  * @async
22
23
  * @function
23
- * @param {{format: PackageFormat} & Record<string, any>} options `format`, plus the options the matching packager (eg. `appImage` or `nsis`) expects.
24
+ * @param {{format: PackageFormat} & Record<string, any>} options `format`, plus the options the matching packager (eg. `appImage`, `nsis` or `dmg`) expects.
24
25
  * @returns {Promise<string>} Path to the resulting packaged artifact.
25
26
  */
26
27
  async function packager({ format, ...options }) {
@@ -29,12 +30,13 @@ async function packager({ format, ...options }) {
29
30
  return appImage(/** @type {any} */ (options));
30
31
  case "nsis":
31
32
  return nsis(/** @type {any} */ (options));
33
+ case "dmg":
34
+ return dmg(/** @type {any} */ (options));
32
35
  case "deb":
33
36
  case "rpm":
34
37
  case "msix":
35
- case "dmg":
36
38
  throw new Error(
37
- `options.format ${JSON.stringify(format)} is not implemented yet. Currently only "AppImage" and "nsis" are supported.`,
39
+ `options.format ${JSON.stringify(format)} is not implemented yet. Currently only "AppImage", "nsis" and "dmg" are supported.`,
38
40
  );
39
41
  default:
40
42
  throw new Error(
package/src/nsis.js CHANGED
@@ -21,6 +21,7 @@ import util from "./util.js";
21
21
  * @property {boolean} [desktopShortcut=true] If true, create a desktop shortcut.
22
22
  * @property {boolean} [startMenuShortcut=true] If true, create a Start Menu shortcut.
23
23
  * @property {string} [makensisPath="makensis"] Path to the NSIS compiler. Defaults to `makensis` on the `PATH`.
24
+ * @property {import("./appImage.js").PublishOptions} [publish] Where releases are published. When set, an `app-update.yml` pointing at it is installed next to the executable and a `latest[-<arch>].yml` update info file is written next to the installer, for `@nwutils/updater` to self update from.
24
25
  */
25
26
 
26
27
  /**
@@ -46,6 +47,12 @@ const ARCH_CHECK_KV = {
46
47
  arm64: [["${IsNativeARM64}"]],
47
48
  };
48
49
 
50
+ /**
51
+ * How long an update waits for the running application to exit.
52
+ * @type {number}
53
+ */
54
+ const UPDATE_WAIT_SECONDS = 60;
55
+
49
56
  /**
50
57
  * Package a built NW.js Windows application as an NSIS installer.
51
58
  *
@@ -55,6 +62,10 @@ const ARCH_CHECK_KV = {
55
62
  * installing a new version first uninstalls the previous one. Both wait for
56
63
  * the application to be closed before touching its files.
57
64
  *
65
+ * Run with `/S /UPDATE`, as `@nwutils/updater` does, the installer instead
66
+ * waits for the running application to exit, updates it silently and starts
67
+ * the new version.
68
+ *
58
69
  * This composes with `@nwutils/builder`: run it against the `outDir` (and
59
70
  * `app.name`) that `@nwutils/builder` produced for `platform: "win"`.
60
71
  * `makensis` runs on Linux and macOS too, so the installer can be built on
@@ -78,6 +89,7 @@ async function nsis({
78
89
  desktopShortcut = true,
79
90
  startMenuShortcut = true,
80
91
  makensisPath = "makensis",
92
+ publish,
81
93
  }) {
82
94
  if (typeof appDir !== "string" || appDir === "") {
83
95
  throw new Error(
@@ -169,6 +181,10 @@ async function nsis({
169
181
  );
170
182
  }
171
183
 
184
+ if (publish !== undefined) {
185
+ util.validatePublish(publish);
186
+ }
187
+
172
188
  const resolvedIconPath =
173
189
  icon === undefined
174
190
  ? undefined
@@ -193,28 +209,45 @@ async function nsis({
193
209
  `${appName}-${resolvedArch}-Setup.exe`,
194
210
  );
195
211
 
196
- const script = nsisScript({
197
- appDir: resolvedAppDir,
198
- appFiles: await listAppFiles(resolvedAppDir),
199
- appName,
200
- appId: resolvedAppId,
201
- version,
202
- fileVersion,
203
- arch: resolvedArch,
204
- installerPath,
205
- iconPath: resolvedIconPath,
206
- licensePath: resolvedLicensePath,
207
- publisher,
208
- copyright,
209
- desktopShortcut,
210
- startMenuShortcut,
211
- });
212
+ const appFiles = await listAppFiles(resolvedAppDir);
212
213
 
213
214
  const scriptDir = await fs.promises.mkdtemp(
214
215
  path.join(os.tmpdir(), "nwutils-packager-nsis-"),
215
216
  );
216
217
 
217
218
  try {
219
+ /*
220
+ * Installed next to the NW.js executable, ie. at
221
+ * `path.dirname(process.execPath)` at runtime, which is where
222
+ * `@nwutils/updater` looks for it.
223
+ */
224
+ let appUpdateYamlPath;
225
+ if (publish !== undefined) {
226
+ appUpdateYamlPath = path.resolve(scriptDir, "app-update.yml");
227
+ await fs.promises.writeFile(
228
+ appUpdateYamlPath,
229
+ util.appUpdateYaml(publish),
230
+ );
231
+ }
232
+
233
+ const script = nsisScript({
234
+ appDir: resolvedAppDir,
235
+ appFiles,
236
+ appName,
237
+ appId: resolvedAppId,
238
+ version,
239
+ fileVersion,
240
+ arch: resolvedArch,
241
+ installerPath,
242
+ iconPath: resolvedIconPath,
243
+ licensePath: resolvedLicensePath,
244
+ publisher,
245
+ copyright,
246
+ desktopShortcut,
247
+ startMenuShortcut,
248
+ appUpdateYamlPath,
249
+ });
250
+
218
251
  const scriptPath = path.resolve(scriptDir, "installer.nsi");
219
252
  await fs.promises.writeFile(scriptPath, script, "utf-8");
220
253
 
@@ -234,6 +267,17 @@ async function nsis({
234
267
  throw error;
235
268
  }
236
269
 
270
+ if (publish !== undefined) {
271
+ await util.writeUpdateInfo({
272
+ filePath: installerPath,
273
+ updateInfoPath: path.resolve(
274
+ resolvedOutDir,
275
+ util.updateInfoFileName(resolvedArch, "win"),
276
+ ),
277
+ version,
278
+ });
279
+ }
280
+
237
281
  return installerPath;
238
282
  } finally {
239
283
  await fs.promises.rm(scriptDir, { recursive: true, force: true });
@@ -325,6 +369,7 @@ async function listAppFiles(appDir) {
325
369
  * @param {string} options.copyright
326
370
  * @param {boolean} options.desktopShortcut
327
371
  * @param {boolean} options.startMenuShortcut
372
+ * @param {string} [options.appUpdateYamlPath] Absolute path to an `app-update.yml` file to install next to the executable.
328
373
  * @returns {string}
329
374
  */
330
375
  function nsisScript({
@@ -342,6 +387,7 @@ function nsisScript({
342
387
  copyright,
343
388
  desktopShortcut,
344
389
  startMenuShortcut,
390
+ appUpdateYamlPath,
345
391
  }) {
346
392
  const e = util.nsisEscape;
347
393
  const p = util.nsisPath;
@@ -363,6 +409,10 @@ function nsisScript({
363
409
  '!include "LogicLib.nsh"',
364
410
  '!include "x64.nsh"',
365
411
  '!include "WinVer.nsh"',
412
+ '!include "FileFunc.nsh"',
413
+ "",
414
+ /* "1" when run with `/UPDATE`, ie. by `@nwutils/updater`. */
415
+ "Var IsUpdate",
366
416
  "",
367
417
  `Name "${e(appName)}"`,
368
418
  `OutFile "${p(installerPath)}"`,
@@ -405,7 +455,17 @@ function nsisScript({
405
455
  if (publisher !== undefined) {
406
456
  lines.push(`VIAddVersionKey "CompanyName" "${e(publisher)}"`);
407
457
  }
408
- lines.push("", "Function .onInit", " SetShellVarContext current");
458
+ lines.push(
459
+ "",
460
+ "Function .onInit",
461
+ " SetShellVarContext current",
462
+ " ${GetParameters} $R0",
463
+ " ClearErrors",
464
+ ' ${GetOptions} $R0 "/UPDATE" $R1',
465
+ " ${IfNot} ${Errors}",
466
+ ' StrCpy $IsUpdate "1"',
467
+ " ${EndIf}",
468
+ );
409
469
  if (archChecks.length > 0) {
410
470
  /* Passing branches are left empty: only the final `${Else}` does anything. */
411
471
  lines.push(
@@ -471,11 +531,11 @@ function nsisScript({
471
531
  }
472
532
  }
473
533
 
474
- lines.push(
475
- ' SetOutPath "$INSTDIR"',
476
- ' WriteUninstaller "$INSTDIR\\Uninstall.exe"',
477
- "",
478
- );
534
+ lines.push(' SetOutPath "$INSTDIR"');
535
+ if (appUpdateYamlPath !== undefined) {
536
+ lines.push(` File "/oname=app-update.yml" "${p(appUpdateYamlPath)}"`);
537
+ }
538
+ lines.push(' WriteUninstaller "$INSTDIR\\Uninstall.exe"', "");
479
539
  if (startMenuShortcut) {
480
540
  lines.push(` CreateShortcut "$SMPROGRAMS\\${e(appName)}.lnk" ${exe}`);
481
541
  }
@@ -500,6 +560,11 @@ function nsisScript({
500
560
  );
501
561
  }
502
562
  lines.push(
563
+ "",
564
+ /* An update runs silently, so the finish page's "Run" checkbox never shows. */
565
+ ' ${If} $IsUpdate == "1"',
566
+ ` Exec '${exe}'`,
567
+ " ${EndIf}",
503
568
  "SectionEnd",
504
569
  "",
505
570
  "Function un.onInit",
@@ -516,6 +581,9 @@ function nsisScript({
516
581
  for (const dir of [...appFiles.dirs].reverse()) {
517
582
  lines.push(` RMDir ${installed(dir)}`);
518
583
  }
584
+ if (appUpdateYamlPath !== undefined) {
585
+ lines.push(' Delete "$INSTDIR\\app-update.yml"');
586
+ }
519
587
  lines.push(
520
588
  ' Delete "$INSTDIR\\Uninstall.exe"',
521
589
  ' RMDir "$INSTDIR"',
@@ -534,7 +602,12 @@ function nsisScript({
534
602
  * the user to close it, so its files can be replaced or deleted. Windows
535
603
  * refuses to open a running executable for writing, which is how a running
536
604
  * copy is detected. Cancelling - which silent mode does straight away -
537
- * aborts the installer or uninstaller. Uses `$2`.
605
+ * aborts the installer or uninstaller.
606
+ *
607
+ * During an update (`$IsUpdate` is "1") nobody is there to answer the prompt:
608
+ * `@nwutils/updater` starts the installer and then quits the application, so
609
+ * it waits for up to `UPDATE_WAIT_SECONDS` instead, then aborts. Uses `$2`
610
+ * and `$3`.
538
611
  * @param {string} exePath Quoted NSIS string with the executable's path, eg. `"$INSTDIR\Demo.exe"`.
539
612
  * @param {string} appName Application name, shown in the prompt.
540
613
  * @param {string} indent Indentation of the generated lines.
@@ -542,6 +615,7 @@ function nsisScript({
542
615
  */
543
616
  function waitForAppToClose(exePath, appName, indent) {
544
617
  return [
618
+ "StrCpy $3 0",
545
619
  "${Do}",
546
620
  ` \${IfNot} \${FileExists} ${exePath}`,
547
621
  " ${Break}",
@@ -552,6 +626,14 @@ function waitForAppToClose(exePath, appName, indent) {
552
626
  " FileClose $2",
553
627
  " ${Break}",
554
628
  " ${EndIf}",
629
+ ' ${If} $IsUpdate == "1"',
630
+ " IntOp $3 $3 + 1",
631
+ ` \${If} $3 > ${UPDATE_WAIT_SECONDS * 2}`,
632
+ " Abort",
633
+ " ${EndIf}",
634
+ " Sleep 500",
635
+ " ${Continue}",
636
+ " ${EndIf}",
555
637
  ` \${If} \${Cmd} \`MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Close ${util.nsisEscape(appName)}, then select Retry." /SD IDCANCEL IDCANCEL\``,
556
638
  " Abort",
557
639
  " ${EndIf}",
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,22 @@ 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`, gets the bare
120
+ * `latest-linux.yml` (Linux), `latest.yml` (Windows) or `latest-mac.yml`
121
+ * (MacOS), every other architecture gets an `-<arch>` suffix.
122
+ * @param {string} arch
123
+ * @param {"linux" | "win" | "osx"} [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 =
128
+ platform === "win"
129
+ ? "latest"
130
+ : platform === "osx"
131
+ ? "latest-mac"
132
+ : "latest-linux";
133
+ return arch === "x64" ? `${base}.yml` : `${base}-${arch}.yml`;
125
134
  }
126
135
 
127
136
  /**
@@ -165,8 +174,7 @@ function stringifyYaml(entries) {
165
174
  }
166
175
 
167
176
  /**
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.
177
+ * Compute the base64 encoded SHA-512 digest of the file at `filePath`.
170
178
  * @async
171
179
  * @function
172
180
  * @param {string} filePath
@@ -269,8 +277,117 @@ function windowsVersion(version) {
269
277
  return parts.join(".");
270
278
  }
271
279
 
280
+ /**
281
+ * Throw if `publish` isn't a valid `PublishOptions` object.
282
+ * @param {unknown} publish
283
+ * @returns {void}
284
+ */
285
+ function validatePublish(publish) {
286
+ if (typeof publish !== "object" || publish === null) {
287
+ throw new Error(
288
+ `Expected "options.publish" to be an object. Received: ${JSON.stringify(publish)}`,
289
+ );
290
+ }
291
+
292
+ const { provider } = /** @type {{provider?: unknown}} */ (publish);
293
+ /** @type {string[]} */
294
+ let requiredKeys;
295
+ if (provider === "github") {
296
+ requiredKeys = ["owner", "repo"];
297
+ } else if (provider === "generic") {
298
+ requiredKeys = ["url"];
299
+ } else {
300
+ throw new Error(
301
+ `Expected "options.publish.provider" to be "github" or "generic". Received: ${JSON.stringify(provider)}`,
302
+ );
303
+ }
304
+
305
+ for (const key of requiredKeys) {
306
+ const value = /** @type {Record<string, unknown>} */ (publish)[key];
307
+ if (typeof value !== "string" || value === "") {
308
+ throw new Error(
309
+ `Expected "options.publish.${key}" to be a non-empty string for provider "${provider}". Received: ${JSON.stringify(value)}`,
310
+ );
311
+ }
312
+ }
313
+
314
+ if (provider === "github") {
315
+ /*
316
+ * The characters GitHub allows in owner and repository names. Anything
317
+ * else - eg. "|", the update information's field separator - is rejected.
318
+ */
319
+ for (const key of requiredKeys) {
320
+ const value = /** @type {Record<string, string>} */ (publish)[key];
321
+ if (/^[A-Za-z0-9._-]+$/.test(value) === false) {
322
+ throw new Error(
323
+ `Expected "options.publish.${key}" to be a GitHub ${key} name (letters, digits, ".", "-" and "_"). Received: ${JSON.stringify(value)}`,
324
+ );
325
+ }
326
+ }
327
+ }
328
+
329
+ if (provider === "generic") {
330
+ const { url } =
331
+ /** @type {import("./appImage.js").GenericPublishOptions} */ (publish);
332
+ let protocol;
333
+ try {
334
+ protocol = new URL(url).protocol;
335
+ } catch {
336
+ protocol = undefined;
337
+ }
338
+ if (protocol !== "https:" && protocol !== "http:") {
339
+ throw new Error(
340
+ `Expected "options.publish.url" to be an http(s) URL. Received: ${JSON.stringify(url)}`,
341
+ );
342
+ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Contents of the `app-update.yml` file `@nwutils/updater` reads to find out
348
+ * where releases are published. It is shipped next to the NW.js executable.
349
+ * @param {import("./appImage.js").PublishOptions} publish
350
+ * @returns {string}
351
+ */
352
+ function appUpdateYaml(publish) {
353
+ return stringifyYaml(
354
+ publish.provider === "github"
355
+ ? { provider: publish.provider, owner: publish.owner, repo: publish.repo }
356
+ : { provider: publish.provider, url: publish.url },
357
+ );
358
+ }
359
+
360
+ /**
361
+ * Write the update info file `@nwutils/updater` compares the running version
362
+ * against, describing the packaged artifact at `filePath`. It must be uploaded
363
+ * to every release next to the artifact.
364
+ * @async
365
+ * @function
366
+ * @param {object} options
367
+ * @param {string} options.filePath Path to the packaged artifact, eg. the AppImage or installer.
368
+ * @param {string} options.updateInfoPath Path the update info file is written to.
369
+ * @param {string} options.version Version of the application.
370
+ * @returns {Promise<void>}
371
+ */
372
+ async function writeUpdateInfo({ filePath, updateInfoPath, version }) {
373
+ const fileName = path.basename(filePath);
374
+ const sha512Digest = await sha512(filePath);
375
+ const { size } = await fs.promises.stat(filePath);
376
+ await fs.promises.writeFile(
377
+ updateInfoPath,
378
+ stringifyYaml({
379
+ version,
380
+ files: [{ url: fileName, sha512: sha512Digest, size }],
381
+ path: fileName,
382
+ sha512: sha512Digest,
383
+ releaseDate: new Date().toISOString(),
384
+ }),
385
+ );
386
+ }
387
+
272
388
  export default {
273
389
  APPIMAGE_ARCH_KV,
390
+ appUpdateYaml,
274
391
  desktopExecArg,
275
392
  fileExists,
276
393
  isWindowsFileName,
@@ -282,5 +399,7 @@ export default {
282
399
  stringifyDesktopEntry,
283
400
  stringifyYaml,
284
401
  updateInfoFileName,
402
+ validatePublish,
285
403
  windowsVersion,
404
+ writeUpdateInfo,
286
405
  };
package/types/dmg.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ export default dmg;
2
+ export type DmgOptions = {
3
+ /**
4
+ * Path to a built NW.js MacOS application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "osx"`.
5
+ */
6
+ appDir: string;
7
+ /**
8
+ * Name of the application. Must match the `app.name` value used to build `appDir` - the bundle is expected at `<appDir>/<appName>.app`.
9
+ */
10
+ appName: string;
11
+ /**
12
+ * Architecture `appDir` was built for. Defaults to the host architecture.
13
+ */
14
+ arch?: "x64" | "arm64" | undefined;
15
+ /**
16
+ * Directory the resulting `<appName>-<arch>.dmg` file is written to. Defaults to the parent directory of `appDir`.
17
+ */
18
+ outDir?: string | undefined;
19
+ /**
20
+ * Name of the volume the DMG mounts as. Defaults to `appName`.
21
+ */
22
+ volumeName?: string | undefined;
23
+ /**
24
+ * Code signing identity, eg. `"Developer ID Application: ACME (TEAMID)"`, or `"-"` to sign ad-hoc. When set, the app is signed with `codesign` before it is put in the DMG. Sign here rather than before packaging when `publish` is set, since adding `app-update.yml` changes the bundle.
25
+ */
26
+ signIdentity?: string | undefined;
27
+ /**
28
+ * 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.
29
+ */
30
+ version?: string | undefined;
31
+ /**
32
+ * Where releases are published. When set, an `app-update.yml` pointing at it is added to the app's `Contents/Resources`, and a `<appName>-<arch>-mac.zip` update archive and a `latest-mac[-<arch>].yml` update info file are written next to the DMG, for `@nwutils/updater` to self update from.
33
+ */
34
+ publish?: import("./appImage.js").PublishOptions | undefined;
35
+ /**
36
+ * Path to `hdiutil`, which creates the DMG.
37
+ */
38
+ hdiutilPath?: string | undefined;
39
+ /**
40
+ * Path to `ditto`, which creates the update archive.
41
+ */
42
+ dittoPath?: string | undefined;
43
+ /**
44
+ * Path to `codesign`, which signs the app.
45
+ */
46
+ codesignPath?: string | undefined;
47
+ };
48
+ /**
49
+ * @typedef {object} DmgOptions
50
+ * @property {string} appDir Path to a built NW.js MacOS application, ie. the `outDir` produced by `@nwutils/builder` for `platform: "osx"`.
51
+ * @property {string} appName Name of the application. Must match the `app.name` value used to build `appDir` - the bundle is expected at `<appDir>/<appName>.app`.
52
+ * @property {"x64" | "arm64"} [arch] Architecture `appDir` was built for. Defaults to the host architecture.
53
+ * @property {string} [outDir] Directory the resulting `<appName>-<arch>.dmg` file is written to. Defaults to the parent directory of `appDir`.
54
+ * @property {string} [volumeName] Name of the volume the DMG mounts as. Defaults to `appName`.
55
+ * @property {string} [signIdentity] Code signing identity, eg. `"Developer ID Application: ACME (TEAMID)"`, or `"-"` to sign ad-hoc. When set, the app is signed with `codesign` before it is put in the DMG. Sign here rather than before packaging when `publish` is set, since adding `app-update.yml` changes the bundle.
56
+ * @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.
57
+ * @property {import("./appImage.js").PublishOptions} [publish] Where releases are published. When set, an `app-update.yml` pointing at it is added to the app's `Contents/Resources`, and a `<appName>-<arch>-mac.zip` update archive and a `latest-mac[-<arch>].yml` update info file are written next to the DMG, for `@nwutils/updater` to self update from.
58
+ * @property {string} [hdiutilPath="hdiutil"] Path to `hdiutil`, which creates the DMG.
59
+ * @property {string} [dittoPath="ditto"] Path to `ditto`, which creates the update archive.
60
+ * @property {string} [codesignPath="codesign"] Path to `codesign`, which signs the app.
61
+ */
62
+ /**
63
+ * Package a built NW.js MacOS application as a DMG disk image.
64
+ *
65
+ * The DMG holds the app next to a link to `/Applications`, so users install it
66
+ * by dragging the app onto the link. Updates don't use the DMG: with
67
+ * `publish` set, a zip of the app is written next to it, which
68
+ * `@nwutils/updater`'s `MacUpdater` downloads and swaps in for the running
69
+ * app.
70
+ *
71
+ * This composes with `@nwutils/builder`: run it against the `outDir` (and
72
+ * `app.name`) that `@nwutils/builder` produced for `platform: "osx"`.
73
+ * `hdiutil`, `ditto` and `codesign` are part of MacOS, so this runs on a MacOS
74
+ * host.
75
+ * @async
76
+ * @function
77
+ * @param {DmgOptions} options
78
+ * @returns {Promise<string>} Path to the resulting `.dmg` file.
79
+ */
80
+ declare function dmg({ appDir, appName, arch, outDir, volumeName, signIdentity, version, publish, hdiutilPath, dittoPath, codesignPath, }: DmgOptions): Promise<string>;
package/types/index.d.ts CHANGED
@@ -3,9 +3,9 @@ export default packager;
3
3
  * Supported packaged output formats.
4
4
  *
5
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.
6
+ * package a Windows build. `dmg` packages a MacOS build. `"AppImage"`,
7
+ * `"nsis"` and `"dmg"` are implemented today - the others are reserved so
8
+ * this option doesn't need to change shape once they land.
9
9
  */
10
10
  export type PackageFormat = "AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg";
11
11
  /**
@@ -16,9 +16,9 @@ export type PublishOptions = import("./appImage.js").PublishOptions;
16
16
  * Supported packaged output formats.
17
17
  *
18
18
  * `AppImage`, `deb` and `rpm` package a Linux build. `MSIX` and `NSIS`
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.
19
+ * package a Windows build. `dmg` packages a MacOS build. `"AppImage"`,
20
+ * `"nsis"` and `"dmg"` are implemented today - the others are reserved so
21
+ * this option doesn't need to change shape once they land.
22
22
  * @typedef {"AppImage" | "deb" | "rpm" | "msix" | "nsis" | "dmg"} PackageFormat
23
23
  */
24
24
  /**
@@ -29,7 +29,7 @@ export type PublishOptions = import("./appImage.js").PublishOptions;
29
29
  * Package a built NW.js application.
30
30
  * @async
31
31
  * @function
32
- * @param {{format: PackageFormat} & Record<string, any>} options `format`, plus the options the matching packager (eg. `appImage` or `nsis`) expects.
32
+ * @param {{format: PackageFormat} & Record<string, any>} options `format`, plus the options the matching packager (eg. `appImage`, `nsis` or `dmg`) expects.
33
33
  * @returns {Promise<string>} Path to the resulting packaged artifact.
34
34
  */
35
35
  declare function packager({ format, ...options }: {
package/types/nsis.d.ts CHANGED
@@ -52,6 +52,10 @@ export type NsisOptions = {
52
52
  * Path to the NSIS compiler. Defaults to `makensis` on the `PATH`.
53
53
  */
54
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;
55
59
  };
56
60
  /**
57
61
  * The files and directories of a built application, relative to its root.
@@ -79,6 +83,10 @@ export type AppFiles = {
79
83
  * installing a new version first uninstalls the previous one. Both wait for
80
84
  * the application to be closed before touching its files.
81
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
+ *
82
90
  * This composes with `@nwutils/builder`: run it against the `outDir` (and
83
91
  * `app.name`) that `@nwutils/builder` produced for `platform: "win"`.
84
92
  * `makensis` runs on Linux and macOS too, so the installer can be built on
@@ -88,7 +96,7 @@ export type AppFiles = {
88
96
  * @param {NsisOptions} options
89
97
  * @returns {Promise<string>} Path to the resulting installer `.exe` file.
90
98
  */
91
- declare function nsis({ appDir, appName, version, arch, outDir, icon, appId, publisher, copyright, license, desktopShortcut, startMenuShortcut, makensisPath, }: NsisOptions): Promise<string>;
99
+ declare function nsis({ appDir, appName, version, arch, outDir, icon, appId, publisher, copyright, license, desktopShortcut, startMenuShortcut, makensisPath, publish, }: NsisOptions): Promise<string>;
92
100
  /**
93
101
  * List the files and directories under `appDir`. Symbolic links are followed,
94
102
  * since NSIS copies what they point at.
@@ -120,9 +128,10 @@ export function listAppFiles(appDir: string): Promise<AppFiles>;
120
128
  * @param {string} options.copyright
121
129
  * @param {boolean} options.desktopShortcut
122
130
  * @param {boolean} options.startMenuShortcut
131
+ * @param {string} [options.appUpdateYamlPath] Absolute path to an `app-update.yml` file to install next to the executable.
123
132
  * @returns {string}
124
133
  */
125
- export function nsisScript({ appDir, appFiles, appName, appId, version, fileVersion, arch, installerPath, iconPath, licensePath, publisher, copyright, desktopShortcut, startMenuShortcut, }: {
134
+ export function nsisScript({ appDir, appFiles, appName, appId, version, fileVersion, arch, installerPath, iconPath, licensePath, publisher, copyright, desktopShortcut, startMenuShortcut, appUpdateYamlPath, }: {
126
135
  appDir: string;
127
136
  appFiles: AppFiles;
128
137
  appName: string;
@@ -137,4 +146,5 @@ export function nsisScript({ appDir, appFiles, appName, appId, version, fileVers
137
146
  copyright: string;
138
147
  desktopShortcut: boolean;
139
148
  startMenuShortcut: boolean;
149
+ appUpdateYamlPath?: string | undefined;
140
150
  }): string;
package/types/util.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  declare namespace _default {
2
2
  export { APPIMAGE_ARCH_KV };
3
+ export { appUpdateYaml };
3
4
  export { desktopExecArg };
4
5
  export { fileExists };
5
6
  export { isWindowsFileName };
@@ -11,7 +12,9 @@ declare namespace _default {
11
12
  export { stringifyDesktopEntry };
12
13
  export { stringifyYaml };
13
14
  export { updateInfoFileName };
15
+ export { validatePublish };
14
16
  export { windowsVersion };
17
+ export { writeUpdateInfo };
15
18
  }
16
19
  export default _default;
17
20
  /**
@@ -20,6 +23,13 @@ export default _default;
20
23
  * @type {Record<"ia32" | "x64" | "arm64", string>}
21
24
  */
22
25
  declare const APPIMAGE_ARCH_KV: Record<"ia32" | "x64" | "arm64", string>;
26
+ /**
27
+ * Contents of the `app-update.yml` file `@nwutils/updater` reads to find out
28
+ * where releases are published. It is shipped next to the NW.js executable.
29
+ * @param {import("./appImage.js").PublishOptions} publish
30
+ * @returns {string}
31
+ */
32
+ declare function appUpdateYaml(publish: import("./appImage.js").PublishOptions): string;
23
33
  /**
24
34
  * Quote `value` as a single argument of a desktop entry's `Exec` key, per the
25
35
  * Desktop Entry Specification: `%` is doubled so it isn't read as a field
@@ -75,8 +85,7 @@ declare function nsisPath(value: string): string;
75
85
  */
76
86
  declare function parseDesktopEntry(contents: string): Record<string, string>;
77
87
  /**
78
- * Compute the base64 encoded SHA-512 digest of the file at `filePath`, the
79
- * same encoding electron-builder writes into its update info files.
88
+ * Compute the base64 encoded SHA-512 digest of the file at `filePath`.
80
89
  * @async
81
90
  * @function
82
91
  * @param {string} filePath
@@ -105,13 +114,21 @@ declare function stringifyDesktopEntry(entries: Record<string, string | undefine
105
114
  */
106
115
  declare function stringifyYaml(entries: Record<string, string | number | boolean | Record<string, string | number | boolean>[] | undefined>): string;
107
116
  /**
108
- * Name of the update info file `@nwutils/updater` downloads to find the
109
- * latest release, following electron-builder's naming: `x64` gets the bare
110
- * `latest-linux.yml`, every other architecture gets an `-<arch>` suffix.
111
- * @param {"ia32" | "x64" | "arm64"} arch
117
+ * Name of the update info file `@nwutils/updater` downloads for `arch` on
118
+ * `platform`, gets the bare
119
+ * `latest-linux.yml` (Linux), `latest.yml` (Windows) or `latest-mac.yml`
120
+ * (MacOS), every other architecture gets an `-<arch>` suffix.
121
+ * @param {string} arch
122
+ * @param {"linux" | "win" | "osx"} [platform="linux"]
112
123
  * @returns {string}
113
124
  */
114
- declare function updateInfoFileName(arch: "ia32" | "x64" | "arm64"): string;
125
+ declare function updateInfoFileName(arch: string, platform?: "linux" | "win" | "osx"): string;
126
+ /**
127
+ * Throw if `publish` isn't a valid `PublishOptions` object.
128
+ * @param {unknown} publish
129
+ * @returns {void}
130
+ */
131
+ declare function validatePublish(publish: unknown): void;
115
132
  /**
116
133
  * Convert a version such as `"1.2.3"` or `"1.2.3-beta.1"` into the
117
134
  * `X.X.X.X` form Windows version resources (and NSIS' `VIProductVersion`)
@@ -121,3 +138,20 @@ declare function updateInfoFileName(arch: "ia32" | "x64" | "arm64"): string;
121
138
  * @returns {string}
122
139
  */
123
140
  declare function windowsVersion(version: string): string;
141
+ /**
142
+ * Write the update info file `@nwutils/updater` compares the running version
143
+ * against, describing the packaged artifact at `filePath`. It must be uploaded
144
+ * to every release next to the artifact.
145
+ * @async
146
+ * @function
147
+ * @param {object} options
148
+ * @param {string} options.filePath Path to the packaged artifact, eg. the AppImage or installer.
149
+ * @param {string} options.updateInfoPath Path the update info file is written to.
150
+ * @param {string} options.version Version of the application.
151
+ * @returns {Promise<void>}
152
+ */
153
+ declare function writeUpdateInfo({ filePath, updateInfoPath, version }: {
154
+ filePath: string;
155
+ updateInfoPath: string;
156
+ version: string;
157
+ }): Promise<void>;