Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90eb7be69 | ||
|
|
2fc7e8f9eb | ||
|
|
877d59bb26 | ||
|
|
8a69e740ee | ||
|
|
d4f284d829 | ||
|
|
0ed83be9eb | ||
|
|
4cc29292f1 | ||
|
|
fca1a7eb60 | ||
|
|
013569ea8b | ||
|
|
9ef83c1a1e | ||
|
|
cabc2b6326 | ||
|
|
b01afc5be7 | ||
|
|
4b211a17ed | ||
|
|
de41ea9dc4 | ||
|
|
8e71671abb | ||
|
|
af36a817fb | ||
|
|
e908d71eb6 | ||
|
|
ee6b1c4183 | ||
|
|
4377c9a735 | ||
|
|
1cf3f90fd6 | ||
|
|
3d8482cf21 | ||
|
|
44b7feb3c0 | ||
|
|
236990ca45 | ||
|
|
8d523cbb87 | ||
|
|
da270de6d6 | ||
|
|
f4bed1b585 | ||
|
|
ae7a7db37e | ||
|
|
dec6a6eb75 | ||
|
|
7a8350b578 | ||
|
|
222ee55189 | ||
|
|
ec7260bd0d | ||
|
|
6d34567d85 | ||
|
|
4395ae0594 | ||
|
|
4ff2337b4d | ||
|
|
5db9321ac4 | ||
|
|
8c14f59c42 | ||
|
|
a7dc3d553c | ||
|
|
264db5127d | ||
|
|
c71e63905b | ||
|
|
0fc124ac22 | ||
|
|
6858798b14 | ||
|
|
c3ad730677 | ||
|
|
0dcfcbfb6f | ||
|
|
31789df35c | ||
|
|
8e45241ddc | ||
|
|
78e9004624 | ||
|
|
2dcea91a63 | ||
|
|
d240b543eb | ||
|
|
1a35ec064c | ||
|
|
204691772c | ||
|
|
15e3f1dd29 | ||
|
|
bcd208684b | ||
|
|
2bdbe4943d | ||
|
|
1c365a55a8 | ||
|
|
8b367ee539 | ||
|
|
9134b8945d | ||
|
|
19b1751e78 | ||
|
|
a20956cde2 | ||
|
|
659171bf0a | ||
|
|
f5b3accc13 | ||
|
|
22e9096f7c | ||
|
|
6aee17fad2 | ||
|
|
b594889239 | ||
|
|
0300e4a72e | ||
|
|
7febb0384f | ||
|
|
55e1f754c5 | ||
|
|
03676a980f | ||
|
|
a74d0cece5 | ||
|
|
b5cd7b1bc9 | ||
|
|
3249dbd4aa | ||
|
|
189c513677 | ||
|
|
05e66b22d1 | ||
|
|
48e6f64c9a | ||
|
|
d55a92623c | ||
|
|
c70a090983 | ||
|
|
cc0270326e | ||
|
|
7601452f93 | ||
|
|
5a78a021cb | ||
|
|
7d465c13d8 | ||
|
|
ca67039e3e | ||
|
|
1e4cdc09b3 | ||
|
|
4e154bcaa7 | ||
|
|
ea784384c1 | ||
|
|
4e813995c9 | ||
|
|
0ffd690098 | ||
|
|
c083256882 | ||
|
|
fd94c96d83 | ||
|
|
d22eb7381c | ||
|
|
dd59f4565c | ||
|
|
f548210387 | ||
|
|
89659322ac | ||
|
|
bfa476a276 | ||
|
|
7f7024d976 | ||
|
|
f6dc91f38c | ||
|
|
721075d969 | ||
|
|
87ce720a31 | ||
|
|
1172fd8f4e | ||
|
|
11f097b969 | ||
|
|
6e992d56af | ||
|
|
49a0a8c9dd | ||
|
|
f63de02861 | ||
|
|
acabb0e101 | ||
|
|
4945097e8b | ||
|
|
2d478be0ad | ||
|
|
0096199334 | ||
|
|
01f28cf044 | ||
|
|
37939b84ac | ||
|
|
9c8e0dcdb6 | ||
|
|
0010051ede | ||
|
|
98bd736a66 | ||
|
|
251efdbb0b | ||
|
|
f219b65650 | ||
|
|
b0907d17b5 | ||
|
|
d3aa3dc6bd | ||
|
|
797bf702cd | ||
|
|
58a09e8701 | ||
|
|
01e16e3abe | ||
|
|
8e4b23d4de | ||
|
|
708b55d919 | ||
|
|
59c8c390b6 | ||
|
|
ad525ef8d1 | ||
|
|
e6fa66bc73 | ||
|
|
2d000bca5e | ||
|
|
c7a663c42b | ||
|
|
a182b7ca59 | ||
|
|
182acb1f21 | ||
|
|
3ff5067d49 | ||
|
|
2816a5cedb | ||
|
|
a4a4d70530 | ||
|
|
073d3b14ef | ||
|
|
5603b94f8a | ||
|
|
28a449b060 | ||
|
|
d2ba7365b0 | ||
|
|
93862686b6 | ||
|
|
8170f3a1b3 | ||
|
|
5fbdaab2d0 | ||
|
|
7db17fb53d | ||
|
|
f6f1982390 | ||
|
|
de61499bab | ||
|
|
6e95221e0b | ||
|
|
12ce671205 | ||
|
|
7a74e19a20 | ||
|
|
4c1ed861ed | ||
|
|
a1df7cd38a | ||
|
|
311385a716 | ||
|
|
3abe5e4c9b | ||
|
|
5457e00ef0 | ||
|
|
00bd5884db | ||
|
|
a3ce649946 | ||
|
|
c97943bfa0 | ||
|
|
e89df508bd | ||
|
|
de247e545b | ||
|
|
4f481a06c3 | ||
|
|
c223bfba72 | ||
|
|
b388191191 | ||
|
|
4896d28c8c | ||
|
|
0e248dc331 | ||
|
|
2277a29a3e | ||
|
|
605b11e849 | ||
|
|
bb4c28c751 | ||
|
|
e0c73211a0 | ||
|
|
ceb5951f24 | ||
|
|
c0ed68d023 | ||
|
|
f3d8a50dc6 |
@@ -0,0 +1,77 @@
|
||||
name: Release Windows Beta
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'win-v*-beta*'
|
||||
|
||||
jobs:
|
||||
release-windows-beta:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: echo "version=${GITHUB_REF_NAME#win-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
dotnet publish windows/ClaudeCheckerWindows.csproj `
|
||||
-c Release -r win-x64 `
|
||||
--self-contained true `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:Version=${{ steps.version.outputs.version }} `
|
||||
-o publish
|
||||
|
||||
- name: Zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
Copy-Item -Recurse windows/Assets publish/Assets
|
||||
Compress-Archive -Path publish/* -DestinationPath ClaudeChecker-Windows.zip
|
||||
|
||||
- name: Create GitHub pre-release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
|
||||
gh release create "$GITHUB_REF_NAME" ClaudeChecker-Windows.zip \
|
||||
--title "$GITHUB_REF_NAME" \
|
||||
--prerelease \
|
||||
--notes "$notes"
|
||||
|
||||
- name: Update version-windows-beta.json on main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
|
||||
content=$(jq -n \
|
||||
--arg version "${{ steps.version.outputs.version }}" \
|
||||
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Windows.zip" \
|
||||
--arg notes "$notes" \
|
||||
'{version: $version, url: $url, notes: $notes}' | base64 -w0)
|
||||
sha=$(gh api "repos/${{ github.repository }}/contents/version-windows-beta.json?ref=main" --jq .sha 2>/dev/null || echo "")
|
||||
if [ -n "$sha" ]; then
|
||||
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows-beta.json" \
|
||||
--field "message=Windows beta release ${{ github.ref_name }}" \
|
||||
--field "content=$content" \
|
||||
--field "branch=main" \
|
||||
--field "sha=$sha"
|
||||
else
|
||||
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows-beta.json" \
|
||||
--field "message=Windows beta release ${{ github.ref_name }}" \
|
||||
--field "content=$content" \
|
||||
--field "branch=main"
|
||||
fi
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Release Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'win-v[0-9]*'
|
||||
- '!win-v*-*'
|
||||
|
||||
jobs:
|
||||
release-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: echo "version=${GITHUB_REF_NAME#win-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
dotnet publish windows/ClaudeCheckerWindows.csproj `
|
||||
-c Release -r win-x64 `
|
||||
--self-contained true `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:Version=${{ steps.version.outputs.version }} `
|
||||
-o publish
|
||||
|
||||
- name: Zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
Copy-Item -Recurse windows/Assets publish/Assets
|
||||
Compress-Archive -Path publish/* -DestinationPath ClaudeChecker-Windows.zip
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
|
||||
gh release create "$GITHUB_REF_NAME" ClaudeChecker-Windows.zip \
|
||||
--title "$GITHUB_REF_NAME" \
|
||||
--notes "$notes"
|
||||
|
||||
- name: Update version-windows.json on main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
|
||||
content=$(jq -n \
|
||||
--arg version "${{ steps.version.outputs.version }}" \
|
||||
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Windows.zip" \
|
||||
--arg notes "$notes" \
|
||||
'{version: $version, url: $url, notes: $notes}' | base64 -w0)
|
||||
sha=$(gh api "repos/${{ github.repository }}/contents/version-windows.json?ref=main" --jq .sha 2>/dev/null || echo "")
|
||||
if [ -n "$sha" ]; then
|
||||
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows.json" \
|
||||
--field "message=Windows release ${{ github.ref_name }}" \
|
||||
--field "content=$content" \
|
||||
--field "branch=main" \
|
||||
--field "sha=$sha"
|
||||
else
|
||||
gh api --method PUT "repos/${{ github.repository }}/contents/version-windows.json" \
|
||||
--field "message=Windows release ${{ github.ref_name }}" \
|
||||
--field "content=$content" \
|
||||
--field "branch=main"
|
||||
fi
|
||||
@@ -10,13 +10,6 @@
|
||||
<string>77</string>
|
||||
<key>objects</key>
|
||||
<dict>
|
||||
<key>AAAAAA009</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
<string>PBXBuildFile</string>
|
||||
<key>fileRef</key>
|
||||
<string>AAAAAA109</string>
|
||||
</dict>
|
||||
<key>AAAAAA007</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
@@ -95,17 +88,6 @@
|
||||
<key>sourceTree</key>
|
||||
<string><group></string>
|
||||
</dict>
|
||||
<key>AAAAAA109</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
<string>PBXFileReference</string>
|
||||
<key>lastKnownFileType</key>
|
||||
<string>sourcecode.swift</string>
|
||||
<key>path</key>
|
||||
<string>DiagnosticsView.swift</string>
|
||||
<key>sourceTree</key>
|
||||
<string><group></string>
|
||||
</dict>
|
||||
<key>AAAAAA101</key>
|
||||
<dict>
|
||||
<key>isa</key>
|
||||
@@ -197,7 +179,6 @@
|
||||
<string>AAAAAA106</string>
|
||||
<string>AAAAAA107</string>
|
||||
<string>AAAAAA108</string>
|
||||
<string>AAAAAA109</string>
|
||||
</array>
|
||||
<key>path</key>
|
||||
<string>ClaudeChecker</string>
|
||||
@@ -311,7 +292,6 @@
|
||||
<string>AAAAAA005</string>
|
||||
<string>AAAAAA007</string>
|
||||
<string>AAAAAA008</string>
|
||||
<string>AAAAAA009</string>
|
||||
</array>
|
||||
<key>runOnlyForDeploymentPostprocessing</key>
|
||||
<string>0</string>
|
||||
|
||||
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
||||
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
|
||||
|
||||
// Status item
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
|
||||
@@ -40,7 +40,8 @@ struct ContentView: View {
|
||||
.environmentObject(updater)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
// Cards grid
|
||||
if vm.limits.isEmpty {
|
||||
EmptyStateView()
|
||||
@@ -95,6 +96,7 @@ struct ContentView: View {
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
|
||||
@@ -116,8 +118,8 @@ struct ContentView: View {
|
||||
showUpdateSheet = true
|
||||
}
|
||||
.sheet(isPresented: $showLogin) {
|
||||
LoginSheetView(isPresented: $showLogin) { webView in
|
||||
Task { await vm.adoptAndRefresh(webView) }
|
||||
LoginSheetView(isPresented: $showLogin) {
|
||||
Task { await vm.refresh() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showUpdateSheet) {
|
||||
@@ -396,11 +398,12 @@ struct DiaryRow: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text("\(totalSaved) samples")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundColor(.secondary)
|
||||
AgentIconView(agent: .claude, size: 12)
|
||||
Text("Claude")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
Spacer()
|
||||
Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
|
||||
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -584,7 +587,6 @@ struct SettingsView: View {
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var showUpdateSheet: Bool
|
||||
@State private var checkingForUpdates = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
||||
("1 min", 60),
|
||||
@@ -771,23 +773,6 @@ struct SettingsView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// Diagnostics
|
||||
HStack {
|
||||
Label("Diagnostics", systemImage: "stethoscope")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Button("Open") { showDiagnostics = true }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.sheet(isPresented: $showDiagnostics) {
|
||||
DiagnosticsView(isPresented: $showDiagnostics)
|
||||
.environmentObject(vm)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// About
|
||||
HStack(spacing: 12) {
|
||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||
@@ -929,12 +914,12 @@ struct ErrorBanner: View {
|
||||
struct EmptyStateView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||
Image(systemName: "key.slash")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Not signed in")
|
||||
Text("No API key configured")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Text("Sign in to claude.ai to see your usage.")
|
||||
Text("Open Settings to add your Anthropic API key.")
|
||||
.font(.system(size: 11.5))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct DiagnosticsView: View {
|
||||
@EnvironmentObject var vm: UsageViewModel
|
||||
@Binding var isPresented: Bool
|
||||
@State private var liveAllCookies: [HTTPCookie] = []
|
||||
@State private var loadingCookies = false
|
||||
@State private var copied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Diagnostics")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer()
|
||||
Button("Copy All") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(fullDiagText, forType: .string)
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
if copied {
|
||||
Text("Copied!")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
Button("Close") { isPresented = false }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
|
||||
// App info
|
||||
DiagSection(title: "App") {
|
||||
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
|
||||
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
|
||||
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
||||
DiagRow("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)")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Last request
|
||||
DiagSection(title: "Last Request") {
|
||||
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
|
||||
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
|
||||
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
|
||||
if let t = vm.diagLastFetch {
|
||||
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// 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 {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
} else if liveAllCookies.isEmpty {
|
||||
Text("No cookies found")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
let claudeCookies = liveAllCookies.filter {
|
||||
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||
}
|
||||
let otherCookies = liveAllCookies.filter {
|
||||
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
|
||||
}
|
||||
if !claudeCookies.isEmpty {
|
||||
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.orange)
|
||||
ForEach(claudeCookies, id: \.name) { c in
|
||||
Text("• \(c.domain) \(c.name)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if !otherCookies.isEmpty {
|
||||
Text("Other domains (\(otherCookies.count))")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 4)
|
||||
ForEach(otherCookies.prefix(10), id: \.name) { c in
|
||||
Text("• \(c.domain) \(c.name)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(Color.secondary.opacity(0.6))
|
||||
}
|
||||
if otherCookies.count > 10 {
|
||||
Text("… and \(otherCookies.count - 10) more")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color.secondary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Refresh cookies") { loadLiveCookies() }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.frame(width: 480, height: 560)
|
||||
.onAppear { loadLiveCookies() }
|
||||
}
|
||||
|
||||
private func loadLiveCookies() {
|
||||
loadingCookies = true
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
DispatchQueue.main.async {
|
||||
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
|
||||
loadingCookies = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fullDiagText: String {
|
||||
var lines: [String] = []
|
||||
lines.append("=== ClaudeChecker Diagnostics ===")
|
||||
lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
|
||||
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("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("")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private struct DiagSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.textCase(.uppercase)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiagRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
init(_ label: String, _ value: String) {
|
||||
self.label = label
|
||||
self.value = value
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 100, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.1</string>
|
||||
<string>1.1.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>75</string>
|
||||
<string>40</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
@@ -45,3 +45,7 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import WebKit
|
||||
// MARK: - Login Web View
|
||||
|
||||
struct LoginWebView: NSViewRepresentable {
|
||||
let onAuthenticated: (WKWebView) -> Void
|
||||
let onAuthenticated: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
@@ -12,13 +12,6 @@ struct LoginWebView: NSViewRepresentable {
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.webView = webView
|
||||
|
||||
// KVO on url catches SPA pushState navigations that don't fire didFinish
|
||||
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
|
||||
coordinator?.checkCurrentURL(wv.url?.absoluteString)
|
||||
}
|
||||
|
||||
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||
return webView
|
||||
}
|
||||
@@ -30,30 +23,30 @@ struct LoginWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
let onAuthenticated: (WKWebView) -> Void
|
||||
weak var webView: WKWebView?
|
||||
let onAuthenticated: () -> Void
|
||||
var didAuthenticate = false
|
||||
var urlObservation: NSKeyValueObservation?
|
||||
|
||||
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
||||
init(onAuthenticated: @escaping () -> Void) {
|
||||
self.onAuthenticated = onAuthenticated
|
||||
}
|
||||
|
||||
func checkCurrentURL(_ url: String?) {
|
||||
guard !didAuthenticate, let url, let wv = webView else { return }
|
||||
// Ignore navigations to external OAuth providers (Google, etc.) —
|
||||
// only consider auth complete when we land back on claude.ai/anthropic.com
|
||||
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
|
||||
if url.contains("/login") || url.contains("/auth") { return }
|
||||
didAuthenticate = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.onAuthenticated(wv)
|
||||
}
|
||||
}
|
||||
|
||||
// Covers full cross-document navigations
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
checkCurrentURL(webView.url?.absoluteString)
|
||||
guard !didAuthenticate else { return }
|
||||
// Don't fire on the login/auth pages themselves
|
||||
if let url = webView.url?.absoluteString,
|
||||
url.contains("/login") || url.contains("/auth") { return }
|
||||
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
let hasSession = cookies.contains {
|
||||
$0.domain.contains("claude.ai") &&
|
||||
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
||||
}
|
||||
guard hasSession, !self.didAuthenticate else { return }
|
||||
self.didAuthenticate = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.onAuthenticated()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +55,7 @@ struct LoginWebView: NSViewRepresentable {
|
||||
|
||||
struct LoginSheetView: View {
|
||||
@Binding var isPresented: Bool
|
||||
let onDone: (WKWebView) -> Void
|
||||
let onDone: () -> Void
|
||||
@State private var authenticated = false
|
||||
|
||||
var body: some View {
|
||||
@@ -72,9 +65,12 @@ struct LoginSheetView: View {
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Spacer()
|
||||
if authenticated {
|
||||
Button("Done") { isPresented = false }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
onDone()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Cancel") { isPresented = false }
|
||||
.buttonStyle(.bordered)
|
||||
@@ -87,13 +83,12 @@ struct LoginSheetView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
LoginWebView { webView in
|
||||
LoginWebView {
|
||||
authenticated = true
|
||||
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
||||
// then auto-dismiss after a moment so the user sees confirmation.
|
||||
onDone(webView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
||||
// Auto-dismiss and refresh after brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
isPresented = false
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import WebKit
|
||||
|
||||
@MainActor
|
||||
class UsageViewModel: ObservableObject {
|
||||
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
|
||||
@Published var limits: [AgentLimit] = []
|
||||
@Published var isLoading = false
|
||||
@Published var lastUpdated: Date?
|
||||
@@ -27,24 +28,12 @@ class UsageViewModel: ObservableObject {
|
||||
|
||||
private var burnHistoryStore: [String: [Double]] = [:]
|
||||
private let maxHistorySamples = 24
|
||||
private var cachedOrgId: String?
|
||||
private var previousPercents: [String: Double] = [:]
|
||||
private var firedThresholds: [String: Set<Int>] = [:]
|
||||
|
||||
// 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
|
||||
@Published var diagClaudeCookieCount: Int = 0
|
||||
@Published var diagCookieDomains: [String] = []
|
||||
@Published var diagLastPath: String = ""
|
||||
@Published var diagLastStatus: Int = 0
|
||||
@Published var diagLastError: String = ""
|
||||
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
|
||||
@Published var diagLastFetch: Date? = nil
|
||||
|
||||
init() {
|
||||
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||
refreshInterval = saved > 0 ? saved : 60
|
||||
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||
@@ -52,160 +41,14 @@ class UsageViewModel: ObservableObject {
|
||||
burnHistoryStore = saved
|
||||
}
|
||||
loadPlaceholderData()
|
||||
Task { await checkInitialSignInState() }
|
||||
}
|
||||
|
||||
// Called after login: store the 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()
|
||||
}
|
||||
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) {
|
||||
guard let wv = apiWebView else { throw AppError.networkError }
|
||||
let js = """
|
||||
const r = await fetch('\(path)', {
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
const body = await r.text();
|
||||
return {status: r.status, body: body};
|
||||
"""
|
||||
let result: Any? = try await withCheckedThrowingContinuation { cont in
|
||||
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
|
||||
switch res {
|
||||
case .success(let val): cont.resume(returning: val)
|
||||
case .failure(let err): cont.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let dict = result as? [String: Any],
|
||||
let status = dict["status"] as? Int,
|
||||
let body = dict["body"] as? String else {
|
||||
throw AppError.networkError
|
||||
}
|
||||
diagLastPath = path
|
||||
diagLastFetch = Date()
|
||||
diagLastStatus = status
|
||||
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
|
||||
return (status, body)
|
||||
}
|
||||
|
||||
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
|
||||
private func apiFetch(_ path: String) async throws -> (Int, String) {
|
||||
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||
return try await jsFetch(path)
|
||||
}
|
||||
return try await urlFetch(path)
|
||||
}
|
||||
|
||||
// Fetches an API path via URLSession with browser-like headers.
|
||||
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
||||
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
||||
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
let claudeCookies = cookies.filter {
|
||||
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
||||
}
|
||||
|
||||
// Record cookie diagnostics
|
||||
diagCookieCount = cookies.count
|
||||
diagClaudeCookieCount = claudeCookies.count
|
||||
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
|
||||
diagLastPath = path
|
||||
diagLastFetch = Date()
|
||||
diagLastError = ""
|
||||
|
||||
guard !claudeCookies.isEmpty else {
|
||||
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
|
||||
diagLastError = msg
|
||||
throw AppError.detail(msg)
|
||||
}
|
||||
|
||||
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
|
||||
req.httpShouldHandleCookies = false
|
||||
// Build cookie header manually (same format as Windows HttpClient) to avoid
|
||||
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
|
||||
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
|
||||
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
||||
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
||||
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
||||
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
||||
req.setValue(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
forHTTPHeaderField: "User-Agent")
|
||||
req.setValue(
|
||||
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
|
||||
forHTTPHeaderField: "sec-ch-ua")
|
||||
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
|
||||
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
|
||||
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
|
||||
|
||||
let session = URLSession(configuration: .ephemeral)
|
||||
do {
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
diagLastStatus = http.statusCode
|
||||
if http.statusCode != 200 {
|
||||
diagLastError = "HTTP \(http.statusCode)"
|
||||
}
|
||||
return (http.statusCode, body)
|
||||
} catch let e as AppError { throw e }
|
||||
catch {
|
||||
let msg = error.localizedDescription
|
||||
diagLastError = msg
|
||||
throw AppError.detail(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkInitialSignInState() async {
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
||||
isSignedIn = true
|
||||
await prepareApiWebView()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
let store = WKWebsiteDataStore.default()
|
||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||
let records = await store.dataRecords(ofTypes: types)
|
||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
|
||||
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
||||
apiWebView = nil
|
||||
isSignedIn = false
|
||||
isNotAuthenticated = true
|
||||
lastUpdated = nil
|
||||
@@ -223,50 +66,18 @@ class UsageViewModel: ObservableObject {
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
// Read lastActiveOrg cookie from JS — most reliable org source since it's
|
||||
// set by the claude.ai frontend to whichever org is currently active.
|
||||
var cookieOrgId: String? = nil
|
||||
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
||||
let js = """
|
||||
return document.cookie.split(';')
|
||||
.map(c => c.trim().split('='))
|
||||
.filter(p => p[0] === 'lastActiveOrg')
|
||||
.map(p => p.slice(1).join('='))[0] || null;
|
||||
"""
|
||||
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
|
||||
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
|
||||
cont.resume(returning: (try? r.get()) ?? nil)
|
||||
}
|
||||
}
|
||||
if let v = raw as? String, !v.isEmpty {
|
||||
cookieOrgId = v
|
||||
diagLastActiveOrg = v
|
||||
}
|
||||
}
|
||||
|
||||
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap(preferredOrgId: cookieOrgId)
|
||||
|
||||
let orgId: String
|
||||
if let id = cookieOrgId ?? fetchedOrgId {
|
||||
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
||||
orgId = id
|
||||
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
||||
orgId = cached
|
||||
} else {
|
||||
throw AppError.notAuthenticated
|
||||
}
|
||||
|
||||
if let email = fetchedEmail { userEmail = email }
|
||||
if let plan = fetchedPlan { planLabel = plan }
|
||||
|
||||
async let usageFetch = fetchUsage(orgId: orgId)
|
||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
||||
let orgId = cachedOrgId!
|
||||
async let usageFetch = fetchUsage(orgId: orgId)
|
||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||
async let emailFetch = fetchUserEmail()
|
||||
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
||||
if let email { userEmail = email }
|
||||
limits = buildLimits(from: usage)
|
||||
extraUsage = usage.extraUsage
|
||||
prepaidCredits = prepaid
|
||||
extraUsage = usage.extraUsage
|
||||
prepaidCredits = prepaid
|
||||
overageSpendLimit = overage
|
||||
planLabel = "Pro"
|
||||
lastUpdated = Date()
|
||||
isSignedIn = true
|
||||
|
||||
@@ -282,7 +93,6 @@ class UsageViewModel: ObservableObject {
|
||||
checkLimitNotifications(for: limits)
|
||||
} catch AppError.notAuthenticated {
|
||||
isNotAuthenticated = true
|
||||
isSignedIn = false
|
||||
errorMessage = "Not signed in"
|
||||
} catch let error as DecodingError {
|
||||
switch error {
|
||||
@@ -300,75 +110,70 @@ class UsageViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
private func fetchBootstrap(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||
let (status, body) = try await apiFetch("/api/bootstrap")
|
||||
if status == 401 || status == 403 {
|
||||
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
||||
}
|
||||
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
|
||||
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
||||
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
||||
}
|
||||
guard let data = body.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return (nil, nil, nil)
|
||||
}
|
||||
|
||||
let account = json["account"] as? [String: Any]
|
||||
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
||||
let allOrgs = memberships?.compactMap { $0["organization"] as? [String: Any] } ?? []
|
||||
let firstOrg = allOrgs.first
|
||||
|
||||
var orgId: String? = firstOrg?["uuid"] as? String
|
||||
if orgId == nil {
|
||||
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
||||
}
|
||||
if orgId == nil {
|
||||
if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
|
||||
orgsStatus == 200,
|
||||
let orgsData = orgsBody.data(using: .utf8),
|
||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
||||
orgId = orgs.first?["uuid"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
let email = account?["email_address"] as? String
|
||||
|
||||
// Read capabilities from the preferred (active) org, falling back to first org.
|
||||
let capOrg = preferredOrgId.flatMap { id in allOrgs.first(where: { $0["uuid"] as? String == id }) } ?? firstOrg
|
||||
var planLabel: String? = nil
|
||||
if let caps = capOrg?["capabilities"] as? [String],
|
||||
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
||||
let name = String(cap.dropFirst("claude_".count))
|
||||
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
||||
}
|
||||
|
||||
return (orgId, email, planLabel)
|
||||
}
|
||||
|
||||
// MARK: - Fetch usage
|
||||
|
||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
|
||||
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
||||
guard status == 200 else { throw AppError.networkError }
|
||||
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
|
||||
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
||||
|
||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func fetchUserEmail() async throws -> String? {
|
||||
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let account = json["account"] as? [String: Any],
|
||||
let email = account["email_address"] as? String else { return nil }
|
||||
return email
|
||||
}
|
||||
|
||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||
status == 200,
|
||||
let data = body.data(using: .utf8) else { return nil }
|
||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||
}
|
||||
|
||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||
status == 200,
|
||||
let data = body.data(using: .utf8) else { return nil }
|
||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||
}
|
||||
|
||||
@@ -440,11 +245,14 @@ class UsageViewModel: ObservableObject {
|
||||
|
||||
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
||||
let thresholds = [80, 95]
|
||||
|
||||
for limit in limits {
|
||||
let key = limit.window.rawValue
|
||||
let curr = limit.usedPercent
|
||||
let prev = previousPercents[key]
|
||||
var fired = firedThresholds[key] ?? []
|
||||
|
||||
// Threshold warnings (80% and 95%)
|
||||
for t in thresholds {
|
||||
let threshold = Double(t)
|
||||
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
||||
@@ -455,6 +263,8 @@ class UsageViewModel: ObservableObject {
|
||||
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
||||
)
|
||||
}
|
||||
|
||||
// Reset detection: was high, now low
|
||||
if let prev, prev > 50, curr < 20 {
|
||||
fired.removeAll()
|
||||
NotificationCenter.default.post(
|
||||
@@ -463,6 +273,7 @@ class UsageViewModel: ObservableObject {
|
||||
userInfo: ["windowName": limit.window.displayName]
|
||||
)
|
||||
}
|
||||
|
||||
firedThresholds[key] = fired
|
||||
previousPercents[key] = curr
|
||||
}
|
||||
@@ -485,27 +296,13 @@ 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
|
||||
case networkError
|
||||
case detail(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||
case .networkError: return "Network error fetching usage data."
|
||||
case .detail(let msg): return msg
|
||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||
case .networkError: return "Network error fetching usage data."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,3 +317,4 @@ extension Notification.Name {
|
||||
static let limitReset = Notification.Name("limitReset")
|
||||
static let openUpdateSheet = Notification.Name("openUpdateSheet")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
[](https://www.apple.com/macos/)
|
||||
[](https://swift.org)
|
||||
[](https://github.com/superdooper86/claudechecker/releases)
|
||||
[](https://github.com/superdooper86/claudechecker/releases)
|
||||
[](LICENSE)
|
||||
<!-- BETA_BADGE -->
|
||||
|
||||
|
||||
+11
-3
@@ -1,4 +1,12 @@
|
||||
## What's new in v1.3.1
|
||||
## What's new in v1.1.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.
|
||||
### UI polish
|
||||
- Limit headers now read "5 Hour Limit" and "7 Day Limit"
|
||||
- Tapping anywhere on the blue update banner opens the update window
|
||||
|
||||
### Session Diary fix
|
||||
- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh
|
||||
|
||||
### Window sizing fixes
|
||||
- The popover now resizes correctly when switching between the main view and settings
|
||||
- Background colour no longer shows through during the resize animation
|
||||
+4
-4
@@ -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.1.3-beta.5",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3-beta.5/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\" instead of \"5hr\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches \u2014 the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
|
||||
}
|
||||
@@ -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.0",
|
||||
"url": "",
|
||||
"notes": ""
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "0.0.0",
|
||||
"url": "",
|
||||
"notes": ""
|
||||
}
|
||||
+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.1.3",
|
||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip",
|
||||
"notes": "## What's new in v1.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
|
||||
}
|
||||
|
||||
@@ -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" IsEnabled="False"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace ClaudeCheckerWindows;
|
||||
|
||||
public partial class LoginWindow : Window
|
||||
{
|
||||
private bool _closing;
|
||||
|
||||
public LoginWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += async (_, _) => await InitBrowserAsync();
|
||||
}
|
||||
|
||||
private async Task InitBrowserAsync()
|
||||
{
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
|
||||
System.IO.Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
|
||||
"ClaudeChecker", "WebView2"));
|
||||
|
||||
await Browser.EnsureCoreWebView2Async(env);
|
||||
Browser.CoreWebView2.Navigate("https://claude.ai");
|
||||
|
||||
Browser.CoreWebView2.NavigationCompleted += async (_, e) =>
|
||||
{
|
||||
if (!e.IsSuccess || _closing) return;
|
||||
var uri = Browser.CoreWebView2.Source;
|
||||
if (!uri.Contains("claude.ai")) return;
|
||||
|
||||
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
|
||||
var signedIn = System.Linq.Enumerable.Any(cookies, c =>
|
||||
c.Name.Equals("sessionKey", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Name.StartsWith("sk-ant", System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
await Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
DoneButton.IsEnabled = !uri.Contains("/login") && !uri.Contains("/signin");
|
||||
StatusText.Text = signedIn
|
||||
? "Signed in — click Done to continue."
|
||||
: "Complete sign-in, then click Done.";
|
||||
});
|
||||
|
||||
if (!uri.Contains("/login") && !uri.Contains("/signin") && signedIn)
|
||||
await SaveAndClose(cookies);
|
||||
};
|
||||
}
|
||||
|
||||
private async void Done_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_closing) return;
|
||||
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
|
||||
await SaveAndClose(cookies);
|
||||
}
|
||||
|
||||
private async Task SaveAndClose(IReadOnlyList<CoreWebView2Cookie> cookies)
|
||||
{
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
|
||||
// Persist cookies so the app knows the user is signed in on next startup.
|
||||
// All live data is fetched by the background WebView2 on the next refresh.
|
||||
UsageViewModel.SaveCookies(cookies);
|
||||
|
||||
await Dispatcher.InvokeAsync(() => DialogResult = true);
|
||||
}
|
||||
}
|
||||
@@ -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,292 @@
|
||||
<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>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="DiagContentPanel" Margin="16,4,16,16"/>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,737 @@
|
||||
using ClaudeCheckerWindows.Controls;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
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: "after reset" when 0%, "today HH:MM" when refreshed today
|
||||
string? refreshLabel = limit.UsedPercent == 0
|
||||
? "after reset"
|
||||
: VM.LastUpdated?.Date == DateTime.Today
|
||||
? "today " + VM.LastUpdated.Value.ToString("h:mm tt")
|
||||
: null;
|
||||
|
||||
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)
|
||||
{
|
||||
Clipboard.SetText(BuildDiagText());
|
||||
CopyDiagButton.Content = "Copied!";
|
||||
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("Email", string.IsNullOrEmpty(VM.UserEmail) ? "(none)" : VM.UserEmail),
|
||||
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);
|
||||
|
||||
// COOKIE STORE
|
||||
AddSection("COOKIE STORE",
|
||||
[
|
||||
MakeRow("Total cookies", VM.DiagCookieCount.ToString()),
|
||||
MakeRow("Claude cookies", VM.DiagClaudeCookieCount.ToString()),
|
||||
MakeRow("All domains", VM.DiagCookieDomains.Count == 0
|
||||
? "(none)" : string.Join(", ", VM.DiagCookieDomains)),
|
||||
]);
|
||||
|
||||
// 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($"Email: {(string.IsNullOrEmpty(VM.UserEmail) ? "(none)" : VM.UserEmail)}");
|
||||
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: {VM.DiagLastError}");
|
||||
if (VM.DiagLastFetch.HasValue)
|
||||
sb.AppendLine($"Last fetch: {VM.DiagLastFetch.Value:HH:mm:ss}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Total cookies: {VM.DiagCookieCount}");
|
||||
sb.AppendLine($"Claude cookies: {VM.DiagClaudeCookieCount}");
|
||||
sb.AppendLine($"Domains: {string.Join(", ", VM.DiagCookieDomains)}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Stored cookies:");
|
||||
foreach (var c in storedNames)
|
||||
sb.AppendLine($" {c.Domain} {c.Name}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Event handlers ───────────────────────────────────────────────
|
||||
|
||||
private void Settings_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
InitSettings();
|
||||
MainPanel.Visibility = Visibility.Collapsed;
|
||||
SettingsPanel.Visibility = Visibility.Visible;
|
||||
UpdatePanel.Visibility = Visibility.Collapsed;
|
||||
DiagnosticsPanel.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
|
||||
|
||||
private void ShowMain()
|
||||
{
|
||||
MainPanel.Visibility = Visibility.Visible;
|
||||
SettingsPanel.Visibility = Visibility.Collapsed;
|
||||
UpdatePanel.Visibility = Visibility.Collapsed;
|
||||
DiagnosticsPanel.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
|
||||
|
||||
private void UpdateBanner_Click(object s, RoutedEventArgs e) => ShowUpdatePanel();
|
||||
|
||||
private void ShowUpdatePanel()
|
||||
{
|
||||
CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
|
||||
NewVerLabel.Text = $"v{Updater.LatestVersion}";
|
||||
ReleaseNotesText.Text = Updater.ReleaseNotes;
|
||||
MainPanel.Visibility = Visibility.Collapsed;
|
||||
SettingsPanel.Visibility = Visibility.Collapsed;
|
||||
UpdatePanel.Visibility = Visibility.Visible;
|
||||
DiagnosticsPanel.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
|
||||
|
||||
private async void Install_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
NotNowButton.Visibility = Visibility.Collapsed;
|
||||
UpdateStatusText.Visibility = Visibility.Visible;
|
||||
UpdateProgress.Visibility = Visibility.Visible;
|
||||
await Updater.DownloadAndInstallAsync();
|
||||
}
|
||||
|
||||
private async void SignIn_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
var login = new LoginWindow { Owner = this };
|
||||
if (login.ShowDialog() == true)
|
||||
{
|
||||
await VM.LoadFromCacheAsync();
|
||||
InitSettings();
|
||||
ShowMain();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(3000);
|
||||
await VM.RefreshAsync();
|
||||
await Application.Current.Dispatcher.InvokeAsync(InitSettings);
|
||||
});
|
||||
}
|
||||
Show();
|
||||
Activate();
|
||||
}
|
||||
|
||||
private async void SignOut_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
await VM.SignOutAsync();
|
||||
InitSettings();
|
||||
}
|
||||
|
||||
private void BetaToggle_Changed(object s, RoutedEventArgs e)
|
||||
{
|
||||
Updater.BetaChannel = BetaToggle.IsChecked == true;
|
||||
_ = Updater.CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
private void RefreshPicker_Changed(object s, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (RefreshPicker.SelectedItem is ComboBoxItem item && item.Tag is int seconds)
|
||||
{
|
||||
VM.RefreshInterval = seconds;
|
||||
((App)Application.Current).ScheduleTimer(seconds);
|
||||
}
|
||||
}
|
||||
|
||||
private async void CheckUpdate_Click(object s, RoutedEventArgs e)
|
||||
{
|
||||
if (Updater.UpdateAvailable) { ShowUpdatePanel(); return; }
|
||||
CheckUpdateButton.Content = "…";
|
||||
await Updater.CheckForUpdatesAsync();
|
||||
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
|
||||
}
|
||||
}
|
||||
@@ -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,23 @@
|
||||
## What's new in beta.49
|
||||
|
||||
### Architecture
|
||||
- All data (usage, plan, overage, prepaid credits) now fetched via the persistent background WebView2 — the same always-live session as the macOS app, so Cloudflare cookies never expire between refreshes
|
||||
- HttpClient path kept only as a brief startup fallback before the browser is ready
|
||||
- Settings file simplified: only stores session signal, burn history, and user preferences — no more cached API responses
|
||||
|
||||
### Bug fixes
|
||||
- Plan label (e.g. "Pro") now updates on every refresh, not just at login
|
||||
- Extra Usage Credits values now correct (were 100× too large)
|
||||
- Limit and balance now update live on every refresh
|
||||
- Refresh interval dropdown now shows "1 min", "2 min" etc. correctly
|
||||
- ComboBox no longer flashes bright blue when clicked
|
||||
- Buttons now show a visible pressed state
|
||||
- Footer "Updated X ago" counter now ticks every second; shows "X min, Y sec ago" past 60 s
|
||||
- Removed stray sparkline bars from the 5 h and 7 d limit cards
|
||||
- App window now shown on launch
|
||||
|
||||
### UI improvements
|
||||
- Gauge percentage larger, "used" label removed
|
||||
- Each limit card now shows an "after reset" or "today HH:MM" badge
|
||||
- Session Diary card redesigned to match macOS layout (stats row + sparkline, no Claude header)
|
||||
- Reset date moved inline with time remaining
|
||||
@@ -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,266 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
|
||||
namespace ClaudeCheckerWindows;
|
||||
|
||||
public class UpdateManager : INotifyPropertyChanged
|
||||
{
|
||||
private const string StableUrl = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-windows.json";
|
||||
private const string BetaUrl = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-windows-beta.json";
|
||||
|
||||
private bool _updateAvailable;
|
||||
private string _latestVersion = "";
|
||||
private string _releaseNotes = "";
|
||||
private string _downloadUrl = "";
|
||||
private bool _betaAvailable;
|
||||
private string _latestBetaVersion = "";
|
||||
private bool _isDownloading;
|
||||
private double _downloadProgress;
|
||||
private string _statusMessage = "";
|
||||
private string? _updateError;
|
||||
private bool _updateComplete;
|
||||
|
||||
public bool UpdateAvailable { get => _updateAvailable; set => Set(ref _updateAvailable, value); }
|
||||
public string LatestVersion { get => _latestVersion; set => Set(ref _latestVersion, value); }
|
||||
public string ReleaseNotes { get => _releaseNotes; set => Set(ref _releaseNotes, value); }
|
||||
public string DownloadUrl { get => _downloadUrl; set => Set(ref _downloadUrl, value); }
|
||||
public bool BetaAvailable { get => _betaAvailable; set => Set(ref _betaAvailable, value); }
|
||||
public string LatestBetaVersion { get => _latestBetaVersion; set => Set(ref _latestBetaVersion, value); }
|
||||
public bool IsDownloading { get => _isDownloading; set => Set(ref _isDownloading, value); }
|
||||
public double DownloadProgress { get => _downloadProgress; set => Set(ref _downloadProgress, value); }
|
||||
public string StatusMessage { get => _statusMessage; set => Set(ref _statusMessage, value); }
|
||||
public string? UpdateError { get => _updateError; set => Set(ref _updateError, value); }
|
||||
public bool UpdateComplete { get => _updateComplete; set => Set(ref _updateComplete, value); }
|
||||
|
||||
private bool _betaChannel;
|
||||
public bool BetaChannel
|
||||
{
|
||||
get => _betaChannel;
|
||||
set
|
||||
{
|
||||
Set(ref _betaChannel, value);
|
||||
AppSettings.Default.BetaChannel = value;
|
||||
AppSettings.Default.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public string CurrentVersion =>
|
||||
System.Reflection.Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion.Split('+')[0]
|
||||
?? "1.0.0";
|
||||
|
||||
private bool IsPreReleaseBuild => CurrentVersion.Contains('-');
|
||||
|
||||
public UpdateManager()
|
||||
{
|
||||
_betaChannel = AppSettings.Default.BetaChannel;
|
||||
}
|
||||
|
||||
public async Task CheckForUpdatesAsync()
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
http.DefaultRequestHeaders.CacheControl =
|
||||
new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true };
|
||||
|
||||
var stable = await FetchVersionAsync(http, StableUrl);
|
||||
var beta = await FetchVersionAsync(http, BetaUrl);
|
||||
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
if (stable != null && IsNewer(stable.Version, CurrentVersion))
|
||||
{
|
||||
LatestVersion = stable.Version;
|
||||
ReleaseNotes = stable.Notes ?? "";
|
||||
DownloadUrl = stable.Url;
|
||||
UpdateAvailable = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateAvailable = false;
|
||||
LatestVersion = "";
|
||||
}
|
||||
|
||||
if (beta != null && IsNewer(beta.Version, CurrentVersion))
|
||||
{
|
||||
LatestBetaVersion = beta.Version;
|
||||
BetaAvailable = true;
|
||||
|
||||
// Show beta update if user opted in, or if they're already running a beta
|
||||
if ((BetaChannel || IsPreReleaseBuild) && IsNewer(beta.Version, LatestVersion))
|
||||
{
|
||||
LatestVersion = beta.Version;
|
||||
ReleaseNotes = beta.Notes ?? "";
|
||||
DownloadUrl = beta.Url;
|
||||
UpdateAvailable = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
BetaAvailable = false;
|
||||
LatestBetaVersion = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<VersionInfo?> FetchVersionAsync(HttpClient http, string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bust = $"{url}?t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||
var json = await http.GetStringAsync(bust);
|
||||
return JsonSerializer.Deserialize<VersionInfo>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public async Task DownloadAndInstallAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(DownloadUrl)) return;
|
||||
|
||||
IsDownloading = true;
|
||||
UpdateError = null;
|
||||
UpdateComplete = false;
|
||||
StatusMessage = "Downloading…";
|
||||
|
||||
try
|
||||
{
|
||||
var tmpDir = Path.Combine(Path.GetTempPath(), $"CCUpdate_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tmpDir);
|
||||
var zipPath = Path.Combine(tmpDir, "ClaudeChecker.zip");
|
||||
|
||||
using var http = new HttpClient();
|
||||
var response = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var total = response.Content.Headers.ContentLength ?? 0;
|
||||
var received = 0L;
|
||||
|
||||
await using (var stream = await response.Content.ReadAsStreamAsync())
|
||||
await using (var file = File.Create(zipPath))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
await file.WriteAsync(buffer.AsMemory(0, read));
|
||||
received += read;
|
||||
if (total > 0)
|
||||
DownloadProgress = (double)received / total * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage = "Unpacking…";
|
||||
DownloadProgress = 0.85;
|
||||
|
||||
var extractDir = Path.Combine(tmpDir, "extracted");
|
||||
ZipFile.ExtractToDirectory(zipPath, extractDir, overwriteFiles: true);
|
||||
|
||||
// Find the installer exe
|
||||
var newExe = FindExe(extractDir);
|
||||
if (newExe == null) throw new Exception("ClaudeChecker.exe not found in update package.");
|
||||
|
||||
DownloadProgress = 0.95;
|
||||
StatusMessage = "Installing…";
|
||||
|
||||
// Write a batch script to replace the exe after we exit
|
||||
var currentExe = Process.GetCurrentProcess().MainModule!.FileName;
|
||||
var script = $"""
|
||||
@echo off
|
||||
timeout /t 2 /nobreak > nul
|
||||
copy /Y "{newExe}" "{currentExe}"
|
||||
start "" "{currentExe}"
|
||||
rmdir /S /Q "{tmpDir}"
|
||||
""";
|
||||
|
||||
var scriptPath = Path.Combine(Path.GetTempPath(), "claudechecker_update.bat");
|
||||
await File.WriteAllTextAsync(scriptPath, script);
|
||||
|
||||
DownloadProgress = 1.0;
|
||||
StatusMessage = "Installed! Relaunching…";
|
||||
UpdateComplete = true;
|
||||
|
||||
await Task.Delay(800);
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/C \"{scriptPath}\"",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
});
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() => Application.Current.Shutdown());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UpdateError = ex.Message;
|
||||
IsDownloading = false;
|
||||
StatusMessage = "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindExe(string dir)
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(dir, "ClaudeChecker.exe", SearchOption.AllDirectories))
|
||||
return f;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsNewer(string version, string current)
|
||||
{
|
||||
static (int[] Base, int[]? Pre) Parse(string v)
|
||||
{
|
||||
if (string.IsNullOrEmpty(v)) return ([], null);
|
||||
var halves = v.Split('-', 2);
|
||||
var baseP = Array.ConvertAll(halves[0].Split('.'), p => int.TryParse(p, out var n) ? n : 0);
|
||||
int[]? pre = halves.Length > 1
|
||||
? Array.ConvertAll(halves[1].Split('.'), p => int.TryParse(p, out var n) ? n : 0)
|
||||
: null;
|
||||
return (baseP, pre);
|
||||
}
|
||||
|
||||
var (ab, ap) = Parse(version);
|
||||
var (bb, bp) = Parse(current);
|
||||
|
||||
for (var i = 0; i < Math.Max(ab.Length, bb.Length); i++)
|
||||
{
|
||||
var av = i < ab.Length ? ab[i] : 0;
|
||||
var bv = i < bb.Length ? bb[i] : 0;
|
||||
if (av != bv) return av > bv;
|
||||
}
|
||||
|
||||
if (ap == null && bp != null) return true;
|
||||
if (ap != null && bp == null) return false;
|
||||
if (ap != null && bp != null)
|
||||
{
|
||||
for (var i = 0; i < Math.Max(ap.Length, bp.Length); i++)
|
||||
{
|
||||
var av = i < ap.Length ? ap[i] : 0;
|
||||
var bv = i < bp.Length ? bp[i] : 0;
|
||||
if (av != bv) return av > bv;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
Application.Current.Dispatcher.InvokeAsync(()
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)));
|
||||
}
|
||||
}
|
||||
@@ -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,145 @@
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.Wpf;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
|
||||
namespace ClaudeCheckerWindows;
|
||||
|
||||
// Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
|
||||
// Uses the same user data folder as LoginWindow so the session (including
|
||||
// cf_clearance) is always shared and fresh — equivalent to macOS's WKWebsiteDataStore.
|
||||
//
|
||||
// Two usage modes:
|
||||
// • Persistent (App.BackgroundBrowser): created once at startup, navigates to
|
||||
// claude.ai once, then scripts run directly on the live page every refresh.
|
||||
// No re-navigation = no memory accumulation.
|
||||
// • One-shot (LoginWindow): created, used, closed — same as before.
|
||||
public sealed class WebViewFetchWindow : Window
|
||||
{
|
||||
private readonly WebView2 _wv = new();
|
||||
private bool _initialized;
|
||||
private bool _readyForScript; // true after first navigation to claude.ai completes
|
||||
|
||||
private static readonly string UserDataFolder =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"ClaudeChecker", "WebView2");
|
||||
|
||||
public bool IsReady => _readyForScript;
|
||||
|
||||
public WebViewFetchWindow()
|
||||
{
|
||||
Width = 1;
|
||||
Height = 1;
|
||||
Left = -9999;
|
||||
Top = -9999;
|
||||
ShowInTaskbar = false;
|
||||
WindowStyle = WindowStyle.None;
|
||||
AllowsTransparency = true;
|
||||
Opacity = 0;
|
||||
Content = _wv;
|
||||
}
|
||||
|
||||
// ── Persistent-mode init ──────────────────────────────────────────────────
|
||||
|
||||
// Call once at startup. Navigates to claude.ai so the session/cookies are
|
||||
// established and cf_clearance is fresh. Subsequent RunScriptAsync calls
|
||||
// skip navigation and just execute JS on the live page.
|
||||
public async Task InitAsync()
|
||||
{
|
||||
await EnsureInitAsync();
|
||||
|
||||
var navDone = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
|
||||
h = (_, e) => { _wv.CoreWebView2.NavigationCompleted -= h; navDone.TrySetResult(e.IsSuccess); };
|
||||
_wv.CoreWebView2.NavigationCompleted += h;
|
||||
_wv.CoreWebView2.Navigate("https://claude.ai");
|
||||
|
||||
// Wait up to 15 s for initial navigation
|
||||
await Task.WhenAny(navDone.Task, Task.Delay(15000));
|
||||
_readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
|
||||
}
|
||||
|
||||
// Run a JS script on the already-loaded claude.ai page.
|
||||
// The script must call window.chrome.webview.postMessage(result).
|
||||
public async Task<string?> RunScriptAsync(string script, int timeoutMs = 10000)
|
||||
{
|
||||
if (!_readyForScript) return null;
|
||||
|
||||
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
|
||||
msgHandler = (_, args) =>
|
||||
{
|
||||
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
|
||||
tcs.TrySetResult(args.WebMessageAsJson);
|
||||
};
|
||||
_wv.CoreWebView2.WebMessageReceived += msgHandler;
|
||||
|
||||
try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
|
||||
catch
|
||||
{
|
||||
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
|
||||
return null;
|
||||
}
|
||||
|
||||
_ = Task.Delay(timeoutMs).ContinueWith(_ =>
|
||||
{
|
||||
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
|
||||
tcs.TrySetResult(null);
|
||||
});
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
// ── One-shot mode (LoginWindow) ───────────────────────────────────────────
|
||||
|
||||
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
|
||||
{
|
||||
await EnsureInitAsync();
|
||||
|
||||
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
|
||||
msgHandler = (_, args) =>
|
||||
{
|
||||
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
|
||||
tcs.TrySetResult(args.WebMessageAsJson);
|
||||
};
|
||||
_wv.CoreWebView2.WebMessageReceived += msgHandler;
|
||||
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs>? navHandler = null;
|
||||
navHandler = async (_, e) =>
|
||||
{
|
||||
_wv.CoreWebView2.NavigationCompleted -= navHandler;
|
||||
if (!e.IsSuccess) { tcs.TrySetResult(null); return; }
|
||||
try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
|
||||
catch { tcs.TrySetResult(null); }
|
||||
};
|
||||
_wv.CoreWebView2.NavigationCompleted += navHandler;
|
||||
_wv.CoreWebView2.Navigate(navigateUrl);
|
||||
|
||||
_ = Task.Delay(timeoutMs).ContinueWith(_ =>
|
||||
{
|
||||
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
|
||||
tcs.TrySetResult(null);
|
||||
});
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
// ── Shared init ───────────────────────────────────────────────────────────
|
||||
|
||||
private int _initGuard;
|
||||
|
||||
private async Task EnsureInitAsync()
|
||||
{
|
||||
if (_initialized) return;
|
||||
if (Interlocked.Exchange(ref _initGuard, 1) != 0) return;
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: UserDataFolder);
|
||||
await _wv.EnsureCoreWebView2Async(env);
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user