Skip to content

Commit c5ced6b

Browse files
committed
fix(electron): configure app icons
1 parent 351ffea commit c5ced6b

8 files changed

Lines changed: 211 additions & 4 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ www/.vitepress/dist/
4949
# Web frontend build artifacts
5050
web/dist/
5151
web/.vite/
52-
web/build/
52+
web/build/*
53+
!web/build/icon.png
54+
!web/build/icon.ico
55+
!web/build/icon.icns
5356
web/release/
5457
web/dist-electron/

web/build/icon.icns

415 KB
Binary file not shown.

web/build/icon.ico

40.6 KB
Binary file not shown.

web/build/icon.png

219 KB
Loading

web/electron-builder.config.cjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* electron-builder 配置
2+
* electron-builder 配置
33
*/
44
const config = {
55
appId: 'com.neocode.app',
@@ -15,10 +15,11 @@ const config = {
1515
{
1616
from: 'build',
1717
to: '.',
18-
filter: ['neocode-gateway', 'neocode-gateway.exe'],
18+
filter: ['neocode-gateway', 'neocode-gateway.exe', 'icon.png'],
1919
},
2020
],
2121
win: {
22+
icon: 'build/icon.ico',
2223
target: [
2324
{
2425
target: 'nsis',
@@ -42,6 +43,7 @@ const config = {
4243
artifactName: 'neocode_${version}_Windows_${arch}_Portable.${ext}',
4344
},
4445
mac: {
46+
icon: 'build/icon.icns',
4547
target: [
4648
{
4749
target: 'dmg',
@@ -51,6 +53,7 @@ const config = {
5153
artifactName: 'neocode_${version}_Darwin_${arch}.${ext}',
5254
},
5355
linux: {
56+
icon: 'build/icon.png',
5457
target: [
5558
{
5659
target: 'AppImage',

web/electron/main.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ let gatewayToken = ''
2020
let currentWorkdir = process.env['NEOCODE_WORKDIR'] ?? ''
2121
let isQuitting = false
2222

23+
/** resolveWindowIconPath 返回开发态与打包态都可用的窗口图标路径。 */
24+
function resolveWindowIconPath(): string | undefined {
25+
const candidates = [
26+
...(is.dev ? [join(__dirname, '..', 'build', 'icon.png')] : []),
27+
join(process.resourcesPath, 'icon.png'),
28+
join(process.resourcesPath, 'build', 'icon.png'),
29+
]
30+
return candidates.find((candidate) => existsSync(candidate))
31+
}
32+
2333
/** 安全发送 Gateway 状态,避免窗口销毁后访问 webContents 触发主进程异常 */
2434
function sendGatewayStatus(data: { ready: boolean; error?: string }): void {
2535
if (isQuitting) return
@@ -37,6 +47,7 @@ function createWindow(): void {
3747
show: false,
3848
title: 'NeoCode',
3949
titleBarStyle: 'hiddenInset',
50+
icon: resolveWindowIconPath(),
4051
webPreferences: {
4152
preload: join(__dirname, 'preload.cjs'),
4253
sandbox: false,

web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"dev": "vite",
1111
"dev:electron": "node scripts/clean-electron.js && node scripts/build-gateway.js && vite --mode electron",
1212
"build": "tsc -b && vite build",
13-
"build:electron": "node scripts/clean-electron.js && vite build --mode electron && node scripts/build-gateway.js && node scripts/verify-electron-preload.js && electron-builder --config electron-builder.config.cjs",
13+
"build:electron": "node scripts/clean-electron.js && node scripts/generate-icons.js && vite build --mode electron && node scripts/build-gateway.js && node scripts/verify-electron-preload.js && electron-builder --config electron-builder.config.cjs",
14+
"generate:icons": "node scripts/generate-icons.js",
1415
"preview": "vite preview",
1516
"test": "vitest run",
1617
"test:ui": "vitest --ui",

web/scripts/generate-icons.js

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { spawnSync } from 'child_process'
2+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'
3+
import { tmpdir } from 'os'
4+
import { dirname, join, resolve } from 'path'
5+
import { fileURLToPath } from 'url'
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url))
8+
const rootDir = resolve(__dirname, '..')
9+
const buildDir = join(rootDir, 'build')
10+
const sourceIcon = join(buildDir, 'icon.png')
11+
const outputIco = join(buildDir, 'icon.ico')
12+
const outputIcns = join(buildDir, 'icon.icns')
13+
const iconSizes = [16, 32, 48, 64, 128, 256, 512, 1024]
14+
15+
if (!existsSync(sourceIcon)) {
16+
throw new Error(`Source icon not found: ${sourceIcon}`)
17+
}
18+
19+
const tempDir = mkdtempSync(join(tmpdir(), 'neocode-icons-'))
20+
21+
try {
22+
const resizedPngs = resizeImages(sourceIcon, tempDir, iconSizes)
23+
writeFileSync(outputIco, buildIco(resizedPngs))
24+
writeFileSync(outputIcns, buildIcns(resizedPngs))
25+
console.log(`Generated ${outputIco}`)
26+
console.log(`Generated ${outputIcns}`)
27+
} finally {
28+
rmSync(tempDir, { recursive: true, force: true })
29+
}
30+
31+
/** resizeImages 根据当前系统选择可用的本地图像缩放工具。 */
32+
function resizeImages(inputPath, outputDir, sizes) {
33+
if (process.platform === 'win32') {
34+
return resizeWithPowerShell(inputPath, outputDir, sizes)
35+
}
36+
if (process.platform === 'darwin' && commandExists('sips')) {
37+
return resizeWithSips(inputPath, outputDir, sizes)
38+
}
39+
if (commandExists('magick')) {
40+
return resizeWithMagick(inputPath, outputDir, sizes)
41+
}
42+
if (outputsAreFresh()) {
43+
console.warn('No local image resizer found; existing icon.ico and icon.icns are up to date.')
44+
process.exit(0)
45+
}
46+
throw new Error('No local image resizer found. Install ImageMagick or run this script on Windows/macOS.')
47+
}
48+
49+
/** resizeWithPowerShell 使用系统图像能力生成多尺寸 PNG,避免引入额外 npm 依赖。 */
50+
function resizeWithPowerShell(inputPath, outputDir, sizes) {
51+
const script = `
52+
Add-Type -AssemblyName System.Drawing
53+
$source = [System.Drawing.Image]::FromFile('${escapePowerShell(inputPath)}')
54+
try {
55+
$sizes = @(${sizes.join(',')})
56+
foreach ($size in $sizes) {
57+
$bitmap = New-Object System.Drawing.Bitmap $size, $size
58+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
59+
try {
60+
$graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
61+
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
62+
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
63+
$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
64+
$graphics.Clear([System.Drawing.Color]::Transparent)
65+
$graphics.DrawImage($source, 0, 0, $size, $size)
66+
$bitmap.Save((Join-Path '${escapePowerShell(outputDir)}' "$size.png"), [System.Drawing.Imaging.ImageFormat]::Png)
67+
} finally {
68+
$graphics.Dispose()
69+
$bitmap.Dispose()
70+
}
71+
}
72+
} finally {
73+
$source.Dispose()
74+
}
75+
`
76+
const result = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], {
77+
encoding: 'utf8',
78+
stdio: ['ignore', 'pipe', 'pipe'],
79+
})
80+
if (result.status !== 0) {
81+
throw new Error(`Icon resize failed:\n${result.stderr || result.stdout}`)
82+
}
83+
return new Map(sizes.map((size) => [size, readFileSync(join(outputDir, `${size}.png`))]))
84+
}
85+
86+
/** resizeWithSips 使用 macOS 内置 sips 生成多尺寸 PNG。 */
87+
function resizeWithSips(inputPath, outputDir, sizes) {
88+
for (const size of sizes) {
89+
const result = spawnSync('sips', ['-z', String(size), String(size), inputPath, '--out', join(outputDir, `${size}.png`)], {
90+
encoding: 'utf8',
91+
stdio: ['ignore', 'pipe', 'pipe'],
92+
})
93+
if (result.status !== 0) {
94+
throw new Error(`Icon resize failed:\n${result.stderr || result.stdout}`)
95+
}
96+
}
97+
return new Map(sizes.map((size) => [size, readFileSync(join(outputDir, `${size}.png`))]))
98+
}
99+
100+
/** resizeWithMagick 使用 ImageMagick 作为 Linux 等环境的可选后备方案。 */
101+
function resizeWithMagick(inputPath, outputDir, sizes) {
102+
for (const size of sizes) {
103+
const result = spawnSync('magick', [inputPath, '-resize', `${size}x${size}`, join(outputDir, `${size}.png`)], {
104+
encoding: 'utf8',
105+
stdio: ['ignore', 'pipe', 'pipe'],
106+
})
107+
if (result.status !== 0) {
108+
throw new Error(`Icon resize failed:\n${result.stderr || result.stdout}`)
109+
}
110+
}
111+
return new Map(sizes.map((size) => [size, readFileSync(join(outputDir, `${size}.png`))]))
112+
}
113+
114+
/** buildIco 将多尺寸 PNG 封装为 Windows ICO 文件。 */
115+
function buildIco(pngs) {
116+
const entries = iconSizes.filter((size) => size <= 256)
117+
const headerSize = 6 + entries.length * 16
118+
let offset = headerSize
119+
const header = Buffer.alloc(headerSize)
120+
121+
header.writeUInt16LE(0, 0)
122+
header.writeUInt16LE(1, 2)
123+
header.writeUInt16LE(entries.length, 4)
124+
125+
entries.forEach((size, index) => {
126+
const image = pngs.get(size)
127+
const entryOffset = 6 + index * 16
128+
header.writeUInt8(size === 256 ? 0 : size, entryOffset)
129+
header.writeUInt8(size === 256 ? 0 : size, entryOffset + 1)
130+
header.writeUInt8(0, entryOffset + 2)
131+
header.writeUInt8(0, entryOffset + 3)
132+
header.writeUInt16LE(1, entryOffset + 4)
133+
header.writeUInt16LE(32, entryOffset + 6)
134+
header.writeUInt32LE(image.length, entryOffset + 8)
135+
header.writeUInt32LE(offset, entryOffset + 12)
136+
offset += image.length
137+
})
138+
139+
return Buffer.concat([header, ...entries.map((size) => pngs.get(size))])
140+
}
141+
142+
/** buildIcns 将 PNG 数据封装为 macOS ICNS 文件,供 electron-builder 直接使用。 */
143+
function buildIcns(pngs) {
144+
const chunks = [
145+
['icp4', 16],
146+
['icp5', 32],
147+
['icp6', 64],
148+
['ic07', 128],
149+
['ic08', 256],
150+
['ic09', 512],
151+
['ic10', 1024],
152+
['ic11', 32],
153+
['ic12', 64],
154+
['ic13', 256],
155+
['ic14', 512],
156+
]
157+
const body = chunks.map(([type, size]) => {
158+
const image = pngs.get(size)
159+
const header = Buffer.alloc(8)
160+
header.write(type, 0, 4, 'ascii')
161+
header.writeUInt32BE(image.length + 8, 4)
162+
return Buffer.concat([header, image])
163+
})
164+
const totalLength = 8 + body.reduce((sum, chunk) => sum + chunk.length, 0)
165+
const header = Buffer.alloc(8)
166+
header.write('icns', 0, 4, 'ascii')
167+
header.writeUInt32BE(totalLength, 4)
168+
return Buffer.concat([header, ...body])
169+
}
170+
171+
/** escapePowerShell 转义路径中的单引号,保证脚本按字面值读取文件。 */
172+
function escapePowerShell(value) {
173+
return value.replace(/'/g, "''")
174+
}
175+
176+
/** commandExists 检查命令是否存在,避免直接执行时报出不清晰的错误。 */
177+
function commandExists(command) {
178+
const probe = process.platform === 'win32'
179+
? spawnSync('where.exe', [command], { stdio: 'ignore' })
180+
: spawnSync('which', [command], { stdio: 'ignore' })
181+
return probe.status === 0
182+
}
183+
184+
/** outputsAreFresh 判断已提交图标是否不旧于源图,可在缺少缩放工具时复用。 */
185+
function outputsAreFresh() {
186+
if (!existsSync(outputIco) || !existsSync(outputIcns)) return false
187+
const sourceMtime = statSync(sourceIcon).mtimeMs
188+
return statSync(outputIco).mtimeMs >= sourceMtime && statSync(outputIcns).mtimeMs >= sourceMtime
189+
}

0 commit comments

Comments
 (0)