feat: auto-update UX for Windows, Linux AppImage, and macOS#39
Merged
Conversation
`electron-updater` was already wired up at startup but the renderer
never listened, leaving users with no signal that an update was
queued, no manual check entry, and no way to apply the update on
demand. This fills in the missing pieces and adds a macOS-safe
fallback for unsigned builds.
## What ships
- Bottom-right banner appears when an update is ready:
* Windows + Linux AppImage: "Restart and install" applies the
downloaded build via Squirrel; "Later" hides until next launch.
* macOS + Linux .deb: "Open download page" jumps to the GitHub
release; "Skip this version" persists per-version so users
aren't nagged on every launch.
- New "About" tab in Settings: app version, platform, last-checked
timestamp, status line, "Check for updates" button, plus
"Restart and install" / "Open download page" depending on mode.
- Periodic re-check every 6 hours after the launch check (skipped if
state is already in flight or terminal-pending).
- electron-updater logs piped into electron-log (rotating file) so
failures users report can be diagnosed after the fact.
## Architecture
- `src/preload/updateTypes.ts` (new) — `UpdateState`, `UpdateStatus`,
`InstallMode`. Single shared type used by main, preload, and
renderer.
- `src/main/services/updateState.ts` (new) — pure reducer with
forward-only guards: `available` / `download-progress` /
`downloaded` ignore late or duplicate events that would otherwise
regress a downloaded or installing state. Capability flags
(`canCheck`, `canInstall`, `canOpenDownload`) are derived
deterministically so the renderer never re-derives platform rules.
- `src/main/services/autoUpdater.ts` (rewritten) — wraps
`electron-updater`, dispatches reducer actions, broadcasts state
via `app:updateStateChanged`, exposes
`getUpdateState`/`checkForUpdates`/`installUpdate`/`openUpdateDownload`.
Platform routing: darwin → manualDownload, win32 → autoInstall,
linux → autoInstall iff `process.env.APPIMAGE`, else
manualDownload. Release URLs are constructed in main from a
constant base — never accepted from the renderer.
- `src/preload/index.ts` — flat `app.*` methods + a typed
`app.onUpdateStateChanged` subscription that returns its own
unsubscribe.
- `src/renderer/src/stores/updateStore.ts` (new) — Zustand store.
Subscribes to push events FIRST then primes from `getUpdateState`,
and only seeds when state is still null so a slow snapshot reply
can't clobber a newer pushed event during App mount.
- `src/renderer/src/components/common/UpdateBanner.tsx` (new) —
per-launch dismiss + persisted `skippedUpdateVersion` setting.
- `src/renderer/src/components/settings/SettingsModal.tsx` — new
About tab + AboutPanel.
## macOS workaround rationale
Squirrel.Mac validates that the running bundle's code-signature
certificate matches the replacement's. Ad-hoc signed builds use
ephemeral identifiers, so the swap is rejected. Until Developer ID
signing + notarization land, macOS sets both `autoDownload` and
`autoInstallOnAppQuit` to false and the renderer renders the
"Open download page" path. Removing the macOS branch is a one-line
change once signing is in place.
## Tests
- 16 reducer transitions covered in
`tests/unit/update-state.test.ts`, including the forward-only
guards added in response to the dual code review.
- Existing 10 e2e tests pass against the new build.
## Code review
Two parallel code reviews (Opus, GPT) found two issues, both fixed:
- Reducer didn't guard `available` / `download-progress` /
`downloaded` from regressing terminal-pending states. Added
forward-only guards + tests.
- Renderer `init()` could clobber a pushed state with a slow
`getUpdateState()` reply. Now subscribes first and seeds only
when state is still null.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the missing user-facing auto-update experience on top of the existing
electron-updaterwiring. Windows and Linux AppImage now get true auto-install with a one-click "Restart and install" banner; macOS and.debbuilds (where in-place replacement isn't safe today) get a "notify + open download page" fallback with a persisted "Skip this version" so users aren't nagged every launch.Highlights
installMode:autoInstall(Win / Linux AppImage): "Restart and install" →quitAndInstall.manualDownload(macOS /.deb): "Open download page" → opens the GitHub release; secondary "Skip this version" persists in settings.electron-logso update failures land in a rotating file users can attach to bug reports.Architecture
src/preload/updateTypes.ts— single shared type (UpdateState,UpdateStatus,InstallMode) used by main, preload, and renderer.src/main/services/updateState.ts— pure reducer with forward-only guards. Capability flags (canCheck,canInstall,canOpenDownload) are derived deterministically so the renderer never re-derives platform rules.src/main/services/autoUpdater.ts— rewritten as a thin wrapper aroundelectron-updaterthat dispatches reducer actions and broadcasts state viaapp:updateStateChanged. Platform routing:darwin→manualDownload(bothautoDownloadandautoInstallOnAppQuitset tofalse).win32→autoInstall.linux→autoInstalliffprocess.env.APPIMAGE, elsemanualDownload.useUpdateStoresubscribes to push events first and seeds fromgetUpdateStateonly when state is still null, so a slow snapshot reply can't clobber a newer pushed event during App mount.macOS workaround rationale
Squirrel.Mac validates that the running bundle's code-signature certificate matches the replacement's. Ad-hoc signed builds use ephemeral identifiers, so the swap is rejected. Until Developer ID signing + notarization land, macOS uses the manual flow. Removing the macOS branch is a one-line change in
resolveInstallMode()once signing is in place.Testing
tests/unit/update-state.test.ts(full state machine, the new forward-only guards, capability derivation, andshouldSkipPeriodicCheck).npm run typecheckclean.Code review
Two parallel code-review agents (Claude Opus + GPT-5.5) flagged two real issues, both fixed in this branch:
available/download-progress/downloadedfrom regressing terminal-pending states (e.g., a staleupdate-availableevent could movedownloadedback toavailable). Added forward-only guards + 5 new tests covering them.init()could clobber a newly-pushed state with a slowgetUpdateState()reply. Now subscribes first and only seeds when state is still null.What's next (out of scope)
resolveInstallMode()so darwin usesautoInstalllike everyone else.allowPrereleaseis implicitly driven by whether the running version contains a-(semver prerelease) — fine for a sub-1.0 product but worth exposing later.