Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f644a958 | ||
|
|
03f151c5a5 | ||
|
|
0af8f2992b | ||
|
|
8cb6602b59 | ||
|
|
e24d9cfb4a | ||
|
|
de3f37bc49 | ||
|
|
0dcb784315 | ||
|
|
201a5c1242 | ||
|
|
edd6f26544 | ||
|
|
072f935497 | ||
|
|
e9af9d4dc5 | ||
|
|
375df6be98 | ||
|
|
46bdc855ad | ||
|
|
ab6c2a3e84 | ||
|
|
e219b47ab7 | ||
|
|
871dc3e5ba | ||
|
|
e0dd60b3c0 | ||
|
|
c7c98621cb | ||
|
|
40d01e51d5 | ||
|
|
0b7fce9934 | ||
|
|
e13d3d8ee8 | ||
|
|
a729a37efc | ||
|
|
c7e4c81b9a | ||
|
|
2313c4378a | ||
|
|
c51086c119 | ||
|
|
5840b34b3b | ||
|
|
5a141095e1 | ||
|
|
4b42a631be | ||
|
|
c6d2ed8261 | ||
|
|
747e26e5eb | ||
|
|
b9fd5a27ec | ||
|
|
60336cffa7 | ||
|
|
953c46077c | ||
|
|
3796139178 | ||
|
|
554eb80c0c | ||
|
|
a22bf3a309 | ||
|
|
eaab3eb539 | ||
|
|
323aa174da | ||
|
|
3803cf37f6 | ||
|
|
1977b51b74 | ||
|
|
64a3f15ac5 | ||
|
|
8fbd1c42c3 | ||
|
|
0bf19c3dc8 | ||
|
|
d56a714bb9 | ||
|
|
88f409971a | ||
|
|
9bac2540e8 | ||
|
|
792c5a26e9 | ||
|
|
421727bfb5 | ||
|
|
21b2905bcf | ||
|
|
38d6f2a582 | ||
|
|
7ea5554506 | ||
|
|
87c5992889 | ||
|
|
034e196214 | ||
|
|
2235a627b5 | ||
|
|
d6c138f1a2 | ||
|
|
432819639e | ||
|
|
52db469c13 | ||
|
|
40c86aaaf2 | ||
|
|
46b09934d7 | ||
|
|
a79a23ac24 | ||
|
|
3785629ee4 | ||
|
|
a338f4e5fe | ||
|
|
88e7d684c0 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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**
|
||||
|
||||
@@ -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="[](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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/build/
|
||||
/publish/
|
||||
/ClaudeChecker.zip
|
||||
/ClaudeChecker-Installer.exe
|
||||
/windows/bin/
|
||||
/windows/obj/
|
||||
__pycache__/
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct DiagnosticsView: View {
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
if copied {
|
||||
Text("Copied!")
|
||||
Text("Copied! (IDs redacted)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
@@ -38,6 +38,15 @@ struct DiagnosticsView: View {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -46,7 +55,6 @@ struct DiagnosticsView: View {
|
||||
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
|
||||
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
|
||||
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
||||
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
|
||||
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)")
|
||||
@@ -66,17 +74,6 @@ struct DiagnosticsView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// Cookie summary
|
||||
DiagSection(title: "Cookie Store") {
|
||||
DiagRow("Total cookies", "\(vm.diagCookieCount)")
|
||||
DiagRow("claude.ai cookies", "\(vm.diagClaudeCookieCount)")
|
||||
DiagRow("All domains", vm.diagCookieDomains.isEmpty
|
||||
? "(none — never fetched)"
|
||||
: vm.diagCookieDomains.joined(separator: ", "))
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Live cookie list
|
||||
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
|
||||
if loadingCookies {
|
||||
@@ -144,28 +141,39 @@ struct DiagnosticsView: View {
|
||||
}
|
||||
|
||||
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: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
|
||||
lines.append("Version: \(version) (\(build))")
|
||||
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
|
||||
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
|
||||
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
|
||||
lines.append("lastActiveOrg: \(vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)")
|
||||
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: \(vm.diagLastError)")
|
||||
lines.append("Total cookies: \(vm.diagCookieCount)")
|
||||
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
|
||||
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
|
||||
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 lines.joined(separator: "\n")
|
||||
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: "****")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.1</string>
|
||||
<string>1.3.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>75</string>
|
||||
<string>82</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -32,7 +32,6 @@ class UsageViewModel: ObservableObject {
|
||||
|
||||
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
||||
private var apiWebView: WKWebView?
|
||||
private var navWaiter: WKNavWaiter?
|
||||
|
||||
// Diagnostics
|
||||
@Published var diagCookieCount: Int = 0
|
||||
@@ -55,38 +54,24 @@ class UsageViewModel: ObservableObject {
|
||||
Task { await checkInitialSignInState() }
|
||||
}
|
||||
|
||||
// Called after login: store the webview (already on claude.ai) so jsFetch can use it.
|
||||
// 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
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if loginWebView.url?.host?.hasSuffix("claude.ai") == true {
|
||||
apiWebView = loginWebView
|
||||
} else {
|
||||
await prepareApiWebView()
|
||||
}
|
||||
apiWebView = loginWebView
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// Creates (or reuses) a background WKWebView navigated to claude.ai for JS fetch.
|
||||
private func prepareApiWebView() async {
|
||||
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true { return }
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
||||
apiWebView = wv
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||
let waiter = WKNavWaiter { cont.resume() }
|
||||
self.navWaiter = waiter
|
||||
wv.navigationDelegate = waiter
|
||||
wv.load(URLRequest(url: URL(string: "https://claude.ai/")!))
|
||||
}
|
||||
wv.navigationDelegate = nil
|
||||
navWaiter = nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -194,8 +179,8 @@ class UsageViewModel: ObservableObject {
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
||||
isSignedIn = true
|
||||
await prepareApiWebView()
|
||||
await refresh()
|
||||
// AppDelegate's primer WebView will call adoptPrimerWebView once it loads,
|
||||
// which triggers the first real data fetch.
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
@@ -486,16 +471,6 @@ class UsageViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
// Navigation delegate that resolves a continuation on first finish/error (idempotent).
|
||||
private class WKNavWaiter: 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() }
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
case notAuthenticated
|
||||
|
||||
@@ -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.**
|
||||
|
||||
[](https://www.apple.com/macos/)
|
||||
[](https://www.microsoft.com/windows/)
|
||||
[](https://swift.org)
|
||||
[](https://github.com/superdooper86/claudechecker/releases)
|
||||
[](LICENSE)
|
||||
<!-- BETA_BADGE -->
|
||||
<br/>
|
||||
[](https://git.jamesbone.net/coder/claudechecker/releases)
|
||||
[](https://git.jamesbone.net/coder/claudechecker/releases/tag/v1.3.3-beta.5) <!-- BETA_BADGE -->
|
||||
[](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
-3
@@ -1,4 +1,4 @@
|
||||
## What's new in v1.3.1
|
||||
## What's new in v1.3.3
|
||||
|
||||
### Privacy
|
||||
- Diagnostics panel no longer displays API response bodies, which could contain account and financial data. Status codes, error messages, cookie names, and request paths are still shown.
|
||||
- Updates and release downloads now come from git.jamesbone.net.
|
||||
- macOS and Windows builds are managed by Gitea Actions from main.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.2.1-beta.25",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.25/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.2.1-beta.18\n\n### Bug fixes\n- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.\n- API calls are now sequential to share a single WebView for all navigations\n- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser\n\n## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls\n- Added diagnostic error messages for JS errors and unexpected responses\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow\n- Fixed login window auto-closing before sign-in completes\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."
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.0/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.3.0\n\n### Multi-org support\n- App now uses the `lastActiveOrg` cookie to identify which organisation is active, matching exactly what the claude.ai frontend does. Previously the app always picked the first org returned by bootstrap, which is the personal \"Individual Org\" for users with multiple organisations — causing persistent 403 errors on all usage endpoints.\n- Plan label (e.g. **Max**, **Pro**, **Team**) now reads capabilities from the active org, not the first membership.\n\n### Reliable API access via WebView\n- All API calls now run through `WKWebView.callAsyncJavaScript`, making real same-origin browser requests instead of URLSession. Claude.ai's org-specific endpoints reject native HTTP clients (403) even with correct cookies and headers; running inside the browser context passes all server-side checks.\n- A background WebView is created at startup when session cookies are detected, so usage data loads immediately without requiring a manual sign-in.\n\n### Diagnostics\n- New **Diagnostics** panel in Settings shows cookie store, last API request/response, bootstrap body, and the `lastActiveOrg` cookie value — making it easy to report issues."
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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="▾" 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 (1–10 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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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; } = "/";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user