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
This commit is contained in:
146
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
146
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal
|
||||
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
|
||||
///
|
||||
/// - Cold start: read the host store once — no paired host → Pairing, else
|
||||
/// SessionList (`ColdStartPolicy.initialRoute`).
|
||||
/// - `SessionListScreen.onOpen` → build ONE `TerminalSessionController`
|
||||
/// (single foreground session, plan §1) and push the terminal.
|
||||
/// - Back → `closeTerminal()` → `engine.close()` (detach; PTY keeps running).
|
||||
/// - scenePhase is forwarded to the open controller (`.background` →
|
||||
/// suspend/close, `.active` → resume/rebuild). The privacy shade is view
|
||||
/// layer (RootView + PrivacyShadePolicy), not coordinator state.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppCoordinator {
|
||||
private(set) var route: ColdStartPolicy.RootRoute = .loading
|
||||
private(set) var terminalController: TerminalSessionController?
|
||||
/// First-run pairing VM (root route). Recreated per entry to keep pairing
|
||||
/// state machines single-shot.
|
||||
private(set) var rootPairingViewModel: PairingViewModel?
|
||||
/// Add-host pairing VM (sheet from the list header).
|
||||
private(set) var addHostPairingViewModel: PairingViewModel?
|
||||
var isAddHostPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
sessionList = SessionListViewModel(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
clock: ContinuousClock()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cold start
|
||||
|
||||
/// Decide the boot route from the host store. A store READ failure routes
|
||||
/// to the session list, whose own `reloadHosts` surfaces the explicit
|
||||
/// error copy (never a silent empty pairing screen hiding a broken store).
|
||||
func bootstrap() async {
|
||||
guard route == .loading else { return }
|
||||
do {
|
||||
let hosts = try await environment.hostStore.loadAll()
|
||||
route = ColdStartPolicy.initialRoute(pairedHostCount: hosts.count)
|
||||
} catch {
|
||||
route = .sessions
|
||||
}
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// First-run pairing done → move to the list (the paired host is already
|
||||
/// in the store — PairingViewModel upserts before signalling).
|
||||
func completeFirstPairing(_ host: HostRegistry.Host) {
|
||||
rootPairingViewModel = nil
|
||||
route = .sessions
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Add-host sheet (list header hook)
|
||||
|
||||
func presentAddHost() {
|
||||
addHostPairingViewModel = makePairingViewModel()
|
||||
isAddHostPresented = true
|
||||
}
|
||||
|
||||
func completeAddHost(_ host: HostRegistry.Host) {
|
||||
isAddHostPresented = false
|
||||
addHostDismissed()
|
||||
}
|
||||
|
||||
/// Sheet gone (paired OR cancelled): drop the VM and refresh hosts — the
|
||||
/// list VM keeps the active host if it still exists.
|
||||
func addHostDismissed() {
|
||||
addHostPairingViewModel = nil
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Terminal open/close
|
||||
|
||||
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
|
||||
/// "继续上次" banner both land here. `sessionId == nil` = new session.
|
||||
func open(_ request: SessionListViewModel.OpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
let controller = TerminalSessionController(
|
||||
host: request.host,
|
||||
sessionId: request.sessionId,
|
||||
environment: environment,
|
||||
onPendingChanged: { [weak self] sessionId, pending in
|
||||
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
|
||||
}
|
||||
)
|
||||
terminalController = controller
|
||||
controller.start()
|
||||
}
|
||||
|
||||
/// Back navigation popped the terminal: explicit detach.
|
||||
func closeTerminal() {
|
||||
terminalController?.teardown()
|
||||
terminalController = nil
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" (cold start step 5)
|
||||
|
||||
var continueLastSessionId: UUID? {
|
||||
ColdStartPolicy.continueLastSessionId(
|
||||
activeHost: sessionList.activeHost,
|
||||
store: environment.lastSessionStore
|
||||
)
|
||||
}
|
||||
|
||||
func openContinueLast() {
|
||||
guard let host = sessionList.activeHost, let sessionId = continueLastSessionId else {
|
||||
return
|
||||
}
|
||||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - scenePhase (plan §7 T-iOS-15 step 3)
|
||||
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .background:
|
||||
terminalController?.suspend()
|
||||
case .active:
|
||||
terminalController?.resumeIfNeeded()
|
||||
case .inactive:
|
||||
break // transient; shade covers it at the view layer
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makePairingViewModel() -> PairingViewModel {
|
||||
PairingViewModel(store: environment.hostStore, probe: environment.probe)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user