Compare commits

...
Author SHA1 Message Date
SuperDooper 1977b51b74 Bump version to 1.3.3-beta.2 (build 78) 2026-05-11 20:49:57 +02:00
SuperDooper 64a3f15ac5 Diagnostics: remove email from copied text 2026-05-11 20:48:46 +02:00
SuperDooper 8fbd1c42c3 Diagnostics: remove email from copy output, add privacy note 2026-05-11 20:48:21 +02:00
SuperDooper 0bf19c3dc8 README: collapse Win Stable placeholder inline to fix 3-row badge layout 2026-05-11 20:46:50 +02:00
SuperDooper d56a714bb9 README: add Win Stable badge placeholder 2026-05-11 20:44:55 +02:00
SuperDooper 88f409971a CI: update macOS beta badge label to macOS_Beta 2026-05-11 20:43:55 +02:00
SuperDooper 9bac2540e8 CI: update stable badge regex for macOS_Stable label 2026-05-11 20:43:51 +02:00
SuperDooper 792c5a26e9 README: rename version badges to macOS Stable / macOS Beta / Win Beta 2026-05-11 20:43:48 +02:00
SuperDooper 421727bfb5 README: add Windows platform badge to top line 2026-05-11 20:42:53 +02:00
SuperDooper 21b2905bcf README: add current Windows beta badge 2026-05-11 20:42:13 +02:00
SuperDooper 38d6f2a582 CI: fix BETA_BADGE handling for new badge layout 2026-05-11 20:41:50 +02:00
SuperDooper 7ea5554506 README: add Windows beta badge, move version badges to new line 2026-05-11 20:41:42 +02:00
SuperDooper 87c5992889 Windows beta release win-v0.0.1-beta.52 2026-05-11 18:35:53 +00:00
github-actions[bot] 034e196214 Beta release v1.3.3-beta.1 2026-05-11 18:35:45 +00:00
SuperDooper 2235a627b5 Bump version to 1.3.3-beta.1 (build 77) 2026-05-11 20:34:15 +02:00
SuperDooper d6c138f1a2 Bug template: update diagnostics copy note 2026-05-11 20:28:38 +02:00
SuperDooper 432819639e Diagnostics: remove email, redact IDs on copy 2026-05-11 20:28:12 +02:00
SuperDooper 52db469c13 Remove org ID from diagnostics output 2026-05-11 20:24:56 +02:00
SuperDooper 40c86aaaf2 Windows beta release win-v0.0.1-beta.51 2026-05-11 13:09:31 +00:00
SuperDooper 46b09934d7 Windows beta release win-v0.0.1-beta.50 2026-05-11 12:20:21 +00:00
SuperDooper a79a23ac24 update bug report template: add diagnostics instructions and Windows field 2026-05-11 14:19:06 +02:00
github-actions[bot] 3785629ee4 Release v1.3.2 2026-05-11 12:01:55 +00:00
superdooper86 a338f4e5fe release 1.3.2: anchor primer WebView in hidden window for reliable startup fetch
Every launch now loads claude.ai in a real (off-screen, invisible) NSWindow so
WebKit doesn't throttle JS execution. The nav delegate fires adoptPrimerWebView
once the page loads, replacing the old 1.5 s sleep-then-refresh approach.
2026-05-11 14:01:07 +02:00
github-actions[bot] 88e7d684c0 Release v1.3.1 2026-05-11 11:45:59 +00:00
superdooper86 9af0d6401c release 1.3.1: remove API response bodies from diagnostics panel 2026-05-11 13:45:09 +02:00
github-actions[bot] 7cb2159b4c Release v1.3.0 2026-05-11 11:31:31 +00:00
superdooper86 206e97780d release 1.3.0: multi-org support, WKWebView JS fetch, diagnostics panel 2026-05-11 13:30:09 +02:00
github-actions[bot] 2b79ba4ae0 Beta release v1.2.1-beta.25 2026-05-11 11:24:25 +00:00
superdooper86 60b6c60c0a beta.25: read plan label from active org (lastActiveOrg) not first membership 2026-05-11 13:23:23 +02:00
github-actions[bot] 8140831236 Beta release v1.2.1-beta.24 2026-05-11 11:16:27 +00:00
superdooper86 9ed8916075 beta.24: read lastActiveOrg cookie via JS, expose bootstrap body and lastActiveOrg in diagnostics 2026-05-11 13:15:08 +02:00
github-actions[bot] 2ab2d913ce Beta release v1.2.1-beta.23 2026-05-11 11:07:55 +00:00
superdooper86 30ad89e458 beta.23: use WKWebView JS fetch for all API calls (avoids URLSession 403 fingerprinting) 2026-05-11 13:07:01 +02:00
github-actions[bot] 3e4bfca2df Beta release v1.2.1-beta.22 2026-05-11 10:53:55 +00:00
superdooper86 4378463600 beta.22: manual cookie join, anthropic-client-platform header, ephemeral session, expose usage 403 body 2026-05-11 12:52:46 +02:00
github-actions[bot] 2cfd7d56a1 Beta release v1.2.1-beta.21 2026-05-11 10:41:34 +00:00
12 changed files with 191 additions and 96 deletions
+8 -2
View File
@@ -18,12 +18,18 @@ Steps to reproduce the behaviour:
**Expected behaviour** **Expected behaviour**
What you expected to happen. What you expected to happen.
**Diagnostics**
Open the app → Settings → Diagnostics, then click "Copy All" and paste the output here.
This shows the error, request state, and cookie info. Email and org IDs are automatically redacted when you click Copy All.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Environment** **Environment**
- macOS version: [e.g. Sequoia 15.4] - Platform: [macOS / Windows]
- ClaudeChecker version: [e.g. 1.1.3] - macOS version: [e.g. Sequoia 15.4] *(if applicable)*
- Windows version: [e.g. Windows 11 24H2] *(if applicable)*
- ClaudeChecker version: [e.g. 1.3.2]
- Claude plan: [e.g. Pro, Max] - Claude plan: [e.g. Pro, Max]
**Additional context** **Additional context**
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
run: | run: |
shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g') shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g')
tag="${{ github.ref_name }}" tag="${{ github.ref_name }}"
export BADGE="[![Beta](https://img.shields.io/badge/beta-${shields_version}-orange?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- BETA_BADGE -->" export BADGE="[![macOS Beta](https://img.shields.io/badge/macOS_Beta-${shields_version}-orange?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- BETA_BADGE -->"
python3 -c " python3 -c "
import os import os
badge = os.environ['BADGE'] badge = os.environ['BADGE']
+2 -8
View File
@@ -73,15 +73,9 @@ jobs:
import os, re import os, re
v = os.environ['NEW_VERSION'] v = os.environ['NEW_VERSION']
content = open('README.md').read() content = open('README.md').read()
content = re.sub(r'version-[0-9][^-]*-orange', 'version-' + v + '-orange', content) content = re.sub(r'macOS_Stable-[^-]+-orange', 'macOS_Stable-' + v + '-orange', content)
lines = content.splitlines(keepends=True) lines = content.splitlines(keepends=True)
out = [] out = ['<!-- BETA_BADGE -->' + chr(10) if '<!-- BETA_BADGE -->' in line else line for line in lines]
for line in lines:
if '<!-- BETA_BADGE -->' in line:
continue
out.append(line)
if 'license-MIT-blue' in line:
out.append('<!-- BETA_BADGE -->' + chr(10))
open('README.md', 'w').writelines(out) open('README.md', 'w').writelines(out)
" "
+40 -10
View File
@@ -18,16 +18,43 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var updateManager = UpdateManager() var updateManager = UpdateManager()
var refreshTimer: Timer? var refreshTimer: Timer?
var cookiePrimerView: WKWebView? var cookiePrimerView: WKWebView?
var cookiePrimerWindow: NSWindow?
var primerNavDelegate: PrimerNavDelegate?
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
// Hidden WKWebView to prime the shared cookie store // Background WebView that loads claude.ai on every launch.
// Anchored in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle
// JS execution. When the page finishes loading the nav delegate calls
// adoptPrimerWebView, which sets it as the API WebView and runs the first fetch.
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default() config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config) let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!)) let win = NSWindow(
contentRect: NSRect(x: -9999, y: -9999, width: 1, height: 1),
styleMask: [],
backing: .buffered,
defer: false
)
win.contentView = wv
win.alphaValue = 0
win.orderFront(nil)
cookiePrimerWindow = win
cookiePrimerView = wv
let delegate = PrimerNavDelegate { [weak self] in
guard let self, let wv = self.cookiePrimerView else { return }
Task { @MainActor [weak self] in
guard let self else { return }
await self.usageViewModel.adoptPrimerWebView(wv)
await self.updateManager.checkForUpdates()
}
}
primerNavDelegate = delegate
wv.navigationDelegate = delegate
wv.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)
@@ -134,12 +161,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
// Initial fetch after cookie primer + update check
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
await usageViewModel.refresh()
await updateManager.checkForUpdates()
}
} }
private func setMenubarIcon(button: NSStatusBarButton) { private func setMenubarIcon(button: NSStatusBarButton) {
@@ -240,4 +261,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
// One-shot WKNavigationDelegate: fires onDone on first finish or error, then goes silent.
final class PrimerNavDelegate: NSObject, WKNavigationDelegate {
private var done = false
private let onDone: () -> Void
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
private func finish() { guard !done else { return }; done = true; onDone() }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
}
+19 -22
View File
@@ -24,7 +24,7 @@ struct DiagnosticsView: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
if copied { if copied {
Text("Copied!") Text("Copied! (IDs redacted)")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.green) .foregroundColor(.green)
} }
@@ -38,6 +38,15 @@ struct DiagnosticsView: View {
Divider() Divider()
Text("Email and org IDs are redacted when copied.")
.font(.system(size: 10))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 6)
Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@@ -46,8 +55,8 @@ struct DiagnosticsView: View {
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?") DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No") DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)") DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
DiagRow("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
DiagRow("Error", vm.errorMessage ?? "(none)") DiagRow("Error", vm.errorMessage ?? "(none)")
} }
@@ -65,21 +74,6 @@ struct DiagnosticsView: View {
Divider() Divider()
// Response body
if !vm.diagLastBody.isEmpty {
DiagSection(title: "Response Body (first 500 chars)") {
Text(vm.diagLastBody)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.primary.opacity(0.04))
.cornerRadius(5)
}
Divider()
}
// Cookie summary // Cookie summary
DiagSection(title: "Cookie Store") { DiagSection(title: "Cookie Store") {
DiagRow("Total cookies", "\(vm.diagCookieCount)") DiagRow("Total cookies", "\(vm.diagCookieCount)")
@@ -162,8 +156,6 @@ struct DiagnosticsView: View {
lines.append("=== ClaudeChecker Diagnostics ===") lines.append("=== ClaudeChecker Diagnostics ===")
lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))") lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")") lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
lines.append("Error: \(vm.errorMessage ?? "(none)")") lines.append("Error: \(vm.errorMessage ?? "(none)")")
lines.append("") lines.append("")
lines.append("Last path: \(vm.diagLastPath)") lines.append("Last path: \(vm.diagLastPath)")
@@ -173,14 +165,19 @@ struct DiagnosticsView: View {
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)") lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))") lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
lines.append("") lines.append("")
lines.append("Response body:")
lines.append(vm.diagLastBody)
lines.append("") lines.append("")
lines.append("Live cookies:") lines.append("Live cookies:")
for c in liveAllCookies { for c in liveAllCookies {
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)") lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
} }
return lines.joined(separator: "\n") return redactUUIDs(lines.joined(separator: "\n"))
}
private func redactUUIDs(_ text: String) -> String {
let pattern = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: "****")
} }
} }
+2 -2
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.2.1-beta.21</string> <string>1.3.3-beta.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>69</string> <string>78</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+102 -21
View File
@@ -30,14 +30,17 @@ 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>] = [:]
// Diagnostics populated on every urlFetch call // WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
private var apiWebView: WKWebView?
// Diagnostics
@Published var diagCookieCount: Int = 0 @Published var diagCookieCount: Int = 0
@Published var diagClaudeCookieCount: Int = 0 @Published var diagClaudeCookieCount: Int = 0
@Published var diagCookieDomains: [String] = [] @Published var diagCookieDomains: [String] = []
@Published var diagLastPath: String = "" @Published var diagLastPath: String = ""
@Published var diagLastStatus: Int = 0 @Published var diagLastStatus: Int = 0
@Published var diagLastBody: String = ""
@Published var diagLastError: String = "" @Published var diagLastError: String = ""
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
@Published var diagLastFetch: Date? = nil @Published var diagLastFetch: Date? = nil
init() { init() {
@@ -51,17 +54,64 @@ class UsageViewModel: ObservableObject {
Task { await checkInitialSignInState() } Task { await checkInitialSignInState() }
} }
// Called after login: poll until session cookies appear (they may commit slightly // Called by AppDelegate once the primer WebView has loaded claude.ai.
// after the auth redirect fires), then refresh. // Sets it as the API WebView and triggers the first data fetch.
func adoptPrimerWebView(_ wv: WKWebView) async {
apiWebView = wv
await refresh()
}
// Called after login: store the login WebView (already on claude.ai) so jsFetch can use it.
func adoptAndRefresh(_ loginWebView: WKWebView) async { func adoptAndRefresh(_ loginWebView: WKWebView) async {
for _ in 0..<30 { for _ in 0..<30 {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break } if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
} }
apiWebView = loginWebView
await refresh() await refresh()
} }
// Runs a fetch() inside the live claude.ai WebView same browser context as the frontend,
// so no CORS/fingerprinting issues that URLSession hits.
private func jsFetch(_ path: String) async throws -> (Int, String) {
guard let wv = apiWebView else { throw AppError.networkError }
let js = """
const r = await fetch('\(path)', {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
const body = await r.text();
return {status: r.status, body: body};
"""
let result: Any? = try await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
switch res {
case .success(let val): cont.resume(returning: val)
case .failure(let err): cont.resume(throwing: err)
}
}
}
guard let dict = result as? [String: Any],
let status = dict["status"] as? Int,
let body = dict["body"] as? String else {
throw AppError.networkError
}
diagLastPath = path
diagLastFetch = Date()
diagLastStatus = status
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
return (status, body)
}
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
private func apiFetch(_ path: String) async throws -> (Int, String) {
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
return try await jsFetch(path)
}
return try await urlFetch(path)
}
// Fetches an API path via URLSession with browser-like headers. // Fetches an API path via URLSession with browser-like headers.
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent // Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
// to avoid 401 matching how the Windows version (HttpClient) handles this. // to avoid 401 matching how the Windows version (HttpClient) handles this.
@@ -87,9 +137,10 @@ class UsageViewModel: ObservableObject {
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!) var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
req.httpShouldHandleCookies = false req.httpShouldHandleCookies = false
if let cookieHeader = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] { // Build cookie header manually (same format as Windows HttpClient) to avoid
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie") // any encoding differences from HTTPCookie.requestHeaderFields(with:)
} let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin") req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer") req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
@@ -104,13 +155,14 @@ class UsageViewModel: ObservableObject {
forHTTPHeaderField: "sec-ch-ua") forHTTPHeaderField: "sec-ch-ua")
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile") req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform") req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
let session = URLSession(configuration: .ephemeral)
do { do {
let (data, response) = try await URLSession.shared.data(for: req) let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError } guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
let body = String(data: data, encoding: .utf8) ?? "" let body = String(data: data, encoding: .utf8) ?? ""
diagLastStatus = http.statusCode diagLastStatus = http.statusCode
diagLastBody = String(body.prefix(500))
if http.statusCode != 200 { if http.statusCode != 200 {
diagLastError = "HTTP \(http.statusCode)" diagLastError = "HTTP \(http.statusCode)"
} }
@@ -125,7 +177,10 @@ class UsageViewModel: ObservableObject {
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 } guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
isSignedIn = true
// AppDelegate's primer WebView will call adoptPrimerWebView once it loads,
// which triggers the first real data fetch.
} }
func signOut() async { func signOut() async {
@@ -135,6 +190,7 @@ class UsageViewModel: ObservableObject {
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") } 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") UserDefaults.standard.removeObject(forKey: "claude_org_id")
apiWebView = nil
isSignedIn = false isSignedIn = false
isNotAuthenticated = true isNotAuthenticated = true
lastUpdated = nil lastUpdated = nil
@@ -152,10 +208,31 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false } defer { isLoading = false }
do { do {
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap() // Read lastActiveOrg cookie from JS most reliable org source since it's
// set by the claude.ai frontend to whichever org is currently active.
var cookieOrgId: String? = nil
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
let js = """
return document.cookie.split(';')
.map(c => c.trim().split('='))
.filter(p => p[0] === 'lastActiveOrg')
.map(p => p.slice(1).join('='))[0] || null;
"""
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
cont.resume(returning: (try? r.get()) ?? nil)
}
}
if let v = raw as? String, !v.isEmpty {
cookieOrgId = v
diagLastActiveOrg = v
}
}
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap(preferredOrgId: cookieOrgId)
let orgId: String let orgId: String
if let id = fetchedOrgId { if let id = cookieOrgId ?? fetchedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id") UserDefaults.standard.set(id, forKey: "claude_org_id")
orgId = id orgId = id
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") { } else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
@@ -210,8 +287,8 @@ class UsageViewModel: ObservableObject {
// MARK: - Bootstrap // MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) { private func fetchBootstrap(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await urlFetch("/api/bootstrap") let (status, body) = try await apiFetch("/api/bootstrap")
if status == 401 || status == 403 { if status == 401 || status == 403 {
throw AppError.detail("HTTP \(status)\(body.prefix(120))") throw AppError.detail("HTTP \(status)\(body.prefix(120))")
} }
@@ -226,14 +303,15 @@ class UsageViewModel: ObservableObject {
let account = json["account"] as? [String: Any] let account = json["account"] as? [String: Any]
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 allOrgs = memberships?.compactMap { $0["organization"] as? [String: Any] } ?? []
let firstOrg = allOrgs.first
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 {
if let (orgsStatus, orgsBody) = try? await urlFetch("/api/organizations"), if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
orgsStatus == 200, orgsStatus == 200,
let orgsData = orgsBody.data(using: .utf8), let orgsData = orgsBody.data(using: .utf8),
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] { let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
@@ -243,8 +321,10 @@ class UsageViewModel: ObservableObject {
let email = account?["email_address"] as? String let email = account?["email_address"] as? String
// Read capabilities from the preferred (active) org, falling back to first org.
let capOrg = preferredOrgId.flatMap { id in allOrgs.first(where: { $0["uuid"] as? String == id }) } ?? firstOrg
var planLabel: String? = nil var planLabel: String? = nil
if let caps = firstOrg?["capabilities"] as? [String], if let caps = capOrg?["capabilities"] as? [String],
let cap = caps.first(where: { $0.hasPrefix("claude_") }) { let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
let name = String(cap.dropFirst("claude_".count)) let name = String(cap.dropFirst("claude_".count))
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased() planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
@@ -256,22 +336,22 @@ class UsageViewModel: ObservableObject {
// MARK: - Fetch usage // MARK: - Fetch usage
private func fetchUsage(orgId: String) async throws -> UsageResponse { private func fetchUsage(orgId: String) async throws -> UsageResponse {
let (status, body) = try await urlFetch("/api/organizations/\(orgId)/usage") let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.notAuthenticated } if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
guard status == 200 else { throw AppError.networkError } guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8) else { throw AppError.networkError } guard let data = body.data(using: .utf8) else { throw AppError.networkError }
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? {
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/prepaid/credits"), guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
status == 200, status == 200,
let data = body.data(using: .utf8) else { return nil } let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data) return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
} }
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? { private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/overage_spend_limit"), guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
status == 200, status == 200,
let data = body.data(using: .utf8) else { return nil } let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data) return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
@@ -391,6 +471,7 @@ class UsageViewModel: ObservableObject {
} }
enum AppError: LocalizedError { enum AppError: LocalizedError {
case notAuthenticated case notAuthenticated
case networkError case networkError
+5 -2
View File
@@ -7,10 +7,13 @@
**A native macOS menubar app for monitoring your Claude AI usage limits in real time.** **A native macOS menubar app for monitoring your Claude AI usage limits in real time.**
[![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/)
[![Windows](https://img.shields.io/badge/Windows-10%2B-0078D4?style=flat&logo=windows&logoColor=white)](https://www.microsoft.com/windows/)
[![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.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](https://img.shields.io/badge/beta-1.2.1--beta.20-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.20) <!-- BETA_BADGE --> <br/>
[![macOS Stable](https://img.shields.io/badge/macOS_Stable-1.3.2-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![macOS Beta](https://img.shields.io/badge/macOS_Beta-1.3.3--beta.1-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.3.3-beta.1) <!-- BETA_BADGE -->
[![Win Beta](https://img.shields.io/badge/Win_Beta-0.0.1--beta.52-blue?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/win-v0.0.1-beta.52) <!-- WIN_BETA_BADGE --> <!-- WIN_STABLE_BADGE -->
</div> </div>
+3 -19
View File
@@ -1,20 +1,4 @@
## What's new in v1.2.1-beta.18 ## What's new in v1.3.2
### Bug fixes ### Reliability
- 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. - Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution.
- 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 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
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.2.1-beta.20", "version": "1.3.3-beta.1",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.20/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.3-beta.1/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1-beta.18\n\n### Bug fixes\n- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.\n- API calls are now sequential to share a single WebView for all navigations\n- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser\n\n## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls\n- Added diagnostic error messages for JS errors and unexpected responses\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow\n- Fixed login window auto-closing before sign-in completes\n- Added /api/organizations as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh" "notes": "## What's new in v1.3.2\n\n### Reliability\n- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution."
} }
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "0.0.1-beta.49", "version": "0.0.1-beta.52",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.52/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" "notes": "## What's new in beta.49\r\n\r\n### Architecture\r\n- All data (usage, plan, overage, prepaid credits) now fetched via the persistent background WebView2 — the same always-live session as the macOS app, so Cloudflare cookies never expire between refreshes\r\n- HttpClient path kept only as a brief startup fallback before the browser is ready\r\n- Settings file simplified: only stores session signal, burn history, and user preferences — no more cached API responses\r\n\r\n### Bug fixes\r\n- Plan label (e.g. \"Pro\") now updates on every refresh, not just at login\r\n- Extra Usage Credits values now correct (were 100× too large)\r\n- Limit and balance now update live on every refresh\r\n- Refresh interval dropdown now shows \"1 min\", \"2 min\" etc. correctly\r\n- ComboBox no longer flashes bright blue when clicked\r\n- Buttons now show a visible pressed state\r\n- Footer \"Updated X ago\" counter now ticks every second; shows \"X min, Y sec ago\" past 60 s\r\n- Removed stray sparkline bars from the 5 h and 7 d limit cards\r\n- App window now shown on launch\r\n\r\n### UI improvements\r\n- Gauge percentage larger, \"used\" label removed\r\n- Each limit card now shows an \"after reset\" or \"today HH:MM\" badge\r\n- Session Diary card redesigned to match macOS layout (stats row + sparkline, no Claude header)\r\n- Reset date moved inline with time remaining"
} }
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.2.0", "version": "1.3.2",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.2/ClaudeChecker.zip",
"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" "notes": "## What's new in v1.3.2\n\n### Reliability\n- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution."
} }