import Combine import Foundation import GhosttyTerminal /// 多会话持有者:一台主机可以有多条会话,会话在后台继续跑(最小化 / 回首页都不断)。 /// /// 设计要点: /// - **会话不因导航而销毁**:只有显式关闭(红点 / ⌘W)才 teardown。 /// - 会话 surface 由 `SessionCanvas` 常驻挂载,故 `%output` 持续消费、内容不丢。 /// - 生命周期广播(前后台)由本类扇出给每条会话——`TerminalSession` 自己不监听 scenePhase。 @MainActor final class SessionManager: ObservableObject { @Published private(set) var sessions: [TerminalSession] = [] /// 当前焦点会话(进终端时显示哪条)。 @Published var focusedID: UUID? /// 「已就绪、可以进终端」的会话(视图层消费后清空)。 /// /// 为什么要这个信号:点主机后**先留在首页**探测 tmux、弹会话选择器,选完(或探测到没有 /// tmux)才进终端页 —— 连接过程属于"准备连接",不该先把用户丢进空终端再弹框。 @Published var enterRequest: UUID? /// 点了主机但还没进终端的会话 id(等探测/选择结果)。 private var awaitingEnter: Set = [] /// 转发每条会话的变化(嵌套 ObservableObject 不冒泡,见 CLAUDE.md)。 private var sessionObservers: [UUID: AnyCancellable] = [:] private weak var credentialStore: (any CredentialStore)? private weak var tailscaleStore: TailscaleStore? var focused: TerminalSession? { guard let id = focusedID else { return nil } return sessions.first { $0.id == id } } func session(_ id: UUID) -> TerminalSession? { sessions.first { $0.id == id } } /// 正在建立中(还没进终端)的会话 —— 首页据此显示连接进度。 var connectingSessions: [TerminalSession] { sessions.filter { awaitingEnter.contains($0.id) } } /// 等用户选 tmux 会话的那条(选择器数据源)。 var sessionAwaitingChoice: TerminalSession? { sessions.first { $0.pendingSessionChoice != nil } } /// 首页「活动会话」区的排序:最小化的置顶(设计要求,否则用户不知道东西去哪了),其余按最近活跃。 var activeSessions: [TerminalSession] { sessions.sorted { if $0.isMinimized != $1.isMinimized { return $0.isMinimized } return $0.lastActiveAt > $1.lastActiveAt } } /// ⌘1…9 的会话顺序(与首页卡片顺序一致)。 func session(atShortcutIndex i: Int) -> TerminalSession? { let list = activeSessions return i >= 0 && i < list.count ? list[i] : nil } /// 注入解析凭据/出口所需的 Store(新建会话时转交)。 func bind(credentials: any CredentialStore, tailscale: TailscaleStore) { credentialStore = credentials tailscaleStore = tailscale for s in sessions { s.bind(credentials: credentials, tailscale: tailscale) } } // MARK: - 开 / 关 /// 打开一台主机:**总是新建会话**(用户定稿:会话与主机不是一对一,再点同一主机 = /// 再开一条并行会话;回到已有会话走侧边栏行 / 首页会话卡,即 `activate`)。 /// 新会话**先不进终端** —— 等 tmux 探测/用户选择完成后由 `enterRequest` 通知视图层。 @discardableResult func open(host: SavedHost) -> TerminalSession { let s = makeSession() sessions.append(s) focus(s) awaitingEnter.insert(s.id) s.connect(to: host) return s } /// 选中**既有**会话(侧边栏行 / 首页会话卡 / ⌘n / 无头 URL):聚焦,且**已断开的自动重连** /// (用户定稿:点选未连接的会话 = 重新连接并重新判定 tmux/原生,不必再进终端找「重试」按钮)。 /// 连接中 / 重连中 / 已连接的不打扰 —— 只有明确死掉(失败 / 已关闭)才触发。 func activate(_ s: TerminalSession) { focus(s) guard !s.isSyntheticFixture else { return } if s.phaseText == "已关闭" || s.phaseText.hasPrefix("失败") { s.retry() } } /// 会话状态变化后判断是否该进终端了。 /// /// 就绪 = 不需要用户选择,且(已建立 / 探测确认无 tmux / 用户选了原生 / 连接失败)。 /// 失败也进终端:那里有「重试 / 返回首页」,比留在首页只显示一行错误更有出路。 private func evaluateEnter(_ s: TerminalSession) { guard awaitingEnter.contains(s.id) else { return } if s.pendingSessionChoice != nil { return } // 等用户选会话 if s.isProbingTmux { return } // 还在探测 → 留在首页,别先把人送进终端 let failed = s.phaseText.hasPrefix("失败") guard s.isLive || s.tmuxUnavailable || s.tmuxSkipped || failed else { return } awaitingEnter.remove(s.id) enterRequest = s.id } /// 焦点切换:记录活跃时刻并解除最小化标记。 func focus(_ s: TerminalSession) { focusedID = s.id s.isMinimized = false s.lastActiveAt = Date() } /// 最小化:**不确认**、不动传输,只标记 + 记时刻(回首页由调用方改路由)。 func minimize(_ id: UUID) { guard let s = session(id) else { return } s.isMinimized = true s.lastActiveAt = Date() } /// 关闭并移除会话。tmux 的「仅断开 / 结束会话」分支在 UI-4 的确认对话框里决定, /// 这里只负责最终 teardown + 摘除(会话对象随之释放,surface 一并回收)。 func close(_ id: UUID) { guard let s = session(id) else { return } s.close() sessions.removeAll { $0.id == id } sessionObservers[id] = nil awaitingEnter.remove(id) if focusedID == id { focusedID = activeSessions.first?.id } } // MARK: - 广播 // 假会话不参与:它没有传输层,广播会经 syncUI 把注入状态覆盖回 idle。 func enterBackground() { for s in sessions where !s.isSyntheticFixture { s.enterBackground() } } func enterForeground() { for s in sessions where !s.isSyntheticFixture { s.enterForeground() } } /// 主题切换:推给所有会话的 raw surface 与 tmux pane。 func applyTerminalTheme(_ t: TerminalTheme) { for s in sessions { s.applyTerminalTheme(t) } } /// 任一会话的 host key 告警(多会话时取第一条,逐条处理)。 var pendingHostKeyMismatch: TerminalSession? { sessions.first { $0.hostKeyMismatch != nil } } // MARK: - 无头验证 /// 启动参数自动连接(simctl launch --args -txHost …)。返回新建的会话供调用方切路由。 @discardableResult func autoConnectIfConfigured() -> TerminalSession? { let d = UserDefaults.standard guard let host = d.string(forKey: "txHost"), !host.isEmpty else { return nil } let s = makeSession() sessions.append(s) focus(s) s.autoConnectIfConfigured() return s } #if DEBUG /// 无头 UI 验证:批量造假会话(`-txUIFixture 1`)。返回第一条供调用方进终端。 @discardableResult func spawnFixtureSessions() -> TerminalSession? { for (i, spec) in UIPreviewFixture.specs.enumerated() { // 复用 UIPreviewFixture.hosts 里的同名主机(同一 id),主机卡才能亮「已有会话」。 guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue } let s = makeSession() s.injectFixture(host: host, lines: spec.lines, link: spec.link, mosh: host.useMosh, tmux: spec.tmux) // 活跃时刻错开,让首页卡片排序稳定可对拍。 s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60) sessions.append(s) } focusedID = sessions.first?.id return sessions.first } #endif private func makeSession() -> TerminalSession { let s = TerminalSession() if let c = credentialStore, let t = tailscaleStore { s.bind(credentials: c, tailscale: t) } // 转发会话变化:① 让订阅 manager 的视图(首页/选择器)跟着刷新;② 驱动 enter 判定。 sessionObservers[s.id] = s.objectWillChange.sink { [weak self, weak s] _ in guard let self else { return } self.objectWillChange.send() if let s { Task { @MainActor in self.evaluateEnter(s) } } } return s } }