Skip to content

Updates & Announcements

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Runtime mechanism

Update status is a folded event stream, owned by main

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:

  1. The wire payload is always a complete UpdateStatus, never a delta. src/shared/update-status.ts is a pure reducer; the renderer never has to merge partial updates.
  2. The renderer holds no authoritative update state. UpdateToast keeps the latest snapshot in local useState only, and nothing about update progress is persisted to disk. Restart the app and the update state resets to idle — 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)"]
Loading

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.

The reducer is permissive and carries version forward

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.

Command failures are absorbed, not thrown

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.

Dev builds short-circuit before touching the feed

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.

Announcements: pure parse in core, fetch in main, filter upstream

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 null when the payload is not an object or has no non-empty string tag_name — this is the only rejection condition;
  • version strips a leading v from tag_name;
  • title falls back to tag_name when name is missing or empty;
  • body falls back to '' when body is 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.

Files and how they collaborate

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.

Extension points

  • New phase or event: add the variant to UpdatePhase/UpdateEvent in src/shared/update-status.ts, add the case to applyUpdateEvent, then add copy and button gating in UpdateToast. Because the reducer's switch has no default, a missing case falls off the end — confirm the typecheck gate catches it (see Build Targets & TypeScript Configuration).
  • Wire update-not-available / a checking phase: 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.setAutoDownload exists and is applied to autoUpdater in packaged builds, but no renderer call to it appears in the reviewed snippets — the visibility of that method on window.termsprawl.updates is unverified here.
  • Persisting update dismissal: not implemented; contrast with announcements, which persist per version.
  • Testing: createUpdateBridge accepts isPackaged, autoDownload and broadcast as injection points, so the dev/no-op path is testable without a network — but note the top-level electron-updater import still evaluates when the module is loaded.

Limitations

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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally