Skip to content

feat(desktop): let the user decide when CoDev updates itself - #265

Merged
quickbeard merged 3 commits into
mainfrom
feat/update-controls
Aug 18, 2026
Merged

quickbeard merged 3 commits into
mainfrom
feat/update-controls

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 18, 2026 •

Copy link
Copy Markdown
Owner

The desktop updater checked at launch and every ten minutes after, and downloaded whatever it found immediately, with nothing anywhere to say otherwise. On the fleet's metered, whitelist-only links that is several hundred megabytes arriving unannounced. The app also already carried translated copy — in both languages — for a "Check for updates on startup" setting that no screen has ever rendered.

Two switches

Settings → General → Updates, both defaulting true, so a machine that never opens Settings behaves exactly as it does today:

Check for updates automatically Gates the launch check and the ten-minute timer
Download updates automatically Off stops a check at the discovery and asks first

"Check for updates" still works with both off. A check the user asks for is never gated — only the automatic ones are.

The download switch renders disabled rather than hidden when checking is off (nothing would find an update to download); a control that vanishes reads as a setting that was lost. Both rows are guarded by platform.updater, which is load-bearing: the Updates section is not desktop-gated, so the web build must not render them.

The available state

UpdaterState gains available — found, not fetched. It is the state the download setting creates and where the consent step lives.

  • The toast offers Download instead of Install and restart while an update sits in it, with its own description.
  • The settings button follows the same projection.
  • The titlebar pill deliberately stays ready-only: it installs, and an update that has not been downloaded cannot be installed.
  • A repeat check while available is a no-op, so the ten-minute timer does not re-ask the feed a question it has already answered while the user leaves the toast up.

Where the prefs live

In the main process, in the existing opencode.updater store under a prefs key — that is where the timer runs, and the alternative was main parsing the renderer's persisted settings format.

  • setPrefs merges partials, so two windows each holding a stale copy of the other's setting cannot clobber it.
  • Prefs broadcast over a second listener on the existing updater-subscribe registration, so a change in one window reaches the rest.
  • The timer reads autoCheck at fire time, so turning it off stops the timer that is already running rather than the next launch.

Feed URL moved, and releases are now per-version

codev-storage/codev-code → codev-storage/codev. The base stays unversioned on purpose, with each release's artifacts under <version>/ beneath it:

…/codev/latest-mac.yml                                    ← the pointer, stable
…/codev/1.18.4-8/codev-code-desktop-mac-universal.zip
…/codev/1.18.4-8/codev-code-desktop-mac-universal.zip.blockmap

A version in the base URL cannot work: app-update.yml is baked at package time and nothing calls setFeedURL, so the folder a build ships with is the only one it will ever read — 1.18.4-8 would poll its own release folder forever and never see 1.18.4-9. I confirmed that by extracting app-update.yml from a packaged build.

electron-builder names artifacts in latest*.yml by bare filename, resolved against the base. The new packages/desktop/scripts/stage-release.ts (bun run stage-release) prefixes each url/path with the version and lays the files out to match; electron-updater resolves the prefixed name with new URL(name, base), so nothing changes client-side. The config test now feeds a versioned entry through the real GenericProvider to pin exactly that resolution.

The script also copies .blockmap files alongside their artifact — they are not named in the manifest, and without them every update silently degrades to a full download — prints uploads artifacts-first so a manifest never names a file that is not yet in place, and is idempotent so a rerun cannot double-prefix. It exits non-zero if no url/path line matched, rather than publish a manifest whose files resolve to the wrong place.

⚠️ Update manifests are only written for beta/prod builds. A dev build carries no publish block at all (pinned by the "leaves dev builds without an update feed" test), so CODEV_CHANNEL=prod is required or dist/ gets artifacts with no latest*.yml — and a stale one from an earlier build is easy to mistake for a fresh one.

⚠️ app-update.yml is baked at package time, so every already-installed build keeps polling the old prefix until it takes one more update (CODEV_UPDATE_URL is build-time only). The next release must be published to both prefixes — old-style flat at …/codev-code/, new-style staged at …/codev/ — after which the old path can be retired.

No manifest is needed for the app installers — electron-builder already emits latest.yml / latest-mac.yml / latest-linux.yml with sha512 digests per artifact, and electron-updater verifies them and uses the .blockmap files for differential downloads on Windows. Upload the artifacts and blockmaps first, the latest*.yml last, same ordering discipline as publish-image.ts.

Cleanup

The dead settings.updates.row.startup.* keys are dropped. Translated in both languages, rendered nowhere, and the copy claimed startup-only — which was never the whole story, since the ten-minute timer ran regardless. The two new rows say what actually happens.

