Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
153 lines
6.2 KiB
Swift
153 lines
6.2 KiB
Swift
import SwiftUI
|
||
|
||
/// T-iPad-2 · size-class 驱动的单一根视图。按 `LayoutPolicy.mode` 在 compact
|
||
/// 宽度(iPhone / iPad Slide Over)选 `StackRootView`(现有路径,字节级不变)、
|
||
/// 在 regular 宽度(iPad 全屏 / 大分屏 / Stage Manager 大窗)选 `SplitRootView`
|
||
/// (分栏)。
|
||
///
|
||
/// **横切布线只声明一次、两分支共享**(PLAN_IOS_IPAD §5 T-iPad-2):
|
||
/// - 隐私遮罩:ZStack 的**最顶层**,覆盖整棵导航树(含 split detail 的终端
|
||
/// 字节)——`scenePhase != .active` 即遮(安全不变式,见 `PrivacyShadePolicy`)。
|
||
/// 上提到这里,split detail 与 stack push 同样被遮,无遗漏。
|
||
/// - `.task` bootstrap / `.onChange(scenePhase)` / `.onOpenURL` / deep-link 提示
|
||
/// alert / add-host sheet / Projects sheet:全部设备无关,原样从旧 `RootView`
|
||
/// 搬到这一层,故 compact 分支拿到与适配前完全一致的横切行为。
|
||
///
|
||
/// size class 是**运行时可变量**(iPad 拉出 Slide Over 立刻 regular→compact),
|
||
/// 所以这不是两套 UI 分叉,而是同一次运行内的自适应切换:`terminalController`
|
||
/// 归 `AppCoordinator` 所有,与布局分支正交,切换时不重建(T-iOS-29 `.id`
|
||
/// 稳定性的前提)。
|
||
struct AdaptiveRootView: View {
|
||
@Bindable var coordinator: AppCoordinator
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
layoutBranch
|
||
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
|
||
PrivacyShadeView()
|
||
}
|
||
}
|
||
.task { await coordinator.bootstrap() }
|
||
.onChange(of: scenePhase) { _, phase in
|
||
coordinator.handleScenePhase(phase)
|
||
}
|
||
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
|
||
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
|
||
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
|
||
} message: {
|
||
Text(coordinator.deepLink.hintMessage ?? "")
|
||
}
|
||
.sheet(
|
||
isPresented: $coordinator.isAddHostPresented,
|
||
onDismiss: { coordinator.addHostDismissed() }
|
||
) {
|
||
addHostSheet
|
||
}
|
||
.sheet(
|
||
isPresented: $coordinator.isProjectsPresented,
|
||
onDismiss: { coordinator.projectsDismissed() }
|
||
) {
|
||
projectsSheet
|
||
}
|
||
.sheet(isPresented: $coordinator.isDeviceCertPresented) {
|
||
deviceCertSheet
|
||
}
|
||
.sheet(
|
||
isPresented: $coordinator.isEnrollmentPresented,
|
||
onDismiss: { coordinator.enrollmentDismissed() }
|
||
) {
|
||
enrollmentSheet
|
||
}
|
||
}
|
||
|
||
// MARK: - Layout branch (the SOLE size-class consumer)
|
||
|
||
@ViewBuilder private var layoutBranch: some View {
|
||
// Split only makes sense once we're in the session list. `.loading` and
|
||
// `.pairing` (genuine iPad first-run, no paired host) get the full-screen
|
||
// stack flow regardless of size class — a split sidebar has nothing to
|
||
// list yet and would strand the user on the not-paired empty state
|
||
// (T-iPad-5 finding). Route-gate the split branch.
|
||
switch LayoutPolicy.mode(horizontalSizeClass: horizontalSizeClass) {
|
||
case .split where coordinator.route == .sessions:
|
||
SplitRootView(coordinator: coordinator)
|
||
default:
|
||
StackRootView(coordinator: coordinator)
|
||
}
|
||
}
|
||
|
||
// MARK: - Deep-link hint alert (T-iOS-22)
|
||
|
||
/// unknown host / store failure 提示(同旧 `RootView`)。
|
||
private var deepLinkHintBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { coordinator.deepLink.hintMessage != nil },
|
||
set: { presented in
|
||
guard !presented else { return }
|
||
coordinator.deepLink.clearHint()
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: - Add-host sheet (multi-host entry, list header)
|
||
|
||
@ViewBuilder private var addHostSheet: some View {
|
||
if let viewModel = coordinator.addHostPairingViewModel {
|
||
NavigationStack {
|
||
PairingScreen(viewModel: viewModel) { host in
|
||
coordinator.completeAddHost(host)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Projects sheet (T-iOS-26)
|
||
|
||
/// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push;
|
||
/// "在此仓库开新会话" 关掉 sheet 并在根导航开终端。
|
||
@ViewBuilder private var projectsSheet: some View {
|
||
if let viewModel = coordinator.projectsViewModel {
|
||
NavigationStack {
|
||
ProjectsScreen(viewModel: viewModel) { request in
|
||
coordinator.openProject(request)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Device certificate sheet (C-iOS-3, HIGH reachability fix)
|
||
|
||
/// 自带 NavigationStack(`ClientCertScreen` 用 `.navigationTitle`)+ 右上「完成」
|
||
/// 关闭按钮,保证导入 .p12 后能明确返回。`ClientCertScreen` 自持
|
||
/// `ClientCertViewModel`(keychain store),此处零依赖注入。
|
||
@ViewBuilder private var deviceCertSheet: some View {
|
||
NavigationStack {
|
||
ClientCertScreen()
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(RootCopy.done) { coordinator.isDeviceCertPresented = false }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Device enrollment sheet (B3, zero-.p12 auto-cert)
|
||
|
||
/// 自带 NavigationStack + 右上「完成」。VM 由 coordinator 每次进入构建
|
||
/// (`presentEnrollment`);关闭后 `enrollmentDismissed` 丢弃 VM 并跑一次轮换。
|
||
@ViewBuilder private var enrollmentSheet: some View {
|
||
if let viewModel = coordinator.enrollmentViewModel {
|
||
NavigationStack {
|
||
EnrollmentScreen(model: viewModel)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(RootCopy.done) { coordinator.isEnrollmentPresented = false }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|