diff --git a/CHANGELOG.md b/CHANGELOG.md index 2287ec941..df3a32108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ file is the human-readable summary of what changed and why. ### Desktop +- **CoDev asks before it updates, then does the whole update for you** — on + launch, when a new version is found, a dialog now names it and asks + **Update now** or **Later**. **Update now** downloads the installer, installs + it and restarts the app on its own — one answer covers all three, and the + dialog shows the download percentage while it runs (you can close it and let + it finish in the background). **Later** keeps you on the version you have and + tells you where to come back to it. Updates are no longer downloaded before + you have been asked: the launch check stops at the discovery, so a few + hundred megabytes never arrive unannounced on a metered connection. + **Help → Check for Updates...** asks the same way: its **Update Now** button + now runs the whole update rather than only fetching the installer and leaving + you to find the restart. +- **The update settings are reachable again** — **Check for updates + automatically** and **Download updates automatically** were built but only + ever added to the old Settings screen, which this fork no longer opens, so + neither could be changed. Both switches now sit in Settings → General → + Updates, above **Check for updates**. Turning automatic downloading on + restores the previous behaviour of fetching an update as soon as it is found. - **"Install and restart" works on macOS** — the desktop build is not signed with an Apple Developer ID, and Squirrel.Mac (which handles macOS updates) refuses to install a bundle it cannot validate against the running app's diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 64891b5cd..534aff2c8 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -232,7 +232,7 @@ from the squash message first, or use a plain merge. | Startup proxy-stall gate: `packages/desktop/src/main/{index,ipc}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/app/src/app.tsx`, `packages/app/src/context/platform.tsx` | Three additive pieces. (1) `main/index.ts` fires `session.defaultSession.resolveProxy(url)` for the sidecar URL as soon as the port is known (fire-and-forget, result logged). (2) A `local-server-health` IPC chain: main answers with the Node-fetch `checkHealth` against the spawned sidecar once `serverReady` resolves (`false` if initialization failed), preload exposes `localServerHealth()`, the renderer platform maps it, and `platform.tsx` gains the optional `localServerHealth?()` capability. (3) `app.tsx` `ConnectionGate`: the health-check effect is restructured around an inner `poll`; when the 10 s blocking window expires without a healthy answer and the connection is the built-in sidecar, it asks `platform.localServerHealth` — if the host process vouches for the server it keeps blocking on the splash for up to another 50 s instead of showing the unreachable error. Background mode and `type: "http"` behavior are unchanged, and without the capability (web) the gate behaves exactly as upstream. | Chromium queues every renderer request — loopback included — until proxy initialization (PAC download / WPAD discovery) resolves; the implicit loopback-bypass rules are only applied once the resolution service is READY (`net/proxy_resolution/configured_proxy_resolution_service.cc`). On corporate networks that takes tens of seconds (measured 21 s vs 64 ms with an unreachable PAC host), so users behind a proxy saw "Could not reach Local Server" at every launch while the server was healthy the whole time — main's own Node-fetch health check (loopback in `NO_PROXY`) had already passed before the window opened. The prewarm overlaps proxy init with sidecar startup; the gate fallback distinguishes "server down" from "renderer network stack not ready". | Keep all three pieces; take upstream's surrounding changes. If upstream restructures `ConnectionGate`, re-apply the vouch-then-extend fallback around whatever its blocking window becomes. Keep the `local-server-health` channel name stable. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`; `autoUpdater.quitAndInstall()` → `quitAndInstall(true, true)`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. The `quitAndInstall` arguments are load-bearing against the assisted installer (see the `electron-builder.config.ts` row): the defaults are `isSilent = false, isForceRunAfter = false`, and `NsisUpdater.doInstall` appends `/S` only when `isSilent` — so with an assisted installer the default would run the full wizard on **every** update, re-asking install mode and directory for a location the user already chose. `isForceRunAfter` is honoured only when `isSilent` is set (`BaseUpdater.quitAndInstall` passes `autoRunAppAfterInstall` instead when it is not), so both arguments are needed to install quietly *and* come back up. | Keep `false`; take upstream's other updater settings. Keep `quitAndInstall(true, true)` for as long as `nsis.oneClick` is `false`; the two only make sense together. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | Unsigned macOS install path: `packages/desktop/src/main/updater.ts`, `packages/desktop/src/main/updater-controller.ts`, `packages/desktop/src/main/updater-controller.test.ts`, `packages/app/src/i18n/{en,es,vi}.ts`, `packages/app/src/i18n/desktop-native.ts` | `UpdaterBackend` gains an optional `prepareInstall()` (run before `stop()`), `quitAndInstall()` may return a promise, and `install()` transitions to `error` with the message on failure instead of silently back to `ready`. `updater.ts` remembers `update-downloaded`'s `downloadedFile`; on macOS `prepareInstall` reads the running bundle's signature with `codesign` and, unless it carries a real `Authority=` chain, stages the update through the fork-only `mac-update.ts` (`ditto` extract, then a sibling `.CoDev.app.update`); `quitAndInstall` then spawns the detached swap script and calls `app.quit()` instead of `autoUpdater.quitAndInstall`. New native key `desktop.updater.error.downloadMissing`. | Squirrel.Mac validates an update against the running app's designated requirement, which only a real signing identity provides. Fleet builds have no Apple Developer ID, so electron-builder leaves Electron's linker ad-hoc signature and ShipIt fails with `Code signature ... did not pass validation: code object is not signed at all` — on an `error` event nothing listened to, so "Install and restart" did nothing while the sidecar had already been stopped. Windows (`verifyUpdateCodeSignature: false`) and AppImage verify nothing, hence macOS-only. Preparing before `stop()` keeps a failed install from leaving a dead app. | Keep the `prepareInstall` hook and the error transition; take upstream's other controller changes and re-apply the order prepare → stop → quitAndInstall if upstream rewrites `install()`. The gate self-disables for Developer-ID-signed builds (`readCodeSignature` returns `signed` and Squirrel is used), so the path can stay even if signing arrives; drop it only if unsigned builds are no longer shipped. | -| Updater dialogs: `packages/desktop/src/main/updater.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/i18n/desktop-native.ts` | `showUpdaterDialog` names versions and speaks the user's language. Every branch gains a `detail` line carrying `app.getVersion()`, and the `available` state (where a check stops once automatic downloading is off) gains a consent dialog whose Download button calls `controller.download()` fire-and-forget. Since the v1.18.19 sync the strings come from the desktop-native registry through `nativeT` (upstream's mechanism); the fork adds the keys upstream lacks — `desktop.updater.dialog.{checkFailed,upToDate,ready}.detail`, `desktop.updater.dialog.available.*`, `desktop.updater.dialog.download` — to `DESKTOP_NATIVE_ENGLISH` and the es/vi dicts. | "Check for Updates..." is asked precisely to answer "what am I on, and what would I move to"; the `available` state previously closed showing nothing. | Keep the version details, the `available` branch, and the fork registry keys; take upstream's surrounding updater changes. New dialog copy needs a registry key plus es/vi dict entries, and `{{placeholder}}`s must survive translation (`placeholders.test.ts`). | +| Updater dialogs: `packages/desktop/src/main/updater.ts`, `packages/app/src/i18n/{en,es,vi}.ts`, `packages/app/src/i18n/desktop-native.ts` | `showUpdaterDialog` names versions and speaks the user's language. Every branch gains a `detail` line carrying `app.getVersion()`, and the `available` state (where a check stops by default) gains a consent dialog whose **Update Now** button calls `controller.downloadAndInstall()` fire-and-forget — the menu asks the same question the launch prompt does, so it has to mean the same thing: download, install, restart. Its `detail` says so. Since the v1.18.19 sync the strings come from the desktop-native registry through `nativeT` (upstream's mechanism); the fork adds the keys upstream lacks — `desktop.updater.dialog.{checkFailed,upToDate,ready}.detail`, `desktop.updater.dialog.available.*`, `desktop.updater.dialog.updateNow` — to `DESKTOP_NATIVE_ENGLISH` and the es/vi dicts. | "Check for Updates..." is asked precisely to answer "what am I on, and what would I move to"; the `available` state previously closed showing nothing. | Keep the version details, the `available` branch (including its `downloadAndInstall` call — reverting it to `download()` leaves the menu path stopping halfway while the launch prompt finishes the job), and the fork registry keys; take upstream's surrounding updater changes. New dialog copy needs a registry key plus es/vi dict entries, and `{{placeholder}}`s must survive translation (`placeholders.test.ts`). | | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; the `install-wsl` job additionally treats exit 3010 (`WSL_INSTALL_REBOOT_REQUIRED`, re-raised by the fork's elevated script) as success with `pendingRestart: true` rather than as a failure — `wsl --version` answers from the freshly installed MSI before the just-enabled VirtualMachinePlatform is active, so no post-install probe can detect the pending reboot; otherwise both return the same `WslCommandResult` shape and the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads from the codev storage host — the runtime as a per-architecture MSI named by its own manifest (`/codev/installers/wsl/manifest.json`, assets under `/codev/installers/wsl//`) and staged through `stageAsset` for resume + sha256 verification, the distro from the pinned `wsl.zip` — enables VirtualMachinePlatform and Microsoft-Windows-Subsystem-Linux via offline dism + installs the MSI behind one UAC prompt (Windows 10 requires the WSL optional component even for the standalone MSI — without it every `wsl` distro command fails with Wsl/WSL_E_WSL_OPTIONAL_COMPONENT_REQUIRED; on machines that already have it dism is a no-op), and falls back to upstream's `wsl --install` / `--web-download` paths only when the download cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps, the 3010 handling in `install-wsl`, and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | `packages/desktop/package.json` | Added the `test` script (`bun test src --only-failures`). | The package had 21 test files and no way to run them: with no `test` script, `bun turbo test` skipped it and every desktop test was dead weight. Scoped to `src` (like `@opencode-ai/ui`) so the built `out/` tree is never scanned. Paired with the `turbo.json` row — the script alone does not enrol the package in CI. | Keep ours. If upstream adds its own `test` script, take theirs only if it covers `src`. | | Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. | @@ -253,7 +253,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, 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`. | -| 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. | +| Update checking and download controls: `packages/app/src/updater.ts`, `packages/app/src/components/{updater-action.ts,settings-general.tsx,settings-v2/general.tsx}`, `packages/app/src/pages/layout.tsx`, `packages/app/src/i18n/{en,es,vi}.ts`, `packages/desktop/src/main/{updater.ts,updater-controller.ts,index.ts,ipc.ts}`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | `UpdaterState` gains `available` (found, not fetched) and the shared `UpdaterPrefs` — `autoCheck` defaulting true, `autoDownload` defaulting **false**. The controller takes `initialPrefs`/`savePrefs`, stops a check at `available` when `autoDownload` is off, gains `download()` for the consent step and `downloadAndInstall()` for "Update now" (joins a download already in flight rather than starting a second, re-checks its way out of the version-less error state so the dialog's retry is not inert, otherwise fetches, then installs), gains `getPrefs`/`setPrefs`/`subscribePrefs`, skips the launch check when `autoCheck` is off, and short-circuits a repeat check while `available`. `download`/`install` moved out of the returned object literal into consts above it so `downloadAndInstall` can call them. `main/updater.ts` reads and writes the prefs in the existing `codev.updater` store under a `prefs` key; `main/index.ts`'s ten-minute timer consults `getPrefs().autoCheck` at fire time; `ipc.ts` adds `updater-download`, `updater-download-install` and `updater-set-prefs` and registers a second listener on the existing `updater-subscribe` so prefs broadcast to every window; preload and `renderer/index.tsx` expose `download`, `downloadAndInstall`, `setPrefs`, `subscribePrefs`. The two pref switches are `SettingsRowV2` rows in **`settings-v2/general.tsx`** — the screen every settings entry point actually resolves to — guarded by `platform.updater`, since the Updates section is not desktop-gated and the web build must not render them; the matching `SettingsRow` pair in the legacy `settings-general.tsx` is kept in step but is unreachable (see the retired-legacy-layout row). The legacy layout's 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), and `main/updater.ts` feeds it from `autoUpdater.on("download-progress")`. The fork-only update dialog (`dialog-update.tsx`) is the consent flow itself: on the launch check it announces the found version and asks **Update now / Later**; **Update now** calls `downloadAndInstall()` once and the dialog reports the download percent and then the install while the app restarts itself; **Later** does not close the dialog but swaps its body for where to find the update again and its footer for a single OK. While the update is running the neutral slot becomes Close, since the download outlives the dialog. i18n adds `toast.update.description.{downloading,installing,later}`, `toast.update.action.{updateNow,later}` and `common.ok`. | 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. `autoDownload` ships **off** because that is the only ordering in which "do you want to update now?" can be asked: with it on, the launch check has already spent the bytes by the time any dialog opens. 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. The switches originally shipped in `settings-general.tsx` alone, which nothing in this fork can open, so both settings were unchangeable and the `available` state they gate was dead in every build — the pair reaching settings-v2 is what makes the rest of this row real rather than nominal. | 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()`) and `downloadAndInstall` rather than the whole diff; all three are covered by `updater-controller.test.ts`. **`autoDownload` must stay `false` and `autoCheck` `true`**: the update prompt announces what the launch check found and asks before spending anything, which needs the check to run and then stop at `available`. Do not "restore" both to true to match upstream — that silently turns the prompt back into a report of a download already underway. Any new updater switch goes in `settings-v2/general.tsx`; adding one only to `settings-general.tsx` ships nothing. | | `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/dialog-update.tsx b/packages/app/src/components/dialog-update.tsx index b1d881add..9668a12a7 100644 --- a/packages/app/src/components/dialog-update.tsx +++ b/packages/app/src/components/dialog-update.tsx @@ -4,10 +4,9 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" -import { createEffect, Show } from "solid-js" +import { createEffect, createSignal, Show } from "solid-js" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useUpdaterAction } from "./updater-action" // Once per app run rather than once per mount: the layout remounts when the // server changes, and an update the user has already answered for must not @@ -17,18 +16,17 @@ let prompted = false // The launch check the main process runs (updater.start) had nowhere to // surface: the titlebar grows an Update button once an update is downloaded, // which is easy to spend a week not noticing. This asks instead, once, the -// first time a check finds something — and "Not yet" leaves the titlebar -// button as the way back to it. +// first time a check finds something. export function UpdatePrompt() { const platform = usePlatform() const dialog = useDialog() createEffect(() => { const status = platform.updater?.state().status - // "available" is a check that stopped before downloading because automatic - // downloading is off; "downloading" is one automatic downloading already - // acted on; "ready" is one that went all the way. Either way there is an - // update the user has not been told about. + // "available" is the normal path: automatic downloading is off by default + // so the check stops at the discovery and this dialog is the ask. The other + // two are what a user who turned automatic downloading back on sees — the + // download already running, or already finished. if (status !== "available" && status !== "downloading" && status !== "ready") return if (prompted) return prompted = true @@ -44,18 +42,25 @@ export function DialogUpdate() { const language = useLanguage() const platform = usePlatform() const dialog = useDialog() - const updater = useUpdaterAction() + // "Later" does not close the dialog: an announcement the user dismisses with + // nothing said back leaves them wondering where the update went, so the same + // dialog turns into the answer and an OK. + const [deferred, setDeferred] = createSignal(false) const state = () => platform.updater?.state() + // The error state carries no version, so the one the dialog opened on is what + // every sentence falls back to rather than announcing an empty bracket. + const opened = platform.updater?.state() + const announced = opened && "version" in opened ? opened.version : "" const version = () => { const value = state() - return value && "version" in value ? value.version : "" + return value && "version" in value ? value.version : announced } const error = () => { const value = state() return value?.status === "error" ? value.message : undefined } - // The two steps that carry on by themselves once started; the button reports + // The two steps that carry on by themselves once started; the dialog reports // them rather than offering to start them again. const busy = () => state()?.status === "downloading" || state()?.status === "installing" const percent = () => { @@ -63,9 +68,11 @@ export function DialogUpdate() { return value?.status === "downloading" ? value.percent : undefined } const descriptionKey = () => { + if (deferred()) return "toast.update.description.later" as const const status = state()?.status - if (status === "ready" || status === "installing") return "toast.update.description" as const + if (status === "installing") return "toast.update.description.installing" as const if (status === "downloading") return "toast.update.description.downloading" as const + if (status === "ready") return "toast.update.description" as const return "toast.update.description.available" as const } @@ -76,9 +83,9 @@ export function DialogUpdate() { - {/* Downloaded, downloading, and downloadable are different asks — one - restarts the app, one is already spending a few hundred megabytes, - one is asking to — so they get different sentences. */} + {/* Announcing, downloading, installing, and declining are different + sentences — one asks, two report work already underway, one says + where to come back to — so they get their own strings. */}

