Skip to content

feat(desktop): auto-update from the CoDev storage bucket - #195

Merged
quickbeard merged 3 commits into
mainfrom
minio-update-feed
Aug 11, 2026
Merged

quickbeard merged 3 commits into
mainfrom
minio-update-feed

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

The desktop updater pointed at quickbeard/codev-code releases, which could never have worked: the repo is private and GitHubProvider requests /releases/latest unauthenticated, and no desktop asset has ever been published there. This points it at the fork's MinIO server, where the installers actually land.

The update feed

Both channels now share one generic publish config. Two of its fields are load-bearing rather than cosmetic, and both are pinned by tests:

  • channel: "latest" — detectUpdateChannel is on by default, so without it electron-builder takes the channel from the version's first semver prerelease component (8 for 1.18.4-8) and writes 8.yml / 8-mac.yml while the app requests latest.yml. A permanent ERR_UPDATER_CHANNEL_FILE_NOT_FOUND.
  • useMultipleRangeRequest: false — MinIO answers a multipart range request with the whole object. electron-updater hardcodes the same false for its own s3 provider citing Multiple HTTP ranges are not supported minio/minio#5285, but defaults it to true for generic URLs.

The host is base64'd behind atob, matching the convention in packages/codev-brand/src/index.ts, and CODEV_UPDATE_URL overrides it per build.

allowDowngrade goes false. A mutable bucket path, unlike an immutable release tag, would otherwise let a stale or re-uploaded latest.yml walk every client backwards on the next poll.

Beta keeps a publish block despite the fork shipping no beta installers: UPDATER_ENABLED covers that channel, and a build with no publish config gets no app-update.yml at all, so every check would fail with ENOENT.

Release scripts

finalize-latest-json.ts and finalize-latest-yml.ts are deleted along with their call in script/publish.ts. Both gh release upload into a GitHub release the fork does not use; finalize-latest-json.ts additionally built a Tauri-style latest.json, signed with the tauri CLI and read by nothing in the Electron path, against artifact names this fork never produces. electron-builder already writes a correct per-platform latest*.yml beside each installer.

Update check on Windows and Linux

"Check for Updates…" lived only in the macOS app menu. createMenu early-returns off darwin, and WindowsAppMenu filters on "windows", which the platforms: ["macos"] app menu group excludes — so there was no menu path to it at all on Windows or Linux, leaving Settings → General as the only manual entry point. It joins the Help menu there.

WindowsAppMenu also ignored enabled: "updater" outright — only command entries could be disabled — so the new item would have shown enabled in dev builds, where clicking it hits a disabled updater. It now reads the updater's own disabled status, the renderer-side equivalent of UPDATER_ENABLED.

Verification

bun typecheck clean across all 32 packages; 657 app tests and 67 desktop tests pass.

electron-builder.config.test.ts — the publish config per channel (provider, channel, useMultipleRangeRequest, and that dev still has no feed), plus a feed-resolution test that constructs electron-updater's real GenericProvider from our real publish config and asserts an artifact resolves to …/codev-storage/codev-code/<file>. That one is guarding a silent failure: the bucket prefix has no trailing slash, and plain new URL(name, base) drops the last path segment without one, resolving to …/codev-storage/codev-code-desktop-mac-universal.zip — a directory too high, and a 404 with nothing in the logs to explain it. electron-updater normalises this in newBaseUrl, which is internal and absent from the typings, so the test drives the real provider rather than restating the rule.

