From d501382cbadc8dc7db621bb6d5f214480bf39034 Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:13:56 +0700 Subject: [PATCH] feat(desktop): let the user choose where Windows installs CoDev The installer was one-click: no pages, no questions, and no way to put the app anywhere but the default. Make it assisted and add the directory page, so the user can take the default location or browse for their own. allowToChangeInstallationDirectory is rejected outright when oneClick is true, so both move together. perMachine stays false: the install-mode page (all users / only for me) comes with any assisted installer and cannot be suppressed without forcing per-machine, so it is there, but only-for-me remains the default and nothing needs admin rights. Updates install with /S. quitAndInstall() defaults to isSilent = false, which for an assisted installer would walk every update through the full wizard and re-ask for a location the user already chose; isForceRunAfter is honoured only alongside isSilent, so both arguments are set. Knock-on to #266, now corrected in the config comment, DIVERGENCES and CHANGELOG: the Windows install directory no longer derives from extraMetadata.name. getWindowsInstallationDirName falls back to sanitizedName only for a one-click per-user install, so the default is productFilename ("CoDev") instead of "codev". The override still names the updater cache directory, which is what keeps it load-bearing. Verified by packaging: electron-builder logs `oneClick=false perMachine=false` and app-update.yml still reads `updaterCacheDirName: codev-updater`. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 ++++++-- DIVERGENCES.md | 8 +++-- .../desktop/electron-builder.config.test.ts | 35 ++++++++++++++---- packages/desktop/electron-builder.config.ts | 36 ++++++++++++++----- packages/desktop/src/main/updater.ts | 10 +++++- 5 files changed, 83 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db652f6f9..3444cb8836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,25 @@ file is the human-readable summary of what changed and why. its downloaded updates in `@opencode-aidesktop-updater` beside it. Both names came from an internal package id that was never meant to be read, by way of a packaging rule that uses it rather than the product name for a per-user - install. They are now `codev` and `codev-updater` (`codev-beta`/`codev-dev` on + install. The update cache is now `codev-updater` (`codev-beta`/`codev-dev` on those channels, which previously shared one folder with each other and with - the release build). Already-installed copies stay where they are and keep + the release build), and the app itself installs into `CoDev` — see the + installer change below, which lets you put it wherever you like instead. + Already-installed copies stay where they are and keep updating — the installer reuses the location it recorded — so this changes where a fresh install lands, not where an existing one lives; a machine that had downloaded an update leaves the old cache folder behind, which is safe to delete. Linux and macOS are unaffected: the `.deb` package name is already `codev`, and nothing on macOS is derived from this name. +- **You can choose where the Windows installer puts CoDev** — it installed + itself wherever it liked and gave you no say: one screen, no questions, done. + It now asks two. First whether to install for everyone on the computer or + just for you — just for you stays the default, and it is the one that needs no + administrator approval. Then where to put it, offering the usual place with a + **Browse** button beside it, so a machine with a small `C:` drive or a policy + about where programs live is no longer stuck. Updates are unaffected: they + still install quietly in the background and never re-ask, because an update + reuses the location you picked. ### Updates diff --git a/DIVERGENCES.md b/DIVERGENCES.md index b73b896fcd..2f398cb730 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -188,7 +188,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. | | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev" (Dev/Beta variants too) | Desktop app display name ("Code" dropped once the app carried a Work section beside Code). | Keep our names. | -| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const, deliberately **unversioned** — it is the only URL an installed build ever reads (`app-update.yml` is baked at package time and nothing calls `setFeedURL`), so a version in it would leave that build able to see only its own release folder. The manifests sit at that base and the artifacts they name live under `/`, which the fork-only `scripts/stage-release.ts` arranges by prefixing each `url`/`path` in `latest*.yml`; electron-updater resolves them with `new URL(name, base)` (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. Also `extraMetadata.name`, single-sourced with `rpm.packageName` as `PACKAGE_NAMES`: package.json `name` stays `@opencode-ai/desktop` per the internal-names rule, but electron-builder does not treat it as internal — `appInfo.sanitizedName` strips the slash to `@opencode-aidesktop` and that reaches users as the per-user Windows install directory (`%LOCALAPPDATA%\Programs\@opencode-aidesktop`; a one-click `perMachine: false` install takes `sanitizedName`, **not** `productFilename` — see `getWindowsInstallationDirName`), as the updater cache directory (`-updater`, baked into `app-update.yml` on every platform), and as the source of the Debian package name when `deb.packageName` is unset. The `deb` name is the one place this changes nothing user-visible: `FpmTarget` passes `appInfo.linuxPackageName` (`sanitizedProductName`, i.e. `CoDev`/`CoDev Dev`, since the package id starts with `@`) straight to `fpm`, which normalizes it itself — it downcases with a warning and turns the space into a hyphen, so the built packages were already `codev`/`codev-beta`/`codev-dev`. Passing a clean name just silences that warning. Do not "fix" the `deb` name by citing this row. It has to be per channel for the same reason appId is: one shared value installs every channel over the same directory and shares one updater cache. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). Keep `extraMetadata.name` and do not "simplify" it away as a restatement of `rpm.packageName` — dropping it silently reinstates `@opencode-aidesktop` as the install directory, which no build log or test mentions unless you look inside a packaged app's `app-update.yml`; `electron-builder.config.test.ts` covers it per channel. | +| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const, deliberately **unversioned** — it is the only URL an installed build ever reads (`app-update.yml` is baked at package time and nothing calls `setFeedURL`), so a version in it would leave that build able to see only its own release folder. The manifests sit at that base and the artifacts they name live under `/`, which the fork-only `scripts/stage-release.ts` arranges by prefixing each `url`/`path` in `latest*.yml`; electron-updater resolves them with `new URL(name, base)` (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. Also `nsis` is an **assisted** installer rather than upstream's one-click: `oneClick: false` + `allowToChangeInstallationDirectory: true` so the user can take the default location or browse for their own, `perMachine` still `false`, and `installerHeaderIcon` dropped (electron-builder reads it only in the one-click branch of `configureDefines`; assisted wizards take `installerHeader`/`installerSidebar` bitmaps, and without them fall back to the stock NSIS sidebar). | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. Also `extraMetadata.name`, single-sourced with `rpm.packageName` as `PACKAGE_NAMES`: package.json `name` stays `@opencode-ai/desktop` per the internal-names rule, but electron-builder does not treat it as internal — `appInfo.sanitizedName` strips the slash to `@opencode-aidesktop` and that reaches users as the updater cache directory (`-updater`, baked into `app-update.yml` on every platform) and as the source of the Debian package name when `deb.packageName` is unset. It named the per-user Windows install directory too (`%LOCALAPPDATA%\Programs\@opencode-aidesktop`) until the installer became assisted: `getWindowsInstallationDirName(appInfo, !oneClick || isPerMachine)` falls back to `sanitizedName` only for a **one-click** per-user install, so with `oneClick: false` the default directory is `productFilename` (`CoDev`/`CoDev Beta`/`CoDev Dev`) and the user can change it anyway — see the assisted-installer note below. The `deb` name is the one place this changes nothing user-visible: `FpmTarget` passes `appInfo.linuxPackageName` (`sanitizedProductName`, i.e. `CoDev`/`CoDev Dev`, since the package id starts with `@`) straight to `fpm`, which normalizes it itself — it downcases with a warning and turns the space into a hyphen, so the built packages were already `codev`/`codev-beta`/`codev-dev`. Passing a clean name just silences that warning. Do not "fix" the `deb` name by citing this row. It has to be per channel for the same reason appId is: one shared value gives every channel the same updater cache directory (and, while the installer was one-click, the same install directory). | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). Keep `extraMetadata.name` and do not "simplify" it away as a restatement of `rpm.packageName` — dropping it silently reinstates `@opencode-aidesktop-updater` as the updater cache directory, which no build log or test mentions unless you look inside a packaged app's `app-update.yml`; `electron-builder.config.test.ts` covers it per channel. Keep `oneClick: false` and `allowToChangeInstallationDirectory` together — the second is a build error without the first, and the first alone silently drops the directory page — and keep them in step with `quitAndInstall(true, true)` in `src/main/updater.ts` (see that row), or every update walks the user back through the wizard. | | `packages/app/src/utils/refcount.ts` (+ `.test.ts`) | `createRefCountMap` creates each item in its own root owned by the scope that created the map (disposed when the last pinning caller releases it), and callers without a reactive owner borrow the item without pinning it. | Upstream ran `create` in the *first caller's* scope, so a shared item's internal cleanups (e.g. the dir SDK context's event unsubscribe in `server-sdk.tsx`) fired when that first view closed while other holders still used the item — live updates for the directory silently died. Ownerless callers (event handlers, async continuations: `notification.tsx` session lookup, `layout.tsx` openSession) also registered cleanups Solid warns can never run and pinned items forever. | Keep ours; take upstream's surrounding changes. The two added tests ("ownerless caller borrows without pinning", "item's internal cleanups survive its first caller") pin the semantics. | | `packages/app/src/app.tsx`, `packages/app/src/utils/session-route.ts` | `session-route.ts` gains `serverKeyFromRoute`, a non-throwing variant of `requireServerKey` (which is now defined in terms of it and kept for callers that want the throw). `app.tsx`'s `TargetServerRoute` and `LegacyTargetSessionRoute` read the route's server key through it: `TargetServerRoute` holds it in a memo, passes `undefined` rather than a bogus connection to the SDK/sync providers, and falls back to ``; `LegacyTargetSessionRoute` wraps its `SessionRouteErrorBoundary` in a `Show` on the same memo. | `requireServerKey` throws on a segment that is missing or does not round-trip, and both call sites ran it during render — so the throw escaped from a memo and killed the entire renderer with the fatal error screen. The param does go missing in normal use: removing a server navigates away (`tabs.removeServer` → `navigate("/")`) and the route's params clear before its subtree unmounts, which is how deleting a sandbox with an open session took the window down. Worse, the desktop restores the last active route on launch, so the crash repeated on every relaunch until the persisted URL changed. Rendering nothing was not enough on its own — a `/server/…` route that cannot mount its providers leaves the shell half-built and fails further down in the command registry — hence the redirect home. This is the same class as the `project-avatar-state.ts` guard below and is an upstream bug. | Keep the guard; take upstream's other changes to these files. If upstream makes the route resilient its own way, drop ours in favour of theirs. Do not "simplify" the route back to calling `requireServerKey` directly — that is the crash. | | `packages/app/src/context/server-sdk.tsx` | Event-stream failures log only after 3 consecutive failed attempts (`logStreamIssue`), with the error normalized to `name: message`; any received event resets the count. | The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious `[global-sdk] event stream error` for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). | Keep the threshold and shared `logStreamIssue`; take upstream's surrounding stream changes. | @@ -203,7 +203,7 @@ from the squash message first, or use a plain merge. | Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | | `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern). | Keep the export; take upstream's other manifest changes. | | `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. | -| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | 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. | Keep `false`; take upstream's other updater settings. `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). | +| `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). | | 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; both return the same `WslCommandResult` shape, so 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 via offline dism + installs the MSI behind one UAC prompt, 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 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. | | 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. | | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | @@ -335,7 +335,9 @@ from the squash message first, or use a plain merge. (`%LOCALAPPDATA%\Programs\@opencode-aidesktop`) and as the updater cache directory (`@opencode-aidesktop-updater`). Fixed without renaming the package: `extraMetadata.name` in `electron-builder.config.ts` overrides what the packager derives, so the internal id and - the user-visible one are now decoupled (see that table row). The desktop app's persisted + the user-visible one are now decoupled (see that table row). The install directory has since + stopped deriving from it at all — the assisted installer names it after `productFilename` + (`CoDev`) — but the updater cache still does, so the override stays load-bearing. The desktop app's persisted store file names (`opencode.settings`, `opencode.{draft,workspace,global,window,updater}.*` in `%APPDATA%\ai.codev.desktop`) are the same shape of leak and are still upstream-branded deliberately — unlike a derived name, renaming those orphans existing state, so it needs a diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index b7d1423406..46dcc133b2 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -29,14 +29,14 @@ for (const channel of channels) { }) } -// electron-builder derives the per-user Windows install directory -// (%LOCALAPPDATA%\Programs\) and the updater cache directory -// (-updater) from package.json `name`, not from productName: a one-click -// perMachine:false install takes appInfo.sanitizedName. Left alone that is -// "@opencode-aidesktop" — the upstream package id, on screen. It also has to be -// per channel, or dev/beta/prod installs land on top of each other. +// electron-builder derives the updater cache directory (-updater, baked +// into app-update.yml) from package.json `name`, not from productName. Left +// alone that is "@opencode-aidesktop" — the upstream package id, on screen. It +// has to be per channel, or dev/beta/prod share one cache. The install +// directory used to come from here too; it is productFilename now that the +// installer is assisted, which the nsis test below pins. for (const channel of channels) { - test(`brands the install and updater cache directories for ${channel.channel}`, async () => { + test(`brands the updater cache directory for ${channel.channel}`, async () => { const previous = process.env.CODEV_CHANNEL process.env.CODEV_CHANNEL = channel.channel @@ -51,6 +51,27 @@ for (const channel of channels) { }) } +// The installer offers the default location or one the user browses to. That +// needs an assisted installer: electron-builder throws +// "allowToChangeInstallationDirectory makes sense only for assisted installer" +// when it is set with oneClick, and oneClick alone drops the directory page +// silently. perMachine stays false so the default is still a per-user install +// that needs no admin rights and updates without a UAC prompt. +test("lets the user choose the install location", async () => { + const previous = process.env.CODEV_CHANNEL + process.env.CODEV_CHANNEL = "prod" + + const module = await import("./electron-builder.config.ts?nsis=prod") + const config = module.default as Configuration + + if (previous === undefined) delete process.env.CODEV_CHANNEL + else process.env.CODEV_CHANNEL = previous + + expect(config.nsis?.oneClick).toBe(false) + expect(config.nsis?.allowToChangeInstallationDirectory).toBe(true) + expect(config.nsis?.perMachine).toBe(false) +}) + // The scheme the OS hands back to us on a deep link. It is registered here and // consumed by app.setAsDefaultProtocolClient plus parseUrl in the renderer's // deep-links module: all three have to agree or the link opens nothing. diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 52858960d2..cbef75006e 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -115,13 +115,18 @@ const getBase = (appId: string): Configuration => ({ // package.json `name` stays "@opencode-ai/desktop" — internal package ids // are deliberately not renamed — but electron-builder does not treat it as // internal. appInfo.sanitizedName strips the slash to "@opencode-aidesktop" - // and that string reaches the user two ways: the per-user Windows install - // directory (%LOCALAPPDATA%\Programs\ — a one-click perMachine:false - // install uses sanitizedName, NOT productFilename, see - // getWindowsInstallationDirName) and the updater cache directory + // and that string reaches the user as the updater cache directory // ("-updater", baked into app-update.yml on every platform). - // Overriding the name here rebrands both without renaming the workspace - // package. It also feeds the Debian package name when deb.packageName is + // Overriding the name here rebrands it without renaming the workspace + // package. + // + // It no longer names the Windows install directory too. That is + // getWindowsInstallationDirName(appInfo, !oneClick || isPerMachine), which + // falls back to sanitizedName only for a one-click per-user install; this + // installer is assisted, so the default directory is productFilename + // ("CoDev") — and the user can change it anyway. + // + // It also feeds the Debian package name when deb.packageName is // unset, but that one was never wrong: fpm downcases and hyphenates what it // is given, so those packages already built as codev/codev-beta/codev-dev. name: PACKAGE_NAMES[channel], @@ -196,10 +201,25 @@ const getBase = (appId: string): Configuration => ({ verifyUpdateCodeSignature: false, }, nsis: { - oneClick: true, + // Assisted rather than one-click, so the user can accept the default + // location or pick their own: allowToChangeInstallationDirectory adds the + // directory page, and electron-builder rejects it outright when oneClick + // is true. The wizard is install mode → directory → progress → finish; + // both the directory and license pages carry skipPageIfUpdated, and + // updates run with /S anyway (src/main/updater.ts), so a reinstall never + // re-asks. + oneClick: false, + allowToChangeInstallationDirectory: true, + // Still per-user by default. Assisted installers always show the install + // mode page (it is suppressed only by perMachine: true), so a user can + // choose all-users from it and take the UAC prompt; nothing here forces + // that on everyone. See the Windows install location notes in + // DIVERGENCES.md for why per-user is the default. perMachine: false, installerIcon: `resources/icons/icon.ico`, - installerHeaderIcon: `resources/icons/icon.ico`, + // No installerHeaderIcon: electron-builder reads it only for one-click + // installers. The assisted wizard takes installerHeader/installerSidebar + // bitmaps instead, and falls back to the stock NSIS sidebar without them. }, linux: { icon: `resources/icons`, diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index 53564ffbcb..c58fc17567 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -52,7 +52,15 @@ export function setupAutoUpdater(stop: () => Promise) { // flag the quit first to keep window ids persisted for restore. setAppQuitting() try { - autoUpdater.quitAndInstall() + // Silent, and relaunch afterwards. The installer is assisted + // (oneClick: false, so users can choose where to install), which + // without /S would walk every update through the full wizard — + // install mode, directory, finish — for a location the user already + // chose. isSilent passes /S; the installer then reuses the recorded + // InstallLocation. isForceRunAfter is only honoured when isSilent is + // set (BaseUpdater.quitAndInstall), so both are needed to come back + // up after an update. + autoUpdater.quitAndInstall(true, true) } catch (error) { // The install failed and the app keeps running; clear the flag so // deliberate window closes prune ids again.