Files
web-terminal/ios/App/WebTerm/Screens/PairingScreen.swift
Yaojia Wang 660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.

Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).

Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
2026-07-05 22:00:31 +02:00

401 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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?
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 .paired(let host):
pairedView(host)
}
}
private var idleView: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
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)
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")
}
}
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)
}
.padding(DS.Space.lg16)
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
@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
if needsSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
.buttonStyle(DSButtonStyle(kind: needsSettings ? .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 servewss"
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
static func probing(_ address: String) -> String { "正在验证 \(address)" }
static func paired(_ name: String) -> String { "已配对:\(name)" }
static func scannerStartFailed(_ reason: String) -> String {
"无法启动相机扫描:\(reason)。可改用手输地址。"
}
}