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

204 lines
7.5 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 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)
}
}