#!/usr/bin/env node /* * Generates every brand icon asset the desktop client needs from SVG sources. * Most assets come from public/logo.svg; the Windows tray icon uses the * colored public/icon-color.svg variant. * * Outputs: * build/icon.icns macOS app icon (electron-builder) * build/icon.png Linux app icon + Windows/icns fallback (1024x1024) * build/icon.ico Windows app icon * src/main/brand-icons.ts base64 icons consumed by the main process * (tray + dock + window + Windows overlay) * * Re-run after changing public/logo.svg or public/icon-color.svg: * node scripts/generate-brand-icons.cjs * * Requires `rsvg-convert`, `sips` and `iconutil` (preinstalled on macOS via * librsvg + the system toolchain). */ const { execFileSync } = require('node:child_process') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const zlib = require('node:zlib') const APP_DIR = path.resolve(__dirname, '..') const LOGO_PATH = path.join(APP_DIR, 'public', 'logo.svg') const WINDOWS_TRAY_LOGO_PATH = path.join(APP_DIR, 'public', 'icon-color.svg') const BUILD_DIR = path.join(APP_DIR, 'build') const OUT_TS = path.join(APP_DIR, 'src', 'main', 'brand-icons.ts') // Brand palette — keep in sync with the renderer (#1677ff is Ant Design blue). const BRAND_BLUE = '#1677ff' const DANGER_RED = '#ff4d4f' const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brand-icons-')) const cleanup = () => fs.rmSync(tmpDir, { recursive: true, force: true }) function rsvg(args) { return execFileSync('rsvg-convert', args) } /** Extract the inner markup and viewBox from an SVG icon source. */ function readLogo(filePath, label) { const raw = fs.readFileSync(filePath, 'utf8') const viewBox = (raw.match(/viewBox="([^"]+)"/) || [])[1] const inner = (raw.match(/]*>([\s\S]*)<\/svg>/) || [])[1] if (!viewBox || !inner) { throw new Error(`Could not parse ${label} (missing viewBox or body)`) } const [x, y, width, height] = viewBox.split(/\s+/).map(Number) if (![x, y, width, height].every(Number.isFinite)) { throw new Error(`Could not parse ${label} viewBox`) } return { label, inner: inner.trim(), x, y, width, height } } const logo = readLogo(LOGO_PATH, 'public/logo.svg') const windowsTrayLogo = readLogo(WINDOWS_TRAY_LOGO_PATH, 'public/icon-color.svg') const logoBounds = measureLogoBounds(logo) const windowsTrayLogoBounds = measureLogoBounds(windowsTrayLogo) function logoSvg(source, inner = source.inner) { return ` ${inner} ` } function paeth(a, b, c) { const p = a + b - c const pa = Math.abs(p - a) const pb = Math.abs(p - b) const pc = Math.abs(p - c) if (pa <= pb && pa <= pc) return a return pb <= pc ? b : c } function readPngAlphaBounds(pngPath) { const png = fs.readFileSync(pngPath) const signature = '89504e470d0a1a0a' if (png.subarray(0, 8).toString('hex') !== signature) { throw new Error(`Expected PNG: ${pngPath}`) } let offset = 8 let width = 0 let height = 0 let bitDepth = 0 let colorType = 0 let interlace = 0 const idat = [] while (offset < png.length) { const length = png.readUInt32BE(offset) const type = png.toString('ascii', offset + 4, offset + 8) const dataStart = offset + 8 const dataEnd = dataStart + length const data = png.subarray(dataStart, dataEnd) if (type === 'IHDR') { width = data.readUInt32BE(0) height = data.readUInt32BE(4) bitDepth = data.readUInt8(8) colorType = data.readUInt8(9) interlace = data.readUInt8(12) } else if (type === 'IDAT') { idat.push(data) } else if (type === 'IEND') { break } offset = dataEnd + 4 } if (bitDepth !== 8 || colorType !== 6 || interlace !== 0) { throw new Error(`Unsupported PNG format in ${pngPath}`) } const bytesPerPixel = 4 const rowBytes = width * bytesPerPixel const raw = zlib.inflateSync(Buffer.concat(idat)) let rawOffset = 0 let prev = new Uint8Array(rowBytes) let minX = width let minY = height let maxX = -1 let maxY = -1 for (let y = 0; y < height; y += 1) { const filter = raw[rawOffset] rawOffset += 1 const row = new Uint8Array(rowBytes) for (let i = 0; i < rowBytes; i += 1) { const value = raw[rawOffset] rawOffset += 1 const left = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0 const up = prev[i] const upLeft = i >= bytesPerPixel ? prev[i - bytesPerPixel] : 0 if (filter === 0) row[i] = value else if (filter === 1) row[i] = (value + left) & 0xff else if (filter === 2) row[i] = (value + up) & 0xff else if (filter === 3) row[i] = (value + Math.floor((left + up) / 2)) & 0xff else if (filter === 4) row[i] = (value + paeth(left, up, upLeft)) & 0xff else throw new Error(`Unsupported PNG filter ${filter} in ${pngPath}`) } for (let x = 0; x < width; x += 1) { if (row[x * bytesPerPixel + 3] === 0) { continue } minX = Math.min(minX, x) minY = Math.min(minY, y) maxX = Math.max(maxX, x) maxY = Math.max(maxY, y) } prev = row } if (maxX < minX || maxY < minY) { return null } return { minX, minY, maxX, maxY, width, height } } function measureLogoBounds(source) { const size = 512 const slug = source.label.replace(/[^a-z0-9._-]+/gi, '-') const svgPath = path.join(tmpDir, `${slug}-measure.svg`) const pngPath = path.join(tmpDir, `${slug}-measure.png`) fs.writeFileSync(svgPath, logoSvg(source)) try { rsvg(['-w', String(size), '-h', String(size), '-o', pngPath, svgPath]) const bounds = readPngAlphaBounds(pngPath) if (!bounds) { throw new Error('logo rendered with no visible pixels') } return { x: source.x + (bounds.minX / bounds.width) * source.width, y: source.y + (bounds.minY / bounds.height) * source.height, width: ((bounds.maxX - bounds.minX + 1) / bounds.width) * source.width, height: ((bounds.maxY - bounds.minY + 1) / bounds.height) * source.height, } } catch (error) { console.warn(`[brand-icons] Could not measure visible bounds for ${source.label}; using full viewBox. ${error.message}`) return { x: source.x, y: source.y, width: source.width, height: source.height } } } function fitLogo(bounds, maxW, maxH, centerX, centerY) { const scale = Math.min(maxW / bounds.width, maxH / bounds.height) const drawnW = bounds.width * scale const drawnH = bounds.height * scale return { scale, tx: centerX - drawnW / 2 - bounds.x * scale, ty: centerY - drawnH / 2 - bounds.y * scale, } } function forceLogoColor(source, fill) { if (!fill) { return source.inner } return `${source.inner} ` } /** A square app icon: logo centered on a rounded-rect (squircle-ish) bg. */ function squareIconSvg(bg, logoSource = logo, boundsSource = logoBounds) { const CANVAS = 1024 // macOS icon grid: 824x824 art area inside a 1024 canvas (100px margin). const PLATE = 824 const MARGIN = (CANVAS - PLATE) / 2 const target = PLATE * 0.62 const { scale, tx, ty } = fitLogo(boundsSource, target, target, MARGIN + PLATE / 2, MARGIN + PLATE / 2) return ` ${logoSource.inner} ` } /** * Tray glyph: the logo centered (with padding) inside a 32px square canvas. * tray.ts loads it with scaleFactor 2, so it shows at ~16pt on retina menu * bars. Padding keeps the wide/landscape mark from touching the canvas edges. */ function trayLogoSvg(source = logo, bounds = logoBounds, fill) { const CANVAS = 32 // tray asset is 32x32 px const { scale, tx, ty } = fitLogo(bounds, 30, 28, CANVAS / 2, CANVAS / 2) const inner = forceLogoColor(source, fill) return ` ${inner} ` } function svgToPng(svg, name, extraArgs = []) { const svgPath = path.join(tmpDir, `${name}.svg`) const pngPath = path.join(tmpDir, `${name}.png`) fs.writeFileSync(svgPath, svg) rsvg([...extraArgs, '-o', pngPath, svgPath]) return pngPath } function b64(pngPath) { return fs.readFileSync(pngPath).toString('base64') } /** * Wrap N square PNG buffers into a multi-image (PNG-payload) .ico container. * Windows picks the closest size when rendering at 16/24/32/48/256 px, so a * single 256px entry forces a blurry downscale in the taskbar/Explorer — and * the old rcedit electron-builder ships with mis-stamps some single-entry ICOs. * Embedding every common size keeps the app icon crisp at all of them. */ function pngsToIco(pngBuffers) { const count = pngBuffers.length const header = Buffer.alloc(6) header.writeUInt16LE(0, 0) // reserved header.writeUInt16LE(1, 2) // type: icon header.writeUInt16LE(count, 4) // image count const dir = Buffer.alloc(16 * count) let offset = 6 + 16 * count pngBuffers.forEach((png, i) => { const w = png.readUInt32BE(16) // PNG IHDR width (byte 16..19) const h = png.readUInt32BE(20) // PNG IHDR height (byte 20..23) const e = i * 16 dir.writeUInt8(w >= 256 ? 0 : w, e + 0) // width (0 => 256) dir.writeUInt8(h >= 256 ? 0 : h, e + 1) // height (0 => 256) dir.writeUInt8(0, e + 2) // palette dir.writeUInt8(0, e + 3) // reserved dir.writeUInt16LE(1, e + 4) // color planes dir.writeUInt16LE(32, e + 6) // bits per pixel dir.writeUInt32LE(png.length, e + 8) // size of image data dir.writeUInt32LE(offset, e + 12) // offset to image data offset += png.length }) return Buffer.concat([header, dir, ...pngBuffers]) } function buildAppIcons() { // 1024 master for the normal app icon. const masterPng = svgToPng(squareIconSvg('#ffffff', windowsTrayLogo, windowsTrayLogoBounds), 'app-normal', ['-w', '1024', '-h', '1024']) fs.mkdirSync(BUILD_DIR, { recursive: true }) fs.copyFileSync(masterPng, path.join(BUILD_DIR, 'icon.png')) // .icns via an .iconset downscaled with sips. const iconset = path.join(tmpDir, 'icon.iconset') fs.mkdirSync(iconset, { recursive: true }) const variants = [ [16, 'icon_16x16.png'], [32, 'icon_16x16@2x.png'], [32, 'icon_32x32.png'], [64, 'icon_32x32@2x.png'], [128, 'icon_128x128.png'], [256, 'icon_128x128@2x.png'], [256, 'icon_256x256.png'], [512, 'icon_256x256@2x.png'], [512, 'icon_512x512.png'], [1024, 'icon_512x512@2x.png'], ] for (const [size, file] of variants) { const dest = path.join(iconset, file) execFileSync('sips', ['-z', String(size), String(size), masterPng, '--out', dest], { stdio: 'ignore', }) } execFileSync('iconutil', ['-c', 'icns', iconset, '-o', path.join(BUILD_DIR, 'icon.icns')]) // .ico with every size Windows asks for (taskbar/Explorer/Alt-Tab/tiles). const icoSizes = [16, 24, 32, 48, 64, 128, 256] const icoPngs = icoSizes.map((s) => fs.readFileSync(svgToPng(squareIconSvg('#ffffff', windowsTrayLogo, windowsTrayLogoBounds), `app-ico-${s}`, ['-w', String(s), '-h', String(s)])), ) fs.writeFileSync(path.join(BUILD_DIR, 'icon.ico'), pngsToIco(icoPngs)) } function buildMainProcessIcons() { // Tray icons are 32x32 px and loaded @2x in tray.ts (=> ~16pt on retina). // Visible artwork is measured and fitted, so SVG viewBox padding does not // shrink the menu-bar/taskbar glyph. const trayNormal = b64(svgToPng(trayLogoSvg(), 'tray-normal')) const trayWindows = b64(svgToPng(trayLogoSvg(windowsTrayLogo, windowsTrayLogoBounds), 'tray-windows')) const trayDanger = b64(svgToPng(trayLogoSvg(logo, logoBounds, DANGER_RED), 'tray-danger')) // Dock / window icons: logo on the brand plate (normal) and danger plate. const appNormal = b64(svgToPng(squareIconSvg('#ffffff', windowsTrayLogo, windowsTrayLogoBounds), 'dock-normal', ['-w', '512', '-h', '512'])) const appDanger = b64(svgToPng(squareIconSvg(DANGER_RED), 'dock-danger', ['-w', '512', '-h', '512'])) // Windows taskbar overlay: a small danger badge (status cue, not the logo). const overlaySvg = ` ` const overlay = b64(svgToPng(overlaySvg, 'win-overlay')) const banner = `// AUTO-GENERATED from public/logo.svg and public/icon-color.svg by scripts/generate-brand-icons.cjs. // Do not edit by hand — run \`node scripts/generate-brand-icons.cjs\` instead. /* eslint-disable */ /* prettier-ignore-start */` const body = [ banner, '', '// macOS menu-bar template silhouette (rendered @2x).', `export const TRAY_ICON_BASE64 =\n '${trayNormal}'`, '', '// Windows tray icon: colored brand mark for light tray flyouts.', `export const TRAY_WINDOWS_ICON_BASE64 =\n '${trayWindows}'`, '', '// Tray icon shown when accounts have health issues (rendered @2x, not a template).', `export const TRAY_DANGER_ICON_BASE64 =\n '${trayDanger}'`, '', '// Dock / window icon — logo on the brand-blue plate.', `export const APP_ICON_NORMAL_BASE64 =\n '${appNormal}'`, '', '// Dock / window icon — logo on a danger plate when issues exist.', `export const APP_ICON_DANGER_BASE64 =\n '${appDanger}'`, '', '// Small Windows taskbar overlay badge signalling account health issues.', `export const WINDOWS_OVERLAY_ICON_BASE64 =\n '${overlay}'`, '/* prettier-ignore-end */', '', ].join('\n') fs.writeFileSync(OUT_TS, body) } try { buildAppIcons() buildMainProcessIcons() console.log('✓ build/icon.icns, build/icon.png, build/icon.ico') console.log('✓ src/main/brand-icons.ts') } finally { cleanup() }