feat(x): client auto-update with restart card and inline release notes (#744)

* feat(x): client auto-update with non-interrupting UX

Auto-update via update.electronjs.org (Squirrel), replacing the native
update dialog with a state machine pushed to the renderer:

- Non-modal "restart to update" toast, deferred while a call/turn is
  active and retracted if one starts; 24h "Later" snooze persisted in
  main so it survives window reloads
- Offline detection: network errors get a soft `offline` state instead
  of a red failure, and don't emit update_failed analytics
- Update-waiting badges: macOS dock badge, Windows taskbar overlay icon
- macOS move-to-Applications prompt parented to the main window, with a
  failure fallback dialog and focus re-check after a manual drag
- Settings > Help: version, manual check with accurate transient
  "You're up to date" feedback, per-state messaging
- "Updated to vX" card on first launch after an upgrade (downgrades
  restamp silently); "What's new" links to release notes
- Toaster: follow app dark mode (fixes unreadable description text) and
  restyle to theme tokens
- Window state persistence so restart-to-update feels lossless
- Tests for version stamping and upgrade comparison

* feat(x): replace update toast with Zed-style titlebar chip

The restart-to-update prompt moves from a sonner toast to a persistent,
non-interrupting titlebar indicator: a spinner while an update downloads,
then a 'Restart to update' chip once staged. Clicking restarts into the
new version; the x snoozes the chip for 24h via the existing persisted
snooze. Busy-deferral is dropped - the chip never interrupts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(x): bottom-left update card with inline release notes

Reframe the restart prompt per PR feedback: by the time the user sees it,
Squirrel has already installed the update — the prompt only asks for a
restart (Chrome-style), and being loud about what shipped matters more
than being unobtrusive during early adoption.

- Replace the titlebar chip with a bottom-left "Update available" card
  showing the new version, an inline "What's new" section rendered from
  the GitHub release notes, and Later / Release notes / Restart now
- Release notes come from Squirrel.Mac's update feed (update.electronjs.org
  passes the release body through); Squirrel.Windows only reports the
  release name, so missing notes are backfilled from the GitHub API
- Drop the 24h snooze machinery — Later/× just dismiss for the session
- Drop offline detection (soft `offline` state) — separate PR later
- Drop the macOS move-to-Applications prompt/move button — separate PR
  later; Settings still explains why updates are unavailable outside
  /Applications
- Drop window state persistence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(x): aggregate release notes across skipped versions, persistent up-to-date status

- Updater aggregates release bodies for every version between the running
  app and the update target, with a commit-log fallback when no release in
  range has notes
- CI fills empty release bodies with GitHub's auto-generated notes
- Settings shows a persistent 'You're up to date' line with last-checked
  time instead of a transient confirmation

* fix(x): restore updater:quitAndInstall lines dropped in merge

The conflict resolution in 67d6a542 truncated the updater:quitAndInstall
entry in both the shared IPC schema and the main-process handler, leaving
an unclosed brace that broke the shared package build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(x): simplify release notes handling per review feedback

Release notes are a process concern, not the updater's: drop the
CI job that auto-filled empty release bodies and the in-app GitHub
API backfill (backfillReleaseNotes + commitLogFallback). The update
card now adapts instead — it renders the notes Squirrel supplies,
or a static "Bug fixes and improvements." line when empty.

Also document that updateElectronApp() configures the same
autoUpdater singleton our listeners observe (no race), and that
gen-install-loading.sh is a manual one-off tool whose committed
GIF feeds Squirrel's loadingGif.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(x): drop update-electron-app, drive autoUpdater directly

With notifyUser: false the package reduced to setFeedURL + an
immediate checkForUpdates() + a 10-minute unconditional timer, plus
guards we already have (isPackaged, platform, app-ready). Inline
those three lines instead and remove the dependency.

The interval now runs through the existing guarded checkForUpdates()
(no-op unless idle/error), so ticks no longer emit Squirrel.Mac
"check already in progress" errors or make Squirrel.Windows
re-download an already-staged update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Harshvardhan Vatsa 2026-07-16 17:59:03 +05:30 committed by GitHub
parent 7b032537bc
commit 70ddf19489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 752 additions and 44 deletions

View file

@ -58,6 +58,22 @@ const KnowledgeSourceConfigSchema = z.object({
filters: z.record(z.string(), z.unknown()).optional(),
});
// Lifecycle of the client auto-updater (apps/main/src/updater.ts).
// - disabled: dev build — the updater never initializes
// - unsupported: platform can't auto-update (`reason` says why)
// - ready: an update is downloaded and installed; restart switches to it
const UpdaterStatusSchema = z.object({
state: z.enum(['disabled', 'unsupported', 'idle', 'checking', 'downloading', 'ready', 'error']),
version: z.string(),
reason: z.enum(['dev', 'platform', 'not-in-applications']).optional(),
newVersion: z.string().optional(),
// Markdown body of the staged update's GitHub release, when known — the
// restart card renders it as "What's new".
releaseNotes: z.string().optional(),
error: z.string().optional(),
lastCheckedAt: z.number().optional(),
});
const ipcSchemas = {
'app:getVersions': {
req: z.null(),
@ -798,6 +814,37 @@ const ipcSchemas = {
url: z.string().nullable(),
}),
},
// Consume-once "the app was just updated" notice. `updatedFrom` is the
// previously recorded version on the first invoke of the first launch
// after an update, and null on every other invoke (fresh install,
// unchanged version, or already consumed this run).
'app:consumeUpdateInfo': {
req: z.null(),
res: z.object({
version: z.string(),
updatedFrom: z.string().nullable(),
}),
},
// --- Client auto-update (apps/main/src/updater.ts) ---
// Pushed to all windows whenever the updater state changes.
'updater:status': {
req: UpdaterStatusSchema,
res: z.null(),
},
'updater:getStatus': {
req: z.null(),
res: UpdaterStatusSchema,
},
// Kick off a manual check (no-op unless idle/error); progress arrives via
// updater:status pushes. Returns the snapshot after initiating.
'updater:check': {
req: z.null(),
res: UpdaterStatusSchema,
},
'updater:quitAndInstall': {
req: z.null(),
res: z.object({}),
},
// Tray commands issued before the renderer was ready (mirrors the pending
// deep-link pull above): the renderer drains this once on mount.
'app:consumePendingTrayCommand': {