Skill 正文
1. 核心原则
1.1 路径处理:开发环境 vs 打包环境
必须根据 app.isPackaged 区分路径:
typescript
const getIconPath = () => {
if (app.isPackaged) {
// 打包后:从 process.resourcesPath 读取
return process.platform === 'win32'
? path.join(process.resourcesPath, 'build', 'icon.ico')
: path.join(process.resourcesPath, 'resources', 'icon.png')
} else {
// 开发环境:从项目目录读取
return process.platform === 'win32'
? path.join(__dirname, '../../../build/icon.ico')
: path.join(__dirname, '../../../resources/icon.png')
}
}
禁止:
- •直接使用相对路径(如
'./icon.png') - •假设
__dirname在打包后路径不变 - •使用
process.cwd()(会随启动目录变化)
2. 图标文件要求
2.1 Windows (.ico)
- •推荐尺寸:16x16, 32x32, 48x48, 64x64, 128x128, 256x256(多尺寸容器)
- •格式:ICO 文件包含多个尺寸,系统自动选择合适的
- •位深度:32-bit PNG with alpha channel
2.2 macOS (.png)
- •必须使用 Template Image:
- •文件名包含
Template关键字(如iconTemplate.png) - •纯黑色 + 透明背景(系统会根据深色/浅色模式反转)
- •尺寸:16x16 和 32x32(@2x 用于 Retina)
- •文件名包含
- •禁止:彩色图标(会在深色模式下不可见)
2.3 Linux (.png)
- •尺寸:16x16, 24x24, 32x32
- •自动使用 StatusNotifierItem
3. Electron Builder 打包配置
3.1 extraResources(将文件复制到 resources 目录)
yaml
# electron-builder.yml
extraResources:
- from: 'build'
to: 'build'
filter:
- 'icon.ico'
- 'icon.png'
3.2 asarUnpack(解压 asar 包中的资源)
yaml
asarUnpack: - resources/**
说明:
- •
extraResources复制到打包后的app.asar.unpacked/或resources/目录 - •
asarUnpack将 asar 包中的文件解压(用于需要文件系统访问的资源)
3.3 常见错误
yaml
# ❌ 错误:files 配置排除了 build 目录
files:
- '!build/*' # 这会导致 icon.ico 无法打包
# ✅ 正确:通过 extraResources 显式包含
extraResources:
- from: 'build'
to: 'build'
filter: ['icon.ico', 'icon.png']
4. Tray 实例化标准流程
4.1 创建托盘(在 app.whenReady() 后)
typescript
import { app, Tray, Menu, nativeImage } from 'electron'
import path from 'path'
class AppService {
private tray: Tray | null = null
async initialize(): Promise<void> {
await app.whenReady()
// 创建窗口...
this.createMainWindow()
// 创建托盘(必须在 app ready 之后)
this.createTray()
}
private createTray(): void {
try {
const iconPath = this.getIconPath()
const icon = nativeImage.createFromPath(iconPath)
// 检查图标是否加载成功
if (icon.isEmpty()) {
logger.error('Failed to load tray icon', { iconPath })
// 创建空托盘(至少保证托盘功能可用)
this.tray = new Tray(nativeImage.createEmpty())
} else {
this.tray = new Tray(icon)
}
// 设置托盘提示
this.tray.setToolTip('应用名称')
// 创建右键菜单
const contextMenu = Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => this.showMainWindow() },
{ type: 'separator' },
{ label: '退出应用', click: () => app.quit() }
])
this.tray.setContextMenu(contextMenu)
// 可选:单击托盘显示/隐藏窗口
this.tray.on('click', () => {
const mainWindow = this.windowService.getMainWindow()
if (mainWindow?.isVisible()) {
mainWindow.hide()
} else {
mainWindow?.show()
}
})
} catch (error) {
logger.error('Failed to create tray', error)
}
}
private getIconPath(): string {
if (app.isPackaged) {
return process.platform === 'win32'
? path.join(process.resourcesPath, 'build', 'icon.ico')
: path.join(process.resourcesPath, 'resources', 'icon.png')
} else {
return process.platform === 'win32'
? path.join(__dirname, '../../../build/icon.ico')
: path.join(__dirname, '../../../resources/icon.png')
}
}
}
4.2 必须保持全局引用
typescript
// ✅ 正确:tray 是类成员变量
private tray: Tray | null = null
// ❌ 错误:局部变量会被 GC 回收导致托盘消失
createTray() {
const tray = new Tray(icon) // 函数结束后被回收
}
5. 窗口关闭行为(保持托盘运行)
5.1 阻止 window-all-closed 退出应用
typescript
app.on('window-all-closed', () => {
// 默认行为:Windows/Linux 下关闭所有窗口会退出应用
// macOS 下会保持应用在 Dock 中运行
// 修改后:所有平台都保持应用在托盘中运行
logger.info('All windows closed, app continues running in tray')
// 不调用 app.quit()
})
5.2 退出应用的正确方式
typescript
// 必须通过托盘菜单或其他 UI 明确调用 app.quit()
const contextMenu = Menu.buildFromTemplate([
{
label: '退出应用',
click: () => {
logger.info('User quit from tray menu')
app.quit() // 唯一的退出方式
}
}
])
5.3 Graceful Shutdown
typescript
app.on('before-quit', async (event) => {
event.preventDefault()
// 清理资源
await this.surrealDBService.shutdown()
apiServerBridge.kill()
// ...
app.exit(0)
})
6. 用户体验最佳实践
6.1 托盘菜单建议
typescript
// 最小菜单(仅退出)
Menu.buildFromTemplate([
{ label: '退出应用', click: () => app.quit() }
])
// 推荐菜单(显示 + 退出)
Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => mainWindow.show() },
{ type: 'separator' },
{ label: '退出应用', click: () => app.quit() }
])
// 完整菜单(带状态/快捷操作)
Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => mainWindow.show() },
{ type: 'separator' },
{ label: '新建文档', click: () => createDocument() },
{ label: '打开最近', submenu: recentFiles },
{ type: 'separator' },
{ label: '状态:运行中', enabled: false },
{ type: 'separator' },
{ label: '退出应用', click: () => app.quit() }
])
6.2 托盘交互
- •单击:显示/隐藏主窗口(最常用)
- •右键:显示上下文菜单(标准)
- •双击:macOS 上可绑定特殊操作
- •中键点击:很少使用,避免依赖
6.3 图标状态
typescript
// 动态更新图标(表示状态变化) const idleIcon = nativeImage.createFromPath(idleIconPath) const busyIcon = nativeImage.createFromPath(busyIconPath) tray.setImage(busyIcon) // 任务进行中 // ... 任务完成 ... tray.setImage(idleIcon) // 恢复空闲状态
7. 调试与排查
7.1 图标不显示的常见原因
- •
路径错误:
- •检查
app.isPackaged分支 - •打印实际路径:
logger.debug('Icon path:', iconPath) - •验证文件存在:
fs.existsSync(iconPath)
- •检查
- •
图标格式问题:
- •Windows:使用 ICO 而非 PNG
- •macOS:忘记使用 Template Image
- •图标尺寸不标准(太小或太大)
- •
资源未打包:
- •检查
electron-builder.yml的extraResources配置 - •构建后检查
dist/win-unpacked/resources/目录
- •检查
- •
图标为空:
typescriptconst icon = nativeImage.createFromPath(iconPath) if (icon.isEmpty()) { logger.error('Icon is empty', { iconPath, exists: fs.existsSync(iconPath), size: icon.getSize() }) }
7.2 调试日志
typescript
logger.debug('Creating tray icon', {
platform: process.platform,
isPackaged: app.isPackaged,
iconPath,
__dirname,
resourcesPath: process.resourcesPath
})
const icon = nativeImage.createFromPath(iconPath)
logger.debug('Icon loaded', {
isEmpty: icon.isEmpty(),
size: icon.getSize()
})
7.3 图片位深度检查
bash
# 使用 ImageMagick 检查 PNG 格式 magick identify -verbose icon.png | grep -E "Colorspace|Depth|Alpha" # 应该输出: # Colorspace: sRGB # Depth: 8-bit # Alpha: On
8. 平台特定注意事项
8.1 Windows
- •使用
.ico格式获得最佳效果 - •图标会显示在任务栏右下角(系统托盘)
- •隐藏的图标需要点击 ^ 箭头展开
- •建议尺寸:16x16, 32x32, 256x256
8.2 macOS
- •必须使用 Template Image(黑色 + 透明)
- •图标显示在菜单栏右侧
- •系统会根据深色/浅色模式自动调整颜色
- •推荐尺寸:16x16 (@1x), 32x32 (@2x)
8.3 Linux
- •使用 StatusNotifierItem / AppIndicator
- •不同桌面环境行为不一致(GNOME / KDE / XFCE)
- •某些环境需要安装
libappindicator库
9. 新项目检查清单
添加托盘功能时必须完成:
- •
准备图标文件
- • Windows:
build/icon.ico(多尺寸容器) - • macOS:
resources/iconTemplate.png(黑色 + 透明) - • Linux:
resources/icon.png
- • Windows:
- •
配置打包
- •
electron-builder.yml添加extraResources - • 测试打包后文件是否存在:
dist/win-unpacked/resources/build/
- •
- •
实现托盘逻辑
- • 创建
createTray()方法(在app.whenReady()后调用) - • 使用
app.isPackaged处理路径 - • 检查
icon.isEmpty() - • 设置托盘菜单
- • 将
tray保存为类成员变量(避免 GC)
- • 创建
- •
修改窗口行为
- • 修改
window-all-closed事件(不调用app.quit()) - • 托盘菜单提供"退出应用"选项
- • 修改
- •
测试验证
- • 开发环境:
pnpm dev托盘图标显示 - • 打包环境:
pnpm build:win安装后托盘图标显示 - • 关闭主窗口后应用继续运行
- • 右键托盘可退出应用
- • 开发环境:
10. 常见陷阱
10.1 路径拼接错误
typescript
// ❌ 错误:硬编码相对路径
const icon = nativeImage.createFromPath('./icon.png')
// ❌ 错误:使用 process.cwd()(会变化)
const icon = nativeImage.createFromPath(path.join(process.cwd(), 'icon.png'))
// ✅ 正确:根据打包状态选择
const iconPath = app.isPackaged
? path.join(process.resourcesPath, 'build', 'icon.ico')
: path.join(__dirname, '../../../build/icon.ico')
10.2 忘记配置 extraResources
yaml
# ❌ 错误:只配置了 files(图标会被排除)
files:
- 'out/**/*'
# ✅ 正确:显式包含图标
extraResources:
- from: 'build'
to: 'build'
filter: ['icon.ico']
10.3 macOS 彩色图标不可见
typescript
// ❌ 错误:使用彩色 PNG
const icon = nativeImage.createFromPath('icon-color.png')
// ✅ 正确:使用 Template Image
const icon = nativeImage.createFromPath('iconTemplate.png')
tray.setImage(icon)
11. 参考实现
完整的 AppService 托盘实现:
typescript
import { app, Tray, Menu, nativeImage, BrowserWindow } from 'electron'
import path from 'path'
import { logger } from './logger'
export class AppService {
private tray: Tray | null = null
private windowService: WindowService
async initialize(): Promise<void> {
await app.whenReady()
this.windowService.createMainWindow()
this.createTray()
this.setupAppEvents()
}
private createTray(): void {
try {
const iconPath = this.getIconPath()
const icon = nativeImage.createFromPath(iconPath)
if (icon.isEmpty()) {
logger.error('Failed to load tray icon', { iconPath, isPackaged: app.isPackaged })
this.tray = new Tray(nativeImage.createEmpty())
} else {
this.tray = new Tray(icon)
}
this.tray.setToolTip('应用名称')
const contextMenu = Menu.buildFromTemplate([
{
label: '显示主窗口',
click: () => {
const mainWindow = this.windowService.getMainWindow()
mainWindow?.show()
}
},
{ type: 'separator' },
{
label: '退出应用',
click: () => {
logger.info('User quit from tray menu')
app.quit()
}
}
])
this.tray.setContextMenu(contextMenu)
this.tray.on('click', () => {
const mainWindow = this.windowService.getMainWindow()
if (mainWindow?.isVisible()) {
mainWindow.hide()
} else {
mainWindow?.show()
}
})
logger.info('Tray icon created successfully')
} catch (error) {
logger.error('Failed to create tray', error)
}
}
private getIconPath(): string {
if (app.isPackaged) {
return process.platform === 'win32'
? path.join(process.resourcesPath, 'build', 'icon.ico')
: path.join(process.resourcesPath, 'resources', 'icon.png')
} else {
return process.platform === 'win32'
? path.join(__dirname, '../../../build/icon.ico')
: path.join(__dirname, '../../../resources/icon.png')
}
}
private setupAppEvents(): void {
app.on('window-all-closed', () => {
logger.info('All windows closed, app continues running in tray')
// 不调用 app.quit()
})
app.on('before-quit', async (event) => {
event.preventDefault()
logger.info('App is quitting, cleaning up...')
// 清理资源
await this.cleanup()
app.exit(0)
})
}
}