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:

PlatformUpload
macOSlatest-mac.yml, the .dmg (and .zip for updates), and their .blockmap
Windowslatest.yml, the NSIS -setup.exe, and its .blockmap
Linuxlatest-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 update Conveyor domain to the /update page 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 stable latest channel ships today; allowPrerelease and allowDowngrade are 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.

On this page