RouteExplorer: real Safari UA + surface real upstream status

Two follow-ups to the WebView-routing commit after user reported
"Could not get any token HTTP -1":

1. WKWebView's default UA is "Mozilla/5.0 (iPhone; ...) Mobile/15E148"
   — missing the "Version/17.5 Safari/604.1" suffix real Safari sends.
   Cloudflare and other bot filters use that suffix to ID true Safari.
   Now we explicitly set a complete Safari UA on the WebView before
   navigating to route-explorer.

2. WebViewFetcher returns its errors as "HTTP <code>: <body>" strings;
   we were always throwing tokenFetchFailed(status: -1) regardless.
   New extractStatus helper parses the real upstream HTTP code out
   of the error string so the thrown error reflects what the server
   actually said — "HTTP 403" instead of "HTTP -1" makes it
   diagnosable from the device.

If the deployed app still 403s after this, the issue is more than UA
(probably Cloudflare clearance cookie needed via interactive challenge)
and we'd have to consider ASWebAuthenticationSession or fall back to
a paid schedule API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-31 13:32:05 -05:00
parent 9612ef558f
commit d122c95342
+29 -7
View File
@@ -182,11 +182,20 @@ actor RouteExplorerClient {
}
}
/// Real iPhone Safari UA WKWebView's default ("Mobile/15E148"
/// only) is missing the `Version/x.x Safari/604.1` suffix that
/// Cloudflare uses to identify true Safari. Setting this on the
/// WebView gets us past the simplest UA-based filters.
private static let safariUA: String =
"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"
/// Runs an XHR from inside a WKWebView that's been navigated to
/// `https://route-explorer.com/`. The page context provides the
/// real Safari TLS fingerprint and any first-party cookies the
/// edge expects. Returns the response body as a string, or
/// throws on non-200.
/// throws with the real upstream status code on failure.
private func fetchViaWebView(
method: String,
apiPath: String,
@@ -200,12 +209,11 @@ actor RouteExplorerClient {
]
for (k, v) in extraHeaders { headers[k] = v }
// For POST, body must be a JS literal re-emit the JSON as
// a JSON.stringify(...) so it survives template substitution.
// For POST, body is interpolated verbatim into a JS literal.
// The body we send is already a JSON-encoded byte string, so
// wrapping in JSON.stringify(...) re-emits the same string.
var bodyJS: String?
if let requestBody {
// Inline the JSON body verbatim XMLHttpRequest.send()
// takes a string and we're already JSON-encoded.
let raw = String(data: requestBody, encoding: .utf8) ?? "null"
bodyJS = "JSON.stringify(\(raw))"
}
@@ -216,12 +224,16 @@ actor RouteExplorerClient {
method: method,
headers: headers,
body: bodyJS,
userAgent: nil,
userAgent: Self.safariUA,
includeCredentials: true
)
if let err = result.error {
// WebViewFetcher returns errors in the form "HTTP <code>: <body>"
// or a free-form description. Extract the code if we can so
// the thrown error carries the real upstream status.
let upstreamStatus = Self.extractStatus(from: err) ?? -1
print("[RouteExplorer] WebView \(method) \(apiPath) failed: \(err)")
throw ClientError.tokenFetchFailed(status: -1)
throw ClientError.tokenFetchFailed(status: upstreamStatus)
}
guard let data = result.data else {
throw ClientError.tokenFetchFailed(status: -1)
@@ -229,6 +241,16 @@ actor RouteExplorerClient {
return data
}
/// Pull the integer HTTP status code from WebViewFetcher's
/// "HTTP <code>: ..." formatted error string. Returns nil for
/// anything we can't parse.
private static func extractStatus(from err: String) -> Int? {
guard let range = err.range(of: #"HTTP (\d+)"#, options: .regularExpression),
let codeRange = err[range].range(of: #"\d+"#, options: .regularExpression)
else { return nil }
return Int(err[range][codeRange])
}
/// Browser-shaped headers `/api/token` and `/api/flight-search` are
/// gated by Origin/Referer in production. Without these the server
/// returns 403 even though the endpoints are otherwise anonymous.