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

279 lines
11 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
/// 单个项目的 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)
}
}