Skip to content

Commit 102ad5e

Browse files
quickbeardQuickbeardclaude
authored
feat(desktop): show live download progress for app updates (#336)
* feat(desktop): show live download progress for app updates The downloading state carries a whole-number percent now: the updater controller gains reportDownloadProgress (floored, downloading-only, and deduped per whole percent so chunk-level events do not fan out to every window), fed from electron-updater's download-progress event. The update dialog renders it as a progress circle and also opens while a download is running, so an automatic download in flight is visible instead of silent until ready. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: changelog entry for update download progress Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quickbeard <quickbeard@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 83ccf00 commit 102ad5e

9 files changed

Lines changed: 87 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ file is the human-readable summary of what changed and why.
193193
**Download**. The dialogs are also translated, along with the rest of the
194194
app — they were English-only before, being built outside the web UI.
195195
([#323](https://github.com/quickbeard/codev-code/pull/323))
196+
- **Downloading an update shows its progress** — the download used to show
197+
only a spinner, and one that automatic downloading started in the background
198+
showed nothing at all until **Install and restart** appeared; on a slow link
199+
a several-hundred-megabyte installer read as a hang. The update dialog now
200+
opens while a download is running and counts it up with a live percentage.
201+
([#336](https://github.com/quickbeard/codev-code/pull/336))
196202
- **The terminal app asks about updates instead of ignoring them** — `codev`
197203
checked for a new version a second after launch and announced it with an
198204
internal event that the interface subscribes to as it starts up. The check

‎DIVERGENCES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ from the squash message first, or use a plain merge.
251251
| `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"). |
252252

253253
| 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, the restart-Windows action with its confirm step, and the failure line). preload exposes five channels on the existing `api.sandboxes` object — `sandbox-wsl-install{,-cancel,-state}`, a `sandbox-wsl-install-subscribe`/`-event` pair, and `sandbox-wsl-restart` (runs `shutdown.exe /r /t 0` for the restart a restart-required install asks for; the renderer confirms first) — 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 five 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`. |
254-
| 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. |
254+
| 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`. The `downloading` state additionally carries a whole-number `percent`: the controller gains `reportDownloadProgress(percent)` (floored, no-op unless currently `downloading` and the whole percent moved — the backend reports every chunk and each transition is a log line plus an IPC send to every window), `main/updater.ts` feeds it from `autoUpdater.on("download-progress")`, and the fork-only update dialog (`dialog-update.tsx`) renders it as a progress circle and opens on `downloading` too, so an automatic download in flight is visible rather than silent until ready. i18n adds `toast.update.description.downloading`. | 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. |
255255
| `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. |
256256

257257
| `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:<name>`. 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:<name>`, 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. |

‎packages/app/src/components/dialog-update.tsx‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useDialog } from "@opencode-ai/ui/context/dialog"
22
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
3+
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
34
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
45
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
56
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
@@ -25,9 +26,10 @@ export function UpdatePrompt() {
2526
createEffect(() => {
2627
const status = platform.updater?.state().status
2728
// "available" is a check that stopped before downloading because automatic
28-
// downloading is off; "ready" is one that went all the way. Either way
29-
// there is an update the user has not been told about.
30-
if (status !== "available" && status !== "ready") return
29+
// downloading is off; "downloading" is one automatic downloading already
30+
// acted on; "ready" is one that went all the way. Either way there is an
31+
// update the user has not been told about.
32+
if (status !== "available" && status !== "downloading" && status !== "ready") return
3133
if (prompted) return
3234
prompted = true
3335
// push, not show: a dialog the user opened themselves must survive a check
@@ -56,6 +58,16 @@ export function DialogUpdate() {
5658
// The two steps that carry on by themselves once started; the button reports
5759
// them rather than offering to start them again.
5860
const busy = () => state()?.status === "downloading" || state()?.status === "installing"
61+
const percent = () => {
62+
const value = state()
63+
return value?.status === "downloading" ? value.percent : undefined
64+
}
65+
const descriptionKey = () => {
66+
const status = state()?.status
67+
if (status === "ready" || status === "installing") return "toast.update.description" as const
68+
if (status === "downloading") return "toast.update.description.downloading" as const
69+
return "toast.update.description.available" as const
70+
}
5971

6072
return (
6173
<Dialog fit>
@@ -64,14 +76,16 @@ export function DialogUpdate() {
6476
</DialogHeader>
6577
<DividerV2 />
6678
<DialogBody class="flex max-w-[440px] flex-col gap-3 px-4 pt-3 pb-1">
67-
{/* Downloaded and downloadable are different asks — one restarts the
68-
app, the other spends a few hundred megabytes — so they get
69-
different sentences. */}
70-
<p class="text-14">
71-
{language.t(state()?.status === "ready" ? "toast.update.description" : "toast.update.description.available", {
72-
version: version(),
73-
})}
74-
</p>
79+
{/* Downloaded, downloading, and downloadable are different asks — one
80+
restarts the app, one is already spending a few hundred megabytes,
81+
one is asking to — so they get different sentences. */}
82+
<p class="text-14">{language.t(descriptionKey(), { version: version() })}</p>
83+
<Show when={percent() !== undefined}>
84+
<div class="flex items-center gap-2 text-12 text-v2-text-text-muted">
85+
<ProgressCircleV2 percentage={percent() ?? 0} />
86+
<span>{percent()}%</span>
87+
</div>
88+
</Show>
7589
<Show when={error()}>
7690
{(message) => (
7791
<p role="alert" class="text-12 text-v2-state-fg-danger">

‎packages/app/src/i18n/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ export const dict = {
698698
"toast.update.title": "Update available",
699699
"toast.update.description": "A new version of CoDev ({{version}}) is now available to install.",
700700
"toast.update.description.available": "A new version of CoDev ({{version}}) is available to download.",
701+
"toast.update.description.downloading": "A new version of CoDev ({{version}}) is downloading.",
701702
"toast.update.action.installRestart": "Install and restart",
702703
"toast.update.action.notYet": "Not yet",
703704

‎packages/app/src/i18n/es.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ export const dict = {
796796
"toast.update.title": "Actualización disponible",
797797
"toast.update.description": "Una nueva versión de CoDev ({{version}}) está disponible para instalar.",
798798
"toast.update.description.available": "Hay una nueva versión de CoDev ({{version}}) disponible para descargar.",
799+
"toast.update.description.downloading": "Se está descargando una nueva versión de CoDev ({{version}}).",
799800
"toast.update.action.installRestart": "Instalar y reiniciar",
800801
"toast.update.action.notYet": "Todavía no",
801802

‎packages/app/src/i18n/vi.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ export const dict = {
662662
"toast.update.title": "Có bản cập nhật",
663663
"toast.update.description": "Phiên bản mới của CoDev ({{version}}) đã sẵn sàng để cài đặt.",
664664
"toast.update.description.available": "Phiên bản mới của CoDev ({{version}}) đã sẵn sàng để tải xuống.",
665+
"toast.update.description.downloading": "Đang tải xuống phiên bản mới của CoDev ({{version}}).",
665666
"toast.update.action.installRestart": "Cài đặt và khởi động lại",
666667
"toast.update.action.notYet": "Để sau",
667668

‎packages/desktop/src/main/updater-controller.test.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import {
66
type UpdaterReadyRecord,
77
} from "./updater-controller"
88

9-
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord; prefs?: Partial<UpdaterPrefs> }) {
9+
function setup(input?: {
10+
currentVersion?: string
11+
ready?: UpdaterReadyRecord
12+
prefs?: Partial<UpdaterPrefs>
13+
download?: () => Promise<void>
14+
}) {
1015
const calls: string[] = []
1116
const backend: UpdaterBackend = {
1217
async checkForUpdates() {
@@ -15,6 +20,7 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord; pr
1520
},
1621
async downloadUpdate() {
1722
calls.push("download")
23+
await input?.download?.()
1824
},
1925
quitAndInstall() {
2026
calls.push("install")
@@ -172,6 +178,34 @@ describe("updater controller", () => {
172178
expect(app.calls).toEqual([])
173179
})
174180

181+
test("progress rides the downloading state in whole percents and dies with it", async () => {
182+
let release!: () => void
183+
const app = setup({ download: () => new Promise((resolve) => (release = resolve)) })
184+
185+
const pending = app.controller.check()
186+
await Bun.sleep(0)
187+
expect(app.controller.getState()).toEqual({ status: "downloading", version: "2.0.0" })
188+
189+
app.controller.reportDownloadProgress(41.9)
190+
expect(app.controller.getState()).toEqual({ status: "downloading", version: "2.0.0", percent: 41 })
191+
192+
// Chunk-level events inside the same whole percent must not fan out to
193+
// every window again.
194+
const states: ReturnType<typeof app.controller.getState>[] = []
195+
const off = app.controller.subscribe((state) => states.push(state))
196+
app.controller.reportDownloadProgress(41.99)
197+
expect(states.length).toBe(1)
198+
off()
199+
200+
release()
201+
await pending
202+
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
203+
204+
// A trailing event after the download settled must not resurrect it.
205+
app.controller.reportDownloadProgress(100)
206+
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
207+
})
208+
175209
test("prefs merge, persist, and reach subscribers", async () => {
176210
const app = setup()
177211
const seen: UpdaterPrefs[] = []

0 commit comments

Comments
 (0)