import APIClient import Foundation import HostRegistry import Observation import WireProtocol /// T-iOS-12 · Pairing state machine (plan §7 / §5.4). /// /// Flow: scan / manual URL → **confirm gate** → two-step probe → /// `Host{id,name}` into the `HostStore` + navigate signal. /// /// Security invariants (plan §5 / task RED list): /// - Scan payloads are UNTRUSTED external input: parsed exclusively through /// `HostEndpoint` (single-point derivation — no hand-assembly), non-http(s) /// rejected with copy, and **zero network happens before the user confirms** /// (probe ① already GETs the target; probe ② spawns a PTY on it). /// - The §5.4 warning tiers render ON the confirm page; the public-host tier /// is BLOCKING — `confirmConnect` refuses to probe until the user has set /// `hasAcknowledgedPublicRisk` explicitly. /// - Every `PairingError` maps to inline copy + a recovery action /// (`localNetworkDenied` → Settings deep-link; `originRejected` surfaces the /// probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording). /// /// Documented decisions: /// - **Manual entry reuses the same confirm state as scanning** (task left it /// free): one code path, and the §5.4 warning tiers apply uniformly to /// typed URLs too. Convenience: input without `://` gets an `http://` /// prefix before the `HostEndpoint` parse (scan payloads get NO such help). /// - **Host classification is (re)implemented here**: APIClient's /// `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the /// tiers (it collapses loopback/Tailscale/RFC1918 into one bucket). /// Duplication noted for the T-iOS-38 dedup pass. /// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they /// resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row /// applies to them the same way. /// /// §3.4 contract ruling (2026-07-04): the injected probe returns the validated /// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are /// not the probe's to know). Production wiring (T-iOS-15) passes /// `runPairingProbe` with the real transports. @MainActor @Observable final class PairingViewModel { /// The probe, injected as a closure so tests can both fake results AND /// assert non-invocation before the user confirms (task RED list). typealias Probe = @Sendable (HostEndpoint) async -> Result // MARK: - UI state model /// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`). enum SecurityWarning: Equatable, Sendable { /// https anywhere private-class, or ws→loopback: nothing to warn about. case none /// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no /// plaintext warning, an optional positive badge instead. case tailscaleEncrypted /// ws→RFC1918 / link-local / `.local`: NON-blocking notice — keystrokes /// and output are sniffable on the same LAN; prefer `tailscale serve`. case plaintextLAN /// Public host (http AND https alike, §5.4 table): strongest BLOCKING /// warning — anyone who can reach the port gets a shell. case publicHostBlocking var isBlocking: Bool { self == .publicHostBlocking } } /// The parsed-but-not-yet-probed target shown on the confirm page. struct PendingHost: Equatable, Sendable { let endpoint: HostEndpoint let warning: SecurityWarning /// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation /// (browser-Origin serialization) — NEVER hand-assembled. var displayAddress: String { endpoint.originHeader } } /// What the failure UI offers besides the message. enum RecoveryAction: Equatable, Sendable { case retry /// iOS Local Network permission was denied → deep-link to the app's /// Settings pane (its 本地网络 toggle lives there). case openLocalNetworkSettings } struct FailureDisplay: Equatable, Sendable { let message: String let action: RecoveryAction } enum Phase: Equatable { case idle case confirming(PendingHost) case probing(PendingHost) case failed(PendingHost, FailureDisplay) case paired(HostRegistry.Host) } // MARK: - Observable state private(set) var phase: Phase = .idle /// Inline rejection copy for invalid scan/manual input (idle-state error). private(set) var inputRejection: String? /// Editable name shown on the confirm page; defaults to the endpoint host /// and falls back to it when the user clears the field. var hostName = "" /// Explicit user acknowledgement for the blocking public-host warning. var hasAcknowledgedPublicRisk = false /// Set when confirm was attempted on a blocking warning WITHOUT the /// acknowledgement — the UI highlights the ack control. private(set) var needsPublicRiskAcknowledgement = false /// Navigate signal: set exactly once when pairing completes (T-iOS-15 /// observes it to move on to the session list). private(set) var pairedHost: HostRegistry.Host? // MARK: - Dependencies (not observed) @ObservationIgnored private let store: any HostStore @ObservationIgnored private let probe: Probe init(store: any HostStore, probe: @escaping Probe) { self.store = store self.probe = probe } // MARK: - Input boundaries (untrusted, validated via HostEndpoint) /// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the /// payload must already be a full http(s) URL — no scheme inference for /// untrusted external input. func handleScannedCode(_ payload: String) { guard canAcceptNewTarget else { return } let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else { inputRejection = PairingCopy.scanRejected return } enterConfirming(endpoint) } /// Manually typed URL. The user knows what they typed, but it still goes /// through the SAME confirm state (uniform warning tiers — documented /// decision). Convenience: no `://` → `http://` prefix before parsing. func submitManualURL(_ text: String) { guard canAcceptNewTarget else { return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { inputRejection = PairingCopy.manualRejected return } let candidate = trimmed.contains(Self.schemeSeparator) ? trimmed : Self.defaultManualScheme + trimmed guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else { inputRejection = PairingCopy.manualRejected return } enterConfirming(endpoint) } /// Back out of confirm/failed to a clean entry state. Never probes. func cancel() { phase = .idle inputRejection = nil hasAcknowledgedPublicRisk = false needsPublicRiskAcknowledgement = false } // MARK: - Confirm → probe → store /// The ONLY way network starts. No-op unless confirming; a blocking /// warning without explicit acknowledgement refuses and flags the UI. func confirmConnect() async { guard case .confirming(let pending) = phase else { return } if pending.warning.isBlocking && !hasAcknowledgedPublicRisk { needsPublicRiskAcknowledgement = true return } await runProbe(for: pending) } /// Re-run the full probe against the same endpoint after a failure. func retry() async { guard case .failed(let pending, _) = phase else { return } await runProbe(for: pending) } private var canAcceptNewTarget: Bool { switch phase { case .idle, .confirming, .failed: return true case .probing, .paired: return false } } private func enterConfirming(_ endpoint: HostEndpoint) { inputRejection = nil hasAcknowledgedPublicRisk = false needsPublicRiskAcknowledgement = false hostName = endpoint.baseURL.host ?? "" phase = .confirming(PendingHost( endpoint: endpoint, warning: Self.warning(for: endpoint) )) } private func runProbe(for pending: PendingHost) async { needsPublicRiskAcknowledgement = false phase = .probing(pending) switch await probe(pending.endpoint) { case .failure(let error): phase = .failed(pending, Self.display(for: error)) case .success(let endpoint): await storePairedHost(endpoint: endpoint, pending: pending) } } /// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED /// endpoint. A store failure is surfaced explicitly (never swallowed); /// retry re-runs the whole confirm flow. private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async { let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines) let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader let host = HostRegistry.Host( id: UUID(), name: trimmedName.isEmpty ? fallbackName : trimmedName, endpoint: endpoint ) do { _ = try await store.upsert(host) } catch { phase = .failed(pending, FailureDisplay( message: PairingCopy.storeFailed, action: .retry )) return } pairedHost = host phase = .paired(host) } // MARK: - PairingError → copy + action (task RED list, one case each) static func display(for error: PairingError) -> FailureDisplay { switch error { case .localNetworkDenied: return FailureDisplay( message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings ) case .hostUnreachable(let underlying): return FailureDisplay( message: PairingCopy.hostUnreachable(underlying), action: .retry ) case .httpOkButNotWebTerminal: return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry) case .originRejected(let hint): // The probe already derived the complete actionable copy from // endpoint.originHeader — surface it VERBATIM, never re-derive. return FailureDisplay(message: hint, action: .retry) case .atsBlocked(let host): return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry) case .tlsFailure: return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry) case .timeout: return FailureDisplay(message: PairingCopy.timeout, action: .retry) } } // MARK: - §5.4 warning tiers /// Decide the confirm-page warning from scheme + host class. Public hosts /// block regardless of scheme (§5.4 table: https is included in the /// public-host confirm warning); otherwise https clears every notice. static func warning(for endpoint: HostEndpoint) -> SecurityWarning { let hostClass = classifyHost(endpoint.baseURL.host ?? "") if hostClass == .publicHost { return .publicHostBlocking } if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme { return .none } switch hostClass { case .loopback: return .none case .tailscale: return .tailscaleEncrypted case .privateLAN: return .plaintextLAN case .publicHost: return .publicHostBlocking // unreachable; keeps the switch total } } /// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's /// internal `isPrivateOrLocalHost` (finer-grained here) — T-iOS-38 dedup. enum HostClass: Equatable, Sendable { case loopback case tailscale case privateLAN case publicHost } static func classifyHost(_ rawHost: String) -> HostClass { let host = rawHost.lowercased() .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets if host == Self.localhostName { return .loopback } if host.hasSuffix(Self.tailscaleMagicDNSSuffix) { return .tailscale } if host.hasSuffix(Self.mdnsSuffix) { return .privateLAN } if let octets = ipv4Octets(host) { return classifyIPv4(octets) } if host.contains(":") { return classifyIPv6(host) } return .publicHost } private static func classifyIPv4(_ octets: [Int]) -> HostClass { switch (octets[0], octets[1]) { case (127, _): return .loopback case (10, _), (192, 168), (169, 254): return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16 case (172, 16...31): return .privateLAN // RFC1918 172.16/12 case (100, 64...127): return .tailscale // CGNAT 100.64/10 default: return .publicHost } } private static func classifyIPv6(_ host: String) -> HostClass { if host == Self.ipv6Loopback { return .loopback } let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix) let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7 return (isLinkLocal || isULA) ? .privateLAN : .publicHost } private static func ipv4Octets(_ host: String) -> [Int]? { let parts = host.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == Self.ipv4OctetCount else { return nil } let octets = parts.compactMap { Int($0) } guard octets.count == Self.ipv4OctetCount, octets.allSatisfy({ Self.ipv4OctetRange.contains($0) }) else { return nil } return octets } // MARK: - Named constants (no magic values, plan §4) private static let schemeSeparator = "://" private static let defaultManualScheme = "http://" private static let httpsScheme = "https" private static let localhostName = "localhost" private static let tailscaleMagicDNSSuffix = ".ts.net" private static let mdnsSuffix = ".local" private static let ipv6Loopback = "::1" private static let ipv6LinkLocalPrefix = "fe80" private static let ipv4OctetCount = 4 private static let ipv4OctetRange = 0...255 } /// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2 /// Local-Network guidance including the iOS 18 restart caveat). enum PairingCopy { static let scanRejected = "二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。" static let manualRejected = "无法解析这个地址。请输入完整 URL,例如 http://192.168.1.5:3000" static let storeFailed = "主机已通过验证,但保存到本机失败,请重试。" static let localNetworkDenied = "无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关" + "(iOS 18 存在需要重启手机才生效的已知问题)。" static let notWebTerminal = "对方在响应 HTTP,但不是 web-terminal——端口对吗?" static let tlsFailure = "TLS 连接失败:证书无效或不受信任。" static let timeout = "连接超时。请确认主机在线、与手机在同一网络后重试。" static func hostUnreachable(_ underlying: String) -> String { "无法连接主机:\(underlying)" } /// §3.4 wording for the ATS cleartext block. static func atsBlocked(host: String) -> String { "明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内," + "请改用 https / tailscale serve,或反馈该网段。" } }