Compare commits

...
Author SHA1 Message Date
SuperDooper 8e7328b2c5 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:34 +02:00
SuperDooper 709eb12382 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:33 +02:00
SuperDooper e866324a48 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:32 +02:00
SuperDooper a62584221b beta.12: route all API calls through background WKWebView 2026-05-11 11:02:30 +02:00
github-actions[bot] fc7fd19652 Beta release v1.2.1-beta.11 2026-05-11 08:47:11 +00:00
superdooper86 58b947e515 fix: add KVO on webView.url to catch SPA pushState navigation
didFinish only fires for cross-document (full page) navigations. After
loading https://claude.ai/login the SPA redirects authenticated users
via history.pushState to /new — this changes the URL visually but never
fires didFinish, so auth was never detected.

KVO on webView.url fires for every URL change including SPA pushState,
covering the case where the app routes client-side after the initial
page load. Both KVO and didFinish now call the same checkCurrentURL
helper so detection is not missed regardless of navigation type.
2026-05-11 10:46:11 +02:00
github-actions[bot] 88252e4d59 Beta release v1.2.1-beta.10 2026-05-11 08:36:44 +00:00
superdooper86 ad2ff3a7b6 fix: revert to URL-based auth detection; remove browser headers
Every JS/cookie-based detection approach failed. Reverting to the
simplest reliable mechanism: if the WebView navigates to any non-login,
non-auth URL, the server redirected us after sign-in — fire onAuthenticated.

Also removing the browser headers added in beta.6. The 1.1.4 version
worked without them and they may be triggering server-side bot detection.
All-cookies approach (beta.8) is kept.
2026-05-11 10:35:30 +02:00
github-actions[bot] 1a9d9cd22a Beta release v1.2.1-beta.9 2026-05-11 08:23:48 +00:00
superdooper86 b8826ace9b fix: NSNumber cast and use .page world in callAsyncJavaScript auth check
callAsyncJavaScript returns JS numbers as NSNumber (Double-backed).
'val as? Int' silently returns nil for 200.0, so onAuthenticated never
fired. Fixed with 'val as? NSNumber then .intValue == 200'.

