两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。 **共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍, 而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、 勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env (目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。 **账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密, 查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。 两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死 「密码的值不会出现在给 AI 的那个目录里」。 三个决定了做法的发现: 1. 「让 AI Read 那个文件」会把密钥写进两个明文文件—— ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API), 以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。 所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和 「不要用 Read/cat/grep」禁令的三句话。换成 source 之后, transcript 里只剩路径和变量名,值一次都不出现。 2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。 README 和代码注释都按分装写,不吹成边界。 3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时, 默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」 这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它—— 文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。 为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。 几个不写下来一定会再踩的坑(已进 NOTES.md): - .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644 - GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开 - 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨; EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点 - LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹 - 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉 - .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号; 多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级 617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、 #、多行 PEM 和含单引号的值。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
33 KiB
HubKit 实现笔记
记录实测踩到的坑和不显然的结论。这些都是在本机验证过的,不是推测。
Liquid Glass
GlassEffectContainer 会把全部子视图纳入玻璃合成
症状:内容层的文字被玻璃材质糊成一团,完全不可读;调试底色能正常显示, 说明布局位置是对的,纯粹是被材质糊了。
原因:GlassEffectContainer 不只作用于挂了 .glassEffect 的子视图,
它的所有子视图都会进入玻璃合成。
做法:容器里只放外壳,内容层留在容器外、叠在它上面。
ZStack(alignment: .top) {
GlassEffectContainer(spacing: 22) { shell } // 只有外壳
contentLayer // 内容在容器外
}
文字可读性靠 scrim,不靠逐字阴影
玻璃高度透明,壁纸一换文字就可能完全读不出来。在玻璃层之上、内容层之下铺一层
随形状裁切的黑色渐变遮罩,成本近乎为零,效果远好于给每个 Text 挂 shadow
(后者还会在半透明背景上糊出一圈脏边)。
深色玻璃上不能用系统的中性灰
#8E8E93(系统 secondary label 灰)在压过 scrim 的深色玻璃上几乎完全看不见 ——
那个值是为浅色背景设计的。改成接近白的浅灰,靠透明度而不是灰度表达"不活跃"。
Liquid Glass 会跟着背景亮度自适应,压不住就和刘海撞色
症状:岛飘到浅色窗口(微信、Finder、文档)上时整块变成灰塑料; 换到纯黑终端上又比刘海亮一截。两种情况下岛和刘海之间都有一条明显的分界线。
原因:.glassEffect(.regular) 会做背景亮度补偿 —— 背景亮它变亮、背景暗它变亮
(后者是为了让盖在上面的内容可读)。而岛顶着的物理刘海恒为纯黑,
材质自己在漂,怎么调都对不齐。
做法:三件事一起做,缺一不可。
- 用
.clear而不是.regular,并压一层固定的黑色 tint。.clear不做亮度补偿,最终亮度完全由自己的 tint + scrim 决定。 - scrim 按两端极值定,不按好看定。 白底下
1 × (1−tint) × (1−scrim)要落在 0.15 附近才和刘海同色系;黑底下又不能把玻璃压没。0.7 上下是这两个约束的交集 —— 低于 0.6 黑底发灰,高于 0.85 白底就成了不透明黑块。 - 刘海下沿加一段 44pt 的黑色渐隐(
IslandTheme.notchBlend)。 不透光的刘海和半透明玻璃直接相接必然有亮度落差,渐隐区间要比刘海本身还高, 太短只会把"硬边"变成"软边",问题性质没变。
另外边缘高光是玻璃感的关键一笔(IslandTheme.edgeHighlight):真实玻璃的边缘
因折射比中间亮,下沿尤其明显。没有这条边,再透明也只会被读成一块半透明色块。
展开态要牺牲质感换可读性
实测岛飘在 Finder 上时,背后的文件名会直接穿透上来糊在会话名下面。 折叠 / 悬停是"看一眼"的形态(内容少、字大),可以留足玻璃感; 展开和审批是"读"的形态,那两个形态里可读性优先,scrim 要再压一档。
结论:Liquid Glass 和"贴合刘海"物理上互斥
调了三轮参数才认清这件事,所以写在最前面。
.glassEffect 的设计目标是"盖在任何东西上,上面的内容都可读",
于是它跟着背景亮度双向补偿:背景亮它变亮,背景暗它也变亮。
而刘海恒为纯黑。实测(.clear + 黑 tint):
| 背景 | 玻璃输出 | 压 0.72 黑罩后 | 刘海 |
|---|---|---|---|
| 白底 Finder | ≈ 1.0 | 0.28(中灰) | 0 |
| 纯黑终端 | ≈ 0.45 | 0.13 | 0 |
白底下要压到刘海那个黑需要 scrim ≈ 0.94,那时"玻璃"已经不存在了。
换 NSVisualEffectView(各种 material 都试过)同样如此 ——
任何"能透出背景"的材质,白底上都必然比黑底上亮,差别只是程度。
最后的做法:一层固定不透明度的暗色基底(IslandTheme.baseOpacity = 0.86),
亮度只由它决定,和背景无关:
最终亮度 ≈ 背景亮度 × (1 − 0.86)
白底 → 0.14 黑底 → 0 刘海 → 0
留下的 14% 就是"还看得出背后有东西"的量。玻璃质感改由背景模糊
(IslandBackdrop)和边缘高光(IslandTheme.edgeHighlight)承担。
悬停要有形状二次判定 + 停留延迟
NSTrackingArea 只能是矩形。折叠态的岛是个上窄下宽的异形,用它的包围盒
做热区就是 217×52pt 的一整块,横跨刘海两侧的菜单栏 ——
鼠标去点右上角图标时路过就会把岛弹出来。
两件事都要做:
- 拿到指针位置后,再用岛的实际形状(
hitPath)判一次; - 加 250ms 的停留延迟,横穿而过不触发。
只做其中一件都不够:没有形状判定,热区太宽;没有延迟,擦边而过也会触发。
窗口
非 key 窗口的点击会被整个吞掉 —— acceptsFirstMouse
症状:岛显示正常,但任何点击都没反应 —— 展开点不动、列表行点不动、 审批按钮点不动。没有任何报错。
原因:AppKit 的规则是,点击一个非 key 窗口时,如果目标视图的
acceptsFirstMouse(for:) 返回 false,这次点击只用来切换 key 状态,
不会派发给视图。NSHostingView 默认返回 false。
而这个 app 是 .accessory(永远不是活跃 app)、panel 是 .nonactivatingPanel
(点它不激活也基本不成为 key),于是每一次点击都算 "first mouse"。
做法:IslandHostingView 覆写 acceptsFirstMouse 返回 true。
一行代码,但不写就等于整个岛是张图片。
NSHostingView.isFlipped 是可读写属性,不能假设
SDK 里的声明是 @objc final override public var isFlipped: Bool { get set } ——
final 所以子类不能覆写,可读写所以默认值不属于契约。
hitTest 里必须按运行时的 isFlipped 归一坐标。猜错的话命中区会整体翻到窗口底部
(720pt 高的窗口,岛在顶部 46pt,翻过来就落在屏幕中间的空气里),
整个岛点不动且没有任何报错 —— 和上面那个坑的症状一模一样,很难区分。
悬停要用 .activeAlways 的 NSTrackingArea,不能只靠全局 monitor
NSEvent.addGlobalMonitorForEvents 拿不到发给本 app 自己的事件,有盲区。
主链路应该是宿主视图上的 NSTrackingArea,关键选项是 .activeAlways ——
它让 mouseEntered / Moved / Exited 在本 app 未激活时照样投递。
默认的 .activeInKeyWindow 对一个永远不是 key 的 panel 等于什么都不做。
窗口还必须设 acceptsMouseMovedEvents = true,否则 .mouseMoved 根本不投递。
全局 leftMouseDown monitor 会收到点在自己身上的点击
症状:展开态点列表任何一行,岛立刻收起 —— 展开态等于不可用。
原因:用全局 monitor 实现"点岛外收起"时,直觉上它只该收到别的 app 的事件。
但本 app 是 .accessory 且从不激活,点岛的事件在系统看来并不属于某个活跃 app,
于是照样会收到。
做法:回调里拿 event.locationInWindow(全局 monitor 里这已经是屏幕坐标)
换算到视图坐标,和 hitPath 比一下,落在岛内就忽略。
形态切换不能靠替换 rootView
hostingView.rootView = AnyView(...) 是一次性的树替换,SwiftUI 看到的是
两棵不相干的树:动画无从发生(withAnimation 和 .glassEffectID 都失去前提),
而且所有 @State 归零(审批面板的时间锁、长按进度、TimelineView 的时钟
每次形态一变就重置)。
形态要放在 @Observable 的模型里,rootView 只设一次。
系统按钮样式在非活跃窗口里会被去饱和
.buttonStyle(.glass) / .glassProminent 在非 key 窗口里会整体变灰,看起来像禁用。
审批面板尤其不能这样 —— 用户看到两个灰按钮的第一反应是"点不了",
而那正是唯一需要他立刻决策的地方。岛上的按钮一律用自绘样式(IslandButtonStyle)。
只有 selected 行才画背景 = 只有文字可点
症状:列表行必须精确点在文字上才有反应,右边一大片空白点不动。
.contentShape(Rectangle()) 不足以让一行"到处都能点"——没有背景的行,
空白区域没有任何可命中的内容。每一行都要有常驻背景(哪怕 0.03 透明度),
顺便还能做悬停反馈。
岛必须强制暗色
不是审美偏好。岛的上半截要和物理刘海严丝合缝,而刘海在浅色模式下也是纯黑。
做浅色岛会在刘海下沿(y = notchHeight)留一条永远消不掉的黑白硬边。
逐像素点击穿透要用形状而不是包围盒
窗口是 640×720 的透明块,不处理会吞掉整个屏幕顶部中间区域的点击。
用 IslandShape 的实际路径做 hitTest:折叠态上半段严格只有刘海那么宽,
两侧的菜单栏图标依然可点;形状变了命中区自动跟着变。
用包围盒近似的话,折叠态两侧多出的 8pt 会压在菜单栏上,把输入法、电量图标吃掉。
窗口尺寸恒定,只改内部布局
形态切换绝不调 NSWindow.setFrame。窗口 frame 动画走 CoreAnimation 隐式事务,
和 SwiftUI 的 spring 不同步,展开时会看到内容被裁掉一两帧。
刘海几何
本机实测(16" M4 Max,默认缩放):
| 值 | |
|---|---|
| 内建屏 frame | 1728 × 1117 @2x |
| 刘海宽 | 185 pt |
| 刘海高 | 32 pt |
| 菜单栏高 | 33 pt |
| 刘海中心 X | 864 pt |
| 外接 LG 4K | 1920×1080,safeAreaInsets.top = 0,无刘海 |
注意和常见的"200×37"传闻不一致。所有布局都必须写成 notchWidth 的函数,
用户改缩放时这个值会变。
会话探测与跳转
权威状态源是 ~/.claude/sessions/<PID>.json
Claude Code 自己在写,含 busy / idle / waiting / shell 和 waitingFor。
不需要任何启发式推断(比如从 pane_title 的 spinner 字符猜 busy)。
死进程的 json 会残留(实测有好几天前的),必须用 kill(pid, 0) 过滤。
跳转的 join 键是 iTerm 的 jobPid
实测排除的方案:
- iTerm session 的
tty→ tmux-CC会话返回missing value tmuxPaneID/tmuxWindowName/tmuxPaneTitle变量 → 全部missing value- 外部
tmux select-window→-CC模式下会被 iTerm 立刻覆盖
可用方案:jobPid 向上走进程树,第一个存在 sessions/<PID>.json 的祖先即所有者。
AppleScript 语法有坑:必须写 tell s to get variable named "jobPid",
写成 variable named "jobPid" of s 会报 -1723「不允许访问」。
bg 会话不在任何终端里
kind: bg 的会话挂在 claude bg-pty-host daemon 下:
23402 (claude bg-spare) → 23371 (claude bg-pty-host) → 70305 (claude daemon) → 1
祖先链必然连不上终端。用户看它的地方是某个跑 claude agents 的 tab,
那是查看器进程,与后台会话没有任何亲缘关系。
对 bg 会话破例用 tab 标题匹配 —— 但和旧实现按 pane_title 匹配有本质区别:
旧实现匹配 ai-title(每轮都变),这里匹配的是创建时一次性派生的稳定 job 名。
且只在祖先链未认领的 tab 里找、要求名字长度 ≥ 8。
tmux 窗口名不可信
automatic-rename 会把窗口名改成前台进程名,而 claude 的进程名就是它的版本号 ——
实测出现过窗口名变成 2.1.173。旧实现用「窗口名 == projects.yaml 的 name」
判断项目是否运行,实测 9 个窗口只有 2 个匹配得上。身份识别绝不能依赖窗口名。
iTerm2 的包名是 iTerm.app,AppleScript 名才是 iTerm2
/Applications/iTerm.app/Contents/MacOS/iTerm2 —— 只有可执行文件叫 iTerm2。
旧 isInstalled 拿 AppleScript 名拼 /Applications/iTerm2.app,永远找不到,
detectTerminal() 一路降级到 macOS 终端。
这个 bug 藏了很久,因为平时 hub session 已存在且有客户端连着,
走的是 addWindow,压根不碰终端检测;只有开机后第一次建 session
才会走 createSession → detectTerminal(),于是现象是
「重启后开项目弹的是系统终端」,看起来像和重启有关,其实和重启无关。
判装没装一律走 bundle id + LaunchServices(NSWorkspace.urlForApplication),
路径只做兜底 —— 用户可能装在 ~/Applications、Setapp 目录、或者改过包名。
macOS 的 pgrep 默认不匹配自己的祖先进程
要加 -a 才把祖先算进来。所以 pgrep -x iTerm2 在从 iTerm 里跑起来的
hubctl / hubprobe 里恒为假 —— 跳转会报「iTerm2 未运行」,终端派发会以为
iTerm 没起来而重复 open。判 app 在不在跑用
NSRunningApplication.runningApplications(withBundleIdentifier:),
它也不会像 tell app "iTerm2" to running 那样触发冷启动。
(同理:CLAUDE.md 里那条「必须用 pgrep -x ClaudeHub」讲的是另一个坑
—— -f 会子串匹配到历史遗留的同名 bundle。两条别混。)
关掉终端不会结束会话,kill(pid, 0) 判不出来
claude 的父进程是 tmux server,关窗口只是 detach,进程照跑。于是
ClaudeSessionReader 的判活依然为真,项目行一直亮着蓝点写「1 个会话」,
而用户明明已经把窗口关了 —— 他会认定界面在撒谎。
界面没撒谎,它只是把「进程活着」当成了「会话在线」。这是两件事,
一颗点表达不了。真相要两半都说:还在跑,但你找不到它了。
判据是 tmux list-clients -F '#{client_session}' 里有没有该 pane 所属的会话名
(SessionStore.detachedIds)。
两个不能省的边界:
- 按 tmux 会话名判,不是「有没有任何客户端」 —— 客户端可能连在别的 session 上。
- 绑不到 pane 的不算 detached。那是「根本不在 tmux 里」(VS Code 扩展、 直接开的终端、bg 任务),有没有终端连着这里判断不了, 硬报 detached 就是拿"不知道"当结论,会让一堆正常会话集体显示成"终端已关"。
「关窗即结束」的前提是接得回来
用户选了这一侧(默认开着,设置里能关),但紧接着补了一句 「那我也打不开了呀」。只会杀不会还的回收器,是把一个显示错误的会话 换成一个找不回来的会话 —— 那不是修好,是换了个方式弄丢东西。
所以 SessionReaper 杀之前先把 sessionId 记进 ClosedSessionStore,
项目行和岛的启动菜单上长出「接着上次」,走 claude --resume <id>。
但 claude --resume 认的是磁盘上那份 transcript,不是这个 id。
一次都没说过话的会话压根没有 transcript 文件 —— 实机验证过:
一个 11:35 起来、只停在提示符上的会话,~/.claude/projects/… 里
一个字节都没有。不查就记的话,界面上会长出一个点下去 claude 报错退出的
按钮,比没有这个按钮更糟。判据用 TranscriptReader.locate。
回收器另外三条边界:
- 绑不到 pane 的一个都不许碰。 不在 tmux 里的会话(VS Code 扩展、 直接开的终端、bg 任务)判断不了"有没有终端连着",杀它们等于拿"不知道" 当处决理由 —— 这一档最容易写漏,漏了就是批量误杀。
- 新起的会话有 90 秒宽限。
createSession是先建 detached 的 tmux 会话、再让终端去 attach 的,iTerm 冷启动那几秒客户端数就是 0。 没宽限期的话回收器会把用户刚点开的项目当场杀掉,现象是"点了没反应"。 - 连续两轮才动手(30 秒)。一轮就杀的话,换窗口、iTerm 重启恢复 会话这类正常的短暂 detach 都会被当成关掉了。
tmux 对不存在的 -c 目录返回 0,然后悄悄用家目录
$ tmux new-session -d -s t -c /不存在的路径 ; echo $?
0
$ tmux list-panes -t t -F '#{pane_current_path}'
/Users/dev
new-window -c 同样如此。open /不存在的路径 则静默什么都不做。
这两个静默回落把一个数据问题彻底伪装成了功能问题。 实机现象是
「右键菜单里 Finder / VS Code 全没反应」+「Claude Code 开到 /Users/dev」,
看起来像刚加的右键菜单写坏了。真实原因是 projects.yaml 里 40 条有 6 条
指向已经被删掉的目录(agentx-agent 那一批)。
我为此连猜三轮代码("path 传成空字符串了吧")都没猜中,直到把派发参数 落盘 + 拿 tmux 实测才定位到。教训有两条:
- 不报错的 API 要主动查前置条件。
TerminalDispatch.open现在在派发前fileExists(isDirectory:),不通过就拒绝并记日志。 - 看不见的东西查不了。
log show对这个 app 是瞎的(ad-hoc 签名, os.Logger 打了看不到),所以派发日志写~/Library/Application Support/claude-hub/launch.log。 另有hubprobe launch <项目名> [mode]走完全相同的那条路, 用来把「界面传了哪个项目」和「派发本身对不对」分开。
扫描只认 .git,所以有一整类项目永远进不来
GitStatus.scanRepositories 撞到 .git 就停止下钻,且只收有 .git 的目录。
于是 monorepo 里的子目录(demo-iot/{ios,android,server})、还没
git init 的目录(fetch、game)全都扫不进来 —— 实机 40 条里有 7 条属于
这一类,只能手写 yaml,而且写进去之后没有任何界面入口能删掉。
补的是两头:ProjectStore.addExisting(path:)(选任意目录登记,配 NSOpenPanel)
和 ProjectStore.remove(_:)。
删除是文本手术(ProjectYAML.removing(names:from:)),不是"解析完重写":
这份文件是给人手改的,重写会把注释、别名、描述、分段空行全抹平 ——
AcceptanceStore 和 SessionWatchdog 都因为"内存状态直接盖盘"弄丢过手改内容。
删除的边界:从 - name: X 一直删到下一个同级列表项或下一个顶层键。
只删 name 那一行的话,剩下的 path: / aliases: 会被解析器挂到前一条
项目上 —— 删一条把另一条也弄坏了,且完全静默。测试钉着这一条。
打包与签名
辅助可执行文件不能放 Contents/MacOS/
症状:hubctl 一执行就返回 EXIT=137(SIGKILL),没有任何错误输出,
codesign --verify 却报告 "valid on disk"。
原因:Contents/MacOS/ 按约定只放 CFBundleExecutable 指向的那一个可执行文件。
把辅助工具也放进去、并且用 codesign --deep --identifier <bundle-id> 签名时,
两个不同的二进制会声称同一个 bundle identifier。Apple Silicon 上这种签名不匹配
是 exec 瞬间 SIGKILL,不给任何提示。
做法:辅助工具放 Contents/Helpers/,用自己的 identifier
(dev.hengjun.claude-hub.hubctl)单独签名。
签名顺序:先内层后外层,不用 --deep
codesign --force --sign "$CERT" --identifier "$BUNDLE_ID.hubctl" App.app/Contents/Helpers/hubctl
codesign --force --sign "$CERT" --identifier "$BUNDLE_ID" App.app
--deep 会把外层的 --identifier 强加到所有嵌套代码上,正是上面那个坑的来源。
先签内层再签外层时,外层签名会自动把 Helpers/ 的内容封进 CodeResources。
也不要加 --options runtime:自签名 + 未公证的情况下 hardened runtime
不提供任何实际保护,反而引入额外的加载限制。
构建脚本必须冒烟测试 hubctl
签名问题不会让 codesign --verify 失败,只会在运行时静默杀进程。
所以 build-swift-app.sh 最后会真的跑一次 hubctl doctor。
bash 变量后面紧跟中文标点要加花括号
log "编译($CONFIG,SDK $SDK_VERSION)…" # 报错:CONFIG?: unbound variable
log "编译(${CONFIG},SDK ${SDK_VERSION})…" # 正确
全角逗号的 UTF-8 字节会被 bash 当成变量名的一部分。
审批链路
超时阶梯必须严格递增
真实事故:ApprovalCoordinator 和 hubctl 的读超时都设成 60 秒。两者同时到期,
hubctl 先判定"没收到应答"并按 fail-open 放行了一条 git push --force origin main ——
而服务端那一侧其实已经决定拒绝。超时的语义从"安全拒绝"翻转成了"静默放行"。
正确的阶梯(见 HookTimeouts),内层先到期,外层留足余量收结果:
| 层 | 超时 | 到期后 |
|---|---|---|
ApprovalCoordinator |
60s | 判定拒绝 |
HookCoordinator |
65s | 等 UI 把拒绝送回 |
| hubctl 读 socket | 75s | 等服务端写回 |
settings.json hook |
90s | Claude 杀进程的上限 |
任何一层反过来,就会出现「内层判定拒绝、外层已超时放行」的危险窗口。
「连不上」和「连上但没应答」的默认值是相反的
- 连不上 = Hub 没运行 = 这次调用压根没被审查过 → 放行。 不能因为工具没开就让用户的每条命令都失败。
- 连上但没应答 = 服务端认为值得拦截,但决策环节出了问题 → 拒绝。 判据是服务端对非高风险操作立即应答,能走到无应答说明它拦下了。
所以 HubSocketClient.send 返回的是 HookSendResult 枚举而不是可选值 ——
把这两种情况混成 nil 正是上面那个事故的根源之一。
hook 进程死了,审批卡不会自己消失
症状:岛上挂着一张审批卡,点了也没用 —— 因为已经没人接收这个结果了。 更糟的是队列先进先出,这张僵尸卡会一直挡住后面真正需要处理的请求。
场景:Claude 被 Ctrl-C、会话被关、hook 进程超时被 kill。服务端此时仍然 阻塞在等决策上,它并不知道对端已经没了。
做法:HookEvent.clientPid 本来就一路传过来了,用它做定期扫描
(ApprovalCoordinator.startOrphanSweep()):kill(pid, 0) 返回 ESRCH 就判定
发起方已死,按拒绝收尾并撤掉卡片。EPERM 说明进程存在但不属于我们,算活着;
clientPid == 0(不知道发起方是谁)绝不能当成已死。
排障时 NSLog 的插值会被当成私有数据
NSLog("...\(text)") 在 log show 里只会显示 <private>,等于没打。
要看到内容必须写成 NSLog("... %{public}@", text)。
在 Bash 工具调用里用 & 起的后台进程会被杀掉
调用一返回,cmd & 起的进程就没了。测试阻塞式 hook(要等用户在岛上点击)时,
这会表现成"hubctl 立刻退出且没有输出"——看起来和"拒绝被吞成放行"一模一样,
极易误判成安全缺陷。要用工具本身的 run_in_background 参数。
数据
两个 projects.yaml,app 读的不是脚本写的那个
症状:主窗口的「项目」「用量」「设置」三个页面全是空的,看起来像"功能没做"。
原因:
- 脚本(
add-project.sh/scan-projects.sh)和 CLAUDE.md 都写~/Documents/code/claude-hub/projects.yaml(21 个项目,格式正常); - app 读的是
~/Library/Application Support/claude-hub/projects.yaml, 且那份是坏的 —— 旧版留下projects: [],后来的追加逻辑又往文件尾部加了 117 条:
projects: [] ← 解析器看到这行就认定项目段是空的
- name: claude-hub ← 后面全部被忽略
解析器判的是 trimmed == "projects:",projects: [] 完全不匹配。
三处都要改:
- 顶层键按键名匹配,别做整行字面比较;
- 追加写入前先把
projects: []规范化成projects:,否则每次扫描都在造这种坏文件; - 解析失败必须能被看见 —— 文件里有
- name:却解析出 0 条时给出诊断, UI 上把实际读的文件路径显示出来。静默返回空数组是这次排查花时间最多的地方。
用量:945 MB 不能整份读进内存
~/.claude/projects 实测 945 MB / 341 个 jsonl。
原实现对每个文件 String(contentsOf:) 再 split(separator: "\n"),
跑不出结果,页面永远停在 0 —— 用户读到的就是"这个功能没做"。
改成 FileHandle 按 1 MB 分块流式解析 + 按 (path, size, mtime) 增量缓存之后:
| 耗时 | |
|---|---|
| 7 天 · 冷缓存 | 3.6 s |
| 7 天 · 热缓存 | 0.22 s |
| 全部 · 冷缓存 | 4.0 s(363 个会话 / 63474 条消息) |
分块读有个必须写对的细节:块边界几乎必然落在某行中间, 残留的尾巴要留到下一块开头拼接。写错的表现是"大文件统计偏低"—— 数字只是小一点,不会报错,极难发现。
密钥与加密
.atomic 写会换 inode,chmod 必须每次都做一遍
data.write(to:options:.atomic) 是「写临时文件 → rename」。临时文件按 umask 建(通常 0644),
rename 之后这是一个新的 inode —— 上一次 chmod 0600 设在旧 inode 上,
跟眼前这个文件毫无关系。
只在创建时设一次权限的写法,从第二次写入开始就是 0644,而且完全没有症状:
文件在、内容对、程序正常。EnvMaterializerTests.testPermissionsSurviveRewrite
就是连写两次再断言权限。
真正承重的其实是目录的 0700:文件权限有个短暂窗口(临时文件建好到 rename 之间) 是敞开的,目录进不去才让那个窗口没有意义。
GCM 的 AAD 必须用「文件里存下来的那段字节」,不能重新序列化
如果 AAD 是「把 header 结构体再 JSONEncoder 编码一遍得到的字节」,那么
Swift 版本升级、Date 编码策略变化、字典键序变化 —— 任何一个都会让
所有历史文件永久打不开。
和 HookInstaller 那句「.sortedKeys 不是审美,是幂等的前提」是同一个道理,
但后果严重一个量级:那边是每隔几次启动无意义地改一次用户文件,
这边是数据再也拿不回来。所以 VaultCrypto.parse 会把 headerBytes 原样返回,
open 直接拿它当 AAD。
「钥匙不对」和「文件坏了」的处置完全相反
- 钥匙不对(换机、重装、钥匙串被重建):数据是好的,绝不能改名或覆盖, 只能锁住等用户拿恢复码回来
- 文件坏了:改名
.broken-<时间>保住,原位置才允许重建
分不出这两种,就一定会在某一次钥匙串抽风时把好数据当垃圾清掉 ——
AcceptanceStore.swift:514-518 那次事故(解码失败 → 内存空 → 下次写入覆盖)
在加密之后触发面大了三倍:写坏、钥匙没了、拿到另一把钥匙、GCM 认证失败。
header 里的 keyID 就是为了分出这两种情况才存在的,而
EncryptedFile.save() 第一行的 guard state.canWrite 是这套区分唯一的落地点。
Keychain 的授权弹窗就是那道防线,不能为了消掉它放宽 ACL
SecItemAdd 不传 kSecAttrAccess 时,默认 ACL 是「只有创建它的 app 能无提示读取」。
在「攻击者 = 跟你同一个 uid 跑的自动化」这个模型下,
能区分「Vibe Foreman 在读」和「security find-generic-password 在读」的只有它 ——
文件权限做不到(同一个 uid),Touch ID 也做不到(LAContext 只是 UI 门,不参与取密钥)。
改成 SecAccessCreate + 空信任列表就再也不弹了,代价是 Claude Code 一条 Bash
就能取走主密钥,然后五行脚本解开整个密码库。
弹窗频率取决于签名(scripts/build-swift-app.sh):有 Claude Hub Dev 自签名证书时,
ACL 存的 designated requirement 是「bundle id + 证书」,不含 cdhash,重编不失效;
ad-hoc 回落(codesign --sign -)时含 cdhash,每次重编都失效。
构建脚本注释里那句「自签名证书让 TCC 授权在每次重编后仍然有效」说的是同一套匹配逻辑。
LAContext 复用才有 reuse duration
touchIDAuthenticationAllowableReuseDuration 只对同一个 LAContext 实例生效。
每次验证都 LAContext() 新建一个的话,这个设置等于没设 ——
用户每看一条密码就得按一次指纹,很快就会把功能关掉。见 LAContextHolder。
剪贴板定时清空必须比对 changeCount
复制密码之后 N 秒自动清空,听起来只是个 Task.sleep + clearContents()。
但用户在这几十秒里多半会复制别的东西,到点再清就变成
「复制了个链接,一分钟后剪贴板莫名其妙空了」—— 这种 bug 用户根本不会
联想到密码功能上,只会觉得系统抽风。
declareTypes 会推进 changeCount,setString 不会,所以写完之后取到的
就是「我们这次复制」的版本号,到点比一下不一样就别动。
.env 的引号:三家的交集只有单引号
消费者是 source 它的 shell、node 的 dotenv、python-dotenv。
唯一的语义交集是单引号内不做任何展开。双引号在三家手里行为各不相同。
两个具体的坑:
- 多行值(PEM)要写字面换行,不能写
\n。\n只有 dotenv 的双引号下 才还原,而 sh 的双引号不还原。 - 含单引号的值没有共同解。 sh 要写成
'\'',而 node dotenv 的正则是'([^']*)',遇到第一个'就截断拿到残值。所以那种值改写成KEY_B64, 显式降级 + 在文件头写清楚,而不是静默写出一个残值 —— 后者会表现成「密钥明明是对的但接口就是不通」。
生成的文件里不能有时间戳
有时间戳,「内容没变就不写」永远命不中,于是每次启动都重写一遍密钥文件, 而每次重写都是一次上面那个权限回归的窗口。
工程
日志:NSLog 的插值会被隐成 <private>
被坑过两次。NSLog("会话数 \(count)") 在 log show 里只显示 <private>。
而 NSLog("%{public}d", count) 也是错的 —— {public} 是 os_log 的格式语法,
NSLog 不认,会把 {public}d 原样打出来。
只有 Logger 的 privacy: 参数是对的(见 HubCore/HubLog.swift):
HubLog.app.notice("已就绪 —— \(count, privacy: .public) 个会话")
log stream --predicate 'subsystem == "dev.hengjun.claude-hub"' --style compact
拿哈希值做算术前必须先取模
abs(seed) * 37 在 seed 来自哈希(量级接近 Int.max)时直接整数溢出,
Swift trap —— 表现是 app 一显示小人就闪退。
而当时的单元测试喂的是 tick * 7919 这种小整数,完全没碰到。
测试的输入不真实 = 测试没在测东西。 现在测试里固定包含
SpriteSeed.stable(...) 的真实量级值以及 Int.max / Int.min 边界。
另外 abs(Int.min) 会 trap,用 .magnitude 不会。
会话形象要用跨进程稳定的哈希
Swift 的 String.hashValue 每次进程启动都重新加盐。
用它派生像素小人的发色/衣服,每次重开 app 所有人的形象都会换一遍,
"记住橙头发那个是 acme-admin"这件事根本立不住。用 FNV-1a 自己算(SpriteSeed)。
分段拼装的像素图要断言尺寸
小人是「发型 + 脸 + 身体 + 笔记本」四段拼出来的 16×16。
某段少一行、某行少一格,Canvas 只会安静地少画几个方块,画面上表现为"有点歪"。
SpriteLibraryTests 对每种发型 × 每个姿势断言 16 行 × 16 列,
第一次跑就抓到了 640 处越界。
macOS 文件系统大小写不敏感
Sources/hubprobe 和 Sources/HubProbe 是同一个目录。
可执行 target 的目录名不能只靠大小写和库 target 区分。
测试 bundle 被系统策略拒绝加载
症状:swift test 报 library load denied by system policy /
code signature ... not valid for use in process,测试一个都跑不了。
macOS 26 对 dylib 加载的签名校验更严,.xctest bundle 在增量链接后
签名会失效。清掉重建再重签即可:
rm -rf .build/arm64-apple-macosx/debug/HubKitPackageTests.xctest
swift build --build-tests
codesign --force --sign - .build/arm64-apple-macosx/debug/HubKitPackageTests.xctest
swift test --skip-build
开发期环境变量
HUB_ISLAND_STATE=hover|expanded—— 钉住某个形态,方便截图核对布局HUB_DEBUG_CONTENT=1—— 给内容框描红色底,一眼看出它被放在哪、有没有被裁掉HUB_DEBUG=1—— hubctl 把收到的HookSendResult打到 stderr。 排查"拒绝有没有被吞成放行"时唯一能直接看到真相的地方;走 stderr 所以 开着也不会被 Claude 当成 hook 的决策输出。
交互缺陷必须用真实鼠标验证
这一轮的全部交互缺陷(点击被系统吞掉、整行只有文字可点、外部点击监听误伤自己) 在截图上一个都看不出来 —— 布局、配色、内容全是对的,只是点不动。 必须真的移动鼠标、真的点下去再截图比对。
配套的坑:先确认光标在哪块屏幕上。光标跑到外接屏时所有移动都落空, 表现成"悬停功能坏了",会浪费大量时间去查根本没坏的代码。