Files
web-terminal/ios/App/WebTerm/Wiring/TerminalSessionController.swift
Yaojia Wang cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00

210 lines
8.0 KiB
Swift

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<SessionEvent>
@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<SessionEvent>
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
}
}