Skip to content

Commit ee0ba93

Browse files
Quickbeardclaude
andcommitted
fix(desktop): give the dev app an icon on linux
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. An unpackaged `bun dev:desktop` therefore showed the generic executable icon, while macOS (app.dock.setIcon) and Windows (the HWND icon) were unaffected. Electron slugifies package.json `productName ?? name` into that app_id before main runs — `@opencode-ai/desktop` becomes `opencode-ai-desktop`, which matches nothing installed. app.setName(), --class and CHROME_DESKTOP were all measured (WAYLAND_DEBUG=1, reading xdg_toplevel.set_app_id) not to override it, so predev writes the matching entry instead. Packaged builds need nothing: a linux deb announces set_app_id("ai.opencode.desktop.dev"), which already agrees with the desktop file, StartupWMClass and hicolor icon electron-builder installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f39a808 commit ee0ba93

4 files changed

Lines changed: 86 additions & 1 deletion

File tree

‎DIVERGENCES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ from the squash message first, or use a plain merge.
195195
| `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. |
196196
| `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 `<link rel="icon">`. | 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. |
197197
| `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. |
198-
| `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. |
198+
| `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. |
199199
| `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. |
200200
| `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. |
201201
| `README.md` | Replaced with fork README | Fork identity, sync + divergence policy. | Keep ours entirely. |
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { electronAppId } from "./install-dev-desktop-entry"
3+
4+
describe("electronAppId", () => {
5+
// Measured against electron 42 on wayland (WAYLAND_DEBUG=1, reading
6+
// xdg_toplevel.set_app_id): these are the exact slugs it announces.
7+
test("slugifies the package name the way electron does", () => {
8+
expect(electronAppId({ name: "@opencode-ai/desktop" })).toBe("opencode-ai-desktop")
9+
expect(electronAppId({ name: "ai.opencode.desktop.dev" })).toBe("ai-opencode-desktop-dev")
10+
expect(electronAppId({ name: "appid-probe" })).toBe("appid-probe")
11+
})
12+
13+
test("prefers productName", () => {
14+
expect(electronAppId({ productName: "CoDev Code Dev", name: "@opencode-ai/desktop" })).toBe("codev-code-dev")
15+
})
16+
17+
test("rejects a manifest with nothing to slugify", () => {
18+
expect(() => electronAppId({ name: "///" })).toThrow()
19+
})
20+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Fork: gives the unpackaged dev app an icon on Linux desktops.
2+
//
3+
// BrowserWindow's `icon` reaches the shell through X11's _NET_WM_ICON, which
4+
// Wayland has no equivalent for (xdg_toplevel_icon_v1 is new and Chromium does
5+
// not speak it). Wayland shells instead resolve a window's icon by matching its
6+
// app_id to an installed .desktop file, so on GNOME/Wayland an unpackaged run
7+
// falls back to the generic executable icon — while macOS (app.dock.setIcon)
8+
// and Windows (the HWND icon) are unaffected.
9+
//
10+
// Packaged builds ship that .desktop file via electron-builder; this writes the
11+
// dev equivalent into the user's applications dir. Idempotent, so predev can
12+
// run it on every launch.
13+
import { mkdir } from "node:fs/promises"
14+
import { homedir } from "node:os"
15+
import { join, resolve } from "node:path"
16+
import { resolveChannel } from "./utils"
17+
18+
const CHANNEL_NAMES = { dev: "CoDev Code Dev", beta: "CoDev Code Beta", prod: "CoDev Code" } as const
19+
20+
// Electron announces a slug of the app's package.json productName (falling back
21+
// to name) as the Wayland app_id and the X11 WM_CLASS: lowercased, with every
22+
// run of non-alphanumerics collapsed to a dash. Neither app.setName() nor
23+
// --class nor CHROME_DESKTOP overrides it — it is read before main runs — so
24+
// the entry has to be named after whatever the manifest yields.
25+
export function electronAppId(manifest: { productName?: string; name?: string }) {
26+
const source = manifest.productName ?? manifest.name ?? ""
27+
const slug = source
28+
.toLowerCase()
29+
.replace(/[^a-z0-9]+/g, "-")
30+
.replace(/^-+|-+$/g, "")
31+
if (!slug.length) throw new Error("could not derive an app id from the desktop package manifest")
32+
return slug
33+
}
34+
35+
// import.meta.main keeps importing this module (the test does) side-effect free.
36+
if (import.meta.main && process.platform === "linux") {
37+
const packageDir = resolve(import.meta.dir, "..")
38+
const appId = electronAppId(await Bun.file(join(packageDir, "package.json")).json())
39+
const applications = join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "applications")
40+
await mkdir(applications, { recursive: true })
41+
const entry = join(applications, `${appId}.desktop`)
42+
// NoDisplay keeps it out of the launcher — it exists only so the shell can
43+
// associate a running dev window with a name and an icon. StartupWMClass
44+
// covers the X11 side, where the match is on WM_CLASS rather than the
45+
// filename. The icon is the channel copy predev already staged, so this
46+
// tracks whichever channel was last built.
47+
await Bun.write(
48+
entry,
49+
`[Desktop Entry]
50+
Type=Application
51+
Name=${CHANNEL_NAMES[resolveChannel()]}
52+
Comment=Open source AI coding agent (development build)
53+
Icon=${join(packageDir, "resources", "icons", "icon.png")}
54+
Exec=bun --cwd ${packageDir} dev
55+
StartupWMClass=${appId}
56+
NoDisplay=true
57+
Categories=Development;
58+
`,
59+
)
60+
console.log(`Installed dev desktop entry for app id ${appId} at ${entry}`)
61+
}

‎packages/desktop/scripts/predev.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ await $`bun run install-electron`
88

99
await $`bun ./scripts/copy-icons.ts ${process.env.CODEV_CHANNEL ?? "dev"}`
1010

11+
// Must follow copy-icons: the entry points at the icon it stages. Linux-only,
12+
// and a no-op everywhere else.
13+
await $`bun ./scripts/install-dev-desktop-entry.ts`
14+
1115
await $`cd ../opencode && bun script/build-node.ts`

0 commit comments

Comments
 (0)