删掉 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>
95 lines
3.9 KiB
Swift
95 lines
3.9 KiB
Swift
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
|
||
}
|
||
}
|