ba0688a412
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).
126 lines
4.9 KiB
Swift
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"
|
|
}
|
|
}
|