diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index e0052557b3e..353dd402d96 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -88,6 +88,22 @@ test("legacy Linux launcher stays hidden", async () => { expect(desktop).toContain("NoDisplay=true") }) +test.each(channels)("macOS icon format for $channel", ({ channel }) => { + const config = load(channel) + expect(config.mac?.icon).toBe(channel === "prod" ? "resources/icons/icon.icon" : "resources/icons/icon.icns") + expect(config.dmg?.icon).toBe(channel === "prod" ? "resources/icons/icon.icns" : undefined) + // The prod override must keep the rest of the shared macOS settings. + expect(config.mac?.target).toEqual(["dmg", "zip"]) + expect(config.mac?.extendInfo?.NSMicrophoneUsageDescription).toBeTruthy() +}) + +test("the production Icon Composer package references its bundled artwork", async () => { + const icon = await Bun.file(path.join(import.meta.dirname, "icons/prod/icon.icon/icon.json")).json() + expect(icon["supported-platforms"]).toEqual({ squares: ["macOS"] }) + const image = icon.groups[0].layers[0]["image-name"] + expect(await Bun.file(path.join(import.meta.dirname, "icons/prod/icon.icon/Assets", image)).exists()).toBe(true) +}) + test("shared packaging defaults", async () => { const config = load("dev") // Packaging runs in secretless build jobs; the release workflow signs and notarizes afterwards. diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 7c090083cba..5d4270cdf08 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -1,4 +1,4 @@ -import { readdir, stat } from "node:fs/promises" +import { copyFile, readdir, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" @@ -126,6 +126,11 @@ const getBase = (appId: string): Configuration => { if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`) + // With an Icon Composer mac.icon, electron-builder bundles the ICNS that actool generates as the pre-macOS 26 + // fallback. That one tops out at 256px and draws a light ring around the squircle, so ship ours instead. + if (context.electronPlatformName === "darwin") + await copyFile(path.join(packageDir, "resources/icons/icon.icns"), path.join(resources, "icon.icns")) + if (context.electronPlatformName !== "win32") return const prebuilds = path.join( @@ -245,6 +250,11 @@ export function getConfig() { appId, productName: "OpenCode", protocols: { name: "OpenCode", schemes: ["opencode"] }, + // An Icon Composer package compiles to Assets.car, which macOS 26 and later draw the app icon from + // (Dock, Finder, system dialogs and the system icon styles). Older macOS keeps using the ICNS. + mac: { ...base.mac, icon: "resources/icons/icon.icon" }, + // The DMG volume icon would otherwise use the same actool-generated ICNS. + dmg: { icon: "resources/icons/icon.icns" }, publish: { provider: "generic", url: "https://opencode.ai/update/api/latest/desktop/opencode/", diff --git a/packages/desktop/icons/prod/icon.icon/Assets/icon.png b/packages/desktop/icons/prod/icon.icon/Assets/icon.png new file mode 100644 index 00000000000..11fa40f03f6 Binary files /dev/null and b/packages/desktop/icons/prod/icon.icon/Assets/icon.png differ diff --git a/packages/desktop/icons/prod/icon.icon/icon.json b/packages/desktop/icons/prod/icon.icon/icon.json new file mode 100644 index 00000000000..3845b88ea9e --- /dev/null +++ b/packages/desktop/icons/prod/icon.icon/icon.json @@ -0,0 +1,25 @@ +{ + "groups": [ + { + "name": "OpenCode", + "layers": [ + { + "name": "OpenCode", + "image-name": "icon.png", + "glass": false + } + ], + "shadow": { + "kind": "none", + "opacity": 0 + }, + "translucency": { + "enabled": false, + "value": 0.5 + } + } + ], + "supported-platforms": { + "squares": ["macOS"] + } +}