Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 | ||
|
|
fd82177a7d | ||
|
|
112210a99b | ||
|
|
f4a83940b1 | ||
|
|
377888a427 | ||
|
|
e24113a78b | ||
|
|
3baab91f70 |
@@ -116,8 +116,8 @@ struct ContentView: View {
|
||||
showUpdateSheet = true
|
||||
}
|
||||
.sheet(isPresented: $showLogin) {
|
||||
LoginSheetView(isPresented: $showLogin) {
|
||||
Task { await vm.reloadAPIWebViewAndRefresh() }
|
||||
LoginSheetView(isPresented: $showLogin) { webView in
|
||||
Task { await vm.adoptAndRefresh(webView) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showUpdateSheet) {
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.1-beta.16</string>
|
||||
<string>1.2.1-beta.18</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>64</string>
|
||||
<string>66</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -4,7 +4,7 @@ import WebKit
|
||||
// MARK: - Login Web View
|
||||
|
||||
struct LoginWebView: NSViewRepresentable {
|
||||
let onAuthenticated: () -> Void
|
||||
let onAuthenticated: (WKWebView) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
@@ -12,6 +12,7 @@ struct LoginWebView: NSViewRepresentable {
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.webView = webView
|
||||
|
||||
// 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
|
||||
@@ -29,23 +30,24 @@ struct LoginWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
let onAuthenticated: () -> Void
|
||||
let onAuthenticated: (WKWebView) -> Void
|
||||
weak var webView: WKWebView?
|
||||
var didAuthenticate = false
|
||||
var urlObservation: NSKeyValueObservation?
|
||||
|
||||
init(onAuthenticated: @escaping () -> Void) {
|
||||
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
||||
self.onAuthenticated = onAuthenticated
|
||||
}
|
||||
|
||||
func checkCurrentURL(_ url: String?) {
|
||||
guard !didAuthenticate, let url else { return }
|
||||
guard !didAuthenticate, let url, let wv = webView else { return }
|
||||
// Ignore navigations to external OAuth providers (Google, etc.) —
|
||||
// only consider auth complete when we land back on claude.ai/anthropic.com
|
||||
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
|
||||
if url.contains("/login") || url.contains("/auth") { return }
|
||||
didAuthenticate = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.onAuthenticated()
|
||||
self.onAuthenticated(wv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
|
||||
|
||||
struct LoginSheetView: View {
|
||||
@Binding var isPresented: Bool
|
||||
let onDone: () -> Void
|
||||
let onDone: (WKWebView) -> Void
|
||||
@State private var authenticated = false
|
||||
|
||||
var body: some View {
|
||||
@@ -70,12 +72,9 @@ struct LoginSheetView: View {
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer()
|
||||
if authenticated {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
onDone()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button("Done") { isPresented = false }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Cancel") { isPresented = false }
|
||||
.buttonStyle(.bordered)
|
||||
@@ -88,12 +87,13 @@ struct LoginSheetView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
LoginWebView {
|
||||
LoginWebView { webView in
|
||||
authenticated = true
|
||||
// Auto-dismiss and refresh after brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
||||
// then auto-dismiss after a moment so the user sees confirmation.
|
||||
onDone(webView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
isPresented = false
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,11 @@ class UsageViewModel: ObservableObject {
|
||||
private var previousPercents: [String: Double] = [:]
|
||||
private var firedThresholds: [String: Set<Int>] = [:]
|
||||
|
||||
// Background WKWebView for API calls via callAsyncJavaScript.
|
||||
// Hosted in a hidden NSWindow — without a window WebKit suspends the WebView
|
||||
// and JS execution stops working, breaking callAsyncJavaScript.
|
||||
// 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 apiDelegate: APIWebViewDelegate?
|
||||
private var apiWindow: NSWindow?
|
||||
private var apiWebViewLoaded = false
|
||||
private var apiReadyContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var currentFetchDelegate: APIFetchDelegate?
|
||||
|
||||
init() {
|
||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||
@@ -57,18 +54,9 @@ class UsageViewModel: ObservableObject {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||
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
|
||||
apiDelegate = del
|
||||
|
||||
// A WKWebView with no window is suspended by macOS — JS execution won't run.
|
||||
// Hosting it in a 1×1 transparent window keeps WebKit's process alive.
|
||||
// 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,
|
||||
@@ -81,49 +69,29 @@ class UsageViewModel: ObservableObject {
|
||||
window.contentView?.addSubview(wv)
|
||||
window.orderFrontRegardless()
|
||||
apiWindow = window
|
||||
|
||||
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||
}
|
||||
|
||||
private func resumeAPIReadyContinuations() {
|
||||
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: reloads the background WebView to pick up the new session.
|
||||
func reloadAPIWebViewAndRefresh() async {
|
||||
guard let wv = apiWebView else {
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
apiWebViewLoaded = false
|
||||
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||
await waitForAPIWebViewReady()
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
// Called after login: adopt the login WebView (proven authenticated) and refresh.
|
||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||
loginWebView.removeFromSuperview()
|
||||
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()
|
||||
}
|
||||
|
||||
// Executes a same-origin fetch inside the background WebView's page context.
|
||||
// This includes all credentials the page has (cookies, localStorage tokens, etc.),
|
||||
// which URLSession cannot access — hence using callAsyncJavaScript instead.
|
||||
// Fetches an API path by navigating the WebView to the URL and reading the response.
|
||||
// Navigation lets WebKit send full browser headers and cookies automatically —
|
||||
// more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors.
|
||||
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
|
||||
guard let wv = apiWebView else { throw AppError.networkError }
|
||||
await waitForAPIWebViewReady()
|
||||
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)
|
||||
guard let wv = apiWebView else { throw AppError.detail("no api webview") }
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
let delegate = APIFetchDelegate(continuation: cont)
|
||||
currentFetchDelegate = delegate
|
||||
wv.navigationDelegate = delegate
|
||||
wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!))
|
||||
}
|
||||
}
|
||||
|
||||
private func checkInitialSignInState() async {
|
||||
@@ -146,7 +114,6 @@ class UsageViewModel: ObservableObject {
|
||||
extraUsage = nil
|
||||
prepaidCredits = nil
|
||||
overageSpendLimit = nil
|
||||
apiWebViewLoaded = false
|
||||
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||
}
|
||||
|
||||
@@ -172,13 +139,13 @@ class UsageViewModel: ObservableObject {
|
||||
if let email = fetchedEmail { userEmail = email }
|
||||
if let plan = fetchedPlan { planLabel = plan }
|
||||
|
||||
async let usageFetch = fetchUsage(orgId: orgId)
|
||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
||||
// Sequential — each call navigates the shared WebView to the next API URL.
|
||||
let usage = try await fetchUsage(orgId: orgId)
|
||||
let prepaid = try? await fetchPrepaidCredits(orgId: orgId)
|
||||
let overage = try? await fetchOverageSpendLimit(orgId: orgId)
|
||||
limits = buildLimits(from: usage)
|
||||
extraUsage = usage.extraUsage
|
||||
prepaidCredits = prepaid
|
||||
extraUsage = usage.extraUsage
|
||||
prepaidCredits = prepaid
|
||||
overageSpendLimit = overage
|
||||
lastUpdated = Date()
|
||||
isSignedIn = true
|
||||
@@ -218,7 +185,11 @@ class UsageViewModel: ObservableObject {
|
||||
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||
let (status, body) = try await webViewFetch("/api/bootstrap")
|
||||
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),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return (nil, nil, nil)
|
||||
@@ -390,29 +361,68 @@ class UsageViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API WebView Delegate
|
||||
// MARK: - API Fetch Delegate
|
||||
|
||||
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
|
||||
var onNavigationEnd: (() -> Void)?
|
||||
// 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!) {
|
||||
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) {
|
||||
onNavigationEnd?()
|
||||
complete(.failure(AppError.detail("nav: \(error.localizedDescription.prefix(80))")))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
onNavigationEnd?()
|
||||
complete(.failure(AppError.detail("prov: \(error.localizedDescription.prefix(80))")))
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case notAuthenticated
|
||||
case networkError
|
||||
case detail(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||
case .networkError: return "Network error fetching usage data."
|
||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||
case .networkError: return "Network error fetching usage data."
|
||||
case .detail(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[](https://swift.org)
|
||||
[](https://github.com/superdooper86/claudechecker/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.15) <!-- BETA_BADGE -->
|
||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- BETA_BADGE -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
+16
-4
@@ -1,8 +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
|
||||
|
||||
### Bug fixes
|
||||
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
|
||||
- Added diagnostic error messages for JS errors and unexpected responses
|
||||
|
||||
## What's new in v1.2.1
|
||||
|
||||
### 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: 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 — 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
|
||||
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
||||
- Fixed login window auto-closing before sign-in completes
|
||||
- Added /api/organizations as a final fallback for org ID resolution
|
||||
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.2.1-beta.15",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.15/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" errors: URLSession was silently discarding the manually-set Cookie header because `httpShouldHandleCookies` defaults to `true`, which makes URLSession replace it with its own (empty) HTTPCookieStorage — claude.ai session cookies live in WKWebsiteDataStore, not HTTPCookieStorage. Setting `httpShouldHandleCookies = false` ensures the cookies are actually sent.\n- Added browser-like request headers (User-Agent, Origin, Referer) matching what Claude's API expects, consistent with the working Windows implementation\n- Removed background WKWebView complexity added in beta.12–13 — reverted to simple URLSession approach with correct cookie handling\n- Fixed login window auto-closing before the user could sign in — login window loads `/login` so auth is only detected after the actual sign-in redirect\n- Fixed login detection for Next.js SPA navigation using KVO on WebView URL (history.pushState doesn't trigger didFinish)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
|
||||
"version": "1.2.1-beta.17",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user