TunnelMesh 托盘:任务栏快捷小窗(macOS)
TunnelMesh 托盘:任务栏快捷小窗(macOS)
目标
- 在托盘「通用」tab 增加「任务栏快捷小窗」单选项(关闭 / 开启,默认关闭)。
- 开启后,点击菜单栏图标不再直接弹菜单,而是弹出快捷小窗:紧凑显示运行状态、服务端连通、隧道与流量汇总、每条隧道状态,并提供「打开主界面 / 启动或停止 / 退出」操作。
- 小窗关闭后不影响隧道;菜单栏菜单在小窗开启时改由右键(或 ⌃ 左键)打开,三个原菜单项(打开主界面 / 打开官网首页 / 退出)全部保留。
- 关闭该设置时行为与现状逐字节一致:任意点击都弹出菜单。
架构决策
- 小窗是同一份前端的一个路由,而不是第二套界面:WKWebView 加载
api.URL() + "#/panel",main.ts按 hash 选择根组件。复用/api/stats、/api/settings、/api/actions/*、i18n 与主题逻辑,避免第二个数据源。 - 小窗只走已存在的本地 API:
show-window、quit、start、stop早已在internal/tray/api.go注册,本次首次把它们接到原生层(SetWindowHandlers此前只在测试里被调用),因此小窗不需要任何新的原生回调面。 - 原生侧只加“什么时候显示什么”:左键/右键分流与
NSPopover归属 macOS shell;配置项真值的唯一来源仍是tray.json,Go 在保存后同时推送给 shell(TMTraySetQuickPanel),避免 shell 反向查询造成卡顿。 - 采用
NSPopover(transient)而非自建NSPanel:定位、指向箭头、点击外部自动收起由系统负责,少一套窗口生命周期代码(KISS)。 - 保持既有隔离约束:全部 Cocoa 代码仍在
//go:build tray && darwin之后,默认构建不触达 WebKit。
技术栈
- Go 1.x(
internal/tray、internal/tray/native、cmd/tunnelmesh-client-tray)。 - Objective-C + AppKit/WebKit(
tray_darwin.m)。 - Vue 3 + TypeScript + Element Plus + vue-i18n + Pinia(
web-tray/),vitest 单测。
规格引用
docs/superpowers/plans/2026-10-05-client-system-tray-macos.md(托盘总体设计与互斥/配置约定)。docs/user-guide/client-tray.md、docs/deployment/macos-client-tray.md。
全局约束
- 不改
tunnelmesh-clientCLI 命令面与外部行为。 tray.json只放 UI 偏好,不污染client.yaml校验;文件 0600。- 本地 API 仍仅绑 loopback 并逐请求校验 secret;小窗与主窗口共用同一 secret,不新增免鉴权端点。
- 新字段向后兼容:旧
tray.json缺quickPanel必须按“关闭”加载。
文件清单
internal/tray/prefs.go:Preferences.QuickPanel+ 默认值。internal/tray/prefs_test.go、internal/tray/app_test.go、internal/tray/api_test.go:红→绿测试。internal/tray/app.go:SettingsView.QuickPanel、SettingsUpdate.QuickPanel、保存与变更通知。internal/tray/api.go:APIServer.PanelURL()。internal/tray/native/native.go:TMTrayConfig.panelURL/quickPanel、SetQuickPanel、Config/DefaultConfig字段。internal/tray/native/tray_darwin.m:状态项左右键分流、NSPopover+ 面板 WKWebView、TMTraySetQuickPanel、面板导航策略与失败重载。cmd/tunnelmesh-client-tray/main.go:面板 URL 注入、偏好变更推送、SetWindowHandlers接线。deploy/macos/tray_shim_test.go:原生外壳守卫测试(源码断言)。web-tray/src/panelRoute.ts(新)、web-tray/src/QuickPanelApp.vue(新):hash 路由与紧凑界面。web-tray/src/main.ts:按 hash 挂载根组件。web-tray/src/views/GeneralView.vue、web-tray/src/stores/settings.ts、web-tray/src/api/types.ts:设置项与表单。web-tray/src/i18n/zh-CN.ts、web-tray/src/i18n/en-US.ts:文案。web-tray/src/tests/helpers.ts、general-view.spec.ts、quick-panel.spec.ts(新):前端测试。docs/user-guide/client-tray.md、docs/deployment/macos-client-tray.md、docs/pull-requests/2026-10-06-tray-quick-panel.md、docs/README.md(索引重建)。
任务间接口
tray.Preferences{ QuickPanel bool \json:”quickPanel”` }`,默认 false。SettingsView.QuickPanel bool \json:”quickPanel”`;SettingsUpdate.QuickPanel *bool `json:”quickPanel,omitempty”``(nil=不改)。func (s *APIServer) PanelURL() string==URL() + "#/panel"。- 前端:
isPanelRoute(hash: string): boolean(仅接受#/panel);SettingsView.quickPanel: boolean、SettingsUpdate.quickPanel?: boolean。 - 原生:
TMTrayConfig{ const char *panelURL; int quickPanel; }、void TMTraySetQuickPanel(int enabled)。
TDD 步骤
- 红:
prefs_test.go断言默认 false、显式 true 能落盘回读;app_test.go断言Settings暴露该值且SaveSettings触发OnPreferencesChanged;api_test.go断言PUT /api/settings {"quickPanel":true}生效、PanelURL()带#/panel。预期:编译失败/断言失败(字段不存在)。 - 绿(Go):加字段与默认值 → 视图/更新处理 →
PanelURL()。 - 红→绿(原生):
tray_shim_test.go增加守卫(面板 URL 载入、NSPopover、左右键分流、TMTraySetQuickPanel、statusItem.menu不再常驻导致左键无法接管);随后实现.m/native.go。预期先失败于缺少符号/字符串,实现后通过。 - 红→绿(前端):
general-view.spec.ts断言出现「任务栏快捷小窗」单选组、默认「关闭」、保存携带quickPanel;新增quick-panel.spec.ts断言#/panel渲染紧凑小窗、2s 轮询 stats、三个动作按钮调用对应本地 API。 - 文档与索引:
python3 scripts/gen_doc_index.py。 - 全量验证:
go test ./... -count=1、go vet ./...、CGO_ENABLED=1 go vet -tags tray ./...、cd web-tray && npm test -- --run && npm run build、bash -n scripts/package-macos-tray.sh、git diff --check。 - macOS 实机冒烟:打包并替换
/Applications/TunnelMesh Client.app,验证左键出小窗、右键出菜单、开关关闭后左键回菜单、小窗内三按钮可用、浅色/深色与中英跟随。
验证命令
见上第 6 步;macOS 打包:bash scripts/package-macos-tray.sh(产出 .app 与拖拽安装 .dmg)。
回滚注意事项
- 纯增量:删除
tray.json中的quickPanel或把设置切回「关闭」即恢复旧行为;.app用备份包覆盖即可回滚,隧道配置与 token 不受影响。 - 无数据库、无协议、无 API 破坏性变更;
/api/v1/*未改动。
追加记录:macOS 实机验证中发现并修掉的四件事
以下都不是推演出来的,是在 macOS 14.6 主机上驱动真实状态项(System Events 点击 + CGEvent 右键 + 窗口列表核对)时暴露的,实现时已一并修掉并补了守卫测试。
- 动作必须挂在 mouse-up。 最初把 action 配成
NSEventMaskLeftMouseDown | NSEventMaskRightMouseDown,实测主线程会永久停在-[NSCell trackMouse:inRect:ofView:untilMouseUp:]里:状态按钮的 tracking 从按下一直占住主线程到 松开,而我们在它内部弹菜单;菜单自己的模态跟踪吃掉了那个 mouse-up,按钮的 tracking 就 再也等不到结束事件。后果是图标失灵,且TMTrayStop排在主队列上永远不执行——SIGTERM 收到后进程不退出,锁与隧道一直被占着。改成 mouse-up 触发后,实测「左键→右键→Esc」再 SIGTERM 约 500ms 内干净退出。 - popover 需要成为 key 窗口。 accessory 应用平时没有 key window,WKWebView 只把键盘
输入路由给 key window,非 key 窗口的控件还会吞掉第一次点击。显示后取
panelWebView.window调makeKeyAndOrderFront:(拿不到就下一轮主队列再试一次)。 - Esc 要能关掉小窗。 用局部事件监视,且只在
panelPopover.isShown时接管,避免把 主窗口里 Esc 关下拉/对话框的语义抢走。 - 不可见窗口的 close 必须忽略。
windowShouldClose:是「关窗=隐藏还是退出」的判 据点;一次经响应链误传到隐藏窗口的 cancel 会让「关闭时最小化到托盘=关」的配置直接退出托 盘、断掉所有隧道。先判sender.isVisible。
另外加了 shutdownWatchdog(3s):信号处理里停完隧道与本地接口后,若 run loop 因任何
AppKit 嵌套循环没能返回,就记一条日志并退出,而不是把退出交给系统的强杀。
追加记录:实机反馈「快捷小窗没有变化」的定位与修复
用户反馈:开启「任务栏快捷小窗」后,菜单栏行为似乎没有变化。按 TDD 先复现、再修:
- 原生侧确认可用。 用 CGEvent 向真实状态项发一次左键(mouse-down/up),
CGWindowList读到 366×446 的 popover;再用无障碍树读它的AXWebArea,内容是小窗本体(状态、服务端、运行时长、 隧道数、活跃流、已接收、重连、逐隧道行、三个动作按钮、更新时间)。因此「弹小窗」这条链路是通的。 - Go 侧排除。
internal/tray的TestAppQuickPanelIsReportedAndObservable与TestAPISettingsQuickPanelRoundTrip覆盖 GET/PUT 与tray.json落盘;安装到/Applications的二进制里quickPanel的 json tag 与当前前端产物(index-BROhsCFn.js)都在, 不是旧包。 - 前端排除,但补了一个真空洞。 原有用例都在挂载之前把 store 灌好(
prepare: seed), 没有覆盖真实顺序:窗口先渲染、/api/settings之后才回来。新增app.spec.ts > general tab reflects the tray after an asynchronous load,按真实顺序断言单选框 选中项(用索引而不是文案,避免语言解析影响断言)。该用例通过,说明读取链路没问题。 - 真正的原因:改动不落盘,而界面不说。 「通用」页所有选项都要点底部保存才写
tray.json;只点单选框时表单变了、托盘行为不变,而保存按钮在 50% 窗口里位于折叠线以下, 看不见就以为设置无效。这是可观测性缺陷,不是功能缺陷。
修复(保持”表单级保存”的既有设计,不改成点击即写盘):
GeneralView.vue增加dirty计算属性,有未保存改动时在保存按钮旁显示general.unsaved(zh:有未保存的更改,点“保存”后生效。/ en:Unsaved changes — press Save to apply them.),保存成功后自动消失。.tm-actions改为position: sticky; bottom: 0并带背景色,窗口再小保存栏也不会被滚走。- 「任务栏快捷小窗」的提示文案补上”点保存后对下一次点击生效,无需重启”,与
docs/user-guide/client-tray.md的表格、FAQ 同步。 - 新增
general-view.spec.ts > unsaved changes:未改动时不显示、点开启后显示、保存后消失。
验证:npx vitest run 87 通过(新增 3);npm run build 通过并镜像 internal/tray/webdist/dist。
追加记录:小窗显示”服务端不可达”而隧道在跑
继续实机核对小窗内容时发现第二个问题,与图标无关,但同样会让用户认为”设置没生效”:
- 现象:小窗「服务端」一行显示不可达,而
curl -x socks5h://127.0.0.1:18081 http://www.baidu.com返回 200,隧道明显在工作。 - 根因:可达性来自
GET /health/ready。本机 Server 部署在 openresty 之后,代理对该路径返回 403(https://tunnelmesh.claw.qihoo.net/health/ready实测 403,站点根路径 200), 而/ws/client与/api/v1是放行的。原实现把”应答但被拒”和”完全无应答”合并成同一个err != nil,于是报告”不可达”,并且检测把整份配置判为不可用。
修复按 TDD 分三层,先红后绿:
internal/tray/server.go:新增AnsweredError{Status},CheckHealth对非 2xx 返回该类型, 传输层失败仍返回普通错误。用例TestServerClientCheckHealthDistinguishesAnAnswer/TestServerClientCheckHealthTransportFailureIsNotAnAnswer。internal/tray/validate.go:应答但被拒 →serverUrl.reachable记为 warning(消息保留原始unexpected status 403),并且不再跳过后续检查,token.valid用真正可用的 API 路径给出结论。 用例TestValidateWarnsWhenTheHealthEndpointIsRefused(含blockedHealthServer夹具:健康端点 403、 API 正常)。internal/tray/app.go:refreshServerProbe把”有应答”计为可达,原始结论继续放在serverProbe明细里。用例TestStatsProbeCountsAnAnsweredRefusalAsReachable。- 前端:小窗只有一行,因此有会话时直接显示「已连接」(会话是比探针更强的证据);统计页在汇总
卡片下面显示健康检查原始结论;检测的总标题不再对警告说”全部检测通过”,改为
「检测通过,但有 N 项警告」。用例见
quick-panel.spec.ts、stats-view.spec.ts、routing-view.spec.ts。
实机复验(v1.3.6-quickpanel,macOS 14.6):
- 左键 → 小窗「服务端」= 已连接,隧道数 1/1、活跃流随流量变化。
- 检测 → 总标题「检测通过,但有 1 项警告。」,
服务端可达性行 = 警告 +unexpected status 403,token.valid等其余项 = 通过。 - 统计 → 汇总下方出现「健康检查结果:server health check: unexpected status 403」。
- 通用 → 「任务栏快捷小窗」= 开启;点「关闭」立刻显示「有未保存的更改,点”保存”后生效。」,
改回「开启」后提示消失,
tray.json未被改动。
运维口径:要消除该警告,应在反向代理上放行 /health/ready;托盘不再因为代理策略而误判配置不可用。