import Foundation import HostRegistry import Observation import SwiftUI import WireProtocol /// 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 /// T-iOS-26 · Projects sheet(入口在 RootView 的 toolbar —— 不碰 /// `SessionListScreen`,该文件 W7 内归 T-iOS-23)。每次呈现新建 VM, /// prefs 每次进入都重新拉取。 private(set) var projectsViewModel: ProjectsViewModel? var isProjectsPresented = false let sessionList: SessionListViewModel @ObservationIgnored let environment: AppEnvironment /// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in /// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension). @ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler() init(environment: AppEnvironment) { self.environment = environment sessionList = SessionListViewModel( hostStore: environment.hostStore, http: environment.http, clock: ContinuousClock(), unreadStore: environment.unreadStore ) } // 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() } await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22) } /// 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: - Projects (T-iOS-26) /// Toolbar 入口(RootView):以当前活跃主机呈现 Projects sheet。 func presentProjects() { guard let host = sessionList.activeHost else { return } projectsViewModel = ProjectsViewModel(host: host, http: environment.http) isProjectsPresented = true } /// Sheet 消失(打开会话 OR 手动关闭):丢弃 VM。 func projectsDismissed() { projectsViewModel = nil } /// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+ /// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。 func openProject(_ request: ProjectOpenRequest) { guard terminalController == nil else { return } // one foreground session isProjectsPresented = false projectsViewModel = nil startTerminal( host: request.host, sessionId: nil, spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput ) } // 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 startTerminal(host: request.host, sessionId: request.sessionId) } /// 唯一的 controller 构造点(普通打开与 T-iOS-26 项目内 spawn 共用)。 private func startTerminal( host: HostRegistry.Host, sessionId: UUID?, spawnCwd: String? = nil, bootstrapInput: String? = nil ) { let controller = TerminalSessionController( host: host, sessionId: sessionId, environment: environment, onPendingChanged: { [weak self] sessionId, pending in self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending) }, onTitleChanged: { [weak self] sessionId, title in // T-iOS-23 · OSC title → list row (already sanitized in the // VM; the list VM sanitizes once more at its own boundary). self?.sessionList.setSessionTitle(sessionId: sessionId, title: title) }, spawnCwd: spawnCwd, bootstrapInput: bootstrapInput ) terminalController = controller controller.start() } /// Back navigation popped the terminal: explicit detach. Also the first /// half of every session SWITCH (single live WS invariant, plan §1): /// list back-nav and `openDeepLinkedSession` both close here before the /// next `open` — one engine at a time, always close→open with replay. func closeTerminal() { // T-iOS-23 · leaving = seen: stamp the unread watermark for the // adopted session so output watched in the terminal never relights // the list dot. if let sessionId = terminalController?.terminalViewModel.sessionId { sessionList.markSeen(sessionId: sessionId) } terminalController?.teardown() terminalController = nil } // MARK: - "在当前目录开新会话" (T-iOS-29) /// 当前终端会话的 cwd。解析次序:/live-sessions 行数据(server-adopted /// id 匹配行,列表 VM 的最近快照)→ controller 的 `spawnCwd`(T-iOS-26 /// 项目 fresh-spawn 尚未进列表轮询)→ nil(未知)。服务器数据不可信: /// 非绝对路径按未知处理(ProjectsViewModel 同款纪律;engine 侧还会再验)。 var currentTerminalCwd: String? { guard let controller = terminalController else { return nil } let fromRows = controller.terminalViewModel.sessionId.flatMap { id in sessionList.rows.first(where: { $0.id == id })?.info.cwd } let candidate = fromRows ?? controller.spawnCwd return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil } } /// TerminalScreen 工具栏与 exit 横幅共用动作:在当前会话的 cwd fresh /// spawn(`attach(null, cwd)`,镜像 web tabs.ts `newTab()` M6)。单活 /// WS 不变式:先 `closeTerminal()`(旧会话记 last-seen 水位、engine /// detach),再开新 controller。cwd 未知 → 普通新会话;无打开的终端 /// → no-op。不注入 bootstrap —— "开新 shell"不是"起 claude"。 func openNewSessionInCurrentCwd() { guard let controller = terminalController else { return } let host = controller.host let cwd = currentTerminalCwd closeTerminal() startTerminal(host: host, sessionId: nil, spawnCwd: cwd) } // 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) } }