feat: add new brand logos
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 3m23s
Backend CI / Backend (push) Successful in 16m58s
Desktop Client Build / Build Desktop Client (push) Successful in 24m56s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 36s

- Introduced icon-color.svg featuring a colorful gradient design.
- Added icon-with-title.svg that includes a title and multiple gradient elements.
This commit is contained in:
2026-05-31 23:05:27 +08:00
parent e490a267ff
commit e07a87224d
21 changed files with 787 additions and 135 deletions
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/*
* Generates every brand icon asset the desktop client needs from the single
* source of truth: public/logo.svg.
* 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)
@@ -10,7 +11,7 @@
* src/main/brand-icons.ts base64 icons consumed by the main process
* (tray + dock + window + Windows overlay)
*
* Re-run after changing public/logo.svg:
* 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
@@ -20,16 +21,17 @@ 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 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 })
@@ -38,35 +40,188 @@ 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')
/** 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 public/logo.svg (missing viewBox or body)')
throw new Error(`Could not parse ${label} (missing viewBox or body)`)
}
const [, , vbW, vbH] = viewBox.split(/\s+/).map(Number)
return { inner: inner.trim(), width: vbW, height: vbH }
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()
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) {
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 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}">
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)})">${logo.inner}</g>
<g transform="translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${scale.toFixed(6)})">${logoSource.inner}</g>
</svg>`
}
@@ -75,18 +230,11 @@ function squareIconSvg(bg) {
* 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) {
function trayLogoSvg(source = logo, bounds = logoBounds, 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}">
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>`
}
@@ -137,7 +285,7 @@ function pngsToIco(pngBuffers) {
function buildAppIcons() {
// 1024 master for the normal app icon.
const masterPng = svgToPng(squareIconSvg(BRAND_BLUE), 'app-normal', ['-w', '1024', '-h', '1024'])
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'))
@@ -167,20 +315,21 @@ function buildAppIcons() {
// .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(BRAND_BLUE), `app-ico-${s}`, ['-w', String(s), '-h', String(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).
// Glyph size is controlled by targetH in trayLogoSvg(), not by an rsvg -h.
// 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(BRAND_BLUE), 'tray-windows'))
const trayDanger = b64(svgToPng(trayLogoSvg(DANGER_RED), 'tray-danger'))
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(BRAND_BLUE), 'dock-normal', ['-w', '512', '-h', '512']))
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).
@@ -191,7 +340,7 @@ function buildMainProcessIcons() {
</svg>`
const overlay = b64(svgToPng(overlaySvg, 'win-overlay'))
const banner = `// AUTO-GENERATED from public/logo.svg by scripts/generate-brand-icons.cjs.
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 */`
@@ -201,7 +350,7 @@ function buildMainProcessIcons() {
'// macOS menu-bar template silhouette (rendered @2x).',
`export const TRAY_ICON_BASE64 =\n '${trayNormal}'`,
'',
'// Windows tray icon: brand-blue silhouette for light tray flyouts.',
'// 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).',