-
Notifications
You must be signed in to change notification settings - Fork 0
Updates & Announcements
This page covers two independent pipelines that answer two different questions: "is a new build available?" (auto-update) and "what changed in the release I'm running?" (announcements). They share no state and no code path — only the fact that both surface themselves as transient chrome at the edge of the app shell. Update status is an event stream owned by the main process and pushed to the renderer; announcements are a one-shot pull on renderer mount, with dismissal persisted in settings.
The single source of truth for update state is a closure variable inside the object returned by createUpdateBridge (src/main/updates.ts#L28-L35). Every mutation goes through one emit helper:
const emit = (event: UpdateEvent): UpdateStatus => {
status = applyUpdateEvent(status, event) // fold
options.broadcast(IPC.updateStatus, status) // push whole snapshot
return status
}Two consequences matter when you touch this code:
-
The wire payload is always a complete
UpdateStatus, never a delta.src/shared/update-status.tsis a pure reducer; the renderer never has to merge partial updates. -
The renderer holds no authoritative update state.
UpdateToastkeeps the latest snapshot in localuseStateonly, and nothing about update progress is persisted to disk. Restart the app and the update state resets toidle— unlike announcements, whose dismissal is durable.
flowchart TD
CMD["renderer command<br/>updates.download / dismiss / install"] --> BRIDGE["createUpdateBridge"]
BRIDGE -->|"check() / download()"| AU["electron-updater autoUpdater"]
BRIDGE -->|dismiss| EMIT
AU -->|"update-available, download-progress,<br/>update-downloaded, error"| EMIT["emit(event)"]
EMIT --> REDUCE["applyUpdateEvent(prev, event)"]
REDUCE --> STATE["closure `status` variable"]
STATE --> BROADCAST["broadcast(IPC.updateStatus, status)"]
BROADCAST --> TOAST["UpdateToast useState"]
TOAST --> CMD
BRIDGE -->|install| QUIT["autoUpdater.quitAndInstall()<br/>(process ends — no broadcast)"]
Key nodes: emit is the only writer of status; the reducer is the only place phase transitions are defined; the broadcast is the only path from main to the toast. install() deliberately bypasses the loop — it calls quitAndInstall() and the process ends, so there is no "installing" phase and no final broadcast.
applyUpdateEvent does not validate that an event is legal for the current phase. Any event can arrive in any phase:
| event | resulting status | version |
|---|---|---|
available |
{ phase: 'available', version } |
replaced by the event |
progress |
{ phase: 'downloading', version: prev.version, percent } |
carried from previous status |
ready |
{ phase: 'ready', version: prev.version } |
carried from previous status |
error |
{ phase: 'error', version: prev.version, message } |
carried from previous status |
dismiss |
{ phase: 'idle' } |
dropped — full reset |
percent is clamped to [0, 100] and rounded before storage (src/shared/update-status.ts#L25-L30). Because progress carries version forward rather than taking it from the event, a progress that arrives without a preceding available produces a downloading status with version === undefined — that is exactly the case the toast renders as "an update" (src/renderer/src/components/UpdateToast.tsx#L13).
There is no checking or not-available phase. Electron-updater's update-not-available is not wired, so a manual check that finds nothing produces no status change at all and the toast stays hidden. If you want user feedback for "you're up to date", that phase and event do not exist yet.
check() and download() are wrapped in try/catch (src/main/updates.ts#L67-L82): a failure is converted into an error event and the promise still resolves with the (now error) status. Callers cannot detect failure by awaiting — they must inspect the returned UpdateStatus. The renderer doesn't: it uses void window.termsprawl.updates.download(), discarding the result and relying entirely on the broadcast to update the toast.
createUpdateBridge returns an early, inert implementation when options.isPackaged is false (src/main/updates.ts#L37-L46). In that branch check()/download() return the current status without network access, install() and setAutoDownload() are no-ops, and only dismiss() still runs through emit so the UI's dismiss button round-trips normally. This is what keeps pnpm run dev from hitting the GitHub Releases feed.
In the packaged branch (src/main/updates.ts#L48-L63) listener registration happens once, at construction, on the process-global electronUpdater.autoUpdater emitter, and autoInstallOnAppQuit is set to true unconditionally. Constructing a second bridge would double-register listeners and duplicate every broadcast on IPC.updateStatus.
parseLatestRelease (src/core/announcements.ts#L7-L14) is deliberately Electron-free and takes unknown, so it can be exercised directly against a captured GitHub payload. Its contract:
- returns
nullwhen the payload is not an object or has no non-empty stringtag_name— this is the only rejection condition; -
versionstrips a leadingvfromtag_name; -
titlefalls back totag_namewhennameis missing or empty; -
bodyfalls back to''whenbodyis not a string (the renderer then substitutes the bold title).
The HTTP fetch itself lives in the main process (per the file header) and is outside the snippets reviewed here.
src/shared/update-status.ts is the contract both processes import. It defines UpdatePhase, the UpdateStatus snapshot shape, the UpdateEvent union, the idleUpdateStatus() constructor, and the applyUpdateEvent reducer. Main uses all four; the renderer imports the type (@shared/update-status) and duplicates the idle default inline.
src/main/updates.ts wraps electron-updater. It owns the status closure, translates autoUpdater emitter events into UpdateEvents, exposes the UpdateBridge command surface (status, check, download, install, setAutoDownload, dismiss), and injects broadcast so the module doesn't reach for a BrowserWindow directly. The call site that constructs the bridge and supplies isPackaged/autoDownload/broadcast is in the main-process bootstrap, which is not part of the reviewed snippets.
src/renderer/src/components/UpdateToast.tsx is the only consumer of the update stream shown here. It subscribes on mount via window.termsprawl.updates.onStatus(setStatus) and returns the unsubscribe function directly as the effect cleanup, so onStatus must be synchronous and return a disposer. It renders null while idle and branches copy by phase; buttons are phase-gated (download only in available, restart only in ready), while dismiss is always available.
Two behaviors worth knowing before you edit it:
-
No pull-on-mount. The component never asks main for the current status — it only subscribes. Any status emitted before the subscription is lost. This is benign today because the phases preceding subscription are idle or transient, but it becomes a real bug if you add a background auto-check at boot; the fix is a
getStatus-style preload call whose result seeds the state. -
No retry affordance for
error. The only action in the error phase is dismiss.
src/core/announcements.ts is the parse boundary described above; it imports only the Announcement type from src/shared/types, keeping the module usable from tests and from non-Electron entrypoints.
src/renderer/src/components/AnnouncementBanner.tsx fetches once on mount (window.termsprawl.announcements.get()), renders the bar whenever an announcement exists and is not locally hidden, and opens a modal containing renderMarkdown(announcement.body || '**' + title + '**') injected with dangerouslySetInnerHTML. Anchors inside that HTML are intercepted on the container: closest('a') → preventDefault() → window.termsprawl.openExternal(href), so changelog links never navigate the app window. Dismissal (× or "Got it") sets local hidden, closes the modal, and fire-and-forgets window.termsprawl.settings.set({ dismissedAnnouncementVersion: announcement.version }).
The important boundary here: the component does not compare its announcement against dismissedAnnouncementVersion. It renders whatever announcements.get() resolves to and only tracks hidden in memory. "Show once per release" therefore has to be enforced upstream — in the main-process handler or the preload bridge — by suppressing the announcement when the persisted version matches. If you change the fetch wiring, verify that filter still exists, or the banner will reappear on every launch.
Two smaller interaction details: the bar is role="button" with Enter/Space handling, and the nested dismiss button calls stopPropagation() so dismissing doesn't also open the modal (the wrapper's keydown handler still sees Enter on the focused dismiss button, but the click handler's setOpen(false) lands immediately after). The modal backdrop closes only when e.target === e.currentTarget, and Escape is bound to a window listener that exists only while the modal is open.
-
New phase or event: add the variant to
UpdatePhase/UpdateEventinsrc/shared/update-status.ts, add the case toapplyUpdateEvent, then add copy and button gating inUpdateToast. Because the reducer's switch has nodefault, a missing case falls off the end — confirm the typecheck gate catches it (see Build Targets & TypeScript Configuration). -
Wire
update-not-available/ acheckingphase: the reducer and the bridge already have a natural slot; only the autoUpdater listener mapping and the copy branch are missing. -
Expose the auto-download toggle:
UpdateBridge.setAutoDownloadexists and is applied toautoUpdaterin packaged builds, but no renderer call to it appears in the reviewed snippets — the visibility of that method onwindow.termsprawl.updatesis unverified here. - Persisting update dismissal: not implemented; contrast with announcements, which persist per version.
-
Testing:
createUpdateBridgeacceptsisPackaged,autoDownloadandbroadcastas injection points, so the dev/no-op path is testable without a network — but note the top-levelelectron-updaterimport still evaluates when the module is loaded.
The reviewed snippets do not include: the main-process call site that creates the update bridge, the main-side announcement fetch and the filter against dismissedAnnouncementVersion, the preload surface in src/preload (so the full set of exposed update methods — notably whether status(), check() and setAutoDownload() are bridged — is unverified), the Announcement type definition in src/shared/types, the IPC.updateStatus channel constant, and the mount points for UpdateToast and AnnouncementBanner in the app shell. Statements about those files above are marked as inferred.
Sources: src/main/updates.ts, src/core/announcements.ts, src/shared/update-status.ts, src/renderer/src/components/UpdateToast.tsx, src/renderer/src/components/AnnouncementBanner.tsx
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance