beta.18: navigate-and-read API fetch replaces callAsyncJavaScript

This commit is contained in:
SuperDooper
2026-05-11 12:20:19 +02:00
parent 9bc023d239
commit 28d952bcbd
+66 -66
View File
@@ -30,14 +30,11 @@ class UsageViewModel: ObservableObject {
private var previousPercents: [String: Double] = [:] private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:] private var firedThresholds: [String: Set<Int>] = [:]
// Background WKWebView for API calls via callAsyncJavaScript. // Background WKWebView for API calls via WebKit navigation.
// Hosted in a hidden NSWindow without a window WebKit suspends the WebView // Hosted in a hidden NSWindow to keep the WebKit process active.
// and JS execution stops working, breaking callAsyncJavaScript.
private var apiWebView: WKWebView? private var apiWebView: WKWebView?
private var apiDelegate: APIWebViewDelegate?
private var apiWindow: NSWindow? private var apiWindow: NSWindow?
private var apiWebViewLoaded = false private var currentFetchDelegate: APIFetchDelegate?
private var apiReadyContinuations: [CheckedContinuation<Void, Never>] = []
init() { init() {
let saved = UserDefaults.standard.double(forKey: "refresh_interval") let saved = UserDefaults.standard.double(forKey: "refresh_interval")
@@ -57,18 +54,9 @@ class UsageViewModel: ObservableObject {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default() config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config) let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
let del = APIWebViewDelegate()
del.onNavigationEnd = { [weak self] in
guard let self else { return }
self.apiWebViewLoaded = true
self.resumeAPIReadyContinuations()
}
wv.navigationDelegate = del
apiWebView = wv apiWebView = wv
apiDelegate = del
// A WKWebView with no window is suspended by macOS JS execution won't run. // Hosting in a 1×1 transparent window keeps the WebKit process active.
// Hosting it in a 1×1 transparent window keeps WebKit's process alive.
let window = NSWindow( let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1), contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
styleMask: .borderless, styleMask: .borderless,
@@ -81,56 +69,28 @@ class UsageViewModel: ObservableObject {
window.contentView?.addSubview(wv) window.contentView?.addSubview(wv)
window.orderFrontRegardless() window.orderFrontRegardless()
apiWindow = window apiWindow = window
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
} }
private func resumeAPIReadyContinuations() { // Called after login: adopt the login WebView (proven authenticated) and refresh.
let pending = apiReadyContinuations
apiReadyContinuations.removeAll()
pending.forEach { $0.resume() }
}
private func waitForAPIWebViewReady() async {
guard !apiWebViewLoaded else { return }
await withCheckedContinuation { cont in
apiReadyContinuations.append(cont)
}
}
// Called after login: adopts the proven-authenticated login WebView for all API calls.
// This avoids the background WebView potentially missing auth tokens that only exist
// in the login WebView's JS context (localStorage, Service Workers, etc.).
func adoptAndRefresh(_ loginWebView: WKWebView) async { func adoptAndRefresh(_ loginWebView: WKWebView) async {
loginWebView.removeFromSuperview() loginWebView.removeFromSuperview()
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1) loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
apiWindow?.contentView?.addSubview(loginWebView) apiWindow?.contentView?.addSubview(loginWebView)
apiWebView = loginWebView apiWebView = loginWebView
apiWebViewLoaded = true
resumeAPIReadyContinuations()
try? await Task.sleep(nanoseconds: 400_000_000) try? await Task.sleep(nanoseconds: 400_000_000)
await refresh() await refresh()
} }
// Executes a same-origin fetch inside the background WebView's page context. // Fetches an API path by navigating the WebView to the URL and reading the response.
// This includes all credentials the page has (cookies, localStorage tokens, etc.), // Navigation lets WebKit send full browser headers and cookies automatically
// which URLSession cannot access hence using callAsyncJavaScript instead. // more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors.
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) { private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
guard let wv = apiWebView else { throw AppError.detail("no webview") } guard let wv = apiWebView else { throw AppError.detail("no api webview") }
await waitForAPIWebViewReady() return try await withCheckedThrowingContinuation { cont in
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};" let delegate = APIFetchDelegate(continuation: cont)
do { currentFetchDelegate = delegate
let result = try await wv.callAsyncJavaScript(js, arguments: ["path": path], in: nil, in: .page) wv.navigationDelegate = delegate
guard let d = result as? [String: Any], wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!))
let s = (d["s"] as? NSNumber)?.intValue,
let b = d["b"] as? String else {
throw AppError.detail("bad result: \(String(describing: result).prefix(120))")
}
return (s, b)
} catch let e as AppError { throw e }
catch {
let loc = wv.url?.absoluteString ?? "?"
throw AppError.detail("JS err @ \(loc): \(error.localizedDescription.prefix(100))")
} }
} }
@@ -154,7 +114,6 @@ class UsageViewModel: ObservableObject {
extraUsage = nil extraUsage = nil
prepaidCredits = nil prepaidCredits = nil
overageSpendLimit = nil overageSpendLimit = nil
apiWebViewLoaded = false
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!)) apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
} }
@@ -180,10 +139,10 @@ class UsageViewModel: ObservableObject {
if let email = fetchedEmail { userEmail = email } if let email = fetchedEmail { userEmail = email }
if let plan = fetchedPlan { planLabel = plan } if let plan = fetchedPlan { planLabel = plan }
async let usageFetch = fetchUsage(orgId: orgId) // Sequential each call navigates the shared WebView to the next API URL.
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId) let usage = try await fetchUsage(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId) let prepaid = try? await fetchPrepaidCredits(orgId: orgId)
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch) let overage = try? await fetchOverageSpendLimit(orgId: orgId)
limits = buildLimits(from: usage) limits = buildLimits(from: usage)
extraUsage = usage.extraUsage extraUsage = usage.extraUsage
prepaidCredits = prepaid prepaidCredits = prepaid
@@ -226,7 +185,11 @@ class UsageViewModel: ObservableObject {
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) { private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await webViewFetch("/api/bootstrap") let (status, body) = try await webViewFetch("/api/bootstrap")
if status == 401 || status == 403 { throw AppError.notAuthenticated } if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.networkError } guard status == 200 else { throw AppError.detail("bootstrap \(status): \(body.prefix(80))") }
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.notAuthenticated
}
guard let data = body.data(using: .utf8), guard let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil) return (nil, nil, nil)
@@ -398,19 +361,56 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - API WebView Delegate // MARK: - API Fetch Delegate
private class APIWebViewDelegate: NSObject, WKNavigationDelegate { // Captures the HTTP status code and response body from a WebView navigation.
var onNavigationEnd: (() -> Void)? // Used by webViewFetch to turn a navigation into an async (statusCode, body) result.
private class APIFetchDelegate: NSObject, WKNavigationDelegate {
private let continuation: CheckedContinuation<(Int, String), Error>
private var capturedStatus = 0
private var finished = false
init(continuation: CheckedContinuation<(Int, String), Error>) {
self.continuation = continuation
}
private func complete(_ result: Result<(Int, String), Error>) {
guard !finished else { return }
finished = true
continuation.resume(with: result)
}
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if let http = response.response as? HTTPURLResponse {
capturedStatus = http.statusCode
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
onNavigationEnd?() let status = capturedStatus
// Detect auth redirect: if WebKit followed a 302 to /login, finalURL changes.
let finalURL = webView.url?.absoluteString ?? ""
if finalURL.contains("/login") || finalURL.contains("/auth") {
complete(.success((401, "redirected:\(finalURL)")))
return
} }
webView.evaluateJavaScript("document.body.innerText ?? ''") { [weak self] result, error in
if let body = result as? String {
self?.complete(.success((status, body)))
} else {
self?.complete(.failure(AppError.detail("body read: \(error?.localizedDescription ?? "nil")")))
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?() complete(.failure(AppError.detail("nav: \(error.localizedDescription.prefix(80))")))
} }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?() complete(.failure(AppError.detail("prov: \(error.localizedDescription.prefix(80))")))
} }
} }