Auto-updater design
Tracking issue: #236.
Applies to the Electron desktop build only; the web build self-refreshes
on reload and ignores settings.updater.*.
Goals
- The packaged app can update itself from GitHub Releases without the user having to download installers manually.
- One backup minimum — a release can never destroy the user's data. A pre-install backup is taken every time and at least one snapshot is kept alive at all times, even when retention is misconfigured.
- Backups must be restorable — the user can roll back to any saved snapshot from inside the app, with a safety snapshot of the current state taken first.
- The user keeps control: an opt-out toggle, configurable retention, and a "notify only" mode for users who never want background downloads.
Architecture
┌────────────────────┐ IPC ┌────────────────────┐
│ Renderer (React) │ <──────► │ Main (Electron) │
│ │ │ │
│ UpdateNotification │ events │ electron/updater │ ──► electron-updater
│ SettingsPage │ ──────► │ electron/backup │ ──► fs (atomic)
│ src/utils/updater │ ──IPC──► │ electron/main │
└────────────────────┘ └────────────────────┘
electron/updater.cjswrapsautoUpdaterfromelectron-updater.- Forces
autoDownload = falseso we always show the banner first. - Emits
update-available,download-progress,update-downloaded,errorto all rendererwebContents. - Exposes
checkForUpdates,downloadUpdate, andquitAndInstall. quitAndInstallcallsbackup.createBackup+backup.rotateBackupsbefore handing control to the installer. If the backup fails we surfacebackupFailedand refuse to install.
- Forces
electron/backup.cjsis a pure-Node module (no Electron imports) so it can be unit-tested under vitest with@vitest-environment node. See Manifest schema below.electron/main.cjsregisters IPC channels inapp.whenReady:updater:check,updater:download,updater:install,updater:getStatusbackup:create,backup:list,backup:restore,backup:rotate,backup:getDir
electron/preload.cjswhitelists those channels and exposes them aswindow.fireTools.updater.*andwindow.fireTools.backup.*.src/utils/updater.tsis a thin renderer-side bridge with typed helpers; it falls back to no-ops in the web build (wherewindow.fireToolsis undefined).src/components/UpdateNotification.tsxsubscribes to events and renders the in-app banner (download / install / dismiss).src/components/SettingsPage.tsx— Updates & backups section surfaces every knob plus the backup table with Restore action.
Settings (UserSettings.updater)
Defined in src/utils/cookieSettings.ts:
interface UpdaterSettings {
autoCheck: boolean; // default true — check on startup + interval
autoDownload: boolean; // default false — auto-download approved updates
notifyOnly: boolean; // default false — never download/install
keepBackups: number; // default 3, clamped to [1, 100]
}
mergeUpdaterSettings(input) is the single, idempotent normalizer:
- Wrong types fall back to defaults.
keepBackupsis floored, clamped, and never goes below1— passing0,-5,NaN, orInfinityall resolve to the minimum.
Backup layout
<userData>/backups/
2025-01-15T12-34-56-789Z-1.2.3/
manifest.json
firetools.db
firetools.db-wal (only if present)
firetools.db-shm (only if present)
window-state.json (if present)
auto-update.json (if present)
2025-01-14T09-12-44-001Z-1.2.2/
...
The directory name is <ISO-timestamp>-<version>, sortable
lexicographically — the listing is returned newest-first.
Note: cookie-backed renderer settings (
fire-calculator-settings) live in the browser cookie store, not inuserData, so they are not part of the snapshot. Users who clear cookies still need to re-enter them. The DB andauto-update.jsonare the durable record.
Manifest schema
manifest.json (schema: 1):
{
"schema": 1,
"id": "2025-01-15T12-34-56-789Z-1.2.3",
"timestamp": "2025-01-15T12:34:56.789Z",
"version": "1.2.3",
"files": [
{ "name": "firetools.db", "bytes": 1572864, "sha256": "..." },
{ "name": "firetools.db-wal", "bytes": 8192, "sha256": "..." },
{ "name": "window-state.json","bytes": 128, "sha256": "..." }
],
"totalBytes": 1581184
}
A snapshot is valid when every file listed in the manifest exists on
disk and re-hashes to the same SHA-256. listBackups returns
valid: false (+ error) for any snapshot that fails this check, so the
UI can disable Restore.
Atomicity
createBackup:
- Writes to
backups/.tmp-<id>/. - Copies each source file in turn, hashing as it goes.
- Writes
manifest.jsonlast. - Renames the tmp dir to the final
<id>/. The rename is the commit point — partial backups never appear inlistBackups.
restoreBackup:
- Calls
createBackup({ version: '<current>-prerestore' })first as a safety net. - Verifies the chosen backup is valid.
- For each file in the manifest, copies into
<userData>/.restore-<id>/then renames over the live file. - Returns the
safetyBackupIdso the UI can offer a single-click undo.
Rotation invariant
rotateBackups({ keep }):
- Clamps
keepto[1, 100].keep <= 0→1.keep > 100→100. - Sorts backups by timestamp descending and removes everything past
index
keep - 1. - Returns
{ kept, removed }.
The clamp guarantees the app can never wipe every backup, even if a future caller passes
0or a corrupt setting sneaks through.
Failure modes & recovery
| Failure | Behaviour |
|---|---|
| Network error during check | Logged, banner not shown, user can retry from Settings |
| Download error | updater:error event → banner shows retry option |
| Backup fails before install | Install aborted, banner shows "Backup failed" with details |
| Install fails after backup | App relaunches old version; snapshot still on disk for rollback |
| DB corrupted after update | Settings → Backups → Restore previous snapshot (one click) |
| User aborts restore midway | Pre-restore safety snapshot is intact; Restore is rename-based |
Testing
tests/shared/electron-backup.test.ts(node env) — backup/list/rotate /restore including the min-1 invariant and.tmp-cleanup.tests/pages/settings/cookieSettings.test.ts—mergeUpdaterSettingsdefaults, clamping, NaN handling, and round-trip through save/load.tests/shared/i18n.test.ts— strict parity across the 5 locales for the newupdate.*/settings.updater.*keys.
Distribution
electron-builder.yml publishes to GitHub Releases:
publish:
- provider: github
owner: fire-tools-inc
repo: app
.github/workflows/release.yml uploads the *.yml and *.blockmap
files alongside the installer so electron-updater can resolve the
latest version.
Artifact naming (do not break the updater)
Artifact file names must not contain spaces. electron-builder.yml
pins a space-free artifactName (Fire.Tools-...) for exactly this
reason. A space in productName ("Fire Tools") is otherwise mangled
three different ways and the updater 404s:
| Stage | Name |
|---|---|
on-disk artifact (default ${productName} template) |
Fire Tools-... (space) |
latest*.yml url (electron-builder sanitizes) |
Fire-Tools-... (hyphen) |
| uploaded GitHub release asset (Releases rewrites) | Fire.Tools-... (dot) |
electron-updater fetches the manifest url (hyphen) which never
matches the uploaded asset (dot) -> 404. Pinning dot-based artifact
names makes the manifest URL and uploaded asset match. The release
workflow's Verify update manifests reference staged assets step still
fails the build if any latest*.yml entry has no matching staged file,
so this can't silently ship again.
Local testing
The updater is disabled by default in npm run electron:dev because
unpacked apps cannot meaningfully self-replace. Two env flags opt in:
FIRETOOLS_UPDATER_FORCE_DEV=1— runs a real check against the configured GitHub repo. Requireselectron/dev-app-update.yml(already committed). Only useful once a release published via therelease.ymlworkflow exists; older releases (e.g.v1.1.0) lack thelatest-mac.yml/latest.ymlmetadata and will 404.FIRETOOLS_UPDATER_MOCK=1— simulates the full lifecycle (checking → available → downloading → downloaded) without contacting GitHub.quitAndInstallis a no-op so the dev app keeps running, but the pre-install backup does execute — inspect it under<userData>/backups/. Use this to verify the UI banner, settings toggles, and backup rotation end-to-end on a developer machine.
Example:
FIRETOOLS_UPDATER_MOCK=1 npm run electron:dev
Then open Settings → Updates & Backups, click Check for updates, and
walk through the simulated download/install. After clicking
Install & restart, a new backup directory will appear under
~/Library/Application Support/Electron/backups/ (macOS) or the
equivalent userData path on your OS.