Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
cb00ddc806 | ||
|
|
1f357b9123 | ||
|
|
9dee674ff0 | ||
|
|
0025690a74 | ||
|
|
7b03909c55 | ||
|
|
c3ea2be809 | ||
|
|
6f6a7361fe | ||
|
|
d9d65da6b7 | ||
|
|
c18054a810 | ||
|
|
032951882f | ||
|
|
ed186af2c6 | ||
|
|
d3dabc7843 | ||
|
|
987bd36137 | ||
|
|
cfcb74f32b | ||
|
|
273e10566e | ||
|
|
76a8f433a7 | ||
|
|
65a171f296 | ||
|
|
26312290ac | ||
|
|
63cbd70f38 |
@@ -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.
|
||||
@@ -54,10 +54,26 @@ jobs:
|
||||
--arg notes "$notes" \
|
||||
'{version: $version, url: $url, notes: $notes}' > version-beta.json
|
||||
|
||||
- name: Commit version-beta.json
|
||||
- name: Update beta badge in README
|
||||
run: |
|
||||
shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g')
|
||||
tag="${{ github.ref_name }}"
|
||||
export BADGE="[](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- BETA_BADGE -->"
|
||||
python3 -c "
|
||||
import os
|
||||
badge = os.environ['BADGE']
|
||||
lines = open('README.md').readlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
out.append(badge + chr(10) if '<!-- BETA_BADGE -->' in line else line)
|
||||
open('README.md', 'w').writelines(out)
|
||||
"
|
||||
|
||||
- name: Commit version-beta.json and README
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add version-beta.json
|
||||
git add version-beta.json README.md
|
||||
git commit -m "Beta release ${{ github.ref_name }}"
|
||||
git pull --rebase origin main
|
||||
git push origin HEAD:main
|
||||
|
||||
@@ -4,10 +4,10 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -66,10 +66,24 @@ jobs:
|
||||
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
|
||||
|
||||
- name: Update README badge
|
||||
env:
|
||||
NEW_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
sed -i '' \
|
||||
's/version-[0-9][^-]*-orange/version-${{ steps.version.outputs.version }}-orange/' \
|
||||
README.md
|
||||
python3 -c "
|
||||
import os, re
|
||||
v = os.environ['NEW_VERSION']
|
||||
content = open('README.md').read()
|
||||
content = re.sub(r'version-[0-9][^-]*-orange', 'version-' + v + '-orange', content)
|
||||
lines = content.splitlines(keepends=True)
|
||||
out = []
|
||||
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)
|
||||
"
|
||||
|
||||
- name: Commit version.json and README
|
||||
run: |
|
||||
@@ -77,6 +91,7 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add version.json README.md
|
||||
git commit -m "Release ${{ github.ref_name }}"
|
||||
git pull --rebase origin main
|
||||
git push origin HEAD:main
|
||||
|
||||
- name: Update Homebrew tap
|
||||
|
||||
@@ -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()
|
||||
@@ -51,7 +50,7 @@ struct ContentView: View {
|
||||
ForEach(vm.limits) { limit in
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(limit.window == .fiveHour ? "5hr Limit" : "7 Day Limit")
|
||||
Text(limit.window == .fiveHour ? "5 Hour Limit" : "7 Day Limit")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.textCase(.uppercase)
|
||||
@@ -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())
|
||||
@@ -789,25 +806,23 @@ struct SettingsView: View {
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.primary.opacity(0.06))
|
||||
.cornerRadius(4)
|
||||
if updater.updateAvailable {
|
||||
Text("v\(updater.latestVersion) available")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(4)
|
||||
.fixedSize()
|
||||
} else if updater.betaAvailable {
|
||||
Text("v\(updater.latestBetaVersion) available")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange)
|
||||
.cornerRadius(4)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
if updater.updateAvailable {
|
||||
Text("v\(updater.latestVersion) available")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(4)
|
||||
} else if updater.betaAvailable {
|
||||
Text("v\(updater.latestBetaVersion) available")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
Text("Built by superdooper86 & claude")
|
||||
.font(.system(size: 11))
|
||||
@@ -914,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)
|
||||
@@ -1040,9 +1055,9 @@ struct UpdateBanner: View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Update available — v\(updater.latestVersion)")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
if !updater.releaseNotes.isEmpty {
|
||||
ReleaseNotesText(text: updater.releaseNotes, font: .system(size: 11))
|
||||
}
|
||||
Text("Tap Update to view what's new & install.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Update") { showSheet = true }
|
||||
@@ -1053,6 +1068,7 @@ struct UpdateBanner: View {
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.blue.opacity(0.08))
|
||||
.overlay(Divider(), alignment: .bottom)
|
||||
.onTapGesture { showSheet = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1170,3 +1186,5 @@ struct UpdateSheet: View {
|
||||
.frame(width: 380)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.2-beta.4</string>
|
||||
<string>1.2.1-beta.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>35</string>
|
||||
<string>69</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,114 @@ 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
|
||||
if let cookieHeader = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||
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")
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.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 +152,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 +184,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 +208,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.notAuthenticated }
|
||||
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 +345,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 +360,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 +368,6 @@ class UsageViewModel: ObservableObject {
|
||||
userInfo: ["windowName": limit.window.displayName]
|
||||
)
|
||||
}
|
||||
|
||||
firedThresholds[key] = fired
|
||||
previousPercents[key] = curr
|
||||
}
|
||||
@@ -292,13 +390,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,8 +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.2.1-beta.20) <!-- BETA_BADGE -->
|
||||
|
||||
</div>
|
||||
|
||||
@@ -29,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,4 +1,20 @@
|
||||
## What's new in v1.1.2
|
||||
## What's new in v1.2.1-beta.18
|
||||
|
||||
### Cleaner card headers
|
||||
Removed the redundant "5h" / "7d" window labels from each card row — the section headers already make it clear which limit is which.
|
||||
### 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.2-beta.3",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.2-beta.3/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.1.2\n\n### Cleaner card headers\nRemoved the redundant \"5h\" / \"7d\" window labels from each card row — the section headers already make it clear which limit is which."
|
||||
"version": "1.2.1-beta.20",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.20/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.1",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.1/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.1.1\n\n### Clearer limit labels\nThe 5hr and 7 Day usage cards now have clear section headers so it's immediately obvious which limit is which.\n\n### Login window fix\nThe sign-in sheet now reliably closes after a successful login. A **Done** button also appears as a fallback once authentication is detected — no more being stuck with only a Cancel button."
|
||||
"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"
|
||||
}
|
||||
|
||||