import Foundation import HostRegistry import Observation import SessionCore import WireProtocol /// T-iOS-15 · One open terminal = one controller: builds and owns the /// per-session stack (engine → fan-out → TerminalViewModel + GateViewModel + /// SessionActivityBridge) and drives the scenePhase lifecycle. /// /// Lifecycle (plan §7 T-iOS-15): /// - `.background` → `suspend()`: `engine.close()` — a clean detach (the /// server-side PTY keeps running), never a half-dead socket that iOS will /// kill mid-suspend anyway. /// - `.active` after a suspend → `resumeIfNeeded()`: `close()` is terminal for /// an engine, so the stack is REBUILT and re-opened against the /// server-adopted sessionId. The fresh SwiftTerm view (new `generation` → /// new UIView) replays the full ring buffer and fires `sizeChanged` on first /// layout — that resize is the latest-writer-wins full-screen reclaim /// (换设备夺回全屏), immediately after the attach. /// - `.active` with the engine still alive (transient `.inactive`, e.g. /// control center / app switcher peek): `engine.notifyForegrounded(dims:)` /// with `TerminalViewModel.lastSentDims` — reconnect-if-needed + resize /// reclaim (latest-writer-wins). Skipped only when no valid dims were ever /// sent (SwiftTerm has not laid out yet → its first `sizeChanged` covers /// it). The W4 documented deviation is CLOSED (orchestrator added the /// `lastSentDims` accessor on behalf of the T-iOS-11 owner). @MainActor @Observable final class TerminalSessionController: Identifiable { let id = UUID() /// Bumped on every rebuild; the container view uses it as the SwiftUI /// identity of `TerminalScreen`, forcing a fresh `makeUIView` so the new /// ViewModel's output sink actually attaches to a new SwiftTerm view. private(set) var generation = 0 private(set) var terminalViewModel: TerminalViewModel private(set) var gateViewModel: GateViewModel /// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow /// (the host to reopen against — a controller is host-bound for life). @ObservationIgnored let host: HostRegistry.Host @ObservationIgnored private let environment: AppEnvironment @ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void /// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title) — /// re-attached to every rebuilt TerminalViewModel so a background→ /// foreground rebuild never silently drops the list-title feed. @ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void /// T-iOS-26 · fresh-spawn 变体:新会话的工作目录(`attach(null, cwd)`)。 /// 仅在目标 sessionId 为 nil 时生效 —— 服务器对既有会话忽略 cwd。 /// (T-iOS-29 起对 coordinator 只读可见:fresh spawn 未进列表轮询时, /// new-in-cwd 以它为 cwd 回退。) @ObservationIgnored let spawnCwd: String? /// T-iOS-26 · attach 后注入的首条输入(如 `claude\r`)。engine 的 /// attach-first 队列语义保证它绝不先于 attach 出线;采纳既有会话 id 后 /// 的重开(suspend→resume)不再注入。 @ObservationIgnored private let bootstrapInput: String? /// 最近一次 open(+bootstrap) 提交任务 —— 测试屏障(ProjectOpenWiringTests)。 @ObservationIgnored private(set) var openTask: Task? @ObservationIgnored private var engine: SessionEngine @ObservationIgnored private var fanOut: EventFanOut @ObservationIgnored private var bridge: SessionActivityBridge /// What the next (re)open attaches to: the server-adopted id once known, /// else the list's requested id (nil = new session). @ObservationIgnored private var targetSessionId: UUID? @ObservationIgnored private var isSuspended = false @ObservationIgnored private var hasStarted = false @ObservationIgnored private var isTornDown = false /// Fan-out branch order (single place, no magic indices). private enum Branch { static let terminal = 0 static let gate = 1 static let activity = 2 static let count = 3 } init( host: HostRegistry.Host, sessionId: UUID?, environment: AppEnvironment, onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void, onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in }, spawnCwd: String? = nil, bootstrapInput: String? = nil ) { self.host = host self.environment = environment self.onPendingChanged = onPendingChanged self.onTitleChanged = onTitleChanged self.spawnCwd = spawnCwd self.bootstrapInput = bootstrapInput self.targetSessionId = sessionId let stack = Self.makeStack( host: host, environment: environment, onPendingChanged: onPendingChanged ) engine = stack.engine fanOut = stack.fanOut terminalViewModel = stack.terminalViewModel gateViewModel = stack.gateViewModel bridge = stack.bridge terminalViewModel.onTitleChanged = onTitleChanged } // MARK: - Lifecycle entry points /// Kick off: consumers first, then the engine connect (events buffer /// unbounded, so this order is belt-and-braces, not a race fix). func start() { guard !hasStarted, !isTornDown else { return } hasStarted = true startConsumers() openEngineAtTarget() } /// scenePhase → `.background`: clean detach (PTY keeps running). func suspend() { guard hasStarted, !isSuspended, !isTornDown else { return } isSuspended = true rememberAdoptedSession() stopConsumers() let engine = engine Task { await engine.close() } } /// scenePhase → `.active`: rebuild-and-reopen if we suspended; with the /// engine still alive, notifyForegrounded (reconnect + size reclaim). func resumeIfNeeded() { guard !isTornDown else { return } guard isSuspended else { notifyForegroundedIfPossible() return } isSuspended = false rebuildStack() startConsumers() openEngineAtTarget() } /// (Re)open 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为 /// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend /// 前已采纳的 id 走前一分支,bootstrap 不会重放。 private func openEngineAtTarget() { let engine = engine let sessionId = targetSessionId let cwd = sessionId == nil ? spawnCwd : nil let bootstrap = sessionId == nil ? bootstrapInput : nil openTask = Task { await engine.open(sessionId: sessionId, cwd: cwd) if let bootstrap { await engine.send(.input(data: bootstrap)) } } } /// Alive-engine `.active` hop: feed the engine the last VALID dims the /// terminal reported so it reconnects (if dropped during `.inactive`) and /// re-stamps the PTY size (latest-writer-wins reclaims full screen). private func notifyForegroundedIfPossible() { guard hasStarted, let dims = terminalViewModel.lastSentDims else { return } let engine = engine Task { await engine.notifyForegrounded(dims: dims.asTuple) } } /// Back navigation / screen dismissed: detach for good. Idempotent. func teardown() { guard !isTornDown else { return } isTornDown = true rememberAdoptedSession() stopConsumers() let engine = engine Task { await engine.close() } } // MARK: - Timeline drill-down (T-iOS-24, additive) /// Per-host timeline fetcher for the container's `TimelineSheet` — the /// same `APIClient.events` wrapper the engine's away-digest uses; the /// single derivation point stays `AppEnvironment.makeEventsSource`. var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] { environment.makeEventsSource(endpoint: host.endpoint) } // MARK: - Stack assembly private struct Stack { let engine: SessionEngine let fanOut: EventFanOut let terminalViewModel: TerminalViewModel let gateViewModel: GateViewModel let bridge: SessionActivityBridge } private static func makeStack( host: HostRegistry.Host, environment: AppEnvironment, onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void ) -> Stack { let engine = SessionEngine( transport: environment.termTransport, clock: ContinuousClock(), endpoint: host.endpoint, eventsSource: environment.makeEventsSource(endpoint: host.endpoint) ) let fanOut = EventFanOut(source: engine.events, branchCount: Branch.count) return Stack( engine: engine, fanOut: fanOut, terminalViewModel: TerminalViewModel( engine: engine, events: fanOut.branches[Branch.terminal] ), gateViewModel: GateViewModel( engine: engine, events: fanOut.branches[Branch.gate], haptics: GateHaptics(), clock: ContinuousClock() ), bridge: SessionActivityBridge( events: fanOut.branches[Branch.activity], hostId: host.id, lastSessionStore: environment.lastSessionStore, onPendingChanged: onPendingChanged ) ) } private func rebuildStack() { fanOut.cancel() let stack = Self.makeStack( host: host, environment: environment, onPendingChanged: onPendingChanged ) engine = stack.engine fanOut = stack.fanOut terminalViewModel = stack.terminalViewModel gateViewModel = stack.gateViewModel bridge = stack.bridge terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired generation += 1 // new SwiftUI identity → fresh SwiftTerm view } private func startConsumers() { terminalViewModel.start() gateViewModel.start() bridge.start() } private func stopConsumers() { terminalViewModel.stop() gateViewModel.stop() bridge.stop() } /// The reopen target: always prefer the server-adopted id (it may differ /// from what was requested — unknown UUIDs mint new sessions). private func rememberAdoptedSession() { targetSessionId = bridge.adoptedSessionId ?? targetSessionId } }