Skip to content

feat(desktop): signed + notarized macOS app, arm64 only, updating itself - #121

Merged
Lexus2016 merged 2 commits into
mainfrom
feat/macos-signed-self-update
Sep 28, 2026
Merged

Lexus2016 merged 2 commits into
mainfrom
feat/macos-signed-self-update

Conversation

@Lexus2016

Copy link
Copy Markdown
Owner

The macOS build is signed with the Developer ID certificate, notarized, stapled and arm64-only. With a signature in place the app updates itself through electron-updater (Squirrel.Mac) like Windows/Linux; the app-triggered brew upgrade --cask flow is removed.

What changes

  • Signing: hardened runtime; entitlements = electron-builder's template + automation.apple-events (server.js drives Terminal via osascript; under the hardened runtime that event is checked against this app) for the app and its helpers.
  • Updates: one electron-updater path on every OS. before-quit-for-update sets app.isQuiting (Squirrel closes windows before quitting; close-to-tray would cancel it). A dmg mount / App-Translocated copy is refused with "move the app to Applications". Only an error during a user-started install turns the banner into Retry.
  • CI: mac leg fails without MAC_CSC_LINK or with an incomplete APPLE_* set — no unsigned fallback. bump-cask handles the single-arch cask and verifies its own sed.
  • Cask: transitional for ~1 month so pre-signing installs (whose button runs brew) can reach this release — see docs/electron-desktop/MAC-SIGNING.md.

Verification

  • npm test green (new mac-signing.test.js, rewritten update-flow.test.js; key assertions mutation-checked).
  • Local signed + notarized build: spctl → source=Notarized Developer ID, stapler validate OK, all 19 Mach-O signed with runtime + timestamp; the app boots (/api/health 200).
  • End-to-end Squirrel.Mac with two signed builds on a local feed: 7.17.90 → 7.17.91 staged, swapped, relaunched, signature valid. Same run without the before-quit-for-update line: stuck on 7.17.90 in the tray.

Before the next release

Set the five repo secrets listed in MAC-SIGNING.md (MAC_CSC_LINK, MAC_CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID), or the mac leg fails by design. The tap cask is converted to the single-arch shape right after this merges.

Lexus2016 and others added 2 commits September 28, 2026 15:20
The macOS build is now signed with the Developer ID Application certificate
(BKZ6Y9W9MF), notarized and stapled, and built for Apple Silicon only. With a
signature in place the app updates itself through electron-updater
(Squirrel.Mac) like Windows/Linux, and the app-triggered `brew upgrade --cask`
flow is gone.

Signing (electron-builder.yml, build/entitlements.mac.plist):
- hardened runtime; entitlements = electron-builder's template plus
  automation.apple-events, for the app AND its helpers. server.js drives
  Terminal via osascript, and under the hardened runtime that Apple Event is
  checked against this app — without the key it is refused with no prompt.
- mac targets pinned to arm64 (macOS 27 dropped Intel; Homebrew moved it to
  Tier 3). NSAppleEventsUsageDescription for the Automation prompt.
- local notarization reads a notarytool keychain profile named in the
  gitignored electron-builder.env, which is also excluded from the bundle.

Updates (electron/main.js, preload.js, the banner in index.html):
- one electron-updater path on every OS; listeners registered once (per-click
  registration stacked a set on every Retry).
- before-quit-for-update sets app.isQuiting: Squirrel closes the windows
  before quitting and close-to-tray would cancel the quit. Verified with two
  signed builds on a local feed: with it, 7.17.90 -> 7.17.91 staged, swapped,
  relaunched; without it the app stayed on 7.17.90 in the tray.
- a dmg mount or an App-Translocated copy cannot be swapped in place; the
  banner says "move the app to Applications" instead of offering a button.
- only an error during an install the user started turns the banner into
  Retry (update:failed) — a failed CHECK also emits `error`.

CI (release-desktop.yml):
- the mac leg FAILS without MAC_CSC_LINK, or with an incomplete APPLE_* set:
  Squirrel refuses unsigned updates, so there is no unsigned fallback.
- bump-cask handles the single-arch cask and refuses to push when sed did not
  rewrite version + sha256 (sed exits 0 on no match).

The Homebrew cask stays for about a month so installs from before this
release, whose update button runs brew, can reach it; then it is retired
(docs/electron-desktop/MAC-SIGNING.md).

Tests: test/mac-signing.test.js (new, 12 checks) and a rewritten
test/update-flow.test.js (35 checks, the banner run in a vm).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
From an independent review (codex, agy) and an end-to-end rerun:

- Block only a read-only VOLUME (EROFS: dmg mount, App Translocation). A
  permission denial (EACCES, /Applications on a standard account) is left to
  Squirrel — "move the app to Applications" was wrong advice for an app
  already there. Measured: dmg → EROFS, /private/var/root → EACCES.
- Offer what electron-updater accepted (isUpdateAvailable), not a version
  comparison: it returns updateInfo for a rejected release too, and
  downloadUpdate() then failed with "Please check update first".
- MacUpdater.quitAndInstall() adds a native listener it never removes when
  Squirrel fails; the app takes it back off so a Retry cannot stack installs.
- One install per attempt, and a synchronous quitAndInstall() failure is a
  reported failure: an e2e run that downloaded twice threw Squirrel's "The
  command is disabled" out of a timer — an uncaught main-process exception.
- Progress and failures go to every window, not getAllWindows()[0].
- Banner: a click refused as blocked restores its title instead of freezing
  on "Updating… 1s"; a rejected start() becomes Retry.

Re-verified end to end with the final code: signed 7.17.90 → 7.17.91 via a
local feed, one download, staged, swapped, relaunched, signature valid.
test/update-flow.test.js: 51 checks, each new guard mutation-checked.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@Lexus2016
Lexus2016 merged commit 061bce4 into main Sep 28, 2026
2 checks passed
@Lexus2016
Lexus2016 deleted the feat/macos-signed-self-update branch September 28, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant