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).
361 lines
14 KiB
Swift
361 lines
14 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import WebKit
|
|
|
|
/// Settings → Tools → Diagnostics. Surfaces every log file
|
|
/// ``DiagnosticLogger`` has written this install, lets the user
|
|
/// preview them inline, and exports any one of them through the iOS
|
|
/// share sheet (AirDrop / mail / Files / iMessage) — the path by
|
|
/// which a user on a real device can ship a forensic dump to us when
|
|
/// something fails in a way we can't reproduce in the simulator.
|
|
///
|
|
/// Buttons:
|
|
/// • Run gate scenario — opens an off-screen WKWebView at
|
|
/// route-explorer.com, polls /api/token every 1.5s for 30s,
|
|
/// captures cookies + status on every tick. This is the
|
|
/// "Turnstile won't pass" debug trace.
|
|
/// • Run search scenario — fires both the route-explorer search
|
|
/// path (with gate-clearance dependency) AND the FlightAware path
|
|
/// so the log shows both transports side-by-side for the same
|
|
/// route+date.
|
|
/// • Tap a row to share — uses ``UIActivityViewController`` so the
|
|
/// user gets the standard share sheet.
|
|
struct DiagnosticsView: View {
|
|
@State private var logFiles: [URL] = []
|
|
@State private var loggerEnabled: Bool = true
|
|
@State private var shareURL: URL?
|
|
@State private var scenarioRunning: String?
|
|
|
|
var body: some View {
|
|
List {
|
|
controlsSection
|
|
scenariosSection
|
|
logsSection
|
|
}
|
|
.navigationTitle("Diagnostics")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear { refresh() }
|
|
.sheet(item: $shareURL) { url in
|
|
ShareSheet(items: [url])
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var controlsSection: some View {
|
|
Section {
|
|
Toggle("Logging enabled", isOn: $loggerEnabled)
|
|
.onChange(of: loggerEnabled) { _, on in
|
|
DiagnosticLogger.shared.setEnabled(on)
|
|
}
|
|
HStack {
|
|
Text("Session ID").foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(DiagnosticLogger.shared.sessionID)
|
|
.font(.footnote.monospaced())
|
|
}
|
|
HStack {
|
|
Text("Current log file").foregroundStyle(.secondary)
|
|
Spacer()
|
|
if let url = DiagnosticLogger.shared.logFileURL {
|
|
Text(url.lastPathComponent)
|
|
.font(.caption2.monospaced())
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
} else {
|
|
Text("(none)").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Button("Clear all log files") {
|
|
DiagnosticLogger.shared.clearAll()
|
|
refresh()
|
|
}
|
|
} header: {
|
|
Text("Controls")
|
|
} footer: {
|
|
Text("Logs are tab-separated text. Each line is one event with timestamp, category, and key=value fields. Files live under the app's Documents/Diagnostics/.")
|
|
}
|
|
}
|
|
|
|
private var scenariosSection: some View {
|
|
Section {
|
|
Button {
|
|
Task { await runGateScenario() }
|
|
} label: {
|
|
scenarioRow(title: "Run gate scenario (30s)",
|
|
subtitle: "Polls route-explorer /api/token; captures cookies + JS console + final status",
|
|
symbol: "shield.lefthalf.filled",
|
|
running: scenarioRunning == "gate")
|
|
}
|
|
.disabled(scenarioRunning != nil)
|
|
|
|
Button {
|
|
Task { await runFlightAwareScenario() }
|
|
} label: {
|
|
scenarioRow(title: "Run FlightAware scenario",
|
|
subtitle: "DFW→AMS direct; captures route.rvt + trackpoll request shapes",
|
|
symbol: "airplane",
|
|
running: scenarioRunning == "fa")
|
|
}
|
|
.disabled(scenarioRunning != nil)
|
|
} header: {
|
|
Text("Scenarios")
|
|
} footer: {
|
|
Text("Tap a scenario to run a fixed trace. Result lands in the current log file; share it from the list below.")
|
|
}
|
|
}
|
|
|
|
private var logsSection: some View {
|
|
Section {
|
|
if logFiles.isEmpty {
|
|
Text("No log files").foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(logFiles, id: \.self) { url in
|
|
Button {
|
|
shareURL = url
|
|
} label: {
|
|
logRow(url: url)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Log files")
|
|
Spacer()
|
|
Button("Refresh") { refresh() }
|
|
.font(.caption)
|
|
}
|
|
} footer: {
|
|
Text("Tap a file to share via AirDrop, email, or iMessage. Open files with a text app to view.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Row builders
|
|
|
|
private func scenarioRow(title: String, subtitle: String, symbol: String, running: Bool) -> some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: symbol)
|
|
.foregroundStyle(.tint)
|
|
.frame(width: 28)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title).font(.footnote.weight(.semibold))
|
|
Text(subtitle).font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if running {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private func logRow(url: URL) -> some View {
|
|
let attrs = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
|
|
let size = (attrs[.size] as? Int) ?? 0
|
|
let date = (attrs[.modificationDate] as? Date) ?? Date()
|
|
let sizeStr = ByteCountFormatter().string(fromByteCount: Int64(size))
|
|
let dateStr = Self.dateFormatter.string(from: date)
|
|
let isCurrent = url == DiagnosticLogger.shared.logFileURL
|
|
return HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(url.lastPathComponent)
|
|
.font(.footnote.monospaced())
|
|
if isCurrent {
|
|
Text("CURRENT").font(.caption2.weight(.heavy)).foregroundStyle(.green)
|
|
}
|
|
}
|
|
Text("\(dateStr) · \(sizeStr)").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Image(systemName: "square.and.arrow.up").foregroundStyle(.tint)
|
|
}
|
|
}
|
|
|
|
private static let dateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateStyle = .short
|
|
f.timeStyle = .medium
|
|
return f
|
|
}()
|
|
|
|
// MARK: - Actions
|
|
|
|
private func refresh() {
|
|
logFiles = DiagnosticLogger.shared.allLogFiles()
|
|
}
|
|
|
|
private func runGateScenario() async {
|
|
scenarioRunning = "gate"
|
|
defer { scenarioRunning = nil; refresh() }
|
|
DiagnosticLogger.shared.log("SCEN", "gateBegin", [:])
|
|
await GateScenarioRunner.run(durationSeconds: 30)
|
|
DiagnosticLogger.shared.log("SCEN", "gateEnd", [:])
|
|
}
|
|
|
|
private func runFlightAwareScenario() async {
|
|
scenarioRunning = "fa"
|
|
defer { scenarioRunning = nil; refresh() }
|
|
DiagnosticLogger.shared.log("SCEN", "faBegin", ["route": "DFW->AMS"])
|
|
let client = FlightAwareScheduleClient(database: AirportDatabase())
|
|
let today = Date()
|
|
do {
|
|
let result = try await client.searchDirectFlights(from: "DFW", to: "AMS", date: today)
|
|
DiagnosticLogger.shared.log("SCEN", "faResult", [
|
|
"connections": result.connections.count,
|
|
])
|
|
} catch {
|
|
DiagnosticLogger.shared.log("SCEN", "faError", [
|
|
"error": error.localizedDescription,
|
|
])
|
|
}
|
|
DiagnosticLogger.shared.log("SCEN", "faEnd", [:])
|
|
}
|
|
}
|
|
|
|
// MARK: - Gate scenario runner
|
|
|
|
/// Encapsulates the off-screen WKWebView poll loop used by the
|
|
/// "Run gate scenario" button. Lives outside the View so it survives
|
|
/// even if the view is dismissed mid-run (no SwiftUI state binding).
|
|
@MainActor
|
|
private enum GateScenarioRunner {
|
|
static func run(durationSeconds: Int) async {
|
|
let config = WKWebViewConfiguration()
|
|
config.websiteDataStore = .default()
|
|
let contentController = WKUserContentController()
|
|
// Bridge console messages into the logger.
|
|
let bridge = """
|
|
(function() {
|
|
const orig = window.console;
|
|
const send = (lvl, args) => {
|
|
try {
|
|
window.webkit.messageHandlers.diag.postMessage(
|
|
lvl + ": " + Array.from(args).map(a =>
|
|
(typeof a === 'object' ? JSON.stringify(a) : String(a))
|
|
).join(' ').substring(0, 240)
|
|
);
|
|
} catch (e) {}
|
|
};
|
|
['log','info','warn','error','debug'].forEach(lvl => {
|
|
const f = orig[lvl];
|
|
orig[lvl] = function(...args) { send(lvl, args); return f.apply(orig, args); };
|
|
});
|
|
})();
|
|
"""
|
|
contentController.addUserScript(WKUserScript(
|
|
source: bridge, injectionTime: .atDocumentStart, forMainFrameOnly: false
|
|
))
|
|
let handler = ScenarioConsoleHandler()
|
|
contentController.add(handler, name: "diag")
|
|
config.userContentController = contentController
|
|
|
|
let webView = WKWebView(frame: .zero, configuration: config)
|
|
webView.customUserAgent =
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
|
|
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
|
|
+ "Mobile/15E148 Safari/604.1"
|
|
let delegate = ScenarioNavigationDelegate()
|
|
webView.navigationDelegate = delegate
|
|
|
|
// Load homepage.
|
|
webView.load(URLRequest(url: URL(string: "https://route-explorer.com/")!))
|
|
DiagnosticLogger.shared.log("SCEN", "gateLoaded", [
|
|
"url": "https://route-explorer.com/",
|
|
])
|
|
|
|
// Wait for first navigation to finish (best-effort).
|
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
|
|
// Poll loop.
|
|
let deadline = Date().addingTimeInterval(TimeInterval(durationSeconds))
|
|
var tick = 0
|
|
while Date() < deadline {
|
|
tick += 1
|
|
await probe(webView: webView, tick: tick)
|
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
}
|
|
// Snapshot final cookies.
|
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
let reCookies = cookies.filter { $0.domain.contains("route-explorer.com") }
|
|
DiagnosticLogger.shared.log("SCEN", "gateFinal", [
|
|
"ticks": tick,
|
|
"cookieCount": reCookies.count,
|
|
"names": reCookies.map { $0.name }.sorted().joined(separator: ","),
|
|
"hasRexClearance": reCookies.contains { $0.name == "rex_clearance" },
|
|
])
|
|
}
|
|
|
|
private static func probe(webView: WKWebView, tick: Int) async {
|
|
let js = """
|
|
return await new Promise((res) => {
|
|
fetch('/api/token', { credentials: 'include' })
|
|
.then(r => r.text().then(t => res({status: r.status, body: t})))
|
|
.catch(e => res({status: -1, body: String(e)}));
|
|
});
|
|
"""
|
|
let raw = try? await webView.callAsyncJavaScript(js, contentWorld: .page)
|
|
let dict = raw as? [String: Any]
|
|
let status = dict?["status"] as? Int ?? -1
|
|
let body = dict?["body"] as? String ?? ""
|
|
DiagnosticLogger.shared.log("SCEN", "gateProbe", [
|
|
"tick": tick,
|
|
"status": status,
|
|
"body": String(body.prefix(200)),
|
|
])
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class ScenarioConsoleHandler: NSObject, WKScriptMessageHandler {
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
guard let body = message.body as? String else { return }
|
|
DiagnosticLogger.shared.log("SCEN", "gateConsole", ["msg": body])
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class ScenarioNavigationDelegate: NSObject, WKNavigationDelegate {
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
DiagnosticLogger.shared.log("SCEN", "gateNavDone", [
|
|
"url": webView.url?.absoluteString ?? "?",
|
|
])
|
|
}
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
DiagnosticLogger.shared.log("SCEN", "gateNavFailed", [
|
|
"error": error.localizedDescription,
|
|
])
|
|
}
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
DiagnosticLogger.shared.log("SCEN", "gateNavFailedProvisional", [
|
|
"error": error.localizedDescription,
|
|
])
|
|
}
|
|
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy {
|
|
if let http = navigationResponse.response as? HTTPURLResponse {
|
|
DiagnosticLogger.shared.log("SCEN", "gateNavResponse", [
|
|
"url": http.url?.absoluteString ?? "?",
|
|
"status": http.statusCode,
|
|
"setCookie": String((http.value(forHTTPHeaderField: "Set-Cookie") ?? "").prefix(200)),
|
|
"cfRay": http.value(forHTTPHeaderField: "CF-Ray") ?? "-",
|
|
"server": http.value(forHTTPHeaderField: "Server") ?? "-",
|
|
])
|
|
}
|
|
return .allow
|
|
}
|
|
}
|
|
|
|
// MARK: - Share sheet
|
|
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
let items: [Any]
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
}
|
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
|
}
|
|
|
|
extension URL: @retroactive Identifiable {
|
|
public var id: String { absoluteString }
|
|
}
|