import ClientTLS 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 /// C-iOS-3 (HIGH reachability fix) · "设备证书" sheet (import / rotate the /// mTLS device cert via `ClientCertScreen`). No VM state — the screen owns /// its own `ClientCertViewModel` over the keychain store; dismissal needs no /// refresh because the transports resolve the identity lazily per connection. var isDeviceCertPresented = false /// B3 · "自动获取证书" sheet — the zero-`.p12` enrollment flow /// (`EnrollmentScreen`): one login → Secure-Enclave key + CSR → device cert, /// presented automatically on the existing mTLS path. VM built per entry. var isEnrollmentPresented = false private(set) var enrollmentViewModel: EnrollmentViewModel? /// B3 · Re-entrancy guard so overlapping foregrounds never launch two silent /// renews at once (a shared PTY-style single-flight for the rotation pass). @ObservationIgnored private var isRotating = false /// B3 (HIGH observability fix) · Persistent, OBSERVABLE flag: the last silent /// rotation pass ended in `.failed` (keychain read fault or renew error). Set /// alongside the os.Logger line so a silently-failing renewal surfaces to the /// UI (a warning banner) instead of vanishing into the log. Cleared by the /// next non-failed pass — a recovered renewal drops the warning automatically. private(set) var isCertificateRenewalFailing = 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() } runCertificateRotationIfDue() // B3 · renew a due device cert on cold launch 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 } // MARK: - Device certificate (C-iOS-3) /// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。 func presentDeviceCert() { isDeviceCertPresented = true } // MARK: - Device enrollment (B3, zero-.p12 auto-cert) /// Toolbar host-menu 入口:呈现「自动获取证书」注册 sheet。VM 每次进入重建。 func presentEnrollment() { enrollmentViewModel = makeEnrollmentViewModel() isEnrollmentPresented = true } /// Sheet 消失:丢弃 VM,并立刻跑一次轮换检查(刚注册完的证书据此登记续期)。 func enrollmentDismissed() { enrollmentViewModel = nil runCertificateRotationIfDue() } /// 生产 VM:登录 → 安全区生成密钥+CSR → `/device/enroll` → 存钥匙串,全部经 /// `DeviceEnrollmentFlow`(复用 ClientTLS 库,本层零加密/钥匙串逻辑)。 private func makeEnrollmentViewModel() -> EnrollmentViewModel { let http = environment.http return EnrollmentViewModel( enrollOperation: { password, subdomain, deviceName, url in try await makeDeviceEnrollmentFlow(controlPlaneURL: url, http: http) .run(password: password, subdomain: subdomain, deviceName: deviceName) }, loadSummary: { (try? KeychainClientIdentityStore().loadSummary()) ?? nil } ) } /// B3 · Silent rotation: check the installed leaf's timing and, if the renew /// window has opened, renew over mTLS against the SAME Secure-Enclave key. /// Fire-and-forget on launch + every foreground; single-flight via /// `isRotating`. A `.notEnrolled` / `.notDue` pass is cheap and common. func runCertificateRotationIfDue() { guard !isRotating else { return } isRotating = true let scheduler = makeCertificateRotationScheduler(http: environment.http) Task { [weak self] in let outcome = await scheduler.runIfDue() self?.isRotating = false // Surface a silently-failing renewal as observable state (not only a // log line); a later successful/not-due pass clears it. self?.isCertificateRenewalFailing = (outcome == .failed) } } /// "在此仓库开新会话":关 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 } /// T-iPad-3 · 终端指针上下文菜单「结束会话」→ 复用既有 kill 通道 /// (`SessionListViewModel.kill` → `APIClient.killSession`,带 Origin,无新 /// 网络路径),随后 detach detail。无 adopted 会话 → no-op。 func killCurrentSession() { guard let sessionId = terminalController?.terminalViewModel.sessionId else { return } Task { await sessionList.kill(sessionId: sessionId) } closeTerminal() } // MARK: - Split-view sidebar bridge (T-iPad-2) /// Detail 面板当前显示的会话 → sidebar 选中态(二向绑定的 getter)。只反映 /// **adopted** 会话;未 adopted / 无打开的 detail → nil(占位「选择或新建 /// 会话」)。分栏与 stack 共享同一 `terminalController`(归 coordinator /// 所有,与布局分支正交)。 var selectedSidebarItem: SidebarItem? { terminalController?.terminalViewModel.sessionId.map(SidebarItem.session) } /// `NavigationSplitView` 的选中绑定。getter 反映 detail 会话;setter 复用 /// 既有路由(`open` / `presentProjects`)—— 分栏只是又一个触发面,绝不新开 /// 一条会话生命周期。取消选中(set nil)不主动关闭 detail。 var sidebarSelection: Binding { Binding( get: { self.selectedSidebarItem }, set: { item in guard let item else { return } self.selectSidebarItem(item) } ) } /// sidebar 触发面:把选中项映射到**同一** open(id) / open(nil) / /// presentProjects API。切会话沿用单活 WS 不变式的 close→open(与 /// new-in-cwd / deep-link 切换同一套原语),`.projects` 走既有 sheet 呈现。 func selectSidebarItem(_ item: SidebarItem) { switch item { case .session(let sessionId): switchTerminal(sessionId: sessionId) case .newSession: switchTerminal(sessionId: nil) case .projects: presentProjects() } } /// 单活 WS 不变式下的会话切换:已在显示同一会话 → no-op(免无谓 churn); /// 否则先 `closeTerminal()`(旧会话记 last-seen 水位、engine detach)再 /// `open()`。复用既有原语,**非**平行生命周期。无 activeHost → no-op。 private func switchTerminal(sessionId: UUID?) { guard let host = sessionList.activeHost else { return } if let sessionId, selectedSidebarItem == .session(sessionId) { return } if terminalController != nil { closeTerminal() } open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId)) } // 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() runCertificateRotationIfDue() // B3 · check for a due renewal each foreground 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) } } /// T-iPad-2 · split 模式下 sidebar 的选中项。与现有 `AppCoordinator` 路由 /// 一一等价映射:`.session(id)` == `open(id)`、`.newSession` == `open(nil)`、 /// `.projects` == `presentProjects()`。 enum SidebarItem: Hashable { case session(UUID) case newSession case projects }