diff --git a/.gitattributes b/.gitattributes index 6313b56c57..a5b6975d66 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,6 @@ * text=auto eol=lf + +# Icon binaries — never text-normalize (a mangled byte corrupts the file). +*.png binary +*.ico binary +*.icns binary diff --git a/__tests__/scripts/desktop-icons.test.ts b/__tests__/scripts/desktop-icons.test.ts new file mode 100644 index 0000000000..5fd84bc14e --- /dev/null +++ b/__tests__/scripts/desktop-icons.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +// The desktop app icons are COMMITTED artifacts (electron/build-resources/ +// icon.ico + icon.icns) that electron-builder auto-discovers and uses as-is. +// We ship them instead of relying on electron-builder's PNG→ICO conversion +// because that conversion emits a single 256×256 PNG-compressed entry which +// several Windows shell surfaces can't render — the app then shows the +// default Electron icon. These tests guard the structural contract of the +// committed files (standard sizes, BMP small entries), that they stay in +// sync with the icon.png master, and that the builder config keeps shipping +// the runtime window icons into the packaged app. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import config from "../../electron-builder.config.mjs"; +import { + REQUIRED_ICNS_TYPES, + REQUIRED_ICO_SIZES, + generateIcons, + listIcnsTypes, + listIcoEntries, +} from "../../scripts/generate-icons.mjs"; + +const resDir = fileURLToPath( + new URL("../../electron/build-resources", import.meta.url), +); +const committedPng = readFileSync(join(resDir, "icon.png")); +const committedIco = readFileSync(join(resDir, "icon.ico")); +const committedIcns = readFileSync(join(resDir, "icon.icns")); + +describe("committed desktop icon artifacts", () => { + it("icon.ico contains every standard Windows size", () => { + const sizes = listIcoEntries(committedIco).map((entry) => entry.size); + for (const size of REQUIRED_ICO_SIZES) { + expect(sizes, `missing ${size}px entry`).toContain(size); + } + }); + + it("icon.ico stores 48px-and-below entries as classic BMP, not PNG", () => { + // The root cause of the Windows default-icon bug: small entries stored + // as PNG-compressed data, which Explorer/NSIS/taskbar can't render. + const smallEntries = listIcoEntries(committedIco).filter( + (entry) => entry.size <= 48, + ); + expect(smallEntries.length).toBeGreaterThan(0); + for (const entry of smallEntries) { + expect(entry.isPng, `${entry.size}px entry must be BMP`).toBe(false); + } + }); + + it("icon.icns contains all ten standard macOS representations", () => { + const types = listIcnsTypes(committedIcns); + for (const type of REQUIRED_ICNS_TYPES) { + expect(types, `missing ${type} representation`).toContain(type); + } + }); + + it("stays in sync with the icon.png master (npm run generate-icons)", () => { + // Fails when icon.png changes without regenerating the committed + // artifacts — generation is deterministic, so bytes must match exactly. + const { ico, icns } = generateIcons(committedPng); + expect(ico.equals(committedIco)).toBe(true); + expect(icns.equals(committedIcns)).toBe(true); + }); +}); + +describe("electron-builder icon wiring", () => { + it("points buildResources at the directory holding the committed icons", () => { + // Auto-discovery contract: icon.icns (mac), icon.ico (win) and icon.png + // (linux) are all picked up from directories.buildResources by name. + expect(config.directories?.buildResources).toBe( + "electron/build-resources", + ); + }); + + it("ships both runtime window icons into the packaged app", () => { + // buildResources is auto-excluded from packaging, so main.mjs's + // BrowserWindow icons (icon.ico on win32, icon.png elsewhere) need + // explicit re-includes to exist on disk at runtime. + expect(config.files).toContain("build-resources/icon.png"); + expect(config.files).toContain("build-resources/icon.ico"); + }); +}); diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index 4b84f79e4e..ed40b4ad05 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -219,8 +219,9 @@ const config = { // Treat electron/ as the app root. electron/package.json provides the // Electron entry point without touching the npm-published root package.json. // `buildResources` points at electron/build-resources so electron-builder - // can auto-discover icon.png (1024×1024 OpenHands raised-hands app icon) - // and generate the platform-specific icon.icns / icon.ico from it. + // can auto-discover the committed icon.icns / icon.ico (generated from the + // 1024×1024 icon.png master via `npm run generate-icons`) and, for Linux, + // icon.png itself. directories: { app: "electron", output: "dist-electron", @@ -247,6 +248,8 @@ const config = { // main.mjs can set it as the BrowserWindow icon at runtime (used for the // Linux taskbar; macOS reads from the .icns inside the .app bundle). "build-resources/icon.png", + // Windows runtime BrowserWindow icon (main.mjs picks .ico on win32). + "build-resources/icon.ico", // Scripts from project root. Mostly Node built-ins; the two spawned // servers additionally need RUNTIME_PACKAGES, restored into // Resources/app/node_modules by the afterPack hook. @@ -304,8 +307,8 @@ const config = { ], }, ], - // Icon auto-discovered from directories.buildResources/icon.png - // (electron-builder generates icon.icns from the 1024×1024 PNG). + // Icon auto-discovered from directories.buildResources/icon.icns + // (committed; regenerate with `npm run generate-icons`). }, dmg: { @@ -324,8 +327,9 @@ const config = { // ── Windows ──────────────────────────────────────────────────────────────── win: { target: [{ target: "nsis", arch: ["x64"] }], - // Icon auto-discovered from directories.buildResources/icon.png - // (electron-builder generates icon.ico from the 1024×1024 PNG). + // Icon auto-discovered from directories.buildResources/icon.ico + // (committed; regenerate with `npm run generate-icons`). Also used for + // the NSIS installer/uninstaller and the rcedit exe icon resource. }, nsis: { diff --git a/electron/build-resources/icon.icns b/electron/build-resources/icon.icns new file mode 100644 index 0000000000..15bf1af745 Binary files /dev/null and b/electron/build-resources/icon.icns differ diff --git a/electron/build-resources/icon.ico b/electron/build-resources/icon.ico new file mode 100644 index 0000000000..dcbe4a8856 Binary files /dev/null and b/electron/build-resources/icon.ico differ diff --git a/electron/main.mjs b/electron/main.mjs index 3f2e5eb6f4..91b677f299 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -54,14 +54,27 @@ const projectRoot = app.isPackaged ? __dirname : join(__dirname, ".."); const buildDir = join(projectRoot, "build"); const scriptsDir = join(projectRoot, "scripts"); -// 1024×1024 OpenHands raised-hands app icon. Used as the BrowserWindow.icon -// option for the Linux taskbar and the Windows title bar / taskbar. On macOS -// the dock icon comes from the .app bundle's icon.icns (generated by -// electron-builder from this same PNG), so this path is unused there. -// In dev mode (`npm run desktop`), __dirname is /electron and the file -// lives next to main.mjs. In a packaged build, electron-builder copies -// build-resources/icon.png into Resources/app/ via the `files:` array. -const appIconPath = join(__dirname, "build-resources", "icon.png"); +// OpenHands raised-hands app icon, used as the BrowserWindow.icon option. +// Windows gets the multi-size icon.ico (16→256, small sizes as classic BMP +// entries — the Windows shell needs those); Linux uses the 1024×1024 PNG +// for its taskbar. On macOS the dock icon comes from the .app bundle's +// icon.icns, so this path is unused there. Both files live next to main.mjs +// in dev and are copied into Resources/app/build-resources/ via the +// `files:` array. Regenerate with `npm run generate-icons`. +const appIconPath = join( + __dirname, + "build-resources", + process.platform === "win32" ? "icon.ico" : "icon.png", +); + +// electron-builder's NSIS shortcuts are stamped with AppUserModelId +// ${APP_ID} (WinShell::SetLnkAUMI in installer.nsh). Declare the same id so +// running/pinned taskbar entries group with the shortcut and inherit its +// icon. Must match appId in electron-builder.config.mjs, and must be set +// before any BrowserWindow is created. +if (process.platform === "win32") { + app.setAppUserModelId("dev.openhands.agent-canvas"); +} // ── Bundled uv ──────────────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json index 3f206b9d2c..58b19ae82c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,7 @@ "jsdom": "30.0.0", "lint-staged": "16.4.0", "msw": "2.15.0", + "png2icons": "2.0.1", "postcss-prefix-selector": "2.1.1", "prettier": "3.8.3", "tailwindcss": "4.3.3", @@ -17746,6 +17747,16 @@ "node": ">=10.4.0" } }, + "node_modules/png2icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/png2icons/-/png2icons-2.0.1.tgz", + "integrity": "sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA==", + "dev": true, + "license": "MIT", + "bin": { + "png2icons": "png2icons-cli.js" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 903a6dc524..d4848d712c 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,8 @@ "build:desktop": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && electron-builder --config electron-builder.config.mjs --publish never", "build:desktop:universal": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && cross-env ELECTRON_ARCH=universal electron-builder --config electron-builder.config.mjs --publish never", "download-uv": "node scripts/download-uv.mjs", - "download-node": "node scripts/download-node.mjs" + "download-node": "node scripts/download-node.mjs", + "generate-icons": "node scripts/generate-icons.mjs" }, "lint-staged": { "src/**/*.{ts,tsx,js}": [ @@ -163,6 +164,7 @@ "jsdom": "30.0.0", "lint-staged": "16.4.0", "msw": "2.15.0", + "png2icons": "2.0.1", "postcss-prefix-selector": "2.1.1", "prettier": "3.8.3", "tailwindcss": "4.3.3", diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000000..fc059c370d --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,109 @@ +/** + * Generate electron/build-resources/icon.ico and icon.icns from the + * 1024×1024 icon.png master. Run after changing icon.png: + * + * npm run generate-icons + * + * The outputs are COMMITTED — electron-builder auto-discovers them in + * directories.buildResources and uses them as-is. We don't rely on + * electron-builder's own PNG→ICO conversion because it emits a single + * 256×256 PNG-compressed entry, which several Windows shell surfaces + * (Explorer small views, NSIS, taskbar) can't render — they fall back to + * the default Electron icon. png2icons with forWinExe=true stores the + * 48/32/24/16 entries as classic BMP, which is what Windows expects. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import png2icons from "png2icons"; + +// Standard Windows app-icon sizes (Microsoft recommends 16/24/32/48/256; +// 64/128 cover intermediate DPI scaling). +export const REQUIRED_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]; + +// The ten standard macOS representations (16 → 512@2x). is32/il32 (+ the +// l8mk/s8mk masks png2icons emits alongside) are the legacy non-retina +// 16/32 forms; ic07–ic14 cover 128 → 512@2x. +export const REQUIRED_ICNS_TYPES = [ + "is32", + "il32", + "ic07", + "ic08", + "ic09", + "ic10", + "ic11", + "ic12", + "ic13", + "ic14", +]; + +/** + * Parse an ICO buffer's ICONDIR into [{size, isPng}] — isPng distinguishes + * PNG-compressed payloads from classic BMP entries. + */ +export function listIcoEntries(ico) { + const entries = []; + for (let i = 0; i < ico.readUInt16LE(4); i++) { + const entry = 6 + i * 16; + const width = ico[entry]; + const offset = ico.readUInt32LE(entry + 12); + entries.push({ + size: width === 0 ? 256 : width, + isPng: ico.readUInt32BE(offset) === 0x89504e47, // \x89PNG + }); + } + return entries; +} + +/** List the 4-char OSType of every chunk in an ICNS buffer. */ +export function listIcnsTypes(icns) { + const types = []; + for (let p = 8; p < icns.length; p += icns.readUInt32BE(p + 4)) { + types.push(icns.toString("ascii", p, p + 4)); + } + return types; +} + +/** + * Convert a PNG buffer into { ico, icns } buffers, asserting the emitted + * size sets so a png2icons upgrade that changes them fails loudly. + */ +export function generateIcons(input) { + // forWinExe: true → 48/32/24/16 stored as classic BMP entries (required + // by Windows shell small-icon surfaces), ≥64 PNG-compressed. 0 = lossless. + const ico = png2icons.createICO(input, png2icons.BICUBIC2, 0, false, true); + const icns = png2icons.createICNS(input, png2icons.BICUBIC2, 0); + if (!ico || !icns) throw new Error("png2icons failed to convert icon.png"); + + const icoSizes = listIcoEntries(ico).map((entry) => entry.size); + for (const size of REQUIRED_ICO_SIZES) { + if (!icoSizes.includes(size)) throw new Error(`icon.ico missing ${size}px`); + } + const icnsTypes = listIcnsTypes(icns); + for (const type of REQUIRED_ICNS_TYPES) { + if (!icnsTypes.includes(type)) throw new Error(`icon.icns missing ${type}`); + } + + return { ico, icns }; +} + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + const resDir = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "electron", + "build-resources", + ); + const { ico, icns } = generateIcons(readFileSync(join(resDir, "icon.png"))); + writeFileSync(join(resDir, "icon.ico"), ico); + writeFileSync(join(resDir, "icon.icns"), icns); + console.log( + `icon.ico (${listIcoEntries(ico) + .map((entry) => entry.size) + .join("/")}px, ${ico.length} B), ` + + `icon.icns (${listIcnsTypes(icns).join(",")}, ${icns.length} B)`, + ); +}