import HostRegistry import SwiftUI import UIKit #if !targetEnvironment(simulator) import VisionKit #endif /// T-iOS-12 · Pairing screen: QR scan (device only) + manual URL entry + probe. /// Pure presentation over `PairingViewModel` — every rule (input validation, the /// zero-network-before-confirm gate, §5.4 warning tiers, error copy/actions) /// lives in the VM where it is unit-tested. Presented first-run (T-iOS-15) and /// as an add/switch-host sheet. Scanner (`DataScannerViewController`) is compiled /// out on the simulator, where manual entry is the pairing path. struct PairingScreen: View { @Bindable var viewModel: PairingViewModel var onPaired: (HostRegistry.Host) -> Void = { _ in } // T-iOS-15 navigate hook @State private var manualURLText = "" @State private var isShowingScanner = false @State private var scannerError: String? /// C1 · Typed access token. Lives in `SecureField` view state only for as /// long as the prompt is up, and is cleared the moment it is submitted or /// abandoned — the persisted copy belongs in the Keychain, nowhere else. @State private var accessTokenText = "" /// C1 · Host pending the "移除主机" confirmation dialog (nil = none). @State private var hostPendingRemoval: HostRegistry.Host? var body: some View { content .navigationTitle(ScreenCopy.title) .onChange(of: viewModel.pairedHost) { _, paired in guard let paired else { return } onPaired(paired) } } @ViewBuilder private var content: some View { switch viewModel.phase { case .idle: idleView case .confirming(let pending): ConfirmHostView(pending: pending, viewModel: viewModel) case .probing(let pending): probingView(pending) case .failed(let pending, let failure): FailureView(pending: pending, failure: failure, viewModel: viewModel) case .awaitingToken(let pending): tokenPrompt(pending, isValidating: false) case .validatingToken(let pending): tokenPrompt(pending, isValidating: true) case .paired(let host): pairedView(host) } } // MARK: - Access-token prompt (C1 · §1.1) /// The host answered 401. `SecureField` (never a plain `TextField`), no /// autocorrect/autocapitalisation — a token is not prose — and the inline /// rejection comes from the VM, which never echoes the value back. private func tokenPrompt( _ pending: PairingViewModel.PendingHost, isValidating: Bool ) -> some View { ScrollView { VStack(spacing: DS.Space.xl20) { tokenFieldCard(pending, isValidating: isValidating) if let rejection = viewModel.tokenRejection { Label(rejection, systemImage: "exclamationmark.circle.fill") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusStuck) .frame(maxWidth: .infinity, alignment: .leading) } VStack(spacing: DS.Space.md12) { if isValidating { ProgressView() } Button(ScreenCopy.tokenSubmit) { submitAccessToken() } .buttonStyle(DSButtonStyle(kind: .primary)) .disabled(isValidating || accessTokenText.isEmpty) .accessibilityIdentifier("pairing.tokenSubmit") Button(ScreenCopy.cancel) { accessTokenText = "" viewModel.cancelAccessTokenEntry() } .buttonStyle(DSButtonStyle(kind: .secondary)) .disabled(isValidating) } } .padding(DS.Space.lg16) } } private func tokenFieldCard( _ pending: PairingViewModel.PendingHost, isValidating: Bool ) -> some View { Card { VStack(alignment: .leading, spacing: DS.Space.md12) { SectionHeader(title: ScreenCopy.tokenSectionTitle) Text(pending.displayAddress) .font(DS.Typography.mono(.caption)) .foregroundStyle(DS.Palette.textSecondary) Text(ScreenCopy.tokenHint) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) Divider() SecureField(ScreenCopy.tokenPlaceholder, text: $accessTokenText) .font(DS.Typography.mono()) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.go) .disabled(isValidating) .onSubmit { submitAccessToken() } .accessibilityIdentifier("pairing.tokenField") } } } /// Hand the token to the VM and drop the view's copy immediately. private func submitAccessToken() { let token = accessTokenText accessTokenText = "" Task { await viewModel.submitAccessToken(token) } } private var idleView: some View { idleContent .sheet(isPresented: $isShowingScanner) { scannerSheet } .task { await viewModel.loadPairedHosts() } .confirmationDialog( ScreenCopy.removeConfirmTitle, isPresented: removalDialogBinding, titleVisibility: .visible, presenting: hostPendingRemoval ) { host in removalDialogActions(host) } message: { _ in Text(ScreenCopy.removeConfirmMessage) } } /// Presented iff a host is staged for removal; dismissal clears the staging. private var removalDialogBinding: Binding { Binding( get: { hostPendingRemoval != nil }, set: { presented in if !presented { hostPendingRemoval = nil } } ) } @ViewBuilder private func removalDialogActions( _ host: HostRegistry.Host ) -> some View { Button(ScreenCopy.removeConfirm(host.name), role: .destructive) { hostPendingRemoval = nil Task { await viewModel.removeHost(id: host.id) } } Button(ScreenCopy.cancel, role: .cancel) { hostPendingRemoval = nil } } private var hero: some View { VStack(spacing: DS.Space.md12) { // inviting hero Image(systemName: "desktopcomputer") .font(DS.Typography.largeTitle) .foregroundStyle(DS.Palette.accent) .padding(.top, DS.Space.sm8) Text(ScreenCopy.heroTitle) .font(DS.Typography.title) .foregroundStyle(DS.Palette.textPrimary) Text(ScreenCopy.heroSubtitle) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) } private var manualEntryCard: some View { Card { VStack(alignment: .leading, spacing: DS.Space.md12) { SectionHeader(title: ScreenCopy.manualSectionTitle) TextField(ScreenCopy.manualPlaceholder, text: $manualURLText) .font(DS.Typography.mono()) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.go) .onSubmit { viewModel.submitManualURL(manualURLText) } .accessibilityIdentifier("pairing.urlField") Divider() Button(ScreenCopy.manualSubmit) { DS.Haptics.selection() viewModel.submitManualURL(manualURLText) } .buttonStyle(DSButtonStyle(kind: .primary)) .accessibilityIdentifier("pairing.submitButton") } } } private var idleContent: some View { ScrollView { VStack(spacing: DS.Space.xl20) { hero manualEntryCard if let rejection = viewModel.inputRejection { Label(rejection, systemImage: "exclamationmark.circle.fill") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusStuck) .frame(maxWidth: .infinity, alignment: .leading) } if PairingScanAvailability.isAvailable { Button { scannerError = nil isShowingScanner = true } label: { Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder") } .buttonStyle(DSButtonStyle(kind: .secondary)) // hidden on sim } Text(ScreenCopy.qrHint) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) pairedHostsSection } .padding(DS.Space.lg16) } } // MARK: - Paired hosts (C1 · remove + "re-pair to add/update the token") /// The app's host-management surface: re-pairing the same address updates /// that host in place (that is how an existing host gains an access token /// after the server enabled `WEBTERM_TOKEN`), and 移除 deletes the record — /// which takes its stored token with it and de-registers its APNs token. @ViewBuilder private var pairedHostsSection: some View { if !viewModel.pairedHosts.isEmpty || viewModel.hostsErrorMessage != nil { Card { VStack(alignment: .leading, spacing: DS.Space.md12) { SectionHeader(title: ScreenCopy.pairedSectionTitle) if let message = viewModel.hostsErrorMessage { Label(message, systemImage: "exclamationmark.triangle.fill") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusStuck) } ForEach(viewModel.pairedHosts) { host in pairedHostRow(host) if host.id != viewModel.pairedHosts.last?.id { Divider() } } Text(ScreenCopy.pairedSectionHint) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } } } } private func pairedHostRow(_ host: HostRegistry.Host) -> some View { HStack(spacing: DS.Space.md12) { VStack(alignment: .leading, spacing: DS.Space.xs2) { Text(verbatim: host.name) .font(DS.Typography.headline) .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) Text(host.endpoint.originHeader) .dsMetaText() .lineLimit(1) .truncationMode(.middle) // PRESENCE only — a token value never reaches the screen. if host.accessToken != nil { Label(ScreenCopy.tokenStored, systemImage: "key.fill") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.accent) } } Spacer(minLength: 0) Button(role: .destructive) { hostPendingRemoval = host } label: { Label(ScreenCopy.remove, systemImage: "trash") .labelStyle(.iconOnly) } .buttonStyle(.plain) .foregroundStyle(DS.Palette.statusStuck) .accessibilityLabel(ScreenCopy.removeAccessibility(host.name)) .accessibilityIdentifier("pairing.removeHost") } } @ViewBuilder private var scannerSheet: some View { #if targetEnvironment(simulator) // Unreachable: the entry is hidden on the simulator. Kept total. Text(ScreenCopy.scanUnavailable) #else ZStack(alignment: .bottom) { QRScannerView( onCode: { payload in isShowingScanner = false viewModel.handleScannedCode(payload) }, onError: { message in scannerError = message } ) if let scannerError { Text(scannerError) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.statusStuck) .padding(DS.Space.md12) .background(.thinMaterial, in: RoundedRectangle( cornerRadius: DS.Radius.sm8 )) .padding(DS.Space.lg16) } } #endif } private func probingView(_ pending: PairingViewModel.PendingHost) -> some View { VStack(spacing: DS.Space.lg16) { ProgressView() Text(ScreenCopy.probing(pending.displayAddress)) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textSecondary) .multilineTextAlignment(.center) } .padding(DS.Space.xl20) .frame(maxWidth: .infinity, maxHeight: .infinity) } private func pairedView(_ host: HostRegistry.Host) -> some View { VStack(spacing: DS.Space.md12) { Image(systemName: "checkmark.circle.fill") .font(DS.Typography.largeTitle) .foregroundStyle(DS.Palette.statusWorking) Text(ScreenCopy.paired(host.name)) .font(DS.Typography.headline) .foregroundStyle(DS.Palette.textPrimary) } .padding(DS.Space.xl20) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { DS.Haptics.success() } } } /// Confirm page — parsed address + §5.4 warning tier + host name. private struct ConfirmHostView: View { let pending: PairingViewModel.PendingHost @Bindable var viewModel: PairingViewModel var body: some View { ScrollView { VStack(spacing: DS.Space.xl20) { addressCard warningTier VStack(spacing: DS.Space.md12) { Button(ScreenCopy.connect) { DS.Haptics.selection() Task { await viewModel.confirmConnect() } } .buttonStyle(DSButtonStyle(kind: .primary)) .accessibilityIdentifier("pairing.confirmButton") Button(ScreenCopy.cancel) { viewModel.cancel() } .buttonStyle(DSButtonStyle(kind: .secondary)) } } .padding(DS.Space.lg16) } } private var addressCard: some View { Card { VStack(alignment: .leading, spacing: DS.Space.md12) { SectionHeader(title: ScreenCopy.confirmSectionTitle) // Single-point-derived origin (UITest asserts this exact string). Text(pending.displayAddress) .font(DS.Typography.mono()) .foregroundStyle(DS.Palette.textPrimary) .textSelection(.enabled) Divider() TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName) .font(DS.Typography.body) } } } // §5.4 warning tiers — LOGIC frozen (switch cases), only presentation restyled. @ViewBuilder private var warningTier: some View { switch pending.warning { case .none: EmptyView() case .tailscaleEncrypted: // positive accent badge — encrypted transport Card { Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield") .font(DS.Typography.callout) .foregroundStyle(DS.Palette.accent) .frame(maxWidth: .infinity, alignment: .leading) } case .plaintextLAN: // subtle amber note Label(ScreenCopy.plaintextNotice, systemImage: "eye") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusWaiting) .frame(maxWidth: .infinity, alignment: .leading) case .publicHostBlocking: publicWarningCard } } /// Prominent red card + acknowledgement gate (Toggle → `hasAcknowledgedPublicRisk`; /// required-message on `needsPublicRiskAcknowledgement`). Logic unchanged. private var publicWarningCard: some View { VStack(alignment: .leading, spacing: DS.Space.md12) { Label { Text(ScreenCopy.publicWarning) .font(DS.Typography.headline) .foregroundStyle(DS.Palette.textPrimary) } icon: { Image(systemName: "exclamationmark.octagon.fill") .foregroundStyle(DS.Palette.statusStuck) } Divider() Toggle(ScreenCopy.publicAcknowledge, isOn: $viewModel.hasAcknowledgedPublicRisk) .font(DS.Typography.callout) .tint(DS.Palette.accent) if viewModel.needsPublicRiskAcknowledgement { Label(ScreenCopy.publicAckRequired, systemImage: "arrow.up") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusStuck) } } .padding(DS.Space.md12) .background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12)) .overlay( RoundedRectangle(cornerRadius: DS.Radius.md12) .strokeBorder(DS.Palette.statusStuck, lineWidth: DS.Stroke.hairline) ) } } /// Failure page — inline copy + recovery actions (retry / settings / back). private struct FailureView: View { let pending: PairingViewModel.PendingHost let failure: PairingViewModel.FailureDisplay let viewModel: PairingViewModel var body: some View { ScrollView { VStack(spacing: DS.Space.xl20) { Card { VStack(alignment: .leading, spacing: DS.Space.sm8) { Text(pending.displayAddress) .font(DS.Typography.mono(.caption)) .foregroundStyle(DS.Palette.textSecondary) .lineLimit(1) .truncationMode(.middle) Label { Text(failure.message) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textPrimary) } icon: { Image(systemName: "xmark.octagon.fill") .foregroundStyle(DS.Palette.statusStuck) } } .frame(maxWidth: .infinity, alignment: .leading) } VStack(spacing: DS.Space.md12) { let needsSettings = failure.action == .openLocalNetworkSettings // C1 · 401 is ambiguous (token OR Origin), so BOTH remedies // stay on screen: the token prompt leads, retry stays put. let needsToken = failure.action == .enterAccessToken if needsSettings { Button(ScreenCopy.openSettings) { openAppSettings() } .buttonStyle(DSButtonStyle(kind: .primary)) } if needsToken { Button(ScreenCopy.enterToken) { viewModel.beginAccessTokenEntry() } .buttonStyle(DSButtonStyle(kind: .primary)) .accessibilityIdentifier("pairing.enterTokenButton") } Button(ScreenCopy.retry) { Task { await viewModel.retry() } } .buttonStyle(DSButtonStyle( kind: (needsSettings || needsToken) ? .secondary : .primary )) Button(ScreenCopy.back) { viewModel.cancel() } .buttonStyle(DSButtonStyle(kind: .secondary)) } } .padding(DS.Space.lg16) } } /// The app's Settings pane hosts its 本地网络 toggle (no deep link exists). private func openAppSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } } enum PairingScanAvailability { /// Simulator: no camera → hidden, manual entry is the pairing path. Device: /// requires VisionKit support (`isSupported` is MainActor-isolated). @MainActor static var isAvailable: Bool { #if targetEnvironment(simulator) return false #else return DataScannerViewController.isSupported #endif } } #if !targetEnvironment(simulator) /// Thin `DataScannerViewController` wrapper: QR only; first recognized code wins; /// payload handed to the VM untouched (validation is the VM's job). private struct QRScannerView: UIViewControllerRepresentable { let onCode: (String) -> Void let onError: (String) -> Void func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } func makeUIViewController(context: Context) -> DataScannerViewController { let scanner = DataScannerViewController( recognizedDataTypes: [.barcode(symbologies: [.qr])], qualityLevel: .balanced, isHighlightingEnabled: true ) scanner.delegate = context.coordinator return scanner } func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { guard !scanner.isScanning else { return } do { try scanner.startScanning() } catch { // Explicit surfacing (plan §4): shows inline; manual entry remains. onError(ScreenCopy.scannerStartFailed(error.localizedDescription)) } } @MainActor final class Coordinator: NSObject, DataScannerViewControllerDelegate { private let onCode: (String) -> Void private var hasDelivered = false init(onCode: @escaping (String) -> Void) { self.onCode = onCode } func dataScanner( _ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem] ) { guard !hasDelivered else { return } for item in addedItems { if case .barcode(let barcode) = item, let payload = barcode.payloadStringValue { hasDelivered = true onCode(payload) return } } } } } #endif private enum ScreenCopy { static let title = "配对主机" static let heroTitle = "连接你的电脑" static let heroSubtitle = "在同一网络里打开电脑上运行的终端会话——手动输入地址,或扫描它的配对二维码。" static let manualSectionTitle = "输入地址" static let manualPlaceholder = "http://192.168.1.5:3000" static let manualSubmit = "连接" static let scanButton = "扫描二维码" static let scanUnavailable = "模拟器不支持扫码,请手输地址。" static let qrHint = "在电脑的 web 终端工具栏点「Connect a device」可显示配对二维码;也可直接输入它的地址。" static let confirmSectionTitle = "确认要连接的主机" static let namePlaceholder = "主机名称" static let connect = "连接" static let cancel = "取消" static let retry = "重试" static let back = "返回" static let openSettings = "去设置" static let tailscaleBadge = "经 Tailscale 加密(WireGuard 网络层)" static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。" static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。" static let publicAcknowledge = "我已了解风险,仍要连接" static let publicAckRequired = "请先勾选上面的风险确认,再点连接。" // C1 · access token + host management static let enterToken = "输入访问令牌" static let tokenSectionTitle = "输入访问令牌" static let tokenPlaceholder = "WEBTERM_TOKEN" static let tokenHint = "这台主机设置了 WEBTERM_TOKEN。令牌只会保存在本机钥匙串里,绝不出现在网址或日志中。" static let tokenSubmit = "验证并保存" static let tokenStored = "已保存访问令牌" static let pairedSectionTitle = "已配对主机" static let pairedSectionHint = "想给已配对的主机补/换访问令牌:在上面重新输入同一个地址配对一次即可(不会重复添加)。" static let remove = "移除" static let removeConfirmTitle = "移除这台主机?" static let removeConfirmMessage = "会同时删除本机保存的访问令牌,并在该主机上注销本设备的推送。主机上正在跑的会话不受影响。" static func removeConfirm(_ name: String) -> String { "移除「\(name)」" } static func removeAccessibility(_ name: String) -> String { "移除主机 \(name)" } static func probing(_ address: String) -> String { "正在验证 \(address) …" } static func paired(_ name: String) -> String { "已配对:\(name)" } static func scannerStartFailed(_ reason: String) -> String { "无法启动相机扫描:\(reason)。可改用手输地址。" } }