381 lines
14 KiB
JavaScript
381 lines
14 KiB
JavaScript
#!/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(/<svg[^>]*>([\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 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${source.width}" height="${source.height}" viewBox="${source.x} ${source.y} ${source.width} ${source.height}">
|
|
${inner}
|
|
</svg>`
|
|
}
|
|
|
|
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 `<g class="brand-icon-color">${source.inner}</g>
|
|
<style>.brand-icon-color *{fill:${fill}!important;stroke:${fill}!important;}</style>`
|
|
}
|
|
|
|
/** 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 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}">
|
|
<rect x="${MARGIN}" y="${MARGIN}" width="${PLATE}" height="${PLATE}" rx="185" ry="185" fill="${bg}"/>
|
|
<g transform="translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${scale.toFixed(6)})">${logoSource.inner}</g>
|
|
</svg>`
|
|
}
|
|
|
|
/**
|
|
* 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 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}">
|
|
<g transform="translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${scale.toFixed(6)})">${inner}</g>
|
|
</svg>`
|
|
}
|
|
|
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
|
<circle cx="16" cy="16" r="15" fill="${DANGER_RED}"/>
|
|
<rect x="14" y="7" width="4" height="12" rx="2" fill="#ffffff"/>
|
|
<circle cx="16" cy="24" r="2.4" fill="#ffffff"/>
|
|
</svg>`
|
|
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()
|
|
}
|