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

296 lines
10 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 HubCore
import HubIPC
import SwiftUI
/// 事件闯入的内容。
public struct IntrusionEvent: Equatable, Sendable {
public enum Kind: Equatable, Sendable {
case finished // Claude 完成一轮
case needsInput // 等待输入或授权
}
public let kind: Kind
public let sessionId: String
public let title: String
public let detail: String
public init(kind: Kind, sessionId: String, title: String, detail: String) {
self.kind = kind
self.sessionId = sessionId
self.title = title
self.detail = detail
}
var accent: Color {
switch kind {
case .finished: return IslandTheme.busy
case .needsInput: return IslandTheme.waiting
}
}
var symbol: String {
switch kind {
case .finished: return "checkmark.circle.fill"
case .needsInput: return "exclamationmark.circle.fill"
}
}
}
/// 闯入态视图。
struct IntrusionContent: View {
let event: IntrusionEvent
let session: AgentSession?
var body: some View {
HStack(spacing: 12) {
if let session {
SpriteScreen(cornerRadius: 7) {
PixelSprite(session: session, pixelSize: 2, gridSize: 16)
}
} else {
Image(systemName: event.symbol)
.font(.system(size: 20))
.foregroundStyle(event.accent)
.frame(width: 32, height: 32)
}
VStack(alignment: .leading, spacing: 2) {
Text(event.title)
.font(IslandTheme.label(13, .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text(event.detail)
.font(IslandTheme.label(11))
.foregroundStyle(.white.opacity(0.62))
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: event.symbol)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(event.accent)
}
.padding(.horizontal, 14)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// 审批态视图。
///
/// 这是整个 app 里**唯一一个做错了会真的删掉代码**的地方,所以防误点护栏是冗余的:
/// 六重保护叠加,任何单点失效都不至于误放行。
struct ApprovalContent: View {
let request: ApprovalRequest
let queueCount: Int
let onAllow: () -> Void
let onDeny: () -> Void
let onSkip: () -> Void
/// ① 时间锁:面板出现后 700ms 内「允许」不可用。
///
/// 这同时也覆盖掉了「闯入态膨胀时指针恰好停在展开区域」的误击窗口 ——
/// 岛是会主动弹出来的,用户的手可能正好在那儿。
@State private var unlocked = false
/// ② 长按进度(仅 L3)。
@State private var holdProgress: Double = 0
@State private var holdTask: Task<Void, Never>?
/// ③ 指针冷却:进入面板后 120ms 内的点击全丢弃。
@State private var pointerSettledAt: Date?
private var allowEnabled: Bool {
guard unlocked else { return false }
guard let settled = pointerSettledAt else { return true }
return Date().timeIntervalSince(settled) > 0.12
}
var body: some View {
VStack(spacing: 0) {
header
commandBlock
Spacer(minLength: 0)
buttons
}
.padding(.vertical, 12)
.onAppear {
Task {
try? await Task.sleep(for: .milliseconds(700))
unlocked = true
}
}
.onContinuousHover { phase in
if case .active = phase, pointerSettledAt == nil {
pointerSettledAt = Date()
}
}
}
private var header: some View {
HStack(spacing: 10) {
Image(systemName: request.risk == .irreversible
? "exclamationmark.octagon.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 18))
.foregroundStyle(riskColor)
VStack(alignment: .leading, spacing: 1) {
Text(request.projectName)
.font(IslandTheme.label(13, .semibold))
.foregroundStyle(.white)
Text(request.toolName)
.font(IslandTheme.label(11))
.foregroundStyle(.white.opacity(0.62))
}
Spacer(minLength: 6)
if queueCount > 1 {
Text("1/\(queueCount)")
.font(IslandTheme.label(10, .semibold))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.62))
.padding(.horizontal, 6)
.frame(height: 18)
.background(.white.opacity(0.10), in: .capsule)
.onTapGesture(perform: onSkip)
}
Text(riskLabel)
.font(IslandTheme.label(10, .bold))
.foregroundStyle(.black)
.padding(.horizontal, 7)
.frame(height: 20)
.background(riskColor, in: .capsule)
}
.padding(.horizontal, 16)
.frame(height: 48)
}
private var commandBlock: some View {
ScrollView {
Text(request.command)
.font(IslandTheme.mono(12))
.foregroundStyle(.white.opacity(0.92))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
.scrollIndicators(.never)
.frame(height: 84)
.background(
// 这块是我们自己压暗的深色底,所以下面放什么材质都可控。
Color.black.opacity(0.48),
in: .rect(cornerRadius: 10, style: .continuous)
)
.padding(.horizontal, 16)
}
private var buttons: some View {
VStack(spacing: 6) {
HStack(spacing: 0) {
Button(action: onDeny) {
Text("拒绝")
}
// 不用系统的 .glass:岛的 panel 基本不是 key window,
// AppKit 会把非活跃窗口里的系统按钮整体去饱和,两个按钮都变灰 ——
// 在唯一需要立刻决策的地方显示成"点不了"是不可接受的。
.buttonStyle(IslandButtonStyle(
emphasis: .secondary, width: 120, height: 34
))
// ⑤ 默认动作是拒绝。⏎ 和 ⎋ 都拒绝,「允许」必须 ⌘⏎。
// **永远不要让 Return 触发允许。**
.keyboardShortcut(.defaultAction)
// ① 空间隔离:24pt 间距,故意大于 GlassEffectContainer 的 22pt 融合阈值,
// 让两个按钮保持独立、不粘连成一块玻璃(粘连会助长误点)。
Spacer().frame(width: 24)
allowButton
}
// 键盘提示。
//
// 这个 app 是 .accessory + nonactivatingPanel,面板未必拿得到 key ——
// 拿不到时 SwiftUI 的 .keyboardShortcut 是死的,由 IslandController 的
// 局部按键监听接管。两条路的键位语义完全一致,所以提示始终成立。
Text(request.requiresHoldToConfirm
? "⏎ / ⎋ 拒绝 · 允许必须按住"
: "⏎ / ⎋ 拒绝 · ⌘⏎ 允许")
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.38))
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 16)
.padding(.top, 10)
}
@ViewBuilder
private var allowButton: some View {
let label = request.requiresHoldToConfirm
? (holdProgress > 0 ? "按住…" : "按住确认")
: "允许"
Button(action: {
// ④ L3 必须长按,普通点击不生效。
guard !request.requiresHoldToConfirm else { return }
guard allowEnabled else { return }
onAllow()
}) {
ZStack {
if request.requiresHoldToConfirm {
GeometryReader { proxy in
Rectangle()
.fill(.black.opacity(0.28))
.frame(width: proxy.size.width * holdProgress)
}
}
Text(label)
}
}
.buttonStyle(IslandButtonStyle(
emphasis: .prominent, tint: riskColor, width: 120, height: 34
))
.keyboardShortcut(.return, modifiers: .command)
.disabled(!allowEnabled)
.opacity(allowEnabled ? 1 : 0.45)
.overlay {
// 时间锁的可视化:一圈进度环,让用户知道为什么点不了。
if !unlocked {
Circle()
.stroke(.white.opacity(0.35), lineWidth: 1.5)
.frame(width: 16, height: 16)
}
}
.simultaneousGesture(holdGesture)
}
private var holdGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard request.requiresHoldToConfirm, allowEnabled, holdTask == nil else { return }
holdTask = Task {
let steps = 30
for step in 1...steps {
try? await Task.sleep(for: .milliseconds(30))
if Task.isCancelled { return }
holdProgress = Double(step) / Double(steps)
}
if !Task.isCancelled { onAllow() }
}
}
.onEnded { _ in
// 松手过早:取消并把进度弹回去。
holdTask?.cancel()
holdTask = nil
withAnimation(.spring(response: 0.25)) { holdProgress = 0 }
}
}
private var riskColor: Color {
request.risk == .irreversible ? IslandTheme.danger : IslandTheme.waiting
}
private var riskLabel: String {
request.risk == .irreversible ? "不可逆" : "危险操作"
}
}