- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
(AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
{ store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
315 lines
13 KiB
Swift
315 lines
13 KiB
Swift
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
|
||
|
||
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
|
||
}
|
||
|
||
// MARK: - Device certificate (C-iOS-3)
|
||
|
||
/// Toolbar host-menu 入口:呈现设备证书导入/轮换 sheet。
|
||
func presentDeviceCert() {
|
||
isDeviceCertPresented = true
|
||
}
|
||
|
||
/// "在此仓库开新会话":关 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<SidebarItem?> {
|
||
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()
|
||
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
|
||
}
|