Files
wuguanganandClaude Opus 5 a9363e43c9 feat: 共用密钥中心 + 项目账号密码库
两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。

**共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍,
而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、
勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env
(目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。

**账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密,
查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。

两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死
「密码的值不会出现在给 AI 的那个目录里」。

三个决定了做法的发现:

1. 「让 AI Read 那个文件」会把密钥写进两个明文文件——
   ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API),
   以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。
   所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和
   「不要用 Read/cat/grep」禁令的三句话。换成 source 之后,
   transcript 里只剩路径和变量名,值一次都不出现。

2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。
   README 和代码注释都按分装写,不吹成边界。

3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时,
   默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」
   这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它——
   文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。
   为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。

几个不写下来一定会再踩的坑(已进 NOTES.md):
- .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644
- GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开
- 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨;
  EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点
- LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹
- 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉
- .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号;
  多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级

617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、
#、多行 PEM 和含单引号的值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:45:20 +08:00

932 lines
40 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 AppKit
import HubCore
import HubProbe
import HubProjects
import SwiftUI
/// 灵动岛的生命周期与交互控制。
///
/// 负责:建窗口、按屏幕几何定位、随形态更新命中路径、悬停探测、状态机迁移。
///
/// **形态本身不存在这里,存在 `IslandModel` 里。** 控制器只是唯一的写入口 ——
/// 这样命中路径、tracking area、外部点击监听这些 AppKit 侧的东西
/// 才有机会跟着形态一起更新。
@MainActor
public final class IslandController {
private let store: SessionStore
private let approvals: ApprovalCoordinator
private let prompts: AgentPromptCoordinator
private let projects: ProjectStore
private let closedSessions: ClosedSessionStore
private let placement: IslandPlacementStore
private let model = IslandModel()
private var panel: IslandPanel?
private var hostingView: IslandHostingView<AnyView>?
private var geometry: NotchGeometry?
private var intrusionDismissTask: Task<Void, Never>?
/// 闯入之前是什么形态,回落时要还原。
private var stateBeforeIntrusion: IslandState = .rest
/// 悬停探测的兜底链路(主链路是宿主视图上的 `NSTrackingArea`)。
private var mouseMonitor: Any?
private var hoverExitWorkItem: DispatchWorkItem?
private var hoverEnterWorkItem: DispatchWorkItem?
/// 展开前要求指针在岛上**停留**多久。
///
/// 没有这个延迟的话,鼠标沿菜单栏横穿去点右上角图标时岛就会弹出来 ——
/// 用户的原话是"还没接近就立刻出来,很奇怪"。
/// 250ms 足够滤掉路过,又不至于让有意悬停的人觉得迟钝。
private static let hoverEnterDelay: TimeInterval = 0.25
/// 展开态下点击岛外任意处收回。只在展开时安装,避免常驻全局监听。
private var outsideClickMonitor: Any?
/// 审批态的按键处理(⌘⏎ / ⎋)。
private var keyMonitor: Any?
/// 收缩命中区的延迟任务,见 `updateHitPath`。
private var hitPathShrinkWork: DispatchWorkItem?
/// 显示器配置变化的监听。插拔外接屏、改缩放都要重新对齐。
private var screenObserver: NSObjectProtocol?
/// 从岛上启动一个项目。由 app 层注入(要用到 TerminalDispatch)。
public var onLaunch: ((Project, LaunchMode) -> Void)?
/// 问这个项目的密钥路径。**没绑定共用密钥就返回 nil**,菜单里那一项也就不出现。
///
/// 返回 `String?` 而不是一个 `() -> Void`:出现条件必须和主窗口那边一模一样,
/// 而主窗口是「没绑定就不给这一项」。给一个点了没反应的菜单项,
/// 正是 `ProjectMenu` 注释里记着的那种漂移。
///
/// app 层注入 —— 岛不该认识 `SharedSecretStore`。
public var secretPath: ((Project) -> String?)?
public init(
store: SessionStore,
approvals: ApprovalCoordinator,
prompts: AgentPromptCoordinator,
projects: ProjectStore,
closedSessions: ClosedSessionStore,
placement: IslandPlacementStore
) {
self.store = store
self.approvals = approvals
self.prompts = prompts
self.projects = projects
self.closedSessions = closedSessions
self.placement = placement
placement.onChange = { [weak self] in self?.relocate() }
}
/// 换屏设置变了 —— 立刻挪过去。
///
/// 不能复用 `realign()`:那里有 `guard geo != geometry else { return }`,
/// 用来挡住显示器参数变化的重复通知。但用户手动切设置时,如果两块屏的
/// 几何恰好相同(比如两台同型号外接屏),那个 guard 会让设置**看起来没生效**。
public func relocate() {
guard let panel, let screen = placement.screen() else { return }
let geo = NotchGeometry(screen: screen)
geometry = geo
position(panel, on: geo)
installRootView(geometry: geo)
updateHitPath()
HubLog.island.notice("""
按设置重新定位 —— 屏幕 \(screen.localizedName, privacy: .public),\
跟随光标 \(self.placement.followsCursor, privacy: .public)
""")
}
// MARK: - 生命周期
public func show() {
guard let screen = placement.screen() else {
HubLog.island.error("找不到可用屏幕,灵动岛未启动")
return
}
let geo = NotchGeometry(screen: screen)
geometry = geo
let hosting = IslandHostingView(rootView: AnyView(EmptyView()))
hosting.frame = CGRect(origin: .zero, size: IslandMetrics.panelSize)
hostingView = hosting
let panel = IslandPanel(contentView: hosting)
self.panel = panel
position(panel, on: geo)
// rootView **只设这一次**。之后全部靠 @Observable 驱动重绘 ——
// 反复替换 rootView 会让动画无从发生、@State 每次归零。
installRootView(geometry: geo)
hosting.onPointerMove = { [weak self] point in
self?.handlePointer(at: point)
}
updateHitPath()
panel.orderFrontRegardless()
installMouseMonitor()
observeScreenChanges()
HubLog.island.notice("""
已启动 —— 屏幕 \(screen.localizedName, privacy: .public),\
刘海 \(Int(geo.notchWidth), privacy: .public)×\
\(Int(geo.notchHeight), privacy: .public)pt,\
中心 X \(geo.centerX, privacy: .public)
""")
applyPinnedStateIfNeeded()
}
public func hide() {
if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) }
screenObserver = nil
removeMouseMonitor()
removeOutsideClickMonitor()
removeKeyMonitor()
cancelHoverEnter()
hoverExitWorkItem?.cancel()
hitPathShrinkWork?.cancel()
intrusionDismissTask?.cancel()
panel?.orderOut(nil)
panel = nil
hostingView = nil
}
// MARK: - 定位
/// 窗口水平居中于刘海中心,顶边贴屏幕顶。
///
/// 窗口尺寸恒为 `IslandMetrics.panelSize`,形态切换只改内部布局 ——
/// 动 window frame 会让 CoreAnimation 的隐式事务和 SwiftUI 的 spring 打架。
private func position(_ panel: IslandPanel, on geo: NotchGeometry) {
let size = IslandMetrics.panelSize
panel.setFrame(
CGRect(
// 取整到整数点。刘海中心可能是 863.5(左右可用区不等宽),
// 半点的窗口原点会让整块内容在 2x 屏上落到半像素,边缘发虚。
// 岛内部的形状仍以 panel 中心对称,所以这里的取整不会引入偏移。
x: (geo.centerX - size.width / 2).rounded(),
y: (geo.topY - size.height).rounded(),
width: size.width,
height: size.height
),
display: false
)
}
/// 显示器配置变了就重新对齐。
///
/// 以前只在 `show()` 里定位一次 —— 插拔外接屏、改分辨率缩放、
/// 甚至把内建屏在排列里挪个位置,岛都会留在旧坐标上,
/// 表现就是"岛歪了 / 跑偏了",而且**再也不会自己回去**。
private func observeScreenChanges() {
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.realign() }
}
}
private func realign() {
guard let panel, let screen = placement.screen() else { return }
let geo = NotchGeometry(screen: screen)
guard geo != geometry else { return }
geometry = geo
position(panel, on: geo)
installRootView(geometry: geo)
updateHitPath()
HubLog.island.notice("""
显示器变化,已重新对齐 —— \(screen.localizedName, privacy: .public),\
刘海 \(Int(geo.notchWidth), privacy: .public)×\
\(Int(geo.notchHeight), privacy: .public)pt
""")
}
// MARK: - 内容
private func installRootView(geometry geo: NotchGeometry) {
hostingView?.rootView = AnyView(
IslandRootView(
store: store,
approvals: approvals,
prompts: prompts,
projects: projects,
closedSessions: closedSessions,
model: model,
geometry: geo,
onToggleExpand: { [weak self] in self?.toggleExpand() },
onSelect: { [weak self] session in self?.select(session) },
onFocus: { [weak self] session in self?.focus(session) },
onCollapse: { [weak self] in self?.collapse() },
onLaunch: { [weak self] project, mode in self?.onLaunch?(project, mode) },
onPickStalled: { [weak self] finding in self?.pickStalled(finding) },
onReply: { [weak self] text in self?.sendReply(text) },
onDialogKey: { [weak self] request, key in
self?.pressDialogKey(request, key: key)
},
onSecretPath: secretPath
)
)
}
/// 开发期把形态钉死,方便在没法可靠控制鼠标的环境里截图核对布局。
///
/// `HUB_ISLAND_STATE=hover|expanded`。生产不会设这个变量。
private var pinnedState: IslandState? {
switch ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] {
case "hover": return .hover
case "expanded", "projects": return .expanded
case "rest": return .rest
case "nudge": return .nudge
case "answer": return .answer
default: return nil
}
}
private func applyPinnedStateIfNeeded() {
guard let pinnedState else { return }
// `projects` 顺便把展开态切到项目栏 —— 那一栏也需要能在没有鼠标的
// 环境里截图核对。
if ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] == "projects" {
model.tab = .projects
}
// 滞留提醒是时间驱动的,真实触发要等会话安静好几分钟 ——
// 钉住形态时必须同时喂进合成的判定结果,否则画面是空的。
//
// `stalled` 对**每个**形态都要喂:折叠态靠它出跑马灯,
// 展开态靠它出滞留横幅和行内徽章。只喂 nudge/answer 的话,
// 那两处新东西在截图里就是空的。
if DemoFixtures.isEnabled {
model.stalled = DemoFixtures.stallFindings()
model.nudge = model.stalled
model.answer = DemoFixtures.answerRequest()
}
model.transition(to: pinnedState)
updateHitPath()
// 打**实际**形态而不是环境变量的值 —— 之前只打后者,结果钉住失效时
// 日志照样显示"已钉住",把排查带偏了。
HubLog.island.notice("""
形态钉住请求 \(String(describing: pinnedState), privacy: .public),\
实际 \(String(describing: self.model.state), privacy: .public),\
滞留 \(self.model.stalled.count, privacy: .public) 条
""")
}
/// 所有形态迁移的唯一入口 —— 迁移之后必须同步 AppKit 侧的命中区。
///
/// 钉住时忽略**自动**迁移。以前只是在启动时设一次,紧接着悬停监听
/// 发现指针不在岛上就把它收了回去 —— 这个调试开关等于是坏的,
/// 而它恰恰是"没法用鼠标验证时"唯一的手段。
///
/// 但**用户主动的操作必须放行**(`userInitiated: true`)。
/// 一律冻住的代价在实机上暴露过:岛钉在展开态、又跟着光标跑到了外接屏,
/// 用户点收起没反应,只能去杀进程 —— 一个调试开关不该能把界面卡死。
private func setState(_ new: IslandState, userInitiated: Bool = false) {
guard userInitiated || pinnedState == nil else { return }
guard new != model.state else { return }
model.transition(to: new)
updateHitPath()
}
/// 命中路径 = 岛当前形状本身(折叠态额外外扩一点作为悬停热区)。
///
/// 用形状而不是包围盒做命中测试很关键:折叠态上半段严格只有刘海那么宽,
/// 用包围盒的话两侧多出的 8pt 会压在菜单栏上,把输入法、电量图标的点击吃掉。
///
/// **变大立即生效,变小等动画放完。** 展开动画有 0.42s,如果命中区在动画
/// 开始时还是折叠态的大小,用户点不到正在长出来的部分;反过来收起时
/// 如果命中区立刻缩小,退场动画的后半段就变成了不可点的"幽灵"。
private func updateHitPath() {
guard let hostingView, let geometry else { return }
hitPathShrinkWork?.cancel()
hitPathShrinkWork = nil
let target = hitPath(for: model.state, geometry: geometry)
let currentArea = hostingView.hitPath.map(\.boundingBox).map { $0.width * $0.height } ?? 0
let targetArea = target.boundingBox.width * target.boundingBox.height
if targetArea >= currentArea {
hostingView.hitPath = target
} else {
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in
guard let self, let hostingView = self.hostingView else { return }
hostingView.hitPath = target
self.hitPathShrinkWork = nil
}
}
hitPathShrinkWork = work
// 0.46s 是 IslandAnimation 里最长的一条转场(rest → expanded)。
DispatchQueue.main.asyncAfter(deadline: .now() + 0.46, execute: work)
}
}
private func hitPath(for state: IslandState, geometry geo: NotchGeometry) -> CGPath {
let metrics = IslandMetrics.metrics(
for: state, geometry: geo, sessionCount: store.sessions.count
)
// 折叠态把可交互高度**只往下**多给 3pt,让那条 14pt 的细缝好点一些。
//
// 以前是向四周各扩 6pt(`bodyWidth + growth * 2`),加上 tracking area
// 用包围盒近似,等效热区宽到覆盖刘海两侧的菜单栏 —— 鼠标路过就触发。
// 横向一点都不能扩:那个方向紧挨着的就是菜单栏图标。
let growth: CGFloat = state == .rest ? 3 : 0
let shape = IslandShape(
notchWidth: geo.notchWidth,
notchHeight: geo.notchHeight,
bodyWidth: metrics.bodyWidth,
bodyHeight: metrics.bodyHeight + growth,
bottomRadius: metrics.bottomRadius
)
let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize)
return shape.path(in: rect).cgPath
}
// MARK: - 悬停
/// 来自宿主视图 `NSTrackingArea` 的指针位置(左上原点视图坐标;nil 表示离开)。
///
/// 这是悬停的**主链路**。`.activeAlways` 让它在 app 未激活时照样投递,
/// 而全局 `NSEvent` monitor 只是兜底 —— 两条链路都汇到这里。
private func handlePointer(at point: CGPoint?) {
guard model.state == .rest || model.state == .hover else { return }
// **形状二次判定。** `NSTrackingArea` 只能是矩形,用的是岛形状的包围盒 ——
// 折叠态那是 217×52pt 的一整块,横跨刘海两侧的菜单栏。只判"进没进矩形"
// 就等于把菜单栏整条都当成了热区,鼠标去点右上角图标必然误触发。
let inside = point.map { hostingView?.hitPath?.contains($0) ?? false } ?? false
guard inside else {
cancelHoverEnter()
scheduleHoverExit()
return
}
hoverExitWorkItem?.cancel()
hoverExitWorkItem = nil
if model.state == .rest { scheduleHoverEnter() }
}
/// 指针停留够久才展开。已经排队的不重复排。
private func scheduleHoverEnter() {
guard hoverEnterWorkItem == nil else { return }
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in
guard let self, self.model.state == .rest else { return }
self.hoverEnterWorkItem = nil
// 再确认一次指针还在岛上 —— 这 250ms 里它完全可能已经走了。
guard self.pointerIsOverIsland() else { return }
self.setState(.hover)
}
}
hoverEnterWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + Self.hoverEnterDelay, execute: work)
}
private func cancelHoverEnter() {
hoverEnterWorkItem?.cancel()
hoverEnterWorkItem = nil
}
/// 当前鼠标位置是否落在岛的形状里。用屏幕坐标现算,不依赖事件。
private func pointerIsOverIsland() -> Bool {
guard let panel, let hitPath = hostingView?.hitPath else { return false }
let screenPoint = NSEvent.mouseLocation
let frame = panel.frame
let local = CGPoint(
x: screenPoint.x - frame.minX,
y: frame.maxY - screenPoint.y // Cocoa 向上 → 视图向下
)
return hitPath.contains(local)
}
private func installMouseMonitor() {
removeMouseMonitor()
// 兜底链路。全局 mouseMoved 监听不需要辅助功能权限(那是键盘事件才要的),
// 但它拿不到发给本 app 自己的事件,所以单靠它会有盲区 —— 主链路是 tracking area。
mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved]) {
[weak self] _ in
Task { @MainActor in self?.handleGlobalMouseMoved() }
}
}
private func removeMouseMonitor() {
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
mouseMonitor = nil
}
/// 上次检查跨屏的时刻。跨屏判定要遍历所有屏幕做矩形命中,
/// 而 mouseMoved 每秒能来几十次 —— 必须节流。
private var lastScreenCheck: Date = .distantPast
private static let screenCheckInterval: TimeInterval = 0.5
/// 指针换屏了就把岛搬过去。
///
/// 这是"用户在外接屏工作时看不到提醒"的正解:岛以前锁死在内建刘海屏上,
/// 用户看着另一块屏的时候,任何提醒在物理上都不在他视野里。
///
/// **只在折叠态搬家。** 展开 / 审批 / 提醒 / 应答态搬家会把用户正在点的
/// 东西从他手底下挪走 —— 尤其应答态,按钮位置一变,本来要点"取消"的
/// 那一下可能落到"确认发送"上。这条不能省。
private func followCursorAcrossScreens(force: Bool = false) {
// 设置说不跟随就一步都不动。
//
// 这个 guard 曾经漏掉过:加「固定在刘海屏」这个设置时只改了 show() /
// realign() / relocate() 三处,因为 grep 的是 activeScreen() 的调用点,
// 而这里调的是 screenUnderCursor(),名字对不上就没被找到。设置界面上
// 关掉了,岛照样跟着光标跑。
//
// 所以 screenUnderCursor() 现在是 NotchGeometry 私有的 —— 想拿屏幕只能
// 走 placement.screen(),编译期堵死这条绕行路。
guard placement.followsCursor else { return }
guard force || (model.state == .rest && pinnedState == nil) else { return }
if !force {
let now = Date()
guard now.timeIntervalSince(lastScreenCheck) >= Self.screenCheckInterval
else { return }
lastScreenCheck = now
}
guard let panel, let screen = placement.screen() else { return }
let geo = NotchGeometry(screen: screen)
guard geo != geometry else { return }
geometry = geo
position(panel, on: geo)
installRootView(geometry: geo)
updateHitPath()
HubLog.island.notice(
"岛跟随光标切到 \(screen.localizedName, privacy: .public)"
)
}
private func handleGlobalMouseMoved() {
followCursorAcrossScreens()
guard let geometry else { return }
// 展开、闯入、审批时不受悬停控制 —— 那几个态有各自的退出条件,
// 尤其审批绝不能因为鼠标移开就消失。
guard model.state == .rest || model.state == .hover else { return }
let mouse = NSEvent.mouseLocation
// 粗筛:只有指针在屏幕顶部一小条带里才继续算,其余直接返回。
// 这只是省算力,真正的判据是下面的形状测试。
let bandHeight = geometry.notchHeight + 48
guard mouse.y >= geometry.topY - bandHeight, mouse.y <= geometry.topY else {
cancelHoverEnter()
scheduleHoverExit()
return
}
// 和 tracking area 那条链路走同一套判据(形状 + 停留延迟),
// 两条链路用不同的热区定义只会让手感变得不可预测。
if pointerIsOverIsland() {
hoverExitWorkItem?.cancel()
hoverExitWorkItem = nil
if model.state == .rest { scheduleHoverEnter() }
} else {
cancelHoverEnter()
scheduleHoverExit()
}
}
/// 离开热区不立刻收回:指针从岛移向岛下方内容时会短暂离开,
/// 立刻收会闪。220ms 的延迟正好盖住这种穿越。
private func scheduleHoverExit() {
guard model.state == .hover, hoverExitWorkItem == nil else { return }
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in
guard let self, self.model.state == .hover else { return }
self.model.hoveredSessionId = nil
self.setState(.rest)
self.hoverExitWorkItem = nil
}
}
hoverExitWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work)
}
// MARK: - 展开
private func toggleExpand() {
// 审批态点击不该收起面板 —— 那等于绕过了决策。
guard model.state != .approval else { return }
if model.state == .expanded {
collapse()
} else {
// 没有明确选中时默认聚焦最需要处理的那个会话。
if model.selectedSessionId == nil {
model.selectedSessionId = (store.mostUrgent ?? store.sessions.first)?.sessionId
}
setState(.expanded, userInitiated: true)
installOutsideClickMonitor()
}
}
/// 点击 hover 态的某个小人 → 展开并显示它的详情。
private func focus(_ session: AgentSession) {
guard model.state != .approval else { return }
model.selectedSessionId = session.sessionId
setState(.expanded)
installOutsideClickMonitor()
}
private func collapse() {
guard model.state != .approval else { return }
// 收起永远是用户主动的动作,钉住也必须能收 —— 否则界面会被卡死。
setState(.rest, userInitiated: true)
removeOutsideClickMonitor()
// 收起时顺便回到光标所在的屏。岛跟着光标走只在折叠态生效,
// 刚收起正是最该重新对齐的时刻(用户很可能已经换屏了)。
followCursorAcrossScreens(force: true)
}
public func expand() {
guard model.state != .approval else { return }
if model.selectedSessionId == nil {
model.selectedSessionId = (store.mostUrgent ?? store.sessions.first)?.sessionId
}
setState(.expanded)
installOutsideClickMonitor()
}
private func installOutsideClickMonitor() {
removeOutsideClickMonitor()
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] event in
let location = event.locationInWindow // 全局监听里这已经是屏幕坐标
Task { @MainActor in
guard let self, self.model.state == .expanded else { return }
// **必须排除落在岛自己身上的点击。**
//
// 全局 monitor 的契约是"发给其它 app 的事件",但本 app 是 .accessory
// 且从不激活,点岛的事件在系统看来并不属于某个活跃 app,于是这里照样
// 会收到 —— 实测结果就是"点列表任何一行,岛立刻收起",展开态等于不可用。
guard !self.islandContains(screenPoint: location) else { return }
self.collapse()
}
}
}
/// 屏幕坐标的点是否落在岛当前的形状里。
///
/// 复用宿主视图那份 `hitPath`(左上原点),保证"能点到"和"算作岛内"
/// 用的是同一套几何 —— 两处各算一遍迟早会漂。
private func islandContains(screenPoint: CGPoint) -> Bool {
guard let panel, let hitPath = hostingView?.hitPath else { return false }
let frame = panel.frame
let local = CGPoint(
x: screenPoint.x - frame.minX,
y: frame.maxY - screenPoint.y // Cocoa 向上 → 视图向下
)
return hitPath.contains(local)
}
private func removeOutsideClickMonitor() {
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
outsideClickMonitor = nil
}
// MARK: - 闯入
/// 事件闯入:短暂膨胀提示,几秒后回落到原来的形态。
public func presentIntrusion(_ event: IntrusionEvent) {
// 审批优先级最高,不能被普通事件挤掉;交互卡背后有 hook 阻塞在等,
// 同样不能被一条两秒的横幅顶掉。
guard model.state != .approval, model.state != .prompt else { return }
intrusionDismissTask?.cancel()
if model.state != .intrusion { stateBeforeIntrusion = model.state }
model.intrusion = event
setState(.intrusion)
// 需要用户行动的停久一点;纯"干完了"的通知快速划过就行。
let duration: Duration = event.kind == .needsInput ? .seconds(2.6) : .seconds(2.0)
intrusionDismissTask = Task { [weak self] in
try? await Task.sleep(for: duration)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self, self.model.state == .intrusion else { return }
self.model.intrusion = nil
self.setState(self.stateBeforeIntrusion == .expanded ? .expanded : .rest)
}
}
}
// MARK: - 滞留提醒
/// 上一次滞留提醒回落到哪个形态。
private var stateBeforeNudge: IslandState = .rest
/// 用户在岛上受理了某个滞留(跳转或应答)。上层要据此压制一轮升级。
public var onAcknowledgeStall: ((String) -> Void)?
/// 更新"当前有哪些卡着"。折叠态的跑马灯和下唇染色读它。
///
/// 和 `presentNudge` 分开:这个**不改变形态**,只更新常驻的被动提示。
/// 已经放弃主动闯入的会话也在这里 —— 不吵,但信息得留着。
public func updateStalled(_ findings: [StallFinding]) {
model.stalled = findings
}
/// 弹出滞留提醒。
///
/// 优先级低于审批和闯入:审批在等一个不可逆操作的决策,
/// 滞留提醒再急也不该把它挤掉。
public func presentNudge(_ findings: [StallFinding]) {
guard !findings.isEmpty else { return }
guard model.state != .approval, model.state != .answer else { return }
// 用户正在展开态里操作,不要把界面从他手底下换掉。
guard model.state == .rest || model.state == .nudge else { return }
nudgeDismissTask?.cancel()
if model.state != .nudge { stateBeforeNudge = model.state }
model.nudge = findings
setState(.nudge)
// 比 intrusion 停得久:这里有多个小人要认,还要给出点击的机会。
nudgeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self, self.model.state == .nudge else { return }
self.model.nudge = []
self.setState(self.stateBeforeNudge == .expanded ? .expanded : .rest)
}
}
}
private var nudgeDismissTask: Task<Void, Never>?
/// 点了某个卡住的会话的小人。
///
/// 能在岛上答的(AI 判定为提问)就进应答态;其余一律跳转过去 ——
/// 断线、待验收这些没有"一句话就能回应"的动作,硬做成按钮是假的。
private func pickStalled(_ finding: StallFinding) {
nudgeDismissTask?.cancel()
onAcknowledgeStall?(finding.sessionId)
guard case .askedQuestion(let question) = finding.reason,
let session = store.sessions.first(where: { $0.sessionId == finding.sessionId })
else {
// 不是提问的(断线、待验收、有后续)没有"一句话就能回应"的动作,
// 但也**不该直接把人甩到终端** —— 用户点这一下是想先看清楚是什么事。
// 展开并选中它,详情卡里有滞留横幅、分支、提交记录和跳转按钮,
// 看完再决定去不去。
model.expand(focusing: finding.sessionId)
updateHitPath()
installOutsideClickMonitor()
return
}
let name = session.name ?? session.fallbackProjectName
model.answerFeedback = nil
model.answer = AnswerRequest(
sessionId: finding.sessionId,
sessionName: name,
question: question,
options: stallOptions[finding.sessionId] ?? [],
target: nil
)
setState(.answer)
// 目标位置要跑 tmux / AppleScript 才知道,放后台解析,回来再补上。
// 先把面板显示出来,不要为了一行副标题卡住 UI。
let reply = self.reply
Task { [weak self] in
let target = await Task.detached(priority: .userInitiated) {
await MainActor.run { reply.locate(sessionId: finding.sessionId, session: session) }
}.value
await MainActor.run {
guard let self, var current = self.model.answer,
current.sessionId == finding.sessionId else { return }
current = AnswerRequest(
sessionId: current.sessionId, sessionName: current.sessionName,
question: current.question, options: current.options, target: target
)
self.model.answer = current
}
}
}
/// AI 给的快捷回答选项,按 sessionId 存。上层在判定时填。
public var stallOptions: [String: [String]] = [:]
private let reply = TerminalReply()
/// 确认发送。**所有安全护栏在 `TerminalReply` 里**,这里只负责回显。
private func sendReply(_ text: String) {
guard let request = model.answer else { return }
model.answerFeedback = "发送中…"
let reply = self.reply
Task { [weak self] in
let outcome = await reply.send(text: text, to: request.sessionId)
await MainActor.run {
guard let self else { return }
switch outcome {
case .sent(let target):
self.model.answerFeedback = "已发送 · \(target)"
self.onAcknowledgeStall?(request.sessionId)
// 发完停一下再收,让用户看清回显。
Task {
try? await Task.sleep(for: .seconds(1.6))
await MainActor.run {
guard self.model.state == .answer else { return }
self.model.answer = nil
self.collapse()
}
}
case .sessionBecameBusy:
// 护栏①触发:用户已经自己在那边动手了。如实说,不要硬发。
self.model.answerFeedback = "它又开始干活了,没发"
case .notLocated:
self.model.answerFeedback = "定位不到终端,请手动过去"
case .rejected(let why):
self.model.answerFeedback = "内容不合法:\(why)"
case .failed(let why):
self.model.answerFeedback = "发送失败:\(why)"
HubLog.jump.error("岛上应答失败:\(why, privacy: .public)")
}
}
}
}
// MARK: - 审批
/// 弹出审批面板。**不会自动回落**,必须等用户决策或超时。
public func presentApproval() {
intrusionDismissTask?.cancel()
model.intrusion = nil
removeOutsideClickMonitor()
setState(.approval)
// 审批要接键盘(⌘⏎ 允许 / ⏎ ⎋ 拒绝),先试着让面板成为 key window。
// .nonactivatingPanel 保证这不会把整个 app 激活到前台。
panel?.makeKeyAndOrderFront(nil)
installKeyMonitor()
}
/// 队列空了就收回去。
public func dismissApprovalIfDone() {
guard model.state == .approval, approvals.current == nil else { return }
removeKeyMonitor()
panel?.resignKey()
// 审批清完了,如果还有交互卡压在队里,接着弹它。
if prompts.current != nil {
setState(.prompt)
} else {
setState(.rest)
}
}
// MARK: - 交互作答(选择题 / 计划审批)
/// 弹出交互作答卡。**不会自动回落**,收回由 coordinator 的超时驱动。
///
/// 当前是审批态时**不抢**:审批是安全刹车,优先级更高;
/// 交互请求的 continuation 还挂在队列里,`dismissApprovalIfDone` /
/// `dismissPromptIfDone` 的轮询会在审批清完后把它带出来。
public func presentPrompt() {
guard model.state != .approval else { return }
intrusionDismissTask?.cancel()
model.intrusion = nil
removeOutsideClickMonitor()
setState(.prompt)
}
/// 队列空了就收回;反过来,如果岛闲着而队列里有货,把它浮出来。
public func dismissPromptIfDone() {
// 权限卡对应的终端对话框可能已经被用户在那边亲手答掉了 ——
// 会话恢复干活(或直接消失)就把卡收走,别让人对着空气点「允许」。
// 3 秒宽限:通知刚到时 store 的状态快照可能还停留在 busy。
if let current = prompts.current,
case .permission = current.payload,
Date().timeIntervalSince(current.createdAt) > 3 {
let session = store.sessions.first { $0.sessionId == current.sessionId }
if session == nil || session?.status.isWorking == true {
prompts.dismiss(current)
}
}
if model.state == .prompt, prompts.current == nil {
setState(.rest)
return
}
if model.state == .rest || model.state == .hover, prompts.current != nil {
presentPrompt()
}
}
/// 在对应终端的对话框里替用户按一个键。权限框、计划确认框共用。
///
/// 先收卡再发键(对阻塞卡收卡即放行 —— 计划确认框恰恰要先放行才会弹出:
/// 实测 2.1.220 里 ExitPlanMode 的计划框属于 plan 状态机自己的 UI,
/// hook 的显式 allow 压不掉它,只能放行后到框里去按键)。
///
/// 带重试:放行到对话框渲染出来有一两秒窗口,期间会话还是 busy,
/// `press` 的护栏会拒发(正确!),等一秒再试。到点还没等到就跳转
/// 让用户自己按 —— 框还在终端里,什么都没损失。
public func pressDialogKey(_ request: AgentPromptRequest, key: TerminalReply.DialogKey) {
prompts.dismiss(request)
let reply = self.reply
let sessionId = request.sessionId
Task { [weak self] in
for attempt in 0..<6 {
try? await Task.sleep(for: .milliseconds(attempt == 0 ? 600 : 1000))
let outcome = await reply.press(key, sessionId: sessionId)
switch outcome {
case .sent:
return
case .sessionBecameBusy:
// 对话框还没渲染出来(Claude 还在跑)。继续等。
continue
case .notLocated, .rejected, .failed:
await MainActor.run {
HubLog.jump.error("对话框按键未送达,跳转让用户自己按")
self?.store.jump(to: sessionId)
}
return
}
}
await MainActor.run {
HubLog.jump.error("等不到对话框出现,跳转让用户自己按")
self?.store.jump(to: sessionId)
}
}
}
/// 审批态的键盘兜底。
///
/// app 是 `.accessory` 且从不激活,`makeKeyAndOrderFront` 未必真能让
/// 这个 nonactivating panel 拿到 key —— 拿不到的话 SwiftUI 的
/// `.keyboardShortcut` 就是死的。这里补一条**局部**监听
/// (`addLocalMonitorForEvents` 不需要辅助功能权限)。
///
/// 键位语义和面板上写的一致,且**绝不让 ⏎ 触发允许**:
/// ⏎ / ⎋ 拒绝,⌘⏎ 允许。
private func installKeyMonitor() {
removeKeyMonitor()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) {
[weak self] event in
guard let self, self.model.state == .approval,
let request = self.approvals.current
else { return event }
let isReturn = event.keyCode == 36 || event.keyCode == 76 // Return / 小键盘 Enter
let isEscape = event.keyCode == 53
if isReturn, event.modifierFlags.contains(.command) {
// L3 要求长按,键盘不提供快捷放行。
guard !request.requiresHoldToConfirm else { return nil }
self.approvals.allow(request)
return nil
}
if isReturn || isEscape {
self.approvals.deny(request)
return nil
}
return event
}
}
private func removeKeyMonitor() {
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
keyMonitor = nil
}
// MARK: - 动作
/// 跳转。**只有真的跳到了才收起岛。**
///
/// 无脑收起的话,跳转失败(会话不在任何终端里、iTerm 没开、绑定丢了)时
/// 岛会连同失败原因一起消失,用户只知道"点了没反应"。留在展开态、
/// 让详情卡把结果说清楚,才有下一步可走。
private func select(_ session: AgentSession) {
model.selectedSessionId = session.sessionId
store.jump(to: session.sessionId) { [weak self] outcome in
guard let self else { return }
if case .focused = outcome { self.collapse() }
}
}
/// 通知点击后的跳转入口。
public func jumpAndCollapse(sessionId: String) {
store.jump(to: sessionId)
intrusionDismissTask?.cancel()
model.intrusion = nil
model.selectedSessionId = sessionId
if model.state != .approval { setState(.rest) }
}
}