Skip to content

Commit dc0808d

Browse files
authored
Fix macOS auto-update: preserve framework symlinks in the update zip (#2049)
1 parent 004024b commit dc0808d

7 files changed

Lines changed: 105 additions & 140 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@executor-js/desktop": patch
3+
---
4+
5+
Fix macOS auto-update. The 1.6.9 update zip was built with a 7-Zip that
6+
expanded the framework symlinks into copies, so the extracted app failed code
7+
signing and Squirrel.Mac silently refused to install it; "Restart to update"
8+
appeared to do nothing. electron-builder is bumped to a release that preserves
9+
symlinks, the publish job now verifies the zip's signature before uploading,
10+
and a rejected install surfaces as "Update failed" instead of leaving the card
11+
untouched.

‎.github/workflows/publish-desktop.yml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,37 @@ jobs:
206206
run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts
207207
working-directory: apps/desktop
208208

209+
# electron-updater installs from the zip, not the DMG, and Squirrel.Mac
210+
# rejects it unless the extracted app passes codesign. 1.6.9 shipped a
211+
# zip whose framework symlinks (Versions/Current -> A) had been expanded
212+
# into copies by a 7-Zip upgrade inside electron-builder; the DMG was
213+
# fine, every auto-update silently failed. Extract the zip the way
214+
# Squirrel does and verify it before anything is uploaded.
215+
- name: Verify mac update zip
216+
if: matrix.platform == 'mac'
217+
shell: bash
218+
env:
219+
CSC_LINK: ${{ secrets.CSC_LINK }}
220+
run: |
221+
set -euo pipefail
222+
zip="apps/desktop/dist/executor-desktop-mac-${{ matrix.arch }}.zip"
223+
links=$(unzip -Z "$zip" | grep -c '^l' || true)
224+
echo "symlink entries in $zip: $links"
225+
if [ "$links" -eq 0 ]; then
226+
echo "::error::$zip has no symlink entries; framework bundles were flattened and Squirrel.Mac will reject the update"
227+
exit 1
228+
fi
229+
# Unsigned builds (forks, no CSC_LINK) cannot pass codesign; the
230+
# symlink check above still catches the flattening on its own.
231+
if [ -z "${CSC_LINK:-}" ]; then
232+
echo "no signing certificate configured; skipping codesign verification"
233+
exit 0
234+
fi
235+
tmp=$(mktemp -d)
236+
ditto -x -k "$zip" "$tmp"
237+
codesign --verify --deep --strict --verbose=1 "$tmp/Executor.app"
238+
rm -rf "$tmp"
239+
209240
# The two mac legs each emit a latest-mac.yml listing only their own
210241
# arch. Rename per-arch here; the release job merges them back into the
211242
# single latest-mac.yml electron-updater clients fetch. Without this,

‎apps/desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"@zip.js/zip.js": "^2.8.26",
4949
"bun-types": "catalog:",
5050
"electron": "41.10.3",
51-
"electron-builder": "^26",
51+
"electron-builder": "26.16.1",
5252
"electron-vite": "^5",
5353
"quickjs-emscripten": "catalog:",
5454
"typescript": "catalog:",

‎apps/desktop/src/main/index.ts‎

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from "node:path";
44
import { fileURLToPath } from "node:url";
55
import {
66
app,
7+
autoUpdater as nativeAutoUpdater,
78
BrowserWindow,
89
dialog,
910
ipcMain,
@@ -806,17 +807,11 @@ const registerIpcHandlers = () => {
806807
// Outside a packaged build there is no real bundle to swap, and quitting
807808
// would tear down the e2e harness — reflect "installing" so the renderer
808809
// can prove the wiring instead.
809-
if (!app.isPackaged) {
810-
setUpdateStatus({ state: "installing", version });
811-
return;
812-
}
813-
// Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching
814-
// the native dialog's restart path.
815-
stopSupervisedMonitor();
816-
if (connection) {
817-
await stopConnection(connection);
818-
connection = null;
819-
}
810+
setUpdateStatus({ state: "installing", version });
811+
if (!app.isPackaged) return;
812+
// Squirrel.Mac only validates the staged bundle now; the sidecar is torn
813+
// down in 'before-quit-for-update' once it has accepted the update, so a
814+
// rejected zip leaves the app usable and surfaces via the 'error' handler.
820815
autoUpdater.quitAndInstall(false, true);
821816
});
822817
// Crash-screen last resort for damaged state: confirm, move the data dir
@@ -937,13 +932,7 @@ const promptInstallUpdate = async (version: string) => {
937932
cancelId: 1,
938933
});
939934
if (response.response === 0) {
940-
// Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A
941-
// supervised daemon is left running — it's independent of this bundle.
942-
stopSupervisedMonitor();
943-
if (connection) {
944-
await stopConnection(connection);
945-
connection = null;
946-
}
935+
setUpdateStatus({ state: "installing", version });
947936
autoUpdater.quitAndInstall(false, true);
948937
return;
949938
}
@@ -963,6 +952,18 @@ const setupAutoUpdater = () => {
963952
autoUpdater.logger = log;
964953
autoUpdater.autoDownload = true;
965954
autoUpdater.autoInstallOnAppQuit = false;
955+
// Fired by Electron's native updater once Squirrel.Mac has downloaded and
956+
// validated the bundle and is about to quit for the swap. Stop a spawned
957+
// sidecar here rather than before quitAndInstall: if Squirrel rejects the
958+
// update (bad signature, corrupt zip) nothing has been torn down. A
959+
// supervised daemon is left running — it's independent of this bundle.
960+
nativeAutoUpdater.on("before-quit-for-update", () => {
961+
stopSupervisedMonitor();
962+
if (connection) {
963+
void stopConnection(connection);
964+
connection = null;
965+
}
966+
});
966967

967968
autoUpdater.on("update-available", (info: UpdateInfo) => {
968969
pendingUpdateVersion = info.version;

‎apps/desktop/src/main/updater-state.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ describe("updater state decisions", () => {
7979
});
8080
});
8181

82+
it("moves a staged or installing update to error when Squirrel rejects it", () => {
83+
expect(
84+
statusAfterUpdateError({ state: "downloaded", version: "1.6.9" }, "Update failed"),
85+
).toEqual({ state: "error", version: "1.6.9", message: "Update failed" });
86+
expect(
87+
statusAfterUpdateError({ state: "installing", version: "1.6.9" }, "Update failed"),
88+
).toEqual({ state: "error", version: "1.6.9", message: "Update failed" });
89+
});
90+
8291
it("restores autoInstallOnAppQuit only when the fatal path recovers", () => {
8392
expect(
8493
planFatalAutoInstallOnQuit({

‎apps/desktop/src/main/updater-state.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,17 @@ export const planDownloadedUpdate = (input: DownloadedUpdateInput): DownloadedUp
8989
};
9090
};
9191

92+
// Any state that names a version is an update in flight, including a staged
93+
// ("downloaded") or installing one: Squirrel.Mac validates the bundle only when
94+
// the install starts, so a rejected zip surfaces as an error *after* the card
95+
// already offered "Restart to update". Dropping that error left the card
96+
// unchanged and the click looked like a no-op.
9297
export const statusAfterUpdateError = (
9398
status: DesktopUpdateStatus,
9499
message: string,
95100
): DesktopUpdateStatus => {
96-
if (status.state === "available" || status.state === "downloading" || status.state === "error") {
97-
return { state: "error", version: status.version, message };
98-
}
99-
return status;
101+
if (status.state === "idle") return status;
102+
return { state: "error", version: status.version, message };
100103
};
101104

102105
export const planFatalAutoInstallOnQuit = (input: {

0 commit comments

Comments
 (0)