import Foundation import HostRegistry import Observation import OSLog import WireProtocol /// T-iOS-22 / T-iOS-35 · Deep-link routing. /// /// Three external entry points share ONE validation surface: /// - `webterminal://open?host=&join=` (scheme registered in /// project.yml `CFBundleURLTypes`), /// - **the web share link `http(s)://[:]/?join=`** /// (T-iOS-35 — the exact shape `public/share.ts:55` puts in the 🔗 QR, i.e. /// `${location.origin}/?join=${sessionId}`), and /// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses /// `route(from:)` — same UUID rules, no second parser). /// /// 安全注 (plan T-iOS-22): a deep link is EXTERNAL input. Every field is /// whitelist-validated (scheme / action / query keys / UUID v4 via the frozen /// `Validation.isValidSessionId` — never re-regexed); ANY invalid part → /// `.ignore` + counter (never crash, never partially apply). The host id /// resolves ONLY through a `HostStore` lookup (`DeepLinkHandler`) — no URL or /// request is ever built from link contents. enum DeepLinkRouter { /// Parse outcome. `.openSession` carries RAW validated ids — host /// EXISTENCE is resolved later against the store (`DeepLinkHandler`). enum Route: Equatable, Sendable { /// `webterminal://open` with both ids syntactically valid. case openSession(hostId: UUID, sessionId: UUID) /// Web share link (`/?join=`). Carries the NORMALISED /// origin (`HostEndpoint.originHeader`) instead of a host id — the web /// UI has no idea what UUID this device filed that host under, so /// resolution is "which paired host serves this origin" (`DeepLinkHandler`). case joinShared(origin: String, sessionId: UUID) /// Push payload with a valid `sessionId` (no host id in the payload — /// T-iOS-21's notification handler owns the resolution strategy). case gateSession(sessionId: UUID) /// Anything else. Callers count + log; they never partially apply. case ignore } /// Whitelisted URL shape (`webterminal://open`). Scheme and URL host are /// case-insensitive per RFC 3986 → compared lowercased. private enum LinkShape { static let scheme = "webterminal" static let action = "open" static let emptyPaths: Set = ["", "/"] } /// Whitelisted web-share shape (`http(s)://[:]/?join=`). /// /// Deliberately STRICTER than the custom-scheme branch: `webterminal://` can /// only come from something that knows our private scheme, while an http(s) /// URL is whatever a QR code or another app decided to hand us. So the query /// must be EXACTLY one `join` key (no "unknown keys are ignored" leniency /// here), the path must be empty/`/`, and userinfo/fragment are rejected /// outright (`http://user:pass@real-host@evil/…` is a classic QR phish). private enum WebShape { static let schemes: Set = ["http", "https"] static let queryItemCount = 1 } /// Whitelisted query keys (exact match; unknown keys are ignored). private enum QueryKey { static let host = "host" static let join = "join" } /// Root key of the APNs payload (server contract, T-iOS-20 final shape). private enum PushKey { static let sessionId = "sessionId" } // MARK: - URL entry (onOpenURL) static func route(url: URL) -> Route { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let scheme = components.scheme?.lowercased(), // Credentials/fragment are never part of either whitelisted shape. components.user == nil, components.password == nil, components.fragment == nil else { return .ignore } if scheme == LinkShape.scheme { return customSchemeRoute(components) } if WebShape.schemes.contains(scheme) { return webShareRoute(url: url, components: components) } return .ignore } /// `webterminal://open?host=&join=` — unchanged semantics /// (unknown extra query keys stay tolerated; both ids required). private static func customSchemeRoute(_ components: URLComponents) -> Route { guard components.host?.lowercased() == LinkShape.action, LinkShape.emptyPaths.contains(components.path), let hostId = uniqueValidatedId(in: components, key: QueryKey.host), let sessionId = uniqueValidatedId(in: components, key: QueryKey.join) else { return .ignore } return .openSession(hostId: hostId, sessionId: sessionId) } /// `http(s)://[:]/?join=` — the web 🔗 share QR. /// /// The origin is derived by `HostEndpoint` (the FROZEN single derivation /// point, plan §3.1) rather than assembled here: it also does the http(s) + /// non-empty-host validation and the default-port/lowercasing normalisation /// the store's own origins were built with, so the comparison in /// `DeepLinkHandler` is apples to apples. private static func webShareRoute(url: URL, components: URLComponents) -> Route { guard LinkShape.emptyPaths.contains(components.path), let items = components.queryItems, items.count == WebShape.queryItemCount, let item = items.first, item.name == QueryKey.join, let raw = item.value, let sessionId = validatedId(raw), let endpoint = HostEndpoint(baseURL: url) else { return .ignore } return .joinShared(origin: endpoint.originHeader, sessionId: sessionId) } // MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21) static func route(from payload: [AnyHashable: Any]) -> Route { guard let raw = payload[PushKey.sessionId] as? String, let sessionId = validatedId(raw) else { return .ignore } return .gateSession(sessionId: sessionId) } // MARK: - Field validation /// Exactly ONE occurrence of `key`, and its value is a v4 UUID. /// Duplicates are ambiguous input → nil (never partially apply). private static func uniqueValidatedId(in components: URLComponents, key: String) -> UUID? { let matches = (components.queryItems ?? []).filter { $0.name == key } guard matches.count == 1, let raw = matches.first?.value else { return nil } return validatedId(raw) } /// Frozen-contract v4 check (`Validation.isValidSessionId`, plan §3.1) /// FIRST — `UUID(uuidString:)` alone would admit non-v4 ids the server's /// SESSION_ID_RE rejects. private static func validatedId(_ raw: String) -> UUID? { guard Validation.isValidSessionId(raw) else { return nil } return UUID(uuidString: raw) } } /// User-facing deep-link copy (plan §4: 显式、可操作的话术). enum DeepLinkCopy { static let hintTitle = "无法打开链接" static let unknownHostHint = "链接指向的主机尚未配对,请先扫码完成配对。" static let hostLoadFailed = "读取已配对主机失败,无法打开链接,请重进 App 后再试。" static let hintConfirm = "好" } /// Applies parsed routes to the app: host-store lookup (the ONLY host-id /// resolution point), the cold-launch stash, the unknown-host → pairing hint, /// and the invalid-link counter. Pure routing stays in `DeepLinkRouter`; this /// class owns the stateful edges so `AppCoordinator`'s diff stays minimal. @MainActor @Observable final class DeepLinkHandler { /// Coordinator-provided effects (closures — the handler never reaches /// into navigation state itself). struct Actions { /// Open `sessionId` on a RESOLVED, store-known host. let openSession: @MainActor (HostRegistry.Host, UUID) -> Void /// Unknown host id → surface the pairing flow. let showPairing: @MainActor () -> Void } /// Alert copy for RootView (unknown host / store failure). nil = no alert. private(set) var hintMessage: String? /// Invalid deep links dropped so far (plan: `.ignore` + log counter). private(set) var ignoredCount = 0 @ObservationIgnored private let loadHosts: @Sendable () async throws -> [HostRegistry.Host] @ObservationIgnored private let actions: Actions /// Cold-launch gate: `handle(url:)` before `markReady()` stashes instead /// of applying (the host list / root route may not exist yet). @ObservationIgnored private var isReady = false /// Single-slot stash — a newer link before readiness replaces the older /// one (two half-applied navigations would be worse than dropping one). @ObservationIgnored private var pendingRoute: DeepLinkRouter.Route? @ObservationIgnored private let logger = Logger( subsystem: DeepLinkLog.subsystem, category: DeepLinkLog.category ) init( loadHosts: @escaping @Sendable () async throws -> [HostRegistry.Host], actions: Actions ) { self.loadHosts = loadHosts self.actions = actions } // MARK: - Entry points /// `.onOpenURL` lands here (via `AppCoordinator.handleDeepLink`). Invalid /// links are counted and dropped — they are NEVER stashed. func handle(url: URL) async { let route = DeepLinkRouter.route(url: url) guard route != .ignore else { recordIgnored() return } guard isReady else { pendingRoute = route return } await apply(route) } /// Cold-start bootstrap finished (root route decided) → flush the stash. /// Idempotent: the slot is cleared before applying, so a second call /// never replays. func markReady() async { isReady = true guard let route = pendingRoute else { return } pendingRoute = nil await apply(route) } func clearHint() { hintMessage = nil } // MARK: - Apply (host id resolves ONLY through the store) private func apply(_ route: DeepLinkRouter.Route) async { switch route { case let .openSession(hostId, sessionId): // Custom scheme: the link carries OUR host id. await openSession(sessionId, onHostMatching: { $0.id == hostId }) case let .joinShared(origin, sessionId): // T-iOS-35 · web share QR: match on the normalised origin. An // unpaired origin must NEVER be auto-added — a QR code is untrusted // input, so it falls through to the pairing flow (where the user // sees and confirms the target) exactly like an unknown host id. await openSession(sessionId, onHostMatching: { $0.endpoint.originHeader == origin }) case .gateSession, .ignore: // `.gateSession` never reaches here in P1: it only exists for the // push path, whose handling (host resolution incl.) is T-iOS-21. return } } /// The ONE host-resolution path (both link shapes funnel through it): store /// lookup → open, or unknown → pairing hint. The hint copy is deliberately /// generic — it never echoes the link's origin or session id (a link is /// untrusted text; quoting it back is a phishing/log-injection surface). private func openSession( _ sessionId: UUID, onHostMatching matches: (HostRegistry.Host) -> Bool ) async { do { let hosts = try await loadHosts() guard let host = hosts.first(where: matches) else { hintMessage = DeepLinkCopy.unknownHostHint actions.showPairing() return } actions.openSession(host, sessionId) } catch { // Explicit failure copy — a broken store must never look like a // silently dead link (plan §4 error-handling rule). hintMessage = DeepLinkCopy.hostLoadFailed logger.error("deep link host-store read failed: \(error)") } } private func recordIgnored() { ignoredCount += 1 // URL contents are untrusted external input — log the counter only, // never echo the link (log-injection / privacy hygiene). logger.notice("ignored invalid deep link (total: \(self.ignoredCount))") } } private enum DeepLinkLog { static let subsystem = "com.yaojia.webterm" static let category = "deep-link" } // MARK: - AppCoordinator wiring (kept here so the coordinator diff stays tiny) extension AppCoordinator { /// Factory for the coordinator's lazy `deepLink` property. func makeDeepLinkHandler() -> DeepLinkHandler { DeepLinkHandler( loadHosts: { [environment] in try await environment.hostStore.loadAll() }, actions: DeepLinkHandler.Actions( openSession: { [weak self] host, sessionId in self?.openDeepLinkedSession(host: host, sessionId: sessionId) }, showPairing: { [weak self] in self?.showPairingForDeepLink() } ) ) } /// RootView's `.onOpenURL` entry (sync SwiftUI context → async apply). /// Fires for BOTH warm links and cold launches — on cold start the /// handler stashes until `bootstrap()` calls `markReady()`. func handleDeepLink(url: URL) { Task { await deepLink.handle(url: url) } } /// Deep links may target a session while another terminal is open: /// detach the current one first (`open` is a no-op otherwise — its /// one-foreground-session guard would swallow the link). private func openDeepLinkedSession(host: HostRegistry.Host, sessionId: UUID) { if terminalController != nil { closeTerminal() } open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId)) } /// Unknown host id → pairing. On the first-run pairing route the pairing /// screen is already frontmost — only the list route needs the sheet. private func showPairingForDeepLink() { guard route == .sessions else { return } presentAddHost() } }