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
147 lines
5.1 KiB
Swift
147 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|