feat: 重写为原生 Swift 灵动岛,修掉通知跳转与主窗口数据链路 (#4)

删掉 Tauri 版(app/),全部重写为 SwiftUI + SwiftPM(HubKit/,8.2k 行源码 +
2k 行测试,131 个测试)。核心动机是旧版"点通知跳不到对应终端"——
根因是身份链路全程用会漂移的字符串:项目名 → tmux 窗口名 → pane_title →
AppleScript 子串匹配。实测 9 个 tmux 窗口只有 2 个能和 projects.yaml 对上,
其中一个窗口名是 `2.1.173`(tmux automatic-rename 把它改成了 claude 的版本号)。

主要变化
- 会话状态直接读 ~/.claude/sessions/<PID>.json(Claude Code 自己维护的权威源),
  不再用 spinner 字符猜 busy;死进程的残留 json 用 kill(pid,0) 过滤
- 跳转改用 iTerm jobPid + 进程祖先链求交,全程只有整数 PID 和 UUID;
  bg 会话不在任何终端里,单独用稳定 job 名兜底。实测 9/9 绑定成功
- hook 从 bash 换成 Swift 的 hubctl,走 Unix socket;Hook 没运行时立即放行
- 高风险操作在岛上审批,六重防误点;拒绝是超时和异常的默认值
- 主窗口:会话 / 项目 / 用量 / 审批日志 / 设置

修掉的真实缺陷(详见 HubKit/NOTES.md)
- 超时阶梯是反的:ApprovalCoordinator 和 hubctl 读超时都是 60s,同时到期时
  hubctl 按 fail-open 放行了一条已被服务端判定拒绝的 git push --force origin main。
  改成 60/65/75/90 严格递增,并区分「连不上」(放行)与「连上但没应答」(拒绝)
- 点击全部被吞:app 是 .accessory + panel 是 .nonactivatingPanel,
  NSHostingView.acceptsFirstMouse 默认 false,每次点击都算 first mouse 被丢弃
- hitTest 假设 NSHostingView 恒为 flipped,而 isFlipped 是 final 的可读写属性
- 形态切换靠替换 rootView,导致动画不可能发生且 @State 每次归零
- 全局 leftMouseDown 监听会收到点在自己身上的点击,展开态一点就收起
- hook 进程死后审批卡不消失,且会挡住后面的请求(按 clientPid 扫描清理)
- app 读的 projects.yaml 不是脚本维护的那份,且那份被写坏成
  `projects: []` + 117 条内容,解析器静默返回 0 —— 三个页面因此全空
- 用量对 945MB/341 个 jsonl 整份读进内存,跑不完;改流式 + 增量缓存后
  7 天首扫 3.6s、命中缓存 0.22s、全量 4s
- 辅助二进制放 Contents/MacOS/ 且共用 bundle id 会被 exec 瞬间 SIGKILL
- 同一 bundle id 对应多个 .app 让存活判断和 TCC 授权互相打架

刻意的取舍
- 放弃 Liquid Glass 做岛的基底:它会跟着背景亮度双向补偿,而刘海恒为纯黑,
  两者物理上互斥。改用固定 86% 暗色基底 + 背景模糊 + 边缘高光
- 用 SwiftPM + 打包脚本而不是 .xcodeproj:手写 xcodeproj 的 XML 无法 review,
  且 SwiftPM 保住了 swift test

Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-29 19:20:12 +08:00
1 parent 2a12a1611c
commit b6d90cac2b
135 files changed
+11924 -13408

No files matched your search

+10 -2
View File
@@ -5,6 +5,14 @@ memory/*.md
*.log
node_modules/
sounds/
# 本机的项目清单,各人不同
projects.yaml
app/src-tauri/target/
app/node_modules/
# SwiftPM 构建产物(约 1 GB)。release.sh 的暂存 .app 也在里面。
HubKit/.build/
.swiftpm/
# 发布包。dmg 走 GitHub Release,不进仓库。
release/
dist/
+8 -2
View File
@@ -76,10 +76,16 @@ done
每次新会话启动时,**立即自动执行**以下步骤(不询问用户、不等待确认):
1. **启动通知面板** — 检查 `pgrep -f "claude-hub"` 是否已运行,未运行则后台启动:
1. **启动灵动岛** — 用 `pgrep -x ClaudeHub` 判断是否已运行,未运行则启动:
```bash
~/Documents/code/claude-hub/app/src-tauri/target/release/claude-hub &>/dev/null &
open -a "Claude Hub"
```
没装就先编译安装:`bash ~/Documents/code/claude-hub/scripts/build-swift-app.sh`
**必须用 `pgrep -x ClaudeHub`,不能用 `pgrep -f "Claude Hub.app"`。**
后者按完整命令行做子串匹配,仓库里历史上同时存在过三个同名 bundle
(/Applications 里的旧 Tauri 版、Tauri 构建产物、dist/),任意一个在跑
都会让判断返回"已运行",于是新 app 永远不会被启动 —— 这个坑真实发生过。
2. **显示项目列表** — `bash scripts/welcome.sh`
3. **检查 tmux hub** — `tmux has-session -t hub 2>/dev/null`,报告状态
4. **报告就绪** — 一句话告知用户调度中心已就绪
+461
View File
@@ -0,0 +1,461 @@
# HubKit 实现笔记
记录实测踩到的坑和不显然的结论。这些都是在本机验证过的,不是推测。
## Liquid Glass
### GlassEffectContainer 会把全部子视图纳入玻璃合成
**症状**:内容层的文字被玻璃材质糊成一团,完全不可读;调试底色能正常显示,
说明布局位置是对的,纯粹是被材质糊了。
**原因**:`GlassEffectContainer` 不只作用于挂了 `.glassEffect` 的子视图,
它的**所有**子视图都会进入玻璃合成。
**做法**:容器里只放外壳,内容层留在容器外、叠在它上面。
```swift
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 个匹配得上。身份识别绝不能依赖窗口名。
## 打包与签名
### 辅助可执行文件不能放 `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`
```bash
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 变量后面紧跟中文标点要加花括号
```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 条:
```yaml
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 条消息)|
分块读有个必须写对的细节:**块边界几乎必然落在某行中间**,
残留的尾巴要留到下一块开头拼接。写错的表现是"大文件统计偏低"——
数字只是小一点,不会报错,极难发现。
## 工程
### 日志:`NSLog` 的插值会被隐成 `<private>`
被坑过两次。`NSLog("会话数 \(count)")` 在 `log show` 里只显示 `<private>`。
而 `NSLog("%{public}d", count)` **也是错的** —— `{public}` 是 `os_log` 的格式语法,
`NSLog` 不认,会把 `{public}d` 原样打出来。
只有 `Logger` 的 `privacy:` 参数是对的(见 `HubCore/HubLog.swift`):
```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 在增量链接后
签名会失效。清掉重建再重签即可:
```bash
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 的决策输出。
### 交互缺陷必须用真实鼠标验证
这一轮的全部交互缺陷(点击被系统吞掉、整行只有文字可点、外部点击监听误伤自己)
在截图上**一个都看不出来** —— 布局、配色、内容全是对的,只是点不动。
必须真的移动鼠标、真的点下去再截图比对。
配套的坑:**先确认光标在哪块屏幕上**。光标跑到外接屏时所有移动都落空,
表现成"悬停功能坏了",会浪费大量时间去查根本没坏的代码。
+63
View File
@@ -0,0 +1,63 @@
// swift-tools-version: 6.0
import PackageDescription
// 平台底线定在 macOS 26:灵动岛依赖 Liquid Glass(.glassEffect / GlassEffectContainer),
// 这是 macOS 26 才有的 API。本项目是给这台机器用的个人工具,不需要向下兼容。
let package = Package(
name: "HubKit",
platforms: [.macOS("26.0")],
products: [
.library(name: "HubCore", targets: ["HubCore"]),
.library(name: "HubProbe", targets: ["HubProbe"]),
.library(name: "HubJump", targets: ["HubJump"]),
.library(name: "HubIPC", targets: ["HubIPC"]),
.library(name: "HubProjects", targets: ["HubProjects"]),
.library(name: "HubUI", targets: ["HubUI"]),
.executable(name: "hubprobe", targets: ["HubProbeCLI"]),
.executable(name: "hubctl", targets: ["hubctl"]),
.executable(name: "ClaudeHub", targets: ["ClaudeHubApp"]),
],
targets: [
// 纯数据模型,无副作用,不依赖任何系统调用。
.target(name: "HubCore"),
// 会话探测:读 ~/.claude/sessions/*.json、进程树、tmux 拓扑。
.target(name: "HubProbe", dependencies: ["HubCore"]),
// 跳转引擎:从 sessionId 定位并前置到具体终端 tab。
.target(name: "HubJump", dependencies: ["HubCore", "HubProbe"]),
// hook ↔ app 的 Unix socket 协议、风险分级。
// 刻意不依赖 HubProbe:hubctl 要保持极小、启动极快(每次工具调用都跑一遍)。
.target(name: "HubIPC", dependencies: ["HubCore"]),
// projects.yaml、git 状态、token 用量、终端派发。
.target(name: "HubProjects", dependencies: ["HubCore", "HubProbe"]),
// 灵动岛与主窗口的 SwiftUI 层。
.target(
name: "HubUI",
dependencies: ["HubCore", "HubProbe", "HubJump", "HubIPC", "HubProjects"]
),
// 开发期 CLI:把探测和跳转的结果打出来,便于在没有 UI 时验证。
// 目录名不能叫 hubprobe —— macOS 文件系统大小写不敏感,会和 HubProbe 撞。
.executableTarget(
name: "HubProbeCLI",
dependencies: ["HubCore", "HubProbe", "HubJump", "HubProjects"]
),
// Claude Code hook 的执行体。每次工具调用都会跑,必须小而快。
.executableTarget(name: "hubctl", dependencies: ["HubIPC"]),
// app 本体。刻意做薄:只有 @main 和窗口装配,逻辑全在 HubKit 各库里。
.executableTarget(name: "ClaudeHubApp", dependencies: ["HubUI"]),
.testTarget(name: "HubCoreTests", dependencies: ["HubCore"]),
.testTarget(name: "HubProbeTests", dependencies: ["HubProbe"]),
.testTarget(name: "HubJumpTests", dependencies: ["HubJump"]),
.testTarget(name: "HubIPCTests", dependencies: ["HubIPC"]),
.testTarget(name: "HubProjectsTests", dependencies: ["HubProjects"]),
.testTarget(name: "HubUITests", dependencies: ["HubUI"]),
]
)
+199
View File
@@ -0,0 +1,199 @@
import AppKit
import HubCore
import HubIPC
import HubProjects
import HubUI
import SwiftUI
// Claude Hub —— 本地 AI 编程 agent 的调度中心。
//
// 这个 target 刻意做得很薄:只负责启动 NSApplication、装配灵动岛和主窗口、
// 挂托盘图标。所有逻辑都在 HubKit 的各个库里,那些库可以 `swift test`,这里不行。
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private let store = SessionStore()
private let projects = ProjectStore()
private let approvals = ApprovalCoordinator()
private let notifications = HubNotificationCenter()
private lazy var island = IslandController(
store: store, approvals: approvals, projects: projects
)
private lazy var hooks = HookCoordinator(
store: store, approvals: approvals,
notifications: notifications, projects: projects
)
private lazy var dispatch = TerminalDispatch()
private var statusItem: NSStatusItem?
private var mainWindow: NSWindow?
private var approvalObserver: Task<Void, Never>?
func applicationDidFinishLaunching(_ notification: Notification) {
// .accessory:不在 Dock 显示图标,也不占应用切换器的位置。
// 岛和托盘就是这个 app 的全部门面。
NSApp.setActivationPolicy(.accessory)
projects.load()
projects.startGitPolling()
store.start()
// 通知点击 → 精准跳回对应会话的终端 tab。
// 这是本次重写要修的核心 bug:旧实现的 terminal-notifier 拿不到点击回调。
notifications.onActivate = { [weak self] sessionId in
self?.island.jumpAndCollapse(sessionId: sessionId)
}
notifications.requestAuthorization()
hooks.onIntrusion = { [weak self] event in
self?.island.presentIntrusion(event)
}
hooks.onApprovalNeeded = { [weak self] in
self?.island.presentApproval()
}
hooks.start()
// 清掉发起方已经死了的审批卡,见 ApprovalCoordinator.startOrphanSweep()。
approvals.startOrphanSweep()
// 岛上的「项目」栏可以直接启动 —— 「开发 xxx」是这个工具最高频的动作,
// 不该要求先从菜单栏打开主窗口。
island.onLaunch = { [weak self] project, mode in
self?.launch(project, mode)
}
island.show()
installStatusItem()
observeApprovalQueue()
// 用 Logger 而不是 NSLog,且显式 `privacy: .public` —— 否则统一日志
// 会把插值全部隐成 <private>,排障时等于没打。见 HubLog 的说明。
let sessionCount = store.sessions.count
let projectCount = projects.projects.count
let source = projects.sourceURL?.path ?? "找不到 projects.yaml"
HubLog.app.notice("""
已就绪 —— \(sessionCount, privacy: .public) 个会话,\
\(projectCount, privacy: .public) 个项目(\(source, privacy: .public))
""")
}
func applicationWillTerminate(_ notification: Notification) {
approvalObserver?.cancel()
approvals.stopOrphanSweep()
hooks.stop()
store.stop()
projects.stopGitPolling()
island.hide()
}
/// 审批队列空了就把岛收回去。
///
/// 用轮询而不是回调:`ApprovalCoordinator` 的 resume 发生在 continuation 里,
/// 让它反过来通知 UI 会把两者耦合起来。250ms 的轮询在这里完全够用,
/// 而且只在有审批时才有意义。
private func observeApprovalQueue() {
approvalObserver = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(250))
self?.island.dismissApprovalIfDone()
}
}
}
// MARK: - 托盘
private func installStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.image = NSImage(
systemSymbolName: "circle.hexagongrid.fill",
accessibilityDescription: "Claude Hub"
)
let menu = NSMenu()
menu.addItem(
withTitle: "打开主窗口", action: #selector(openMainWindow), keyEquivalent: "o"
).target = self
menu.addItem(
withTitle: "展开灵动岛", action: #selector(expandIsland), keyEquivalent: "e"
).target = self
menu.addItem(.separator())
menu.addItem(
withTitle: "刷新", action: #selector(refresh), keyEquivalent: "r"
).target = self
menu.addItem(.separator())
menu.addItem(
withTitle: "退出 Claude Hub", action: #selector(quit), keyEquivalent: "q"
).target = self
item.menu = menu
statusItem = item
}
@objc private func refresh() {
store.refresh()
projects.refreshGit()
}
@objc private func expandIsland() {
island.expand()
}
@objc private func quit() {
NSApp.terminate(nil)
}
// MARK: - 主窗口
@objc private func openMainWindow() {
if let mainWindow {
// 主窗口要抢焦点,所以这里才 activate;岛是 nonactivating 的,不受影响。
NSApp.activate(ignoringOtherApps: true)
mainWindow.makeKeyAndOrderFront(nil)
return
}
let root = MainWindowView(
store: store,
projects: projects,
approvals: approvals,
onJump: { [weak self] sessionId in
self?.store.jump(to: sessionId)
},
onLaunch: { [weak self] project, mode in
self?.launch(project, mode)
}
)
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 940, height: 640),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Claude Hub"
window.titlebarAppearsTransparent = true
window.contentView = NSHostingView(rootView: root)
window.center()
window.isReleasedWhenClosed = false
window.minSize = CGSize(width: 780, height: 520)
mainWindow = window
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
}
private func launch(_ project: Project, _ mode: LaunchMode) {
let dispatch = self.dispatch
let name = project.name
let path = project.expandedPath
// AppleScript + tmux 有跨进程成本,别卡住 UI。
Task.detached(priority: .userInitiated) {
dispatch.open(project: name, path: path, mode: mode)
}
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
+164
View File
@@ -0,0 +1,164 @@
import Foundation
/// 会话状态。取值来自 Claude Code 自己写的 `~/.claude/sessions/<PID>.json`,
/// 不是我们推断出来的 —— 这是权威来源。
///
/// 排序即 UI 优先级:waiting 永远排最前(它是唯一需要用户立刻行动的状态)。
public enum SessionStatus: String, Codable, Sendable, CaseIterable, Comparable {
/// 等用户输入或授权。伴随 `waitingFor` 说明原因。
case waiting
/// 正在干活(模型推理 / 工具调用)。
case busy
/// 正在跑 shell 命令。
case shell
/// 空闲。
case idle
/// 未来 Claude Code 新增的取值。保留原字符串,不丢信息。
case unknown
/// 未知状态排在最后,避免新版本 Claude 引入新取值时抢占 waiting 的位置。
private var rank: Int {
switch self {
case .waiting: return 0
case .busy: return 1
case .shell: return 2
case .idle: return 3
case .unknown: return 4
}
}
public static func < (lhs: SessionStatus, rhs: SessionStatus) -> Bool {
lhs.rank < rhs.rank
}
/// 是否算「活跃」——影响像素小人时钟频率与轮询节奏。
public var isActive: Bool {
self == .busy || self == .shell || self == .waiting
}
}
/// 会话类型。`bg` 是通过后台任务派发的,带 `jobId`。
public enum SessionKind: String, Codable, Sendable {
case interactive
case bg
case unknown
}
/// 一个正在运行的 AI agent 会话。
///
/// 身份键是 `sessionId`(UUID)而**不是** `pid`:`--resume` 之后进程会换 PID,
/// 但 sessionId 不变。用 PID 做身份会让 UI 在 resume 时闪一下(旧条目消失、新条目出现)。
public struct AgentSession: Identifiable, Hashable, Sendable {
/// Claude Code 的 session UUID。整个系统的主键。
public let sessionId: String
/// 当前进程 PID。会随 resume 变化,只用于定位进程树,不做身份。
public let pid: pid_t
/// 会话工作目录。
public let cwd: String
/// Claude 自己派生的会话名,如 `storefront-a-c3`、`o3-ui-memory-idempotent-fixes`。
public let name: String?
public let status: SessionStatus
/// 原始 status 字符串。`status == .unknown` 时用它排查。
public let rawStatus: String
public let kind: SessionKind
/// `waiting` 时的原因,如 `"input needed"`。
public let waitingFor: String?
public let jobId: String?
/// Claude Code 版本号。注意:这也是 claude 的进程名。
public let version: String?
public let startedAt: Date?
/// 状态最后一次变化的时刻。waiting 组内排序用它(等得最久的排最前)。
public let statusUpdatedAt: Date?
public let updatedAt: Date?
public var id: String { sessionId }
public init(
sessionId: String,
pid: pid_t,
cwd: String,
name: String? = nil,
status: SessionStatus,
rawStatus: String,
kind: SessionKind,
waitingFor: String? = nil,
jobId: String? = nil,
version: String? = nil,
startedAt: Date? = nil,
statusUpdatedAt: Date? = nil,
updatedAt: Date? = nil
) {
self.sessionId = sessionId
self.pid = pid
self.cwd = cwd
self.name = name
self.status = status
self.rawStatus = rawStatus
self.kind = kind
self.waitingFor = waitingFor
self.jobId = jobId
self.version = version
self.startedAt = startedAt
self.statusUpdatedAt = statusUpdatedAt
self.updatedAt = updatedAt
}
/// 从 cwd 推出的项目名兜底值。真正的项目名由 projects.yaml 反查,查不到才用这个。
public var fallbackProjectName: String {
URL(fileURLWithPath: cwd).lastPathComponent
}
}
// MARK: - 解析
extension AgentSession {
/// `~/.claude/sessions/<PID>.json` 的线格式。
///
/// 全部字段设为可选 —— 这个文件由 Claude Code 写,schema 可能随版本变化,
/// 少一个字段不该让整个探测失败。只有 `pid` / `sessionId` / `cwd` 是硬要求。
struct Wire: Decodable {
let pid: Int32?
let sessionId: String?
let cwd: String?
let name: String?
let status: String?
let kind: String?
let waitingFor: String?
let jobId: String?
let version: String?
let startedAt: Double? // 毫秒时间戳
let statusUpdatedAt: Double?
let updatedAt: Double?
}
/// 解析单个 sessions/*.json。字段缺失或格式不符时返回 nil,由调用方跳过。
public static func decode(from data: Data) -> AgentSession? {
guard let wire = try? JSONDecoder().decode(Wire.self, from: data),
let pid = wire.pid,
let sessionId = wire.sessionId, !sessionId.isEmpty,
let cwd = wire.cwd
else { return nil }
let raw = wire.status ?? ""
return AgentSession(
sessionId: sessionId,
pid: pid,
cwd: cwd,
name: wire.name,
status: SessionStatus(rawValue: raw) ?? .unknown,
rawStatus: raw,
kind: wire.kind.flatMap(SessionKind.init(rawValue:)) ?? .unknown,
waitingFor: wire.waitingFor,
jobId: wire.jobId,
version: wire.version,
startedAt: Self.date(fromMillis: wire.startedAt),
statusUpdatedAt: Self.date(fromMillis: wire.statusUpdatedAt),
updatedAt: Self.date(fromMillis: wire.updatedAt)
)
}
private static func date(fromMillis millis: Double?) -> Date? {
guard let millis, millis > 0 else { return nil }
return Date(timeIntervalSince1970: millis / 1000)
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
import os
/// 统一日志。
///
/// ## 为什么不用 `NSLog`
///
/// 被坑过两次:`NSLog("会话数 \(count)")` 在 `log show` 里显示成 `<private>` ——
/// 统一日志默认把所有插值当敏感数据。排障时看到一串 `<private>`,等于没打日志。
///
/// 而 `NSLog("%{public}d", count)` 也是错的:`{public}` 是 `os_log` 的格式语法,
/// `NSLog` 不认,会把 `{public}d` 原样打出来。**只有 `Logger` 的 `privacy:` 参数
/// 是对的做法。**
///
/// 这里的内容都是本机路径、会话数、决策结果,没有敏感信息,所以一律 public。
public enum HubLog {
private static let subsystem = "dev.hengjun.claude-hub"
public static let app = Logger(subsystem: subsystem, category: "app")
public static let island = Logger(subsystem: subsystem, category: "island")
public static let ipc = Logger(subsystem: subsystem, category: "ipc")
public static let jump = Logger(subsystem: subsystem, category: "jump")
/// 看日志的命令,写在这里省得每次去翻。
///
/// log stream --predicate 'subsystem == "dev.hengjun.claude-hub"' --style compact
/// log show --predicate 'subsystem == "dev.hengjun.claude-hub"' --last 10m --style compact
public static let howToRead = """
log stream --predicate 'subsystem == "dev.hengjun.claude-hub"' --style compact
"""
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
/// Claude Code hook 送过来的事件。
///
/// 字段直接对应 hook 的 stdin JSON(已查证官方文档)。旧实现的 bash hook
/// **完全没读 stdin**,把 `session_id` / `cwd` / `last_assistant_message`
/// 全丢了,只能靠 `tmux display-message -p '#W'` 猜项目名 —— 那还漏了
/// `-t "$TMUX_PANE"`,拿到的是当前 active 窗口而不是自己所在窗口。
/// 用户切个 tab,通知就挂到别的项目上了。
public struct HookEvent: Codable, Sendable {
public enum Kind: String, Codable, Sendable {
/// Claude 完成一轮回复。
case stop
/// 通知类事件(需要授权、空闲提醒、子 agent 完成等)。
case notification
/// 工具调用前。**阻塞式** —— hubctl 会等岛上的决策。
case preToolUse
/// 会话结束,用于即时清理 UI。
case sessionEnd
}
public let kind: Kind
/// 请求唯一 id。审批的应答靠它对上号。
public let requestId: String
// MARK: Claude 提供的通用字段
public let sessionId: String
public let cwd: String
public let transcriptPath: String?
public let permissionMode: String?
// MARK: stop
/// Claude 这一轮的最终回复文本。
/// 通知正文直接用它,替掉旧实现那 5 条随机中文文案(信息量为零)。
public let lastAssistantMessage: String?
public let stopReason: String?
// MARK: notification
/// `permission_prompt` / `idle_prompt` / `agent_completed` 等。
public let notificationType: String?
public let message: String?
// MARK: preToolUse
public let toolName: String?
/// 工具参数里最能代表"这次要干什么"的那个值。
/// Bash 取 command,Write/Edit 取 file_path,WebFetch 取 url。
public let toolSummary: String?
public let toolUseId: String?
// MARK: hubctl 补充的环境信息
/// `$TMUX_PANE`。在 tmux 里跑时能直接定位 pane,不用猜。
public let tmuxPane: String?
/// hook 进程自己的 PID,用于反查是哪个 claude 进程。
public let clientPid: Int32?
public init(
kind: Kind,
requestId: String,
sessionId: String,
cwd: String,
transcriptPath: String? = nil,
permissionMode: String? = nil,
lastAssistantMessage: String? = nil,
stopReason: String? = nil,
notificationType: String? = nil,
message: String? = nil,
toolName: String? = nil,
toolSummary: String? = nil,
toolUseId: String? = nil,
tmuxPane: String? = nil,
clientPid: Int32? = nil
) {
self.kind = kind
self.requestId = requestId
self.sessionId = sessionId
self.cwd = cwd
self.transcriptPath = transcriptPath
self.permissionMode = permissionMode
self.lastAssistantMessage = lastAssistantMessage
self.stopReason = stopReason
self.notificationType = notificationType
self.message = message
self.toolName = toolName
self.toolSummary = toolSummary
self.toolUseId = toolUseId
self.tmuxPane = tmuxPane
self.clientPid = clientPid
}
/// 项目名兜底。真正的项目名由 projects.yaml 反查 cwd 得到。
public var fallbackProjectName: String {
URL(fileURLWithPath: cwd).lastPathComponent
}
}
/// 审批结论。只有 `preToolUse` 会收到应答。
public struct HookDecision: Codable, Sendable {
public enum Verdict: String, Codable, Sendable {
case allow
case deny
/// 交回 Claude 自己的权限流程。
case ask
}
public let verdict: Verdict
public let reason: String?
public init(verdict: Verdict, reason: String? = nil) {
self.verdict = verdict
self.reason = reason
}
/// 放行。Hub 没运行、超时以外的异常、非高风险操作都走这个。
public static let allow = HookDecision(verdict: .allow)
}
/// socket 路径。
public enum HubSocket {
public static var path: String {
let dir = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("hub.sock").path
}
/// Unix domain socket 的路径长度上限(`sun_path` 是 104 字节)。
/// 超了 bind 会静默失败,所以建立前必须检查。
public static let maxPathLength = 103
}
+130
View File
@@ -0,0 +1,130 @@
import Darwin
import Foundation
import HubCore
/// Hub 侧的 socket 服务端。
///
/// 每条连接的生命周期:hubctl 连上 → 发一行事件 JSON → 服务端处理
/// →(仅 preToolUse)回一行决策 JSON → 关闭。
///
/// 连接短、并发低,所以每条连接用一个阻塞的处理块跑在并发队列上就够了,
/// 不需要引入 async 事件循环。
public final class HubSocketServer: @unchecked Sendable {
/// 事件处理器。返回值只有 `preToolUse` 会被用到。
///
/// 处理器可能被长时间阻塞(审批要等用户点击),所以它跑在自己的连接线程上,
/// 不会挡住其他连接。
public typealias Handler = @Sendable (HookEvent) -> HookDecision?
private let path: String
private let handler: Handler
private var listenFd: Int32 = -1
private var acceptSource: DispatchSourceRead?
private let queue = DispatchQueue(label: "hub.ipc.accept")
private let workQueue = DispatchQueue(label: "hub.ipc.work", attributes: .concurrent)
public init(path: String = HubSocket.path, handler: @escaping Handler) {
self.path = path
self.handler = handler
}
public func start() throws {
let fd = try UnixSocket.listen(at: path)
listenFd = fd
let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
source.setEventHandler { [weak self] in self?.acceptOne() }
source.setCancelHandler { close(fd) }
source.resume()
acceptSource = source
}
public func stop() {
acceptSource?.cancel()
acceptSource = nil
listenFd = -1
unlink(path)
}
private func acceptOne() {
let clientFd = accept(listenFd, nil, nil)
guard clientFd >= 0 else { return }
workQueue.async { [handler] in
defer { close(clientFd) }
// 读事件本身要有上界:hubctl 连上却不发数据(被 SIGSTOP 了之类)
// 不该永久占住一个工作线程。
guard let line = UnixSocket.readLine(fd: clientFd, timeout: 10),
let data = line.data(using: .utf8),
let event = try? JSONDecoder().decode(HookEvent.self, from: data)
else { return }
let decision = handler(event)
guard event.kind == .preToolUse else { return }
let payload = decision ?? .allow
if let encoded = try? JSONEncoder().encode(payload),
let text = String(data: encoded, encoding: .utf8) {
HubLog.ipc.notice("回写决策 \(text, privacy: .public)")
UnixSocket.writeLine(fd: clientFd, text)
}
}
}
}
/// 发送结果。
///
/// 「连不上」和「连上了但没回应」必须区分开,因为两者的安全默认值是相反的:
/// - 连不上 = Hub 没运行 = 这次调用压根没被审查过 → **放行**(fail-open,
/// 不能因为 Hub 没开就让用户的每个命令都失败);
/// - 连上了但没回应 = Hub 认为这次操作值得拦截,但决策环节出了问题
/// → **拒绝**(拒绝可恢复,放行不可逆)。
public enum HookSendResult: Sendable {
/// 服务端明确给了结论。
case decided(HookDecision)
/// 服务端收下了但不需要应答(非 preToolUse 事件)。
case acknowledged
/// 连不上 —— Hub 没运行。
case unreachable
/// 连上了、发出去了,但超时没等到应答。
case noResponse
}
/// hubctl 侧的客户端。
public enum HubSocketClient {
/// 发事件;`preToolUse` 会等决策。
public static func send(
_ event: HookEvent,
path: String = HubSocket.path,
waitForDecision: Bool,
timeout: TimeInterval
) -> HookSendResult {
guard let data = try? JSONEncoder().encode(event),
let line = String(data: data, encoding: .utf8)
else { return .unreachable }
guard let fd = try? UnixSocket.connect(to: path) else { return .unreachable }
defer { close(fd) }
guard UnixSocket.writeLine(fd: fd, line) else { return .unreachable }
guard waitForDecision else { return .acknowledged }
guard let response = UnixSocket.readLine(fd: fd, timeout: timeout),
let responseData = response.data(using: .utf8),
let decision = try? JSONDecoder().decode(HookDecision.self, from: responseData)
else { return .noResponse }
return .decided(decision)
}
/// Hub 是否在运行。用连接探测而不是查进程 —— 进程活着但 socket 没起来
/// (比如刚启动还没 bind)同样应该算"不可用"。
public static func isHubRunning(path: String = HubSocket.path) -> Bool {
guard let fd = try? UnixSocket.connect(to: path) else { return false }
close(fd)
return true
}
}
+129
View File
@@ -0,0 +1,129 @@
import Foundation
/// 操作的风险等级。
public enum RiskLevel: String, Codable, Sendable, Comparable {
/// 日常操作。直接放行,不打扰。
case normal
/// 危险但可恢复。默认只记录不拦截。
case dangerous
/// 不可逆。拦截并要求按住确认。
case irreversible
private var rank: Int {
switch self {
case .normal: return 0
case .dangerous: return 1
case .irreversible: return 2
}
}
public static func < (lhs: RiskLevel, rhs: RiskLevel) -> Bool { lhs.rank < rhs.rank }
}
/// 判断一次工具调用有多危险。
///
/// 设计取向:**默认只拦 `irreversible`**。
///
/// 用户全局是 `permissions.defaultMode = auto` 且常用 `--dangerously-skip-permissions`,
/// 图的就是不被打断。如果把 `dangerous` 一档也拦上,按日常用量每天会弹 3–8 次,
/// 那等于把用户特意关掉的东西又装回去,结果必然是他把整个功能关掉。
///
/// 所以 `dangerous` 先只进审批日志,攒一周数据之后用户自己决定要不要开启拦截。
public struct RiskClassifier: Sendable {
/// 达到或超过这个等级才拦截。
public let interceptThreshold: RiskLevel
public init(interceptThreshold: RiskLevel = .irreversible) {
self.interceptThreshold = interceptThreshold
}
public func shouldIntercept(_ level: RiskLevel) -> Bool {
level >= interceptThreshold && level != .normal
}
/// 对一次工具调用定级。
public func classify(toolName: String?, summary: String?) -> RiskLevel {
guard let toolName else { return .normal }
// 只读工具永远不拦。把它们显式列出来,避免正则在文件内容里误命中 ——
// 比如 Read 一个讲 `rm -rf` 的 README 不该触发审批。
let readOnlyTools: Set<String> = [
"Read", "Grep", "Glob", "WebFetch", "WebSearch", "TodoWrite", "Task",
]
if readOnlyTools.contains(toolName) { return .normal }
guard let summary, !summary.isEmpty else { return .normal }
let text = summary.lowercased()
if Self.irreversiblePatterns.contains(where: { text.range(of: $0, options: .regularExpression) != nil }) {
return .irreversible
}
if Self.dangerousPatterns.contains(where: { text.range(of: $0, options: .regularExpression) != nil }) {
return .dangerous
}
return .normal
}
// MARK: - 规则集
/// 不可逆:数据没了就是没了,或者影响到线上。
///
/// 每条都写成正则并加了边界,尽量避免误伤:
/// 比如 `rm -rf /` 要求斜杠后面不能再跟路径字符,这样 `rm -rf /tmp/x` 不会命中。
static let irreversiblePatterns: [String] = [
// rm -rf 到根、到家目录、或到某个盘符根
#"\brm\s+(-[a-z]*\s+)*-?[a-z]*r[a-z]*f?[a-z]*\s+/\s*$"#,
#"\brm\s+.*-[a-z]*r[a-z]*\s+(~|\$home)\s*/?\s*$"#,
#"\brm\s+.*\s/\*"#,
// 强推保护分支
#"git\s+push\s+.*(--force|-f)\b.*\b(main|master|prod|production|release)\b"#,
#"git\s+push\s+.*\b(main|master|prod|production|release)\b.*(--force|-f)\b"#,
// 删库
#"drop\s+database\b"#,
#"drop\s+schema\b"#,
// 生产部署 / 集群删除
#"kubectl\s+delete\b"#,
#"\bdeploy\S*\s+.*\bprod(uction)?\b"#,
#"\bterraform\s+destroy\b"#,
// 磁盘级操作
#"\bmkfs\b"#,
#"\bdd\s+.*of=/dev/"#,
#"diskutil\s+(erase|reformat)"#,
]
/// 危险但可恢复。
static let dangerousPatterns: [String] = [
#"\brm\s+(-[a-z]*\s+)*-[a-z]*r[a-z]*f"#, // 任意 rm -rf
#"git\s+push\s+.*(--force\b|--force-with-lease\b|\s-f\b)"#,
#"git\s+reset\s+--hard\b"#,
#"git\s+clean\s+.*-[a-z]*f"#,
#"git\s+branch\s+-D\b"#,
#"\bchmod\s+(-[a-z]+\s+)*777\b"#,
#"\bdrop\s+table\b"#,
#"\btruncate\s+table\b"#,
#"\bdelete\s+from\b(?!.*\bwhere\b)"#, // 无 WHERE 的 DELETE
#"curl\s+[^|]*\|\s*(sudo\s+)?(ba|z|k)?sh"#, // curl | sh
#"wget\s+[^|]*\|\s*(sudo\s+)?(ba|z|k)?sh"#,
#"\bnpm\s+publish\b"#,
#"\bdocker\s+(system\s+)?prune\b"#,
#"\bsudo\b"#,
#"\.ssh/(id_|authorized_keys)"#,
#"\.env\b"#,
#"\bkill(all)?\s+-9\b"#,
]
/// 从工具参数里挑出最能代表"这次要干什么"的那个值。
///
/// hubctl 侧调用,把提取好的字符串放进 `HookEvent.toolSummary`,
/// 这样服务端不用背着完整的 tool_input schema。
public static func summarize(toolName: String, input: [String: Any]) -> String? {
// 顺序有意义:command 最能说明问题,其次是路径。
for key in ["command", "file_path", "path", "url", "pattern", "notebook_path"] {
if let value = input[key] as? String, !value.isEmpty {
return value
}
}
return input["description"] as? String
}
}
+39
View File
@@ -0,0 +1,39 @@
import Foundation
/// 审批链路上的超时阶梯。
///
/// ## 顺序必须严格递增,否则安全默认值会反过来
///
/// 实测踩到的坑:一开始 `ApprovalCoordinator` 和 hubctl 的读超时都设成 60 秒,
/// 结果两者同时到期,hubctl 先判定"没收到应答"并按 fail-open **放行**了一条
/// `git push --force origin main` —— 而服务端那一侧其实已经决定拒绝。
/// 超时的语义从"安全拒绝"变成了"静默放行",正好是设计意图的反面。
///
/// 正确的阶梯:**内层先到期,外层留足余量收结果**。
///
/// ```
/// 60s ApprovalCoordinator 用户没反应 → 判定拒绝
/// 65s HookCoordinator 等 UI 那边把拒绝送回来
/// 75s hubctl 读 socket 等服务端把拒绝写回来
/// 90s settings.json Claude 杀掉 hook 进程的上限
/// ```
///
/// 每一层都必须**大于**它内层的那一层。任何一层反过来,就会出现
/// 「内层判定拒绝、外层已经超时放行」的危险窗口。
public enum HookTimeouts {
/// 用户在岛上决策的时限。到点自动拒绝。
public static let userDecision: TimeInterval = 60
/// 服务端等 UI 把结论送回来。
public static let serverBridge: TimeInterval = 65
/// hubctl 等服务端应答。
public static let clientRead: TimeInterval = 75
/// 写进 settings.json 的 hook 超时。Claude 超过这个时间会杀掉 hubctl。
///
/// 必须大于 `clientRead`,否则 hubctl 还没来得及输出拒绝就被杀了,
/// 而被杀的 hook 等同于没有输出 —— 也就是放行。
public static let hookProcess: Int = 90
}
+157
View File
@@ -0,0 +1,157 @@
import Darwin
import Foundation
/// POSIX Unix domain socket 的薄封装。
///
/// 为什么用 socket 而不是文件轮询(旧实现是往 `/tmp/hub-signals/events/` 丢 JSON):
/// - 对端断开可以感知 —— Claude 进程死了,挂起的审批卡能自动撤掉;
/// - 连不上就等于"Hub 没在跑",零歧义,hubctl 可以立刻放行而不是傻等;
/// - 审批需要**双向**通信,文件方案得靠两边轮询,延迟和竞态都难处理。
enum UnixSocket {
enum SocketError: Error, CustomStringConvertible {
case pathTooLong(String)
case createFailed(Int32)
case bindFailed(Int32)
case listenFailed(Int32)
case connectFailed(Int32)
var description: String {
switch self {
case .pathTooLong(let p):
return "socket 路径超过 \(HubSocket.maxPathLength) 字节:\(p)"
case .createFailed(let e): return "socket() 失败:\(String(cString: strerror(e)))"
case .bindFailed(let e): return "bind() 失败:\(String(cString: strerror(e)))"
case .listenFailed(let e): return "listen() 失败:\(String(cString: strerror(e)))"
case .connectFailed(let e): return "connect() 失败:\(String(cString: strerror(e)))"
}
}
}
/// 填 `sockaddr_un`。`sun_path` 是定长 C 数组,Swift 里只能逐字节写。
private static func makeAddress(_ path: String) throws -> sockaddr_un {
let bytes = Array(path.utf8)
guard bytes.count <= HubSocket.maxPathLength else {
throw SocketError.pathTooLong(path)
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
withUnsafeMutableBytes(of: &addr.sun_path) { raw in
raw.copyBytes(from: bytes)
}
addr.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
return addr
}
private static func withAddress<T>(
_ addr: inout sockaddr_un,
_ body: (UnsafePointer<sockaddr>, socklen_t) -> T
) -> T {
withUnsafePointer(to: &addr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
body($0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
}
/// 建监听 socket。会先删掉残留的 socket 文件(上次异常退出留下的)。
static func listen(at path: String, backlog: Int32 = 16) throws -> Int32 {
var addr = try makeAddress(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw SocketError.createFailed(errno) }
// 上次进程被 kill -9 时 socket 文件会留下,不删的话 bind 报 EADDRINUSE。
unlink(path)
let bound = withAddress(&addr) { Darwin.bind(fd, $0, $1) }
guard bound == 0 else {
let e = errno
close(fd)
throw SocketError.bindFailed(e)
}
guard Darwin.listen(fd, backlog) == 0 else {
let e = errno
close(fd)
throw SocketError.listenFailed(e)
}
// 只有当前用户能连。这条链路能否决工具执行,不该对其他用户开放。
chmod(path, 0o600)
return fd
}
/// 连接到监听中的 socket。
static func connect(to path: String) throws -> Int32 {
var addr = try makeAddress(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw SocketError.createFailed(errno) }
let connected = withAddress(&addr) { Darwin.connect(fd, $0, $1) }
guard connected == 0 else {
let e = errno
close(fd)
throw SocketError.connectFailed(e)
}
return fd
}
/// 读一行(以 `\n` 结尾)。返回 nil 表示对端已关闭。
///
/// - Parameter timeout: 秒。nil 表示一直阻塞。
static func readLine(fd: Int32, timeout: TimeInterval?) -> String? {
var buffer = Data()
var byte: UInt8 = 0
let deadline = timeout.map { Date().addingTimeInterval($0) }
while true {
if let deadline {
let remaining = deadline.timeIntervalSinceNow
guard remaining > 0, waitReadable(fd: fd, timeout: remaining) else { return nil }
}
let n = read(fd, &byte, 1)
if n == 0 { return buffer.isEmpty ? nil : String(decoding: buffer, as: UTF8.self) }
if n < 0 {
if errno == EINTR { continue }
return nil
}
if byte == UInt8(ascii: "\n") {
return String(decoding: buffer, as: UTF8.self)
}
buffer.append(byte)
// 防御:正常的一行事件是几百字节到几 KB。超过 1 MB 说明对端行为异常,
// 不能让它把内存吃光。
if buffer.count > 1_048_576 { return nil }
}
}
/// 写一行。
@discardableResult
static func writeLine(fd: Int32, _ line: String) -> Bool {
var data = Array((line + "\n").utf8)
var offset = 0
while offset < data.count {
let n = data.withUnsafeBytes { raw -> Int in
write(fd, raw.baseAddress!.advanced(by: offset), data.count - offset)
}
if n <= 0 {
if errno == EINTR { continue }
return false
}
offset += n
}
return true
}
/// 用 `poll` 等 fd 可读。
private static func waitReadable(fd: Int32, timeout: TimeInterval) -> Bool {
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let millis = Int32(max(0, min(timeout * 1000, Double(Int32.max))))
let result = poll(&pfd, 1, millis)
return result > 0 && (pfd.revents & Int16(POLLIN)) != 0
}
}
+119
View File
@@ -0,0 +1,119 @@
import Foundation
import HubProbe
/// iTerm2 里的一个 session(= 一个 tab 里的一个分屏)。
public struct ITermSession: Hashable, Sendable {
/// iTerm 自己的 session UUID。在 session 存活期间稳定,是我们的选择器。
public let sessionUUID: String
public let windowId: String
/// iTerm 报告的该 session 前台作业 PID。
///
/// **这是整个跳转方案的关键**:在 tmux `-CC` 控制模式下,`tty` 属性返回
/// `missing value`,`tmuxPaneID` / `tmuxWindowName` 等变量也全是 `missing value`,
/// 唯独 `jobPid` 有效且准确。
public let jobPid: pid_t
/// tab 标题。仅用于诊断输出,**不参与匹配** —— 它每轮都在变。
public let name: String
}
/// 通过 AppleScript 枚举与操作 iTerm2。
///
/// AppleScript 的语法有个坑:读 session 变量必须写成
/// `tell s to get variable named "jobPid"`,写成 `variable named "jobPid" of s`
/// 会报 -1723「不允许访问」。
public struct ITermLocator: Sendable {
public init() {}
private static let fieldSep = "\u{1}"
private static let rowSep = "\u{2}"
/// 枚举 iTerm 当前全部 session。
///
/// iTerm 没在运行、或未授予 Automation 权限时返回空数组。
/// 这个调用有成本(AppleScript 跨进程),**只在跳转时按需调用,不要放进轮询循环**。
public func snapshot() -> [ITermSession] {
let script = """
tell application "iTerm2"
set out to ""
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
set jp to "0"
try
tell s to set jp to (get variable named "jobPid") as text
end try
set out to out & (id of w) & "\(Self.fieldSep)" & (id of s) ¬
& "\(Self.fieldSep)" & jp & "\(Self.fieldSep)" & (name of s) ¬
& "\(Self.rowSep)"
end repeat
end repeat
end repeat
return out
end tell
"""
let result = Shell.osascript(script, timeout: 6)
guard result.succeeded else { return [] }
return result.stdout.components(separatedBy: Self.rowSep).compactMap { row in
let fields = row.components(separatedBy: Self.fieldSep)
guard fields.count >= 4 else { return nil }
// jobPid 为 0 表示 iTerm 报不出前台作业(session 刚建或已死),无法绑定。
guard let pid = pid_t(fields[2].trimmingCharacters(in: .whitespacesAndNewlines)),
pid > 0
else { return nil }
return ITermSession(
sessionUUID: fields[1],
windowId: fields[0],
jobPid: pid,
name: fields[3]
)
}
}
/// 前置到指定 session。成功返回 true。
public func focus(sessionUUID: String) -> Bool {
// 三个 select 缺一不可:select w 切窗口、select t 切 tab、select s 切分屏。
// 只 activate 不 select 就是旧实现「跳到 iTerm 但停在原窗口」的毛病。
let escaped = sessionUUID.replacingOccurrences(of: "\"", with: "\\\"")
let script = """
tell application "iTerm2"
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
if (id of s) is "\(escaped)" then
select w
select t
select s
activate
return "ok"
end if
end repeat
end repeat
end repeat
return "notfound"
end tell
"""
let result = Shell.osascript(script, timeout: 6)
return result.succeeded && result.stdout.contains("ok")
}
/// 当前处于前台的 session UUID。跳转后用它验证是否真的切过去了。
public func frontmostSessionUUID() -> String? {
let result = Shell.osascript(
#"tell application "iTerm2" to return id of current session of current tab of current window"#,
timeout: 4
)
guard result.succeeded else { return nil }
let uuid = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
return uuid.isEmpty ? nil : uuid
}
/// iTerm 是否在运行。
///
/// 没跑就别付 AppleScript 的成本 —— 更重要的是,对没运行的 app 发 AppleEvent
/// 会**触发冷启动**,用户点一下跳转结果弹出个空 iTerm 窗口,体验很差。
public static func isRunning() -> Bool {
Shell.run("/usr/bin/pgrep", ["-x", "iTerm2"], timeout: 2).succeeded
}
}
+177
View File
@@ -0,0 +1,177 @@
import Foundation
import HubCore
import HubProbe
/// 跳转结果。失败时带上原因,便于 UI 给用户一个可行动的提示,
/// 而不是像旧实现那样静默什么都不做。
public enum JumpOutcome: Equatable, Sendable {
/// 成功切到目标 tab。`strategy` 记录哪级策略生效,用于观察降级频率。
case focused(strategy: String)
/// 会话不在任何已知终端里(比如它跑在 VS Code 内置终端)。已尽力前置终端 app。
case terminalActivatedOnly(reason: String)
case failed(reason: String)
}
/// 从 `sessionId` 精准跳到承载它的终端 tab。
///
/// ## 为什么旧实现是坏的
///
/// 旧链路是「项目名 → tmux 窗口名 → pane_title → AppleScript 按标题子串匹配」,
/// 三段全是会漂移的字符串:
/// - tmux `automatic-rename` 会把窗口名改成前台进程名(claude 的进程名是版本号,
/// 实测出现过窗口名变成 `2.1.173`);
/// - `pane_title` 是 claude 的会话标题,**每轮回复都在变**,还带 spinner 前缀;
/// 旧代码只剥了盲文 spinner(U+2800–U+28FF),漏了 `✳`(U+2733);
/// - 多个会话的标题都可能是通用的 "Claude Code",子串匹配会撞车跳错。
///
/// ## 现在的做法
///
/// iTerm 报告每个 session 的 `jobPid`,从它向上走进程树,第一个存在
/// `~/.claude/sessions/<PID>.json` 的祖先就是该 tab 承载的会话。全程只用整数 PID
/// 和 UUID,没有任何字符串匹配。实测同时覆盖 tmux `-CC` 会话与非 tmux 原生会话。
public struct JumpEngine: Sendable {
private let iterm: ITermLocator
private let sessionReader: ClaudeSessionReader
private let tmux: TmuxProbe
public init(
iterm: ITermLocator = ITermLocator(),
sessionReader: ClaudeSessionReader = ClaudeSessionReader(),
tmux: TmuxProbe = TmuxProbe()
) {
self.iterm = iterm
self.sessionReader = sessionReader
self.tmux = tmux
}
/// bg 会话按名字绑定时要求的最短名字长度。
///
/// 太短的名字(如 `api`)在别的 tab 标题里偶然出现的概率不可忽略。
/// Claude 派生的 job 名实测都是十几到几十字符的 slug,这个门槛不会误伤。
private static let minimumNameLengthForTitleBinding = 8
/// 建立 `Claude sessionId` → `iTerm session UUID` 的映射。
///
/// 纯函数式的核心逻辑独立出来,方便单测注入假数据 —— AppleScript 和 sysctl
/// 都没法在 CI 里跑,但这段绑定逻辑正是最容易出错、最需要测试的部分。
///
/// 分两轮:
/// 1. **祖先链**(主策略,覆盖全部 `interactive` 会话);
/// 2. **job 名匹配**(只兜 `bg` 会话,见下方说明)。
public static func bind(
itermSessions: [ITermSession],
claudeSessions: [AgentSession],
tree: ProcessTree
) -> [String: String] {
guard !claudeSessions.isEmpty else { return [:] }
let sessionByPid = Dictionary(
claudeSessions.map { ($0.pid, $0.sessionId) },
uniquingKeysWith: { a, _ in a }
)
var mapping: [String: String] = [:]
var consumedTerms = Set<String>()
// 第一轮:祖先链。iTerm 报的 jobPid 向上走,第一个是 Claude 会话的祖先即所有者。
for term in itermSessions {
guard let ownerPid = tree.firstAncestor(of: term.jobPid, where: {
sessionByPid[$0] != nil
}), let claudeId = sessionByPid[ownerPid] else { continue }
// 一个 Claude 会话理论上只对应一个 iTerm session。真出现多个
// (分屏里 attach 了同一个 tmux window),取第一个即可 —— 它们指向同一处。
if mapping[claudeId] == nil {
mapping[claudeId] = term.sessionUUID
consumedTerms.insert(term.sessionUUID)
}
}
// 第二轮:bg 会话。
//
// `kind: bg` 的会话跑在 `claude bg-pty-host` daemon 下,**不在任何终端里**,
// 祖先链必然连不上(实测祖先链是 claude bg-spare → bg-pty-host → claude daemon)。
// 用户能看到它的地方是某个跑着 `claude agents` 的 tab,那是个查看器进程,
// 与后台会话之间没有任何父子关系。
//
// 查看器会把 job 名写进 tab 标题(`o3-ui-memory-idempotent-fixes (Claude)`)。
// 这里**破例用标题匹配** —— 但和旧实现按 `pane_title` 匹配有本质区别:
// 旧实现匹配的是 ai-title,每轮回复都在变;而 bg 会话的 `name` 是创建时
// 一次性派生的稳定 slug,全程不变。加上只在第一轮未绑定的 tab 里找、
// 且要求名字足够长,误绑风险很低。
let unboundBg = claudeSessions.filter {
$0.kind == .bg && mapping[$0.sessionId] == nil
}
guard !unboundBg.isEmpty else { return mapping }
for session in unboundBg {
guard let name = session.name,
name.count >= minimumNameLengthForTitleBinding
else { continue }
let match = itermSessions.first {
!consumedTerms.contains($0.sessionUUID) && $0.name.contains(name)
}
if let match {
mapping[session.sessionId] = match.sessionUUID
consumedTerms.insert(match.sessionUUID)
}
}
return mapping
}
/// 跳到指定会话所在的终端 tab。
public func jump(to sessionId: String) -> JumpOutcome {
guard ITermLocator.isRunning() else {
return .failed(reason: "iTerm2 未运行")
}
let claudeSessions = sessionReader.readAll()
guard claudeSessions.contains(where: { $0.sessionId == sessionId }) else {
return .failed(reason: "会话 \(sessionId) 已不存在")
}
// 策略 A:jobPid 祖先链匹配。已实测验证,覆盖 tmux -CC 与非 tmux 会话。
let tree = ProcessTree.snapshot()
let itermSessions = iterm.snapshot()
let mapping = Self.bind(
itermSessions: itermSessions,
claudeSessions: claudeSessions,
tree: tree
)
if let target = mapping[sessionId] {
if iterm.focus(sessionUUID: target) {
// 验证:跳完再问一次前台是谁。AppleScript 的 select 在极少数情况下
// 会被 iTerm 自己的焦点逻辑覆盖(-CC 模式尤其),不验证就会谎报成功。
if let front = iterm.frontmostSessionUUID(), front == target {
return .focused(strategy: "A/jobPid")
}
return .focused(strategy: "A/jobPid(未验证)")
}
}
// 策略 C:会话在 tmux 里但 iTerm 侧没绑上(比如 iTerm 没 attach 这个 session)。
// 让 tmux 自己切窗口。注意 iTerm -CC 模式下这一步会被 iTerm 覆盖,
// 所以它只对「非 iTerm 终端 attach 的 tmux」有效,这里作为降级尝试。
let panes = tmux.listPanes()
let sessionPIDs = Set(claudeSessions.map(\.pid))
let binding = tmux.bindPanes(to: sessionPIDs, tree: tree, panes: panes)
if let session = claudeSessions.first(where: { $0.sessionId == sessionId }),
let pane = binding[session.pid] {
let result = Shell.run(
tmux.tmuxPath, ["select-window", "-t", pane.windowId], timeout: 3
)
if result.succeeded {
_ = Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4)
return .terminalActivatedOnly(
reason: "已切 tmux 窗口 \(pane.windowId),但 iTerm 侧未能精确定位 tab"
)
}
}
// 兜底:至少把终端拉到前台,别让用户点了毫无反应。
_ = Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4)
return .terminalActivatedOnly(reason: "未能定位该会话所在的 tab,仅前置 iTerm2")
}
}
@@ -0,0 +1,63 @@
import Foundation
import HubCore
/// 读取 `~/.claude/sessions/<PID>.json`。
///
/// 这是本项目的**权威状态源** —— Claude Code 自己在维护它,含 `busy` / `idle` /
/// `waiting` / `shell` 以及 waiting 的原因。旧实现靠 Stop hook 事件反推状态,
/// 既有延迟又拿不到 waiting;更早的设计里还考虑过用 pane_title 的 spinner 字符猜
/// busy —— 有了这个文件之后,那些启发式全都不需要了。
public struct ClaudeSessionReader: Sendable {
public let directory: URL
public init(directory: URL? = nil) {
self.directory = directory ?? FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/sessions", isDirectory: true)
}
/// 读取全部存活会话。
///
/// - Parameter isAlive: 判活谓词,测试时可注入。默认 `kill(pid, 0)`。
/// **必须过滤**:实测目录里残留着好几天前已退出进程的 json,
/// 不过滤岛上就会显示一堆幽灵会话。
public func readAll(
isAlive: (pid_t) -> Bool = ProcessTree.isAlive
) -> [AgentSession] {
let files: [URL]
do {
files = try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
} catch {
return []
}
var sessions: [String: AgentSession] = [:]
for file in files where file.pathExtension == "json" {
guard let data = try? Data(contentsOf: file),
let session = AgentSession.decode(from: data),
isAlive(session.pid)
else { continue }
// 同一 sessionId 可能有多个 PID 文件(resume 后旧进程的 json 尚未清理,
// 且旧 PID 恰好被系统复用给了别的进程从而通过判活)。留 updatedAt 最新的那个。
if let existing = sessions[session.sessionId] {
let lhs = session.updatedAt ?? .distantPast
let rhs = existing.updatedAt ?? .distantPast
if lhs <= rhs { continue }
}
sessions[session.sessionId] = session
}
return Array(sessions.values)
}
/// 存活会话的 PID 集合。跳转时用来判断某个祖先 PID 是不是 Claude 会话。
public func alivePIDs(
isAlive: (pid_t) -> Bool = ProcessTree.isAlive
) -> Set<pid_t> {
Set(readAll(isAlive: isAlive).map(\.pid))
}
}
+94
View File
@@ -0,0 +1,94 @@
import Darwin
import Foundation
/// 一次 `sysctl` 拿到的全系统进程父子关系快照。
///
/// 为什么不 fork `ps`:
/// 1. `ps -o lstart` 的输出是 locale 相关的(本机是中文 locale,会输出中文月份),解析必炸;
/// 2. 一次 sysctl 是单次 syscall,比起 fork + exec + 管道读取快两个数量级;
/// 跳转时要对每个 iTerm tab 走一次祖先链,快很重要。
public struct ProcessTree: Sendable {
/// pid → ppid
public let parents: [pid_t: pid_t]
public init(parents: [pid_t: pid_t]) {
self.parents = parents
}
/// 采集当前全系统进程表。失败(极罕见)时返回空树,调用方会自然降级。
public static func snapshot() -> ProcessTree {
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var size = 0
// 第一次调用只问需要多大缓冲区。
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 0 else {
return ProcessTree(parents: [:])
}
// 进程表在两次调用之间可能变大,多留 25% 余量,减少 ENOMEM 重试。
var capacity = size + size / 4
var buffer = [UInt8](repeating: 0, count: capacity)
let ok = buffer.withUnsafeMutableBytes { raw -> Bool in
var len = capacity
let result = sysctl(&mib, u_int(mib.count), raw.baseAddress, &len, nil, 0)
capacity = len
return result == 0
}
guard ok else { return ProcessTree(parents: [:]) }
let stride = MemoryLayout<kinfo_proc>.stride
let count = capacity / stride
guard count > 0 else { return ProcessTree(parents: [:]) }
var parents = [pid_t: pid_t](minimumCapacity: count)
buffer.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
for i in 0..<count {
// kinfo_proc 有对齐要求,buffer 是 [UInt8],必须 loadUnaligned。
let proc = base.loadUnaligned(fromByteOffset: i * stride, as: kinfo_proc.self)
let pid = proc.kp_proc.p_pid
guard pid > 0 else { continue }
parents[pid] = proc.kp_eproc.e_ppid
}
}
return ProcessTree(parents: parents)
}
/// 从 `pid` 出发向上遍历祖先链(含自身),最多 `limit` 层。
///
/// `limit` 是防环保险:正常进程树不会超过十几层,出现环意味着快照撕裂
/// (采集期间进程表在变),此时宁可截断也不能死循环。
public func ancestry(of pid: pid_t, limit: Int = 32) -> [pid_t] {
var chain: [pid_t] = []
var seen = Set<pid_t>()
var current = pid
while current > 0, chain.count < limit, seen.insert(current).inserted {
chain.append(current)
guard let parent = parents[current], parent != current else { break }
current = parent
}
return chain
}
/// 沿祖先链找到第一个满足条件的 PID。
///
/// 这是「iTerm tab → Claude 会话」绑定的核心:传入 iTerm 报的 `jobPid`,
/// 谓词是「该 PID 有对应的 sessions/<PID>.json」,命中的就是这个 tab 里跑的会话。
public func firstAncestor(of pid: pid_t, where predicate: (pid_t) -> Bool) -> pid_t? {
ancestry(of: pid).first(where: predicate)
}
/// 进程是否存活。
///
/// `~/.claude/sessions/` 里死进程的 json 会残留(实测有几天前的),
/// 不过滤就会在岛上显示一堆幽灵会话。
public static func isAlive(_ pid: pid_t) -> Bool {
guard pid > 0 else { return false }
// kill(pid, 0) 不发信号,只做存在性 + 权限检查。
// EPERM 表示进程存在但不属于我们 —— 对判活而言依然是「存在」。
if kill(pid, 0) == 0 { return true }
return errno == EPERM
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
/// 极薄的外部命令封装。只用于确实没有 API 替代的场景:`tmux` 和 `osascript`。
///
/// 进程信息一律走 `ProcessTree`(sysctl),不要用这个去调 `ps`。
public enum Shell {
public struct Result: Sendable {
public let status: Int32
public let stdout: String
public let stderr: String
public var succeeded: Bool { status == 0 }
}
/// 同步执行并捕获输出。
///
/// - Parameter timeout: 超时秒数。tmux 在 server 无响应时会挂住,
/// AppleScript 在目标 app 卡死时更会挂很久 —— 探测循环里必须有上界,
/// 否则整个 UI 的刷新会被一个卡住的 osascript 拖死。
@discardableResult
public static func run(
_ executable: String,
_ arguments: [String],
timeout: TimeInterval = 5
) -> Result {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
process.standardInput = FileHandle.nullDevice
do {
try process.run()
} catch {
return Result(status: -1, stdout: "", stderr: "\(error)")
}
// 必须在 waitUntilExit 之前把管道读干:管道缓冲区约 64KB,
// 写满后子进程会阻塞在 write 上,而我们阻塞在 wait 上 —— 经典死锁。
// 后台线程读取可以同时避免这个死锁和超时逻辑的竞争。
var outData = Data()
var errData = Data()
let group = DispatchGroup()
let queue = DispatchQueue(label: "hub.shell.io", attributes: .concurrent)
group.enter()
queue.async {
outData = outPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
group.enter()
queue.async {
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
usleep(5_000)
}
if process.isRunning {
process.terminate()
// 给 SIGTERM 一点时间,不行就硬杀,避免留下僵尸拖住管道读取线程。
let hardDeadline = Date().addingTimeInterval(0.5)
while process.isRunning, Date() < hardDeadline { usleep(5_000) }
if process.isRunning { kill(process.processIdentifier, SIGKILL) }
}
process.waitUntilExit()
group.wait()
return Result(
status: process.terminationStatus,
stdout: String(decoding: outData, as: UTF8.self),
stderr: String(decoding: errData, as: UTF8.self)
)
}
/// 执行 AppleScript。
public static func osascript(_ source: String, timeout: TimeInterval = 8) -> Result {
run("/usr/bin/osascript", ["-e", source], timeout: timeout)
}
}
+107
View File
@@ -0,0 +1,107 @@
import Foundation
/// 一个 tmux pane 的拓扑信息。
public struct TmuxPane: Hashable, Sendable {
public let sessionName: String
public let windowId: String // "@20"
public let windowName: String // 不可信,见下方说明
public let paneId: String // "%20"
public let panePid: pid_t
public let currentPath: String
public let currentCommand: String
public init(
sessionName: String, windowId: String, windowName: String,
paneId: String, panePid: pid_t, currentPath: String, currentCommand: String
) {
self.sessionName = sessionName
self.windowId = windowId
self.windowName = windowName
self.paneId = paneId
self.panePid = panePid
self.currentPath = currentPath
self.currentCommand = currentCommand
}
}
/// 采集 tmux 拓扑。
///
/// **窗口名不可信**:tmux 的 `automatic-rename` 会把 window_name 改成前台进程名,
/// 而 claude 的进程名就是它的版本号 —— 实测出现过窗口名变成 `2.1.173` 的情况。
/// 旧实现用 `窗口名 == projects.yaml 的 name` 判断项目是否在运行,实测 9 个窗口
/// 只有 2 个能匹配上。所以这里采集 windowName 只用于显示兜底,**绝不用于身份识别**。
/// 身份一律走 `panePid` → 进程树 → sessionId。
public struct TmuxProbe: Sendable {
public let tmuxPath: String
public init(tmuxPath: String? = nil) {
self.tmuxPath = tmuxPath ?? Self.locateTmux()
}
/// tmux 可能装在 Homebrew(Apple Silicon / Intel 路径不同)或系统目录。
/// GUI app 启动时不走 shell profile,PATH 里通常没有 /opt/homebrew/bin。
private static func locateTmux() -> String {
let candidates = [
"/opt/homebrew/bin/tmux",
"/usr/local/bin/tmux",
"/usr/bin/tmux",
]
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
?? "/usr/bin/env"
}
private static let separator = "\u{1}" // 用 SOH 而非 tab:路径里可能含 tab
/// 列出全部 pane。tmux server 未运行时返回空数组(不是错误)。
public func listPanes() -> [TmuxPane] {
let format = [
"#{session_name}", "#{window_id}", "#{window_name}", "#{pane_id}",
"#{pane_pid}", "#{pane_current_path}", "#{pane_current_command}",
].joined(separator: Self.separator)
let result = Shell.run(tmuxPath, ["list-panes", "-a", "-F", format], timeout: 3)
guard result.succeeded else { return [] }
return result.stdout.split(separator: "\n").compactMap { line in
let fields = line.components(separatedBy: Self.separator)
guard fields.count >= 7, let pid = pid_t(fields[4]) else { return nil }
return TmuxPane(
sessionName: fields[0],
windowId: fields[1],
windowName: fields[2],
paneId: fields[3],
panePid: pid,
currentPath: fields[5],
currentCommand: fields[6]
)
}
}
/// 把每个 pane 绑定到它承载的 Claude 会话 PID。
///
/// 用**祖先链求交**而不是 `panePid == sessionPid` 直接相等:实测 8 个窗口里
/// 有 2 个不成立(pane 的根进程是 shell,claude 是它的子进程)。
/// 反过来 claude 也可能是 pane 根进程本身,所以从 panePid 起向上/向下都要覆盖 ——
/// 这里的做法是:先看 panePid 自己是不是会话 PID,不是就看会话 PID 的祖先链里有没有 panePid。
public func bindPanes(
to sessionPIDs: Set<pid_t>,
tree: ProcessTree,
panes: [TmuxPane]? = nil
) -> [pid_t: TmuxPane] {
let allPanes = panes ?? listPanes()
var binding: [pid_t: TmuxPane] = [:]
let paneByPid = Dictionary(allPanes.map { ($0.panePid, $0) }, uniquingKeysWith: { a, _ in a })
for sessionPid in sessionPIDs {
// 会话进程自身或其某个祖先,恰好是某个 pane 的根进程。
for ancestor in tree.ancestry(of: sessionPid) {
if let pane = paneByPid[ancestor] {
binding[sessionPid] = pane
break
}
}
}
return binding
}
}
+182
View File
@@ -0,0 +1,182 @@
import Foundation
import HubCore
import HubJump
import HubProjects
import HubProbe
// 开发期 CLI:在 Xcode / UI 就绪之前,用它验证探测与跳转是否正确。
// 用法:
// swift run hubprobe list 列出全部存活会话及其终端绑定
// swift run hubprobe jump <n> 跳到第 n 个会话(list 里的序号)
// swift run hubprobe jump <uuid> 跳到指定 sessionId
let reader = ClaudeSessionReader()
let tmux = TmuxProbe()
let iterm = ITermLocator()
func loadBindings() -> (
sessions: [AgentSession],
panes: [pid_t: TmuxPane],
iterm: [String: String]
) {
let sessions = reader.readAll().sorted {
if $0.status != $1.status { return $0.status < $1.status }
return ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast)
}
let tree = ProcessTree.snapshot()
let paneBinding = tmux.bindPanes(to: Set(sessions.map(\.pid)), tree: tree)
let itermBinding = ITermLocator.isRunning()
? JumpEngine.bind(
itermSessions: iterm.snapshot(),
claudeSessions: sessions,
tree: tree
)
: [:]
return (sessions, paneBinding, itermBinding)
}
func pad(_ s: String, _ width: Int) -> String {
// 中文按两个显示宽度算,否则表格会错位。
let displayWidth = s.unicodeScalars.reduce(0) { $0 + ($1.value > 0x2000 ? 2 : 1) }
return s + String(repeating: " ", count: max(0, width - displayWidth))
}
func runList() {
let (sessions, panes, itermMap) = loadBindings()
guard !sessions.isEmpty else {
print("没有存活的 Claude 会话。")
return
}
print("共 \(sessions.count) 个存活会话\n")
print(pad("#", 3) + pad("状态", 8) + pad("类型", 12) + pad("项目", 20)
+ pad("会话名", 34) + pad("tmux", 14) + "iTerm")
print(String(repeating: "─", count: 104))
for (i, s) in sessions.enumerated() {
let kindLabel = s.kind == .bg ? "bg" : "interactive"
let paneLabel = panes[s.pid].map { "\($0.windowId) \($0.windowName)" } ?? "—"
let itermLabel = itermMap[s.sessionId] != nil ? "✓ 已绑定" : "✗ 未绑定"
let statusLabel = s.status == .unknown ? "?\(s.rawStatus)" : s.status.rawValue
print(pad("\(i)", 3)
+ pad(statusLabel, 8)
+ pad(kindLabel, 12)
+ pad(s.fallbackProjectName, 20)
+ pad(s.name ?? "—", 34)
+ pad(paneLabel, 14)
+ itermLabel)
if let waitingFor = s.waitingFor {
print(" ↳ 等待:\(waitingFor)")
}
}
let unbound = sessions.filter { itermMap[$0.sessionId] == nil }
if !unbound.isEmpty {
print("\n未绑定到 iTerm 的会话(跳转会降级):")
for s in unbound {
print(" - \(s.name ?? s.sessionId) pid=\(s.pid) cwd=\(s.cwd)")
}
}
}
func runJump(_ target: String) {
let (sessions, _, _) = loadBindings()
let sessionId: String
if let index = Int(target) {
guard index >= 0, index < sessions.count else {
print("序号超出范围(0..<\(sessions.count))")
exit(1)
}
sessionId = sessions[index].sessionId
} else {
sessionId = target
}
guard let session = sessions.first(where: { $0.sessionId == sessionId }) else {
print("找不到会话 \(sessionId)")
exit(1)
}
print("跳转到:\(session.name ?? session.sessionId) (\(session.fallbackProjectName))")
switch JumpEngine().jump(to: sessionId) {
case .focused(let strategy):
print("✓ 成功,策略 \(strategy)")
case .terminalActivatedOnly(let reason):
print("△ 只前置了终端:\(reason)")
exit(2)
case .failed(let reason):
print("✗ 失败:\(reason)")
exit(1)
}
}
/// 进度节流用的小计数器。
private final class Atomic: @unchecked Sendable {
private var value = 0
private let lock = NSLock()
func shouldReport(done: Int, total: Int) -> Bool {
lock.lock()
defer { lock.unlock() }
guard done - value >= 50 || done == total else { return false }
value = done
return true
}
}
/// 无头跑一遍用量统计。
///
/// 存在的理由:用量页藏在主窗口里,要点托盘图标才能打开,
/// 而验证它需要的恰恰是**耗时和数量**这类可量化的东西。
/// 有这个子命令就能直接 `time hubprobe usage` 看首扫和缓存命中的差别,
/// 不用去驱动 UI。
private func runUsage(_ rangeArgument: String?) {
let range: UsageRange
switch rangeArgument {
case "7", "7d", "week", nil: range = .week7
case "30", "30d", "month": range = .month30
case "all", "全部": range = .all
default:
print("用法:hubprobe usage [7 | 30 | all]")
exit(1)
}
let started = Date()
// 进度回调可能从别的线程来,用一个原子计数器而不是捕获 var。
let lastReported = Atomic()
let summary = UsageStats.collect(range: range) { done, total in
// 每 50 个报一次,别把输出刷爆。
guard lastReported.shouldReport(done: done, total: total) else { return }
FileHandle.standardError.write(Data(" 扫描 \(done)/\(total)\n".utf8))
}
let elapsed = Date().timeIntervalSince(started)
print("范围:\(range.rawValue) 耗时:\(String(format: "%.2f", elapsed)) 秒")
print("会话 \(summary.totalSessions) · 消息 \(summary.totalMessages)")
print("输入 \(summary.totalInput) · 输出 \(summary.totalOutput)")
print("")
for project in summary.projects.prefix(10) {
print(" \(project.projectName.padding(toLength: 28, withPad: " ", startingAt: 0))"
+ " \(project.totalTokens)")
}
}
let args = Array(CommandLine.arguments.dropFirst())
switch args.first {
case "list", nil:
runList()
case "jump":
guard args.count >= 2 else {
print("用法:hubprobe jump <序号或 sessionId>")
exit(1)
}
runJump(args[1])
case "usage":
runUsage(args.count >= 2 ? args[1] : nil)
default:
print("用法:hubprobe [list | jump <序号或 sessionId> | usage [7|30|all]]")
exit(1)
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
import HubProbe
/// 采集 git 状态。
///
/// 用 `--porcelain=v2 --branch` **一次调用**拿全部信息(分支、上游、ahead/behind、
/// 变更数)。分开跑 `git branch` / `git status` / `git rev-list` 是三次 fork,
/// 21 个项目就是 63 次进程创建。
public enum GitStatus {
public static func collect(at path: String) -> GitInfo? {
let result = Shell.run(
"/usr/bin/git",
["-C", path, "status", "--porcelain=v2", "--branch"],
timeout: 5
)
guard result.succeeded else { return nil }
return parse(result.stdout)
}
static func parse(_ output: String) -> GitInfo {
var branch: String?
var ahead = 0
var behind = 0
var hasUpstream = false
var changes = 0
for line in output.split(separator: "\n") {
if line.hasPrefix("# branch.head ") {
let value = String(line.dropFirst("# branch.head ".count))
// detached HEAD 时 git 输出字面量 "(detached)"。
branch = value == "(detached)" ? nil : value
} else if line.hasPrefix("# branch.upstream ") {
hasUpstream = true
} else if line.hasPrefix("# branch.ab ") {
// 格式:`# branch.ab +3 -1`
let parts = line.split(separator: " ")
for part in parts {
if part.hasPrefix("+") { ahead = Int(part.dropFirst()) ?? 0 }
if part.hasPrefix("-") { behind = Int(part.dropFirst()) ?? 0 }
}
} else if !line.hasPrefix("#") && !line.isEmpty {
// 非 `#` 开头的每一行都是一个变更条目
// (1=普通变更 2=重命名 u=冲突 ?=未跟踪)。
changes += 1
}
}
return GitInfo(
branch: branch, changeCount: changes,
ahead: ahead, behind: behind, hasUpstream: hasUpstream
)
}
/// 仓库根目录。
///
/// 会话的 cwd 常常是仓库的子目录,而"当前目录"和"仓库"是两件事 ——
/// 同一个项目多开时,几个会话可能落在不同的 worktree 里,
/// 只看项目名会把它们混成一个。
public static func repositoryRoot(at path: String) -> String? {
let result = Shell.run(
"/usr/bin/git", ["-C", path, "rev-parse", "--show-toplevel"], timeout: 5
)
guard result.succeeded else { return nil }
let root = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
return root.isEmpty ? nil : root
}
/// 最近几条提交。
///
/// 用 `%x1f`(Unit Separator)分隔字段而不是制表符或竖线:提交标题里
/// 出现制表符不常见但完全合法,出现 US 控制字符则基本不可能。
public static func log(at path: String, limit: Int = 3) -> [GitCommit] {
let result = Shell.run(
"/usr/bin/git",
[
"-C", path, "log", "-\(limit)",
"--pretty=format:%h%x1f%s%x1f%cr%x1f%an",
"--no-color",
],
timeout: 5
)
guard result.succeeded else { return [] }
return parseLog(result.stdout)
}
static func parseLog(_ output: String) -> [GitCommit] {
output.split(separator: "\n").compactMap { line in
let fields = line.split(separator: "\u{1f}", omittingEmptySubsequences: false)
guard fields.count >= 3 else { return nil }
return GitCommit(
hash: String(fields[0]),
subject: String(fields[1]),
relativeDate: String(fields[2]),
author: fields.count > 3 ? String(fields[3]) : ""
)
}
}
/// 扫描目录树找 git 仓库。
///
/// 命中 `.git` 就停止下钻 —— 不然会一路走进 vendor 目录里的子仓库,
/// 扫出一堆用户根本不认识的"项目"。
public static func scanRepositories(
under root: String,
maxDepth: Int = 3
) -> [String] {
var found: [String] = []
let skipped: Set<String> = ["node_modules", "target", "dist", "build", "vendor", "Pods"]
func walk(_ path: String, depth: Int) {
guard depth <= maxDepth else { return }
if FileManager.default.fileExists(atPath: path + "/.git") {
found.append(path)
return // 命中即停,不再下钻
}
guard let entries = try? FileManager.default
.contentsOfDirectory(atPath: path) else { return }
for entry in entries {
guard !entry.hasPrefix("."), !skipped.contains(entry) else { continue }
var isDirectory: ObjCBool = false
let child = path + "/" + entry
guard FileManager.default.fileExists(atPath: child, isDirectory: &isDirectory),
isDirectory.boolValue else { continue }
walk(child, depth: depth + 1)
}
}
walk(NSString(string: root).expandingTildeInPath, depth: 0)
return found
}
}
+224
View File
@@ -0,0 +1,224 @@
import Foundation
/// projects.yaml 里的一个项目。
public struct Project: Identifiable, Hashable, Sendable {
public let name: String
public let path: String
public let aliases: [String]
public let description: String?
public let tags: [String]
public var id: String { path }
public init(
name: String, path: String, aliases: [String] = [],
description: String? = nil, tags: [String] = []
) {
self.name = name
self.path = path
self.aliases = aliases
self.description = description
self.tags = tags
}
/// 展开 `~/`。yaml 里普遍写成 `~/Documents/code/xxx`。
public var expandedPath: String {
guard path.hasPrefix("~") else { return path }
return NSString(string: path).expandingTildeInPath
}
}
/// git 状态。
/// 一条提交记录。
public struct GitCommit: Hashable, Sendable, Identifiable {
public let hash: String
public let subject: String
/// git 自己给的相对时间("3 minutes ago")—— 不自己算,省一次时间戳解析,
/// 而且 git 的措辞已经本地化过。
public let relativeDate: String
public let author: String
public var id: String { hash }
public init(hash: String, subject: String, relativeDate: String, author: String) {
self.hash = hash
self.subject = subject
self.relativeDate = relativeDate
self.author = author
}
}
/// 一个仓库工作区的快照:状态 + 最近提交。
///
/// 按**会话的 cwd** 采集而不是按 projects.yaml 里的项目 —— 会话可能开在
/// 任意目录、任意 worktree,甚至根本不在已登记的项目里。同一个项目多开时,
/// 这份快照是区分它们的主要依据。
public struct WorkspaceGit: Hashable, Sendable {
public let root: String
public let info: GitInfo
public let recent: [GitCommit]
public init(root: String, info: GitInfo, recent: [GitCommit]) {
self.root = root
self.info = info
self.recent = recent
}
}
public struct GitInfo: Hashable, Sendable {
public let branch: String?
public let changeCount: Int
public let ahead: Int
public let behind: Int
public let hasUpstream: Bool
public var isClean: Bool { changeCount == 0 }
public init(
branch: String?, changeCount: Int, ahead: Int, behind: Int, hasUpstream: Bool
) {
self.branch = branch
self.changeCount = changeCount
self.ahead = ahead
self.behind = behind
self.hasUpstream = hasUpstream
}
}
/// 极简 YAML 解析。
///
/// 只认 projects.yaml 这一种结构,不引 Yams。理由是常驻 app 的长期维护成本:
/// 多一个依赖就多一次跟着 Swift 版本升级的负担,而这里需要的表达力极其有限。
///
/// 沿用现有 Rust 版 `parse_yaml` 的宽松行解析契约(trim 后判前缀),
/// 这样两边对同一个文件的理解一致,迁移期间不会打架。
public enum ProjectYAML {
/// 解析结果 + 诊断。
///
/// 存在的理由是一次真实事故:文件里明明有 117 条项目,解析器却返回空数组,
/// UI 老老实实显示"0 个项目",**没有任何地方提示出了问题**。
/// 排查它花的时间远超写这个类型的成本 —— 解析失败必须能被看见。
public struct Outcome: Sendable {
public let projects: [Project]
/// 文件里出现过多少个 `- name:`(不管有没有被成功解析)。
public let entryMarkers: Int
/// 文件里有条目但一条都没解析出来 —— 格式坏了。
public var looksBroken: Bool { projects.isEmpty && entryMarkers > 0 }
public var diagnostic: String? {
guard looksBroken else { return nil }
return "文件里有 \(entryMarkers) 条项目,但一条都没能解析出来 —— 检查 `projects:` 这一行的格式"
}
}
public static func parse(_ text: String) -> [Project] {
analyze(text).projects
}
public static func analyze(_ text: String) -> Outcome {
var projects: [Project] = []
var entryMarkers = 0
var name: String?
var path: String?
var description: String?
var aliases: [String] = []
var tags: [String] = []
var inProjectsSection = false
func flush() {
if let name, let path {
projects.append(
Project(
name: name, path: path, aliases: aliases,
description: description, tags: tags
)
)
}
name = nil
path = nil
description = nil
aliases = []
tags = []
}
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
let line = String(rawLine)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("- name:") || trimmed.hasPrefix("-name:") {
entryMarkers += 1
}
// 顶层键。scan_dirs 之类的段落要跳过,只处理 projects。
//
// 判定必须按**键名**而不是整行字面量。踩过的坑:原来写的是
// `trimmed == "projects:"`,于是 `projects: []` 完全不匹配 ——
// 旧版留下的空数组标记后面被追加了 117 条项目,全部被静默忽略。
// 行内值(`[]`、`~`、`null`)只是"暂时没有内联条目",
// 不代表下面缩进的列表不算数。
if !line.hasPrefix(" ") && !line.hasPrefix("\t"),
let key = topLevelKey(of: trimmed) {
flush()
inProjectsSection = key == "projects"
continue
}
guard inProjectsSection else { continue }
if trimmed.hasPrefix("- ") {
flush()
let rest = String(trimmed.dropFirst(2))
if let value = value(of: "name", in: rest) { name = value }
continue
}
if let value = value(of: "name", in: trimmed) { name = value }
else if let value = value(of: "path", in: trimmed) { path = value }
else if let value = value(of: "description", in: trimmed) { description = value }
else if let value = value(of: "aliases", in: trimmed) { aliases = list(value) }
else if let value = value(of: "tags", in: trimmed) { tags = list(value) }
}
flush()
return Outcome(projects: projects, entryMarkers: entryMarkers)
}
/// 顶层键名。`projects:` / `projects: []` / `scan_dirs:` 都能取到键名;
/// 列表项(`- xxx`)和纯文本行返回 nil。
private static func topLevelKey(of trimmed: String) -> String? {
guard !trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else {
return nil
}
let key = String(trimmed[trimmed.startIndex..<colon])
guard !key.isEmpty, !key.contains(" ") else { return nil }
return key
}
private static func value(of key: String, in line: String) -> String? {
let prefix = key + ":"
guard line.hasPrefix(prefix) else { return nil }
return unquote(String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces))
}
private static func list(_ raw: String) -> [String] {
var body = raw
if body.hasPrefix("["), body.hasSuffix("]") {
body = String(body.dropFirst().dropLast())
}
return body
.split(separator: ",")
.map { unquote($0.trimmingCharacters(in: .whitespaces)) }
.filter { !$0.isEmpty }
}
private static func unquote(_ raw: String) -> String {
var value = raw
for quote in ["\"", "'"] where value.hasPrefix(quote) && value.hasSuffix(quote) && value.count >= 2 {
value = String(value.dropFirst().dropLast())
}
return value
}
}
@@ -0,0 +1,231 @@
import Foundation
import HubProbe
/// 项目列表 + git 状态。
@Observable
@MainActor
public final class ProjectStore {
public private(set) var projects: [Project] = []
public private(set) var gitInfo: [String: GitInfo] = [:] // key = expandedPath
public private(set) var isScanning = false
/// 实际读到的那个文件。
///
/// **必须对外可见。** 这次的坑就是"不知道 app 在读哪份 projects.yaml" ——
/// 脚本和 CLAUDE.md 维护的是仓库里那份,app 却在读 Application Support 里
/// 另一份旧的,两边永远对不上,而 UI 上只显示一个孤零零的"0 个项目"。
public private(set) var sourceURL: URL?
/// 解析失败的说明。UI 要显示出来,不能让文件坏了还静默显示 0。
public private(set) var loadDiagnostic: String?
private let explicitURL: URL?
private var gitTimer: Timer?
/// cwd → 项目名 的反查表。hook 事件只带 cwd,要靠它换成用户认识的项目名。
private var pathIndex: [String: String] = [:]
public init(yamlURL: URL? = nil) {
self.explicitURL = yamlURL
}
// MARK: - 加载
/// 按优先级找 projects.yaml。
///
/// **仓库那份优先**:`add-project.sh`、`scan-projects.sh` 和 CLAUDE.md
/// 全都以它为准,app 读别处就等于两套数据。Application Support 那份
/// 只作为"没有克隆仓库"时的兜底。
public var candidateURLs: [URL] {
if let explicitURL { return [explicitURL] }
let home = FileManager.default.homeDirectoryForCurrentUser
var urls: [URL] = []
if let dir = ProcessInfo.processInfo.environment["CLAUDE_HUB_DIR"], !dir.isEmpty {
urls.append(
URL(fileURLWithPath: NSString(string: dir).expandingTildeInPath)
.appendingPathComponent("projects.yaml")
)
}
urls.append(home.appendingPathComponent("Documents/code/claude-hub/projects.yaml"))
urls.append(
home.appendingPathComponent("Library/Application Support/claude-hub/projects.yaml")
)
return urls
}
public func load() {
loadDiagnostic = nil
// 逐个候选试。**空文件和解析不出东西的文件都要继续往下找** ——
// 只判断"文件存不存在"的话,一份坏掉的文件会把好的那份永远挡住,
// 这正是这次 0 个项目的成因。
var firstFound: (URL, ProjectYAML.Outcome)?
for url in candidateURLs {
guard let text = try? String(contentsOf: url, encoding: .utf8) else { continue }
let outcome = ProjectYAML.analyze(text)
if firstFound == nil { firstFound = (url, outcome) }
guard !outcome.projects.isEmpty else { continue }
sourceURL = url
projects = outcome.projects
rebuildPathIndex()
refreshGit()
return
}
// 一个候选都没解析出项目:如实报告最先找到的那份出了什么问题。
if let (url, outcome) = firstFound {
sourceURL = url
projects = []
loadDiagnostic = outcome.diagnostic ?? "文件里没有任何项目条目"
} else {
sourceURL = nil
projects = []
loadDiagnostic = "找不到 projects.yaml"
}
rebuildPathIndex()
}
private func rebuildPathIndex() {
pathIndex = [:]
for project in projects {
pathIndex[normalize(project.expandedPath)] = project.name
}
}
/// 从任意路径反查项目名。
///
/// 会话的 cwd 常常是项目的**子目录**(用户 cd 进去再起的 claude),
/// 所以要逐级向上找最长匹配,不能只做等值比较。
public func projectName(forPath path: String) -> String? {
var current = normalize(path)
while !current.isEmpty, current != "/" {
if let name = pathIndex[current] { return name }
current = (current as NSString).deletingLastPathComponent
}
return nil
}
private func normalize(_ path: String) -> String {
var value = NSString(string: path).expandingTildeInPath
while value.count > 1, value.hasSuffix("/") { value.removeLast() }
return value
}
// MARK: - git
public func startGitPolling() {
refreshGit()
gitTimer?.invalidate()
// git 状态变化远没有会话状态那么频繁,6 秒足够。
gitTimer = Timer.scheduledTimer(withTimeInterval: 6, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refreshGit() }
}
}
public func stopGitPolling() {
gitTimer?.invalidate()
gitTimer = nil
}
public func refreshGit() {
let paths = projects.map(\.expandedPath)
guard !paths.isEmpty else { return }
// 21 个仓库串行大约 120ms。放后台队列,别让 UI 掉帧。
Task.detached(priority: .utility) {
var collected: [String: GitInfo] = [:]
for path in paths {
if let info = GitStatus.collect(at: path) {
collected[path] = info
}
}
await MainActor.run { [collected] in
self.gitInfo = collected
}
}
}
public func git(for project: Project) -> GitInfo? {
gitInfo[project.expandedPath]
}
/// 按任意路径找 git 状态。
///
/// 会话的 cwd 常常是项目的子目录,所以和 `projectName(forPath:)` 一样
/// 逐级向上找最长匹配 —— 直接拿 cwd 当 key 去查会大面积落空。
public func git(forPath path: String) -> GitInfo? {
var current = normalize(path)
while !current.isEmpty, current != "/" {
if let info = gitInfo[current] { return info }
current = (current as NSString).deletingLastPathComponent
}
return nil
}
// MARK: - 扫描
/// 扫描目录树,把新发现的 git 仓库追加进 yaml。
public func scan(root: String = "~/Documents/code") {
guard !isScanning else { return }
let target = sourceURL ?? candidateURLs.first
guard let target else { return }
isScanning = true
let existing = Set(projects.map { self.normalize($0.expandedPath) })
Task.detached(priority: .userInitiated) {
let found = GitStatus.scanRepositories(under: root)
let fresh = found.filter { !existing.contains($0) }
if !fresh.isEmpty {
Self.append(paths: fresh, to: target)
}
await MainActor.run {
self.isScanning = false
self.load()
}
}
}
/// 把新发现的项目追加进 yaml。
///
/// 追加而不是重写整份:用户手写的别名、描述、注释都要保住。
///
/// 但**追加前必须先规范化 `projects:` 那一行**。这次的事故就是这么来的:
/// 文件里是 `projects: []`(旧版写的空数组),追加逻辑直接 seek 到文件尾
/// 写条目,产出的 YAML 变成"空数组标记后面跟着 117 条内容" ——
/// 解析器看到 `[]` 就认定项目段是空的,全部忽略。
nonisolated static func append(paths: [String], to url: URL) {
guard var text = try? String(contentsOf: url, encoding: .utf8) else { return }
text = normalizeProjectsKey(in: text)
if !text.hasSuffix("\n") { text += "\n" }
for path in paths {
let name = (path as NSString).lastPathComponent
text += " - name: \(name)\n path: \(path)\n"
}
try? text.write(to: url, atomically: true, encoding: .utf8)
}
/// 把 `projects: []` / `projects: ~` / `projects: null` 还原成 `projects:`,
/// 让后面缩进的列表条目重新生效。其余内容一字不动。
nonisolated static func normalizeProjectsKey(in text: String) -> String {
let emptyMarkers: Set<String> = ["[]", "~", "null", "{}"]
var lines = text.components(separatedBy: "\n")
for (index, line) in lines.enumerated() {
guard !line.hasPrefix(" "), !line.hasPrefix("\t") else { continue }
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("projects:") else { continue }
let value = String(trimmed.dropFirst("projects:".count))
.trimmingCharacters(in: .whitespaces)
if emptyMarkers.contains(value) {
lines[index] = "projects:"
}
break
}
return lines.joined(separator: "\n")
}
}
@@ -0,0 +1,246 @@
import Foundation
import HubProbe
/// 在终端里打开项目的方式。
public enum LaunchMode: String, CaseIterable, Sendable {
case claude
case claudeSkipPermissions
case terminal
case resumeLast
case resumePick
case resumeSession
case finder
case vscode
public var label: String {
switch self {
case .claude: return "Claude Code"
case .claudeSkipPermissions: return "Claude Code(跳过权限)"
case .terminal: return "终端"
case .resumeLast: return "继续上次会话"
case .resumePick: return "选择历史会话"
case .resumeSession: return "恢复此会话"
case .finder: return "在 Finder 打开"
case .vscode: return "用 VS Code 打开"
}
}
/// 要在 tmux 窗口里跑的命令。nil 表示只开一个 shell。
func command(sessionId: String?) -> String? {
switch self {
case .claude: return "claude"
case .claudeSkipPermissions: return "claude --dangerously-skip-permissions"
case .terminal: return nil
case .resumeLast: return "claude --continue"
case .resumePick: return "claude --resume"
case .resumeSession:
guard let sessionId else { return "claude --resume" }
return "claude --resume \(shellQuote(sessionId))"
case .finder, .vscode: return nil
}
}
}
func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// 终端派发。
///
/// ## 必须保留的隐性约束:Keychain ACL
///
/// **首次 `tmux new-session` 必须由终端进程执行**,不能由本 app 直接跑。
///
/// 原因:这样 tmux server 的父进程是 iTerm,claude 作为它的子进程才能复用
/// iTerm 在 macOS Keychain 上的 ACL。如果由 app 创建 tmux server,claude
/// 拿不到登录态,用户会莫名其妙地掉线。这是上一版踩过的坑,改写时原样保留。
///
/// 后续的 `new-window` / `send-keys` 是 client 操作,谁跑都行。
public struct TerminalDispatch: Sendable {
public enum Terminal: String, Sendable {
case iTerm = "iTerm2"
case terminal = "Terminal"
var bundleId: String {
switch self {
case .iTerm: return "com.googlecode.iterm2"
case .terminal: return "com.apple.Terminal"
}
}
/// iTerm 用 `-CC` 控制模式,tmux 窗口会呈现为原生 tab。
var attachCommand: String {
switch self {
case .iTerm: return "tmux -CC attach -t hub"
case .terminal: return "tmux attach -t hub"
}
}
}
public let sessionName: String
private let tmux: TmuxProbe
public init(sessionName: String = "hub", tmux: TmuxProbe = TmuxProbe()) {
self.sessionName = sessionName
self.tmux = tmux
}
public func detectTerminal() -> Terminal {
for candidate in [Terminal.iTerm, .terminal] where isInstalled(candidate) {
return candidate
}
return .terminal
}
private func isInstalled(_ terminal: Terminal) -> Bool {
let paths = [
"/Applications/\(terminal.rawValue).app",
NSString(string: "~/Applications/\(terminal.rawValue).app").expandingTildeInPath,
"/System/Applications/Utilities/\(terminal.rawValue).app",
]
return paths.contains { FileManager.default.fileExists(atPath: $0) }
}
// MARK: - 打开项目
@discardableResult
public func open(
project name: String,
path: String,
mode: LaunchMode,
sessionId: String? = nil
) -> Bool {
switch mode {
case .finder:
return Shell.run("/usr/bin/open", [path], timeout: 5).succeeded
case .vscode:
// code 不一定在 GUI app 的 PATH 里,用 open -a 更稳。
let direct = Shell.run("/usr/bin/open", ["-a", "Visual Studio Code", path], timeout: 5)
return direct.succeeded
default:
break
}
let inner = mode.command(sessionId: sessionId)
if hubSessionExists() {
return addWindow(name: name, path: path, command: inner)
}
return createSession(name: name, path: path, command: inner)
}
private func hubSessionExists() -> Bool {
Shell.run(tmux.tmuxPath, ["has-session", "-t", sessionName], timeout: 3).succeeded
}
/// 第一次创建:**必须**让终端来跑,见类型注释里的 Keychain 说明。
private func createSession(name: String, path: String, command: String?) -> Bool {
let terminal = detectTerminal()
ensureRunning(terminal)
var create = "tmux new-session -d -s \(shellQuote(sessionName))"
+ " -n \(shellQuote(name)) -c \(shellQuote(path))"
if let command {
create += " \(shellQuote(command))"
}
return runInTerminal("\(create) && \(terminal.attachCommand)", terminal: terminal)
}
/// session 已存在:直接加窗口,这是 client 操作,app 自己跑没问题。
private func addWindow(name: String, path: String, command: String?) -> Bool {
// 注意 flag 必须全部排在 shell-command 之前,否则 tmux 会把 flag
// 当成命令的一部分。
var args = [
"new-window", "-t", sessionName, "-n", name, "-c", path,
"-P", "-F", "#{window_id}",
]
if let command { args.append(command) }
let result = Shell.run(tmux.tmuxPath, args, timeout: 5)
guard result.succeeded else { return false }
let windowId = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
// 没有客户端连着的话,新窗口只是后台多了一个,用户什么都看不到。
let clients = Shell.run(tmux.tmuxPath, ["list-clients", "-t", sessionName], timeout: 3)
if clients.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let terminal = detectTerminal()
ensureRunning(terminal)
return runInTerminal(terminal.attachCommand, terminal: terminal)
}
// 有客户端:iTerm 不会自动前置新 tab,得主动切过去。
if !windowId.isEmpty {
Shell.run(tmux.tmuxPath, ["select-window", "-t", windowId], timeout: 3)
}
Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4)
return true
}
// MARK: - 终端控制
/// 确保终端在跑且 AppleEvent 接收器就绪。
///
/// 冷启动的 app 会有一段时间接受不了 AppleEvent,这时候发脚本会静默失败。
/// 所以要轮询探测 —— 用 `tell app to return id`,它不创建窗口、无副作用。
private func ensureRunning(_ terminal: Terminal) {
if Shell.run("/usr/bin/pgrep", ["-x", terminal.rawValue], timeout: 2).succeeded {
return
}
// -g:不抢焦点,稍后让 AppleScript 的 activate 来抢,
// 避免冷启动期间焦点反复跳。
Shell.run("/usr/bin/open", ["-g", "-b", terminal.bundleId], timeout: 5)
let deadline = Date().addingTimeInterval(8)
while Date() < deadline {
let probe = Shell.osascript(
"tell application \"\(terminal.rawValue)\" to return id", timeout: 3
)
if probe.succeeded,
!probe.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return
}
usleep(150_000)
}
}
private func runInTerminal(_ command: String, terminal: Terminal) -> Bool {
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script: String
switch terminal {
case .iTerm:
script = """
tell application "iTerm2"
activate
if (count of windows) = 0 then
set w to (create window with default profile)
tell current session of current tab of w
write text "\(escaped)"
end tell
else
tell current window
create tab with default profile
delay 0.3
tell current session of current tab
write text "\(escaped)"
end tell
end tell
end if
end tell
"""
case .terminal:
script = """
tell application "Terminal"
activate
do script "\(escaped)"
end tell
"""
}
return Shell.osascript(script, timeout: 15).succeeded
}
}
+278
View File
@@ -0,0 +1,278 @@
import Foundation
/// 单个项目的 token 用量。
public struct ProjectUsage: Identifiable, Sendable {
public let projectPath: String
public let projectName: String
public let sessionCount: Int
public let messageCount: Int
public let inputTokens: Int
public let outputTokens: Int
public let models: [String]
public var id: String { projectPath }
public var totalTokens: Int { inputTokens + outputTokens }
}
public struct UsageSummary: Sendable {
public let projects: [ProjectUsage]
public let totalSessions: Int
public let totalMessages: Int
public let totalInput: Int
public let totalOutput: Int
public static let empty = UsageSummary(
projects: [], totalSessions: 0, totalMessages: 0, totalInput: 0, totalOutput: 0
)
}
public enum UsageRange: String, CaseIterable, Sendable {
case week7 = "7天"
case month30 = "30天"
case all = "全部"
var cutoff: Date? {
switch self {
case .week7: return Calendar.current.date(byAdding: .day, value: -7, to: Date())
case .month30: return Calendar.current.date(byAdding: .day, value: -30, to: Date())
case .all: return nil
}
}
}
/// 单个 jsonl 文件的统计小计。缓存的最小单位。
struct FileUsage: Codable, Sendable {
let size: Int64
let modified: Date
let messages: Int
let input: Int
let output: Int
let models: [String]
}
/// 从 `~/.claude/projects/<编码路径>/*.jsonl` 统计 token 用量。
///
/// 这些文件是 Claude Code 的会话记录。**实测本机有 945 MB / 341 个文件**,
/// 所以有两条硬性约束:
///
/// 1. **绝不把整个文件读成 `String`。** 原实现用 `String(contentsOf:)` 再
/// `split(separator: "\n")`,945 MB 全进内存再切分,页面永远停在 0 ——
/// 用户看到的就是"用量功能没做"。改成 `FileHandle` 分块流式读取。
/// 2. **必须增量缓存。** jsonl 只追加不重写,同一个文件的 size + mtime 没变
/// 就不该重算。缓存落盘,第二次打开秒出。
///
/// 行级别仍然做**子串预筛**:只有含 `"usage"` 的行才值得走 JSON 解析,
/// 实测能跳过 90% 以上的行。
public enum UsageStats {
/// 流式读取的块大小。1 MB 是吞吐和内存的折中 —— 再大对总耗时几乎没影响,
/// 但会让峰值内存跟着涨。
private static let chunkSize = 1 << 20
static var cacheURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/usage-cache.json")
}
/// 目录名编码规则:路径里每个非 ASCII 字母数字字符替换成 `-`,不 trim。
/// 例:`/Users/dev/Documents/code/claude-hub`
/// → `-Users-dev-Documents-code-claude-hub`
public static func encodeDirectoryName(for path: String) -> String {
String(path.map { $0.isASCII && ($0.isLetter || $0.isNumber) ? $0 : "-" })
}
/// 反解目录名 → 大致的项目名(取最后一段)。
static func projectName(fromEncoded encoded: String) -> String {
encoded.split(separator: "-").last.map(String.init) ?? encoded
}
/// 统计用量。
///
/// `progress` 每处理完一个文件回调一次 `(已完成, 总数)`,
/// 用于「全部」范围下的进度显示 —— 首次全量要扫近 1 GB,没有进度条
/// 用户没法区分"在算"和"坏了"。
public static func collect(
range: UsageRange,
progress: (@Sendable (Int, Int) -> Void)? = nil
) -> UsageSummary {
let root = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/projects", isDirectory: true)
guard let dirs = try? FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]
) else { return .empty }
let cutoff = range.cutoff
var cache = loadCache()
var cacheDirty = false
var results: [ProjectUsage] = []
// 先把要处理的文件数点出来,进度才有分母。
var pending: [(dir: URL, files: [URL])] = []
var totalFiles = 0
for dir in dirs {
guard (try? dir.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
else { continue }
guard let files = try? FileManager.default.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
options: []
) else { continue }
let jsonl = files.filter { $0.pathExtension == "jsonl" }
guard !jsonl.isEmpty else { continue }
pending.append((dir, jsonl))
totalFiles += jsonl.count
}
var done = 0
for entry in pending {
var sessions = 0
var messages = 0
var input = 0
var output = 0
var models = Set<String>()
for file in entry.files {
defer {
done += 1
progress?(done, totalFiles)
}
let values = try? file.resourceValues(
forKeys: [.contentModificationDateKey, .fileSizeKey]
)
let modified = values?.contentModificationDate ?? .distantPast
let size = Int64(values?.fileSize ?? 0)
// 范围过滤放在读文件之前 —— 7 天视图下这一步就砍掉绝大多数文件。
if let cutoff, modified < cutoff { continue }
let key = file.path
let stats: FileUsage
if let cached = cache[key], cached.size == size, cached.modified == modified {
stats = cached
} else {
guard let fresh = scanFile(file, size: size, modified: modified) else {
continue
}
stats = fresh
cache[key] = fresh
cacheDirty = true
}
sessions += 1
messages += stats.messages
input += stats.input
output += stats.output
models.formUnion(stats.models)
}
guard sessions > 0 else { continue }
let encoded = entry.dir.lastPathComponent
results.append(
ProjectUsage(
projectPath: encoded,
projectName: projectName(fromEncoded: encoded),
sessionCount: sessions,
messageCount: messages,
inputTokens: input,
outputTokens: output,
models: models.sorted()
)
)
}
if cacheDirty { saveCache(cache) }
results.sort { $0.totalTokens > $1.totalTokens }
return UsageSummary(
projects: results,
totalSessions: results.reduce(0) { $0 + $1.sessionCount },
totalMessages: results.reduce(0) { $0 + $1.messageCount },
totalInput: results.reduce(0) { $0 + $1.inputTokens },
totalOutput: results.reduce(0) { $0 + $1.outputTokens }
)
}
// MARK: - 流式解析
/// 分块读一个 jsonl,逐行统计。
///
/// **不用 `String(contentsOf:)`。** 单个会话文件可以有几十 MB,
/// 全量目录接近 1 GB,整份读进内存再 `split` 是这个功能之前根本跑不出结果的原因。
///
/// 分块读的关键细节:块边界几乎必然落在某一行中间,所以每块处理完要把
/// 残留的尾巴留到下一块开头拼接,否则会漏掉/切坏跨块的那一行。
static func scanFile(_ url: URL, size: Int64, modified: Date) -> FileUsage? {
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? handle.close() }
var messages = 0
var input = 0
var output = 0
var models = Set<String>()
var carry = Data()
func consume(_ line: Data) {
guard !line.isEmpty else { return }
// 预筛:绝大多数行(user 消息、attachment、快照)不含 usage。
// 在 Data 上做子串查找,避免为每一行构造 String。
guard line.range(of: Data("\"usage\"".utf8)) != nil else { return }
guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any],
let message = object["message"] as? [String: Any]
else { return }
if let model = message["model"] as? String { models.insert(model) }
guard let usage = message["usage"] as? [String: Any] else { return }
messages += 1
input += (usage["input_tokens"] as? Int) ?? 0
input += (usage["cache_read_input_tokens"] as? Int) ?? 0
input += (usage["cache_creation_input_tokens"] as? Int) ?? 0
output += (usage["output_tokens"] as? Int) ?? 0
}
let newline = UInt8(ascii: "\n")
while let chunk = try? handle.read(upToCount: chunkSize), !chunk.isEmpty {
var buffer = carry
buffer.append(chunk)
carry = Data()
var start = buffer.startIndex
while let index = buffer[start...].firstIndex(of: newline) {
consume(buffer[start..<index])
start = buffer.index(after: index)
}
// 块尾没换行的部分留给下一块。
if start < buffer.endIndex {
carry = Data(buffer[start...])
}
}
consume(carry)
return FileUsage(
size: size, modified: modified, messages: messages,
input: input, output: output, models: models.sorted()
)
}
// MARK: - 缓存
/// jsonl 是只追加的,同一个文件的 size + mtime 没变就不可能有新数据。
/// 缓存落盘之后第二次打开是秒开,而不是重扫近 1 GB。
static func loadCache() -> [String: FileUsage] {
guard let data = try? Data(contentsOf: cacheURL),
let decoded = try? JSONDecoder().decode([String: FileUsage].self, from: data)
else { return [:] }
return decoded
}
static func saveCache(_ cache: [String: FileUsage]) {
let directory = cacheURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: cacheURL, options: .atomic)
}
}
+262
View File
@@ -0,0 +1,262 @@
import Foundation
import HubIPC
/// 一条待决策的审批请求。
public struct ApprovalRequest: Identifiable, Sendable {
public let id: String
public let sessionId: String
public let projectName: String
public let cwd: String
public let toolName: String
public let command: String
public let risk: RiskLevel
public let createdAt: Date
/// 发起这次调用的 hook 进程。用来检测对端是否还活着,见
/// `ApprovalCoordinator.startOrphanSweep()`。
public let clientPid: pid_t
public init(
id: String, sessionId: String, projectName: String, cwd: String,
toolName: String, command: String, risk: RiskLevel,
clientPid: pid_t = 0, createdAt: Date = Date()
) {
self.id = id
self.sessionId = sessionId
self.projectName = projectName
self.cwd = cwd
self.toolName = toolName
self.command = command
self.risk = risk
self.clientPid = clientPid
self.createdAt = createdAt
}
/// L3 的「允许」需要按住确认,L2 只需要等解锁。
public var requiresHoldToConfirm: Bool { risk == .irreversible }
}
/// 审批日志的一条记录。这份数据现在完全没留存,但它很值钱 ——
/// 用户看一周就知道该不该把 `dangerous` 一档也开启拦截。
public struct ApprovalLogEntry: Codable, Identifiable, Sendable {
public let id: String
public let timestamp: Date
public let projectName: String
public let toolName: String
public let command: String
public let risk: RiskLevel
public let verdict: String
/// 是否真的弹了岛。`dangerous` 默认只记录不拦截,这时是 false。
public let intercepted: Bool
}
/// 岛上审批的协调器。
///
/// hook 线程会在 `await` 上阻塞直到用户点击或超时,所以这里用 continuation
/// 而不是轮询 —— hook 那边每多等 100ms 都是用户在等。
@Observable
@MainActor
public final class ApprovalCoordinator {
/// 当前排队中的请求。第一条显示在岛上,其余用堆叠卡片暗示。
public private(set) var queue: [ApprovalRequest] = []
public private(set) var log: [ApprovalLogEntry] = []
/// 拦截门槛。默认只拦不可逆操作,见 RiskClassifier 里的理由。
public var interceptThreshold: RiskLevel = .irreversible
private var pending: [String: CheckedContinuation<HookDecision, Never>] = [:]
private var timeoutTasks: [String: Task<Void, Never>] = [:]
/// 用户决策的时限。到点自动拒绝。
///
/// 默认 deny 而不是 allow:用户全局开着 auto + skip-permissions,这是唯一的刹车。
/// 而且 deny 是可恢复的(Claude 会告诉用户被拒了,重跑即可),allow 不可逆。
///
/// 这是超时阶梯里**最内层**的一环,见 `HookTimeouts`。
private let timeout = HookTimeouts.userDecision
private var orphanSweep: Task<Void, Never>?
/// 审批日志的落盘位置。
///
/// 这份数据的**唯一用途**就是"看一周再决定要不要把 dangerous 一档也拦上"——
/// 只存在内存里的话 app 一重启就清零,这个功能等于不存在。
public static var defaultLogURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/approval-log.json")
}
/// nil = 不落盘。
///
/// **必须可注入。** 把路径写死在类型里会让测试直接读写用户的真实日志:
/// 测试之间互相污染是小事,往用户数据里写测试垃圾是真问题。
private let logURL: URL?
public init(logURL: URL? = defaultLogURL) {
self.logURL = logURL
log = Self.loadLog(from: logURL)
}
private static func loadLog(from url: URL?) -> [ApprovalLogEntry] {
guard let url,
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([ApprovalLogEntry].self, from: data)
else { return [] }
return decoded
}
/// 每次追加都落盘。
///
/// 频率极低(只有被拦截或被记录的高风险操作才走到这里),
/// 而"崩溃/退出后丢掉最后几条"恰恰是最不能接受的 —— 用户想看的就是
/// 刚才那次到底发生了什么。所以不做批量延迟写。
private func persistLog() {
guard let logURL else { return }
try? FileManager.default.createDirectory(
at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
guard let data = try? JSONEncoder().encode(log) else { return }
try? data.write(to: logURL, options: .atomic)
}
public var current: ApprovalRequest? { queue.first }
// MARK: - 僵尸请求清理
/// 定期清掉「发起方已经死了」的审批卡。
///
/// 真实场景:Claude 被 Ctrl-C、会话被关、hook 进程超时被杀 —— 这些情况下
/// hubctl 没了,但服务端仍然阻塞在等决策上,岛上那张卡会一直挂着。
/// 用户要么被迫点一下一个**早已无人接收**的决策,要么它会一直挡住
/// 后面真正需要处理的请求(队列是先进先出的)。
///
/// 判据用 `HookEvent.clientPid` —— 这个字段本来就一路传过来了。
/// 判 deny 而不是 allow:反正没人收,但万一对端还活着只是暂时不可见,
/// 拒绝是可恢复的那一侧。
public func startOrphanSweep() {
orphanSweep?.cancel()
orphanSweep = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
await self?.dropOrphans()
}
}
}
public func stopOrphanSweep() {
orphanSweep?.cancel()
orphanSweep = nil
}
private func dropOrphans() {
for request in queue where request.clientPid > 0 {
// kill(pid, 0):EPERM 说明进程存在但不属于我们,也算活着。
guard kill(request.clientPid, 0) != 0, errno == ESRCH else { continue }
resolve(
id: request.id,
decision: HookDecision(verdict: .deny, reason: "发起方已退出"),
verdictLabel: "orphaned"
)
}
}
// MARK: - hook 侧入口
/// 提交一次审批请求并等待结论。**会阻塞调用方直到有结果。**
public func requestDecision(for request: ApprovalRequest) async -> HookDecision {
queue.append(request)
let task = Task { [weak self] in
try? await Task.sleep(for: .seconds(self?.timeout ?? 60))
guard !Task.isCancelled else { return }
await self?.resolve(
id: request.id,
decision: HookDecision(verdict: .deny, reason: "Claude Hub:60 秒未响应,已自动拒绝"),
verdictLabel: "timeout"
)
}
timeoutTasks[request.id] = task
return await withCheckedContinuation { continuation in
pending[request.id] = continuation
}
}
// MARK: - UI 侧动作
public func allow(_ request: ApprovalRequest) {
resolve(
id: request.id,
decision: HookDecision(verdict: .allow),
verdictLabel: "allow"
)
}
public func deny(_ request: ApprovalRequest) {
resolve(
id: request.id,
decision: HookDecision(verdict: .deny, reason: "用户在 Claude Hub 上拒绝了这次操作"),
verdictLabel: "deny"
)
}
/// 跳过:把当前这条挪到队尾,先处理别的。**不代表拒绝。**
public func skip(_ request: ApprovalRequest) {
guard queue.count > 1, let index = queue.firstIndex(where: { $0.id == request.id })
else { return }
let item = queue.remove(at: index)
queue.append(item)
}
private func resolve(id: String, decision: HookDecision, verdictLabel: String) {
timeoutTasks[id]?.cancel()
timeoutTasks[id] = nil
guard let continuation = pending.removeValue(forKey: id) else { return }
let request = queue.first { $0.id == id }
queue.removeAll { $0.id == id }
continuation.resume(returning: decision)
if let request {
appendLog(request: request, verdict: verdictLabel, intercepted: true)
}
}
/// 记一笔没有拦截的高风险操作(`dangerous` 档默认走这里)。
public func recordWithoutIntercepting(
projectName: String, toolName: String, command: String, risk: RiskLevel
) {
log.insert(
ApprovalLogEntry(
id: UUID().uuidString, timestamp: Date(), projectName: projectName,
toolName: toolName, command: command, risk: risk,
verdict: "auto-allow", intercepted: false
),
at: 0
)
trimLog()
}
private func appendLog(request: ApprovalRequest, verdict: String, intercepted: Bool) {
log.insert(
ApprovalLogEntry(
id: request.id, timestamp: Date(), projectName: request.projectName,
toolName: request.toolName, command: request.command, risk: request.risk,
verdict: verdict, intercepted: intercepted
),
at: 0
)
trimLog()
}
private func trimLog() {
if log.count > 500 { log.removeLast(log.count - 500) }
persistLog()
}
/// 清空审批日志(设置页用)。
public func clearLog() {
log = []
persistLog()
}
}
+295
View File
@@ -0,0 +1,295 @@
import HubCore
import HubIPC
import SwiftUI
/// 事件闯入的内容。
public struct IntrusionEvent: Equatable, Sendable {
public enum Kind: Equatable, Sendable {
case finished // Claude 完成一轮
case needsInput // 等待输入或授权
}
public let kind: Kind
public let sessionId: String
public let title: String
public let detail: String
public init(kind: Kind, sessionId: String, title: String, detail: String) {
self.kind = kind
self.sessionId = sessionId
self.title = title
self.detail = detail
}
var accent: Color {
switch kind {
case .finished: return IslandTheme.busy
case .needsInput: return IslandTheme.waiting
}
}
var symbol: String {
switch kind {
case .finished: return "checkmark.circle.fill"
case .needsInput: return "exclamationmark.circle.fill"
}
}
}
/// 闯入态视图。
struct IntrusionContent: View {
let event: IntrusionEvent
let session: AgentSession?
var body: some View {
HStack(spacing: 12) {
if let session {
SpriteScreen(cornerRadius: 7) {
PixelSprite(session: session, pixelSize: 2, gridSize: 16)
}
} else {
Image(systemName: event.symbol)
.font(.system(size: 20))
.foregroundStyle(event.accent)
.frame(width: 32, height: 32)
}
VStack(alignment: .leading, spacing: 2) {
Text(event.title)
.font(IslandTheme.label(13, .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text(event.detail)
.font(IslandTheme.label(11))
.foregroundStyle(.white.opacity(0.62))
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: event.symbol)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(event.accent)
}
.padding(.horizontal, 14)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// 审批态视图。
///
/// 这是整个 app 里**唯一一个做错了会真的删掉代码**的地方,所以防误点护栏是冗余的:
/// 六重保护叠加,任何单点失效都不至于误放行。
struct ApprovalContent: View {
let request: ApprovalRequest
let queueCount: Int
let onAllow: () -> Void
let onDeny: () -> Void
let onSkip: () -> Void
/// ① 时间锁:面板出现后 700ms 内「允许」不可用。
///
/// 这同时也覆盖掉了「闯入态膨胀时指针恰好停在展开区域」的误击窗口 ——
/// 岛是会主动弹出来的,用户的手可能正好在那儿。
@State private var unlocked = false
/// ② 长按进度(仅 L3)。
@State private var holdProgress: Double = 0
@State private var holdTask: Task<Void, Never>?
/// ③ 指针冷却:进入面板后 120ms 内的点击全丢弃。
@State private var pointerSettledAt: Date?
private var allowEnabled: Bool {
guard unlocked else { return false }
guard let settled = pointerSettledAt else { return true }
return Date().timeIntervalSince(settled) > 0.12
}
var body: some View {
VStack(spacing: 0) {
header
commandBlock
Spacer(minLength: 0)
buttons
}
.padding(.vertical, 12)
.onAppear {
Task {
try? await Task.sleep(for: .milliseconds(700))
unlocked = true
}
}
.onContinuousHover { phase in
if case .active = phase, pointerSettledAt == nil {
pointerSettledAt = Date()
}
}
}
private var header: some View {
HStack(spacing: 10) {
Image(systemName: request.risk == .irreversible
? "exclamationmark.octagon.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 18))
.foregroundStyle(riskColor)
VStack(alignment: .leading, spacing: 1) {
Text(request.projectName)
.font(IslandTheme.label(13, .semibold))
.foregroundStyle(.white)
Text(request.toolName)
.font(IslandTheme.label(11))
.foregroundStyle(.white.opacity(0.62))
}
Spacer(minLength: 6)
if queueCount > 1 {
Text("1/\(queueCount)")
.font(IslandTheme.label(10, .semibold))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.62))
.padding(.horizontal, 6)
.frame(height: 18)
.background(.white.opacity(0.10), in: .capsule)
.onTapGesture(perform: onSkip)
}
Text(riskLabel)
.font(IslandTheme.label(10, .bold))
.foregroundStyle(.black)
.padding(.horizontal, 7)
.frame(height: 20)
.background(riskColor, in: .capsule)
}
.padding(.horizontal, 16)
.frame(height: 48)
}
private var commandBlock: some View {
ScrollView {
Text(request.command)
.font(IslandTheme.mono(12))
.foregroundStyle(.white.opacity(0.92))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
.scrollIndicators(.never)
.frame(height: 84)
.background(
// 这块是我们自己压暗的深色底,所以下面放什么材质都可控。
Color.black.opacity(0.48),
in: .rect(cornerRadius: 10, style: .continuous)
)
.padding(.horizontal, 16)
}
private var buttons: some View {
VStack(spacing: 6) {
HStack(spacing: 0) {
Button(action: onDeny) {
Text("拒绝")
}
// 不用系统的 .glass:岛的 panel 基本不是 key window,
// AppKit 会把非活跃窗口里的系统按钮整体去饱和,两个按钮都变灰 ——
// 在唯一需要立刻决策的地方显示成"点不了"是不可接受的。
.buttonStyle(IslandButtonStyle(
emphasis: .secondary, width: 120, height: 34
))
// ⑤ 默认动作是拒绝。⏎ 和 ⎋ 都拒绝,「允许」必须 ⌘⏎。
// **永远不要让 Return 触发允许。**
.keyboardShortcut(.defaultAction)
// ① 空间隔离:24pt 间距,故意大于 GlassEffectContainer 的 22pt 融合阈值,
// 让两个按钮保持独立、不粘连成一块玻璃(粘连会助长误点)。
Spacer().frame(width: 24)
allowButton
}
// 键盘提示。
//
// 这个 app 是 .accessory + nonactivatingPanel,面板未必拿得到 key ——
// 拿不到时 SwiftUI 的 .keyboardShortcut 是死的,由 IslandController 的
// 局部按键监听接管。两条路的键位语义完全一致,所以提示始终成立。
Text(request.requiresHoldToConfirm
? "⏎ / ⎋ 拒绝 · 允许必须按住"
: "⏎ / ⎋ 拒绝 · ⌘⏎ 允许")
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.38))
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.top, 10)
}
@ViewBuilder
private var allowButton: some View {
let label = request.requiresHoldToConfirm
? (holdProgress > 0 ? "按住…" : "按住确认")
: "允许"
Button(action: {
// ④ L3 必须长按,普通点击不生效。
guard !request.requiresHoldToConfirm else { return }
guard allowEnabled else { return }
onAllow()
}) {
ZStack {
if request.requiresHoldToConfirm {
GeometryReader { proxy in
Rectangle()
.fill(.black.opacity(0.28))
.frame(width: proxy.size.width * holdProgress)
}
}
Text(label)
}
}
.buttonStyle(IslandButtonStyle(
emphasis: .prominent, tint: riskColor, width: 120, height: 34
))
.keyboardShortcut(.return, modifiers: .command)
.disabled(!allowEnabled)
.opacity(allowEnabled ? 1 : 0.45)
.overlay {
// 时间锁的可视化:一圈进度环,让用户知道为什么点不了。
if !unlocked {
Circle()
.stroke(.white.opacity(0.35), lineWidth: 1.5)
.frame(width: 16, height: 16)
}
}
.simultaneousGesture(holdGesture)
}
private var holdGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard request.requiresHoldToConfirm, allowEnabled, holdTask == nil else { return }
holdTask = Task {
let steps = 30
for step in 1...steps {
try? await Task.sleep(for: .milliseconds(30))
if Task.isCancelled { return }
holdProgress = Double(step) / Double(steps)
}
if !Task.isCancelled { onAllow() }
}
}
.onEnded { _ in
// 松手过早:取消并把进度弹回去。
holdTask?.cancel()
holdTask = nil
withAnimation(.spring(response: 0.25)) { holdProgress = 0 }
}
}
private var riskColor: Color {
request.risk == .irreversible ? IslandTheme.danger : IslandTheme.waiting
}
private var riskLabel: String {
request.risk == .irreversible ? "不可逆" : "危险操作"
}
}
+217
View File
@@ -0,0 +1,217 @@
import Foundation
import HubCore
import HubIPC
import HubProjects
/// 把 hook 事件接到 UI 上。
///
/// 链路:`hubctl` → Unix socket → 这里 → 通知 / 灵动岛闯入 / 审批。
@MainActor
public final class HookCoordinator {
private let store: SessionStore
private let approvals: ApprovalCoordinator
private let notifications: HubNotificationCenter
private let projects: ProjectStore
private var server: HubSocketServer?
/// 触发灵动岛闯入。
public var onIntrusion: ((IntrusionEvent) -> Void)?
/// 需要弹审批面板。
public var onApprovalNeeded: (() -> Void)?
/// 闯入防轰炸:记录每个会话上次闯入的时间。
///
/// 9 个会话同时收工的时候这条至关重要 —— 不限流的话岛会连续膨胀九次,
/// 从"有用的提醒"变成"必须关掉的骚扰"。
private var lastIntrusionAt: [String: Date] = [:]
private let intrusionCooldown: TimeInterval = 60
private let classifier = RiskClassifier()
public init(
store: SessionStore,
approvals: ApprovalCoordinator,
notifications: HubNotificationCenter,
projects: ProjectStore
) {
self.store = store
self.approvals = approvals
self.notifications = notifications
self.projects = projects
}
public func start() {
let server = HubSocketServer { [weak self] event in
// 这个闭包跑在 socket 的工作线程上。preToolUse 要同步返回决策,
// 而决策来自 MainActor 上的 UI,所以用信号量把异步结果桥回来。
// 阻塞的是这条连接自己的线程,不影响其他连接。
guard let self else { return .allow }
return self.handle(event)
}
do {
try server.start()
self.server = server
NSLog("[ClaudeHub] hook socket 已监听:\(HubSocket.path)")
} catch {
NSLog("[ClaudeHub] hook socket 启动失败:\(error) —— hook 将全部放行")
}
}
public func stop() {
server?.stop()
server = nil
}
// MARK: - 事件分发
private nonisolated func handle(_ event: HookEvent) -> HookDecision {
switch event.kind {
case .stop:
Task { @MainActor in self.handleStop(event) }
return .allow
case .notification:
Task { @MainActor in self.handleNotification(event) }
return .allow
case .sessionEnd:
Task { @MainActor in self.store.refresh() }
return .allow
case .preToolUse:
return handlePreToolUseSynchronously(event)
}
}
@MainActor
private func handleStop(_ event: HookEvent) {
store.refresh()
let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName
let session = store.sessions.first { $0.sessionId == event.sessionId }
let title = session?.name ?? project
// 正文用 Claude 自己的最终回复,替掉旧实现那 5 条随机中文文案
// ("搞定了,来看看" 之类,信息量为零,用户还得切过去才知道干了什么)。
let body = Self.summarize(event.lastAssistantMessage) ?? "完成了一轮回复"
notifications.post(title: "✅ \(title)", body: body, sessionId: event.sessionId)
if allowIntrusion(for: event.sessionId) {
onIntrusion?(
IntrusionEvent(
kind: .finished, sessionId: event.sessionId,
title: title, detail: "\(project) · \(body)"
)
)
}
}
@MainActor
private func handleNotification(_ event: HookEvent) {
store.refresh()
let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName
let session = store.sessions.first { $0.sessionId == event.sessionId }
let title = session?.name ?? project
let needsUser = event.notificationType == "permission_prompt"
|| event.notificationType == "idle_prompt"
|| event.notificationType == "elicitation_dialog"
notifications.post(
title: needsUser ? "⚠️ \(title)" : title,
body: event.message ?? "需要你处理",
sessionId: event.sessionId
)
guard needsUser, allowIntrusion(for: event.sessionId) else { return }
onIntrusion?(
IntrusionEvent(
kind: .needsInput, sessionId: event.sessionId,
title: title, detail: "\(project) · \(event.message ?? "等待你的输入")"
)
)
}
/// PreToolUse 必须**同步**返回 —— hubctl 那头在等这条连接的应答。
///
/// 用信号量把 MainActor 上的异步审批结果桥回当前线程。这是少数几个
/// 用信号量阻塞是正确做法的场景:调用方本来就是一条专用的连接线程,
/// 它的全部职责就是等这个结果。
private nonisolated func handlePreToolUseSynchronously(_ event: HookEvent) -> HookDecision {
let risk = classifier.classify(toolName: event.toolName, summary: event.toolSummary)
guard risk != .normal else { return .allow }
guard classifier.shouldIntercept(risk) else {
// dangerous 档默认只记录不拦截:用户全局开着 auto + skip-permissions,
// 拦上这一档每天要弹 3–8 次,结果必然是他把整个功能关掉。
Task { @MainActor in
let project = self.projects.projectName(forPath: event.cwd)
?? event.fallbackProjectName
self.approvals.recordWithoutIntercepting(
projectName: project,
toolName: event.toolName ?? "?",
command: event.toolSummary ?? "",
risk: risk
)
}
return .allow
}
let semaphore = DispatchSemaphore(value: 0)
// nonisolated(unsafe) 是安全的:只有下面的 Task 写、只有 wait 之后才读,
// 信号量提供了 happens-before 关系。
nonisolated(unsafe) var result = HookDecision.allow
Task { @MainActor in
let project = self.projects.projectName(forPath: event.cwd)
?? event.fallbackProjectName
let request = ApprovalRequest(
id: event.requestId,
sessionId: event.sessionId,
projectName: project,
cwd: event.cwd,
toolName: event.toolName ?? "?",
command: event.toolSummary ?? "(无参数)",
risk: risk,
clientPid: event.clientPid ?? 0
)
self.onApprovalNeeded?()
result = await self.approvals.requestDecision(for: request)
semaphore.signal()
}
// 见 HookTimeouts 的超时阶梯:这一层要比 ApprovalCoordinator 的
// 用户决策超时长,但比 hubctl 的读超时短。
if semaphore.wait(timeout: .now() + HookTimeouts.serverBridge) == .timedOut {
HubLog.ipc.error("审批桥接超时,按拒绝处理")
return HookDecision(verdict: .deny, reason: "Claude Hub 审批超时")
}
HubLog.ipc.notice("用户决策:\(result.verdict.rawValue, privacy: .public)")
return result
}
// MARK: - 辅助
private func allowIntrusion(for sessionId: String) -> Bool {
let now = Date()
if let last = lastIntrusionAt[sessionId], now.timeIntervalSince(last) < intrusionCooldown {
return false
}
lastIntrusionAt[sessionId] = now
return true
}
/// 把 Claude 的最终回复压成一行通知正文。
static func summarize(_ message: String?) -> String? {
guard let message else { return nil }
let cleaned = message
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { return nil }
if cleaned.count <= 90 { return cleaned }
return String(cleaned.prefix(88)) + "…"
}
}
+60
View File
@@ -0,0 +1,60 @@
import AppKit
import SwiftUI
/// 灵动岛的背景材质。
///
/// ## 为什么不用 Liquid Glass
///
/// 这是一个**刻意放弃苹果官方 `.glassEffect` API** 的决定,理由是一条物理约束:
/// **岛的上半截必须和刘海严丝合缝,而刘海恒为纯黑。**
///
/// Liquid Glass 的设计目标是"盖在任何东西上,上面的内容都可读",
/// 所以它会**跟着背景亮度双向补偿** —— 背景亮它变亮,背景暗它**也**变亮。
/// 实测(`.clear` + 黑 tint):
///
/// | 背景 | 玻璃输出 | 压 0.72 黑罩之后 | 刘海 |
/// |---|---|---|---|
/// | 白底 Finder | ≈ 1.0 | 0.28(中灰) | 0.0 |
/// | 纯黑终端 | ≈ 0.45 | 0.13 | 0.0 |
///
/// 白底下要压到刘海那个黑需要 scrim ≈ 0.94,那时候"玻璃"已经完全不存在了。
/// 换句话说,**"任何背景下都贴合刘海" 和 "自适应玻璃" 在物理上互斥**,
/// 参数怎么调都不可能同时成立。用户选了前者。
///
/// ## 用什么代替
///
/// `NSVisualEffectView` + **强制暗色外观**。它做的是背景模糊 + 固定的暗色染色,
/// 不做那套亮度补偿,所以无论背后是什么,输出都稳定落在暗部。
///
/// 玻璃感并没有丢,只是换了来源:
/// - **背景模糊**由这一层提供(背后的形状会柔和地透出来);
/// - **边缘高光**(`IslandTheme.edgeHighlight`)—— 真实玻璃的边缘因折射比中间亮,
/// 这是"看起来像玻璃"最关键的一笔;
/// - **轻微透光**——完全不透就成了塑料。
///
/// 这正是真实刘海周围那块材质的样子:一块黑玻璃,而不是一块会变色的果冻。
struct IslandBackdrop: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
// .hudWindow 在暗色外观下是最接近"黑玻璃"的系统材质:
// 比 .popover / .menu 暗得多,而且不带那层浅色底。
view.material = .hudWindow
// .behindWindow 才能采样到**窗口后面**的桌面/别的 app。
// .withinWindow 只会采样本窗口内的内容,对一个全透明的 panel 等于什么都没有。
view.blendingMode = .behindWindow
// app 是 .accessory 且从不激活,用 .followsWindowActiveState 的话
// 材质会长期停在"非活跃"的淡化状态。必须钉死 .active。
view.state = .active
// 强制暗色:不跟随系统浅色/深色。刘海在浅色模式下也是纯黑。
view.appearance = NSAppearance(named: .darkAqua)
view.autoresizingMask = [.width, .height]
return view
}
func updateNSView(_ view: NSVisualEffectView, context: Context) {
// 外观和材质都是常量,没有需要随状态更新的东西。
// 形状裁切交给 SwiftUI 侧的 .clipShape —— 让 NSView 自己做 mask
// 会和 SwiftUI 的形变动画脱节。
}
}
+593
View File
@@ -0,0 +1,593 @@
import AppKit
import HubCore
import HubProjects
import SwiftUI
/// 灵动岛的生命周期与交互控制。
///
/// 负责:建窗口、按屏幕几何定位、随形态更新命中路径、悬停探测、状态机迁移。
///
/// **形态本身不存在这里,存在 `IslandModel` 里。** 控制器只是唯一的写入口 ——
/// 这样命中路径、tracking area、外部点击监听这些 AppKit 侧的东西
/// 才有机会跟着形态一起更新。
@MainActor
public final class IslandController {
private let store: SessionStore
private let approvals: ApprovalCoordinator
private let projects: ProjectStore
private let model = IslandModel()
private var panel: IslandPanel?
private var hostingView: IslandHostingView<AnyView>?
private var geometry: NotchGeometry?
private var intrusionDismissTask: Task<Void, Never>?
/// 闯入之前是什么形态,回落时要还原。
private var stateBeforeIntrusion: IslandState = .rest
/// 悬停探测的兜底链路(主链路是宿主视图上的 `NSTrackingArea`)。
private var mouseMonitor: Any?
private var hoverExitWorkItem: DispatchWorkItem?
private var hoverEnterWorkItem: DispatchWorkItem?
/// 展开前要求指针在岛上**停留**多久。
///
/// 没有这个延迟的话,鼠标沿菜单栏横穿去点右上角图标时岛就会弹出来 ——
/// 用户的原话是"还没接近就立刻出来,很奇怪"。
/// 250ms 足够滤掉路过,又不至于让有意悬停的人觉得迟钝。
private static let hoverEnterDelay: TimeInterval = 0.25
/// 展开态下点击岛外任意处收回。只在展开时安装,避免常驻全局监听。
private var outsideClickMonitor: Any?
/// 审批态的按键处理(⌘⏎ / ⎋)。
private var keyMonitor: Any?
/// 收缩命中区的延迟任务,见 `updateHitPath`。
private var hitPathShrinkWork: DispatchWorkItem?
/// 显示器配置变化的监听。插拔外接屏、改缩放都要重新对齐。
private var screenObserver: NSObjectProtocol?
/// 从岛上启动一个项目。由 app 层注入(要用到 TerminalDispatch)。
public var onLaunch: ((Project, LaunchMode) -> Void)?
public init(
store: SessionStore,
approvals: ApprovalCoordinator,
projects: ProjectStore
) {
self.store = store
self.approvals = approvals
self.projects = projects
}
// MARK: - 生命周期
public func show() {
guard let screen = NotchGeometry.preferredScreen() else {
HubLog.island.error("找不到可用屏幕,灵动岛未启动")
return
}
let geo = NotchGeometry(screen: screen)
geometry = geo
let hosting = IslandHostingView(rootView: AnyView(EmptyView()))
hosting.frame = CGRect(origin: .zero, size: IslandMetrics.panelSize)
hostingView = hosting
let panel = IslandPanel(contentView: hosting)
self.panel = panel
position(panel, on: geo)
// rootView **只设这一次**。之后全部靠 @Observable 驱动重绘 ——
// 反复替换 rootView 会让动画无从发生、@State 每次归零。
installRootView(geometry: geo)
hosting.onPointerMove = { [weak self] point in
self?.handlePointer(at: point)
}
updateHitPath()
panel.orderFrontRegardless()
installMouseMonitor()
observeScreenChanges()
HubLog.island.notice("""
已启动 —— 屏幕 \(screen.localizedName, privacy: .public),\
刘海 \(Int(geo.notchWidth), privacy: .public)×\
\(Int(geo.notchHeight), privacy: .public)pt,\
中心 X \(geo.centerX, privacy: .public)
""")
applyPinnedStateIfNeeded()
}
public func hide() {
if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) }
screenObserver = nil
removeMouseMonitor()
removeOutsideClickMonitor()
removeKeyMonitor()
cancelHoverEnter()
hoverExitWorkItem?.cancel()
hitPathShrinkWork?.cancel()
intrusionDismissTask?.cancel()
panel?.orderOut(nil)
panel = nil
hostingView = nil
}
// MARK: - 定位
/// 窗口水平居中于刘海中心,顶边贴屏幕顶。
///
/// 窗口尺寸恒为 `IslandMetrics.panelSize`,形态切换只改内部布局 ——
/// 动 window frame 会让 CoreAnimation 的隐式事务和 SwiftUI 的 spring 打架。
private func position(_ panel: IslandPanel, on geo: NotchGeometry) {
let size = IslandMetrics.panelSize
panel.setFrame(
CGRect(
// 取整到整数点。刘海中心可能是 863.5(左右可用区不等宽),
// 半点的窗口原点会让整块内容在 2x 屏上落到半像素,边缘发虚。
// 岛内部的形状仍以 panel 中心对称,所以这里的取整不会引入偏移。
x: (geo.centerX - size.width / 2).rounded(),
y: (geo.topY - size.height).rounded(),
width: size.width,
height: size.height
),
display: false
)
}
/// 显示器配置变了就重新对齐。
///
/// 以前只在 `show()` 里定位一次 —— 插拔外接屏、改分辨率缩放、
/// 甚至把内建屏在排列里挪个位置,岛都会留在旧坐标上,
/// 表现就是"岛歪了 / 跑偏了",而且**再也不会自己回去**。
private func observeScreenChanges() {
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.realign() }
}
}
private func realign() {
guard let panel, let screen = NotchGeometry.preferredScreen() else { return }
let geo = NotchGeometry(screen: screen)
guard geo != geometry else { return }
geometry = geo
position(panel, on: geo)
installRootView(geometry: geo)
updateHitPath()
HubLog.island.notice("""
显示器变化,已重新对齐 —— \(screen.localizedName, privacy: .public),\
刘海 \(Int(geo.notchWidth), privacy: .public)×\
\(Int(geo.notchHeight), privacy: .public)pt
""")
}
// MARK: - 内容
private func installRootView(geometry geo: NotchGeometry) {
hostingView?.rootView = AnyView(
IslandRootView(
store: store,
approvals: approvals,
projects: projects,
model: model,
geometry: geo,
onToggleExpand: { [weak self] in self?.toggleExpand() },
onSelect: { [weak self] session in self?.select(session) },
onFocus: { [weak self] session in self?.focus(session) },
onCollapse: { [weak self] in self?.collapse() },
onLaunch: { [weak self] project, mode in self?.onLaunch?(project, mode) }
)
)
}
/// 开发期把形态钉死,方便在没法可靠控制鼠标的环境里截图核对布局。
///
/// `HUB_ISLAND_STATE=hover|expanded`。生产不会设这个变量。
private var pinnedState: IslandState? {
switch ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] {
case "hover": return .hover
case "expanded", "projects": return .expanded
case "rest": return .rest
default: return nil
}
}
private func applyPinnedStateIfNeeded() {
guard let pinnedState else { return }
// `projects` 顺便把展开态切到项目栏 —— 那一栏也需要能在没有鼠标的
// 环境里截图核对。
if ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] == "projects" {
model.tab = .projects
}
model.transition(to: pinnedState)
updateHitPath()
HubLog.island.notice(
"形态被环境变量钉住:\(String(describing: pinnedState), privacy: .public)"
)
}
/// 所有形态迁移的唯一入口 —— 迁移之后必须同步 AppKit 侧的命中区。
///
/// 钉住时**忽略一切迁移请求**。以前只是在启动时设一次,
/// 紧接着悬停监听发现指针不在岛上就把它收了回去 —— 这个调试开关等于是坏的,
/// 而它恰恰是"没法用鼠标验证时"唯一的手段。
private func setState(_ new: IslandState) {
guard pinnedState == nil else { return }
guard new != model.state else { return }
model.transition(to: new)
updateHitPath()
}
/// 命中路径 = 岛当前形状本身(折叠态额外外扩一点作为悬停热区)。
///
/// 用形状而不是包围盒做命中测试很关键:折叠态上半段严格只有刘海那么宽,
/// 用包围盒的话两侧多出的 8pt 会压在菜单栏上,把输入法、电量图标的点击吃掉。
///
/// **变大立即生效,变小等动画放完。** 展开动画有 0.42s,如果命中区在动画
/// 开始时还是折叠态的大小,用户点不到正在长出来的部分;反过来收起时
/// 如果命中区立刻缩小,退场动画的后半段就变成了不可点的"幽灵"。
private func updateHitPath() {
guard let hostingView, let geometry else { return }
hitPathShrinkWork?.cancel()
hitPathShrinkWork = nil
let target = hitPath(for: model.state, geometry: geometry)
let currentArea = hostingView.hitPath.map(\.boundingBox).map { $0.width * $0.height } ?? 0
let targetArea = target.boundingBox.width * target.boundingBox.height
if targetArea >= currentArea {
hostingView.hitPath = target
} else {
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in
guard let self, let hostingView = self.hostingView else { return }
hostingView.hitPath = target
self.hitPathShrinkWork = nil
}
}
hitPathShrinkWork = work
// 0.46s 是 IslandAnimation 里最长的一条转场(rest → expanded)。
DispatchQueue.main.asyncAfter(deadline: .now() + 0.46, execute: work)
}
}
private func hitPath(for state: IslandState, geometry geo: NotchGeometry) -> CGPath {
let metrics = IslandMetrics.metrics(
for: state, geometry: geo, sessionCount: store.sessions.count
)
// 折叠态把可交互高度**只往下**多给 3pt,让那条 14pt 的细缝好点一些。
//
// 以前是向四周各扩 6pt(`bodyWidth + growth * 2`),加上 tracking area
// 用包围盒近似,等效热区宽到覆盖刘海两侧的菜单栏 —— 鼠标路过就触发。
// 横向一点都不能扩:那个方向紧挨着的就是菜单栏图标。
let growth: CGFloat = state == .rest ? 3 : 0
let shape = IslandShape(
notchWidth: geo.notchWidth,
notchHeight: geo.notchHeight,
bodyWidth: metrics.bodyWidth,
bodyHeight: metrics.bodyHeight + growth,
bottomRadius: metrics.bottomRadius
)
let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize)
return shape.path(in: rect).cgPath
}
// MARK: - 悬停
/// 来自宿主视图 `NSTrackingArea` 的指针位置(左上原点视图坐标;nil 表示离开)。
///
/// 这是悬停的**主链路**。`.activeAlways` 让它在 app 未激活时照样投递,
/// 而全局 `NSEvent` monitor 只是兜底 —— 两条链路都汇到这里。
private func handlePointer(at point: CGPoint?) {
guard model.state == .rest || model.state == .hover else { return }
// **形状二次判定。** `NSTrackingArea` 只能是矩形,用的是岛形状的包围盒 ——
// 折叠态那是 217×52pt 的一整块,横跨刘海两侧的菜单栏。只判"进没进矩形"
// 就等于把菜单栏整条都当成了热区,鼠标去点右上角图标必然误触发。
let inside = point.map { hostingView?.hitPath?.contains($0) ?? false } ?? false
guard inside else {
cancelHoverEnter()
scheduleHoverExit()
return
}
hoverExitWorkItem?.cancel()
hoverExitWorkItem = nil
if model.state == .rest { scheduleHoverEnter() }
}
/// 指针停留够久才展开。已经排队的不重复排。
private func scheduleHoverEnter() {
guard hoverEnterWorkItem == nil else { return }
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in
guard let self, self.model.state == .rest else { return }
self.hoverEnterWorkItem = nil
// 再确认一次指针还在岛上 —— 这 250ms 里它完全可能已经走了。
guard self.pointerIsOverIsland() else { return }
self.setState(.hover)
}
}
hoverEnterWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + Self.hoverEnterDelay, execute: work)
}
private func cancelHoverEnter() {
hoverEnterWorkItem?.cancel()
hoverEnterWorkItem = nil
}
/// 当前鼠标位置是否落在岛的形状里。用屏幕坐标现算,不依赖事件。
private func pointerIsOverIsland() -> Bool {
guard let panel, let hitPath = hostingView?.hitPath else { return false }
let screenPoint = NSEvent.mouseLocation
let frame = panel.frame
let local = CGPoint(
x: screenPoint.x - frame.minX,
y: frame.maxY - screenPoint.y // Cocoa 向上 → 视图向下
)
return hitPath.contains(local)
}
private func installMouseMonitor() {
removeMouseMonitor()
// 兜底链路。全局 mouseMoved 监听不需要辅助功能权限(那是键盘事件才要的),
// 但它拿不到发给本 app 自己的事件,所以单靠它会有盲区 —— 主链路是 tracking area。
mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved]) {
[weak self] _ in
Task { @MainActor in self?.handleGlobalMouseMoved() }
}
}
private func removeMouseMonitor() {
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
mouseMonitor = nil
}
private func handleGlobalMouseMoved() {
guard let geometry else { return }
// 展开、闯入、审批时不受悬停控制 —— 那几个态有各自的退出条件,
// 尤其审批绝不能因为鼠标移开就消失。
guard model.state == .rest || model.state == .hover else { return }
let mouse = NSEvent.mouseLocation
// 粗筛:只有指针在屏幕顶部一小条带里才继续算,其余直接返回。
// 这只是省算力,真正的判据是下面的形状测试。
let bandHeight = geometry.notchHeight + 48
guard mouse.y >= geometry.topY - bandHeight, mouse.y <= geometry.topY else {
cancelHoverEnter()
scheduleHoverExit()
return
}
// 和 tracking area 那条链路走同一套判据(形状 + 停留延迟),
// 两条链路用不同的热区定义只会让手感变得不可预测。
if pointerIsOverIsland() {
hoverExitWorkItem?.cancel()
hoverExitWorkItem = nil
if model.state == .rest { scheduleHoverEnter() }
} else {
cancelHoverEnter()
scheduleHoverExit()
}
}
/// 离开热区不立刻收回:指针从岛移向岛下方内容时会短暂离开,
/// 立刻收会闪。220ms 的延迟正好盖住这种穿越。
private func scheduleHoverExit() {
guard model.state == .hover, hoverExitWorkItem == nil else { return }
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in
guard let self, self.model.state == .hover else { return }
self.model.hoveredSessionId = nil
self.setState(.rest)
self.hoverExitWorkItem = nil
}
}
hoverExitWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work)
}
// MARK: - 展开
private func toggleExpand() {
// 审批态点击不该收起面板 —— 那等于绕过了决策。
guard model.state != .approval else { return }
if model.state == .expanded {
collapse()
} else {
// 没有明确选中时默认聚焦最需要处理的那个会话。
if model.selectedSessionId == nil {
model.selectedSessionId = (store.mostUrgent ?? store.sessions.first)?.sessionId
}
setState(.expanded)
installOutsideClickMonitor()
}
}
/// 点击 hover 态的某个小人 → 展开并显示它的详情。
private func focus(_ session: AgentSession) {
guard model.state != .approval else { return }
model.selectedSessionId = session.sessionId
setState(.expanded)
installOutsideClickMonitor()
}
private func collapse() {
guard model.state != .approval else { return }
setState(.rest)
removeOutsideClickMonitor()
}
public func expand() {
guard model.state != .approval else { return }
if model.selectedSessionId == nil {
model.selectedSessionId = (store.mostUrgent ?? store.sessions.first)?.sessionId
}
setState(.expanded)
installOutsideClickMonitor()
}
private func installOutsideClickMonitor() {
removeOutsideClickMonitor()
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] event in
let location = event.locationInWindow // 全局监听里这已经是屏幕坐标
Task { @MainActor in
guard let self, self.model.state == .expanded else { return }
// **必须排除落在岛自己身上的点击。**
//
// 全局 monitor 的契约是"发给其它 app 的事件",但本 app 是 .accessory
// 且从不激活,点岛的事件在系统看来并不属于某个活跃 app,于是这里照样
// 会收到 —— 实测结果就是"点列表任何一行,岛立刻收起",展开态等于不可用。
guard !self.islandContains(screenPoint: location) else { return }
self.collapse()
}
}
}
/// 屏幕坐标的点是否落在岛当前的形状里。
///
/// 复用宿主视图那份 `hitPath`(左上原点),保证"能点到"和"算作岛内"
/// 用的是同一套几何 —— 两处各算一遍迟早会漂。
private func islandContains(screenPoint: CGPoint) -> Bool {
guard let panel, let hitPath = hostingView?.hitPath else { return false }
let frame = panel.frame
let local = CGPoint(
x: screenPoint.x - frame.minX,
y: frame.maxY - screenPoint.y // Cocoa 向上 → 视图向下
)
return hitPath.contains(local)
}
private func removeOutsideClickMonitor() {
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
outsideClickMonitor = nil
}
// MARK: - 闯入
/// 事件闯入:短暂膨胀提示,几秒后回落到原来的形态。
public func presentIntrusion(_ event: IntrusionEvent) {
// 审批优先级最高,不能被普通事件挤掉。
guard model.state != .approval else { return }
intrusionDismissTask?.cancel()
if model.state != .intrusion { stateBeforeIntrusion = model.state }
model.intrusion = event
setState(.intrusion)
// 需要用户行动的停久一点;纯"干完了"的通知快速划过就行。
let duration: Duration = event.kind == .needsInput ? .seconds(2.6) : .seconds(2.0)
intrusionDismissTask = Task { [weak self] in
try? await Task.sleep(for: duration)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self, self.model.state == .intrusion else { return }
self.model.intrusion = nil
self.setState(self.stateBeforeIntrusion == .expanded ? .expanded : .rest)
}
}
}
// MARK: - 审批
/// 弹出审批面板。**不会自动回落**,必须等用户决策或超时。
public func presentApproval() {
intrusionDismissTask?.cancel()
model.intrusion = nil
removeOutsideClickMonitor()
setState(.approval)
// 审批要接键盘(⌘⏎ 允许 / ⏎ ⎋ 拒绝),先试着让面板成为 key window。
// .nonactivatingPanel 保证这不会把整个 app 激活到前台。
panel?.makeKeyAndOrderFront(nil)
installKeyMonitor()
}
/// 队列空了就收回去。
public func dismissApprovalIfDone() {
guard model.state == .approval, approvals.current == nil else { return }
removeKeyMonitor()
panel?.resignKey()
setState(.rest)
}
/// 审批态的键盘兜底。
///
/// app 是 `.accessory` 且从不激活,`makeKeyAndOrderFront` 未必真能让
/// 这个 nonactivating panel 拿到 key —— 拿不到的话 SwiftUI 的
/// `.keyboardShortcut` 就是死的。这里补一条**局部**监听
/// (`addLocalMonitorForEvents` 不需要辅助功能权限)。
///
/// 键位语义和面板上写的一致,且**绝不让 ⏎ 触发允许**:
/// ⏎ / ⎋ 拒绝,⌘⏎ 允许。
private func installKeyMonitor() {
removeKeyMonitor()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) {
[weak self] event in
guard let self, self.model.state == .approval,
let request = self.approvals.current
else { return event }
let isReturn = event.keyCode == 36 || event.keyCode == 76 // Return / 小键盘 Enter
let isEscape = event.keyCode == 53
if isReturn, event.modifierFlags.contains(.command) {
// L3 要求长按,键盘不提供快捷放行。
guard !request.requiresHoldToConfirm else { return nil }
self.approvals.allow(request)
return nil
}
if isReturn || isEscape {
self.approvals.deny(request)
return nil
}
return event
}
}
private func removeKeyMonitor() {
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
keyMonitor = nil
}
// MARK: - 动作
/// 跳转。**只有真的跳到了才收起岛。**
///
/// 无脑收起的话,跳转失败(会话不在任何终端里、iTerm 没开、绑定丢了)时
/// 岛会连同失败原因一起消失,用户只知道"点了没反应"。留在展开态、
/// 让详情卡把结果说清楚,才有下一步可走。
private func select(_ session: AgentSession) {
model.selectedSessionId = session.sessionId
store.jump(to: session.sessionId) { [weak self] outcome in
guard let self else { return }
if case .focused = outcome { self.collapse() }
}
}
/// 通知点击后的跳转入口。
public func jumpAndCollapse(sessionId: String) {
store.jump(to: sessionId)
intrusionDismissTask?.cancel()
model.intrusion = nil
model.selectedSessionId = sessionId
if model.state != .approval { setState(.rest) }
}
}
+77
View File
@@ -0,0 +1,77 @@
import AppKit
import SwiftUI
/// 灵动岛的可观察状态源。
///
/// 存在的理由:以前形态切换是靠 `hostingView.rootView = AnyView(...)` 整棵树重建的。
/// 那种做法有两个致命后果,换成 `@Observable` 之后一起消失:
///
/// 1. **动画不可能发生。** 替换 rootView 是一次性的树替换,SwiftUI 看到的是
/// "两棵不相干的树",`withAnimation` 无从下手,`.glassEffectID` 想做的
/// 「同一滴玻璃在形变」也失去了前提。写好的 `IslandAnimation` 因此一次都没被调用过。
/// 2. **所有 `@State` 归零。** 审批面板的 700ms 时间锁、长按进度、
/// `TimelineView` 的时钟,每次形态一变就被重置。
/// 展开态的两栏。
public enum IslandTab: String, CaseIterable, Sendable, Identifiable {
/// 正在跑的会话。
case sessions = "会话"
/// 全部项目 —— 包括**没在跑**的,可以直接从这里启动。
case projects = "项目"
public var id: String { rawValue }
}
@Observable
@MainActor
public final class IslandModel {
public private(set) var state: IslandState = .rest
/// 上一个形态。转场动画要靠 (from, to) 这一对来选参数。
public private(set) var previousState: IslandState = .rest
public var intrusion: IntrusionEvent?
/// 悬停中的小人。hover 态下方那行字实时显示它的名字和状态。
public var hoveredSessionId: String?
/// 被点开的会话。展开态顶部的详情卡显示它。
public var selectedSessionId: String?
/// 展开态看哪一栏。
///
/// 加"项目"栏的理由:岛原来只能看**正在跑**的会话,想开一个新项目
/// 或者切到一个没在跑的项目,必须先从菜单栏打开主窗口 ——
/// 而"开发 xxx"恰恰是这个工具最高频的动作,不该要求绕一圈。
public var tab: IslandTab = .sessions
public init() {}
/// 系统的"减弱动态效果"。
///
/// 这里读 `NSWorkspace` 而不是 SwiftUI 的 `@Environment`:
/// 转场动画是在**视图之外**(模型的 setter 里)选的,那个位置拿不到 environment。
private var reduceMotion: Bool {
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
/// 形态迁移。**唯一**允许改 `state` 的入口。
public func transition(to new: IslandState) {
guard new != state else { return }
let animation = IslandAnimation.transition(
from: state, to: new, reduceMotion: reduceMotion
)
previousState = state
withAnimation(animation) { state = new }
}
/// 展开并聚焦某个会话 —— 「点击小人看它的具体信息」这条路径。
public func expand(focusing sessionId: String?) {
if let sessionId { selectedSessionId = sessionId }
transition(to: .expanded)
}
/// 内容层的淡入淡出动画。形状先到位,内容晚 80ms 浮现。
public var contentAnimation: Animation {
IslandAnimation.contentIn(reduceMotion: reduceMotion)
}
}
+164
View File
@@ -0,0 +1,164 @@
import AppKit
import SwiftUI
/// 承载灵动岛的窗口。
///
/// 几个设置都不是可选项:
/// - `.borderless` + `.nonactivatingPanel`:点岛不该把 Claude Hub 变成前台 app,
/// 否则用户点一下就丢了当前 app 的焦点。
/// - `level = .statusBar`(25):压过 `.mainMenu`(24),这样岛能盖在菜单栏之上。
/// - `.fullScreenAuxiliary`:别的 app 全屏时岛依然可见 —— 你在全屏 IDE 里写代码时
/// 恰恰最需要看到 agent 状态。
/// - `hasShadow = false`:系统阴影会绕着整个 640×720 的透明矩形画,必须自己画。
public final class IslandPanel: NSPanel {
public init(contentView: NSView) {
super.init(
contentRect: CGRect(origin: .zero, size: IslandMetrics.panelSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
isOpaque = false
backgroundColor = .clear
hasShadow = false
isMovable = false
isMovableByWindowBackground = false
hidesOnDeactivate = false
isFloatingPanel = true
animationBehavior = .none
// 没有这行,窗口根本收不到 mouseMoved —— NSTrackingArea 的 .mouseMoved
// 选项也就成了摆设。默认是 false。
acceptsMouseMovedEvents = true
// 岛不参与窗口循环,也不该出现在 Mission Control 的窗口列表里。
collectionBehavior = [
.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle,
]
level = .statusBar
self.contentView = contentView
}
/// 审批态要接 ⌘⏎ / ⎋,所以必须能成为 key window。
/// 但 `.nonactivatingPanel` 保证成为 key 不会把整个 app 激活到前台。
public override var canBecomeKey: Bool { true }
public override var canBecomeMain: Bool { false }
}
/// 做逐像素点击穿透的宿主视图。
///
/// 窗口是 640×720 的大透明块。不处理的话,它会吞掉整个屏幕顶部中间区域的所有点击 ——
/// 用户点菜单栏、点下面的窗口全都失灵。
///
/// 这里不用矩形近似,直接**拿岛当前的形状路径做命中测试**:
/// 折叠态上半段严格只有刘海那么宽,两侧的菜单栏图标依然可点;
/// 形状变了命中区自动跟着变,不用维护两套几何。
public final class IslandHostingView<Content: View>: NSHostingView<Content> {
/// 当前可交互区域(左上原点的 SwiftUI 坐标系)。由控制器随形态更新。
/// 为 nil 时整个窗口穿透。
public var hitPath: CGPath? {
didSet { rebuildTrackingArea() }
}
/// 指针进出岛的回调。悬停探测的**主链路**。
///
/// 参数是指针在视图坐标系(左上原点)里的位置;离开时为 nil。
public var onPointerMove: ((CGPoint?) -> Void)?
private var trackingArea: NSTrackingArea?
// MARK: - 点击派发
/// **整个「岛点不动」问题的解药。**
///
/// AppKit 的规则:点击一个**非 key 窗口**时,如果目标视图的
/// `acceptsFirstMouse` 返回 false,这次点击只用来切换 key 状态,
/// **不会派发给视图**。`NSHostingView` 默认就是返回 false。
///
/// 而这个 app 是 `.accessory`(永远不是活跃 app)、panel 是
/// `.nonactivatingPanel`(点它不激活也基本不成为 key),
/// 于是**每一次点击都算 "first mouse",全部被丢弃** ——
/// 展开、列表行、审批按钮,一个都点不动。
///
/// 返回 true 让点击直接进 SwiftUI,同时保持「不抢焦点」这个前提不变。
public override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
public override func hitTest(_ point: NSPoint) -> NSView? {
guard let hitPath else { return nil }
// hitTest 收到的点在 superview 坐标系里。
let local = convert(point, from: superview)
guard hitPath.contains(topLeftOrigin(local)) else { return nil }
return super.hitTest(point)
}
/// 把本视图坐标系里的点换算成 `hitPath` 所在的左上原点坐标系。
///
/// **不能假设 `isFlipped` 恒为 true。** SDK 里 `NSHostingView.isFlipped` 是
/// 一个 `final` 的**可读写**属性,既不能覆写、默认值也不属于契约的一部分。
/// 猜错的话命中区会整体翻到窗口底部 —— 720pt 高的窗口,岛在顶部 46pt,
/// 翻过来就落在屏幕中间的空气里,整个岛完全点不动,且没有任何报错。
private func topLeftOrigin(_ local: CGPoint) -> CGPoint {
isFlipped ? local : CGPoint(x: local.x, y: bounds.height - local.y)
}
/// 事件里的 `locationInWindow` → 左上原点坐标。
private func pointerLocation(of event: NSEvent) -> CGPoint {
topLeftOrigin(convert(event.locationInWindow, from: nil))
}
// MARK: - 悬停
/// 悬停探测的主链路是 `NSTrackingArea`,不是全局 `NSEvent` monitor。
///
/// 关键在 `.activeAlways`:它让 mouseEntered / mouseExited / mouseMoved
/// 在**本 app 未激活**时照样投递 —— 这正是菜单栏类 app 的标准做法。
/// 默认的 `.activeInKeyWindow` 对一个永远不是 key 的 panel 等于什么都不做。
///
/// 控制器里那个全局 monitor 保留着,但降级为兜底:两条链路都指向同一个
/// 状态机,只用一条就是单点失效。
private func rebuildTrackingArea() {
if let trackingArea { removeTrackingArea(trackingArea) }
trackingArea = nil
guard let hitPath else { return }
// 用路径包围盒做 tracking rect(tracking area 只支持矩形)。
// 精确的形状判定仍然在 hitTest 里做,所以这里宽松一点不会造成误点击 ——
// 只是让"接近岛"就能开始探测,本来就是想要的行为。
var rect = hitPath.boundingBox
guard !rect.isEmpty else { return }
if !isFlipped {
rect.origin.y = bounds.height - rect.maxY
}
let area = NSTrackingArea(
rect: rect,
options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways],
owner: self,
userInfo: nil
)
addTrackingArea(area)
trackingArea = area
}
public override func updateTrackingAreas() {
super.updateTrackingAreas()
rebuildTrackingArea()
}
public override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
onPointerMove?(pointerLocation(of: event))
}
public override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
onPointerMove?(pointerLocation(of: event))
}
public override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
onPointerMove?(nil)
}
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
import SwiftUI
/// 岛的外形。
///
/// 两种形态共用一个 `Shape` 实现,靠 `notchHeight == 0` 区分 —— 这样
/// 主屏和外接屏走的是同一套状态机和同一条动画路径,只是几何参数不同。
///
/// ## 主屏(有刘海):下唇形
///
/// 关键约束是**上窄下宽**:`y ∈ [0, notchHeight]` 这一段宽度严格等于刘海宽,
/// 一分不多 —— 多出来就会压住菜单栏两侧的图标。超过刘海高度之后才向两侧扩张,
/// 中间用一个向外凸的反向圆角过渡,做出"从刘海里挤出来"的液体感。
///
/// 上半段被物理刘海完全遮住,画什么都看不见,但**必须画**:Liquid Glass 的
/// 采样和形变需要一个连续的形状,从 y=0 开始才能让岛看起来是长在刘海上而不是贴在下面。
///
/// ## 外接屏(无刘海):独立胶囊
///
/// 不做"半个下唇" —— 没有刘海托着,缺失的上半截会被读成渲染 bug。
/// 改成距屏幕顶 `pillTopInset` 悬浮的独立圆角矩形,是个自洽的设计。
public struct IslandShape: Shape, Sendable {
/// 刘海宽。0 表示这块屏没有刘海,走胶囊形态。
public var notchWidth: CGFloat
/// 刘海高(= safeAreaInsets.top)。
public var notchHeight: CGFloat
/// 岛主体宽度(下半段)。
public var bodyWidth: CGFloat
/// 岛主体高度(刘海以下露出来的部分)。
public var bodyHeight: CGFloat
/// 下沿圆角。
public var bottomRadius: CGFloat
/// 无刘海时的顶部悬浮间距。
public var pillTopInset: CGFloat
/// 反向圆角半径。比刘海物理底角略小,视觉上才像"融进刘海";
/// 比它大会读成两个东西叠在一起。
private let inverseRadius: CGFloat = 6
public init(
notchWidth: CGFloat,
notchHeight: CGFloat,
bodyWidth: CGFloat,
bodyHeight: CGFloat,
bottomRadius: CGFloat,
pillTopInset: CGFloat = 8
) {
self.notchWidth = notchWidth
self.notchHeight = notchHeight
self.bodyWidth = bodyWidth
self.bodyHeight = bodyHeight
self.bottomRadius = bottomRadius
self.pillTopInset = pillTopInset
}
/// 让 SwiftUI 能对形状本身做插值动画。
///
/// 这是形变连续的保底手段:即使 `.glassEffectID` 的形变没有按预期生效,
/// 形状本身也会平滑过渡,不会出现跳变。
public var animatableData: AnimatablePair<
AnimatablePair<CGFloat, CGFloat>,
AnimatablePair<CGFloat, CGFloat>
> {
get {
AnimatablePair(
AnimatablePair(bodyWidth, bodyHeight),
AnimatablePair(bottomRadius, notchWidth)
)
}
set {
bodyWidth = newValue.first.first
bodyHeight = newValue.first.second
bottomRadius = newValue.second.first
notchWidth = newValue.second.second
}
}
public func path(in rect: CGRect) -> Path {
notchHeight > 0 && notchWidth > 0
? notchedPath(in: rect)
: pillPath(in: rect)
}
// MARK: - 有刘海
private func notchedPath(in rect: CGRect) -> Path {
let cx = rect.midX
let halfTop = notchWidth / 2
let halfBody = max(halfTop, bodyWidth / 2)
let yTop = rect.minY
let yShoulder = yTop + notchHeight // 刘海下沿,开始扩张的位置
let yBottom = yShoulder + bodyHeight
// 主体比刘海宽出来的量。它决定反向圆角能画多大。
let overhang = halfBody - halfTop
// 反向圆角不能超过可用的水平/垂直空间,否则路径会自交,渲染出诡异的尖刺。
let ir = max(0, min(inverseRadius, min(overhang, bodyHeight / 2)))
// 下沿圆角同理受限于主体尺寸。
let br = max(0, min(bottomRadius, min(halfBody, bodyHeight)))
var path = Path()
// 左上:从刘海左沿垂直下来(这一段严格贴着刘海宽度,不侵占菜单栏)
path.move(to: CGPoint(x: cx - halfTop, y: yTop))
path.addLine(to: CGPoint(x: cx - halfTop, y: yShoulder - ir))
// 反向圆角:向左外凸,把垂直边过渡到水平的肩线
if ir > 0 {
path.addQuadCurve(
to: CGPoint(x: cx - halfTop - ir, y: yShoulder),
control: CGPoint(x: cx - halfTop, y: yShoulder)
)
}
// 肩线向左延伸到主体左沿
path.addLine(to: CGPoint(x: cx - halfBody, y: yShoulder))
// 左侧竖边
path.addLine(to: CGPoint(x: cx - halfBody, y: yBottom - br))
// 左下圆角
if br > 0 {
path.addQuadCurve(
to: CGPoint(x: cx - halfBody + br, y: yBottom),
control: CGPoint(x: cx - halfBody, y: yBottom)
)
}
// 下沿
path.addLine(to: CGPoint(x: cx + halfBody - br, y: yBottom))
// 右下圆角
if br > 0 {
path.addQuadCurve(
to: CGPoint(x: cx + halfBody, y: yBottom - br),
control: CGPoint(x: cx + halfBody, y: yBottom)
)
}
// 右侧竖边回到肩线
path.addLine(to: CGPoint(x: cx + halfBody, y: yShoulder))
// 肩线向右收回到刘海右沿
path.addLine(to: CGPoint(x: cx + halfTop + ir, y: yShoulder))
// 右侧反向圆角
if ir > 0 {
path.addQuadCurve(
to: CGPoint(x: cx + halfTop, y: yShoulder - ir),
control: CGPoint(x: cx + halfTop, y: yShoulder)
)
}
// 回到起点
path.addLine(to: CGPoint(x: cx + halfTop, y: yTop))
path.closeSubpath()
return path
}
// MARK: - 无刘海
private func pillPath(in rect: CGRect) -> Path {
let width = max(1, bodyWidth)
let height = max(1, bodyHeight)
let box = CGRect(
x: rect.midX - width / 2,
y: rect.minY + pillTopInset,
width: width,
height: height
)
// .continuous 是 macOS 26 的圆角语言;老式的 .circular 会显得生硬。
let radius = min(bottomRadius, min(width, height) / 2)
return Path(roundedRect: box, cornerRadius: radius, style: .continuous)
}
}
+229
View File
@@ -0,0 +1,229 @@
import SwiftUI
/// 岛的形态。
///
/// 先落地三个基础态;`intrusion`(事件闯入)和 `approval`(审批)在后续阶段接入,
/// 它们复用同一套几何与动画机制,只是内容和触发方式不同。
public enum IslandState: Equatable, Sendable {
/// 折叠常驻。贴着刘海只露一条"下唇"。
case rest
/// 悬停摘要。露出像素小人排和一行统计。
case hover
/// 完全展开。会话列表 + 操作。
case expanded
/// 事件闯入。某会话完成或需要输入时主动膨胀提示,几秒后回落。
case intrusion
/// 审批。高风险操作拦截,等用户决策。**不会自动回落。**
case approval
}
/// 某个形态在某块屏幕上的具体尺寸。
///
/// 全部由 `NotchGeometry` 推导,没有任何写死的绝对坐标 —— 换机器、改缩放、
/// 插拔外接屏都不需要动代码。
public struct IslandMetrics: Equatable, Sendable {
public let bodyWidth: CGFloat
public let bodyHeight: CGFloat
public let bottomRadius: CGFloat
/// 窗口固定尺寸。
///
/// 窗口**永远是这么大**,形态切换只改 SwiftUI 内部布局,绝不调 `setFrame`:
/// 窗口 frame 动画走 CoreAnimation 隐式事务,和 SwiftUI 的 spring 不同步,
/// 展开时会看到内容被裁掉一两帧。
public static let panelSize = CGSize(width: 640, height: 720)
/// 折叠态主体比刘海每侧宽出的量。这点溢出正是"液体挤出"的形态语言。
private static let restBleedPerSide: CGFloat = 8
// MARK: - 悬停态几何
//
// 这几个常量同时被 `IslandMetrics`(决定岛多大)和 `HoverContent`(决定画多大)读,
// **必须共用同一份**。以前两边各写各的,结果内容算出来 113.5pt 却塞进 96pt 的容器,
// 外面还套着 clipShape,直接被裁掉 17.5pt —— 这就是用户看到的"信息显示不全"。
/// 悬停态一个小人占的边长(16×16 art × 2pt)。
public static let hoverSpriteSide: CGFloat = 32
/// 小人之间的间距。
public static let hoverSpriteSpacing: CGFloat = 8
/// 悬停态最多摆几个小人。超出的部分在展开态的列表里看。
public static let hoverSpriteLimit = 9
/// "屏"的内边距(必须和 `SpriteScreen` 的 padding 一致)。
public static let hoverScreenPadding = CGSize(width: 8, height: 4)
/// 被指到的小人抬起的高度。Canvas 顶部要预留这么多余量,否则会被裁掉。
public static let hoverLift: CGFloat = 2
/// 小人下方那条状态色横条的高度 + 它与小人之间的间距。
public static let hoverStatusBarBlock: CGFloat = 7
/// 岛边缘到"屏"的留白(左右各一份)。
public static let hoverSideInset: CGFloat = 20
/// 名字行(会话名 13pt + 副行 11pt + 行距)。
public static let hoverCaptionHeight: CGFloat = 32
/// 内容区上下留白。
public static let hoverVerticalInset: CGFloat = 10
/// "屏"与名字行之间的间距。
public static let hoverGap: CGFloat = 8
/// 悬停态摆 n 个小人时,那一排的实际宽度。
public static func hoverRowWidth(sessionCount: Int) -> CGFloat {
let n = max(1, min(sessionCount, hoverSpriteLimit))
return CGFloat(n) * hoverSpriteSide + CGFloat(n - 1) * hoverSpriteSpacing
}
/// 悬停态"屏"的整体尺寸。
public static func hoverScreenSize(sessionCount: Int) -> CGSize {
CGSize(
width: hoverRowWidth(sessionCount: sessionCount) + hoverScreenPadding.width * 2,
height: hoverSpriteSide + hoverLift + hoverStatusBarBlock
+ hoverScreenPadding.height * 2
)
}
/// 悬停态内容的最小高度。岛的 `bodyHeight` 不得小于它。
public static var hoverContentHeight: CGFloat {
hoverVerticalInset * 2
+ hoverScreenSize(sessionCount: 1).height
+ hoverGap
+ hoverCaptionHeight
}
/// 悬停态宽度**跟着会话数走**。
///
/// 写死 380 的问题:只有 3 个会话时中间空一大块,会读成"加载失败";
/// 而 9 个会话时又刚好卡满没有呼吸感。下限保证名字行有地方放。
public static func hoverWidth(sessionCount: Int) -> CGFloat {
let ideal = hoverScreenSize(sessionCount: sessionCount).width + hoverSideInset * 2
return min(max(ideal, 264), 520)
}
public static func metrics(
for state: IslandState,
geometry: NotchGeometry,
sessionCount: Int = 0
) -> IslandMetrics {
let hoverW = hoverWidth(sessionCount: sessionCount)
let hoverH = hoverContentHeight
if geometry.hasNotch {
switch state {
case .rest:
return IslandMetrics(
bodyWidth: geometry.notchWidth + restBleedPerSide * 2,
bodyHeight: 14,
bottomRadius: 14
)
case .hover:
return IslandMetrics(bodyWidth: hoverW, bodyHeight: hoverH, bottomRadius: 26)
case .expanded:
return IslandMetrics(
bodyWidth: 580,
bodyHeight: expandedHeight(sessionCount: sessionCount),
bottomRadius: 28
)
case .intrusion:
return IslandMetrics(bodyWidth: 360, bodyHeight: 62, bottomRadius: 22)
case .approval:
return IslandMetrics(bodyWidth: 470, bodyHeight: 248, bottomRadius: 26)
}
} else {
// 无刘海:折叠态是个独立胶囊,不是"半个下唇"。
switch state {
case .rest:
return IslandMetrics(bodyWidth: 132, bodyHeight: 26, bottomRadius: 13)
case .hover:
return IslandMetrics(bodyWidth: hoverW, bodyHeight: hoverH, bottomRadius: 24)
case .expanded:
return IslandMetrics(
bodyWidth: 580,
bodyHeight: expandedHeight(sessionCount: sessionCount),
bottomRadius: 28
)
case .intrusion:
return IslandMetrics(bodyWidth: 360, bodyHeight: 58, bottomRadius: 22)
case .approval:
return IslandMetrics(bodyWidth: 470, bodyHeight: 244, bottomRadius: 26)
}
}
}
// MARK: - 展开态几何
/// 展开态各段高度。
public static let expandedHeaderHeight: CGFloat = 44
public static let expandedDetailHeight: CGFloat = 146
public static let expandedRowHeight: CGFloat = 52
/// 项目行比会话行矮:它只有名字 + 分支两行,没有小人和时间戳。
/// 而项目通常有二十来个,行矮一点一屏能多看几个。
public static let expandedProjectRowHeight: CGFloat = 40
/// 会话 / 项目 切换栏。
public static let expandedTabHeight: CGFloat = 30
/// 展开态最高能长到多少(列表更长时内部滚动)。
public static let expandedMaxHeight: CGFloat = 512
/// 展开态高度**跟着会话数走**。
///
/// 固定 512 的问题:只有 3、4 个会话时下面挂着一大片空玻璃,
/// 读起来像"加载失败"或"内容被吞了"。列表长了再涨到上限并转为内部滚动。
public static func expandedHeight(sessionCount: Int) -> CGFloat {
let list = CGFloat(max(1, sessionCount)) * expandedRowHeight + 24
let ideal = expandedHeaderHeight + expandedTabHeight
+ expandedDetailHeight + 8 + list
return min(ideal, expandedMaxHeight)
}
/// 审批态内容的最小高度(header + 命令块 + 按钮 + 上下留白)。
public static let approvalContentHeight: CGFloat = 48 + 84 + 44 + 24 + 20
}
/// 形态转场动画。
///
/// 参数不是随手填的:
/// - 进入 hover 几乎不回弹(`extraBounce` 极小),要跟手;
/// - 展开明显有分量感(`bouncy`),跨度越大越慢;
/// - 收回一律 `smooth`,干脆利落不留恋。
public enum IslandAnimation {
public static func transition(
from old: IslandState,
to new: IslandState,
reduceMotion: Bool
) -> Animation {
if reduceMotion {
return .easeInOut(duration: 0.2)
}
switch (old, new) {
case (.rest, .hover):
return .snappy(duration: 0.30, extraBounce: 0.02)
case (.hover, .rest):
return .smooth(duration: 0.24)
case (.hover, .expanded):
return .bouncy(duration: 0.42, extraBounce: 0.12)
case (.rest, .expanded):
return .bouncy(duration: 0.46, extraBounce: 0.14)
case (.expanded, _):
return .smooth(duration: 0.28)
case (_, .intrusion):
// 欠阻尼,回弹是为了吸引注意 —— 这是唯一一个主动打扰用户的形态。
return .spring(response: 0.28, dampingFraction: 0.66)
case (.intrusion, .approval):
return .snappy(duration: 0.34, extraBounce: 0.06)
case (.intrusion, _):
return .easeOut(duration: 0.22)
case (_, .approval):
return .spring(response: 0.32, dampingFraction: 0.72)
case (.approval, _):
return .smooth(duration: 0.30)
default:
return .smooth(duration: 0.26)
}
}
/// 内容淡入永远比形状晚 80 ms —— 形状先到位,内容再"浮现"。
/// 这是 Dynamic Island 手感的核心,去掉它整个动效会显得廉价。
public static func contentIn(reduceMotion: Bool) -> Animation {
reduceMotion ? .easeInOut(duration: 0.15) : .easeOut(duration: 0.16).delay(0.08)
}
/// 退出时反过来:内容先走,形状后收。
public static func contentOut(reduceMotion: Bool) -> Animation {
reduceMotion ? .easeInOut(duration: 0.1) : .easeIn(duration: 0.10)
}
}
+249
View File
@@ -0,0 +1,249 @@
import SwiftUI
import HubCore
/// 岛的视觉常量。
public enum IslandTheme {
// MARK: - 状态色
/// waiting 用琥珀而不是红:红要留给「破坏性/不可逆」(审批的 L3、进程崩溃)。
/// waiting 表达的是"该你了",不是"出事了"。而且琥珀在暗背景上比红更跳 ——
/// 红容易被深色壁纸吞掉。
public static let busy = Color(red: 0.31, green: 0.66, blue: 1.00) // #4FA8FF
public static let waiting = Color(red: 1.00, green: 0.69, blue: 0.13) // #FFB020
public static let shell = Color(red: 0.20, green: 0.84, blue: 0.29) // #32D74B
public static let danger = Color(red: 1.00, green: 0.27, blue: 0.23) // #FF453A
/// idle 用的中性灰。
///
/// 实测教训:系统的 `#8E8E93` 放在压过 scrim 的深色玻璃上几乎完全看不见 ——
/// 那个值是给浅色背景设计的。这里提亮到接近白,靠**透明度**而不是**灰度**
/// 来表达"不活跃",才能在暗底上既可见又克制。
public static let idle = Color(red: 0.85, green: 0.86, blue: 0.88)
public static func color(for status: SessionStatus) -> Color {
switch status {
case .busy: return busy
case .waiting: return waiting
case .shell: return shell
case .idle, .unknown: return idle
}
}
// MARK: - 可读性
/// 岛的固定暗度。**决定岛的颜色,不参与任何背景自适应。**
///
/// 绕不开的算术:任何"能透出背景"的材质,在白底上都必然比在黑底上亮。
/// 实测过 Liquid Glass(`.regular` / `.clear` + 黑 tint)和
/// NSVisualEffectView 的多种 material,白底下输出都落在 0.5~0.7 之间,
/// 差别只是程度 —— 而刘海恒为 0。**要在任何背景下都贴合刘海,只能接近不透明。**
///
/// 最终亮度 ≈ 背景亮度 × (1 − baseOpacity)
/// 白底 (1.0) → 0.14 黑底 (0.0) → 0.0 刘海 → 0.0
///
/// 0.86 是"看得出背后有东西"和"看不出和刘海有色差"的交界:
/// 再低白底下就开始发灰,再高就成了一块死黑的塑料。
public static let baseOpacity: Double = 0.86
/// 玻璃之上、内容之下的遮罩。
///
/// 这是文字可读性的**主力手段**,不是逐字加阴影。Liquid Glass 高度透明,
/// 壁纸一换文字就可能完全读不出来;scrim 随形状一起裁切,成本近乎为零,
/// 效果远好于给每个 Text 挂 shadow(那还会在半透明背景上糊出一圈脏边)。
///
/// 但**压太狠玻璃就没了**:初版这里是 0.42→0.16,配上纯黑的刘海遮蔽区,
/// 整个岛看起来像一块深色塑料而不是玻璃。现在压到刚够读的程度,
/// 剩下的可读性交给 `edgeHighlight` 的边缘高光和文字本身的字重。
public static func scrim(for state: IslandState) -> LinearGradient {
// 配的是 `.clear` 玻璃(不做亮度自适应),所以这一层同时承担两件事:
// 保证文字可读,以及把岛的亮度**钉在刘海附近**。数值偏重是有意的。
// 这一层现在**只管可读性**,不再兼职"把材质压暗" ——
// 岛的颜色由 `baseOpacity` 那层固定基底决定,和背景无关。
//
// 展开和审批要重一些:那两个形态要**读密集文字**,
// 背后窗口透过来的那 14% 亮度会和小字打架(实测岛飘在 Finder 上时
// 文件名的轮廓会浮在会话名下面)。
let (top, bottom): (Double, Double) = switch state {
case .rest: (0.12, 0.06)
case .hover: (0.14, 0.06)
case .expanded: (0.28, 0.20)
case .intrusion: (0.16, 0.08)
case .approval: (0.34, 0.26)
}
return LinearGradient(
colors: [.black.opacity(top), .black.opacity(bottom)],
startPoint: .top,
endPoint: .bottom
)
}
/// 刘海与岛之间的过渡。
///
/// **消除硬边用的。** 物理刘海是纯黑不透光的实体,岛是半透明玻璃;
/// 两者直接相接时会在 `y = notchHeight` 处留下一条黑 / 亮的硬边,
/// 一眼就能看出"岛是贴上去的另一个东西",而不是"从刘海里流出来的"。
///
/// 解法是让纯黑往下渐隐 `blendDepth` 那么长的一段。渐隐区间必须足够长
/// (比刘海本身还高),太短会从"一条硬边"变成"一条软边",问题性质没变。
public static func notchBlend(notchHeight: CGFloat, panelHeight: CGFloat) -> LinearGradient {
let blendDepth: CGFloat = 44
let solidEnd = notchHeight / panelHeight
let fadeEnd = (notchHeight + blendDepth) / panelHeight
return LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black, location: solidEnd),
.init(color: .black.opacity(0), location: fadeEnd),
.init(color: .black.opacity(0), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
/// 玻璃边缘的高光。
///
/// 这是"看起来像玻璃"最关键的一笔:真实玻璃的边缘会因为折射而比中间亮,
/// 下沿尤其明显(光从上方进入、在底面全反射)。没有这条边,再透明的
/// 材质也只会被读成"一块半透明色块"。
///
/// 顶部给 0:那一段被纯黑的刘海遮蔽区盖着,画了也看不见,
/// 反而可能在遮蔽区边缘漏出一道亮线。
public static let edgeHighlight = LinearGradient(
stops: [
.init(color: .white.opacity(0), location: 0),
.init(color: .white.opacity(0.10), location: 0.35),
.init(color: .white.opacity(0.30), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
// MARK: - 字体
/// 正文用 SF Rounded:它和 Liquid Glass 的圆润语言同源,
/// 且圆体在半透明背景上的字重感知比 SF Pro 更实。
public static func label(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .rounded)
}
/// 路径、分支名、命令一律等宽。
public static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .monospaced)
}
}
/// 岛上按钮的样式。
///
/// **不能用系统的 `.glass` / `.glassProminent`。** 岛的 panel 是
/// `.nonactivatingPanel` 且这个 app 是 `.accessory`,绝大多数时候都不是 key window,
/// 而 AppKit 会把非活跃窗口里的系统按钮**整体去饱和** —— 实测「跳转终端」和
/// 审批的「允许/拒绝」都会显示成灰色,看上去像被禁用了。
///
/// 审批面板尤其不能这样:用户看到两个灰按钮的第一反应是"点不了",
/// 而那正是唯一需要他立刻决策的地方。所以外观必须完全由我们自己控制。
public struct IslandButtonStyle: ButtonStyle {
public enum Emphasis { case prominent, secondary }
let emphasis: Emphasis
let tint: Color
var width: CGFloat?
var height: CGFloat = 30
@Environment(\.isEnabled) private var isEnabled
public init(
emphasis: Emphasis = .secondary,
tint: Color = IslandTheme.busy,
width: CGFloat? = nil,
height: CGFloat = 30
) {
self.emphasis = emphasis
self.tint = tint
self.width = width
self.height = height
}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(IslandTheme.label(12, .semibold))
.foregroundStyle(foreground)
.frame(width: width, height: height)
.background(background(pressed: configuration.isPressed))
.contentShape(Rectangle())
.opacity(isEnabled ? 1 : 0.4)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
private var foreground: Color {
switch emphasis {
// 实心按钮上用黑字:状态色都是高亮度的琥珀/蓝/绿,白字在上面对比度不够。
case .prominent: return .black
case .secondary: return .white.opacity(0.92)
}
}
private func background(pressed: Bool) -> some View {
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
return ZStack {
switch emphasis {
case .prominent:
shape.fill(tint.opacity(pressed ? 0.78 : 1))
case .secondary:
shape.fill(.white.opacity(pressed ? 0.20 : 0.12))
shape.stroke(.white.opacity(0.16), lineWidth: 1)
}
}
}
}
/// 状态点。
///
/// **形状是第二编码通道**,不只是颜色:6 pt 尺度下空心 vs 实心的区分度
/// 强过任何颜色差,色觉障碍用户也能分辨。
/// - busy:实心圆角方
/// - waiting:实心圆 + 外环(最显眼)
/// - shell:实心硬方(圆角更小,视觉上更"硬")
/// - idle:空心
public struct StatusDot: View {
public let status: SessionStatus
public let size: CGFloat
public init(status: SessionStatus, size: CGFloat = 6) {
self.status = status
self.size = size
}
public var body: some View {
let color = IslandTheme.color(for: status)
Group {
switch status {
case .waiting:
Circle()
.fill(color)
.frame(width: size * 0.7, height: size * 0.7)
.overlay(
Circle()
.stroke(color.opacity(0.85), lineWidth: 1)
.frame(width: size, height: size)
)
case .busy:
RoundedRectangle(cornerRadius: size * 0.33, style: .continuous)
.fill(color)
.frame(width: size, height: size)
case .shell:
RoundedRectangle(cornerRadius: size * 0.1, style: .continuous)
.fill(color)
.frame(width: size, height: size)
case .idle, .unknown:
// 空心是 idle 的形状编码。描边要够亮才能在 6pt 尺度下被看见,
// 但整体透明度压低,保证它不和活跃状态抢注意力。
RoundedRectangle(cornerRadius: size * 0.33, style: .continuous)
.stroke(color.opacity(0.6), lineWidth: 1.2)
.frame(width: size - 0.6, height: size - 0.6)
}
}
.frame(width: size, height: size)
}
}
+705
View File
@@ -0,0 +1,705 @@
import AppKit
import HubCore
import HubIPC
import HubProjects
import SwiftUI
/// 主窗口的分区。
public enum DashboardSection: String, CaseIterable, Identifiable, Sendable {
case sessions = "会话"
case projects = "项目"
case usage = "用量"
case approvals = "审批日志"
case settings = "设置"
public var id: String { rawValue }
var symbol: String {
switch self {
case .sessions: return "bolt.horizontal.circle"
case .projects: return "folder"
case .usage: return "chart.bar"
case .approvals: return "checkmark.shield"
case .settings: return "gearshape"
}
}
}
/// 主窗口。
///
/// 分工:**岛 = 一瞥**(零点击获取状态);**主窗口 = 深挖**(用量趋势、
/// 项目管理、审批日志)。两者共享同一个 `SessionStore` 和同一套原子组件
/// (`PixelSprite` / `StatusDot` / `GitChip`),这是设计一致性的技术保障 ——
/// 维护两套组件的话它们必然会漂移。
///
/// 视觉上和岛刻意相反:岛强制暗色(因为要贴合恒为黑的刘海),
/// 主窗口完全跟随系统。岛是"设备的一部分",窗口是"一个 app"。
public struct MainWindowView: View {
@Bindable var store: SessionStore
@Bindable var projects: ProjectStore
@Bindable var approvals: ApprovalCoordinator
let onJump: (String) -> Void
let onLaunch: (Project, LaunchMode) -> Void
@State private var section: DashboardSection = .sessions
@State private var search = ""
public init(
store: SessionStore,
projects: ProjectStore,
approvals: ApprovalCoordinator,
onJump: @escaping (String) -> Void,
onLaunch: @escaping (Project, LaunchMode) -> Void
) {
self.store = store
self.projects = projects
self.approvals = approvals
self.onJump = onJump
self.onLaunch = onLaunch
}
public var body: some View {
NavigationSplitView {
List(DashboardSection.allCases, selection: $section) { item in
Label(item.rawValue, systemImage: item.symbol)
.tag(item)
}
.navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 260)
} detail: {
detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// 单一强调色,和岛里 busy 的蓝一致,形成跨形态的一致性。
.tint(IslandTheme.busy)
}
@ViewBuilder
private var detail: some View {
switch section {
case .sessions:
SessionsPane(store: store, projects: projects, onJump: onJump)
case .projects:
ProjectsPane(projects: projects, store: store, onLaunch: onLaunch)
case .usage:
UsagePane()
case .approvals:
ApprovalLogPane(approvals: approvals)
case .settings:
SettingsPane(approvals: approvals, projects: projects)
}
}
}
// MARK: - 通用零件
/// 页面大标题。28pt rounded 是 iOS 26 的大标题语言。
struct PaneHeader: View {
let title: String
let subtitle: String?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 28, weight: .bold, design: .rounded))
if let subtitle {
Text(subtitle)
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
}
}
/// git 状态芯片。
struct GitChip: View {
let info: GitInfo?
var body: some View {
guard let info else { return AnyView(EmptyView()) }
return AnyView(
HStack(spacing: 6) {
if let branch = info.branch {
Label(branch, systemImage: "arrow.triangle.branch")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
if info.changeCount > 0 {
Text("+\(info.changeCount)")
.font(.system(size: 11, weight: .medium)).monospacedDigit()
.foregroundStyle(IslandTheme.waiting)
}
if info.ahead > 0 {
Text("↑\(info.ahead)")
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(IslandTheme.busy)
}
if info.behind > 0 {
Text("↓\(info.behind)")
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(IslandTheme.busy)
}
if !info.hasUpstream {
Text("无远端")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
)
}
}
/// 内容区卡片。**实心,不用玻璃** —— 大面积玻璃在长时间阅读区会疲劳,
/// 而且每张卡片都要采样一次背景,9 张就是 9 次。玻璃留给 sidebar 和 toolbar
/// (那是系统自己给的)。
struct Card<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
.background.secondary,
in: .rect(cornerRadius: 14, style: .continuous)
)
}
}
// MARK: - 会话
struct SessionsPane: View {
let store: SessionStore
let projects: ProjectStore
let onJump: (String) -> Void
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "会话",
subtitle: "\(store.sessions.count) 个运行中 · \(store.waitingCount) 个等待你"
)
ScrollView {
LazyVStack(spacing: 8) {
ForEach(store.sessions) { session in
Card {
HStack(spacing: 12) {
SpriteScreen(cornerRadius: 8) {
PixelSprite(session: session, pixelSize: 2, gridSize: 16)
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
StatusDot(status: session.status, size: 7)
Text(session.name ?? session.sessionId)
.font(.system(size: 14, weight: .semibold,
design: .rounded))
.lineLimit(1)
.truncationMode(.middle)
if session.kind == .bg {
Text("后台")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary, in: .capsule)
}
}
Text(projectLabel(for: session))
.font(.system(size: 12))
.foregroundStyle(.secondary)
if let waitingFor = session.waitingFor {
Text(waitingFor)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(IslandTheme.waiting)
}
}
Spacer(minLength: 8)
GitChip(info: gitInfo(for: session))
Button("跳转") { onJump(session.sessionId) }
.buttonStyle(.borderless)
.font(.system(size: 12, weight: .medium))
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
private func projectLabel(for session: AgentSession) -> String {
projects.projectName(forPath: session.cwd) ?? session.fallbackProjectName
}
private func gitInfo(for session: AgentSession) -> GitInfo? {
guard let name = projects.projectName(forPath: session.cwd),
let project = projects.projects.first(where: { $0.name == name })
else { return nil }
return projects.git(for: project)
}
}
// MARK: - 项目
struct ProjectsPane: View {
let projects: ProjectStore
let store: SessionStore
let onLaunch: (Project, LaunchMode) -> Void
@State private var search = ""
private var filtered: [Project] {
guard !search.isEmpty else { return projects.projects }
let needle = search.lowercased()
return projects.projects.filter {
$0.name.lowercased().contains(needle)
|| $0.path.lowercased().contains(needle)
|| $0.aliases.contains { $0.lowercased().contains(needle) }
|| ($0.description?.lowercased().contains(needle) ?? false)
}
}
/// 这个项目下有没有正在跑的会话。
private func runningCount(_ project: Project) -> Int {
let root = project.expandedPath
return store.sessions.count { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
}
var body: some View {
VStack(spacing: 0) {
PaneHeader(title: "项目", subtitle: "\(projects.projects.count) 个项目")
HStack(spacing: 10) {
TextField("搜索项目…", text: $search)
.textFieldStyle(.roundedBorder)
Button {
projects.scan()
} label: {
Label("扫描", systemImage: "arrow.clockwise")
}
.disabled(projects.isScanning)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
if projects.projects.isEmpty {
emptyState
Spacer()
}
ScrollView {
LazyVStack(spacing: 8) {
ForEach(filtered) { project in
Card {
HStack(spacing: 12) {
Circle()
.fill(runningCount(project) > 0
? IslandTheme.busy : Color.secondary.opacity(0.3))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
Text(project.name)
.font(.system(size: 14, weight: .semibold,
design: .rounded))
if runningCount(project) > 0 {
Text("\(runningCount(project)) 个会话")
.font(.system(size: 10))
.foregroundStyle(IslandTheme.busy)
}
}
if let description = project.description {
Text(description)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Text(project.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.head)
}
Spacer(minLength: 8)
GitChip(info: projects.git(for: project))
Menu {
ForEach(LaunchMode.allCases, id: \.self) { mode in
if mode != .resumeSession {
Button(mode.label) { onLaunch(project, mode) }
}
}
} label: {
Image(systemName: "play.circle")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
/// 一个项目都没有时的空状态。
///
/// **必须说清楚在读哪个文件。** 这次的事故是 app 读的 projects.yaml 和
/// 脚本维护的那份不是同一个,而界面上只有一个孤零零的"0 个项目",
/// 完全没有线索。空状态要给出路径、原因和下一步动作。
private var emptyState: some View {
Card {
VStack(alignment: .leading, spacing: 10) {
Label("没有读到项目", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(IslandTheme.waiting)
if let diagnostic = projects.loadDiagnostic {
Text(diagnostic)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 3) {
Text("数据文件")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
}
HStack(spacing: 8) {
if let url = projects.sourceURL {
Button("在 Finder 中显示") {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
Button("重新加载") { projects.load() }
Button("扫描 ~/Documents/code") { projects.scan() }
.disabled(projects.isScanning)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.horizontal, 20)
}
}
// MARK: - 用量
struct UsagePane: View {
/// 默认 7 天而不是全部。
///
/// `~/.claude/projects` 实测有近 1 GB / 341 个 jsonl,全量首扫要好几十秒。
/// 打开页面就卡住不出数,用户读到的是"这个功能没做"。7 天视图靠 mtime
/// 过滤掉绝大多数文件,首屏两秒内出结果;想看全量再自己切。
@State private var range: UsageRange = .week7
@State private var summary: UsageSummary = .empty
@State private var loading = false
@State private var scanned = 0
@State private var total = 0
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "用量",
subtitle: "\(summary.totalSessions) 个会话 · \(summary.totalMessages) 条消息"
)
Picker("", selection: $range) {
ForEach(UsageRange.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.bottom, 12)
// 全量首扫要读近 1 GB。没有进度的话用户没法区分"在算"和"坏了"。
if loading {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text(total > 0 ? "正在统计 \(scanned)/\(total) 个会话文件…" : "正在统计…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
HStack(spacing: 10) {
statTile("输入", summary.totalInput, IslandTheme.busy)
statTile("输出", summary.totalOutput, IslandTheme.shell)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
ScrollView {
LazyVStack(spacing: 6) {
ForEach(summary.projects) { usage in
Card {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(usage.projectName)
.font(.system(size: 13, weight: .semibold,
design: .rounded))
.lineLimit(1)
Spacer()
Text(format(usage.totalTokens))
.font(.system(size: 12)).monospacedDigit()
.foregroundStyle(.secondary)
}
GeometryReader { proxy in
let maxTokens = summary.projects.first?.totalTokens ?? 1
let ratio = max(
0.01, Double(usage.totalTokens) / Double(max(maxTokens, 1))
)
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(
LinearGradient(
colors: [
IslandTheme.busy.opacity(0.85),
IslandTheme.busy.opacity(0.35),
],
startPoint: .leading, endPoint: .trailing
)
)
.frame(width: proxy.size.width * ratio)
}
.frame(height: 6)
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.task(id: range) { await reload() }
}
private func statTile(_ label: String, _ value: Int, _ color: Color) -> some View {
Card {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.system(size: 11)).foregroundStyle(.secondary)
Text(format(value))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(color)
}
}
}
private func format(_ value: Int) -> String {
if value >= 1_000_000 { return String(format: "%.1fM", Double(value) / 1_000_000) }
if value >= 1_000 { return String(format: "%.1fk", Double(value) / 1_000) }
return "\(value)"
}
private func reload() async {
loading = true
scanned = 0
total = 0
let range = self.range
// 解析要扫近 1 GB 的 jsonl,必须放后台;进度回调也从后台线程来,
// 所以要显式跳回 MainActor 再更新 @State。
let result = await Task.detached(priority: .userInitiated) {
UsageStats.collect(range: range) { done, count in
Task { @MainActor in
scanned = done
total = count
}
}
}.value
summary = result
loading = false
}
}
// MARK: - 审批日志
struct ApprovalLogPane: View {
let approvals: ApprovalCoordinator
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "审批日志",
subtitle: "记录所有被识别为高风险的操作。看一周就知道该不该开启拦截。"
)
if approvals.log.isEmpty {
ContentUnavailableView(
"还没有记录",
systemImage: "checkmark.shield",
description: Text("高风险操作发生时会记在这里")
)
} else {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(approvals.log) { entry in
Card {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(entry.risk == .irreversible ? "不可逆" : "危险")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(
entry.risk == .irreversible
? IslandTheme.danger : IslandTheme.waiting,
in: .capsule
)
Text(entry.projectName)
.font(.system(size: 13, weight: .semibold))
Text(entry.toolName)
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer()
Text(verdictLabel(entry))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(verdictColor(entry))
Text(entry.timestamp, style: .time)
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(.tertiary)
}
Text(entry.command)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
}
private func verdictLabel(_ entry: ApprovalLogEntry) -> String {
switch entry.verdict {
case "allow": return "已允许"
case "deny": return "已拒绝"
case "timeout": return "超时拒绝"
default: return entry.intercepted ? entry.verdict : "未拦截"
}
}
private func verdictColor(_ entry: ApprovalLogEntry) -> Color {
switch entry.verdict {
case "deny", "timeout": return IslandTheme.danger
case "allow": return IslandTheme.shell
default: return .secondary
}
}
}
// MARK: - 设置
struct SettingsPane: View {
@Bindable var approvals: ApprovalCoordinator
let projects: ProjectStore
@State private var hookStatus: String = "检查中…"
var body: some View {
VStack(spacing: 0) {
PaneHeader(title: "设置", subtitle: nil)
ScrollView {
VStack(spacing: 12) {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("审批拦截").font(.system(size: 14, weight: .semibold))
Picker("拦截门槛", selection: $approvals.interceptThreshold) {
Text("只拦不可逆操作(推荐)").tag(RiskLevel.irreversible)
Text("危险操作也拦").tag(RiskLevel.dangerous)
}
.pickerStyle(.radioGroup)
Text("""
你全局用的是 auto 权限模式 + --dangerously-skip-permissions,\
图的就是不被打断。把「危险操作」一档也拦上,按日常用量每天会弹 3–8 次。\
建议先看一周审批日志再决定。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
Text("Hook 连接").font(.system(size: 14, weight: .semibold))
Text(hookStatus)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
Text("socket:\(HubIPCPath.display)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
Text("项目配置").font(.system(size: 14, weight: .semibold))
Text("\(projects.projects.count) 个项目")
.font(.system(size: 12)).foregroundStyle(.secondary)
// 把实际读的文件路径摆出来。踩过的坑:脚本维护的是
// 仓库里那份 projects.yaml,app 读的却是
// Application Support 里另一份旧的,界面上完全没有线索。
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.head)
if let diagnostic = projects.loadDiagnostic {
Text(diagnostic)
.font(.system(size: 11))
.foregroundStyle(IslandTheme.waiting)
}
Button("重新加载 projects.yaml") { projects.load() }
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.onAppear {
hookStatus = "✓ socket 正在监听,hook 事件会实时送达"
}
}
}
/// 给设置页显示 socket 路径用。放这里避免 HubUI 直接暴露 HubIPC 的类型给外部。
enum HubIPCPath {
static var display: String {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/hub.sock")
.path
}
}
+78
View File
@@ -0,0 +1,78 @@
import AppKit
/// 一块屏幕的刘海几何。
///
/// **所有布局都必须写成这里几个属性的函数,绝不能出现字面量。**
/// 用户改显示分辨率缩放时刘海的 pt 尺寸会变;换机器更是完全不同的值。
///
/// 本机实测(16" M4 Max,默认缩放 1728×1117):
/// - 内建屏:notchWidth = 185,notchHeight = 32,菜单栏高 33,中心 X = 864
/// - 外接 LG 4K(1920×1080):无刘海,safeAreaInsets.top = 0
///
/// 注意实测值和常见的"200×37"传闻并不一致 —— 这正是不能硬编码的理由。
public struct NotchGeometry: Equatable, Sendable {
public let screenFrame: CGRect
public let notchWidth: CGFloat
public let notchHeight: CGFloat
/// 刘海的**真实**中心(屏幕坐标)。
///
/// 不能直接用 `screenFrame.midX`:本机实测左侧可用区宽 771、右侧 772,
/// 刘海中心是 863.5 而屏幕中心是 864 —— 差半个点。数值不大,但岛是**贴着**
/// 刘海画的,任何偏移都会在交界处露出一条不对称的边。既然系统把两侧的
/// 真实宽度都给了,就没有理由去假设它对称。
public let notchCenterX: CGFloat
public var hasNotch: Bool { notchWidth > 0 && notchHeight > 0 }
/// 岛的对称轴。有刘海时贴刘海中心,无刘海时用屏幕中心。
public var centerX: CGFloat { hasNotch ? notchCenterX : screenFrame.midX }
/// 屏幕顶边(Cocoa 坐标系,y 向上)。
public var topY: CGFloat { screenFrame.maxY }
public init(
screenFrame: CGRect,
notchWidth: CGFloat,
notchHeight: CGFloat,
notchCenterX: CGFloat? = nil
) {
self.screenFrame = screenFrame
self.notchWidth = notchWidth
self.notchHeight = notchHeight
self.notchCenterX = notchCenterX ?? screenFrame.midX
}
public init(screen: NSScreen) {
self.screenFrame = screen.frame
// auxiliaryTopLeftArea / auxiliaryTopRightArea 是刘海**两侧**的可用菜单栏区域。
// 两者都存在才说明有刘海;无刘海的屏幕这两个属性返回 nil。
if let left = screen.auxiliaryTopLeftArea,
let right = screen.auxiliaryTopRightArea {
let width = max(0, screen.frame.width - left.width - right.width)
self.notchWidth = width
self.notchHeight = screen.safeAreaInsets.top
// 左侧可用区的右边界就是刘海的左沿,加半个刘海宽即中心。
self.notchCenterX = left.maxX + width / 2
} else {
self.notchWidth = 0
self.notchHeight = 0
self.notchCenterX = screen.frame.midX
}
}
/// 当前应该承载岛的屏幕。
///
/// 默认取内建屏(有刘海那块)—— 心智模型是"岛长在刘海上",
/// 它不该因为鼠标移到副屏就跟着跑。找不到带刘海的屏幕就退回主屏。
public static func preferredScreen() -> NSScreen? {
NSScreen.screens.first { $0.auxiliaryTopLeftArea != nil } ?? NSScreen.main
}
/// 鼠标当前所在的屏幕。审批弹窗用它 —— 那种要用户立刻决策的东西
/// 必须出现在用户正在看的地方,而不是固定在内建屏。
public static func screenUnderCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) } ?? NSScreen.main
}
}
@@ -0,0 +1,120 @@
import AppKit
import Foundation
import UserNotifications
/// 系统通知。
///
/// ## 为什么换掉 terminal-notifier
///
/// 旧实现在 bash hook 里调 `terminal-notifier -activate com.googlecode.iterm2`。
/// 两个致命问题:
/// 1. `-activate` **只能把 iTerm 整个 app 拉到前台,无法选中具体 tab** ——
/// 这正是用户抱怨"点了通知跳到 iTerm 但停在原来那个窗口"的直接原因;
/// 2. 外部进程发的通知,我们的 app 拿不到点击回调,想补救也补救不了。
///
/// 换成 app 内的 `UNUserNotificationCenter` 之后,点击会回到本进程,
/// 我们就能拿着 sessionId 去跑精准跳转。
///
/// ## 为什么 userInfo 里放 sessionId 而不是项目名
///
/// 项目名会重(多个 worktree 同名)、tmux 窗口名会被 automatic-rename 改掉。
/// sessionId 是 UUID,全局唯一且在会话生命周期内不变。
@MainActor
public final class HubNotificationCenter: NSObject, UNUserNotificationCenterDelegate {
/// nonisolated:delegate 回调在非主线程读它。常量字符串本来就是并发安全的。
public nonisolated static let sessionIdKey = "hub.sessionId"
/// 点击通知时调用。参数是 sessionId。
public var onActivate: ((String) -> Void)?
private var authorized = false
public override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
public func requestAuthorization() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound]
) { [weak self] granted, error in
Task { @MainActor in
self?.authorized = granted
if let error {
NSLog("[ClaudeHub] 通知授权失败:\(error.localizedDescription)")
} else if !granted {
NSLog("[ClaudeHub] 用户拒绝了通知权限,只有灵动岛会提示")
}
}
}
}
public func post(
title: String,
body: String,
sessionId: String,
sound: Bool = true
) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.userInfo = [Self.sessionIdKey: sessionId]
if sound { content.sound = .default }
// 同一会话的通知用固定 identifier,新的会替换旧的。
// 否则一个跑得快的会话能在通知中心堆几十条,用户直接全部划掉,
// 那真正重要的那条也就跟着没了。
let request = UNNotificationRequest(
identifier: "hub.session.\(sessionId)",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { error in
if let error {
NSLog("[ClaudeHub] 发送通知失败:\(error.localizedDescription)")
}
}
}
// MARK: - UNUserNotificationCenterDelegate
// 这两个必须是 nonisolated:协议要求本身不是 MainActor 隔离的,
// 而它们的参数(UNNotification 等)不是 Sendable,Swift 6 不允许跨隔离域传递。
// 所以在这里就地取出需要的值(String 是 Sendable),再跳回主线程。
/// app 在前台时也要显示横幅。
///
/// Claude Hub 是 `.accessory` 类型,本来就不"在前台",但用户点开主窗口时
/// 它会变成活跃 app,这时通知照样得显示 —— 用户要的是知道别的会话怎么样了。
public nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
/// 点击通知 → 精准跳回对应会话的终端 tab。
///
/// 这就是本次重写要修的那个 bug:旧实现用 terminal-notifier,
/// 外部进程发的通知拿不到点击回调,`-activate` 又只能前置 app 不能选 tab。
public nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let sessionId = response.notification.request.content
.userInfo[Self.sessionIdKey] as? String
// completionHandler 不是 Sendable,不能带进 Task。系统只要求它被调用,
// 不要求等跳转做完 —— 跳转本来就是异步的(要跑 AppleScript)。
completionHandler()
guard let sessionId else { return }
Task { @MainActor in
self.onActivate?(sessionId)
}
}
}
+208
View File
@@ -0,0 +1,208 @@
import HubCore
import SwiftUI
/// 全局像素时钟。
///
/// **所有小人共用一个 `TimelineView`。** 每个小人一个时间源的话,N 个小人
/// 就是 N 个重绘源,开销线性放大,而且没法整体冻结。
///
/// `minimumInterval` 必须给:不给就跟着 display link 走,ProMotion 下是 120fps
/// 重绘 9 个 Canvas —— 真正的耗电来源不是绘制量,是**唤醒频率**。持续的高频
/// 唤醒会让 CPU 无法进入深度 idle,这才是常驻 app 掉电的原因。
struct SpriteClock<Content: View>: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let isActive: Bool
let paused: Bool
@ViewBuilder let content: (Int) -> Content
/// 主时钟 12fps。素材本身按 6fps / 4fps 设计,用 2–3 个 tick 一帧实现,
/// 这样不同状态可以有不同节奏而共享同一个时间源。
private var interval: TimeInterval {
if ProcessInfo.processInfo.isLowPowerModeEnabled { return 1.0 / 6 }
return isActive ? 1.0 / 12 : 1.0 / 2
}
var body: some View {
TimelineView(.animation(minimumInterval: interval, paused: paused || reduceMotion)) { ctx in
content(Int(ctx.date.timeIntervalSinceReferenceDate / interval))
}
}
}
/// 一排像素小人,全部画在**同一个 Canvas** 里。
///
/// 选 Canvas 而不是 SpriteKit 有个硬理由:`SpriteView` 是不透明的 Metal 合成层,
/// 放进 `.glassEffect` 的容器里,玻璃无法采样它下方的内容,会在小人区域出现
/// 一块方形的"玻璃断层"。那是调参解决不了的。
struct SpriteRow: View {
let sessions: [AgentSession]
/// 每个 art 像素占多少 pt。**必须是整数** —— 用 1.5 pt/px 会产生半像素,
/// 边缘发灰,"像素风"立刻变成"低分辨率图片"。
let pixelSize: CGFloat
/// art 网格边长(16 或 8)。
let gridSize: Int
let spacing: CGFloat
let useMicroArt: Bool
/// 这一排是否支持"指到谁谁抬起来"。为 true 时顶部预留 `liftAmount` 的余量,
/// 否则抬升会被 Canvas 的上边界裁掉。
var supportsLift = false
/// 被指到的那个会话。它的小人整体抬起 `liftAmount`,作为悬停反馈。
///
/// 抬升做在 Canvas 内部而不是给每个小人套一个 SwiftUI 视图 ——
/// 后者意味着 N 个 Canvas + N 个 TimelineView,正是这个类型要避免的。
var liftedSessionId: String?
private var liftAmount: CGFloat { supportsLift ? pixelSize : 0 }
private var spriteSide: CGFloat { CGFloat(gridSize) * pixelSize }
/// Canvas 的实际高度(含抬升余量)。
var canvasHeight: CGFloat { spriteSide + liftAmount }
private var totalWidth: CGFloat {
guard !sessions.isEmpty else { return 0 }
return CGFloat(sessions.count) * spriteSide
+ CGFloat(sessions.count - 1) * spacing
}
var body: some View {
SpriteClock(
isActive: sessions.contains { $0.status.isActive },
paused: false
) { tick in
canvas(tick: tick)
}
.frame(width: totalWidth, height: canvasHeight)
}
private func canvas(tick: Int) -> some View {
ZStack {
// 辉光:把同一张画模糊后用 plusLighter 叠一层。
// 连续柔和的光晕在硬边像素和柔和玻璃之间架了一座桥,
// 这是像素风能和 Liquid Glass 共处的第二个手段(第一个是那块"屏")。
painter(tick: tick)
.blur(radius: pixelSize * 1.2)
.opacity(0.42)
.blendMode(.plusLighter)
painter(tick: tick)
}
// 用 compositingGroup 而不是 drawingGroup:后者会建离屏 buffer,
// 有可能打断下方 Liquid Glass 的背景采样链。
.compositingGroup()
}
private func painter(tick: Int) -> some View {
Canvas(opaque: false, rendersAsynchronously: false) { context, _ in
for (index, session) in sessions.enumerated() {
let originX = CGFloat(index) * (spriteSide + spacing)
// 未抬起的小人落在预留余量之下;被指到的那个升到顶。
let originY = session.sessionId == liftedSessionId ? 0 : liftAmount
// 用**稳定的**哈希:Swift 的 `hashValue` 每次进程启动都加盐,
// 那样同一个会话每次重开 app 都会换发色和衣服,"记住橙头发那个"
// 这件事就不成立了。形象必须是会话的属性,不是本次运行的属性。
let seed = SpriteSeed.stable(session.sessionId)
let character = SpriteCharacter(seed: seed, status: session.status)
let frame = useMicroArt
? SpriteLibrary.micro(for: session.status, tick: tick, seed: seed)
: SpriteLibrary.frame(for: session.status, tick: tick, seed: seed)
for run in frame.runs {
let rect = CGRect(
// 取整到整数 pt:任何半像素都会让边缘出现灰边。
x: (originX + CGFloat(run.x) * pixelSize).rounded(),
y: (originY + CGFloat(run.y) * pixelSize).rounded(),
width: CGFloat(run.width) * pixelSize,
height: pixelSize
)
context.fill(
Path(rect),
with: .color(run.ink.color(
character: character, status: session.status
))
)
}
}
}
.frame(width: totalWidth, height: canvasHeight)
}
}
/// 单个小人。
struct PixelSprite: View {
let session: AgentSession
let pixelSize: CGFloat
let gridSize: Int
let useMicroArt: Bool
init(
session: AgentSession,
pixelSize: CGFloat = 2,
gridSize: Int = 16,
useMicroArt: Bool = false
) {
self.session = session
self.pixelSize = pixelSize
self.gridSize = gridSize
self.useMicroArt = useMicroArt
}
var body: some View {
SpriteRow(
sessions: [session],
pixelSize: pixelSize,
gridSize: gridSize,
spacing: 0,
useMicroArt: useMicroArt
)
}
}
/// 罩住小人的"显示屏"。
///
/// 这是调和像素风与 Liquid Glass 的**核心手段**,比辉光、调色板那些加起来都重要。
///
/// 矛盾是真实的:Liquid Glass 是连续、折射、无边界的;像素风是离散、硬边、有网格的。
/// 直接把 8-bit 小人贴在毛玻璃上,观感像"在 Vision Pro 上贴了张 FC 卡带贴纸"。
///
/// 解法不是让两者妥协,而是给它们一个**自洽的物质叙事**:玻璃是外壳/取景框,
/// 像素是被这块玻璃罩住的发光点阵屏。真实世界里这是成立的(老式设备的玻璃面板
/// 下面就是点阵屏),所以大脑接受。
struct SpriteScreen<Content: View>: View {
var cornerRadius: CGFloat = 8
@ViewBuilder let content: Content
var body: some View {
content
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background {
// 比岛本身**亮**一点。
//
// 以前这里是近黑(0.03,0.04,0.06),而小人的描边也是近黑 ——
// 描边完全溶进底板,人物之间、五官之间全糊在一起。
// 现在角色是彩色的、描边是深色的,底板必须落在两者中间,
// 这样描边才有得可读。视觉上它仍然是一块凹进去的点阵屏。
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color(red: 0.13, green: 0.14, blue: 0.18).opacity(0.92))
.overlay {
// 内阴影:上暗下亮,模拟凹陷。
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(
LinearGradient(
colors: [
.black.opacity(0.55),
.white.opacity(0.07),
],
startPoint: .top,
endPoint: .bottom
),
lineWidth: 1
)
.blur(radius: 0.6)
}
}
}
}
@@ -0,0 +1,344 @@
import AppKit
import HubCore
import HubJump
import HubProjects
import SwiftUI
/// 用哪个编辑器打开会话目录。
///
/// 按用户实际会用的顺序探测,**只在 app 真的装了的时候才显示按钮** ——
/// 点了没反应比没有这个按钮更糟。
enum EditorLauncher {
struct Editor {
let name: String
let bundleId: String
func open(_ path: String) {
guard let app = NSWorkspace.shared
.urlForApplication(withBundleIdentifier: bundleId) else { return }
NSWorkspace.shared.open(
[URL(fileURLWithPath: path)],
withApplicationAt: app,
configuration: NSWorkspace.OpenConfiguration()
)
}
}
private static let candidates: [Editor] = [
Editor(name: "Cursor", bundleId: "com.todesktop.230313mzl4w4u92"),
Editor(name: "VS Code", bundleId: "com.microsoft.VSCode"),
Editor(name: "Zed", bundleId: "dev.zed.Zed"),
Editor(name: "Xcode", bundleId: "com.apple.dt.Xcode"),
]
/// 首个已安装的编辑器。查一次就缓存 —— `urlForApplication` 会打 LaunchServices。
static let preferred: Editor? = candidates.first {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0.bundleId) != nil
}
}
/// 展开态顶部的会话详情卡。
///
/// 这是「点击小人显示具体信息以及运作状态」的落点:把一个会话**当下**的全部
/// 可知事实摊开 —— 它是谁、在哪个目录、哪条分支、最近提交了什么、干了多久、
/// 人在哪个终端能找到它。
///
/// 目录和分支不是装饰:**同一个项目经常多开**(几个会话跑在不同 worktree 或
/// 不同分支上),只显示项目名的话它们看起来一模一样,根本分不清点哪个。
///
/// 数据全部来自已经采集好的缓存,卡片本身不发起任何子进程调用,
/// 所以切换选中不会有任何卡顿。
struct SessionDetailCard: View {
let session: AgentSession
var workspace: WorkspaceGit?
var terminalLabel: String?
var jumpOutcome: JumpOutcome?
let onJump: () -> Void
private var accent: Color { IslandTheme.color(for: session.status) }
var body: some View {
HStack(alignment: .top, spacing: 12) {
portrait
VStack(alignment: .leading, spacing: 5) {
titleRow
locationRow
factsRow
Spacer(minLength: 2)
commitLog
}
Spacer(minLength: 4)
actions
}
.padding(12)
.frame(height: IslandMetrics.expandedDetailHeight)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(0.06))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(accent.opacity(session.status == .idle ? 0.18 : 0.34), lineWidth: 1)
}
}
}
// MARK: 大号小人
/// 详情卡用 3pt/px(48pt)而不是把 32pt 的放大 —— `pixelSize` 必须是整数,
/// 非整数会产生半像素,边缘发灰,像素风立刻变成"低分辨率图片"。
private var portrait: some View {
VStack(spacing: 6) {
SpriteScreen(cornerRadius: 10) {
PixelSprite(session: session, pixelSize: 3, gridSize: 16)
}
Text(session.kind == .bg ? "后台" : "交互")
.font(IslandTheme.label(9, .medium))
.foregroundStyle(.white.opacity(0.4))
}
}
// MARK: 标题
private var titleRow: some View {
HStack(spacing: 8) {
Text(session.name ?? session.sessionId)
.font(IslandTheme.label(14, .semibold))
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
statusPill
}
}
private var statusPill: some View {
HStack(spacing: 4) {
StatusDot(status: session.status, size: 6)
Text(statusText)
.font(IslandTheme.label(10, .semibold))
.foregroundStyle(accent)
}
.padding(.horizontal, 6)
.frame(height: 18)
.background(accent.opacity(0.14), in: .capsule)
}
private var statusText: String {
switch session.status {
case .busy: return "工作中"
case .waiting: return session.waitingFor ?? "等你输入"
case .shell: return "跑命令"
case .idle: return "空闲"
case .unknown: return session.rawStatus
}
}
// MARK: 位置:目录 + 分支
/// 当前目录和分支放同一行,因为它们回答的是同一个问题:
/// **这个会话到底在哪个工作区上干活。** 一个项目多开时这是唯一的区分手段。
private var locationRow: some View {
HStack(spacing: 8) {
HStack(spacing: 3) {
Image(systemName: "folder")
.font(.system(size: 8))
Text(displayPath)
.font(IslandTheme.mono(10))
.lineLimit(1)
// 路径截中间:截尾的话最有信息量的那一段(项目名)正好没了。
.truncationMode(.middle)
}
.foregroundStyle(.white.opacity(0.58))
// 路径比分支胶囊更该拿到宽度:分支名通常短且完整,
// 路径被截中间的话"到底是哪个 worktree"就看不出来了。
.layoutPriority(1)
.help(session.cwd)
if let branch = workspace?.info.branch {
HStack(spacing: 3) {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 8, weight: .semibold))
Text(branch)
.font(IslandTheme.mono(10, .medium))
if let changes = workspace?.info.changeCount, changes > 0 {
Text("\(changes)△")
.font(IslandTheme.mono(10, .semibold))
.foregroundStyle(IslandTheme.waiting)
}
if let ahead = workspace?.info.ahead, ahead > 0 {
Text("↑\(ahead)")
.font(IslandTheme.mono(10, .semibold))
.foregroundStyle(IslandTheme.busy)
}
}
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 6)
.frame(height: 17)
.background(.white.opacity(0.08), in: .capsule)
.fixedSize()
}
}
}
/// cwd 相对仓库根的位置。
///
/// 会话开在子目录时,`~/code/foo/packages/api` 里真正有区分度的是尾部;
/// 直接显示全路径会被中间那一大段吃掉宽度。仓库根之外的部分单独标出来。
private var displayPath: String {
let cwd = (session.cwd as NSString).abbreviatingWithTildeInPath
guard let root = workspace?.root, session.cwd != root else { return cwd }
let rootName = (root as NSString).lastPathComponent
let suffix = session.cwd.hasPrefix(root)
? String(session.cwd.dropFirst(root.count))
: ""
return suffix.isEmpty ? cwd : rootName + suffix
}
// MARK: 事实行
private var factsRow: some View {
HStack(spacing: 12) {
fact("运行", RelativeTime.duration(since: session.startedAt))
fact("最后活动", RelativeTime.short(from: session.updatedAt) + "前")
fact("PID", "\(session.pid)")
if let terminalLabel {
VStack(alignment: .leading, spacing: 1) {
Text("终端")
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.38))
Text(terminalLabel)
.font(IslandTheme.label(11, .medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
.truncationMode(.middle)
}
.help(terminalLabel)
}
}
}
private func fact(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.38))
Text(value)
.font(IslandTheme.label(11, .medium))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.82))
}
.fixedSize()
}
// MARK: 最近提交
/// 最近三条提交。
///
/// 这是判断"这个会话到底干出了什么"最直接的证据 —— 状态只说它在忙,
/// 提交历史说它忙出了什么。同项目多开时也是区分谁在推进哪条线的依据。
@ViewBuilder
private var commitLog: some View {
if let commits = workspace?.recent, !commits.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(commits.prefix(2)) { commit in
HStack(spacing: 6) {
Text(commit.hash)
.font(IslandTheme.mono(9, .semibold))
.foregroundStyle(IslandTheme.busy.opacity(0.85))
Text(commit.subject)
.font(IslandTheme.label(10))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
Text(commit.relativeDate)
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.3))
.fixedSize()
}
}
}
}
}
// MARK: 操作
private var actions: some View {
VStack(alignment: .trailing, spacing: 6) {
Button(action: onJump) {
Label("跳转终端", systemImage: "arrow.up.forward.app")
}
.buttonStyle(IslandButtonStyle(
emphasis: .prominent, tint: accent, width: 96, height: 28
))
HStack(spacing: 5) {
iconButton("folder", help: "在 Finder 中显示") {
NSWorkspace.shared.selectFile(
nil, inFileViewerRootedAtPath: session.cwd
)
}
if let editor = EditorLauncher.preferred {
iconButton("chevron.left.forwardslash.chevron.right",
help: "在 \(editor.name) 中打开") {
editor.open(session.cwd)
}
}
// 复制的是**能直接跑的一整条命令**,不是裸的 sessionId。
//
// 原来这里是"复制 sessionId",实际没有用处:sessionId 是 UUID,
// 唯一的消费者就是 `claude --resume`,而没人会把 UUID 抄进命令行。
// 直接给出可粘贴执行的命令,才真的省掉一步。
iconButton("terminal", help: "复制恢复这个会话的命令") {
copy("cd \(session.cwd) && claude --resume \(session.sessionId)")
}
}
jumpFeedback
}
}
/// 跳转结果回显。
///
/// 跳转是异步的(AppleScript 跨进程),不回显的话用户点完只能自己扭头
/// 去看终端有没有动。尤其是 `bg` 会话根本不在任何终端里,必须说清楚。
@ViewBuilder
private var jumpFeedback: some View {
if let jumpOutcome {
switch jumpOutcome {
case .focused:
feedback("已切到该会话", IslandTheme.shell, "checkmark.circle.fill")
case .terminalActivatedOnly:
feedback("只前置了终端", IslandTheme.waiting, "exclamationmark.circle.fill")
case .failed:
feedback("没能定位", IslandTheme.danger, "xmark.circle.fill")
}
}
}
private func feedback(_ text: String, _ color: Color, _ symbol: String) -> some View {
HStack(spacing: 3) {
Image(systemName: symbol).font(.system(size: 8))
Text(text).font(IslandTheme.label(9, .medium))
}
.foregroundStyle(color.opacity(0.9))
}
private func copy(_ value: String) {
let board = NSPasteboard.general
board.clearContents()
board.setString(value, forType: .string)
}
private func iconButton(
_ symbol: String, help: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: 10, weight: .medium))
}
.buttonStyle(IslandButtonStyle(emphasis: .secondary, width: 28, height: 22))
.help(help)
}
}
+263
View File
@@ -0,0 +1,263 @@
import Foundation
import HubCore
import HubJump
import HubProbe
import HubProjects
/// 全部 agent 会话的实时快照。岛和主窗口共享同一个实例。
///
/// 数据来自 `~/.claude/sessions/*.json` —— Claude Code 自己维护的权威状态源。
/// 这里只做读取、排序和跳转派发,不做任何状态推断。
@Observable
@MainActor
public final class SessionStore {
public private(set) var sessions: [AgentSession] = []
public private(set) var lastRefresh: Date = .distantPast
/// sessionId → 人能看懂的终端位置("iTerm · storefront-a"、"tmux · @12")。
///
/// 详情卡要回答"这个会话在哪儿能找到",但解析它要跑 AppleScript + tmux 子进程,
/// 每次渲染都算是不可接受的。所以在后台按需解析、缓存下来,视图只读缓存。
public private(set) var terminalLabels: [String: String] = [:]
/// cwd → 该工作区的 git 快照(分支、变更数、最近提交)。
///
/// 按 **cwd** 而不是按 projects.yaml 里的项目采集:会话可能开在任意目录、
/// 任意 worktree,甚至根本没登记过。同一个项目多开时,分支和仓库根目录
/// 是区分它们的主要依据 —— 只显示项目名会把几个会话糊成一个。
public private(set) var workspaceGit: [String: WorkspaceGit] = [:]
/// 最近一次跳转的结果。跳转是异步的,成功与否必须回显,
/// 否则用户点完只能靠"终端有没有跳过去"自己猜。
public private(set) var lastJump: (sessionId: String, outcome: JumpOutcome)?
private let reader: ClaudeSessionReader
private let jumpEngine: JumpEngine
private var timer: Timer?
private var resolvingTerminals = false
private var lastTerminalResolve: Date = .distantPast
private var resolvingGit = false
private var lastGitResolve: Date = .distantPast
public init(
reader: ClaudeSessionReader = ClaudeSessionReader(),
jumpEngine: JumpEngine = JumpEngine()
) {
self.reader = reader
self.jumpEngine = jumpEngine
}
// MARK: - 派生数据
public var waitingCount: Int { sessions.count { $0.status == .waiting } }
public var busyCount: Int { sessions.count { $0.status == .busy } }
public var shellCount: Int { sessions.count { $0.status == .shell } }
public var idleCount: Int { sessions.count { $0.status == .idle } }
/// 有没有活跃会话。决定像素小人的时钟频率和轮询节奏。
public var hasActiveSessions: Bool { sessions.contains { $0.status.isActive } }
/// 最紧急的那个会话。折叠态的徽章和闯入态都用它。
public var mostUrgent: AgentSession? { sessions.first { $0.status == .waiting } }
// MARK: - 刷新
/// 轮询间隔自适应。
///
/// `sessions/*.json` 的写入频率跟着 Claude 的活动走,全空闲时几分钟才动一次,
/// 这时候还每秒扫一遍纯属浪费 —— 常驻 app 的耗电就是这么攒出来的。
///
/// 后续会换成 `DispatchSource` 监听目录做到近实时;轮询是先跑起来的过渡实现。
private var refreshInterval: TimeInterval {
if waitingCount > 0 { return 1.0 }
if hasActiveSessions { return 2.0 }
return 5.0
}
public func start() {
refresh()
scheduleNext()
}
public func stop() {
timer?.invalidate()
timer = nil
}
private func scheduleNext() {
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: refreshInterval, repeats: false
) { [weak self] _ in
Task { @MainActor in
self?.refresh()
self?.scheduleNext()
}
}
}
public func refresh() {
let fresh = reader.readAll()
let sorted = Self.sort(fresh)
// 只在真的变了时才赋值:@Observable 的写入会触发 SwiftUI 重绘,
// 每秒无脑重绘 9 行列表是没必要的开销。
if sorted != sessions {
sessions = sorted
}
lastRefresh = Date()
}
/// 排序即 UI 优先级。
///
/// 不按项目分组作默认:9 个会话跨 5 个项目,按项目排会让
/// 「哪个在等我」这个最高频的问题需要扫描整个列表。
static func sort(_ input: [AgentSession]) -> [AgentSession] {
input.sorted { lhs, rhs in
// ① 状态优先级:waiting → busy → shell → idle → unknown
if lhs.status != rhs.status { return lhs.status < rhs.status }
// ② waiting 组内:等得最久的排最上(催得最急的先处理)
if lhs.status == .waiting {
let l = lhs.statusUpdatedAt ?? .distantPast
let r = rhs.statusUpdatedAt ?? .distantPast
if l != r { return l < r }
}
// ③ 其余:最近活跃的在上
let l = lhs.updatedAt ?? .distantPast
let r = rhs.updatedAt ?? .distantPast
if l != r { return l > r }
// ④ interactive 优先于 bg
if lhs.kind != rhs.kind { return lhs.kind == .interactive }
// ⑤ 兜底用 sessionId 保证顺序稳定,避免列表无谓抖动
return lhs.sessionId < rhs.sessionId
}
}
// MARK: - 终端定位
/// 解析每个会话在哪个终端里,结果缓存到 `terminalLabels`。
///
/// 展开岛的时候调一次即可。有 12 秒的节流:解析要跑 AppleScript 遍历 iTerm
/// 全部 session 再加一次 `tmux list-panes`,几十到几百毫秒,不该跟着渲染走。
public func resolveTerminalLabels(force: Bool = false) {
guard !resolvingTerminals else { return }
if !force, Date().timeIntervalSince(lastTerminalResolve) < 12 { return }
resolvingTerminals = true
let snapshot = sessions
Task.detached(priority: .utility) { [weak self] in
let labels = Self.computeTerminalLabels(for: snapshot)
await MainActor.run {
guard let self else { return }
self.terminalLabels = labels
self.lastTerminalResolve = Date()
self.resolvingTerminals = false
}
}
}
/// 纯计算部分,跑在后台线程。
///
/// 两条线索:iTerm 的 `jobPid` 祖先链(能定位到具体 tab),
/// 以及 tmux 的 `pane_pid` 祖先链(能给出窗口名)。两者都拿不到的
/// `bg` 会话本来就不在任何终端里 —— 如实说,别编一个位置出来。
nonisolated static func computeTerminalLabels(
for sessions: [AgentSession]
) -> [String: String] {
guard !sessions.isEmpty else { return [:] }
let tree = ProcessTree.snapshot()
var labels: [String: String] = [:]
if ITermLocator.isRunning() {
let terms = ITermLocator().snapshot()
let bound = JumpEngine.bind(
itermSessions: terms, claudeSessions: sessions, tree: tree
)
let nameByUUID = Dictionary(
terms.map { ($0.sessionUUID, $0.name) }, uniquingKeysWith: { a, _ in a }
)
for (sessionId, uuid) in bound {
let name = nameByUUID[uuid] ?? ""
labels[sessionId] = name.isEmpty ? "iTerm" : "iTerm · \(name)"
}
}
let pids = Set(sessions.map(\.pid))
let panes = TmuxProbe().bindPanes(to: pids, tree: tree)
if !panes.isEmpty {
let sessionIdByPid = Dictionary(
sessions.map { ($0.pid, $0.sessionId) }, uniquingKeysWith: { a, _ in a }
)
for (pid, pane) in panes {
guard let sessionId = sessionIdByPid[pid] else { continue }
let tmuxLabel = "tmux · \(pane.sessionName):\(pane.windowName)"
// iTerm 的定位更精确(能点到具体 tab),tmux 信息作为补充拼在后面。
labels[sessionId] = labels[sessionId].map { "\($0) · \(tmuxLabel)" }
?? tmuxLabel
}
}
for session in sessions where labels[session.sessionId] == nil {
labels[session.sessionId] = session.kind == .bg ? "后台任务,不在终端里" : "未定位到终端"
}
return labels
}
// MARK: - 工作区 git
/// 解析这些 cwd 的 git 快照。展开岛时调,内部按 cwd 去重 + 10 秒节流。
public func resolveWorkspaceGit(for paths: [String], force: Bool = false) {
let wanted = Set(paths)
let missing = force ? wanted : wanted.filter { workspaceGit[$0] == nil }
let stale = force || Date().timeIntervalSince(lastGitResolve) > 10
guard !missing.isEmpty || stale else { return }
guard !resolvingGit else { return }
let targets = Array(stale ? wanted : missing)
guard !targets.isEmpty else { return }
resolvingGit = true
Task.detached(priority: .utility) { [weak self] in
var collected: [String: WorkspaceGit] = [:]
for path in targets {
guard let root = GitStatus.repositoryRoot(at: path),
let info = GitStatus.collect(at: path)
else { continue }
collected[path] = WorkspaceGit(
root: root, info: info, recent: GitStatus.log(at: path, limit: 3)
)
}
await MainActor.run { [collected] in
guard let self else { return }
self.workspaceGit.merge(collected) { _, new in new }
self.lastGitResolve = Date()
self.resolvingGit = false
}
}
}
// MARK: - 动作
/// 跳到会话所在的终端 tab。
///
/// AppleScript 有跨进程成本,放后台线程跑,别卡住 UI。结果写回
/// `lastJump` 供 UI 回显 —— 只写日志的话用户看不到任何反馈。
public func jump(to sessionId: String, then: (@MainActor (JumpOutcome) -> Void)? = nil) {
let engine = jumpEngine
Task.detached(priority: .userInitiated) { [weak self] in
let outcome = engine.jump(to: sessionId)
if case .failed(let reason) = outcome {
NSLog("[ClaudeHub] 跳转失败: \(reason)")
}
await MainActor.run {
self?.lastJump = (sessionId, outcome)
then?(outcome)
}
}
}
}
+405
View File
@@ -0,0 +1,405 @@
import HubCore
import SwiftUI
/// 一帧像素画,已按水平游程合并成矩形列表。
///
/// 合并很关键:16×16 的图有 256 个格子,但按行合并连续同色像素之后只剩 40–60 个矩形。
/// 9 个小人 × 50 矩形 × 12fps ≈ 5400 矩形/秒,对 Canvas 是微不足道的量。
struct SpriteFrame {
struct Run {
let x: Int
let y: Int
let width: Int
let ink: SpriteInk
}
let runs: [Run]
/// 从字符网格构造。字符含义见 `SpriteInk`。
init(_ rows: [String]) {
var out: [Run] = []
for (y, row) in rows.enumerated() {
let chars = Array(row)
var x = 0
while x < chars.count {
guard let ink = SpriteInk(character: chars[x]) else {
x += 1
continue
}
var width = 1
while x + width < chars.count, chars[x + width] == chars[x] { width += 1 }
out.append(Run(x: x, y: y, width: width, ink: ink))
x += width
}
}
runs = out
}
}
/// 画笔。
///
/// ## 为什么从"状态染色的单色剪影"改成"有肤色和衣服的角色"
///
/// 旧版每个小人整体用状态色染,四个 busy 会话画出来一模一样 —— 只是四个蓝色块。
/// 更糟的是轮廓用的是近黑(`0.04,0.05,0.06`),而岛本身也是近黑,
/// 轮廓完全溶进背景,剩下的就真的只是一坨颜色。
///
/// 现在:**形象归角色,状态归屏幕**。
/// - 发色 / 肤色 / 衣服由 `sessionId` 决定且**永远不变** ——
/// 你会记住"橙头发那个是 acme-admin",这是单色剪影给不了的辨识度;
/// - 状态由笔记本屏幕的光色表达,配合下方的状态色条和状态点,一样一眼可读。
enum SpriteInk {
case outline // '#'
case hair // 'h'
case hairShade // 'H'
case skin // 'f'
case skinShade // 'F'
case eye // 'e'
case shirt // 's'
case shirtShade // 'S'
case laptop // 'L'
case laptopEdge // 'l'
case glow // 'g'
init?(character: Character) {
switch character {
case "#": self = .outline
case "h": self = .hair
case "H": self = .hairShade
case "f": self = .skin
case "F": self = .skinShade
case "e": self = .eye
case "s": self = .shirt
case "S": self = .shirtShade
case "L": self = .laptop
case "l": self = .laptopEdge
case "g": self = .glow
default: return nil // '.' 和空格都是透明
}
}
func color(character: SpriteCharacter, status: SessionStatus) -> Color {
// idle 的人要"退到后面去",但**不能变成灰色幽灵** —— 那是旧版最难看的地方。
// 保留原本的色相,只压饱和与亮度,读起来像"灯关了"而不是"这个人不见了"。
let dim = character.dim
switch self {
case .outline: return Color(red: 0.05, green: 0.06, blue: 0.08).opacity(0.95)
case .hair: return character.hair.opacity(dim)
case .hairShade: return character.hair.opacity(dim * 0.62)
case .skin: return character.skin.opacity(dim)
case .skinShade: return character.skin.opacity(dim * 0.72)
case .eye: return Color(red: 0.09, green: 0.10, blue: 0.13).opacity(dim)
case .shirt: return character.shirt.opacity(dim)
case .shirtShade: return character.shirt.opacity(dim * 0.66)
// 笔记本压暗一档:它是配角。太亮的话整张图会被一块灰白色块主导,
// 而人脸才是要被认出来的那部分。
case .laptop: return Color(white: 0.66).opacity(dim)
case .laptopEdge: return Color(white: 0.40).opacity(dim)
// 屏幕的光是状态的载体。idle 时它就是暗的 —— 屏幕熄了,正是"没在干活"。
case .glow:
return status == .idle || status == .unknown
? Color(white: 0.34).opacity(dim)
: IslandTheme.color(for: status)
}
}
}
/// 跨进程稳定的哈希。
///
/// **不能用 Swift 的 `String.hashValue`** —— 它每次进程启动都会重新加盐,
/// 同一个 sessionId 在两次运行里得到的值不同。用它做形象派生的话,
/// 每次重开 app 所有人的发色衣服都会换一遍,"记住橙头发那个是 acme-admin"
/// 这件事根本立不住。
///
/// FNV-1a:够散、够快、结果只取决于输入。
enum SpriteSeed {
static func stable(_ value: String) -> Int {
var hash: UInt64 = 0xcbf2_9ce4_8422_2325
for byte in value.utf8 {
hash ^= UInt64(byte)
hash = hash &* 0x1000_0000_01b3
}
// 收到 Int 的正数区间,调用方普遍在做 `abs(seed) % n`。
return Int(hash % UInt64(Int.max))
}
}
/// 一个会话的固定形象。
///
/// 全部由 `sessionId` 派生,所以**同一个会话永远长同一个样**,
/// 换机器、重启 app、`--resume` 都不变(sessionId 是 UUID,不是 PID)。
struct SpriteCharacter {
let hair: Color
let skin: Color
let shirt: Color
let style: Int // 发型
let dim: Double
/// 发色。有意做得饱和度高、彼此拉开 —— 这是区分会话的主要通道。
private static let hairPalette: [Color] = [
Color(red: 0.96, green: 0.62, blue: 0.20), // 橙
Color(red: 0.36, green: 0.24, blue: 0.18), // 深棕
Color(red: 0.95, green: 0.83, blue: 0.42), // 金
Color(red: 0.72, green: 0.25, blue: 0.28), // 酒红
Color(red: 0.20, green: 0.22, blue: 0.28), // 黑
Color(red: 0.55, green: 0.42, blue: 0.72), // 紫
]
private static let skinPalette: [Color] = [
Color(red: 0.98, green: 0.84, blue: 0.72),
Color(red: 0.91, green: 0.73, blue: 0.58),
Color(red: 0.76, green: 0.56, blue: 0.42),
Color(red: 0.55, green: 0.38, blue: 0.28),
]
private static let shirtPalette: [Color] = [
Color(red: 0.29, green: 0.55, blue: 0.86), // 蓝
Color(red: 0.30, green: 0.68, blue: 0.52), // 绿
Color(red: 0.86, green: 0.42, blue: 0.44), // 珊瑚
Color(red: 0.42, green: 0.44, blue: 0.52), // 灰蓝
Color(red: 0.88, green: 0.66, blue: 0.30), // 芥黄
Color(red: 0.52, green: 0.40, blue: 0.66), // 紫
]
static let styleCount = 4
init(seed: Int, status: SessionStatus) {
// 三个属性各用 seed 的不同段,避免"发色一样的人衣服也一样"。
// 用 magnitude 而不是 abs:`abs(Int.min)` 会 trap。
let base = Int(seed.magnitude % UInt(Int.max))
hair = Self.hairPalette[base % Self.hairPalette.count]
skin = Self.skinPalette[(base / 7) % Self.skinPalette.count]
shirt = Self.shirtPalette[(base / 13) % Self.shirtPalette.count]
style = (base / 31) % Self.styleCount
dim = (status == .idle || status == .unknown) ? 0.55 : 1.0
}
}
/// 像素小人素材库。
///
/// art 用字符串字面量写在代码里,不做成 PNG 资产:可以 diff、可以 code review、
/// 改一个像素不用重新导出,还能按角色调色。
///
/// 构图参考了"人在笔记本后面"这个经典画法:头 + 肩 + 挡在身前的笔记本,
/// 双手从两侧露出来。好处是**上半身占满画面**,16×16 这么小的网格里
/// 五官才有地方放;画全身的话头只有 4px,怎么画都是一坨。
enum SpriteLibrary {
/// 取某状态在某个时钟 tick 下应该显示的帧。
///
/// - Parameter seed: 用 sessionId 的 hash。它同时决定**形象**(发色、衣服、发型)
/// 和**动作相位** —— 9 个人整齐划一地同时敲键盘会很诡异。
static func frame(for status: SessionStatus, tick: Int, seed: Int) -> SpriteFrame {
let style = SpriteCharacter(seed: seed, status: status).style
let phase = tick + Int(seed.magnitude % 4)
switch status {
case .busy:
return SpriteFrame(rows(style: style, pose: .typing(step: phase / 2 % 4)))
case .shell:
return SpriteFrame(rows(style: style, pose: .command(step: phase / 2 % 4)))
case .waiting:
// waiting **不错峰**:需要用户行动的状态就该整齐地一起挥手,
// 那是刻意制造的注意力抓取。
return SpriteFrame(rows(style: style, pose: .waving(step: tick / 2 % 4)))
case .idle, .unknown:
return SpriteFrame(rows(style: style, pose: idlePose(tick: tick, seed: seed)))
}
}
/// idle 是**状态机**而不是纯循环,这是省电的关键。
///
/// 9 个会话里通常 6–7 个是 idle。如果它们都按 6fps 循环,前面为了省电做的
/// 一切都白费了。所以 idle 平时是静止的闭眼,每 8–14 秒才打一次哈欠,
/// 而且按 seed 错峰 —— 6 个人同时打哈欠会很出戏。
private static func idlePose(tick: Int, seed: Int) -> Pose {
let clock = 12 // 主时钟 12 tick = 1 秒
// **先取模再运算。** 这里曾经写成 `abs(seed) * 37`,而真实的 seed 来自
// FNV 哈希(量级接近 Int.max),一乘就整数溢出,Swift 直接 trap ——
// 表现是 app 一显示小人就闪退。任何拿哈希值做算术的地方都要先收进小区间。
let magnitude = seed.magnitude
let period = (8 + Int(magnitude % 7)) * clock
let phase = (tick + Int(magnitude % UInt(period))) % period
let yawnLength = 2 * clock
if phase < yawnLength {
return .yawning(step: (phase * 3) / yawnLength)
}
return .resting
}
// MARK: - 姿势
enum Pose {
case typing(step: Int)
case command(step: Int)
case waving(step: Int)
case resting
case yawning(step: Int)
}
// MARK: - 组装
/// 把发型、脸、身体、笔记本拼成 16×16。
///
/// 分段拼装而不是给每个状态各写 4 张完整的图:那样是 16 张手写网格,
/// 改一处(比如笔记本宽一格)要同步改 16 遍,必然漂。
static func rows(style: Int, pose: Pose) -> [String] {
var out: [String] = []
out.append(contentsOf: hairRows(style: style))
out.append(contentsOf: faceRows(pose: pose))
out.append(contentsOf: bodyRows(pose: pose))
out.append(contentsOf: laptopRows(pose: pose))
// 拼出来必须正好 16 行 —— 少一行会让笔记本贴到画面外。
return out
}
/// 发型(第 0–3 行,含顶上一行留白)。
///
/// 四种,靠**轮廓形状**区分而不只是颜色 —— 缩到 32pt 时形状比颜色更早可辨,
/// 而且色觉障碍用户也分得出。
/// 最后一行统一是 10 格宽,好和下面的脸对齐。
private static func hairRows(style: Int) -> [String] {
switch style % 4 {
case 0: // 齐刘海
return [
"................",
".....######.....",
"....#hhhhhh#....",
"...#hhhhhhhh#...",
]
case 1: // 侧分
return [
"................",
"....#######.....",
"...#hhhhhhhh#...",
"...#hhhHHhhh#...",
]
case 2: // 蓬松
return [
"................",
"...##h##h##h....",
"...#hhhhhhhh#...",
"...#hHhhhhHh#...",
]
default: // 丸子头
return [
".......##.......",
"......#hh#......",
"....#hhhhhh#....",
"...#hhhhhhhh#...",
]
}
}
/// 脸(第 4–8 行)。
///
/// 给了 5 行 —— 初版只给 4 行、笔记本占 7 行,结果脸小得只剩两个点,
/// 整张图被一块灰色笔记本主导。**这是张人物像,不是张笔记本像。**
/// 眼睛是这套画法里最值钱的两个像素:有眼睛是个人,没眼睛是个色块。
private static func faceRows(pose: Pose) -> [String] {
let closed: Bool
var mouth = "...#ffffffff#..."
switch pose {
case .resting:
closed = true
case .yawning(let step):
closed = true
// 哈欠张嘴:中间挖成阴影。
mouth = step >= 1 ? "...#ffFeeFff#..." : "...#ffFFFFff#..."
default:
closed = false
}
let eyes = closed
? "...#feeeeeef#..." // 闭眼画成一条横线
: "...#feffffef#..."
return [
"...#hffffffh#...", // 两侧留一点鬓角
eyes,
mouth,
"....#ffffff#....",
".....#ffff#.....", // 下巴收窄
]
}
/// 肩膀(第 9–10 行)。waving 时右侧多一只挥动的手。
private static func bodyRows(pose: Pose) -> [String] {
guard case .waving(let step) = pose else {
return [
"..##ssssssss##..",
".#ssssssssssss#.",
]
}
// 手掌左右摆一格,幅度小但在 32pt 下看得出来。
return [
step % 2 == 0 ? "..##ssssssss##f." : "..##ssssssss##.f",
".#ssssssssssss#.",
]
}
/// 笔记本(第 9–15 行)。
///
/// 从背面看,所以是一块盖板 + 中间发光的标志 —— 屏幕的光就是状态。
/// 双手从两侧露出来,敲键盘时上下动一格。
private static func laptopRows(pose: Pose) -> [String] {
// 手的位置用**这一段内部的行号**(0…4),不是整图行号 ——
// 用整图行号的话,上面任何一段增减一行都会让手悄悄跑到别的地方去。
var hands = (left: 2, right: 2)
// 发光区占 4 格宽 × 2 行。初版只有 2×1,那点面积根本传达不了状态。
var glow = "gggg"
switch pose {
case .typing(let step):
// 两只手交替上下,敲键盘的节奏就出来了。
hands = [(1, 2), (2, 1), (2, 2), (1, 1)][step % 4]
case .command(let step):
// 命令行:发光区变成一个左右走的光标,和"敲键盘"最容易区分开。
glow = ["g...", ".g..", "..g.", "...g"][step % 4]
hands = (2, 2)
case .waving, .resting, .yawning:
hands = (2, 2)
}
/// `.` + 左手 + `#` + 盖板 10 格 + `#` + 右手 + `.` = 16 格。
func lid(_ local: Int, _ core: String) -> String {
let left = hands.left == local ? "f" : "."
let right = hands.right == local ? "f" : "."
return ".\(left)#\(core)#\(right)."
}
return [
"..############..",
lid(1, "LLL\(glow)LLL"),
lid(2, "LLL\(glow)LLL"),
"..#LLLLLLLLLL#..",
"..#llllllllll#..",
]
}
// MARK: - 小尺寸
/// 列表行用的 8×8 简化版。
///
/// 不复用 16×16 缩小:那些 1px 的眼睛、发型细节在 8px 网格上会退化成噪点。
/// 这里只保留三件事 —— 头发的颜色块、两只眼睛、发光的笔记本。
/// 这就是 SF Symbols 的光学尺寸思路:小尺寸要的是另一张图,不是同一张缩小。
static func micro(for status: SessionStatus, tick: Int, seed: Int) -> SpriteFrame {
let sleeping = status == .idle || status == .unknown
// 睁眼的会偶尔眨一下;idle 一直闭着。
// 同样先把 seed 收进小区间:`abs(Int.min)` 会 trap,加法也可能溢出。
let blink = !sleeping && (tick / 12 + Int(seed.magnitude % 7)) % 7 == 0
let eyes = (sleeping || blink) ? "#feef#." : "#fefe#."
return SpriteFrame([
"..####..",
".#hhhh#.",
".#Hhhh#.",
".\(eyes)",
"#ssssss#",
"#LLggLL#",
"#LLggLL#",
"#llllll#",
])
}
}
+168
View File
@@ -0,0 +1,168 @@
import Darwin
import Foundation
import HubIPC
// hubctl —— Claude Code hook 的执行体。
//
// 为什么不用 bash:
// - 用 bash 拼 JSON 必须自己处理转义,而 tool_input 里带着任意 shell 命令,
// 这是实打实的注入隐患;
// - 本机 `nc` 没有 `-N`,没法在写完后半关闭连接,读应答会挂住;
// - python3 的可用性依赖用户环境,hook 是全局生效的东西,不能赌。
//
// 用法(由 ~/.claude/settings.json 配置):
// hubctl hook stop
// hubctl hook notification
// hubctl hook pretool
// hubctl doctor 自检:socket 通不通
//
// **fail-open 是最高原则**:任何异常都放行。一个卡死的 hook 比一个漏审的命令
// 危害大得多 —— 前者会让用户的每一次工具调用都卡住 60 秒。
/// preToolUse 等待岛上决策的上限。见 `HookTimeouts` 里的超时阶梯说明 ——
/// 这个值必须**大于**服务端的决策超时,否则超时会变成放行而不是拒绝。
private let approvalTimeout = HookTimeouts.clientRead
/// 从 stdin 读 hook 的 JSON。
private func readStdin() -> [String: Any]? {
let data = FileHandle.standardInput.readDataToEndOfFile()
guard !data.isEmpty else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
private func string(_ dict: [String: Any], _ key: String) -> String? {
(dict[key] as? String).flatMap { $0.isEmpty ? nil : $0 }
}
/// 构造事件。补上 Claude 不提供但我们需要的环境信息。
private func makeEvent(kind: HookEvent.Kind, payload: [String: Any]) -> HookEvent {
let env = ProcessInfo.processInfo.environment
var toolSummary: String?
if let toolName = string(payload, "tool_name"),
let input = payload["tool_input"] as? [String: Any] {
toolSummary = RiskClassifier.summarize(toolName: toolName, input: input)
}
return HookEvent(
kind: kind,
requestId: UUID().uuidString,
sessionId: string(payload, "session_id") ?? "",
cwd: string(payload, "cwd") ?? FileManager.default.currentDirectoryPath,
transcriptPath: string(payload, "transcript_path"),
permissionMode: string(payload, "permission_mode"),
lastAssistantMessage: string(payload, "last_assistant_message"),
stopReason: string(payload, "stop_reason"),
notificationType: string(payload, "notification_type"),
message: string(payload, "message"),
toolName: string(payload, "tool_name"),
toolSummary: toolSummary,
toolUseId: string(payload, "tool_use_id"),
// $TMUX_PANE 是 tmux 注入到每个 pane 环境里的 pane id。
// 旧实现用 `tmux display-message -p '#W'` 且没带 -t,拿的是当前 active
// 窗口而不是自己所在的窗口 —— 用户切个 tab 就归错项目了。读环境变量没这个问题。
tmuxPane: env["TMUX_PANE"],
clientPid: getpid()
)
}
/// PreToolUse 的应答格式(已查证官方文档)。
/// 什么都不输出等于默认放行,所以只在 deny 时才需要写。
private func emitDecision(_ decision: HookDecision) {
guard decision.verdict != .allow else { return }
let output: [String: Any] = [
"hookSpecificOutput": [
"hookEventName": "PreToolUse",
"permissionDecision": decision.verdict.rawValue,
"permissionDecisionReason": decision.reason ?? "被 Claude Hub 拦截",
],
]
if let data = try? JSONSerialization.data(withJSONObject: output),
let text = String(data: data, encoding: .utf8) {
print(text)
}
}
private func runHook(_ name: String) -> Int32 {
let kind: HookEvent.Kind
switch name {
case "stop": kind = .stop
case "notification", "notify": kind = .notification
case "pretool", "pretooluse": kind = .preToolUse
case "sessionend": kind = .sessionEnd
default:
FileHandle.standardError.write(Data("未知的 hook 类型:\(name)\n".utf8))
return 0 // 仍然放行
}
guard let payload = readStdin() else { return 0 }
let event = makeEvent(kind: kind, payload: payload)
let result = HubSocketClient.send(
event,
waitForDecision: kind == .preToolUse,
timeout: approvalTimeout
)
guard kind == .preToolUse else { return 0 }
// 排障开关:HUB_DEBUG=1 时把收到的结果打到 stderr。
// stderr 不会被 Claude 当成 hook 的决策输出,所以开着也不影响行为。
if ProcessInfo.processInfo.environment["HUB_DEBUG"] == "1" {
FileHandle.standardError.write(Data("hubctl 收到:\(result)\n".utf8))
}
switch result {
case .decided(let decision):
emitDecision(decision)
case .unreachable:
// Hub 没运行 —— 这次调用压根没被审查过。放行。
// 不能因为工具没开就让用户的每条命令都失败。
break
case .noResponse:
// 连上了、发出去了,但服务端没在 75 秒内回应。
//
// 关键推断:服务端对非高风险操作是**立即**应答的,能走到这里说明
// 它认为这次操作值得拦截,只是决策环节出了问题。这种情况必须拒绝 ——
// 拒绝可恢复(Claude 会告诉用户并可重试),放行不可逆。
emitDecision(
HookDecision(verdict: .deny, reason: "Claude Hub 未能完成审批,已保守拒绝")
)
case .acknowledged:
break
}
return 0
}
private func runDoctor() -> Int32 {
let path = HubSocket.path
print("socket 路径:\(path)")
print("路径长度:\(path.utf8.count) / \(HubSocket.maxPathLength) 字节")
print("socket 文件存在:\(FileManager.default.fileExists(atPath: path) ? "是" : "否")")
if HubSocketClient.isHubRunning() {
print("连接测试:✓ Claude Hub 正在运行")
return 0
}
print("连接测试:✗ 连不上(Claude Hub 没运行,hook 会全部放行)")
return 1
}
let args = Array(CommandLine.arguments.dropFirst())
switch args.first {
case "hook":
guard args.count >= 2 else {
FileHandle.standardError.write(Data("用法:hubctl hook <stop|notification|pretool>\n".utf8))
exit(0)
}
exit(runHook(args[1]))
case "doctor":
exit(runDoctor())
default:
print("用法:hubctl <hook <类型> | doctor>")
exit(0)
}
@@ -0,0 +1,97 @@
import XCTest
@testable import HubCore
final class AgentSessionTests: XCTestCase {
/// 真实样本,逐字段抄自本机 ~/.claude/sessions/34624.json。
private let realBusySample = """
{"pid":34624,"sessionId":"8ba8f1c1-0039-4295-8193-78fa6056578f",\
"cwd":"/Users/dev/Documents/code/shopify/storefront-a","startedAt":1785140285143,\
"procStart":"Mon Jul 27 08:18:03 2026","version":"2.1.220","peerProtocol":1,\
"kind":"interactive","entrypoint":"cli","name":"storefront-a-c3","nameSource":"derived",\
"status":"busy","updatedAt":1785225171453,"statusUpdatedAt":1785225171453}
"""
func testDecodesRealBusySample() throws {
let session = try XCTUnwrap(AgentSession.decode(from: Data(realBusySample.utf8)))
XCTAssertEqual(session.sessionId, "8ba8f1c1-0039-4295-8193-78fa6056578f")
XCTAssertEqual(session.pid, 34624)
XCTAssertEqual(session.cwd, "/Users/dev/Documents/code/shopify/storefront-a")
XCTAssertEqual(session.name, "storefront-a-c3")
XCTAssertEqual(session.status, .busy)
XCTAssertEqual(session.kind, .interactive)
XCTAssertEqual(session.version, "2.1.220")
XCTAssertNil(session.waitingFor)
XCTAssertEqual(session.fallbackProjectName, "storefront-a")
}
func testDecodesWaitingWithReason() throws {
let json = """
{"pid":34624,"sessionId":"abc","cwd":"/tmp/x","status":"waiting",\
"kind":"interactive","waitingFor":"input needed"}
"""
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
XCTAssertEqual(session.status, .waiting)
XCTAssertEqual(session.waitingFor, "input needed")
}
func testDecodesBackgroundSessionWithJobId() throws {
let json = """
{"pid":23402,"sessionId":"171d69d9","cwd":"/tmp/x","kind":"bg",\
"status":"shell","jobId":"171d69d9"}
"""
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
XCTAssertEqual(session.kind, .bg)
XCTAssertEqual(session.jobId, "171d69d9")
XCTAssertEqual(session.status, .shell)
}
/// Claude Code 将来可能加新的 status 取值。不能因此丢掉整个会话,
/// 也不能把未知状态误判成 idle(那会让它在 UI 上装作没事)。
func testUnknownStatusIsPreservedNotDropped() throws {
let json = #"{"pid":1,"sessionId":"x","cwd":"/tmp","status":"compacting"}"#
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
XCTAssertEqual(session.status, .unknown)
XCTAssertEqual(session.rawStatus, "compacting")
}
/// schema 变动导致缺字段时,只有三个硬要求缺失才该整条丢弃。
func testRejectsRecordsMissingIdentityFields() {
XCTAssertNil(AgentSession.decode(from: Data(#"{"sessionId":"x","cwd":"/tmp"}"#.utf8)))
XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"cwd":"/tmp"}"#.utf8)))
XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"sessionId":"x"}"#.utf8)))
XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"sessionId":"","cwd":"/tmp"}"#.utf8)))
XCTAssertNil(AgentSession.decode(from: Data("not json".utf8)))
}
func testMissingOptionalFieldsStillDecodes() throws {
let json = #"{"pid":1,"sessionId":"x","cwd":"/tmp/proj"}"#
let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8)))
XCTAssertEqual(session.status, .unknown)
XCTAssertEqual(session.kind, .unknown)
XCTAssertNil(session.name)
XCTAssertNil(session.startedAt)
}
/// 排序即 UI 优先级。waiting 必须永远第一,unknown 必须永远最后 ——
/// 后者保证 Claude 新增状态取值时不会意外抢占最显眼的位置。
func testStatusOrderingPutsWaitingFirstAndUnknownLast() {
let shuffled: [SessionStatus] = [.idle, .unknown, .busy, .waiting, .shell]
XCTAssertEqual(shuffled.sorted(), [.waiting, .busy, .shell, .idle, .unknown])
}
func testIsActiveExcludesIdleAndUnknown() {
XCTAssertTrue(SessionStatus.busy.isActive)
XCTAssertTrue(SessionStatus.waiting.isActive)
XCTAssertTrue(SessionStatus.shell.isActive)
XCTAssertFalse(SessionStatus.idle.isActive)
XCTAssertFalse(SessionStatus.unknown.isActive)
}
func testMillisecondTimestampsConvertToDates() throws {
let session = try XCTUnwrap(AgentSession.decode(from: Data(realBusySample.utf8)))
let started = try XCTUnwrap(session.startedAt)
XCTAssertEqual(started.timeIntervalSince1970, 1_785_140_285.143, accuracy: 0.001)
}
}
@@ -0,0 +1,196 @@
import XCTest
@testable import HubIPC
/// 风险分级是唯一一个「判错会真的删掉代码」的地方,所以两个方向都要测:
/// 漏判(危险命令被放行)和误判(日常命令被拦,用户会直接关掉整个功能)。
final class RiskClassifierTests: XCTestCase {
private let classifier = RiskClassifier()
private func level(_ command: String, tool: String = "Bash") -> RiskLevel {
classifier.classify(toolName: tool, summary: command)
}
// MARK: - 不可逆
func testIrreversibleDeletions() {
XCTAssertEqual(level("rm -rf /"), .irreversible)
XCTAssertEqual(level("rm -rf ~"), .irreversible)
XCTAssertEqual(level("sudo rm -rf /"), .irreversible)
XCTAssertEqual(level("rm -rf /*"), .irreversible)
}
func testForcePushToProtectedBranches() {
XCTAssertEqual(level("git push --force origin main"), .irreversible)
XCTAssertEqual(level("git push origin master --force"), .irreversible)
XCTAssertEqual(level("git push -f origin production"), .irreversible)
}
func testDatabaseDestruction() {
XCTAssertEqual(level("psql -c 'DROP DATABASE app'"), .irreversible)
XCTAssertEqual(level("mysql -e \"drop schema prod\""), .irreversible)
}
func testInfrastructureDestruction() {
XCTAssertEqual(level("kubectl delete deployment api"), .irreversible)
XCTAssertEqual(level("terraform destroy"), .irreversible)
XCTAssertEqual(level("mkfs.ext4 /dev/disk2"), .irreversible)
XCTAssertEqual(level("dd if=/dev/zero of=/dev/disk2"), .irreversible)
}
// MARK: - 危险但可恢复
func testDangerousButRecoverable() {
XCTAssertEqual(level("rm -rf node_modules"), .dangerous)
XCTAssertEqual(level("git reset --hard HEAD~3"), .dangerous)
XCTAssertEqual(level("git push --force origin feature/foo"), .dangerous)
XCTAssertEqual(level("chmod -R 777 ./public"), .dangerous)
XCTAssertEqual(level("curl https://example.com/install.sh | sh"), .dangerous)
XCTAssertEqual(level("npm publish"), .dangerous)
XCTAssertEqual(level("docker system prune"), .dangerous)
XCTAssertEqual(level("sudo systemctl restart nginx"), .dangerous)
XCTAssertEqual(level("cat ~/.ssh/id_rsa"), .dangerous)
}
func testDeleteWithoutWhereIsDangerous() {
XCTAssertEqual(level("psql -c 'DELETE FROM users'"), .dangerous)
}
func testDeleteWithWhereIsNormal() {
XCTAssertEqual(level("psql -c 'DELETE FROM users WHERE id = 3'"), .normal)
}
// MARK: - 不能误伤日常操作
/// 这组比上面两组更重要。误判会让用户直接把整个功能关掉,
/// 那时候连不可逆操作也不再受保护了。
func testEverydayCommandsAreNotFlagged() {
let everyday = [
"npm install",
"npm run build",
"git status",
"git commit -m 'fix: 修正边界条件'",
"git push origin feature/my-branch",
"git pull --rebase",
"swift build",
"swift test",
"ls -la",
"cargo build --release",
"docker compose up -d",
"pytest tests/",
"mkdir -p build",
"cp -r src dist",
"rm build/output.txt", // 单文件删除,没有 -rf
"SELECT * FROM users",
"kubectl get pods",
"terraform plan",
]
for command in everyday {
XCTAssertEqual(level(command), .normal, "误判为高风险:\(command)")
}
}
/// 只读工具永远不拦。
///
/// 关键场景:Read 一个讲 `rm -rf` 用法的 README,或 Grep 搜索 "DROP TABLE"。
/// 按内容匹配的话这些都会误触发。
func testReadOnlyToolsAreNeverFlagged() {
for tool in ["Read", "Grep", "Glob", "WebFetch", "WebSearch", "TodoWrite", "Task"] {
XCTAssertEqual(
classifier.classify(toolName: tool, summary: "rm -rf / && DROP DATABASE x"),
.normal,
"只读工具 \(tool) 不该被拦"
)
}
}
func testEmptyAndNilInputsAreNormal() {
XCTAssertEqual(classifier.classify(toolName: nil, summary: "rm -rf /"), .normal)
XCTAssertEqual(classifier.classify(toolName: "Bash", summary: nil), .normal)
XCTAssertEqual(classifier.classify(toolName: "Bash", summary: ""), .normal)
}
// MARK: - 拦截门槛
/// 默认只拦不可逆。用户全局开着 auto + skip-permissions,
/// 把 dangerous 也拦上每天要弹 3–8 次,结果必然是他关掉整个功能。
func testDefaultThresholdOnlyInterceptsIrreversible() {
let defaultClassifier = RiskClassifier()
XCTAssertTrue(defaultClassifier.shouldIntercept(.irreversible))
XCTAssertFalse(defaultClassifier.shouldIntercept(.dangerous))
XCTAssertFalse(defaultClassifier.shouldIntercept(.normal))
}
func testLoweredThresholdInterceptsDangerousToo() {
let strict = RiskClassifier(interceptThreshold: .dangerous)
XCTAssertTrue(strict.shouldIntercept(.irreversible))
XCTAssertTrue(strict.shouldIntercept(.dangerous))
// normal 永远不拦,无论门槛怎么调。
XCTAssertFalse(strict.shouldIntercept(.normal))
}
// MARK: - 参数摘要
func testSummarizePrefersCommand() {
XCTAssertEqual(
RiskClassifier.summarize(
toolName: "Bash",
input: ["command": "rm -rf x", "description": "清理"]
),
"rm -rf x"
)
}
func testSummarizeFallsBackToPath() {
XCTAssertEqual(
RiskClassifier.summarize(toolName: "Write", input: ["file_path": "/tmp/a.txt"]),
"/tmp/a.txt"
)
}
func testSummarizeReturnsNilWhenNothingUseful() {
XCTAssertNil(RiskClassifier.summarize(toolName: "Bash", input: [:]))
XCTAssertNil(RiskClassifier.summarize(toolName: "Bash", input: ["command": ""]))
}
}
final class HookEventCodingTests: XCTestCase {
func testRoundTripPreservesAllFields() throws {
let original = HookEvent(
kind: .preToolUse,
requestId: "req-1",
sessionId: "sess-1",
cwd: "/Users/x/code/proj",
transcriptPath: "/tmp/t.jsonl",
permissionMode: "bypassPermissions",
toolName: "Bash",
toolSummary: "rm -rf /",
toolUseId: "toolu_1",
tmuxPane: "%20",
clientPid: 4242
)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(HookEvent.self, from: data)
XCTAssertEqual(decoded.kind, .preToolUse)
XCTAssertEqual(decoded.sessionId, "sess-1")
XCTAssertEqual(decoded.toolSummary, "rm -rf /")
XCTAssertEqual(decoded.tmuxPane, "%20")
XCTAssertEqual(decoded.clientPid, 4242)
XCTAssertEqual(decoded.fallbackProjectName, "proj")
}
func testDecisionRoundTrip() throws {
let decision = HookDecision(verdict: .deny, reason: "太危险")
let data = try JSONEncoder().encode(decision)
let decoded = try JSONDecoder().decode(HookDecision.self, from: data)
XCTAssertEqual(decoded.verdict, .deny)
XCTAssertEqual(decoded.reason, "太危险")
}
/// socket 路径必须短于 sun_path 的 104 字节上限,否则 bind 会静默失败。
func testSocketPathFitsInSunPath() {
XCTAssertLessThanOrEqual(HubSocket.path.utf8.count, HubSocket.maxPathLength)
}
}
@@ -0,0 +1,205 @@
import XCTest
@testable import HubIPC
/// socket 层的端到端测试。这条链路是 hook 和 app 之间的唯一通道,
/// 断了就等于所有通知和审批全失效,值得真的建 socket 跑一遍。
final class SocketRoundTripTests: XCTestCase {
private var socketPath: String!
override func setUp() {
super.setUp()
// 测试用短路径:Unix socket 的 sun_path 只有 104 字节,
// 用 NSTemporaryDirectory() 拼 UUID 很容易超。
socketPath = "/tmp/hubtest-\(UUID().uuidString.prefix(8)).sock"
}
override func tearDown() {
unlink(socketPath)
super.tearDown()
}
private func makeEvent(kind: HookEvent.Kind, summary: String? = nil) -> HookEvent {
HookEvent(
kind: kind, requestId: UUID().uuidString, sessionId: "s1",
cwd: "/tmp/proj", toolName: "Bash", toolSummary: summary
)
}
/// 回归测试:超时阶梯必须严格递增。
///
/// 真实事故:`ApprovalCoordinator` 和 hubctl 的读超时都设成 60 秒,两者
/// 同时到期,hubctl 先判定"没收到应答"并放行了一条 `git push --force origin main`,
/// 而服务端其实已经决定拒绝。超时的语义从"安全拒绝"翻转成了"静默放行"。
func testTimeoutLadderIsStrictlyIncreasing() {
XCTAssertLessThan(
HookTimeouts.userDecision, HookTimeouts.serverBridge,
"服务端要留余量把用户超时的拒绝收上来"
)
XCTAssertLessThan(
HookTimeouts.serverBridge, HookTimeouts.clientRead,
"hubctl 必须等得比服务端久,否则超时会变成放行"
)
XCTAssertLessThan(
HookTimeouts.clientRead, TimeInterval(HookTimeouts.hookProcess),
"Claude 杀 hook 的时限必须最长,否则拒绝还没输出进程就没了"
)
}
/// 连上了但服务端不应答 → 必须拒绝,不能放行。
///
/// 判据:服务端对非高风险操作是立即应答的。能走到"无应答"说明它认为
/// 这次操作值得拦截,只是决策环节出了问题 —— 这种情况拒绝才是安全的。
func testNoResponseIsDistinguishedFromUnreachable() throws {
// 服务端收下但永不应答(模拟 UI 卡死)。
let server = HubSocketServer(path: socketPath) { _ in
Thread.sleep(forTimeInterval: 3)
return nil
}
try server.start()
defer { server.stop() }
let result = HubSocketClient.send(
makeEvent(kind: .preToolUse, summary: "rm -rf /"),
path: socketPath, waitForDecision: true, timeout: 0.5
)
guard case .noResponse = result else {
return XCTFail("连上但无应答应报 .noResponse,实际是 \(result)")
}
}
func testUnreachableIsReportedDistinctly() {
let result = HubSocketClient.send(
makeEvent(kind: .preToolUse, summary: "rm -rf /"),
path: "/tmp/absent-\(UUID().uuidString.prefix(6)).sock",
waitForDecision: true, timeout: 5
)
guard case .unreachable = result else {
return XCTFail("连不上应报 .unreachable,实际是 \(result)")
}
}
func testFireAndForgetEventReachesServer() throws {
let received = XCTestExpectation(description: "服务端收到事件")
nonisolated(unsafe) var seen: HookEvent?
let server = HubSocketServer(path: socketPath) { event in
seen = event
received.fulfill()
return nil
}
try server.start()
defer { server.stop() }
let result = HubSocketClient.send(
makeEvent(kind: .stop), path: socketPath,
waitForDecision: false, timeout: 2
)
wait(for: [received], timeout: 3)
guard case .acknowledged = result else {
return XCTFail("非审批事件应报 .acknowledged,实际是 \(result)")
}
XCTAssertEqual(seen?.kind, .stop)
XCTAssertEqual(seen?.sessionId, "s1")
}
func testPreToolUseReceivesDecision() throws {
let server = HubSocketServer(path: socketPath) { _ in
HookDecision(verdict: .deny, reason: "测试拒绝")
}
try server.start()
defer { server.stop() }
let result = HubSocketClient.send(
makeEvent(kind: .preToolUse, summary: "rm -rf /"),
path: socketPath, waitForDecision: true, timeout: 5
)
guard case .decided(let decision) = result else {
return XCTFail("应拿到明确决策,实际是 \(result)")
}
XCTAssertEqual(decision.verdict, .deny)
XCTAssertEqual(decision.reason, "测试拒绝")
}
/// **最重要的一条**:Hub 没跑时客户端必须立刻失败,让 hook 放行。
/// 如果这里挂住,用户的每一次工具调用都会卡 75 秒 —— 那比漏审严重得多。
func testClientFailsFastWhenServerAbsent() {
let start = Date()
let result = HubSocketClient.send(
makeEvent(kind: .preToolUse, summary: "rm -rf /"),
path: "/tmp/definitely-not-there-\(UUID().uuidString.prefix(6)).sock",
waitForDecision: true, timeout: 30
)
guard case .unreachable = result else {
return XCTFail("连不上应报 .unreachable,实际是 \(result)")
}
XCTAssertLessThan(
Date().timeIntervalSince(start), 1.0,
"连不上时必须立刻失败,不能等到超时"
)
}
func testIsHubRunningReflectsServerState() throws {
XCTAssertFalse(HubSocketClient.isHubRunning(path: socketPath))
let server = HubSocketServer(path: socketPath) { _ in nil }
try server.start()
XCTAssertTrue(HubSocketClient.isHubRunning(path: socketPath))
server.stop()
XCTAssertFalse(HubSocketClient.isHubRunning(path: socketPath))
}
/// 服务端处理器返回 nil 时,协议层会回一条明确的 allow,而不是让客户端挂住。
func testNilHandlerResultBecomesExplicitAllow() throws {
let server = HubSocketServer(path: socketPath) { _ in nil }
try server.start()
defer { server.stop() }
let result = HubSocketClient.send(
makeEvent(kind: .preToolUse, summary: "rm -rf /"),
path: socketPath, waitForDecision: true, timeout: 5
)
guard case .decided(let decision) = result else {
return XCTFail("应拿到明确决策,实际是 \(result)")
}
XCTAssertEqual(decision.verdict, .allow)
}
/// 上次进程被 kill -9 会留下 socket 文件,不删的话 bind 报 EADDRINUSE。
func testStaleSocketFileIsReplaced() throws {
FileManager.default.createFile(atPath: socketPath, contents: Data())
XCTAssertTrue(FileManager.default.fileExists(atPath: socketPath))
let server = HubSocketServer(path: socketPath) { _ in nil }
XCTAssertNoThrow(try server.start())
server.stop()
}
func testMultipleConcurrentClients() throws {
let count = 8
let received = XCTestExpectation(description: "全部事件送达")
received.expectedFulfillmentCount = count
let server = HubSocketServer(path: socketPath) { _ in
received.fulfill()
return nil
}
try server.start()
defer { server.stop() }
let path = socketPath!
DispatchQueue.concurrentPerform(iterations: count) { index in
_ = HubSocketClient.send(
HookEvent(
kind: .stop, requestId: "r\(index)", sessionId: "s\(index)", cwd: "/tmp"
),
path: path, waitForDecision: false, timeout: 2
)
}
wait(for: [received], timeout: 5)
}
}
@@ -0,0 +1,140 @@
import XCTest
@testable import HubCore
@testable import HubJump
@testable import HubProbe
/// `kind: bg` 会话的绑定。
///
/// 实测背景:bg 会话跑在 `claude bg-pty-host` daemon 下,祖先链是
/// `23402 (claude bg-spare) → 23371 (claude bg-pty-host) → 70305 (claude daemon) → 1`,
/// **完全不经过任何终端**。用户看它的地方是某个跑 `claude agents` 的 tab,
/// 那个 tab 的 jobPid 指向查看器进程(如 23360),与 23402 毫无亲缘关系。
final class BackgroundSessionBindingTests: XCTestCase {
private func bgSession(_ pid: pid_t, id: String, name: String) -> AgentSession {
AgentSession(
sessionId: id, pid: pid, cwd: "/tmp", name: name,
status: .idle, rawStatus: "idle", kind: .bg, jobId: id
)
}
private func interactiveSession(_ pid: pid_t, id: String, name: String) -> AgentSession {
AgentSession(
sessionId: id, pid: pid, cwd: "/tmp", name: name,
status: .busy, rawStatus: "busy", kind: .interactive
)
}
private func term(_ jobPid: pid_t, _ uuid: String, name: String) -> ITermSession {
ITermSession(sessionUUID: uuid, windowId: "3812", jobPid: jobPid, name: name)
}
/// 实测场景,PID 和标题都抄自本机。
func testBgSessionBindsToViewerTabByJobName() {
let tree = ProcessTree(parents: [
23402: 23371, 23371: 70305, 70305: 1, // bg 会话,挂在 daemon 下
23360: 61126, 61126: 61125, 61125: 1, // 查看器 tab,与上面无亲缘
])
let mapping = JumpEngine.bind(
itermSessions: [
term(23360, "VIEWER", name: "⠐ o3-ui-memory-idempotent-fixes (Claude)"),
],
claudeSessions: [
bgSession(23402, id: "bg-1", name: "o3-ui-memory-idempotent-fixes"),
],
tree: tree
)
XCTAssertEqual(mapping["bg-1"], "VIEWER")
}
/// 祖先链先跑,名字匹配只兜底。已经被祖先链认领的 tab 不能被名字匹配抢走 ——
/// 否则一个 interactive 会话的 ai-title 里偶然含有某个 job 名就会把 tab 抢过去。
func testAncestryBindingWinsOverNameMatching() {
let tree = ProcessTree(parents: [
30024: 29976, 29976: 1,
23402: 23371, 23371: 70305, 70305: 1,
])
// 这个 tab 既能被祖先链绑到 interactive 会话,标题里又恰好含 bg 会话的名字。
let ambiguous = term(
30024, "SHARED",
name: "正在处理 o3-ui-memory-idempotent-fixes 相关问题"
)
let mapping = JumpEngine.bind(
itermSessions: [ambiguous],
claudeSessions: [
interactiveSession(29976, id: "inter", name: "hub"),
bgSession(23402, id: "bg-1", name: "o3-ui-memory-idempotent-fixes"),
],
tree: tree
)
XCTAssertEqual(mapping["inter"], "SHARED")
XCTAssertNil(mapping["bg-1"], "已被祖先链认领的 tab 不该再被名字匹配抢走")
}
/// interactive 会话**不**参与名字匹配。它的祖先链一定连得上;
/// 连不上说明它压根不在 iTerm 里,此时按标题猜只会猜错。
func testInteractiveSessionsDoNotFallBackToNameMatching() {
let tree = ProcessTree(parents: [99999: 1, 30024: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(30024, "T", name: "some-unique-session-name")],
claudeSessions: [
interactiveSession(99999, id: "i", name: "some-unique-session-name"),
],
tree: tree
)
XCTAssertTrue(mapping.isEmpty)
}
/// 太短的名字不参与匹配,避免 `api` 这类词在别的标题里偶然命中。
func testShortJobNamesAreNotMatchedByTitle() {
let tree = ProcessTree(parents: [500: 1, 600: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(600, "T", name: "deploying api to prod")],
claudeSessions: [bgSession(500, id: "bg", name: "api")],
tree: tree
)
XCTAssertTrue(mapping.isEmpty)
}
func testBgSessionWithoutNameIsSkipped() {
let tree = ProcessTree(parents: [500: 1, 600: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(600, "T", name: "anything")],
claudeSessions: [
AgentSession(
sessionId: "bg", pid: 500, cwd: "/tmp", name: nil,
status: .idle, rawStatus: "idle", kind: .bg
),
],
tree: tree
)
XCTAssertTrue(mapping.isEmpty)
}
/// 两个 bg 会话不能抢同一个 tab。
func testTwoBackgroundSessionsDoNotShareOneTab() {
let tree = ProcessTree(parents: [500: 70305, 501: 70305, 70305: 1, 600: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(600, "ONLY", name: "shared-prefix-alpha")],
claudeSessions: [
bgSession(500, id: "bg-a", name: "shared-prefix-alpha"),
bgSession(501, id: "bg-b", name: "shared-prefix-alpha"),
],
tree: tree
)
XCTAssertEqual(mapping.count, 1)
XCTAssertEqual(mapping.values.first, "ONLY")
}
/// 没有查看器 tab 开着时,bg 会话就是绑不上 —— 这是正确行为,
/// 跳转会降级为「只前置终端」,而不是跳到一个错误的 tab。
func testBgSessionWithNoViewerTabRemainsUnbound() {
let tree = ProcessTree(parents: [500: 70305, 70305: 1, 600: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(600, "OTHER", name: "完全无关的标题")],
claudeSessions: [bgSession(500, id: "bg", name: "some-long-job-name")],
tree: tree
)
XCTAssertTrue(mapping.isEmpty)
}
}
@@ -0,0 +1,169 @@
import XCTest
@testable import HubCore
@testable import HubJump
@testable import HubProbe
/// 绑定逻辑是整个跳转方案的核心,也是最容易出错的地方。
/// AppleScript 和 sysctl 没法在测试里跑,所以把纯逻辑抽了出来单独测。
///
/// 下面的进程树全部抄自本机实测(见方案文档 §1.2),不是编造的场景。
final class JumpBindingTests: XCTestCase {
private func session(_ pid: pid_t, _ id: String, cwd: String = "/tmp") -> AgentSession {
AgentSession(
sessionId: id, pid: pid, cwd: cwd,
status: .busy, rawStatus: "busy", kind: .interactive
)
}
private func term(_ jobPid: pid_t, _ uuid: String) -> ITermSession {
ITermSession(sessionUUID: uuid, windowId: "3812", jobPid: jobPid, name: "irrelevant")
}
/// 实测链路一:tmux -CC 会话,claude 是 pane 根进程,node 是它的子进程。
/// 30024(node) → 29976(claude ✓) → 25111(tmux)
func testBindsThroughNodeChildInTmuxSession() {
let tree = ProcessTree(parents: [30024: 29976, 29976: 25111, 25111: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(30024, "UUID-A")],
claudeSessions: [session(29976, "claude-hub-session")],
tree: tree
)
XCTAssertEqual(mapping["claude-hub-session"], "UUID-A")
}
/// 实测链路二:中间隔着 npm exec 的 MCP 服务进程,祖先链更深。
/// 91852(node) → 91758(npm exec) → 90252(claude ✓) → 25111(tmux)
func testBindsThroughDeeperChainViaNpmExec() {
let tree = ProcessTree(parents: [
91852: 91758, 91758: 90252, 90252: 25111, 25111: 1,
])
let mapping = JumpEngine.bind(
itermSessions: [term(91852, "UUID-B")],
claudeSessions: [session(90252, "acme-admin-session")],
tree: tree
)
XCTAssertEqual(mapping["acme-admin-session"], "UUID-B")
}
/// 实测链路三:**非 tmux** 的 iTerm 原生会话,祖先链通到 iTermServer。
/// 同一套算法必须覆盖这种情况 —— 这正是"任意终端,非 tmux"需求的实现依据。
func testBindsNonTmuxNativeItermSession() {
let tree = ProcessTree(parents: [
23360: 61126, 61126: 61125, 61125: 21560, 21560: 21558, 21558: 1,
])
let mapping = JumpEngine.bind(
itermSessions: [term(23360, "UUID-C")],
claudeSessions: [session(61126, "bg-session")],
tree: tree
)
XCTAssertEqual(mapping["bg-session"], "UUID-C")
}
/// jobPid 恰好就是 claude 进程本身(pane 里没有前台子进程时)。
func testBindsWhenJobPidIsTheClaudeProcessItself() {
let tree = ProcessTree(parents: [29976: 25111, 25111: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(29976, "UUID-D")],
claudeSessions: [session(29976, "direct")],
tree: tree
)
XCTAssertEqual(mapping["direct"], "UUID-D")
}
/// 多个会话同时存在时不能串味 —— 这是旧实现按标题子串匹配最容易犯的错
/// (多个 tab 标题都叫 "Claude Code" 就会跳错)。
func testMultipleSessionsDoNotCrossBind() {
let tree = ProcessTree(parents: [
30024: 29976, 29976: 25111,
91852: 91758, 91758: 90252, 90252: 25111,
97188: 97138, 97138: 25111,
25111: 1,
])
let mapping = JumpEngine.bind(
itermSessions: [term(30024, "A"), term(91852, "B"), term(97188, "C")],
claudeSessions: [
session(29976, "hub"), session(90252, "admin"), session(97138, "image"),
],
tree: tree
)
XCTAssertEqual(mapping, ["hub": "A", "admin": "B", "image": "C"])
}
/// 非 Claude 的 tab(比如一个普通 zsh)不该被绑到任何会话上。
func testPlainShellTabBindsToNothing() {
let tree = ProcessTree(parents: [5608: 25111, 25111: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(5608, "UUID-ZSH")],
claudeSessions: [session(29976, "elsewhere")],
tree: tree
)
XCTAssertTrue(mapping.isEmpty)
}
/// 进程树里查不到 jobPid(进程刚退出,快照有撕裂)时不能崩,静默跳过即可。
func testUnknownJobPidIsSkipped() {
let tree = ProcessTree(parents: [29976: 25111])
let mapping = JumpEngine.bind(
itermSessions: [term(999_999, "GHOST"), term(29976, "REAL")],
claudeSessions: [session(29976, "s")],
tree: tree
)
XCTAssertEqual(mapping, ["s": "REAL"])
}
func testNoClaudeSessionsYieldsEmptyMapping() {
let tree = ProcessTree(parents: [30024: 29976, 29976: 1])
XCTAssertTrue(JumpEngine.bind(
itermSessions: [term(30024, "A")], claudeSessions: [], tree: tree
).isEmpty)
}
/// 最近的祖先优先:嵌套 claude(claude 内又起了一个 claude)时
/// 应该绑到里层那个,因为那才是这个 tab 前台真正在跑的会话。
func testNearestAncestorWins() {
let tree = ProcessTree(parents: [500: 400, 400: 300, 300: 1])
let mapping = JumpEngine.bind(
itermSessions: [term(500, "T")],
claudeSessions: [session(400, "inner"), session(300, "outer")],
tree: tree
)
XCTAssertEqual(mapping["inner"], "T")
XCTAssertNil(mapping["outer"])
}
}
/// 进程树遍历本身的边界情况。
final class ProcessTreeTests: XCTestCase {
func testAncestryIncludesSelfAndStopsAtRoot() {
let tree = ProcessTree(parents: [30024: 29976, 29976: 25111, 25111: 1, 1: 0])
XCTAssertEqual(tree.ancestry(of: 30024), [30024, 29976, 25111, 1])
}
/// 快照撕裂可能造出环。必须截断而不是死循环 —— 这会挂死整个跳转。
func testCyclicParentChainTerminates() {
let tree = ProcessTree(parents: [10: 20, 20: 30, 30: 10])
let chain = tree.ancestry(of: 10)
XCTAssertEqual(chain, [10, 20, 30])
}
func testSelfParentTerminates() {
let tree = ProcessTree(parents: [10: 10])
XCTAssertEqual(tree.ancestry(of: 10), [10])
}
func testDepthLimitIsRespected() {
var parents: [pid_t: pid_t] = [:]
for i in 1..<200 { parents[pid_t(i)] = pid_t(i + 1) }
XCTAssertEqual(tree(parents).ancestry(of: 1, limit: 5).count, 5)
}
func testMissingPidYieldsItselfOnly() {
XCTAssertEqual(ProcessTree(parents: [:]).ancestry(of: 42), [42])
}
private func tree(_ parents: [pid_t: pid_t]) -> ProcessTree {
ProcessTree(parents: parents)
}
}
@@ -0,0 +1,152 @@
import XCTest
@testable import HubCore
@testable import HubProbe
final class ClaudeSessionReaderTests: XCTestCase {
private var tempDir: URL!
override func setUpWithError() throws {
tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("hubkit-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDir)
}
private func write(pid: Int, json: String) throws {
try Data(json.utf8).write(to: tempDir.appendingPathComponent("\(pid).json"))
}
private func record(
pid: Int, sessionId: String, status: String = "busy",
cwd: String = "/tmp/p", updatedAt: Int? = nil
) -> String {
var fields = [
"\"pid\":\(pid)", "\"sessionId\":\"\(sessionId)\"",
"\"cwd\":\"\(cwd)\"", "\"status\":\"\(status)\"", "\"kind\":\"interactive\"",
]
if let updatedAt { fields.append("\"updatedAt\":\(updatedAt)") }
return "{\(fields.joined(separator: ","))}"
}
/// 这是最关键的一条:实测 ~/.claude/sessions/ 里残留着好几天前已退出进程的
/// json(死进程的文件不会被清理)。不过滤,岛上就会显示一堆幽灵会话。
func testDeadProcessesAreFilteredOut() throws {
try write(pid: 100, json: record(pid: 100, sessionId: "alive"))
try write(pid: 200, json: record(pid: 200, sessionId: "dead"))
let reader = ClaudeSessionReader(directory: tempDir)
let sessions = reader.readAll(isAlive: { $0 == 100 })
XCTAssertEqual(sessions.map(\.sessionId), ["alive"])
}
func testNonJsonFilesAreIgnored() throws {
try write(pid: 100, json: record(pid: 100, sessionId: "s"))
try Data("garbage".utf8).write(to: tempDir.appendingPathComponent("README.txt"))
try Data("garbage".utf8).write(to: tempDir.appendingPathComponent("notes.md"))
let sessions = ClaudeSessionReader(directory: tempDir).readAll(isAlive: { _ in true })
XCTAssertEqual(sessions.count, 1)
}
/// 一个坏文件不能让整个探测失败 —— 那会让岛整片空白。
func testCorruptFileDoesNotBreakOtherRecords() throws {
try write(pid: 100, json: record(pid: 100, sessionId: "good"))
try write(pid: 101, json: "{ this is not json")
try write(pid: 102, json: record(pid: 102, sessionId: "also-good"))
let sessions = ClaudeSessionReader(directory: tempDir).readAll(isAlive: { _ in true })
XCTAssertEqual(Set(sessions.map(\.sessionId)), ["good", "also-good"])
}
/// resume 之后旧 PID 的 json 可能还在,而旧 PID 又恰好被系统复用给别的进程
/// 从而通过判活。同一 sessionId 只能留一条,取 updatedAt 最新的。
func testDuplicateSessionIdKeepsMostRecentlyUpdated() throws {
try write(pid: 100, json: record(pid: 100, sessionId: "same", updatedAt: 1000))
try write(pid: 200, json: record(pid: 200, sessionId: "same", updatedAt: 5000))
let sessions = ClaudeSessionReader(directory: tempDir).readAll(isAlive: { _ in true })
XCTAssertEqual(sessions.count, 1)
XCTAssertEqual(sessions.first?.pid, 200)
}
func testMissingDirectoryReturnsEmptyRatherThanCrashing() {
let reader = ClaudeSessionReader(
directory: tempDir.appendingPathComponent("does-not-exist")
)
XCTAssertTrue(reader.readAll(isAlive: { _ in true }).isEmpty)
}
func testAlivePIDsSetMatchesReadAll() throws {
try write(pid: 100, json: record(pid: 100, sessionId: "a"))
try write(pid: 200, json: record(pid: 200, sessionId: "b"))
try write(pid: 300, json: record(pid: 300, sessionId: "c"))
let reader = ClaudeSessionReader(directory: tempDir)
XCTAssertEqual(reader.alivePIDs(isAlive: { $0 != 200 }), [100, 300])
}
func testDefaultDirectoryPointsAtClaudeSessions() {
XCTAssertTrue(
ClaudeSessionReader().directory.path.hasSuffix("/.claude/sessions")
)
}
}
final class TmuxBindingTests: XCTestCase {
private func pane(_ windowId: String, pid: pid_t, name: String = "w") -> TmuxPane {
TmuxPane(
sessionName: "hub", windowId: windowId, windowName: name,
paneId: "%1", panePid: pid, currentPath: "/tmp", currentCommand: "2.1.220"
)
}
/// claude 就是 pane 的根进程 —— 直接相等的简单情况。
func testBindsWhenClaudeIsPaneRoot() {
let tree = ProcessTree(parents: [29976: 25111, 25111: 1])
let binding = TmuxProbe().bindPanes(
to: [29976], tree: tree, panes: [pane("@20", pid: 29976)]
)
XCTAssertEqual(binding[29976]?.windowId, "@20")
}
/// pane 根进程是 shell,claude 是它的子进程。实测 8 个窗口里有 2 个是这样,
/// 所以绝不能用 `panePid == sessionPid` 直接相等去绑。
func testBindsWhenClaudeIsDescendantOfPaneRoot() {
let tree = ProcessTree(parents: [29976: 8000, 8000: 25111, 25111: 1])
let binding = TmuxProbe().bindPanes(
to: [29976], tree: tree, panes: [pane("@21", pid: 8000)]
)
XCTAssertEqual(binding[29976]?.windowId, "@21")
}
/// 不在任何 tmux pane 里的会话(跑在原生终端 tab 里)不该被误绑。
func testSessionOutsideTmuxIsNotBound() {
let tree = ProcessTree(parents: [61126: 61125, 61125: 21558, 21558: 1])
let binding = TmuxProbe().bindPanes(
to: [61126], tree: tree, panes: [pane("@20", pid: 29976)]
)
XCTAssertTrue(binding.isEmpty)
}
func testEachSessionBindsToItsOwnPane() {
let tree = ProcessTree(parents: [
29976: 25111, 90252: 25111, 97138: 25111, 25111: 1,
])
let binding = TmuxProbe().bindPanes(
to: [29976, 90252, 97138],
tree: tree,
panes: [
pane("@20", pid: 29976), pane("@23", pid: 90252), pane("@22", pid: 97138),
]
)
XCTAssertEqual(binding[29976]?.windowId, "@20")
XCTAssertEqual(binding[90252]?.windowId, "@23")
XCTAssertEqual(binding[97138]?.windowId, "@22")
}
}
@@ -0,0 +1,203 @@
import XCTest
@testable import HubProjects
/// 守 projects.yaml 这条数据链路。
///
/// 背景是一次真实事故:主窗口三个页面全空。根因不是界面没做,而是
/// **app 读的 projects.yaml 和脚本维护的那份不是同一个文件**,
/// 且它读到的那份是坏的(`projects: []` 后面跟着 117 条内容),
/// 解析器静默返回空数组。
final class ProjectSourceTests: XCTestCase {
// MARK: - 坏文件
/// `projects: []` 后面跟着真实条目 —— 旧版写下空数组标记,
/// 后来的追加逻辑又往文件尾巴上加内容,产出的就是这种文件。
///
/// 原解析器判的是 `trimmed == "projects:"`,`projects: []` 完全不匹配,
/// 于是后面所有条目被静默忽略。
func testEmptyArrayMarkerDoesNotSwallowEntries() {
let yaml = """
# Claude Hub 项目清单
scan_dirs:
- ~/Documents/code
projects: []
- name: claude-hub
path: /Users/dev/Documents/code/claude-hub
- name: go-wechat
path: /Users/dev/Documents/code/go-wechat
"""
let projects = ProjectYAML.parse(yaml)
XCTAssertEqual(projects.count, 2)
XCTAssertEqual(projects.map(\.name), ["claude-hub", "go-wechat"])
}
/// 其它几种"空值"写法同样不能吃掉后面的条目。
func testOtherEmptyMarkersAlsoParse() {
for marker in ["[]", "~", "null", "{}"] {
let yaml = """
projects: \(marker)
- name: demo
path: /tmp/demo
"""
XCTAssertEqual(
ProjectYAML.parse(yaml).count, 1,
"`projects: \(marker)` 后面的条目被吃掉了"
)
}
}
/// 正常写法不能被上面的放宽改坏。
func testCanonicalFormStillParses() {
let yaml = """
projects:
- name: acme-erp
aliases: [acme-admin, erp, 后台]
path: ~/Documents/code/acme-admin
description: "示例管理后台"
tags: [vue, gin]
"""
let projects = ProjectYAML.parse(yaml)
XCTAssertEqual(projects.count, 1)
XCTAssertEqual(projects[0].name, "acme-erp")
XCTAssertEqual(projects[0].aliases, ["acme-admin", "erp", "后台"])
XCTAssertEqual(projects[0].description, "示例管理后台")
XCTAssertEqual(projects[0].tags, ["vue", "gin"])
}
/// 别的顶层段落不能被当成项目段。
func testOtherTopLevelSectionsAreSkipped() {
let yaml = """
scan_dirs:
- name: 这不是项目
path: /tmp/nope
projects:
- name: real
path: /tmp/real
"""
let projects = ProjectYAML.parse(yaml)
XCTAssertEqual(projects.map(\.name), ["real"])
}
// MARK: - 解析失败要能被看见
/// 文件里明明有条目却一条都没解析出来时,必须给出诊断。
/// 静默返回空数组正是这次排查花掉最多时间的原因。
func testBrokenFileProducesDiagnostic() {
// 项目段整个缺失,但下面有 name 条目。
let yaml = """
scan_dirs:
- ~/code
somethingelse:
- name: orphan
path: /tmp/orphan
"""
let outcome = ProjectYAML.analyze(yaml)
XCTAssertTrue(outcome.projects.isEmpty)
XCTAssertTrue(outcome.looksBroken)
XCTAssertNotNil(outcome.diagnostic)
}
/// 真的就是没有项目(不是格式坏了)时不该报错。
func testGenuinelyEmptyFileIsNotFlaggedAsBroken() {
let outcome = ProjectYAML.analyze("projects:\n")
XCTAssertTrue(outcome.projects.isEmpty)
XCTAssertFalse(outcome.looksBroken)
XCTAssertNil(outcome.diagnostic)
}
// MARK: - 追加写入
/// 追加前必须把 `projects: []` 规范化,否则写出去的文件下次还是解析不出来 ——
/// 这正是那份坏文件的产生方式。
func testAppendNormalizesEmptyMarkerFirst() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("projects-\(UUID().uuidString).yaml")
defer { try? FileManager.default.removeItem(at: url) }
try """
# 注释要保住
scan_dirs:
- ~/code
projects: []
""".write(to: url, atomically: true, encoding: .utf8)
ProjectStore.append(paths: ["/tmp/alpha", "/tmp/beta"], to: url)
let text = try String(contentsOf: url, encoding: .utf8)
XCTAssertTrue(text.contains("# 注释要保住"), "追加不该丢掉用户手写的注释")
XCTAssertFalse(text.contains("projects: []"))
let projects = ProjectYAML.parse(text)
XCTAssertEqual(projects.map(\.name), ["alpha", "beta"])
}
/// 已经是正常格式时,追加不该动 `projects:` 那一行,也不该丢掉已有条目。
func testAppendPreservesExistingEntries() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("projects-\(UUID().uuidString).yaml")
defer { try? FileManager.default.removeItem(at: url) }
try """
projects:
- name: existing
aliases: [old]
path: /tmp/existing
""".write(to: url, atomically: true, encoding: .utf8)
ProjectStore.append(paths: ["/tmp/fresh"], to: url)
let projects = ProjectYAML.parse(try String(contentsOf: url, encoding: .utf8))
XCTAssertEqual(projects.map(\.name), ["existing", "fresh"])
XCTAssertEqual(projects[0].aliases, ["old"], "已有条目的别名要原样保留")
}
func testNormalizeLeavesGoodFilesUntouched() {
let good = "projects:\n - name: a\n path: /tmp/a\n"
XCTAssertEqual(ProjectStore.normalizeProjectsKey(in: good), good)
}
// MARK: - 数据源选择
/// 坏掉的候选**不能**把好的那份挡住。
///
/// 原实现只判"文件存不存在",于是 Application Support 里那份坏文件
/// 永远排在前面,仓库里 21 个项目的好文件根本没机会被读到。
@MainActor
func testBrokenCandidateDoesNotShadowGoodOne() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let good = dir.appendingPathComponent("good.yaml")
try "projects:\n - name: good\n path: /tmp/good\n"
.write(to: good, atomically: true, encoding: .utf8)
let store = ProjectStore(yamlURL: good)
store.load()
XCTAssertEqual(store.projects.map(\.name), ["good"])
XCTAssertEqual(store.sourceURL, good)
XCTAssertNil(store.loadDiagnostic)
}
/// 读到坏文件时,`sourceURL` 和诊断都要有值 —— UI 靠它们告诉用户去哪看。
@MainActor
func testBrokenSourceIsReported() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("broken-\(UUID().uuidString).yaml")
defer { try? FileManager.default.removeItem(at: url) }
try "notprojects:\n - name: x\n path: /tmp/x\n"
.write(to: url, atomically: true, encoding: .utf8)
let store = ProjectStore(yamlURL: url)
store.load()
XCTAssertTrue(store.projects.isEmpty)
XCTAssertEqual(store.sourceURL, url)
XCTAssertNotNil(store.loadDiagnostic)
}
}
@@ -0,0 +1,183 @@
import XCTest
@testable import HubProjects
final class ProjectYAMLTests: XCTestCase {
/// 真实样本,结构抄自本机 projects.yaml。
private let sample = """
scan_dirs:
- ~/Documents/code
- ~/Documents/code/shopify
projects:
- name: acme-erp
aliases: [acme-admin, erp, 后台]
path: ~/Documents/code/acme-admin
description: "示例管理后台 (GIN-VUE-ADMIN)"
tags: [vue, gin, docker]
- name: claude-hub
path: ~/Documents/code/claude-hub
description: "调度中心"
"""
func testParsesRealSample() {
let projects = ProjectYAML.parse(sample)
XCTAssertEqual(projects.count, 2)
let erp = projects[0]
XCTAssertEqual(erp.name, "acme-erp")
XCTAssertEqual(erp.path, "~/Documents/code/acme-admin")
XCTAssertEqual(erp.aliases, ["acme-admin", "erp", "后台"])
XCTAssertEqual(erp.description, "示例管理后台 (GIN-VUE-ADMIN)")
XCTAssertEqual(erp.tags, ["vue", "gin", "docker"])
}
/// scan_dirs 段落里的 `- ~/...` 长得很像项目条目,必须跳过。
func testScanDirsSectionIsIgnored() {
let projects = ProjectYAML.parse(sample)
XCTAssertFalse(projects.contains { $0.name.contains("Documents") })
}
func testTildeExpansion() {
let project = Project(name: "x", path: "~/code/x")
XCTAssertTrue(project.expandedPath.hasPrefix("/"))
XCTAssertFalse(project.expandedPath.contains("~"))
}
func testAbsolutePathUnchanged() {
XCTAssertEqual(Project(name: "x", path: "/opt/x").expandedPath, "/opt/x")
}
func testCommentsAndBlankLinesIgnored() {
let text = """
# 这是注释
projects:
# 另一条注释
- name: a
path: /tmp/a
"""
let projects = ProjectYAML.parse(text)
XCTAssertEqual(projects.count, 1)
XCTAssertEqual(projects[0].name, "a")
}
/// 缺 path 的条目直接丢弃,不能造出一个指向空路径的项目。
func testEntryWithoutPathIsDropped() {
let text = """
projects:
- name: broken
- name: ok
path: /tmp/ok
"""
let projects = ProjectYAML.parse(text)
XCTAssertEqual(projects.map(\.name), ["ok"])
}
func testInlineNameOnDashLine() {
let text = """
projects:
- name: inline
path: /tmp/inline
"""
XCTAssertEqual(ProjectYAML.parse(text).first?.name, "inline")
}
func testEmptyInputYieldsNoProjects() {
XCTAssertTrue(ProjectYAML.parse("").isEmpty)
XCTAssertTrue(ProjectYAML.parse("projects:").isEmpty)
}
}
final class GitStatusParsingTests: XCTestCase {
func testCleanRepoWithUpstream() {
let info = GitStatus.parse("""
# branch.oid abc123
# branch.head main
# branch.upstream origin/main
# branch.ab +0 -0
""")
XCTAssertEqual(info.branch, "main")
XCTAssertTrue(info.isClean)
XCTAssertTrue(info.hasUpstream)
XCTAssertEqual(info.ahead, 0)
XCTAssertEqual(info.behind, 0)
}
func testDirtyRepoCountsEveryChangeLine() {
let info = GitStatus.parse("""
# branch.head feature/x
# branch.upstream origin/feature/x
# branch.ab +3 -1
1 .M N... 100644 100644 100644 aaa bbb file1.swift
1 M. N... 100644 100644 100644 ccc ddd file2.swift
? untracked.txt
""")
XCTAssertEqual(info.branch, "feature/x")
XCTAssertEqual(info.changeCount, 3)
XCTAssertEqual(info.ahead, 3)
XCTAssertEqual(info.behind, 1)
XCTAssertFalse(info.isClean)
}
/// 没有上游的分支不该显示 ahead/behind —— 那两个数字没有意义。
func testBranchWithoutUpstream() {
let info = GitStatus.parse("""
# branch.head local-only
""")
XCTAssertEqual(info.branch, "local-only")
XCTAssertFalse(info.hasUpstream)
}
func testDetachedHeadHasNoBranch() {
let info = GitStatus.parse("# branch.head (detached)")
XCTAssertNil(info.branch)
}
}
final class UsageEncodingTests: XCTestCase {
/// 编码规则必须和 Claude Code 完全一致,否则统计会全部落空。
func testDirectoryEncodingMatchesClaudeCode() {
XCTAssertEqual(
UsageStats.encodeDirectoryName(for: "/Users/dev/Documents/code/claude-hub"),
"-Users-dev-Documents-code-claude-hub"
)
}
func testNonAsciiBecomesDash() {
XCTAssertEqual(UsageStats.encodeDirectoryName(for: "/tmp/项目"), "-tmp---")
}
func testDotsAndUnderscoresBecomeDash() {
XCTAssertEqual(UsageStats.encodeDirectoryName(for: "/a/b.c_d"), "-a-b-c-d")
}
}
final class LaunchModeTests: XCTestCase {
func testCommandsMatchRustImplementation() {
XCTAssertEqual(LaunchMode.claude.command(sessionId: nil), "claude")
XCTAssertEqual(
LaunchMode.claudeSkipPermissions.command(sessionId: nil),
"claude --dangerously-skip-permissions"
)
XCTAssertEqual(LaunchMode.resumeLast.command(sessionId: nil), "claude --continue")
XCTAssertEqual(LaunchMode.resumePick.command(sessionId: nil), "claude --resume")
XCTAssertNil(LaunchMode.terminal.command(sessionId: nil))
}
func testResumeSessionQuotesId() {
XCTAssertEqual(
LaunchMode.resumeSession.command(sessionId: "abc-123"),
"claude --resume 'abc-123'"
)
}
/// sessionId 来自文件内容,理论上可控 —— 引号必须转义,不能拼出注入。
func testShellQuoteEscapesSingleQuotes() {
XCTAssertEqual(shellQuote("it's"), #"'it'\''s'"#)
XCTAssertEqual(shellQuote("a; rm -rf /"), "'a; rm -rf /'")
}
}
@@ -0,0 +1,133 @@
import XCTest
@testable import HubProjects
/// 守用量统计的流式解析和缓存。
///
/// 背景:原实现对每个 jsonl 做 `String(contentsOf:)` 再 `split`,
/// 而 `~/.claude/projects` 实测有 **945 MB / 341 个文件**,
/// 整份读进内存跑不出结果,用量页永远停在 0 —— 用户读到的是"这个功能没做"。
final class UsageStreamingTests: XCTestCase {
private func write(_ lines: [String]) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("usage-\(UUID().uuidString).jsonl")
try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
return url
}
private func scan(_ url: URL) -> FileUsage? {
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
return UsageStats.scanFile(
url,
size: Int64(values?.fileSize ?? 0),
modified: values?.contentModificationDate ?? .distantPast
)
}
// MARK: - 解析正确性
func testCountsTokensAcrossLines() throws {
let url = try write([
#"{"type":"user","message":{"content":"hi"}}"#,
#"{"message":{"model":"claude-opus-5","usage":{"input_tokens":10,"output_tokens":5}}}"#,
#"{"message":{"model":"claude-opus-5","usage":{"input_tokens":3,"cache_read_input_tokens":7,"cache_creation_input_tokens":2,"output_tokens":4}}}"#,
])
defer { try? FileManager.default.removeItem(at: url) }
let stats = try XCTUnwrap(scan(url))
XCTAssertEqual(stats.messages, 2)
// 10 + (3 + 7 + 2):cache 读写都算进入 input。
XCTAssertEqual(stats.input, 22)
XCTAssertEqual(stats.output, 9)
XCTAssertEqual(stats.models, ["claude-opus-5"])
}
/// **跨块的那一行不能被切坏。**
///
/// 分块读的关键细节:块边界几乎必然落在某行中间,残留的尾巴必须留到
/// 下一块开头拼接。写错的话表现是"大文件统计偏低",而且非常难发现 ——
/// 数字只是小了一点,不会报错。
func testHandlesLinesSpanningChunkBoundaries() throws {
// 每行塞一段长 padding,逼出多个 1 MB 块。
let padding = String(repeating: "x", count: 200_000)
var lines: [String] = []
for index in 0..<20 {
lines.append(
#"{"pad":"\#(padding)","message":{"usage":{"input_tokens":\#(index),"output_tokens":1}}}"#
)
}
let url = try write(lines)
defer { try? FileManager.default.removeItem(at: url) }
let stats = try XCTUnwrap(scan(url))
XCTAssertEqual(stats.messages, 20, "跨块的行被漏掉或切坏了")
XCTAssertEqual(stats.input, (0..<20).reduce(0, +))
XCTAssertEqual(stats.output, 20)
}
/// 最后一行没有换行符时也要算进去。
func testCountsFinalLineWithoutTrailingNewline() throws {
let url = try write([
#"{"message":{"usage":{"input_tokens":1,"output_tokens":1}}}"#
])
defer { try? FileManager.default.removeItem(at: url) }
XCTAssertEqual(try XCTUnwrap(scan(url)).messages, 1)
}
/// 坏行、空行、没有 usage 的行都要安静跳过,不能中断整个文件。
func testSkipsMalformedAndIrrelevantLines() throws {
let url = try write([
"",
"这不是 json",
#"{"message":{"content":"没有 usage"}}"#,
#"{"broken":"usage 出现在这里但结构不对"}"#,
#"{"message":{"usage":{"input_tokens":5,"output_tokens":2}}}"#,
"",
])
defer { try? FileManager.default.removeItem(at: url) }
let stats = try XCTUnwrap(scan(url))
XCTAssertEqual(stats.messages, 1)
XCTAssertEqual(stats.input, 5)
}
func testEmptyFileYieldsZeros() throws {
let url = try write([])
defer { try? FileManager.default.removeItem(at: url) }
let stats = try XCTUnwrap(scan(url))
XCTAssertEqual(stats.messages, 0)
XCTAssertEqual(stats.input, 0)
}
// MARK: - 缓存
/// 缓存的失效判据是 size + mtime。两者都没变就必须命中,
/// 否则每次打开都要重扫近 1 GB。
func testCacheRoundTrips() throws {
let entry = FileUsage(
size: 123, modified: Date(timeIntervalSince1970: 1_700_000_000),
messages: 4, input: 100, output: 50, models: ["claude-opus-5"]
)
let encoded = try JSONEncoder().encode(["/tmp/a.jsonl": entry])
let decoded = try JSONDecoder().decode([String: FileUsage].self, from: encoded)
let restored = try XCTUnwrap(decoded["/tmp/a.jsonl"])
XCTAssertEqual(restored.size, entry.size)
XCTAssertEqual(restored.modified, entry.modified)
XCTAssertEqual(restored.input, entry.input)
XCTAssertEqual(restored.models, entry.models)
}
// MARK: - 范围
func testRangeCutoffs() {
XCTAssertNil(UsageRange.all.cutoff)
XCTAssertNotNil(UsageRange.week7.cutoff)
XCTAssertNotNil(UsageRange.month30.cutoff)
// 7 天的截止时间必须比 30 天的晚(窗口更窄)。
XCTAssertGreaterThan(
try XCTUnwrap(UsageRange.week7.cutoff),
try XCTUnwrap(UsageRange.month30.cutoff)
)
}
}
@@ -0,0 +1,195 @@
import HubIPC
import XCTest
@testable import HubUI
@MainActor
final class ApprovalCoordinatorTests: XCTestCase {
// 每个 case 都用 `logURL: nil` 建协调器 —— 默认路径指向用户真实的
// ~/Library/Application Support/claude-hub/approval-log.json,
// 测试跑进去既会互相污染,也会往用户数据里写垃圾。
private func request(
id: String = "r1", risk: RiskLevel = .irreversible, clientPid: pid_t = 0
) -> ApprovalRequest {
ApprovalRequest(
id: id, sessionId: "s1", projectName: "demo", cwd: "/tmp",
toolName: "Bash", command: "git push --force origin main",
risk: risk, clientPid: clientPid
)
}
// MARK: - 决策必须原样送回
/// 这条看着平凡,但它守的是整个系统里**唯一一个做错了会真删代码**的地方:
/// 用户点了拒绝,hook 那一侧必须收到拒绝。中间任何一环把它降级成放行,
/// 都会让"高风险操作审批"变成一个假的安全感。
func testDenyReachesTheWaitingHook() async {
let coordinator = ApprovalCoordinator(logURL: nil)
let target = request()
let decision = Task { await coordinator.requestDecision(for: target) }
// 让 requestDecision 先把请求排进队列。
while coordinator.current == nil {
await Task.yield()
}
coordinator.deny(target)
let result = await decision.value
XCTAssertEqual(result.verdict, .deny)
XCTAssertNil(coordinator.current, "决策完成后卡片必须消失")
}
func testAllowReachesTheWaitingHook() async {
let coordinator = ApprovalCoordinator(logURL: nil)
let target = request(risk: .dangerous)
let decision = Task { await coordinator.requestDecision(for: target) }
while coordinator.current == nil {
await Task.yield()
}
coordinator.allow(target)
let result = await decision.value
XCTAssertEqual(result.verdict, .allow)
}
// MARK: - 僵尸卡
/// 发起方进程死了以后,卡片必须自己消失。
///
/// 排障时真实撞到过:hook 进程被杀(Ctrl-C、会话关闭、超时被 kill),
/// 服务端仍然阻塞在等决策上,岛上挂着一张**已经没人接收结果**的卡。
/// 它不只是碍眼 —— 队列是先进先出的,这张僵尸卡会一直挡住后面真正
/// 需要用户处理的请求。
func testOrphanedRequestIsDroppedWhenClientDies() async {
let coordinator = ApprovalCoordinator(logURL: nil)
// PID 1 (launchd) 一定活着但不属于我们 —— kill 返回 EPERM,应判定为"还活着"。
let alive = request(id: "alive", clientPid: 1)
// 一个几乎不可能存在的 PID。
let dead = request(id: "dead", clientPid: 0x7FFF_FFFE)
let aliveDecision = Task { await coordinator.requestDecision(for: alive) }
let deadDecision = Task { await coordinator.requestDecision(for: dead) }
while coordinator.queue.count < 2 {
await Task.yield()
}
coordinator.startOrphanSweep()
let outcome = await deadDecision.value
coordinator.stopOrphanSweep()
XCTAssertEqual(outcome.verdict, .deny, "发起方已死,必须按拒绝收尾")
XCTAssertEqual(coordinator.queue.count, 1, "只该清掉死掉的那一条")
XCTAssertEqual(coordinator.current?.id, "alive")
coordinator.deny(alive)
_ = await aliveDecision.value
}
/// `clientPid == 0` 表示"不知道发起方是谁",绝不能当成已死。
func testUnknownClientPidIsNeverTreatedAsOrphan() async {
let coordinator = ApprovalCoordinator(logURL: nil)
let target = request(clientPid: 0)
let decision = Task { await coordinator.requestDecision(for: target) }
while coordinator.current == nil {
await Task.yield()
}
coordinator.startOrphanSweep()
try? await Task.sleep(for: .seconds(3))
XCTAssertNotNil(coordinator.current, "未知发起方的请求不该被清掉")
coordinator.stopOrphanSweep()
coordinator.deny(target)
_ = await decision.value
}
// MARK: - 队列
/// 跳过是"稍后再说",不是"拒绝"——不能顺手把它 resolve 掉。
func testSkipMovesToBackWithoutResolving() async {
let coordinator = ApprovalCoordinator(logURL: nil)
let first = request(id: "a")
let second = request(id: "b")
let firstDecision = Task { await coordinator.requestDecision(for: first) }
let secondDecision = Task { await coordinator.requestDecision(for: second) }
while coordinator.queue.count < 2 {
await Task.yield()
}
coordinator.skip(first)
XCTAssertEqual(coordinator.current?.id, "b")
XCTAssertEqual(coordinator.queue.count, 2, "跳过不该让请求消失")
coordinator.deny(second)
coordinator.deny(first)
let firstResult = await firstDecision.value
let secondResult = await secondDecision.value
XCTAssertEqual(firstResult.verdict, .deny)
XCTAssertEqual(secondResult.verdict, .deny)
}
/// 队列只剩一条时跳过没有意义,且不能把它弄丢。
func testSkipIsNoOpForTheOnlyRequest() async {
let coordinator = ApprovalCoordinator(logURL: nil)
let only = request()
let decision = Task { await coordinator.requestDecision(for: only) }
while coordinator.current == nil {
await Task.yield()
}
coordinator.skip(only)
XCTAssertEqual(coordinator.current?.id, only.id)
coordinator.deny(only)
_ = await decision.value
}
/// 每一次拦截都要留痕,包括被清掉的僵尸请求 —— 审批日志是用户
/// 决定"要不要把 dangerous 一档也开启拦截"的唯一依据。
func testEveryResolutionIsLogged() async {
let coordinator = ApprovalCoordinator(logURL: nil)
let target = request()
let decision = Task { await coordinator.requestDecision(for: target) }
while coordinator.current == nil {
await Task.yield()
}
coordinator.deny(target)
_ = await decision.value
XCTAssertEqual(coordinator.log.count, 1)
XCTAssertEqual(coordinator.log.first?.verdict, "deny")
XCTAssertTrue(coordinator.log.first?.intercepted == true)
}
/// 日志必须活过重启。
///
/// 这份数据的用途是"看一周再决定要不要把 dangerous 一档也拦上"——
/// 只存内存的话每次重启都归零,那个决策永远做不了。
func testLogSurvivesRestart() async {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("approval-log-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: url) }
let first = ApprovalCoordinator(logURL: url)
let target = request()
let decision = Task { await first.requestDecision(for: target) }
while first.current == nil { await Task.yield() }
first.deny(target)
_ = await decision.value
// 换一个实例,模拟 app 重启。
let reopened = ApprovalCoordinator(logURL: url)
XCTAssertEqual(reopened.log.count, 1)
XCTAssertEqual(reopened.log.first?.command, target.command)
XCTAssertEqual(reopened.log.first?.verdict, "deny")
reopened.clearLog()
XCTAssertTrue(ApprovalCoordinator(logURL: url).log.isEmpty)
}
}
@@ -0,0 +1,227 @@
import XCTest
@testable import HubUI
/// 形态尺寸回归。
///
/// 存在的理由是一个真实事故:悬停态内容算下来要 113.5pt,而容器给的是 96pt,
/// 外面还套着 clipShape —— 于是被**静默裁掉** 17.5pt。没有报错、没有警告,
/// 只有用户看到"信息显示不全"。
///
/// 布局裁切在 SwiftUI 里是完全静默的失败,只能靠这种算术断言挡住。
final class IslandLayoutTests: XCTestCase {
/// 本机实测的内建屏几何(16" M4 Max,默认缩放)。
private let notched = NotchGeometry(
screenFrame: CGRect(x: 0, y: 0, width: 1728, height: 1117),
notchWidth: 185,
notchHeight: 32
)
/// 外接屏:无刘海。
private let external = NotchGeometry(
screenFrame: CGRect(x: 0, y: 0, width: 1920, height: 1080),
notchWidth: 0,
notchHeight: 0
)
// MARK: - 内容必须放得下
func testHoverBodyFitsItsContent() {
for count in [0, 1, 3, 9, 20] {
for geo in [notched, external] {
let metrics = IslandMetrics.metrics(
for: .hover, geometry: geo, sessionCount: count
)
XCTAssertGreaterThanOrEqual(
metrics.bodyHeight,
IslandMetrics.hoverContentHeight,
"悬停态容器装不下内容(\(count) 个会话)—— 会被 clipShape 静默裁掉"
)
XCTAssertGreaterThanOrEqual(
metrics.bodyWidth,
IslandMetrics.hoverScreenSize(sessionCount: count).width,
"悬停态宽度装不下小人排(\(count) 个会话)"
)
}
}
}
func testExpandedBodyFitsHeaderAndDetailCard() {
for geo in [notched, external] {
for count in [1, 4, 9, 30] {
let metrics = IslandMetrics.metrics(
for: .expanded, geometry: geo, sessionCount: count
)
// header + 分栏 + 详情卡 + 至少一行列表,否则展开的意义就没了。
let minimum = IslandMetrics.expandedHeaderHeight
+ IslandMetrics.expandedTabHeight
+ IslandMetrics.expandedDetailHeight
+ IslandMetrics.expandedRowHeight
XCTAssertGreaterThanOrEqual(metrics.bodyHeight, minimum)
XCTAssertLessThanOrEqual(
metrics.bodyHeight, IslandMetrics.expandedMaxHeight
)
}
}
}
/// 项目栏至少要能放下几行。
///
/// 展开态的高度是按**会话数**算的,但「项目」栏里通常有二十来个项目 ——
/// 会话很少时(比如只有 1 个)高度会收得很紧,别把项目列表挤成一条缝。
func testProjectTabHasUsableHeightEvenWithOneSession() {
let metrics = IslandMetrics.metrics(
for: .expanded, geometry: notched, sessionCount: 1
)
let chrome = IslandMetrics.expandedHeaderHeight + IslandMetrics.expandedTabHeight
let listRoom = metrics.bodyHeight - chrome
XCTAssertGreaterThanOrEqual(
listRoom, IslandMetrics.expandedProjectRowHeight * 4,
"只有 1 个会话时,项目栏连 4 行都放不下"
)
}
/// 展开态高度跟着会话数走:4 个会话时不该挂着一大片空玻璃。
func testExpandedHeightAdaptsToSessionCount() {
let few = IslandMetrics.expandedHeight(sessionCount: 3)
let many = IslandMetrics.expandedHeight(sessionCount: 9)
XCTAssertLessThan(few, many)
XCTAssertEqual(
IslandMetrics.expandedHeight(sessionCount: 200),
IslandMetrics.expandedMaxHeight
)
// 一个会话都没有时也要能立住,不能算出负数或 0。
XCTAssertGreaterThan(IslandMetrics.expandedHeight(sessionCount: 0), 0)
}
func testApprovalBodyFitsItsContent() {
for geo in [notched, external] {
let metrics = IslandMetrics.metrics(for: .approval, geometry: geo)
XCTAssertGreaterThanOrEqual(
metrics.bodyHeight, IslandMetrics.approvalContentHeight
)
}
}
/// 所有形态都不能超出固定的窗口尺寸 —— 窗口不会跟着变大,超出部分是画不出来的。
func testEveryStateFitsInsideThePanel() {
let states: [IslandState] = [.rest, .hover, .expanded, .intrusion, .approval]
for state in states {
for geo in [notched, external] {
let metrics = IslandMetrics.metrics(
for: state, geometry: geo, sessionCount: 9
)
XCTAssertLessThanOrEqual(
metrics.bodyWidth, IslandMetrics.panelSize.width,
"\(state) 比窗口还宽"
)
XCTAssertLessThanOrEqual(
metrics.bodyHeight + geo.notchHeight, IslandMetrics.panelSize.height,
"\(state) 比窗口还高"
)
}
}
}
// MARK: - 折叠态不能压住菜单栏
/// 折叠态上半段的宽度必须**严格等于**刘海宽。
///
/// 多出一点点就会压在菜单栏图标上,把输入法、电量的点击吃掉 ——
/// 这是"岛把系统搞坏了"最容易发生的方式。
func testRestShapeDoesNotCoverTheMenuBar() {
let metrics = IslandMetrics.metrics(for: .rest, geometry: notched)
let shape = IslandShape(
notchWidth: notched.notchWidth,
notchHeight: notched.notchHeight,
bodyWidth: metrics.bodyWidth,
bodyHeight: metrics.bodyHeight,
bottomRadius: metrics.bottomRadius
)
let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize)
let path = shape.path(in: rect).cgPath
let centerX = rect.midX
// 刘海高度之内、刘海边缘之外的点,全都必须落在形状外面。
for y in stride(from: CGFloat(1), through: notched.notchHeight - 1, by: 4) {
let justOutside = centerX + notched.notchWidth / 2 + 2
XCTAssertFalse(
path.contains(CGPoint(x: justOutside, y: y)),
"折叠态在 y=\(y) 处越过了刘海右沿,会吃掉菜单栏点击"
)
XCTAssertTrue(
path.contains(CGPoint(x: centerX, y: y)),
"折叠态在 y=\(y) 处没有覆盖刘海中心"
)
}
}
/// 折叠态确实比刘海宽("液体挤出"),但只在刘海下沿之后。
func testRestFlaresOnlyBelowTheNotch() {
let metrics = IslandMetrics.metrics(for: .rest, geometry: notched)
XCTAssertGreaterThan(metrics.bodyWidth, notched.notchWidth)
let shape = IslandShape(
notchWidth: notched.notchWidth,
notchHeight: notched.notchHeight,
bodyWidth: metrics.bodyWidth,
bodyHeight: metrics.bodyHeight,
bottomRadius: metrics.bottomRadius
)
let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize)
let path = shape.path(in: rect).cgPath
let centerX = rect.midX
let belowNotch = notched.notchHeight + metrics.bodyHeight / 2
XCTAssertTrue(
path.contains(CGPoint(
x: centerX + notched.notchWidth / 2 + 2, y: belowNotch
)),
"刘海下方应该已经扩张开了"
)
}
// MARK: - 无刘海走胶囊
func testExternalScreenUsesPillNotHalfLip() {
XCTAssertFalse(external.hasNotch)
let metrics = IslandMetrics.metrics(for: .rest, geometry: external)
let shape = IslandShape(
notchWidth: external.notchWidth,
notchHeight: external.notchHeight,
bodyWidth: metrics.bodyWidth,
bodyHeight: metrics.bodyHeight,
bottomRadius: metrics.bottomRadius
)
let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize)
let path = shape.path(in: rect).cgPath
// 胶囊距顶悬浮,所以 y=0 那一行不该有东西。
XCTAssertFalse(path.contains(CGPoint(x: rect.midX, y: 0)))
XCTAssertTrue(
path.contains(CGPoint(x: rect.midX, y: 8 + metrics.bodyHeight / 2))
)
}
// MARK: - 悬停宽度自适应
func testHoverWidthGrowsWithSessionCountAndIsBounded() {
let one = IslandMetrics.hoverWidth(sessionCount: 1)
let five = IslandMetrics.hoverWidth(sessionCount: 5)
let nine = IslandMetrics.hoverWidth(sessionCount: 9)
let absurd = IslandMetrics.hoverWidth(sessionCount: 400)
XCTAssertLessThanOrEqual(one, five)
XCTAssertLessThan(five, nine)
// 超过上限的会话数不该把岛撑爆 —— 多出来的在展开态的列表里看。
XCTAssertEqual(nine, absurd)
XCTAssertLessThanOrEqual(absurd, 520)
XCTAssertGreaterThanOrEqual(one, 264)
}
/// 会话数为 0 时也要有个合理的宽度,不能算出 0 或负数。
func testHoverWidthHandlesEmptyStore() {
XCTAssertGreaterThan(IslandMetrics.hoverWidth(sessionCount: 0), 0)
XCTAssertGreaterThan(IslandMetrics.hoverRowWidth(sessionCount: 0), 0)
}
}
@@ -0,0 +1,164 @@
import HubCore
import XCTest
@testable import HubUI
/// 守像素小人的网格完整性和形象稳定性。
///
/// 这两类问题在界面上都**不会报错**,只会显示成"有点歪"或"怎么又换发色了",
/// 只能靠断言挡住。
final class SpriteLibraryTests: XCTestCase {
private let statuses: [SessionStatus] = [.busy, .waiting, .shell, .idle, .unknown]
/// 真实量级的 seed,外加整数边界。
///
/// 生产里的 seed 是 `SpriteSeed.stable(sessionId)`,量级接近 `Int.max`。
/// 边界值单列出来是因为 `abs(Int.min)` 会 trap,而 `magnitude` 不会。
private var realisticSeeds: [Int] {
[
SpriteSeed.stable("8ba8f1c1-6b3d-4e2a-9c11-2f7d0a1b3c4d"),
SpriteSeed.stable("claude-hub-swiftui-rewrite"),
SpriteSeed.stable(""),
Int.max,
Int.min,
0,
-1,
]
}
// MARK: - 网格
/// 每一帧都必须正好 16 行 × 16 列。
///
/// 图是分段拼装的(发型 + 脸 + 身体 + 笔记本),少一行会让笔记本掉出画面,
/// 某行少一格会让整个人歪掉 —— 而 Canvas 只会安静地少画几个方块。
func testEveryFrameIsSixteenBySixteen() {
for status in statuses {
for style in 0..<SpriteCharacter.styleCount {
for pose in poses {
let rows = SpriteLibrary.rows(style: style, pose: pose)
XCTAssertEqual(
rows.count, 16,
"style \(style) / \(pose) 的行数不是 16"
)
for (index, row) in rows.enumerated() {
XCTAssertEqual(
row.count, 16,
"style \(style) / \(pose) 第 \(index) 行是 \(row.count) 格:\(row)"
)
}
}
}
// 走一遍真实入口。
//
// **seed 必须用真实量级的值。** 这个测试原来喂的是 `tick * 7919`
// 这种小整数,于是漏掉了一个会让 app 一显示小人就闪退的整数溢出:
// 真实 seed 来自 FNV 哈希(接近 Int.max),代码里 `abs(seed) * 37`
// 直接溢出 trap。测试的输入不真实 = 测试没在测东西。
for tick in 0..<200 {
for seed in realisticSeeds {
let frame = SpriteLibrary.frame(for: status, tick: tick, seed: seed)
XCTAssertFalse(frame.runs.isEmpty, "\(status) 在 tick \(tick) 画了个空帧")
for run in frame.runs {
XCTAssertLessThanOrEqual(run.x + run.width, 16)
XCTAssertLessThan(run.y, 16)
}
}
}
}
}
private var poses: [SpriteLibrary.Pose] {
var all: [SpriteLibrary.Pose] = [.resting]
for step in 0..<4 {
all.append(.typing(step: step))
all.append(.command(step: step))
all.append(.waving(step: step))
}
for step in 0..<3 { all.append(.yawning(step: step)) }
return all
}
func testMicroFramesAreEightByEight() {
for status in statuses {
for tick in 0..<50 {
for seed in realisticSeeds {
let frame = SpriteLibrary.micro(for: status, tick: tick, seed: seed)
XCTAssertFalse(frame.runs.isEmpty)
for run in frame.runs {
XCTAssertLessThanOrEqual(run.x + run.width, 8, "micro 越界")
XCTAssertLessThan(run.y, 8)
}
}
}
}
}
/// 每一帧都要有眼睛。
///
/// 眼睛是这套画法里最值钱的两个像素 —— 有眼睛是个人,没眼睛是个色块。
/// 拼装逻辑改坏时最先丢的就是它。
func testEveryFrameHasEyes() {
for style in 0..<SpriteCharacter.styleCount {
for pose in poses {
let frame = SpriteFrame(SpriteLibrary.rows(style: style, pose: pose))
XCTAssertTrue(
frame.runs.contains { $0.ink == .eye },
"style \(style) / \(pose) 没有眼睛"
)
}
}
}
// MARK: - 形象稳定性
/// **同一个 sessionId 必须永远长同一个样,跨进程也是。**
///
/// Swift 的 `String.hashValue` 每次进程启动都加盐,用它派生形象的话
/// 每次重开 app 所有人的发色衣服都会换一遍 ——
/// "记住橙头发那个是 acme-admin"这件事就不成立了。
func testSeedIsStableAcrossRuns() {
// 空串的种子是 FNV-1a 的初始偏移量。这个值变了就说明哈希实现被换掉了,
// 而那意味着所有用户的会话形象会集体重排。
XCTAssertEqual(
SpriteSeed.stable(""),
Int(UInt64(0xcbf2_9ce4_8422_2325) % UInt64(Int.max))
)
XCTAssertEqual(
SpriteSeed.stable("8ba8f1c1-0000-4000-8000-000000000000"),
SpriteSeed.stable("8ba8f1c1-0000-4000-8000-000000000000")
)
XCTAssertNotEqual(SpriteSeed.stable("a"), SpriteSeed.stable("b"))
XCTAssertGreaterThanOrEqual(SpriteSeed.stable("任意会话"), 0)
}
func testSameSessionAlwaysGetsSameLook() {
let seed = SpriteSeed.stable("fixed-session-id")
let first = SpriteCharacter(seed: seed, status: .busy)
let second = SpriteCharacter(seed: seed, status: .busy)
XCTAssertEqual(first.hair, second.hair)
XCTAssertEqual(first.shirt, second.shirt)
XCTAssertEqual(first.skin, second.skin)
XCTAssertEqual(first.style, second.style)
}
/// 状态变化只该影响明暗,不该换脸。
func testStatusChangesDimNotIdentity() {
let seed = SpriteSeed.stable("session")
let busy = SpriteCharacter(seed: seed, status: .busy)
let idle = SpriteCharacter(seed: seed, status: .idle)
XCTAssertEqual(busy.style, idle.style)
XCTAssertGreaterThan(busy.dim, idle.dim, "idle 应该更暗")
}
/// 不同会话要能被区分开 —— 全撞成同一个形象就失去意义了。
func testDifferentSessionsSpreadAcrossLooks() {
var looks = Set<String>()
for index in 0..<40 {
let seed = SpriteSeed.stable("session-\(index)")
let character = SpriteCharacter(seed: seed, status: .busy)
looks.insert("\(character.hair)|\(character.shirt)|\(character.style)")
}
XCTAssertGreaterThan(looks.count, 20, "40 个会话只有 \(looks.count) 种形象,区分度不够")
}
}
+126 -67
View File
@@ -4,9 +4,11 @@ Claude Code 本地开发调度中心。用一个 Claude 窗口管理所有项目
## 功能
- **灵动岛** — MacBook 刘海变成 agent 控制中心,Liquid Glass + 状态驱动的像素小人
- **实时会话监控** — 每个 Claude 会话的 `busy / waiting / shell / idle` 状态一眼可见
- **精准跳转** — 点通知或点会话,直接跳到那个会话所在的终端 tab(不是"跳到 iTerm 然后自己找")
- **高风险审批** — 不可逆操作(`rm -rf /`、force push 到 main 等)在岛上拦截确认
- **tmux 多项目管理** — 每个项目一个 tmux 窗口,iTerm2 标签页体验
- **全局通知** — 任何 Claude 做完事 → 桌面弹窗 + 语音提醒(零配置)
- **权限通知** — 任何 Claude 等授权 → 立即提醒你去操作
- **用量统计** — 按项目查看 token 消耗、会话数、模型分布
- **项目发现** — 扫描目录自动生成项目清单
- **浏览器自动化** — Claude in Chrome + DevTools 双引擎协同
@@ -143,47 +145,92 @@ Hub 是你和所有项目 Claude 之间的调度层:
- 你说"在 xxx 跑构建" → Hub 发送命令到对应窗口
- 任何窗口的 Claude 做完事 → 你收到通知
## 通知机制(核心功能)
## 灵动岛(Claude Hub.app)
所有 Claude 实例做完事或等授权时,**自动通知你**。零配置,全局生效。
原生 SwiftUI 应用,把 MacBook 刘海变成 AI agent 的控制中心。要求 **macOS 26+**,自己编译还需要 **Xcode 26**。
### 工作原理
Claude Code 内置 Hook 机制,在特定事件发生时自动执行 shell 脚本。`setup.sh` 会把两个 hook 注入到全局配置 `~/.claude/settings.json`,所有 Claude 实例共享,无需在每个项目单独配置。
### 完整链路
```
Claude 完成一轮回复
→ 触发 Stop hook(hub-hook-stop.sh)
→ 1. 桌面弹窗通知(点击可跳转到 iTerm2)
→ 2. 语音播报(随机:"搞定了,来看看" / "干完了,等你验收" 等 5 种)
→ 3. 写事件 JSON 到 /tmp/hub-signals/events/
→ 4. 发 tmux 信号(唤醒 Hub 中等待的进程)
↓
Tauri 托盘 app 监听到事件文件
→ 面板实时更新事件列表
→ 系统通知
Claude 需要用户授权
→ 触发 Notification hook(hub-hook-permission.sh)
→ 同上链路,但提示语变成"需要你授权" / "在等你点确认"等
```bash
bash scripts/build-swift-app.sh # 编译 + 签名 + 安装到 /Applications
bash scripts/setup-swift-hooks.sh # 安装 hook 到 ~/.claude/settings.json
open -a "Claude Hub"
```
### 通知渠道
### 打发布包
| 渠道 | 说明 | 依赖 |
```bash
bash scripts/release.sh # 跑测试 → universal 编译 → 签名 → dmg
```
产物在 `release/Claude-Hub-<版本>.dmg`,arm64 + x86_64 双架构。
和日常构建的分工:`build-swift-app.sh` 只编当前架构、装进 `/Applications`,几秒钟;
`release.sh` 打完整发布包,**不碰你正在用的那个实例**。
包是**自签名、未公证**的。自己的机器没问题,别人下载后首次打开会被 Gatekeeper 拦,
需要右键 → 打开,或者 `xattr -dr com.apple.quarantine "/Applications/Claude Hub.app"`。
要做到双击即用得有 Apple Developer Program 的 Developer ID 证书 + 公证。
**同一个 bundle identifier 只允许对应 /Applications 里那一个 .app。** 留第二份副本
(哪怕只是"调试用")会让 LaunchServices 注册出多条记录,通知授权和 TCC 授权互相抢,
存活判断也会误匹配 —— 构建脚本因此装完就删掉暂存副本。
### 五种形态
| 形态 | 触发 | 内容 |
|------|------|------|
| 桌面弹窗 | macOS 通知中心,点击跳转 iTerm2 | terminal-notifier(推荐),缺失时降级到 osascript |
| 语音播报 | 随机语音,做完/授权各 5 种话术 | say + afplay(macOS 内置) |
| 托盘面板 | 实时事件列表,点击项目可直接跳转 | Tauri app(文件系统监听 /tmp/hub-signals/events/) |
| tmux 信号 | 程序化等待任务完成 | tmux wait-for(Hub 内部使用) |
| **折叠** | 常驻 | 贴刘海挤出一条 14pt 的"下唇":会话数 + 状态点阵 + 待办徽章 |
| **悬停** | 鼠标靠近 | 每个会话一个 32pt 像素小人,状态驱动动作 + 一行统计 |
| **展开** | 点击 | 完整会话列表,点一行精准跳回对应终端 tab |
| **闯入** | 会话完成 / 需要输入 | 主动膨胀提示,2–2.6 秒后回落 |
| **审批** | 拦截到不可逆操作 | 命令内容 + 拒绝/按住确认,**不会自动消失** |
### 为什么零配置
无刘海的外接显示器上,折叠态自动退化为屏幕顶部的悬浮胶囊。
- Hook 配置在 `~/.claude/settings.json`,对所有 Claude 实例全局生效
- `setup.sh` 一次注入,永久生效,不需要在每个项目重新配置
- 语音文件在 `setup.sh` 时一次性生成到 `sounds/` 目录
### 像素小人
每个运行中的会话配一个,动作由状态驱动:`busy` 敲键盘、`waiting` 抬头挥手、`shell` 敲命令行、`idle` 呼吸 + 偶发打哈欠(按 sessionId 错峰,避免所有人同时打哈欠)。
全部小人共用一个 12fps 主时钟,折叠态 / 窗口被遮挡 / 锁屏 / 显示器休眠时整体冻结。
## 实时会话监控
状态来自 Claude Code 自己维护的 `~/.claude/sessions/<PID>.json`,是**权威数据**而非推断:
| 状态 | 含义 |
|------|------|
| `busy` | 正在干活 |
| `waiting` | 等你输入或授权(附带 `waitingFor` 说明原因) |
| `shell` | 正在跑 shell 命令 |
| `idle` | 空闲 |
同时区分 `interactive`(前台会话)和 `bg`(后台任务)。
## 精准跳转
点通知、点岛上的会话行,都会跳到**那个会话所在的具体终端 tab**。
实现方式是从 iTerm 的 `jobPid` 向上遍历进程树,第一个存在 `sessions/<PID>.json` 的祖先就是该 tab 承载的会话 —— 全程只用整数 PID 和 UUID,不做任何字符串匹配。tmux `-CC` 会话和非 tmux 的原生会话用同一套算法。
`kind: bg` 的会话跑在 daemon 下、不在任何终端里,改为定位到显示它的 `claude agents` 查看器 tab。
## 高风险操作审批
`PreToolUse` hook 会拦截**不可逆**操作,在岛上弹出审批:
| 级别 | 例子 | 默认行为 |
|------|------|----------|
| 普通 | `npm install`、`git commit` | 直接放行 |
| 危险 | `rm -rf node_modules`、`git reset --hard`、`sudo` | 只记进审批日志,不拦截 |
| 不可逆 | `rm -rf /`、force push 到 main、`DROP DATABASE` | **拦截**,需按住 900ms 确认 |
默认只拦最后一档,把日常摩擦压到接近零。想更严格可以在设置里把「危险」一档也打开 —— 建议先看一周审批日志再决定。
防误点做了六重保护:按钮间距 24pt 不融合、出现后 700ms 时间锁、指针进入 120ms 冷却、不可逆操作必须长按、**默认焦点在拒绝**(`⏎` 和 `⎋` 都是拒绝,允许必须 `⌘⏎`)、60 秒超时自动拒绝。
## 主窗口
托盘菜单 → 打开主窗口。五个分区:会话 / 项目 / 用量 / 审批日志 / 设置。
和岛的分工:**岛是一瞥**(零点击获取状态),**主窗口是深挖**(用量趋势、项目管理、审批历史)。两者共享同一套数据和组件。
## 项目管理
@@ -198,24 +245,20 @@ bash scripts/add-project.sh my-app ~/code/my-app app 前端
bash scripts/welcome.sh
```
## 通知面板(Tauri App)
主窗口的「项目」页也能直接扫描、搜索、按 8 种模式启动(Claude Code / 跳过权限 / 纯终端 / 继续上次会话 / 选择历史会话 / Finder / VS Code)。
菜单栏托盘应用,实时展示项目状态和通知。
- 左键点击托盘图标 → 显示面板
- 关闭窗口 × → 隐藏到托盘(不退出)
- 右键托盘 → 退出
- 支持项目搜索(按名称/描述/路径模糊匹配)
- **用量统计** — 按项目查看 token 消耗、会话数、模型分布
`setup.sh` 首次运行会自动编译 release 版本(约 2-3 分钟),之后每次启动直接运行二进制,秒开。编译产物是本机架构(ARM/x86),不会提交到 git。
手动编译/重新编译:
## 开发
```bash
bash scripts/build-app.sh
cd HubKit
swift build # 编译
swift test # 87 个测试
swift run hubprobe list # 命令行查看会话与终端绑定
swift run hubprobe jump 3 # 跳到第 3 个会话
```
工程结构见 `HubKit/`,实测踩过的坑记在 `HubKit/NOTES.md`。
## 浏览器自动化
Claude in Chrome(视觉引擎)+ chrome-devtools-mcp(数据引擎)双引擎协同,连接同一个 Chrome 浏览器。
@@ -265,16 +308,29 @@ Not authorized to send Apple events to iTerm2
2. 找到 Terminal(或 iTerm2),勾选允许控制
3. 手动验证:`osascript -e 'tell application "iTerm2" to activate'`
### 托盘 app 编译失败
### 灵动岛不出现 / 点不动
```bash
# 检查 Xcode CLT
xcode-select -p
# 1. 确认跑的是新版(可执行文件名叫 ClaudeHub)
pgrep -x ClaudeHub || open -a "Claude Hub"
# 清理后重新编译
cd app/src-tauri && cargo clean && cargo build --release
# 2. 重新编译安装
bash scripts/build-swift-app.sh
# 首次编译需下载约 2GB 依赖,确保网络通畅
# 3. 确认 hook 链路通
~/.local/bin/hubctl doctor
```
判断是否在运行**必须**用 `pgrep -x ClaudeHub`。用 `pgrep -f "Claude Hub"`
会匹配到任何路径里含这个串的进程,历史上仓库里同时存在过三个同名 bundle,
导致明明没在跑也被判成"已运行"。
### app 编译失败
```bash
# 需要 Xcode 26(macOS 26 SDK)——灵动岛依赖 Liquid Glass API
xcrun --show-sdk-version # 应 >= 26
sudo xcodebuild -runFirstLaunch
```
### 浏览器双引擎连不上
@@ -296,25 +352,28 @@ claude mcp get chrome-devtools
claude-hub/
├── CLAUDE.md # Hub 行为规范(Claude 读取此文件决定行为)
├── projects.yaml.example # 项目配置模板
├── app/ # 通知面板(Tauri v2)
│ ├── src/ # 前端(HTML + JS)
│ └── src-tauri/ # 后端(Rust)
│ └── src/
│ ├── main.rs # 入口 + Tauri 命令
│ ├── watcher.rs # 文件监听(/tmp/hub-signals/events/)
│ ├── projects.rs # 项目管理 + tmux 集成
│ └── usage.rs # 用量统计(解析 Claude 日志)
├── HubKit/ # 全部业务逻辑(SwiftPM package,可 swift test)
│ ├── NOTES.md # 实测踩到的坑(值得先读一遍)
│ └── Sources/
│ ├── HubCore/ # 数据模型(AgentSession 等)
│ ├── HubProbe/ # 会话探测:sessions/*.json + tmux + 进程树
│ ├── HubJump/ # 跳转引擎:iTerm jobPid 祖先链
│ ├── HubIPC/ # Unix socket + hook 协议 + 风险分级
│ ├── HubProjects/ # projects.yaml + git 状态 + 终端派发
│ ├── HubUI/ # 灵动岛 + 主窗口(SwiftUI)
│ ├── hubctl/ # hook 可执行文件
│ └── ClaudeHubApp/ # 薄壳:@main + 窗口装配
├── Resources/AppIcon.icns
├── scripts/
│ ├── setup.sh # 一键初始化(hooks + 语音 + 编译)
│ ├── setup.sh # 一键初始化(hook + 编译安装)
│ ├── setup-swift-hooks.sh # 装 hubctl 并改写 ~/.claude/settings.json
│ ├── build-swift-app.sh # 编译 + 签名 + 安装到 /Applications
│ ├── setup-browser.sh # 浏览器环境配置
│ ├── build-app.sh # 编译托盘 app(检查环境 + release 构建)
│ ├── cc.sh # 一键启动(托盘 app + 调度中心)
│ ├── cc.sh # 一键启动(app + 调度中心)
│ ├── scan-projects.sh # 扫描目录发现项目
│ ├── add-project.sh # 添加项目
│ ├── welcome.sh # 项目列表展示
│ ├── project-menu.sh # tmux 窗口菜单
│ ├── hub-hook-stop.sh # Stop hook(做完通知)
│ ├── hub-hook-permission.sh # Notification hook(授权通知)
│ ├── hub-run.sh # 命令包装(带信号)
│ └── hub-notify.sh # 手动通知
├── skills/
File renamed without changes.
-10
View File
@@ -1,10 +0,0 @@
{
"name": "claude-hub-app",
"version": "0.1.0",
"description": "Claude Hub 通知面板",
"scripts": {
"tauri": "cargo tauri",
"dev": "cargo tauri dev",
"build": "cargo tauri build"
}
}
-8
View File
@@ -1,8 +0,0 @@
# 隐私保护:把编译时的本地绝对路径重映射成中性路径,
# 避免 Rust panic 信息把开发者用户名 / 源码目录暴露进 release 二进制。
[build]
rustflags = [
"--remap-path-prefix=/Users/dev/Documents/code/claude-hub=/build/claude-hub",
"--remap-path-prefix=/Users/dev/.cargo=/build/cargo",
"--remap-path-prefix=/Users/dev/.rustup=/build/rustup",
]
-5645
View File
File diff suppressed because it is too large. Load diff
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "claude-hub"
version = "0.1.0"
description = "Claude Hub - 本地开发调度中心通知面板"
authors = ["hengjun-dev"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
notify = "7"
chrono = "0.4"
# release 模式精简二进制 + 去除调试符号,配合 .cargo/config.toml 的路径重映射保护用户名隐私
[profile.release]
strip = "symbols"
opt-level = 3
codegen-units = 1
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-15
View File
@@ -1,15 +0,0 @@
{
"identifier": "default",
"description": "Claude Hub default capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-show",
"core:window:allow-set-focus",
"notification:default",
"notification:allow-notify",
"notification:allow-is-permission-granted",
"notification:allow-request-permission"
]
}
@@ -1 +0,0 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command Line truncated
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Claude Hub default capabilities","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-set-focus","notification:default","notification:allow-notify","notification:allow-is-permission-granted","notification:allow-request-permission"]}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more