diff --git a/CHANGELOG.md b/CHANGELOG.md index 3444cb8836..e857462234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,15 @@ file is the human-readable summary of what changed and why. 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. +- **CoDev is listed as "CoDev", not "CoDev 1.18.4-8"** — the entry in Windows + Settings → Apps carried its version number in the name, which that list + already shows in its own Version column. It is just the app's name now, and + it stops looking out of date the moment you update. +- **One less redundant line in the installer** — the page that asks who to + install for explained the choice you had just been offered: picking "Only for + me" added "Fresh install for current user only." underneath it. The line for + installing for everyone stays, because it says something the choice itself + does not — that Windows will ask for administrator approval. ### Updates diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 2f398cb730..8ad19730a3 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. 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/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). Plus two installer-text changes: `uninstallDisplayName: "${productName}"` drops the version from the Apps & Features name (electron-builder defaults to `${productName} ${version}`, and that list already has a Version column fed by the separate `DisplayVersion` the installer writes, so the default prints it twice), and `warningsAsErrors: false` exists solely to permit the fork-only `resources/installer.nsh` — see the on-conflict column. | 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. `warningsAsErrors: false` and `resources/installer.nsh` (fork-only, auto-resolved as the `nsis.include` fallback) are likewise a pair: the file blanks the install-mode page's "Fresh install for current user only." line — redundant beside a radio that already reads "Only for me (user)" — by redefining that `LangString`, which is the only hook available because `addCustomMessageFileInclude` reads `assistedMessages.yml` from app-builder-lib's own template dir and never from the project. NSIS answers the redefinition with warning 6030 ×26 (one per bundled language), fatal under makensis's default `-WX`. Delete one and the other is either a broken build or a pointless loss of a safety net. Know what the flag costs: `checkMakensisOutput` rethrows only on `Error:` lines and prints nothing on success, so a *new* NSIS warning becomes invisible as well as non-fatal — flip the flag back to `true` and rebuild to audit. The override must cover every language in `app-builder-lib/out/util/langs.js#bundledLanguages`, since the string is emitted per language; `electron-builder.config.test.ts` pins that against the real list so an upstream language addition fails there rather than silently restoring the line. | | `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. | diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 46dcc133b2..7d1caff413 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import { createRequire } from "node:module" import path from "node:path" import type { Configuration } from "electron-builder" import type { GenericServerOptions } from "builder-util-runtime" @@ -72,6 +73,71 @@ test("lets the user choose the install location", async () => { expect(config.nsis?.perMachine).toBe(false) }) +// Apps & Features shows DisplayName, which electron-builder defaults to +// "${productName} ${version}". That list already has its own Version column, +// fed by the DisplayVersion the installer writes separately, so the default +// prints the version twice. +test("keeps the version out of the Apps & Features name", async () => { + const previous = process.env.CODEV_CHANNEL + process.env.CODEV_CHANNEL = "prod" + + const module = await import("./electron-builder.config.ts?uninstall=prod") + const config = module.default as Configuration + + if (previous === undefined) delete process.env.CODEV_CHANNEL + else process.env.CODEV_CHANNEL = previous + + expect(config.nsis?.uninstallDisplayName).toBe("${productName}") + expect(config.nsis?.uninstallDisplayName).not.toInclude("version") +}) + +// resources/installer.nsh blanks the install-mode page's description line by +// redefining a LangString, which NSIS reports as warning 6030 — fatal under +// makensis's default -WX. The two have to travel together: without the +// override the flag is a needless loss of a safety net, and without the flag +// the override fails the build outright. +test("permits the LangString override it depends on", async () => { + const previous = process.env.CODEV_CHANNEL + process.env.CODEV_CHANNEL = "prod" + + const module = await import("./electron-builder.config.ts?warnings=prod") + const config = module.default as Configuration + + if (previous === undefined) delete process.env.CODEV_CHANNEL + else process.env.CODEV_CHANNEL = previous + + expect(config.nsis?.warningsAsErrors).toBe(false) + expect(await Bun.file(new URL("./resources/installer.nsh", import.meta.url)).exists()).toBe(true) +}) + +// The override has to cover every language electron-builder bundles: the +// string is emitted once per language, so an English-only override would leave +// the other 25 showing it. Pin that against app-builder-lib's own list rather +// than a hardcoded count, so a language added upstream fails here instead of +// silently reintroducing the line for its users. +test("blanks the install-mode description in every bundled language", async () => { + // Resolved through electron-builder rather than declared: app-builder-lib is + // its internals, already in the tree, and promoting it to a direct + // devDependency re-hoists the whole lockfile — it pins older @electron/get, + // env-paths and friends, which would displace the versions electron itself + // resolves. Not a trade worth making for one import in one test. + const nodeRequire = createRequire(import.meta.url) + const { bundledLanguages, lcid } = nodeRequire( + nodeRequire.resolve("app-builder-lib/out/util/langs.js", { paths: [nodeRequire.resolve("electron-builder")] }), + ) + const script = await Bun.file(new URL("./resources/installer.nsh", import.meta.url)).text() + + const covered = new Set( + script + .split("\n") + .map((line) => /^LangString\s+freshInstallForCurrent\s+(\d+)\s+""\s*$/.exec(line)?.[1]) + .filter((id) => id !== undefined), + ) + + expect([...bundledLanguages].filter((lang) => !covered.has(String(lcid[lang])))).toEqual([]) + expect(covered.size).toBe(bundledLanguages.length) +}) + // 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 cbef75006e..2241f05053 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -216,6 +216,25 @@ const getBase = (appId: string): Configuration => ({ // that on everyone. See the Windows install location notes in // DIVERGENCES.md for why per-user is the default. perMachine: false, + // Just the product name in Apps & Features. The default is + // "${productName} ${version}", which is the only place Windows shows a + // version twice — the list already has a Version column, fed by the + // DisplayVersion value the installer writes separately, so the suffix is + // duplication that also goes stale-looking between releases. + uninstallDisplayName: "${productName}", + // The price of resources/installer.nsh overriding a built-in string. + // Redefining a LangString is the only hook there is, and NSIS answers it + // with "warning 6030: LangString ... set multiple times, wasting space" — + // 26 of them, one per bundled language — which makensis's default -WX + // turns into a failed build. + // + // Nothing else warned before the override (this build was clean under -WX), + // so no existing warning is being hidden. But note what dropping the flag + // costs: checkMakensisOutput only rethrows on "Error:" lines and never + // prints makensis output on a successful run, so a NEW warning becomes + // both non-fatal and invisible. To audit for one, flip this back to true + // and rebuild — every warning then fails the build and is printed. + warningsAsErrors: false, installerIcon: `resources/icons/icon.ico`, // No installerHeaderIcon: electron-builder reads it only for one-click // installers. The assisted wizard takes installerHeader/installerSidebar diff --git a/packages/desktop/resources/installer.nsh b/packages/desktop/resources/installer.nsh new file mode 100644 index 0000000000..80cb5b9cf2 --- /dev/null +++ b/packages/desktop/resources/installer.nsh @@ -0,0 +1,46 @@ +# Picked up automatically by electron-builder: NsisTarget resolves +# `nsis.include` and falls back to /installer.nsh, and includes +# it *after* its own messages.yml/assistedMessages.yml language files. Later +# LangString definitions win, which is the only hook for changing a built-in +# installer string — addCustomMessageFileInclude reads those YAML files from +# app-builder-lib's own template directory and never from the project. +# +# Blanks the install-mode page's description line. On a first install it read +# "Fresh install for current user only." under radio buttons that already say +# "Only for me ()" — the same fact twice, in a smaller font. The +# all-users line is left alone: "Fresh install for all users. (will prompt for +# admin credentials)" tells the user something the radio does not, namely that +# choosing it raises a UAC prompt. +# +# One definition per bundled language: multiLanguageInstaller is on by default, +# so app-builder-lib emits this string for all 26 languages in +# util/langs.ts#bundledLanguages, and an override for English alone would leave +# the other 25 showing it. Numeric LCIDs rather than ${LANG_*} constants, for +# the same reason electron-builder uses them — they do not require the language +# to have been inserted yet. +LangString freshInstallForCurrent 1033 "" +LangString freshInstallForCurrent 1031 "" +LangString freshInstallForCurrent 1036 "" +LangString freshInstallForCurrent 3082 "" +LangString freshInstallForCurrent 2052 "" +LangString freshInstallForCurrent 1028 "" +LangString freshInstallForCurrent 1041 "" +LangString freshInstallForCurrent 1042 "" +LangString freshInstallForCurrent 1040 "" +LangString freshInstallForCurrent 1043 "" +LangString freshInstallForCurrent 1030 "" +LangString freshInstallForCurrent 1053 "" +LangString freshInstallForCurrent 1044 "" +LangString freshInstallForCurrent 1035 "" +LangString freshInstallForCurrent 1049 "" +LangString freshInstallForCurrent 2070 "" +LangString freshInstallForCurrent 1046 "" +LangString freshInstallForCurrent 1045 "" +LangString freshInstallForCurrent 1058 "" +LangString freshInstallForCurrent 1029 "" +LangString freshInstallForCurrent 1051 "" +LangString freshInstallForCurrent 1038 "" +LangString freshInstallForCurrent 1025 "" +LangString freshInstallForCurrent 1055 "" +LangString freshInstallForCurrent 1054 "" +LangString freshInstallForCurrent 1066 ""