Skip to content

Commit e18c22d

Browse files
committed
fix(electron): retry DMG detach using the disk device
1 parent 4e53323 commit e18c22d

5 files changed

Lines changed: 87 additions & 4 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ jobs:
127127
- name: Install dependencies
128128
run: pnpm install --frozen-lockfile
129129

130-
- name: Validate Windows packaging configuration
131-
if: startsWith(matrix.os, 'windows')
130+
- name: Validate packaging configuration
131+
if: startsWith(matrix.os, 'windows') || startsWith(matrix.os, 'macos')
132132
run: pnpm --dir electron exec node --import tsx --test tests/forge-artifacts.test.ts
133133

134134
- name: Sync version

‎electron/tests/forge-artifacts.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,58 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis
55
import os from "node:os";
66
import path from "node:path";
77
import test from "node:test";
8+
import { promisify } from "node:util";
89
import { createJiti } from "jiti";
910
import { MakerNsis } from "../maker-nsis";
1011
import { MakerWix } from "@electron-forge/maker-wix";
1112

1213
// Load the configuration with the same TypeScript loader used by Electron Forge.
1314
const configPromise = createJiti(import.meta.url).import<ForgeConfig>("../forge.config.ts", { default: true });
1415

16+
test(
17+
"DMG cleanup retries the device after the volume has been unmounted",
18+
{ skip: process.platform !== "darwin" },
19+
async (context) => {
20+
const require = createRequire(import.meta.url);
21+
const installerRequire = createRequire(require.resolve("electron-installer-dmg"));
22+
const appdmgRequire = createRequire(installerRequire.resolve("appdmg"));
23+
const appdmg = installerRequire("appdmg");
24+
const Pipeline = appdmgRequire("./lib/pipeline");
25+
const util = appdmgRequire("./lib/util");
26+
27+
// Exercise the installed dependency's mount/cleanup steps without mounting disks.
28+
context.mock.method(Pipeline.prototype, "run", function () {
29+
return this;
30+
});
31+
for (const stdout of [
32+
"/dev/disk4\tApple_partition_scheme\n/dev/disk4s1\tApple_HFS\t/Volumes/JumpServer Test\n",
33+
"/dev/disk4\tGUID_partition_scheme\n/dev/disk4s2\tApple_APFS\n/dev/disk5\tEF57347C-0000-11AA-AA11-00306543ECAC\n/dev/disk5s1\t41504653-0000-11AA-AA11-00306543ECAC\t/Volumes/JumpServer Test\n"
34+
]) {
35+
const detachTargets: string[] = [];
36+
context.mock.method(util, "sh", (_command, args, callback) => {
37+
if (args[0] === "attach") return callback(null, { stdout });
38+
assert.equal(args[0], "detach");
39+
detachTargets.push(args[1]);
40+
if (detachTargets.length === 1) {
41+
return callback(Object.assign(new Error("Resource busy after unmount"), { code: 16 }));
42+
}
43+
if (args[1].startsWith("/Volumes/")) {
44+
return callback(Object.assign(new Error("No such file or directory"), { code: 2 }));
45+
}
46+
callback(null);
47+
});
48+
const pipeline = appdmg({ target: "unused.dmg", basepath: ".", specification: {} });
49+
await promisify(pipeline.steps.find((step) => step.title === "Mounting temporary image").fn)();
50+
await promisify(pipeline.steps.find((step) => step.title === "Unmounting temporary image").fn)();
51+
assert.deepEqual(detachTargets, ["/dev/disk4", "/dev/disk4"]);
52+
}
53+
54+
const denied = Object.assign(new Error("Permission denied"), { code: 13 });
55+
context.mock.method(util, "sh", (_command, _args, callback) => callback(denied));
56+
await assert.rejects(promisify(appdmgRequire("./lib/hdiutil").detach)("/dev/disk4"), denied);
57+
}
58+
);
59+
1560
test("NSIS builder resolves the download cache API required on clean machines", () => {
1661
const require = createRequire(import.meta.url);
1762
const builderRequire = createRequire(require.resolve("app-builder-lib"));

‎patches/appdmg@0.6.6.patch‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
diff --git a/lib/appdmg.js b/lib/appdmg.js
2+
index 77748bfcdaf55ea9597f65b03c9953c63ffa50dc..ade23ea0f311a18d88b4344362430b64e10a78eb 100644
3+
--- a/lib/appdmg.js
4+
+++ b/lib/appdmg.js
5+
@@ -223,11 +223,13 @@ module.exports = exports = function (options) {
6+
**/
7+
8+
pipeline.addStep('Mounting temporary image', function (next) {
9+
- hdiutil.attach(global.temporaryImagePath, function (err, temporaryMountPath) {
10+
+ hdiutil.attach(global.temporaryImagePath, function (err, temporaryMountPath, temporaryDevice) {
11+
if (err) return next(err)
12+
13+
pipeline.addCleanupStep('unmount-temporary-image', 'Unmounting temporary image', function (next) {
14+
- hdiutil.detach(temporaryMountPath, next)
15+
+ // A busy detach can unmount the volume before failing. Retry the device,
16+
+ // since the mount path may no longer exist: LinusU/node-appdmg#228.
17+
+ hdiutil.detach(temporaryDevice, next)
18+
})
19+
20+
global.temporaryMountPath = temporaryMountPath
21+
diff --git a/lib/hdiutil.js b/lib/hdiutil.js
22+
index a85a3af77883bfd25480db3426a491c4c6ad1632..7f072ba9f999a45d0450b864eea33364426d7a16 100644
23+
--- a/lib/hdiutil.js
24+
+++ b/lib/hdiutil.js
25+
@@ -56,7 +56,10 @@ exports.attach = function (path, cb) {
26+
const m = /\s+(\/Volumes\/.+)$/m.exec(res.stdout)
27+
if (m === null) return cb(new Error('Failed to mount image'))
28+
29+
- cb(null, m[1])
30+
+ const device = /^(\/dev\/disk\d+)\s/m.exec(res.stdout)
31+
+ if (device === null) return cb(new Error('Failed to find mounted image device'))
32+
+
33+
+ cb(null, m[1], device[1])
34+
})
35+
}
36+

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pnpm-workspace.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ allowBuilds:
3232
vue-demi: true
3333

3434
patchedDependencies:
35+
appdmg@0.6.6: patches/appdmg@0.6.6.patch
3536
asciinema-player@3.17.0: patches/asciinema-player@3.17.0.patch
3637
guacamole-common-js-jumpserver@1.1.0-c: >-
3738
patches/guacamole-common-js-jumpserver@1.1.0-c.patch

0 commit comments

Comments
 (0)