diff --git a/CHANGELOG.md b/CHANGELOG.md
index 138743024c..b78d4fdee5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,6 +119,17 @@ file is the human-readable summary of what changed and why.
is off, the dialog offers **Download** first and switches to **Install and
restart** when it lands.
([#305](https://github.com/quickbeard/codev-code/pull/305))
+- **Check for Updates now tells you which version you are on** — every dialog
+ the menu item opened talked about versions without naming yours: "You're up
+ to date." with nothing to check it against, and an update prompt that named
+ the new version but not the one it was replacing. All of them now show the
+ running version, and the update prompts show both — so "update to what, from
+ what" is answerable without hunting through About or Settings. A check that
+ found an update you have not downloaded yet used to close with no dialog at
+ all if automatic downloading was off; it now names the version and offers
+ **Download**. The dialogs are also translated, along with the rest of the
+ app — they were English-only before, being built outside the web UI.
+ ([#323](https://github.com/quickbeard/codev-code/pull/323))
- **The terminal app asks about updates instead of ignoring them** — `codev`
checked for a new version a second after launch and announced it with an
internal event that the interface subscribes to as it starts up. The check
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 0b04163905..e3eaa8440d 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -186,7 +186,7 @@ from the squash message first, or use a plain merge.
| `packages/app/src/components/help-button.tsx` | Deleted (as of the v1.18.3 sync). The fork had removed the `TabsInfoPopup` component and its `introducing-tabs.mp4` import, keeping the rebranded `HelpButton`; upstream v1.18.3 deleted `HelpButton` itself, leaving only the popup — so nothing remains and the file is gone. | The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If upstream mounts `TabsInfoPopup` somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the rows around it retire. |
| `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) | Deleted | The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed `TabsInfoPopup` (row above) and they bloat every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup. |
| `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev"; "OpenCode Documentation" item → "CoDev Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev menu" (×2). Plus a **"Check for Updates..." item added to the Help menu**, it and a preceding separator both `platforms: ["windows"]`; `desktop-menu.ts` gains an exported `desktopMenuItemDisabled(item, {updaterStatus, commandDisabled})` — sibling to the existing `desktopMenuVisible` — which `WindowsAppMenu` now calls to honour `enabled: "updater"`. | Upstream only ever exposes the update check from the macOS app menu (`platforms: ["macos"]`), and `createMenu` early-returns on non-darwin while `WindowsAppMenu` filters on `"windows"` — so on Windows and Linux there was no menu path to it at all, leaving Settings → General as the only manual entry point. `WindowsAppMenu` renders for both Windows and Linux (`titlebar.tsx` gates on `windows() \|\| linux()`) but filters with the `"windows"` key, so `platforms: ["windows"]` reaches both without widening `DesktopMenuPlatform`. The separator carries the same restriction or macOS's Help menu ends on a stray divider. `enabled: "updater"` was silently ignored in this component — only `command` entries could disable — so a dev build would have shown an item that errors on click; the updater's own `disabled` status is the renderer-side equivalent of the main process's `UPDATER_ENABLED`. | Keep our labels (they live in the dicts now — see the localized desktop menu row), the Help entry, and `entryDisabled`; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. If upstream adds its own non-macOS update entry, drop ours for theirs. Covered by `packages/app/src/desktop-menu.test.ts` ("offers an update check on every platform exactly once"), which will fail on a duplicate rather than let both ship, plus the `item disabled state` block over `desktopMenuItemDisabled`. Keep the disabled logic in `desktop-menu.ts` rather than inlining it back into the component — it is unreachable from a test there, since the app package has no component-render harness. |
-| Localized desktop menu: `packages/app/src/desktop-menu.ts`, `packages/app/src/desktop-menu.test.ts`, `packages/app/src/components/windows-app-menu.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/main/{menu,ipc,index}.ts` | Every menu label is a dictionary key (`labelKey`, typed as `keyof typeof dict` against the English dict) instead of English text, and both renderers resolve it: `windows-app-menu.tsx` through `useLanguage().t`, and the macOS menu bar through the fork-only `packages/desktop/src/main/menu-i18n.ts` — `createMenu` is now async and awaits a translator that dynamic-imports the selected locale's dict (`@opencode-ai/app/i18n/*`, a new export — see the `packages/app/package.json` row), so Windows and Linux, which return before building a native menu, load no dictionary at all. The locale comes from the same `codev.global.dat` `language` record the renderer's own i18n reads, parsed by the electron-free `main/menu-locale.ts` (`pickMenuLocale`, unit-tested) and falling back to `app.getLocale()` when nothing was ever chosen. `menu.ts` keeps the last `Deps` so its exported `refreshMenuLanguage()` can rebuild the bar; `ipc.ts`'s `store-set` handler calls it when that record is written, and `index.ts` calls `void createMenu(...)`. i18n gains the `menu.*` block (45 keys, en/es/vi), and `desktop-menu.test.ts` gains a test asserting every `labelKey` resolves in all three locales. | The app menu was the last surface that stayed English in a translated UI: the labels were literals in a table shared by the macOS native menu and the Windows/Linux titlebar menu, and the main process — which builds the former — has no i18n of its own. Keys rather than an English-plus-key pair because a duplicated English string is a string that drifts. | Keep the keys and both resolvers. An upstream menu item arrives with a literal label: give it a `menu.*` key in all three dicts instead — the parity test fails otherwise, which is the intended tripwire. Do not make `createMenu` synchronous again; the dictionary load is what keeps the native menu out of the startup path. Keep `pickMenuLocale` in its own electron-free module so it stays testable. |
+| Localized desktop menu: `packages/app/src/desktop-menu.ts`, `packages/app/src/desktop-menu.test.ts`, `packages/app/src/components/windows-app-menu.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/main/{menu,ipc,index}.ts` | Every menu label is a dictionary key (`labelKey`, typed as `keyof typeof dict` against the English dict) instead of English text, and both renderers resolve it: `windows-app-menu.tsx` through `useLanguage().t`, and the macOS menu bar through the fork-only `packages/desktop/src/main/i18n.ts` — `createMenu` is now async and awaits a translator that dynamic-imports the selected locale's dict (`@opencode-ai/app/i18n/*`, a new export — see the `packages/app/package.json` row), so Windows and Linux, which return before building a native menu, load no dictionary at all. The locale comes from the same `codev.global.dat` `language` record the renderer's own i18n reads, parsed by the electron-free `main/locale.ts` (`pickLocale`, unit-tested) and falling back to `app.getLocale()` when nothing was ever chosen. `menu.ts` keeps the last `Deps` so its exported `refreshMenuLanguage()` can rebuild the bar; `ipc.ts`'s `store-set` handler calls it when that record is written, and `index.ts` calls `void createMenu(...)`. i18n gains the `menu.*` block (45 keys, en/es/vi), and `desktop-menu.test.ts` gains a test asserting every `labelKey` resolves in all three locales. | The app menu was the last surface that stayed English in a translated UI: the labels were literals in a table shared by the macOS native menu and the Windows/Linux titlebar menu, and the main process — which builds the former — has no i18n of its own. Keys rather than an English-plus-key pair because a duplicated English string is a string that drifts. | Keep the keys and both resolvers. An upstream menu item arrives with a literal label: give it a `menu.*` key in all three dicts instead — the parity test fails otherwise, which is the intended tripwire. Do not make `createMenu` synchronous again; the dictionary load is what keeps the native menu out of the startup path. Keep `pickLocale` in its own electron-free module so it stays testable. |
| Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. |
| **Universal macOS builds**: `packages/desktop/electron.vite.config.ts`, `packages/desktop/electron-builder.config.ts`, `packages/desktop/package.json` | Three changes that together make `electron-builder --mac --universal` (and `--x64`) produce a working app. (1) The `codev:node-pty-narrower` plugin and its `nodePtyPkg` constant are gone; the main build externalises `"@lydell/node-pty"` itself. (2) `@lydell/node-pty` moves from `devDependencies` to `dependencies`, so the wrapper is actually packed. (3) `mac.x64ArchFiles` is set to `**/app.asar.unpacked/node_modules/**`. | (1) Upstream rewrote the import to `@lydell/node-pty-${process.platform}-${process.arch}` — the **build host's** arch, resolved once, while `electron-vite build` runs before `electron-builder` and emits one bundle for whatever is packaged after it. On Apple Silicon that baked `darwin-arm64` in, so an `--x64` or `--universal` build shipped an Electron shell that starts and then cannot load its terminal backend. The wrapper's own entry does the same platform/arch lookup at run time, so externalising it picks the right binary on the machine that runs the app. (2) The wrapper was a devDependency and therefore never shipped: the old narrowing worked only because it pointed at the *platform* packages, which are `optionalDependencies`. Verified against a packaged build — `asar list` showed `@lydell/node-pty-darwin-*` present and `@lydell/node-pty/index.js` absent — so `require("@lydell/node-pty")` could not resolve. The bundled server (`packages/opencode/dist/node/node.js`) already required the bare wrapper, so this was a live bug in packaged builds before the vite change, not one it introduced. (3) All six platform packages ship in both the x64 and arm64 halves, so eight Mach-O files (node-pty's `pty.node` and its extension-less `spawn-helper`, `@parcel/watcher`, `msgpackr-extract`) are byte-identical across them; `@electron/universal` reads that as a mistake and refuses to merge. They are prebuilt binaries chosen at run time, not per-arch build output. | Keep all three. On conflict take upstream's files and re-apply. Do not reintroduce host-arch narrowing to silence a bundler warning, and do not move `@lydell/node-pty` back to `devDependencies` — the two are load-bearing together. Verify a packaged build rather than trusting the build log: `grep -o "@lydell/node-pty[a-z0-9-]*" out/main/index.js` must print the bare wrapper with no platform suffix, `asar list` must contain `/node_modules/@lydell/node-pty/index.js`, and the app must load its `pty.node` from inside the bundle when run from **outside** this repo — inside it, Node's module walk-up finds the repo's `node_modules` and masks a missing wrapper. |
| Desktop app text + identifier rebrand: `packages/desktop/**` (README, `package.json`, `src/renderer/index.html`, `src/renderer/i18n/{en,es}.ts`, `src/main/{index,windows,server,logging}.ts`, `src/main/wsl/{sidecar,startup,servers,servers.test}.ts`, `src/main/install-state.test.ts`, `scripts/copy-metainfo.ts`, `electron-builder.config.ts`) plus the WSL/deep-link surfaces it shares with `packages/app` (`src/wsl/{types,settings-model,settings-model.test,settings,dialog-add-server}.ts(x)`, `src/i18n/{en,es}.ts`, `src/pages/layout/{deep-links,helpers.test}.ts`, `src/app.tsx`, `src/index.ts`) and `packages/desktop/src/{preload,renderer}` | Every user-visible "OpenCode" the desktop app renders → "CoDev" (originally "CoDev Code"; the "Code" suffix was dropped in 2026-08 once the app carried a Work section beside Code): crash dialogs, window `
`, updater/CLI-installed i18n (en+es, with the CLI-command hint `'opencode'`→`'codev'`), the unpackaged app-name fallback `"OpenCode Dev"`, the AppStream metainfo (product name, description, `developer` → `CoDev` with the upstream `id="ly.anoma"` dropped, the three URLs repointed to the fork repo, upstream's lander screenshot removed), the OS deep-link handler display name, README, and `package.json` (`homepage` → the fork repo, `author` → `CoDev `; both were briefly deleted outright, which broke `deb`/`rpm` packaging — electron-builder derives the Debian `Maintainer` field from `author.email` and refuses to build fpm targets without `homepage`, so the two fields have to carry fork values rather than be absent. `AppImage`/`nsis`/`dmg` do not use fpm and never noticed. Values match the AppStream metainfo `scripts/copy-metainfo.ts` already emits: `CoDev` and ``). Codebase identifiers renamed `Opencode`→`Codev`/`opencode`→`codev` (`WslOpencodeCheck`, `resolveWslOpencode`, `installWslOpencode`, `expectOpencodeVersion`, `releaseOpencodeResolve`, `checkOpencode`/`refreshOpencodeCheck*`/`setOpencodeCheck`, `opencodeChecks`, `wslOpencodeAction`, …) together with the internal wire values whose producer and consumer both ship here (`virtual:opencode-server`, the `opencode:{node-pty,virtual-server,copy-server}` vite plugin names, the `wsl-servers-install-opencode` IPC channel, the `install-opencode` job kind, the `__OPENCODE__` deep-link bridge global, the `opencode:deep-link` renderer event, the `opencode-onboarding-` temp dir, the `wsl.onboarding.installOpencode*` i18n keys), and the server-log collector path `/opencode/log` → `/codev/log` (a real bug fix — the fork CLI writes to `/codev/log`, `packages/core/src/global.ts` `app = "codev"`, so debug-log export was silently collecting nothing). Two stale product-named locals renamed to what they hold (`opencode`→`binary` in wsl/sidecar.ts, `opencode`→`checked` in the servers test). | The fork ships the desktop app, so every surface it shows and every identifier a maintainer reads should say codev. | Keep our strings + identifiers; take upstream's surrounding changes. PRESERVED deliberately, do not rename: `@opencode-ai/*` package ids, the `opencode` data dir in install-state, the basic-auth username `"opencode"` (must match the server default in `packages/server/src/auth.ts`), and the `../opencode/dist` build paths. The persisted store names were preserved here originally and are now rebranded with a migration — see the persisted-store-name row below. |
@@ -212,6 +212,7 @@ from the squash message first, or use a plain merge.
| `packages/desktop/src/main/index.ts` | Deleted upstream's `app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")`. | Chromium's `<-loopback>` bypass rule means the opposite of what it reads as: it *subtracts* the implicit rules that keep loopback off the proxy, i.e. it opts localhost traffic *into* proxying (`net/docs/proxy.md`). Today it is inert — Electron reads `--proxy-bypass-list` only together with `--proxy-server` (`shell/browser/browser_process_impl.cc`), which the app never passes — but it becomes actively harmful the moment anything launches the app with `--proxy-server`: the renderer's sidecar traffic would be routed into the proxy. Chromium already bypasses loopback by default, so nothing replaces it. | Keep deleted. If upstream adds new proxy switches, check them against Chromium's implicit-bypass semantics before taking them. |
| 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). |
+| Updater dialogs: `packages/desktop/src/main/updater.ts`, `packages/app/src/i18n/{en,es}.ts` | `showUpdaterDialog` names versions and speaks the user's language. Every branch gains a `detail` line carrying `app.getVersion()` — the up-to-date and error dialogs name the running version, the ready dialog names both it and the downloaded one — and the `available` state, which a check stops at once automatic downloading is off, gains a dialog of its own (previously it fell through the `status !== "ready"` guard and the menu item closed showing nothing at all); its Download button calls `controller.download()` fire-and-forget, since the renderer already surfaces the progress and the restart prompt. All strings, titles and buttons included, come from the app dictionaries through `main/i18n.ts`'s `createTranslator` (see the localized desktop menu row) rather than being English literals; i18n gains the `updater.dialog.*` block. | "Check for Updates..." is asked precisely to answer "what am I on, and what would I move to", and every dialog left the first half out — "You're up to date." gave the user nothing to verify. The dialogs are built in the main process, so they were the last strings in the app that no locale could reach. | Keep the version details, the `available` branch, and the dictionary lookups. New dialog copy needs an `updater.dialog.*` key in all three dicts, and any `{{placeholder}}` must survive translation — `packages/app/src/i18n/placeholders.test.ts` (fork-only) fails otherwise. Take upstream's surrounding updater changes; this row does not touch the download or install logic, only what the dialogs say. |
| 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. |
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 60cf1480da..2315db405b 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -673,6 +673,24 @@ export const dict = {
"Your CoDev sign-in expired, so sessions are no longer uploaded for analysis. Log in again to resume — pending sessions upload automatically afterwards.",
"toast.sessionUpload.paused.action.login": "Log in",
+ // The updater dialogs, which the desktop main process builds itself — see
+ // desktop/src/main/updater.ts. Every one names the running version.
+ "updater.dialog.error.title": "Update Error",
+ "updater.dialog.error.message": "Update check failed.",
+ "updater.dialog.error.detail": "You're on CoDev {{version}}.",
+ "updater.dialog.upToDate.title": "No Updates",
+ "updater.dialog.upToDate.message": "You're up to date.",
+ "updater.dialog.upToDate.detail": "CoDev {{version}} is the latest version.",
+ "updater.dialog.available.title": "Update Available",
+ "updater.dialog.available.message": "CoDev {{version}} is available.",
+ "updater.dialog.available.detail": "You're on {{current}}. Download it now?",
+ "updater.dialog.ready.title": "Update Ready",
+ "updater.dialog.ready.message": "CoDev {{version}} downloaded. Restart now?",
+ "updater.dialog.ready.detail": "You're on {{current}}.",
+ "updater.dialog.action.download": "Download",
+ "updater.dialog.action.restart": "Restart",
+ "updater.dialog.action.later": "Later",
+
// The desktop app menu (macOS native menu bar and the Windows/Linux
// titlebar menu) — see desktop-menu.ts.
"menu.app": "CoDev",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 38deb9d53c..4cf7c5ce80 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -665,6 +665,24 @@ export const dict = {
"Tu sesión de CoDev caducó, así que las sesiones ya no se suben para su análisis. Vuelve a iniciar sesión para reanudarla: las sesiones pendientes se subirán automáticamente después.",
"toast.sessionUpload.paused.action.login": "Iniciar sesión",
+ // The updater dialogs, which the desktop main process builds itself — see
+ // desktop/src/main/updater.ts. Every one names the running version.
+ "updater.dialog.error.title": "Error de actualización",
+ "updater.dialog.error.message": "No se pudo comprobar si hay actualizaciones.",
+ "updater.dialog.error.detail": "Tienes CoDev {{version}}.",
+ "updater.dialog.upToDate.title": "Sin actualizaciones",
+ "updater.dialog.upToDate.message": "Todo está actualizado.",
+ "updater.dialog.upToDate.detail": "CoDev {{version}} es la versión más reciente.",
+ "updater.dialog.available.title": "Actualización disponible",
+ "updater.dialog.available.message": "CoDev {{version}} ya está disponible.",
+ "updater.dialog.available.detail": "Tienes la {{current}}. ¿Descargarla ahora?",
+ "updater.dialog.ready.title": "Actualización lista",
+ "updater.dialog.ready.message": "CoDev {{version}} descargada. ¿Reiniciar ahora?",
+ "updater.dialog.ready.detail": "Tienes la {{current}}.",
+ "updater.dialog.action.download": "Descargar",
+ "updater.dialog.action.restart": "Reiniciar",
+ "updater.dialog.action.later": "Más tarde",
+
// The desktop app menu (macOS native menu bar and the Windows/Linux
// titlebar menu) — see desktop-menu.ts.
"menu.app": "CoDev",
diff --git a/packages/app/src/i18n/placeholders.test.ts b/packages/app/src/i18n/placeholders.test.ts
new file mode 100644
index 0000000000..1d36f726ef
--- /dev/null
+++ b/packages/app/src/i18n/placeholders.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+
+import { dict as en } from "./en"
+import { dict as es } from "./es"
+import { dict as vi } from "./vi"
+
+// A dropped or renamed `{{placeholder}}` renders as literal text — the version
+// number silently missing from "You're on CoDev {{version}}." rather than an
+// error anyone would notice. The desktop main process is where that hurts
+// most: its dialogs and menu bar are assembled from these strings with no
+// renderer, no fallback, and no test rendering them.
+const placeholders = (value: string) =>
+ [...value.matchAll(/\{\{\s*(\w+)\s*\}\}/g)]
+ .map((match) => match[1])
+ .sort()
+ .join(",")
+
+describe("locale placeholders", () => {
+ for (const [locale, dict] of [
+ ["es", es],
+ ["vi", vi],
+ ] as const) {
+ test(`${locale} interpolates the same values as en`, () => {
+ const translated: Record = dict
+ const mismatched = Object.entries(en as Record).flatMap(([key, value]) => {
+ const other = translated[key]
+ if (other === undefined) return []
+ if (placeholders(value) === placeholders(other)) return []
+ return [`${key}: en has [${placeholders(value)}], ${locale} has [${placeholders(other)}]`]
+ })
+
+ expect(mismatched).toEqual([])
+ })
+ }
+})
diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts
index ef18a3a947..d8d6312543 100644
--- a/packages/app/src/i18n/vi.ts
+++ b/packages/app/src/i18n/vi.ts
@@ -670,6 +670,24 @@ export const dict = {
"Phiên đăng nhập CoDev của bạn đã hết hạn nên các phiên làm việc không còn được tải lên để phân tích. Hãy đăng nhập lại để tiếp tục — các phiên đang chờ sẽ tự động được tải lên sau đó.",
"toast.sessionUpload.paused.action.login": "Đăng nhập",
+ // The updater dialogs, which the desktop main process builds itself — see
+ // desktop/src/main/updater.ts. Every one names the running version.
+ "updater.dialog.error.title": "Lỗi cập nhật",
+ "updater.dialog.error.message": "Không thể kiểm tra cập nhật.",
+ "updater.dialog.error.detail": "Bạn đang dùng CoDev {{version}}.",
+ "updater.dialog.upToDate.title": "Không có bản cập nhật",
+ "updater.dialog.upToDate.message": "Bạn đang dùng bản mới nhất.",
+ "updater.dialog.upToDate.detail": "CoDev {{version}} là phiên bản mới nhất.",
+ "updater.dialog.available.title": "Có bản cập nhật",
+ "updater.dialog.available.message": "Đã có CoDev {{version}}.",
+ "updater.dialog.available.detail": "Bạn đang dùng {{current}}. Tải xuống ngay?",
+ "updater.dialog.ready.title": "Bản cập nhật đã sẵn sàng",
+ "updater.dialog.ready.message": "Đã tải xuống CoDev {{version}}. Khởi động lại ngay?",
+ "updater.dialog.ready.detail": "Bạn đang dùng {{current}}.",
+ "updater.dialog.action.download": "Tải xuống",
+ "updater.dialog.action.restart": "Khởi động lại",
+ "updater.dialog.action.later": "Để sau",
+
// The desktop app menu (macOS native menu bar and the Windows/Linux
// titlebar menu) — see desktop-menu.ts.
"menu.app": "CoDev",
diff --git a/packages/desktop/src/main/i18n.ts b/packages/desktop/src/main/i18n.ts
new file mode 100644
index 0000000000..a237f88ddb
--- /dev/null
+++ b/packages/desktop/src/main/i18n.ts
@@ -0,0 +1,40 @@
+import { resolveTemplate, translator } from "@solid-primitives/i18n"
+import { app } from "electron"
+import type { dict as english } from "@opencode-ai/app/i18n/en"
+
+import { pickLocale, type Locale } from "./locale"
+import { getStore } from "./store"
+
+// Fork: the main process renders two surfaces of its own — the macOS native
+// menu bar and the updater dialogs — and has no renderer i18n to resolve their
+// strings with. It reads the app dictionaries directly instead, in the locale
+// the app persisted into the renderer-owned global store, so a main-process
+// string can never disagree with the UI around it.
+const GLOBAL_STORE = "codev.global.dat"
+
+export type TranslationKey = keyof typeof english
+
+// Dynamically imported so a language nobody selected is never loaded, and so a
+// caller that returns before translating anything — createMenu on Windows and
+// Linux — pays for no dictionary at all. Repeat calls hit the module cache.
+export async function createTranslator() {
+ const { dict } = await import("@opencode-ai/app/i18n/en")
+ const locale = pickLocale(getStore(GLOBAL_STORE).get("language"), app.getLocale())
+ // Layered over English rather than used alone: a locale dict can trail it by
+ // a key between translation passes, and an English string beats a blank one.
+ const translated: Record = { ...dict, ...(await localeDict(locale)) }
+ const translate = translator(() => translated, resolveTemplate)
+ return (key: TranslationKey, params?: Record) => translate(key, params) ?? key
+}
+
+async function localeDict(locale: Locale) {
+ if (locale === "es") {
+ const { dict } = await import("@opencode-ai/app/i18n/es")
+ return dict
+ }
+ if (locale === "vi") {
+ const { dict } = await import("@opencode-ai/app/i18n/vi")
+ return dict
+ }
+ return {}
+}
diff --git a/packages/desktop/src/main/locale.test.ts b/packages/desktop/src/main/locale.test.ts
new file mode 100644
index 0000000000..b28bb75047
--- /dev/null
+++ b/packages/desktop/src/main/locale.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from "bun:test"
+
+import { pickLocale } from "./locale"
+
+describe("pickLocale", () => {
+ // The shape the app actually persists (checked against a real
+ // codev.global.dat): the language record, JSON-stringified.
+ test("reads the locale out of the stored language record", () => {
+ expect(pickLocale('{"locale":"vi","direction":"ltr"}', "en-US")).toBe("vi")
+ expect(pickLocale('{ "locale" : "es" }', "en-US")).toBe("es")
+ })
+
+ test("accepts a bare locale and an already-parsed record", () => {
+ expect(pickLocale("vi", "en-US")).toBe("vi")
+ expect(pickLocale({ locale: "es" }, "en-US")).toBe("es")
+ })
+
+ test("falls back to the system locale when nothing was chosen", () => {
+ expect(pickLocale(undefined, "vi-VN")).toBe("vi")
+ expect(pickLocale(null, "es-ES")).toBe("es")
+ expect(pickLocale(undefined, "fr-FR")).toBe("en")
+ })
+
+ test("ignores a locale the menu has no dictionary for", () => {
+ expect(pickLocale('{"locale":"de"}', "vi-VN")).toBe("vi")
+ expect(pickLocale("th", "fr-FR")).toBe("en")
+ })
+})
diff --git a/packages/desktop/src/main/menu-locale.ts b/packages/desktop/src/main/locale.ts
similarity index 57%
rename from packages/desktop/src/main/menu-locale.ts
rename to packages/desktop/src/main/locale.ts
index 80e112e74c..acf25bf9df 100644
--- a/packages/desktop/src/main/menu-locale.ts
+++ b/packages/desktop/src/main/locale.ts
@@ -1,10 +1,11 @@
-// Fork: the locale the macOS menu bar builds in, picked from the record the
-// renderer persists into the global store. Kept apart from menu-i18n.ts so it
+// Fork: the locale the main process renders in — the menu bar and the updater
+// dialogs, which are built here rather than in the renderer. Picked from the
+// record the app persists into the global store. Kept apart from i18n.ts so it
// stays importable — and testable — outside the Electron runtime.
-export const MENU_LOCALES = ["en", "es", "vi"] as const
+export const LOCALES = ["en", "es", "vi"] as const
-export type MenuLocale = (typeof MENU_LOCALES)[number]
+export type Locale = (typeof LOCALES)[number]
// `stored` is whatever the store holds under `language`: the app writes the
// language record JSON-stringified, so matching the locale out of the text
@@ -12,12 +13,12 @@ export type MenuLocale = (typeof MENU_LOCALES)[number]
// same record the same way). A bare locale string and an already-parsed record
// both still resolve. `system` is the fallback when nothing was ever chosen —
// the main-process equivalent of the renderer detecting navigator.languages.
-export function pickMenuLocale(stored: unknown, system: string): MenuLocale {
+export function pickLocale(stored: unknown, system: string): Locale {
const value =
typeof stored === "string"
? (stored.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] ?? stored)
: (stored as { locale?: unknown } | null | undefined)?.locale
- const selected = MENU_LOCALES.find((locale) => locale === value)
+ const selected = LOCALES.find((locale) => locale === value)
if (selected) return selected
- return MENU_LOCALES.find((locale) => system.toLowerCase().startsWith(locale)) ?? "en"
+ return LOCALES.find((locale) => system.toLowerCase().startsWith(locale)) ?? "en"
}
diff --git a/packages/desktop/src/main/menu-i18n.ts b/packages/desktop/src/main/menu-i18n.ts
deleted file mode 100644
index ea9a270d47..0000000000
--- a/packages/desktop/src/main/menu-i18n.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { app } from "electron"
-import type { DesktopMenuLabelKey } from "@opencode-ai/app/desktop-menu"
-
-import { pickMenuLocale } from "./menu-locale"
-import { getStore } from "./store"
-
-// Fork: the macOS native menu bar is built in the main process, which has no
-// renderer i18n — so it resolves DESKTOP_MENU's dictionary keys itself. The
-// locale comes from the record the app persisted into the renderer-owned
-// global store, the same one src/renderer/i18n reads, so the menu bar and the
-// UI can never disagree about the language.
-const GLOBAL_STORE = "codev.global.dat"
-
-// Dynamic imports so a language nobody selected is never loaded, and so the
-// Windows and Linux builds — where createMenu returns before calling this —
-// pay for no dictionary at all.
-export async function menuTranslator() {
- const { dict: english } = await import("@opencode-ai/app/i18n/en")
- const locale = pickMenuLocale(getStore(GLOBAL_STORE).get("language"), app.getLocale())
- if (locale === "en") return (key: DesktopMenuLabelKey) => english[key]
- const { dict } = locale === "vi" ? await import("@opencode-ai/app/i18n/vi") : await import("@opencode-ai/app/i18n/es")
- const translated: Record = dict
- // A locale dict can trail English by a key between translation passes; an
- // English label beats rendering "undefined" in the menu bar.
- return (key: DesktopMenuLabelKey) => translated[key] ?? english[key]
-}
diff --git a/packages/desktop/src/main/menu-locale.test.ts b/packages/desktop/src/main/menu-locale.test.ts
deleted file mode 100644
index 6d0ef1360c..0000000000
--- a/packages/desktop/src/main/menu-locale.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { describe, expect, test } from "bun:test"
-
-import { pickMenuLocale } from "./menu-locale"
-
-describe("pickMenuLocale", () => {
- // The shape the app actually persists (checked against a real
- // codev.global.dat): the language record, JSON-stringified.
- test("reads the locale out of the stored language record", () => {
- expect(pickMenuLocale('{"locale":"vi","direction":"ltr"}', "en-US")).toBe("vi")
- expect(pickMenuLocale('{ "locale" : "es" }', "en-US")).toBe("es")
- })
-
- test("accepts a bare locale and an already-parsed record", () => {
- expect(pickMenuLocale("vi", "en-US")).toBe("vi")
- expect(pickMenuLocale({ locale: "es" }, "en-US")).toBe("es")
- })
-
- test("falls back to the system locale when nothing was chosen", () => {
- expect(pickMenuLocale(undefined, "vi-VN")).toBe("vi")
- expect(pickMenuLocale(null, "es-ES")).toBe("es")
- expect(pickMenuLocale(undefined, "fr-FR")).toBe("en")
- })
-
- test("ignores a locale the menu has no dictionary for", () => {
- expect(pickMenuLocale('{"locale":"de"}', "vi-VN")).toBe("vi")
- expect(pickMenuLocale("th", "fr-FR")).toBe("en")
- })
-})
diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts
index 6f3332877b..10025277bd 100644
--- a/packages/desktop/src/main/menu.ts
+++ b/packages/desktop/src/main/menu.ts
@@ -10,7 +10,7 @@ import {
import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
-import { menuTranslator } from "./menu-i18n"
+import { createTranslator } from "./i18n"
type Deps = {
trigger: (id: string) => void
@@ -28,7 +28,7 @@ export async function createMenu(deps: Deps) {
if (process.platform !== "darwin") return
current = deps
- const t = await menuTranslator()
+ const t = await createTranslator()
const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
if (menu.role) return { role: nativeRole(menu.role) }
return {
diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts
index a30b2d128a..1fdd4bc477 100644
--- a/packages/desktop/src/main/updater.ts
+++ b/packages/desktop/src/main/updater.ts
@@ -1,6 +1,7 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
+import { createTranslator } from "./i18n"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
@@ -83,25 +84,62 @@ export function setupAutoUpdater(stop: () => Promise) {
})
}
+// Fork: every dialog names the version the user is on, and the one they would
+// move to — the whole question "Check for Updates..." is asked to answer, which
+// a bare "You're up to date." left the user to take on faith. Strings come from
+// the app dictionaries (see i18n.ts): these run in the main process, so they
+// have no renderer i18n of their own.
export async function showUpdaterDialog(controller: ReturnType, alertOnFail: boolean) {
const state = await controller.check()
+ const t = await createTranslator()
+ const current = app.getVersion()
+
if (state.status === "error") {
if (!alertOnFail) return
- await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
+ await dialog.showMessageBox({
+ type: "error",
+ title: t("updater.dialog.error.title"),
+ message: t("updater.dialog.error.message"),
+ detail: t("updater.dialog.error.detail", { version: current }),
+ })
return
}
if (state.status === "up-to-date") {
if (!alertOnFail) return
- await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
+ await dialog.showMessageBox({
+ type: "info",
+ title: t("updater.dialog.upToDate.title"),
+ message: t("updater.dialog.upToDate.message"),
+ detail: t("updater.dialog.upToDate.detail", { version: current }),
+ })
+ return
+ }
+ // Found but not fetched, which is where a check stops once automatic
+ // downloading is off. Silent until now: the one state that exists for the
+ // user to consent to the download had no dialog to consent in.
+ if (state.status === "available") {
+ const response = await dialog.showMessageBox({
+ type: "info",
+ title: t("updater.dialog.available.title"),
+ message: t("updater.dialog.available.message", { version: state.version }),
+ detail: t("updater.dialog.available.detail", { current }),
+ buttons: [t("updater.dialog.action.download"), t("updater.dialog.action.later")],
+ defaultId: 0,
+ cancelId: 1,
+ })
+ // Fire and forget: the renderer already surfaces the download and offers
+ // the restart once it lands.
+ if (response.response === 0) void controller.download()
return
}
if (state.status !== "ready") return
const response = await dialog.showMessageBox({
type: "info",
- message: `Update ${state.version} downloaded. Restart now?`,
- title: "Update Ready",
- buttons: ["Restart", "Later"],
+ title: t("updater.dialog.ready.title"),
+ message: t("updater.dialog.ready.message", { version: state.version }),
+ detail: t("updater.dialog.ready.detail", { current }),
+ buttons: [t("updater.dialog.action.restart"), t("updater.dialog.action.later")],
defaultId: 0,
cancelId: 1,
})