Compare commits

...
Author SHA1 Message Date
superdooper86 b8826ace9b fix: NSNumber cast and use .page world in callAsyncJavaScript auth check
callAsyncJavaScript returns JS numbers as NSNumber (Double-backed).
'val as? Int' silently returns nil for 200.0, so onAuthenticated never
fired. Fixed with 'val as? NSNumber then .intValue == 200'.

Also switched content world from .defaultClient to .page so the fetch
runs in the same JS context as the loaded page.
2026-05-11 10:22:59 +02:00
github-actions[bot] 2a0de269ff Beta release v1.2.1-beta.8 2026-05-11 08:07:51 +00:00
superdooper86 ac7ffe81af fix: use WebView JS fetch for auth detection; send all cookies to API
Cookie domain filtering was wrong — the session token domain is unknown
and was never found by claude.ai/anthropic.com filters.

LoginView: replace getAllCookies domain check with callAsyncJavaScript
that fetches /api/bootstrap directly from the WebView. The WebView uses
its own full session (all cookies, any domain) so auth is detected
correctly regardless of where the token lives.

UsageViewModel: claudeCookieHeader now sends all cookies from the app's
WKWebsiteDataStore instead of filtering by domain. checkInitialSignInState
likewise checks for any cookie.
2026-05-11 10:06:33 +02:00
github-actions[bot] f28f7b8a5a Beta release v1.2.1-beta.7 2026-05-11 07:51:25 +00:00
superdooper86 aaed64484c fix: include anthropic.com cookies in all auth checks and API requests
Claude session cookies are on anthropic.com, not claude.ai. The login
window was not detecting auth (Cancel stayed, no Done) and API calls
were sent without the actual session token.

- claudeCookieHeader: include anthropic.com cookies so the token is
  sent to the usage/bootstrap endpoints
- checkInitialSignInState: detect anthropic.com cookies on startup
- didFinish in LoginView: fire auth when anthropic.com cookies found
- signOut: clear anthropic.com data alongside claude.ai
- notAuthenticated catch: set isSignedIn = false so Settings stays
  in sync with the main panel
2026-05-11 09:49:57 +02:00
github-actions[bot] 2e3ee350ca Beta release v1.2.1-beta.6 2026-05-11 07:37:40 +00:00
superdooper86 e939bdb88b fix: add browser headers to all API requests to resolve 403 on usage endpoint
Claude's usage/prepaid/overage endpoints require Origin, Referer, and
User-Agent headers to pass CORS/auth checks. Without them, bootstrap
succeeds (more permissive) but usage returns 403 -> 'Not signed in'.

Added claudeAPIRequest(for:) helper that sets all required browser-like
headers on every request. Bootstrap, usage, prepaid, overage, and the
orgs fallback all go through it.
2026-05-11 09:36:35 +02:00
github-actions[bot] 6749c5f158 Beta release v1.2.1-beta.5 2026-05-11 07:27:17 +00:00
superdooper86 de71ec2794 fix: load /login instead of root so premature auth detection is prevented
Loading https://claude.ai as the start URL caused didFinish to fire on
the landing page while stale/tracking cookies were already in
WKWebsiteDataStore. The 'any claude.ai cookie' check then fired
immediately, closing the login sheet before the user could sign in.

