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 @ObservationIgnored private let host: HostRegistry.Host @ObservationIgnored private let environment: AppEnvironment @ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void @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 ) { self.host = host self.environment = environment self.onPendingChanged = onPendingChanged 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 } // 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() let engine = engine let sessionId = targetSessionId Task { await engine.open(sessionId: sessionId, cwd: nil) } } /// 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() let engine = engine let sessionId = targetSessionId Task { await engine.open(sessionId: sessionId, cwd: nil) } } /// 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: - 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 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 } }