Compare commits

..
Author SHA1 Message Date
SuperDooper f318713ba5 Fix installer OutputDir: use . relative to SourceDir 2026-05-11 21:34:23 +02:00
SuperDooper b9d2c8a5f3 CI: build Inno Setup installer instead of zip 2026-05-11 21:27:59 +02:00
SuperDooper 8883000098 CI: build Inno Setup installer instead of zip 2026-05-11 21:27:57 +02:00
SuperDooper a8acf734ed Updater: use Inno Setup installer for auto-update 2026-05-11 21:27:46 +02:00
SuperDooper c432355be1 Add Inno Setup installer script 2026-05-11 21:27:36 +02:00
SuperDooper 9d3a06bf7a Diagnostics: retry clipboard on COMException to fix crash 2026-05-11 21:20:04 +02:00
SuperDooper 6cc4c1dc2f Diagnostics: update privacy note 2026-05-11 21:07:53 +02:00
SuperDooper e2fff371ee Diagnostics: show (none) for empty last error in copy 2026-05-11 21:06:53 +02:00
SuperDooper 872cdbc684 Diagnostics: clean up orphaned AddSection brackets 2026-05-11 20:51:56 +02:00
SuperDooper 0f78931599 Diagnostics: remove stale Cookie Store section 2026-05-11 20:51:36 +02:00
SuperDooper a84b2645fc Diagnostics: add privacy note 2026-05-11 20:48:25 +02:00
SuperDooper 6152371b16 CI: handle inline WIN_STABLE_BADGE placeholder on first stable release 2026-05-11 20:46:58 +02:00
SuperDooper 60acaf3eea CI: update Win Stable badge and clear Win Beta on stable release 2026-05-11 20:45:03 +02:00
SuperDooper e064e14425 CI: update Windows beta badge label to Win_Beta 2026-05-11 20:43:58 +02:00
SuperDooper 33e35243c4 CI: update Windows beta badge in README on release 2026-05-11 20:41:59 +02:00
SuperDooper e16bfa59d0 Diagnostics: remove email, redact IDs on copy 2026-05-11 20:28:26 +02:00
SuperDooper b0542d7291 fix projected hit badge on Windows — show after-reset or hit time, not last-refresh time 2026-05-11 15:07:47 +02:00
SuperDooper c90eb7be69 port multi-org fix and add diagnostics panel to Windows app
Read lastActiveOrg cookie in both the browser JS path and HttpClient
fallback, then prefer the matching org in ParseBootstrap for both
org ID and plan label selection — same fix as macOS v1.3.0.

