Compare commits

..
130 Commits
Author SHA1 Message Date
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
James Bone abf05b04fc v1.1.1 — clearer limit labels and login window fix 2026-05-07 22:25:26 +02:00
github-actions[bot] 849e94c942 Release v1.1.0 2026-05-07 16:34:12 +00:00
superdooper86 d76c7496bb Release v1.1.0 2026-05-07 18:33:04 +02:00
github-actions[bot] 45a3925f65 Release v1.0.12 2026-05-07 16:29:13 +00:00
superdooper86 113e91583d Release v1.0.12 2026-05-07 18:28:11 +02:00
github-actions[bot] c60fb93e1a Release v1.0.11 2026-05-07 16:24:21 +00:00
superdooper86 b16c24cb44 Release v1.0.11 — update indicator in menubar, undismissable banner
- Menubar shows ↑ Update alongside limits when an update is available
- Removed × dismiss button from update banner; it stays until installed
2026-05-07 18:23:26 +02:00
superdooper86 89a79c6f13 Temporary: bump version.json to 1.1.1 for update testing 2026-05-07 18:14:08 +02:00
github-actions[bot] aa529835f7 Release v1.0.10 2026-05-07 16:13:13 +00:00
superdooper86 b5c3cc6379 Rerelease as v1.0.10 (was incorrectly versioned as v1.1.0) 2026-05-07 18:11:45 +02:00
github-actions[bot] c8adaf1baf Release v1.1.0 2026-05-07 16:02:51 +00:00
superdooper86 3e99e1417e Release v1.1.0 — always open to main window
Post popoverWillOpen notification on show; ContentView resets showSettings
on receive so reopening always lands on the main window, not Settings.
2026-05-07 18:02:04 +02:00
github-actions[bot] e41d3d0c0f Release v1.0.9 2026-05-07 15:56:24 +00:00
superdooper86 1078951c43 Release v1.0.9 — fix update notification popup showing raw markdown
Sync UpdateNotificationWindow.swift: showUpdate now always uses the
static subtitle "Tap to view release notes & update now!" instead of
passing the full release notes text to the popup.
2026-05-07 17:55:05 +02:00
github-actions[bot] ed6a5e1b51 Release v1.0.8 2026-05-07 15:49:47 +00:00
superdooper86 8701fb1326 Release v1.0.8 2026-05-07 17:48:32 +02:00
github-actions[bot] 247b5fc202 Release v1.0.7 2026-05-07 15:45:02 +00:00
superdooper86 255de797f8 Release v1.0.7 — fix markdown rendering in release notes
Add ReleaseNotesText view that pre-processes headers (##, ###) to bold
and converts - bullets to • before passing to AttributedString, which
only supports inline markdown. Replaces both Text(.init()) call sites.
2026-05-07 17:43:46 +02:00
github-actions[bot] 0046c53283 Release v1.0.6 2026-05-07 15:39:24 +00:00
superdooper86 8d222d63f0 Release v1.0.6 — improve update notification subtitle
Change notification prompt to "Tap to view release notes & update now!"
2026-05-07 17:38:34 +02:00
github-actions[bot] 5720370649 Release v1.0.5 2026-05-07 15:31:00 +00:00
superdooper86 0843730ae1 Release v1.0.5 — fix markdown rendering in release notes
Use Text(.init()) to parse markdown in update sheet and banner.
Notification popup now shows a fixed subtitle instead of raw notes.
2026-05-07 17:29:29 +02:00
superdooper86 9c030fcdd5 Fix version.json containing invalid JSON (literal newlines in notes)
Use jq to build version.json so multiline release notes are properly
escaped as \n sequences. Also fix current version.json for v1.0.4.
2026-05-07 17:22:57 +02:00
github-actions[bot] dd68ff39d6 Release v1.0.4 2026-05-07 15:16:04 +00:00
superdooper86 b76bdf3bc1 Release v1.0.4 — clickable notifications
Notifications are now interactive: limit warnings and resets open the
main window on tap; update notifications open the update sheet directly.

Bump version to 1.0.4 (build 23).
2026-05-07 17:14:29 +02:00
github-actions[bot] c31950fd76 Release v1.0.3 2026-05-07 15:09:05 +00:00
superdooper86 4e6ae21825 Bump version to 1.0.3 2026-05-07 17:08:12 +02:00
superdooper86 c0bf2e4873 Clickable notifications — limit opens popover, update opens update sheet 2026-05-07 17:07:17 +02:00
github-actions[bot] 081d4804ab Release v1.0.2 2026-05-07 15:02:07 +00:00
24 changed files with 481 additions and 146 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
+25 -10
View File
@@ -7,6 +7,7 @@ on:
jobs: jobs:
release: release:
if: ${{ !contains(github.ref_name, '-') }}
runs-on: macos-latest runs-on: macos-latest
permissions: permissions:
contents: write contents: write
@@ -50,13 +51,12 @@ jobs:
- name: Update version.json - name: Update version.json
run: | run: |
cat > version.json << JSON notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Release ${{ steps.version.outputs.version }}")
{ jq -n \
"version": "${{ steps.version.outputs.version }}", --arg version "${{ steps.version.outputs.version }}" \
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip", --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
"notes": "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')" --arg notes "$notes" \
} '{version: $version, url: $url, notes: $notes}' > version.json
JSON
- name: Update Homebrew cask - name: Update Homebrew cask
run: | run: |
@@ -66,10 +66,24 @@ jobs:
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true ../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
- name: Update README badge - name: Update README badge
env:
NEW_VERSION: ${{ steps.version.outputs.version }}
run: | run: |
sed -i '' \ python3 -c "
's/version-[0-9][^-]*-orange/version-${{ steps.version.outputs.version }}-orange/' \ import os, re
README.md 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 - name: Commit version.json and README
run: | run: |
@@ -77,6 +91,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.json README.md git add version.json README.md
git commit -m "Release ${{ github.ref_name }}" git commit -m "Release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main git push origin HEAD:main
- name: Update Homebrew tap - 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
+22 -4
View File
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default() config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config) 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 // Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -40,14 +40,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Popover // Popover
popover = NSPopover() popover = NSPopover()
popover?.contentSize = NSSize(width: 480, height: 640)
popover?.behavior = .transient popover?.behavior = .transient
popover?.animates = true popover?.animates = true
popover?.contentViewController = NSHostingController( let hc = NSHostingController(
rootView: ContentView() rootView: ContentView()
.environmentObject(usageViewModel) .environmentObject(usageViewModel)
.environmentObject(updateManager) .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 // React to limits or menubar toggle changes
usageViewModel.$limits usageViewModel.$limits
@@ -60,6 +63,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
.sink { [weak self] _ in self?.updateStatusItem() } .sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables) .store(in: &cancellables)
updateManager.$updateAvailable
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
// Refresh timer // Refresh timer
scheduleTimer(interval: usageViewModel.refreshInterval) scheduleTimer(interval: usageViewModel.refreshInterval)
@@ -166,7 +174,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "" let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? ""
button.image = nil button.image = nil
button.attributedTitle = NSAttributedString(string: "") button.attributedTitle = NSAttributedString(string: "")
button.title = "\(fhStr) \(sdStr)" 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.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else { } else {
button.title = "" button.title = ""
@@ -199,6 +213,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func showPopover() { @objc func showPopover() {
guard let button = statusItem?.button, let popover else { return } guard let button = statusItem?.button, let popover else { return }
if !popover.isShown { if !popover.isShown {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
@@ -209,6 +224,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if popover.isShown { if popover.isShown {
popover.performClose(nil) popover.performClose(nil)
} else { } else {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
@@ -223,3 +239,5 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
+66 -21
View File
@@ -47,13 +47,29 @@ struct ContentView: View {
EmptyStateView() EmptyStateView()
.padding(.vertical, 40) .padding(.vertical, 40)
} else { } else {
VStack(spacing: 1) { VStack(spacing: 8) {
ForEach(vm.limits) { limit in 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) 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)
} }
} }
.background(Color.primary.opacity(0.05)) }
.padding(.vertical, 1) .padding(.bottom, 4)
// Extra credits row // Extra credits row
if let extra = vm.extraUsage, extra.isEnabled { if let extra = vm.extraUsage, extra.isEnabled {
@@ -94,6 +110,13 @@ struct ContentView: View {
} }
.frame(width: 480) .frame(width: 480)
.animation(.easeInOut(duration: 0.2), value: showSettings) .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) { .sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) { LoginSheetView(isPresented: $showLogin) {
Task { await vm.refresh() } Task { await vm.refresh() }
@@ -189,7 +212,7 @@ struct LimitCard: View {
AgentIconView(agent: .claude, size: 14) AgentIconView(agent: .claude, size: 14)
Text("Claude") Text("Claude")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Text("· \(vm.planLabel) · \(limit.window.label)") Text("· \(vm.planLabel)")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
@@ -406,7 +429,9 @@ struct SparklineView: View {
var body: some View { var body: some View {
GeometryReader { geo in 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 w = geo.size.width
let h = geo.size.height let h = geo.size.height
let minV = data.min() ?? 0 let minV = data.min() ?? 0
@@ -766,6 +791,7 @@ struct SettingsView: View {
.padding(.vertical, 1) .padding(.vertical, 1)
.background(Color.primary.opacity(0.06)) .background(Color.primary.opacity(0.06))
.cornerRadius(4) .cornerRadius(4)
}
if updater.updateAvailable { if updater.updateAvailable {
Text("v\(updater.latestVersion) available") Text("v\(updater.latestVersion) available")
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
@@ -774,7 +800,6 @@ struct SettingsView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Color.blue) .background(Color.blue)
.cornerRadius(4) .cornerRadius(4)
.fixedSize()
} else if updater.betaAvailable { } else if updater.betaAvailable {
Text("v\(updater.latestBetaVersion) available") Text("v\(updater.latestBetaVersion) available")
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
@@ -783,8 +808,6 @@ struct SettingsView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Color.orange) .background(Color.orange)
.cornerRadius(4) .cornerRadius(4)
.fixedSize()
}
} }
Text("Built by superdooper86 & claude") Text("Built by superdooper86 & claude")
.font(.system(size: 11)) .font(.system(size: 11))
@@ -973,6 +996,36 @@ 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 // MARK: - Update Banner
struct UpdateBanner: View { struct UpdateBanner: View {
@@ -987,28 +1040,20 @@ struct UpdateBanner: View {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text("Update available — v\(updater.latestVersion)") Text("Update available — v\(updater.latestVersion)")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
if !updater.releaseNotes.isEmpty { Text("Tap Update to view what's new & install.")
Text(updater.releaseNotes)
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} }
Spacer() Spacer()
Button("Update") { showSheet = true } Button("Update") { showSheet = true }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.small) .controlSize(.small)
Button { updater.updateAvailable = false } label: {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color.blue.opacity(0.08)) .background(Color.blue.opacity(0.08))
.overlay(Divider(), alignment: .bottom) .overlay(Divider(), alignment: .bottom)
.onTapGesture { showSheet = true }
} }
} }
@@ -1057,9 +1102,7 @@ struct UpdateSheet: View {
.foregroundColor(.blue) .foregroundColor(.blue)
} }
if !updater.releaseNotes.isEmpty { if !updater.releaseNotes.isEmpty {
Text(updater.releaseNotes) ReleaseNotesText(text: updater.releaseNotes)
.font(.system(size: 12))
.foregroundColor(.secondary)
} }
} }
Spacer() Spacer()
@@ -1128,3 +1171,5 @@ struct UpdateSheet: View {
.frame(width: 380) .frame(width: 380)
} }
} }
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.2</string> <string>1.1.4-beta.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>21</string> <string>41</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+17 -2
View File
@@ -24,25 +24,31 @@ struct LoginWebView: NSViewRepresentable {
class Coordinator: NSObject, WKNavigationDelegate { class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: () -> Void let onAuthenticated: () -> Void
var didAuthenticate = false
init(onAuthenticated: @escaping () -> Void) { init(onAuthenticated: @escaping () -> Void) {
self.onAuthenticated = onAuthenticated self.onAuthenticated = onAuthenticated
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard !didAuthenticate else { return }
// Don't fire on the login/auth pages themselves
if let url = webView.url?.absoluteString,
url.contains("/login") || url.contains("/auth") { return }
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
let hasSession = cookies.contains { let hasSession = cookies.contains {
$0.domain.contains("claude.ai") && $0.domain.contains("claude.ai") &&
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token") ($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
} }
if hasSession { guard hasSession, !self.didAuthenticate else { return }
self.didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated() self.onAuthenticated()
} }
} }
} }
} }
}
} }
// MARK: - Login Sheet View // MARK: - Login Sheet View
@@ -58,10 +64,19 @@ struct LoginSheetView: View {
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude") Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Spacer() Spacer()
if authenticated {
Button("Done") {
isPresented = false
onDone()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Button("Cancel") { isPresented = false } Button("Cancel") { isPresented = false }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
} }
}
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color(nsColor: .windowBackgroundColor)) .background(Color(nsColor: .windowBackgroundColor))
+23 -5
View File
@@ -318,13 +318,31 @@ class UpdateManager: ObservableObject {
} }
private func isNewer(_ version: String, than current: String) -> Bool { private func isNewer(_ version: String, than current: String) -> Bool {
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } } func parse(_ v: String) -> (base: [Int], pre: [Int]?) {
let a = toInts(version), b = toInts(current) guard !v.isEmpty else { return ([], nil) }
for i in 0..<max(a.count, b.count) { let halves = v.split(separator: "-", maxSplits: 1)
let av = i < a.count ? a[i] : 0 let base = halves[0].split(separator: ".").compactMap { Int($0) }
let bv = i < b.count ? b[i] : 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 } 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 return false
} }
} }
+30 -7
View File
@@ -14,7 +14,7 @@ class UpdateNotificationWindowController: NSWindowController {
// Called when a new update is detected while running // Called when a new update is detected while running
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) { static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
showWindow(version: version, subtitle: notes.isEmpty ? "Tap to update now." : notes, near: statusItem, isUpdate: true) showWindow(version: version, subtitle: "Tap to view release notes & update now!", near: statusItem, isUpdate: true)
} }
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) { static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
@@ -39,7 +39,12 @@ class UpdateNotificationWindowController: NSWindowController {
sharedLimit?.close() sharedLimit?.close()
let content = UpdateNotificationView( let content = UpdateNotificationView(
version: "", subtitle: subtitle, isUpdate: false, version: "", subtitle: subtitle, isUpdate: false,
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor,
onTap: {
sharedLimit?.close()
sharedLimit = nil
NotificationCenter.default.post(name: .showPopover, object: nil)
}
) { ) {
sharedLimit?.close() sharedLimit?.close()
sharedLimit = nil sharedLimit = nil
@@ -53,7 +58,16 @@ class UpdateNotificationWindowController: NSWindowController {
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) { private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
shared?.close() shared?.close()
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) { 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)
}
}
) {
shared?.close() shared?.close()
shared = nil shared = nil
} }
@@ -129,6 +143,7 @@ struct UpdateNotificationView: View {
var titleText: String? = nil var titleText: String? = nil
var badgeIcon: String? = nil var badgeIcon: String? = nil
var badgeColor: Color? = nil var badgeColor: Color? = nil
var onTap: (() -> Void)? = nil
let onDismiss: () -> Void let onDismiss: () -> Void
private var effectiveTitle: String { private var effectiveTitle: String {
@@ -142,6 +157,8 @@ struct UpdateNotificationView: View {
} }
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: { onTap?() }) {
HStack(spacing: 12) { HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
@@ -159,13 +176,21 @@ struct UpdateNotificationView: View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(effectiveTitle) Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundColor(.primary)
Text(subtitle) Text(subtitle)
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Spacer(minLength: 8) Spacer(minLength: 24)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button(action: onDismiss) { Button(action: onDismiss) {
Image(systemName: "xmark") Image(systemName: "xmark")
@@ -173,10 +198,8 @@ struct UpdateNotificationView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(14)
} }
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.background( .background(
RoundedRectangle(cornerRadius: 13) RoundedRectangle(cornerRadius: 13)
.fill(Color(nsColor: .windowBackgroundColor)) .fill(Color(nsColor: .windowBackgroundColor))
+71 -35
View File
@@ -3,7 +3,6 @@ import WebKit
@MainActor @MainActor
class UsageViewModel: ObservableObject { class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = [] @Published var limits: [AgentLimit] = []
@Published var isLoading = false @Published var isLoading = false
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@@ -26,17 +25,20 @@ class UsageViewModel: ObservableObject {
} }
} }
private var cachedOrgId: String?
private var burnHistoryStore: [String: [Double]] = [:] private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24 private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:] private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:] private var firedThresholds: [String: Set<Int>] = [:]
init() { 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") let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60 refreshInterval = saved > 0 ? saved : 60
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true 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() loadPlaceholderData()
} }
@@ -46,6 +48,8 @@ class UsageViewModel: ObservableObject {
let records = await store.dataRecords(ofTypes: types) let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") } let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
await store.removeData(ofTypes: types, for: claudeRecords) await store.removeData(ofTypes: types, for: claudeRecords)
cachedOrgId = nil
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false isSignedIn = false
isNotAuthenticated = true isNotAuthenticated = true
lastUpdated = nil lastUpdated = nil
@@ -63,7 +67,17 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false } defer { isLoading = false }
do { do {
let orgId = cachedOrgId! // Fetch org ID dynamically if not cached
if cachedOrgId == nil {
cachedOrgId = try await fetchOrgId()
if let id = cachedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
}
}
guard let orgId = cachedOrgId else {
throw AppError.notAuthenticated
}
async let usageFetch = fetchUsage(orgId: orgId) async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId) async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId) async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
@@ -84,8 +98,9 @@ class UsageViewModel: ObservableObject {
history.append(limits[i].usedPercent) history.append(limits[i].usedPercent)
if history.count > maxHistorySamples { history.removeFirst() } if history.count > maxHistorySamples { history.removeFirst() }
burnHistoryStore[key] = history 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) checkLimitNotifications(for: limits)
} catch AppError.notAuthenticated { } catch AppError.notAuthenticated {
isNotAuthenticated = true isNotAuthenticated = true
@@ -106,25 +121,62 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - Fetch org ID from bootstrap
private func fetchOrgId() 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 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 }
// 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
}
// 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
}
// root organizations array
if let orgs = json["organizations"] as? [[String: Any]],
let uuid = orgs.first?["uuid"] as? String {
return uuid
}
return nil
}
// MARK: - Fetch usage // 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 { private func fetchUsage(orgId: String) async throws -> UsageResponse {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")! let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept") req.setValue("application/json", forHTTPHeaderField: "accept")
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
// 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) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError } guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated } if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError } guard http.statusCode == 200 else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data) return try JSONDecoder().decode(UsageResponse.self, from: data)
} }
@@ -132,11 +184,7 @@ class UsageViewModel: ObservableObject {
let url = URL(string: "https://claude.ai/api/bootstrap")! let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept") req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
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) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -149,11 +197,7 @@ class UsageViewModel: ObservableObject {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")! let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept") req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
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) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data) return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
@@ -163,11 +207,7 @@ class UsageViewModel: ObservableObject {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")! let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept") req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies() if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
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) let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data) return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
@@ -241,14 +281,11 @@ class UsageViewModel: ObservableObject {
private func checkLimitNotifications(for limits: [AgentLimit]) { private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95] let thresholds = [80, 95]
for limit in limits { for limit in limits {
let key = limit.window.rawValue let key = limit.window.rawValue
let curr = limit.usedPercent let curr = limit.usedPercent
let prev = previousPercents[key] let prev = previousPercents[key]
var fired = firedThresholds[key] ?? [] var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds { for t in thresholds {
let threshold = Double(t) let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue } guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
@@ -259,8 +296,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName, "percent": t] userInfo: ["windowName": limit.window.displayName, "percent": t]
) )
} }
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 { if let prev, prev > 50, curr < 20 {
fired.removeAll() fired.removeAll()
NotificationCenter.default.post( NotificationCenter.default.post(
@@ -269,7 +304,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName] userInfo: ["windowName": limit.window.displayName]
) )
} }
firedThresholds[key] = fired firedThresholds[key] = fired
previousPercents[key] = curr previousPercents[key] = curr
} }
@@ -307,7 +341,9 @@ extension Notification.Name {
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged") static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
static let closePopover = Notification.Name("closePopover") static let closePopover = Notification.Name("closePopover")
static let showPopover = Notification.Name("showPopover") static let showPopover = Notification.Name("showPopover")
static let popoverWillOpen = Notification.Name("popoverWillOpen")
static let updateDetected = Notification.Name("updateDetected") static let updateDetected = Notification.Name("updateDetected")
static let limitWarning = Notification.Name("limitWarning") static let limitWarning = Notification.Name("limitWarning")
static let limitReset = Notification.Name("limitReset") static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet")
} }
+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/) [![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) [![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.0.1-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) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
<!-- BETA_BADGE -->
</div> </div>
@@ -29,21 +30,21 @@ No API key required. It reads directly from your `claude.ai` session using a bui
<table> <table>
<tr> <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/main-v3.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/settings-v3.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
</tr> </tr>
<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/update-banner.png" width="280" alt="Update available" /><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/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>
<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-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> <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>
<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> </table>
<br/> <br/>
+4
View File
@@ -0,0 +1,4 @@
## What's new in v1.1.4-beta.1
### Bug fix
- 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
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.3-beta.5",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3-beta.5/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\" instead of \"5hr\"\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 \u2014 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"
}
+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.0.1", "version": "1.1.3",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.0.1/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip",
"notes": "Update banner can now be dismissed. Fixes update state not clearing correctly." "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"
} }