pnpm-pub 有过一个很小的 bug。窗口最小化以后,托盘菜单还写着 Hide Window。
用户看到这个文案会有点困惑:窗口明明不在眼前,为什么还要 Hide?代码看起来也很好修,加一个 isOpen = false 不就行了。
麻烦就在 isOpen 这个词。一个托盘窗口被最小化了,它算开着吗?被隐藏但 WebView 还在,它算开着吗?窗口失焦后自动收起,和用户主动关闭,是不是同一种状态?
如果这几个问题没有先说清楚,Show/Hide 文案只是第一个出错的地方。后面还会遇到重复创建窗口、页面状态丢失、任务栏多出图标,以及同一个 broker 被反复订阅。
托盘图标和窗口不是一回事
普通桌面应用通常把主窗口当成应用本身。窗口关了,应用也就结束了。托盘应用不是这样。托盘图标可以一直存在,窗口则随时显示或隐藏。
OpenTray 里至少有三个对象:托盘图标、原生窗口和 WebView 会话。托盘图标负责入口和菜单;原生窗口负责位置、焦点、任务栏和最小化;WebView 会话负责页面、JavaScript 状态和浏览器资源。
托盘应用
├─ Tray icon:长期存在,接收点击
├─ Native window:显示、隐藏、最小化、置顶
└─ WebView session:页面状态、Cookie、脚本运行时
用户隐藏窗口时,通常只想收起面板,并不想重新登录,也不想丢掉刚才打开的工作区。因此 OpenTray 使用 retained session,也就是保留式窗口会话:窗口暂时不显示,但 HWND、WebView 和页面状态继续存在。
show() 只做第一次
第一次打开窗口时,OpenTray 要创建原生窗口、装载 WebView、注册事件,再把它显示出来。这一整套动作由 show() 完成。它更接近 bootstrap,不是一个可以反复调用的普通显示开关。
会话建立以后,动作就要分开:
show() 第一次创建并装载窗口会话
toVisible() 恢复已经隐藏或最小化的窗口
close() 隐藏窗口,但保留 WebView 会话
destroy() 释放原生窗口和 WebView
如果每次点击托盘都重新 show(),页面可能重复装载,listener 也可能重复注册。反过来,如果每次 close() 都销毁窗口,WebView 页面和缓存又会不断重建。
所以托盘点击不是“创建或销毁”,而是“显示或隐藏同一个 retained session”。真正退出程序时才调用 destroy()。
最小化为什么不算可见
现在回到最开始那个菜单文案。Windows 有一个 IsWindowVisible API,但它回答的是窗口 style 层面的可见性,不完全等于用户能不能操作。一个最小化窗口可能仍然属于 visible window,却已经不在用户眼前。
OpenTray 因此使用 operational visibility。这个词听起来有点长,意思其实很普通:窗口没有被隐藏,也没有最小化,用户现在可以操作它。
operationalVisible = windowExists
and not hidden
and not minimized
托盘菜单关心的是这个状态。窗口最小化以后,菜单应该显示 Show Window;用户点击以后,OpenTray 调用 toVisible(),先恢复原生窗口,再把它带回前台。
不要让页面猜 HWND
WebView 页面也有 document.visibilityState 和 blur 事件。它们很容易被拿来判断窗口状态,但它们回答的是页面问题。document.visibilityState 描述文档是否可见,blur 描述焦点离开,二者都不知道 HWND 是否最小化、被原生代码隐藏,或者还在任务切换器里。
pnpm-pub 早期如果自己维护一份 shown 布尔值,就会遇到一个很现实的问题:页面以为窗口还显示着,Windows 已经把它最小化了。两份状态没有谁能自动纠正谁。
OpenTray 让原生窗口成为事实源。应用可以主动查询 isVisible(),也可以订阅 visibleChange。最小化、原生 close、失焦 auto-hide 都会收敛成同一种事件。
onNativeVisibilityChanged(visible):
tray.primaryLabel = visible ? "Hide Window" : "Show Window"
webUi.windowVisible = visible
托盘菜单和页面都只是这个事实的投影。菜单文字不能反过来充当状态,也不应该再发一个私有 windowHidden 事件维护第二份答案。
一次托盘点击应该怎么写
托盘主按钮的逻辑可以保持很短,但它必须先问原生窗口:
toggleWindow():
visible = await nativeWindow.isVisible()
if visible:
await nativeWindow.close()
else:
await nativeWindow.toVisible()
注意这里返回的是 Promise。原生显示和隐藏不是同步赋值,broker 可能拒绝命令,窗口也可能正在退出。菜单事件可以不使用返回值,但适配器应该把同一个 Promise 交出来,让关闭流程、测试和上层宿主都能等到动作结束。
pnpm-pub 后来还在 toggle() 内部记录原生拒绝,避免事件源忽略 Promise 后出现 unhandled rejection。这个细节看起来偏底层,却会直接决定 daemon 会不会因为一次托盘点击留下未处理错误。
autoHide 是谁负责关门
多数托盘面板有一个共同习惯:用户点到别处,窗口自动收起。OpenTray 把它叫作 autoHide,默认由原生宿主处理。
onWindowBlur():
if style.autoHide and not style.keepOnTop:
hideRetainedSession()
emit visibleChange(false)
为什么还要看 keepOnTop?因为一个明确要求置顶的窗口,不应该每次失焦都自动消失。keepOnTop 和 autoHide 是两个设置,只是在这个条件里产生交集。
pnpm-pub 又是一个例外。它关闭窗口前要播放页面动画,因此设置 autoHide: false。页面完成动画以后只报告 completeAutoClose,daemon 再调用原生 close()。页面负责动画,原生宿主负责窗口事实。
这个分工很适合带确认步骤、草稿保护或者退出动画的托盘应用。普通工具则直接使用原生 auto-hide,少维护一套页面计时。
任务栏里有没有图标,是另一件事
托盘窗口通常不应该出现在 Windows 任务栏或 Alt+Tab。这个行为和 auto-hide、keep-on-top 都没有从属关系。
OpenTray 用 showInSwitchers 表达它。Windows 内部会投影到 extended window style:WS_EX_APPWINDOW 倾向于让窗口进入任务栏,WS_EX_TOOLWINDOW 则更适合工具窗和托盘面板。
showInSwitchers = false
-> add WS_EX_TOOLWINDOW
-> remove WS_EX_APPWINDOW
窗口可以保持置顶但不进任务栏,也可以失焦自动隐藏但仍进入任务栏。把这些选择合成一个 pinned 字段,短期少写几个配置,长期一定会遇到互相覆盖的判断。
为什么终端会重复打印 broker connection closed
窗口状态最终要从原生 broker 传回 JavaScript。OpenTray 的 WebView listener 会轮询一条事件 drain 接口。一次实现问题让每个 listener 都启动自己的轮询,几个请求可能在同一时刻读同一条连接。
broker 一旦关闭,这些重叠请求会一起失败,于是终端反复打印 broker connection closed。看上去像窗口连续崩了很多次,实际是同一个 transport 失败被多个轮询重复报告。
修复方式是 shared single-flight loop:
drainLoop():
if requestInFlight:
return
requestInFlight = true
try:
events = await broker.drainWindowEvents()
dispatch(events)
catch transportError:
stopLoop()
reportOnce(transportError)
finally:
requestInFlight = false
listener 的数量只影响事件分发,不应该改变 broker 请求数量。熟悉前端的人可以把它类比成共享一条 WebSocket;熟悉后端的人可以把它看成一个不能被重复消费的长轮询。
托盘窗口的状态机
把上面的规则合在一起,状态机并不复杂:
未创建
│ show()
▼
可操作可见
├─ close() ─────────────> 隐藏 retained session
├─ minimize() ──────────> 最小化 retained session
├─ blur + autoHide ─────> 隐藏 retained session
└─ destroy() ───────────> 已销毁
隐藏或最小化
├─ toVisible() ─────────> 可操作可见
└─ destroy() ───────────> 已销毁
每次状态变化都由原生窗口计算 operational visibility,再发出 visibleChange。托盘菜单更新下一次动作,页面更新显示投影,但没有任何一层重新发明窗口状态。
到这里,最开始那个 Hide Window bug 已经不用单独打补丁了。菜单每次都向原生窗口询问 operational visibility:看得见就显示 Hide,看不见就显示 Show。最小化、失焦收起和用户主动隐藏,都会回到同一个事实源。
以后再做 Electron、Tauri、WebView2 或原生托盘应用,我会先在纸上画出托盘图标、原生窗口和页面会话,再问三件事:谁决定窗口是否可操作,谁负责保留页面状态,谁控制任务栏投影。框架名称会变,这三个边界不会跟着消失。
OpenTray 这轮修复从一句错误的菜单文案开始,最后留下的不是一个更聪明的布尔值,而是一套能解释隐藏、最小化、恢复和销毁的窗口状态机。