Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
566258e9c8 | ||
|
|
dc2a8e2307 | ||
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 | ||
|
|
fd82177a7d | ||
|
|
112210a99b | ||
|
|
f4a83940b1 | ||
|
|
377888a427 | ||
|
|
e24113a78b | ||
|
|
3baab91f70 | ||
|
|
0aa8837b2f | ||
|
|
373ca1dc14 | ||
|
|
7a4c21768f | ||
|
|
7b26629dc7 | ||
|
|
e446ea97f9 | ||
|
|
36af325e2d | ||
|
|
e2ef8b6f2a | ||
|
|
7a4975fa3e | ||
|
|
c9912f8463 | ||
|
|
238614a94b | ||
|
|
b11507221f | ||
|
|
b6fef53264 | ||
|
|
ec6ae6621a | ||
|
|
105a7706fa | ||
|
|
a950392a6f | ||
|
|
8087cc029d | ||
|
|
11f9e0c9b6 | ||
|
|
cc835ee2b0 | ||
|
|
8e7328b2c5 | ||
|
|
709eb12382 | ||
|
|
e866324a48 | ||
|
|
a62584221b | ||
|
|
fc7fd19652 |
@@ -116,8 +116,8 @@ struct ContentView: View {
|
|||||||
showUpdateSheet = true
|
showUpdateSheet = true
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showLogin) {
|
.sheet(isPresented: $showLogin) {
|
||||||
LoginSheetView(isPresented: $showLogin) {
|
LoginSheetView(isPresented: $showLogin) { webView in
|
||||||
Task { await vm.refresh() }
|
Task { await vm.adoptAndRefresh(webView) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showUpdateSheet) {
|
.sheet(isPresented: $showUpdateSheet) {
|
||||||
|
|||||||
@@ -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.11</string>
|
<string>1.2.1-beta.19</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>59</string>
|
<string>67</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import WebKit
|
|||||||
// MARK: - Login Web View
|
// MARK: - Login Web View
|
||||||
|
|
||||||
struct LoginWebView: NSViewRepresentable {
|
struct LoginWebView: NSViewRepresentable {
|
||||||
let onAuthenticated: () -> Void
|
let onAuthenticated: (WKWebView) -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> WKWebView {
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
@@ -12,6 +12,7 @@ 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
|
||||||
|
context.coordinator.webView = webView
|
||||||
|
|
||||||
// KVO on url catches SPA pushState navigations that don't fire didFinish
|
// 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
|
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
|
||||||
@@ -29,20 +30,24 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, WKNavigationDelegate {
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
let onAuthenticated: () -> Void
|
let onAuthenticated: (WKWebView) -> Void
|
||||||
|
weak var webView: WKWebView?
|
||||||
var didAuthenticate = false
|
var didAuthenticate = false
|
||||||
var urlObservation: NSKeyValueObservation?
|
var urlObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
init(onAuthenticated: @escaping () -> Void) {
|
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
||||||
self.onAuthenticated = onAuthenticated
|
self.onAuthenticated = onAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkCurrentURL(_ url: String?) {
|
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 }
|
if url.contains("/login") || url.contains("/auth") { return }
|
||||||
didAuthenticate = true
|
didAuthenticate = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
self.onAuthenticated()
|
self.onAuthenticated(wv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
struct LoginSheetView: View {
|
struct LoginSheetView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let onDone: () -> Void
|
let onDone: (WKWebView) -> Void
|
||||||
@State private var authenticated = false
|
@State private var authenticated = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -67,12 +72,9 @@ struct LoginSheetView: View {
|
|||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
if authenticated {
|
if authenticated {
|
||||||
Button("Done") {
|
Button("Done") { isPresented = false }
|
||||||
isPresented = false
|
.buttonStyle(.borderedProminent)
|
||||||
onDone()
|
.controlSize(.small)
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.small)
|
|
||||||
} else {
|
} else {
|
||||||
Button("Cancel") { isPresented = false }
|
Button("Cancel") { isPresented = false }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -85,12 +87,13 @@ struct LoginSheetView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LoginWebView {
|
LoginWebView { webView in
|
||||||
authenticated = true
|
authenticated = true
|
||||||
// Auto-dismiss and refresh after brief delay
|
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
// then auto-dismiss after a moment so the user sees confirmation.
|
||||||
|
onDone(webView)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||||
isPresented = false
|
isPresented = false
|
||||||
onDone()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ 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
|
||||||
@@ -38,9 +44,56 @@ 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: 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 {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 -> (Int, String) {
|
||||||
|
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 {
|
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 +114,7 @@ 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 {
|
||||||
@@ -85,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
|
||||||
@@ -126,44 +180,33 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap (org ID + email + plan label in one call)
|
// MARK: - Bootstrap
|
||||||
|
|
||||||
private func claudeAPIRequest(for url: URL) async -> URLRequest {
|
|
||||||
var req = URLRequest(url: url)
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
|
||||||
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.detail("bootstrap \(status): \(body.prefix(80))") }
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
throw AppError.notAuthenticated
|
||||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
}
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
guard let data = body.data(using: .utf8),
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -171,7 +214,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_") }) {
|
||||||
@@ -184,37 +226,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +361,68 @@ 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
|
||||||
case networkError
|
case networkError
|
||||||
|
case detail(String)
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||||
case .networkError: return "Network error fetching usage data."
|
case .networkError: return "Network error fetching usage data."
|
||||||
|
case .detail(let msg): return msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.10) <!-- BETA_BADGE -->
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -1,12 +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
|
## What's new in v1.2.1
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
- Fixed "Not signed in" showing incorrectly on launch when the session was already active
|
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
||||||
- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes
|
- Fixed login window auto-closing before sign-in 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
|
- Added /api/organizations as a final fallback for org ID resolution
|
||||||
- Fixed "No API key configured" showing after signing out — now correctly shows "Not signed in" with a prompt to sign in
|
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
||||||
- 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
|
|
||||||
- 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
|
|
||||||
- Fixed API requests not including session cookies — now sends all cookies from the app's WebView store rather than filtering by domain
|
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.10",
|
"version": "1.2.1-beta.17",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.10/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 \"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"
|
"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