Files
Flights/Flights/Views/DiagnosticsView.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

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 }
}