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 = + "" + + "" + + `
${tileSvg}
` + + `
${smallSvg}
`; - let image = captured; - if (!image || image.isEmpty()) { - try { - image = await win.webContents.capturePage(); - } catch (e) { - return fail("offscreen paint empty and capturePage failed: " + e.message); - } - } - if (!image || image.isEmpty()) return fail("renderer produced an empty image"); + const win = new BrowserWindow({ + width: W, + height: H, + show: false, + frame: false, + transparent: true, + backgroundColor: "#00000000", + useContentSize: true, + webPreferences: { offscreen: true }, + }); + let captured = null; + win.webContents.on("paint", (_e, _d, image) => { + if (image && !image.isEmpty()) captured = image; + }); + await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html)); + await wait(500); + const frame = captured || (await win.webContents.capturePage()); + if (frame.isEmpty()) throw new Error("offscreen produced an empty frame"); - const size = image.getSize(); - if (size.width !== SIZE || size.height !== SIZE) { - image = image.resize({ width: SIZE, height: SIZE, quality: "best" }); - } + const base = { + tile: frame.crop({ x: 0, y: 0, width: TILE, height: TILE }), + small: frame.crop({ x: TILE, y: 0, width: SMALL, height: SMALL }), + }; + win.destroy(); + + writeFileSync(join(iconsDir, "icon.png"), pngAt(base.tile, 1024)); + writeFileSync(join(iconsDir, "icon.ico"), buildIco(base, [16, 24, 32, 48, 64, 128, 256])); + writeFileSync(join(iconsDir, "tray.ico"), buildIco(base, [16, 24, 32])); - writeFileSync(outPath, image.toPNG()); - clearTimeout(giveUp); - console.log("[build-icon] wrote " + outPath + " (" + SIZE + "x" + SIZE + ")"); - win.destroy(); - app.quit(); - process.exit(0); + console.log("[build-icon] wrote icon.png (1024), icon.ico (16-256), tray.ico (16-32)"); + app.quit(); + process.exit(0); + } catch (e) { + console.error("[build-icon] " + (e && e.stack ? e.stack : e)); + process.exit(1); + } }); diff --git a/src/main/index.ts b/src/main/index.ts index ae6345c..1344ca1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -216,6 +216,7 @@ function createWindow( minWidth: 1100, minHeight: 760, title: "DropPilot", + icon: resolveWindowIcon(), show: !startHidden, autoHideMenuBar: !isMac, // Windows: hide the OS title bar entirely; the renderer Titlebar draws @@ -326,20 +327,31 @@ function createWindow( return win; } -function resolveTrayIcon() { - // Package icons along with extraResources; fall back to repo icons in dev - const prodIcon = join(process.resourcesPath, "icons", "icon.png"); +function resolveIconPath(filename: string) { + // Packaged: icons ship via extraResources. Dev: fall back to repo icons. if (app.isPackaged) { - return nativeImage.createFromPath(prodIcon); + return join(process.resourcesPath, "icons", filename); } const devCandidates = [ - join(process.cwd(), "icons", "icon.png"), - join(app.getAppPath(), "icons", "icon.png"), - join(app.getAppPath(), "..", "icons", "icon.png"), - join(app.getAppPath(), "..", "..", "icons", "icon.png"), + join(process.cwd(), "icons", filename), + join(app.getAppPath(), "icons", filename), + join(app.getAppPath(), "..", "icons", filename), + join(app.getAppPath(), "..", "..", "icons", filename), ]; - const devIcon = devCandidates.find((candidate) => existsSync(candidate)) ?? devCandidates[0]; - return nativeImage.createFromPath(devIcon); + return devCandidates.find((candidate) => existsSync(candidate)) ?? devCandidates[0]; +} + +// Windows: a window icon picks the right small size from the optical .ico, so +// the taskbar stays crisp. Other platforms use the square PNG. +function resolveWindowIcon() { + return resolveIconPath(process.platform === "win32" ? "icon.ico" : "icon.png"); +} + +function resolveTrayIcon() { + // Windows: a dedicated small multi-size .ico stays crisp in the tray (a + // downscaled 1024px PNG looks pixelated). Other platforms scale the PNG. + const file = process.platform === "win32" ? "tray.ico" : "icon.png"; + return nativeImage.createFromPath(resolveIconPath(file)); } function createTray(win: BrowserWindow) {