fix: ship multi-size app icons for Windows and macOS (#16352)

This commit is contained in:
Hiep Le
2026-08-06 18:25:57 +07:00
committed by GitHub
parent 82bdd88269
commit 31b94014e0
9 changed files with 242 additions and 15 deletions
+5
View File
@@ -1 +1,6 @@
* text=auto eol=lf
# Icon binaries — never text-normalize (a mangled byte corrupts the file).
*.png binary
*.ico binary
*.icns binary
+83
View File
@@ -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");
});
});
+10 -6
View File
@@ -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: {
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+21 -8
View File
@@ -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 <repo>/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 ────────────────────────────────────────────────────────────────
+11
View File
@@ -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",
+3 -1
View File
@@ -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",
+109
View File
@@ -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)`,
);
}