diff --git a/DIVERGENCES.md b/DIVERGENCES.md index e7c4789f2c..8342c56268 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -195,7 +195,7 @@ from the squash message first, or use a plain merge. | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | | `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. | | `packages/desktop/scripts/prebuild.ts` | On macOS, after the node-sidecar build, runs `bun script/build-vzhelper.ts --universal` in `packages/codev-sandbox` so `dist/codev-vmm` exists (universal) before electron-builder copies it via `mac.extraResources`. | The sandbox vz backend spawns the helper from `process.resourcesPath/codev-vmm` in packaged builds; universal keeps one already-fat Mach-O valid for `--arm64`, `--x64`, and `--universal` packaging (an identical thin binary in both halves would trip `@electron/universal`'s identical-file check). | Keep the darwin-gated build line; take upstream's other steps. It must stay after nothing in particular but before packaging — prebuild is the hook `package:*` runs. | -| `packages/desktop/scripts/predev.ts` | Runs `bun run install-electron` before the existing icon copy and node-sidecar build. | `bun dev:desktop` was dead on a fresh clone: Electron 42 removed the `postinstall` hook that downloaded the platform binary and exposes it as an `install-electron` bin the consumer must call (electron 41's manifest still had `"postinstall": "node install.js"`; 42.3.3's has no `scripts` field at all), so `bun install` leaves `node_modules/electron` with no `dist/` and `electron-vite dev` aborts with `Error: Electron uninstall` from `getElectronPath`. `predev` is the right hook rather than the root `postinstall`: only someone actually launching the desktop app pays the ~100 MB download, and `install.js` short-circuits once `dist/version` and `path.txt` match, so repeat runs cost nothing. Not added to `prebuild`/`package` — `electron-vite build` never spawns Electron, and `electron-builder` fetches its own copy through `@electron/get`. | Keep our installer line at the top; take upstream's other steps. Drop the line if upstream adds its own binary-install step or pins Electron back below 42. | +| `packages/desktop/scripts/predev.ts` | Runs `bun run install-electron` before the existing icon copy and node-sidecar build, and `bun ./scripts/install-dev-desktop-entry.ts` (fork-only) after it. | `bun dev:desktop` was dead on a fresh clone: Electron 42 removed the `postinstall` hook that downloaded the platform binary and exposes it as an `install-electron` bin the consumer must call (electron 41's manifest still had `"postinstall": "node install.js"`; 42.3.3's has no `scripts` field at all), so `bun install` leaves `node_modules/electron` with no `dist/` and `electron-vite dev` aborts with `Error: Electron uninstall` from `getElectronPath`. `predev` is the right hook rather than the root `postinstall`: only someone actually launching the desktop app pays the ~100 MB download, and `install.js` short-circuits once `dist/version` and `path.txt` match, so repeat runs cost nothing. Not added to `prebuild`/`package` — `electron-vite build` never spawns Electron, and `electron-builder` fetches its own copy through `@electron/get`. The desktop-entry line fixes the dev app's Linux icon: `BrowserWindow`'s `icon` only reaches the shell through X11's `_NET_WM_ICON`, and Electron 42 runs as a native Wayland client on GNOME, where the icon comes from the .desktop file matching the window's app_id — so an unpackaged run shows the generic executable icon while macOS (`app.dock.setIcon`) and Windows (the HWND icon) are fine. Electron slugifies package.json `productName ?? name` into that app_id before main runs (`@opencode-ai/desktop` → `opencode-ai-desktop`); `app.setName()`, `--class` and `CHROME_DESKTOP` were all measured not to override it, so the script writes the matching entry instead. | Keep our installer line at the top; take upstream's other steps. Drop the line if upstream adds its own binary-install step or pins Electron back below 42. Keep the desktop-entry line after `copy-icons` — the entry points at the icon that step stages — and drop it only if the desktop package's `name`/`productName` stops deciding the app_id. | | `packages/ui/src/assets/favicon/` (favicon-v3.svg, favicon-v3.ico, site.webmanifest, favicon-96x96-v3.png, apple-touch-icon-v3.png, web-app-manifest-192/512.png) | New codev mark: the blocky "C" in brand red `#ee0033` on a white tile, generated from the same geometry as the app icon by `packages/desktop/scripts/generate-icons.ts` (4x5 grid, one-unit stroke, unit = 120/1024, corner radius 229/1024); `favicon-v3.svg` is the hand-written 512-unit source of that geometry. The tab and `.ico` icons keep the rounded tile so they read as an app icon; `apple-touch-icon-v3.png` and the two maskable `web-app-manifest-*` icons are square and full-bleed, because iOS and PWA launchers apply their own mask and a pre-rounded icon leaves transparent corners that render black. Manifest name "CoDev Code"/"codev"; its `theme_color`/`background_color` stay `#080808` — those describe the PWA's chrome and splash, not the icon. **Was `#00cdcd` on a dark tile** until this row was rewritten: PR #10 pinned the favicon to the then-cyan TUI banner accent, that banner later moved to the navy/red wordmark, and the favicon was left behind as the only cyan in the tree — visibly so on desktop, where the dock icon was red while the window and notification icons were cyan. | Every surface that draws the mark should draw the same mark: the dock icon, the tab, notifications, the `codev --mini` badge and the landing page icon are now one geometry in one red. Cyan on `#131010` was the higher-contrast tile at 16px (~9.4:1 versus red's ~4.2:1) — a real cost, accepted so the brand reads consistently. | Keep our assets (binary: take ours wholesale). Regenerate with the script rather than hand-editing, and edit `icons/icon.svg` + `favicon-v3.svg` together if the geometry ever changes. | | `AGENTS.md` | Default-branch note: `dev` → `main` (single merged line; upstream has two lines saying local `main` may not exist); added fork bullets requiring every upstream-file change to get a `DIVERGENCES.md` row (and matching en + es doc updates) in the same PR, and pointing agents at this file's Divergence policy (prefer config/plugins, then `packages/codev-*`, upstream edits last) and Syncing with upstream sections | The fork's default branch is `main` (see the workflow-triggers row); agents kept basing diffs on a nonexistent `dev`. Agents edit upstream files without recording the divergence — and don't apply the placement policy or sync runbook — unless the instructions file says to. | Keep our lines; take upstream's other guidance. | | `README.md` | Replaced with fork README | Fork identity, sync + divergence policy. | Keep ours entirely. | diff --git a/packages/desktop/scripts/install-dev-desktop-entry.test.ts b/packages/desktop/scripts/install-dev-desktop-entry.test.ts new file mode 100644 index 0000000000..c7b0adc1f2 --- /dev/null +++ b/packages/desktop/scripts/install-dev-desktop-entry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { electronAppId } from "./install-dev-desktop-entry" + +describe("electronAppId", () => { + // Measured against electron 42 on wayland (WAYLAND_DEBUG=1, reading + // xdg_toplevel.set_app_id): these are the exact slugs it announces. + test("slugifies the package name the way electron does", () => { + expect(electronAppId({ name: "@opencode-ai/desktop" })).toBe("opencode-ai-desktop") + expect(electronAppId({ name: "ai.opencode.desktop.dev" })).toBe("ai-opencode-desktop-dev") + expect(electronAppId({ name: "appid-probe" })).toBe("appid-probe") + }) + + test("prefers productName", () => { + expect(electronAppId({ productName: "CoDev Code Dev", name: "@opencode-ai/desktop" })).toBe("codev-code-dev") + }) + + test("rejects a manifest with nothing to slugify", () => { + expect(() => electronAppId({ name: "///" })).toThrow() + }) +}) diff --git a/packages/desktop/scripts/install-dev-desktop-entry.ts b/packages/desktop/scripts/install-dev-desktop-entry.ts new file mode 100644 index 0000000000..6405a921fc --- /dev/null +++ b/packages/desktop/scripts/install-dev-desktop-entry.ts @@ -0,0 +1,61 @@ +// Fork: gives the unpackaged dev app an icon on Linux desktops. +// +// BrowserWindow's `icon` reaches the shell through X11's _NET_WM_ICON, which +// Wayland has no equivalent for (xdg_toplevel_icon_v1 is new and Chromium does +// not speak it). Wayland shells instead resolve a window's icon by matching its +// app_id to an installed .desktop file, so on GNOME/Wayland an unpackaged run +// falls back to the generic executable icon — while macOS (app.dock.setIcon) +// and Windows (the HWND icon) are unaffected. +// +// Packaged builds ship that .desktop file via electron-builder; this writes the +// dev equivalent into the user's applications dir. Idempotent, so predev can +// run it on every launch. +import { mkdir } from "node:fs/promises" +import { homedir } from "node:os" +import { join, resolve } from "node:path" +import { resolveChannel } from "./utils" + +const CHANNEL_NAMES = { dev: "CoDev Code Dev", beta: "CoDev Code Beta", prod: "CoDev Code" } as const + +// Electron announces a slug of the app's package.json productName (falling back +// to name) as the Wayland app_id and the X11 WM_CLASS: lowercased, with every +// run of non-alphanumerics collapsed to a dash. Neither app.setName() nor +// --class nor CHROME_DESKTOP overrides it — it is read before main runs — so +// the entry has to be named after whatever the manifest yields. +export function electronAppId(manifest: { productName?: string; name?: string }) { + const source = manifest.productName ?? manifest.name ?? "" + const slug = source + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + if (!slug.length) throw new Error("could not derive an app id from the desktop package manifest") + return slug +} + +// import.meta.main keeps importing this module (the test does) side-effect free. +if (import.meta.main && process.platform === "linux") { + const packageDir = resolve(import.meta.dir, "..") + const appId = electronAppId(await Bun.file(join(packageDir, "package.json")).json()) + const applications = join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "applications") + await mkdir(applications, { recursive: true }) + const entry = join(applications, `${appId}.desktop`) + // NoDisplay keeps it out of the launcher — it exists only so the shell can + // associate a running dev window with a name and an icon. StartupWMClass + // covers the X11 side, where the match is on WM_CLASS rather than the + // filename. The icon is the channel copy predev already staged, so this + // tracks whichever channel was last built. + await Bun.write( + entry, + `[Desktop Entry] +Type=Application +Name=${CHANNEL_NAMES[resolveChannel()]} +Comment=Open source AI coding agent (development build) +Icon=${join(packageDir, "resources", "icons", "icon.png")} +Exec=bun --cwd ${packageDir} dev +StartupWMClass=${appId} +NoDisplay=true +Categories=Development; +`, + ) + console.log(`Installed dev desktop entry for app id ${appId} at ${entry}`) +} diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 90b22de5bc..6f065f7bee 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -8,4 +8,8 @@ await $`bun run install-electron` await $`bun ./scripts/copy-icons.ts ${process.env.CODEV_CHANNEL ?? "dev"}` +// Must follow copy-icons: the entry points at the icon it stages. Linux-only, +// and a no-op everywhere else. +await $`bun ./scripts/install-dev-desktop-entry.ts` + await $`cd ../opencode && bun script/build-node.ts`