mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 14:33:11 +08:00
fix: ship multi-size app icons for Windows and macOS (#16352)
This commit is contained in:
@@ -1 +1,6 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Icon binaries — never text-normalize (a mangled byte corrupts the file).
|
||||||
|
*.png binary
|
||||||
|
*.ico binary
|
||||||
|
*.icns binary
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -219,8 +219,9 @@ const config = {
|
|||||||
// Treat electron/ as the app root. electron/package.json provides the
|
// Treat electron/ as the app root. electron/package.json provides the
|
||||||
// Electron entry point without touching the npm-published root package.json.
|
// Electron entry point without touching the npm-published root package.json.
|
||||||
// `buildResources` points at electron/build-resources so electron-builder
|
// `buildResources` points at electron/build-resources so electron-builder
|
||||||
// can auto-discover icon.png (1024×1024 OpenHands raised-hands app icon)
|
// can auto-discover the committed icon.icns / icon.ico (generated from the
|
||||||
// and generate the platform-specific icon.icns / icon.ico from it.
|
// 1024×1024 icon.png master via `npm run generate-icons`) and, for Linux,
|
||||||
|
// icon.png itself.
|
||||||
directories: {
|
directories: {
|
||||||
app: "electron",
|
app: "electron",
|
||||||
output: "dist-electron",
|
output: "dist-electron",
|
||||||
@@ -247,6 +248,8 @@ const config = {
|
|||||||
// main.mjs can set it as the BrowserWindow icon at runtime (used for the
|
// 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).
|
// Linux taskbar; macOS reads from the .icns inside the .app bundle).
|
||||||
"build-resources/icon.png",
|
"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
|
// Scripts from project root. Mostly Node built-ins; the two spawned
|
||||||
// servers additionally need RUNTIME_PACKAGES, restored into
|
// servers additionally need RUNTIME_PACKAGES, restored into
|
||||||
// Resources/app/node_modules by the afterPack hook.
|
// Resources/app/node_modules by the afterPack hook.
|
||||||
@@ -304,8 +307,8 @@ const config = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Icon auto-discovered from directories.buildResources/icon.png
|
// Icon auto-discovered from directories.buildResources/icon.icns
|
||||||
// (electron-builder generates icon.icns from the 1024×1024 PNG).
|
// (committed; regenerate with `npm run generate-icons`).
|
||||||
},
|
},
|
||||||
|
|
||||||
dmg: {
|
dmg: {
|
||||||
@@ -324,8 +327,9 @@ const config = {
|
|||||||
// ── Windows ────────────────────────────────────────────────────────────────
|
// ── Windows ────────────────────────────────────────────────────────────────
|
||||||
win: {
|
win: {
|
||||||
target: [{ target: "nsis", arch: ["x64"] }],
|
target: [{ target: "nsis", arch: ["x64"] }],
|
||||||
// Icon auto-discovered from directories.buildResources/icon.png
|
// Icon auto-discovered from directories.buildResources/icon.ico
|
||||||
// (electron-builder generates icon.ico from the 1024×1024 PNG).
|
// (committed; regenerate with `npm run generate-icons`). Also used for
|
||||||
|
// the NSIS installer/uninstaller and the rcedit exe icon resource.
|
||||||
},
|
},
|
||||||
|
|
||||||
nsis: {
|
nsis: {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
+21
-8
@@ -54,14 +54,27 @@ const projectRoot = app.isPackaged ? __dirname : join(__dirname, "..");
|
|||||||
const buildDir = join(projectRoot, "build");
|
const buildDir = join(projectRoot, "build");
|
||||||
const scriptsDir = join(projectRoot, "scripts");
|
const scriptsDir = join(projectRoot, "scripts");
|
||||||
|
|
||||||
// 1024×1024 OpenHands raised-hands app icon. Used as the BrowserWindow.icon
|
// OpenHands raised-hands app icon, used as the BrowserWindow.icon option.
|
||||||
// option for the Linux taskbar and the Windows title bar / taskbar. On macOS
|
// Windows gets the multi-size icon.ico (16→256, small sizes as classic BMP
|
||||||
// the dock icon comes from the .app bundle's icon.icns (generated by
|
// entries — the Windows shell needs those); Linux uses the 1024×1024 PNG
|
||||||
// electron-builder from this same PNG), so this path is unused there.
|
// for its taskbar. On macOS the dock icon comes from the .app bundle's
|
||||||
// In dev mode (`npm run desktop`), __dirname is <repo>/electron and the file
|
// icon.icns, so this path is unused there. Both files live next to main.mjs
|
||||||
// lives next to main.mjs. In a packaged build, electron-builder copies
|
// in dev and are copied into Resources/app/build-resources/ via the
|
||||||
// build-resources/icon.png into Resources/app/ via the `files:` array.
|
// `files:` array. Regenerate with `npm run generate-icons`.
|
||||||
const appIconPath = join(__dirname, "build-resources", "icon.png");
|
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 ────────────────────────────────────────────────────────────────
|
// ── Bundled uv ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Generated
+11
@@ -104,6 +104,7 @@
|
|||||||
"jsdom": "30.0.0",
|
"jsdom": "30.0.0",
|
||||||
"lint-staged": "16.4.0",
|
"lint-staged": "16.4.0",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
|
"png2icons": "2.0.1",
|
||||||
"postcss-prefix-selector": "2.1.1",
|
"postcss-prefix-selector": "2.1.1",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
@@ -17746,6 +17747,16 @@
|
|||||||
"node": ">=10.4.0"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -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": "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",
|
"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-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": {
|
"lint-staged": {
|
||||||
"src/**/*.{ts,tsx,js}": [
|
"src/**/*.{ts,tsx,js}": [
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
"jsdom": "30.0.0",
|
"jsdom": "30.0.0",
|
||||||
"lint-staged": "16.4.0",
|
"lint-staged": "16.4.0",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
|
"png2icons": "2.0.1",
|
||||||
"postcss-prefix-selector": "2.1.1",
|
"postcss-prefix-selector": "2.1.1",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user