feat(desktop): add type declaration for @brand-logo alias in brand-logo.d.ts
Deployment Config CI / Deployment Config (push) Successful in 12s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 30s
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Desktop Client Build / Build Desktop Client (push) Successful in 22m8s
Deployment Config CI / Deployment Config (push) Successful in 12s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 30s
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Desktop Client Build / Build Desktop Client (push) Successful in 22m8s
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
#!/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 <path> 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(/<svg[^>]*>([\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 `<svg xmlns="http://www.w3.org/2000/svg" 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)})">${logo.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(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 `<svg xmlns="http://www.w3.org/2000/svg" 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 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 = `<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 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()
|
||||
}
|
||||
Reference in New Issue
Block a user