Skip to content

feat: auto-update UX for Windows, Linux AppImage, and macOS#39

Merged
Ron537 merged 1 commit intomainfrom
feat/auto-update
May 7, 2026
Merged

feat: auto-update UX for Windows, Linux AppImage, and macOS#39
Ron537 merged 1 commit intomainfrom
feat/auto-update

Conversation

@Ron537
Copy link
Copy Markdown
Owner

@Ron537 Ron537 commented May 7, 2026

Summary

Adds the missing user-facing auto-update experience on top of the existing electron-updater wiring. Windows and Linux AppImage now get true auto-install with a one-click "Restart and install" banner; macOS and .deb builds (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

  • Update banner at bottom-right when something is ready, two flavors driven by 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.
  • About tab in Settings shows version, platform, last-checked time, status, and a manual "Check for updates" button.
  • Periodic re-check every 6 hours after launch (skipped while a check is in flight or once a download is pending/installing).
  • Logging via electron-log so 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 around electron-updater that dispatches reducer actions and broadcasts state via app:updateStateChanged. Platform routing:
    • darwinmanualDownload (both autoDownload and autoInstallOnAppQuit set to false).
    • win32autoInstall.
    • linuxautoInstall iff process.env.APPIMAGE, else manualDownload.
  • Release URLs are built in main from a constant base — never accepted from the renderer.
  • Renderer useUpdateStore subscribes to push events first and seeds from getUpdateState only 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

  • ✅ 16 reducer transitions covered in tests/unit/update-state.test.ts (full state machine, the new forward-only guards, capability derivation, and shouldSkipPeriodicCheck).
  • ✅ Existing 10 Playwright e2e tests pass against the new build.
  • npm run typecheck clean.

Code review

Two parallel code-review agents (Claude Opus + GPT-5.5) flagged two real issues, both fixed in this branch:

  1. Reducer didn't guard available / download-progress / downloaded from regressing terminal-pending states (e.g., a stale update-available event could move downloaded back to available). Added forward-only guards + 5 new tests covering them.
  2. Renderer init() could clobber a newly-pushed state with a slow getUpdateState() reply. Now subscribes first and only seeds when state is still null.

What's next (out of scope)

  • Developer ID signing + notarization for macOS — once that lands, drop the macOS branch in resolveInstallMode() so darwin uses autoInstall like everyone else.
  • Optional release-channel switcher in the About tab. Today allowPrerelease is implicitly driven by whether the running version contains a - (semver prerelease) — fine for a sub-1.0 product but worth exposing later.

`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>
@Ron537 Ron537 merged commit c3b3654 into main May 7, 2026
5 checks passed
@Ron537 Ron537 deleted the feat/auto-update branch May 7, 2026 18:02
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