Files
web-terminal/ios/App/WebTerm/ViewModels/PairingViewModel.swift
Yaojia Wang 5098643355 feat(ios): W3 UI layer + integration CI + ntfy docs
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
2026-07-05 00:13:14 +02:00

399 lines
16 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 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<HostEndpoint, PairingError>
// 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 wsloopback: nothing to warn about.
case none
/// ws100.64/10 or `*.ts.net`: WireGuard already encrypts no
/// plaintext warning, an optional positive badge instead.
case tailscaleEncrypted
/// wsRFC1918 / 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或反馈该网段。"
}
}