Compare commits

..
Author SHA1 Message Date
SuperDooper f48b53fd8e beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:22 +02:00
SuperDooper ccfee938b7 beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:21 +02:00
SuperDooper 28d952bcbd beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:19 +02:00
github-actions[bot] 9bc023d239 Beta release v1.2.1-beta.17 2026-05-11 10:07:03 +00:00
SuperDooper fd82177a7d beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:30 +02:00
SuperDooper 112210a99b beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:29 +02:00
SuperDooper f4a83940b1 beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:28 +02:00
SuperDooper 377888a427 beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:26 +02:00
SuperDooper e24113a78b beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:24 +02:00
github-actions[bot] 3baab91f70 Beta release v1.2.1-beta.16 2026-05-11 09:47:48 +00:00
SuperDooper 0aa8837b2f beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:09 +02:00
SuperDooper 373ca1dc14 beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:08 +02:00
SuperDooper 7a4c21768f beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:06 +02:00
SuperDooper 7b26629dc7 beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:05 +02:00
github-actions[bot] e446ea97f9 Beta release v1.2.1-beta.15 2026-05-11 09:43:20 +00:00
SuperDooper 36af325e2d beta.15: only detect auth when back on claude.ai, not on OAuth provider pages 2026-05-11 11:42:26 +02:00
SuperDooper e2ef8b6f2a beta.15: only detect auth when back on claude.ai, not on OAuth provider pages 2026-05-11 11:42:25 +02:00
SuperDooper 7a4975fa3e beta.15: only detect auth when back on claude.ai, not on OAuth provider pages 2026-05-11 11:42:24 +02:00
github-actions[bot] c9912f8463 Beta release v1.2.1-beta.14 2026-05-11 09:30:35 +00:00
SuperDooper 238614a94b beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:40 +02:00
SuperDooper b11507221f beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:38 +02:00
SuperDooper b6fef53264 beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:37 +02:00
SuperDooper ec6ae6621a beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:35 +02:00
github-actions[bot] 105a7706fa Beta release v1.2.1-beta.13 2026-05-11 09:21:33 +00:00
SuperDooper a950392a6f beta.13: wait for apiWebView didFinish before making JS fetch calls 2026-05-11 11:20:51 +02:00
SuperDooper 8087cc029d beta.13: wait for apiWebView didFinish before making JS fetch calls 2026-05-11 11:20:50 +02:00
SuperDooper 11f9e0c9b6 beta.13: wait for apiWebView didFinish before making JS fetch calls 2026-05-11 11:20:49 +02:00
github-actions[bot] cc835ee2b0 Beta release v1.2.1-beta.12 2026-05-11 09:03:50 +00:00
SuperDooper 8e7328b2c5 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:34 +02:00
SuperDooper 709eb12382 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:33 +02:00
SuperDooper e866324a48 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:32 +02:00
SuperDooper a62584221b beta.12: route all API calls through background WKWebView 2026-05-11 11:02:30 +02:00
github-actions[bot] fc7fd19652 Beta release v1.2.1-beta.11 2026-05-11 08:47:11 +00:00
superdooper86 58b947e515 fix: add KVO on webView.url to catch SPA pushState navigation
didFinish only fires for cross-document (full page) navigations. After
loading https://claude.ai/login the SPA redirects authenticated users
via history.pushState to /new — this changes the URL visually but never
fires didFinish, so auth was never detected.

KVO on webView.url fires for every URL change including SPA pushState,
covering the case where the app routes client-side after the initial
page load. Both KVO and didFinish now call the same checkCurrentURL
helper so detection is not missed regardless of navigation type.
2026-05-11 10:46:11 +02:00
github-actions[bot] 88252e4d59 Beta release v1.2.1-beta.10 2026-05-11 08:36:44 +00:00
superdooper86 ad2ff3a7b6 fix: revert to URL-based auth detection; remove browser headers
Every JS/cookie-based detection approach failed. Reverting to the
simplest reliable mechanism: if the WebView navigates to any non-login,
non-auth URL, the server redirected us after sign-in — fire onAuthenticated.

Also removing the browser headers added in beta.6. The 1.1.4 version
worked without them and they may be triggering server-side bot detection.
All-cookies approach (beta.8) is kept.
2026-05-11 10:35:30 +02:00
github-actions[bot] 1a9d9cd22a Beta release v1.2.1-beta.9 2026-05-11 08:23:48 +00:00
superdooper86 b8826ace9b fix: NSNumber cast and use .page world in callAsyncJavaScript auth check
callAsyncJavaScript returns JS numbers as NSNumber (Double-backed).
'val as? Int' silently returns nil for 200.0, so onAuthenticated never
fired. Fixed with 'val as? NSNumber then .intValue == 200'.

Also switched content world from .defaultClient to .page so the fetch
runs in the same JS context as the loaded page.
2026-05-11 10:22:59 +02:00
github-actions[bot] 2a0de269ff Beta release v1.2.1-beta.8 2026-05-11 08:07:51 +00:00
superdooper86 ac7ffe81af fix: use WebView JS fetch for auth detection; send all cookies to API
Cookie domain filtering was wrong — the session token domain is unknown
and was never found by claude.ai/anthropic.com filters.

LoginView: replace getAllCookies domain check with callAsyncJavaScript
that fetches /api/bootstrap directly from the WebView. The WebView uses
its own full session (all cookies, any domain) so auth is detected
correctly regardless of where the token lives.

UsageViewModel: claudeCookieHeader now sends all cookies from the app's
WKWebsiteDataStore instead of filtering by domain. checkInitialSignInState
likewise checks for any cookie.
2026-05-11 10:06:33 +02:00
github-actions[bot] f28f7b8a5a Beta release v1.2.1-beta.7 2026-05-11 07:51:25 +00:00
superdooper86 aaed64484c fix: include anthropic.com cookies in all auth checks and API requests
Claude session cookies are on anthropic.com, not claude.ai. The login
window was not detecting auth (Cancel stayed, no Done) and API calls
were sent without the actual session token.

- claudeCookieHeader: include anthropic.com cookies so the token is
  sent to the usage/bootstrap endpoints
- checkInitialSignInState: detect anthropic.com cookies on startup
- didFinish in LoginView: fire auth when anthropic.com cookies found
- signOut: clear anthropic.com data alongside claude.ai
- notAuthenticated catch: set isSignedIn = false so Settings stays
  in sync with the main panel
2026-05-11 09:49:57 +02:00
github-actions[bot] 2e3ee350ca Beta release v1.2.1-beta.6 2026-05-11 07:37:40 +00:00
superdooper86 e939bdb88b fix: add browser headers to all API requests to resolve 403 on usage endpoint
Claude's usage/prepaid/overage endpoints require Origin, Referer, and
User-Agent headers to pass CORS/auth checks. Without them, bootstrap
succeeds (more permissive) but usage returns 403 -> 'Not signed in'.

Added claudeAPIRequest(for:) helper that sets all required browser-like
headers on every request. Bootstrap, usage, prepaid, overage, and the
orgs fallback all go through it.
2026-05-11 09:36:35 +02:00
github-actions[bot] 6749c5f158 Beta release v1.2.1-beta.5 2026-05-11 07:27:17 +00:00
superdooper86 de71ec2794 fix: load /login instead of root so premature auth detection is prevented
Loading https://claude.ai as the start URL caused didFinish to fire on
the landing page while stale/tracking cookies were already in
WKWebsiteDataStore. The 'any claude.ai cookie' check then fired
immediately, closing the login sheet before the user could sign in.

