Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/mac-updater-zip-symlinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@executor-js/desktop": patch
---

Fix macOS auto-update. The 1.6.9 update zip was built with a 7-Zip that
expanded the framework symlinks into copies, so the extracted app failed code
signing and Squirrel.Mac silently refused to install it; "Restart to update"
appeared to do nothing. electron-builder is bumped to a release that preserves
symlinks, the publish job now verifies the zip's signature before uploading,
and a rejected install surfaces as "Update failed" instead of leaving the card
untouched.
31 changes: 31 additions & 0 deletions .github/workflows/publish-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,37 @@ jobs:
run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts
working-directory: apps/desktop

# electron-updater installs from the zip, not the DMG, and Squirrel.Mac
# rejects it unless the extracted app passes codesign. 1.6.9 shipped a
# zip whose framework symlinks (Versions/Current -> A) had been expanded
# into copies by a 7-Zip upgrade inside electron-builder; the DMG was
# fine, every auto-update silently failed. Extract the zip the way
# Squirrel does and verify it before anything is uploaded.
- name: Verify mac update zip
if: matrix.platform == 'mac'
shell: bash
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
run: |
set -euo pipefail
zip="apps/desktop/dist/executor-desktop-mac-${{ matrix.arch }}.zip"
links=$(unzip -Z "$zip" | grep -c '^l' || true)
echo "symlink entries in $zip: $links"
if [ "$links" -eq 0 ]; then
echo "::error::$zip has no symlink entries; framework bundles were flattened and Squirrel.Mac will reject the update"
exit 1
fi
# Unsigned builds (forks, no CSC_LINK) cannot pass codesign; the
# symlink check above still catches the flattening on its own.
if [ -z "${CSC_LINK:-}" ]; then
echo "no signing certificate configured; skipping codesign verification"
exit 0
fi
tmp=$(mktemp -d)
ditto -x -k "$zip" "$tmp"
codesign --verify --deep --strict --verbose=1 "$tmp/Executor.app"
rm -rf "$tmp"

# The two mac legs each emit a latest-mac.yml listing only their own
# arch. Rename per-arch here; the release job merges them back into the
# single latest-mac.yml electron-updater clients fetch. Without this,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"@zip.js/zip.js": "^2.8.26",
"bun-types": "catalog:",
"electron": "41.10.3",
"electron-builder": "^26",
"electron-builder": "26.16.1",
"electron-vite": "^5",
"quickjs-emscripten": "catalog:",
"typescript": "catalog:",
Expand Down
37 changes: 19 additions & 18 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
app,
autoUpdater as nativeAutoUpdater,
BrowserWindow,
dialog,
ipcMain,
Expand Down Expand Up @@ -806,17 +807,11 @@ const registerIpcHandlers = () => {
// Outside a packaged build there is no real bundle to swap, and quitting
// would tear down the e2e harness — reflect "installing" so the renderer
// can prove the wiring instead.
if (!app.isPackaged) {
setUpdateStatus({ state: "installing", version });
return;
}
// Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching
// the native dialog's restart path.
stopSupervisedMonitor();
if (connection) {
await stopConnection(connection);
connection = null;
}
setUpdateStatus({ state: "installing", version });
if (!app.isPackaged) return;
// Squirrel.Mac only validates the staged bundle now; the sidecar is torn
// down in 'before-quit-for-update' once it has accepted the update, so a
// rejected zip leaves the app usable and surfaces via the 'error' handler.
autoUpdater.quitAndInstall(false, true);
});
// Crash-screen last resort for damaged state: confirm, move the data dir
Expand Down Expand Up @@ -937,13 +932,7 @@ const promptInstallUpdate = async (version: string) => {
cancelId: 1,
});
if (response.response === 0) {
// Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A
// supervised daemon is left running — it's independent of this bundle.
stopSupervisedMonitor();
if (connection) {
await stopConnection(connection);
connection = null;
}
setUpdateStatus({ state: "installing", version });
autoUpdater.quitAndInstall(false, true);
return;
}
Expand All @@ -963,6 +952,18 @@ const setupAutoUpdater = () => {
autoUpdater.logger = log;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false;
// Fired by Electron's native updater once Squirrel.Mac has downloaded and
// validated the bundle and is about to quit for the swap. Stop a spawned
// sidecar here rather than before quitAndInstall: if Squirrel rejects the
// update (bad signature, corrupt zip) nothing has been torn down. A
// supervised daemon is left running — it's independent of this bundle.
nativeAutoUpdater.on("before-quit-for-update", () => {
stopSupervisedMonitor();
if (connection) {
void stopConnection(connection);
connection = null;
}
});

autoUpdater.on("update-available", (info: UpdateInfo) => {
pendingUpdateVersion = info.version;
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/updater-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ describe("updater state decisions", () => {
});
});

it("moves a staged or installing update to error when Squirrel rejects it", () => {
expect(
statusAfterUpdateError({ state: "downloaded", version: "1.6.9" }, "Update failed"),
).toEqual({ state: "error", version: "1.6.9", message: "Update failed" });
expect(
statusAfterUpdateError({ state: "installing", version: "1.6.9" }, "Update failed"),
).toEqual({ state: "error", version: "1.6.9", message: "Update failed" });
});

it("restores autoInstallOnAppQuit only when the fatal path recovers", () => {
expect(
planFatalAutoInstallOnQuit({
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/main/updater-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,17 @@ export const planDownloadedUpdate = (input: DownloadedUpdateInput): DownloadedUp
};
};

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

export const planFatalAutoInstallOnQuit = (input: {
Expand Down
Loading
Loading