Files
b6d90cac2b 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>
2026-07-29 19:20:12 +08:00

178 lines
8.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}