Add Settings → Diagnostics panel showing app state, last request
details, cookie store counts/domains, and stored cookie names.
2026-05-11 14:18:59 +02:00
SuperDooper 2fc7e8f9eb chore: update release notes for beta.49 2026-05-08 22:52:11 +02:00
SuperDooper 877d59bb26 fix: restore PopupWindow; 1s footer timer; Xm Ys ago format 2026-05-08 22:45:27 +02:00
SuperDooper 8a69e740ee fix: footer timer 1s; format Xm Ys ago when over 60s 2026-05-08 22:42:40 +02:00
SuperDooper d4f284d829 fix: remove sparkline from limit cards (belongs only in Session Diary) 2026-05-08 22:37:32 +02:00
SuperDooper 0ed83be9eb fix: ToggleButton transparent template; IsPressed states; IsSelected ComboBoxItem 2026-05-08 22:37:31 +02:00
SuperDooper 4cc29292f1 fix: ComboBox items use ComboBoxItem; diary stats inside card 2026-05-08 22:27:34 +02:00
SuperDooper fca1a7eb60 fix: credit values /100; add after-reset/today badge to cards 2026-05-08 22:17:33 +02:00
SuperDooper 013569ea8b fix: gauge — remove 'used' label, increase pct font to 18 2026-05-08 22:17:32 +02:00
SuperDooper 9ef83c1a1e refactor: LoginWindow only saves cookies; all data fetched by background browser 2026-05-08 22:04:10 +02:00
SuperDooper cabc2b6326 refactor: remove all API cache reads/writes; live-only refresh via browser 2026-05-08 22:04:10 +02:00
SuperDooper b01afc5be7 refactor: strip API-response cache fields from AppSettings 2026-05-08 22:04:08 +02:00
SuperDooper 4b211a17ed refactor: use persistent WebView2 as primary refresh path, HttpClient as fallback 2026-05-08 21:57:55 +02:00
SuperDooper de41ea9dc4 fix: make WebViewFetchWindow public; add IsReady property 2026-05-08 21:57:54 +02:00
SuperDooper 8e71671abb fix: persistent background WebView2 for live overage/prepaid on every refresh 2026-05-08 21:47:28 +02:00
SuperDooper af36a817fb fix: persistent background WebView2 for live overage/prepaid on every refresh 2026-05-08 21:47:27 +02:00
SuperDooper e908d71eb6 fix: persistent background WebView2 for live overage/prepaid on every refresh 2026-05-08 21:47:26 +02:00
SuperDooper ee6b1c4183 fix: session diary at bottom; show popup on launch 2026-05-08 21:40:44 +02:00
SuperDooper 4377c9a735 fix: session diary at bottom; show popup on launch 2026-05-08 21:40:43 +02:00
SuperDooper 1cf3f90fd6 fix: correct extra_usage credit unit (/100), add space in currency, Unlimited label, fetch overage/prepaid at login 2026-05-08 21:37:32 +02:00
SuperDooper 3d8482cf21 fix: correct extra_usage credit unit (/100), add space in currency, Unlimited label, fetch overage/prepaid at login 2026-05-08 21:37:32 +02:00
SuperDooper 44b7feb3c0 fix: show extra usage credits from extra_usage fallback; preserve on refresh 2026-05-08 21:32:19 +02:00
SuperDooper 236990ca45 fix: show extra usage credits from extra_usage fallback; preserve on refresh 2026-05-08 21:32:18 +02:00
SuperDooper 8d523cbb87 fix: show extra usage credits from extra_usage fallback; preserve on refresh 2026-05-08 21:32:17 +02:00
SuperDooper da270de6d6 fix: extract plan label from capabilities in SaveAndClose; try org endpoint on refresh 2026-05-08 21:16:00 +02:00
SuperDooper f4bed1b585 fix: extract plan label from capabilities in SaveAndClose; try org endpoint on refresh 2026-05-08 21:15:59 +02:00
SuperDooper ae7a7db37e debug: log org property names and capabilities value instead of truncated raw JSON 2026-05-08 21:05:59 +02:00
SuperDooper dec6a6eb75 debug: save raw bootstrap JSON to DebugInfo to diagnose plan label 2026-05-08 20:55:40 +02:00
SuperDooper 7a8350b578 Update release notes for beta.36 2026-05-08 19:38:29 +02:00
SuperDooper 222ee55189 Load cached state immediately on startup before background refresh 2026-05-08 19:38:19 +02:00
SuperDooper ec7260bd0d Fix auth failure handling, cache plan/overage/prepaid, restore all from cache on startup 2026-05-08 19:38:18 +02:00
SuperDooper 6d34567d85 Add PlanLabel, OverageJson, PrepaidJson cache fields 2026-05-08 19:38:16 +02:00
SuperDooper 4395ae0594 Update release notes for beta.10 2026-05-08 19:19:51 +02:00
SuperDooper 4ff2337b4d Fix compile errors, surface HTTP errors, save live UsageJson on successful fetch 2026-05-08 19:18:03 +02:00
SuperDooper 5db9321ac4 Parse plan label from capabilities array instead of plan_type 2026-05-08 19:14:55 +02:00
SuperDooper 8c14f59c42 Parse plan label from bootstrap API instead of hardcoding 2026-05-08 19:09:33 +02:00
SuperDooper a7dc3d553c Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:19:07 +02:00
SuperDooper 264db5127d Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:18:58 +02:00
SuperDooper c71e63905b Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:18:57 +02:00
SuperDooper 0fc124ac22 Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:18:56 +02:00
SuperDooper 6858798b14 Release notes for beta.33 2026-05-08 17:01:42 +02:00
SuperDooper c3ad730677 Remove debug banner references from PopupWindow code-behind (beta.33) 2026-05-08 17:01:37 +02:00
SuperDooper 0dcfcbfb6f Remove debug banner from main panel (beta.33) 2026-05-08 17:01:31 +02:00
SuperDooper 31789df35c Fix gauge text centering (beta.33) 2026-05-08 17:01:25 +02:00
SuperDooper 8e45241ddc chore: release notes for beta.32 2026-05-08 16:49:56 +02:00
SuperDooper 78e9004624 fix: correct usage response field names — five_hour/seven_day not claude_ai_default_5h/claude_ai_default 2026-05-08 16:48:46 +02:00
SuperDooper 2dcea91a63 chore: release notes for beta.31 2026-05-08 16:46:17 +02:00
SuperDooper d240b543eb fix: show placeholder cards when signed in but no cached usage data 2026-05-08 16:46:04 +02:00
SuperDooper 1a35ec064c debug: include raw usage API response in debug output 2026-05-08 16:46:03 +02:00
SuperDooper 204691772c chore: release notes for beta.30 2026-05-08 16:35:48 +02:00
SuperDooper 15e3f1dd29 fix: remove WebView2 from periodic refresh (memory), add CORS headers to HttpClient 2026-05-08 16:35:35 +02:00
SuperDooper bcd208684b chore: release notes for beta.29 2026-05-08 16:29:03 +02:00
SuperDooper 2bdbe4943d fix: same org ID path fix in ParseBootstrap and WebView2 refresh script 2026-05-08 16:28:48 +02:00
SuperDooper 1c365a55a8 fix: org ID is at b.account.memberships[0].organization.uuid, not b.memberships 2026-05-08 16:28:47 +02:00
SuperDooper 8b367ee539 chore: release notes for beta.28 2026-05-08 16:24:42 +02:00
SuperDooper 9134b8945d fix: don't wipe existing limits when refresh returns no data 2026-05-08 16:24:31 +02:00
SuperDooper 19b1751e78 debug: wire DebugBanner in InitSettings 2026-05-08 16:24:30 +02:00
SuperDooper a20956cde2 debug: show debug banner on main panel so it's always visible 2026-05-08 16:24:29 +02:00
SuperDooper 659171bf0a debug: capture raw bootstrap JSON in debug field, try account.uuid as org ID fallback 2026-05-08 16:24:28 +02:00
SuperDooper f5b3accc13 chore: release notes for beta.27 2026-05-08 16:15:17 +02:00
SuperDooper 22e9096f7c fix: post plain objects in WebView2 refresh script 2026-05-08 16:15:07 +02:00
SuperDooper 6aee17fad2 fix: use WebMessageAsJson 2026-05-08 16:15:05 +02:00
SuperDooper b594889239 fix: use WebMessageAsJson and post plain objects (not JSON strings) 2026-05-08 16:15:04 +02:00
SuperDooper 0300e4a72e chore: release notes for beta.26 2026-05-08 16:12:06 +02:00
SuperDooper 7febb0384f fix: update WebView2 refresh script to use postMessage 2026-05-08 16:11:55 +02:00
SuperDooper 55e1f754c5 fix: switch WebViewFetchWindow to WebMessageReceived for script results 2026-05-08 16:11:54 +02:00
SuperDooper 03676a980f fix: use WebMessageReceived instead of ExecuteScriptAsync return value for async script results 2026-05-08 16:11:53 +02:00
SuperDooper a74d0cece5 chore: release notes for beta.25 2026-05-08 16:06:16 +02:00
SuperDooper b5cd7b1bc9 debug: show DebugInfo in settings panel after login 2026-05-08 16:06:06 +02:00
SuperDooper 3249dbd4aa debug: add DebugText block in settings panel 2026-05-08 16:06:05 +02:00
SuperDooper 189c513677 debug: capture bootstrap keys and save to DebugInfo 2026-05-08 16:06:04 +02:00
SuperDooper 05e66b22d1 feat: add DebugInfo field to AppSettings 2026-05-08 16:06:03 +02:00
SuperDooper 48e6f64c9a chore: release notes for beta.24 2026-05-08 16:00:28 +02:00
SuperDooper d55a92623c fix: add LoadFromCacheAsync, same JS fallbacks in WebView2 refresh path 2026-05-08 16:00:04 +02:00
SuperDooper c70a090983 fix: load from cache immediately after login, background refresh after 3s 2026-05-08 16:00:01 +02:00
SuperDooper cc0270326e fix: add /api/organizations + personal /api/usage fallbacks in login JS 2026-05-08 16:00:00 +02:00
SuperDooper 7601452f93 chore: release notes for beta.23 2026-05-08 15:50:04 +02:00
SuperDooper 5a78a021cb fix: trust cookies/cached auth for IsSignedIn, wider cookie domain filter 2026-05-08 15:49:47 +02:00
SuperDooper 7d465c13d8 fix: show update label in About, delay before refresh after login 2026-05-08 15:49:47 +02:00
SuperDooper ca67039e3e feat: add update-available label in About section 2026-05-08 15:49:46 +02:00
SuperDooper 1e4cdc09b3 fix: broaden JS orgId/email paths, URL-only auto-close 2026-05-08 15:49:45 +02:00
SuperDooper 4e154bcaa7 chore: release notes for beta.22 2026-05-08 15:30:34 +02:00
SuperDooper ea784384c1 fix: remove unused _dialogOpen field 2026-05-08 15:30:27 +02:00
SuperDooper 4e813995c9 fix: Unwrap Task<Task<string>> from Dispatcher.InvokeAsync with async lambda 2026-05-08 15:30:26 +02:00
SuperDooper 0ffd690098 chore: release notes for beta.21 2026-05-08 15:05:43 +02:00
SuperDooper c083256882 feat: hidden WebView2 window for authenticated API calls 2026-05-08 15:05:35 +02:00
SuperDooper fd94c96d83 fix: WebView2 fallback for API calls, cached usage from login, decouple auth from HttpClient 2026-05-08 15:05:31 +02:00
SuperDooper d22eb7381c feat: add Email and UsageJson cache fields 2026-05-08 15:05:26 +02:00
SuperDooper dd59f4565c fix: auto-close on any signed-in claude.ai page; fetch bootstrap+usage via WebView2 at login 2026-05-08 15:05:25 +02:00
SuperDooper f548210387 chore: release notes for beta.20 2026-05-08 14:54:35 +02:00
SuperDooper 89659322ac fix: decouple auth check from org ID, add UA header, try multiple org ID paths 2026-05-08 14:54:28 +02:00
SuperDooper bfa476a276 chore: release notes for beta.19 2026-05-08 14:40:45 +02:00
SuperDooper 7f7024d976 fix: embed icon.png as WPF Resource so XAML Image source resolves correctly 2026-05-08 14:40:34 +02:00
SuperDooper f6dc91f38c fix: fetch org ID dynamically from bootstrap/organizations, fix IsSignedIn gate, add anthropic-client-platform header 2026-05-08 14:40:29 +02:00
SuperDooper 721075d969 feat: add OrgId to settings for caching 2026-05-08 14:40:28 +02:00
SuperDooper 87ce720a31 fix: use TextBrush instead of hardcoded Brushes.White for auth label 2026-05-08 14:40:18 +02:00
SuperDooper 1172fd8f4e fix: version badge uses BadgeBrush instead of hardcoded #F0F0F0 2026-05-08 14:40:17 +02:00
SuperDooper 11f097b969 fix: HoverBrush on SmallButton hover, full ComboBox dark theme template, BadgeBrush resource 2026-05-08 14:40:12 +02:00
SuperDooper 6e992d56af fix: add HoverBrush and BadgeBrush for dark/light theme 2026-05-08 14:40:11 +02:00
SuperDooper 49a0a8c9dd chore: update Windows release notes for beta.18 2026-05-08 14:27:04 +02:00
SuperDooper f63de02861 fix: use DynamicResource for all theme brush bindings 2026-05-08 14:26:23 +02:00
SuperDooper acabb0e101 fix: use DynamicResource for theme brushes in styles 2026-05-08 14:26:19 +02:00
SuperDooper 4945097e8b fix: replace frozen brushes at runtime using new SolidColorBrush instances 2026-05-08 14:26:12 +02:00
SuperDooper 2d478be0ad fix: po:Freeze=False on all theme brushes to allow runtime mutation 2026-05-08 14:15:46 +02:00
SuperDooper 0096199334 debug: log startup crashes to LocalApplicationData/ClaudeChecker/startup_crash.txt 2026-05-08 14:11:21 +02:00
SuperDooper 01f28cf044 fix: replace SystemEvents with DispatcherTimer to avoid missing package crash 2026-05-08 14:04:32 +02:00
SuperDooper 37939b84ac fix: add missing using System.Reflection for GetCustomAttribute 2026-05-08 13:57:45 +02:00
SuperDooper 9c8e0dcdb6 fix: use InformationalVersion for semver, auto-check beta if running pre-release 2026-05-08 13:53:53 +02:00
SuperDooper 0010051ede feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:50 +02:00
SuperDooper 98bd736a66 feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:49 +02:00
SuperDooper 251efdbb0b feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:47 +02:00
SuperDooper f219b65650 feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:46 +02:00
SuperDooper b0907d17b5 feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:45 +02:00
SuperDooper d3aa3dc6bd feat: system light/dark theme support with live switching and dark title bar 2026-05-08 13:47:44 +02:00
SuperDooper 797bf702cd feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:33 +02:00
SuperDooper 58a09e8701 feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:32 +02:00
SuperDooper 01e16e3abe feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:30 +02:00
SuperDooper 8e4b23d4de feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:29 +02:00
SuperDooper 708b55d919 feat: native Windows UI — light theme, centered window, standard chrome 2026-05-08 13:34:28 +02:00
SuperDooper 59c8c390b6 fix: await refresh after login, update settings when UserEmail/IsSignedIn change 2026-05-08 13:28:11 +02:00
SuperDooper ad525ef8d1 fix: only auto-close login on explicit post-login redirect, not initial load 2026-05-08 13:14:48 +02:00
SuperDooper e6fa66bc73 fix: suppress Deactivated hide during login dialog, restore popup after sign-in 2026-05-08 13:14:47 +02:00
SuperDooper 2d000bca5e fix: correct session cookie detection and auto-close on login 2026-05-08 13:07:06 +02:00
SuperDooper c7a663c42b fix: use RELEASE_TOKEN to bypass branch protection when writing version JSON 2026-05-08 12:59:04 +02:00
SuperDooper a182b7ca59 fix: use RELEASE_TOKEN to bypass branch protection when writing version JSON 2026-05-08 12:59:03 +02:00
SuperDooper 182acb1f21 fix: use GitHub API to update version JSON, avoid git push to protected main 2026-05-08 12:52:21 +02:00
SuperDooper 3ff5067d49 fix: use GitHub API to update version JSON, avoid git push to protected main 2026-05-08 12:52:20 +02:00
SuperDooper 2816a5cedb fix: remove duplicate Color alias (now in GlobalUsings.cs) 2026-05-08 12:40:03 +02:00
SuperDooper a4a4d70530 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:04 +02:00
SuperDooper 073d3b14ef fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:02 +02:00
SuperDooper 5603b94f8a fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:01 +02:00
SuperDooper 28a449b060 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:34:00 +02:00
SuperDooper d2ba7365b0 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:59 +02:00
SuperDooper 93862686b6 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:57 +02:00
SuperDooper 8170f3a1b3 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:56 +02:00
SuperDooper 5fbdaab2d0 fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:55 +02:00
SuperDooper 7db17fb53d fix: resolve build errors — AppSettings, GlobalUsings, namespace aliases 2026-05-08 12:33:54 +02:00
SuperDooper f6f1982390 fix: add explicit using aliases to resolve System.Drawing ambiguity 2026-05-08 12:24:36 +02:00
SuperDooper de61499bab fix: add explicit using aliases to resolve System.Drawing ambiguity 2026-05-08 12:24:35 +02:00
SuperDooper 6e95221e0b fix: rewrite BuildCard with clean imperative code — no invalid method chaining 2026-05-08 12:22:54 +02:00
SuperDooper 12ce671205 fix: checkout main before updating version-windows-beta.json 2026-05-08 12:20:22 +02:00
SuperDooper 7a74e19a20 fix: checkout main before updating version-windows.json 2026-05-08 12:20:21 +02:00
SuperDooper 4c1ed861ed chore: add Windows app icon (ico) 2026-05-08 12:17:55 +02:00
SuperDooper a1df7cd38a chore: add Windows app icon (png) 2026-05-08 12:17:53 +02:00
SuperDooper 311385a716 docs: add Windows release notes file 2026-05-08 12:16:07 +02:00
SuperDooper 3abe5e4c9b chore: seed version-windows-beta.json 2026-05-08 12:16:06 +02:00
SuperDooper 5457e00ef0 chore: seed version-windows.json 2026-05-08 12:16:04 +02:00
SuperDooper 00bd5884db ci: add Windows beta release workflow 2026-05-08 12:16:03 +02:00
SuperDooper a3ce649946 ci: add Windows stable release workflow 2026-05-08 12:16:02 +02:00
SuperDooper c97943bfa0 fix: point Windows updater to version-windows.json files 2026-05-08 12:16:01 +02:00
SuperDooper e89df508bd feat(windows): add README.md 2026-05-08 12:12:58 +02:00
SuperDooper de247e545b feat(windows): add Settings.settings 2026-05-08 12:12:56 +02:00
SuperDooper 4f481a06c3 feat(windows): add SparklineControl.cs 2026-05-08 12:12:55 +02:00
SuperDooper c223bfba72 feat(windows): add GaugeControl.cs 2026-05-08 12:12:54 +02:00
SuperDooper b388191191 feat(windows): add LoginWindow.xaml.cs 2026-05-08 12:12:53 +02:00
SuperDooper 4896d28c8c feat(windows): add LoginWindow.xaml 2026-05-08 12:12:52 +02:00
SuperDooper 0e248dc331 feat(windows): add PopupWindow.xaml.cs 2026-05-08 12:12:50 +02:00
SuperDooper 2277a29a3e feat(windows): add PopupWindow.xaml 2026-05-08 12:12:49 +02:00
SuperDooper 605b11e849 feat(windows): add UpdateManager.cs 2026-05-08 12:12:48 +02:00
SuperDooper bb4c28c751 feat(windows): add UsageViewModel.cs 2026-05-08 12:12:47 +02:00
SuperDooper e0c73211a0 feat(windows): add Models.cs 2026-05-08 12:12:45 +02:00
SuperDooper ceb5951f24 feat(windows): add App.xaml.cs 2026-05-08 12:12:44 +02:00
SuperDooper c0ed68d023 feat(windows): add App.xaml 2026-05-08 12:12:43 +02:00
SuperDooper f3d8a50dc6 feat(windows): add ClaudeCheckerWindows.csproj 2026-05-08 12:12:42 +02:00
32 changed files with 3300 additions and 110 deletions
+102
View File
@@ -0,0 +1,102 @@
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: Build installer
shell: pwsh
run: |
Copy-Item -Recurse windows/Assets publish/Assets
& "C:\Program Files (x86)\Inno Setup 6\iscc.exe" /DAppVersion="${{ steps.version.outputs.version }}" windows\installer.iss
- 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-Installer.exe \
--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-Installer.exe" \
--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
- name: Update Windows beta badge in README on main
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
version="${{ steps.version.outputs.version }}"
tag="${{ github.ref_name }}"
shields_version=$(echo "$version" | sed 's/-/--/g')
export WIN_BADGE="[![Win Beta](https://img.shields.io/badge/Win_Beta-${shields_version}-blue?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- WIN_BETA_BADGE -->"
sha=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .sha)
content=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .content | python3 -c "
import sys, base64, os
raw = sys.stdin.read().strip()
text = base64.b64decode(raw).decode()
badge = os.environ['WIN_BADGE']
lines = text.splitlines(keepends=True)
out = [badge + chr(10) if '<!-- WIN_BETA_BADGE -->' in line else line for line in lines]
print(base64.b64encode(''.join(out).encode()).decode(), end='')
")
gh api --method PUT "repos/${{ github.repository }}/contents/README.md" \
--field "message=Windows beta badge $tag" \
--field "content=$content" \
--field "branch=main" \
--field "sha=$sha"
+114
View File
@@ -0,0 +1,114 @@
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: Build installer
shell: pwsh
run: |
Copy-Item -Recurse windows/Assets publish/Assets
& "C:\Program Files (x86)\Inno Setup 6\iscc.exe" /DAppVersion="${{ steps.version.outputs.version }}" windows\installer.iss
- 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-Installer.exe \
--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-Installer.exe" \
--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
- name: Update Windows stable badge in README on main
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
version="${{ steps.version.outputs.version }}"
tag="${{ github.ref_name }}"
shields_version=$(echo "$version" | sed 's/-/--/g')
export WIN_STABLE_BADGE="[![Win Stable](https://img.shields.io/badge/Win_Stable-${shields_version}-blue?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- WIN_STABLE_BADGE -->"
sha=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .sha)
content=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .content | python3 -c "
import sys, base64, os
raw = sys.stdin.read().strip()
text = base64.b64decode(raw).decode()
stable_badge = os.environ['WIN_STABLE_BADGE']
lines = text.splitlines(keepends=True)
out = []
for line in lines:
if '<!-- WIN_STABLE_BADGE -->' in line:
out.append(stable_badge + chr(10))
rest = line.replace(' <!-- WIN_STABLE_BADGE -->', '').replace('<!-- WIN_STABLE_BADGE -->', '')
if rest.strip() and '<!-- WIN_BETA_BADGE -->' in rest:
out.append('<!-- WIN_BETA_BADGE -->' + chr(10))
elif rest.strip():
out.append(rest)
elif '<!-- WIN_BETA_BADGE -->' in line:
out.append('<!-- WIN_BETA_BADGE -->' + chr(10))
else:
out.append(line)
print(base64.b64encode(''.join(out).encode()).decode(), end='')
")
gh api --method PUT "repos/${{ github.repository }}/contents/README.md" \
--field "message=Windows stable badge $tag" \
--field "content=$content" \
--field "branch=main" \
--field "sha=$sha"
+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")!))
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
// Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+6 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.4-beta.4</string>
<string>1.1.3</string>
<key>CFBundleVersion</key>
<string>44</string>
<string>40</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
@@ -45,3 +45,7 @@
</dict>
</dict>
</plist>
+48 -94
View File
@@ -3,6 +3,7 @@ import WebKit
@MainActor
class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = []
@Published var isLoading = false
@Published var lastUpdated: Date?
@@ -25,14 +26,14 @@ class UsageViewModel: ObservableObject {
}
}
private var cachedOrgId: String?
private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id")
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
@@ -48,8 +49,6 @@ class UsageViewModel: ObservableObject {
let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
await store.removeData(ofTypes: types, for: claudeRecords)
cachedOrgId = nil
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -67,30 +66,18 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false }
do {
// Fetch org ID dynamically if not cached
if cachedOrgId == nil {
let (fetchedOrgId, fetchedPlan) = try await fetchOrgId()
cachedOrgId = fetchedOrgId
if let id = cachedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
}
if let plan = fetchedPlan { planLabel = plan }
}
guard let orgId = cachedOrgId else {
throw AppError.notAuthenticated
}
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
async let emailFetch = fetchUserEmail()
let (usage, prepaid, overage, emailResult) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
if let email = emailResult.email { userEmail = email }
if let plan = emailResult.planLabel { planLabel = plan }
let orgId = cachedOrgId!
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
async let emailFetch = fetchUserEmail()
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
if let email { userEmail = email }
limits = buildLimits(from: usage)
extraUsage = usage.extraUsage
prepaidCredits = prepaid
overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date()
isSignedIn = true
@@ -123,98 +110,54 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - Fetch org ID from bootstrap
private func fetchOrgId() async throws -> (orgId: String?, planLabel: String?) {
let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if claudeCookies.isEmpty { throw AppError.notAuthenticated }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) }
func planFromOrg(_ org: [String: Any]?) -> String? {
guard let caps = org?["capabilities"] as? [String] else { return nil }
guard let cap = caps.first(where: { $0.hasPrefix("claude_") }) else { return nil }
let name = String(cap.dropFirst("claude_".count))
return name.prefix(1).uppercased() + name.dropFirst().lowercased()
}
// account.memberships[0].organization.uuid
if let account = json["account"] as? [String: Any],
let memberships = account["memberships"] as? [[String: Any]],
let org = memberships.first?["organization"] as? [String: Any],
let uuid = org["uuid"] as? String {
return (uuid, planFromOrg(org))
}
// root memberships (older API shape)
if let memberships = json["memberships"] as? [[String: Any]],
let org = memberships.first?["organization"] as? [String: Any],
let uuid = org["uuid"] as? String {
return (uuid, planFromOrg(org))
}
// root organizations array
if let orgs = json["organizations"] as? [[String: Any]],
let uuid = orgs.first?["uuid"] as? String {
return (uuid, planFromOrg(orgs.first))
}
return (nil, nil)
}
// MARK: - Fetch usage
private func claudeCookieHeader() async -> String? {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
return HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"]
}
private func fetchUsage(orgId: String) async throws -> UsageResponse {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data)
}
private func fetchUserEmail() async throws -> (email: String?, planLabel: String?) {
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")
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return (nil, nil) }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let account = json["account"] as? [String: Any] else { return (nil, nil) }
let email = account["email_address"] as? String
var planLabel: String? = nil
if let memberships = account["memberships"] as? [[String: Any]],
let org = memberships.first?["organization"] as? [String: Any],
let caps = org["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()
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")
}
return (email, planLabel)
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")
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
@@ -224,7 +167,11 @@ class UsageViewModel: ObservableObject {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
@@ -298,11 +245,14 @@ class UsageViewModel: ObservableObject {
private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95]
for limit in limits {
let key = limit.window.rawValue
let curr = limit.usedPercent
let prev = previousPercents[key]
var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds {
let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
@@ -313,6 +263,8 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName, "percent": t]
)
}
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 {
fired.removeAll()
NotificationCenter.default.post(
@@ -321,6 +273,7 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName]
)
}
firedThresholds[key] = fired
previousPercents[key] = curr
}
@@ -364,3 +317,4 @@ extension Notification.Name {
static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet")
}
+1 -1
View File
@@ -10,7 +10,7 @@
[![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)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
[![Beta](https://img.shields.io/badge/beta-1.1.4--beta.2-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.1.4-beta.2) <!-- BETA_BADGE -->
<!-- BETA_BADGE -->
</div>
+11 -3
View File
@@ -1,4 +1,12 @@
## What's new in v1.1.4-beta.4
## What's new in v1.1.3
### Bug fix
- Plan name now updates correctly on every refresh (was only set on first launch)
### UI polish
- Limit headers now read "5 Hour Limit" and "7 Day Limit"
- Tapping anywhere on the blue update banner opens the update window
### Session Diary fix
- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh
### Window sizing fixes
- The popover now resizes correctly when switching between the main view and settings
- Background colour no longer shows through during the resize animation
+4 -4
View File
@@ -1,5 +1,5 @@
{
"version": "1.1.4-beta.2",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.4-beta.2/ClaudeChecker.zip",
"notes": "## What's new in v1.1.4-beta.2\n\n### Improvements\n- Plan name (e.g. Pro, Max) is now read from the API instead of being hardcoded"
}
"version": "1.1.3-beta.5",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3-beta.5/ClaudeChecker.zip",
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\" instead of \"5hr\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches \u2014 the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
}
+5 -5
View File
@@ -1,5 +1,5 @@
{
"version": "0.0.1-beta.35",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.35/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.10\r\n\r\n### Bug fixes\r\n- Limits now update correctly on each refresh cycle\r\n- Session expiry is detected and prompts re-authentication instead of silently showing stale data\r\n- Plan name (e.g. Pro, Max) is now read from the API instead of being hardcoded"
}
{
"version": "0.0.0",
"url": "",
"notes": ""
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": "0.0.0",
"url": "",
"notes": ""
}
+198
View File
@@ -0,0 +1,198 @@
<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="PressedBrush" Color="#D6D6D6" 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="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#106EBE"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#005A9E"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" 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="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
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 TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource PressedBrush}"/>
</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="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="Bd"
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="&#x25BE;" FontSize="10"
Foreground="{DynamicResource SecondaryBrush}"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,8,0" IsHitTestVisible="False"/>
<!-- Fully transparent toggle — no system chrome bleeds through -->
<ToggleButton Focusable="False" ClickMode="Press"
FocusVisualStyle="{x:Null}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent"/>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</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.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</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="FocusVisualStyle" Value="{x:Null}"/>
<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>
<Trigger Property="IsSelected" 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>
+151
View File
@@ -0,0 +1,151 @@
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();
// Persistent hidden WebView2 — shares the same user data folder as LoginWindow
// so its cookies (including cf_clearance) are always live. Used on every refresh
// for endpoints that need a real browser session (overage, prepaid).
public static WebViewFetchWindow BackgroundBrowser { 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();
ShowPopup();
ScheduleTimer(ViewModel.RefreshInterval);
// Show background browser window on the UI thread before Task.Run
BackgroundBrowser.Show();
_ = Task.Run(async () =>
{
await ViewModel.LoadFromCacheAsync();
// Initialize the persistent background browser (navigates to claude.ai once)
await Application.Current.Dispatcher.InvokeAsync(
() => BackgroundBrowser.InitAsync()).Task.Unwrap();
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);
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.IO;
using System.Text.Json;
namespace ClaudeCheckerWindows;
public class AppSettings
{
// Auth signal — non-empty means the user has completed sign-in at least once.
// The WebView2 user-data folder holds the live session; this is only for startup.
public string CookieStore { get; set; } = "";
// Accumulated sparkline data — built up over multiple refreshes.
public string BurnHistory { get; set; } = "";
// User preferences
public int RefreshInterval { get; set; } = 120;
public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false;
private static readonly string FilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "settings.json");
private static AppSettings? _instance;
public static AppSettings Default => _instance ??= Load();
private static AppSettings Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath)) ?? new();
}
catch { }
return new();
}
public void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this,
new JsonSerializerOptions { WriteIndented = true }));
}
catch { }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+32
View File
@@ -0,0 +1,32 @@
<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>
+84
View File
@@ -0,0 +1,84 @@
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 pctText = new FormattedText(
$"{(int)Math.Round(Percent)}%",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
18, new SolidColorBrush(textColor), dpi);
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - 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
@@ -0,0 +1,60 @@
using Point = System.Windows.Point;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
namespace ClaudeCheckerWindows.Controls;
public class SparklineControl : FrameworkElement
{
public static readonly DependencyProperty DataProperty =
DependencyProperty.Register(nameof(Data), typeof(IList<double>), typeof(SparklineControl),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty LineColorProperty =
DependencyProperty.Register(nameof(LineColor), typeof(Color), typeof(SparklineControl),
new FrameworkPropertyMetadata(Colors.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
public IList<double>? Data { get => (IList<double>?)GetValue(DataProperty); set => SetValue(DataProperty, value); }
public Color LineColor { get => (Color)GetValue(LineColorProperty); set => SetValue(LineColorProperty, value); }
protected override void OnRender(DrawingContext dc)
{
var data = Data;
if (data == null || data.Count < 1) return;
var plot = data.Count == 1 ? new[] { data[0], data[0] } : [.. data];
var w = RenderSize.Width;
var h = RenderSize.Height;
var minV = double.MaxValue;
var maxV = double.MinValue;
foreach (var v in plot) { if (v < minV) minV = v; if (v > maxV) maxV = v; }
var range = System.Math.Max(maxV - minV, 1);
Point Pt(int i) => new(
i / (double)(plot.Length - 1) * w,
h - (plot[i] - minV) / range * (h - 6) - 3);
var fill = new StreamGeometry();
using (var ctx = fill.Open())
{
ctx.BeginFigure(new Point(0, h), true, true);
for (var i = 0; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false);
ctx.LineTo(new Point(w, h), true, false);
}
fill.Freeze();
dc.DrawGeometry(new SolidColorBrush(Color.FromArgb(26, LineColor.R, LineColor.G, LineColor.B)), null, fill);
var line = new StreamGeometry();
using (var ctx = line.Open())
{
ctx.BeginFigure(Pt(0), false, false);
for (var i = 1; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false);
}
line.Freeze();
dc.DrawGeometry(null,
new System.Windows.Media.Pen(new SolidColorBrush(LineColor), 1.5) { LineJoin = PenLineJoin.Round },
line);
}
}
+9
View File
@@ -0,0 +1,9 @@
// Global aliases to resolve ambiguities between System.Windows.* and System.Drawing.*
global using Application = System.Windows.Application;
global using Brushes = System.Windows.Media.Brushes;
global using Color = System.Windows.Media.Color;
global using FlowDirection = System.Windows.FlowDirection;
global using FontFamily = System.Windows.Media.FontFamily;
global using Orientation = System.Windows.Controls.Orientation;
global using ProgressBar = System.Windows.Controls.ProgressBar;
global using Size = System.Windows.Size;
+42
View File
@@ -0,0 +1,42 @@
<Window x:Class="ClaudeCheckerWindows.LoginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="Sign in to claude.ai"
Width="520" Height="680"
WindowStartupLocation="CenterOwner"
Background="#1C1C1E">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#2C2C2E" Padding="16,12">
<StackPanel>
<TextBlock Text="Sign in to claude.ai" FontSize="14" FontWeight="SemiBold"
Foreground="White"/>
<TextBlock Text="Sign in below. ClaudeChecker will save your session cookies to fetch usage data."
FontSize="11" Foreground="#888888" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<wv2:WebView2 x:Name="Browser" Grid.Row="1"/>
<Border Grid.Row="2" Background="#2C2C2E" Padding="16,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="StatusText" FontSize="11" Foreground="#888888"
VerticalAlignment="Center" Text="Waiting for sign-in…"/>
<Button Grid.Column="1" Content="Done — I'm signed in"
Style="{StaticResource PrimaryButton}" Click="Done_Click"
x:Name="DoneButton" IsEnabled="False"/>
</Grid>
</Border>
</Grid>
</Window>
+70
View File
@@ -0,0 +1,70 @@
using Microsoft.Web.WebView2.Core;
using System.Collections.Generic;
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(
System.Environment.GetFolderPath(System.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 = System.Linq.Enumerable.Any(cookies, c =>
c.Name.Equals("sessionKey", System.StringComparison.OrdinalIgnoreCase) ||
c.Name.StartsWith("sk-ant", System.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;
// Persist cookies so the app knows the user is signed in on next startup.
// All live data is fetched by the background WebView2 on the next refresh.
UsageViewModel.SaveCookies(cookies);
await Dispatcher.InvokeAsync(() => DialogResult = true);
}
}
+71
View File
@@ -0,0 +1,71 @@
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; }
}
+295
View File
@@ -0,0 +1,295 @@
<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" Margin="0,0,0,16">
<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>
<TextBlock Text="DIAGNOSTICS" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Padding="14,12">
<StackPanel>
<TextBlock Text="View app state, last request details, and stored cookies."
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<Button Content="Open diagnostics" Style="{StaticResource SmallButton}"
HorizontalAlignment="Left" Click="Diagnostics_Click"/>
</StackPanel>
</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>
<!-- Diagnostics panel -->
<Border x:Name="DiagnosticsPanel" 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="BackFromDiag_Click"/>
<TextBlock Text="Diagnostics" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Button x:Name="CopyDiagButton" Content="Copy All" HorizontalAlignment="Right"
Style="{StaticResource SmallButton}" Click="CopyDiag_Click"/>
</Grid>
</Border>
<TextBlock DockPanel.Dock="Top" Text="Org IDs are redacted when copied."
FontSize="10" Foreground="{DynamicResource SecondaryBrush}"
Margin="14,6,14,6"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="DiagContentPanel" Margin="16,4,16,16"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Window>
+740
View File
@@ -0,0 +1,740 @@
using ClaudeCheckerWindows.Controls;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
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(1) };
_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));
if (VM.ExtraUsage?.IsEnabled == true)
CardsPanel.Children.Add(BuildExtraUsageSection());
var fiveHour = VM.Limits.FirstOrDefault(l => l.Window == WindowKind.FiveHour);
if (fiveHour != null && fiveHour.BurnHistory.Count > 0)
CardsPanel.Children.Add(BuildDiarySection(fiveHour));
}
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"];
var gauge = new GaugeControl { Width = 76, Height = 76, Percent = limit.UsedPercent, Accent = accent };
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)) }
};
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),
};
// Left: time remaining + reset date inline
var timeLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
timeLeft.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
timeLeft.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12,
FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center });
timeLeft.Children.Add(new TextBlock
{
Text = $" Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}",
FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
});
// Right: projected limit-hit time, or "after reset" if burn rate won't reach 100% before reset.
// BurnRate is stored as usedPercent / windowHours, so hoursToFull = remainingPercent / BurnRate.
string? refreshLabel;
if (limit.BurnRate <= 0)
refreshLabel = "after reset";
else
{
var projectedHit = DateTime.Now.AddHours((100.0 - limit.UsedPercent) / limit.BurnRate);
refreshLabel = projectedHit > limit.ResetDate
? "after reset"
: projectedHit.Date == DateTime.Today
? projectedHit.ToString("h:mm tt")
: projectedHit.ToString("d MMM h:mm tt");
}
UIElement timeRight;
if (refreshLabel != null)
{
var inner = new StackPanel { Orientation = Orientation.Horizontal };
inner.Children.Add(new TextBlock { Text = "↗ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
inner.Children.Add(new TextBlock { Text = refreshLabel, FontSize = 10, FontWeight = FontWeights.Medium,
Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
timeRight = new Border
{
Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 2, 6, 2),
Child = inner
};
}
else
{
timeRight = new TextBlock();
}
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(timeLeft, 0);
Grid.SetColumn(timeRight, 1);
timeRow.Children.Add(timeLeft);
timeRow.Children.Add(timeRight);
var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
infoPanel.Children.Add(headerRow);
infoPanel.Children.Add(progress);
infoPanel.Children.Add(timeRow);
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 secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var statsRow = new Grid { Margin = new Thickness(12, 10, 12, 6) };
statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var samplesText = new TextBlock
{
Text = $"{limit.BurnHistory.Count} samples",
FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
};
var burnText = new TextBlock
{
Text = $"Avg burn rate: {limit.BurnRate:F1}%/h",
FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(samplesText, 0);
Grid.SetColumn(burnText, 1);
statsRow.Children.Add(samplesText);
statsRow.Children.Add(burnText);
var sparkline = new SparklineControl
{
Height = 40,
Margin = new Thickness(8, 0, 8, 8),
LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
Data = limit.BurnHistory,
};
var cardContent = new StackPanel();
cardContent.Children.Add(statsRow);
cardContent.Children.Add(sparkline);
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 = cardContent,
};
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(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 ?? "$";
var spent = (overage?.UsedCredits / 100.0) ?? (VM.ExtraUsage?.UsedCredits / 100.0) ?? 0;
var limit = (overage?.MonthlyCreditLimit / 100.0) ?? (VM.ExtraUsage?.MonthlyLimit / 100.0) ?? 0;
var balance = (prepaid?.Amount / 100.0) ?? 0;
bool unlimited = overage?.MonthlyCreditLimit == null && VM.ExtraUsage?.MonthlyLimit == null;
UIElement MakeRow(string label, string value, bool highlight = false)
{
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 = highlight ? new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)) : 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}", spent > 0));
if (unlimited) contentPanel.Children.Add(MakeRow("Limit", "Unlimited"));
else 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;
string age;
if (diff.TotalSeconds < 10)
age = "just now";
else if (diff.TotalMinutes < 1)
age = $"{(int)diff.TotalSeconds} sec ago";
else
age = $"{(int)diff.TotalMinutes} min, {diff.Seconds} sec ago";
LastUpdatedText.Text = $"Updated {age}";
}
else
{
LastUpdatedText.Text = "Not yet updated";
}
LoadingBar.Visibility = VM.IsLoading ? Visibility.Visible : Visibility.Collapsed;
}
// ── Settings ─────────────────────────────────────────────────────
private static readonly (string Label, int Seconds)[] RefreshIntervals =
[
("1 min", 60),
("2 min", 120),
("3 min", 180),
("4 min", 240),
("5 min", 300),
("10 min", 600),
];
private void InitSettings()
{
VersionLabel.Text = $"v{Updater.CurrentVersion}";
BetaToggle.IsChecked = Updater.BetaChannel;
UpdateAboutSection();
RefreshPicker.SelectionChanged -= RefreshPicker_Changed;
RefreshPicker.Items.Clear();
int selectIdx = 1;
for (int i = 0; i < RefreshIntervals.Length; i++)
{
var (label, seconds) = RefreshIntervals[i];
RefreshPicker.Items.Add(new ComboBoxItem { Content = label, Tag = seconds });
if (seconds == VM.RefreshInterval) selectIdx = i;
}
RefreshPicker.SelectedIndex = selectIdx;
RefreshPicker.SelectionChanged += RefreshPicker_Changed;
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 label2 = 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(label2);
AuthPanel.Children.Add(authRow);
SignOutButton.Visibility = VM.IsSignedIn ? Visibility.Visible : Visibility.Collapsed;
SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In";
}
// ── Diagnostics ──────────────────────────────────────────────────
private void Diagnostics_Click(object s, RoutedEventArgs e)
{
RebuildDiag();
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Visible;
}
private void BackFromDiag_Click(object s, RoutedEventArgs e)
{
DiagnosticsPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
}
private void CopyDiag_Click(object s, RoutedEventArgs e)
{
var diagText = BuildDiagText();
for (int i = 0; i < 5; i++)
{
try { Clipboard.SetText(diagText); break; }
catch (Exception) { System.Threading.Thread.Sleep(100); }
}
CopyDiagButton.Content = "Copied! (IDs redacted)";
var t = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
t.Tick += (_, _) => { CopyDiagButton.Content = "Copy All"; t.Stop(); };
t.Start();
}
private void RebuildDiag()
{
DiagContentPanel.Children.Clear();
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
FrameworkElement MakeRow(string label, string value)
{
var grid = new Grid { Margin = new Thickness(0, 3, 0, 3) };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var lbl = new TextBlock
{
Text = label, FontSize = 11, Foreground = secondary,
VerticalAlignment = VerticalAlignment.Top,
};
var val = new TextBlock
{
Text = value, FontSize = 11, FontFamily = new FontFamily("Consolas"),
Foreground = text, TextWrapping = TextWrapping.Wrap,
};
Grid.SetColumn(lbl, 0);
Grid.SetColumn(val, 1);
grid.Children.Add(lbl);
grid.Children.Add(val);
return grid;
}
void AddSection(string title, IEnumerable<FrameworkElement> rows)
{
DiagContentPanel.Children.Add(new TextBlock
{
Text = title, Style = (Style)Application.Current.Resources["HeaderText"],
Margin = new Thickness(0, 12, 0, 6),
});
var panel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
foreach (var row in rows) panel.Children.Add(row);
DiagContentPanel.Children.Add(new Border
{
Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
CornerRadius = new CornerRadius(6),
BorderBrush = border, BorderThickness = new Thickness(1),
Child = panel,
});
}
// APP
AddSection("APP",
[
MakeRow("Version", Updater.CurrentVersion),
MakeRow("Signed in", VM.IsSignedIn ? "Yes" : "No"),
MakeRow("Org ID", string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId),
MakeRow("lastActiveOrg", string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg),
MakeRow("Error", VM.ErrorMessage ?? "(none)"),
]);
// LAST REQUEST
var reqRows = new List<FrameworkElement>
{
MakeRow("Path", string.IsNullOrEmpty(VM.DiagLastPath) ? "(none)" : VM.DiagLastPath),
MakeRow("Status", VM.DiagLastStatus == 0 ? "(none)" : VM.DiagLastStatus.ToString()),
MakeRow("Error", string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError),
};
if (VM.DiagLastFetch.HasValue)
reqRows.Add(MakeRow("Time", VM.DiagLastFetch.Value.ToString("HH:mm:ss")));
AddSection("LAST REQUEST", reqRows);
// STORED COOKIE NAMES
var storedNames = GetStoredCookieNames();
var nameRows = new List<FrameworkElement>();
if (storedNames.Count == 0)
{
nameRows.Add(MakeRow("(none)", ""));
}
else
{
var claudeC = storedNames.Where(c =>
c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com")).ToList();
var otherC = storedNames.Where(c =>
!c.Domain.Contains("claude.ai") && !c.Domain.Contains("anthropic.com")).ToList();
foreach (var c in claudeC)
nameRows.Add(MakeRow(c.Domain, c.Name));
if (otherC.Count > 0)
nameRows.Add(MakeRow($"other ({otherC.Count})",
string.Join(", ", otherC.Take(5).Select(c => c.Name))));
}
AddSection("STORED COOKIE NAMES", nameRows);
}
private static List<(string Name, string Domain)> GetStoredCookieNames()
{
try
{
var raw = AppSettings.Default.CookieStore;
if (string.IsNullOrEmpty(raw)) return [];
using var doc = JsonDocument.Parse(raw);
var list = new List<(string Name, string Domain)>();
foreach (var elem in doc.RootElement.EnumerateArray())
{
var name = elem.TryGetProperty("Name", out var n) ? n.GetString() ?? "" : "";
var domain = elem.TryGetProperty("Domain", out var d) ? d.GetString() ?? "" : "";
list.Add((name, domain));
}
return list;
}
catch { return []; }
}
private static string BuildDiagText()
{
var storedNames = GetStoredCookieNames();
var sb = new StringBuilder();
sb.AppendLine("=== ClaudeChecker Diagnostics (Windows) ===");
sb.AppendLine($"Version: {Updater.CurrentVersion}");
sb.AppendLine($"Signed in: {(VM.IsSignedIn ? "Yes" : "No")}");
sb.AppendLine($"Org ID: {(string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId)}");
sb.AppendLine($"lastActiveOrg: {(string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg)}");
sb.AppendLine($"Error: {VM.ErrorMessage ?? "(none)"}");
sb.AppendLine();
sb.AppendLine($"Last path: {VM.DiagLastPath}");
sb.AppendLine($"Last status: {VM.DiagLastStatus}");
sb.AppendLine($"Last error: {(string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError)}");
if (VM.DiagLastFetch.HasValue)
sb.AppendLine($"Last fetch: {VM.DiagLastFetch.Value:HH:mm:ss}");
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("Stored cookies:");
foreach (var c in storedNames)
sb.AppendLine($" {c.Domain} {c.Name}");
return RedactUUIDs(sb.ToString());
}
private static string RedactUUIDs(string text) =>
Regex.Replace(text, @"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "****");
// ── Event handlers ───────────────────────────────────────────────
private void Settings_Click(object s, RoutedEventArgs e)
{
InitSettings();
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.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;
DiagnosticsPanel.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;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
}
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)
{
await VM.LoadFromCacheAsync();
InitSettings();
ShowMain();
_ = 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 ComboBoxItem item && item.Tag is int seconds)
{
VM.RefreshInterval = seconds;
((App)Application.Current).ScheduleTimer(seconds);
}
}
private async void CheckUpdate_Click(object s, RoutedEventArgs e)
{
if (Updater.UpdateAvailable) { ShowUpdatePanel(); return; }
CheckUpdateButton.Content = "…";
await Updater.CheckForUpdatesAsync();
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
}
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="uri:settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)"/>
</Profiles>
<Settings>
<Setting Name="CookieStore" Type="System.String" Scope="User">
<Value Profile="(Default)"></Value>
</Setting>
<Setting Name="BurnHistory" Type="System.String" Scope="User">
<Value Profile="(Default)"></Value>
</Setting>
<Setting Name="RefreshInterval" Type="System.Int32" Scope="User">
<Value Profile="(Default)">120</Value>
</Setting>
<Setting Name="ShowInTaskbar" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
<Setting Name="BetaChannel" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
</Settings>
</SettingsFile>
+50
View File
@@ -0,0 +1,50 @@
# ClaudeChecker for Windows
A native Windows port of ClaudeChecker — a system tray app for monitoring your Claude AI usage limits in real time.
## Requirements
- Windows 10 / 11 (x64)
- [.NET 8 Runtime](https://dotnet.microsoft.com/download/dotnet/8.0)
- [WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/) (usually pre-installed on Windows 11)
## Building
```powershell
cd windows
dotnet build -c Release
```
The output is a single `ClaudeChecker.exe` in `bin\Release\net8.0-windows\`.
> **Assets:** You need to supply `Assets/icon.ico` and `Assets/icon.png` (128×128) before building.
> Copy them from the macOS project's `ClaudeChecker/Assets.xcassets/AppIcon.appiconset/`.
## Features
- System tray icon — left-click to show/hide the popup
- 5-hour and 7-day usage gauges with burn rate sparklines
- Cookie-based auth via embedded WebView2 browser (sign in once, session persists)
- Auto-refresh on a configurable interval (110 min)
- Update checking against the same `version.json` / `version-beta.json` as the macOS app
- Beta channel toggle
- Settings panel (auth, refresh interval, update channel)
## Architecture
| File | Purpose |
|---|---|
| `App.xaml.cs` | Tray icon, popup lifecycle, refresh timer |
| `PopupWindow.xaml/cs` | Main UI — limit cards, settings, update sheet |
| `LoginWindow.xaml/cs` | WebView2 browser for claude.ai auth |
| `UsageViewModel.cs` | Data fetching, cookie handling, state |
| `UpdateManager.cs` | Version checking, download and install |
| `Models.cs` | Data models |
| `Controls/GaugeControl.cs` | Custom circular gauge |
| `Controls/SparklineControl.cs` | Burn history sparkline |
## Notes
- Cookies are stored in user settings (via `Properties.Settings`), not the WebView2 profile, so they survive WebView2 cache clears.
- Updates download a `.zip`, extract, and run a `.bat` script that replaces the exe after the app exits.
- The popup positions itself in the bottom-right corner near the system tray.
+23
View File
@@ -0,0 +1,23 @@
## What's new in beta.49
### Architecture
- All data (usage, plan, overage, prepaid credits) now fetched via the persistent background WebView2 — the same always-live session as the macOS app, so Cloudflare cookies never expire between refreshes
- HttpClient path kept only as a brief startup fallback before the browser is ready
- Settings file simplified: only stores session signal, burn history, and user preferences — no more cached API responses
### Bug fixes
- Plan label (e.g. "Pro") now updates on every refresh, not just at login
- Extra Usage Credits values now correct (were 100× too large)
- Limit and balance now update live on every refresh
- Refresh interval dropdown now shows "1 min", "2 min" etc. correctly
- ComboBox no longer flashes bright blue when clicked
- Buttons now show a visible pressed state
- Footer "Updated X ago" counter now ticks every second; shows "X min, Y sec ago" past 60 s
- Removed stray sparkline bars from the 5 h and 7 d limit cards
- App window now shown on launch
### UI improvements
- Gauge percentage larger, "used" label removed
- Each limit card now shows an "after reset" or "today HH:MM" badge
- Session Diary card redesigned to match macOS layout (stats row + sparkline, no Claude header)
- Reset date moved inline with time remaining
+81
View File
@@ -0,0 +1,81 @@
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);
}
}
+229
View File
@@ -0,0 +1,229 @@
using System;
using System.Diagnostics;
using System.Reflection;
using System.IO;
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 installerPath = Path.Combine(Path.GetTempPath(), $"ClaudeCheckerInstaller_{Guid.NewGuid():N}.exe");
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(installerPath))
{
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;
}
}
DownloadProgress = 1.0;
StatusMessage = "Installing…";
UpdateComplete = true;
await Task.Delay(500);
Process.Start(new ProcessStartInfo
{
FileName = installerPath,
Arguments = "/SILENT /CLOSEAPPLICATIONS",
UseShellExecute = true,
});
Application.Current.Dispatcher.Invoke(() => Application.Current.Shutdown());
}
catch (Exception ex)
{
UpdateError = ex.Message;
IsDownloading = false;
StatusMessage = "";
}
}
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)));
}
}
+609
View File
@@ -0,0 +1,609 @@
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); }
// Diagnostics — updated after each refresh, read by DiagnosticsPanel on demand
public string DiagLastActiveOrg { get; private set; } = "";
public string DiagOrgId { get; private set; } = "";
public string DiagLastPath { get; private set; } = "";
public int DiagLastStatus { get; private set; }
public string DiagLastError { get; private set; } = "";
public int DiagCookieCount { get; private set; }
public int DiagClaudeCookieCount { get; private set; }
public List<string> DiagCookieDomains { get; private set; } = [];
public DateTime? DiagLastFetch { get; private set; }
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);
// Snapshot stored cookies once — used for both diag and HttpClient fallback path.
var storedCookies = await GetCookiesAsync();
try
{
// CookieStore being non-empty is our signal that the user has signed in.
if (string.IsNullOrEmpty(AppSettings.Default.CookieStore))
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
ErrorMessage = "Not signed in — click Sign In to authenticate.";
IsLoading = false;
});
return;
}
List<AgentLimit> limits = [];
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
ExtraUsage? extraUsage = null;
string? email = null, orgId = null, planLabel = null;
string? refreshError = null;
// Primary: persistent background WebView2 — always-live cookies, same as macOS.
// Fallback: HttpClient with saved cookies, only if the browser isn't ready yet
// (the brief window while InitAsync is still navigating at startup).
if (App.BackgroundBrowser.IsReady)
{
try
{
(limits, overage, prepaid, extraUsage, email, orgId, planLabel) =
await TryBrowserRefreshAsync();
}
catch (Exception ex)
{
refreshError = ex.Message;
}
}
else
{
if (storedCookies.Count > 0)
{
try
{
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(storedCookies);
}
catch (Exception ex)
{
refreshError = ex.Message;
}
}
}
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;
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;
// Update diagnostics
if (!string.IsNullOrEmpty(orgId)) DiagOrgId = orgId;
DiagLastFetch = DateTime.Now;
DiagLastError = refreshError ?? "";
DiagCookieCount = storedCookies.Count;
DiagClaudeCookieCount = storedCookies.Count(c =>
c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"));
DiagCookieDomains = storedCookies
.Select(c => c.Domain).Distinct().OrderBy(d => d).ToList();
});
}
catch (Exception ex)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
ErrorMessage = ex.Message;
IsLoading = false;
});
}
}
// ── Browser-based refresh (primary) ───────────────────────────────────────
// Runs JS on the persistent claude.ai page. Same live cookies as macOS WKWebView.
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
TryBrowserRefreshAsync()
{
// Read lastActiveOrg from document.cookie — the only reliable org source when the
// user belongs to multiple orgs. memberships[0] is always the "Individual Org" and
// would return 403 on usage endpoints if the paid plan is on a different org.
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
if(b?.error_type==='authentication_error'){window.chrome.webview.postMessage({authError:true});return;}
const lastActiveOrg=(document.cookie.split(';').map(c=>c.trim().split('=')).find(p=>p[0]==='lastActiveOrg')||[])[1]||null;
const allOrgs=(b?.account?.memberships||b?.memberships||[]).map(m=>m?.organization).filter(Boolean);
const activeOrg=(lastActiveOrg?allOrgs.find(o=>o?.uuid===lastActiveOrg):null)||allOrgs[0];
let id=activeOrg?.uuid||b?.organizations?.[0]?.uuid||null;
const email=b?.account?.email_address||b?.account?.email||null;
const caps=activeOrg?.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;
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){window.chrome.webview.postMessage({noOrg:true,email});return;}
const [u,ov,pp]=await Promise.all([
fetch('/api/organizations/'+id+'/usage',h).then(r=>r.json()),
fetch('/api/organizations/'+id+'/overage_spend_limit',h).then(r=>r.ok?r.json():null).catch(()=>null),
fetch('/api/organizations/'+id+'/prepaid/credits',h).then(r=>r.ok?r.json():null).catch(()=>null)
]);
window.chrome.webview.postMessage({email,orgId:id,planLabel,lastActiveOrg,usage:u,overage:ov,prepaid:pp});
}catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
var json = await Application.Current.Dispatcher.InvokeAsync(
() => App.BackgroundBrowser.RunScriptAsync(script)).Task.Unwrap();
if (string.IsNullOrEmpty(json) || json == "null")
throw new Exception("Browser refresh returned no data.");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("authError", out _))
throw new Exception("Session expired — please re-authenticate.");
if (root.TryGetProperty("error", out var errEl))
throw new Exception("Browser script error: " + errEl.GetString());
if (root.TryGetProperty("noOrg", out _))
throw new Exception("Could not determine organization ID.");
string? email = root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String ? em.GetString() : null;
string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String ? oi.GetString() : null;
string? planLabel = root.TryGetProperty("planLabel", out var pl) && pl.ValueKind == JsonValueKind.String ? pl.GetString() : null;
string? lao = root.TryGetProperty("lastActiveOrg", out var laoProp) && laoProp.ValueKind == JsonValueKind.String ? laoProp.GetString() : null;
DiagLastActiveOrg = lao ?? "";
DiagLastPath = $"/api/bootstrap + /api/organizations/…/usage (browser)";
DiagLastStatus = 200;
UsageResponse? usage = null;
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
usage = JsonSerializer.Deserialize<UsageResponse>(us.GetRawText(), JsonOpts);
if (root.TryGetProperty("overage", out var ov) && ov.ValueKind == JsonValueKind.Object)
overage = JsonSerializer.Deserialize<OverageSpendLimit>(ov.GetRawText(), JsonOpts);
if (root.TryGetProperty("prepaid", out var pp) && pp.ValueKind == JsonValueKind.Object)
prepaid = JsonSerializer.Deserialize<PrepaidCredits>(pp.GetRawText(), JsonOpts);
return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel);
}
// ── HttpClient refresh (fallback — before browser is ready) ──────────────
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{
// Prefer the org the user last had active, not necessarily memberships[0].
var lastActiveOrg = cookies.FirstOrDefault(c => c.Name == "lastActiveOrg").Value;
DiagLastActiveOrg = lastActiveOrg ?? "";
using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
DiagLastPath = "/api/bootstrap";
DiagLastStatus = (int)bootstrapResp.StatusCode;
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();
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson, lastActiveOrg);
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, planLabel, true);
var ut = http.GetAsync($"https://claude.ai/api/organizations/{orgId}/usage");
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");
await Task.WhenAll(ut, ot, pt);
var usageResp = ut.Result;
DiagLastPath = $"/api/organizations/{orgId}/usage";
DiagLastStatus = (int)usageResp.StatusCode;
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);
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
}
// ── Startup cache ─────────────────────────────────────────────────────────
// Called before the browser is ready. Sets sign-in state and shows placeholders
// with any previously accumulated burn history so the popup isn't blank.
public async Task LoadFromCacheAsync()
{
if (string.IsNullOrEmpty(AppSettings.Default.CookieStore)) return;
var placeholders = LoadPlaceholderLimits();
foreach (var limit in placeholders)
{
var key = limit.Window.ToString();
if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
limit.BurnHistory = [.. hist];
}
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Limits = placeholders;
IsSignedIn = true;
});
}
public async Task SignOutAsync()
{
AppSettings.Default.CookieStore = "";
AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
UserEmail = "";
PlanLabel = "Claude";
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; }
}
// Parses bootstrap JSON and returns email, org ID, and plan label.
// preferredOrgId (from the lastActiveOrg cookie) selects the correct org when the
// user belongs to multiple — memberships[0] is always the "Individual Org" which
// returns 403 on usage endpoints if the paid plan is on a different org.
private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json, string? preferredOrgId = null)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
string? email = null;
if (root.TryGetProperty("account", out var acct) &&
acct.TryGetProperty("email_address", out var em))
email = em.GetString();
// Collect all org objects from memberships
var allOrgs = new List<JsonElement>();
if (root.TryGetProperty("account", out var acctNode) &&
acctNode.TryGetProperty("memberships", out var mems))
{
foreach (var mem in mems.EnumerateArray())
if (mem.TryGetProperty("organization", out var org))
allOrgs.Add(org);
}
if (allOrgs.Count == 0 && root.TryGetProperty("memberships", out var rootMems))
{
foreach (var mem in rootMems.EnumerateArray())
if (mem.TryGetProperty("organization", out var org))
allOrgs.Add(org);
}
// Prefer the org matching preferredOrgId, fall back to first
JsonElement activeOrg = default;
if (!string.IsNullOrEmpty(preferredOrgId))
{
foreach (var org in allOrgs)
{
if (org.TryGetProperty("uuid", out var u) && u.GetString() == preferredOrgId)
{
activeOrg = org;
break;
}
}
}
if (activeOrg.ValueKind == JsonValueKind.Undefined && allOrgs.Count > 0)
activeOrg = allOrgs[0];
string? orgId = null;
if (activeOrg.ValueKind != JsonValueKind.Undefined &&
activeOrg.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
{
if (orgs[0].TryGetProperty("uuid", out var u))
orgId = u.GetString();
}
string? planLabel = null;
if (activeOrg.ValueKind != JsonValueKind.Undefined &&
activeOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == 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; } = "/";
}
}
+145
View File
@@ -0,0 +1,145 @@
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using System;
using System.IO;
using System.Threading;
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 (including
// cf_clearance) is always shared and fresh — equivalent to macOS's WKWebsiteDataStore.
//
// Two usage modes:
// • Persistent (App.BackgroundBrowser): created once at startup, navigates to
// claude.ai once, then scripts run directly on the live page every refresh.
// No re-navigation = no memory accumulation.
// • One-shot (LoginWindow): created, used, closed — same as before.
public sealed class WebViewFetchWindow : Window
{
private readonly WebView2 _wv = new();
private bool _initialized;
private bool _readyForScript; // true after first navigation to claude.ai completes
private static readonly string UserDataFolder =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2");
public bool IsReady => _readyForScript;
public WebViewFetchWindow()
{
Width = 1;
Height = 1;
Left = -9999;
Top = -9999;
ShowInTaskbar = false;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Opacity = 0;
Content = _wv;
}
// ── Persistent-mode init ──────────────────────────────────────────────────
// Call once at startup. Navigates to claude.ai so the session/cookies are
// established and cf_clearance is fresh. Subsequent RunScriptAsync calls
// skip navigation and just execute JS on the live page.
public async Task InitAsync()
{
await EnsureInitAsync();
var navDone = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, e) => { _wv.CoreWebView2.NavigationCompleted -= h; navDone.TrySetResult(e.IsSuccess); };
_wv.CoreWebView2.NavigationCompleted += h;
_wv.CoreWebView2.Navigate("https://claude.ai");
// Wait up to 15 s for initial navigation
await Task.WhenAny(navDone.Task, Task.Delay(15000));
_readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
}
// Run a JS script on the already-loaded claude.ai page.
// The script must call window.chrome.webview.postMessage(result).
public async Task<string?> RunScriptAsync(string script, int timeoutMs = 10000)
{
if (!_readyForScript) return null;
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(args.WebMessageAsJson);
};
_wv.CoreWebView2.WebMessageReceived += msgHandler;
try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
catch
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
return null;
}
_ = Task.Delay(timeoutMs).ContinueWith(_ =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(null);
});
return await tcs.Task;
}
// ── One-shot mode (LoginWindow) ───────────────────────────────────────────
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
{
await EnsureInitAsync();
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
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;
}
// ── Shared init ───────────────────────────────────────────────────────────
private int _initGuard;
private async Task EnsureInitAsync()
{
if (_initialized) return;
if (Interlocked.Exchange(ref _initGuard, 1) != 0) return;
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: UserDataFolder);
await _wv.EnsureCoreWebView2Async(env);
_initialized = true;
}
}
+42
View File
@@ -0,0 +1,42 @@
#ifndef AppVersion
#define AppVersion "0.0.1"
#endif
[Setup]
AppName=ClaudeChecker
AppVersion={#AppVersion}
AppPublisher=superdooper86
AppPublisherURL=https://github.com/superdooper86/claudechecker
AppSupportURL=https://github.com/superdooper86/claudechecker/issues
AppUpdatesURL=https://github.com/superdooper86/claudechecker/releases
DefaultDirName={localappdata}\ClaudeChecker
DefaultGroupName=ClaudeChecker
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=.
OutputBaseFilename=ClaudeChecker-Installer
Compression=lzma
SolidCompression=yes
WizardStyle=modern
UninstallDisplayIcon={app}\ClaudeChecker.exe
SourceDir=..
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "publish\ClaudeChecker.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "publish\Assets\*"; DestDir: "{app}\Assets"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"
Name: "{commondesktop}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"; Tasks: desktopicon
[Run]
; Silent install (auto-update): launch automatically
Filename: "{app}\ClaudeChecker.exe"; Flags: nowait; Check: WizardSilent
; Manual install: show launch checkbox on final page
Filename: "{app}\ClaudeChecker.exe"; Description: "{cm:LaunchProgram,ClaudeChecker}"; Flags: nowait postinstall skipifsilent