feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal file
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import OSLog
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-22 · Deep-link routing.
|
||||
///
|
||||
/// Two external entry points share ONE validation surface:
|
||||
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
|
||||
/// project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是
|
||||
/// T-iOS-35 的增量), 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)
|
||||
/// 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 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),
|
||||
components.scheme?.lowercased() == LinkShape.scheme,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// `.gateSession` never reaches here in P1: it only exists for the
|
||||
// push path, whose handling (host resolution incl.) is T-iOS-21.
|
||||
guard case let .openSession(hostId, sessionId) = route else { return }
|
||||
do {
|
||||
let hosts = try await loadHosts()
|
||||
guard let host = hosts.first(where: { $0.id == hostId }) 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user