返回博客

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——但只是打个日志、把 traynull没有同步修改 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 已经被改成了什么都不做——进程永久挂起。

坑:Tray 失败时 window-all-closed 的 noop 会让进程永久驻留

「关 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 失败你很可能看不到本地日志
正解:用 isCreated() 契约让 window-all-closed 与 Tray 状态绑定

Tray 成功 → window-all-closed noop,attach() 挂 close 拦截。

Tray 失败 → window-all-closed 清理子进程后 app.quit()attach() 什么都不做。

两个分支互斥,不混合。

验证清单

  1. 正常路径:tray 成功 → 关 X → 窗口消失、托盘可见、单击唤回、菜单退出彻底
  2. 兜底冒烟:人为破坏 tray icon 路径(改环境变量或删 packaged resource)→ 启动后日志出 tray create failed → 关 X → 进程完整退出ps -ef | grep <app> 无残留)
  3. packaged 验证:用真实 dmg / exe 跑兜底冒烟,不要只在 dev 测——packaged 才是 tray 失败的高发场景

一句话外卖

「关 X → hide」以 Tray 存活为前提,Tray 是单点入口;单点失败时必须有降级路径,否则 try/catch 吞掉的不是错误,是用户的退出权。