Compare commits

..
Author SHA1 Message Date
SuperDooper e13d3d8ee8 Bump version to 1.3.3-beta.5 (build 81) 2026-05-11 21:12:49 +02:00
SuperDooper a729a37efc Diagnostics: fix Swift string interpolation compile errors in fullDiagText 2026-05-11 21:12:41 +02:00
SuperDooper c7e4c81b9a Windows beta badge win-v0.0.1-beta.55 2026-05-11 19:10:52 +00:00
SuperDooper 2313c4378a Windows beta release win-v0.0.1-beta.55 2026-05-11 19:10:46 +00:00
SuperDooper c51086c119 Bump version to 1.3.3-beta.4 (build 80) 2026-05-11 21:08:03 +02:00
SuperDooper 5840b34b3b Diagnostics: update privacy note, fix last error (none), add missing copy fields 2026-05-11 21:07:50 +02:00
SuperDooper 5a141095e1 Diagnostics: show (none) for empty last error in copy 2026-05-11 21:06:49 +02:00
SuperDooper 4b42a631be Diagnostics: add Org ID, lastActiveOrg, and Time to copied text 2026-05-11 21:05:12 +02:00
SuperDooper c6d2ed8261 CI: retry push on rebase conflict in beta workflow 2026-05-11 20:58:13 +02:00
SuperDooper 747e26e5eb Beta release v1.3.3-beta.3 2026-05-11 20:58:10 +02:00
SuperDooper b9fd5a27ec Beta release v1.3.3-beta.3 2026-05-11 20:58:09 +02:00
SuperDooper 60336cffa7 Windows beta badge win-v0.0.1-beta.54 2026-05-11 18:55:00 +00:00
SuperDooper 953c46077c Windows beta release win-v0.0.1-beta.54 2026-05-11 18:54:58 +00:00
SuperDooper 3796139178 Bump version to 1.3.3-beta.3 (build 79) 2026-05-11 20:53:46 +02:00
SuperDooper 554eb80c0c Diagnostics: remove cookie stats from copied text 2026-05-11 20:51:31 +02:00
SuperDooper a22bf3a309 Diagnostics: remove stale Cookie Store section 2026-05-11 20:51:10 +02:00
SuperDooper eaab3eb539 Windows beta badge win-v0.0.1-beta.53 2026-05-11 18:51:04 +00:00
SuperDooper 323aa174da Windows beta release win-v0.0.1-beta.53 2026-05-11 18:51:02 +00:00
github-actions[bot] 3803cf37f6 Beta release v1.3.3-beta.2 2026-05-11 18:50:53 +00:00
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
SuperDooper 84bdfbd786 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:50 +02:00
SuperDooper beba7586f9 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:48 +02:00
SuperDooper a4dff4bde9 beta.21: add DiagnosticsView.swift 2026-05-11 12:40:47 +02:00
SuperDooper d0b33e8f43 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:38 +02:00
SuperDooper 6a5a3c9cd1 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:36 +02:00
github-actions[bot] 15e225439b Beta release v1.2.1-beta.20 2026-05-11 10:33:30 +00:00
SuperDooper 91829b0ed9 beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:34 +02:00
SuperDooper caffc996ff beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:33 +02:00
github-actions[bot] cd13a6d124 Beta release v1.2.1-beta.19 2026-05-11 10:22:52 +00:00
SuperDooper 566258e9c8 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:14 +02:00
SuperDooper dc2a8e2307 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:13 +02:00
14 changed files with 505 additions and 175 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**
+5 -3
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']
@@ -75,5 +75,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add version-beta.json README.md git add version-beta.json README.md
git commit -m "Beta release ${{ github.ref_name }}" git commit -m "Beta release ${{ github.ref_name }}"
git pull --rebase origin main for i in 1 2 3 4 5; do
git push origin HEAD:main git pull --rebase origin main && git push origin HEAD:main && break
echo "Push attempt $i failed, retrying..."; sleep 3
done
+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)
" "
+20
View File
@@ -10,6 +10,13 @@
<string>77</string> <string>77</string>
<key>objects</key> <key>objects</key>
<dict> <dict>
<key>AAAAAA009</key>
<dict>
<key>isa</key>
<string>PBXBuildFile</string>
<key>fileRef</key>
<string>AAAAAA109</string>
</dict>
<key>AAAAAA007</key> <key>AAAAAA007</key>
<dict> <dict>
<key>isa</key> <key>isa</key>
@@ -88,6 +95,17 @@
<key>sourceTree</key> <key>sourceTree</key>
<string>&lt;group&gt;</string> <string>&lt;group&gt;</string>
</dict> </dict>
<key>AAAAAA109</key>
<dict>
<key>isa</key>
<string>PBXFileReference</string>
<key>lastKnownFileType</key>
<string>sourcecode.swift</string>
<key>path</key>
<string>DiagnosticsView.swift</string>
<key>sourceTree</key>
<string>&lt;group&gt;</string>
</dict>
<key>AAAAAA101</key> <key>AAAAAA101</key>
<dict> <dict>
<key>isa</key> <key>isa</key>
@@ -179,6 +197,7 @@
<string>AAAAAA106</string> <string>AAAAAA106</string>
<string>AAAAAA107</string> <string>AAAAAA107</string>
<string>AAAAAA108</string> <string>AAAAAA108</string>
<string>AAAAAA109</string>
</array> </array>
<key>path</key> <key>path</key>
<string>ClaudeChecker</string> <string>ClaudeChecker</string>
@@ -292,6 +311,7 @@
<string>AAAAAA005</string> <string>AAAAAA005</string>
<string>AAAAAA007</string> <string>AAAAAA007</string>
<string>AAAAAA008</string> <string>AAAAAA008</string>
<string>AAAAAA009</string>
</array> </array>
<key>runOnlyForDeploymentPostprocessing</key> <key>runOnlyForDeploymentPostprocessing</key>
<string>0</string> <string>0</string>
+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() }
}
+18
View File
@@ -584,6 +584,7 @@ struct SettingsView: View {
@Binding var showSettings: Bool @Binding var showSettings: Bool
@Binding var showUpdateSheet: Bool @Binding var showUpdateSheet: Bool
@State private var checkingForUpdates = false @State private var checkingForUpdates = false
@State private var showDiagnostics = false
let refreshOptions: [(label: String, seconds: TimeInterval)] = [ let refreshOptions: [(label: String, seconds: TimeInterval)] = [
("1 min", 60), ("1 min", 60),
@@ -770,6 +771,23 @@ struct SettingsView: View {
Divider() Divider()
// Diagnostics
HStack {
Label("Diagnostics", systemImage: "stethoscope")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
Spacer()
Button("Open") { showDiagnostics = true }
.buttonStyle(.bordered)
.controlSize(.small)
}
.sheet(isPresented: $showDiagnostics) {
DiagnosticsView(isPresented: $showDiagnostics)
.environmentObject(vm)
}
Divider()
// About // About
HStack(spacing: 12) { HStack(spacing: 12) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
+219
View File
@@ -0,0 +1,219 @@
import SwiftUI
import WebKit
struct DiagnosticsView: View {
@EnvironmentObject var vm: UsageViewModel
@Binding var isPresented: Bool
@State private var liveAllCookies: [HTTPCookie] = []
@State private var loadingCookies = false
@State private var copied = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Diagnostics")
.font(.system(size: 13, weight: .semibold))
Spacer()
Button("Copy All") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fullDiagText, forType: .string)
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
.buttonStyle(.bordered)
.controlSize(.small)
if copied {
Text("Copied! (IDs redacted)")
.font(.system(size: 11))
.foregroundColor(.green)
}
Button("Close") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color(nsColor: .windowBackgroundColor))
Divider()
Text("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 {
VStack(alignment: .leading, spacing: 14) {
// App info
DiagSection(title: "App") {
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
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)")
}
Divider()
// Last request
DiagSection(title: "Last Request") {
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
if let t = vm.diagLastFetch {
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
}
}
Divider()
// Live cookie list
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
if loadingCookies {
ProgressView().scaleEffect(0.7)
} else if liveAllCookies.isEmpty {
Text("No cookies found")
.font(.system(size: 11))
.foregroundColor(.secondary)
} else {
let claudeCookies = liveAllCookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
let otherCookies = liveAllCookies.filter {
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
}
if !claudeCookies.isEmpty {
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.orange)
ForEach(claudeCookies, id: \.name) { c in
Text("\(c.domain) \(c.name)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
}
}
if !otherCookies.isEmpty {
Text("Other domains (\(otherCookies.count))")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
.padding(.top, 4)
ForEach(otherCookies.prefix(10), id: \.name) { c in
Text("\(c.domain) \(c.name)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Color.secondary.opacity(0.6))
}
if otherCookies.count > 10 {
Text("… and \(otherCookies.count - 10) more")
.font(.system(size: 10))
.foregroundColor(Color.secondary.opacity(0.5))
}
}
}
Button("Refresh cookies") { loadLiveCookies() }
.buttonStyle(.bordered)
.controlSize(.small)
.padding(.top, 4)
}
}
.padding(14)
}
}
.frame(width: 480, height: 560)
.onAppear { loadLiveCookies() }
}
private func loadLiveCookies() {
loadingCookies = true
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
DispatchQueue.main.async {
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
loadingCookies = false
}
}
}
private var fullDiagText: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
let orgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)"
let lastOrg = vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg
let lastErr = vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError
var lines: [String] = []
lines.append("=== ClaudeChecker Diagnostics ===")
lines.append("Version: \(version) (\(build))")
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
lines.append("Org ID: \(orgId)")
lines.append("lastActiveOrg: \(lastOrg)")
lines.append("Error: \(vm.errorMessage ?? "(none)")")
lines.append("")
lines.append("Last path: \(vm.diagLastPath)")
lines.append("Last status: \(vm.diagLastStatus)")
lines.append("Last error: \(lastErr)")
if let t = vm.diagLastFetch {
lines.append("Time: \(t.formatted(date: .omitted, time: .standard))")
}
lines.append("")
lines.append("")
lines.append("Live cookies:")
for c in liveAllCookies {
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
}
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: "****")
}
}
// MARK: - Helpers
private struct DiagSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
content()
}
}
}
private struct DiagRow: View {
let label: String
let value: String
init(_ label: String, _ value: String) {
self.label = label
self.value = value
}
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)
.frame(width: 100, alignment: .leading)
Text(value)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
+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.18</string> <string>1.3.3-beta.5</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>66</string> <string>81</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+176 -118
View File
@@ -30,11 +30,18 @@ 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. // WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
// Hosted in a hidden NSWindow to keep the WebKit process active.
private var apiWebView: WKWebView? private var apiWebView: WKWebView?
private var apiWindow: NSWindow?
private var currentFetchDelegate: APIFetchDelegate? // Diagnostics
@Published var diagCookieCount: Int = 0
@Published var diagClaudeCookieCount: Int = 0
@Published var diagCookieDomains: [String] = []
@Published var diagLastPath: String = ""
@Published var diagLastStatus: Int = 0
@Published var diagLastError: String = ""
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
@Published var diagLastFetch: Date? = nil
init() { init() {
let saved = UserDefaults.standard.double(forKey: "refresh_interval") let saved = UserDefaults.standard.double(forKey: "refresh_interval")
@@ -44,59 +51,136 @@ class UsageViewModel: ObservableObject {
burnHistoryStore = saved burnHistoryStore = saved
} }
loadPlaceholderData() loadPlaceholderData()
setupAPIWebView()
Task { await checkInitialSignInState() } Task { await checkInitialSignInState() }
} }
// MARK: - Background API WebView // Called by AppDelegate once the primer WebView has loaded claude.ai.
// Sets it as the API WebView and triggers the first data fetch.
private func setupAPIWebView() { func adoptPrimerWebView(_ wv: WKWebView) async {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
apiWebView = wv 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() await refresh()
} }
// Fetches an API path by navigating the WebView to the URL and reading the response. // Called after login: store the login WebView (already on claude.ai) so jsFetch can use it.
// Navigation lets WebKit send full browser headers and cookies automatically func adoptAndRefresh(_ loginWebView: WKWebView) async {
// more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors. for _ in 0..<30 {
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) { let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
guard let wv = apiWebView else { throw AppError.detail("no api webview") } if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
return try await withCheckedThrowingContinuation { cont in try? await Task.sleep(nanoseconds: 300_000_000)
let delegate = APIFetchDelegate(continuation: cont) }
currentFetchDelegate = delegate apiWebView = loginWebView
wv.navigationDelegate = delegate await refresh()
wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!)) }
// 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.
// 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.
private func urlFetch(_ path: String) async throws -> (Int, String) {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
// Record cookie diagnostics
diagCookieCount = cookies.count
diagClaudeCookieCount = claudeCookies.count
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
diagLastPath = path
diagLastFetch = Date()
diagLastError = ""
guard !claudeCookies.isEmpty else {
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
diagLastError = msg
throw AppError.detail(msg)
}
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
req.httpShouldHandleCookies = false
// Build cookie header manually (same format as Windows HttpClient) to avoid
// 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("https://claude.ai", forHTTPHeaderField: "Origin")
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
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")
req.setValue(
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
forHTTPHeaderField: "sec-ch-ua")
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
let session = URLSession(configuration: .ephemeral)
do {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
let body = String(data: data, encoding: .utf8) ?? ""
diagLastStatus = http.statusCode
if http.statusCode != 200 {
diagLastError = "HTTP \(http.statusCode)"
}
return (http.statusCode, body)
} catch let e as AppError { throw e }
catch {
let msg = error.localizedDescription
diagLastError = msg
throw AppError.detail(msg)
} }
} }
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 {
@@ -106,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
@@ -114,7 +199,6 @@ 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 {
@@ -124,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") {
@@ -139,13 +244,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 }
// Sequential each call navigates the shared WebView to the next API URL. async let usageFetch = fetchUsage(orgId: orgId)
let usage = try await fetchUsage(orgId: orgId) async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
let prepaid = try? await fetchPrepaidCredits(orgId: orgId) async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
let overage = try? await 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
lastUpdated = Date() lastUpdated = Date()
isSignedIn = true isSignedIn = true
@@ -182,13 +287,14 @@ 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 webViewFetch("/api/bootstrap") let (status, body) = try await apiFetch("/api/bootstrap")
if status == 401 || status == 403 { throw AppError.notAuthenticated } if status == 401 || status == 403 {
guard status == 200 else { throw AppError.detail("bootstrap \(status): \(body.prefix(80))") } throw AppError.detail("HTTP \(status) \(body.prefix(120))")
// If WebKit followed a redirect to the login page we get HTML instead of JSON. }
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else { guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.notAuthenticated throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
} }
guard let data = body.data(using: .utf8), guard let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
@@ -197,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 webViewFetch("/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]] {
@@ -214,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()
@@ -227,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 webViewFetch("/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 webViewFetch("/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 webViewFetch("/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)
@@ -361,58 +470,7 @@ 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
+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.17-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- 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.3-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.3.3-beta.3) <!-- BETA_BADGE -->
[![Win Beta](https://img.shields.io/badge/Win_Beta-0.0.1--beta.55-blue?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/win-v0.0.1-beta.55) <!-- WIN_BETA_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
+1 -5
View File
@@ -1,5 +1 @@
{ {"version": "1.3.3-beta.3", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.3-beta.3/ClaudeChecker.zip", "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."}
"version": "1.2.1-beta.17",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires\n- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "0.0.1-beta.49", "version": "0.0.1-beta.55",
"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.55/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."
} }