From bcce6c86237b93b18860a632097e0a9d874f8d20 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 18 Aug 2026 17:40:50 +0700 Subject: [PATCH 1/3] feat(desktop): let the user decide when CoDev updates itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 18 ++++ DIVERGENCES.md | 1 + .../app/src/components/settings-general.tsx | 34 +++++++ .../app/src/components/updater-action.test.ts | 6 ++ packages/app/src/components/updater-action.ts | 5 ++ packages/app/src/i18n/en.ts | 10 ++- packages/app/src/i18n/es.ts | 10 ++- packages/app/src/pages/layout.tsx | 36 ++++++-- packages/app/src/updater.ts | 21 +++++ .../desktop/electron-builder.config.test.ts | 2 +- packages/desktop/electron-builder.config.ts | 2 +- packages/desktop/src/main/index.ts | 9 +- packages/desktop/src/main/ipc.ts | 30 +++++-- .../src/main/updater-controller.test.ts | 88 ++++++++++++++++++- .../desktop/src/main/updater-controller.ts | 78 ++++++++++++---- packages/desktop/src/main/updater.ts | 14 +++ packages/desktop/src/preload/index.ts | 9 +- packages/desktop/src/preload/types.ts | 7 +- packages/desktop/src/renderer/index.tsx | 11 ++- 19 files changed, 349 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acb999277b..fee854a5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,24 @@ file is the human-readable summary of what changed and why. tarball, packed in by accident whenever a release was retried. Removing it takes each platform package from ~112MB back to ~55MB. +### Updates + +- **You can now decide when CoDev updates itself** — it checked for updates at + launch and every ten minutes after, and downloaded whatever it found straight + away, with no way to say otherwise. Settings → General → Updates now has two + switches: **Check for updates automatically** and **Download updates + automatically**. Turning downloading off stops a check at the discovery and + asks first — the installer is several hundred megabytes, which is not a + download to start on its own over a metered connection. **Check for updates** + still works with both switches off; it is the one check that was always yours + to ask for. There was a translated "Check for updates on startup" setting in + the app that no screen ever showed; these two replace it. +- **The update feed moved** — installers are now published to + `codev-storage/codev` rather than `codev-storage/codev-code`. Builds already + installed keep updating: the new location ships in the next build's + `app-update.yml`, so the change reaches a machine with the update that + carries it. + ### Sandbox - **CoDev installs WSL for you when a sandbox needs it** — a sandbox on Windows diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 4c5803d5ea..46dc36c4e8 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -222,6 +222,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). | | WSL auto-install from the sandbox dialogs: `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | i18n gains the `sandbox.wsl.*` family (install action/retry, the permission-and-size hint, download progress with and without a content-length, the elevated step, restart-required, and the failure line). preload exposes four channels on the existing `api.sandboxes` object — `sandbox-wsl-install{,-cancel,-state}` and a `sandbox-wsl-install-subscribe`/`-event` pair — and re-exports `WslInstallState`. All the logic is fork-only: `src/main/sandbox/wsl-install.ts` (the state machine), `src/main/wsl/install-bundle.ts` (manifest-driven per-arch MSI acquisition), `src/app/sandbox/wsl-install.tsx` (the shared UI both create-sandbox dialogs render in place of their form). | A Windows machine without WSL could not create a sandbox and was told to go install it by hand — while the app already shipped an installer for the Code side's WSL servers. The sandbox needs only the runtime (it imports its own rootfs and mounts its own server), so this reuses `installWslRuntimeDualPath` and skips the distro and CoDev-in-distro steps entirely. Its own channel set rather than a second `sandbox-event` type because this is platform state that exists before any sandbox does, and it lives in main rather than in the dialog because a ~250MB download on a whitelisted-proxy link must survive the dialog closing. | Keep the key family and the four channels; take upstream's surrounding changes. The channel names are preload/renderer contract — renaming one side alone breaks the dialogs silently. If upstream reworks `api.sandboxes`, keep the install methods on it rather than promoting them to a new top-level namespace: the renderer reaches them through `platform.sandboxes`. | +| Update checking and download controls: `packages/app/src/updater.ts`, `packages/app/src/components/{updater-action.ts,settings-general.tsx}`, `packages/app/src/pages/layout.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/main/{updater.ts,updater-controller.ts,index.ts,ipc.ts}`, `packages/desktop/src/preload/{index,types}.ts` | `UpdaterState` gains `available` (found, not fetched) and the shared `UpdaterPrefs` (`autoCheck`, `autoDownload`, both defaulting true — upstream's behaviour). The controller takes `initialPrefs`/`savePrefs`, stops a check at `available` when `autoDownload` is off, gains `download()` for the consent step, gains `getPrefs`/`setPrefs`/`subscribePrefs`, skips the launch check when `autoCheck` is off, and short-circuits a repeat check while `available`. `main/updater.ts` reads and writes the prefs in the existing `opencode.updater` store under a `prefs` key; `main/index.ts`'s ten-minute timer consults `getPrefs().autoCheck` at fire time; `ipc.ts` adds `updater-download` and `updater-set-prefs` and registers a second listener on the existing `updater-subscribe` so prefs broadcast to every window; preload exposes `download`, `setPrefs`, `subscribePrefs`. The renderer adds two `SettingsRow` switches guarded by `platform.updater` (the Updates section is not desktop-gated, so the web build must not render them), the toast offers Download when the update is not on disk, and the titlebar pill deliberately stays `ready`-only — it installs, and an undownloaded update cannot be installed. i18n drops the dead `settings.updates.row.startup.*` keys (translated, never rendered) for `settings.updates.row.{autoCheck,autoDownload}.*`, `settings.updates.action.download` and `toast.update.description.available`. | Upstream checks and downloads unconditionally. The fleet is on metered, whitelist-only links where a several-hundred-megabyte installer arriving unannounced is a real cost, and the app already shipped the copy for a setting it never built. Prefs live in the main process because that is where the timer runs; putting them in the renderer's settings store would have meant main parsing the renderer's persisted format. | Keep the `available` state and the prefs plumbing together — the state is unreachable without `autoDownload`, and the setting is meaningless without the state. Take upstream's other changes to these files. If upstream reworks `createUpdaterController`, re-apply the two gates (`autoCheck` in `start()`, `autoDownload` in `check()`) rather than the whole diff; both are covered by `updater-controller.test.ts`. Defaults must stay true on both — a machine that never opens Settings has to behave exactly as it did before. | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | | `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index c3e22ff494..214783253a 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -601,6 +601,40 @@ export const SettingsGeneral: Component = () => { + + {(updaterPlatform) => ( + <> + +
+ void updaterPlatform().setPrefs({ autoCheck: checked })} + /> +
+
+ {/* Nested under the check toggle in meaning, not in markup: with + checking off nothing finds an update to download, so the + control is shown disabled rather than hidden — hiding it would + read as the setting having been lost. */} + +
+ void updaterPlatform().setPrefs({ autoDownload: checked })} + /> +
+
+ + )} +
+ { run: "check", }) expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" }) + // Found but not fetched: the action is the download the user has to + // consent to, not an install of something that is not on disk. + expect(updaterAction({ status: "available", version: "2.0.0" })).toEqual({ + label: "settings.updates.action.download", + run: "download", + }) expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({ label: "settings.updates.action.downloading", }) diff --git a/packages/app/src/components/updater-action.ts b/packages/app/src/components/updater-action.ts index 9c13c542b4..96b3a66efc 100644 --- a/packages/app/src/components/updater-action.ts +++ b/packages/app/src/components/updater-action.ts @@ -9,6 +9,10 @@ export function updaterAction(state: UpdaterState | undefined) { switch (state.status) { case "checking": return { label: "settings.updates.action.checking" as const } + // Found but not fetched, because automatic downloading is off. The + // download is the step the user is being asked to consent to. + case "available": + return { label: "settings.updates.action.download" as const, run: "download" as const } case "downloading": return { label: "settings.updates.action.downloading" as const } case "ready": @@ -32,6 +36,7 @@ export function useUpdaterAction() { async run() { const run = action().run if (run === "install") return platform.updater?.install() + if (run === "download") return void platform.updater?.download() if (run !== "check") return const state = await platform.updater?.check() diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a984a2255b..c9ed48a507 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -648,6 +648,7 @@ export const dict = { "toast.update.title": "Update available", "toast.update.description": "A new version of CoDev ({{version}}) is now available to install.", + "toast.update.description.available": "A new version of CoDev ({{version}}) is available to download.", "toast.update.action.installRestart": "Install and restart", "toast.update.action.notYet": "Not yet", @@ -1058,8 +1059,13 @@ export const dict = { "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", - "settings.updates.row.startup.title": "Check for updates on startup", - "settings.updates.row.startup.description": "Automatically check for updates when CoDev launches", + "settings.updates.row.autoCheck.title": "Check for updates automatically", + "settings.updates.row.autoCheck.description": + "Look for updates when CoDev launches, and every ten minutes while it runs", + "settings.updates.row.autoDownload.title": "Download updates automatically", + "settings.updates.row.autoDownload.description": + "Fetch an update as soon as it is found. Turn this off to be asked first — the download is several hundred megabytes", + "settings.updates.action.download": "Download", "settings.updates.row.check.title": "Check for updates", "settings.updates.row.check.description": "Manually check for updates and install if available", "settings.updates.action.checkNow": "Check now", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 2f5ecf5f9f..c7cb4c653b 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -637,6 +637,7 @@ export const dict = { "toast.update.title": "Actualización disponible", "toast.update.description": "Una nueva versión de CoDev ({{version}}) está disponible para instalar.", + "toast.update.description.available": "Hay una nueva versión de CoDev ({{version}}) disponible para descargar.", "toast.update.action.installRestart": "Instalar y reiniciar", "toast.update.action.notYet": "Todavía no", @@ -974,8 +975,13 @@ export const dict = { "settings.general.row.releaseNotes.description": 'Mostrar ventanas emergentes de "Novedades" después de las actualizaciones', - "settings.updates.row.startup.title": "Buscar actualizaciones al iniciar", - "settings.updates.row.startup.description": "Buscar actualizaciones automáticamente cuando se inicia CoDev", + "settings.updates.row.autoCheck.title": "Buscar actualizaciones automáticamente", + "settings.updates.row.autoCheck.description": + "Buscar actualizaciones al iniciar CoDev y cada diez minutos mientras se ejecuta", + "settings.updates.row.autoDownload.title": "Descargar actualizaciones automáticamente", + "settings.updates.row.autoDownload.description": + "Descargar una actualización en cuanto se encuentre. Desactívalo para que te pregunten primero: la descarga ocupa varios cientos de megabytes", + "settings.updates.action.download": "Descargar", "settings.updates.row.check.title": "Buscar actualizaciones", "settings.updates.row.check.description": "Buscar actualizaciones manualmente e instalarlas si hay alguna", "settings.updates.action.checkNow": "Buscar ahora", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7ab38e24af..21e0ca370a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -163,7 +163,16 @@ export default function LegacyLayout(props: ParentProps) { if (state?.status !== "ready") return return state.version } + // Deliberately separate from updateVersion, which the titlebar pill reads: + // that pill installs, and an update that has not been downloaded cannot be + // installed. The toast is the surface that can offer either. + const availableVersion = () => { + const state = platform.updater?.state() + if (state?.status !== "available") return + return state.version + } const installUpdate = () => void platform.updater?.install() + const downloadUpdate = () => void platform.updater?.download() const titlebarUpdate: TitlebarUpdate = { version: updateVersion, installing: () => platform.updater?.state().status === "installing", @@ -2223,8 +2232,16 @@ export default function LegacyLayout(props: ParentProps) { : undefined } /> - - + + {(version) => ( + + )}
@@ -2376,7 +2393,11 @@ export default function LegacyLayout(props: ParentProps) { function UpdateAvailableToast(props: { version: string + // Whether the installer is already on disk. Off, the toast offers to fetch + // it; the description says which of the two is being asked for. + downloaded: boolean install: () => void + download: () => void language: ReturnType }) { let toastId: number | undefined @@ -2386,11 +2407,16 @@ function UpdateAvailableToast(props: { persistent: true, icon: "download", title: props.language.t("toast.update.title"), - description: props.language.t("toast.update.description", { version: props.version }), + description: props.language.t( + props.downloaded ? "toast.update.description" : "toast.update.description.available", + { version: props.version }, + ), actions: [ { - label: props.language.t("toast.update.action.installRestart"), - onClick: props.install, + label: props.language.t( + props.downloaded ? "toast.update.action.installRestart" : "settings.updates.action.download", + ), + onClick: props.downloaded ? props.install : props.download, }, { label: props.language.t("toast.update.action.notYet"), diff --git a/packages/app/src/updater.ts b/packages/app/src/updater.ts index 228743488c..e7a49a3604 100644 --- a/packages/app/src/updater.ts +++ b/packages/app/src/updater.ts @@ -4,14 +4,35 @@ export type UpdaterState = | { status: "disabled" } | { status: "idle" } | { status: "checking" } + // Found, not fetched: where a check stops when automatic downloading is off. + // The download is the expensive half, so this is the state that exists for + // the user to consent to it. + | { status: "available"; version: string } | { status: "downloading"; version: string; percent?: number } | { status: "ready"; version: string } | { status: "up-to-date" } | { status: "installing"; version: string } | { status: "error"; message: string } +// Owned by the main process, which is where the update timer runs. The +// renderer only ever renders and edits them. +export type UpdaterPrefs = { + // Gates the check at launch and the periodic one after it. Never gates a + // check the user asked for: "Check now" works with this off. + autoCheck: boolean + // Whether finding an update also fetches it. Off stops the check at + // "available" and waits to be told. + autoDownload: boolean +} + +// Both on: the behaviour before either setting existed. +export const UPDATER_PREFS_DEFAULT: UpdaterPrefs = { autoCheck: true, autoDownload: true } + export type UpdaterPlatform = { state: Accessor + prefs: Accessor check(): Promise + download(): Promise install(): Promise + setPrefs(next: Partial): Promise } diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 10d5d41345..4e5789e02b 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -101,7 +101,7 @@ test("resolves artifacts against the bucket prefix, not its parent", async () => }) expect(resolved[0]?.url.href).toBe( - "https://netmind.viettel.vn/codev-storage/codev-code/codev-code-desktop-mac-universal.zip", + "https://netmind.viettel.vn/codev-storage/codev/codev-code-desktop-mac-universal.zip", ) }) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 87be342030..6a74f51337 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -70,7 +70,7 @@ const APP_IDS = { // Base64 (like packages/codev-brand/src/index.ts) to keep the host out of // casual greps, not as a secret — the decoded URL ships in every packaged // build's app-update.yml. -const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldi1jb2Rl") +const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldg==") // electron-builder bakes this into app-update.yml; the installers are uploaded // to the bucket by hand, so `generic` (which has no publisher) is the whole diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index e7ca4da3a4..49e7fc72b0 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -336,7 +336,14 @@ const main = Effect.gen(function* () { }), ) void updater.start() - const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) + // Checked at fire time rather than captured: turning automatic checks off + // has to stop the timer that is already running, not the next app launch. + const updateTimer = setInterval( + () => { + if (updater.getPrefs().autoCheck) void updater.check() + }, + 10 * 60 * 1000, + ) updateTimer.unref() app.once("will-quit", () => clearInterval(updateTimer)) yield* Effect.promise(() => startNetLog()).pipe( diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index e753a1bd70..57e3a04cb2 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -68,18 +68,34 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) ipcMain.handle("updater-subscribe", (event) => { const id = event.sender.id - updaterSubscriptions.set( - id, - deps.updater.subscribe((state) => { - if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id) - event.sender.send("updater-state", state) - }), - ) + // One registration, two streams: prefs live in the main process, so every + // window has to hear about a change another window made. + const offState = deps.updater.subscribe((state) => { + if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id) + event.sender.send("updater-state", state) + }) + const offPrefs = deps.updater.subscribePrefs((prefs) => { + if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id) + event.sender.send("updater-prefs", prefs) + }) + updaterSubscriptions.set(id, () => { + offState() + offPrefs() + }) event.sender.once("destroyed", () => updaterSubscriptions.delete(id)) }) ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id)) ipcMain.handle("updater-check", () => deps.updater.check()) + ipcMain.handle("updater-download", () => deps.updater.download()) ipcMain.handle("updater-install", () => deps.updater.install()) + ipcMain.handle("updater-set-prefs", (_event: IpcMainInvokeEvent, next: unknown) => { + if (!next || typeof next !== "object") throw new Error("Invalid updater prefs") + const record = next as Record + return deps.updater.setPrefs({ + ...(typeof record.autoCheck === "boolean" ? { autoCheck: record.autoCheck } : {}), + ...(typeof record.autoDownload === "boolean" ? { autoDownload: record.autoDownload } : {}), + }) + }) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs()) ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) => diff --git a/packages/desktop/src/main/updater-controller.test.ts b/packages/desktop/src/main/updater-controller.test.ts index ff3f7d1dfd..bec12c2cce 100644 --- a/packages/desktop/src/main/updater-controller.test.ts +++ b/packages/desktop/src/main/updater-controller.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" -import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller" - -function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) { +import { + createUpdaterController, + type UpdaterBackend, + type UpdaterPrefs, + type UpdaterReadyRecord, +} from "./updater-controller" + +function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord; prefs?: Partial }) { const calls: string[] = [] const backend: UpdaterBackend = { async checkForUpdates() { @@ -16,10 +21,15 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) }, } let ready = input?.ready + let saved: UpdaterPrefs | undefined const controller = createUpdaterController({ enabled: true, currentVersion: input?.currentVersion ?? "1.0.0", backend, + initialPrefs: input?.prefs, + savePrefs: (prefs) => { + saved = prefs + }, persistence: { get: () => ready, set: (value) => { @@ -33,7 +43,7 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) calls.push("stop") }, }) - return { controller, calls, getReady: () => ready } + return { controller, calls, getReady: () => ready, getSavedPrefs: () => saved } } describe("updater controller", () => { @@ -108,4 +118,74 @@ describe("updater controller", () => { await expect(failed.install()).rejects.toThrow("stop failed") expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" }) }) + + // Both on is what the updater did before either setting existed. + test("checks and downloads automatically by default", async () => { + const app = setup() + await app.controller.start() + expect(app.controller.getPrefs()).toEqual({ autoCheck: true, autoDownload: true }) + expect(app.calls).toEqual(["check", "download"]) + }) + + test("automatic checking off skips the launch check but not a manual one", async () => { + const app = setup({ prefs: { autoCheck: false } }) + + await app.controller.start() + expect(app.calls).toEqual([]) + expect(app.controller.getState()).toEqual({ status: "idle" }) + + // "Check now" is the user asking, which this setting never gates. + await app.controller.check() + expect(app.calls).toEqual(["check", "download"]) + }) + + test("automatic downloading off stops at available and waits to be told", async () => { + const app = setup({ prefs: { autoDownload: false } }) + const states: ReturnType[] = [] + app.controller.subscribe((state) => states.push(state)) + + await app.controller.start() + expect(app.calls).toEqual(["check"]) + expect(app.controller.getState()).toEqual({ status: "available", version: "2.0.0" }) + // Nothing is on disk yet, so nothing may claim to be ready. + expect(app.getReady()).toBeUndefined() + + await app.controller.download() + expect(app.calls).toEqual(["check", "download"]) + expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) + expect(app.getReady()).toEqual({ version: "2.0.0" }) + expect(states.map((state) => state.status)).toEqual(["idle", "checking", "available", "downloading", "ready"]) + }) + + // Re-checking would ask the feed a question it has already answered, and the + // 10-minute timer would do it forever while the user leaves the toast up. + test("a further check while an update is available is a no-op", async () => { + const app = setup({ prefs: { autoDownload: false } }) + await app.controller.start() + await app.controller.check() + expect(app.calls).toEqual(["check"]) + }) + + test("downloading is only possible from available", async () => { + const app = setup({ prefs: { autoDownload: false } }) + expect((await app.controller.download()).status).toBe("idle") + expect(app.calls).toEqual([]) + }) + + test("prefs merge, persist, and reach subscribers", async () => { + const app = setup() + const seen: UpdaterPrefs[] = [] + app.controller.subscribePrefs((prefs) => seen.push(prefs)) + + await app.controller.setPrefs({ autoDownload: false }) + + expect(app.controller.getPrefs()).toEqual({ autoCheck: true, autoDownload: false }) + expect(app.getSavedPrefs()).toEqual({ autoCheck: true, autoDownload: false }) + // Replayed on attach, then the change: a second window must not have to + // ask for the value it just missed. + expect(seen).toEqual([ + { autoCheck: true, autoDownload: true }, + { autoCheck: true, autoDownload: false }, + ]) + }) }) diff --git a/packages/desktop/src/main/updater-controller.ts b/packages/desktop/src/main/updater-controller.ts index a0ae1a8927..89374a4f3f 100644 --- a/packages/desktop/src/main/updater-controller.ts +++ b/packages/desktop/src/main/updater-controller.ts @@ -1,6 +1,6 @@ -import type { UpdaterState } from "@opencode-ai/app/updater" +import { UPDATER_PREFS_DEFAULT, type UpdaterPrefs, type UpdaterState } from "@opencode-ai/app/updater" -export type { UpdaterState } from "@opencode-ai/app/updater" +export type { UpdaterPrefs, UpdaterState } from "@opencode-ai/app/updater" export type UpdaterReadyRecord = { version: string } @@ -21,12 +21,16 @@ export function createUpdaterController(input: { currentVersion: string backend: UpdaterBackend persistence: UpdaterPersistence + initialPrefs?: Partial + savePrefs?: (prefs: UpdaterPrefs) => void | Promise stop: () => Promise log?: (message: string, data?: object) => void }) { let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" } + let prefs: UpdaterPrefs = { ...UPDATER_PREFS_DEFAULT, ...input.initialPrefs } let pending: Promise | undefined const listeners = new Set<(state: UpdaterState) => void>() + const prefsListeners = new Set<(prefs: UpdaterPrefs) => void>() const transition = (next: UpdaterState) => { input.log?.("updater state changed", { from: state.status, to: next.status }) @@ -35,12 +39,35 @@ export function createUpdaterController(input: { return state } + // Shared by an automatic download and one the user asked for, so the two + // cannot drift on what a finished download leaves behind. + const fetchUpdate = async (version: string) => { + transition({ status: "downloading", version }) + await input.backend.downloadUpdate() + await input.persistence.set({ version }) + return transition({ status: "ready", version }) + } + + const run = (work: () => Promise) => { + pending = work() + .catch((error) => + transition({ status: "error", message: error instanceof Error ? error.message : String(error) }), + ) + .finally(() => { + pending = undefined + }) + return pending + } + const check = () => { if (!input.enabled) return Promise.resolve(state) if (state.status === "ready") return Promise.resolve(state) + // Already found and waiting to be downloaded: checking again would only + // ask the feed the same question. + if (state.status === "available") return Promise.resolve(state) if (pending) return pending - pending = (async () => { + return run(async () => { transition({ status: "checking" }) const result = await input.backend.checkForUpdates() const version = result?.updateInfo?.version @@ -48,34 +75,53 @@ export function createUpdaterController(input: { await input.persistence.clear() return transition({ status: "up-to-date" }) } - - transition({ status: "downloading", version }) - await input.backend.downloadUpdate() - await input.persistence.set({ version }) - return transition({ status: "ready", version }) - })() - .catch((error) => - transition({ status: "error", message: error instanceof Error ? error.message : String(error) }), - ) - .finally(() => { - pending = undefined - }) - return pending + // Stop at the discovery when the download has not been consented to. + // The installers are hundreds of megabytes, and the fleet's links are + // metered — that is the whole reason this setting exists. + if (!prefs.autoDownload) return transition({ status: "available", version }) + return fetchUpdate(version) + }) } return { getState: () => state, + getPrefs: () => prefs, subscribe(listener: (state: UpdaterState) => void) { listeners.add(listener) listener(state) return () => listeners.delete(listener) }, + subscribePrefs(listener: (prefs: UpdaterPrefs) => void) { + prefsListeners.add(listener) + listener(prefs) + return () => prefsListeners.delete(listener) + }, + // Merged rather than replaced: two windows can each hold a stale copy of + // the other's setting, and a full write would undo it. + async setPrefs(next: Partial) { + prefs = { ...prefs, ...next } + await input.savePrefs?.(prefs) + input.log?.("updater prefs changed", prefs) + prefsListeners.forEach((listener) => listener(prefs)) + return prefs + }, async start() { const ready = await input.persistence.get() if (ready?.version === input.currentVersion) await input.persistence.clear() + // Launch checking is the half of automatic checking the user sees; the + // timer in index.ts is the other. A check they ask for is never gated. + if (!prefs.autoCheck) return state return check() }, check, + // The consent step for a download that automatic downloading would have + // started by itself. + download() { + if (state.status !== "available") return Promise.resolve(state) + if (pending) return pending + const version = state.version + return run(() => fetchUpdate(version)) + }, async install() { if (state.status !== "ready") throw new Error("Update is not ready to install") const version = state.version diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index 2d87f13348..53564ffbcb 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -8,6 +8,7 @@ import { setAppQuitting } from "./windows" const { autoUpdater } = pkg const key = "ready" +const prefsKey = "prefs" export function setupAutoUpdater(stop: () => Promise) { const logger = getLogger() @@ -27,8 +28,21 @@ export function setupAutoUpdater(stop: () => Promise) { }) const store = getStore("opencode.updater") + // Same store as the ready record: one file for everything the updater + // remembers between launches. + const readPrefs = () => { + const value = store.get(prefsKey) + if (!value || typeof value !== "object") return undefined + const record = value as Record + return { + ...(typeof record.autoCheck === "boolean" ? { autoCheck: record.autoCheck } : {}), + ...(typeof record.autoDownload === "boolean" ? { autoDownload: record.autoDownload } : {}), + } + } return createUpdaterController({ enabled: UPDATER_ENABLED, + initialPrefs: readPrefs(), + savePrefs: (prefs) => store.set(prefsKey, prefs), currentVersion: app.getVersion(), backend: { checkForUpdates: () => autoUpdater.checkForUpdates(), diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 031a9d63f8..6b35ed4bcb 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" import type { ElectronAPI, GatewaySsoState, SandboxesEvent, WslInstallState, WslServersEvent } from "./types" -import type { UpdaterState } from "@opencode-ai/app/updater" +import type { UpdaterPrefs, UpdaterState } from "@opencode-ai/app/updater" const updaterCallbacks = new Set<(state: UpdaterState) => void>() let updaterState: UpdaterState | undefined @@ -109,7 +109,14 @@ const api: ElectronAPI = { } }, check: () => ipcRenderer.invoke("updater-check"), + download: () => ipcRenderer.invoke("updater-download"), install: () => ipcRenderer.invoke("updater-install"), + setPrefs: (next) => ipcRenderer.invoke("updater-set-prefs", next), + subscribePrefs: (cb) => { + const handler = (_: unknown, prefs: UpdaterPrefs) => cb(prefs) + ipcRenderer.on("updater-prefs", handler) + return () => ipcRenderer.removeListener("updater-prefs", handler) + }, }, consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 122b9162be..0658b13102 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -2,7 +2,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" import type { SandboxesPlatform } from "@opencode-ai/app/sandbox/types" import type { GatewaySsoPlatform } from "@opencode-ai/app/gateway/types" -import type { UpdaterState } from "@opencode-ai/app/updater" +import type { UpdaterPrefs, UpdaterState } from "@opencode-ai/app/updater" export type { WslDistroProbe, WslInstalledDistro, @@ -39,8 +39,13 @@ export type SandboxesAPI = SandboxesPlatform export type GatewaySsoAPI = GatewaySsoPlatform export type UpdaterAPI = { subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void> + // Registered by updater-subscribe alongside the state stream, so this only + // needs to detach the listener. + subscribePrefs: (cb: (prefs: UpdaterPrefs) => void) => () => void check: () => Promise + download: () => Promise install: () => Promise + setPrefs: (next: Partial) => Promise } export type LinuxDisplayBackend = "wayland" | "auto" diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index cd420cb0e0..baa502b792 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -15,7 +15,7 @@ import { useSandboxes, useWslServers, } from "@opencode-ai/app" -import type { UpdaterState } from "@opencode-ai/app/updater" +import { UPDATER_PREFS_DEFAULT, type UpdaterPrefs, type UpdaterState } from "@opencode-ai/app/updater" import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router" @@ -63,6 +63,10 @@ if (import.meta.env.VITE_SENTRY_DSN) { void initI18n() const [updaterState, setUpdaterState] = createSignal({ status: "disabled" }) +const [updaterPrefs, setUpdaterPrefs] = createSignal(UPDATER_PREFS_DEFAULT) +// Both streams come from the one updater-subscribe registration, and each +// replays its current value on attach. +window.api.updater.subscribePrefs(setUpdaterPrefs) void window.api.updater.subscribe(setUpdaterState) const deepLinkEvent = "codev:deep-link" @@ -243,8 +247,13 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { updater: { state: updaterState, + prefs: updaterPrefs, check: () => window.api.updater.check(), + download: () => window.api.updater.download(), install: () => window.api.updater.install(), + setPrefs: async (next: Partial) => { + setUpdaterPrefs(await window.api.updater.setPrefs(next)) + }, }, exportDebugLogs: () => window.api.exportDebugLogs(), From 7d4c2730bc7fea454b81810d8d6b4064cbc2a85f Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 18 Aug 2026 17:53:51 +0700 Subject: [PATCH 2/3] chore(desktop): point the update feed at a per-version folder The artifacts now live under codev-storage/codev/ 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. --- CHANGELOG.md | 10 +++++----- DIVERGENCES.md | 2 +- packages/desktop/electron-builder.config.test.ts | 2 +- packages/desktop/electron-builder.config.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee854a5c0..6ca82f0c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,11 +26,11 @@ file is the human-readable summary of what changed and why. still works with both switches off; it is the one check that was always yours to ask for. There was a translated "Check for updates on startup" setting in the app that no screen ever showed; these two replace it. -- **The update feed moved** — installers are now published to - `codev-storage/codev` rather than `codev-storage/codev-code`. Builds already - installed keep updating: the new location ships in the next build's - `app-update.yml`, so the change reaches a machine with the update that - carries it. +- **The update feed moved** — installers are now published under + `codev-storage/codev/` rather than `codev-storage/codev-code`. + Builds already installed keep updating: the new location ships in the next + build's `app-update.yml`, so the change reaches a machine with the update + that carries it. ### Sandbox diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 46dc36c4e8..d17c4c6f88 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -188,7 +188,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. | | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev" (Dev/Beta variants too) | Desktop app display name ("Code" dropped once the app carried a Work section beside Code). | Keep our names. | -| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which 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 to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `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. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | +| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (a per-version prefix, `…/codev/`) (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which 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 to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `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. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | | `packages/app/src/utils/refcount.ts` (+ `.test.ts`) | `createRefCountMap` creates each item in its own root owned by the scope that created the map (disposed when the last pinning caller releases it), and callers without a reactive owner borrow the item without pinning it. | Upstream ran `create` in the *first caller's* scope, so a shared item's internal cleanups (e.g. the dir SDK context's event unsubscribe in `server-sdk.tsx`) fired when that first view closed while other holders still used the item — live updates for the directory silently died. Ownerless callers (event handlers, async continuations: `notification.tsx` session lookup, `layout.tsx` openSession) also registered cleanups Solid warns can never run and pinned items forever. | Keep ours; take upstream's surrounding changes. The two added tests ("ownerless caller borrows without pinning", "item's internal cleanups survive its first caller") pin the semantics. | | `packages/app/src/app.tsx`, `packages/app/src/utils/session-route.ts` | `session-route.ts` gains `serverKeyFromRoute`, a non-throwing variant of `requireServerKey` (which is now defined in terms of it and kept for callers that want the throw). `app.tsx`'s `TargetServerRoute` and `LegacyTargetSessionRoute` read the route's server key through it: `TargetServerRoute` holds it in a memo, passes `undefined` rather than a bogus connection to the SDK/sync providers, and falls back to ``; `LegacyTargetSessionRoute` wraps its `SessionRouteErrorBoundary` in a `Show` on the same memo. | `requireServerKey` throws on a segment that is missing or does not round-trip, and both call sites ran it during render — so the throw escaped from a memo and killed the entire renderer with the fatal error screen. The param does go missing in normal use: removing a server navigates away (`tabs.removeServer` → `navigate("/")`) and the route's params clear before its subtree unmounts, which is how deleting a sandbox with an open session took the window down. Worse, the desktop restores the last active route on launch, so the crash repeated on every relaunch until the persisted URL changed. Rendering nothing was not enough on its own — a `/server/…` route that cannot mount its providers leaves the shell half-built and fails further down in the command registry — hence the redirect home. This is the same class as the `project-avatar-state.ts` guard below and is an upstream bug. | Keep the guard; take upstream's other changes to these files. If upstream makes the route resilient its own way, drop ours in favour of theirs. Do not "simplify" the route back to calling `requireServerKey` directly — that is the crash. | | `packages/app/src/context/server-sdk.tsx` | Event-stream failures log only after 3 consecutive failed attempts (`logStreamIssue`), with the error normalized to `name: message`; any received event resets the count. | The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious `[global-sdk] event stream error` for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). | Keep the threshold and shared `logStreamIssue`; take upstream's surrounding stream changes. | diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 4e5789e02b..d324e746e6 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -101,7 +101,7 @@ test("resolves artifacts against the bucket prefix, not its parent", async () => }) expect(resolved[0]?.url.href).toBe( - "https://netmind.viettel.vn/codev-storage/codev/codev-code-desktop-mac-universal.zip", + "https://netmind.viettel.vn/codev-storage/codev/1.18.4-8/codev-code-desktop-mac-universal.zip", ) }) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 6a74f51337..474f285826 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -70,7 +70,7 @@ const APP_IDS = { // Base64 (like packages/codev-brand/src/index.ts) to keep the host out of // casual greps, not as a secret — the decoded URL ships in every packaged // build's app-update.yml. -const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldg==") +const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldi8xLjE4LjQtOA==") // electron-builder bakes this into app-update.yml; the installers are uploaded // to the bucket by hand, so `generic` (which has no publisher) is the whole From 9341497fdd73778503a6ea439c8954021c32cd46 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 18 Aug 2026 18:04:47 +0700 Subject: [PATCH 3/3] chore(desktop): keep the update manifest at a stable root, artifacts per version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /. 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. --- CHANGELOG.md | 5 +- DIVERGENCES.md | 2 +- .../desktop/electron-builder.config.test.ts | 6 +- packages/desktop/electron-builder.config.ts | 8 +- packages/desktop/package.json | 1 + packages/desktop/scripts/stage-release.ts | 100 ++++++++++++++++++ 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 packages/desktop/scripts/stage-release.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ca82f0c84..11bcbd2295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,9 @@ file is the human-readable summary of what changed and why. still works with both switches off; it is the one check that was always yours to ask for. There was a translated "Check for updates on startup" setting in the app that no screen ever showed; these two replace it. -- **The update feed moved** — installers are now published under - `codev-storage/codev/` rather than `codev-storage/codev-code`. +- **The update feed moved** — it is now `codev-storage/codev` rather than + `codev-storage/codev-code`, and each release's installers sit in their own + `` folder beneath it instead of overwriting the previous release. Builds already installed keep updating: the new location ships in the next build's `app-update.yml`, so the change reaches a machine with the update that carries it. diff --git a/DIVERGENCES.md b/DIVERGENCES.md index d17c4c6f88..654785f70f 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -188,7 +188,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. | | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev" (Dev/Beta variants too) | Desktop app display name ("Code" dropped once the app carried a Work section beside Code). | Keep our names. | -| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (a per-version prefix, `…/codev/`) (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which 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 to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `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. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | +| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const, deliberately **unversioned** — it is the only URL an installed build ever reads (`app-update.yml` is baked at package time and nothing calls `setFeedURL`), so a version in it would leave that build able to see only its own release folder. The manifests sit at that base and the artifacts they name live under `/`, which the fork-only `scripts/stage-release.ts` arranges by prefixing each `url`/`path` in `latest*.yml`; electron-updater resolves them with `new URL(name, base)` (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which 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 to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `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. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | | `packages/app/src/utils/refcount.ts` (+ `.test.ts`) | `createRefCountMap` creates each item in its own root owned by the scope that created the map (disposed when the last pinning caller releases it), and callers without a reactive owner borrow the item without pinning it. | Upstream ran `create` in the *first caller's* scope, so a shared item's internal cleanups (e.g. the dir SDK context's event unsubscribe in `server-sdk.tsx`) fired when that first view closed while other holders still used the item — live updates for the directory silently died. Ownerless callers (event handlers, async continuations: `notification.tsx` session lookup, `layout.tsx` openSession) also registered cleanups Solid warns can never run and pinned items forever. | Keep ours; take upstream's surrounding changes. The two added tests ("ownerless caller borrows without pinning", "item's internal cleanups survive its first caller") pin the semantics. | | `packages/app/src/app.tsx`, `packages/app/src/utils/session-route.ts` | `session-route.ts` gains `serverKeyFromRoute`, a non-throwing variant of `requireServerKey` (which is now defined in terms of it and kept for callers that want the throw). `app.tsx`'s `TargetServerRoute` and `LegacyTargetSessionRoute` read the route's server key through it: `TargetServerRoute` holds it in a memo, passes `undefined` rather than a bogus connection to the SDK/sync providers, and falls back to ``; `LegacyTargetSessionRoute` wraps its `SessionRouteErrorBoundary` in a `Show` on the same memo. | `requireServerKey` throws on a segment that is missing or does not round-trip, and both call sites ran it during render — so the throw escaped from a memo and killed the entire renderer with the fatal error screen. The param does go missing in normal use: removing a server navigates away (`tabs.removeServer` → `navigate("/")`) and the route's params clear before its subtree unmounts, which is how deleting a sandbox with an open session took the window down. Worse, the desktop restores the last active route on launch, so the crash repeated on every relaunch until the persisted URL changed. Rendering nothing was not enough on its own — a `/server/…` route that cannot mount its providers leaves the shell half-built and fails further down in the command registry — hence the redirect home. This is the same class as the `project-avatar-state.ts` guard below and is an upstream bug. | Keep the guard; take upstream's other changes to these files. If upstream makes the route resilient its own way, drop ours in favour of theirs. Do not "simplify" the route back to calling `requireServerKey` directly — that is the crash. | | `packages/app/src/context/server-sdk.tsx` | Event-stream failures log only after 3 consecutive failed attempts (`logStreamIssue`), with the error normalized to `name: message`; any received event resets the count. | The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious `[global-sdk] event stream error` for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). | Keep the threshold and shared `logStreamIssue`; take upstream's surrounding stream changes. | diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index d324e746e6..f01e2be206 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -94,8 +94,10 @@ test("resolves artifacts against the bucket prefix, not its parent", async () => const resolved = provider.resolveFiles({ version: "1.18.4-9", - files: [{ url: "codev-code-desktop-mac-universal.zip", sha512: "x", size: 1 }], - path: "codev-code-desktop-mac-universal.zip", + // Staged manifests name artifacts under their version (stage-release.ts); + // this pins the relative resolution the whole layout depends on. + files: [{ url: "1.18.4-8/codev-code-desktop-mac-universal.zip", sha512: "x", size: 1 }], + path: "1.18.4-8/codev-code-desktop-mac-universal.zip", sha512: "x", releaseDate: "2026-08-11T00:00:00.000Z", }) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 474f285826..cc26787eef 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -70,7 +70,13 @@ const APP_IDS = { // Base64 (like packages/codev-brand/src/index.ts) to keep the host out of // casual greps, not as a secret — the decoded URL ships in every packaged // build's app-update.yml. -const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldi8xLjE4LjQtOA==") +// +// Deliberately unversioned. This is the one URL an installed build ever reads +// (app-update.yml is baked at package time and nothing calls setFeedURL), so a +// version in it would leave that build looking only at its own release folder, +// unable to see any later one. The manifests sit here; the artifacts they name +// live under /, which scripts/stage-release.ts arranges. +const UPDATE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZS9jb2Rldg==") // electron-builder bakes this into app-update.yml; the installers are uploaded // to the bucket by hand, so `generic` (which has no publisher) is the whole diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 93e72c743b..3d50ca0f12 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -17,6 +17,7 @@ "build": "electron-vite build", "preview": "electron-vite preview", "package": "electron-builder --config electron-builder.config.ts", + "stage-release": "bun ./scripts/stage-release.ts", "package:mac": "electron-builder --mac --config electron-builder.config.ts", "package:win": "electron-builder --win --config electron-builder.config.ts", "package:linux": "electron-builder --linux --config electron-builder.config.ts", diff --git a/packages/desktop/scripts/stage-release.ts b/packages/desktop/scripts/stage-release.ts new file mode 100644 index 0000000000..9fbe638861 --- /dev/null +++ b/packages/desktop/scripts/stage-release.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env bun +// Lays a packaged build out the way the update bucket expects it. +// Usage: bun scripts/stage-release.ts [--dist dist] [--out dist/release] +// +// electron-builder names every artifact in latest*.yml by bare filename, which +// resolves against the feed's base URL — so out of the box a release can only +// live at that base, and each one overwrites the last. Prefixing those names +// with the version puts the artifacts in a per-version folder while the +// manifest stays at the base URL, which is the one path an installed build +// ever reads (its app-update.yml is baked at package time and nothing calls +// setFeedURL). electron-updater resolves the prefixed name with +// `new URL(name, base)`, so no client change is needed. +// +// Produces, for base https://…/codev: +// /latest-mac.yml -> …/codev/latest-mac.yml +// // -> …/codev//… +import { $ } from "bun" +import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" +import { join } from "node:path" + +const distDir = argValue("--dist") ?? join(import.meta.dir, "..", "dist") +const outDir = argValue("--out") ?? join(distDir, "release") + +const manifests = (await readdir(distDir)).filter((name) => /^latest(-\w+)*\.yml$/.test(name)) +if (!manifests.length) { + console.error(`no latest*.yml in ${distDir}`) + console.error("update manifests are only written for beta/prod builds: CODEV_CHANNEL=prod bun run package:mac") + process.exit(1) +} + +for (const manifest of manifests) { + const original = await readFile(join(distDir, manifest), "utf8") + const version = /^version:\s*(\S+)\s*$/m.exec(original)?.[1] + if (!version) { + console.error(`${manifest} has no version line`) + process.exit(1) + } + + // Only the lines that name a file. Rewriting is idempotent so a second run + // over an already-staged manifest is a no-op rather than a double prefix. + const named: string[] = [] + const staged = original + .split("\n") + .map((line) => { + const match = /^(\s*-?\s*(?:url|path):\s*)(\S+)\s*$/.exec(line) + if (!match) return line + const [, prefix, name] = match + if (name!.startsWith(`${version}/`)) return line + named.push(name!) + return `${prefix}${version}/${name}` + }) + .join("\n") + // A format change upstream must fail here rather than quietly publish a + // manifest whose files resolve to the wrong place. + if (!named.length) { + console.error(`${manifest}: no url/path entries were rewritten; check the electron-builder output format`) + process.exit(1) + } + + await mkdir(join(outDir, version), { recursive: true }) + await writeFile(join(outDir, manifest), staged) + + // Blockmaps are not listed in the manifest — electron-updater derives their + // URL from the artifact's — so they have to travel with it or every update + // silently falls back to a full download. + for (const name of [...new Set(named)]) { + for (const file of [name, `${name}.blockmap`]) { + const source = join(distDir, file) + if (!(await stat(source).catch(() => null))?.isFile()) { + if (file.endsWith(".blockmap")) continue + console.error(`${manifest} names ${file}, which is not in ${distDir}`) + process.exit(1) + } + await copyFile(source, join(outDir, version, file)) + } + } + console.log(`${manifest}: ${[...new Set(named)].length} artifact(s) staged under ${version}/`) +} + +console.log(`\nstaged in ${outDir}`) +const target = process.env.CODEV_UPDATE_TARGET +const upload = (from: string, to: string) => + target + ? $`mc cp ${from} ${target}/${to}` + : Promise.resolve(console.log(` mc cp ${from} //codev/${to}`)) + +if (!target) console.log("\nCODEV_UPDATE_TARGET is unset; run these where the storage credentials live:") +// Artifacts first, manifests last: a client that reads the manifest must find +// every file it names already in place. +for (const manifest of manifests) { + const version = /^version:\s*(\S+)\s*$/m.exec(await readFile(join(outDir, manifest), "utf8"))![1]! + for (const file of await readdir(join(outDir, version))) + await upload(join(outDir, version, file), `${version}/${file}`) +} +for (const manifest of manifests) await upload(join(outDir, manifest), manifest) + +function argValue(flag: string) { + const index = process.argv.indexOf(flag) + return index >= 0 ? process.argv[index + 1] : undefined +}