feat(tray): Windows 系统托盘客户端与 NSIS 发行打包
feat(tray): Windows 系统托盘客户端与 NSIS 发行打包
- 目标分支:
main - 实施计划:
docs/superpowers/plans/2026-10-07-client-system-tray-windows.md
摘要
把 macOS 已有的托盘客户端复刻到 Windows:只替换原生壳层。internal/tray(配置读写、
loopback API、检测、统计、互斥)与 web-tray/(四 tab 设置界面 + 快捷小窗)原样复用,新增
internal/tray/native 的 Windows 实现——纯 Go 的 Win32 + WebView2,无 cgo,可从 Linux 交叉编译。
发行侧新增 scripts/package-windows-tray.sh:go-winres 把品牌图标、版本信息与应用清单戳进
exe,makensis 产出每用户安装包,与绿色 .zip 一起进 GitHub Release 与 CI。
托盘菜单三项(打开主界面 / 打开官网首页 / 退出)、主窗口取工作区 50%、通知区域左键快捷小窗、
开机启动、关闭最小化到托盘、与 tunnelmesh-client run 按配置文件互斥——两平台语义一致。
用户影响
- Windows 用户第一次有了图形客户端;macOS 行为逐条未变(含
installerLayout、磁盘映像与 登录项语义)。 tunnelmesh-client的 CLI 命令面与参数不变。Windows 上的tunnelmesh-client run新增一条 语义:同一份client.yaml已被另一个实例(托盘或另一个 CLI)持有时拒启并给出明确错误。 这与 macOS 上已发布的行为一致,是“互斥”要求的直接结果。- 设置界面里与平台相关的文案(主题跟随、开机启动、快捷小窗)改为按
/api/settings返回的platform取词,不再硬编码 “macOS/菜单栏”;未知平台回落中性词。 - Windows 需要 Microsoft Edge WebView2 运行时(Win11 与近年 Win10 已预装)。缺失时托盘仍启动, tooltip 提示、“打开主界面”改用系统浏览器打开同一个本地地址、关于页显示降级横幅。
API / Schema / 配置影响
- 无 HTTP API 变更、无数据库 Schema 变更、无
config.Validate模型变更(client.tunnels及其 全部已支持字段沿用现状)。 - 本地接口
GET /api/settings响应新增platform(macos/windows,由runtime.GOOS得出),GET /api/system新增renderer/rendererDetail。两者都是只读展示字段,不参与写路径。 - 配置文件与目录结构不变:
client.yaml、tray.json、client.lock、tray.log。Windows 默认 目录同为%USERPROFILE%\.config\tunnelmesh\。Windows 新增<PrefsDir>\webview2(WebView2 自己的 profile,不与系统 Edge 配置共享)。 - 锁实现按平台分文件:Unix
flock、WindowsLockFileEx(LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY),错误值仍是同一个ErrRuntimeAlreadyRunning;runtime_lock_other.go的约束收窄为!unix && !windows,即其他平台仍无锁(现状不变)。 go.mod:新增直接依赖github.com/wailsapp/go-webview2、golang.org/x/sys由 indirect 转 direct,新增tool github.com/tc-hib/go-winres。默认(无标签)构建不链接任何一个。
安全与授权
- 本地 API 仍是
127.0.0.1随机端口 + 每次启动随机 secret 逐请求常量时间比较 + Origin 校验 + CSP/X-Frame-Options;Windows 复用同一实现,未新增免鉴权端点,也未把 secret 放进 URL 之外的 持久位置。 - Token 只在 Go 侧持有完整值,
/api/settings仍返回掩码;日志与错误信息不含 Token。 - WebView2 显式关闭:状态栏、默认右键菜单、表单自动填充、密码自动保存;开发者工具仅在
TUNNELMESH_TRAY_DEVTOOLS=1时开放——设置页里唯一敏感字段是 Token,不允许宿主顺手保存它。 - 开机启动写
HKCU(当前用户),不需要管理员,也不会把隧道带到别的用户会话里。 - exe 清单为
asInvoker(不申请提权)+ per-monitor-v2 DPI;单实例由Local\TunnelMeshClientTray命名 mutex 保证。 - 交付物未做 Authenticode 签名:首次下载会触发 SmartScreen。Release 说明与
README.txt给出“更多信息 → 仍要运行”,并要求先按SHA256SUMS核对。签名/公证是发布动作,本 PR 不擅自 假设证书存在。
测试证据
go test ./... -count=1:全绿(含deploy/windows新包、scripts新断言、internal/tray/platform_test.go)。go test -race ./... -count=1:除internal/e2e的TestSOCKS5WebPageLatency/one_blocked_dial_does_not_delay_other_opens外全绿。该子测试断言 发往10.255.255.1:18081的拨号必须失败;单独重跑三次全部通过,非 race 全量跑也通过,本机nc -z -G 2 10.255.255.1 18081当前确实不可达——它是开发机网络环境(默认网关应答出站 SYN) 导致的既有抖动,与本改动无关(本改动不触碰 Agent 侧拨号与 SSRF 策略)。go vet ./...、gofmt -l internal cmd scripts deploy、git diff --check:均无输出。- 交叉编译门禁:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64|arm64 go build -tags tray ./...与GOOS=windows go vet -tags tray ./...通过;CGO_ENABLED=1 go vet -tags tray ./...(macOS 半) 通过——两平台同时绿才说明types.go的抽离没把任何一端拆坏。 - 前端:
cd web-tray && npx vitest run11 文件 97 用例全绿(新增platform.spec.ts8 例: macos/darwin/windows/win32 归一、未知平台回落中性词、四 tab 与快捷小窗回归);npm run build通过并镜像到internal/tray/webdist/dist。 - 资源戳记实测:
VERSION=v1.4.2 ./scripts/package-windows-tray.sh产出两个.zip+SHA256SUMS+manifest.json;go version -m读回GOOS=windows、GOARCH=amd64、tags=tray、CGO_ENABLED=0;go-winres extract在 exe 里读到恰好一份RT_MANIFEST(per-monitor-v2 + asInvoker + common controls v6)、一份RT_GROUP_ICON/APP(372526 字节的 品牌.ico)与RT_VERSION(OriginalFilename=TunnelMeshClient.exe、版本1.4.2); PESubsystem读回 2(GUI)。 - 失败分支实测:
VERSION=1.2、ARCHES=386、INSTALLER_ARCH=x86各自在编译前退出并给出原因; 移走installer.nsi时报 “missing build input”;makensis退出 0 但没写出文件时报 “makensis did not produce …setup.exe” 且退出码 1(不会发布空安装包)。 - 互斥:
GOOS=windows go vet ./internal/client/通过(Windows 锁实现编译进client),runtime_lock_windows_test.go带//go:build windows,只能在 Windows 上执行(见下)。 - 图标确定性:
WINDOWS_ONLY=1 scripts/generate-tray-icon.sh重跑后TunnelMeshTray.ico仍为 7886 字节、TunnelMeshClient.ico仍为 372526 字节,且deploy/macos/TunnelMeshClient.icns保持 111303 字节未变;deploy与internal/tray/native/icons两份.ico逐字节一致(测试断言)。 - macOS 回归:
VERSION=v0.0.0-wincheck ./scripts/package-macos-tray.sh完整跑通两个.dmg,codesign --verify与hdiutil verify通过。 - 文档:
python3 scripts/gen_doc_index.py重建索引;deploy/README.md产物清单补齐deploy/windows/四个新文件与(此前遗漏的)菜单栏 PNG。
未能在本机自动执行的部分
makensis冒烟编译:本机makensis不可得(Homebrew 无nsis公式、容器镜像源被网络策略 挡住)。改为静态核对:安装脚本引用的每个 MUI2 宏与语法(MUI_FINISHPAGE_SHOWREADME_FUNCTION等)都对照 NSIS 3.10 发行包自带的Contrib/Modern UI 2与Examples逐个确认存在,MUI_UNGETFILENAME这类只在 MUI1 存在的宏已删除。.github/workflows/ci.yml的新windows-tray作业会在每次 PR 上用真实makensis编译一次——这是该文件的第一道可执行门禁。- Windows 真机冒烟:本机无 Windows。清单在
docs/deployment/windows-client-tray.md#真机冒烟清单(12 项),需由使用者执行一次并把结果记回 本文件;runtime_lock_windows_test.go与 WebView2 行为只有在那台机器上才成立。
发布步骤
- 合并后打标签
vMAJOR.MINOR.PATCH(或手动 dispatchreleaseworkflow)。 macos-tray与windows-tray两个作业分别产出.dmg与.zip+setup.exe并上传 artifact;release作业下载两者、跑跨平台矩阵、合并成一个 Release(11 个资产、一份SHA256SUMS、 三份 manifest)。- 下载页任一资产缺失即作业失败(数量断言 +
if-no-files-found: error),不会发布半成品。 - 需要签名时:对
TunnelMeshClient.exe与setup.exe追加 Authenticode 签名后重传, NSIS 脚本与打包脚本无需改动;同时在 Release 说明里移除 SmartScreen 段落。
回滚步骤
- 发行层:
gh release edit <上一版> --draft=false --latest(必要时gh release delete <坏版本> --cleanup-tag --yes)。托盘不改数据库、不迁移配置,回滚没有数据面。 - 代码层:删除
internal/tray/native/*_windows.go、internal/client/runtime_lock_windows.go、scripts/package-windows-tray.sh、deploy/windows/四个新文件与cmd/的两个信号文件,并把types.go内容并回native.go。默认构建、CLI、macOS 托盘与跨平台矩阵均不依赖它们。 - 已装机器:卸载程序删除 exe、快捷方式与两处
HKCU登记;配置目录保留。手工清理reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v TunnelMeshClient /f。
Reviewer 关注点
build-release.sh的托盘合并改成了循环:两个 hand-off 走同一段逻辑,mergeScript的调用 点仍是两处(早校验、晚合并),测试对此有计数断言。请确认${!handoff_var}的间接展开在 bash 3.2(macOS 默认)上按预期工作——脚本已显式给两个变量赋值,因此不会在set -u下炸。merge-tray-dist.sh不再按.dmg白名单挑文件,而是排除SHA256SUMS/manifest.json、 点开头文件与.log/.txt/.json。这意味着托盘输出目录里“任何别的已发布文件”都会进 Release, 请确认这是想要的默认(打包脚本自身只写 zip/exe/清单)。- Windows 上的
client run语义变化:拒启而非并存。这是“互斥”要求的落地,但它是 Windows 用户可感知的新行为。 - WebView2 的
Embed(hwnd)必须在消息循环内调用(它自己泵消息),因此嵌入动作经wmMsgEmbedMain/wmMsgEmbedPanel投递回 UI 线程;go-webview2 内部失败会os.Exit(1), 所以运行库探测必须发生在Embed之前。改这段时请先读webview_windows.go顶部注释。 .syso是构建输入:只由打包脚本临时生成。守卫测试同时检查“不入库”和“Windows 侧不得import "C"”,不要为了让 CI 绿而放宽。winres.json的三层结构(类型→名称→语言 ID→资源)不要回退成两层:那是本次实现踩过的坑 (报invalid language identifier,且只在打包时才暴露)。
集成状态
- 分支:
codex/tray-windows(自main的e86a6ed切出),两个提交:feat(tray): 任务栏快捷小窗与平台中立的托盘共享层(macOS 轮次与共享层)与feat(tray): Windows 系统托盘与 NSIS 发行打包(Windows 壳、打包、CI 与文档)。 - 分支已推送;合并
main需用户授权后由作业或维护者执行。 - Windows GUI 行为与 Windows 真机冒烟为未闭环项,分别由 ci.yml 作业(含
makensis冒烟编译) 与docs/deployment/windows-client-tray.md的清单兜底。
追加:CI 首跑反馈的一处构建隔离缺陷
ci.yml 的 windows-tray 作业首次运行即失败,两条编译错误:
internal/server/web.go:19:12: pattern all:web_dist: no matching files found
internal/tray/webdist/embed.go:32:12: pattern all:dist: no matching files found
根因与修复:
- 该作业用
go build/vet -tags tray ./...。./...会连带编译internal/server,而它的//go:embed all:web_dist需要管理后台产物;作业从未构建web/,所以在干净检出里必然失败。 本地跑不出这一条,是因为开发机上那份产物早就存在——这类“本机有产物、CI 没有”的差异正是build-test作业先跑npm ci+npm run build的原因。 -tags tray同时让internal/tray/webdist/embed.go生效,托盘前端产物也必须存在。- 修法两头都做:作业显式
cd web-tray && npm ci+npm run build(托盘二进制不依赖管理后台), 并把包清单从./...收窄到带tray标签的目录加 client 锁 (./cmd/tunnelmesh-client-tray ./internal/tray/...、./internal/client)。后者顺带带来一个 额外收益:go vet会读测试文件,runtime_lock_windows_test.go因此在没有 Windows 主机的 情况下也被编译校验。 scripts/ci_workflow_test.go同时断言“必须出现收窄后的清单”和“不得再出现./...”,避免有人 为了省事把它改回去;docs/development/testing.md与docs/deployment/windows-client-tray.md里的命令块同步改写并写明为何不用./...。- 复验方式:把
HEAD用git archive导到干净目录(不预置internal/server/web_dist, 也没有internal/tray/webdist/dist),按作业顺序跑npm run build→gofmt→ 两架构go build→ 两条go vet→ 守卫与逻辑测试,全部通过;release.yml的windows-tray作业 不受影响,因为package-windows-tray.sh只编译./cmd/tunnelmesh-client-tray且已构建web-tray。
追加:CI 首跑反馈的第二处——快捷小窗测试在数微任务
Release 作业的 macOS runner 与 Linux runner 同时红在 4 个 quick-panel 用例上,本机 97 用例全绿:
× renders the run summary and one row per tunnel expected '已停止' to contain '运行中'
× reports a blocked lock instead of an empty panel expected '已停止' to contain '另一个'
× names the live session ... expected '未配置' to be '已连接'
× keeps saying 不可达 ... expected '未配置' to be '不可达'
- 根因:测试用
flush(6)这类手调的微任务圈数等接口回填 DOM。小窗是串行两跳 (settings应用语言/主题 →stats渲染摘要),一跳花几拍由运行时的fetch实现决定: 本机 Node 24 恰好够,runner 的 Node 22 不够,于是渲染出“取数之前”的状态。属于测试写法问题, 不是小窗功能问题——三处已停止/未配置正是组件的初始值。 - 修法按根因来:
src/tests/helpers.ts增加flushUntil(谓词)(微任务自旋到条件成立,默认 4000 轮;只用微任务,因为有 3 个用例跑在 fake timers 下),小窗的 11 处等待全部改为看条件;flush(n)保留给“点一下再看调用记录”的场合,实现改成每单元排空一轮,不再假设一跳一拍。 - 为什么不是把 6 改成更大的数:新增的
src/tests/panel-timing.spec.ts给每个响应人为加 500 拍, 用固定圈数(即便 6×64)必红、用flushUntil必绿——把这条教训钉成可执行断言。 - 顺带补缺口:
ci.yml的windows-tray作业此前只构建不测试托盘前端,所以托盘前端要等到 Release 作业才被第一次跑到。现在该作业必须执行cd web-tray && npm test -- --run,scripts/ci_workflow_test.go对这一步有断言。 - 复验:
cd web-tray && npx vitest run12 文件 98 用例全绿(新增 1 个守卫用例);npm run build重新镜像托盘产物;go test ./scripts绿;go test ./... -count=1绿。