Loading /login ensures the URL-guard catches the initial page load and
only checks cookies after the real post-login redirect.
2026-05-11 09:25:45 +02:00
github-actions[bot] ac17ce154f Beta release v1.2.1-beta.4 2026-05-11 06:59:20 +00:00
SuperDooper d2eac62897 chore: bump to v1.2.1-beta.4 2026-05-11 08:58:05 +02:00
SuperDooper 8c64fc50ad fix: checkInitialSignInState uses any claude.ai cookie, not specific names 2026-05-11 08:58:04 +02:00
SuperDooper b2c138f665 fix: detect auth by any claude.ai cookie, not specific cookie names 2026-05-11 08:58:02 +02:00
github-actions[bot] aa6c299e2a Beta release v1.2.1-beta.3 2026-05-10 21:41:28 +00:00
SuperDooper ecbae7d7ca chore: release notes for v1.2.1-beta.3 2026-05-10 23:40:31 +02:00
SuperDooper 37a039e1d3 chore: bump to v1.2.1-beta.3 2026-05-10 23:40:30 +02:00
SuperDooper 8f3ead2f65 fix: replace stale 'No API key configured' with correct signed-out message 2026-05-10 23:40:29 +02:00
github-actions[bot] 734670bb3f Beta release v1.2.1-beta.2 2026-05-10 21:29:37 +00:00
SuperDooper 94430e00ba chore: release notes for v1.2.1-beta.2 2026-05-10 23:28:40 +02:00
SuperDooper 7b0368b001 chore: bump to v1.2.1-beta.2 2026-05-10 23:28:39 +02:00
SuperDooper 4629aeffbc fix: load claude.ai instead of /login so already-signed-in users are detected 2026-05-10 23:28:38 +02:00
github-actions[bot] 2cb4ddfe97 Beta release v1.2.1-beta.1 2026-05-10 21:16:08 +00:00
SuperDooper 73b251a1e9 chore: release notes for v1.2.1-beta.1 2026-05-10 23:14:48 +02:00
SuperDooper 7bab6a0df0 chore: bump to v1.2.1-beta.1 2026-05-10 23:14:47 +02:00
SuperDooper db6380fa66 fix: detect sign-in state from cookies on startup, add orgs API fallback for org ID 2026-05-10 23:14:45 +02:00
github-actions[bot] 47a148fa67 Release v1.2.0 2026-05-08 21:20:18 +00:00
SuperDooper 85ac329d36 chore: release notes for v1.2.0 2026-05-08 23:19:24 +02:00
SuperDooper a0fb6eabcf chore: bump to v1.2.0 2026-05-08 23:19:23 +02:00
github-actions[bot] b778d03323 Beta release v1.1.4-beta.7 2026-05-08 21:11:36 +00:00
SuperDooper 13387888b2 chore: release notes for 1.1.4-beta.7 2026-05-08 23:10:35 +02:00
SuperDooper 44be1649a1 chore: bump to 1.1.4-beta.7 2026-05-08 23:10:34 +02:00
SuperDooper 1160712e1b fix: remove ScrollView from main panel so popover auto-sizes to content 2026-05-08 23:10:22 +02:00
github-actions[bot] e4f55c80ee Beta release v1.1.4-beta.6 2026-05-08 21:05:48 +00:00
SuperDooper 526752dc36 chore: release notes for 1.1.4-beta.6 2026-05-08 23:04:54 +02:00
SuperDooper a2d983b555 chore: bump to 1.1.4-beta.6 2026-05-08 23:04:53 +02:00
SuperDooper 0d807c2285 fix: Session Diary — remove Claude icon/header, split stats left/right 2026-05-08 23:04:52 +02:00
SuperDooper 1c42433145 Windows beta release win-v0.0.1-beta.49 2026-05-08 20:46:42 +00:00
SuperDooper c0ac2e5a66 Windows beta release win-v0.0.1-beta.48 2026-05-08 20:39:17 +00:00
SuperDooper 405e65f9f7 Windows beta release win-v0.0.1-beta.47 2026-05-08 20:28:41 +00:00
SuperDooper 1c7433ebea Windows beta release win-v0.0.1-beta.46 2026-05-08 20:19:52 +00:00
SuperDooper a39ef800e8 Windows beta release win-v0.0.1-beta.45 2026-05-08 20:05:58 +00:00
SuperDooper 76dec8b85d Windows beta release win-v0.0.1-beta.44 2026-05-08 20:00:55 +00:00
SuperDooper 2f04f8581c Windows beta release win-v0.0.1-beta.42 2026-05-08 19:41:45 +00:00
SuperDooper 788c066a1e Windows beta release win-v0.0.1-beta.41 2026-05-08 19:39:07 +00:00
SuperDooper 31280d4ab2 Windows beta release win-v0.0.1-beta.40 2026-05-08 19:33:28 +00:00
SuperDooper 7b1c5bbf8e Windows beta release win-v0.0.1-beta.39 2026-05-08 19:19:11 +00:00
SuperDooper f2ba44bbaa Windows beta release win-v0.0.1-beta.38 2026-05-08 19:07:16 +00:00
SuperDooper de1fffe0e3 Windows beta release win-v0.0.1-beta.37 2026-05-08 19:00:02 +00:00
SuperDooper e80f07d1b9 Windows beta release win-v0.0.1-beta.36 2026-05-08 17:40:07 +00:00
github-actions[bot] e9ca8a7efc Beta release v1.1.4-beta.5 2026-05-08 17:32:10 +00:00
SuperDooper 63ef2005a4 Update release notes for 1.1.4-beta.5 2026-05-08 19:31:20 +02:00
SuperDooper 5ad7bb26f0 Bump version to 1.1.4-beta.5 2026-05-08 19:31:18 +02:00
SuperDooper 132a2a8f8d Consolidate bootstrap into single fetchBootstrap() call 2026-05-08 19:31:06 +02:00
github-actions[bot] debd1c1850 Beta release v1.1.4-beta.4 2026-05-08 17:27:59 +00:00
SuperDooper 9bcbb2ae7d Update release notes for 1.1.4-beta.4 2026-05-08 19:26:58 +02:00
SuperDooper c9384f6424 Bump version to 1.1.4-beta.4 2026-05-08 19:26:57 +02:00
SuperDooper e4693fc18f Fix fetchUserEmail return type and capabilities subscript 2026-05-08 19:26:48 +02:00
SuperDooper 84a8592bae Windows beta release win-v0.0.1-beta.35 2026-05-08 17:24:24 +00:00
SuperDooper bdd2038364 Update release notes for 1.1.4-beta.3 2026-05-08 19:22:51 +02:00
SuperDooper fd61face77 Bump version to 1.1.4-beta.3 2026-05-08 19:22:50 +02:00
SuperDooper 1f58a81a26 Parse plan label from bootstrap on every refresh via fetchUserEmail 2026-05-08 19:22:41 +02:00
github-actions[bot] 7f6da51860 Beta release v1.1.4-beta.2 2026-05-08 17:20:51 +00:00
SuperDooper b49ac23863 Update release notes for 1.1.4-beta.2 2026-05-08 19:19:50 +02:00
SuperDooper 01d268c3e5 Bump version to 1.1.4-beta.2 2026-05-08 19:19:49 +02:00
SuperDooper 871d5245ac Parse plan label from capabilities array instead of plan_type 2026-05-08 19:14:53 +02:00
SuperDooper 0524f9b791 Parse plan label from bootstrap API instead of hardcoding 2026-05-08 19:09:06 +02:00
github-actions[bot] d68d564351 Beta release v1.1.4-beta.1 2026-05-08 15:12:07 +00:00
SuperDooper d55bad01db Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:27 +02:00
SuperDooper 7bc72e240b Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:26 +02:00
SuperDooper d77ed23d09 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:06 +02:00
SuperDooper 6136b0ded0 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:00 +02:00
SuperDooper aba7168448 Windows beta release win-v0.0.1-beta.33 2026-05-08 15:03:42 +00:00
SuperDooper a8c2083397 fix: remove hardcoded orgId from cookie primer URL 2026-05-08 16:54:48 +02:00
SuperDooper ad23446ed4 chore: release notes for v1.1.4-beta.1 2026-05-08 16:52:17 +02:00
SuperDooper d8b75d279c chore: bump version to 1.1.4-beta.1 2026-05-08 16:52:16 +02:00
SuperDooper 2658ff7f6a Windows beta release win-v0.0.1-beta.32 2026-05-08 14:51:18 +00:00
SuperDooper e10d79e5a6 fix: fetch org ID dynamically from bootstrap instead of hardcoding it 2026-05-08 16:49:44 +02:00
SuperDooper 88daa27630 Windows beta release win-v0.0.1-beta.31 2026-05-08 14:47:27 +00:00
SuperDooper b7c4958a74 Windows beta release win-v0.0.1-beta.30 2026-05-08 14:38:16 +00:00
SuperDooper f46b13d967 Windows beta release win-v0.0.1-beta.29 2026-05-08 14:30:44 +00:00
SuperDooper c449882ec6 Windows beta release win-v0.0.1-beta.28 2026-05-08 14:26:00 +00:00
SuperDooper 3c1a2ef1be Windows beta release win-v0.0.1-beta.27 2026-05-08 14:17:00 +00:00
SuperDooper c238db963b Windows beta release win-v0.0.1-beta.25 2026-05-08 14:07:31 +00:00
SuperDooper 63c9a8582d Windows beta release win-v0.0.1-beta.24 2026-05-08 14:02:59 +00:00
SuperDooper 6f256b3308 Windows beta release win-v0.0.1-beta.23 2026-05-08 13:51:22 +00:00
SuperDooper 2761b31a12 Windows beta release win-v0.0.1-beta.22 2026-05-08 13:31:58 +00:00
SuperDooper f9d8abebd5 Windows beta release win-v0.0.1-beta.20 2026-05-08 12:55:50 +00:00
SuperDooper 66ee233b1b Windows beta release win-v0.0.1-beta.19 2026-05-08 12:41:51 +00:00
SuperDooper a12c664bde Windows beta release win-v0.0.1-beta.18 2026-05-08 12:28:14 +00:00
SuperDooper c7251d8785 Windows beta release win-v0.0.1-beta.17 2026-05-08 12:16:57 +00:00
SuperDooper 7a95b100ef Windows beta release win-v0.0.1-beta.16 2026-05-08 12:12:23 +00:00
SuperDooper fb98c69c34 Windows beta release win-v0.0.1-beta.15 2026-05-08 12:06:07 +00:00
SuperDooper 5629c6b61e Windows beta release win-v0.0.1-beta.14 2026-05-08 11:58:49 +00:00
SuperDooper 4709f5b0f5 Windows beta release win-v0.0.1-beta.12 2026-05-08 11:49:10 +00:00
SuperDooper 5f8aeaa6e9 Windows beta release win-v0.0.1-beta.11 2026-05-08 11:35:50 +00:00
SuperDooper 27f1f45109 Windows beta release win-v0.0.1-beta.10 2026-05-08 11:29:15 +00:00
SuperDooper 7a5512cc28 Windows beta release win-v0.0.1-beta.9 2026-05-08 11:17:01 +00:00
SuperDooper 8c89ce4636 Windows beta release win-v0.0.1-beta.8 2026-05-08 11:08:41 +00:00
SuperDooper 3a964dc2f8 Windows beta release win-v0.0.1-beta.7 2026-05-08 11:00:23 +00:00
34 changed files with 274 additions and 3058 deletions
@@ -1,77 +0,0 @@
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
@@ -1,77 +0,0 @@
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
+1 -1
View File
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
// Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+10 -13
View File
@@ -40,8 +40,7 @@ struct ContentView: View {
.environmentObject(updater)
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
VStack(spacing: 0) {
// Cards grid
if vm.limits.isEmpty {
EmptyStateView()
@@ -96,7 +95,6 @@ struct ContentView: View {
.padding(.bottom, 8)
}
}
}
}
.transition(.opacity)
@@ -118,8 +116,8 @@ struct ContentView: View {
showUpdateSheet = true
}
.sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) {
Task { await vm.refresh() }
LoginSheetView(isPresented: $showLogin) { webView in
Task { await vm.adoptAndRefresh(webView) }
}
}
.sheet(isPresented: $showUpdateSheet) {
@@ -398,12 +396,11 @@ struct DiaryRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
AgentIconView(agent: .claude, size: 12)
Text("Claude")
.font(.system(size: 12, weight: .medium))
.foregroundColor(color)
Text("\(totalSaved) samples")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
Spacer()
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
}
@@ -914,12 +911,12 @@ struct ErrorBanner: View {
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 10) {
Image(systemName: "key.slash")
Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("No API key configured")
Text("Not signed in")
.font(.system(size: 13, weight: .medium))
Text("Open Settings to add your Anthropic API key.")
Text("Sign in to claude.ai to see your usage.")
.font(.system(size: 11.5))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
+2 -6
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.3</string>
<string>1.2.1-beta.18</string>
<key>CFBundleVersion</key>
<string>40</string>
<string>66</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
@@ -45,7 +45,3 @@
</dict>
</dict>
</plist>
+35 -30
View File
@@ -4,7 +4,7 @@ import WebKit
// MARK: - Login Web View
struct LoginWebView: NSViewRepresentable {
let onAuthenticated: () -> Void
let onAuthenticated: (WKWebView) -> Void
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
@@ -12,6 +12,13 @@ struct LoginWebView: NSViewRepresentable {
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
context.coordinator.webView = webView
// KVO on url catches SPA pushState navigations that don't fire didFinish
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
coordinator?.checkCurrentURL(wv.url?.absoluteString)
}
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
return webView
}
@@ -23,31 +30,31 @@ struct LoginWebView: NSViewRepresentable {
}
class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: () -> Void
let onAuthenticated: (WKWebView) -> Void
weak var webView: WKWebView?
var didAuthenticate = false
var urlObservation: NSKeyValueObservation?
init(onAuthenticated: @escaping () -> Void) {
init(onAuthenticated: @escaping (WKWebView) -> Void) {
self.onAuthenticated = onAuthenticated
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard !didAuthenticate else { return }
// Don't fire on the login/auth pages themselves
if let url = webView.url?.absoluteString,
url.contains("/login") || url.contains("/auth") { return }
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
let hasSession = cookies.contains {
$0.domain.contains("claude.ai") &&
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
}
guard hasSession, !self.didAuthenticate else { return }
self.didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated()
}
func checkCurrentURL(_ url: String?) {
guard !didAuthenticate, let url, let wv = webView else { return }
// Ignore navigations to external OAuth providers (Google, etc.)
// only consider auth complete when we land back on claude.ai/anthropic.com
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
if url.contains("/login") || url.contains("/auth") { return }
didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated(wv)
}
}
// Covers full cross-document navigations
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
checkCurrentURL(webView.url?.absoluteString)
}
}
}
@@ -55,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
struct LoginSheetView: View {
@Binding var isPresented: Bool
let onDone: () -> Void
let onDone: (WKWebView) -> Void
@State private var authenticated = false
var body: some View {
@@ -65,12 +72,9 @@ struct LoginSheetView: View {
.font(.system(size: 13, weight: .semibold))
Spacer()
if authenticated {
Button("Done") {
isPresented = false
onDone()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Done") { isPresented = false }
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
@@ -83,12 +87,13 @@ struct LoginSheetView: View {
Divider()
LoginWebView {
LoginWebView { webView in
authenticated = true
// Auto-dismiss and refresh after brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
// Adopt the authenticated WebView immediately (before sheet tears it down),
// then auto-dismiss after a moment so the user sees confirmation.
onDone(webView)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
isPresented = false
onDone()
}
}
}
+195 -76
View File
@@ -3,7 +3,6 @@ import WebKit
@MainActor
class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = []
@Published var isLoading = false
@Published var lastUpdated: Date?
@@ -28,12 +27,16 @@ class UsageViewModel: ObservableObject {
private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
// Background WKWebView for API calls via WebKit navigation.
// Hosted in a hidden NSWindow to keep the WebKit process active.
private var apiWebView: WKWebView?
private var apiWindow: NSWindow?
private var currentFetchDelegate: APIFetchDelegate?
init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
@@ -41,14 +44,68 @@ class UsageViewModel: ObservableObject {
burnHistoryStore = saved
}
loadPlaceholderData()
setupAPIWebView()
Task { await checkInitialSignInState() }
}
// MARK: - Background API WebView
private func setupAPIWebView() {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
apiWebView = wv
// Hosting in a 1×1 transparent window keeps the WebKit process active.
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
styleMask: .borderless,
backing: .buffered,
defer: false)
window.alphaValue = 0.0
window.ignoresMouseEvents = true
window.isReleasedWhenClosed = false
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.contentView?.addSubview(wv)
window.orderFrontRegardless()
apiWindow = window
}
// Called after login: adopt the login WebView (proven authenticated) and refresh.
func adoptAndRefresh(_ loginWebView: WKWebView) async {
loginWebView.removeFromSuperview()
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
apiWindow?.contentView?.addSubview(loginWebView)
apiWebView = loginWebView
try? await Task.sleep(nanoseconds: 400_000_000)
await refresh()
}
// Fetches an API path by navigating the WebView to the URL and reading the response.
// Navigation lets WebKit send full browser headers and cookies automatically
// more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors.
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
guard let wv = apiWebView else { throw AppError.detail("no api webview") }
return try await withCheckedThrowingContinuation { cont in
let delegate = APIFetchDelegate(continuation: cont)
currentFetchDelegate = delegate
wv.navigationDelegate = delegate
wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!))
}
}
private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true }
}
func signOut() async {
let store = WKWebsiteDataStore.default()
let types = WKWebsiteDataStore.allWebsiteDataTypes()
let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -57,6 +114,7 @@ class UsageViewModel: ObservableObject {
extraUsage = nil
prepaidCredits = nil
overageSpendLimit = nil
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
func refresh() async {
@@ -66,18 +124,29 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false }
do {
let orgId = cachedOrgId!
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
async let emailFetch = fetchUserEmail()
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
if let email { userEmail = email }
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
let orgId: String
if let id = fetchedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
orgId = id
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
orgId = cached
} else {
throw AppError.notAuthenticated
}
if let email = fetchedEmail { userEmail = email }
if let plan = fetchedPlan { planLabel = plan }
// Sequential each call navigates the shared WebView to the next API URL.
let usage = try await fetchUsage(orgId: orgId)
let prepaid = try? await fetchPrepaidCredits(orgId: orgId)
let overage = try? await fetchOverageSpendLimit(orgId: orgId)
limits = buildLimits(from: usage)
extraUsage = usage.extraUsage
prepaidCredits = prepaid
extraUsage = usage.extraUsage
prepaidCredits = prepaid
overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date()
isSignedIn = true
@@ -93,6 +162,7 @@ class UsageViewModel: ObservableObject {
checkLimitNotifications(for: limits)
} catch AppError.notAuthenticated {
isNotAuthenticated = true
isSignedIn = false
errorMessage = "Not signed in"
} catch let error as DecodingError {
switch error {
@@ -110,70 +180,71 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await webViewFetch("/api/bootstrap")
if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.detail("bootstrap \(status): \(body.prefix(80))") }
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.notAuthenticated
}
guard let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil)
}
let account = json["account"] as? [String: Any]
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
let firstOrg = memberships?.first?["organization"] as? [String: Any]
var orgId: String? = firstOrg?["uuid"] as? String
if orgId == nil {
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
}
if orgId == nil {
if let (orgsStatus, orgsBody) = try? await webViewFetch("/api/organizations"),
orgsStatus == 200,
let orgsData = orgsBody.data(using: .utf8),
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
orgId = orgs.first?["uuid"] as? String
}
}
let email = account?["email_address"] as? String
var planLabel: String? = nil
if let caps = firstOrg?["capabilities"] as? [String],
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
let name = String(cap.dropFirst("claude_".count))
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
}
return (orgId, email, planLabel)
}
// MARK: - Fetch usage
private func fetchUsage(orgId: String) async throws -> UsageResponse {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data)
}
private func fetchUserEmail() async throws -> String? {
let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let account = json["account"] as? [String: Any],
let email = account["email_address"] as? String else { return nil }
return email
}
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
}
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
}
@@ -245,14 +316,11 @@ class UsageViewModel: ObservableObject {
private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95]
for limit in limits {
let key = limit.window.rawValue
let curr = limit.usedPercent
let prev = previousPercents[key]
var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds {
let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
@@ -263,8 +331,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName, "percent": t]
)
}
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 {
fired.removeAll()
NotificationCenter.default.post(
@@ -273,7 +339,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName]
)
}
firedThresholds[key] = fired
previousPercents[key] = curr
}
@@ -296,13 +361,68 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - API Fetch Delegate
// Captures the HTTP status code and response body from a WebView navigation.
// Used by webViewFetch to turn a navigation into an async (statusCode, body) result.
private class APIFetchDelegate: NSObject, WKNavigationDelegate {
private let continuation: CheckedContinuation<(Int, String), Error>
private var capturedStatus = 0
private var finished = false
init(continuation: CheckedContinuation<(Int, String), Error>) {
self.continuation = continuation
}
private func complete(_ result: Result<(Int, String), Error>) {
guard !finished else { return }
finished = true
continuation.resume(with: result)
}
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if let http = response.response as? HTTPURLResponse {
capturedStatus = http.statusCode
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let status = capturedStatus
// Detect auth redirect: if WebKit followed a 302 to /login, finalURL changes.
let finalURL = webView.url?.absoluteString ?? ""
if finalURL.contains("/login") || finalURL.contains("/auth") {
complete(.success((401, "redirected:\(finalURL)")))
return
}
webView.evaluateJavaScript("document.body.innerText ?? ''") { [weak self] result, error in
if let body = result as? String {
self?.complete(.success((status, body)))
} else {
self?.complete(.failure(AppError.detail("body read: \(error?.localizedDescription ?? "nil")")))
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
complete(.failure(AppError.detail("nav: \(error.localizedDescription.prefix(80))")))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
complete(.failure(AppError.detail("prov: \(error.localizedDescription.prefix(80))")))
}
}
enum AppError: LocalizedError {
case notAuthenticated
case networkError
case detail(String)
var errorDescription: String? {
switch self {
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data."
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data."
case .detail(let msg): return msg
}
}
}
@@ -317,4 +437,3 @@ extension Notification.Name {
static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet")
}
+2 -2
View File
@@ -8,9 +8,9 @@
[![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org)
[![Version](https://img.shields.io/badge/version-1.1.3-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
<!-- BETA_BADGE -->
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.17-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- BETA_BADGE -->
</div>
+17 -9
View File
@@ -1,12 +1,20 @@
## What's new in v1.1.3
## What's new in v1.2.1-beta.18
### UI polish
- Limit headers now read "5 Hour Limit" and "7 Day Limit"
- Tapping anywhere on the blue update banner opens the update window
### Bug fixes
- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.
- API calls are now sequential to share a single WebView for all navigations
- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser
### Session Diary fix
- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh
## What's new in v1.2.1-beta.17
### 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
### Bug fixes
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
- Added diagnostic error messages for JS errors and unexpected responses
## What's new in v1.2.1
### Bug fixes
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
- Fixed login window auto-closing before sign-in completes
- Added /api/organizations as a final fallback for org ID resolution
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
+4 -4
View File
@@ -1,5 +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"
}
"version": "1.2.1-beta.17",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires\n- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
}
+5 -5
View File
@@ -1,5 +1,5 @@
{
"version": "0.0.0",
"url": "",
"notes": ""
}
{
"version": "0.0.1-beta.49",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.36\r\n\r\n### Bug fixes\r\n- Settings no longer incorrectly shows \"Not signed in\" when limits are working\r\n- Session Diary now shows correctly after the first successful refresh\r\n- Extra Usage Credits section now restored from cache on startup\r\n- Plan name, overage, and prepaid credits are now cached and shown immediately on launch\r\n- App now loads cached state instantly on startup before the background refresh completes"
}
-5
View File
@@ -1,5 +0,0 @@
{
"version": "0.0.0",
"url": "",
"notes": ""
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.1.3",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip",
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
"version": "1.2.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
"notes": "## What's new in v1.2.0\n\n### Bug fixes\n- App now works for all users — org ID is fetched dynamically from the API instead of being hardcoded\n- Plan name (e.g. Pro, Max) now updates correctly on every refresh\n\n### Improvements\n- Plan name is read from the API rather than hardcoded\n- Bootstrap API call consolidated — org ID, email, and plan name fetched in a single request per refresh\n- Main panel no longer scrolls — popover auto-sizes to fit content\n- Session Diary card redesigned — sample count and avg burn rate shown left/right above the sparkline, Claude header removed"
}
-170
View File
@@ -1,170 +0,0 @@
<Application x:Class="ClaudeCheckerWindows.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:po="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<SolidColorBrush x:Key="BackgroundBrush" Color="#F3F3F3" po:Freeze="False"/>
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF" po:Freeze="False"/>
<SolidColorBrush x:Key="AccentBrush" Color="#F97316" po:Freeze="False"/>
<SolidColorBrush x:Key="TextBrush" Color="#1C1C1C" po:Freeze="False"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="#6E6E6E" po:Freeze="False"/>
<SolidColorBrush x:Key="BorderBrush" Color="#E0E0E0" po:Freeze="False"/>
<SolidColorBrush x:Key="BlueBrush" Color="#0078D4" po:Freeze="False"/>
<SolidColorBrush x:Key="ButtonBrush" Color="#FFFFFF" po:Freeze="False"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="#FAFAFA" po:Freeze="False"/>
<SolidColorBrush x:Key="BannerBgBrush" Color="#E8F2FB" po:Freeze="False"/>
<SolidColorBrush x:Key="BannerBorderBrush" Color="#B3D6F5" po:Freeze="False"/>
<SolidColorBrush x:Key="HoverBrush" Color="#EBEBEB" po:Freeze="False"/>
<SolidColorBrush x:Key="BadgeBrush" Color="#F0F0F0" po:Freeze="False"/>
<Style x:Key="HeaderText" TargetType="TextBlock">
<Setter Property="FontSize" Value="10"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource SecondaryBrush}"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource 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="{DynamicResource ButtonBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource 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="{DynamicResource HoverBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Background" Value="{DynamicResource CardBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<ContentPresenter x:Name="ContentSite"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" HorizontalAlignment="Left"/>
<TextBlock Text="▾" FontSize="10"
Foreground="{DynamicResource SecondaryBrush}"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,8,0" IsHitTestVisible="False"/>
<ToggleButton Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
Background="Transparent" BorderThickness="0"/>
</Grid>
</Border>
<Popup x:Name="Popup" Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False"
PopupAnimation="Slide">
<Border SnapsToDevicePixels="True"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
Background="{DynamicResource CardBrush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="4">
<ScrollViewer Margin="0,2">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Background" Value="{DynamicResource CardBrush}"/>
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{DynamicResource HoverBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollViewer">
<Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
</Style>
</Application.Resources>
</Application>
-141
View File
@@ -1,141 +0,0 @@
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)
{
var logPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "startup_crash.txt");
AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
System.IO.File.WriteAllText(logPath, ex.ExceptionObject?.ToString() ?? "unknown");
DispatcherUnhandledException += (_, ex) =>
{
System.IO.File.WriteAllText(logPath, ex.Exception?.ToString() ?? "unknown");
ex.Handled = false;
};
base.OnStartup(e);
ThemeManager.Initialize();
SetupTray();
ScheduleTimer(ViewModel.RefreshInterval);
_ = Task.Run(async () =>
{
await ViewModel.LoadFromCacheAsync();
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);
}
}
-50
View File
@@ -1,50 +0,0 @@
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 string OrgId { get; set; } = "";
public string Email { get; set; } = "";
public string UsageJson { get; set; } = "";
public int RefreshInterval { get; set; } = 120;
public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false;
public string DebugInfo { get; set; } = "";
public string PlanLabel { get; set; } = "";
public string OverageJson { get; set; } = "";
public string PrepaidJson { get; set; } = "";
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.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-32
View File
@@ -1,32 +0,0 @@
<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>
<Resource Include="Assets\icon.png"/>
</ItemGroup>
</Project>
-97
View File
@@ -1,97 +0,0 @@
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);
var sub = new FormattedText(
"used",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), 8,
new SolidColorBrush(subColor), dpi)
{
TextAlignment = TextAlignment.Center
};
var totalHeight = pctText.Height + 2 + sub.Height;
var startY = cy - totalHeight / 2;
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, startY));
dc.DrawText(sub, new Point(cx - sub.Width / 2, startY + pctText.Height + 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
@@ -1,60 +0,0 @@
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
@@ -1,9 +0,0 @@
// 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
@@ -1,42 +0,0 @@
<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>
-154
View File
@@ -1,154 +0,0 @@
using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
namespace ClaudeCheckerWindows;
public partial class LoginWindow : Window
{
private bool _closing;
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 || _closing) 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.";
});
if (!uri.Contains("/login") && !uri.Contains("/signin") && signedIn)
await SaveAndClose(cookies);
};
}
private async void Done_Click(object sender, RoutedEventArgs e)
{
if (_closing) return;
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
await SaveAndClose(cookies);
}
private async Task SaveAndClose(IReadOnlyList<CoreWebView2Cookie> cookies)
{
if (_closing) return;
_closing = true;
UsageViewModel.SaveCookies(cookies);
try
{
// Use WebMessageReceived so the async script can post back without relying
// on Promise-awaiting support in the WebView2 runtime version.
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) =>
{
Browser.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(args.WebMessageAsJson);
};
Browser.CoreWebView2.WebMessageReceived += msgHandler;
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
const org0=b?.account?.memberships?.[0]?.organization;
let id=org0?.uuid||b?.memberships?.[0]?.organization?.uuid
||b?.organizations?.[0]?.uuid||b?.default_organization?.uuid||null;
const e=b?.account?.email_address||b?.account?.email||b?.email||null;
const caps=org0?.capabilities||[];
const capStr=caps.find(c=>typeof c==='string'&&c.startsWith('claude_'))||null;
const planLabel=capStr?(capStr.slice(7,8).toUpperCase()+capStr.slice(8).toLowerCase()):null;
let orgSrc='bootstrap';
if(!id){
try{const ol=await(await fetch('/api/organizations',h)).json();
if(Array.isArray(ol)&&ol.length>0){id=ol[0]?.uuid||null;orgSrc='orgs-list';}}catch(e2){}
}
if(!id){
let pu=null;
try{pu=await(await fetch('/api/usage',h)).json();}catch(e3){}
window.chrome.webview.postMessage({email:e,orgId:null,planLabel,usage:(pu&&!pu.error?pu:null),debug:'no-org'});
return;
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
window.chrome.webview.postMessage({email:e,orgId:id,planLabel,usage:u,debug:'src:'+orgSrc+'|plan:'+(planLabel||'none')});
}catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
await Browser.CoreWebView2.ExecuteScriptAsync(script);
// Wait up to 15 s for the script to post its message
var completed = await Task.WhenAny(tcs.Task, Task.Delay(15000));
var json = completed == tcs.Task ? tcs.Task.Result : null;
if (!string.IsNullOrEmpty(json))
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String)
AppSettings.Default.Email = em.GetString() ?? "";
if (root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String)
AppSettings.Default.OrgId = oi.GetString() ?? "";
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
AppSettings.Default.UsageJson = us.GetRawText();
if (root.TryGetProperty("planLabel", out var pl) && pl.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(pl.GetString()))
AppSettings.Default.PlanLabel = pl.GetString()!;
if (root.TryGetProperty("debug", out var dbg) && dbg.ValueKind == JsonValueKind.String)
AppSettings.Default.DebugInfo = dbg.GetString() ?? "";
else if (root.TryGetProperty("error", out var err))
AppSettings.Default.DebugInfo = "JS error: " + err.GetRawText();
else
AppSettings.Default.DebugInfo = "timeout or empty message";
AppSettings.Default.Save();
}
else
{
AppSettings.Default.DebugInfo = json == null ? "script timeout (15s)" : "empty message";
AppSettings.Default.Save();
}
}
catch (Exception ex)
{
AppSettings.Default.DebugInfo = "exception: " + ex.Message;
AppSettings.Default.Save();
}
await Dispatcher.InvokeAsync(() => DialogResult = true);
}
}
-71
View File
@@ -1,71 +0,0 @@
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("five_hour")] public WindowData? FiveHour { get; set; }
[JsonPropertyName("seven_day")] public WindowData? SevenDay { get; set; }
[JsonPropertyName("extra_usage")] public ExtraUsage? ExtraUsage { get; set; }
}
public class WindowData
{
[JsonPropertyName("utilization")] public double Utilization { get; set; }
[JsonPropertyName("resets_at")] public string? ResetsAt { get; set; }
}
public class ExtraUsage
{
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; }
[JsonPropertyName("currency")] public string? Currency { get; set; }
[JsonPropertyName("used_credits")] public double? UsedCredits { get; set; }
[JsonPropertyName("monthly_limit")] public double? MonthlyLimit { 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; }
}
-254
View File
@@ -1,254 +0,0 @@
<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="{DynamicResource 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="{DynamicResource BannerBgBrush}"
BorderBrush="{DynamicResource 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="{DynamicResource BlueBrush}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="10,0">
<TextBlock x:Name="UpdateBannerTitle" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"/>
<TextBlock Text="Click to view release notes and install."
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
</StackPanel>
<Button Grid.Column="2" Content="Update" Style="{StaticResource SmallButton}"
Foreground="{DynamicResource BlueBrush}" Click="UpdateBanner_Click"/>
</Grid>
</Border>
<!-- Footer -->
<Border DockPanel.Dock="Bottom" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="0,1,0,0" Background="{DynamicResource 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="{DynamicResource 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="{DynamicResource BackgroundBrush}">
<DockPanel>
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="0,0,0,1" Background="{DynamicResource SurfaceBrush}" Padding="12,10">
<Grid>
<Button Content="← Back" HorizontalAlignment="Left"
Style="{StaticResource SmallButton}" BorderThickness="0"
Foreground="{DynamicResource BlueBrush}" Background="Transparent"
Click="BackFromSettings_Click"/>
<TextBlock Text="Settings" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource 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="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource 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="{DynamicResource 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="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource 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="{DynamicResource TextBrush}"/>
<TextBlock Text="Receive early access builds — may be less stable"
FontSize="11" Foreground="{DynamicResource 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="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource 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="{DynamicResource 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="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource 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="{DynamicResource TextBrush}"/>
<Border Background="{DynamicResource BadgeBrush}" CornerRadius="3"
Margin="6,0,0,0" Padding="5,1">
<TextBlock x:Name="VersionLabel" FontSize="11"
Foreground="{DynamicResource SecondaryBrush}"
FontFamily="Consolas"/>
</Border>
</StackPanel>
<TextBlock Text="Built by superdooper86 &amp; claude"
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<Button x:Name="CheckUpdateButton"
Style="{StaticResource SmallButton}"
Click="CheckUpdate_Click"/>
<TextBlock x:Name="UpdateAvailableText" FontSize="10"
Foreground="{DynamicResource BlueBrush}"
HorizontalAlignment="Center"
Visibility="Collapsed" Margin="0,4,0,0"/>
</StackPanel>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Border>
<!-- Update panel -->
<Border x:Name="UpdatePanel" Visibility="Collapsed"
Background="{DynamicResource BackgroundBrush}">
<DockPanel>
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="0,0,0,1" Background="{DynamicResource SurfaceBrush}" Padding="12,10">
<Grid>
<Button Content="← Back" HorizontalAlignment="Left"
Style="{StaticResource SmallButton}" BorderThickness="0"
Foreground="{DynamicResource BlueBrush}" Background="Transparent"
Click="CloseUpdate_Click"/>
<TextBlock Text="Update Available" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource 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="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource 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="{DynamicResource SecondaryBrush}" VerticalAlignment="Center"/>
<TextBlock Text=" → " FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
VerticalAlignment="Center"/>
<TextBlock x:Name="NewVerLabel" FontSize="13" FontWeight="SemiBold"
FontFamily="Consolas" Foreground="{DynamicResource BlueBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="ReleaseNotesText" FontSize="11" TextWrapping="Wrap"
Foreground="{DynamicResource SecondaryBrush}" MaxHeight="160"/>
</StackPanel>
</Grid>
</Border>
<TextBlock x:Name="UpdateStatusText" Foreground="{DynamicResource 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="{DynamicResource BlueBrush}"/>
<TextBlock Text="ClaudeChecker will download, replace itself, and relaunch automatically."
FontSize="11" Foreground="{DynamicResource 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>
-509
View File
@@ -1,509 +0,0 @@
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;
[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));
var fiveHour = VM.Limits.FirstOrDefault(l => l.Window == WindowKind.FiveHour);
if (fiveHour != null && fiveHour.BurnHistory.Count > 0)
CardsPanel.Children.Add(BuildDiarySection(fiveHour));
if (VM.ExtraUsage?.IsEnabled == true)
CardsPanel.Children.Add(BuildExtraUsageSection());
}
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;
}
private static UIElement BuildDiarySection(AgentLimit limit)
{
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var samples = limit.BurnHistory.Count;
var avgBurn = limit.BurnRate;
var subtitle = new TextBlock
{
Text = $"{samples} samples · {avgBurn:F1}%/h avg burn rate",
FontSize = 11,
Foreground = secondary,
Margin = new Thickness(16, 0, 16, 6),
};
var sparkline = new SparklineControl
{
Height = 32,
Margin = new Thickness(8, 8, 8, 8),
LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
Data = limit.BurnHistory,
};
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 = sparkline,
};
var sectionLabel = new TextBlock
{
Text = "SESSION DIARY",
Style = (Style)Application.Current.Resources["HeaderText"],
Margin = new Thickness(16, 12, 16, 6),
};
var section = new StackPanel();
section.Children.Add(sectionLabel);
section.Children.Add(subtitle);
section.Children.Add(card);
return section;
}
private UIElement BuildExtraUsageSection()
{
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var accent = new SolidColorBrush(Color.FromRgb(0xF9, 0x73, 0x16));
var overage = VM.Overage;
var prepaid = VM.Prepaid;
var currency = VM.ExtraUsage?.Currency ?? overage?.Currency ?? "$";
// Fall back to extra_usage fields when the overage/prepaid endpoints return nothing
var spent = overage?.UsedCredits ?? VM.ExtraUsage?.UsedCredits ?? 0;
var limit = overage?.MonthlyCreditLimit ?? VM.ExtraUsage?.MonthlyLimit ?? 0;
var balance = prepaid?.Amount ?? 0;
UIElement MakeRow(string label, string value)
{
var row = new Grid { Margin = new Thickness(0, 3, 0, 3) };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var lbl = new TextBlock { Text = label, FontSize = 12, Foreground = secondary };
var val = new TextBlock { Text = value, FontSize = 12, FontWeight = FontWeights.Medium, Foreground = text };
Grid.SetColumn(lbl, 0);
Grid.SetColumn(val, 1);
row.Children.Add(lbl);
row.Children.Add(val);
return row;
}
var contentPanel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
contentPanel.Children.Add(MakeRow("Spent", $"{currency}{spent:F2}"));
if (limit > 0) contentPanel.Children.Add(MakeRow("Limit", $"{currency}{limit:F2}"));
if (balance > 0) contentPanel.Children.Add(MakeRow("Balance", $"{currency}{balance:F2}"));
if (limit > 0)
{
var pct = Math.Min(spent / limit * 100, 100);
contentPanel.Children.Add(new ProgressBar
{
Value = pct, Maximum = 100, Height = 4,
Margin = new Thickness(0, 6, 0, 0),
Foreground = accent,
Background = new SolidColorBrush(Color.FromArgb(20, 0, 0, 0)),
BorderThickness = new Thickness(0),
});
}
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 = contentPanel,
};
var sectionLabel = new TextBlock
{
Text = "EXTRA USAGE CREDITS",
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)
or nameof(UsageViewModel.ExtraUsage) or nameof(UsageViewModel.Overage) or nameof(UsageViewModel.Prepaid))
{
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();
UpdateAboutSection();
}
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 UpdateAboutSection()
{
if (Updater.UpdateAvailable)
{
UpdateAvailableText.Text = $"v{Updater.LatestVersion} available";
UpdateAvailableText.Visibility = Visibility.Visible;
CheckUpdateButton.Content = "Install";
}
else
{
UpdateAvailableText.Visibility = Visibility.Collapsed;
CheckUpdateButton.Content = "Check";
}
}
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;
UpdateAboutSection();
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 = (SolidColorBrush)Application.Current.Resources["TextBrush"],
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)
{
var login = new LoginWindow { Owner = this };
if (login.ShowDialog() == true)
{
// Show cached data from SaveAndClose immediately (no WebView2 needed)
await VM.LoadFromCacheAsync();
InitSettings();
ShowMain();
// Full refresh in background after WebView2 folder is definitely released
_ = Task.Run(async () =>
{
await Task.Delay(3000);
await VM.RefreshAsync();
await Application.Current.Dispatcher.InvokeAsync(InitSettings);
});
}
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
@@ -1,23 +0,0 @@
<?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
@@ -1,50 +0,0 @@
# 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.
-8
View File
@@ -1,8 +0,0 @@
## What's new in beta.36
### Bug fixes
- Settings no longer incorrectly shows "Not signed in" when limits are working
- Session Diary now shows correctly after the first successful refresh
- Extra Usage Credits section now restored from cache on startup
- Plan name, overage, and prepaid credits are now cached and shown immediately on launch
- App now loads cached state instantly on startup before the background refresh completes
-81
View File
@@ -1,81 +0,0 @@
using Microsoft.Win32;
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
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();
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
timer.Tick += (_, _) =>
{
var nowDark = ReadIsDark();
if (nowDark == IsDark) return;
IsDark = nowDark;
Apply();
ThemeChanged?.Invoke();
};
timer.Start();
}
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));
Set(res, "HoverBrush", Color.FromRgb(0x4A, 0x4A, 0x4C));
Set(res, "BadgeBrush", Color.FromRgb(0x3A, 0x3A, 0x3C));
}
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));
Set(res, "HoverBrush", Color.FromRgb(0xEB, 0xEB, 0xEB));
Set(res, "BadgeBrush", Color.FromRgb(0xF0, 0xF0, 0xF0));
}
}
private static void Set(ResourceDictionary res, string key, Color color)
{
res[key] = new SolidColorBrush(color);
}
}
-266
View File
@@ -1,266 +0,0 @@
using System;
using System.Diagnostics;
using System.Reflection;
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)));
}
}
-665
View File
@@ -1,665 +0,0 @@
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 JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private List<AgentLimit> _limits = [];
private bool _isLoading;
private string? _errorMessage;
private bool _isSignedIn;
private string _userEmail = "";
private string _planLabel = "Claude";
private DateTime? _lastUpdated;
private OverageSpendLimit? _overage;
private PrepaidCredits? _prepaid;
private ExtraUsage? _extraUsage;
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); }
public ExtraUsage? ExtraUsage { get => _extraUsage; set => Set(ref _extraUsage, 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();
// Check if we have any persistent proof that the user authenticated
bool hasCookies = cookies.Count > 0;
bool hasCachedAuth = !string.IsNullOrEmpty(AppSettings.Default.Email) ||
!string.IsNullOrEmpty(AppSettings.Default.OrgId);
if (!hasCookies && !hasCachedAuth)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
ErrorMessage = "Not signed in — click Sign In to authenticate.";
IsLoading = false;
});
return;
}
// HttpClient refresh — WebView2 is NOT used here to avoid spawning a heavy
// browser process every refresh cycle (causes memory accumulation).
// SaveAndClose at login time is responsible for fetching live data via WebView2.
List<AgentLimit> limits = [];
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
ExtraUsage? extraUsage = null;
string? email = null, orgId = null, planLabel = null;
string? refreshError = null;
if (hasCookies)
{
try
{
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(cookies);
}
catch (Exception ex)
{
refreshError = ex.Message;
}
}
// Fill in any blanks from cached values saved at login time
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
if (string.IsNullOrEmpty(orgId)) orgId = AppSettings.Default.OrgId;
if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{
try
{
var cached = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached);
}
catch { }
}
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(() =>
{
if (limits.Count > 0)
{
Limits = limits;
Overage = overage;
Prepaid = prepaid;
// Only clear ExtraUsage if the API explicitly disables it; keep cached value
// if the HTTP endpoint omits the field (it often does for non-browser clients)
if (extraUsage != null) ExtraUsage = extraUsage;
}
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true;
ErrorMessage = refreshError;
LastUpdated = DateTime.Now;
IsLoading = false;
});
}
catch (Exception ex)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
ErrorMessage = ex.Message;
IsLoading = false;
});
}
}
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{
try
{
using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
if ((int)bootstrapResp.StatusCode is 401 or 403)
throw new Exception("Session expired — please re-authenticate.");
if (!bootstrapResp.IsSuccessStatusCode)
throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
// Targeted debug: log org property names + capabilities value so we can see
// exactly what the HttpClient bootstrap response contains
try
{
using var dbgDoc = System.Text.Json.JsonDocument.Parse(bootstrapJson);
var dbgRoot = dbgDoc.RootElement;
var dbg = $"len:{bootstrapJson.Length}";
if (dbgRoot.TryGetProperty("account", out var dbgAcct) &&
dbgAcct.TryGetProperty("memberships", out var dbgMems) &&
dbgMems.GetArrayLength() > 0 &&
dbgMems[0].TryGetProperty("organization", out var dbgOrg))
{
var keys = string.Join(",", dbgOrg.EnumerateObject().Select(p => p.Name));
dbg += $"|org_keys:{keys}";
if (dbgOrg.TryGetProperty("capabilities", out var dbgCaps))
dbg += $"|caps:{dbgCaps.GetRawText()}";
else
dbg += "|caps:MISSING";
}
else
{
dbg += "|no_memberships";
}
AppSettings.Default.DebugInfo = dbg;
AppSettings.Default.Save();
}
catch { /* debug only — never block refresh */ }
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson);
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, planLabel, true);
AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save();
var usageUrl = $"https://claude.ai/api/organizations/{orgId}/usage";
var ut = http.GetAsync(usageUrl);
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
// Fetch org details as a fallback source for capabilities (bootstrap omits them via HttpClient)
var odt = http.GetAsync($"https://claude.ai/api/organizations/{orgId}");
await Task.WhenAll(ut, ot, pt, odt);
var usageResp = ut.Result;
if (!usageResp.IsSuccessStatusCode)
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
var usageJson = await usageResp.Content.ReadAsStringAsync();
var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
// Try org endpoint for capabilities if bootstrap didn't return them
if (string.IsNullOrEmpty(planLabel) && odt.Result.IsSuccessStatusCode)
{
try
{
var orgJson = await odt.Result.Content.ReadAsStringAsync();
using var orgDoc = JsonDocument.Parse(orgJson);
if (orgDoc.RootElement.TryGetProperty("capabilities", out var orgCaps) &&
orgCaps.ValueKind == JsonValueKind.Array)
{
foreach (var cap in orgCaps.EnumerateArray())
{
var s = cap.GetString() ?? "";
if (s.StartsWith("claude_", StringComparison.OrdinalIgnoreCase))
{
var name = s.Substring("claude_".Length);
if (name.Length > 0)
planLabel = char.ToUpper(name[0]) + name.Substring(1).ToLower();
break;
}
}
}
}
catch { /* best-effort */ }
}
// Persist fresh usage so cache reflects live data
AppSettings.Default.UsageJson = usageJson;
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
if (ot.Result != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(ot.Result);
if (pt.Result != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(pt.Result);
AppSettings.Default.Save();
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
}
catch { throw; }
}
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
{
try
{
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
let id=b?.account?.memberships?.[0]?.organization?.uuid
||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
||b?.default_organization?.uuid||null;
const em=b?.account?.email_address||b?.account?.email||b?.email||null;
if(!id){
try{const ol=await(await fetch('/api/organizations',h)).json();
if(Array.isArray(ol)&&ol.length>0)id=ol[0]?.uuid||null;}catch(e2){}
}
if(!id){
let pu=null;
try{pu=await(await fetch('/api/usage',h)).json();}catch(e3){}
window.chrome.webview.postMessage({email:em,orgId:null,usage:(pu&&!pu.error?pu:null)});
return;
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
window.chrome.webview.postMessage({email:em,orgId:id,usage:u});
}catch(ex){window.chrome.webview.postMessage(null);}})();";
var resultJson = await Application.Current.Dispatcher.InvokeAsync(async () =>
{
var host = new WebViewFetchWindow();
host.Show();
try { return await host.FetchAsync("https://claude.ai", script); }
finally { host.Close(); }
}).Task.Unwrap();
if (resultJson == null || resultJson == "null") return ([], null, null);
using var doc = System.Text.Json.JsonDocument.Parse(resultJson);
var root = doc.RootElement;
string? email = root.TryGetProperty("email", out var em) && em.ValueKind == System.Text.Json.JsonValueKind.String
? em.GetString() : null;
string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == System.Text.Json.JsonValueKind.String
? oi.GetString() : null;
List<AgentLimit> limits = [];
if (root.TryGetProperty("usage", out var us) && us.ValueKind == System.Text.Json.JsonValueKind.Object)
{
var usage = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
us.GetRawText(), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
limits = BuildLimits(usage);
// Cache for next time
AppSettings.Default.UsageJson = us.GetRawText();
if (!string.IsNullOrEmpty(orgId)) AppSettings.Default.OrgId = orgId;
if (!string.IsNullOrEmpty(email)) AppSettings.Default.Email = email;
AppSettings.Default.Save();
}
return (limits, email, orgId);
}
catch { return ([], null, null); }
}
public async Task LoadFromCacheAsync()
{
var email = AppSettings.Default.Email;
var orgId = AppSettings.Default.OrgId;
var cookie = AppSettings.Default.CookieStore;
bool isAuth = !string.IsNullOrEmpty(email) || !string.IsNullOrEmpty(orgId)
|| !string.IsNullOrEmpty(cookie);
if (!isAuth) return;
List<AgentLimit> limits = [];
ExtraUsage? extraUsage = null;
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
if (!string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{
try
{
var cached = JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached);
extraUsage = cached?.ExtraUsage;
}
catch { }
}
if (!string.IsNullOrEmpty(AppSettings.Default.OverageJson))
{
try { overage = JsonSerializer.Deserialize<OverageSpendLimit>(AppSettings.Default.OverageJson, JsonOpts); }
catch { }
}
if (!string.IsNullOrEmpty(AppSettings.Default.PrepaidJson))
{
try { prepaid = JsonSerializer.Deserialize<PrepaidCredits>(AppSettings.Default.PrepaidJson, JsonOpts); }
catch { }
}
// Attach persisted burn history to cached limits
foreach (var limit in limits)
{
var key = limit.Window.ToString();
if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
limit.BurnHistory = [.. hist];
}
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (!string.IsNullOrEmpty(email)) UserEmail = email;
if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel;
Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits();
Overage = overage;
Prepaid = prepaid;
ExtraUsage = extraUsage;
IsSignedIn = true;
ErrorMessage = null;
LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated;
});
}
public async Task SignOutAsync()
{
AppSettings.Default.CookieStore = "";
AppSettings.Default.OrgId = "";
AppSettings.Default.Email = "";
AppSettings.Default.UsageJson = "";
AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
UserEmail = "";
ErrorMessage = "Signed out.";
Limits = [];
});
}
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") || c.Domain.Contains("anthropic.com"))
.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");
http.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
http.DefaultRequestHeaders.Add("Origin", "https://claude.ai");
http.DefaultRequestHeaders.Add("Referer", "https://claude.ai/");
http.DefaultRequestHeaders.Add("sec-fetch-dest", "empty");
http.DefaultRequestHeaders.Add("sec-fetch-mode", "cors");
http.DefaultRequestHeaders.Add("sec-fetch-site", "same-origin");
http.DefaultRequestHeaders.Add("sec-ch-ua", "\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\"");
http.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0");
http.DefaultRequestHeaders.Add("sec-ch-ua-platform", "\"Windows\"");
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; }
}
// Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID)
private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
string? email = null;
string? orgId = null;
if (root.TryGetProperty("account", out var acct) &&
acct.TryGetProperty("email_address", out var em))
email = em.GetString();
// Path 1: account.memberships[0].organization.uuid (Claude.ai personal/pro accounts)
if (root.TryGetProperty("account", out var acctNode) &&
acctNode.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0)
{
var first = mems[0];
if (first.TryGetProperty("organization", out var org) &&
org.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
}
// Path 2: memberships[0].organization.uuid (root-level, older API shape)
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("memberships", out var rootMems) && rootMems.GetArrayLength() > 0)
{
var first = rootMems[0];
if (first.TryGetProperty("organization", out var org) &&
org.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
}
// Path 3: organizations[0].uuid (flat list on root)
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
{
if (orgs[0].TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
}
string? planLabel = null;
if (root.TryGetProperty("account", out var acctPlan) &&
acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 &&
plMems[0].TryGetProperty("organization", out var plOrg) &&
plOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var cap in caps.EnumerateArray())
{
var s = cap.GetString() ?? "";
if (s.StartsWith("claude_", StringComparison.OrdinalIgnoreCase))
{
var name = s.Substring("claude_".Length);
if (name.Length > 0)
planLabel = char.ToUpper(name[0]) + name.Substring(1).ToLower();
break;
}
}
}
return (email, orgId, planLabel);
}
catch { return (null, null, null); }
}
private static async Task<string?> FetchOrgIdFromListAsync(HttpClient http)
{
try
{
var resp = await http.GetAsync("https://claude.ai/api/organizations");
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() > 0)
{
var first = root[0];
if (first.TryGetProperty("uuid", out var uuid))
return uuid.GetString();
}
return null;
}
catch { return null; }
}
private static 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 static List<AgentLimit> LoadPlaceholderLimits()
{
var now = DateTime.Now;
return
[
new() { Window = WindowKind.FiveHour, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddHours(1) },
new() { Window = WindowKind.SevenDay, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddDays(7) },
];
}
private void LoadPlaceholders() => Limits = LoadPlaceholderLimits();
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; } = "/";
}
}
-68
View File
@@ -1,68 +0,0 @@
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
namespace ClaudeCheckerWindows;
// Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
// Uses the same user data folder as LoginWindow so the session is shared.
internal sealed class WebViewFetchWindow : Window
{
private readonly WebView2 _wv = new();
public WebViewFetchWindow()
{
Width = 1;
Height = 1;
Left = -9999;
Top = -9999;
ShowInTaskbar = false;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Opacity = 0;
Content = _wv;
}
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
{
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await _wv.EnsureCoreWebView2Async(env);
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
// Use WebMessageReceived so the script can post back asynchronously without
// relying on Promise-awaiting support in the WebView2 runtime version.
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(args.WebMessageAsJson);
};
_wv.CoreWebView2.WebMessageReceived += msgHandler;
EventHandler<CoreWebView2NavigationCompletedEventArgs>? navHandler = null;
navHandler = async (_, e) =>
{
_wv.CoreWebView2.NavigationCompleted -= navHandler;
if (!e.IsSuccess) { tcs.TrySetResult(null); return; }
try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
catch { tcs.TrySetResult(null); }
};
_wv.CoreWebView2.NavigationCompleted += navHandler;
_wv.CoreWebView2.Navigate(navigateUrl);
_ = Task.Delay(timeoutMs).ContinueWith(_ =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(null);
});
return await tcs.Task;
}
}