Loading /login ensures the URL-guard catches the initial page load and
only checks cookies after the real post-login redirect.
2026-05-11 09:25:45 +02:00
github-actions[bot] ac17ce154f Beta release v1.2.1-beta.4 2026-05-11 06:59:20 +00:00
SuperDooper d2eac62897 chore: bump to v1.2.1-beta.4 2026-05-11 08:58:05 +02:00
SuperDooper 8c64fc50ad fix: checkInitialSignInState uses any claude.ai cookie, not specific names 2026-05-11 08:58:04 +02:00
SuperDooper b2c138f665 fix: detect auth by any claude.ai cookie, not specific cookie names 2026-05-11 08:58:02 +02:00
github-actions[bot] aa6c299e2a Beta release v1.2.1-beta.3 2026-05-10 21:41:28 +00:00
SuperDooper ecbae7d7ca chore: release notes for v1.2.1-beta.3 2026-05-10 23:40:31 +02:00
SuperDooper 37a039e1d3 chore: bump to v1.2.1-beta.3 2026-05-10 23:40:30 +02:00
SuperDooper 8f3ead2f65 fix: replace stale 'No API key configured' with correct signed-out message 2026-05-10 23:40:29 +02:00
github-actions[bot] 734670bb3f Beta release v1.2.1-beta.2 2026-05-10 21:29:37 +00:00
SuperDooper 94430e00ba chore: release notes for v1.2.1-beta.2 2026-05-10 23:28:40 +02:00
SuperDooper 7b0368b001 chore: bump to v1.2.1-beta.2 2026-05-10 23:28:39 +02:00
SuperDooper 4629aeffbc fix: load claude.ai instead of /login so already-signed-in users are detected 2026-05-10 23:28:38 +02:00
github-actions[bot] 2cb4ddfe97 Beta release v1.2.1-beta.1 2026-05-10 21:16:08 +00:00
SuperDooper 73b251a1e9 chore: release notes for v1.2.1-beta.1 2026-05-10 23:14:48 +02:00
SuperDooper 7bab6a0df0 chore: bump to v1.2.1-beta.1 2026-05-10 23:14:47 +02:00
SuperDooper db6380fa66 fix: detect sign-in state from cookies on startup, add orgs API fallback for org ID 2026-05-10 23:14:45 +02:00
github-actions[bot] 47a148fa67 Release v1.2.0 2026-05-08 21:20:18 +00:00
SuperDooper 85ac329d36 chore: release notes for v1.2.0 2026-05-08 23:19:24 +02:00
SuperDooper a0fb6eabcf chore: bump to v1.2.0 2026-05-08 23:19:23 +02:00
github-actions[bot] b778d03323 Beta release v1.1.4-beta.7 2026-05-08 21:11:36 +00:00
SuperDooper 13387888b2 chore: release notes for 1.1.4-beta.7 2026-05-08 23:10:35 +02:00
SuperDooper 44be1649a1 chore: bump to 1.1.4-beta.7 2026-05-08 23:10:34 +02:00
SuperDooper 1160712e1b fix: remove ScrollView from main panel so popover auto-sizes to content 2026-05-08 23:10:22 +02:00
github-actions[bot] e4f55c80ee Beta release v1.1.4-beta.6 2026-05-08 21:05:48 +00:00
SuperDooper 526752dc36 chore: release notes for 1.1.4-beta.6 2026-05-08 23:04:54 +02:00
SuperDooper a2d983b555 chore: bump to 1.1.4-beta.6 2026-05-08 23:04:53 +02:00
SuperDooper 0d807c2285 fix: Session Diary — remove Claude icon/header, split stats left/right 2026-05-08 23:04:52 +02:00
SuperDooper 1c42433145 Windows beta release win-v0.0.1-beta.49 2026-05-08 20:46:42 +00:00
SuperDooper c0ac2e5a66 Windows beta release win-v0.0.1-beta.48 2026-05-08 20:39:17 +00:00
SuperDooper 405e65f9f7 Windows beta release win-v0.0.1-beta.47 2026-05-08 20:28:41 +00:00
SuperDooper 1c7433ebea Windows beta release win-v0.0.1-beta.46 2026-05-08 20:19:52 +00:00
SuperDooper a39ef800e8 Windows beta release win-v0.0.1-beta.45 2026-05-08 20:05:58 +00:00
SuperDooper 76dec8b85d Windows beta release win-v0.0.1-beta.44 2026-05-08 20:00:55 +00:00
SuperDooper 2f04f8581c Windows beta release win-v0.0.1-beta.42 2026-05-08 19:41:45 +00:00
SuperDooper 788c066a1e Windows beta release win-v0.0.1-beta.41 2026-05-08 19:39:07 +00:00
SuperDooper 31280d4ab2 Windows beta release win-v0.0.1-beta.40 2026-05-08 19:33:28 +00:00
SuperDooper 7b1c5bbf8e Windows beta release win-v0.0.1-beta.39 2026-05-08 19:19:11 +00:00
SuperDooper f2ba44bbaa Windows beta release win-v0.0.1-beta.38 2026-05-08 19:07:16 +00:00
SuperDooper de1fffe0e3 Windows beta release win-v0.0.1-beta.37 2026-05-08 19:00:02 +00:00
SuperDooper e80f07d1b9 Windows beta release win-v0.0.1-beta.36 2026-05-08 17:40:07 +00:00
github-actions[bot] e9ca8a7efc Beta release v1.1.4-beta.5 2026-05-08 17:32:10 +00:00
SuperDooper 63ef2005a4 Update release notes for 1.1.4-beta.5 2026-05-08 19:31:20 +02:00
SuperDooper 5ad7bb26f0 Bump version to 1.1.4-beta.5 2026-05-08 19:31:18 +02:00
SuperDooper 132a2a8f8d Consolidate bootstrap into single fetchBootstrap() call 2026-05-08 19:31:06 +02:00
github-actions[bot] debd1c1850 Beta release v1.1.4-beta.4 2026-05-08 17:27:59 +00:00
SuperDooper 9bcbb2ae7d Update release notes for 1.1.4-beta.4 2026-05-08 19:26:58 +02:00
SuperDooper c9384f6424 Bump version to 1.1.4-beta.4 2026-05-08 19:26:57 +02:00
SuperDooper e4693fc18f Fix fetchUserEmail return type and capabilities subscript 2026-05-08 19:26:48 +02:00
SuperDooper 84a8592bae Windows beta release win-v0.0.1-beta.35 2026-05-08 17:24:24 +00:00
SuperDooper bdd2038364 Update release notes for 1.1.4-beta.3 2026-05-08 19:22:51 +02:00
SuperDooper fd61face77 Bump version to 1.1.4-beta.3 2026-05-08 19:22:50 +02:00
SuperDooper 1f58a81a26 Parse plan label from bootstrap on every refresh via fetchUserEmail 2026-05-08 19:22:41 +02:00
github-actions[bot] 7f6da51860 Beta release v1.1.4-beta.2 2026-05-08 17:20:51 +00:00
SuperDooper b49ac23863 Update release notes for 1.1.4-beta.2 2026-05-08 19:19:50 +02:00
SuperDooper 01d268c3e5 Bump version to 1.1.4-beta.2 2026-05-08 19:19:49 +02:00
SuperDooper 871d5245ac Parse plan label from capabilities array instead of plan_type 2026-05-08 19:14:53 +02:00
SuperDooper 0524f9b791 Parse plan label from bootstrap API instead of hardcoding 2026-05-08 19:09:06 +02:00
github-actions[bot] d68d564351 Beta release v1.1.4-beta.1 2026-05-08 15:12:07 +00:00
SuperDooper d55bad01db Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:27 +02:00
SuperDooper 7bc72e240b Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:26 +02:00
SuperDooper d77ed23d09 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:06 +02:00
SuperDooper 6136b0ded0 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:00 +02:00
SuperDooper aba7168448 Windows beta release win-v0.0.1-beta.33 2026-05-08 15:03:42 +00:00
SuperDooper a8c2083397 fix: remove hardcoded orgId from cookie primer URL 2026-05-08 16:54:48 +02:00
SuperDooper ad23446ed4 chore: release notes for v1.1.4-beta.1 2026-05-08 16:52:17 +02:00
SuperDooper d8b75d279c chore: bump version to 1.1.4-beta.1 2026-05-08 16:52:16 +02:00
SuperDooper 2658ff7f6a Windows beta release win-v0.0.1-beta.32 2026-05-08 14:51:18 +00:00
SuperDooper e10d79e5a6 fix: fetch org ID dynamically from bootstrap instead of hardcoding it 2026-05-08 16:49:44 +02:00
SuperDooper 88daa27630 Windows beta release win-v0.0.1-beta.31 2026-05-08 14:47:27 +00:00
SuperDooper b7c4958a74 Windows beta release win-v0.0.1-beta.30 2026-05-08 14:38:16 +00:00
SuperDooper f46b13d967 Windows beta release win-v0.0.1-beta.29 2026-05-08 14:30:44 +00:00
SuperDooper c449882ec6 Windows beta release win-v0.0.1-beta.28 2026-05-08 14:26:00 +00:00
SuperDooper 3c1a2ef1be Windows beta release win-v0.0.1-beta.27 2026-05-08 14:17:00 +00:00
SuperDooper c238db963b Windows beta release win-v0.0.1-beta.25 2026-05-08 14:07:31 +00:00
SuperDooper 63c9a8582d Windows beta release win-v0.0.1-beta.24 2026-05-08 14:02:59 +00:00
SuperDooper 6f256b3308 Windows beta release win-v0.0.1-beta.23 2026-05-08 13:51:22 +00:00
SuperDooper 2761b31a12 Windows beta release win-v0.0.1-beta.22 2026-05-08 13:31:58 +00:00
SuperDooper f9d8abebd5 Windows beta release win-v0.0.1-beta.20 2026-05-08 12:55:50 +00:00
SuperDooper 66ee233b1b Windows beta release win-v0.0.1-beta.19 2026-05-08 12:41:51 +00:00
SuperDooper a12c664bde Windows beta release win-v0.0.1-beta.18 2026-05-08 12:28:14 +00:00
SuperDooper c7251d8785 Windows beta release win-v0.0.1-beta.17 2026-05-08 12:16:57 +00:00
SuperDooper 7a95b100ef Windows beta release win-v0.0.1-beta.16 2026-05-08 12:12:23 +00:00
SuperDooper fb98c69c34 Windows beta release win-v0.0.1-beta.15 2026-05-08 12:06:07 +00:00
SuperDooper 5629c6b61e Windows beta release win-v0.0.1-beta.14 2026-05-08 11:58:49 +00:00
SuperDooper 4709f5b0f5 Windows beta release win-v0.0.1-beta.12 2026-05-08 11:49:10 +00:00
SuperDooper 5f8aeaa6e9 Windows beta release win-v0.0.1-beta.11 2026-05-08 11:35:50 +00:00
SuperDooper 27f1f45109 Windows beta release win-v0.0.1-beta.10 2026-05-08 11:29:15 +00:00
SuperDooper 7a5512cc28 Windows beta release win-v0.0.1-beta.9 2026-05-08 11:17:01 +00:00
SuperDooper 8c89ce4636 Windows beta release win-v0.0.1-beta.8 2026-05-08 11:08:41 +00:00
SuperDooper 3a964dc2f8 Windows beta release win-v0.0.1-beta.7 2026-05-08 11:00:23 +00:00
10 changed files with 148 additions and 104 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default() config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config) cookiePrimerView = WKWebView(frame: .zero, configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!)) cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
// Status item // Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+8 -11
View File
@@ -40,8 +40,7 @@ struct ContentView: View {
.environmentObject(updater) .environmentObject(updater)
.transition(.move(edge: .trailing).combined(with: .opacity)) .transition(.move(edge: .trailing).combined(with: .opacity))
} else { } else {
ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) {
VStack(spacing: 0) {
// Cards grid // Cards grid
if vm.limits.isEmpty { if vm.limits.isEmpty {
EmptyStateView() EmptyStateView()
@@ -96,7 +95,6 @@ struct ContentView: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
}
} }
.transition(.opacity) .transition(.opacity)
@@ -398,12 +396,11 @@ struct DiaryRow: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack { HStack {
AgentIconView(agent: .claude, size: 12) Text("\(totalSaved) samples")
Text("Claude") .font(.system(size: 10.5))
.font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary)
.foregroundColor(color)
Spacer() Spacer()
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h") Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
.font(.system(size: 10.5)) .font(.system(size: 10.5))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -914,12 +911,12 @@ struct ErrorBanner: View {
struct EmptyStateView: View { struct EmptyStateView: View {
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "key.slash") Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 28)) .font(.system(size: 28))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("No API key configured") Text("Not signed in")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
Text("Open Settings to add your Anthropic API key.") Text("Sign in to claude.ai to see your usage.")
.font(.system(size: 11.5)) .font(.system(size: 11.5))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
+2 -6
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.3</string> <string>1.2.1-beta.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>40</string> <string>57</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
@@ -45,7 +45,3 @@
</dict> </dict>
</dict> </dict>
</plist> </plist>
+15 -9
View File
@@ -36,15 +36,21 @@ struct LoginWebView: NSViewRepresentable {
if let url = webView.url?.absoluteString, if let url = webView.url?.absoluteString,
url.contains("/login") || url.contains("/auth") { return } url.contains("/login") || url.contains("/auth") { return }
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in // Ask the WebView itself whether we're authenticated it uses its own
let hasSession = cookies.contains { // session (cookies, localStorage, etc.) so we don't need to know the
$0.domain.contains("claude.ai") && // cookie domain or name.
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token") webView.callAsyncJavaScript(
} "const r = await fetch('/api/bootstrap', {credentials: 'include'}); return r.status;",
guard hasSession, !self.didAuthenticate else { return } arguments: [:], in: nil, in: .page
self.didAuthenticate = true ) { [weak self] result in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { guard let self, !self.didAuthenticate else { return }
self.onAuthenticated() // JS numbers arrive as NSNumber (Double-backed), not Swift Int
if case .success(let val) = result,
let n = val as? NSNumber, n.intValue == 200 {
self.didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.onAuthenticated()
}
} }
} }
} }
+97 -57
View File
@@ -3,7 +3,6 @@ import WebKit
@MainActor @MainActor
class UsageViewModel: ObservableObject { class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = [] @Published var limits: [AgentLimit] = []
@Published var isLoading = false @Published var isLoading = false
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@@ -28,12 +27,10 @@ class UsageViewModel: ObservableObject {
private var burnHistoryStore: [String: [Double]] = [:] private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24 private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:] private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:] private var firedThresholds: [String: Set<Int>] = [:]
init() { init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
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
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
@@ -41,14 +38,21 @@ class UsageViewModel: ObservableObject {
burnHistoryStore = saved burnHistoryStore = saved
} }
loadPlaceholderData() loadPlaceholderData()
Task { await checkInitialSignInState() }
}
private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true }
} }
func signOut() async { func signOut() async {
let store = WKWebsiteDataStore.default() let store = WKWebsiteDataStore.default()
let types = WKWebsiteDataStore.allWebsiteDataTypes() let types = WKWebsiteDataStore.allWebsiteDataTypes()
let records = await store.dataRecords(ofTypes: types) let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") } let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
await store.removeData(ofTypes: types, for: claudeRecords) await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false isSignedIn = false
isNotAuthenticated = true isNotAuthenticated = true
lastUpdated = nil lastUpdated = nil
@@ -66,18 +70,29 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false } defer { isLoading = false }
do { do {
let orgId = cachedOrgId! let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId) let orgId: String
async let overageFetch = fetchOverageSpendLimit(orgId: orgId) if let id = fetchedOrgId {
async let emailFetch = fetchUserEmail() UserDefaults.standard.set(id, forKey: "claude_org_id")
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch) orgId = id
if let email { userEmail = email } } else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
orgId = cached
} else {
throw AppError.notAuthenticated
}
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)
limits = buildLimits(from: usage) limits = buildLimits(from: usage)
extraUsage = usage.extraUsage extraUsage = usage.extraUsage
prepaidCredits = prepaid prepaidCredits = prepaid
overageSpendLimit = overage overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date() lastUpdated = Date()
isSignedIn = true isSignedIn = true
@@ -93,6 +108,7 @@ class UsageViewModel: ObservableObject {
checkLimitNotifications(for: limits) checkLimitNotifications(for: limits)
} catch AppError.notAuthenticated { } catch AppError.notAuthenticated {
isNotAuthenticated = true isNotAuthenticated = true
isSignedIn = false
errorMessage = "Not signed in" errorMessage = "Not signed in"
} catch let error as DecodingError { } catch let error as DecodingError {
switch error { switch error {
@@ -110,54 +126,91 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - Fetch usage // MARK: - Bootstrap (org ID + email + plan label in one call)
private func fetchUsage(orgId: String) async throws -> UsageResponse { private func claudeAPIRequest(for url: URL) async -> URLRequest {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept") req.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept")
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")
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app) req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") } req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] { req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
req.setValue(header, forHTTPHeaderField: "Cookie") req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
if let cookie = await claudeCookieHeader() {
req.setValue(cookie, forHTTPHeaderField: "Cookie")
} }
return req
}
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = await claudeAPIRequest(for: url)
guard req.value(forHTTPHeaderField: "Cookie") != nil else { throw AppError.notAuthenticated }
let (data, response) = try await URLSession.shared.data(for: req) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError } guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated } if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError } guard http.statusCode == 200 else { throw AppError.networkError }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil)
}
return try JSONDecoder().decode(UsageResponse.self, from: data) 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 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
if orgId == nil {
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
}
if orgId == nil {
// Final fallback: fetch /api/organizations directly
let orgsReq = await claudeAPIRequest(for: URL(string: "https://claude.ai/api/organizations")!)
if let (orgsData, orgsResp) = try? await URLSession.shared.data(for: orgsReq),
let orgsHttp = orgsResp as? HTTPURLResponse, orgsHttp.statusCode == 200,
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
orgId = orgs.first?["uuid"] as? String
}
}
let email = account?["email_address"] as? String
// plan label from capabilities e.g. "claude_pro" -> "Pro"
var planLabel: String? = nil
if let caps = firstOrg?["capabilities"] as? [String],
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
let name = String(cap.dropFirst("claude_".count))
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
}
return (orgId, email, planLabel)
} }
private func fetchUserEmail() async throws -> String? { // MARK: - Fetch usage
let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = URLRequest(url: url) private func claudeCookieHeader() async -> String? {
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") } guard !cookies.isEmpty else { return nil }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] { // Send all cookies from the app's WebView store the session token may be
req.setValue(header, forHTTPHeaderField: "Cookie") // 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 {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
let req = await claudeAPIRequest(for: url)
let (data, response) = try await URLSession.shared.data(for: req) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
let account = json["account"] as? [String: Any], guard http.statusCode == 200 else { throw AppError.networkError }
let email = account["email_address"] as? String else { return nil } return try JSONDecoder().decode(UsageResponse.self, from: data)
return email
} }
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")! let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
var req = URLRequest(url: url) let req = await claudeAPIRequest(for: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 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)
@@ -165,13 +218,7 @@ class UsageViewModel: ObservableObject {
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")! let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
var req = URLRequest(url: url) let req = await claudeAPIRequest(for: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 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)
@@ -245,14 +292,11 @@ class UsageViewModel: ObservableObject {
private func checkLimitNotifications(for limits: [AgentLimit]) { private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95] let thresholds = [80, 95]
for limit in limits { for limit in limits {
let key = limit.window.rawValue let key = limit.window.rawValue
let curr = limit.usedPercent let curr = limit.usedPercent
let prev = previousPercents[key] let prev = previousPercents[key]
var fired = firedThresholds[key] ?? [] var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds { for t in thresholds {
let threshold = Double(t) let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue } guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
@@ -263,8 +307,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName, "percent": t] userInfo: ["windowName": limit.window.displayName, "percent": t]
) )
} }
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 { if let prev, prev > 50, curr < 20 {
fired.removeAll() fired.removeAll()
NotificationCenter.default.post( NotificationCenter.default.post(
@@ -273,7 +315,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName] userInfo: ["windowName": limit.window.displayName]
) )
} }
firedThresholds[key] = fired firedThresholds[key] = fired
previousPercents[key] = curr previousPercents[key] = curr
} }
@@ -317,4 +358,3 @@ extension Notification.Name {
static let limitReset = Notification.Name("limitReset") static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet") static let openUpdateSheet = Notification.Name("openUpdateSheet")
} }
+2 -2
View File
@@ -8,9 +8,9 @@
[![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/) [![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) [![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.1.3-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) [![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) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
<!-- BETA_BADGE --> [![Beta](https://img.shields.io/badge/beta-1.2.1--beta.8-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.8) <!-- BETA_BADGE -->
</div> </div>
+11 -11
View File
@@ -1,12 +1,12 @@
## What's new in v1.1.3 ## What's new in v1.2.1
### UI polish ### Bug fixes
- Limit headers now read "5 Hour Limit" and "7 Day Limit" - Fixed "Not signed in" showing incorrectly on launch when the session was already active
- Tapping anywhere on the blue update banner opens the update window - Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh 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
### Session Diary fix - Fixed "No API key configured" showing after signing out — now correctly shows "Not signed in" with a prompt to sign in
- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual 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)
### Window sizing fixes - Fixed Settings incorrectly showing "Signed in" after a failed refresh — sign-in state now resets when authentication fails
- The popover now resizes correctly when switching between the main view and settings - 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
- Background colour no longer shows through during the resize animation - Fixed API requests not including session cookies — now sends all cookies from the app's WebView store rather than filtering by domain
+4 -4
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.1.3-beta.5", "version": "1.2.1-beta.8",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3-beta.5/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.8/ClaudeChecker.zip",
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\" instead of \"5hr\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches \u2014 the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation" "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"
} }
+5
View File
@@ -0,0 +1,5 @@
{
"version": "0.0.1-beta.49",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.36\r\n\r\n### Bug fixes\r\n- Settings no longer incorrectly shows \"Not signed in\" when limits are working\r\n- Session Diary now shows correctly after the first successful refresh\r\n- Extra Usage Credits section now restored from cache on startup\r\n- Plan name, overage, and prepaid credits are now cached and shown immediately on launch\r\n- App now loads cached state instantly on startup before the background refresh completes"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.1.3", "version": "1.2.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation" "notes": "## What's new in v1.2.0\n\n### Bug fixes\n- App now works for all users — org ID is fetched dynamically from the API instead of being hardcoded\n- Plan name (e.g. Pro, Max) now updates correctly on every refresh\n\n### Improvements\n- Plan name is read from the API rather than hardcoded\n- Bootstrap API call consolidated — org ID, email, and plan name fetched in a single request per refresh\n- Main panel no longer scrolls — popover auto-sizes to fit content\n- Session Diary card redesigned — sample count and avg burn rate shown left/right above the sparkline, Claude header removed"
} }