Compare commits

...
90 Commits
Author SHA1 Message Date
coder d1f644a958 Document shared host runners [skip ci] 2026-09-10 09:04:37 +02:00
coder 03f151c5a5 Check each architecture separately with Xcode lipo
Build / macos (push) Successful in 9s
Build / windows (push) Successful in 30s
2026-09-10 09:00:58 +02:00
coder 0af8f2992b Fix universal architecture verification argument order
Build / macos (push) Failing after 9s
Build / windows (push) Successful in 31s
2026-09-10 09:00:04 +02:00
coder 8cb6602b59 Move ClaudeChecker builds and updates to Gitea
Build / macos (push) Failing after 14s
Build / windows (push) Successful in 37s
2026-09-10 08:59:09 +02:00
SuperDooper e24d9cfb4a Update release notes for v1.3.3 2026-05-11 22:27:30 +02:00
SuperDooper de3f37bc49 Windows beta badge win-v0.0.1-beta.63 2026-05-11 20:25:52 +00:00
SuperDooper 0dcb784315 Windows beta release win-v0.0.1-beta.63 2026-05-11 20:25:49 +00:00
SuperDooper 201a5c1242 Windows beta badge win-v0.0.1-beta.62 2026-05-11 20:15:52 +00:00
SuperDooper edd6f26544 Windows beta release win-v0.0.1-beta.62 2026-05-11 20:15:48 +00:00
SuperDooper 072f935497 Windows beta badge win-v0.0.1-beta.61 2026-05-11 20:07:33 +00:00
SuperDooper e9af9d4dc5 Windows beta release win-v0.0.1-beta.61 2026-05-11 20:07:29 +00:00
SuperDooper 375df6be98 Windows beta badge win-v0.0.1-beta.60 2026-05-11 19:48:35 +00:00
SuperDooper 46bdc855ad Windows beta release win-v0.0.1-beta.60 2026-05-11 19:48:32 +00:00
SuperDooper ab6c2a3e84 Windows beta badge win-v0.0.1-beta.59 2026-05-11 19:42:38 +00:00
SuperDooper e219b47ab7 Windows beta release win-v0.0.1-beta.59 2026-05-11 19:42:36 +00:00
SuperDooper 871dc3e5ba Windows beta badge win-v0.0.1-beta.58 2026-05-11 19:38:21 +00:00
SuperDooper e0dd60b3c0 Windows beta release win-v0.0.1-beta.58 2026-05-11 19:38:14 +00:00
SuperDooper c7c98621cb Windows beta badge win-v0.0.1-beta.56 2026-05-11 19:21:46 +00:00
SuperDooper 40d01e51d5 Windows beta release win-v0.0.1-beta.56 2026-05-11 19:21:43 +00:00
github-actions[bot] 0b7fce9934 Beta release v1.3.3-beta.5 2026-05-11 19:13:53 +00:00
SuperDooper e13d3d8ee8 Bump version to 1.3.3-beta.5 (build 81) 2026-05-11 21:12:49 +02:00
SuperDooper a729a37efc Diagnostics: fix Swift string interpolation compile errors in fullDiagText 2026-05-11 21:12:41 +02:00
SuperDooper c7e4c81b9a Windows beta badge win-v0.0.1-beta.55 2026-05-11 19:10:52 +00:00
SuperDooper 2313c4378a Windows beta release win-v0.0.1-beta.55 2026-05-11 19:10:46 +00:00
SuperDooper c51086c119 Bump version to 1.3.3-beta.4 (build 80) 2026-05-11 21:08:03 +02:00
SuperDooper 5840b34b3b Diagnostics: update privacy note, fix last error (none), add missing copy fields 2026-05-11 21:07:50 +02:00
SuperDooper 5a141095e1 Diagnostics: show (none) for empty last error in copy 2026-05-11 21:06:49 +02:00
SuperDooper 4b42a631be Diagnostics: add Org ID, lastActiveOrg, and Time to copied text 2026-05-11 21:05:12 +02:00
SuperDooper c6d2ed8261 CI: retry push on rebase conflict in beta workflow 2026-05-11 20:58:13 +02:00
SuperDooper 747e26e5eb Beta release v1.3.3-beta.3 2026-05-11 20:58:10 +02:00
SuperDooper b9fd5a27ec Beta release v1.3.3-beta.3 2026-05-11 20:58:09 +02:00
SuperDooper 60336cffa7 Windows beta badge win-v0.0.1-beta.54 2026-05-11 18:55:00 +00:00
SuperDooper 953c46077c Windows beta release win-v0.0.1-beta.54 2026-05-11 18:54:58 +00:00
SuperDooper 3796139178 Bump version to 1.3.3-beta.3 (build 79) 2026-05-11 20:53:46 +02:00
SuperDooper 554eb80c0c Diagnostics: remove cookie stats from copied text 2026-05-11 20:51:31 +02:00
SuperDooper a22bf3a309 Diagnostics: remove stale Cookie Store section 2026-05-11 20:51:10 +02:00
SuperDooper eaab3eb539 Windows beta badge win-v0.0.1-beta.53 2026-05-11 18:51:04 +00:00
SuperDooper 323aa174da Windows beta release win-v0.0.1-beta.53 2026-05-11 18:51:02 +00:00
github-actions[bot] 3803cf37f6 Beta release v1.3.3-beta.2 2026-05-11 18:50:53 +00:00
SuperDooper 1977b51b74 Bump version to 1.3.3-beta.2 (build 78) 2026-05-11 20:49:57 +02:00
SuperDooper 64a3f15ac5 Diagnostics: remove email from copied text 2026-05-11 20:48:46 +02:00
SuperDooper 8fbd1c42c3 Diagnostics: remove email from copy output, add privacy note 2026-05-11 20:48:21 +02:00
SuperDooper 0bf19c3dc8 README: collapse Win Stable placeholder inline to fix 3-row badge layout 2026-05-11 20:46:50 +02:00
SuperDooper d56a714bb9 README: add Win Stable badge placeholder 2026-05-11 20:44:55 +02:00
SuperDooper 88f409971a CI: update macOS beta badge label to macOS_Beta 2026-05-11 20:43:55 +02:00
SuperDooper 9bac2540e8 CI: update stable badge regex for macOS_Stable label 2026-05-11 20:43:51 +02:00
SuperDooper 792c5a26e9 README: rename version badges to macOS Stable / macOS Beta / Win Beta 2026-05-11 20:43:48 +02:00
SuperDooper 421727bfb5 README: add Windows platform badge to top line 2026-05-11 20:42:53 +02:00
SuperDooper 21b2905bcf README: add current Windows beta badge 2026-05-11 20:42:13 +02:00
SuperDooper 38d6f2a582 CI: fix BETA_BADGE handling for new badge layout 2026-05-11 20:41:50 +02:00
SuperDooper 7ea5554506 README: add Windows beta badge, move version badges to new line 2026-05-11 20:41:42 +02:00
SuperDooper 87c5992889 Windows beta release win-v0.0.1-beta.52 2026-05-11 18:35:53 +00:00
github-actions[bot] 034e196214 Beta release v1.3.3-beta.1 2026-05-11 18:35:45 +00:00
SuperDooper 2235a627b5 Bump version to 1.3.3-beta.1 (build 77) 2026-05-11 20:34:15 +02:00
SuperDooper d6c138f1a2 Bug template: update diagnostics copy note 2026-05-11 20:28:38 +02:00
SuperDooper 432819639e Diagnostics: remove email, redact IDs on copy 2026-05-11 20:28:12 +02:00
SuperDooper 52db469c13 Remove org ID from diagnostics output 2026-05-11 20:24:56 +02:00
SuperDooper 40c86aaaf2 Windows beta release win-v0.0.1-beta.51 2026-05-11 13:09:31 +00:00
SuperDooper 46b09934d7 Windows beta release win-v0.0.1-beta.50 2026-05-11 12:20:21 +00:00
SuperDooper a79a23ac24 update bug report template: add diagnostics instructions and Windows field 2026-05-11 14:19:06 +02:00
github-actions[bot] 3785629ee4 Release v1.3.2 2026-05-11 12:01:55 +00:00
superdooper86 a338f4e5fe release 1.3.2: anchor primer WebView in hidden window for reliable startup fetch
Every launch now loads claude.ai in a real (off-screen, invisible) NSWindow so
WebKit doesn't throttle JS execution. The nav delegate fires adoptPrimerWebView
once the page loads, replacing the old 1.5 s sleep-then-refresh approach.
2026-05-11 14:01:07 +02:00
github-actions[bot] 88e7d684c0 Release v1.3.1 2026-05-11 11:45:59 +00:00
superdooper86 9af0d6401c release 1.3.1: remove API response bodies from diagnostics panel 2026-05-11 13:45:09 +02:00
github-actions[bot] 7cb2159b4c Release v1.3.0 2026-05-11 11:31:31 +00:00
superdooper86 206e97780d release 1.3.0: multi-org support, WKWebView JS fetch, diagnostics panel 2026-05-11 13:30:09 +02:00
github-actions[bot] 2b79ba4ae0 Beta release v1.2.1-beta.25 2026-05-11 11:24:25 +00:00
superdooper86 60b6c60c0a beta.25: read plan label from active org (lastActiveOrg) not first membership 2026-05-11 13:23:23 +02:00
github-actions[bot] 8140831236 Beta release v1.2.1-beta.24 2026-05-11 11:16:27 +00:00
superdooper86 9ed8916075 beta.24: read lastActiveOrg cookie via JS, expose bootstrap body and lastActiveOrg in diagnostics 2026-05-11 13:15:08 +02:00
github-actions[bot] 2ab2d913ce Beta release v1.2.1-beta.23 2026-05-11 11:07:55 +00:00
superdooper86 30ad89e458 beta.23: use WKWebView JS fetch for all API calls (avoids URLSession 403 fingerprinting) 2026-05-11 13:07:01 +02:00
github-actions[bot] 3e4bfca2df Beta release v1.2.1-beta.22 2026-05-11 10:53:55 +00:00
superdooper86 4378463600 beta.22: manual cookie join, anthropic-client-platform header, ephemeral session, expose usage 403 body 2026-05-11 12:52:46 +02:00
github-actions[bot] 2cfd7d56a1 Beta release v1.2.1-beta.21 2026-05-11 10:41:34 +00:00
SuperDooper 84bdfbd786 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:50 +02:00
SuperDooper beba7586f9 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:48 +02:00
SuperDooper a4dff4bde9 beta.21: add DiagnosticsView.swift 2026-05-11 12:40:47 +02:00
SuperDooper d0b33e8f43 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:38 +02:00
SuperDooper 6a5a3c9cd1 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:36 +02:00
github-actions[bot] 15e225439b Beta release v1.2.1-beta.20 2026-05-11 10:33:30 +00:00
SuperDooper 91829b0ed9 beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:34 +02:00
SuperDooper caffc996ff beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:33 +02:00
github-actions[bot] cd13a6d124 Beta release v1.2.1-beta.19 2026-05-11 10:22:52 +00:00
SuperDooper 566258e9c8 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:14 +02:00
SuperDooper dc2a8e2307 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:13 +02:00
SuperDooper f48b53fd8e beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:22 +02:00
SuperDooper ccfee938b7 beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:21 +02:00
SuperDooper 28d952bcbd beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:19 +02:00
github-actions[bot] 9bc023d239 Beta release v1.2.1-beta.17 2026-05-11 10:07:03 +00:00
45 changed files with 3731 additions and 350 deletions
+29
View File
@@ -0,0 +1,29 @@
name: Build
on:
push:
branches: [main]
workflow_dispatch:
jobs:
macos:
runs-on: claudechecker-macos-arm64
steps:
- uses: actions/checkout@v4
- name: Build universal macOS app
run: bash scripts/build-macos.sh
- uses: actions/upload-artifact@v3
with:
name: ClaudeChecker-macOS
path: ClaudeChecker.zip
windows:
runs-on: claudechecker-windows-x64
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@v4
- name: Build Windows installer
run: ./scripts/build-windows.ps1
- uses: actions/upload-artifact@v3
with:
name: ClaudeChecker-Windows
path: ClaudeChecker-Installer.exe
+32
View File
@@ -0,0 +1,32 @@
name: Release Windows
on:
workflow_dispatch:
inputs:
version:
description: 'Version, e.g. 0.0.1-beta.64'
required: true
type: string
channel:
description: Release channel
required: true
default: windows-beta
type: choice
options: [windows-beta, windows-stable]
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: claudechecker-windows-x64
defaults:
run:
shell: powershell
env:
VERSION: ${{ inputs.version }}
CHANNEL: ${{ inputs.channel }}
steps:
- uses: actions/checkout@v4
- name: Build Windows installer
run: ./scripts/build-windows.ps1 -Version $env:VERSION
- name: Publish release and update feed
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python scripts/publish-release.py
+29
View File
@@ -0,0 +1,29 @@
name: Release macOS
on:
workflow_dispatch:
inputs:
version:
description: 'Version, e.g. 1.3.3 or 1.3.4-beta.1'
required: true
type: string
channel:
description: Release channel
required: true
default: stable
type: choice
options: [stable, beta]
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: claudechecker-macos-arm64
env:
VERSION: ${{ inputs.version }}
CHANNEL: ${{ inputs.channel }}
steps:
- uses: actions/checkout@v4
- name: Build universal macOS app
run: bash scripts/build-macos.sh
- name: Publish release and update feed
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/publish-release.py
+8 -2
View File
@@ -18,12 +18,18 @@ Steps to reproduce the behaviour:
**Expected behaviour**
What you expected to happen.
**Diagnostics**
Open the app → Settings → Diagnostics, then click "Copy All" and paste the output here.
This shows the error, request state, and cookie info. Email and org IDs are automatically redacted when you click Copy All.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment**
- macOS version: [e.g. Sequoia 15.4]
- ClaudeChecker version: [e.g. 1.1.3]
- Platform: [macOS / Windows]
- macOS version: [e.g. Sequoia 15.4] *(if applicable)*
- Windows version: [e.g. Windows 11 24H2] *(if applicable)*
- ClaudeChecker version: [e.g. 1.3.2]
- Claude plan: [e.g. Pro, Max]
**Additional context**
-79
View File
@@ -1,79 +0,0 @@
name: Release Beta
on:
push:
tags:
- 'v*-beta*'
jobs:
release-beta:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build
run: |
xcodebuild \
-project ClaudeChecker.xcodeproj \
-scheme ClaudeChecker \
-configuration Release \
-derivedDataPath build \
clean build
- name: Zip app
run: |
ditto -c -k --keepParent \
build/Build/Products/Release/ClaudeChecker.app \
ClaudeChecker.zip
- name: Create GitHub pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
--title "$GITHUB_REF_NAME" \
--prerelease \
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Beta release ${{ steps.version.outputs.version }}')"
- name: Update version-beta.json
run: |
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Beta release ${{ steps.version.outputs.version }}")
jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' > version-beta.json
- name: Update beta badge in README
run: |
shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g')
tag="${{ github.ref_name }}"
export BADGE="[![Beta](https://img.shields.io/badge/beta-${shields_version}-orange?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- BETA_BADGE -->"
python3 -c "
import os
badge = os.environ['BADGE']
lines = open('README.md').readlines()
out = []
for line in lines:
out.append(badge + chr(10) if '<!-- BETA_BADGE -->' in line else line)
open('README.md', 'w').writelines(out)
"
- name: Commit version-beta.json and README
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version-beta.json README.md
git commit -m "Beta release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main
-111
View File
@@ -1,111 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
if: ${{ !contains(github.ref_name, '-') }}
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build
run: |
xcodebuild \
-project ClaudeChecker.xcodeproj \
-scheme ClaudeChecker \
-configuration Release \
-derivedDataPath build \
clean build
- name: Zip app
run: |
ditto -c -k --keepParent \
build/Build/Products/Release/ClaudeChecker.app \
ClaudeChecker.zip
- name: Compute SHA256
id: sha
run: echo "sha256=$(shasum -a 256 ClaudeChecker.zip | awk '{print $1}')" >> $GITHUB_OUTPUT
- name: Create GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
--title "$GITHUB_REF_NAME" \
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')"
- name: Update version.json
run: |
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Release ${{ steps.version.outputs.version }}")
jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' > version.json
- name: Update Homebrew cask
run: |
sed -i '' \
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
- name: Update README badge
env:
NEW_VERSION: ${{ steps.version.outputs.version }}
run: |
python3 -c "
import os, re
v = os.environ['NEW_VERSION']
content = open('README.md').read()
content = re.sub(r'version-[0-9][^-]*-orange', 'version-' + v + '-orange', content)
lines = content.splitlines(keepends=True)
out = []
for line in lines:
if '<!-- BETA_BADGE -->' in line:
continue
out.append(line)
if 'license-MIT-blue' in line:
out.append('<!-- BETA_BADGE -->' + chr(10))
open('README.md', 'w').writelines(out)
"
- name: Commit version.json and README
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.json README.md
git commit -m "Release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main
- name: Update Homebrew tap
env:
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
run: |
git clone https://x-access-token:${{ secrets.TAP_TOKEN }}@github.com/${{ github.repository_owner }}/homebrew-tap /tmp/homebrew-tap
sed -i '' \
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
/tmp/homebrew-tap/Casks/claudechecker.rb
cd /tmp/homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/claudechecker.rb
git commit -m "Update claudechecker to ${{ github.ref_name }}"
git push
+7
View File
@@ -0,0 +1,7 @@
/build/
/publish/
/ClaudeChecker.zip
/ClaudeChecker-Installer.exe
/windows/bin/
/windows/obj/
__pycache__/
+1 -1
View File
@@ -4,7 +4,7 @@ Thanks for your interest in ClaudeChecker!
## Reporting bugs & requesting features
Please use the [issue templates](https://github.com/superdooper86/claudechecker/issues/new/choose) — they keep reports consistent and easy to act on.
Please use the [issue templates](https://git.jamesbone.net/coder/claudechecker/issues/new/choose) — they keep reports consistent and easy to act on.
## Pull requests
+20
View File
@@ -10,6 +10,13 @@
<string>77</string>
<key>objects</key>
<dict>
<key>AAAAAA009</key>
<dict>
<key>isa</key>
<string>PBXBuildFile</string>
<key>fileRef</key>
<string>AAAAAA109</string>
</dict>
<key>AAAAAA007</key>
<dict>
<key>isa</key>
@@ -88,6 +95,17 @@
<key>sourceTree</key>
<string>&lt;group&gt;</string>
</dict>
<key>AAAAAA109</key>
<dict>
<key>isa</key>
<string>PBXFileReference</string>
<key>lastKnownFileType</key>
<string>sourcecode.swift</string>
<key>path</key>
<string>DiagnosticsView.swift</string>
<key>sourceTree</key>
<string>&lt;group&gt;</string>
</dict>
<key>AAAAAA101</key>
<dict>
<key>isa</key>
@@ -179,6 +197,7 @@
<string>AAAAAA106</string>
<string>AAAAAA107</string>
<string>AAAAAA108</string>
<string>AAAAAA109</string>
</array>
<key>path</key>
<string>ClaudeChecker</string>
@@ -292,6 +311,7 @@
<string>AAAAAA005</string>
<string>AAAAAA007</string>
<string>AAAAAA008</string>
<string>AAAAAA009</string>
</array>
<key>runOnlyForDeploymentPostprocessing</key>
<string>0</string>
+40 -10
View File
@@ -18,16 +18,43 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var updateManager = UpdateManager()
var refreshTimer: Timer?
var cookiePrimerView: WKWebView?
var cookiePrimerWindow: NSWindow?
var primerNavDelegate: PrimerNavDelegate?
var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Hidden WKWebView to prime the shared cookie store
// Background WebView that loads claude.ai on every launch.
// Anchored in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle
// JS execution. When the page finishes loading the nav delegate calls
// adoptPrimerWebView, which sets it as the API WebView and runs the first fetch.
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
let win = NSWindow(
contentRect: NSRect(x: -9999, y: -9999, width: 1, height: 1),
styleMask: [],
backing: .buffered,
defer: false
)
win.contentView = wv
win.alphaValue = 0
win.orderFront(nil)
cookiePrimerWindow = win
cookiePrimerView = wv
let delegate = PrimerNavDelegate { [weak self] in
guard let self, let wv = self.cookiePrimerView else { return }
Task { @MainActor [weak self] in
guard let self else { return }
await self.usageViewModel.adoptPrimerWebView(wv)
await self.updateManager.checkForUpdates()
}
}
primerNavDelegate = delegate
wv.navigationDelegate = delegate
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
// Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -134,12 +161,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// Initial fetch after cookie primer + update check
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
await usageViewModel.refresh()
await updateManager.checkForUpdates()
}
}
private func setMenubarIcon(button: NSStatusBarButton) {
@@ -240,4 +261,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
// One-shot WKNavigationDelegate: fires onDone on first finish or error, then goes silent.
final class PrimerNavDelegate: NSObject, WKNavigationDelegate {
private var done = false
private let onDone: () -> Void
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
private func finish() { guard !done else { return }; done = true; onDone() }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
}
+18
View File
@@ -584,6 +584,7 @@ struct SettingsView: View {
@Binding var showSettings: Bool
@Binding var showUpdateSheet: Bool
@State private var checkingForUpdates = false
@State private var showDiagnostics = false
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
("1 min", 60),
@@ -770,6 +771,23 @@ struct SettingsView: View {
Divider()
// Diagnostics
HStack {
Label("Diagnostics", systemImage: "stethoscope")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
Spacer()
Button("Open") { showDiagnostics = true }
.buttonStyle(.bordered)
.controlSize(.small)
}
.sheet(isPresented: $showDiagnostics) {
DiagnosticsView(isPresented: $showDiagnostics)
.environmentObject(vm)
}
Divider()
// About
HStack(spacing: 12) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
+219
View File
@@ -0,0 +1,219 @@
import SwiftUI
import WebKit
struct DiagnosticsView: View {
@EnvironmentObject var vm: UsageViewModel
@Binding var isPresented: Bool
@State private var liveAllCookies: [HTTPCookie] = []
@State private var loadingCookies = false
@State private var copied = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Diagnostics")
.font(.system(size: 13, weight: .semibold))
Spacer()
Button("Copy All") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fullDiagText, forType: .string)
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
.buttonStyle(.bordered)
.controlSize(.small)
if copied {
Text("Copied! (IDs redacted)")
.font(.system(size: 11))
.foregroundColor(.green)
}
Button("Close") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color(nsColor: .windowBackgroundColor))
Divider()
Text("Org IDs are redacted when copied.")
.font(.system(size: 10))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 6)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
// App info
DiagSection(title: "App") {
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
DiagRow("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
DiagRow("Error", vm.errorMessage ?? "(none)")
}
Divider()
// Last request
DiagSection(title: "Last Request") {
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
if let t = vm.diagLastFetch {
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
}
}
Divider()
// Live cookie list
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
if loadingCookies {
ProgressView().scaleEffect(0.7)
} else if liveAllCookies.isEmpty {
Text("No cookies found")
.font(.system(size: 11))
.foregroundColor(.secondary)
} else {
let claudeCookies = liveAllCookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
let otherCookies = liveAllCookies.filter {
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
}
if !claudeCookies.isEmpty {
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.orange)
ForEach(claudeCookies, id: \.name) { c in
Text("\(c.domain) \(c.name)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
}
}
if !otherCookies.isEmpty {
Text("Other domains (\(otherCookies.count))")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
.padding(.top, 4)
ForEach(otherCookies.prefix(10), id: \.name) { c in
Text("\(c.domain) \(c.name)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Color.secondary.opacity(0.6))
}
if otherCookies.count > 10 {
Text("… and \(otherCookies.count - 10) more")
.font(.system(size: 10))
.foregroundColor(Color.secondary.opacity(0.5))
}
}
}
Button("Refresh cookies") { loadLiveCookies() }
.buttonStyle(.bordered)
.controlSize(.small)
.padding(.top, 4)
}
}
.padding(14)
}
}
.frame(width: 480, height: 560)
.onAppear { loadLiveCookies() }
}
private func loadLiveCookies() {
loadingCookies = true
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
DispatchQueue.main.async {
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
loadingCookies = false
}
}
}
private var fullDiagText: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
let orgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)"
let lastOrg = vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg
let lastErr = vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError
var lines: [String] = []
lines.append("=== ClaudeChecker Diagnostics ===")
lines.append("Version: \(version) (\(build))")
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
lines.append("Org ID: \(orgId)")
lines.append("lastActiveOrg: \(lastOrg)")
lines.append("Error: \(vm.errorMessage ?? "(none)")")
lines.append("")
lines.append("Last path: \(vm.diagLastPath)")
lines.append("Last status: \(vm.diagLastStatus)")
lines.append("Last error: \(lastErr)")
if let t = vm.diagLastFetch {
lines.append("Time: \(t.formatted(date: .omitted, time: .standard))")
}
lines.append("")
lines.append("")
lines.append("Live cookies:")
for c in liveAllCookies {
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
}
return redactUUIDs(lines.joined(separator: "\n"))
}
private func redactUUIDs(_ text: String) -> String {
let pattern = "[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}"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: "****")
}
}
// MARK: - Helpers
private struct DiagSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
content()
}
}
}
private struct DiagRow: View {
let label: String
let value: String
init(_ label: String, _ value: String) {
self.label = label
self.value = value
}
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)
.frame(width: 100, alignment: .leading)
Text(value)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2.1-beta.17</string>
<string>1.3.3</string>
<key>CFBundleVersion</key>
<string>65</string>
<string>82</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
+3 -3
View File
@@ -30,8 +30,8 @@ enum UpdateError: LocalizedError {
@MainActor
class UpdateManager: ObservableObject {
static let versionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version.json"
static let betaVersionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-beta.json"
static let versionURL = "https://git.jamesbone.net/coder/claudechecker/raw/branch/main/version.json"
static let betaVersionURL = "https://git.jamesbone.net/coder/claudechecker/raw/branch/main/version-beta.json"
@Published var updateAvailable = false
@Published var latestVersion = ""
@@ -118,7 +118,7 @@ class UpdateManager: ObservableObject {
}
private func fetchVersion(from urlString: String) async -> VersionInfo? {
// Add timestamp to bust GitHub's 5-minute CDN cache
// Add timestamp to bust cached update metadata
let cacheBusted = urlString + "?t=\(Int(Date().timeIntervalSince1970))"
guard let url = URL(string: cacheBusted) else {
print("[UpdateManager] Invalid URL: \(urlString)")
+171 -113
View File
@@ -30,14 +30,18 @@ class UsageViewModel: ObservableObject {
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
// Background WKWebView for API calls via callAsyncJavaScript.
// Hosted in a hidden NSWindow without a window WebKit suspends the WebView
// and JS execution stops working, breaking callAsyncJavaScript.
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
private var apiWebView: WKWebView?
private var apiDelegate: APIWebViewDelegate?
private var apiWindow: NSWindow?
private var apiWebViewLoaded = false
private var apiReadyContinuations: [CheckedContinuation<Void, Never>] = []
// Diagnostics
@Published var diagCookieCount: Int = 0
@Published var diagClaudeCookieCount: Int = 0
@Published var diagCookieDomains: [String] = []
@Published var diagLastPath: String = ""
@Published var diagLastStatus: Int = 0
@Published var diagLastError: String = ""
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
@Published var diagLastFetch: Date? = nil
init() {
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
@@ -47,96 +51,136 @@ class UsageViewModel: ObservableObject {
burnHistoryStore = saved
}
loadPlaceholderData()
setupAPIWebView()
Task { await checkInitialSignInState() }
}
// MARK: - Background API WebView
private func setupAPIWebView() {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
let del = APIWebViewDelegate()
del.onNavigationEnd = { [weak self] in
guard let self else { return }
self.apiWebViewLoaded = true
self.resumeAPIReadyContinuations()
}
wv.navigationDelegate = del
// Called by AppDelegate once the primer WebView has loaded claude.ai.
// Sets it as the API WebView and triggers the first data fetch.
func adoptPrimerWebView(_ wv: WKWebView) async {
apiWebView = wv
apiDelegate = del
// A WKWebView with no window is suspended by macOS JS execution won't run.
// Hosting it in a 1×1 transparent window keeps WebKit's process alive.
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
styleMask: .borderless,
backing: .buffered,
defer: false)
window.alphaValue = 0.0
window.ignoresMouseEvents = true
window.isReleasedWhenClosed = false
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.contentView?.addSubview(wv)
window.orderFrontRegardless()
apiWindow = window
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
private func resumeAPIReadyContinuations() {
let pending = apiReadyContinuations
apiReadyContinuations.removeAll()
pending.forEach { $0.resume() }
}
private func waitForAPIWebViewReady() async {
guard !apiWebViewLoaded else { return }
await withCheckedContinuation { cont in
apiReadyContinuations.append(cont)
}
}
// Called after login: adopts the proven-authenticated login WebView for all API calls.
// This avoids the background WebView potentially missing auth tokens that only exist
// in the login WebView's JS context (localStorage, Service Workers, etc.).
func adoptAndRefresh(_ loginWebView: WKWebView) async {
loginWebView.removeFromSuperview()
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
apiWindow?.contentView?.addSubview(loginWebView)
apiWebView = loginWebView
apiWebViewLoaded = true
resumeAPIReadyContinuations()
try? await Task.sleep(nanoseconds: 400_000_000)
await refresh()
}
// Executes a same-origin fetch inside the background WebView's page context.
// This includes all credentials the page has (cookies, localStorage tokens, etc.),
// which URLSession cannot access hence using callAsyncJavaScript instead.
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
guard let wv = apiWebView else { throw AppError.detail("no webview") }
await waitForAPIWebViewReady()
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};"
do {
let result = try await wv.callAsyncJavaScript(js, arguments: ["path": path], in: nil, in: .page)
guard let d = result as? [String: Any],
let s = (d["s"] as? NSNumber)?.intValue,
let b = d["b"] as? String else {
throw AppError.detail("bad result: \(String(describing: result).prefix(120))")
// Called after login: store the login WebView (already on claude.ai) so jsFetch can use it.
func adoptAndRefresh(_ loginWebView: WKWebView) async {
for _ in 0..<30 {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
try? await Task.sleep(nanoseconds: 300_000_000)
}
apiWebView = loginWebView
await refresh()
}
// Runs a fetch() inside the live claude.ai WebView same browser context as the frontend,
// so no CORS/fingerprinting issues that URLSession hits.
private func jsFetch(_ path: String) async throws -> (Int, String) {
guard let wv = apiWebView else { throw AppError.networkError }
let js = """
const r = await fetch('\(path)', {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
const body = await r.text();
return {status: r.status, body: body};
"""
let result: Any? = try await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
switch res {
case .success(let val): cont.resume(returning: val)
case .failure(let err): cont.resume(throwing: err)
}
}
return (s, b)
}
guard let dict = result as? [String: Any],
let status = dict["status"] as? Int,
let body = dict["body"] as? String else {
throw AppError.networkError
}
diagLastPath = path
diagLastFetch = Date()
diagLastStatus = status
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
return (status, body)
}
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
private func apiFetch(_ path: String) async throws -> (Int, String) {
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
return try await jsFetch(path)
}
return try await urlFetch(path)
}
// Fetches an API path via URLSession with browser-like headers.
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
// to avoid 401 matching how the Windows version (HttpClient) handles this.
private func urlFetch(_ path: String) async throws -> (Int, String) {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
// Record cookie diagnostics
diagCookieCount = cookies.count
diagClaudeCookieCount = claudeCookies.count
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
diagLastPath = path
diagLastFetch = Date()
diagLastError = ""
guard !claudeCookies.isEmpty else {
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
diagLastError = msg
throw AppError.detail(msg)
}
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
req.httpShouldHandleCookies = false
// Build cookie header manually (same format as Windows HttpClient) to avoid
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
req.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent")
req.setValue(
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
forHTTPHeaderField: "sec-ch-ua")
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
let session = URLSession(configuration: .ephemeral)
do {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
let body = String(data: data, encoding: .utf8) ?? ""
diagLastStatus = http.statusCode
if http.statusCode != 200 {
diagLastError = "HTTP \(http.statusCode)"
}
return (http.statusCode, body)
} catch let e as AppError { throw e }
catch {
let loc = wv.url?.absoluteString ?? "?"
throw AppError.detail("JS err @ \(loc): \(error.localizedDescription.prefix(100))")
let msg = error.localizedDescription
diagLastError = msg
throw AppError.detail(msg)
}
}
private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true }
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
isSignedIn = true
// AppDelegate's primer WebView will call adoptPrimerWebView once it loads,
// which triggers the first real data fetch.
}
func signOut() async {
@@ -146,6 +190,7 @@ class UsageViewModel: ObservableObject {
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
apiWebView = nil
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -154,8 +199,6 @@ class UsageViewModel: ObservableObject {
extraUsage = nil
prepaidCredits = nil
overageSpendLimit = nil
apiWebViewLoaded = false
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
func refresh() async {
@@ -165,10 +208,31 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false }
do {
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
// Read lastActiveOrg cookie from JS most reliable org source since it's
// set by the claude.ai frontend to whichever org is currently active.
var cookieOrgId: String? = nil
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
let js = """
return document.cookie.split(';')
.map(c => c.trim().split('='))
.filter(p => p[0] === 'lastActiveOrg')
.map(p => p.slice(1).join('='))[0] || null;
"""
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
cont.resume(returning: (try? r.get()) ?? nil)
}
}
if let v = raw as? String, !v.isEmpty {
cookieOrgId = v
diagLastActiveOrg = v
}
}
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap(preferredOrgId: cookieOrgId)
let orgId: String
if let id = fetchedOrgId {
if let id = cookieOrgId ?? fetchedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
orgId = id
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
@@ -185,8 +249,8 @@ class UsageViewModel: ObservableObject {
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
limits = buildLimits(from: usage)
extraUsage = usage.extraUsage
prepaidCredits = prepaid
extraUsage = usage.extraUsage
prepaidCredits = prepaid
overageSpendLimit = overage
lastUpdated = Date()
isSignedIn = true
@@ -223,10 +287,15 @@ class UsageViewModel: ObservableObject {
// MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await webViewFetch("/api/bootstrap")
if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.networkError }
private func fetchBootstrap(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await apiFetch("/api/bootstrap")
if status == 401 || status == 403 {
throw AppError.detail("HTTP \(status)\(body.prefix(120))")
}
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
}
guard let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil)
@@ -234,14 +303,15 @@ class UsageViewModel: ObservableObject {
let account = json["account"] as? [String: Any]
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
let firstOrg = memberships?.first?["organization"] as? [String: Any]
let allOrgs = memberships?.compactMap { $0["organization"] as? [String: Any] } ?? []
let firstOrg = allOrgs.first
var orgId: String? = firstOrg?["uuid"] as? String
if orgId == nil {
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
}
if orgId == nil {
if let (orgsStatus, orgsBody) = try? await webViewFetch("/api/organizations"),
if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
orgsStatus == 200,
let orgsData = orgsBody.data(using: .utf8),
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
@@ -251,8 +321,10 @@ class UsageViewModel: ObservableObject {
let email = account?["email_address"] as? String
// Read capabilities from the preferred (active) org, falling back to first org.
let capOrg = preferredOrgId.flatMap { id in allOrgs.first(where: { $0["uuid"] as? String == id }) } ?? firstOrg
var planLabel: String? = nil
if let caps = firstOrg?["capabilities"] as? [String],
if let caps = capOrg?["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()
@@ -264,22 +336,22 @@ class UsageViewModel: ObservableObject {
// MARK: - Fetch usage
private func fetchUsage(orgId: String) async throws -> UsageResponse {
let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.notAuthenticated }
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data)
}
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
}
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
@@ -398,21 +470,7 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - API WebView Delegate
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
var onNavigationEnd: (() -> Void)?
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
onNavigationEnd?()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?()
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?()
}
}
enum AppError: LocalizedError {
case notAuthenticated
+33 -5
View File
@@ -1,16 +1,19 @@
<div align="center">
<img src="https://raw.githubusercontent.com/superdooper86/claudechecker/main/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png" width="128" height="128" alt="ClaudeChecker icon" />
<img src="https://git.jamesbone.net/coder/claudechecker/raw/branch/main/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png" width="128" height="128" alt="ClaudeChecker icon" />
# ClaudeChecker
**A native macOS menubar app for monitoring your Claude AI usage limits in real time.**
[![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![Windows](https://img.shields.io/badge/Windows-10%2B-0078D4?style=flat&logo=windows&logoColor=white)](https://www.microsoft.com/windows/)
[![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.2.0-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.2.1--beta.16-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.16) <!-- BETA_BADGE -->
<br/>
[![macOS Stable](https://img.shields.io/badge/macOS_Stable-1.3.2-orange?style=flat)](https://git.jamesbone.net/coder/claudechecker/releases)
[![macOS Beta](https://img.shields.io/badge/macOS_Beta-1.3.3--beta.5-orange?style=flat)](https://git.jamesbone.net/coder/claudechecker/releases/tag/v1.3.3-beta.5) <!-- BETA_BADGE -->
[![Win Beta](https://img.shields.io/badge/Win_Beta-0.0.1--beta.63-blue?style=flat)](https://git.jamesbone.net/coder/claudechecker/releases/tag/win-v0.0.1-beta.63) <!-- WIN_BETA_BADGE -->
</div>
@@ -127,7 +130,7 @@ brew install --cask claudechecker
```
### Download
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
1. Go to [Releases](https://git.jamesbone.net/coder/claudechecker/releases)
2. Download `ClaudeChecker.zip` from the latest release
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
4. Open from Applications — it will appear in your menubar
@@ -163,7 +166,7 @@ ClaudeChecker reads from the following `claude.ai` API endpoints (authenticated
## Privacy
- No data leaves your machine except to `claude.ai` (your own session) and `raw.githubusercontent.com` (version check)
- No data leaves your machine except to `claude.ai` (your own session) and `git.jamesbone.net` (version check)
- Session stored in macOS `WKWebsiteDataStore.default()` — same as Safari
- No analytics, no tracking, no ads
@@ -178,3 +181,28 @@ ClaudeChecker reads from the following `claude.ai` API endpoints (authenticated
<div align="center">
<sub>ClaudeChecker is not affiliated with or endorsed by Anthropic.</sub>
</div>
## Building and releasing
The canonical repository is https://git.jamesbone.net/coder/claudechecker.
Both macOS and Windows source live on `main`; the original Windows feature
branch and all historical tags/releases are retained.
Gitea **Build** validates every push to `main` on two shared account-scoped host runners (one service and one job slot per host):
`mac-mini` (label `claudechecker-macos-arm64`, Xcode) and `jamespc` (label `claudechecker-windows-x64`)
(Windows host, .NET 8, Inno Setup). Each run uploads its app/installer artifact.
Use **Release macOS** or **Release Windows**, select `main`, and supply a version
and channel. Stable versions use `X.Y.Z`; beta versions use `X.Y.Z-beta.N`.
The workflow builds that main revision, creates the release and uploads the asset
before advancing the corresponding `version*.json` feed on main. Releases are
never overwritten. macOS builds retain the existing ad-hoc signing model; this
project does not currently perform Developer ID signing or notarization.
Existing installs still check the old GitHub `version*.json` URLs. Those files
are kept as a migration bridge advertising the first Gitea-hosted update. Keep
the GitHub repository accessible for those clients; subsequent updates use Gitea.
GitHub workflows are disabled after the Gitea builds are verified.
Local build commands are `bash scripts/build-macos.sh` and, on Windows,
`./scripts/build-windows.ps1` with `INNO_SETUP_COMPILER` pointing to `ISCC.exe`.
+3 -13
View File
@@ -1,14 +1,4 @@
## What's new in v1.2.1-beta.17
## What's new in v1.3.3
### Bug fixes
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires
- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging
## What's new in v1.2.1
### Bug fixes
- Fixed the root cause of "Not signed in" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.
- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.
- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)
- Added `/api/organizations` as a final fallback for org ID resolution
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
- Updates and release downloads now come from git.jamesbone.net.
- macOS and Windows builds are managed by Gitea Actions from main.
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail
if [[ -n "${VERSION:-}" ]]; then
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]] || exit 2
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" ClaudeChecker/Info.plist
fi
xcodebuild -project ClaudeChecker.xcodeproj -scheme ClaudeChecker \
-configuration Release -derivedDataPath build \
ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO CODE_SIGN_IDENTITY=- clean build
codesign --verify --deep --strict build/Build/Products/Release/ClaudeChecker.app
for arch in arm64 x86_64; do
lipo build/Build/Products/Release/ClaudeChecker.app/Contents/MacOS/ClaudeChecker -verify_arch "$arch"
done
ditto -c -k --keepParent build/Build/Products/Release/ClaudeChecker.app ClaudeChecker.zip
+10
View File
@@ -0,0 +1,10 @@
param([string]$Version = '0.0.1-beta.64')
$ErrorActionPreference = 'Stop'
if ($Version -notmatch '^\d+\.\d+\.\d+(-beta\.\d+)?$') { throw 'Invalid version' }
dotnet publish windows/ClaudeCheckerWindows.csproj -c Release -r win-x64 --self-contained true "-p:Version=$Version" -o publish
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed' }
Copy-Item -Recurse -Force windows/Assets publish/Assets
& $env:INNO_SETUP_COMPILER "/DAppVersion=$Version" windows/installer.iss
if ($LASTEXITCODE -ne 0) { throw 'Inno Setup failed' }
if (!(Test-Path ClaudeChecker-Installer.exe)) { throw 'Installer missing' }
Get-FileHash ClaudeChecker-Installer.exe -Algorithm SHA256
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Publish a completed main build, then advance its update channel."""
import base64
import json
import os
from pathlib import Path
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def main():
version = os.environ['VERSION']
channel = os.environ['CHANNEL']
if channel not in ('stable', 'beta', 'windows-beta', 'windows-stable'):
raise ValueError('Unknown release channel')
if not re.fullmatch(r'\d+\.\d+\.\d+(-beta\.\d+)?', version):
raise ValueError('Invalid release version')
if ('beta' in channel) != ('-beta.' in version):
raise ValueError('Version must match release channel')
if os.environ.get('GITHUB_REF') != 'refs/heads/main':
raise ValueError('Releases must build from main')
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
windows = channel.startswith('windows-')
tag = ('win-v' if windows else 'v') + version
artifact = Path('ClaudeChecker-Installer.exe' if windows else 'ClaudeChecker.zip')
notes = Path('windows/RELEASE_NOTES.md' if windows else 'RELEASE_NOTES.md').read_text()
feed = {'stable': 'version.json', 'beta': 'version-beta.json',
'windows-beta': 'version-windows-beta.json', 'windows-stable': 'version-windows.json'}[channel]
server = os.environ['GITHUB_SERVER_URL'].rstrip('/')
repo = os.environ['GITHUB_REPOSITORY']
opener = urllib.request.build_opener(NoRedirect)
def api(path, method='GET', data=None, binary=False):
body = data if binary else (json.dumps(data).encode() if data is not None else None)
request = urllib.request.Request(f'{server}/api/v1/repos/{repo}{path}', data=body, method=method,
headers={'Authorization': 'token ' + os.environ['GITEA_TOKEN'],
'Content-Type': 'application/octet-stream' if binary else 'application/json'})
with opener.open(request, timeout=600) as response:
return json.load(response)
# Never replace an existing release or advertise a partially uploaded asset.
release = api('/releases', 'POST', {'tag_name': tag, 'target_commitish': revision,
'name': tag, 'body': notes + '\n\nBuilt from main: ' + revision,
'draft': True, 'prerelease': 'beta' in channel})
asset = api(f"/releases/{release['id']}/assets?name={urllib.parse.quote(artifact.name)}",
'POST', artifact.read_bytes(), binary=True)
if asset['size'] != artifact.stat().st_size:
raise ValueError('Uploaded asset size mismatch')
api(f"/releases/{release['id']}", 'PATCH', {'draft': False})
content = json.dumps({'version': version,
'url': f'https://git.jamesbone.net/{repo}/releases/download/{tag}/{artifact.name}',
'notes': notes}, indent=2) + '\n'
try:
current = api(f'/contents/{feed}?ref=main')
except urllib.error.HTTPError as error:
if error.code != 404:
raise
current = None
payload = {'branch': 'main', 'message': f'Update {channel} feed to {tag}',
'content': base64.b64encode(content.encode()).decode()}
if current:
payload['sha'] = current['sha']
api(f'/contents/{feed}', 'PUT' if current else 'POST', payload)
print(f'Published {tag} and {feed} from {revision}')
if __name__ == '__main__':
main()
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.2.1-beta.16",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.16/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
"version": "1.3.3-beta.5",
"url": "https://git.jamesbone.net/coder/claudechecker/releases/download/v1.3.3-beta.5/ClaudeChecker.zip",
"notes": "## What's new in v1.3.2\n\n### Reliability\n- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution."
}
+5 -5
View File
@@ -1,5 +1,5 @@
{
"version": "0.0.1-beta.49",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.36\r\n\r\n### Bug fixes\r\n- Settings no longer incorrectly shows \"Not signed in\" when limits are working\r\n- Session Diary now shows correctly after the first successful refresh\r\n- Extra Usage Credits section now restored from cache on startup\r\n- Plan name, overage, and prepaid credits are now cached and shown immediately on launch\r\n- App now loads cached state instantly on startup before the background refresh completes"
}
{
"version": "0.0.1-beta.63",
"url": "https://git.jamesbone.net/coder/claudechecker/releases/download/win-v0.0.1-beta.63/ClaudeChecker-Installer.exe",
"notes": "## What's new in beta.62\r\n\r\n### Installer\r\n- App now ships as a proper Windows installer (no more zip extract)\r\n- Installs to user AppData — no admin rights required\r\n- Auto-update now downloads and silently runs the new installer, then relaunches\r\n- Desktop shortcut creation is optional during install\r\n\r\n### Bug fixes\r\n- Fixed app crash on launch (startup DllNotFoundException caused by single-file publish; all DLLs now installed alongside the exe)\r\n- Fixed clicking Copy in Diagnostics crashing the app (CLIPBRD_E_CANT_OPEN)\r\n- Fixed Sign In — Done button now always clickable; session detection no longer depends on specific cookie names that may have changed\r\n\r\n### Diagnostics\r\n- Org IDs are now redacted when copying diagnostics text\r\n- Removed stale Cookie Store section (always showed 0)\r\n- Fixed missing fields in copied diagnostics output\r\n- Privacy note added below diagnostics header"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.2.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
"notes": "## What's new in v1.2.0\n\n### Bug fixes\n- App now works for all users — org ID is fetched dynamically from the API instead of being hardcoded\n- Plan name (e.g. Pro, Max) now updates correctly on every refresh\n\n### Improvements\n- Plan name is read from the API rather than hardcoded\n- Bootstrap API call consolidated — org ID, email, and plan name fetched in a single request per refresh\n- Main panel no longer scrolls — popover auto-sizes to fit content\n- Session Diary card redesigned — sample count and avg burn rate shown left/right above the sparkline, Claude header removed"
"version": "1.3.2",
"url": "https://git.jamesbone.net/coder/claudechecker/releases/download/v1.3.2/ClaudeChecker.zip",
"notes": "## What's new in v1.3.2\n\n### Reliability\n- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution."
}
+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"/>
</Grid>
</Border>
</Grid>
</Window>
+68
View File
@@ -0,0 +1,68 @@
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.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"));
await Dispatcher.InvokeAsync(() =>
{
StatusText.Text = signedIn && !uri.Contains("/login") && !uri.Contains("/signin")
? "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>
+750
View File
@@ -0,0 +1,750 @@
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)
{
try
{
// SetDataObject(data, false) skips Flush() — avoids CLIPBRD_E_CANT_OPEN crash
var diagText = BuildDiagText();
for (int i = 0; i < 5; i++)
{
try { Clipboard.SetDataObject(diagText, false); break; }
catch (Exception) { System.Threading.Thread.Sleep(100); }
}
CopyDiagButton.Content = "Copied! (IDs redacted)";
}
catch (Exception)
{
CopyDiagButton.Content = "Copy failed";
}
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 () =>
{
// Re-navigate background browser with fresh session, then refresh immediately
await Application.Current.Dispatcher.InvokeAsync(
() => App.BackgroundBrowser.ReloadAsync()).Task.Unwrap();
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.
+4
View File
@@ -0,0 +1,4 @@
## What's new in beta.64
- Updates and release downloads now come from git.jamesbone.net.
- Windows installers now build through Gitea Actions from main.
+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://git.jamesbone.net/coder/claudechecker/raw/branch/main/version-windows.json";
private const string BetaUrl = "https://git.jamesbone.net/coder/claudechecker/raw/branch/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; } = "/";
}
}
+159
View File
@@ -0,0 +1,159 @@
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;
}
// Re-navigate to claude.ai with the current session (call after sign-in).
public async Task ReloadAsync()
{
if (_wv.CoreWebView2 == null) { await InitAsync(); return; }
_readyForScript = false;
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");
await Task.WhenAny(navDone.Task, Task.Delay(15000));
_readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
}
// ── 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;
}
}
+41
View File
@@ -0,0 +1,41 @@
#ifndef AppVersion
#define AppVersion "0.0.1"
#endif
[Setup]
AppName=ClaudeChecker
AppVersion={#AppVersion}
AppPublisher=superdooper86
AppPublisherURL=https://git.jamesbone.net/coder/claudechecker
AppSupportURL=https://git.jamesbone.net/coder/claudechecker/issues
AppUpdatesURL=https://git.jamesbone.net/coder/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\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"
Name: "{userdesktop}\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