From b74b5c821a1d2ceeed32e5f2cb761b93c7f52108 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Tue, 25 Aug 2026 10:43:34 +0700 Subject: [PATCH] feat(desktop): name the versions in the update dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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." with nothing to check it against, and a restart prompt that named the downloaded version but not the one it replaced. Every branch now carries a detail line with app.getVersion(); the ready dialog names both versions. The available state — where a check stops once automatic downloading is off — gains a dialog of its own, having previously fallen through the status !== "ready" guard so the menu item closed showing nothing at all. All the strings move into the app dictionaries and resolve through main/i18n.ts, which the macOS menu bar already used: menu-i18n.ts and menu-locale.ts are renamed i18n.ts and locale.ts now that they serve more than the menu, and the translator gained placeholder interpolation (@solid-primitives/i18n's resolveTemplate, already a desktop dep). A new placeholders test asserts es and vi interpolate the same values as en, since a dropped {{version}} renders as silence rather than an error. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 +++++ DIVERGENCES.md | 3 +- packages/app/src/i18n/en.ts | 18 +++++++ packages/app/src/i18n/es.ts | 18 +++++++ packages/app/src/i18n/placeholders.test.ts | 35 ++++++++++++++ packages/app/src/i18n/vi.ts | 18 +++++++ packages/desktop/src/main/i18n.ts | 40 ++++++++++++++++ packages/desktop/src/main/locale.test.ts | 28 +++++++++++ .../src/main/{menu-locale.ts => locale.ts} | 15 +++--- packages/desktop/src/main/menu-i18n.ts | 26 ---------- packages/desktop/src/main/menu-locale.test.ts | 28 ----------- packages/desktop/src/main/menu.ts | 4 +- packages/desktop/src/main/updater.ts | 48 +++++++++++++++++-- 13 files changed, 223 insertions(+), 69 deletions(-) create mode 100644 packages/app/src/i18n/placeholders.test.ts create mode 100644 packages/desktop/src/main/i18n.ts create mode 100644 packages/desktop/src/main/locale.test.ts rename packages/desktop/src/main/{menu-locale.ts => locale.ts} (57%) delete mode 100644 packages/desktop/src/main/menu-i18n.ts delete mode 100644 packages/desktop/src/main/menu-locale.test.ts 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` → `<name>CoDev</name>` 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 <minhnh49@viettel.com.vn>`; 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: `<developer><name>CoDev</name>` and `<url type="homepage">`). 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 `<xdgData>/opencode/log` → `/codev/log` (a real bug fix — the fork CLI writes to `<xdgData>/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/<version>/`) 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<string, string> = dict + const mismatched = Object.entries(en as Record<string, string>).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<string, string> = { ...dict, ...(await localeDict(locale)) } + const translate = translator(() => translated, resolveTemplate) + return (key: TranslationKey, params?: Record<string, string | number>) => 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<string, string> = 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<void>) { }) } +// 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<typeof setupAutoUpdater>, 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, })