import Foundation import Observation import SessionCore import WireProtocol /// Terminal screen state (T-iOS-11, plan §3.5): consumes the engine's /// `SessionEvent` stream on the MainActor and turns it into UI state — output /// forwarded to SwiftTerm, connection banner, non-retryable failure copy and /// the read-only exit state. All outbound traffic (key bar, hardware key /// commands, SwiftTerm delegate) funnels through here into ONE ordered queue. /// /// Testability / stream-sharing decision (documented per task brief): /// `engine.events` is a single-consumer `AsyncStream`, and GateViewModel /// (T-iOS-14) must eventually observe `.gate`/`.digest` from the SAME stream. /// So the events stream is injected SEPARATELY from the engine: today callers /// pass `engine.events` verbatim (tests do exactly that, over /// `TestSupport.FakeTransport`); the T-iOS-15 wiring may pass a fan-out branch /// instead without touching this class. /// /// Swift 6 strict concurrency: the class is `@MainActor`, the terminal sink is /// `@MainActor`-typed — `feed()` off the main actor cannot compile. @MainActor @Observable final class TerminalViewModel { // MARK: - UI state model /// Connection banner state (mirrors the web client's status line: /// public/terminal-session.ts `SessionStatus`). enum ConnectionBanner: Equatable { case none case connecting /// Retry `attempt` fires after `next` (ReconnectMachine ladder 1s→30s). case reconnecting(attempt: Int, next: Duration) } /// Which terminal the user is looking at: a live one, a dead-for-good one /// (non-retryable failure), or a finished one (read-only). enum TerminalPhase: Equatable { case live /// Non-retryable terminal failure — actionable copy instead of a spinner. case failed(message: String) /// The shell exited; the terminal stays readable but accepts no input. case exited(code: Int, reason: String?) } /// Terminal geometry snapshot (Equatable for test assertions; the frozen /// engine API takes a tuple, so `asTuple` bridges). struct TerminalDims: Equatable, Sendable { let cols: Int let rows: Int var asTuple: (cols: Int, rows: Int) { (cols, rows) } } /// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1 /// coupling warning): reconnecting would deterministically fail forever, /// so the user must change a knob, not wait. static let replayTooLargeMessage = "服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限" /// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`). /// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen /// §3.2 signature needs real cols/rows and this is their single source. private(set) var lastSentDims: TerminalDims? private(set) var banner: ConnectionBanner = .none private(set) var phase: TerminalPhase = .live /// Server-adopted session id (ALWAYS the server-issued one — persisting it /// per host is the T-iOS-15 wiring's job via `LastSessionStore`). private(set) var sessionId: UUID? /// Read-only = no input reaches the PTY (exit / terminal failure). Resize /// is NOT gated here — the engine owns terminal-state dropping. var isReadOnly: Bool { phase != .live } /// Single render input for `ReconnectBanner`: terminal phases win over /// transient connection states. var bannerModel: ReconnectBanner.Model? { switch phase { case .failed(let message): return .failed(message: message) case .exited(let code, let reason): return .exited(code: code, reason: reason) case .live: break } switch banner { case .none: return nil case .connecting: return .connecting case .reconnecting(let attempt, let next): return .reconnecting(attempt: attempt, retryIn: next) } } // MARK: - Dependencies & plumbing (not observed) @ObservationIgnored private let engine: SessionEngine @ObservationIgnored private let events: AsyncStream @ObservationIgnored private var consumeTask: Task? /// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed: /// feeding off the main actor is a compile error. @ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)? /// Output that arrived before the SwiftTerm view existed; flushed in order /// the moment the sink attaches (replay must never be dropped). @ObservationIgnored private var pendingOutput: [String] = [] /// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send` /// is async — one pump task preserves submission order (two quick key taps /// must never race each other onto the wire). @ObservationIgnored private var sendQueue: [ClientMessage] = [] @ObservationIgnored private var isPumping = false // MARK: - Test-visible diagnostics & deterministic barriers (internal) /// Events applied so far — `waitUntilProcessed` barrier counter. @ObservationIgnored private(set) var processedEventCount = 0 /// Sends handed to the engine so far — `waitUntilForwarded` barrier counter. @ObservationIgnored private(set) var forwardedSendCount = 0 /// Input dropped because the terminal is read-only (exit/failed). @ObservationIgnored private(set) var droppedReadOnlyInputCount = 0 /// Test tap, called after each event is applied (state already coherent). @ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)? private struct CountWaiter { let target: Int let continuation: CheckedContinuation } @ObservationIgnored private var eventWaiters: [CountWaiter] = [] @ObservationIgnored private var sendWaiters: [CountWaiter] = [] // MARK: - Lifecycle /// - Parameters: /// - engine: send-side dependency (`send` only — `open`/`close` belong to /// the T-iOS-15 wiring that constructs the engine). /// - events: the event stream to consume; pass `engine.events` unless a /// fan-out branch is needed (see type doc). init(engine: SessionEngine, events: AsyncStream) { self.engine = engine self.events = events } /// Begin consuming events. Idempotent — a second call is a no-op (the /// stream has exactly one consumer). func start() { guard consumeTask == nil else { return } consumeTask = Task { [weak self] in guard let events = self?.events else { return } for await event in events { guard let self else { return } self.apply(event) } self?.releaseAllWaiters() // stream over — never leave a test hanging } } /// Stop consuming (screen torn down). Does NOT close the engine — detach /// vs. keep-running is the T-iOS-15 lifecycle owner's call. func stop() { consumeTask?.cancel() consumeTask = nil releaseAllWaiters() } // MARK: - Terminal output sink /// Attach the SwiftTerm feed target. Buffered output (anything that /// arrived before the view existed) flushes immediately, in order. func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) { terminalSink = sink let buffered = pendingOutput pendingOutput = [] for chunk in buffered { sink(chunk) } } // MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate) /// Key-bar or hardware key press: bytes resolved through `KeyByteMap` /// (the single source of truth — plan §7 T-iOS-11). func send(key: KeyByteMap.Key) { sendInput(KeyByteMap.bytes(for: key)) } /// Raw input bytes, verbatim (invariant #9 — no content filtering). /// Dropped (and counted) while the terminal is read-only. func sendInput(_ data: String) { guard !isReadOnly else { droppedReadOnlyInputCount += 1 return } enqueueSend(.input(data: data)) } /// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded — /// the engine validates bounds and owns terminal-state dropping. Valid /// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed /// `engine.notifyForegrounded(dims:)` on scenePhase reactivation (closes /// the W4 documented deviation; invalid dims never overwrite a good pair). func sendResize(cols: Int, rows: Int) { if Validation.isValidResize(cols: cols, rows: rows) { lastSentDims = TerminalDims(cols: cols, rows: rows) } enqueueSend(.resize(cols: cols, rows: rows)) } // MARK: - Event application (single consumer, MainActor) private func apply(_ event: SessionEvent) { switch event { case .connection(let state): applyConnection(state) case .adopted(let id): sessionId = id // ALWAYS adopt the server-issued id case .output(let data): deliverOutput(data) case .exited(let code, let reason): phase = .exited(code: code, reason: reason) case .gate, .telemetry, .digest: break // GateViewModel's domain (T-iOS-14; wired in T-iOS-15) } processedEventCount += 1 onEventApplied?(event) resumeEventWaiters() } private func applyConnection(_ state: ConnectionState) { switch state { case .connecting: banner = .connecting case .connected: banner = .none case .reconnecting(let attempt, let next): banner = .reconnecting(attempt: attempt, next: next) case .closed: banner = .none // deliberate end; exit/failure phase (if any) stays case .failed(.replayTooLarge): banner = .none phase = .failed(message: Self.replayTooLargeMessage) } } private func deliverOutput(_ data: String) { guard let terminalSink else { pendingOutput = pendingOutput + [data] return } terminalSink(data) } // MARK: - Ordered send pump private func enqueueSend(_ message: ClientMessage) { sendQueue = sendQueue + [message] guard !isPumping else { return } isPumping = true Task { await self.pumpSendQueue() } } private func pumpSendQueue() async { while let next = sendQueue.first { sendQueue = Array(sendQueue.dropFirst()) await engine.send(next) forwardedSendCount += 1 resumeSendWaiters() } isPumping = false } // MARK: - Deterministic test barriers (no polling, no real sleeps) /// Suspends until at least `eventCount` events have been applied. func waitUntilProcessed(eventCount target: Int) async { await withCheckedContinuation { continuation in guard processedEventCount < target else { continuation.resume() return } eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)] } } /// Suspends until at least `sendCount` messages were handed to the engine. func waitUntilForwarded(sendCount target: Int) async { await withCheckedContinuation { continuation in guard forwardedSendCount < target else { continuation.resume() return } sendWaiters = sendWaiters + [CountWaiter(target: target, continuation: continuation)] } } private func resumeEventWaiters() { let satisfied = eventWaiters.filter { $0.target <= processedEventCount } eventWaiters = eventWaiters.filter { $0.target > processedEventCount } for waiter in satisfied { waiter.continuation.resume() } } private func resumeSendWaiters() { let satisfied = sendWaiters.filter { $0.target <= forwardedSendCount } sendWaiters = sendWaiters.filter { $0.target > forwardedSendCount } for waiter in satisfied { waiter.continuation.resume() } } private func releaseAllWaiters() { let all = eventWaiters + sendWaiters eventWaiters = [] sendWaiters = [] for waiter in all { waiter.continuation.resume() } } }