T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
351 lines
12 KiB
Swift
351 lines
12 KiB
Swift
import HostRegistry
|
||
import SwiftUI
|
||
import UIKit
|
||
#if !targetEnvironment(simulator)
|
||
import VisionKit
|
||
#endif
|
||
|
||
/// T-iOS-12 · Pairing screen: QR scan (real device only) + manual URL entry +
|
||
/// probe UI. 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.
|
||
///
|
||
/// Presentation-agnostic on purpose: T-iOS-15 pushes it as the first-run
|
||
/// screen, and the session list header presents the SAME screen as a sheet to
|
||
/// add/switch hosts (the "多 host 切换入口" of the task's step list).
|
||
///
|
||
/// Scanner: VisionKit `DataScannerViewController` — compiled out for the
|
||
/// simulator (`#if targetEnvironment(simulator)`), where manual entry is the
|
||
/// pairing path; on device the entry also hides when scanning is unsupported.
|
||
/// `NSCameraUsageDescription` is already declared (project.yml, plan §5.2).
|
||
struct PairingScreen: View {
|
||
@Bindable var viewModel: PairingViewModel
|
||
/// Navigate-on-paired hook for the T-iOS-15 wiring.
|
||
var onPaired: (HostRegistry.Host) -> Void = { _ in }
|
||
|
||
@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)
|
||
}
|
||
}
|
||
|
||
// MARK: - Idle: manual entry + scan entry
|
||
|
||
private var idleView: some View {
|
||
Form {
|
||
Section(ScreenCopy.manualSectionTitle) {
|
||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||
.keyboardType(.URL)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
Button(ScreenCopy.manualSubmit) {
|
||
viewModel.submitManualURL(manualURLText)
|
||
}
|
||
}
|
||
if let rejection = viewModel.inputRejection {
|
||
Section {
|
||
Text(rejection).foregroundStyle(.red)
|
||
}
|
||
}
|
||
if PairingScanAvailability.isAvailable {
|
||
Section {
|
||
Button {
|
||
scannerError = nil
|
||
isShowingScanner = true
|
||
} label: {
|
||
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
|
||
}
|
||
}
|
||
}
|
||
Section {
|
||
Text(ScreenCopy.qrHint)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.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)
|
||
.foregroundStyle(.red)
|
||
.padding()
|
||
.background(.thinMaterial, in: RoundedRectangle(
|
||
cornerRadius: Metrics.errorCornerRadius
|
||
))
|
||
.padding()
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Probing / paired
|
||
|
||
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
|
||
VStack(spacing: Metrics.stackSpacing) {
|
||
ProgressView()
|
||
Text(ScreenCopy.probing(pending.displayAddress))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding()
|
||
}
|
||
|
||
private func pairedView(_ host: HostRegistry.Host) -> some View {
|
||
VStack(spacing: Metrics.stackSpacing) {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.largeTitle)
|
||
.foregroundStyle(.green)
|
||
Text(ScreenCopy.paired(host.name))
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
// MARK: - Confirm page (parsed address + §5.4 warning tier + name)
|
||
|
||
private struct ConfirmHostView: View {
|
||
let pending: PairingViewModel.PendingHost
|
||
@Bindable var viewModel: PairingViewModel
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section(ScreenCopy.confirmSectionTitle) {
|
||
// Single-point-derived scheme://host[:port] — never hand-built.
|
||
Text(pending.displayAddress)
|
||
.font(.system(.body, design: .monospaced))
|
||
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
|
||
}
|
||
warningSection
|
||
Section {
|
||
Button(ScreenCopy.connect) {
|
||
Task { await viewModel.confirmConnect() }
|
||
}
|
||
Button(ScreenCopy.cancel, role: .cancel) {
|
||
viewModel.cancel()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var warningSection: some View {
|
||
switch pending.warning {
|
||
case .none:
|
||
EmptyView()
|
||
case .tailscaleEncrypted:
|
||
Section {
|
||
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
|
||
.foregroundStyle(.green)
|
||
}
|
||
case .plaintextLAN:
|
||
Section {
|
||
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
|
||
.foregroundStyle(.orange)
|
||
}
|
||
case .publicHostBlocking:
|
||
Section {
|
||
Label(ScreenCopy.publicWarning, systemImage: "exclamationmark.octagon.fill")
|
||
.foregroundStyle(.red)
|
||
.font(.headline)
|
||
Toggle(ScreenCopy.publicAcknowledge,
|
||
isOn: $viewModel.hasAcknowledgedPublicRisk)
|
||
if viewModel.needsPublicRiskAcknowledgement {
|
||
Text(ScreenCopy.publicAckRequired)
|
||
.foregroundStyle(.red)
|
||
.font(.footnote)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Failure page (inline copy + recovery action)
|
||
|
||
private struct FailureView: View {
|
||
let pending: PairingViewModel.PendingHost
|
||
let failure: PairingViewModel.FailureDisplay
|
||
let viewModel: PairingViewModel
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section(pending.displayAddress) {
|
||
Label(failure.message, systemImage: "xmark.octagon")
|
||
.foregroundStyle(.red)
|
||
}
|
||
Section {
|
||
if failure.action == .openLocalNetworkSettings {
|
||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||
}
|
||
Button(ScreenCopy.retry) {
|
||
Task { await viewModel.retry() }
|
||
}
|
||
Button(ScreenCopy.back, role: .cancel) {
|
||
viewModel.cancel()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The app's Settings pane hosts its 本地网络 toggle — there is no public
|
||
/// deep link straight to 隐私 → 本地网络 (plan §5.2 guidance).
|
||
private func openAppSettings() {
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
|
||
// MARK: - Scan availability
|
||
|
||
enum PairingScanAvailability {
|
||
/// Simulator: no camera → entry hidden, manual entry is the pairing path
|
||
/// (task ruling). Device: also requires VisionKit support.
|
||
/// (`DataScannerViewController.isSupported` is MainActor-isolated.)
|
||
@MainActor static var isAvailable: Bool {
|
||
#if targetEnvironment(simulator)
|
||
return false
|
||
#else
|
||
return DataScannerViewController.isSupported
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// MARK: - VisionKit scanner (device only)
|
||
|
||
#if !targetEnvironment(simulator)
|
||
/// Thin `DataScannerViewController` wrapper: QR symbology only; the FIRST
|
||
/// recognized code wins (the web QR fills the screen — no tap-to-pick needed).
|
||
/// The payload is 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, never a silent swallow (plan §4): camera
|
||
// denied/busy shows inline in the sheet; 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
|
||
|
||
// MARK: - Screen constants
|
||
|
||
private enum Metrics {
|
||
static let stackSpacing: CGFloat = 12
|
||
static let errorCornerRadius: CGFloat = 8
|
||
}
|
||
|
||
private enum ScreenCopy {
|
||
static let title = "配对主机"
|
||
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 = "请先勾选上面的风险确认,再点连接。"
|
||
|
||
static func probing(_ address: String) -> String {
|
||
"正在验证 \(address) …"
|
||
}
|
||
|
||
static func paired(_ name: String) -> String {
|
||
"已配对:\(name)"
|
||
}
|
||
|
||
static func scannerStartFailed(_ reason: String) -> String {
|
||
"无法启动相机扫描:\(reason)。可改用手输地址。"
|
||
}
|
||
}
|