#!/usr/bin/env node /* * Generates every brand icon asset the desktop client needs from the single * source of truth: public/logo.svg. * * 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: * 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 APP_DIR = path.resolve(__dirname, '..') const LOGO_PATH = path.join(APP_DIR, 'public', 'logo.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 LOGO_FILL = '#dfdfdf' // the fill baked into logo.svg 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 (the elements) and viewBox from logo.svg. */ function readLogo() { const raw = fs.readFileSync(LOGO_PATH, 'utf8') const viewBox = (raw.match(/viewBox="([^"]+)"/) || [])[1] const inner = (raw.match(/]*>([\s\S]*)<\/svg>/) || [])[1] if (!viewBox || !inner) { throw new Error('Could not parse public/logo.svg (missing viewBox or body)') } const [, , vbW, vbH] = viewBox.split(/\s+/).map(Number) return { inner: inner.trim(), width: vbW, height: vbH } } const logo = readLogo() /** A square app icon: logo centered on a rounded-rect (squircle-ish) bg. */ function squareIconSvg(bg) { const CANVAS = 1024 // macOS icon grid: 824x824 art area inside a 1024 canvas (100px margin). const PLATE = 824 const MARGIN = (CANVAS - PLATE) / 2 const targetW = PLATE * 0.62 const scale = targetW / logo.width const drawnW = logo.width * scale const drawnH = logo.height * scale const tx = MARGIN + (PLATE - drawnW) / 2 const ty = MARGIN + (PLATE - drawnH) / 2 return ` ${logo.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(fill) { const CANVAS = 32 // tray asset is 32x32 px // Glyph height inside the 32px canvas — the single knob for tray size. // Bump up for a larger glyph, down for a smaller one. const targetH = 20 const scale = targetH / logo.height const drawnW = logo.width * scale const drawnH = logo.height * scale const tx = (CANVAS - drawnW) / 2 const ty = (CANVAS - drawnH) / 2 const inner = fill ? logo.inner.split(LOGO_FILL).join(fill) : logo.inner 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 a PNG buffer in a minimal (PNG-payload) .ico container. */ function pngToIco(pngBuffer) { const header = Buffer.alloc(6) header.writeUInt16LE(0, 0) // reserved header.writeUInt16LE(1, 2) // type: icon header.writeUInt16LE(1, 4) // image count const entry = Buffer.alloc(16) entry.writeUInt8(0, 0) // width (0 => 256) entry.writeUInt8(0, 1) // height (0 => 256) entry.writeUInt8(0, 2) // palette entry.writeUInt8(0, 3) // reserved entry.writeUInt16LE(1, 4) // color planes entry.writeUInt16LE(32, 6) // bits per pixel entry.writeUInt32LE(pngBuffer.length, 8) // size of image data entry.writeUInt32LE(6 + 16, 12) // offset to image data return Buffer.concat([header, entry, pngBuffer]) } function buildAppIcons() { // 1024 master for the normal app icon. const masterPng = svgToPng(squareIconSvg(BRAND_BLUE), '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 from a 256px render. const ico256 = svgToPng(squareIconSvg(BRAND_BLUE), 'app-ico', ['-w', '256', '-h', '256']) fs.writeFileSync(path.join(BUILD_DIR, 'icon.ico'), pngToIco(fs.readFileSync(ico256))) } function buildMainProcessIcons() { // Tray icons are 32x32 px and loaded @2x in tray.ts (=> ~16pt on retina). // Glyph size is controlled by targetH in trayLogoSvg(), not by an rsvg -h. const trayNormal = b64(svgToPng(trayLogoSvg(), 'tray-normal')) const trayDanger = b64(svgToPng(trayLogoSvg(DANGER_RED), 'tray-danger')) // Dock / window icons: logo on the brand plate (normal) and danger plate. const appNormal = b64(svgToPng(squareIconSvg(BRAND_BLUE), '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 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}'`, '', '// 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() }