Also switched content world from .defaultClient to .page so the fetch
runs in the same JS context as the loaded page.
2026-05-11 10:22:59 +02:00
github-actions[bot] 2a0de269ff Beta release v1.2.1-beta.8 2026-05-11 08:07:51 +00:00
7 changed files with 109 additions and 82 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ struct ContentView: View {
} }
.sheet(isPresented: $showLogin) { .sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) { LoginSheetView(isPresented: $showLogin) {
Task { await vm.refresh() } Task { await vm.reloadAPIWebViewAndRefresh() }
} }
} }
.sheet(isPresented: $showUpdateSheet) { .sheet(isPresented: $showUpdateSheet) {
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.2.1-beta.8</string> <string>1.2.1-beta.12</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>56</string> <string>60</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+18 -20
View File
@@ -12,6 +12,12 @@ struct LoginWebView: NSViewRepresentable {
let webView = WKWebView(frame: .zero, configuration: config) let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator webView.navigationDelegate = context.coordinator
// KVO on url catches SPA pushState navigations that don't fire didFinish
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
coordinator?.checkCurrentURL(wv.url?.absoluteString)
}
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!)) webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
return webView return webView
} }
@@ -25,33 +31,25 @@ struct LoginWebView: NSViewRepresentable {
class Coordinator: NSObject, WKNavigationDelegate { class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: () -> Void let onAuthenticated: () -> Void
var didAuthenticate = false var didAuthenticate = false
var urlObservation: NSKeyValueObservation?
init(onAuthenticated: @escaping () -> Void) { init(onAuthenticated: @escaping () -> Void) {
self.onAuthenticated = onAuthenticated self.onAuthenticated = onAuthenticated
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func checkCurrentURL(_ url: String?) {
guard !didAuthenticate else { return } guard !didAuthenticate, let url else { return }
// Don't fire on the login/auth pages themselves if url.contains("/login") || url.contains("/auth") { return }
if let url = webView.url?.absoluteString, didAuthenticate = true
url.contains("/login") || url.contains("/auth") { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated()
// Ask the WebView itself whether we're authenticated it uses its own
// session (cookies, localStorage, etc.) so we don't need to know the
// cookie domain or name.
webView.callAsyncJavaScript(
"const r = await fetch('/api/bootstrap', {credentials: 'include'}); return r.status;",
arguments: [:], in: nil, in: .defaultClient
) { [weak self] result in
guard let self, !self.didAuthenticate else { return }
if case .success(let val) = result, let status = val as? Int, status == 200 {
self.didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.onAuthenticated()
}
}
} }
} }
// Covers full cross-document navigations
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
checkCurrentURL(webView.url?.absoluteString)
}
} }
} }
+81 -52
View File
@@ -30,6 +30,10 @@ 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 used for all API calls runs fetch() in the page's auth context
private var apiWebView: WKWebView?
private var apiDelegate: APIWebViewDelegate?
init() { init() {
let saved = UserDefaults.standard.double(forKey: "refresh_interval") let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60 refreshInterval = saved > 0 ? saved : 60
@@ -38,9 +42,50 @@ class UsageViewModel: ObservableObject {
burnHistoryStore = saved burnHistoryStore = saved
} }
loadPlaceholderData() loadPlaceholderData()
setupAPIWebView()
Task { await checkInitialSignInState() } Task { await checkInitialSignInState() }
} }
// MARK: - Background API WebView
private func setupAPIWebView() {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
let del = APIWebViewDelegate()
wv.navigationDelegate = del
apiWebView = wv
apiDelegate = del
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
// Called after login: reloads the background WebView to pick up the new session, then refreshes.
func reloadAPIWebViewAndRefresh() async {
guard let wv = apiWebView, let del = apiDelegate else {
await refresh()
return
}
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
del.onDidFinish = { cont.resume() }
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
// Brief pause for the page's JS auth state to settle after navigation
try? await Task.sleep(nanoseconds: 500_000_000)
await refresh()
}
// Runs a fetch() call inside the background WebView's page context (same-origin, credentials included).
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
guard let wv = apiWebView else { throw AppError.networkError }
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};"
let result = try await wv.callAsyncJavaScript(
js, arguments: ["path": path], in: nil, in: .page)
guard let d = result as? [String: Any],
let s = (d["s"] as? NSNumber)?.intValue,
let b = d["b"] as? String else { throw AppError.networkError }
return (s, b)
}
private func checkInitialSignInState() async { private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true } if !cookies.isEmpty { isSignedIn = true }
@@ -61,6 +106,8 @@ class UsageViewModel: ObservableObject {
extraUsage = nil extraUsage = nil
prepaidCredits = nil prepaidCredits = nil
overageSpendLimit = nil overageSpendLimit = nil
// Reload background WebView to clear its session too
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
} }
func refresh() async { func refresh() async {
@@ -128,48 +175,27 @@ class UsageViewModel: ObservableObject {
// MARK: - Bootstrap (org ID + email + plan label in one call) // MARK: - Bootstrap (org ID + email + plan label in one call)
private func claudeAPIRequest(for url: URL) async -> URLRequest {
var req = URLRequest(url: url)
req.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept")
req.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
if let cookie = await claudeCookieHeader() {
req.setValue(cookie, forHTTPHeaderField: "Cookie")
}
return req
}
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) { private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let url = URL(string: "https://claude.ai/api/bootstrap")! let (status, body) = try await webViewFetch("/api/bootstrap")
var req = await claudeAPIRequest(for: url) if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard req.value(forHTTPHeaderField: "Cookie") != nil else { throw AppError.notAuthenticated } guard status == 200 else { throw AppError.networkError }
let (data, response) = try await URLSession.shared.data(for: req) guard let data = body.data(using: .utf8),
guard let http = response as? HTTPURLResponse else { throw AppError.networkError } let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil) return (nil, nil, nil)
} }
let account = json["account"] as? [String: Any] let account = json["account"] as? [String: Any]
// memberships may live under account or at root (older API shape)
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]] let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
let firstOrg = memberships?.first?["organization"] as? [String: Any] let firstOrg = memberships?.first?["organization"] as? [String: Any]
// org ID primary path then flat-list fallback then dedicated endpoint
var orgId: String? = firstOrg?["uuid"] as? String var orgId: String? = firstOrg?["uuid"] as? String
if orgId == nil { if orgId == nil {
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
} }
if orgId == nil { if orgId == nil {
// Final fallback: fetch /api/organizations directly if let (orgsStatus, orgsBody) = try? await webViewFetch("/api/organizations"),
let orgsReq = await claudeAPIRequest(for: URL(string: "https://claude.ai/api/organizations")!) orgsStatus == 200,
if let (orgsData, orgsResp) = try? await URLSession.shared.data(for: orgsReq), let orgsData = orgsBody.data(using: .utf8),
let orgsHttp = orgsResp as? HTTPURLResponse, orgsHttp.statusCode == 200,
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] { let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
orgId = orgs.first?["uuid"] as? String orgId = orgs.first?["uuid"] as? String
} }
@@ -177,7 +203,6 @@ class UsageViewModel: ObservableObject {
let email = account?["email_address"] as? String let email = account?["email_address"] as? String
// plan label from capabilities e.g. "claude_pro" -> "Pro"
var planLabel: String? = nil var planLabel: String? = nil
if let caps = firstOrg?["capabilities"] as? [String], if let caps = firstOrg?["capabilities"] as? [String],
let cap = caps.first(where: { $0.hasPrefix("claude_") }) { let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
@@ -190,37 +215,25 @@ class UsageViewModel: ObservableObject {
// MARK: - Fetch usage // MARK: - Fetch usage
private func claudeCookieHeader() async -> String? {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
guard !cookies.isEmpty else { return nil }
// Send all cookies from the app's WebView store the session token may be
// on any domain (claude.ai, anthropic.com, or an auth sub-service).
return HTTPCookie.requestHeaderFields(with: cookies)["Cookie"]
}
private func fetchUsage(orgId: String) async throws -> UsageResponse { private func fetchUsage(orgId: String) async throws -> UsageResponse {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")! let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
let req = await claudeAPIRequest(for: url) if status == 401 || status == 403 { throw AppError.notAuthenticated }
let (data, response) = try await URLSession.shared.data(for: req) guard status == 200 else { throw AppError.networkError }
guard let http = response as? HTTPURLResponse else { throw AppError.networkError } guard let data = body.data(using: .utf8) else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data) return try JSONDecoder().decode(UsageResponse.self, from: data)
} }
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? { private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")! guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
let req = await claudeAPIRequest(for: url) status == 200,
let (data, response) = try await URLSession.shared.data(for: req) let data = body.data(using: .utf8) else { return nil }
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data) return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
} }
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? { private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")! guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
let req = await claudeAPIRequest(for: url) status == 200,
let (data, response) = try await URLSession.shared.data(for: req) let data = body.data(using: .utf8) else { return nil }
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data) return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
} }
@@ -337,6 +350,22 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - API WebView Delegate
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
var onDidFinish: (() -> Void)?
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let cb = onDidFinish; onDidFinish = nil; cb?()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
let cb = onDidFinish; onDidFinish = nil; cb?()
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
let cb = onDidFinish; onDidFinish = nil; cb?()
}
}
enum AppError: LocalizedError { enum AppError: LocalizedError {
case notAuthenticated case notAuthenticated
case networkError case networkError
+1 -1
View File
@@ -10,7 +10,7 @@
[![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) [![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org)
[![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) [![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.7-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.7) <!-- BETA_BADGE --> [![Beta](https://img.shields.io/badge/beta-1.2.1--beta.11-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.11) <!-- BETA_BADGE -->
</div> </div>
+3 -3
View File
@@ -1,12 +1,12 @@
## What's new in v1.2.1 ## What's new in v1.2.1
### Bug fixes ### Bug fixes
- Fixed usage data not loading after sign-in — API calls now run inside a persistent background WebView using the page's own fetch(), so all credentials (cookies, localStorage tokens, etc.) are included automatically
- Fixed "Not signed in" showing after login — the background WebView is now reloaded after sign-in to pick up the new session before the first data refresh
- Fixed "Not signed in" showing incorrectly on launch when the session was already active - Fixed "Not signed in" showing incorrectly on launch when the session was already active
- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes - Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes
- Fixed login window auto-closing before the user could sign in — the login window now correctly loads the `/login` page so it only detects auth after the actual sign-in redirect - Fixed login window auto-closing before the user could sign in — the login window now correctly loads the `/login` page so it only detects auth after the actual sign-in redirect
- Fixed "No API key configured" showing after signing out — now correctly shows "Not signed in" with a prompt to sign in - Fixed "No API key configured" showing after signing out — now correctly shows "Not signed in" with a prompt to sign in
- Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it - Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it
- Fixed usage data not loading — API requests now include required browser-like headers (Origin, Referer, User-Agent)
- Fixed Settings incorrectly showing "Signed in" after a failed refresh — sign-in state now resets when authentication fails - Fixed Settings incorrectly showing "Signed in" after a failed refresh — sign-in state now resets when authentication fails
- Rewrote login detection to use the WebView's own fetch call instead of inspecting cookie domains — correctly detects auth regardless of which domain the session token is stored on - Rewrote login detection to use KVO on the WebView URL — correctly detects auth for Next.js SPA navigation (history.pushState) that doesn't trigger didFinish
- Fixed API requests not including session cookies — now sends all cookies from the app's WebView store rather than filtering by domain
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.2.1-beta.7", "version": "1.2.1-beta.11",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.7/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.11/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed \"Not signed in\" showing incorrectly on launch when the session was already active\n- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes\n- Fixed login window auto-closing before the user could sign in — the login window now correctly loads the `/login` page so it only detects auth after the actual sign-in redirect\n- Fixed \"No API key configured\" showing after signing out — now correctly shows \"Not signed in\" with a prompt to sign in\n- Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it\n- Fixed usage data not loading — API requests now include required browser-like headers (Origin, Referer, User-Agent)\n- Fixed sign-in detection and cookie handling for accounts whose session cookies are on the `anthropic.com` domain rather than `claude.ai`\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh — sign-in state now resets when authentication fails" "notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed \"Not signed in\" showing incorrectly on launch when the session was already active\n- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes\n- Fixed login window auto-closing before the user could sign in — the login window now correctly loads the `/login` page so it only detects auth after the actual sign-in redirect\n- Fixed \"No API key configured\" showing after signing out — now correctly shows \"Not signed in\" with a prompt to sign in\n- Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it\n- Fixed usage data not loading — API requests now include required browser-like headers (Origin, Referer, User-Agent)\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh — sign-in state now resets when authentication fails\n- Rewrote login detection to use the WebView's own fetch call instead of inspecting cookie domains — correctly detects auth regardless of which domain the session token is stored on\n- Fixed API requests not including session cookies — now sends all cookies from the app's WebView store rather than filtering by domain"
} }