App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
323 lines
14 KiB
Swift
323 lines
14 KiB
Swift
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=<uuid>&join=<uuid>` (scheme registered in
|
|
/// project.yml `CFBundleURLTypes`),
|
|
/// - **the web share link `http(s)://<host>[:<port>]/?join=<uuid>`**
|
|
/// (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 (`<origin>/?join=<uuid>`). 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<String> = ["", "/"]
|
|
}
|
|
|
|
/// Whitelisted web-share shape (`http(s)://<host>[:<port>]/?join=<uuid>`).
|
|
///
|
|
/// 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<String> = ["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=<uuid>&join=<uuid>` — 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)://<host>[:<port>]/?join=<uuid>` — 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()
|
|
}
|
|
}
|