Compare commits

..
Author SHA1 Message Date
SuperDooper 9c8e0dcdb6 fix: use InformationalVersion for semver, auto-check beta if running pre-release 2026-05-08 13:53:53 +02:00
SuperDooper 0010051ede feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:50 +02:00
SuperDooper 98bd736a66 feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:49 +02:00
SuperDooper 251efdbb0b feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:47 +02:00
SuperDooper f219b65650 feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:46 +02:00
SuperDooper b0907d17b5 feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:45 +02:00
SuperDooper d3aa3dc6bd feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:44 +02:00
SuperDooper 797bf702cd feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:33 +02:00
SuperDooper 58a09e8701 feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:32 +02:00
SuperDooper 01e16e3abe feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:30 +02:00
SuperDooper 8e4b23d4de feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:29 +02:00
SuperDooper 708b55d919 feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:28 +02:00
SuperDooper 59c8c390b6 fix: await refresh after login, update settings when UserEmail/IsSignedIn change 2026-05-08 13:28:11 +02:00
SuperDooper ad525ef8d1 fix: only auto-close login on explicit post-login redirect, not initial load 2026-05-08 13:14:48 +02:00
SuperDooper e6fa66bc73 fix: suppress Deactivated hide during login dialog, restore popup after sign-in 2026-05-08 13:14:47 +02:00
SuperDooper 2d000bca5e fix: correct session cookie detection and auto-close on login 2026-05-08 13:07:06 +02:00
SuperDooper c7a663c42b fix: use RELEASE_TOKEN to bypass branch protection when writing version JSON 2026-05-08 12:59:04 +02:00
SuperDooper a182b7ca59 fix: use RELEASE_TOKEN to bypass branch protection when writing version JSON 2026-05-08 12:59:03 +02:00
SuperDooper 182acb1f21 fix: use GitHub API to update version JSON, avoid git push to protected main 2026-05-08 12:52:21 +02:00
SuperDooper 3ff5067d49 fix: use GitHub API to update version JSON, avoid git push to protected main 2026-05-08 12:52:20 +02:00
SuperDooper 2816a5cedb fix: remove duplicate Color alias (now in GlobalUsings.cs) 2026-05-08 12:40:03 +02:00
SuperDooper a4a4d70530 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:04 +02:00
SuperDooper 073d3b14ef fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:02 +02:00
SuperDooper 5603b94f8a fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:01 +02:00
SuperDooper 28a449b060 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:00 +02:00
SuperDooper d2ba7365b0 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:59 +02:00
SuperDooper 93862686b6 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:57 +02:00
SuperDooper 8170f3a1b3 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:56 +02:00
SuperDooper 5fbdaab2d0 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:55 +02:00
SuperDooper 7db17fb53d fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:54 +02:00
SuperDooper f6f1982390 fix: add explicit using aliases to resolve System.Drawing ambiguity 2026-05-08 12:24:36 +02:00
SuperDooper de61499bab fix: add explicit using aliases to resolve System.Drawing ambiguity 2026-05-08 12:24:35 +02:00
SuperDooper 6e95221e0b fix: rewrite BuildCard with clean imperative code — no invalid method chaining 2026-05-08 12:22:54 +02:00
SuperDooper 12ce671205 fix: checkout main before updating version-windows-beta.json 2026-05-08 12:20:22 +02:00
SuperDooper 7a74e19a20 fix: checkout main before updating version-windows.json 2026-05-08 12:20:21 +02:00
SuperDooper 4c1ed861ed chore: add Windows app icon (ico) 2026-05-08 12:17:55 +02:00
SuperDooper a1df7cd38a chore: add Windows app icon (png) 2026-05-08 12:17:53 +02:00
SuperDooper 311385a716 docs: add Windows release notes file 2026-05-08 12:16:07 +02:00
SuperDooper 3abe5e4c9b chore: seed version-windows-beta.json 2026-05-08 12:16:06 +02:00
SuperDooper 5457e00ef0 chore: seed version-windows.json 2026-05-08 12:16:04 +02:00
SuperDooper 00bd5884db ci: add Windows beta release workflow 2026-05-08 12:16:03 +02:00
SuperDooper a3ce649946 ci: add Windows stable release workflow 2026-05-08 12:16:02 +02:00
SuperDooper c97943bfa0 fix: point Windows updater to version-windows.json files 2026-05-08 12:16:01 +02:00
SuperDooper e89df508bd feat(windows): add README.md 2026-05-08 12:12:58 +02:00
SuperDooper de247e545b feat(windows): add Settings.settings 2026-05-08 12:12:56 +02:00
SuperDooper 4f481a06c3 feat(windows): add SparklineControl.cs 2026-05-08 12:12:55 +02:00
SuperDooper c223bfba72 feat(windows): add GaugeControl.cs 2026-05-08 12:12:54 +02:00
SuperDooper b388191191 feat(windows): add LoginWindow.xaml.cs 2026-05-08 12:12:53 +02:00
SuperDooper 4896d28c8c feat(windows): add LoginWindow.xaml 2026-05-08 12:12:52 +02:00
SuperDooper 0e248dc331 feat(windows): add PopupWindow.xaml.cs 2026-05-08 12:12:50 +02:00
SuperDooper 2277a29a3e feat(windows): add PopupWindow.xaml 2026-05-08 12:12:49 +02:00
SuperDooper 605b11e849 feat(windows): add UpdateManager.cs 2026-05-08 12:12:48 +02:00
SuperDooper bb4c28c751 feat(windows): add UsageViewModel.cs 2026-05-08 12:12:47 +02:00
SuperDooper e0c73211a0 feat(windows): add Models.cs 2026-05-08 12:12:45 +02:00
SuperDooper ceb5951f24 feat(windows): add App.xaml.cs 2026-05-08 12:12:44 +02:00
SuperDooper c0ed68d023 feat(windows): add App.xaml 2026-05-08 12:12:43 +02:00
SuperDooper f3d8a50dc6 feat(windows): add ClaudeCheckerWindows.csproj 2026-05-08 12:12:42 +02: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
45 changed files with 2393 additions and 55 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
@@ -0,0 +1,77 @@
name: Release Windows Beta
on:
push:
tags:
- 'win-v*-beta*'
jobs:
release-windows-beta:
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Get version from tag
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#win-v}" >> $GITHUB_OUTPUT
- name: Publish
run: |
dotnet publish windows/ClaudeCheckerWindows.csproj `
-c Release -r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:Version=${{ steps.version.outputs.version }} `
-o publish
- name: Zip
shell: pwsh
run: |
Copy-Item -Recurse windows/Assets publish/Assets
Compress-Archive -Path publish/* -DestinationPath ClaudeChecker-Windows.zip
- name: Create GitHub pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
gh release create "$GITHUB_REF_NAME" ClaudeChecker-Windows.zip \
--title "$GITHUB_REF_NAME" \
--prerelease \
--notes "$notes"
- name: Update version-windows-beta.json on main
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
content=$(jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Windows.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' | base64 -w0)
sha=$(gh api "repos/${{ github.repository }}/contents/version-windows-beta.json?ref=main" --jq .sha 2>/dev/null || echo "")
if [ -n "$sha" ]; then
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows-beta.json" \
--field "message=Windows beta release ${{ github.ref_name }}" \
--field "content=$content" \
--field "branch=main" \
--field "sha=$sha"
else
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows-beta.json" \
--field "message=Windows beta release ${{ github.ref_name }}" \
--field "content=$content" \
--field "branch=main"
fi
+77
View File
@@ -0,0 +1,77 @@
name: Release Windows
on:
push:
tags:
- 'win-v[0-9]*'
- '!win-v*-*'
jobs:
release-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Get version from tag
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#win-v}" >> $GITHUB_OUTPUT
- name: Publish
run: |
dotnet publish windows/ClaudeCheckerWindows.csproj `
-c Release -r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:Version=${{ steps.version.outputs.version }} `
-o publish
- name: Zip
shell: pwsh
run: |
Copy-Item -Recurse windows/Assets publish/Assets
Compress-Archive -Path publish/* -DestinationPath ClaudeChecker-Windows.zip
- name: Create GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
gh release create "$GITHUB_REF_NAME" ClaudeChecker-Windows.zip \
--title "$GITHUB_REF_NAME" \
--notes "$notes"
- name: Update version-windows.json on main
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
content=$(jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Windows.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' | base64 -w0)
sha=$(gh api "repos/${{ github.repository }}/contents/version-windows.json?ref=main" --jq .sha 2>/dev/null || echo "")
if [ -n "$sha" ]; then
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows.json" \
--field "message=Windows release ${{ github.ref_name }}" \
--field "content=$content" \
--field "branch=main" \
--field "sha=$sha"
else
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows.json" \
--field "message=Windows release ${{ github.ref_name }}" \
--field "content=$content" \
--field "branch=main"
fi
+19 -3
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
@@ -65,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: |
@@ -76,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
+7 -2
View File
@@ -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
@@ -236,3 +239,5 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
+28 -25
View File
@@ -51,7 +51,7 @@ struct ContentView: View {
ForEach(vm.limits) { limit in ForEach(vm.limits) { limit in
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { 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)) .font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.textCase(.uppercase) .textCase(.uppercase)
@@ -212,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()
@@ -429,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
@@ -789,25 +791,23 @@ 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 { }
Text("v\(updater.latestVersion) available") if updater.updateAvailable {
.font(.system(size: 10, weight: .medium)) Text("v\(updater.latestVersion) available")
.foregroundColor(.white) .font(.system(size: 10, weight: .medium))
.padding(.horizontal, 6) .foregroundColor(.white)
.padding(.vertical, 2) .padding(.horizontal, 6)
.background(Color.blue) .padding(.vertical, 2)
.cornerRadius(4) .background(Color.blue)
.fixedSize() .cornerRadius(4)
} 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))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.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))
@@ -1040,9 +1040,9 @@ 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.")
ReleaseNotesText(text: updater.releaseNotes, font: .system(size: 11)) .font(.system(size: 11))
} .foregroundColor(.secondary)
} }
Spacer() Spacer()
Button("Update") { showSheet = true } Button("Update") { showSheet = true }
@@ -1053,6 +1053,7 @@ struct UpdateBanner: View {
.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 }
} }
} }
@@ -1170,3 +1171,5 @@ struct UpdateSheet: View {
.frame(width: 380) .frame(width: 380)
} }
} }
+6 -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.1.1</string> <string>1.1.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>34</string> <string>40</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
@@ -45,3 +45,7 @@
</dict> </dict>
</dict> </dict>
</plist> </plist>
+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
} }
} }
+6 -1
View File
@@ -37,6 +37,9 @@ class UsageViewModel: ObservableObject {
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()
} }
@@ -84,8 +87,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
@@ -313,3 +317,4 @@ extension Notification.Name {
static let limitReset = Notification.Name("limitReset") static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet") 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.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) [![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/>
+10 -5
View File
@@ -1,7 +1,12 @@
## What's new in v1.1.1 ## What's new in v1.1.3
### Clearer limit labels ### UI polish
The 5hr and 7 Day usage cards now have clear section headers so it's immediately obvious which limit is which. - Limit headers now read "5 Hour Limit" and "7 Day Limit"
- Tapping anywhere on the blue update banner opens the update window
### Login window fix ### Session Diary 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. - Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh
### Window sizing fixes
- The popover now resizes correctly when switching between the main view and settings
- Background colour no longer shows through during the resize animation
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.0",
"url": "",
"notes": ""
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": "0.0.0",
"url": "",
"notes": ""
}
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.1.0", "version": "1.1.3",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.0/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/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." "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"
} }
+91
View File
@@ -0,0 +1,91 @@
<Application x:Class="ClaudeCheckerWindows.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<SolidColorBrush x:Key="BackgroundBrush" Color="#F3F3F3"/>
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AccentBrush" Color="#F97316"/>
<SolidColorBrush x:Key="TextBrush" Color="#1C1C1C"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="#6E6E6E"/>
<SolidColorBrush x:Key="BorderBrush" Color="#E0E0E0"/>
<SolidColorBrush x:Key="BlueBrush" Color="#0078D4"/>
<SolidColorBrush x:Key="ButtonBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="#FAFAFA"/>
<SolidColorBrush x:Key="BannerBgBrush" Color="#E8F2FB"/>
<SolidColorBrush x:Key="BannerBorderBrush" Color="#B3D6F5"/>
<Style x:Key="HeaderText" TargetType="TextBlock">
<Setter Property="FontSize" Value="10"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource SecondaryBrush}"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource BlueBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,6"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#106EBE"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="#BDBDBD"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SmallButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource ButtonBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Padding" Value="10,4"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#F0F0F0"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style TargetType="ScrollViewer">
<Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
</Style>
</Application.Resources>
</Application>
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using Forms = System.Windows.Forms;
namespace ClaudeCheckerWindows;
public partial class App : Application
{
public static UsageViewModel ViewModel { get; } = new();
public static UpdateManager Updater { get; } = new();
private Forms.NotifyIcon? _tray;
private PopupWindow? _popup;
private DispatcherTimer? _timer;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
ThemeManager.Initialize();
SetupTray();
ScheduleTimer(ViewModel.RefreshInterval);
_ = Task.Run(async () =>
{
await Task.Delay(1000);
await ViewModel.RefreshAsync();
await Updater.CheckForUpdatesAsync();
});
}
private void SetupTray()
{
_tray = new Forms.NotifyIcon
{
Text = "ClaudeChecker",
Visible = true,
Icon = LoadIcon(),
};
_tray.MouseClick += (_, e) =>
{
if (e.Button == Forms.MouseButtons.Left)
Dispatcher.InvokeAsync(TogglePopup);
};
_tray.ContextMenuStrip = BuildContextMenu();
ViewModel.PropertyChanged += (_, _) => UpdateTrayText();
Updater.PropertyChanged += (_, _) => UpdateTrayText();
}
private static System.Drawing.Icon LoadIcon()
{
try { return new System.Drawing.Icon("Assets/icon.ico"); }
catch { return System.Drawing.SystemIcons.Application; }
}
private Forms.ContextMenuStrip BuildContextMenu()
{
var menu = new Forms.ContextMenuStrip();
menu.Items.Add("Show ClaudeChecker", null, (_, _) => Dispatcher.InvokeAsync(ShowPopup));
menu.Items.Add(new Forms.ToolStripSeparator());
menu.Items.Add("Quit", null, (_, _) => Dispatcher.InvokeAsync(Quit));
return menu;
}
private void TogglePopup()
{
if (_popup == null || !_popup.IsVisible)
ShowPopup();
else
_popup.Hide();
}
private void ShowPopup()
{
if (_popup == null)
{
_popup = new PopupWindow();
}
_popup.Show();
_popup.Activate();
}
public void ScheduleTimer(int seconds)
{
_timer?.Stop();
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) };
_timer.Tick += async (_, _) =>
{
await ViewModel.RefreshAsync();
await Updater.CheckForUpdatesAsync();
};
_timer.Start();
}
private void UpdateTrayText()
{
if (_tray == null) return;
var limits = ViewModel.Limits;
if (ViewModel.ShowInTaskbar && limits.Count >= 2)
{
var fh = limits.Find(l => l.Window == WindowKind.FiveHour);
var sd = limits.Find(l => l.Window == WindowKind.SevenDay);
if (fh != null && sd != null && fh.IsLive)
{
_tray.Text = $"ClaudeChecker {(int)fh.UsedPercent}% {(int)sd.UsedPercent}%";
return;
}
}
_tray.Text = "ClaudeChecker";
}
private void Quit()
{
_tray?.Dispose();
Shutdown();
}
protected override void OnExit(ExitEventArgs e)
{
_tray?.Dispose();
base.OnExit(e);
}
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.IO;
using System.Text.Json;
namespace ClaudeCheckerWindows;
public class AppSettings
{
public string CookieStore { get; set; } = "";
public string BurnHistory { get; set; } = "";
public int RefreshInterval { get; set; } = 120;
public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false;
private static readonly string FilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "settings.json");
private static AppSettings? _instance;
public static AppSettings Default => _instance ??= Load();
private static AppSettings Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath)) ?? new();
}
catch { }
return new();
}
public void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this,
new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+34
View File
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>ClaudeChecker</AssemblyName>
<RootNamespace>ClaudeCheckerWindows</RootNamespace>
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
<Version>1.0.0</Version>
<Platforms>x64</Platforms>
</PropertyGroup>
<!-- WinForms referenced explicitly so NotifyIcon is available without polluting global namespace -->
<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2651.64"/>
</ItemGroup>
<ItemGroup>
<None Update="Assets\icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+95
View File
@@ -0,0 +1,95 @@
using Brush = System.Windows.Media.Brush;
using Pen = System.Windows.Media.Pen;
using Point = System.Windows.Point;
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
namespace ClaudeCheckerWindows.Controls;
public class GaugeControl : FrameworkElement
{
public static readonly DependencyProperty PercentProperty =
DependencyProperty.Register(nameof(Percent), typeof(double), typeof(GaugeControl),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty AccentProperty =
DependencyProperty.Register(nameof(Accent), typeof(Brush), typeof(GaugeControl),
new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
public double Percent { get => (double)GetValue(PercentProperty); set => SetValue(PercentProperty, value); }
public Brush Accent { get => (Brush)GetValue(AccentProperty); set => SetValue(AccentProperty, value); }
public GaugeControl()
{
ThemeManager.ThemeChanged += InvalidateVisual;
}
private const double StartAngleDeg = 135;
private const double SweepDeg = 270;
private const double StrokeWidth = 6;
protected override void OnRender(DrawingContext dc)
{
var cx = RenderSize.Width / 2;
var cy = RenderSize.Height / 2;
var radius = Math.Min(cx, cy) - StrokeWidth / 2 - 1;
var trackColor = ThemeManager.IsDark
? Color.FromArgb(40, 255, 255, 255)
: Color.FromArgb(30, 0, 0, 0);
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg,
new Pen(new SolidColorBrush(trackColor), StrokeWidth));
if (Percent > 0)
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0),
new Pen(Accent, StrokeWidth));
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C);
var subColor = Color.FromRgb(0x9E, 0x9E, 0x9E);
var pctText = new FormattedText(
$"{(int)Math.Round(Percent)}%",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
14, new SolidColorBrush(textColor), dpi);
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height - 1));
var sub = new FormattedText(
"Updated\nnow",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), 7,
new SolidColorBrush(subColor), dpi)
{
TextAlignment = TextAlignment.Center
};
dc.DrawText(sub, new Point(cx - sub.Width / 2, cy + 2));
}
private static void DrawArc(DrawingContext dc, double cx, double cy, double r,
double startDeg, double sweepDeg, Pen pen)
{
if (sweepDeg <= 0) return;
var start = PointOnCircle(cx, cy, r, startDeg);
var end = PointOnCircle(cx, cy, r, startDeg + sweepDeg);
var isLarge = sweepDeg > 180;
var geo = new StreamGeometry();
using (var ctx = geo.Open())
{
ctx.BeginFigure(start, false, false);
ctx.ArcTo(end, new Size(r, r), 0, isLarge, SweepDirection.Clockwise, true, false);
}
geo.Freeze();
dc.DrawGeometry(null, pen, geo);
}
private static Point PointOnCircle(double cx, double cy, double r, double deg)
{
var rad = deg * Math.PI / 180;
return new Point(cx + r * Math.Cos(rad), cy + r * Math.Sin(rad));
}
}
+60
View File
@@ -0,0 +1,60 @@
using Point = System.Windows.Point;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
namespace ClaudeCheckerWindows.Controls;
public class SparklineControl : FrameworkElement
{
public static readonly DependencyProperty DataProperty =
DependencyProperty.Register(nameof(Data), typeof(IList<double>), typeof(SparklineControl),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty LineColorProperty =
DependencyProperty.Register(nameof(LineColor), typeof(Color), typeof(SparklineControl),
new FrameworkPropertyMetadata(Colors.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
public IList<double>? Data { get => (IList<double>?)GetValue(DataProperty); set => SetValue(DataProperty, value); }
public Color LineColor { get => (Color)GetValue(LineColorProperty); set => SetValue(LineColorProperty, value); }
protected override void OnRender(DrawingContext dc)
{
var data = Data;
if (data == null || data.Count < 1) return;
var plot = data.Count == 1 ? new[] { data[0], data[0] } : [.. data];
var w = RenderSize.Width;
var h = RenderSize.Height;
var minV = double.MaxValue;
var maxV = double.MinValue;
foreach (var v in plot) { if (v < minV) minV = v; if (v > maxV) maxV = v; }
var range = System.Math.Max(maxV - minV, 1);
Point Pt(int i) => new(
i / (double)(plot.Length - 1) * w,
h - (plot[i] - minV) / range * (h - 6) - 3);
var fill = new StreamGeometry();
using (var ctx = fill.Open())
{
ctx.BeginFigure(new Point(0, h), true, true);
for (var i = 0; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false);
ctx.LineTo(new Point(w, h), true, false);
}
fill.Freeze();
dc.DrawGeometry(new SolidColorBrush(Color.FromArgb(26, LineColor.R, LineColor.G, LineColor.B)), null, fill);
var line = new StreamGeometry();
using (var ctx = line.Open())
{
ctx.BeginFigure(Pt(0), false, false);
for (var i = 1; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false);
}
line.Freeze();
dc.DrawGeometry(null,
new System.Windows.Media.Pen(new SolidColorBrush(LineColor), 1.5) { LineJoin = PenLineJoin.Round },
line);
}
}
+9
View File
@@ -0,0 +1,9 @@
// Global aliases to resolve ambiguities between System.Windows.* and System.Drawing.*
global using Application = System.Windows.Application;
global using Brushes = System.Windows.Media.Brushes;
global using Color = System.Windows.Media.Color;
global using FlowDirection = System.Windows.FlowDirection;
global using FontFamily = System.Windows.Media.FontFamily;
global using Orientation = System.Windows.Controls.Orientation;
global using ProgressBar = System.Windows.Controls.ProgressBar;
global using Size = System.Windows.Size;
+42
View File
@@ -0,0 +1,42 @@
<Window x:Class="ClaudeCheckerWindows.LoginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="Sign in to claude.ai"
Width="520" Height="680"
WindowStartupLocation="CenterOwner"
Background="#1C1C1E">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#2C2C2E" Padding="16,12">
<StackPanel>
<TextBlock Text="Sign in to claude.ai" FontSize="14" FontWeight="SemiBold"
Foreground="White"/>
<TextBlock Text="Sign in below. ClaudeChecker will save your session cookies to fetch usage data."
FontSize="11" Foreground="#888888" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<wv2:WebView2 x:Name="Browser" Grid.Row="1"/>
<Border Grid.Row="2" Background="#2C2C2E" Padding="16,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="StatusText" FontSize="11" Foreground="#888888"
VerticalAlignment="Center" Text="Waiting for sign-in…"/>
<Button Grid.Column="1" Content="Done — I'm signed in"
Style="{StaticResource PrimaryButton}" Click="Done_Click"
x:Name="DoneButton" IsEnabled="False"/>
</Grid>
</Border>
</Grid>
</Window>
+66
View File
@@ -0,0 +1,66 @@
using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
namespace ClaudeCheckerWindows;
public partial class LoginWindow : Window
{
public LoginWindow()
{
InitializeComponent();
Loaded += async (_, _) => await InitBrowserAsync();
}
private async Task InitBrowserAsync()
{
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await Browser.EnsureCoreWebView2Async(env);
Browser.CoreWebView2.Navigate("https://claude.ai");
Browser.CoreWebView2.NavigationCompleted += async (_, e) =>
{
if (!e.IsSuccess) return;
var uri = Browser.CoreWebView2.Source;
if (!uri.Contains("claude.ai")) return;
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
var signedIn = cookies.Any(c =>
c.Name.Equals("sessionKey", StringComparison.OrdinalIgnoreCase) ||
c.Name.StartsWith("sk-ant", StringComparison.OrdinalIgnoreCase));
await Dispatcher.InvokeAsync(() =>
{
DoneButton.IsEnabled = !uri.Contains("/login") && !uri.Contains("/signin");
StatusText.Text = signedIn
? "Signed in — click Done to continue."
: "Complete sign-in, then click Done.";
});
// Auto-close only on explicit post-login redirects, not on initial load
if (signedIn && (uri.Contains("/chats") || uri.Contains("/new")))
{
await SaveAndClose(cookies);
}
};
}
private async void Done_Click(object sender, RoutedEventArgs e)
{
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
await SaveAndClose(cookies);
}
private async Task SaveAndClose(IReadOnlyList<CoreWebView2Cookie> cookies)
{
UsageViewModel.SaveCookies(cookies);
await Dispatcher.InvokeAsync(() => DialogResult = true);
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace ClaudeCheckerWindows;
public enum WindowKind { FiveHour, SevenDay }
public class AgentLimit
{
public WindowKind Window { get; init; }
public double UsedPercent { get; set; }
public string TimeRemaining { get; set; } = "";
public DateTime ResetDate { get; set; }
public double BurnRate { get; set; }
public List<double> BurnHistory { get; set; } = [];
public bool IsLive { get; set; }
public string UsageLabel => UsedPercent switch { < 33 => "low", < 66 => "med", _ => "high" };
public string WindowLabel => Window == WindowKind.FiveHour ? "5 HOUR LIMIT" : "7 DAY LIMIT";
}
public class UsageResponse
{
[JsonPropertyName("claude_ai_default_5h")] public WindowData? FiveHour { get; set; }
[JsonPropertyName("claude_ai_default")] public WindowData? SevenDay { get; set; }
}
public class WindowData
{
[JsonPropertyName("utilization")] public double Utilization { get; set; }
[JsonPropertyName("resets_at")] public string? ResetsAt { get; set; }
}
public class BootstrapResponse
{
[JsonPropertyName("account")] public AccountInfo? Account { get; set; }
}
public class AccountInfo
{
[JsonPropertyName("email_address")] public string? EmailAddress { get; set; }
}
public class OverageSpendLimit
{
[JsonPropertyName("monthly_credit_limit")] public double? MonthlyCreditLimit { get; set; }
[JsonPropertyName("used_credits")] public double? UsedCredits { get; set; }
[JsonPropertyName("currency")] public string? Currency { get; set; }
}
public class PrepaidCredits
{
[JsonPropertyName("amount")] public double? Amount { get; set; }
[JsonPropertyName("currency")] public string? Currency { get; set; }
}
public class VersionInfo
{
[JsonPropertyName("version")] public string Version { get; set; } = "";
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("notes")] public string? Notes { get; set; }
}
+248
View File
@@ -0,0 +1,248 @@
<Window x:Class="ClaudeCheckerWindows.PopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctrl="clr-namespace:ClaudeCheckerWindows.Controls"
Title="ClaudeChecker"
Width="480" Height="660"
WindowStartupLocation="CenterScreen"
Background="{StaticResource BackgroundBrush}"
FontFamily="Segoe UI"
ShowInTaskbar="True"
ResizeMode="NoResize">
<Grid>
<!-- Main panel -->
<DockPanel x:Name="MainPanel">
<!-- Update banner -->
<Border x:Name="UpdateBanner" DockPanel.Dock="Top" Visibility="Collapsed"
Background="{StaticResource BannerBgBrush}"
BorderBrush="{StaticResource BannerBorderBrush}" BorderThickness="0,0,0,1"
Cursor="Hand" MouseLeftButtonUp="UpdateBanner_Click">
<Grid Margin="16,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="⬇" FontSize="14"
Foreground="{StaticResource BlueBrush}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="10,0">
<TextBlock x:Name="UpdateBannerTitle" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"/>
<TextBlock Text="Click to view release notes and install."
FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
</StackPanel>
<Button Grid.Column="2" Content="Update" Style="{StaticResource SmallButton}"
Foreground="{StaticResource BlueBrush}" Click="UpdateBanner_Click"/>
</Grid>
</Border>
<!-- Footer -->
<Border DockPanel.Dock="Bottom" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,1,0,0" Background="{StaticResource SurfaceBrush}">
<Grid Margin="12,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<ProgressBar x:Name="LoadingBar" IsIndeterminate="True"
Width="12" Height="12" Visibility="Collapsed"
Style="{x:Null}" Margin="0,0,6,0"/>
<TextBlock x:Name="LastUpdatedText" FontSize="11"
Foreground="{StaticResource SecondaryBrush}" VerticalAlignment="Center"/>
</StackPanel>
<Button Grid.Column="1" Content="↻ Refresh"
Style="{StaticResource SmallButton}" Click="Refresh_Click"/>
<Button Grid.Column="3" x:Name="SettingsButton" Content="⚙ Settings"
Style="{StaticResource SmallButton}" Click="Settings_Click"/>
</Grid>
</Border>
<!-- Cards scroll area -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CardsPanel" Margin="0,4,0,4"/>
</ScrollViewer>
</DockPanel>
<!-- Settings panel -->
<Border x:Name="SettingsPanel" Visibility="Collapsed"
Background="{StaticResource BackgroundBrush}">
<DockPanel>
<Border DockPanel.Dock="Top" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1" Background="{StaticResource SurfaceBrush}" Padding="12,10">
<Grid>
<Button Content="← Back" HorizontalAlignment="Left"
Style="{StaticResource SmallButton}" BorderThickness="0"
Foreground="{StaticResource BlueBrush}" Background="Transparent"
Click="BackFromSettings_Click"/>
<TextBlock Text="Settings" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="20,16,20,20">
<TextBlock Text="AUTHENTICATION" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{StaticResource CardBrush}" CornerRadius="6"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
Padding="14,12" Margin="0,0,0,16">
<StackPanel>
<StackPanel x:Name="AuthPanel" Margin="0,0,0,10"/>
<TextBlock Text="Your session is stored locally and persists across restarts."
FontSize="11" Foreground="{StaticResource SecondaryBrush}"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<StackPanel Orientation="Horizontal">
<Button x:Name="SignInButton" Content="Sign In"
Style="{StaticResource SmallButton}"
Click="SignIn_Click" Margin="0,0,8,0"/>
<Button x:Name="SignOutButton" Content="Sign Out"
Style="{StaticResource SmallButton}"
Click="SignOut_Click" Foreground="#C42B1C"
Visibility="Collapsed"/>
</StackPanel>
</StackPanel>
</Border>
<TextBlock Text="UPDATE CHANNEL" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{StaticResource CardBrush}" CornerRadius="6"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
Padding="14,12" Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Beta updates" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"/>
<TextBlock Text="Receive early access builds — may be less stable"
FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
</StackPanel>
<CheckBox Grid.Column="1" x:Name="BetaToggle" VerticalAlignment="Center"
Checked="BetaToggle_Changed" Unchecked="BetaToggle_Changed"/>
</Grid>
</Border>
<TextBlock Text="AUTO-REFRESH" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{StaticResource CardBrush}" CornerRadius="6"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
Padding="14,12" Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Refresh interval" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" x:Name="RefreshPicker" Width="100"
SelectionChanged="RefreshPicker_Changed"/>
</Grid>
</Border>
<TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{StaticResource CardBrush}" CornerRadius="6"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
Padding="14,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Image Width="36" Height="36" Source="Assets/icon.png"
RenderOptions.BitmapScalingMode="HighQuality" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="ClaudeChecker" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"/>
<Border Background="#F0F0F0" CornerRadius="3"
Margin="6,0,0,0" Padding="5,1">
<TextBlock x:Name="VersionLabel" FontSize="11"
Foreground="{StaticResource SecondaryBrush}"
FontFamily="Consolas"/>
</Border>
</StackPanel>
<TextBlock Text="Built by superdooper86 &amp; claude"
FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
</StackPanel>
<Button Grid.Column="2" x:Name="CheckUpdateButton"
Style="{StaticResource SmallButton}"
Click="CheckUpdate_Click" VerticalAlignment="Center"/>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Border>
<!-- Update panel -->
<Border x:Name="UpdatePanel" Visibility="Collapsed"
Background="{StaticResource BackgroundBrush}">
<DockPanel>
<Border DockPanel.Dock="Top" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,0,0,1" Background="{StaticResource SurfaceBrush}" Padding="12,10">
<Grid>
<Button Content="← Back" HorizontalAlignment="Left"
Style="{StaticResource SmallButton}" BorderThickness="0"
Foreground="{StaticResource BlueBrush}" Background="Transparent"
Click="CloseUpdate_Click"/>
<TextBlock Text="Update Available" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Button x:Name="NotNowButton" Content="Not Now" HorizontalAlignment="Right"
Style="{StaticResource SmallButton}" Click="CloseUpdate_Click"/>
</Grid>
</Border>
<StackPanel Margin="20,16">
<Border Background="{StaticResource CardBrush}" CornerRadius="6"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
Padding="14,12" Margin="0,0,0,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Image Width="44" Height="44" Source="Assets/icon.png"
RenderOptions.BitmapScalingMode="HighQuality" Margin="0,0,14,0"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock x:Name="CurrentVerLabel" FontSize="12" FontFamily="Consolas"
Foreground="{StaticResource SecondaryBrush}" VerticalAlignment="Center"/>
<TextBlock Text=" → " FontSize="11" Foreground="{StaticResource SecondaryBrush}"
VerticalAlignment="Center"/>
<TextBlock x:Name="NewVerLabel" FontSize="13" FontWeight="SemiBold"
FontFamily="Consolas" Foreground="{StaticResource BlueBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="ReleaseNotesText" FontSize="11" TextWrapping="Wrap"
Foreground="{StaticResource SecondaryBrush}" MaxHeight="160"/>
</StackPanel>
</Grid>
</Border>
<TextBlock x:Name="UpdateStatusText" Foreground="{StaticResource SecondaryBrush}"
FontSize="11" TextWrapping="Wrap" TextAlignment="Center"
Visibility="Collapsed" Margin="0,0,0,8"/>
<ProgressBar x:Name="UpdateProgress" Visibility="Collapsed"
Height="4" Margin="0,0,0,12" Foreground="{StaticResource BlueBrush}"/>
<TextBlock Text="ClaudeChecker will download, replace itself, and relaunch automatically."
FontSize="11" Foreground="{StaticResource SecondaryBrush}"
TextWrapping="Wrap" TextAlignment="Center" Margin="0,0,0,12"/>
<Button x:Name="InstallButton" Content="⬇ Download &amp; Install"
Style="{StaticResource PrimaryButton}" HorizontalAlignment="Stretch"
Click="Install_Click"/>
</StackPanel>
</DockPanel>
</Border>
</Grid>
</Window>
+355
View File
@@ -0,0 +1,355 @@
using ClaudeCheckerWindows.Controls;
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
namespace ClaudeCheckerWindows;
public partial class PopupWindow : Window
{
private static readonly UsageViewModel VM = App.ViewModel;
private static readonly UpdateManager Updater = App.Updater;
private readonly DispatcherTimer _clockTimer;
private bool _dialogOpen;
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
private void ApplyTitleBarTheme()
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
int dark = ThemeManager.IsDark ? 1 : 0;
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
}
public PopupWindow()
{
InitializeComponent();
VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName));
Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName));
_clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_clockTimer.Tick += (_, _) => UpdateFooter();
_clockTimer.Start();
InitSettings();
RebuildCards();
UpdateBannerState();
UpdateFooter();
Closing += (_, e) => { e.Cancel = true; Hide(); };
SourceInitialized += (_, _) => ApplyTitleBarTheme();
ThemeManager.ThemeChanged += () => { ApplyTitleBarTheme(); RebuildCards(); };
}
// ── Card rendering ───────────────────────────────────────────────
private void RebuildCards()
{
CardsPanel.Children.Clear();
foreach (var limit in VM.Limits)
CardsPanel.Children.Add(BuildCard(limit));
}
private static UIElement BuildCard(AgentLimit limit)
{
var accent = new SolidColorBrush(Color.FromRgb(0xF9, 0x73, 0x16));
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
// Gauge
var gauge = new GaugeControl { Width = 76, Height = 76, Percent = limit.UsedPercent, Accent = accent };
// Live badge
var liveBadge = new Border
{
Background = new SolidColorBrush(Color.FromArgb(30, 0, 160, 0)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 2, 6, 2),
Margin = new Thickness(0, 0, 6, 0),
Visibility = limit.IsLive ? Visibility.Visible : Visibility.Collapsed,
Child = new TextBlock { Text = "Live", FontSize = 10, FontWeight = FontWeights.Medium,
Foreground = new SolidColorBrush(Color.FromRgb(0, 120, 0)) }
};
// Usage badge
var usageBadge = new Border
{
Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 2, 6, 2),
Child = new TextBlock { Text = $"Usg: {limit.UsageLabel}", FontSize = 10,
FontWeight = FontWeights.Medium, Foreground = secondary }
};
var badgeStack = new StackPanel { Orientation = Orientation.Horizontal };
badgeStack.Children.Add(liveBadge);
badgeStack.Children.Add(usageBadge);
var nameStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
nameStack.Children.Add(new TextBlock { Text = "✦", FontSize = 11, Foreground = accent, VerticalAlignment = VerticalAlignment.Center });
nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11,
Foreground = secondary, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
var headerRow = new Grid();
headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetColumn(nameStack, 0);
Grid.SetColumn(badgeStack, 1);
headerRow.Children.Add(nameStack);
headerRow.Children.Add(badgeStack);
var progress = new ProgressBar
{
Value = limit.UsedPercent, Maximum = 100, Height = 4,
Margin = new Thickness(0, 4, 0, 4),
Foreground = accent,
Background = new SolidColorBrush(Color.FromArgb(20, 0, 0, 0)),
BorderThickness = new Thickness(0),
};
var timeStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
timeStack.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
timeStack.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12,
FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center });
var resetText = new TextBlock
{
Text = $"Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}",
FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
};
var timeRow = new Grid();
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetColumn(timeStack, 0);
Grid.SetColumn(resetText, 1);
timeRow.Children.Add(timeStack);
timeRow.Children.Add(resetText);
var sparkline = new SparklineControl
{
Height = 28, Margin = new Thickness(0, 8, 0, 0), LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
Data = limit.BurnHistory.Count > 0 ? limit.BurnHistory : null,
};
var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
infoPanel.Children.Add(headerRow);
infoPanel.Children.Add(progress);
infoPanel.Children.Add(timeRow);
infoPanel.Children.Add(sparkline);
var cardGrid = new Grid { Margin = new Thickness(16, 12, 16, 12) };
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16, GridUnitType.Pixel) });
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
Grid.SetColumn(gauge, 0);
Grid.SetColumn(infoPanel, 2);
cardGrid.Children.Add(gauge);
cardGrid.Children.Add(infoPanel);
var card = new Border
{
Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
CornerRadius = new CornerRadius(6),
BorderBrush = border,
BorderThickness = new Thickness(1),
Margin = new Thickness(12, 0, 12, 0),
Child = cardGrid,
};
var sectionLabel = new TextBlock
{
Text = limit.WindowLabel,
Style = (Style)Application.Current.Resources["HeaderText"],
Margin = new Thickness(16, 12, 16, 6),
};
var section = new StackPanel();
section.Children.Add(sectionLabel);
section.Children.Add(card);
return section;
}
// ── State updates ────────────────────────────────────────────────
private void OnVmChanged(string? prop)
{
if (prop is nameof(UsageViewModel.Limits) or nameof(UsageViewModel.IsLoading)
or nameof(UsageViewModel.LastUpdated) or nameof(UsageViewModel.ErrorMessage))
{
RebuildCards();
UpdateFooter();
}
if (prop is nameof(UsageViewModel.IsSignedIn) or nameof(UsageViewModel.UserEmail))
{
InitSettings();
}
}
private void OnUpdaterChanged(string? prop)
{
if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion))
UpdateBannerState();
if (prop is nameof(UpdateManager.DownloadProgress))
UpdateProgress.Value = Updater.DownloadProgress * 100;
if (prop is nameof(UpdateManager.StatusMessage))
UpdateStatusText.Text = Updater.StatusMessage;
if (prop is nameof(UpdateManager.UpdateComplete) && Updater.UpdateComplete)
InstallButton.Visibility = Visibility.Collapsed;
}
private void UpdateBannerState()
{
var visible = Updater.UpdateAvailable;
UpdateBanner.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
if (visible) UpdateBannerTitle.Text = $"Update available — v{Updater.LatestVersion}";
}
private void UpdateFooter()
{
if (VM.LastUpdated.HasValue)
{
var diff = DateTime.Now - VM.LastUpdated.Value;
LastUpdatedText.Text = diff.TotalSeconds < 10 ? "Updated just now"
: $"Updated {(int)diff.TotalSeconds}s ago";
}
else
{
LastUpdatedText.Text = "Not yet updated";
}
LoadingBar.Visibility = VM.IsLoading ? Visibility.Visible : Visibility.Collapsed;
}
// ── Settings ─────────────────────────────────────────────────────
private void InitSettings()
{
VersionLabel.Text = $"v{Updater.CurrentVersion}";
BetaToggle.IsChecked = Updater.BetaChannel;
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
var intervals = new[] { ("1 min", 60), ("2 min", 120), ("3 min", 180),
("4 min", 240), ("5 min", 300), ("10 min", 600) };
RefreshPicker.ItemsSource = intervals;
RefreshPicker.DisplayMemberPath = "Item1";
var idx = Array.FindIndex(intervals, x => x.Item2 == VM.RefreshInterval);
RefreshPicker.SelectedIndex = idx < 0 ? 1 : idx;
AuthPanel.Children.Clear();
var dot = new System.Windows.Shapes.Ellipse
{
Width = 7, Height = 7, Margin = new Thickness(0, 0, 6, 0),
Fill = VM.IsSignedIn ? Brushes.LimeGreen : Brushes.Orange,
VerticalAlignment = VerticalAlignment.Center,
};
var label = new TextBlock
{
Text = VM.IsSignedIn ? $"Signed in — {VM.UserEmail}" : "Not signed in",
FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
};
var authRow = new StackPanel { Orientation = Orientation.Horizontal };
authRow.Children.Add(dot);
authRow.Children.Add(label);
AuthPanel.Children.Add(authRow);
SignOutButton.Visibility = VM.IsSignedIn ? Visibility.Visible : Visibility.Collapsed;
SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In";
}
// ── Event handlers ───────────────────────────────────────────────
private void Settings_Click(object s, RoutedEventArgs e)
{
InitSettings();
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
UpdatePanel.Visibility = Visibility.Collapsed;
}
private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
private void ShowMain()
{
MainPanel.Visibility = Visibility.Visible;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed;
}
private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
private void UpdateBanner_Click(object s, RoutedEventArgs e) => ShowUpdatePanel();
private void ShowUpdatePanel()
{
CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
NewVerLabel.Text = $"v{Updater.LatestVersion}";
ReleaseNotesText.Text = Updater.ReleaseNotes;
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Visible;
}
private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
private async void Install_Click(object s, RoutedEventArgs e)
{
NotNowButton.Visibility = Visibility.Collapsed;
UpdateStatusText.Visibility = Visibility.Visible;
UpdateProgress.Visibility = Visibility.Visible;
await Updater.DownloadAndInstallAsync();
}
private async void SignIn_Click(object s, RoutedEventArgs e)
{
_dialogOpen = true;
var login = new LoginWindow { Owner = this };
if (login.ShowDialog() == true)
{
await VM.RefreshAsync();
InitSettings();
}
_dialogOpen = false;
Show();
Activate();
}
private async void SignOut_Click(object s, RoutedEventArgs e)
{
await VM.SignOutAsync();
InitSettings();
}
private void BetaToggle_Changed(object s, RoutedEventArgs e)
{
Updater.BetaChannel = BetaToggle.IsChecked == true;
_ = Updater.CheckForUpdatesAsync();
}
private void RefreshPicker_Changed(object s, SelectionChangedEventArgs e)
{
if (RefreshPicker.SelectedItem is ValueTuple<string, int> selected)
{
VM.RefreshInterval = selected.Item2;
((App)Application.Current).ScheduleTimer(selected.Item2);
}
}
private async void CheckUpdate_Click(object s, RoutedEventArgs e)
{
if (Updater.UpdateAvailable) { ShowUpdatePanel(); return; }
CheckUpdateButton.Content = "…";
await Updater.CheckForUpdatesAsync();
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
}
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="uri:settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)"/>
</Profiles>
<Settings>
<Setting Name="CookieStore" Type="System.String" Scope="User">
<Value Profile="(Default)"></Value>
</Setting>
<Setting Name="BurnHistory" Type="System.String" Scope="User">
<Value Profile="(Default)"></Value>
</Setting>
<Setting Name="RefreshInterval" Type="System.Int32" Scope="User">
<Value Profile="(Default)">120</Value>
</Setting>
<Setting Name="ShowInTaskbar" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
<Setting Name="BetaChannel" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
</Settings>
</SettingsFile>
+50
View File
@@ -0,0 +1,50 @@
# ClaudeChecker for Windows
A native Windows port of ClaudeChecker — a system tray app for monitoring your Claude AI usage limits in real time.
## Requirements
- Windows 10 / 11 (x64)
- [.NET 8 Runtime](https://dotnet.microsoft.com/download/dotnet/8.0)
- [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) (usually pre-installed on Windows 11)
## Building
```powershell
cd windows
dotnet build -c Release
```
The output is a single `ClaudeChecker.exe` in `bin\Release\net8.0-windows\`.
> **Assets:** You need to supply `Assets/icon.ico` and `Assets/icon.png` (128×128) before building.
> Copy them from the macOS project's `ClaudeChecker/Assets.xcassets/AppIcon.appiconset/`.
## Features
- System tray icon — left-click to show/hide the popup
- 5-hour and 7-day usage gauges with burn rate sparklines
- Cookie-based auth via embedded WebView2 browser (sign in once, session persists)
- Auto-refresh on a configurable interval (110 min)
- Update checking against the same `version.json` / `version-beta.json` as the macOS app
- Beta channel toggle
- Settings panel (auth, refresh interval, update channel)
## Architecture
| File | Purpose |
|---|---|
| `App.xaml.cs` | Tray icon, popup lifecycle, refresh timer |
| `PopupWindow.xaml/cs` | Main UI — limit cards, settings, update sheet |
| `LoginWindow.xaml/cs` | WebView2 browser for claude.ai auth |
| `UsageViewModel.cs` | Data fetching, cookie handling, state |
| `UpdateManager.cs` | Version checking, download and install |
| `Models.cs` | Data models |
| `Controls/GaugeControl.cs` | Custom circular gauge |
| `Controls/SparklineControl.cs` | Burn history sparkline |
## Notes
- Cookies are stored in user settings (via `Properties.Settings`), not the WebView2 profile, so they survive WebView2 cache clears.
- Updates download a `.zip`, extract, and run a `.bat` script that replaces the exe after the app exits.
- The popup positions itself in the bottom-right corner near the system tray.
+3
View File
@@ -0,0 +1,3 @@
## What's new
Initial Windows release.
+79
View File
@@ -0,0 +1,79 @@
using Microsoft.Win32;
using System;
using System.Windows;
using System.Windows.Media;
namespace ClaudeCheckerWindows;
public static class ThemeManager
{
public static bool IsDark { get; private set; }
public static event Action? ThemeChanged;
public static void Initialize()
{
IsDark = ReadIsDark();
Apply();
SystemEvents.UserPreferenceChanged += OnPreferenceChanged;
}
private static void OnPreferenceChanged(object sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
var nowDark = ReadIsDark();
if (nowDark == IsDark) return;
IsDark = nowDark;
Application.Current.Dispatcher.Invoke(() =>
{
Apply();
ThemeChanged?.Invoke();
});
}
private static bool ReadIsDark()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
return (int)(key?.GetValue("AppsUseLightTheme") ?? 1) == 0;
}
catch { return false; }
}
private static void Apply()
{
var res = Application.Current.Resources;
if (IsDark)
{
Set(res, "BackgroundBrush", Color.FromRgb(0x1C, 0x1C, 0x1E));
Set(res, "CardBrush", Color.FromRgb(0x2C, 0x2C, 0x2E));
Set(res, "TextBrush", Color.FromRgb(0xF5, 0xF5, 0xF5));
Set(res, "SecondaryBrush", Color.FromRgb(0x8A, 0x8A, 0x8E));
Set(res, "BorderBrush", Color.FromRgb(0x3A, 0x3A, 0x3C));
Set(res, "BlueBrush", Color.FromRgb(0x3B, 0x82, 0xF6));
Set(res, "ButtonBrush", Color.FromRgb(0x3A, 0x3A, 0x3C));
Set(res, "SurfaceBrush", Color.FromRgb(0x25, 0x25, 0x27));
Set(res, "BannerBgBrush", Color.FromRgb(0x1A, 0x31, 0x52));
Set(res, "BannerBorderBrush", Color.FromRgb(0x2A, 0x4A, 0x72));
}
else
{
Set(res, "BackgroundBrush", Color.FromRgb(0xF3, 0xF3, 0xF3));
Set(res, "CardBrush", Colors.White);
Set(res, "TextBrush", Color.FromRgb(0x1C, 0x1C, 0x1C));
Set(res, "SecondaryBrush", Color.FromRgb(0x6E, 0x6E, 0x6E));
Set(res, "BorderBrush", Color.FromRgb(0xE0, 0xE0, 0xE0));
Set(res, "BlueBrush", Color.FromRgb(0x00, 0x78, 0xD4));
Set(res, "ButtonBrush", Colors.White);
Set(res, "SurfaceBrush", Color.FromRgb(0xFA, 0xFA, 0xFA));
Set(res, "BannerBgBrush", Color.FromRgb(0xE8, 0xF2, 0xFB));
Set(res, "BannerBorderBrush", Color.FromRgb(0xB3, 0xD6, 0xF5));
}
}
private static void Set(ResourceDictionary res, string key, Color color)
{
if (res[key] is SolidColorBrush b) b.Color = color;
}
}
+265
View File
@@ -0,0 +1,265 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Collections.Generic;
using System.Windows;
namespace ClaudeCheckerWindows;
public class UpdateManager : INotifyPropertyChanged
{
private const string StableUrl = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-windows.json";
private const string BetaUrl = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-windows-beta.json";
private bool _updateAvailable;
private string _latestVersion = "";
private string _releaseNotes = "";
private string _downloadUrl = "";
private bool _betaAvailable;
private string _latestBetaVersion = "";
private bool _isDownloading;
private double _downloadProgress;
private string _statusMessage = "";
private string? _updateError;
private bool _updateComplete;
public bool UpdateAvailable { get => _updateAvailable; set => Set(ref _updateAvailable, value); }
public string LatestVersion { get => _latestVersion; set => Set(ref _latestVersion, value); }
public string ReleaseNotes { get => _releaseNotes; set => Set(ref _releaseNotes, value); }
public string DownloadUrl { get => _downloadUrl; set => Set(ref _downloadUrl, value); }
public bool BetaAvailable { get => _betaAvailable; set => Set(ref _betaAvailable, value); }
public string LatestBetaVersion { get => _latestBetaVersion; set => Set(ref _latestBetaVersion, value); }
public bool IsDownloading { get => _isDownloading; set => Set(ref _isDownloading, value); }
public double DownloadProgress { get => _downloadProgress; set => Set(ref _downloadProgress, value); }
public string StatusMessage { get => _statusMessage; set => Set(ref _statusMessage, value); }
public string? UpdateError { get => _updateError; set => Set(ref _updateError, value); }
public bool UpdateComplete { get => _updateComplete; set => Set(ref _updateComplete, value); }
private bool _betaChannel;
public bool BetaChannel
{
get => _betaChannel;
set
{
Set(ref _betaChannel, value);
AppSettings.Default.BetaChannel = value;
AppSettings.Default.Save();
}
}
public string CurrentVersion =>
System.Reflection.Assembly.GetExecutingAssembly()
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+')[0]
?? "1.0.0";
private bool IsPreReleaseBuild => CurrentVersion.Contains('-');
public UpdateManager()
{
_betaChannel = AppSettings.Default.BetaChannel;
}
public async Task CheckForUpdatesAsync()
{
using var http = new HttpClient();
http.DefaultRequestHeaders.CacheControl =
new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true };
var stable = await FetchVersionAsync(http, StableUrl);
var beta = await FetchVersionAsync(http, BetaUrl);
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (stable != null && IsNewer(stable.Version, CurrentVersion))
{
LatestVersion = stable.Version;
ReleaseNotes = stable.Notes ?? "";
DownloadUrl = stable.Url;
UpdateAvailable = true;
}
else
{
UpdateAvailable = false;
LatestVersion = "";
}
if (beta != null && IsNewer(beta.Version, CurrentVersion))
{
LatestBetaVersion = beta.Version;
BetaAvailable = true;
// Show beta update if user opted in, or if they're already running a beta
if ((BetaChannel || IsPreReleaseBuild) && IsNewer(beta.Version, LatestVersion))
{
LatestVersion = beta.Version;
ReleaseNotes = beta.Notes ?? "";
DownloadUrl = beta.Url;
UpdateAvailable = true;
}
}
else
{
BetaAvailable = false;
LatestBetaVersion = "";
}
});
}
private static async Task<VersionInfo?> FetchVersionAsync(HttpClient http, string url)
{
try
{
var bust = $"{url}?t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
var json = await http.GetStringAsync(bust);
return JsonSerializer.Deserialize<VersionInfo>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch { return null; }
}
public async Task DownloadAndInstallAsync()
{
if (string.IsNullOrEmpty(DownloadUrl)) return;
IsDownloading = true;
UpdateError = null;
UpdateComplete = false;
StatusMessage = "Downloading…";
try
{
var tmpDir = Path.Combine(Path.GetTempPath(), $"CCUpdate_{Guid.NewGuid():N}");
Directory.CreateDirectory(tmpDir);
var zipPath = Path.Combine(tmpDir, "ClaudeChecker.zip");
using var http = new HttpClient();
var response = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
var received = 0L;
await using (var stream = await response.Content.ReadAsStreamAsync())
await using (var file = File.Create(zipPath))
{
var buffer = new byte[81920];
int read;
while ((read = await stream.ReadAsync(buffer)) > 0)
{
await file.WriteAsync(buffer.AsMemory(0, read));
received += read;
if (total > 0)
DownloadProgress = (double)received / total * 0.8;
}
}
StatusMessage = "Unpacking…";
DownloadProgress = 0.85;
var extractDir = Path.Combine(tmpDir, "extracted");
ZipFile.ExtractToDirectory(zipPath, extractDir, overwriteFiles: true);
// Find the installer exe
var newExe = FindExe(extractDir);
if (newExe == null) throw new Exception("ClaudeChecker.exe not found in update package.");
DownloadProgress = 0.95;
StatusMessage = "Installing…";
// Write a batch script to replace the exe after we exit
var currentExe = Process.GetCurrentProcess().MainModule!.FileName;
var script = $"""
@echo off
timeout /t 2 /nobreak > nul
copy /Y "{newExe}" "{currentExe}"
start "" "{currentExe}"
rmdir /S /Q "{tmpDir}"
""";
var scriptPath = Path.Combine(Path.GetTempPath(), "claudechecker_update.bat");
await File.WriteAllTextAsync(scriptPath, script);
DownloadProgress = 1.0;
StatusMessage = "Installed! Relaunching…";
UpdateComplete = true;
await Task.Delay(800);
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/C \"{scriptPath}\"",
CreateNoWindow = true,
UseShellExecute = false,
});
Application.Current.Dispatcher.Invoke(() => Application.Current.Shutdown());
}
catch (Exception ex)
{
UpdateError = ex.Message;
IsDownloading = false;
StatusMessage = "";
}
}
private static string? FindExe(string dir)
{
foreach (var f in Directory.EnumerateFiles(dir, "ClaudeChecker.exe", SearchOption.AllDirectories))
return f;
return null;
}
public static bool IsNewer(string version, string current)
{
static (int[] Base, int[]? Pre) Parse(string v)
{
if (string.IsNullOrEmpty(v)) return ([], null);
var halves = v.Split('-', 2);
var baseP = Array.ConvertAll(halves[0].Split('.'), p => int.TryParse(p, out var n) ? n : 0);
int[]? pre = halves.Length > 1
? Array.ConvertAll(halves[1].Split('.'), p => int.TryParse(p, out var n) ? n : 0)
: null;
return (baseP, pre);
}
var (ab, ap) = Parse(version);
var (bb, bp) = Parse(current);
for (var i = 0; i < Math.Max(ab.Length, bb.Length); i++)
{
var av = i < ab.Length ? ab[i] : 0;
var bv = i < bb.Length ? bb[i] : 0;
if (av != bv) return av > bv;
}
if (ap == null && bp != null) return true;
if (ap != null && bp == null) return false;
if (ap != null && bp != null)
{
for (var i = 0; i < Math.Max(ap.Length, bp.Length); i++)
{
var av = i < ap.Length ? ap[i] : 0;
var bv = i < bp.Length ? bp[i] : 0;
if (av != bv) return av > bv;
}
}
return false;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
Application.Current.Dispatcher.InvokeAsync(()
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)));
}
}
+310
View File
@@ -0,0 +1,310 @@
using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
namespace ClaudeCheckerWindows;
public class UsageViewModel : INotifyPropertyChanged
{
private static readonly string OrgId = "daf626a9-4924-4ff3-ba98-23b523062f8e";
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private List<AgentLimit> _limits = [];
private bool _isLoading;
private string? _errorMessage;
private bool _isSignedIn;
private string _userEmail = "";
private string _planLabel = "Pro";
private DateTime? _lastUpdated;
private OverageSpendLimit? _overage;
private PrepaidCredits? _prepaid;
private readonly Dictionary<string, List<double>> _burnHistory = [];
private const int MaxHistorySamples = 24;
public List<AgentLimit> Limits { get => _limits; set => Set(ref _limits, value); }
public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); }
public string? ErrorMessage { get => _errorMessage; set => Set(ref _errorMessage, value); }
public bool IsSignedIn { get => _isSignedIn; set => Set(ref _isSignedIn, value); }
public string UserEmail { get => _userEmail; set => Set(ref _userEmail, value); }
public string PlanLabel { get => _planLabel; set => Set(ref _planLabel, value); }
public DateTime? LastUpdated { get => _lastUpdated; set => Set(ref _lastUpdated, value); }
public OverageSpendLimit? Overage { get => _overage; set => Set(ref _overage, value); }
public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); }
private int _refreshInterval = 120;
public int RefreshInterval
{
get => _refreshInterval;
set
{
Set(ref _refreshInterval, value);
AppSettings.Default.RefreshInterval = value;
AppSettings.Default.Save();
}
}
private bool _showInTaskbar = true;
public bool ShowInTaskbar
{
get => _showInTaskbar;
set
{
Set(ref _showInTaskbar, value);
AppSettings.Default.ShowInTaskbar = value;
AppSettings.Default.Save();
}
}
public UsageViewModel()
{
_refreshInterval = AppSettings.Default.RefreshInterval > 0
? AppSettings.Default.RefreshInterval : 120;
_showInTaskbar = AppSettings.Default.ShowInTaskbar;
LoadBurnHistory();
LoadPlaceholders();
}
public async Task RefreshAsync()
{
await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true);
try
{
var cookies = await GetCookiesAsync();
if (cookies.Count == 0)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
ErrorMessage = "Not signed in — click Sign In to authenticate.";
IsLoading = false;
});
return;
}
using var http = BuildClient(cookies);
var usageTask = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{OrgId}/usage");
var overageTask = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{OrgId}/overage_spend_limit");
var prepaidTask = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{OrgId}/prepaid/credits");
var emailTask = FetchEmailAsync(http);
await Task.WhenAll(usageTask, overageTask, prepaidTask, emailTask);
var usage = usageTask.Result;
var limits = BuildLimits(usage);
foreach (var limit in limits)
{
var key = limit.Window.ToString();
if (!_burnHistory.ContainsKey(key)) _burnHistory[key] = [];
_burnHistory[key].Add(limit.UsedPercent);
if (_burnHistory[key].Count > MaxHistorySamples)
_burnHistory[key].RemoveAt(0);
limit.BurnHistory = [.. _burnHistory[key]];
}
SaveBurnHistory();
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Limits = limits;
Overage = overageTask.Result;
Prepaid = prepaidTask.Result;
UserEmail = emailTask.Result ?? UserEmail;
IsSignedIn = true;
ErrorMessage = null;
LastUpdated = DateTime.Now;
IsLoading = false;
});
}
catch (Exception ex)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
ErrorMessage = ex.Message;
IsLoading = false;
});
}
}
public async Task SignOutAsync()
{
AppSettings.Default.CookieStore = "";
AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
UserEmail = "";
ErrorMessage = "Signed out.";
Limits = [];
});
}
// Cookie store: we persist cookies as JSON in settings after login
public static async Task<List<(string Name, string Value, string Domain, string Path)>> GetCookiesAsync()
{
var raw = AppSettings.Default.CookieStore;
if (string.IsNullOrEmpty(raw)) return [];
try
{
return JsonSerializer.Deserialize<List<CookieEntry>>(raw)
?.Select(c => (c.Name, c.Value, c.Domain, c.Path))
.ToList() ?? [];
}
catch { return []; }
}
public static void SaveCookies(IEnumerable<CoreWebView2Cookie> cookies)
{
var entries = cookies
.Where(c => c.Domain.Contains("claude.ai"))
.Select(c => new CookieEntry { Name = c.Name, Value = c.Value, Domain = c.Domain, Path = c.Path })
.ToList();
AppSettings.Default.CookieStore = JsonSerializer.Serialize(entries);
AppSettings.Default.Save();
}
private static HttpClient BuildClient(List<(string Name, string Value, string Domain, string Path)> cookies)
{
var handler = new HttpClientHandler { UseCookies = false };
var http = new HttpClient(handler);
http.DefaultRequestHeaders.Add("accept", "application/json");
var cookieHeader = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
http.DefaultRequestHeaders.Add("Cookie", cookieHeader);
return http;
}
private static async Task<T?> FetchAsync<T>(HttpClient http, string url) where T : class
{
try
{
var resp = await http.GetAsync(url);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
catch { return null; }
}
private static async Task<string?> FetchEmailAsync(HttpClient http)
{
try
{
var resp = await http.GetAsync("https://claude.ai/api/bootstrap");
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
var boot = JsonSerializer.Deserialize<BootstrapResponse>(json, JsonOpts);
return boot?.Account?.EmailAddress;
}
catch { return null; }
}
private List<AgentLimit> BuildLimits(UsageResponse? usage)
{
if (usage == null) return [];
var now = DateTime.Now;
var result = new List<AgentLimit>();
if (usage.FiveHour != null)
{
var reset = ParseDate(usage.FiveHour.ResetsAt, now);
var pct = Math.Clamp(usage.FiveHour.Utilization, 0, 100);
result.Add(new AgentLimit
{
Window = WindowKind.FiveHour,
UsedPercent = pct,
TimeRemaining = TimeLeft(reset, now),
ResetDate = reset,
BurnRate = pct / 5.0,
IsLive = true,
});
}
if (usage.SevenDay != null)
{
var reset = ParseDate(usage.SevenDay.ResetsAt, now);
var pct = Math.Clamp(usage.SevenDay.Utilization, 0, 100);
result.Add(new AgentLimit
{
Window = WindowKind.SevenDay,
UsedPercent = pct,
TimeRemaining = TimeLeft(reset, now),
ResetDate = reset,
BurnRate = pct / (7 * 24.0),
IsLive = true,
});
}
return result;
}
private static DateTime ParseDate(string? s, DateTime fallback)
{
if (s == null) return fallback.AddHours(1);
return DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.RoundtripKind, out var d)
? d.ToLocalTime() : fallback.AddHours(1);
}
private static string TimeLeft(DateTime reset, DateTime now)
{
var diff = reset - now;
if (diff <= TimeSpan.Zero) return "resetting...";
if (diff.TotalDays >= 1)
return $"{(int)diff.TotalDays}d {diff.Hours}h";
return $"{diff.Hours}h {diff.Minutes}m";
}
private void LoadPlaceholders()
{
var now = DateTime.Now;
Limits =
[
new() { Window = WindowKind.FiveHour, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddHours(1) },
new() { Window = WindowKind.SevenDay, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddDays(7) },
];
}
private void LoadBurnHistory()
{
try
{
var raw = AppSettings.Default.BurnHistory;
if (string.IsNullOrEmpty(raw)) return;
var saved = JsonSerializer.Deserialize<Dictionary<string, List<double>>>(raw);
if (saved != null)
foreach (var kv in saved) _burnHistory[kv.Key] = kv.Value;
}
catch { }
}
private void SaveBurnHistory()
{
AppSettings.Default.BurnHistory = JsonSerializer.Serialize(_burnHistory);
AppSettings.Default.Save();
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
private class CookieEntry
{
public string Name { get; set; } = "";
public string Value { get; set; } = "";
public string Domain { get; set; } = "";
public string Path { get; set; } = "/";
}
}