diff --git a/Flights/Services/RouteExplorerClient.swift b/Flights/Services/RouteExplorerClient.swift index d2048dc..9effb3a 100644 --- a/Flights/Services/RouteExplorerClient.swift +++ b/Flights/Services/RouteExplorerClient.swift @@ -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 : " + // 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 : ..." 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.