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
210 lines
8.0 KiB
Swift
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
|
|
}
|
|
}
|