Testing

bun test green in desktop (134) and app (702); bun typecheck clean in both. Six new controller tests plus an action test cover: the both-on default matching today's behaviour, autoCheck off skipping the launch check but not a manual one, autoDownload off stopping at available with nothing written to disk, the full available → download → ready transition sequence, the no-op repeat check, download refusing from any state but available, and prefs merging/persisting/replaying to subscribers.

The feed change is covered by electron-builder.config.test.ts, which asserts the exact resolved artifact URL.

Not verified against a real feed — the updater is disabled unless app.isPackaged && CHANNEL !== "dev", so end-to-end confirmation needs a packaged non-dev build pointed at the bucket.

Divergences

Every file touched here exists upstream — I checked sst/opencode directly rather than inferring from the fork's truncated history, which traces everything to one root commit. updater-controller.ts and app/src/updater.ts are upstream files that simply had no divergence row yet. One new row covers the set; the existing row for main/updater.ts (allowDowngrade) is untouched and still accurate.

🤖 Generated with Claude Code

quickbeard and others added 3 commits August 18, 2026 17:41
The updater checked at launch and every ten minutes after, and downloaded
whatever it found immediately, with nothing to say otherwise. On the fleet's
metered, whitelist-only links that is several hundred megabytes arriving
unannounced. The app already carried translated copy for a "Check for updates
on startup" setting that no screen ever rendered.

Settings → General → Updates now has two switches, both defaulting true so a
machine that never opens Settings behaves exactly as before:

  Check for updates automatically  — gates the launch check and the timer
  Download updates automatically   — off stops a check at the discovery

"Check for updates" still works with both off: a check the user asks for is
never gated, only the automatic ones are.

UpdaterState gains `available` — found, not fetched — which is the state the
download setting creates and where the consent step lives. The toast offers
Download rather than Install while an update is in it, and the settings button
follows. The titlebar pill deliberately stays ready-only: it installs, and an
undownloaded update cannot be installed. A repeat check while available is a
no-op, so the ten-minute timer does not re-ask the feed a question it has
already answered.

Prefs live in the main process, in the existing opencode.updater store, because
that is where the timer runs — the alternative was main parsing the renderer's
persisted settings format. setPrefs merges partials so two windows cannot
clobber each other, and prefs broadcast over a second listener on the existing
updater-subscribe registration so a change in one window reaches the rest. The
timer reads autoCheck at fire time, so turning it off stops the timer that is
already running rather than the next launch.

The dead settings.updates.row.startup.* keys are dropped: translated in both
languages, rendered nowhere, and their copy claimed startup-only, which was
never the whole story.

Also repoints the update feed from codev-storage/codev-code to
codev-storage/codev. app-update.yml is baked at package time, so installed
builds keep polling the old path until they take one more update: the next
release has to be published to both prefixes, after which the old one can be
retired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The artifacts now live under codev-storage/codev/<version> rather than at the
prefix root, so each release's installers, blockmaps and latest*.yml sit
together instead of overwriting the previous release's.

Note this makes the baked feed URL version-specific: app-update.yml is written
at package time, so a build only ever reads the folder it shipped with.
…per version

A version in the feed URL cannot work: app-update.yml is baked at package time
and nothing calls setFeedURL, so the folder a build ships with is the only one
it ever reads — 1.18.4-8 would look at .../codev/1.18.4-8/ forever and never
see 1.18.4-9. Verified by extracting app-update.yml from a packaged build.

So the base URL goes back to .../codev, where the manifests live, and the
artifacts move under <version>/. electron-builder names artifacts in
latest*.yml by bare filename, which resolves against the base, so
scripts/stage-release.ts prefixes each url/path with the version and lays the
files out the way the bucket expects. electron-updater resolves the prefixed
name with new URL(name, base), so nothing changes client-side; the config test
now feeds a versioned entry through the real GenericProvider to pin exactly
that.

The script copies blockmaps alongside their artifact — they are not named in
the manifest, and without them every update silently falls back to a full
download — prints the uploads artifacts-first so a manifest never names a file
that is not yet in place, and is idempotent so a rerun cannot double-prefix. It
fails loudly if no url/path line matched, rather than publish a manifest whose
files resolve to the wrong place.

Note update manifests are only written for beta/prod builds: a dev build has no
publish config at all, so CODEV_CHANNEL=prod is required to produce one.
@quickbeard
quickbeard merged commit 2bdc354 into main Aug 18, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the feat/update-controls branch August 18, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant