Files
wuguanganandClaude Opus 5 a9363e43c9 feat: 共用密钥中心 + 项目账号密码库
两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。

**共用密钥**:平台密钥(飞书、领星、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>
2026-08-17 12:45:20 +08:00

33 KiB
Raw Permalink Blame History

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) 会做背景亮度补偿 —— 背景亮它变亮、背景暗它变亮 (后者是为了让盖在上面的内容可读)。而岛顶着的物理刘海恒为纯黑, 材质自己在漂,怎么调都对不齐。

做法:三件事一起做,缺一不可。

  1. 用 .clear 而不是 .regular,并压一层固定的黑色 tint。 .clear 不做亮度补偿,最终亮度完全由自己的 tint + scrim 决定。
  2. scrim 按两端极值定,不按好看定。 白底下 1 × (1−tint) × (1−scrim) 要落在 0.15 附近才和刘海同色系;黑底下又不能把玻璃压没。0.7 上下是这两个约束的交集 —— 低于 0.6 黑底发灰,高于 0.85 白底就成了不透明黑块。
  3. 刘海下沿加一段 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 的一整块,横跨刘海两侧的菜单栏 —— 鼠标去点右上角图标时路过就会把岛弹出来。

两件事都要做:

  1. 拿到指针位置后,再用岛的实际形状(hitPath)判一次;
  2. 加 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 实测才定位到。教训有两条:

  1. 不报错的 API 要主动查前置条件。 TerminalDispatch.open 现在在派发前 fileExists(isDirectory:),不通过就拒绝并记日志。
  2. 看不见的东西查不了。 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: [] 完全不匹配。

三处都要改:

  1. 顶层键按键名匹配,别做整行字面比较;
  2. 追加写入前先把 projects: [] 规范化成 projects:,否则每次扫描都在造这种坏文件;
  3. 解析失败必须能被看见 —— 文件里有 - 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 的决策输出。

交互缺陷必须用真实鼠标验证

这一轮的全部交互缺陷(点击被系统吞掉、整行只有文字可点、外部点击监听误伤自己) 在截图上一个都看不出来 —— 布局、配色、内容全是对的,只是点不动。 必须真的移动鼠标、真的点下去再截图比对。

配套的坑:先确认光标在哪块屏幕上。光标跑到外接屏时所有移动都落空, 表现成"悬停功能坏了",会浪费大量时间去查根本没坏的代码。