Files
Flights/Flights/Services/RouteExplorerTokenStore.swift
T
Trey T ba0688a412 Search: FlightAware backbone, blob catalog, diagnostic infra
route-explorer's /api/token sits behind invisible Cloudflare Turnstile
that requires Apple's Private Access Token attestation. Third-party
iOS apps don't qualify for PAT issuance, and Linux Docker containers
can't pass it either (cross-OS fingerprint, even with patchright /
Camoufox). Migrates direct-flight search to FlightAware; multi-stop
and where-can-I-go remain via embedded SFSafariViewController.

- FlightAwareScheduleClient — scrapes route.rvt + trackpoll JSON for
  real schedules without auth. T+0..2 day window. Tests against
  captured HTML fixtures.
- BlobRouteClient — pulls the public Vercel blob route catalog
  route-explorer's frontend reads (no auth, no Turnstile).
- DiagnosticLogger + LoggingURLSessionDelegate + DiagnosticsView —
  device-shareable forensic trace. Boot header captures device, OS,
  locale, UA; share-sheet export of session logs.
- TurnstileDebugView — live WKWebView gate inspector. Used to prove
  the PAT-entitlement gap on a real device.
- RouteExplorerBrowserView — SFSafariViewController wrapper. Real
  Safari clears Turnstile naturally; the in-app browser opens at
  pre-filled search URLs. Surfaced from Search ("Open in
  route-explorer") and Settings → Tools.
- RouteExplorerTokenStore + RouteExplorerSetupView — bookmarklet
  capture flow (token round-tripped via flights://routeexplorer-token
  URL scheme). Kept dormant for future use.

backend/ — Docker proxy attempts (Playwright, patchright, Camoufox).
All fail on Linux because Cloudflare auto-denies before the Turnstile
widget renders. Documented; kept as scaffolding for a future paid-
solver integration.

scripts/probe_flightaware.py — reference algorithm for the FA path.
scripts/probe_nodriver.py — local-Mac sanity check confirming the
gate clears with real macOS Chrome (proves the blocker is
fingerprint-level, not network-level).
2026-06-06 01:09:59 -05:00

126 lines
4.9 KiB
Swift

import Foundation
import Combine
/// Persists a route-explorer `/api/token` value (with expiry) that the
/// user captured from Safari via the bookmarklet flow. Backed by
/// `UserDefaults` because the data is small (~250 bytes) and survives
/// process restarts.
///
/// Why this exists: route-explorer's edge gates `/api/token` behind a
/// Cloudflare Turnstile challenge that requires Apple's Private Access
/// Token. PAT issuance is restricted to apps with the
/// `com.apple.developer.web-browser` entitlement (Safari, Chrome, Brave,
/// DuckDuckGo, etc.) third-party apps don't qualify, so our WKWebView
/// can never mint a token. Safari on the same device *can*, so we let
/// the user trip Turnstile in Safari with a bookmarklet, send the freshly
/// minted token back to the app via the `flights://routeexplorer-token`
/// URL scheme, and use that token from URLSession until it expires.
@MainActor
final class RouteExplorerTokenStore: ObservableObject {
static let shared = RouteExplorerTokenStore()
private let defaults = UserDefaults.standard
@Published private(set) var token: String?
@Published private(set) var expiresAt: Date?
/// Optional cookie jar captured at the same time as the token. Some
/// route-explorer endpoints may also gate on `rex_clearance` /
/// `am_user_session`; if the bookmarklet manages to capture them
/// (they need to be non-HttpOnly for `document.cookie` to read them),
/// we attach them on outgoing requests.
@Published private(set) var capturedCookieHeader: String?
private init() {
if let stored = defaults.string(forKey: Keys.token),
let expEpoch = defaults.object(forKey: Keys.expiresAt) as? TimeInterval {
self.token = stored
self.expiresAt = Date(timeIntervalSince1970: expEpoch)
}
self.capturedCookieHeader = defaults.string(forKey: Keys.cookieHeader)
}
var isValid: Bool {
guard let token, !token.isEmpty,
let expiresAt, expiresAt > Date()
else { return false }
_ = token
return true
}
var timeRemaining: TimeInterval {
guard let expiresAt else { return 0 }
return max(0, expiresAt.timeIntervalSinceNow)
}
/// Store a token captured from the Safari bookmarklet flow.
/// `expiresInSeconds` defaults to 30 minutes (route-explorer's
/// typical token TTL); the caller can override if the bookmarklet
/// surfaces a precise expiry.
func store(token: String,
expiresInSeconds: TimeInterval = 30 * 60,
cookieHeader: String? = nil) {
let exp = Date(timeIntervalSinceNow: expiresInSeconds)
self.token = token
self.expiresAt = exp
self.capturedCookieHeader = cookieHeader
defaults.set(token, forKey: Keys.token)
defaults.set(exp.timeIntervalSince1970, forKey: Keys.expiresAt)
if let cookieHeader, !cookieHeader.isEmpty {
defaults.set(cookieHeader, forKey: Keys.cookieHeader)
} else {
defaults.removeObject(forKey: Keys.cookieHeader)
}
DiagnosticLogger.shared.log("RETOK", "stored", [
"expiresAt": exp.timeIntervalSince1970,
"cookieLen": cookieHeader?.count ?? 0,
])
}
func clear() {
token = nil
expiresAt = nil
capturedCookieHeader = nil
defaults.removeObject(forKey: Keys.token)
defaults.removeObject(forKey: Keys.expiresAt)
defaults.removeObject(forKey: Keys.cookieHeader)
DiagnosticLogger.shared.log("RETOK", "cleared", [:])
}
// MARK: - URL scheme ingest
/// Returns true if `url` is the route-explorer token deep link and
/// the credentials were successfully extracted + stored.
@discardableResult
func ingest(url: URL) -> Bool {
guard url.scheme == "flights",
url.host == "routeexplorer-token"
else { return false }
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
let items = comps?.queryItems ?? []
func val(_ k: String) -> String? { items.first { $0.name == k }?.value }
guard let token = val("token"), !token.isEmpty else {
DiagnosticLogger.shared.log("RETOK", "ingestNoToken", [
"url": url.absoluteString,
])
return false
}
let exp: TimeInterval = {
if let expStr = val("exp"), let expVal = TimeInterval(expStr) {
return max(0, expVal - Date().timeIntervalSince1970)
}
return 30 * 60
}()
let cookie = val("cookie")?.removingPercentEncoding
store(token: token, expiresInSeconds: exp, cookieHeader: cookie)
return true
}
// MARK: - Storage keys
private enum Keys {
static let token = "re.token.value"
static let expiresAt = "re.token.expiresAt"
static let cookieHeader = "re.token.cookieHeader"
}
}