From 5f19e48172cb8958d6556c943fa82505d4413514 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 11 Apr 2026 13:50:54 -0500 Subject: [PATCH] JSX: clean up dead WKWebView fallback paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that we've confirmed the direct in-page fetch() POST to /api/nsk/v4/availability/search/simple works end-to-end on real iOS devices (and is the only thing that does — simulator is blocked at the transport layer by Akamai per-endpoint fingerprinting), delete the dead simulator-era attempts that were kept around as hopeful fallbacks: - Delete nativePOSTSearchSimple and all the URLSession+cookie-replay plumbing. URLSession can't reach /search/simple from iOS Simulator either (TLS fingerprint same as WKWebView), and on real device the in-page fetch already works so the URLSession path is never useful. - Delete the ~150 lines of SPA state-harvest JavaScript that walked __ngContext__ to find the parsed availability payload inside Angular services as an attempt-2 fallback. The state-harvest was a proxy for "maybe the POST went through but our interceptor swallowed the response" — that theory is dead now that we know the POST itself is what's blocked in the simulator. - Delete the capturedBody instance property that only nativePOST wrote to. Step 17 is now exactly what it claims to be: read the sessionStorage token, fire a single direct fetch() POST from the page context, return the body on success. ~400 lines removed from JSXWebViewFetcher.swift (2148 -> 1748). Step 18's low-fare fallback stays as graceful degradation when the POST fails (which happens on iOS Simulator). The fallback cabin is now labeled "Route day-total (fallback)" instead of "Route (day total)" so the UI clearly distinguishes a per-flight seat count from a route estimate. JSX_NOTES.md corrected: removed the inaccurate claim that WKWebView POSTs to /search/simple just work. The anti-bot-surface table now separates iOS Simulator (fails) from real iOS device (works) with the specific error modes for each. TL;DR adds a visible caveat at the top that the working path requires a real device; develop with the low-fare fallback in the simulator. Co-Authored-By: Claude Opus 4.6 (1M context) --- Flights/Services/AirlineLoadService.swift | 7 +- Flights/Services/JSXWebViewFetcher.swift | 490 ++-------------------- JSX_NOTES.md | 20 +- 3 files changed, 66 insertions(+), 451 deletions(-) diff --git a/Flights/Services/AirlineLoadService.swift b/Flights/Services/AirlineLoadService.swift index e5fef5e..dba16b2 100644 --- a/Flights/Services/AirlineLoadService.swift +++ b/Flights/Services/AirlineLoadService.swift @@ -698,8 +698,9 @@ actor AirlineLoadService { // Graceful degradation: /search/simple was unreachable but we have // the /lowfare/estimate day-total, which gives us at least // "how many seats are available somewhere on the route today". - // We can't attribute that to a specific flight, so we show it as - // a route-level cabin so the UI isn't empty. + // We can't attribute that to a specific flight, so we label the + // cabin clearly as a day-level estimate so the UI doesn't imply + // these numbers belong to the tapped flight specifically. if let lowFare = result.lowFareFallback { print("[XE] Falling back to low-fare estimate: \(lowFare.available) seats available on \(lowFare.date)") let booked = max(0, capacity - lowFare.available) @@ -708,7 +709,7 @@ actor AirlineLoadService { flightNumber: "XE\(num)", cabins: [ CabinLoad( - name: "Route (day total)", + name: "Route day-total (fallback)", capacity: capacity, booked: booked, revenueStandby: 0, diff --git a/Flights/Services/JSXWebViewFetcher.swift b/Flights/Services/JSXWebViewFetcher.swift index 437e272..27fb1bd 100644 --- a/Flights/Services/JSXWebViewFetcher.swift +++ b/Flights/Services/JSXWebViewFetcher.swift @@ -76,7 +76,6 @@ private final class JSXFlow { private var webView: WKWebView! private var stepNumber = 0 - private var capturedBody: String? // Parsed date parts (used by step 14 aria-label match). private let targetYear: Int @@ -768,54 +767,30 @@ private final class JSXFlow { if !s16.ok { return fail("Step 16 failed: \(s16.message)") } // ---- STEP 17 ---- - // Invoke JSX's flight-search Angular component's `search()` method - // directly via __ngContext__, bypassing the Find Flights button - // click handler entirely. + // ---- STEP 17 ---- + // Direct POST /api/nsk/v4/availability/search/simple from the + // loaded jsx.com page context, using the anonymous auth token + // from sessionStorage and the page's own Akamai session cookies. + // This is the only path that actually works end-to-end. // - // Context: the introspection dump from an earlier iteration revealed - // the real component shape. It's NOT Angular Reactive Forms — it's - // plain instance properties on a custom component (minified class - // `Me` in the current build, but class names change per build, so - // we match by shape). The component sits at DOM depth 4 (the - // `
` element, context index 8), - // and by the time we get here the UI driving steps have already - // set `beginDate=Date(...)`, `origin=Object{...}`, and - // `destination=Object{...}` on it. All we need to do is call its - // `search()` method — Angular's own HttpClient then fires the POST - // via WebKit's network stack, the interceptor captures the - // response, and downstream parse runs normally. + // IMPORTANT: this POST fails on iOS Simulator (`TypeError: Load + // failed` / NSURLErrorNetworkConnectionLost) because simulator + // WebKit runs on macOS's CFNetwork stack, whose TLS/H2 fingerprint + // Akamai's per-endpoint protection tier on /search/simple treats + // as untrusted. On a real iOS device the POST returns 200 OK. + // Other api.jsx.com endpoints (token, graph/*, lowfare/estimate) + // work from both simulator and device because they're in a looser + // Akamai group — that's why the low-fare fallback in step 18 still + // returns usable data from the simulator. // - // Why this works when the click doesn't: the click handler - // presumably checks some derived `isValid` or other gate that our - // synthetic click/form state can't satisfy. `search()` doesn't - // check, so it just runs. - // - // Shape match criteria: any object that is BOTH: - // - a state-bearing component (has `beginDate` + `origin` + - // `destination` as own properties) - // - callable (has a `search` function) - // This is unique to JSX's flight search component in practice — - // no other Angular component on the page has all four. + // If this step fails for any reason, step 18 falls back to a + // direct GET of /lowfare/estimate which gives us a day-level + // seat count and lowest price per route — not per flight, but + // enough to avoid showing nothing. let s17 = await jsStep( "Direct fetch POST /search/simple from page context", action: """ return await (async () => { - // This run deliberately uses a completely unwrapped fetch() - // — the step 4 `install passive performance observer` does - // NOT touch window.fetch or XMLHttpRequest. We also use the - // EXACT same shape that the step-18 low-fare fallback uses, - // which we've confirmed works (returns a 200 body) from - // this same page context. The only differences between the - // working GET and this POST: - // - method: GET → POST - // - Content-Type header (not sent on GET) - // - body (not sent on GET) - // - // If this fetch works, we parse the body directly and - // skip component.search() entirely. If it fails, we log - // the error and fall through to component.search() + state - // harvest as a secondary attempt. - const readToken = () => { try { const raw = sessionStorage.getItem("navitaire.digital.token"); @@ -846,11 +821,6 @@ private final class JSXFlow { ssrCollectionsMode: 1 }); - // Attempt 1: straight fetch POST. Uses the unwrapped - // window.fetch. credentials:include forwards the Akamai - // session cookies the page already has from loading - // https://www.jsx.com. - let directFetchResult; try { const resp = await fetch(url, { method: "POST", @@ -863,238 +833,47 @@ private final class JSXFlow { body }); const text = await resp.text(); - directFetchResult = { - ok: resp.status >= 200 && resp.status < 300 && text.length > 100, + const ok = resp.status >= 200 && resp.status < 300 && text.length > 100; + return JSON.stringify({ + ok, + message: ok + ? ("status=" + resp.status + " len=" + text.length) + : ("status=" + resp.status + " len=" + text.length + " (not ok)"), status: resp.status, length: text.length, - body: text - }; + body: ok ? text : null + }); } catch (err) { - directFetchResult = { + return JSON.stringify({ ok: false, + message: "fetch threw: " + String(err && err.message ? err.message : err), status: 0, length: 0, error: String(err && err.message ? err.message : err) - }; - } - - if (directFetchResult.ok) { - return JSON.stringify({ - ok: true, - message: "direct fetch POST succeeded status=" + directFetchResult.status - + " len=" + directFetchResult.length, - via: "direct-fetch", - directStatus: directFetchResult.status, - directLength: directFetchResult.length, - body: directFetchResult.body }); } - - // Attempt 2: component.search() + SPA state harvest. Wrap - // the whole thing in try/catch so that if anything in the - // fallback path throws (which happened with a WeakSet - // misuse in a prior iteration), we still return a result - // instead of killing the function with an uncaught error. - let fallbackError = null; - let componentInfo = null; - try { - let component = null; - for (const el of document.querySelectorAll("*")) { - const ctxKey = Object.keys(el).find(k => k.startsWith("__ngContext__")); - if (!ctxKey) continue; - const ctx = el[ctxKey]; - if (!Array.isArray(ctx)) continue; - for (let idx = 0; idx < ctx.length; idx++) { - const item = ctx[idx]; - if (!item || typeof item !== "object") continue; - if ("beginDate" in item && "origin" in item - && "destination" in item && typeof item.search === "function") { - component = item; - const proto = Object.getPrototypeOf(item); - componentInfo = { - ctor: proto && proto.constructor ? proto.constructor.name : "?", - idx - }; - break; - } - } - if (component) break; - } - - if (component) { - if (!(component.beginDate instanceof Date)) { - component.beginDate = new Date(\(targetYear), \(targetMonth - 1), \(targetDay), 12, 0, 0); - } - try { - const r = component.search(); - if (r && typeof r.then === "function") { try { await r; } catch (_) {} } - } catch (_) {} - - // Walk __ngContext__ for the parsed availability shape. - // Single globally-shared WeakSet (not recreated per - // recursion) — WeakSet isn't iterable so we can't - // clone it anyway. - const hasShape = (obj) => { - if (!obj || typeof obj !== "object") return false; - try { - if (obj.faresAvailable && typeof obj.faresAvailable === "object" - && Object.keys(obj.faresAvailable).length > 0) return true; - if (Array.isArray(obj.results)) { - for (const x of obj.results) { - if (x && Array.isArray(x.trips)) { - for (const t of x.trips) { - if (t && t.journeysAvailableByMarket) return true; - } - } - } - } - if (obj.journeysAvailableByMarket) return true; - } catch (_) {} - return false; - }; - const seen = new WeakSet(); - const find = (obj, depth) => { - if (depth > 6 || !obj || typeof obj !== "object") return null; - if (seen.has(obj)) return null; - seen.add(obj); - if (hasShape(obj)) return obj; - if (Array.isArray(obj)) { - for (let i = 0; i < Math.min(obj.length, 40); i++) { - const h = find(obj[i], depth + 1); - if (h) return h; - } - return null; - } - const keys = Object.keys(obj); - for (let i = 0; i < Math.min(keys.length, 40); i++) { - const k = keys[i]; - if (k.startsWith("_$") || k === "__proto__") continue; - let child; - try { child = obj[k]; } catch (_) { continue; } - const h = find(child, depth + 1); - if (h) return h; - } - return null; - }; - - const deadline = Date.now() + 10000; - let hit = null; - while (Date.now() < deadline && !hit) { - for (const el of document.querySelectorAll("*")) { - const k = Object.keys(el).find(k => k.startsWith("__ngContext__")); - if (!k) continue; - const ctx = el[k]; - if (!Array.isArray(ctx)) continue; - for (const item of ctx) { - hit = find(item, 0); - if (hit) break; - } - if (hit) break; - } - if (!hit) await new Promise(r => setTimeout(r, 300)); - } - - if (hit) { - const jsonSafe = (v, d = 0) => { - if (d > 10) return null; - if (v === null || v === undefined) return v; - if (typeof v === "function") return undefined; - if (v instanceof Date) return v.toISOString(); - if (typeof v !== "object") return v; - if (Array.isArray(v)) { - const out = []; - for (let i = 0; i < Math.min(v.length, 100); i++) { - const e = jsonSafe(v[i], d + 1); - if (e !== undefined) out.push(e); - } - return out; - } - const out = {}; - for (const k of Object.keys(v)) { - if (k.startsWith("_$") || k === "__proto__") continue; - let c; - try { c = v[k]; } catch (_) { continue; } - const s = jsonSafe(c, d + 1); - if (s !== undefined) out[k] = s; - } - return out; - }; - const wrapped = { data: jsonSafe(hit) }; - const serialized = JSON.stringify(wrapped); - return JSON.stringify({ - ok: true, - message: "harvested from SPA state (direct fetch failed: " - + (directFetchResult.error || ("status " + directFetchResult.status)) + ")", - via: "state-harvest", - component: componentInfo, - directFetchError: directFetchResult.error || ("status " + directFetchResult.status), - body: serialized - }); - } - } - } catch (err) { - fallbackError = String(err && err.message ? err.message : err); - } - - // Both attempts failed. Return the direct-fetch diagnostic - // verbatim so we can see the real reason the direct fetch - // didn't work. - return JSON.stringify({ - ok: false, - message: "direct fetch POST failed AND SPA state harvest found nothing" - + " (direct: " + (directFetchResult.error || ("status=" + directFetchResult.status)) + ")", - via: "neither", - directStatus: directFetchResult.status, - directError: directFetchResult.error || null, - fallbackError, - component: componentInfo, - href: location.href - }); })(); """, - verifiers: [ - Verifier(name: "body present in action result", js: """ - return await (async () => { - // The action sets body as an action data field; the - // runner can't see it from JS. Just confirm SPA is still - // on jsx.com (not a redirect or error page). - return JSON.stringify({ - ok: location.href.includes("jsx.com"), - detail: location.href - }); - })(); - """) - ] + verifiers: [] ) - // Log the component identity + network observation. - if let info = s17.data["component"] as? [String: Any] { - print("[JSX] ▸ flight search component: ctor=\(info["ctor"] ?? "?")" - + " idx=\(info["idx"] ?? "?")" - + " host=<\(info["elTag"] ?? "?") class='\(info["elClass"] ?? "")'>") - } - if let searchCount = s17.data["searchResourceCount"] as? Int { - let completed = (s17.data["completedResourceCount"] as? Int) ?? 0 - print("[JSX] ▸ /search/simple resource entries: \(searchCount) total, \(completed) with body") - } - // ---- STEP 18 ---- - // Parse whatever step 17 harvested. If step 17 produced a `body` - // field, that's the wrapped availability payload; feed it straight - // to parseFlights(). If not, fall back to a direct GET of - // /lowfare/estimate from the page context (GETs are known to - // succeed from WKWebView) so the UI still has something to show. + // Parse the /search/simple body from step 17, or fall back to a + // direct GET of /lowfare/estimate. The low-fare GET works from + // both simulator and real device (it's in a looser Akamai + // protection group than /search/simple), so it's the graceful + // degradation when step 17's POST couldn't complete. var parsedFlights: [JSXFlight] = [] - var rawHarvested: String? = s17.data["body"] as? String + let rawBody: String? = s17.data["body"] as? String var lowFareFallback: JSXLowFareEstimate? = nil - let s18 = await nativeStep("Parse harvested availability data") { [weak self] in + let s18 = await nativeStep("Parse /search/simple body (or low-fare fallback)") { [weak self] in guard let self else { return (false, "self gone", [:]) } - if let rawHarvested, !rawHarvested.isEmpty { - parsedFlights = self.parseFlights(from: rawHarvested) + if let rawBody, !rawBody.isEmpty { + parsedFlights = self.parseFlights(from: rawBody) if parsedFlights.isEmpty { - return (false, "parser returned 0 flights from \(rawHarvested.count)-byte harvested payload", - ["rawLength": rawHarvested.count]) + return (false, "parser returned 0 flights from \(rawBody.count)-byte body", + ["rawLength": rawBody.count]) } for flight in parsedFlights { let classesText = flight.classes.map { c in @@ -1105,10 +884,10 @@ private final class JSXFlow { + "\(flight.departureLocal) → \(flight.arrivalLocal) " + "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]") } - return (true, "decoded \(parsedFlights.count) flights from harvested SPA state", + return (true, "decoded \(parsedFlights.count) flights", ["count": parsedFlights.count]) } else { - print("[JSX] ▸ no harvested payload; fetching /lowfare/estimate directly from page context") + print("[JSX] ▸ step 17 did not produce a response body; falling back to /lowfare/estimate") if let lf = await self.fetchLowFareEstimateDirectly() { lowFareFallback = lf print("[JSX] │ lowfare fallback: \(lf.date) available=\(lf.available)" @@ -1116,7 +895,7 @@ private final class JSXFlow { return (true, "using low-fare fallback (\(lf.available) seats on \(lf.date))", ["available": lf.available, "date": lf.date]) } - return (false, "no harvested payload and direct low-fare fetch failed", [:]) + return (false, "no response body and direct low-fare fetch failed", [:]) } } verify: { [weak self] in guard let self else { return [] } @@ -1156,7 +935,7 @@ private final class JSXFlow { self.webView = nil return JSXSearchResult( flights: parsedFlights, - rawSearchBody: rawHarvested, + rawSearchBody: rawBody, lowFareFallback: lowFareFallback, error: nil ) @@ -1444,185 +1223,6 @@ private final class JSXFlow { } } - // MARK: - Native URLSession POST (step 17) - - /// Fires the `/availability/search/simple` POST via a native URLSession - /// request rather than from inside the WebKit web-content process. - /// URLSession uses iOS's native HTTPS stack whose TLS/HTTP2 fingerprint - /// matches Safari, so Akamai accepts it where it rejects WKWebView's - /// script-side `fetch()`/`XHR`. - /// - /// Forwards the session cookies from `WKHTTPCookieStore` (including - /// HttpOnly Akamai cookies that `document.cookie` can't read) and the - /// anonymous JWT from `sessionStorage["navitaire.digital.token"]`. - /// - /// On success, stores the response body on `self.capturedBody` so the - /// parse step can read it. On failure, calls `fail(...)` semantics are - /// handled by the caller checking `self.capturedBody`. - private func nativePOSTSearchSimple() async { - stepNumber += 1 - let label = "STEP \(String(format: "%02d", stepNumber))" - let name = "POST /availability/search/simple via native URLSession" - print("[JSX] ┌─ \(label) \(name)") - let start = Date() - - // 1. Read the auth token out of sessionStorage. This is the only JS - // call — everything else is native. - let tokenResult = await runJS(""" - return await (async () => { - const readToken = () => { - try { - const raw = sessionStorage.getItem("navitaire.digital.token"); - if (!raw) return ""; - const parsed = JSON.parse(raw); - return (parsed && parsed.token) || ""; - } catch (_) { return ""; } - }; - let token = readToken(); - if (!token) { - const deadline = Date.now() + 3000; - while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, 200)); - token = readToken(); - if (token) break; - } - } - return JSON.stringify({ ok: !!token, token: token || "" }); - })(); - """) - let token = (tokenResult?["token"] as? String) ?? "" - if token.isEmpty { - print("[JSX] │ action: no auth token in sessionStorage after 3s wait") - let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) - print("[JSX] └─ \(label) FAIL (\(elapsed))") - return - } - print("[JSX] │ action.token: \(token.prefix(24))…") - - // 2. Pull all cookies out of the WKWebView cookie store. This - // includes HttpOnly cookies that `document.cookie` can't see — - // which is critical because Akamai's session cookies are HttpOnly. - guard let webView = self.webView else { - print("[JSX] │ action: webView is nil") - print("[JSX] └─ \(label) FAIL") - return - } - let allCookies: [HTTPCookie] = await webView.configuration - .websiteDataStore.httpCookieStore.allCookies() - // Filter to cookies that would actually be sent to api.jsx.com. - // HTTPCookie.domain can be "jsx.com" (host and all subdomains), - // ".jsx.com" (same meaning, older style), or "api.jsx.com". - let jsxCookies = allCookies.filter { cookie in - let d = cookie.domain.lowercased() - return d == "api.jsx.com" - || d == "jsx.com" || d == ".jsx.com" - || d.hasSuffix(".jsx.com") - } - let cookieHeader = jsxCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") - print("[JSX] │ action.cookies: \(jsxCookies.count) from WKWebView store — \(jsxCookies.map(\.name).sorted().joined(separator: ", "))") - - // 3. Build the request. Headers mirror what the JSX SPA sends, so - // Akamai has no reason to flag us. User-Agent matches the UA we - // set on the WKWebView in step 01. - guard let url = URL(string: "https://api.jsx.com/api/nsk/v4/availability/search/simple") else { - print("[JSX] │ action: invalid URL") - print("[JSX] └─ \(label) FAIL") - return - } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") - request.setValue(token, forHTTPHeaderField: "Authorization") - request.setValue("https://www.jsx.com", forHTTPHeaderField: "Origin") - request.setValue("https://www.jsx.com/home/search", forHTTPHeaderField: "Referer") - request.setValue( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " - + "(KHTML, like Gecko) Version/17.4 Safari/605.1.15", - forHTTPHeaderField: "User-Agent" - ) - if !cookieHeader.isEmpty { - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - } - - let bodyDict: [String: Any] = [ - "beginDate": self.date, - "destination": self.destination, - "origin": self.origin, - "passengers": ["types": [["count": 1, "type": "ADT"]]], - "taxesAndFees": 2, - "filters": [ - "maxConnections": 4, - "compressionType": 1, - "sortOptions": [4], - "fareTypes": ["R"], - "exclusionType": 2 - ], - "numberOfFaresPerJourney": 10, - "codes": ["currencyCode": "USD"], - "ssrCollectionsMode": 1 - ] - guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyDict) else { - print("[JSX] │ action: failed to serialize request body") - print("[JSX] └─ \(label) FAIL") - return - } - request.httpBody = bodyData - - // 4. Fire via URLSession.shared. URLSession's default ephemeral - // session uses iOS's native HTTPS stack — same TLS fingerprint as - // Safari — which is what Akamai is happy to serve. - let session = URLSession.shared - do { - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { - print("[JSX] │ action: non-HTTP response") - print("[JSX] └─ \(label) FAIL") - return - } - let bodyText = String(data: data, encoding: .utf8) ?? "" - print("[JSX] │ action: URLSession status=\(http.statusCode), body=\(bodyText.count) bytes") - - let statusOk = (200..<300).contains(http.statusCode) - let bodyOk = bodyText.count > 100 - let parseOk: Bool - if let d = bodyText.data(using: .utf8), - let root = try? JSONSerialization.jsonObject(with: d) as? [String: Any], - let payload = root["data"] as? [String: Any], - payload["results"] is [[String: Any]] { - parseOk = true - } else { - parseOk = false - } - - let g1 = statusOk ? "✓" : "✗" - let g2 = bodyOk ? "✓" : "✗" - let g3 = parseOk ? "✓" : "✗" - print("[JSX] │ verify \(g1) status is 2xx — status=\(http.statusCode)") - print("[JSX] │ verify \(g2) body is non-empty — \(bodyText.count) bytes") - print("[JSX] │ verify \(g3) body parses as JSON with data.results") - - if statusOk && bodyOk && parseOk { - self.capturedBody = bodyText - } else { - // Surface a snippet of whatever we got back so we can see - // what Akamai / Navitaire sent instead of the expected - // payload. - let preview = String(bodyText.prefix(500)) - print("[JSX] │ response preview: \(preview)") - } - - let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) - let status = (statusOk && bodyOk && parseOk) ? "OK" : "FAIL" - print("[JSX] └─ \(label) \(status) (\(elapsed))") - } catch { - let nsErr = error as NSError - print("[JSX] │ action: URLSession error: \(error.localizedDescription) [domain=\(nsErr.domain) code=\(nsErr.code)]") - let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) - print("[JSX] └─ \(label) FAIL (\(elapsed))") - } - } - // MARK: - JS snippet builders private func selectStationActionJS(indexIsDestination: Bool) -> String { @@ -2119,7 +1719,7 @@ private final class JSXFlow { self.webView = nil return JSXSearchResult( flights: [], - rawSearchBody: capturedBody, + rawSearchBody: nil, lowFareFallback: nil, error: reason ) diff --git a/JSX_NOTES.md b/JSX_NOTES.md index 6872750..c296959 100644 --- a/JSX_NOTES.md +++ b/JSX_NOTES.md @@ -21,6 +21,13 @@ hardware against the live production site in April 2026. 4. Driving the on-page UI to submit the search form is a dead end in **WKWebView** (but works in Playwright) because of a subtle trusted-event issue described below. +5. **Environment caveat**: the in-page `fetch()` POST (#3) works on a + **real iOS device** but **fails in the iOS Simulator**. Simulator + WebKit runs on macOS's CFNetwork stack, whose TLS/H2 fingerprint + Akamai's per-endpoint protection tier on `/search/simple` treats as + untrusted. Other `api.jsx.com` endpoints (tokens, bootstrap graph + calls, low-fare estimate) work from both. **Test on device; develop + with the low-fare fallback in the simulator.** The working pattern is therefore: @@ -28,7 +35,7 @@ The working pattern is therefore: Wait for SPA bootstrap (station buttons exist, token call finishes) Read token from sessionStorage POST /api/nsk/v4/availability/search/simple ← from inside the page - Parse the response + Parse the response (real device only) --- @@ -346,6 +353,12 @@ via a direct API call whose request shape matches exactly what This is what `Flights/Services/JSXWebViewFetcher.swift` step 17 does. +**Important environment caveat:** this workaround works on a **real iOS +device** but **fails in the iOS Simulator**. See the anti-bot surface +table below for the specific failure modes. Develop with a real device +plugged in, or accept the low-fare day-total fallback when running +against the simulator. + --- ## Anti-bot surface (Akamai) @@ -357,8 +370,9 @@ JSX fronts `api.jsx.com` with Akamai Bot Manager. Observed behavior: | Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. | | Playwright's built-in `chromium.launch()` (both bundled chromium and `channel: "chrome"`) | GET requests succeed, but `POST /availability/search/simple` specifically returns `ERR_HTTP2_PROTOCOL_ERROR`. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. | | Real Chrome spawned as a plain process + Playwright attached via `chromium.connectOverCDP()` | **Works reliably.** Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. | -| WKWebView on macOS / iOS | GET requests succeed. Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` also succeeds. The browser session's cookies and TLS fingerprint are trusted. | -| WKWebView with UI-driven Find Flights click | Fails — but for an unrelated reason (the trusted-events trap above). Angular never fires the POST in the first place, so Akamai never sees it. | +| **WKWebView on iOS Simulator** | GETs to `api.jsx.com` succeed (`/lowfare/estimate`, `/nsk/v1/token`, `/graph/*`). **`POST /availability/search/simple` fails** with `TypeError: Load failed` (in-page `fetch()`/`XHR`) or `NSURLErrorNetworkConnectionLost` (-1005) (native `URLSession` with cookies forwarded from the WKWebView store). iOS Simulator WebKit runs against macOS's CFNetwork stack, whose TLS/H2 fingerprint Akamai's per-endpoint protection tier treats as untrusted for this specific endpoint. No configuration (custom UA, XHR vs fetch, credentials mode, content type, cookie replay into URLSession) makes it work. | +| **WKWebView on real iOS hardware** | **Works.** Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` returns `status=200` with the full per-flight availability payload. Real iOS WebKit has the iOS-specific network stack whose TLS/H2 fingerprint Akamai accepts. The fetcher in `Flights/Services/JSXWebViewFetcher.swift` step 17 does exactly this and produces correct per-class seat counts. | +| WKWebView with UI-driven Find Flights click (any platform) | Fails for an unrelated reason: WKWebView's synthetic `MouseEvent` has `isTrusted === false`, and JSX's custom datepicker only commits its day-cell selection to the Angular form model on a trusted user gesture. Result: the input visually shows the selected date but the underlying model stays null, `form.invalid === true`, and Angular's `search()` silently returns without firing a request. The direct-fetch POST bypasses this entirely. | Observations: