Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
566258e9c8 | ||
|
|
dc2a8e2307 | ||
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 |
@@ -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.17</string>
|
<string>1.2.1-beta.19</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>65</string>
|
<string>67</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -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 -> (Int, 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,13 +139,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 }
|
||||||
|
|
||||||
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
|
||||||
overageSpendLimit = overage
|
overageSpendLimit = overage
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
@@ -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))")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.16) <!-- BETA_BADGE -->
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -1,14 +1,20 @@
|
|||||||
|
## What's new in v1.2.1-beta.18
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
- 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.
|
||||||
|
- API calls are now sequential to share a single WebView for all navigations
|
||||||
|
- 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
|
||||||
|
|
||||||
## What's new in v1.2.1-beta.17
|
## What's new in v1.2.1-beta.17
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
- 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
|
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
|
||||||
- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging
|
- Added diagnostic error messages for JS errors and unexpected responses
|
||||||
|
|
||||||
## What's new in v1.2.1
|
## What's new in v1.2.1
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
- 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.
|
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
||||||
- 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.
|
- Fixed login window auto-closing before sign-in completes
|
||||||
- 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)
|
- Added /api/organizations as a final fallback for org ID resolution
|
||||||
- Added `/api/organizations` as a final fallback for org ID resolution
|
|
||||||
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.16",
|
"version": "1.2.1-beta.17",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.16/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/ClaudeChecker.zip",
|
||||||
"notes": "## 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.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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user