Brainstormed with the user (ULTRATHINK context). Sub-project 1 of 4 in the v5+ roadmap (production polish → cloud/licensing → plugins → perf+a11y). Spec covers: - Two-channel distribution: GitHub Releases (default) + ConcreteInfo self-hosted - Light, skippable first-run wizard (theme + update channel + starter template) - Auto-migrate v4.4.1 settings with v4 backup and toast on success/failure - Local crash dump capture (no third-party) with a CrashReportModal - Non-blocking update banner with user-confirmed restart-to-install - IPC allowlist additions for updater:* and crash:* - GitHub Actions CI extension (latest.yml generation, PR test suite) Defers to v5.1: code signing, Sentry, delta updates, beta tracks. After user review and approval, write the implementation plan via the writing-plans skill.
18 KiB
Production polish — v5 shippable build
Date: 2026-06-07
Branch: react-electron
Status: Draft, pending implementation plan
Author: Claude (brainstormed with Amit Haridas)
1. Goals & non-goals
Goals
v5 is reliably shippable to two distribution channels:
- GitHub Releases (public, free, existing workflow).
- ConcreteInfo update server at
https://updates.concreteinfo.co.in/v5/(self-hosted, for ConcreteInfo's own users).
Concretely:
- A non-blocking update banner surfaces "v5.0.2 is available" with a user-confirmed restart-to-install flow. No silent background installs.
- A light first-run wizard (theme + update channel + starter template), all skippable.
- One-shot auto-migration from v4.4.1 settings, with a v4 backup file and a toast on success/failure.
- Local crash dump capture (no third-party). CrashReportModal lets the user open the dump folder and copy/delete dumps.
- GitHub Actions CI that builds + uploads artifacts on tag and runs the test suite on every PR.
Non-goals (deferred to v5.1+)
- Code signing. macOS Gatekeeper and Windows SmartScreen will warn. Re-evaluated for v5.1.
- Sentry or any third-party crash reporting. Local dumps only.
- Auto-install on quit. Always user-confirmed.
- Delta updates (full downloads only in v5).
- Plugin system, cloud/licensing, perf/a11y — each is its own subsequent spec.
2. Architecture
GitHub Releases (public) ConcreteInfo (CI feed)
└── *.zip, *.dmg, *.exe, └── *.zip, *.dmg, *.exe,
*.deb, *.rpm, *.deb, *.rpm,
latest.yml, latest- latest.yml, latest-
mac.yml, latest- mac.yml, latest-
linux.yml, latest- linux.yml, latest-
windows.yml windows.yml
▲ ▲
│ │
└──────┬───────────────────────┘
│ electron-updater reads
│ the channel chosen in
│ Settings (default: GitHub)
▼
┌─────────────────────────────────────────────────────┐
│ Main process (src/main/updater/) │
│ - Updater service: wraps electron-updater │
│ - Channel resolver: returns feed URL from setting │
│ - State machine: idle → checking → available → │
│ downloading → ready → installing │
│ - Crash writer: catches process.on('uncaught…) │
│ - Migration runner: idempotent, runs on app start│
└─────────────────────────────────────────────────────┘
▲ ▲
│ IPC (renderer→main, │ IPC (renderer→main,
│ allowlisted SEND) │ allowlisted SEND)
│ updater:check, │ crash:read,
│ updater:install, │ crash:open-dir,
│ updater:get-state │ crash:delete
│ │
│ IPC (main→renderer, │
│ allowlisted RECEIVE) │
│ updater:status │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Renderer (preload bridge + React) │
│ - Update banner: "v5.0.2 available" │
│ - Settings: Update channel radio │
│ - First-run wizard: skip-links, all optional │
│ - Crash dialog: open dump dir, copy file │
│ - Migration: first-launch indicator in settings │
└─────────────────────────────────────────────────────┘
Key boundaries
src/main/updater/is a single new directory.updater-service.jsowns theelectron-updaterlifecycle, never the renderer.- Renderer talks to updater over IPC only. No direct
autoUpdaterimport in preload. Channels must be inALLOWED_SEND_CHANNELS/ALLOWED_RECEIVE_CHANNELS. - Channel switching is a settings change. No restart required; the next
checkForUpdates()reads the new feed URL. - Migration is idempotent. Stores a
migration.versionkey; skips if already at v5. - Crash writer never blocks startup. Initialized in main entry, but a write failure is non-fatal.
3. Component breakdown
| Unit | What it does | How it's used | Depends on |
|---|---|---|---|
main/updater/updater-service.js |
Owns electron-updater; exposes check/download/install; emits status events |
Main process singleton; started after app.whenReady() |
electron-updater, electron |
main/updater/feed-config.js |
Maps channel setting → feed URL; reads from app.getPath('userData')/settings.json |
Called by updater-service on each check | settings store |
main/updater/crash-writer.js |
Hooks process.on('uncaughtException', …); writes minidump + stack to app.getPath('userData')/crashDumps/{timestamp}.json |
Initialized in main entry, before anything else | electron, app.getPath |
main/updater/migration-runner.js |
Reads v4 settings; transforms to v5 schema; writes backup; updates migration.version |
Run from main entry on app.whenReady() |
fs, settings store |
main/ipc/updater-handlers.js |
IPC handlers: updater:check, updater:install, updater:get-state |
Registered in main entry | updater-service |
main/ipc/crash-handlers.js |
IPC handlers: crash:read, crash:open-dir |
Registered in main entry | crash-writer |
preload.js (extension) |
Add updater.check(), updater.install(), crash.read(), crash.openDir() to electronAPI; add channels to allowlist |
Preload runs at boot | existing preload |
renderer/lib/updater-store.ts |
Zustand store mirroring updater state; subscribes to updater:status events |
Renderer; consumed by banner, settings, modals | zustand, IPC bridge |
renderer/components/UpdateBanner.tsx |
Non-blocking banner shown when updater-store.status is `available |
downloading | ready` |
renderer/components/FirstRunWizard.tsx |
3-step modal with skip links; opens on first launch when app-store.firstRun === true |
Mounted in App.tsx |
app-store, settings-store, command-store |
renderer/components/modals/SettingsModal.tsx (extend) |
Add "Updates" section: channel radio, "Check now" button, auto-check toggle | Mounted via useAppStore.modal |
updater-store, settings-store |
renderer/components/modals/CrashReportModal.tsx |
List local crash dumps; "Open dump folder", "Copy to clipboard", "Delete" actions | Mounted via useAppStore.modal |
crash ipc, sonner |
renderer/lib/migrations/v4-to-v5.ts |
Pure function: (v4Settings) => v5Settings |
Called from migration-runner |
zod schemas |
Data flow — "user clicks Check for updates"
- Settings panel:
useSettingsStore.getState().setSetting('updateChannel', 'github' | 'concreteinfo'). SettingsModalcallsawait ipc.updater.check().- Preload
safeCall→window.electronAPI.updater.check()→ipcRenderer.invoke('updater:check'). - Main handler asks
updater-servicetoautoUpdater.checkForUpdates()with the channel's feed URL. updater-serviceemits'updater:status', { state: 'available', version: '5.0.2' }.- Preload forwards to
updater-storeviawindow.electronAPI.on('updater:status', cb). updater-storestate flips toavailable;UpdateBannerrenders.- User clicks "View release notes" → opens
https://github.com/.../releases/tag/v5.0.2viaipc.app.openExternal. - User clicks "Restart to update" → calls
ipc.updater.install().updater-servicecallsautoUpdater.quitAndInstall(). App quits, relaunches into v5.0.2.
4. Error handling
Updater
- No network:
autoUpdater.checkForUpdates()rejects withENOTFOUND/ETIMEDOUT. We catch, log to main log, emitupdater:status { state: 'error', code: 'NETWORK' }. Banner shows "Couldn't check for updates. Try again." Toast fires inline at the wire point inUpdateBanner/SettingsModalon retry, matching the existing pattern (toasts-inline-at-wire-points). - Feed 404 / signature missing (N/A in v5 since unsigned): Treat as configuration error. Log to main log with full URL. Banner shows "Update feed is misconfigured. Report this on GitHub." Crash-log-style entry is written.
- Update available but download fails: Banner flips to "Download failed. Try again." Clicking "Try again" re-invokes
autoUpdater.downloadUpdate(). Three consecutive failures show a "Report issue" link in the banner. - Update installs but launch fails: Worst case. We catch the post-
quitAndInstallcrash viacrash-writerand write a marker fileapp.getPath('userData')/update-failed.jsonwith the previous version. On next boot, the user is offered to revert manually by re-downloading from the GitHub release page (a "rolled back" banner instead of the wizard). - Concurrent checks: Debounce 60s. A second
checkForUpdates()within 60s is a no-op; returns the current state. The Check-Now button is disabled during a check.
Migration
- v4 settings file missing/corrupt: Treat as "nothing to migrate." App starts with v5 defaults. Toast: "Welcome to v5 — using default settings."
- Migration throws mid-transform: Original v4 file is preserved as
settings.v4.bak.json(already on disk). App starts with v5 defaults. Toast: "Couldn't migrate v4 settings — your old settings are preserved at {path}." migration.version === 5: Skip entirely. No-op.
Crash writer
- Dump write fails (disk full, permissions): Print to stderr; do not crash the app. Recovery is "user's problem" — we never block app startup on the crash writer.
- No dumps in dir: CrashReportModal shows empty state: "No crashes recorded — nice work!"
First-run wizard
- Skip clicked at any step: Sets
app-store.firstRun = falseand proceeds. All defaults already match the unselected choice, so this is non-destructive. - Wizard cannot reach settings (corrupt store): Falls back to defaults silently. Wizard still closes.
5. Testing
Unit (Vitest, renderer-side)
useUpdaterStore: state transitions on eachupdater:statusevent; debounce oncheck(); reject on missing channel.FirstRunWizard: each step's CTA writes the right slice; "Skip" leaves defaults; "Skip at step 1" still closes the wizard and setsfirstRun = false.migrations/v4-to-v5: golden-file tests — every v4 shape we ship transforms to expected v5 output. Idempotency: running twice yields identical v5 settings.feed-config: each channel setting resolves to the right feed URL; missing/unknown channel falls back to GitHub.
Component (Vitest + RTL)
UpdateBanner: hidden when state isidleorerror-network; visible with the right copy inavailable/downloading/ready; Restart button callsipc.updater.install(); "View release notes" opens the right URL viaipc.app.openExternal.FirstRunWizard: skip links work; theme picker updates the store; channel picker persists; template picker inserts into the new buffer.CrashReportModal: lists dumps from the IPC response; "Open folder" invokescrash.openDir; delete removes the file.SettingsModal > Updates: channel radio flipssettings-store.updateChannel; "Check now" firesipc.updater.check; auto-check toggle persists.
Integration (Vitest, full app)
- Migration end-to-end: pre-seed a v4 settings file in
app.getPath('userData')/settings.json, launch the app, verify v5 file is written, v4 backup exists,migration.version === 5, and the wizard opens on first run. - Update flow mock: stub
electron-updaterto emitavailable→downloading→ready; verify the banner appears, Restart click triggersquitAndInstall, and the IPC sequence matches the allowlist.
E2E (Playwright + Electron, runs the live app)
- Open the app, verify the FirstRun wizard renders. Skip. Verify the editor is visible.
- Open Settings, switch update channel from GitHub to ConcreteInfo, click Check now. Stub the feed response. Verify the banner appears with the right version.
- Click Restart. Verify the app calls
quitAndInstall(assert on a mockedautoUpdater.quitAndInstall). - Trigger an unhandled rejection in the renderer. Verify a dump is written to
crashDumps/. Open the CrashReportModal, verify it appears in the list.
Coverage targets
- Updater service: ≥ 90% (small surface, hot path).
- Migration runner: 100% of branches (idempotency, corrupt file, missing file, transform failure).
- FirstRunWizard: ≥ 80%.
- Component tests above any preset.
6. Distribution feed contracts
GitHub Releases (default)
Feed URL pattern:
https://github.com/{owner}/{repo}/releases/download/v{version}/
For v5.0.2:
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-mac.yml
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-linux.yml
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-windows.yml
electron-updater reads these automatically given the GitHub owner/repo config. The release workflow already exists at .github/workflows/release.yml and builds .dmg, .zip, .exe, .deb, .rpm. We add latest.yml generation via electron-builder --publish always in the CI.
ConcreteInfo feed
Feed URL:
https://updates.concreteinfo.co.in/v5/latest.yml
https://updates.concreteinfo.co.in/v5/latest-mac.yml
https://updates.concreteinfo.co.in/v5/latest-linux.yml
https://updates.concreteinfo.co.in/v5/latest-windows.yml
CI mirrors the GitHub Release artifacts to this path on every release. ConcreteInfo infra is documented in ~/.claude-mmax/CLAUDE.md; the coolify server is localhost:8000. The mirror is a static-file route — no auth, just version-prefixed.
7. First-run wizard contract
Three steps. Each step has "Skip" and "Back" links. Final step is "Done."
- Theme: Light / Dark / System. Default: System. Persists to
settings-store.theme. - Update channel: GitHub Releases / ConcreteInfo. Default: GitHub Releases. Persists to
settings-store.updateChannel. - Starter template: Blank / README / Meeting notes / Blog post. Default: Blank. On "Done," creates an untitled buffer with the chosen template content.
firstRun lives in app-store (not persisted across app data resets) and is checked on every launch. Setting it to false either explicitly (skip/done) or implicitly (v4 migration succeeded) prevents re-showing.
8. Settings migration contract (v4.4.1 → v5.0.0)
migration-runner runs once on first v5 launch. It:
- Reads
app.getPath('userData')/settings.jsonif it exists. - Validates the v4 shape with a zod schema (
v4SettingsSchema). - If valid: transforms via
migrations/v4-to-v5.tsand writes the v5 settings tosettings.json. Backs up v4 tosettings.v4.bak.json. Setsmigration.version = 5in the v5 file. - If missing or invalid: writes a fresh v5 settings file with defaults.
migration.version = 5. - If transform throws: leaves v4 file in place as the "backup." App starts with defaults. Toast warns the user.
The transform handles:
- v4
theme('light' | 'dark' | 'auto') → v5theme('light' | 'dark' | 'system'). - v4
customCssstring → v5customCssPathfile path ornull. - v4
recentFiles: string[]→ v5recentFiles(unchanged shape). - v4
editorFontSize: number→ v5editorFontSize(unchanged shape). - v4
keyBindings: object→ v5userBindings(keymap). - v4
snippets: array→ v5snippets(unchanged shape). - v4
pdfExportOptionsetc. → v5 schema for the new export pipeline.
Anything not in the v4 schema is dropped. Defaults are taken from the v5 zod schema.
9. IPC allowlist additions
src/preload.js adds these to ALLOWED_SEND_CHANNELS:
'updater:check',
'updater:install',
'updater:get-state',
'crash:read',
'crash:open-dir',
'crash:delete',
ALLOWED_RECEIVE_CHANNELS:
'updater:status',
The electronAPI object gets:
updater: {
check: () => ipcRenderer.invoke('updater:check'),
install: () => ipcRenderer.invoke('updater:install'),
getState: () => ipcRenderer.invoke('updater:get-state'),
onStatus: (cb) => ipcRenderer.on('updater:status', (_, payload) => cb(payload)),
},
crash: {
read: () => ipcRenderer.invoke('crash:read'),
openDir: () => ipcRenderer.send('crash:open-dir'),
delete: (filename) => ipcRenderer.invoke('crash:delete', filename),
},
10. Risks & mitigations
| Risk | Mitigation |
|---|---|
| First-run wizard is annoying to long-time users | Skippable; only shows on first launch. firstRun = false is set permanently on first skip/done. |
| Auto-migration corrupts settings | Original v4 file is preserved as settings.v4.bak.json. Transform errors fall back to defaults and toast the user. |
| Crash writer fills disk | Cap at 20 dumps; oldest auto-pruned. |
| Update feed mirror drift between GitHub and CI | Mirror runs in CI on every release; manual npm run publish:concreteinfo available as fallback. |
electron-updater is unsigned, so anyone can publish a "v5.0.2" to a mirror |
We do not enable auto-install in v5 — every install is user-confirmed via the release-notes page. v5.1 adds signing. |
| First-run wizard blocks app start on a slow renderer | Wizard is non-blocking. App shell mounts and is interactive behind the modal. |
Debounced checkForUpdates() may mask real errors |
"Check now" button bypasses the debounce; debounce only applies to automatic checks. |
| CI release workflow is on a tag — broken tags must be re-tagged manually | Documented in CONTRIBUTING.md; pre-release tag re-runs the workflow. |
11. Out of scope (deferred)
- Code signing.
- Delta updates.
- Per-channel pre-release / beta tracks (only stable feeds in v5).
- Crash analytics / Sentry.
- Squirrel.Windows-specific quirks (we publish NSIS only).