{language.t(descriptionKey(), { version: version() })}

@@ -95,14 +102,35 @@ export function DialogUpdate() { - dialog.close()}> - {language.t("toast.update.action.notYet")} - - void updater.run()}> - }> - {language.t(updater.action().label)} - - + + {/* Once the update is running, declining it is no longer on + offer — the download outlives this dialog — so the same slot + becomes the way to get the window back and let it finish. */} + (busy() ? dialog.close() : setDeferred(true))}> + {language.t(busy() ? "common.close" : "toast.update.action.later")} + + void platform.updater?.downloadAndInstall().catch(() => undefined)} + > + }> + {language.t("toast.update.action.updateNow")} + + + + } + > + dialog.close()}> + {language.t("common.ok")} + + ) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 25944253a..df66237b4 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -609,6 +609,40 @@ export const SettingsGeneralV2: 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 })} + /> +
+
+ + )} +
+ prefs: Accessor check(): Promise download(): Promise + // "Update now": one decision covering the download, the install and the + // restart, so a download that finishes after the user has moved on still + // installs instead of waiting behind a dialog nobody is looking at. + downloadAndInstall(): Promise install(): Promise setPrefs(next: Partial): Promise } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 92f98379c..f40ad4f6b 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -104,6 +104,7 @@ export function registerIpcHandlers(deps: Deps) { 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-download-install", () => deps.updater.downloadAndInstall()) 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") diff --git a/packages/desktop/src/main/updater-controller.test.ts b/packages/desktop/src/main/updater-controller.test.ts index c4d1f88e2..3d15ea2cc 100644 --- a/packages/desktop/src/main/updater-controller.test.ts +++ b/packages/desktop/src/main/updater-controller.test.ts @@ -54,7 +54,7 @@ function setup(input?: { describe("updater controller", () => { test("checks, downloads, persists, and publishes one authoritative ready state", async () => { - const app = setup() + const app = setup({ prefs: { autoDownload: true } }) const states: ReturnType[] = [] app.controller.subscribe((state) => states.push(state)) @@ -67,7 +67,7 @@ describe("updater controller", () => { }) test("revalidates a persisted target through the updater cache on launch", async () => { - const app = setup({ ready: { version: "2.0.0" } }) + const app = setup({ ready: { version: "2.0.0" }, prefs: { autoDownload: true } }) await app.controller.start() @@ -85,7 +85,7 @@ describe("updater controller", () => { }) test("coalesces concurrent checks", async () => { - const app = setup() + const app = setup({ prefs: { autoDownload: true } }) await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()]) @@ -93,7 +93,7 @@ describe("updater controller", () => { }) test("returns to ready when quitAndInstall returns without exiting", async () => { - const app = setup() + const app = setup({ prefs: { autoDownload: true } }) await app.controller.start() await app.controller.install() @@ -112,6 +112,7 @@ describe("updater controller", () => { quitAndInstall() {}, }, persistence: { get: () => undefined, set() {}, clear() {} }, + initialPrefs: { autoDownload: true }, stop: async () => { throw new Error("stop failed") }, @@ -138,6 +139,7 @@ describe("updater controller", () => { }, }, persistence: { get: () => undefined, set() {}, clear() {} }, + initialPrefs: { autoDownload: true }, stop: async () => { calls.push("stop") }, @@ -167,6 +169,7 @@ describe("updater controller", () => { }, }, persistence: { get: () => undefined, set() {}, clear() {} }, + initialPrefs: { autoDownload: true }, stop: async () => { calls.push("stop") }, @@ -178,12 +181,14 @@ describe("updater controller", () => { expect(controller.getState()).toEqual({ status: "error", message: "No application bundle found in update.zip" }) }) - // Both on is what the updater did before either setting existed. - test("checks and downloads automatically by default", async () => { + // The update prompt announces before it spends anything, which it can only + // do if the launch check runs and then stops at the discovery. + test("checks automatically but waits to be told before downloading 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"]) + expect(app.controller.getPrefs()).toEqual({ autoCheck: true, autoDownload: false }) + expect(app.calls).toEqual(["check"]) + expect(app.controller.getState()).toEqual({ status: "available", version: "2.0.0" }) }) test("automatic checking off skips the launch check but not a manual one", async () => { @@ -195,7 +200,7 @@ describe("updater controller", () => { // "Check now" is the user asking, which this setting never gates. await app.controller.check() - expect(app.calls).toEqual(["check", "download"]) + expect(app.calls).toEqual(["check"]) }) test("automatic downloading off stops at available and waits to be told", async () => { @@ -231,9 +236,88 @@ describe("updater controller", () => { expect(app.calls).toEqual([]) }) + // "Update now" in the update prompt: the user consents once and the app is + // expected to come back up on the new version by itself. + test("update now downloads and installs in one call", async () => { + const app = setup() + const states: ReturnType[] = [] + await app.controller.start() + app.controller.subscribe((state) => states.push(state)) + + await app.controller.downloadAndInstall() + + expect(app.calls).toEqual(["check", "download", "stop", "install"]) + expect(states.map((state) => state.status)).toEqual(["available", "downloading", "ready", "installing", "ready"]) + }) + + test("update now installs an update already on disk instead of fetching it twice", async () => { + const app = setup({ prefs: { autoDownload: true } }) + await app.controller.start() + + await app.controller.downloadAndInstall() + + expect(app.calls).toEqual(["check", "download", "stop", "install"]) + }) + + // Automatic downloading is on, so the prompt opens on a download already in + // flight. Consenting there must ride that download rather than start another. + test("update now joins a download already running", async () => { + let release!: () => void + const app = setup({ + prefs: { autoDownload: true }, + download: () => new Promise((resolve) => (release = resolve)), + }) + + const checking = app.controller.check() + await Bun.sleep(0) + expect(app.controller.getState()).toEqual({ status: "downloading", version: "2.0.0" }) + + const updating = app.controller.downloadAndInstall() + release() + await Promise.all([checking, updating]) + + expect(app.calls).toEqual(["check", "download", "stop", "install"]) + }) + + test("update now stops at a failed download rather than installing nothing", async () => { + const app = setup({ + download: async () => { + throw new Error("network is down") + }, + }) + await app.controller.start() + + expect(await app.controller.downloadAndInstall()).toEqual({ status: "error", message: "network is down" }) + expect(app.calls).toEqual(["check", "download"]) + }) + + // The error state has no version to fetch, so the retry the dialog offers has + // to go back to the feed or the button does nothing at all. + test("update now retries from the error state by checking again", async () => { + let fail = true + const app = setup({ + download: async () => { + if (!fail) return + fail = false + throw new Error("network is down") + }, + }) + await app.controller.start() + await app.controller.downloadAndInstall() + expect(app.controller.getState().status).toBe("error") + + await app.controller.downloadAndInstall() + + expect(app.calls).toEqual(["check", "download", "check", "download", "stop", "install"]) + expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) + }) + test("progress rides the downloading state in whole percents and dies with it", async () => { let release!: () => void - const app = setup({ download: () => new Promise((resolve) => (release = resolve)) }) + const app = setup({ + prefs: { autoDownload: true }, + download: () => new Promise((resolve) => (release = resolve)), + }) const pending = app.controller.check() await Bun.sleep(0) @@ -264,15 +348,15 @@ describe("updater controller", () => { const seen: UpdaterPrefs[] = [] app.controller.subscribePrefs((prefs) => seen.push(prefs)) - await app.controller.setPrefs({ autoDownload: false }) + await app.controller.setPrefs({ autoDownload: true }) - expect(app.controller.getPrefs()).toEqual({ autoCheck: true, autoDownload: false }) - expect(app.getSavedPrefs()).toEqual({ autoCheck: true, autoDownload: false }) + expect(app.controller.getPrefs()).toEqual({ autoCheck: true, autoDownload: true }) + expect(app.getSavedPrefs()).toEqual({ autoCheck: true, autoDownload: true }) // 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 }, + { autoCheck: true, autoDownload: true }, ]) }) }) diff --git a/packages/desktop/src/main/updater-controller.ts b/packages/desktop/src/main/updater-controller.ts index 229cb022a..e252fd216 100644 --- a/packages/desktop/src/main/updater-controller.ts +++ b/packages/desktop/src/main/updater-controller.ts @@ -86,6 +86,52 @@ export function createUpdaterController(input: { }) } + // The consent step for a download that automatic downloading would have + // started by itself. + const download = () => { + if (state.status !== "available") return Promise.resolve(state) + if (pending) return pending + const version = state.version + return run(() => fetchUpdate(version)) + } + + const install = async () => { + if (state.status !== "ready") throw new Error("Update is not ready to install") + const version = state.version + transition({ status: "installing", version }) + try { + await input.backend.prepareInstall?.() + await input.stop() + await input.backend.quitAndInstall() + transition({ status: "ready", version }) + } catch (error) { + // Shown in the update dialog. Reverting to ready instead left the + // button offering the same failing install with nothing to explain + // why the app was still running. + transition({ status: "error", message: error instanceof Error ? error.message : String(error) }) + throw error + } + } + + // "Update now" answered in one call. The dialog that asks is dismissible and + // the download runs for minutes, so the restart cannot hang off a renderer + // still being on screen when the bytes land. + const downloadAndInstall = async () => { + // A download already running — automatic downloading, or another window's + // "Update now" — is the same download: join it rather than start a second. + if (pending) await pending + // A failed download or install leaves the error state, which carries no + // version to fetch. Asking the feed again is the only way back, and it + // lands on available (or, with automatic downloading on, ready) with one. + if (state.status === "error") await check() + if (state.status === "available") await download() + // run() turns a failed download into the error state rather than throwing, + // so there is nothing to install and the dialog reports what it says. + if (state.status !== "ready") return state + await install() + return state + } + return { getState: () => state, getPrefs: () => prefs, @@ -128,31 +174,9 @@ export function createUpdaterController(input: { if (state.percent === next) return transition({ status: "downloading", version: state.version, percent: next }) }, - // 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 - transition({ status: "installing", version }) - try { - await input.backend.prepareInstall?.() - await input.stop() - await input.backend.quitAndInstall() - transition({ status: "ready", version }) - } catch (error) { - // Shown in the update dialog. Reverting to ready instead left the - // button offering the same failing install with nothing to explain - // why the app was still running. - transition({ status: "error", message: error instanceof Error ? error.message : String(error) }) - throw error - } - }, + download, + downloadAndInstall, + install, } } diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index 05b6a2380..3203c15dd 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -161,22 +161,23 @@ export async function showUpdaterDialog(controller: ReturnType ipcRenderer.invoke("updater-check"), download: () => ipcRenderer.invoke("updater-download"), + downloadAndInstall: () => ipcRenderer.invoke("updater-download-install"), install: () => ipcRenderer.invoke("updater-install"), setPrefs: (next) => ipcRenderer.invoke("updater-set-prefs", next), subscribePrefs: (cb) => { diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 5c7d44520..259e5f7ef 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -52,6 +52,7 @@ export type UpdaterAPI = { subscribePrefs: (cb: (prefs: UpdaterPrefs) => void) => () => void check: () => Promise download: () => Promise + downloadAndInstall: () => Promise install: () => Promise setPrefs: (next: Partial) => Promise } diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index e6ddf5bad..c3ca2b191 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -252,6 +252,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { prefs: updaterPrefs, check: () => window.api.updater.check(), download: () => window.api.updater.download(), + downloadAndInstall: () => window.api.updater.downloadAndInstall(), install: () => window.api.updater.install(), setPrefs: async (next: Partial) => { setUpdaterPrefs(await window.api.updater.setPrefs(next))