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) } }