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

95 lines
3.9 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 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
}
}