Compare commits

...
106 Commits
Author SHA1 Message Date
SuperDooper b49ac23863 Update release notes for 1.1.4-beta.2 2026-05-08 19:19:50 +02:00
SuperDooper 01d268c3e5 Bump version to 1.1.4-beta.2 2026-05-08 19:19:49 +02:00
SuperDooper 871d5245ac Parse plan label from capabilities array instead of plan_type 2026-05-08 19:14:53 +02:00
SuperDooper 0524f9b791 Parse plan label from bootstrap API instead of hardcoding 2026-05-08 19:09:06 +02:00
github-actions[bot] d68d564351 Beta release v1.1.4-beta.1 2026-05-08 15:12:07 +00:00
SuperDooper d55bad01db Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:27 +02:00
SuperDooper 7bc72e240b Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:26 +02:00
SuperDooper d77ed23d09 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:06 +02:00
SuperDooper 6136b0ded0 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:00 +02:00
SuperDooper aba7168448 Windows beta release win-v0.0.1-beta.33 2026-05-08 15:03:42 +00:00
SuperDooper a8c2083397 fix: remove hardcoded orgId from cookie primer URL 2026-05-08 16:54:48 +02:00
SuperDooper ad23446ed4 chore: release notes for v1.1.4-beta.1 2026-05-08 16:52:17 +02:00
SuperDooper d8b75d279c chore: bump version to 1.1.4-beta.1 2026-05-08 16:52:16 +02:00
SuperDooper 2658ff7f6a Windows beta release win-v0.0.1-beta.32 2026-05-08 14:51:18 +00:00
SuperDooper e10d79e5a6 fix: fetch org ID dynamically from bootstrap instead of hardcoding it 2026-05-08 16:49:44 +02:00
SuperDooper 88daa27630 Windows beta release win-v0.0.1-beta.31 2026-05-08 14:47:27 +00:00
SuperDooper b7c4958a74 Windows beta release win-v0.0.1-beta.30 2026-05-08 14:38:16 +00:00
SuperDooper f46b13d967 Windows beta release win-v0.0.1-beta.29 2026-05-08 14:30:44 +00:00
SuperDooper c449882ec6 Windows beta release win-v0.0.1-beta.28 2026-05-08 14:26:00 +00:00
SuperDooper 3c1a2ef1be Windows beta release win-v0.0.1-beta.27 2026-05-08 14:17:00 +00:00
SuperDooper c238db963b Windows beta release win-v0.0.1-beta.25 2026-05-08 14:07:31 +00:00
SuperDooper 63c9a8582d Windows beta release win-v0.0.1-beta.24 2026-05-08 14:02:59 +00:00
SuperDooper 6f256b3308 Windows beta release win-v0.0.1-beta.23 2026-05-08 13:51:22 +00:00
SuperDooper 2761b31a12 Windows beta release win-v0.0.1-beta.22 2026-05-08 13:31:58 +00:00
SuperDooper f9d8abebd5 Windows beta release win-v0.0.1-beta.20 2026-05-08 12:55:50 +00:00
SuperDooper 66ee233b1b Windows beta release win-v0.0.1-beta.19 2026-05-08 12:41:51 +00:00
SuperDooper a12c664bde Windows beta release win-v0.0.1-beta.18 2026-05-08 12:28:14 +00:00
SuperDooper c7251d8785 Windows beta release win-v0.0.1-beta.17 2026-05-08 12:16:57 +00:00
SuperDooper 7a95b100ef Windows beta release win-v0.0.1-beta.16 2026-05-08 12:12:23 +00:00
SuperDooper fb98c69c34 Windows beta release win-v0.0.1-beta.15 2026-05-08 12:06:07 +00:00
SuperDooper 5629c6b61e Windows beta release win-v0.0.1-beta.14 2026-05-08 11:58:49 +00:00
SuperDooper 4709f5b0f5 Windows beta release win-v0.0.1-beta.12 2026-05-08 11:49:10 +00:00
SuperDooper 5f8aeaa6e9 Windows beta release win-v0.0.1-beta.11 2026-05-08 11:35:50 +00:00
SuperDooper 27f1f45109 Windows beta release win-v0.0.1-beta.10 2026-05-08 11:29:15 +00:00
SuperDooper 7a5512cc28 Windows beta release win-v0.0.1-beta.9 2026-05-08 11:17:01 +00:00
SuperDooper 8c89ce4636 Windows beta release win-v0.0.1-beta.8 2026-05-08 11:08:41 +00:00
SuperDooper 3a964dc2f8 Windows beta release win-v0.0.1-beta.7 2026-05-08 11:00:23 +00:00
SuperDooper b553d3922a docs: reorder screenshot grid 2026-05-08 11:38:54 +02:00
SuperDooper 61e8455e5e docs: update screenshot captions and add release notes screenshot 2026-05-08 11:37:44 +02:00
SuperDooper c4b9d7454f docs: add release notes screenshot 2026-05-08 11:37:35 +02:00
SuperDooper 75aeb03332 docs: update banner screenshot 2026-05-08 11:37:34 +02:00
SuperDooper f5203a0299 docs: update notification screenshot 2026-05-08 11:37:33 +02:00
SuperDooper fe27e0d4c0 revert: restore version-beta.json to 1.1.3-beta.5 2026-05-08 11:36:33 +02:00
SuperDooper fb5e1ece25 test: fake beta version for screenshot 2026-05-08 11:31:45 +02:00
SuperDooper 71666224f1 chore: remove outdated settings-v2 screenshot 2026-05-08 11:31:02 +02:00
SuperDooper bd693cdf94 chore: remove outdated main-v2 screenshot 2026-05-08 11:31:01 +02:00
SuperDooper d24a354e1c docs: update README to use v3 screenshots 2026-05-08 11:30:50 +02:00
SuperDooper 9e47041715 docs: add settings screenshot v3 2026-05-08 11:30:36 +02:00
SuperDooper 22151f0af4 docs: add main window screenshot v3 2026-05-08 11:30:35 +02:00
SuperDooper 3508fa1a94 docs: update settings screenshot 2026-05-08 11:29:50 +02:00
SuperDooper b2fcafd3f0 docs: update main window screenshot 2026-05-08 11:29:48 +02:00
SuperDooper 45215de350 docs: add contributing guidelines 2026-05-08 11:25:46 +02:00
SuperDooper f969da8006 chore: add feature request issue template 2026-05-08 11:23:46 +02:00
SuperDooper 7f769414cc chore: add bug report issue template 2026-05-08 11:23:45 +02:00
github-actions[bot] db707340bb Release v1.1.3 2026-05-08 09:20:27 +00:00
SuperDooper be6bb1495d docs: release notes for 1.1.3 stable 2026-05-08 11:19:38 +02:00
SuperDooper 06755de91d chore: bump to 1.1.3 stable (build 40) 2026-05-08 11:19:38 +02:00
SuperDooper 2855155361 fix: populate notes in version-beta.json for 1.1.3-beta.5 2026-05-08 10:01:37 +02:00
SuperDooper ecfb1aa44d docs: fix empty release notes 2026-05-08 10:01:37 +02:00
github-actions[bot] 8b35e5df56 Beta release v1.1.3-beta.5 2026-05-08 07:58:08 +00:00
SuperDooper 794a6cdb65 docs: update release notes for 1.1.3-beta.5 2026-05-08 09:57:06 +02:00
SuperDooper e613ace503 chore: bump to 1.1.3-beta.5 (build 39) 2026-05-08 09:57:05 +02:00
SuperDooper 38d7683843 fix: fill popover background during resize animation 2026-05-08 09:56:41 +02:00
github-actions[bot] 802cf14a71 Beta release v1.1.3-beta.4 2026-05-08 07:53:11 +00:00
SuperDooper ae736a7e4a docs: update release notes for 1.1.3-beta.4 2026-05-08 09:52:28 +02:00
SuperDooper a1bb186af2 chore: bump to 1.1.3-beta.4 (build 38) 2026-05-08 09:52:27 +02:00
SuperDooper e93a4e6df5 fix: auto-resize popover to match SwiftUI content height 2026-05-08 09:52:04 +02:00
github-actions[bot] a3fbce3f0e Beta release v1.1.3-beta.3 2026-05-08 07:35:57 +00:00
SuperDooper 4b4280630c docs: update release notes for 1.1.3-beta.3 2026-05-08 09:35:01 +02:00
SuperDooper 90acbcb968 chore: bump to 1.1.3-beta.3 (build 37) 2026-05-08 09:34:52 +02:00
SuperDooper f2f2be1305 fix: render sparkline with a single data point (flat line) 2026-05-08 09:34:38 +02:00
SuperDooper f17694622c fix: persist burn history across launches; show sparkline from first refresh 2026-05-08 09:34:33 +02:00
github-actions[bot] aa10f77776 Beta release v1.1.3-beta.2 2026-05-08 07:22:39 +00:00
SuperDooper cb00ddc806 docs: update release notes for 1.1.3-beta.2 2026-05-08 09:21:48 +02:00
SuperDooper 1f357b9123 chore: bump to 1.1.3-beta.2 (build 36) 2026-05-08 09:21:42 +02:00
SuperDooper 9dee674ff0 fix: update banner label to '5 Hour Limit' and make banner tappable 2026-05-08 09:19:16 +02:00
github-actions[bot] 0025690a74 Beta release v1.1.3-beta.1 2026-05-07 21:31:40 +00:00
james 7b03909c55 Add rebase before push in both workflows to handle concurrent commits 2026-05-07 23:30:18 +02:00
james c3ea2be809 Fix YAML syntax in stable workflow — indent Python, use env var for version 2026-05-07 23:28:42 +02:00
james 6f6a7361fe Fix YAML syntax in beta workflow — indent Python code, use env var for badge 2026-05-07 23:28:13 +02:00
james d9d65da6b7 Fix stable workflow: use if condition to skip beta tags, fix YAML syntax in README step 2026-05-07 23:26:42 +02:00
james c18054a810 Compact update banner — remove release notes, fix scroll issue; start 1.1.3 beta 2026-05-07 23:24:52 +02:00
james 032951882f Add beta badge to README, managed by workflows 2026-05-07 23:17:02 +02:00
github-actions[bot] ed186af2c6 Release v1.1.2 2026-05-07 21:15:23 +00:00
james d3dabc7843 v1.1.2 stable release 2026-05-07 23:14:32 +02:00
github-actions[bot] 987bd36137 Beta release v1.1.2-beta.7 2026-05-07 21:07:31 +00:00
james cfcb74f32b Bump to 1.1.2-beta.7 2026-05-07 23:06:46 +02:00
github-actions[bot] 273e10566e Beta release v1.1.2-beta.6 2026-05-07 21:04:20 +00:00
james 76a8f433a7 Bump to 1.1.2-beta.6 2026-05-07 23:03:11 +02:00
github-actions[bot] 65a171f296 Beta release v1.1.2-beta.5 2026-05-07 20:59:40 +00:00
james 26312290ac Move update badge to own line to prevent name/version truncation; bump to 1.1.2-beta.5 2026-05-07 22:58:19 +02:00
github-actions[bot] 63cbd70f38 Beta release v1.1.2-beta.4 2026-05-07 20:56:53 +00:00
james 235ab81efb Fix crash in isNewer when latestVersion is empty; bump to 1.1.2-beta.4 2026-05-07 22:56:12 +02:00
github-actions[bot] 12d7855ceb Beta release v1.1.2-beta.3 2026-05-07 20:52:28 +00:00
james d1ab5206bd Set bundle version to 1.1.2-beta.3 2026-05-07 22:50:59 +02:00
github-actions[bot] 997e820be8 Beta release v1.1.2-beta.2 2026-05-07 20:46:59 +00:00
james 7fc78d5a83 Set bundle version to 1.1.2-beta.2 2026-05-07 22:46:06 +02:00
james 818d4aa6c2 Set bundle version to 1.1.2-beta.1 2026-05-07 22:45:39 +02:00
github-actions[bot] 6aa74fcabc Beta release v1.1.2-beta.1 2026-05-07 20:43:45 +00:00
james 2a01d6f5b8 Handle semver pre-release versions in isNewer; restore full version string in beta workflow 2026-05-07 22:42:35 +02:00
github-actions[bot] af920eda83 Beta release v1.1.2-beta.1 2026-05-07 20:41:57 +00:00
james 6b6802eaea Fix beta version format in version-beta.json — strip pre-release suffix for isNewer compatibility 2026-05-07 22:40:34 +02:00
github-actions[bot] e9d29db2ee Beta release v1.1.2-beta.1 2026-05-07 20:36:45 +00:00
james 9be5069aac v1.1.2-beta.1 — remove redundant window labels from card rows 2026-05-07 22:36:02 +02:00
james cc9e2aaba1 Add beta release workflow, restrict stable to non-prerelease tags 2026-05-07 22:33:47 +02:00
github-actions[bot] 19bb6459ed Release v1.1.1 2026-05-07 20:28:16 +00:00
22 changed files with 338 additions and 96 deletions
+30
View File
@@ -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.
+22
View File
@@ -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.
+79
View File
@@ -0,0 +1,79 @@
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
+19 -3
View File
@@ -7,6 +7,7 @@ on:
jobs:
release:
if: ${{ !contains(github.ref_name, '-') }}
runs-on: macos-latest
permissions:
contents: write
@@ -65,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: |
@@ -76,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
+19
View File
@@ -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
+8 -3
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/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 {
}
}
+28 -25
View File
@@ -51,7 +51,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)
@@ -212,7 +212,7 @@ struct LimitCard: View {
AgentIconView(agent: .claude, size: 14)
Text("Claude")
.font(.system(size: 13, weight: .semibold))
Text("· \(vm.planLabel) · \(limit.window.label)")
Text("· \(vm.planLabel)")
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
@@ -429,7 +429,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
@@ -789,25 +791,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))
@@ -1040,9 +1040,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 +1053,7 @@ struct UpdateBanner: View {
.padding(.vertical, 10)
.background(Color.blue.opacity(0.08))
.overlay(Divider(), alignment: .bottom)
.onTapGesture { showSheet = true }
}
}
@@ -1170,3 +1171,5 @@ struct UpdateSheet: View {
.frame(width: 380)
}
}
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.1</string>
<string>1.1.4-beta.2</string>
<key>CFBundleVersion</key>
<string>34</string>
<string>42</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
+23 -5
View File
@@ -318,13 +318,31 @@ class UpdateManager: ObservableObject {
}
private func isNewer(_ version: String, than current: String) -> Bool {
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
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
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
}
}
+82 -40
View File
@@ -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?
@@ -26,17 +25,20 @@ class UsageViewModel: ObservableObject {
}
}
private var cachedOrgId: String?
private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id")
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()
}
@@ -46,6 +48,8 @@ class UsageViewModel: ObservableObject {
let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
await store.removeData(ofTypes: types, for: claudeRecords)
cachedOrgId = nil
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -63,18 +67,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()
// Fetch org ID dynamically if not cached
if cachedOrgId == nil {
let (fetchedOrgId, fetchedPlan) = try await fetchOrgId()
cachedOrgId = fetchedOrgId
if let id = cachedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
}
if let plan = fetchedPlan { planLabel = plan }
}
guard let orgId = cachedOrgId else {
throw AppError.notAuthenticated
}
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
overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date()
isSignedIn = true
@@ -84,8 +99,9 @@ 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
@@ -106,25 +122,69 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - Fetch org ID from bootstrap
private func fetchOrgId() async throws -> (orgId: String?, planLabel: 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 claudeCookies.isEmpty { throw AppError.notAuthenticated }
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 }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) }
func planFromOrg(_ org: [String: Any]?) -> String? {
guard let caps = org?["capabilities"] as? [String] else { return nil }
guard let cap = caps.first(where: { $0.hasPrefix("claude_") }) else { return nil }
let name = String(cap.dropFirst("claude_".count))
return name.prefix(1).uppercased() + name.dropFirst().lowercased()
}
// account.memberships[0].organization.uuid
if let account = json["account"] as? [String: Any],
let memberships = account["memberships"] as? [[String: Any]],
let org = memberships.first?["organization"] as? [String: Any],
let uuid = org["uuid"] as? String {
return (uuid, planFromOrg(org))
}
// root memberships (older API shape)
if let memberships = json["memberships"] as? [[String: Any]],
let org = memberships.first?["organization"] as? [String: Any],
let uuid = org["uuid"] as? String {
return (uuid, planFromOrg(org))
}
// root organizations array
if let orgs = json["organizations"] as? [[String: Any]],
let uuid = orgs.first?["uuid"] as? String {
return (uuid, planFromOrg(orgs.first))
}
return (nil, nil)
}
// MARK: - Fetch usage
private func claudeCookieHeader() async -> String? {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
return HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"]
}
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")
}
if let cookie = await claudeCookieHeader() { req.setValue(cookie, 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)
}
@@ -132,11 +192,7 @@ class UsageViewModel: ObservableObject {
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")
}
if let cookie = await claudeCookieHeader() { req.setValue(cookie, 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],
@@ -149,11 +205,7 @@ class UsageViewModel: ObservableObject {
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")
}
if let cookie = await claudeCookieHeader() { req.setValue(cookie, 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)
@@ -163,11 +215,7 @@ class UsageViewModel: ObservableObject {
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")
}
if let cookie = await claudeCookieHeader() { req.setValue(cookie, 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)
@@ -241,14 +289,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 +304,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 +312,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName]
)
}
firedThresholds[key] = fired
previousPercents[key] = curr
}
+10 -9
View File
@@ -8,8 +8,9 @@
[![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.1.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![Version](https://img.shields.io/badge/version-1.1.3-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.1.4--beta.1-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.1.4-beta.1) <!-- 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/>
+3 -6
View File
@@ -1,7 +1,4 @@
## What's new in v1.1.1
## What's new in v1.1.4-beta.2
### Clearer limit labels
The 5hr and 7 Day usage cards now have clear section headers so it's immediately obvious which limit is which.
### Login window fix
The 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.
### Improvements
- Plan name (e.g. Pro, Max) is now read from the API instead of being hardcoded
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.1.4-beta.1",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.4-beta.1/ClaudeChecker.zip",
"notes": "## What's new in v1.1.4-beta.1\n\n### Bug fix\n- Org ID is now fetched dynamically from the bootstrap API instead of being hardcoded — the app now works for all users, not just the developer's account"
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": "0.0.1-beta.33",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.33/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.33\r\n\r\n- Fix: gauge percentage text is now vertically centered within the dial\r\n- Remove: debug banner no longer appears on the main panel"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.1.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.0/ClaudeChecker.zip",
"notes": "## What's new in v1.1.0\n\n### Clickable notifications\nTapping a limit warning or reset notification opens the main window. Tapping an update notification opens the update sheet directly — no need to manually click the menubar icon.\n\n### Update indicator in menubar\nWhen an update is available, the menubar shows **↑ Update** alongside your usage percentages so you know at a glance without opening the app.\n\n### Release notes render correctly\nThe update banner and update sheet now render markdown formatting — headers, **bold text**, and bullet points display properly.\n\n### Update notification improved\nThe update popup now reads **\"Tap to view release notes & update now!\"** making it clear you can read what changed before installing.\n\n### Main window always opens on click\nClosing the app while Settings was open would cause the next click to reopen straight to Settings. The app now always returns to the main window."
"version": "1.1.3",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip",
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
}