Skip to content

Commit 3a1c41f

Browse files
authored
Merge pull request #622 from Yumiue/html_progress_clean
fix(electron): configure app icons
2 parents 591a6b3 + 8f37dbd commit 3a1c41f

10 files changed

Lines changed: 218 additions & 3 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/

README.en.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ neocode web
126126

127127
Tagged release builds already embed Web UI assets (`web/dist`) into the `neocode` binary, so running `neocode web` does not require Node.js or npm on the target machine. If you run from source with `go run ./cmd/neocode web`, NeoCode will still automatically try to build the frontend when `web/dist` is missing.
128128

129+
Electron desktop releases use the checked-in `web/build/icon.png`, `web/build/icon.ico`, and `web/build/icon.icns` assets. Only run `npm run generate:icons` from `web/` after replacing the source `web/build/icon.png`; the command uses PowerShell/.NET image APIs on Windows, `sips` on macOS, and requires ImageMagick's `magick` command on Linux.
130+
129131
### 4. Quick Web / Feishu Entry
130132

131133
```bash

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ neocode web
133133

134134
标签发布版已经将 Web UI 的 `web/dist` 内嵌进 `neocode` 二进制,执行 `neocode web` 时不再要求用户机器安装 Node.js 或 npm。如果你在源码仓库里运行 `go run ./cmd/neocode web`,当本地缺少 `web/dist` 时仍会自动尝试构建前端。
135135

136+
Electron 桌面端发布图标使用已提交的 `web/build/icon.png``web/build/icon.ico``web/build/icon.icns`。只有替换 `web/build/icon.png` 源图时,才需要在 `web/` 目录手动运行 `npm run generate:icons` 重新生成 Windows 与 macOS 图标;该命令在 Windows 使用 PowerShell/.NET 图像能力,在 macOS 使用 `sips`,在 Linux 需要 ImageMagick 的 `magick` 命令。
137+
136138
### 4. Web / 飞书快速入口
137139

138140
```bash

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

0 commit comments

Comments
 (0)