feat(tray): 任务栏快捷小窗与统一的菜单栏图标
feat(tray): 任务栏快捷小窗与统一的菜单栏图标
- 目标分支:
main - 实施计划:
docs/superpowers/plans/2026-10-06-tray-quick-panel.md
摘要
托盘「通用」页新增「任务栏快捷小窗」单选项(关闭 / 开启,默认关闭)。开启后点击菜单栏图标
弹出紧凑小窗,显示运行状态、服务端可达性、运行时长、监听中/隧道数、活跃流、已接收字节、重连
次数与逐隧道状态(含最近错误),并提供「打开主界面 / 启动或停止 / 退出」三个动作;右键(或
⌃ 点击)始终打开原菜单。同时把菜单栏图标从系统符号 network 换成与 Finder 同一枚品牌标记的
单色 template 版。
小窗不是第二套界面:它是同一份 web-tray 产物按 URL fragment(#/panel)选出的另一个根组件,
读的是同一个 /api/stats、同一份本地接口与同一个启动 secret,因此不新增任何免鉴权面。
用户影响
- 默认行为与升级前逐字节一致(左键仍是菜单)。
- 开启小窗后:左键=小窗,右键/⌃点击=菜单,⌘Q 与「退出」语义不变。
- 菜单栏图标随系统深浅色自动着色,形状与 Finder/Dock 中的 app 图标一致。
- 收起小窗不影响隧道;托盘退出时小窗随之关闭。
API / Schema / 配置影响
tray.json新增布尔quickPanel,默认false;缺该键的旧文件按关闭加载(向后兼容)。- 本地接口
GET/PUT /api/settings新增quickPanel(更新用指针语义,未携带即不改)。 GET /api/v1/*与数据库 Schema 无改动;tunnelmesh-clientCLI 命令面与外部行为无改动。TMTrayConfig新增panelURL/quickPanel与TMTraySetQuickPanel,仅存在于-tags tray的 macOS 构建;默认CGO_ENABLED=0构建不编译这些文件(scripts/tray_build_tag_test.go仍绿)。
安全与授权
- 小窗与主窗口同源、同端口、同 secret,逐请求校验仍生效;没有新增监听地址或跨源面。
/api/actions/show-window与/api/actions/quit早已注册但从未被原生层接线(会返回 501)。 本次由cmd/tunnelmesh-client-tray通过SetWindowHandlers接上,二者都只在带 secret 的 loopback 请求下可达。
测试证据
go test ./... -count=1:全绿(28 包含新断言)。go test -race ./...:全绿。go vet ./...与CGO_ENABLED=1 go vet -tags tray ./...:无输出。cd web-tray && npm test -- --run:84 用例通过(新增 11:快捷小窗设置 3、小窗渲染/动作/轮询/错误 7、路由 1)。npm run build通过并镜像到internal/tray/webdist/dist(embed 目录被 .gitignore 排除)。deploy/macos:新增外壳守卫(左右键分流、NSPopover、TMTraySetQuickPanel、菜单不再常驻statusItem.menu)与图标守卫(三档 PNG 尺寸/纯黑/覆盖率、打包与生成链、Go↔TS fragment 配对)。bash scripts/generate-tray-icon.sh重跑后TunnelMeshClient.icns字节不变(111303), 证明生成链确定性;菜单栏 PNG 由同一次运行产出。- macOS 实机冒烟(见下):打包脚本自校验通过,
otool -l读回minos 13.0,codesign -v通过。
发布步骤
cd web-tray && npm ci && npm run buildbash scripts/package-macos-tray.sh(产出.app与.dmg,deploy/macos/TunnelMeshMenuBar*.png会被拷进Contents/Resources)- 覆盖
/Applications/TunnelMesh Client.app,重新登录或手动启动;已注册登录项的 bundle 路径 未变,无需重开「开机启动」。
回滚步骤
- 把「任务栏快捷小窗」改回「关闭」即恢复左键出菜单;或直接用上一版发行包覆盖
.app。 - 删除
tray.json中的quickPanel键等价于默认值。隧道配置与 token 不受影响,无数据迁移。
Reviewer 关注点
internal/tray/native/tray_darwin.m:NSStatusItem.menu改为自持statusMenu是必要代价—— 菜单常驻在按钮上时NSStatusBarButton吞掉所有点击、action 永不触发。请确认右键/⌃点击在 你的机器上仍能稳定弹菜单。TMPanelReopenGuard:transient popover 会在同一次 mouse-down 里自行关闭,没有这个时间戳 就会出现「再点一次图标关掉又立刻弹开」。- 小窗的启动/停止复用
useStatsStore,与统计页同一份轮询实现;endPolling挂在onBeforeUnmount,收起即停表。 - 状态项动作挂在 mouse-up(见计划文档的追加记录):挂在 mouse-down 会把主线程关进
-[NSCell trackMouse:...untilMouseUp:],菜单弹出后再也等不到松开事件,图标失灵且 SIGTERM 退不掉。这条只能靠实机点出来,守卫测试已把两个 mask 都钉住。 - 小窗显示后主动
makeKeyAndOrderFront:;Esc 关闭小窗;windowShouldClose:忽略不可见 窗口的关闭,避免一次误传的 cancel 在「关闭即退出」配置下断掉隧道。
实机验证(macOS 14.6)
用 System Events 驱动真实状态项、CGWindowList 核对窗口层:
- 左键 → 出现 366×446 的 popover(小窗),再点一次关闭,第三次再打开(toggle 正常)。
- 右键 → 出现 layer=101 的菜单窗口,同时小窗自动收起;菜单项读回为
打开主界面 / 打开官网首页 / (分隔线) / 退出。 - Esc → 菜单关闭;随后 SIGTERM 约 500ms 内退出并记录
menu bar shell stopped。 - 新包(v1.3.3-quickpanel)替换
/Applications/TunnelMesh Client.app后,curl -x socks5h://127.0.0.1:18081 http://www.baidu.com→ 200,隧道未中断。 codesign --verify --deep --strict通过;otool -l读回minos 13.0。- 息屏期间
screencapture只能拿到旧帧,因此以上验证以窗口列表与无障碍树为准。
集成状态
- 分支
codex/tray-quick-panel,基于main。 - 未签名/未公证:仍为 ad-hoc
codesign -s -,Developer ID 与公证是独立后续项。
追加:实机反馈后的两处修复
用户反馈「任务栏快捷小窗似乎没有变化」。定位过程与结论见
docs/superpowers/plans/2026-10-06-tray-quick-panel.md 的两条追加记录;摘要如下。
A. 设置改了但界面不说”还没保存”。 小窗本身经 CGEvent 真实左键 + 无障碍树复验为可用
(366×446 popover,内容为小窗而非主界面)。真正的问题是「通用」页所有选项都要点底部保存
才写 tray.json,而保存按钮在 50% 窗口里位于折叠线以下:点了单选框、看不到保存、菜单栏行为不变,
就只能理解为”没变化”。
GeneralView.vue:新增dirty,有未保存改动时在保存按钮旁显示「有未保存的更改,点”保存”后生效。」, 保存成功后消失;.tm-actions改为position: sticky; bottom: 0带背景,窗口再小也不会滚走。- 「任务栏快捷小窗」提示文案补”点保存后对下一次点击生效,无需重启”。
- 新增用例:
app.spec.ts(先挂载、后异步加载的真实顺序下,单选框反映已存值;断言用选中项索引, 不依赖语言)、general-view.spec.ts > unsaved changes。
B. 反向代理挡了 /health/ready,托盘把”应答但被拒”当成”不可达”。 本机 Server 在 openresty 之后,
GET /health/ready 返回 403 而 /ws/client、/api/v1 正常,于是小窗在隧道跑着的情况下显示
「服务端:不可达」,检测还会把整份配置判为不可用。
internal/tray/server.go:新增AnsweredError,把 HTTP 应答与无应答分开。internal/tray/validate.go:serverUrl.reachable变为 warning(保留原始消息),后续检查照常执行。internal/tray/app.go:refreshServerProbe计”有应答”为可达,原始结论留在serverProbe。- 前端:小窗在有会话时直接显示「已连接」;统计页汇总下方显示健康检查原始结论;检测总标题改为 「检测通过,但有 N 项警告。」而不是对警告说”全部通过”。
追加后的用户影响
- 默认行为不变;
GET /api/stats的serverReachable语义放宽为”该地址有应答”,明细在serverProbe。 - 检测不再因为代理策略把可用配置判为不可用;需要放行
/health/ready才能消除该警告(文档已写明)。
追加的测试证据
cd web-tray && npx vitest run:91 通过(本轮新增 4:异步加载下的单选框、未保存提示、 小窗「已连接」优先、检测警告标题)。npm run build通过并镜像internal/tray/webdist/dist。go test ./internal/tray/ ./internal/client/ ./scripts/ ./deploy/macos/ -count=1:全绿;go test ./... -count=1:全绿;go vet ./...、gofmt -l、git diff --check:干净。- 实机(v1.3.6-quickpanel,arm64,
/Applications已替换):小窗「服务端=已连接」、 检测「检测通过,但有 1 项警告。」+unexpected status 403、通用页未保存提示出现/消失、curl -x socks5h://127.0.0.1:18081 http://www.baidu.com→ 200、codesign --verify --deep --strict通过。
追加的回滚
- 纯 UI/展示语义:回滚
internal/tray/{server,validate,app}.go与web-tray产物即可, 不涉及tray.json、client.yaml结构或任何接口签名。