Compare commits

...
5 changed files with 142 additions and 17 deletions
+22 -2
View File
@@ -48,6 +48,7 @@ struct DiagnosticsView: View {
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
DiagRow("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
DiagRow("Error", vm.errorMessage ?? "(none)")
}
@@ -65,9 +66,24 @@ struct DiagnosticsView: View {
Divider()
// Bootstrap body
if !vm.diagBootstrapBody.isEmpty {
DiagSection(title: "Bootstrap Response (first 500 chars)") {
Text(vm.diagBootstrapBody)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.primary.opacity(0.04))
.cornerRadius(5)
}
Divider()
}
// Response body
if !vm.diagLastBody.isEmpty {
DiagSection(title: "Response Body (first 500 chars)") {
DiagSection(title: "Last API Response (first 500 chars)") {
Text(vm.diagLastBody)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
@@ -164,6 +180,7 @@ struct DiagnosticsView: View {
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
lines.append("lastActiveOrg: \(vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)")
lines.append("Error: \(vm.errorMessage ?? "(none)")")
lines.append("")
lines.append("Last path: \(vm.diagLastPath)")
@@ -173,7 +190,10 @@ struct DiagnosticsView: View {
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
lines.append("")
lines.append("Response body:")
lines.append("Bootstrap body:")
lines.append(vm.diagBootstrapBody)
lines.append("")
lines.append("Last API response:")
lines.append(vm.diagLastBody)
lines.append("")
lines.append("Live cookies:")
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2.1-beta.22</string>
<string>1.2.1-beta.24</string>
<key>CFBundleVersion</key>
<string>70</string>
<string>72</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
+115 -10
View File
@@ -30,7 +30,11 @@ class UsageViewModel: ObservableObject {
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
// Diagnostics populated on every urlFetch call
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
private var apiWebView: WKWebView?
private var navWaiter: WKNavWaiter?
// Diagnostics
@Published var diagCookieCount: Int = 0
@Published var diagClaudeCookieCount: Int = 0
@Published var diagCookieDomains: [String] = []
@@ -38,6 +42,8 @@ class UsageViewModel: ObservableObject {
@Published var diagLastStatus: Int = 0
@Published var diagLastBody: String = ""
@Published var diagLastError: String = ""
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
@Published var diagBootstrapBody: String = "" // raw bootstrap response (first 500 chars)
@Published var diagLastFetch: Date? = nil
init() {
@@ -51,17 +57,79 @@ class UsageViewModel: ObservableObject {
Task { await checkInitialSignInState() }
}
// Called after login: poll until session cookies appear (they may commit slightly
// after the auth redirect fires), then refresh.
// Called after login: store the webview (already on claude.ai) so jsFetch can use it.
func adoptAndRefresh(_ loginWebView: WKWebView) async {
for _ in 0..<30 {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
try? await Task.sleep(nanoseconds: 300_000_000)
}
if loginWebView.url?.host?.hasSuffix("claude.ai") == true {
apiWebView = loginWebView
} else {
await prepareApiWebView()
}
await refresh()
}
// Creates (or reuses) a background WKWebView navigated to claude.ai for JS fetch.
private func prepareApiWebView() async {
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true { return }
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
apiWebView = wv
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let waiter = WKNavWaiter { cont.resume() }
self.navWaiter = waiter
wv.navigationDelegate = waiter
wv.load(URLRequest(url: URL(string: "https://claude.ai/")!))
}
wv.navigationDelegate = nil
navWaiter = nil
}
// Runs a fetch() inside the live claude.ai WebView same browser context as the frontend,
// so no CORS/fingerprinting issues that URLSession hits.
private func jsFetch(_ path: String) async throws -> (Int, String) {
guard let wv = apiWebView else { throw AppError.networkError }
let js = """
const r = await fetch('\(path)', {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
const body = await r.text();
return {status: r.status, body: body};
"""
let result: Any? = try await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
switch res {
case .success(let val): cont.resume(returning: val)
case .failure(let err): cont.resume(throwing: err)
}
}
}
guard let dict = result as? [String: Any],
let status = dict["status"] as? Int,
let body = dict["body"] as? String else {
throw AppError.networkError
}
diagLastPath = path
diagLastFetch = Date()
diagLastStatus = status
diagLastBody = String(body.prefix(500))
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
return (status, body)
}
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
private func apiFetch(_ path: String) async throws -> (Int, String) {
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
return try await jsFetch(path)
}
return try await urlFetch(path)
}
// Fetches an API path via URLSession with browser-like headers.
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
// to avoid 401 matching how the Windows version (HttpClient) handles this.
@@ -128,7 +196,10 @@ class UsageViewModel: ObservableObject {
private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true }
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
isSignedIn = true
await prepareApiWebView()
await refresh()
}
func signOut() async {
@@ -138,6 +209,7 @@ class UsageViewModel: ObservableObject {
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
apiWebView = nil
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -155,10 +227,31 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false }
do {
// Read lastActiveOrg cookie from JS most reliable org source since it's
// set by the claude.ai frontend to whichever org is currently active.
var cookieOrgId: String? = nil
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
let js = """
return document.cookie.split(';')
.map(c => c.trim().split('='))
.filter(p => p[0] === 'lastActiveOrg')
.map(p => p.slice(1).join('='))[0] || null;
"""
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
cont.resume(returning: (try? r.get()) ?? nil)
}
}
if let v = raw as? String, !v.isEmpty {
cookieOrgId = v
diagLastActiveOrg = v
}
}
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
let orgId: String
if let id = fetchedOrgId {
if let id = cookieOrgId ?? fetchedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
orgId = id
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
@@ -214,7 +307,7 @@ class UsageViewModel: ObservableObject {
// MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await urlFetch("/api/bootstrap")
let (status, body) = try await apiFetch("/api/bootstrap")
if status == 401 || status == 403 {
throw AppError.detail("HTTP \(status)\(body.prefix(120))")
}
@@ -222,6 +315,7 @@ class UsageViewModel: ObservableObject {
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
}
diagBootstrapBody = String(body.prefix(500))
guard let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil)
@@ -236,7 +330,7 @@ class UsageViewModel: ObservableObject {
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
}
if orgId == nil {
if let (orgsStatus, orgsBody) = try? await urlFetch("/api/organizations"),
if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
orgsStatus == 200,
let orgsData = orgsBody.data(using: .utf8),
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
@@ -259,7 +353,7 @@ class UsageViewModel: ObservableObject {
// MARK: - Fetch usage
private func fetchUsage(orgId: String) async throws -> UsageResponse {
let (status, body) = try await urlFetch("/api/organizations/\(orgId)/usage")
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
@@ -267,14 +361,14 @@ class UsageViewModel: ObservableObject {
}
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/prepaid/credits"),
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
}
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/overage_spend_limit"),
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
@@ -394,6 +488,17 @@ class UsageViewModel: ObservableObject {
}
// Navigation delegate that resolves a continuation on first finish/error (idempotent).
private class WKNavWaiter: NSObject, WKNavigationDelegate {
private var done = false
private let onDone: () -> Void
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
private func finish() { guard !done else { return }; done = true; onDone() }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
}
enum AppError: LocalizedError {
case notAuthenticated
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)
[![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)
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.21-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.21) <!-- BETA_BADGE -->
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.23-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.23) <!-- BETA_BADGE -->
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.2.1-beta.21",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.21/ClaudeChecker.zip",
"version": "1.2.1-beta.23",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.23/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1-beta.18\n\n### Bug fixes\n- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.\n- API calls are now sequential to share a single WebView for all navigations\n- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser\n\n## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls\n- Added diagnostic error messages for JS errors and unexpected responses\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow\n- Fixed login window auto-closing before sign-in completes\n- Added /api/organizations as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
}