Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91829b0ed9 | ||
|
|
caffc996ff | ||
|
|
cd13a6d124 | ||
|
|
566258e9c8 | ||
|
|
dc2a8e2307 |
@@ -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.18</string>
|
<string>1.2.1-beta.20</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>66</string>
|
<string>68</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ 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 WebKit navigation.
|
|
||||||
// Hosted in a hidden NSWindow to keep the WebKit process active.
|
|
||||||
private var apiWebView: WKWebView?
|
|
||||||
private var apiWindow: NSWindow?
|
|
||||||
private var currentFetchDelegate: APIFetchDelegate?
|
|
||||||
|
|
||||||
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
|
||||||
@@ -44,54 +38,50 @@ class UsageViewModel: ObservableObject {
|
|||||||
burnHistoryStore = saved
|
burnHistoryStore = saved
|
||||||
}
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
setupAPIWebView()
|
|
||||||
Task { await checkInitialSignInState() }
|
Task { await checkInitialSignInState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background API WebView
|
// Called after login: cookies are already in WKWebsiteDataStore.default() —
|
||||||
|
// brief sleep lets the store commit, then refresh uses URLSession.
|
||||||
private func setupAPIWebView() {
|
|
||||||
let config = WKWebViewConfiguration()
|
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
|
||||||
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
|
||||||
apiWebView = wv
|
|
||||||
|
|
||||||
// Hosting in a 1×1 transparent window keeps the WebKit process active.
|
|
||||||
let window = NSWindow(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
|
|
||||||
styleMask: .borderless,
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false)
|
|
||||||
window.alphaValue = 0.0
|
|
||||||
window.ignoresMouseEvents = true
|
|
||||||
window.isReleasedWhenClosed = false
|
|
||||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
|
||||||
window.contentView?.addSubview(wv)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
apiWindow = window
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called after login: adopt the login WebView (proven authenticated) and refresh.
|
|
||||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||||
loginWebView.removeFromSuperview()
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
|
|
||||||
apiWindow?.contentView?.addSubview(loginWebView)
|
|
||||||
apiWebView = loginWebView
|
|
||||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches an API path by navigating the WebView to the URL and reading the response.
|
// Fetches an API path via URLSession with browser-like headers.
|
||||||
// Navigation lets WebKit send full browser headers and cookies automatically —
|
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
||||||
// more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors.
|
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||||
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
|
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
||||||
guard let wv = apiWebView else { throw AppError.detail("no api webview") }
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
return try await withCheckedThrowingContinuation { cont in
|
let claudeCookies = cookies.filter {
|
||||||
let delegate = APIFetchDelegate(continuation: cont)
|
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||||
currentFetchDelegate = delegate
|
|
||||||
wv.navigationDelegate = delegate
|
|
||||||
wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!))
|
|
||||||
}
|
}
|
||||||
|
guard !claudeCookies.isEmpty else { throw AppError.notAuthenticated }
|
||||||
|
|
||||||
|
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
|
||||||
|
req.httpShouldHandleCookies = false
|
||||||
|
if let cookieHeader = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
|
||||||
|
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
||||||
|
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
||||||
|
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
||||||
|
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
||||||
|
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(
|
||||||
|
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
|
||||||
|
forHTTPHeaderField: "sec-ch-ua")
|
||||||
|
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
|
||||||
|
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
return (http.statusCode, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkInitialSignInState() async {
|
private func checkInitialSignInState() async {
|
||||||
@@ -114,7 +104,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
extraUsage = nil
|
extraUsage = nil
|
||||||
prepaidCredits = nil
|
prepaidCredits = nil
|
||||||
overageSpendLimit = nil
|
overageSpendLimit = nil
|
||||||
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -139,13 +128,13 @@ 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 }
|
||||||
|
|
||||||
// Sequential — each call navigates the shared WebView to the next API URL.
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
let usage = try await fetchUsage(orgId: orgId)
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
let prepaid = try? await fetchPrepaidCredits(orgId: orgId)
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
let overage = try? await fetchOverageSpendLimit(orgId: orgId)
|
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
||||||
limits = buildLimits(from: usage)
|
limits = buildLimits(from: usage)
|
||||||
extraUsage = usage.extraUsage
|
extraUsage = usage.extraUsage
|
||||||
prepaidCredits = prepaid
|
prepaidCredits = prepaid
|
||||||
overageSpendLimit = overage
|
overageSpendLimit = overage
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
@@ -183,7 +172,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
// MARK: - Bootstrap
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
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 urlFetch("/api/bootstrap")
|
||||||
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
||||||
guard status == 200 else { throw AppError.detail("bootstrap \(status): \(body.prefix(80))") }
|
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.
|
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
|
||||||
@@ -204,7 +193,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
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 {
|
||||||
if let (orgsStatus, orgsBody) = try? await webViewFetch("/api/organizations"),
|
if let (orgsStatus, orgsBody) = try? await urlFetch("/api/organizations"),
|
||||||
orgsStatus == 200,
|
orgsStatus == 200,
|
||||||
let orgsData = orgsBody.data(using: .utf8),
|
let orgsData = orgsBody.data(using: .utf8),
|
||||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
||||||
@@ -227,7 +216,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
// MARK: - Fetch usage
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
|
let (status, body) = try await urlFetch("/api/organizations/\(orgId)/usage")
|
||||||
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.networkError }
|
||||||
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||||
@@ -235,14 +224,14 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||||
status == 200,
|
status == 200,
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
let data = body.data(using: .utf8) 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? {
|
||||||
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||||
status == 200,
|
status == 200,
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
@@ -361,58 +350,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - API Fetch Delegate
|
|
||||||
|
|
||||||
// Captures the HTTP status code and response body from a WebView navigation.
|
|
||||||
// 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!) {
|
|
||||||
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) {
|
|
||||||
complete(.failure(AppError.detail("nav: \(error.localizedDescription.prefix(80))")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
||||||
complete(.failure(AppError.detail("prov: \(error.localizedDescription.prefix(80))")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError {
|
||||||
case notAuthenticated
|
case notAuthenticated
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
[](https://swift.org)
|
[](https://swift.org)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases)
|
[](https://github.com/superdooper86/claudechecker/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- BETA_BADGE -->
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.19) <!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.17",
|
"version": "1.2.1-beta.19",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.19/ClaudeChecker.zip",
|
||||||
"notes": "## 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 — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires\n- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user