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>
No files matched your search
@@ -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/
|
||||
@@ -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. **报告就绪** — 一句话告知用户调度中心已就绪
|
||||
|
||||
@@ -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 的决策输出。
|
||||
|
||||
### 交互缺陷必须用真实鼠标验证
|
||||
|
||||
这一轮的全部交互缺陷(点击被系统吞掉、整行只有文字可点、外部点击监听误伤自己)
|
||||
在截图上**一个都看不出来** —— 布局、配色、内容全是对的,只是点不动。
|
||||
必须真的移动鼠标、真的点下去再截图比对。
|
||||
|
||||
配套的坑:**先确认光标在哪块屏幕上**。光标跑到外接屏时所有移动都落空,
|
||||
表现成"悬停功能坏了",会浪费大量时间去查根本没坏的代码。
|
||||
@@ -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"]),
|
||||
]
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
"""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 ? "不可逆" : "危险操作"
|
||||
}
|
||||
}
|
||||
@@ -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)) + "…"
|
||||
}
|
||||
}
|
||||
@@ -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 的形变动画脱节。
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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#",
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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) 种形象,区分度不够")
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"]}}
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 32 KiB |