Compare commits

..
54 Commits
Author SHA1 Message Date
superdooper86 c72a7923e3 v1.0.0 final — fix update notification dedup, beta badge format 2026-05-07 16:25:02 +02:00
superdooper86 42e36372db Temp: fake 3.0.1 to test update notification 2026-05-07 16:19:25 +02:00
superdooper86 ce1b5c0a50 Temp: fake 1.0.1 to test update notification 2026-05-07 16:18:29 +02:00
superdooper86 30b64683e0 v1.0.0 — clean release, remove fake beta, fix beta badge and notifications 2026-05-07 16:16:38 +02:00
superdooper86 02440c5340 Add fake beta version 3.1.0-beta for testing 2026-05-07 16:06:23 +02:00
superdooper86 6fe3e9722b v3.0.0 — built-by credit fix, version bump 2026-05-07 16:03:23 +02:00
superdooper86 f5114a31f8 README: add notification details, split update section, remove internal beta notes 2026-05-07 15:59:28 +02:00
superdooper86 4f260a3d4b v2.0.10 — limit notifications, fix positioning, add notification screenshots 2026-05-07 15:57:06 +02:00
superdooper86 3d1bae3d0a v2.0.9 — limit notifications using custom floating window (same style as update notifications) 2026-05-07 15:44:38 +02:00
superdooper86 b95102cb1b v2.0.9 — limit notifications at 80%/95% and on reset 2026-05-07 15:40:13 +02:00
superdooper86 2b48114996 Rename screenshots to bust CDN cache 2026-05-07 15:29:47 +02:00
superdooper86 6a4bb73c4f v2.0.8 — Fix data source endpoints in settings, update screenshots 2026-05-07 15:28:08 +02:00
superdooper86 d2815d65ce v2.0.7 — Show account email in settings auth section 2026-05-07 15:21:35 +02:00
superdooper86 a1c3c0b314 v2.0.6 — Auth section shows sign-in status, re-authenticate and sign out buttons 2026-05-07 15:17:11 +02:00
superdooper86 d3ec3f5dd7 revert: restore v2.0.5 after testing 2026-05-07 14:47:04 +02:00
superdooper86 5fee0af18b temp: bump fake version to 9.9.10 2026-05-07 14:37:39 +02:00
superdooper86 4edca2962c temp: fake update for testing 2026-05-07 14:33:51 +02:00
superdooper86 ef2cdd8bf3 v2.0.5 — Fixed window size on open, update banner now clears when up to date 2026-05-07 14:28:33 +02:00
superdooper86 2da3eeaf72 Improve screenshots layout with grid table 2026-05-07 14:24:36 +02:00
superdooper86 d229581de1 Add screenshots to README 2026-05-07 14:21:26 +02:00
superdooper86 169cfd8950 revert: restore v2.0.4 after screenshot 2026-05-07 14:17:37 +02:00
superdooper86 a5218fa44f temp: bump version for screenshot 2026-05-07 14:16:08 +02:00
superdooper86 1f7afab61c Update Data Sources section with new billing endpoints 2026-05-07 14:06:46 +02:00
superdooper86 1507c6490f v2.0.4 — Show spent, limit and balance from dedicated billing endpoints 2026-05-07 14:04:19 +02:00
superdooper86 ccf3468621 v2.0.3 — Clean up extra usage display, remove non-existent balance field 2026-05-07 13:56:31 +02:00
superdooper86 3199b228a8 v2.0.2 — Show spent, limit, and balance in extra usage section 2026-05-07 13:46:36 +02:00
superdooper86 255f3f6cd5 v2.0.2 — Fix unlimited monthly limit display in extra usage section 2026-05-07 13:44:05 +02:00
superdooper86 773fcb1a46 Bump to v2.0.1 2026-05-07 13:39:18 +02:00
superdooper86 464884e88f Fix crash when extra_usage fields are null 2026-05-07 13:36:23 +02:00
superdooper86 edd8d42c8d Bump CFBundleShortVersionString to 2.0 2026-05-07 13:33:16 +02:00
superdooper86 e123b48812 Bump to v2.0 2026-05-07 13:22:06 +02:00
superdooper86 603d405c87 Fix icon URL in README to raw GitHub path 2026-05-07 13:19:25 +02:00
superdooper86 f976a90875 Add Xcode project source for v1.6 2026-05-07 13:18:00 +02:00
SuperDooper c79bd41504 Bump version to 1.6 and update download URL 2026-05-07 12:53:37 +02:00
SuperDooper dfbd276616 Remove instructions from README
Removed instructions
2026-05-07 12:38:27 +02:00
SuperDooper dbf14bdd70 Update contributors in README.md 2026-05-07 12:37:08 +02:00
SuperDooper 6c894c32a4 Create README.md for ClaudeChecker app
Add README for ClaudeChecker macOS app with features and installation instructions.
2026-05-07 12:36:35 +02:00
SuperDooper 345150e894 Update version to 1.5 with notification fixes 2026-05-07 12:32:57 +02:00
SuperDooper d724622533 Downgrade version from 1.5 to 1.4 2026-05-07 12:21:37 +02:00
SuperDooper 8ecb5f3670 Bump version to 1.5 and update release notes 2026-05-07 12:17:08 +02:00
SuperDooper cf6f9184e3 Remove beta version information from JSON 2026-05-07 12:16:49 +02:00
SuperDooper d734fec836 Update version to 1.4 and change download URL 2026-05-07 12:08:18 +02:00
SuperDooper b6542e099a Update version to 1.3 and change download URL 2026-05-07 11:56:24 +02:00
SuperDooper c4e798b484 Update download URL in version.json 2026-05-07 11:47:15 +02:00
SuperDooper 0dd1ac3a8f Update version.json for release 1.2
Updated the version URL and release notes for version 1.2.
2026-05-07 11:45:28 +02:00
SuperDooper 2d8a3629da Rename version-beta to version-beta.json 2026-05-07 11:35:10 +02:00
SuperDooper e07af519a6 Rename version to version.json 2026-05-07 11:35:00 +02:00
SuperDooper 6b1f85dbaf Update version to 1.2 2026-05-07 11:31:12 +02:00
SuperDooper 74fe785898 Add version information for beta release 2026-05-07 11:26:21 +02:00
SuperDooper 3de4e09876 Create version-beta 2026-05-07 11:24:20 +02:00
SuperDooper 43dbb155f5 Delete version-beta.json 2026-05-07 11:24:08 +02:00
SuperDooper 79209eee6f Create version-beta.json 2026-05-07 11:23:50 +02:00
SuperDooper d9598a70ea Add version file with release information 2026-05-07 10:59:12 +02:00
SuperDooper c8cae1827f Initial commit 2026-05-07 10:58:23 +02:00
26 changed files with 204 additions and 1080 deletions
-30
View File
@@ -1,30 +0,0 @@
---
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.
-22
View File
@@ -1,22 +0,0 @@
---
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.
-79
View File
@@ -1,79 +0,0 @@
name: Release Beta
on:
push:
tags:
- 'v*-beta*'
jobs:
release-beta:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build
run: |
xcodebuild \
-project ClaudeChecker.xcodeproj \
-scheme ClaudeChecker \
-configuration Release \
-derivedDataPath build \
clean build
- name: Zip app
run: |
ditto -c -k --keepParent \
build/Build/Products/Release/ClaudeChecker.app \
ClaudeChecker.zip
- name: Create GitHub pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
--title "$GITHUB_REF_NAME" \
--prerelease \
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Beta release ${{ steps.version.outputs.version }}')"
- name: Update version-beta.json
run: |
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Beta release ${{ steps.version.outputs.version }}")
jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' > 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="[![Beta](https://img.shields.io/badge/beta-${shields_version}-orange?style=flat)](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 README.md
git commit -m "Beta release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main
-111
View File
@@ -1,111 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
if: ${{ !contains(github.ref_name, '-') }}
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build
run: |
xcodebuild \
-project ClaudeChecker.xcodeproj \
-scheme ClaudeChecker \
-configuration Release \
-derivedDataPath build \
clean build
- name: Zip app
run: |
ditto -c -k --keepParent \
build/Build/Products/Release/ClaudeChecker.app \
ClaudeChecker.zip
- name: Compute SHA256
id: sha
run: echo "sha256=$(shasum -a 256 ClaudeChecker.zip | awk '{print $1}')" >> $GITHUB_OUTPUT
- name: Create GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
--title "$GITHUB_REF_NAME" \
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')"
- name: Update version.json
run: |
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Release ${{ steps.version.outputs.version }}")
jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' > version.json
- name: Update Homebrew cask
run: |
sed -i '' \
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
- name: Update README badge
env:
NEW_VERSION: ${{ steps.version.outputs.version }}
run: |
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: |
git config user.name "github-actions[bot]"
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
env:
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
run: |
git clone https://x-access-token:${{ secrets.TAP_TOKEN }}@github.com/${{ github.repository_owner }}/homebrew-tap /tmp/homebrew-tap
sed -i '' \
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
/tmp/homebrew-tap/Casks/claudechecker.rb
cd /tmp/homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/claudechecker.rb
git commit -m "Update claudechecker to ${{ github.ref_name }}"
git push
-19
View File
@@ -1,19 +0,0 @@
# 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
-20
View File
@@ -10,13 +10,6 @@
<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>
@@ -95,17 +88,6 @@
<key>sourceTree</key>
<string>&lt;group&gt;</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>&lt;group&gt;</string>
</dict>
<key>AAAAAA101</key>
<dict>
<key>isa</key>
@@ -197,7 +179,6 @@
<string>AAAAAA106</string>
<string>AAAAAA107</string>
<string>AAAAAA108</string>
<string>AAAAAA109</string>
</array>
<key>path</key>
<string>ClaudeChecker</string>
@@ -311,7 +292,6 @@
<string>AAAAAA005</string>
<string>AAAAAA007</string>
<string>AAAAAA008</string>
<string>AAAAAA009</string>
</array>
<key>runOnlyForDeploymentPostprocessing</key>
<string>0</string>
+4 -22
View File
@@ -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")!))
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
// Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -40,17 +40,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Popover
popover = NSPopover()
popover?.contentSize = NSSize(width: 480, height: 640)
popover?.behavior = .transient
popover?.animates = true
let hc = NSHostingController(
popover?.contentViewController = 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
@@ -63,11 +60,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
updateManager.$updateAvailable
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
// Refresh timer
scheduleTimer(interval: usageViewModel.refreshInterval)
@@ -174,13 +166,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? ""
button.image = nil
button.attributedTitle = NSAttributedString(string: "")
let updatePrefix = updateManager.updateAvailable ? "↑ Update " : ""
button.title = "\(updatePrefix)\(fhStr) \(sdStr)"
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else if updateManager.updateAvailable {
button.image = nil
button.attributedTitle = NSAttributedString(string: "")
button.title = "↑ Update"
button.title = "\(fhStr) \(sdStr)"
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else {
button.title = ""
@@ -213,7 +199,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func showPopover() {
guard let button = statusItem?.button, let popover else { return }
if !popover.isShown {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
@@ -224,7 +209,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if popover.isShown {
popover.performClose(nil)
} else {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
@@ -239,5 +223,3 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
+47 -113
View File
@@ -40,35 +40,20 @@ struct ContentView: View {
.environmentObject(updater)
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
VStack(spacing: 0) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// Cards grid
if vm.limits.isEmpty {
EmptyStateView()
.padding(.vertical, 40)
} else {
VStack(spacing: 8) {
VStack(spacing: 1) {
ForEach(vm.limits) { limit in
VStack(spacing: 0) {
HStack {
Text(limit.window == .fiveHour ? "5 Hour Limit" : "7 Day Limit")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 4)
LimitCard(limit: limit)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.08), lineWidth: 0.5))
.padding(.horizontal, 8)
}
LimitCard(limit: limit)
}
}
.padding(.bottom, 4)
.background(Color.primary.opacity(0.05))
.padding(.vertical, 1)
// Extra credits row
if let extra = vm.extraUsage, extra.isEnabled {
@@ -95,6 +80,7 @@ struct ContentView: View {
.padding(.bottom, 8)
}
}
}
}
.transition(.opacity)
@@ -108,16 +94,9 @@ struct ContentView: View {
}
.frame(width: 480)
.animation(.easeInOut(duration: 0.2), value: showSettings)
.onReceive(NotificationCenter.default.publisher(for: .popoverWillOpen)) { _ in
showSettings = false
}
.onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in
showSettings = false
showUpdateSheet = true
}
.sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) { webView in
Task { await vm.adoptAndRefresh(webView) }
LoginSheetView(isPresented: $showLogin) {
Task { await vm.refresh() }
}
}
.sheet(isPresented: $showUpdateSheet) {
@@ -210,7 +189,7 @@ struct LimitCard: View {
AgentIconView(agent: .claude, size: 14)
Text("Claude")
.font(.system(size: 13, weight: .semibold))
Text("· \(vm.planLabel)")
Text("· \(vm.planLabel) · \(limit.window.label)")
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
@@ -396,11 +375,12 @@ struct DiaryRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text("\(totalSaved) samples")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
AgentIconView(agent: .claude, size: 12)
Text("Claude")
.font(.system(size: 12, weight: .medium))
.foregroundColor(color)
Spacer()
Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
}
@@ -426,9 +406,7 @@ struct SparklineView: View {
var body: some View {
GeometryReader { geo in
let plotData = data.count == 1 ? [data[0], data[0]] : data
if plotData.count > 1 {
let data = plotData
if data.count > 1 {
let w = geo.size.width
let h = geo.size.height
let minV = data.min() ?? 0
@@ -584,7 +562,6 @@ 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,23 +748,6 @@ 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())
@@ -806,23 +766,25 @@ 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)
} 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)
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()
}
}
Text("Built by superdooper86 & claude")
.font(.system(size: 11))
@@ -929,12 +891,12 @@ struct ErrorBanner: View {
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 10) {
Image(systemName: "person.crop.circle.badge.questionmark")
Image(systemName: "key.slash")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("Not signed in")
Text("No API key configured")
.font(.system(size: 13, weight: .medium))
Text("Sign in to claude.ai to see your usage.")
Text("Open Settings to add your Anthropic API key.")
.font(.system(size: 11.5))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
@@ -1011,36 +973,6 @@ struct UpdateSuccessBanner: View {
}
}
// MARK: - Markdown notes renderer
struct ReleaseNotesText: View {
let text: String
var font: Font = .system(size: 12)
var body: some View {
let processed = text
.split(separator: "\n", omittingEmptySubsequences: false)
.map { line -> String in
let s = String(line)
if s.hasPrefix("### ") { return "**\(s.dropFirst(4))**" }
if s.hasPrefix("## ") { return "**\(s.dropFirst(3))**" }
if s.hasPrefix("# ") { return "**\(s.dropFirst(2))**" }
if s.hasPrefix("- ") { return "\(s.dropFirst(2))" }
return s
}
.joined(separator: "\n")
if let attr = try? AttributedString(markdown: processed,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attr).font(font).foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(text).font(font).foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Update Banner
struct UpdateBanner: View {
@@ -1055,9 +987,12 @@ struct UpdateBanner: View {
VStack(alignment: .leading, spacing: 1) {
Text("Update available — v\(updater.latestVersion)")
.font(.system(size: 12, weight: .semibold))
Text("Tap Update to view what's new & install.")
.font(.system(size: 11))
.foregroundColor(.secondary)
if !updater.releaseNotes.isEmpty {
Text(updater.releaseNotes)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer()
Button("Update") { showSheet = true }
@@ -1068,7 +1003,6 @@ struct UpdateBanner: View {
.padding(.vertical, 10)
.background(Color.blue.opacity(0.08))
.overlay(Divider(), alignment: .bottom)
.onTapGesture { showSheet = true }
}
}
@@ -1117,7 +1051,9 @@ struct UpdateSheet: View {
.foregroundColor(.blue)
}
if !updater.releaseNotes.isEmpty {
ReleaseNotesText(text: updater.releaseNotes)
Text(updater.releaseNotes)
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
Spacer()
@@ -1186,5 +1122,3 @@ struct UpdateSheet: View {
.frame(width: 380)
}
}
-226
View File
@@ -1,226 +0,0 @@
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)
}
}
}
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2.1-beta.23</string>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>71</string>
<string>19</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
+22 -42
View File
@@ -4,7 +4,7 @@ import WebKit
// MARK: - Login Web View
struct LoginWebView: NSViewRepresentable {
let onAuthenticated: (WKWebView) -> Void
let onAuthenticated: () -> Void
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
@@ -12,13 +12,6 @@ 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
}
@@ -30,30 +23,24 @@ struct LoginWebView: NSViewRepresentable {
}
class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: (WKWebView) -> Void
weak var webView: WKWebView?
var didAuthenticate = false
var urlObservation: NSKeyValueObservation?
let onAuthenticated: () -> Void
init(onAuthenticated: @escaping (WKWebView) -> Void) {
init(onAuthenticated: @escaping () -> Void) {
self.onAuthenticated = 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)
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")
}
if hasSession {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated()
}
}
}
}
}
}
@@ -62,7 +49,7 @@ struct LoginWebView: NSViewRepresentable {
struct LoginSheetView: View {
@Binding var isPresented: Bool
let onDone: (WKWebView) -> Void
let onDone: () -> Void
@State private var authenticated = false
var body: some View {
@@ -71,15 +58,9 @@ struct LoginSheetView: View {
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
.font(.system(size: 13, weight: .semibold))
Spacer()
if authenticated {
Button("Done") { isPresented = false }
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
@@ -87,13 +68,12 @@ struct LoginSheetView: View {
Divider()
LoginWebView { webView in
LoginWebView {
authenticated = true
// 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) {
// Auto-dismiss and refresh after brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isPresented = false
onDone()
}
}
}
+5 -25
View File
@@ -87,8 +87,6 @@ class UpdateManager: ObservableObject {
}
} else {
print("[UpdateManager] Stable fetch returned nil")
updateAvailable = false
latestVersion = ""
}
if let beta = await betaResult {
@@ -318,31 +316,13 @@ class UpdateManager: ObservableObject {
}
private func isNewer(_ version: String, than current: String) -> Bool {
func parse(_ v: String) -> (base: [Int], pre: [Int]?) {
guard !v.isEmpty else { return ([], nil) }
let halves = v.split(separator: "-", maxSplits: 1)
let base = halves[0].split(separator: ".").compactMap { Int($0) }
let pre = halves.count > 1 ? String(halves[1]).split(separator: ".").compactMap { Int($0) } : nil
return (base, pre)
}
let a = parse(version), b = parse(current)
// Compare base version numbers
for i in 0..<max(a.base.count, b.base.count) {
let av = i < a.base.count ? a.base[i] : 0
let bv = i < b.base.count ? b.base[i] : 0
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } }
let a = toInts(version), b = toInts(current)
for i in 0..<max(a.count, b.count) {
let av = i < a.count ? a[i] : 0
let bv = i < b.count ? b[i] : 0
if av != bv { return av > bv }
}
// Base equal: stable beats pre-release
if a.pre == nil && b.pre != nil { return true }
if a.pre != nil && b.pre == nil { return false }
// Both pre-release: compare pre-release numbers
if let ap = a.pre, let bp = b.pre {
for i in 0..<max(ap.count, bp.count) {
let av = i < ap.count ? ap[i] : 0
let bv = i < bp.count ? bp[i] : 0
if av != bv { return av > bv }
}
}
return false
}
}
+28 -51
View File
@@ -14,7 +14,7 @@ class UpdateNotificationWindowController: NSWindowController {
// Called when a new update is detected while running
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
showWindow(version: version, subtitle: "Tap to view release notes & update now!", near: statusItem, isUpdate: true)
showWindow(version: version, subtitle: notes.isEmpty ? "Tap to update now." : notes, near: statusItem, isUpdate: true)
}
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
@@ -39,12 +39,7 @@ class UpdateNotificationWindowController: NSWindowController {
sharedLimit?.close()
let content = UpdateNotificationView(
version: "", subtitle: subtitle, isUpdate: false,
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor,
onTap: {
sharedLimit?.close()
sharedLimit = nil
NotificationCenter.default.post(name: .showPopover, object: nil)
}
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor
) {
sharedLimit?.close()
sharedLimit = nil
@@ -58,16 +53,7 @@ class UpdateNotificationWindowController: NSWindowController {
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
shared?.close()
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate,
onTap: {
shared?.close()
shared = nil
NotificationCenter.default.post(name: .showPopover, object: nil)
if isUpdate {
NotificationCenter.default.post(name: .openUpdateSheet, object: nil)
}
}
) {
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) {
shared?.close()
shared = nil
}
@@ -143,7 +129,6 @@ struct UpdateNotificationView: View {
var titleText: String? = nil
var badgeIcon: String? = nil
var badgeColor: Color? = nil
var onTap: (() -> Void)? = nil
let onDismiss: () -> Void
private var effectiveTitle: String {
@@ -157,40 +142,30 @@ struct UpdateNotificationView: View {
}
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: { onTap?() }) {
HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 44, height: 44)
.cornerRadius(10)
HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 44, height: 44)
.cornerRadius(10)
Image(systemName: effectiveBadgeIcon)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(effectiveBadgeColor)
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
.offset(x: 4, y: 4)
}
VStack(alignment: .leading, spacing: 3) {
Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.primary)
Text(subtitle)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 24)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.contentShape(Rectangle())
Image(systemName: effectiveBadgeIcon)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(effectiveBadgeColor)
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
.offset(x: 4, y: 4)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 3) {
Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold))
Text(subtitle)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Button(action: onDismiss) {
Image(systemName: "xmark")
@@ -198,8 +173,10 @@ struct UpdateNotificationView: View {
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.padding(14)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 13)
.fill(Color(nsColor: .windowBackgroundColor))
+76 -263
View File
@@ -3,6 +3,7 @@ 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?
@@ -27,187 +28,24 @@ 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>] = [:]
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
private var apiWebView: WKWebView?
private var navWaiter: WKNavWaiter?
// 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: store the webview (already on claude.ai) so jsFetch can use it.
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)
}
if loginWebView.url?.host?.hasSuffix("claude.ai") == true {
apiWebView = loginWebView
} else {
await prepareApiWebView()
}
await refresh()
}
// Creates (or reuses) a background WKWebView navigated to claude.ai for JS fetch.
private func prepareApiWebView() async {
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true { return }
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
apiWebView = wv
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let waiter = WKNavWaiter { cont.resume() }
self.navWaiter = waiter
wv.navigationDelegate = waiter
wv.load(URLRequest(url: URL(string: "https://claude.ai/")!))
}
wv.navigationDelegate = nil
navWaiter = nil
}
// Runs a fetch() inside the live claude.ai WebView same browser context as the frontend,
// so no CORS/fingerprinting issues that URLSession hits.
private func jsFetch(_ path: String) async throws -> (Int, String) {
guard let wv = apiWebView else { throw AppError.networkError }
let js = """
const r = await fetch('\(path)', {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
const body = await r.text();
return {status: r.status, body: body};
"""
let result: Any? = try await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
switch res {
case .success(let val): cont.resume(returning: val)
case .failure(let err): cont.resume(throwing: err)
}
}
}
guard let dict = result as? [String: Any],
let status = dict["status"] as? Int,
let body = dict["body"] as? String else {
throw AppError.networkError
}
diagLastPath = path
diagLastFetch = Date()
diagLastStatus = status
diagLastBody = String(body.prefix(500))
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
return (status, body)
}
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
private func apiFetch(_ path: String) async throws -> (Int, String) {
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
return try await jsFetch(path)
}
return try await urlFetch(path)
}
// Fetches an API path via URLSession with browser-like headers.
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
// to avoid 401 matching how the Windows version (HttpClient) handles this.
private func urlFetch(_ path: String) async throws -> (Int, String) {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
// Record cookie diagnostics
diagCookieCount = cookies.count
diagClaudeCookieCount = claudeCookies.count
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
diagLastPath = path
diagLastFetch = Date()
diagLastError = ""
guard !claudeCookies.isEmpty else {
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
diagLastError = msg
throw AppError.detail(msg)
}
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
req.httpShouldHandleCookies = false
// Build cookie header manually (same format as Windows HttpClient) to avoid
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
req.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent")
req.setValue(
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
forHTTPHeaderField: "sec-ch-ua")
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
let session = URLSession(configuration: .ephemeral)
do {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
let body = String(data: data, encoding: .utf8) ?? ""
diagLastStatus = http.statusCode
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()
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
isSignedIn = true
await prepareApiWebView()
await refresh()
}
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") || $0.displayName.contains("anthropic.com") }
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
apiWebView = nil
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -225,29 +63,18 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false }
do {
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)
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 }
limits = buildLimits(from: usage)
extraUsage = usage.extraUsage
prepaidCredits = prepaid
extraUsage = usage.extraUsage
prepaidCredits = prepaid
overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date()
isSignedIn = true
@@ -257,13 +84,11 @@ class UsageViewModel: ObservableObject {
history.append(limits[i].usedPercent)
if history.count > maxHistorySamples { history.removeFirst() }
burnHistoryStore[key] = history
limits[i].burnHistory = history
if history.count > 1 { 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 {
@@ -281,72 +106,70 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await apiFetch("/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 apiFetch("/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 (status, body) = try await apiFetch("/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 }
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 }
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? {
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
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 }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
}
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
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 }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
}
@@ -418,11 +241,14 @@ 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 }
@@ -433,6 +259,8 @@ 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(
@@ -441,6 +269,7 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName]
)
}
firedThresholds[key] = fired
previousPercents[key] = curr
}
@@ -463,27 +292,13 @@ class UsageViewModel: ObservableObject {
}
}
// Navigation delegate that resolves a continuation on first finish/error (idempotent).
private class WKNavWaiter: NSObject, WKNavigationDelegate {
private var done = false
private let onDone: () -> Void
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
private func finish() { guard !done else { return }; done = true; onDone() }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
}
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 .detail(let msg): return msg
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data."
}
}
}
@@ -492,9 +307,7 @@ extension Notification.Name {
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
static let closePopover = Notification.Name("closePopover")
static let showPopover = Notification.Name("showPopover")
static let popoverWillOpen = Notification.Name("popoverWillOpen")
static let updateDetected = Notification.Name("updateDetected")
static let limitWarning = Notification.Name("limitWarning")
static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet")
}
+17 -22
View File
@@ -8,9 +8,8 @@
[![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org)
[![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![Version](https://img.shields.io/badge/version-1.0.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.22-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.22) <!-- BETA_BADGE -->
</div>
@@ -30,21 +29,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-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>
<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>
</tr>
<tr>
<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>
<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>
</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/>
@@ -114,29 +113,25 @@ No API key required. It reads directly from your `claude.ai` session using a bui
## Requirements
- macOS 13 Ventura or later
- Xcode 15+ (to build from source)
- A claude.ai account
---
## Installation
### Homebrew (easiest)
```bash
brew tap superdooper86/tap
brew install --cask claudechecker
```
### Download
### Download (recommended)
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
2. Download `ClaudeChecker.zip` from the latest release
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
4. Open from Applications — it will appear in your menubar
> **macOS security prompt?** If macOS blocks the app on first launch, run this in Terminal:
> ```bash
> xattr -cr /Applications/ClaudeChecker.app
> ```
> This is not needed when installing via Homebrew.
### Build from source
```bash
git clone https://github.com/superdooper86/claudechecker.git
open ClaudeChecker/ClaudeChecker.xcodeproj
```
Then in Xcode: set your Team under Signing & Capabilities, then **⌘R** to run.
---
-20
View File
@@ -1,20 +0,0 @@
## What's new in v1.2.1-beta.18
### 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

-5
View File
@@ -1,5 +0,0 @@
{
"version": "1.2.1-beta.22",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.22/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"
}
-5
View File
@@ -1,5 +0,0 @@
{
"version": "0.0.1-beta.49",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.36\r\n\r\n### Bug fixes\r\n- Settings no longer incorrectly shows \"Not signed in\" when limits are working\r\n- Session Diary now shows correctly after the first successful refresh\r\n- Extra Usage Credits section now restored from cache on startup\r\n- Plan name, overage, and prepaid credits are now cached and shown immediately on launch\r\n- App now loads cached state instantly on startup before the background refresh completes"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.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"
"version": "1.0.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.0.0/ClaudeChecker.zip",
"notes": "Initial release."
}