macOS 系统托盘 Client
macOS 系统托盘 Client
关联实施计划:2026-10-05-client-system-tray-macos
标题
feat(client): add a macOS menu-bar tray client
目标分支
main
摘要
新增第四个可执行程序 tunnelmesh-client-tray(macOS 菜单栏客户端)。它在自己的进程里承载
隧道,能力等价于 tunnelmesh-client run,与命令行 Client 共用同一份
~/.config/tunnelmesh/client.yaml,并通过按配置路径派生的 advisory 锁与之互斥。
原有 tunnelmesh-client 的命令面、参数与外部行为不变。
实现分四层,边界是刻意划的:
| 层 | 位置 | 约束 |
|---|---|---|
| 共享运行时 | internal/client/runtime.go、runtime_lock*.go |
从 internal/cli/client_run.go 抽出装配逻辑,CLI 改为委托;无 cgo |
| 托盘逻辑 | internal/tray/(paths、prefs、configstore、server、validate、app、api) |
无 cgo、无 Cocoa,CGO_ENABLED=0 下可在 Linux 测试 |
| 原生外壳 | internal/tray/native/(native.go + tray_darwin.m) |
//go:build tray && darwin,唯一触碰 Cocoa/WebKit 的地方 |
| 设置界面 | web-tray/(Vue 3 + TS + Element Plus + Pinia + vue-i18n) |
独立于管理后台 web/,产物嵌入 internal/tray/webdist/dist |
服务端只有一处 additive 变更:GET /api/v1/client/agents,用 client service token 鉴权,
返回该 token 作用域内可用 Agent 的 {id, name, online},作为路由配置页 Agent 下拉的数据源。
打包走独立的 scripts/package-macos-tray.sh,不进跨平台 release 矩阵:托盘必须通过
cgo 链接 Cocoa/WebKit,只能在 macOS 主机构建,而矩阵用 CGO_ENABLED=0 从 Linux 交叉编译。
用户影响
- macOS 用户获得菜单栏客户端:三项菜单(打开主界面 / 打开官网首页 / 退出)、约半屏的四
tab 设置窗口(通用 / 统计 / 路由配置 / 关于)、图形化编辑
client.yaml、Agent 下拉选择、 逐项配置检测、运行统计、开机启动与关闭最小化。 - 命令行用户获得一条新语义:同一份配置已有 Client 在跑时,
run以退出码 1 拒绝启动并打印client: another TunnelMesh client is already running with this configuration。这是计划 中“互斥”要求的直接结果,也是本次唯一一处对既有命令行为的改变。 - 非 macOS 用户不受影响:默认构建不编译任何原生代码,
tunnelmesh-client-tray在非-tags tray构建里给出“仅 macOS”的说明而不是编译失败。 - 使用者文档:macOS 系统托盘 Client; 部署文档:macOS 托盘客户端打包。
API、Schema 与配置影响
API:新增 GET /api/v1/client/agents(additive,无破坏性变更)。
- 单数
/api/v1/client/命名空间与既有复数/api/v1/clients/刻意分开:前者用 service token(service_tokens,type=client)鉴权,后者用控制台 bearer(api_tokens)。 合并会让一个隧道凭据获得管理视图。 - 鉴权在
ServeHTTP的 pre-auth 段完成(仿handlePublicAuth),因为API.authenticate只解析 api_tokens。校验器由 runtime 注入既有credentialService实例,不新建,避免 第二个 “last used” 后台 worker 泄漏。 - 过滤:owner(SQL)+
Enabled+ 与scope.AgentIDs求交(空 scope = 不限)。enabled 与 scope 过滤在累积页时进行而不是在截断后丢弃,否则会静默缩短结果并让 cursor 失步。 - 响应仅
{id, name, online},additionalProperties: false,不含能力、metadata 或归属信息。 - 状态码:200 / 401(所有拒绝原因同一个不透明错误,避免成为 token oracle)/ 405 / 503 (未注入校验器,属接线故障,不折进 401)。
- 同步更新
docs/api/openapi.yaml(新增 path、clientTokenAuth安全方案、ClientAgent、ClientAgentPage、ClientAgentPageEnvelope)、docs/README.md、docs/user-guide/client.md与docs/deployment/binary-release.md。
Schema:无变更。SchemaVersion 未动,migrations/ 未动,不需要数据库迁移。
internal/storage/repository.go 只新增了导出函数 AgentCursor(Agent) string,用于让
过滤型调用方能构造与 agentRepo.List 同构的复合 cursor;传裸 Agent ID 不会报错,
decodeCursor 会原样返回、复合切分找不到分隔符、List 静默从第一页重来并永远重复同一批行,
因此这个函数必须导出而不是让调用方自己拼。
配置:
client.yaml结构不变。client.token的yaml:"-"/json:"-"标签未改动;viper 的 mapstructure 路径仍会加载它,而print-config与RedactedJSON因为json:"-"依然不输出。- 新增托盘专属偏好文件
tray.json(语言、主题、配置目录、开机启动、最小化)。刻意不写进client.yaml:那个文件要过config.Validate,未定义的界面键只会被拒绝或被静默忽略。 - 新增
client.lock(0600),由client.LockPathForConfig从配置路径派生,托盘与 CLI 用 同一个函数,因此不会各自算出不同的锁。 internal/cli/client_run.go从 243 行降到委托实现(净 -336/+188 行,含文档)。
安全与授权影响
- 回环 + 每次启动随机 secret:设置界面由托盘进程在
127.0.0.1随机端口提供,只绑回环。 每次启动生成 32 字节 secret,随首屏 URL 注入 WebView,之后每个/api请求都要带 (header / bearer / cookie / query 四种携带方式),比较使用常量时间。本机其他进程既猜不到 端口也拿不到 secret,无法驱动托盘或读出 token。 - Origin 校验 + 反嵌入:
X-Frame-Options: DENY、X-Content-Type-Options: nosniff、default-src 'self'CSP。持有 token 的页面不能被别的站点框住。 - 状态码边界:401 只代表本地 secret 校验失败;锁竞争是 409,配置不可用是 422,其余 400。 把服务端错误也映射成 401 会让界面误报“本机接口失效”。
- token 不出 Go 进程:
RoutingView只带TokenPresent;RoutingUpdate.Token是三态指针 (nil保留 /""清除 / 有值替换),因为只有指针能区分“表单从未展示过 secret”和 “操作者删掉了它”。任何视图接口都不返回 token 明文。 - 静态资源不要求 secret:它们就是仓库里公开的 JavaScript,不含凭据;而首屏文档带
?secret=、随后请求资源时不带,给静态资源加密只会让首屏加载失败。 - WebView 只允许自己的回环源:
decidePolicyForNavigationAction校验 scheme/host/port, 外部链接交给NSWorkspace,避免把一个持有 secret 的 WebView 变成通用浏览器。 - 文件权限:
client.yaml、tray.json、tray.log、client.lock均0600,目录0750, 写入走临时文件 + 原子替换。 - 登录项:
SMAppService以 bundle cdhash 记录,移动或重签.app会使注册失效;托盘每次 启动用偏好里的意图重新对齐一次,注册被拒时不写偏好,界面不会声称成功。 - 不实现:P2P NAT traversal、任意远程命令执行;未新增任何远程执行面。
- 未引入新的敏感配置项,无硬编码凭据;
git diff --check与全量新增文件的尾随空白/冲突标记 扫描均干净。
测试证据
自动化(本机 macOS Darwin arm64,Go 1.27.1,Node 24.15):
| 命令 | 结果 |
|---|---|
go build ./... |
通过 |
go vet ./... |
通过 |
go test ./... -count=1 |
退出码 0,28 个包 ok,0 个 FAIL |
go test -race ./... |
退出码 0,28 个包 ok,无 DATA RACE |
CGO_ENABLED=1 go vet -tags tray ./... |
通过 |
CGO_ENABLED=1 go test -tags tray ./internal/tray/... ./scripts/ ./deploy/... |
全部 ok |
git diff --check |
干净(另对 90 个新增文件扫描尾随空白、冲突标记、CRLF,均干净) |
cd web-tray && npm test -- --run |
9 个文件 / 65 个用例全部通过 |
cd web-tray && npm run build |
通过,产物与 internal/tray/webdist/dist diff -qr 一致 |
ARCHES="arm64 amd64" ./scripts/package-macos-tray.sh |
两个架构均构建、签名、codesign --verify 通过(valid on disk / satisfies its Designated Requirement) |
本次新增 121 个 Go 测试函数,覆盖:服务端新接口的未授权、错误 token 类型(agent / api_token
均拒)、owner 越权、scope 过滤、分页与 cursor 可续、online 标记、方法/路径、缺校验器 503;
共享运行时的装配等价性与既有 client 测试全绿;互斥锁的同配置二次启动失败、释放后可重启、
崩溃后 flock 自动释放;Stats 字段;配置与 token 的加载、RedactedJSON 不泄露、0600、
mode=cluster 缺 MySQL 时被预校验拦截;本地 API 的设置/路由读写、Agent 代理、检测、统计、
secret 校验与回环绑定。
结构性守卫(均为“先证明会红”再落地):
scripts/tray_build_tag_test.go:含 cgo 的文件、importinternal/tray/native的文件、//go:embed设置界面产物的文件都必须带tray标签;含原生源码的目录里所有 Go 文件都必须 带标签;internal/tray/本体禁止 cgo;cmd/tunnelmesh-client-tray必须保留反向标签的 fallback。已用两个临时违规文件验证会失败(//go:build linux+ import native、 无标签//go:embed),随后删除恢复绿。约束判定用go/build/constraint求值而非字符串匹配, 因此tray || linux这类“看着带标签其实仍会编译”的写法也会被抓到。internal/server/client_scoped_openapi_test.go:文档化的 client 作用域接口集合 == 实际派发的 集合;GET 必须要求clientTokenAuth且不得接受bearerAuth;响应状态集合固定;ClientAgent的additionalProperties: false、属性集合与clientAgentView实际输出逐字段 相等、required 数量相等。已用两处文档突变验证会失败(多文档化一个capabilities字段、 把 security 换成bearerAuth),随后还原恢复绿。deploy/macos/tray_bundle_test.go:Info.plist 三个静默故障键(LSUIElement、NSAllowsLocalNetworking、NSRequiresAquaSystemAppearance=false)与 bundle id / 可执行名 / 最低系统版本;模板与打包脚本不得漂移;scripts/build-release.sh里不得出现托盘二进制或-tags tray。
打包后手工冒烟(ad-hoc 签名的 TunnelMesh Client.app,隔离 HOME):
- 进程启动,本地接口监听
127.0.0.1随机端口;GET /返回 200 且是真实嵌入产物 (index.html 649 B,/assets/index-*.js50.6 KB,Content-Type正确),证明go:embed、 ATS 放行本地 http、WKWebView 加载链路都通。 GET /api/stats不带 secret 与带错误 secret 均返回 401this request does not carry the tray secret;响应头含 CSP、X-Frame-Options: DENY、nosniff、Cache-Control: no-store。- 配置齐全时托盘进程内承载隧道:
lsof显示127.0.0.1:18099 (LISTEN)属于tunnelmesh-client-tray,nc连接成功。 - 互斥实测:托盘运行期间
tunnelmesh-client --config <同一份> run退出码 1,输出client: another TunnelMesh client is already running with this configuration。 kill -TERM后约 1 秒退出,日志出现received terminated, shutting down与menu bar shell stopped;监听端口随即释放。- 托盘退出后同一份配置的 CLI
run成功拿到锁并绑定同一个端口(tcp tunnel smoke-tcp listening on 127.0.0.1:18099),证明锁随进程退出释放。 - 缺少
client.yaml时不崩溃,日志给出可操作的client run requires client.server_url and client.token。 - 登录项两个方向实测:开启后
sfltool dumpbtm出现Name: TunnelMesh Client、Disposition: [enabled, ...];关闭后变为[disabled, ...]且不报错。 - 文件权限实测:
client.lock、client.yaml、tray.log均0600。
冒烟过程中发现并修复两个真实缺陷:
[NSApp stop:]不唤醒 run loop:菜单栏 app 窗口隐藏时没有待处理事件,stop:只置标志位, 进程永不退出——SIGTERM 与菜单“退出”都表现为“没反应但隧道还在跑”。改为stop:后补投一个 application-defined 事件唤醒循环;同时TMTrayStop由dispatch_sync改为dispatch_async, 因为信号处理器在NSApp run之前就已安装,此时主线程还在 Go 里、不排空主队列,同步派发会死锁。- 注销未注册的登录项报错:
SMAppService对无记录的 bundle 返回NotFound而非NotRegistered,原实现只对后者提前返回,于是关闭“开机启动”会把Operation not permitted抛到界面上。现在两种状态都视为“本来就没开”。
一处计划假设被实测推翻并已更新文档:计划认为“未签名/ad-hoc 可能不被 SMAppService 接受”,
实测 ad-hoc 签名的 bundle 注册登录项成功;被拒的是不含 bundle 的裸二进制。打包脚本的
提示信息与部署文档均按实测改写。
发布步骤
- 常规发布不变:
releaseworkflow 与scripts/build-release.sh仍只产出三个二进制, 不产出托盘。 -
托盘在 macOS 主机上单独打包:
cd web-tray && npm ci && npm test -- --run && npm run build && cd .. VERSION=vX.Y.Z ./scripts/package-macos-tray.sh shasum -a 256 -c dist/vX.Y.Z/macos-tray/SHA256SUMS - 产物为每架构一个 zip(内含
.app,Contents/Resources/带 LICENSE 与 NOTICE,满足 Apache-2.0 4(a)/4(d))、SHA256SUMS与manifest.json(含notarized: false)。 - 上传到 GitHub Release 时必须在说明里写清是 ad-hoc 签名:下载副本会被 Gatekeeper 拦一次, 需要用户显式放行。
- 正式对外分发前需要补 Developer ID 签名 + 公证(
SIGN_IDENTITY="Developer ID Application: ..."已支持,notarytool/stapler步骤本次不做)。 - 服务端无需迁移;新旧 Server 可与新 client 共存,但旧 Server 没有
GET /api/v1/client/agents,此时路由配置页的 Agent 下拉为空——文档已写明这一现象。
回滚步骤
- 客户端:app bundle 自包含,回滚就是用上一版 zip 覆盖
/Applications里的目录, 5 分钟内可完成。client.yaml格式未变,旧版托盘与命令行 Client 都能读新版写出的配置, 不需要配置回滚。唯一状态残留是登录项,换 bundle 后重新打开一次“开机启动”即可。 - 服务端:
GET /api/v1/client/agents是 additive 的独立命名空间,回滚 Server 即消失; 已发出该请求的托盘只会看到 Agent 下拉为空,不影响既有隧道。 - 共享运行时重构:
internal/cli/client_run.go改为委托internal/client.Runtime。 回滚代码即恢复原实现,配置与磁盘状态无变化。 - 需要保留“CLI 可与托盘同时运行”的旧行为时,让两者指向不同的
client.yaml即可,锁按配置 路径派生。
Reviewer 关注点
- 互斥语义是不是可接受的破坏:这是本次唯一改变既有命令行为的地方(
run在已有实例时 拒启)。锁按配置路径派生,因此只影响指向同一份client.yaml的进程。 - 权限过滤与分页的交互:
listAgentsForClientToken在累积页时过滤,HasMore取page.HasMore || index < len(page.Items)-1,cursor 用新导出的storage.AgentCursor。 请重点看“过滤后恰好取满 limit”和“过滤后本页为空但后续页有匹配”两种边界。 - 响应面是否够窄:
clientAgentView只有三个字段,且被 openapi 测试双向钉住。 新增字段应被视为信息披露变更而不是排版变更。 client.token标签未动:yaml:"-"/json:"-"保持原样,明文只存在于0600的client.yaml与 Go 进程内存中,界面永远拿不到。请确认没有新增路径能把它带出去。- 构建隔离是不是真的:
scripts/tray_build_tag_test.go五条守卫 +go vet ./...(无 cgo)+go vet -tags tray ./...(有 cgo)两条命令。 webdist的双半:embed.go(tray)与embed_stub.go(!tray)必须同时存在。 无标签时FS()返回nil而不是一个空 FS——api.go正是用这个 nil 选择占位页面, 返回非 nil 空 FS 会渲染出空白窗口。- 原生 shim 的两处修复(run loop 唤醒、
dispatch_async、NotFound视为未注册) 都来自实测,注释里写了为什么。 internal/tray与internal/tray/native的分层:前者无 cgo 以便在 Linux CI 跑测试, 后者是唯一的 Cocoa 落点,依赖方向只能是 native → tray。- 文档口径:统计页只给入站字节数(只有这个方向被埋点),没有为了对称而编造出站值。
集成状态
- 未提交、未推送、未创建远端 PR:按 AGENTS.md,未经明确授权不执行 commit / push / merge。
- 工作区状态:18 个既有文件被修改,79 个新增文件(含
web-tray/前端与internal/tray/)。internal/tray/webdist/dist/与web-tray/dist/是构建产物,与web/dist、internal/server/web_dist一样被.gitignore的dist/规则排除,不入库。 - 修改的既有文件:
Makefile(新增tray-web-build/tray-release两个目标)、README.md与README.zh-CN.md(组件表新增一行)、deploy/README.md(产物清单与模板约定)、docs/README.md、docs/api/openapi.yaml、docs/deployment/binary-release.md、docs/development/testing.md、docs/user-guide/client.md、internal/cli/{client_run,root,serve_watch}.go、internal/client/session_pool.go、internal/server/{api,runtime}.go、internal/storage/repository.go。 另有docs/pull-requests/README.md与docs/superpowers/plans/README.md由scripts/gen_doc_index.py重新生成——其中2026-09-29-audit-log-details-phase-p0一行多出的 计划链接是既有索引相对生成器的漂移被本次重算纠正,与托盘无关。 - 本次未改动:管理后台
web/、internal/server/web_dist、migrations/、SchemaVersion、scripts/build-release.sh的构建矩阵、.github/workflows/。 - 最终验证(全部本机执行):
go build ./...、go vet ./...、git diff --check、CGO_ENABLED=1 go vet -tags tray ./...通过;go test ./... -count=1退出码 0、28 个包 ok;go test -race ./... -timeout 30m退出码 0、28 个包 ok、无 DATA RACE;cd web-tray && npm test -- --run9 文件 / 65 用例通过,npm run build产物与internal/tray/webdist/distdiff -qr一致。 - 明确的后续项(本次不做):Developer ID 签名与公证、
.icns图标资产、Windows / Linux 托盘、 出站字节数埋点、把go build/test ./...与-tags tray构建加进 CI(当前 CI 无通用 Go 构建 作业,属既有缺口)。
一处需要 Reviewer 决定的口径问题:AGENTS.md 的 project-doc 段仍写“包含三个可执行程序”。
本次没有改动 AGENTS.md(它是工程规范的权威来源,且托盘不进发行矩阵,就发行物而言“三个”
仍然成立)。如果希望把托盘登记为第四个程序,需要单独确认。
补记(2026-10-06):进发行矩阵与安装映像
上文是当时的时点记录,不改写。本节只追加其后发生的两件事:托盘进入 GitHub Release,以及
磁盘映像从“能装”变成“看起来就是安装包”。自本节起,上文“本次未改动
.github/workflows/、scripts/build-release.sh 的构建矩阵”与“托盘不进发行矩阵”两处口径不再
成立;其余结论不变。
变更
.github/workflows/release.yml拆成三个作业:version一次解析并校验版本号 (^v[0-9]+\.[0-9]+\.[0-9]+$,tag 与手动 dispatch 走不同来源,因此必须只解析一次),输出给 另外两个作业;macos-tray在 macos-15 上构建web-tray并调用打包脚本,校验两个.dmg、shasum -a 256 -c SHA256SUMS、清单可解析,上传 artifactmacos-tray;release在 ubuntu-latest 上跑跨平台矩阵并以TRAY_DIST_DIR合并托盘,最后gh release create。 两个作业不可能对“同一个 Release 是哪个版本”产生分歧。- 新增
scripts/merge-tray-dist.sh:先校验来源目录自己的SHA256SUMS(artifact 经过上传下载, 文件名相同不等于字节相同),再复制.dmg、把重算的校验和追加进同一份SHA256SUMS、把 托盘清单发布为manifest-tray.json。scripts/build-release.sh在十八次交叉编译之前先--check,未设置TRAY_DIST_DIR时行为与此前完全一致;跨平台manifest.json的 schema 不变。 Makefile新增release-with-tray。scripts/package-macos-tray.sh的产物由 zip 改为.dmg,再改为两段式安装映像:hdiutil create -format UDRW -fs HFS+ -size <内容+空余>→hdiutil attach -mountpoint→osascript deploy/macos/tray-dmg-layout.applescript→sync+hdiutil detach→hdiutil convert -format UDZO -imagekey zlib-level=9→hdiutil verify。- 新增
deploy/macos/tray-dmg-layout.applescript:驱动 Finder 把挂载窗口摆成 640×420 居中的 图标视图、96px 图标,app 落在(170, 210)、Applications落在(470, 210),并在关闭窗口 前读回坐标作为成功判据。窗口几何只有这一个来源。 - 布局这一步是尽力而为:没有可脚本化 Finder 的主机会重试三次、把警告打到 stderr、继续产出
映像,并把该资产记为
installerLayout: false;release.yml读取该字段写进 job summary, 缺失时打::warning::。静默降级成普通文件夹窗口是这里唯一不可接受的失败形态。 - 新增
DMG_BACKGROUND(可选):背景 png 落到卷内.background/background.png并打上chflags hidden;仓库自身不带背景,默认无。
测试(先红后绿)
deploy/macos/tray_bundle_test.go 新增 TestPackageScriptLaysOutAnInstallerWindow 与
TestLayoutScriptDrivesFinderThroughTheInstallerWindow,scripts/release_workflow_test.go 的
macOS 作业断言加入 installerLayout。三条守卫在实现前均按预期失败,实现后通过。
验证证据(本机 macOS 14.6 / arm64)
VERSION=v0.0.0-review ARCHES="arm64 amd64" ./scripts/package-macos-tray.sh退出 0,无布局 警告;manifest.json两个资产均为installerLayout: true。映像体积比直接-format UDZO更小(arm64 5,144,260 → 4,507,946 字节,amd64 5,725,896 → 4,992,000 字节)。- 只读挂载产出的映像后向 Finder 读回:
170210|470210|icon view|96|4152681055688,即两个坐标、 图标视图、96px、窗口{415,268}–{1055,688}(640×420 居中)。卷内 app 的codesign --verify --verbose=2通过,Contents/Resources里有LICENSE与NOTICE。 DMG_BACKGROUND=<png>的一次性构建:卷内.background带hidden标记,.DS_Store里有backgroundImageAlias。- 把布局脚本换成必然失败的版本后的一次性构建:退出码仍为 0,stderr 出现三次
installer layout attempt N/3 failed与一条降级警告,清单记installerLayout: false; 布局脚本随后按 sha256 还原确认未改动。 - 端到端发行链路:
VERSION=v0.0.0-installer ./scripts/package-macos-tray.sh后VERSION=v0.0.0-installer TRAY_DIST_DIR=dist/v0.0.0-installer/macos-tray ./scripts/build-release.sh退出 0,产出 6 个归档 + 2 个.dmg+ 一份SHA256SUMS+manifest.json+manifest-tray.json;shasum -a 256 -c SHA256SUMS八项全 OK,manifest.json仍为schemaVersion: 16/ 6 platforms / 6 assets,合并后的.dmg再挂载读回仍是170210|470210|icon view|96。 - 重复合并被拒:对已含托盘的 Release 目录再次合并时,
merge-tray-dist.sh以already exists; refusing to publish two builds under one asset name失败,不覆盖既有资产。 - 常规门禁:
go test ./... -count=128 个包 ok;go test -race ./... -timeout 30m28 个包 ok、无 DATA RACE;go vet ./...与CGO_ENABLED=1 go vet -tags tray ./...通过;bash -n三个脚本通过;git diff --check干净。全量并行跑go test ./...时internal/e2e的TestSOCKS5WebPageLatency/refused_target_does_not_reconnect_agent_session失败过一次,单独连续三次运行均通过;本次改动不含任何 Go 运行时代码,判定为既有的时序敏感 用例在全量并发下抖动,未纳入本次修复范围。
仍待确认与后续
- GitHub macOS runner 上 Finder 是否总能被脚本驱动,本次未在真实 runner 上验证。不可用时
发布不会失败,但产物会退化成普通文件夹窗口,并由
installerLayout: false与 job summary 如实标出——首个由 workflow 产出的 Release 需要人工看一眼这两处。 - Developer ID 签名与公证、
.icns图标资产、把托盘接进download_api.go的下载清单,仍沿用 上文的后续项口径;公证与装订会改变映像字节,之后必须重算SHA256SUMS并把manifest-tray.json的notarized置为true再合并。
补记(2026-10-06):图标、键盘粘贴、文本辅助与 auth_mode 校正
背景
真机反馈两条:Finder 里 TunnelMesh Client.app 没有图标;主界面路由配置的输入框不能粘贴
(⌘V 与 ⌃V 都不生效),且首字母会被自动大写。顺着“已支持的配置项是否都进了界面”自查时,
又发现托盘表单提供了 config.Validate 并不接受的 auth_mode: remote。
变更
- 图标:新增
scripts/trayicon/main.go(纯标准库绘制,几何即代码)、scripts/generate-tray-icon.sh(go run+iconutil)与产物deploy/macos/TunnelMeshClient.icns。打包脚本在 Finder 布局之后把同一份.icns作为.VolumeIcon.icns写入挂载点并SetFile -a C,安装器卷因此也带品牌图标。 - 键盘:
internal/tray/native/tray_darwin.m安装应用主菜单——Edit 的 ⌘X/⌘C/⌘V/⌘Z/⇧⌘Z/ ⌘A 走 responder chain,⌘Q 复用托盘自己的TMActionQuit路径(停运行时、释放锁再退出); 另加 local key-down monitor,把 ⌃X/⌃C/⌃V 直接派发到 responder chain。⌃A/⌃E 保持 Cocoa 原有 的行首/行尾语义,不改写。 - 文本辅助:
TMDisableSystemTextAssists同时registerDefaults:与写本 bundle 持久域, 关掉自动大写、自动纠错、文本替换、引号替换与破折号替换;前端新增web-tray/src/textInput.ts,路由配置与配置目录每个技术字段绑定autocapitalize=none/autocorrect=off/spellcheck=false/autocomplete=off。 - auth_mode 校正:下拉去掉
remote(socks5只有none/password,http-proxy只有none/basic);auth_url与认证方式解耦,代理协议下始终可填并带说明;store 里把空的authMode归一为none;检测新增tunnel.authUrl——必须是绝对 http(s) URL,写在tcp/udp/http上给警告,未填不报(远程校验本就关闭)。 - 配置项覆盖度:隧道 9 个字段(
name/protocol/listen/agent_id/target_host/target_port/auth_mode/allow_remote/auth_url)与server_url/token/mode均已进界面, 协议列表取自client.SupportedTunnelProtocols()单一来源。allow_remote原本就在,但两端都 没有回归,故补 Go 的client.yaml往返断言与前端开关断言。未进界面的instance_id/instance_id_path/connections.*/stream.*/remote_validation.*/metadata由节点树式保存原样保留,这一点写进了用户文档。 - 文档:
docs/user-guide/client-tray.md(快捷键、字段表纠正listen_addr→listen、未进界面 的配置项、检测项、排障两条)、docs/deployment/macos-client-tray.md(应用图标章节、卷图标 顺序与原因、可机器核对的图标验证片段)、deploy/README.md一行。
实测证据(本机 macOS 14.6 / arm64,有 window server 会话)
- 粘贴因果链:用合成
NSEvent经-[NSApplication sendEvent:]打给真实 WKWebView,剪贴板 预置pasted-ok。无主菜单 → 字段仍为空(复现反馈);装上带 Edit 动作的主菜单 → 同一 ⌘V 粘贴成功;再装 monitor → ⌃V 也成功(sendAction:paste:由WKWebView接受,返回 1)。 monitor 的 mask 必须是NSEventMaskKeyDown:传NSEventTypeKeyDown时 handler 一次都不触发, 这个坑有守卫测试兜住。 - 首字母大写:WebKit 侧未复现——当前输入源是拼音(
com.apple.inputmethod.SCIM.ITABC)时, 逐字符注入hello world原样落在字段里;合成事件绕过输入法转换,因此该实验只排除了 WebKit 自身的大写逻辑,不能证明输入法上屏显示(拼音首字母显示为大写)不是来源。这台机器的系统开关 确认是开的:defaults read -g NSAutomaticCapitalizationEnabled= 1。 - 两种覆盖手段各有作用域:
NSSpellChecker类属性实测——registerDefaults:关掉自动纠错, 对自动大写无效;自动大写只认持久域。而objectForKey:会穿透到全局域,所以“已有值就不写” 的判断恰好跳过了唯一需要写的那一项:首版实现只持久化了 4 个键,改为无条件写入后 5 个键齐全, 且defaults read -g NSAutomaticCapitalizationEnabled仍为 1(不动用户的系统设置)。 - 图标:
NSWorkspace iconForFile:对最终映像里的 app 与卷都解析出品牌图标;映像内CFBundleIconFile=AppIcon、Resources/AppIcon.icns111303 字节、卷根带.VolumeIcon.icns。 顺序实测:布局前拷入 → 挂载后是通用磁盘图标(update volume会应用并删除该文件,而被应用的 图标活不过hdiutil convert);布局后拷入 +SetFile -a C→ 品牌图标。 - 产物:
VERSION=v0.0.0-trayfix ARCHES=arm64 ./scripts/package-macos-tray.sh产出 4654356 字节映像,installerLayout: true,codesign --verify通过;打包脚本的产物新鲜度检查通过, 发行 JS 里 socks5 的认证方式已是`none`,`password`,不再出现remote。 - 本环境无法验证:真键盘输入被 TCC 拒绝(
AXIsProcessTrusted=0,osascript发送按键报 1002),因此 ⌘V/⌃V 与首字母大写的最终确认仍需在 GUI 里手点一次。
测试(先红后绿)
- 红:
TestShimInstallsAMainMenuForEditingShortcuts、TestShimMapsControlKeyEditingShortcuts、TestShimTurnsOffSystemTextAssists、TestBundleShipsABrandIcon、TestValidateRejectsUnusableAuthURL、TestValidateAcceptsAWellFormedAuthURL、TestValidateWarnsWhenAuthURLCannotApply、TestValidateStaysSilentWithoutAnAuthURL, 以及前端 proxy-auth 两条与文本辅助两条。 - 绿:
go test ./... -count=1全部 ok;go test -race ./... -count=1全部 ok、无 DATA RACE;go vet ./...与CGO_ENABLED=1 go vet -tags tray ./...通过;cd web-tray && npm test -- --run70 passed、npm run build通过;bash -n四个脚本通过;git diff --check干净;gofmt -l internal scripts deploy cmd无输出。
顺带
internal/server/api.go 与 internal/server/client_scoped_api_test.go 的 gofmt 对齐(上一轮遗留,
纯空白)。上文“仍待确认与后续”里列的 .icns 图标资产一项,本次已交付。
补记(2026-10-06):v1.3.1 的托盘映像装不上 macOS 14
现象与根因
合并进 main 后发出的 v1.3.1 里,tunnelmesh-client-tray-v1.3.1-darwin-arm64.dmg 在
macOS 14.6 上打开报“应用程序 “TunnelMesh Client” 的这个版本不能与此版本的 macOS 配合使用。
你使用的是 macOS 14.6。该应用程序要求 macOS 15.0 或更高版本”。
对已发布产物直接取证:
hdiutil attach -nobrowse -readonly tunnelmesh-client-tray-v1.3.1-darwin-arm64.dmg
otool -l "/Volumes/TunnelMesh Client/TunnelMesh Client.app/Contents/MacOS/tunnelmesh-client-tray" \
| grep -A4 LC_BUILD_VERSION # -> minos 15.0, sdk 15.5
plutil -extract LSMinimumSystemVersion raw \
"/Volumes/TunnelMesh Client/TunnelMesh Client.app/Contents/Info.plist" # -> 13.0
hdiutil detach "/Volumes/TunnelMesh Client"
Info.plist 与 manifest.json 都写着 13.0,但 LaunchServices 判定用的是 Mach-O 的
LC_BUILD_VERSION.minos。托盘走 cgo,链接由 clang 完成,clang 在没被指定目标版本时按构建主机
写 minos;.github/workflows/release.yml 的托盘作业跑在 macos-15,于是产物记成 15.0。
scripts/package-macos-tray.sh 里原本有一行 MIN_MACOS="13.0",但它只被写进 manifest.json,
从未参与编译,也没有任何地方核对产物——三处可以同时“看起来正确”,而包根本装不上。
同一发行包里的 tunnelmesh-v1.3.1-darwin-arm64.tar.gz(server/agent/client 三个二进制)是 Linux
交叉编译、CGO_ENABLED=0 走 Go 内部链接器,实测 minos 12.0,不受影响。
变更
scripts/package-macos-tray.sh:删掉脚本里那份MIN_MACOS字面量,改为plutil -extract LSMinimumSystemVersion raw "$INFO_TEMPLATE"从 plist 读出(单一来源;读出的值 不是X.Y就直接失败);编译时同时设MACOSX_DEPLOYMENT_TARGET与CGO_CFLAGS/CGO_LDFLAGS的-mmacosx-version-min;新增verify_deployment_target,每个架构链接完立刻otool -l读回minos并与 plist 比对,不一致就让打包失败;otool进必备工具清单。deploy/macos/tray_bundle_test.go:新增TestPackageScriptPinsTheMachOSDeploymentTarget, 守卫“从 plist 派生 + 两个编译开关 + 读回校验”,并禁止脚本再出现MIN_MACOS="<数字>字面量;TestPackageScriptMatchesTheTemplate对脚本的要求从“含13.0字面量”改为“含LSMinimumSystemVersion”,即要求它去读 plist。- 文档:
docs/deployment/macos-client-tray.md新增“最低系统版本”一节(含如何检查一个已经下载 下来的映像);docs/user-guide/client-tray.md排障表新增该现象;deploy/README.md与docs/development/testing.md同步守卫口径。
实测证据(本机 macOS 14.6 / arm64)
- 对照实验说明为什么两个开关都要设:默认构建
minos 14.0(等于宿主);只设MACOSX_DEPLOYMENT_TARGET=13.0得到minos 13.0,但伴随 16 条ld: warning: object file ... was built for newer 'macOS' version (14.0) than being linked (13.0); 再加CGO_CFLAGS/CGO_LDFLAGS后minos 13.0且零警告。 - 红:新测试在未改脚本时报 5 条
must reference与 1 条字面量重复。 - 绿:
go test ./deploy/macos/ -count=1通过。 - 端到端:
VERSION=v0.0.0-minosfix ARCHES=arm64 ./scripts/package-macos-tray.sh打印verified: ... runs on macOS 13.0 or later;挂载产物后LC_BUILD_VERSION为minos 13.0/sdk 15.0,LSMinimumSystemVersion为 13.0,codesign --verify通过, 映像 4660838 字节。
补记(2026-10-06):Agent 下拉为空不是作用域问题,是根本没去拉
现象与取证
用户在 v1.3.2(9b9c9e4,minos 13.0,安装于 /Applications/TunnelMesh Client.app)的
路由配置里选 SOCKS5 隧道,Agent 下拉展开显示“该 token 作用域内没有可用 Agent。”
先用配置里真实存在的 server/token 直接取证(token 不回显):
TOKEN=$(awk '/^[[:space:]]*token:/{gsub(/["'"'"' ]/,"",$2); print $2; exit}' ~/.config/tunnelmesh/client.yaml)
curl -H "Authorization: Bearer $TOKEN" 'https://tunnelmesh.claw.qihoo.net/api/v1/client/agents?limit=500'
服务端返回 code 200、7 个 Agent(4 个 online),limit=500 与游标翻页都正常;同一 token 打
/api/v1/agents 返回 401(正确:那是 api_tokens 命名空间)。所以“作用域内没有可用 Agent”是假话。
根因
web-tray/src/App.vue 在 onMounted 里 await Promise.allSettled([settings.load(), routing.load(), about.load()]),
而 Element Plus 的 el-tab-pane 默认立即渲染(shouldBeRender = !lazy || loaded || active),
四个 tab 的子组件在父组件 onMounted 之前就已挂载。于是 RoutingView 的
onMounted(() => { if (routing.serverUrl && routing.tokenPresent) void loadAgents() }) 永远看到空 store,
自动拉取一次都不会发生;而下拉的 #empty 槽位无条件写着“该 token 作用域内没有可用 Agent。”——
把“没人问过”说成了“问过且为空”。既有单测全部用 prepare: () => seed() 在挂载前灌好 store,
正好绕开了这个时序,所以 70 个用例全绿也拦不住。
变更
web-tray/src/views/RoutingView.vue:自动拉取改为带闩锁的watch(() => [routing.serverUrl, routing.tokenPresent], …, { immediate: true }), 配置晚到也能拉一次,且每窗口只一次(serverUrl是实时表单字段,不闩锁就变成打字即请求); 新增agentListState/agentStateText,把 未拉取 / 正在拉取 / 拉取失败(带原因) / 确实为空 四种状态 分开呈现,下拉#empty槽位与字段下方[data-test="agent-state"]共用同一份文案。web-tray/src/stores/routing.ts:新增agentsFetched,仅在成功返回后置位——它是“能不能说作用域为空”的唯一依据。- i18n:
routing.tunnels.agent.notLoaded中英各一条。 - 文档:
docs/user-guide/client-tray.md说明自动拉取时机与四种状态怎么读,排障行改为按状态分流;docs/development/testing.md补该覆盖点。
测试(先红后绿)
- 红:3 条新用例(
routing tab agent picker)全部失败——挂载后灌入配置不触发请求、空态文案不分状态。 - 绿:
npm test -- --run20/20(该文件)→ 全量 73 passed(原 70);npm run build通过并镜像到internal/tray/webdist/dist。 - 顺带修正既有用例“loads the agent picker from the server…”的时序假设:自动拉取在飞时按钮是
loading态、Element Plus 会禁用它,点击是 no-op,因此显式刷新前先flush等待首拉结束。 这条不是被改坏了,是它原先隐含的“点击必定再发一次”不再成立。