Auto Update
Ship updates through a self-hosted electron-updater feed.
Auto-update is Flag-gated, default ON. It uses electron-updater against a generic, self-hosted feed — there's no update server to run, just static file hosting. The app checks the feed, and updates install only with the user's consent.
The model
electron-builder.yml publishes to a generic provider whose URL comes from the
packaging env:
publish:
provider: generic
channel: latest
url: ${env.UPDATE_SERVER_URL}Nothing dynamic runs on the server. For each release you upload a small set of static files per platform to that URL:
| Platform | Upload |
|---|---|
| macOS | latest-mac.yml, the .dmg (and .zip for updates), and their .blockmap |
| Windows | latest.yml, the NSIS -setup.exe, and its .blockmap |
| Linux | latest-linux.yml and the .AppImage |
The latest*.yml metadata file is what electron-updater reads to discover the
newest version; the .blockmap enables differential downloads. UPDATE_SERVER_URL
is set in electron-builder.env — see Packaging.
In-app behavior
lib/main/updater.ts (UpdateService) is deliberately consent-first — it never
downloads or installs behind the user's back:
// lib/main/updater.ts
this.updater.autoDownload = false
this.updater.autoInstallOnAppQuit = false- Background checks run every 6 hours while the user's auto-update
setting is on, with a 30-second startup delay so a cold launch is never
blocked on the network (
AUTO_UPDATE_POLL_INTERVAL_MS/AUTO_UPDATE_STARTUP_DELAY_MS). - Status is streamed over the
updateConveyor domain to the/updatepage and the Updates settings tab, and to the tray. States:idle,checking,available,downloading,progress,downloaded,error. - Manual check / download / install are user actions on the Update page; a
found update prompts, downloads on request, then
quitAndInstall()on the user's click. - Channel is read from settings (
applyChannel). Only the stablelatestchannel ships today;allowPrereleaseandallowDowngradeare both forced off.
Feed errors are swallowed into the published error status rather than thrown,
so an unreachable feed surfaces as a message in the UI, never a crash.
Signing is a prerequisite
An unsigned build can't reliably self-update:
- macOS updates require signed + notarized builds — Gatekeeper rejects an unsigned replacement.
- Windows signing is strongly recommended; unsigned updates trip SmartScreen.
Set up signing in Packaging before relying on auto-update in production.
Release flow
Bump the version
Increment version in package.json.
Build signed installers
pnpm build:mac|win|linux on each platform (or CI), with signing/notarization
credentials set.
Upload to the feed
Copy the installers + latest*.yml + .blockmap files to UPDATE_SERVER_URL.
Verify
Launch an older install and confirm it discovers the new version and updates.
The repo ships a .github/workflows/release.yml that automates exactly this: on a
v* tag it matrix-builds signed/notarized installers for all three platforms and
rsyncs them (with the latest channel metadata) to the feed. All of its secrets
are optional and degrade gracefully when unset.
Turning it off
Set VITE_AUTO_UPDATE_ENABLED=false to strip the update UI — the Updates
settings tab and /update route surfaces disappear (the route still resolves if
reached directly). The main-process UpdateService is still constructed, but its
background scheduler only runs when the user's autoUpdate setting is on (default
off), so a flagged-off build simply never surfaces update controls. See
Environment Variables.
