Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4378463600 | ||
|
|
2cfd7d56a1 | ||
|
|
84bdfbd786 | ||
|
|
beba7586f9 | ||
|
|
a4dff4bde9 | ||
|
|
d0b33e8f43 | ||
|
|
6a5a3c9cd1 | ||
|
|
15e225439b | ||
|
|
91829b0ed9 | ||
|
|
caffc996ff | ||
|
|
cd13a6d124 | ||
|
|
566258e9c8 | ||
|
|
dc2a8e2307 | ||
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 | ||
|
|
fd82177a7d | ||
|
|
112210a99b | ||
|
|
f4a83940b1 | ||
|
|
377888a427 | ||
|
|
e24113a78b | ||
|
|
3baab91f70 | ||
|
|
0aa8837b2f | ||
|
|
373ca1dc14 | ||
|
|
7a4c21768f | ||
|
|
7b26629dc7 | ||
|
|
e446ea97f9 | ||
|
|
36af325e2d | ||
|
|
e2ef8b6f2a | ||
|
|
7a4975fa3e | ||
|
|
c9912f8463 | ||
|
|
238614a94b | ||
|
|
b11507221f | ||
|
|
b6fef53264 | ||
|
|
ec6ae6621a | ||
|
|
105a7706fa | ||
|
|
a950392a6f | ||
|
|
8087cc029d | ||
|
|
11f9e0c9b6 | ||
|
|
cc835ee2b0 | ||
|
|
8e7328b2c5 | ||
|
|
709eb12382 | ||
|
|
e866324a48 | ||
|
|
a62584221b | ||
|
|
fc7fd19652 | ||
|
|
58b947e515 | ||
|
|
88252e4d59 | ||
|
|
ad2ff3a7b6 | ||
|
|
1a9d9cd22a | ||
|
|
b8826ace9b | ||
|
|
2a0de269ff | ||
|
|
ac7ffe81af | ||
|
|
f28f7b8a5a | ||
|
|
aaed64484c | ||
|
|
2e3ee350ca | ||
|
|
e939bdb88b | ||
|
|
6749c5f158 | ||
|
|
de71ec2794 | ||
|
|
ac17ce154f | ||
|
|
d2eac62897 | ||
|
|
8c64fc50ad | ||
|
|
b2c138f665 | ||
|
|
aa6c299e2a | ||
|
|
ecbae7d7ca | ||
|
|
37a039e1d3 | ||
|
|
8f3ead2f65 | ||
|
|
734670bb3f | ||
|
|
94430e00ba | ||
|
|
7b0368b001 | ||
|
|
4629aeffbc | ||
|
|
2cb4ddfe97 | ||
|
|
73b251a1e9 | ||
|
|
7bab6a0df0 | ||
|
|
db6380fa66 | ||
|
|
47a148fa67 | ||
|
|
85ac329d36 | ||
|
|
a0fb6eabcf | ||
|
|
b778d03323 | ||
|
|
13387888b2 | ||
|
|
44be1649a1 | ||
|
|
1160712e1b | ||
|
|
e4f55c80ee | ||
|
|
526752dc36 | ||
|
|
a2d983b555 | ||
|
|
0d807c2285 | ||
|
|
1c42433145 | ||
|
|
c0ac2e5a66 | ||
|
|
405e65f9f7 | ||
|
|
1c7433ebea | ||
|
|
a39ef800e8 | ||
|
|
76dec8b85d | ||
|
|
2f04f8581c | ||
|
|
788c066a1e | ||
|
|
31280d4ab2 | ||
|
|
7b1c5bbf8e | ||
|
|
f2ba44bbaa | ||
|
|
de1fffe0e3 | ||
|
|
e80f07d1b9 | ||
|
|
e9ca8a7efc | ||
|
|
63ef2005a4 | ||
|
|
5ad7bb26f0 | ||
|
|
132a2a8f8d | ||
|
|
debd1c1850 | ||
|
|
9bcbb2ae7d | ||
|
|
c9384f6424 | ||
|
|
e4693fc18f | ||
|
|
84a8592bae | ||
|
|
bdd2038364 | ||
|
|
fd61face77 | ||
|
|
1f58a81a26 | ||
|
|
7f6da51860 | ||
|
|
b49ac23863 | ||
|
|
01d268c3e5 | ||
|
|
871d5245ac | ||
|
|
0524f9b791 | ||
|
|
d68d564351 | ||
|
|
d55bad01db | ||
|
|
7bc72e240b | ||
|
|
d77ed23d09 | ||
|
|
6136b0ded0 | ||
|
|
aba7168448 | ||
|
|
a8c2083397 | ||
|
|
ad23446ed4 | ||
|
|
d8b75d279c | ||
|
|
2658ff7f6a | ||
|
|
e10d79e5a6 | ||
|
|
88daa27630 | ||
|
|
b7c4958a74 | ||
|
|
f46b13d967 | ||
|
|
c449882ec6 | ||
|
|
3c1a2ef1be | ||
|
|
c238db963b | ||
|
|
63c9a8582d | ||
|
|
6f256b3308 | ||
|
|
2761b31a12 | ||
|
|
f9d8abebd5 | ||
|
|
66ee233b1b | ||
|
|
a12c664bde | ||
|
|
c7251d8785 | ||
|
|
7a95b100ef | ||
|
|
fb98c69c34 | ||
|
|
5629c6b61e | ||
|
|
4709f5b0f5 | ||
|
|
5f8aeaa6e9 | ||
|
|
27f1f45109 | ||
|
|
7a5512cc28 | ||
|
|
8c89ce4636 | ||
|
|
3a964dc2f8 | ||
|
|
b553d3922a | ||
|
|
61e8455e5e | ||
|
|
c4b9d7454f | ||
|
|
75aeb03332 | ||
|
|
f5203a0299 | ||
|
|
fe27e0d4c0 | ||
|
|
fb5e1ece25 | ||
|
|
71666224f1 | ||
|
|
bd693cdf94 | ||
|
|
d24a354e1c | ||
|
|
9e47041715 | ||
|
|
22151f0af4 | ||
|
|
3508fa1a94 | ||
|
|
b2fcafd3f0 | ||
|
|
45215de350 | ||
|
|
f969da8006 | ||
|
|
7f769414cc | ||
|
|
db707340bb | ||
|
|
be6bb1495d | ||
|
|
06755de91d | ||
|
|
2855155361 | ||
|
|
ecfb1aa44d | ||
|
|
8b35e5df56 | ||
|
|
794a6cdb65 | ||
|
|
e613ace503 | ||
|
|
38d7683843 | ||
|
|
802cf14a71 | ||
|
|
ae736a7e4a | ||
|
|
a1bb186af2 | ||
|
|
e93a4e6df5 | ||
|
|
a3fbce3f0e | ||
|
|
4b4280630c | ||
|
|
90acbcb968 | ||
|
|
f2f2be1305 | ||
|
|
f17694622c | ||
|
|
aa10f77776 |
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report something that isn't working correctly
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behaviour:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behaviour**
|
||||
What you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment**
|
||||
- macOS version: [e.g. Sequoia 15.4]
|
||||
- ClaudeChecker version: [e.g. 1.1.3]
|
||||
- Claude plan: [e.g. Pro, Max]
|
||||
|
||||
**Additional context**
|
||||
Any other context about the problem here.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**What problem does this solve?**
|
||||
A clear and concise description of the problem or limitation you're running into.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
What you'd like to happen.
|
||||
|
||||
**Alternatives considered**
|
||||
Any other approaches you've thought about.
|
||||
|
||||
**Screenshots or mockups**
|
||||
If applicable, add visuals to help explain your idea.
|
||||
|
||||
**Additional context**
|
||||
Any other context here.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in ClaudeChecker!
|
||||
|
||||
## Reporting bugs & requesting features
|
||||
|
||||
Please use the [issue templates](https://github.com/superdooper86/claudechecker/issues/new/choose) — they keep reports consistent and easy to act on.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Open an issue first to discuss anything non-trivial. This avoids wasted effort if the direction isn't a fit.
|
||||
- Keep changes focused — one fix or feature per PR.
|
||||
- Follow the existing Swift code style in the repo.
|
||||
- There's no guarantee PRs will be merged; this is a personal project maintained in spare time.
|
||||
|
||||
## Notes
|
||||
|
||||
- This app targets macOS 13+
|
||||
- No external dependencies — keep it that way
|
||||
@@ -10,6 +10,13 @@
|
||||
<string>77</string>
|
||||
<key>objects</key>
|
||||
<dict>
|
||||
<key>AAAAAA009</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
<string>PBXBuildFile</string>
|
||||
<key>fileRef</key>
|
||||
<string>AAAAAA109</string>
|
||||
</dict>
|
||||
<key>AAAAAA007</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
@@ -88,6 +95,17 @@
|
||||
<key>sourceTree</key>
|
||||
<string><group></string>
|
||||
</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><group></string>
|
||||
</dict>
|
||||
<key>AAAAAA101</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
@@ -179,6 +197,7 @@
|
||||
<string>AAAAAA106</string>
|
||||
<string>AAAAAA107</string>
|
||||
<string>AAAAAA108</string>
|
||||
<string>AAAAAA109</string>
|
||||
</array>
|
||||
<key>path</key>
|
||||
<string>ClaudeChecker</string>
|
||||
@@ -292,6 +311,7 @@
|
||||
<string>AAAAAA005</string>
|
||||
<string>AAAAAA007</string>
|
||||
<string>AAAAAA008</string>
|
||||
<string>AAAAAA009</string>
|
||||
</array>
|
||||
<key>runOnlyForDeploymentPostprocessing</key>
|
||||
<string>0</string>
|
||||
|
||||
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||
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
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
@@ -40,14 +40,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// Popover
|
||||
popover = NSPopover()
|
||||
popover?.contentSize = NSSize(width: 480, height: 640)
|
||||
popover?.behavior = .transient
|
||||
popover?.animates = true
|
||||
popover?.contentViewController = NSHostingController(
|
||||
let hc = NSHostingController(
|
||||
rootView: ContentView()
|
||||
.environmentObject(usageViewModel)
|
||||
.environmentObject(updateManager)
|
||||
)
|
||||
hc.sizingOptions = .preferredContentSize
|
||||
hc.view.wantsLayer = true
|
||||
hc.view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
popover?.contentViewController = hc
|
||||
|
||||
// React to limits or menubar toggle changes
|
||||
usageViewModel.$limits
|
||||
@@ -236,3 +239,5 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -40,8 +40,7 @@ struct ContentView: View {
|
||||
.environmentObject(updater)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
// Cards grid
|
||||
if vm.limits.isEmpty {
|
||||
EmptyStateView()
|
||||
@@ -96,7 +95,6 @@ struct ContentView: View {
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
|
||||
@@ -118,8 +116,8 @@ struct ContentView: View {
|
||||
showUpdateSheet = true
|
||||
}
|
||||
.sheet(isPresented: $showLogin) {
|
||||
LoginSheetView(isPresented: $showLogin) {
|
||||
Task { await vm.refresh() }
|
||||
LoginSheetView(isPresented: $showLogin) { webView in
|
||||
Task { await vm.adoptAndRefresh(webView) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showUpdateSheet) {
|
||||
@@ -398,12 +396,11 @@ struct DiaryRow: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
AgentIconView(agent: .claude, size: 12)
|
||||
Text("Claude")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
Text("\(totalSaved) samples")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundColor(.secondary)
|
||||
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))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -429,7 +426,9 @@ struct SparklineView: View {
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if data.count > 1 {
|
||||
let plotData = data.count == 1 ? [data[0], data[0]] : data
|
||||
if plotData.count > 1 {
|
||||
let data = plotData
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height
|
||||
let minV = data.min() ?? 0
|
||||
@@ -585,6 +584,7 @@ struct SettingsView: View {
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showUpdateSheet: Bool
|
||||
@State private var checkingForUpdates = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
||||
("1 min", 60),
|
||||
@@ -771,6 +771,23 @@ struct SettingsView: View {
|
||||
|
||||
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
|
||||
HStack(spacing: 12) {
|
||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||
@@ -912,12 +929,12 @@ struct ErrorBanner: View {
|
||||
struct EmptyStateView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "key.slash")
|
||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No API key configured")
|
||||
Text("Not signed in")
|
||||
.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))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -1170,3 +1187,4 @@ struct UpdateSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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!")
|
||||
.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()
|
||||
|
||||
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("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
|
||||
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
|
||||
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()
|
||||
|
||||
// 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
|
||||
DiagSection(title: "Cookie Store") {
|
||||
DiagRow("Total cookies", "\(vm.diagCookieCount)")
|
||||
DiagRow("claude.ai cookies", "\(vm.diagClaudeCookieCount)")
|
||||
DiagRow("All domains", vm.diagCookieDomains.isEmpty
|
||||
? "(none — never fetched)"
|
||||
: vm.diagCookieDomains.joined(separator: ", "))
|
||||
}
|
||||
|
||||
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 {
|
||||
var lines: [String] = []
|
||||
lines.append("=== ClaudeChecker Diagnostics ===")
|
||||
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("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("")
|
||||
lines.append("Last path: \(vm.diagLastPath)")
|
||||
lines.append("Last status: \(vm.diagLastStatus)")
|
||||
lines.append("Last error: \(vm.diagLastError)")
|
||||
lines.append("Total cookies: \(vm.diagCookieCount)")
|
||||
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
|
||||
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
|
||||
lines.append("")
|
||||
lines.append("Response body:")
|
||||
lines.append(vm.diagLastBody)
|
||||
lines.append("")
|
||||
lines.append("Live cookies:")
|
||||
for c in liveAllCookies {
|
||||
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.3-beta.2</string>
|
||||
<string>1.2.1-beta.22</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>36</string>
|
||||
<string>70</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -4,7 +4,7 @@ import WebKit
|
||||
// MARK: - Login Web View
|
||||
|
||||
struct LoginWebView: NSViewRepresentable {
|
||||
let onAuthenticated: () -> Void
|
||||
let onAuthenticated: (WKWebView) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
@@ -12,6 +12,13 @@ struct LoginWebView: NSViewRepresentable {
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.webView = webView
|
||||
|
||||
// KVO on url catches SPA pushState navigations that don't fire didFinish
|
||||
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
|
||||
coordinator?.checkCurrentURL(wv.url?.absoluteString)
|
||||
}
|
||||
|
||||
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||
return webView
|
||||
}
|
||||
@@ -23,31 +30,31 @@ struct LoginWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
let onAuthenticated: () -> Void
|
||||
let onAuthenticated: (WKWebView) -> Void
|
||||
weak var webView: WKWebView?
|
||||
var didAuthenticate = false
|
||||
var urlObservation: NSKeyValueObservation?
|
||||
|
||||
init(onAuthenticated: @escaping () -> Void) {
|
||||
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
||||
self.onAuthenticated = onAuthenticated
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard !didAuthenticate else { return }
|
||||
// Don't fire on the login/auth pages themselves
|
||||
if let url = webView.url?.absoluteString,
|
||||
url.contains("/login") || url.contains("/auth") { return }
|
||||
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
let hasSession = cookies.contains {
|
||||
$0.domain.contains("claude.ai") &&
|
||||
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
||||
}
|
||||
guard hasSession, !self.didAuthenticate else { return }
|
||||
self.didAuthenticate = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.onAuthenticated()
|
||||
}
|
||||
func checkCurrentURL(_ url: String?) {
|
||||
guard !didAuthenticate, let url, let wv = webView else { return }
|
||||
// Ignore navigations to external OAuth providers (Google, etc.) —
|
||||
// only consider auth complete when we land back on claude.ai/anthropic.com
|
||||
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
|
||||
if url.contains("/login") || url.contains("/auth") { return }
|
||||
didAuthenticate = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.onAuthenticated(wv)
|
||||
}
|
||||
}
|
||||
|
||||
// Covers full cross-document navigations
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
checkCurrentURL(webView.url?.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
|
||||
|
||||
struct LoginSheetView: View {
|
||||
@Binding var isPresented: Bool
|
||||
let onDone: () -> Void
|
||||
let onDone: (WKWebView) -> Void
|
||||
@State private var authenticated = false
|
||||
|
||||
var body: some View {
|
||||
@@ -65,12 +72,9 @@ struct LoginSheetView: View {
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer()
|
||||
if authenticated {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
onDone()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button("Done") { isPresented = false }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Cancel") { isPresented = false }
|
||||
.buttonStyle(.bordered)
|
||||
@@ -83,12 +87,13 @@ struct LoginSheetView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
LoginWebView {
|
||||
LoginWebView { webView in
|
||||
authenticated = true
|
||||
// Auto-dismiss and refresh after brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
||||
// then auto-dismiss after a moment so the user sees confirmation.
|
||||
onDone(webView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
isPresented = false
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import WebKit
|
||||
|
||||
@MainActor
|
||||
class UsageViewModel: ObservableObject {
|
||||
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
|
||||
@Published var limits: [AgentLimit] = []
|
||||
@Published var isLoading = false
|
||||
@Published var lastUpdated: Date?
|
||||
@@ -28,24 +27,117 @@ class UsageViewModel: ObservableObject {
|
||||
|
||||
private var burnHistoryStore: [String: [Double]] = [:]
|
||||
private let maxHistorySamples = 24
|
||||
private var cachedOrgId: String?
|
||||
private var previousPercents: [String: Double] = [:]
|
||||
private var firedThresholds: [String: Set<Int>] = [:]
|
||||
|
||||
// Diagnostics — populated on every urlFetch call
|
||||
@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 diagLastBody: String = ""
|
||||
@Published var diagLastError: String = ""
|
||||
@Published var diagLastFetch: Date? = nil
|
||||
|
||||
init() {
|
||||
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||
refreshInterval = saved > 0 ? saved : 60
|
||||
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||
if let saved = UserDefaults.standard.object(forKey: "burn_history") as? [String: [Double]] {
|
||||
burnHistoryStore = saved
|
||||
}
|
||||
loadPlaceholderData()
|
||||
Task { await checkInitialSignInState() }
|
||||
}
|
||||
|
||||
// Called after login: poll until session cookies appear (they may commit slightly
|
||||
// after the auth redirect fires), then refresh.
|
||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
||||
for _ in 0..<30 {
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// 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
|
||||
diagLastBody = String(body.prefix(500))
|
||||
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 {
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
if !cookies.isEmpty { isSignedIn = true }
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
let store = WKWebsiteDataStore.default()
|
||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||
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)
|
||||
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
||||
isSignedIn = false
|
||||
isNotAuthenticated = true
|
||||
lastUpdated = nil
|
||||
@@ -63,18 +155,29 @@ class UsageViewModel: ObservableObject {
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let orgId = cachedOrgId!
|
||||
async let usageFetch = fetchUsage(orgId: orgId)
|
||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||
async let emailFetch = fetchUserEmail()
|
||||
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
||||
if let email { userEmail = email }
|
||||
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
|
||||
|
||||
let orgId: String
|
||||
if let id = fetchedOrgId {
|
||||
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
||||
orgId = id
|
||||
} 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)
|
||||
extraUsage = usage.extraUsage
|
||||
prepaidCredits = prepaid
|
||||
extraUsage = usage.extraUsage
|
||||
prepaidCredits = prepaid
|
||||
overageSpendLimit = overage
|
||||
planLabel = "Pro"
|
||||
lastUpdated = Date()
|
||||
isSignedIn = true
|
||||
|
||||
@@ -84,11 +187,13 @@ class UsageViewModel: ObservableObject {
|
||||
history.append(limits[i].usedPercent)
|
||||
if history.count > maxHistorySamples { history.removeFirst() }
|
||||
burnHistoryStore[key] = history
|
||||
if history.count > 1 { limits[i].burnHistory = history }
|
||||
limits[i].burnHistory = history
|
||||
}
|
||||
UserDefaults.standard.set(burnHistoryStore, forKey: "burn_history")
|
||||
checkLimitNotifications(for: limits)
|
||||
} catch AppError.notAuthenticated {
|
||||
isNotAuthenticated = true
|
||||
isSignedIn = false
|
||||
errorMessage = "Not signed in"
|
||||
} catch let error as DecodingError {
|
||||
switch error {
|
||||
@@ -106,70 +211,72 @@ class UsageViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||
let (status, body) = try await urlFetch("/api/bootstrap")
|
||||
if status == 401 || status == 403 {
|
||||
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
||||
}
|
||||
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
|
||||
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
||||
}
|
||||
guard let data = body.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return (nil, nil, nil)
|
||||
}
|
||||
|
||||
let account = json["account"] as? [String: Any]
|
||||
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
||||
let firstOrg = memberships?.first?["organization"] as? [String: Any]
|
||||
|
||||
var orgId: String? = firstOrg?["uuid"] as? String
|
||||
if orgId == nil {
|
||||
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
||||
}
|
||||
if orgId == nil {
|
||||
if let (orgsStatus, orgsBody) = try? await urlFetch("/api/organizations"),
|
||||
orgsStatus == 200,
|
||||
let orgsData = orgsBody.data(using: .utf8),
|
||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
||||
orgId = orgs.first?["uuid"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
let email = account?["email_address"] as? String
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - Fetch usage
|
||||
|
||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
|
||||
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
|
||||
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)
|
||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
||||
|
||||
let (status, body) = try await urlFetch("/api/organizations/\(orgId)/usage")
|
||||
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
||||
guard status == 200 else { throw AppError.networkError }
|
||||
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func fetchUserEmail() async throws -> String? {
|
||||
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
||||
var req = URLRequest(url: 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)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let account = json["account"] as? [String: Any],
|
||||
let email = account["email_address"] as? String else { return nil }
|
||||
return email
|
||||
}
|
||||
|
||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
||||
var req = URLRequest(url: 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)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||
status == 200,
|
||||
let data = body.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||
}
|
||||
|
||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
||||
var req = URLRequest(url: 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)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||
status == 200,
|
||||
let data = body.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||
}
|
||||
|
||||
@@ -241,14 +348,11 @@ class UsageViewModel: ObservableObject {
|
||||
|
||||
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
||||
let thresholds = [80, 95]
|
||||
|
||||
for limit in limits {
|
||||
let key = limit.window.rawValue
|
||||
let curr = limit.usedPercent
|
||||
let prev = previousPercents[key]
|
||||
var fired = firedThresholds[key] ?? []
|
||||
|
||||
// Threshold warnings (80% and 95%)
|
||||
for t in thresholds {
|
||||
let threshold = Double(t)
|
||||
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
||||
@@ -259,8 +363,6 @@ class UsageViewModel: ObservableObject {
|
||||
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
||||
)
|
||||
}
|
||||
|
||||
// Reset detection: was high, now low
|
||||
if let prev, prev > 50, curr < 20 {
|
||||
fired.removeAll()
|
||||
NotificationCenter.default.post(
|
||||
@@ -269,7 +371,6 @@ class UsageViewModel: ObservableObject {
|
||||
userInfo: ["windowName": limit.window.displayName]
|
||||
)
|
||||
}
|
||||
|
||||
firedThresholds[key] = fired
|
||||
previousPercents[key] = curr
|
||||
}
|
||||
@@ -292,13 +393,16 @@ class UsageViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case notAuthenticated
|
||||
case networkError
|
||||
case detail(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||
case .networkError: return "Network error fetching usage data."
|
||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||
case .networkError: return "Network error fetching usage data."
|
||||
case .detail(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
[](https://www.apple.com/macos/)
|
||||
[](https://swift.org)
|
||||
[](https://github.com/superdooper86/claudechecker/releases)
|
||||
[](https://github.com/superdooper86/claudechecker/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.1.3-beta.1) <!-- BETA_BADGE -->
|
||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.21) <!-- BETA_BADGE -->
|
||||
|
||||
</div>
|
||||
|
||||
@@ -30,21 +30,21 @@ No API key required. It reads directly from your `claude.ai` session using a bui
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/main-v2.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td>
|
||||
<td align="center"><img src="screenshots/settings-v2.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
|
||||
<td align="center"><img src="screenshots/main-v3.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td>
|
||||
<td align="center"><img src="screenshots/settings-v3.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update banner" /><br/><sub>Update available</sub></td>
|
||||
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>System notification</sub></td>
|
||||
<td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update available" /><br/><sub>Update available</sub></td>
|
||||
<td align="center"><img src="screenshots/release-notes.png" width="280" alt="Release notes" /><br/><sub>Release notes</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>Update notification</sub></td>
|
||||
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/notif-limit-80.png" width="280" alt="80% limit warning" /><br/><sub>80% limit warning</sub></td>
|
||||
<td align="center"><img src="screenshots/notif-limit-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
## What's new in v1.1.3
|
||||
## What's new in v1.2.1-beta.18
|
||||
|
||||
### UI polish
|
||||
- Limit headers now read "5 Hour Limit" and "7 Day Limit" instead of "5hr"
|
||||
- Tapping anywhere on the blue update banner now opens the update window (not just the Update button)
|
||||
### Bug fixes
|
||||
- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.
|
||||
- API calls are now sequential to share a single WebView for all navigations
|
||||
- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser
|
||||
|
||||
## What's new in v1.2.1-beta.17
|
||||
|
||||
### Bug fixes
|
||||
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
|
||||
- Added diagnostic error messages for JS errors and unexpected responses
|
||||
|
||||
## What's new in v1.2.1
|
||||
|
||||
### Bug fixes
|
||||
- Fixed 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
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.1.3-beta.1",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3-beta.1/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.1.3\n\n### Compact update banner\nThe in-app update banner no longer shows full release notes — it now shows a short message and lets you tap Update to read what's new in the install sheet. This prevents the banner from pushing content around when release notes are long."
|
||||
"version": "1.2.1-beta.21",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.21/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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.1.2",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.2/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.1.2\n\n### Cleaner usage cards\nRemoved the redundant \"5h\" / \"7d\" labels from each card row — the section headers already make it clear which limit is which.\n\n### Beta update support\nThe app now correctly detects and compares beta versions (e.g. v1.1.2-beta.2 > v1.1.2-beta.1) when the beta channel is enabled in Settings. Stable releases also correctly supersede beta builds of the same version — so users on a beta are prompted to install the final release.\n\n### Fixed crash when enabling beta channel\nEnabling the beta toggle could crash the app if no stable update was available. This is now fixed.\n\n### Fixed settings panel truncation\nThe app name and version number no longer get cut off when an update badge is displayed in Settings."
|
||||
"version": "1.2.0",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/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"
|
||||
}
|
||||
|
||||