diff --git a/icons/icon-small.svg b/icons/icon-small.svg new file mode 100644 index 0000000..f483280 --- /dev/null +++ b/icons/icon-small.svg @@ -0,0 +1,7 @@ + diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000..31fad41 Binary files /dev/null and b/icons/icon.ico differ diff --git a/icons/tray.ico b/icons/tray.ico new file mode 100644 index 0000000..5719c76 Binary files /dev/null and b/icons/tray.ico differ diff --git a/package.json b/package.json index 9347b96..3cf3302 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "output": "release" }, "win": { + "icon": "icons/icon.ico", "artifactName": "DropPilot-Setup-${version}.${ext}", "target": "nsis" }, diff --git a/scripts/build-icon.mjs b/scripts/build-icon.mjs index df354db..310d9bf 100644 --- a/scripts/build-icon.mjs +++ b/scripts/build-icon.mjs @@ -1,73 +1,124 @@ -// Renders icons/icon.svg -> icons/icon.png (1024x1024, RGBA) using the Electron -// binary that already ships with this project — no extra native dependency and -// no network access. electron-builder generates the installer .ico from this -// PNG, and the tray reuses it (see src/main/index.ts resolveTrayIcon). +// Generates the DropPilot icon assets from SVG — dependency-free, via the +// Electron binary already in the project (no extra npm dep, no network). // -// Run: npm run build:icon (after editing icons/icon.svg) +// Optical sizing: small sizes use the high-contrast violet tile +// (icon-small.svg) so the taskbar/tray icon stays crisp and readable on the +// dark Windows chrome; large sizes use the dark tile (icon.svg) that looks best +// as a desktop/installer icon. electron-builder cannot do this from a single +// PNG (it only downscales), which is why the .ico files are built here. +// +// icons/icon.png 1024 dark tile -> build.icon (macOS/default) +// icons/icon.ico 16-256 optical (mixed) -> build.win.icon + BrowserWindow +// icons/tray.ico 16/24/32 violet -> system tray +// +// Both SVGs are rasterized in ONE offscreen window + ONE load (a second +// offscreen window/data-load fails with ERR_FAILED), then cropped apart and +// resized down with a high-quality filter for each target size. +// +// Run: npm run build:icon (after editing icon.svg / icon-small.svg) import { app, BrowserWindow } from "electron"; import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const SIZE = 1024; -const svgPath = join(root, "icons", "icon.svg"); -const outPath = join(root, "icons", "icon.png"); +const iconsDir = join(root, "icons"); +const tileSvg = readFileSync(join(iconsDir, "icon.svg"), "utf8"); +const smallSvg = readFileSync(join(iconsDir, "icon-small.svg"), "utf8"); -function fail(msg) { - console.error("[build-icon] " + msg); - process.exit(1); -} +const TILE = 1024; +const SMALL = 512; +const W = TILE + SMALL; +const H = TILE; +// Force devicePixelRatio 1 so the offscreen window paints exactly W×H pixels. +app.commandLine.appendSwitch("force-device-scale-factor", "1"); app.disableHardwareAcceleration(); -const giveUp = setTimeout(() => fail("timed out waiting for a rendered frame"), 20000); -app.whenReady().then(async () => { - const svg = readFileSync(svgPath, "utf8"); - const html = - "" + - "" + - svg; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const win = new BrowserWindow({ - width: SIZE, - height: SIZE, - show: false, - frame: false, - transparent: true, - backgroundColor: "#00000000", - useContentSize: true, - webPreferences: { offscreen: true }, - }); +function pngAt(baseImage, size) { + const img = + baseImage.getSize().width === size + ? baseImage + : baseImage.resize({ width: size, height: size, quality: "best" }); + const png = img.toPNG(); + if (!png || png.length === 0) throw new Error("empty PNG at size " + size); + return png; +} - let captured = null; - win.webContents.on("paint", (_e, _dirty, image) => { - if (image && !image.isEmpty()) captured = image; +// Pack PNG-compressed entries into a Windows .ico (Vista+/Win10/11 support PNG +// payloads at every size). Sizes < 64 use the violet small art, >= 64 the tile. +function buildIco(base, sizes) { + const entries = sizes + .map((size) => ({ size, png: pngAt(size < 64 ? base.small : base.tile, size) })) + .sort((a, b) => a.size - b.size); + const count = entries.length; + const header = Buffer.alloc(6 + count * 16); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(count, 4); + let offset = 6 + count * 16; + const blobs = []; + entries.forEach((e, i) => { + const at = 6 + i * 16; + header.writeUInt8(e.size >= 256 ? 0 : e.size, at + 0); // width (0 = 256) + header.writeUInt8(e.size >= 256 ? 0 : e.size, at + 1); // height + header.writeUInt8(0, at + 2); // palette count + header.writeUInt8(0, at + 3); // reserved + header.writeUInt16LE(1, at + 4); // color planes + header.writeUInt16LE(32, at + 6); // bits per pixel + header.writeUInt32LE(e.png.length, at + 8); + header.writeUInt32LE(offset, at + 12); + offset += e.png.length; + blobs.push(e.png); }); + return Buffer.concat([header, ...blobs]); +} - await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); - await new Promise((r) => setTimeout(r, 800)); // let the compositor settle +app.whenReady().then(async () => { + try { + const html = + "" + + "" + + `