TunnelMesh Client 系统托盘(Windows)
TunnelMesh Client 系统托盘(Windows)
目标
- 在不动 macOS 托盘行为的前提下,为 Windows 复刻同一套托盘:通知区域图标 + 三项菜单
(打开主界面 / 打开官网首页 / 退出)、约屏幕 50% 的主窗口、四 tab(通用 / 统计 / 路由配置 /
关于)、左键快捷小窗、开机启动、关闭最小化到托盘、与
tunnelmesh-client run互斥。 - 实现方式是只替换原生壳层:
internal/tray(配置、loopback API、检测、统计)与web-tray/(界面)原样复用,新增internal/tray/native的 Windows 实现(纯 Go + WebView2, 无 cgo,可交叉编译),打包为 NSIS 每用户安装包 + 绿色 zip,进 GitHub Release 矩阵与 CI。
架构决策
- 一层原生壳、两个实现、一份契约:把平台无关的类型(
Config/Handlers/SystemInfo)与 默认值(DefaultConfig、fraction钳位)抽到internal/tray/native/types.go(//go:build tray && (darwin || windows)),两端导出的 API 面逐一对齐, 因此cmd/tunnelmesh-client-tray/main.go不需要按平台分叉,只分叉信号处理。 对齐本身由scripts/tray_build_tag_test.go用源码解析断言(两端符号集必须相等)。 - 不用 systray 类库:托盘的
NSApplication/消息循环必须只有一个所有者。第三方托盘库各自 起线程泵消息,结果是菜单不响应与 webview 覆盖自己的窗口;macOS 侧已经手写,Windows 侧沿用 同一决定,Win32 + WebView2 直接用golang.org/x/sys/windows。 - 纯 Go 而非 cgo:这是 Windows 半边的关键取舍。WebView2 的 COM 接口由
github.com/wailsapp/go-webview2用go-winloader动态加载(WebView2Loader.dll内嵌), 因此CGO_ENABLED=0 GOOS=windows go build -tags tray ./...成立,Linux/macOS 开发机与 CI 都 不需要 Windows 主机或 C 工具链。 - 设置界面仍由托盘自己的 loopback HTTP 服务提供:
go:embed的前端 +127.0.0.1随机端口 + 每次启动随机 secret 逐请求校验,两平台同一实现,Windows 不新增端点、不改鉴权。 - WebView2 运行时缺失是降级而不是失败:托盘照常启动,tooltip 追加提示,“打开主界面”改用系统 默认浏览器打开同一个带 secret 的地址,关于页显示降级横幅。不内嵌微软运行时安装包(那是分发 决策,且会引入一份需要独自跟踪更新的第三方二进制)。
- 互斥按 config 路径派生锁:Windows 用
LockFileEx(LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY),与 Unixflock语义等价(进程退出或被杀由内核回收)。效果是 Windows 上托盘与指向同一client.yaml的client run真正互斥,CLI 由此获得与 mac 一致的 “已有实例则拒启”语义。 - UI 偏好继续只写
tray.json:client.yaml要过config.Validate,界面键不进配置模型。 - 进 CI 与发行矩阵,但不进交叉编译矩阵:
build-release.sh永远不出现-tags tray,托盘由 专属作业产出、由merge-tray-dist.sh合并;Windows 作业能在 ubuntu runner 上完成,因为它只需 要 Go 工具链 +makensis。
技术栈
- Go(
internal/tray、internal/tray/native、internal/client、cmd/tunnelmesh-client-tray)。 - Win32 API(
golang.org/x/sys/windows,LazySystemDLL+NewCallback)与github.com/wailsapp/go-webview2(MIT,WebView2/COM 绑定,传递依赖github.com/jchv/go-winloader)。 github.com/tc-hib/go-winres(tool指令引入)生成 exe 的图标 / 版本信息 / 应用清单.syso。- NSIS(
makensis,Debian/Ubuntu 的nsis包)产出每用户安装包。 - Vue 3 + TypeScript + Element Plus + vue-i18n + Pinia(
web-tray/),vitest 单测;本次零新增 前端依赖。
规格引用
docs/superpowers/plans/2026-10-05-client-system-tray-macos.md:托盘总体设计、共用配置与互斥 约定、loopback + secret 的安全边界、四 tab 的功能定义。docs/superpowers/plans/2026-10-06-tray-quick-panel.md:快捷小窗的路由约定(#/panel由tray.PanelRoute与web-tray/src/panelRoute.ts两端钉死)。docs/user-guide/client-tray.md、docs/deployment/macos-client-tray.md、docs/deployment/binary-release.md。
全局约束
- 不改
tunnelmesh-clientCLI 的命令面与外部行为;internal/tray的逻辑、client.yaml/tray.json/client.lock的结构与本地 API 均复用,不新增行为分支。 - 所有新的原生代码必须在
//go:build tray && windows之后;Windows 侧不得import "C", 默认go build/test ./...(CGO_ENABLED=0、无标签)不编译、不触达 Win32/WebView2。 cmd/tunnelmesh-client-tray必须同时保留“不支持平台的回退入口”,否则无标签构建会丢一个命令。- 生成的
.syso是构建输入,绝不入库(.gitignore+ 守卫测试双重固定)。 - 图标一律由
scripts/trayicon生成,不接受手工放图;两个平台的图标必须同源同几何。 - 日志与错误信息不得输出 Token;本地 API 不返回 Token 全文。
文件清单
新增:
internal/tray/native/types.go:两端共享的类型与默认值。internal/tray/native/winapi_windows.go:x/sys 未提供的结构(wndClassEx、notifyIconData、msg、minMaxInfo、monitorInfo、point、bitmapInfoHeader)、WM_*等常量与 user32/kernel32/shell32/shcore/dwmapi 的 proc 表。internal/tray/native/icon_windows.go:go:embed的TunnelMeshTray.ico→HICON(解析 ICO 目录,支持 BMP 与 PNG 条目,按 DPI 选帧并缓存句柄)。internal/tray/native/webview_windows.go:WebView2 宿主(edge.NewChromium+Embed(hwnd)+Navigate/Resize)、设置项裁剪、运行时探测。internal/tray/native/tray_windows.go:消息循环、Shell_NotifyIconW、TrackPopupMenu、 主窗口、快捷小窗、WM_CLOSE语义、单实例互斥、AppUserModelID、OpenURL。internal/tray/native/autostart_windows.go:HKCU\...\Run登录项。internal/tray/native/hostinfo_windows.go:系统版本(注册表NT\CurrentVersion)、架构、 工作区、DWM 标题栏深浅色。internal/tray/native/icons/TunnelMeshTray.ico:被 embed 的通知区域图标(与 deploy 下那份逐 字节一致)。internal/client/runtime_lock_windows.go+runtime_lock_windows_test.go:LockFileEx互斥。internal/tray/platform.go+platform_test.go:SettingsView.platform的来源。cmd/tunnelmesh-client-tray/shutdown.go、signals_darwin.go、signals_windows.go。deploy/windows/{TunnelMeshClient.ico,TunnelMeshTray.ico,winres.json,installer.nsi}。deploy/windows/tray_bundle_test.go。scripts/package-windows-tray.sh、scripts/ci_workflow_test.go。web-tray/src/platform.ts、web-tray/src/tests/platform.spec.ts。docs/deployment/windows-client-tray.md、本文件与对应 PR 记录。
修改:
internal/tray/native/native.go(只留 darwin/cgo 实现,共享部分移入types.go)、internal/tray/{app.go,api.go,prefs.go,server.go,...}(renderer 上报与 platform 字段)。internal/client/runtime_lock_other.go(约束收窄为!unix && !windows)。cmd/tunnelmesh-client-tray/main.go、main_unsupported.go(两个平台 + 回退)。scripts/{trayicon/main.go,generate-tray-icon.sh,merge-tray-dist.sh,merge_tray_dist_test.go,build-release.sh,tray_build_tag_test.go,release_workflow_test.go}。.gitignore、Makefile、go.mod、go.sum、deploy/README.md、docs/README.md、docs/deployment/binary-release.md、docs/user-guide/client-tray.md、README.md、README.zh-CN.md、.github/workflows/{ci.yml,release.yml}、web-tray/src/{api/types.ts,i18n/*,views/*,styles/tokens.css}。
任务间接口
native对cmd的契约就是scripts/tray_build_tag_test.go里的shellAPISurface,两平台 必须逐个同名同形:Run(Config) error、Stop()、ShowWindow()、HideWindow()、SetMinimizeToTray(bool)、SetQuickPanel(bool)、SetHandlers(Handlers)、DefaultConfig(url string) Config、OpenURL(string) error、System() SystemInfo、PreferredLanguage() string、RendererName() string、RendererDetail() string、NewAutostart() *Autostart。登录项是两平台各自实现的Autostart类型,方法集相同:Supported() bool、Enabled() (bool, error)、SetEnabled(bool) error;macOS 的状态来自LoginItemStatus(能表达“系统拒绝注册”的原因),Windows 恒为支持。Config新增消费项:PanelURL(快捷小窗地址,等于api.PanelURL())、WebViewDataDir(Windows 上设为<PrefsDir>\webview2,macOS 忽略)。internal/client:StartRuntime取锁失败返回ErrRuntimeAlreadyRunning;Windows 实现与 Unix 实现共享同一错误值,托盘与 CLI 的提示文案不变。internal/tray:SettingsView.platform∈ {macos,windows}(由runtime.GOOS得出,不依赖 原生层,因此无标签构建与单测同样能验证);SystemInfo.renderer/rendererDetail由native.RendererName()/RendererDetail()填充。- 前端按
platform取词的键:general.theme.hint、general.launchAtLogin.hint、general.launchAtLogin.failed、general.quickPanel.hint,每键取值为{macos, windows, other}三元组,未知平台回落other;文案不得再硬编码“macOS/菜单栏”。 - 打包脚本对 CI 的契约:输出目录里必有
SHA256SUMS与manifest.json,且manifest.json.assets[]每项带platform/archive/extension/installerLayout。merge-tray-dist.sh只认这两份清单文件,其余已发布文件一律复制。
TDD 步骤
- 红:扩展
scripts/tray_build_tag_test.go——两端符号集必须相等、*_windows.go必须带tray且不得import "C"、不得提交.syso。此时 Windows 文件尚不存在,断言失败。 - 绿:抽
types.go,Windows 实现补齐 API 面。 - 红:
internal/client/runtime_lock_windows_test.go(//go:build windows)写“第二个句柄取锁 必须失败、释放后可重取”,runtime_lock_windows.go未实现时GOOS=windows go vet失败。 - 红:
deploy/windows/tray_bundle_test.go解析.ico目录、winres.json三层结构、installer.nsi的每用户属性、打包脚本关键参数、PANEL_ROUTE对齐、build-release.sh不含-tags tray。 - 绿:
scripts/trayicon增加-ico输出与WINDOWS_ONLY=1生成路径,产出两个.ico;winres.json、installer.nsi、scripts/package-windows-tray.sh落位。 - 红→绿:
internal/tray/platform_test.go与前端src/tests/platform.spec.ts(平台词表分支、 四 tab 与快捷小窗回归)。 - 红→绿:
scripts/merge_tray_dist_test.go混合形状与--manifest-name;scripts/ci_workflow_test.go;scripts/release_workflow_test.go的 windows 作业与 “release 作业必须同时需要两个托盘作业”。 - 重构:合并两份托盘的构建清单到
docs/deployment/windows-client-tray.md与docs/user-guide/client-tray.md的对应小节,重跑scripts/gen_doc_index.py。
验证命令
go test ./... -count=1
go test -race ./internal/tray ./internal/client
go vet ./...
gofmt -l internal cmd scripts
git diff --check
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -tags tray ./...
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -tags tray ./...
GOOS=windows go vet -tags tray ./...
CGO_ENABLED=1 go vet -tags tray ./...
cd web-tray && npx vitest run && npm run build
scripts/generate-tray-icon.sh # 重跑后 .icns 与两个 .ico 字节不变
VERSION=vX.Y.Z ./scripts/package-macos-tray.sh
VERSION=vX.Y.Z ./scripts/package-windows-tray.sh
makensis -DVERSION=0.0.0-ci -DARCH=amd64 -DBUILD_DIR=<stage> \
-DICON_FILE=deploy/windows/TunnelMeshClient.ico \
-DOUTFILE=/tmp/setup.exe deploy/windows/installer.nsi # 安装脚本冒烟编译
回滚注意事项
- 托盘是新增产物,不是既有路径:回滚一次发行只需把 GitHub Release 指向上一版
(
gh release edit <prev> --latest),已下载的安装包与配置不受影响。 - 代码层面回滚本特性 = 删除
internal/tray/native/*_windows.go、autostart_windows.go、hostinfo_windows.go、icon_windows.go、winapi_windows.go、webview_windows.go、internal/client/runtime_lock_windows.go、scripts/package-windows-tray.sh、deploy/windows/下四个新文件与cmd的两个信号文件,并把types.go的类型放回native.go。默认构建、CLI 与 macOS 托盘的行为不依赖其中任何一项。 - 唯一影响既有文件的语义变化:
internal/client的锁在 Windows 上生效,因此 Windows 的tunnelmesh-client run在已有同配置实例时会拒启(这正是“互斥”的要求)。若需要退回旧行为, 删除runtime_lock_windows.go并把runtime_lock_other.go的约束改回!unix,锁在 Windows 上 退化为“无锁”,托盘仍受单实例 mutex 保护。 - Windows 注册表侧只有
HKCU\...\Run一个值;卸载程序删除它,也可手工reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v TunnelMeshClient /f。 配置目录%USERPROFILE%\.config\tunnelmesh\不被安装或卸载触碰。
假设与边界
- 本机没有 Windows 主机,也没有可运行的
makensis(Homebrew 无nsis公式,容器镜像源不可达)。 因此自动门禁 = 交叉编译 + 守卫/单元测试 + 源码结构断言,安装脚本另用 NSIS 3.10 自带的Contrib/Modern UI 2与Examples逐个核对了所引用的宏与语法;GUI 行为由docs/deployment/windows-client-tray.md的真机冒烟清单兜底。 - 默认配置目录沿用
%USERPROFILE%\.config\tunnelmesh\,与 Linux/macOS 及既有文档一致。 - exe 名统一
TunnelMeshClient.exe(zip 与 setup 同名),GOARCH只有 amd64/arm64,不出 386。 - 不做 Authenticode 签名与 SmartScreen 例外申请(文档记为发布后续项);不内嵌 WebView2 运行库。
- 本次不改配置模型:
client.tunnels及其全部已支持字段(含allow_remote)沿用现状。