Electron 最小化到托盘:Tray 静默失败会留下无法退出的僵尸进程
「关 X 不退出,常驻托盘后台」是桌面客户端的经典 UX 需求。实现思路很直接:拦截 close 事件、调 window.hide()、把 window-all-closed 改成 noop,再在 Tray 图标上挂「点击唤回 + 菜单退出」。
看起来干净——但有一个被普遍忽视的前提:Tray 必须创建成功。一旦这个前提在打包产物里悄悄失效,整条 UX 路径就会在用户侧无声崩塌,留下一个没有任何 UI 入口、又无法正常退出的僵尸进程。
现象
用户点 X 关闭主窗口:
- 窗口消失
- 托盘区看不到任何图标
- 应用进程仍在活动监视器 / 任务管理器里运行,CPU/内存持续占用
- 后台 sidecar(Chromium、RPC server 等)全部存活
- 再次启动应用时,若 sidecar 占用了端口,新实例启动失败
用户唯一的逃生路是打开任务管理器强杀,或者重启机器。
根因
问题出在 new Tray(iconPath) 在打包环境里的失败率远高于 dev。常见原因:
- 托盘图标资源没有打进
resources/目录(路径解析用了相对路径,dev 下恰好存在) - macOS 权限或 Linux 无 systray 支持,OS 拒绝创建 status item
- 路径含空格或特殊字符,packaged 时解析出错
大多数实现会在 Tray 构造处加 try/catch——但只是打个日志、把 tray 置 null,没有同步修改 window-all-closed 的行为。
于是三行代码凑成一个陷阱:
// close 拦截挂在 tray.attach() 里 ——tray 失败时根本没挂
shell.window.on('close', e => {
e.preventDefault()
shell.window.hide()
})
// 这行是为了 tray 接管而改的 noop
app.on('window-all-closed', () => {})tray.attach() 判断 tray !== null 就直接 return,close 拦截没有注册,window 正常关闭触发 window-all-closed,然而 window-all-closed 已经被改成了什么都不做——进程永久挂起。
「关 X → hide」的前提是 Tray 是用户重新呼出窗口和真正退出的唯一入口。
Tray 失败 = 入口消失 = hide 没有资格存在。
此时 window-all-closed 是 noop,进程无法通过任何正常 UI 路径退出。
正解
暴露 isCreated() 契约,在 main.ts 里互斥挂载两套 window-all-closed 行为:
// tray-host.ts
let tray: Tray | null = null
try {
tray = new Tray(input.iconPath)
// ... 配置 menu / click ...
} catch (err) {
log.error('tray create failed', err)
// tray 留 null,不做任何假装
}
return {
isCreated: () => tray !== null,
attach(shell: { window: BaseWindow }) {
if (!tray) return // 关键:tray 失败时不挂 close 拦截
shell.window.on('close', e => {
if (isQuitting) return
e.preventDefault()
shell.window.hide()
})
},
}// main.ts
const trayHost = createTrayHost({ ... })
if (trayHost.isCreated()) {
// 正常路径:tray 接管,close = hide,window-all-closed = noop
trayHost.attach(shell)
app.on('window-all-closed', () => {})
} else {
// 兜底路径:退化回「关 X = 干净退」原行为
app.on('window-all-closed', () => {
browser?.destroy()
sidecar?.stop()
app.quit() // mac + win 都真退,不保留 mac 特殊分支
})
}几个细节:
- 两个分支互斥,不要在正常路径里保留
app.quit()兜底——那会让 tray 存在时也提前退出 - macOS 同样要在兜底路径里调
app.quit();「tray 没了还在后台跑」比「干净退出」风险大得多 - fallback 路径先 destroy/stop 子进程,再
app.quit(),顺序不要颠倒 log.error要打到可远程查询的 sink,而不只是console.error——打包后的 tray 失败你很可能看不到本地日志
Tray 成功 → window-all-closed noop,attach() 挂 close 拦截。
Tray 失败 → window-all-closed 清理子进程后 app.quit(),attach() 什么都不做。
两个分支互斥,不混合。
验证清单
- 正常路径:tray 成功 → 关 X → 窗口消失、托盘可见、单击唤回、菜单退出彻底
- 兜底冒烟:人为破坏 tray icon 路径(改环境变量或删 packaged resource)→ 启动后日志出
tray create failed→ 关 X → 进程完整退出(ps -ef | grep <app>无残留) - packaged 验证:用真实 dmg / exe 跑兜底冒烟,不要只在 dev 测——packaged 才是 tray 失败的高发场景
一句话外卖
「关 X → hide」以 Tray 存活为前提,Tray 是单点入口;单点失败时必须有降级路径,否则 try/catch 吞掉的不是错误,是用户的退出权。