desktop-menu.test.ts — exactly one update entry per platform (so an upstream merge can't quietly duplicate it on macOS), no trailing separator in the macOS Help menu, and an item disabled state block over desktopMenuItemDisabled: greyed out when the updater reports itself disabled or is absent entirely, live in every other updater state, and command entries still deferring to the command registry.

That last block needed the disabled logic to move out of WindowsAppMenu and into desktop-menu.ts as desktopMenuItemDisabled, a sibling to the existing desktopMenuVisible. The app package has no component-render harness, so logic living inside the component is unreachable from a test.

On e2e

None added, because the changed surfaces are not reachable from the harness we have. packages/app/e2e drives the web build in Chromium via a vite dev server, and entry.tsx hardcodes platform: "web"; WindowsAppMenu renders only when platform.platform === "desktop", so the Help menu does not exist in that harness at all. No existing spec fakes a desktop platform. Everything else in this PR — the publish config, the main-process updater — is outside the browser entirely.

A real end-to-end test of the update path needs three things we do not have: a Playwright _electron harness, a packaged build (UPDATER_ENABLED requires app.isPackaged, so an unpackaged dev launch reports disabled), and a way to point that build at a fake feed — which app-update.yml bakes in at package time, so it would need the runtime setFeedURL override this PR deliberately left out. Worth doing as its own piece of work if we want the updater under e2e; it is not a small addition to this one.

The tests assert our input to detectUpdateChannel, not electron-builder's output, so the metadata filenames were also checked against real CODEV_CHANNEL=prod builds — one per platform, unsigned and single-arch (--mac zip --arm64, --linux deb --x64, --win nsis --x64), which is a valid proxy since getArchPrefixForUpdateFile only adds an arch component on Linux:

Build Metadata written Artifact
--mac zip --arm64 latest-mac.yml codev-code-desktop-mac-arm64.zip + .blockmap
--linux deb --x64 latest-linux.yml codev-code-desktop-linux-amd64.deb (no blockmap — FpmTarget generates none, so deb updates are always full downloads)
--win nsis --x64 latest.yml codev-code-desktop-win-x64.exe + .blockmap

No 8*.yml anywhere. All three packaged apps carry the same resolved feed in resources/app-update.yml:

provider: generic
url: https://netmind.viettel.vn/codev-storage/codev-code
channel: latest
useMultipleRangeRequest: false
updaterCacheDirName: '@opencode-aidesktop-updater'

That's the stronger result than the filenames alone — all three fields survive into the shipped binary. (updaterCacheDirName is derived from the @opencode-ai/desktop package name and only names a local download-cache directory; DIVERGENCES.md already records that those package ids stay upstream deliberately.)

These were throwaway builds: unsigned, and without bun run native:build, so they logged file source doesn't exist … /native and are inspectable rather than runnable. Neither affects channel resolution.

What to upload to the bucket

Everything goes in one flat prefix — resolveFiles resolves each files[].url in a latest*.yml relative to the base URL, so there is no room for per-platform subdirectories. Every filename below came out of a real build, except the rpm: rpmbuild is not available on macOS, so that one is electron-builder's own --package path from the fpm invocation it attempted rather than a file that was produced.

codev-storage/codev-code/

Platform Files
macOS latest-mac.yml
codev-code-desktop-mac-universal.zip
codev-code-desktop-mac-universal.zip.blockmap
codev-code-desktop-mac-universal.dmg
codev-code-desktop-mac-universal.dmg.blockmap
Windows latest.yml
codev-code-desktop-win-x64.exe
codev-code-desktop-win-x64.exe.blockmap
Linux latest-linux.yml
codev-code-desktop-linux-amd64.deb
codev-code-desktop-linux-x86_64.AppImage
codev-code-desktop-linux-x86_64.rpm
mc cp dist/codev-code-desktop-* myminio/codev-storage/codev-code/
mc cp dist/latest*.yml           myminio/codev-storage/codev-code/

Run that once per platform build. Each machine emits only its own artifacts and its own latest*.yml, and the three metadata filenames are distinct, so they merge into the prefix without colliding. The one exception is Windows x64 + arm64, which both write latest.yml — shipping both arches needs them merged into a single file rather than overwriting each other.

Do not upload builder-debug.yml (file patterns only, no resolved config) or the unpacked staging directories mac-universal/, win-unpacked/, linux-unpacked/.

Five things that are easy to get wrong:

  • The dmg does not auto-update. MacUpdater calls findFile(files, "zip", ["pkg", "dmg"]) — it wants the zip and ignores the dmg outright. The dmg is there for fresh installs only, and it is listed in latest-mac.yml either way.
  • No Linux target has a sidecar .blockmap. deb and rpm get none at all (FpmTarget generates none), so those updates are always full downloads. AppImage does have one, but electron-builder logs building embedded block map and writes it inside the AppImage — the yml carries a blockMapSize and there is no .AppImage.blockmap file to upload. Only macOS and Windows produce sidecars.
  • Arch naming differs per target. getArtifactArchName maps x64 → amd64 for deb but → x86_64 for rpm and AppImage, while macOS and Windows keep x64/universal. Three different spellings of the same architecture in one directory is expected, not a mistake.
  • The .yml describes what was built, not what was uploaded. latest-linux.yml lists every Linux target present in that build, and the updater picks by installed package type — DebUpdater looks for deb, RpmUpdater for rpm, AppImageUpdater for AppImage. Ship a partial set and users on the missing type 404. Either build and upload all three, or narrow linux.target to what we actually distribute.
  • Upload the .yml files last. Clients poll every ten minutes and start downloading the moment one appears.

Build with --publish never: the generic provider has no publisher (createPublisher returns null for it), so electron-builder writes app-update.yml and nothing else. Uploading is ours to do.

DIVERGENCES.md rows updated for every upstream file touched, plus new rows for the deletions.

🤖 Generated with Claude Code

quickbeard and others added 3 commits August 11, 2026 21:39
The desktop updater pointed at `quickbeard/codev-code` releases, which
could never have worked: the repo is private and `GitHubProvider` requests
`/releases/latest` unauthenticated, and no desktop asset has ever been
published there. Point it at the fork's MinIO server instead, where the
installers actually land.

Both channels now share one `generic` publish config. Two of its fields are
load-bearing rather than cosmetic:

- `channel: "latest"` — `detectUpdateChannel` is on by default, so without it
  electron-builder takes the channel from the version's first semver
  prerelease component ("8" for 1.18.4-8) and writes 8.yml/8-mac.yml while
  the app requests latest.yml. A permanent ERR_UPDATER_CHANNEL_FILE_NOT_FOUND.
- `useMultipleRangeRequest: false` — MinIO answers a multipart range request
  with the whole object. electron-updater hardcodes the same false for its own
  s3 provider citing minio/minio#5285, but defaults it true for generic URLs.

`allowDowngrade` goes false: a mutable bucket path, unlike an immutable
release tag, would otherwise let a stale latest.yml walk every client
backwards on the next poll.

The two finalize-latest-* scripts and their call in script/publish.ts are
gone. Both uploaded into a GitHub release the fork does not use, and
finalize-latest-json.ts built a Tauri-style latest.json — signed with the
tauri CLI, read by nothing — against artifact names this fork never produces.

Also surfaces the update check off macOS. "Check for Updates..." lived only
in the macOS app menu, and neither `createMenu` (darwin-only) nor
`WindowsAppMenu` (filters on "windows", which the app menu group excludes)
offered it anywhere else, leaving Settings as the only manual path on Windows
and Linux. It joins the Help menu there. `WindowsAppMenu` also ignored
`enabled: "updater"` outright — only command entries could be disabled — so
the new item would have shown enabled in dev builds; it now reads the
updater's own disabled status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps the PR's existing tests left open.

The bucket prefix has no trailing slash, and `new URL(name, base)` drops the
last path segment without one — every artifact would resolve a directory too
high and 404, silently. electron-updater normalises this in `newBaseUrl`,
which is internal and absent from the typings, so the test drives the real
`GenericProvider` built from our real publish config and asserts a resolved
artifact URL instead of restating the rule.

`WindowsAppMenu`'s disabled logic was unreachable from a test — the app
package has no component-render harness. Moved it to `desktop-menu.ts` as
`desktopMenuItemDisabled`, a sibling to the existing `desktopMenuVisible`,
and covered the cases that matter: the update check greys out when the
updater reports itself disabled or is absent entirely, stays live in every
other updater state, and command entries still defer to the command registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 19a0b90 into main Aug 11, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the minio-update-feed branch August 11, 2026 15:07
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