import HostRegistry import SwiftUI /// T-iOS-15 / T-iPad-2 · App 根入口。历史上这里是单一 `NavigationStack` + /// 隐私遮罩 + sheets/scenePhase/deepLink 的全部布线;iPad 自适应(T-iPad-2) /// 把它拆成两层: /// - `RootView`(本 struct)保持 `@main` 的唯一实例化点不变(WebTermApp 仍 /// `RootView(coordinator:)`),内部委托给 `AdaptiveRootView` 并在这唯一处 /// 注入设计系统的 accent tint(`DS.Palette.accent`,DS 建议的「根注入一次」) /// —— 全 App 的系统控件/工具栏/`.borderedProminent` 由此统一到靛紫强调色。 /// - `AdaptiveRootView` 按 `horizontalSizeClass` 选 `StackRootView`(compact, /// 现有路径,字节级不变)或 `SplitRootView`(regular,iPad 分栏),并把 /// 隐私遮罩 / scenePhase / deepLink / sheets 这些**设备无关的横切布线** /// 上提到唯一一处,两分支共享(遮罩因此仍是两分支共同的 ZStack 顶层)。 /// /// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它 /// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层 /// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。 /// T-iOS-34 · 主题(`ThemeStore`)就挂在这一层:它是 `@main` 唯一的实例化点, /// 所以「一个 store、一次注入、一处 `preferredColorScheme`」在这里成立, /// 不会出现两份主题真值。 struct RootView: View { @Bindable var coordinator: AppCoordinator /// 主题设置的单一真值(`UserDefaults` 持久化)。`@State` ⇒ 与根视图同寿命。 @State private var themeStore = ThemeStore() var body: some View { AdaptiveRootView(coordinator: coordinator) // DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的 // 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。 .tint(DS.Palette.accent) // 主题:默认仍是深色(对齐桌面 web `DEFAULT_SETTINGS.theme='dark'`, // 也等于此前硬锁 `.preferredColorScheme(.dark)` 的观感 ⇒ 升级零变化)。 // 「跟随系统」时 `colorScheme` 为 nil = 不表态,交给 iOS。 // 曾经硬锁深色的理由是「金色在浅底对比不足」;那是 token 问题而不是 // 主题问题,已在 `Tokens.swift` 逐色补了浅色值(WCAG 3:1 起)。 .preferredColorScheme(themeStore.theme.colorScheme) // 设置页与终端预览都从环境取同一个 store(不传参穿透整棵树)。 .environment(themeStore) } } /// T-iPad-2 · compact 宽度(iPhone / iPad Slide Over)的根视图 —— 原 /// `RootView` 的 `NavigationStack` 主体原样搬迁,行为字节级不变。横切布线 /// (遮罩/scenePhase/deepLink/sheets)不在这里,已上提到 `AdaptiveRootView`。 struct StackRootView: View { @Bindable var coordinator: AppCoordinator @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { NavigationStack { rootContent .navigationDestination(isPresented: terminalBinding) { terminalDestination } } } // MARK: - Route switch @ViewBuilder private var rootContent: some View { switch coordinator.route { case .loading: ProgressView() case .pairing: firstRunPairing case .sessions: sessionList } } @ViewBuilder private var firstRunPairing: some View { if let viewModel = coordinator.rootPairingViewModel { PairingScreen(viewModel: viewModel) { host in coordinator.completeFirstPairing(host) } } else { ProgressView() } } private var sessionList: some View { SessionListScreen( viewModel: coordinator.sessionList, onOpen: { coordinator.open($0) }, onAddHost: { coordinator.presentAddHost() }, onDeviceCert: { coordinator.presentDeviceCert() }, onEnroll: { coordinator.presentEnrollment() } ) .safeAreaInset(edge: .bottom) { continueLastBanner } // B3 (HIGH) · A silently-failing device-cert renewal is surfaced here // (top inset), so it is observable instead of buried in os.Logger. .safeAreaInset(edge: .top) { certRenewalWarningBanner } // 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。 .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: coordinator.continueLastSessionId ) // T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是 // T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位, // 避开列表自己的 topBarTrailing hostMenu。 .toolbar { ProjectsToolbarItem(coordinator: coordinator) } } // MARK: - "继续上次" highlight (cold start step 5) @ViewBuilder private var continueLastBanner: some View { if coordinator.continueLastSessionId != nil { ContinueLastBanner { coordinator.openContinueLast() } .transition(.move(edge: .bottom).combined(with: .opacity)) } } // MARK: - Device-cert renewal warning (B3 HIGH observability fix) @ViewBuilder private var certRenewalWarningBanner: some View { if coordinator.isCertificateRenewalFailing { CertRenewalWarningBanner() .transition(.move(edge: .top).combined(with: .opacity)) } } // MARK: - Terminal push private var terminalBinding: Binding { Binding( get: { coordinator.terminalController != nil }, set: { presented in guard !presented else { return } coordinator.closeTerminal() // back → engine.close() (detach) } ) } @ViewBuilder private var terminalDestination: some View { if let controller = coordinator.terminalController { TerminalContainerView( controller: controller, onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() }, onKillSession: { coordinator.killCurrentSession() } // T-iPad-3 ) // T-iOS-29 · identity PER CONTROLLER: an in-place session switch // (new-in-cwd, deep link) swaps the controller while the // destination stays presented — without a new identity SwiftUI // keeps the old SwiftTerm UIView and the new ViewModel's output // sink never attaches (makeUIView never re-runs). Also resets the // container's per-session @State (plan-gate dismissal, timeline). .id(controller.id) } } } // MARK: - Continue-last banner (shared stack + split, DRY) /// 冷启动第 5 步的「继续上次会话」再入 CTA —— stack 底栏与 split sidebar 底栏 /// 共用同一个组件(DRY)。设计:`.regularMaterial` 底 + 顶部发丝分隔 + /// 全宽 accent 主按钮(`DSButtonStyle(.primary)`),克制而不喧宾夺主。行为仅 /// 一条 `action`(= `coordinator.openContinueLast()`),出现条件由调用点把关。 struct ContinueLastBanner: View { let action: () -> Void var body: some View { Button(action: action) { Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill") } .buttonStyle(DSButtonStyle(kind: .primary)) .accessibilityIdentifier("root.continueLastButton") .padding(.horizontal, DS.Space.lg16) .padding(.top, DS.Space.md12) .padding(.bottom, DS.Space.sm8) .background(.regularMaterial) .overlay(alignment: .top) { Rectangle() .fill(DS.Palette.hairline) .frame(height: DS.Stroke.hairline) } } } // MARK: - Device-cert renewal warning (B3 HIGH observability fix) /// A quiet, non-blocking warning that the silent device-certificate renewal is /// failing — surfaced from `AppCoordinator.isCertificateRenewalFailing` so a /// failing renewal is observable in the UI instead of only in os.Logger. Amber /// (the `waiting`/needs-me status color) + an SF Symbol so meaning is never /// carried by color alone. Non-interactive: the existing cert stays valid until /// expiry, and the next foreground retries automatically. struct CertRenewalWarningBanner: View { var body: some View { Label(RootCopy.certRenewalFailing, systemImage: "exclamationmark.shield") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusWaiting) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, DS.Space.lg16) .padding(.vertical, DS.Space.sm8) .background(.regularMaterial) .overlay(alignment: .bottom) { Rectangle() .fill(DS.Palette.hairline) .frame(height: DS.Stroke.hairline) } .accessibilityIdentifier("root.certRenewalWarning") } } // MARK: - Root leading toolbar (shared stack + split, DRY) /// 会话列表的 leading 工具栏组 —— stack 与 split 共用(同 disabled 条件、同动作、 /// 同 a11y id)。根 tint 令其 label 呈 accent。 /// /// 组里现在有两项:「项目」(原有)+「设置」(T-iOS-34 主题入口)。 /// **命名保留** `ProjectsToolbarItem`:`SplitRootView.swift` 按此名引用它,而该 /// 文件不在 C4 的 Owns 里;把设置项加进这个共用组,是让 iPhone(stack) 与 /// iPad(split) 同时拿到入口且不越界编辑的唯一办法。改名 → /// `RootLeadingToolbar` 留给拥有 `SplitRootView.swift` 的后续任务(纯机械重命名)。 struct ProjectsToolbarItem: ToolbarContent { @Bindable var coordinator: AppCoordinator var body: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { Button { coordinator.presentProjects() } label: { Label(RootCopy.projects, systemImage: "folder") } .disabled(coordinator.sessionList.activeHost == nil) .accessibilityIdentifier("sessions.projectsButton") } ToolbarItem(placement: .topBarLeading) { SettingsToolbarButton() } } } /// 设置入口(齿轮)+ 它自己的 sheet。自持 `@State` 表示态,所以不需要在 /// `AppCoordinator` 里再开一个 `isSettingsPresented` —— 设置页与会话生命周期 /// 完全无关,没有理由进协调器的状态机。 /// /// `ThemeStore` 从环境取,且是**可选**读取:若某个预览/测试没注入 store, /// 这里就不渲染齿轮,而不是崩(环境非可选读取在缺注入时会 crash)。 struct SettingsToolbarButton: View { @Environment(ThemeStore.self) private var themeStore: ThemeStore? @State private var isPresented = false var body: some View { if let themeStore { Button { isPresented = true } label: { Label(RootCopy.settings, systemImage: "gearshape") } .accessibilityIdentifier("sessions.settingsButton") .sheet(isPresented: $isPresented) { NavigationStack { SettingsScreen(themeStore: themeStore) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(RootCopy.done) { isPresented = false } } } } } } } } /// 根层用户可见文案(internal —— `SplitRootView` 复用同一「项目」/「继续」标签,DRY)。 enum RootCopy { static let continueLast = "继续上次会话" static let projects = "项目" static let settings = "设置" static let done = "完成" /// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing. static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试" }