Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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>
|
<string>77</string>
|
||||||
<key>objects</key>
|
<key>objects</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>AAAAAA009</key>
|
|
||||||
<dict>
|
|
||||||
<key>isa</key>
|
|
||||||
<string>PBXBuildFile</string>
|
|
||||||
<key>fileRef</key>
|
|
||||||
<string>AAAAAA109</string>
|
|
||||||
</dict>
|
|
||||||
<key>AAAAAA007</key>
|
<key>AAAAAA007</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -95,17 +88,6 @@
|
|||||||
<key>sourceTree</key>
|
<key>sourceTree</key>
|
||||||
<string><group></string>
|
<string><group></string>
|
||||||
</dict>
|
</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>
|
<key>AAAAAA101</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -197,7 +179,6 @@
|
|||||||
<string>AAAAAA106</string>
|
<string>AAAAAA106</string>
|
||||||
<string>AAAAAA107</string>
|
<string>AAAAAA107</string>
|
||||||
<string>AAAAAA108</string>
|
<string>AAAAAA108</string>
|
||||||
<string>AAAAAA109</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>path</key>
|
<key>path</key>
|
||||||
<string>ClaudeChecker</string>
|
<string>ClaudeChecker</string>
|
||||||
@@ -311,7 +292,6 @@
|
|||||||
<string>AAAAAA005</string>
|
<string>AAAAAA005</string>
|
||||||
<string>AAAAAA007</string>
|
<string>AAAAAA007</string>
|
||||||
<string>AAAAAA008</string>
|
<string>AAAAAA008</string>
|
||||||
<string>AAAAAA009</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>runOnlyForDeploymentPostprocessing</key>
|
<key>runOnlyForDeploymentPostprocessing</key>
|
||||||
<string>0</string>
|
<string>0</string>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
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
|
// Status item
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ struct ContentView: View {
|
|||||||
.environmentObject(updater)
|
.environmentObject(updater)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 0) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
// Cards grid
|
// Cards grid
|
||||||
if vm.limits.isEmpty {
|
if vm.limits.isEmpty {
|
||||||
EmptyStateView()
|
EmptyStateView()
|
||||||
@@ -95,6 +96,7 @@ struct ContentView: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
||||||
@@ -116,8 +118,8 @@ struct ContentView: View {
|
|||||||
showUpdateSheet = true
|
showUpdateSheet = true
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showLogin) {
|
.sheet(isPresented: $showLogin) {
|
||||||
LoginSheetView(isPresented: $showLogin) { webView in
|
LoginSheetView(isPresented: $showLogin) {
|
||||||
Task { await vm.adoptAndRefresh(webView) }
|
Task { await vm.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showUpdateSheet) {
|
.sheet(isPresented: $showUpdateSheet) {
|
||||||
@@ -396,11 +398,12 @@ struct DiaryRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(totalSaved) samples")
|
AgentIconView(agent: .claude, size: 12)
|
||||||
.font(.system(size: 10.5))
|
Text("Claude")
|
||||||
.foregroundColor(.secondary)
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
Spacer()
|
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))
|
.font(.system(size: 10.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -584,7 +587,6 @@ struct SettingsView: View {
|
|||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@Binding var showUpdateSheet: Bool
|
@Binding var showUpdateSheet: Bool
|
||||||
@State private var checkingForUpdates = false
|
@State private var checkingForUpdates = false
|
||||||
@State private var showDiagnostics = false
|
|
||||||
|
|
||||||
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
||||||
("1 min", 60),
|
("1 min", 60),
|
||||||
@@ -771,23 +773,6 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider()
|
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
|
// About
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
@@ -929,12 +914,12 @@ struct ErrorBanner: View {
|
|||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
Image(systemName: "key.slash")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("Not signed in")
|
Text("No API key configured")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.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))
|
.font(.system(size: 11.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -1,226 +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("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()
|
|
||||||
|
|
||||||
// Response body
|
|
||||||
if !vm.diagLastBody.isEmpty {
|
|
||||||
DiagSection(title: "Response Body (first 500 chars)") {
|
|
||||||
Text(vm.diagLastBody)
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(8)
|
|
||||||
.background(Color.primary.opacity(0.04))
|
|
||||||
.cornerRadius(5)
|
|
||||||
}
|
|
||||||
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("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("Response body:")
|
|
||||||
lines.append(vm.diagLastBody)
|
|
||||||
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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.2.1-beta.22</string>
|
<string>1.1.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>70</string>
|
<string>40</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
@@ -45,3 +45,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import WebKit
|
|||||||
// MARK: - Login Web View
|
// MARK: - Login Web View
|
||||||
|
|
||||||
struct LoginWebView: NSViewRepresentable {
|
struct LoginWebView: NSViewRepresentable {
|
||||||
let onAuthenticated: (WKWebView) -> Void
|
let onAuthenticated: () -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> WKWebView {
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
@@ -12,13 +12,6 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
let webView = WKWebView(frame: .zero, configuration: config)
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
webView.navigationDelegate = context.coordinator
|
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")!))
|
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@@ -30,30 +23,30 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, WKNavigationDelegate {
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
let onAuthenticated: (WKWebView) -> Void
|
let onAuthenticated: () -> Void
|
||||||
weak var webView: WKWebView?
|
|
||||||
var didAuthenticate = false
|
var didAuthenticate = false
|
||||||
var urlObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
init(onAuthenticated: @escaping () -> Void) {
|
||||||
self.onAuthenticated = onAuthenticated
|
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!) {
|
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 {
|
struct LoginSheetView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let onDone: (WKWebView) -> Void
|
let onDone: () -> Void
|
||||||
@State private var authenticated = false
|
@State private var authenticated = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -72,9 +65,12 @@ struct LoginSheetView: View {
|
|||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
if authenticated {
|
if authenticated {
|
||||||
Button("Done") { isPresented = false }
|
Button("Done") {
|
||||||
.buttonStyle(.borderedProminent)
|
isPresented = false
|
||||||
.controlSize(.small)
|
onDone()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
} else {
|
} else {
|
||||||
Button("Cancel") { isPresented = false }
|
Button("Cancel") { isPresented = false }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -87,13 +83,12 @@ struct LoginSheetView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LoginWebView { webView in
|
LoginWebView {
|
||||||
authenticated = true
|
authenticated = true
|
||||||
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
// Auto-dismiss and refresh after brief delay
|
||||||
// then auto-dismiss after a moment so the user sees confirmation.
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||||
onDone(webView)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
||||||
isPresented = false
|
isPresented = false
|
||||||
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import WebKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class UsageViewModel: ObservableObject {
|
class UsageViewModel: ObservableObject {
|
||||||
|
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
|
||||||
@Published var limits: [AgentLimit] = []
|
@Published var limits: [AgentLimit] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var lastUpdated: Date?
|
@Published var lastUpdated: Date?
|
||||||
@@ -27,20 +28,12 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
private var burnHistoryStore: [String: [Double]] = [:]
|
private var burnHistoryStore: [String: [Double]] = [:]
|
||||||
private let maxHistorySamples = 24
|
private let maxHistorySamples = 24
|
||||||
|
private var cachedOrgId: String?
|
||||||
private var previousPercents: [String: Double] = [:]
|
private var previousPercents: [String: Double] = [:]
|
||||||
private var firedThresholds: [String: Set<Int>] = [:]
|
private var firedThresholds: [String: Set<Int>] = [:]
|
||||||
|
|
||||||
// Diagnostics — populated on every urlFetch call
|
|
||||||
@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 diagLastBody: String = ""
|
|
||||||
@Published var diagLastError: String = ""
|
|
||||||
@Published var diagLastFetch: Date? = nil
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
||||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||||
refreshInterval = saved > 0 ? saved : 60
|
refreshInterval = saved > 0 ? saved : 60
|
||||||
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||||
@@ -48,96 +41,14 @@ class UsageViewModel: ObservableObject {
|
|||||||
burnHistoryStore = saved
|
burnHistoryStore = saved
|
||||||
}
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
Task { await checkInitialSignInState() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called after login: poll until session cookies appear (they may commit slightly
|
|
||||||
// after the auth redirect fires), then refresh.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
diagLastBody = String(body.prefix(500))
|
|
||||||
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()
|
|
||||||
if !cookies.isEmpty { isSignedIn = true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
let store = WKWebsiteDataStore.default()
|
let store = WKWebsiteDataStore.default()
|
||||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||||
let records = await store.dataRecords(ofTypes: types)
|
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)
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||||
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
lastUpdated = nil
|
lastUpdated = nil
|
||||||
@@ -155,29 +66,18 @@ class UsageViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
|
let orgId = cachedOrgId!
|
||||||
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
let orgId: String
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
if let id = fetchedOrgId {
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
async let emailFetch = fetchUserEmail()
|
||||||
orgId = id
|
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
||||||
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
if let email { userEmail = email }
|
||||||
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)
|
|
||||||
limits = buildLimits(from: usage)
|
limits = buildLimits(from: usage)
|
||||||
extraUsage = usage.extraUsage
|
extraUsage = usage.extraUsage
|
||||||
prepaidCredits = prepaid
|
prepaidCredits = prepaid
|
||||||
overageSpendLimit = overage
|
overageSpendLimit = overage
|
||||||
|
planLabel = "Pro"
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
|
|
||||||
@@ -193,7 +93,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
checkLimitNotifications(for: limits)
|
checkLimitNotifications(for: limits)
|
||||||
} catch AppError.notAuthenticated {
|
} catch AppError.notAuthenticated {
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
isSignedIn = false
|
|
||||||
errorMessage = "Not signed in"
|
errorMessage = "Not signed in"
|
||||||
} catch let error as DecodingError {
|
} catch let error as DecodingError {
|
||||||
switch error {
|
switch error {
|
||||||
@@ -211,72 +110,70 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap
|
|
||||||
|
|
||||||
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
|
||||||
let (status, body) = try await urlFetch("/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 firstOrg = memberships?.first?["organization"] as? [String: Any]
|
|
||||||
|
|
||||||
var orgId: String? = firstOrg?["uuid"] as? String
|
|
||||||
if orgId == nil {
|
|
||||||
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
|
||||||
}
|
|
||||||
if orgId == nil {
|
|
||||||
if let (orgsStatus, orgsBody) = try? await urlFetch("/api/organizations"),
|
|
||||||
orgsStatus == 200,
|
|
||||||
let orgsData = orgsBody.data(using: .utf8),
|
|
||||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
|
||||||
orgId = orgs.first?["uuid"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let email = account?["email_address"] as? String
|
|
||||||
|
|
||||||
var planLabel: String? = nil
|
|
||||||
if let caps = firstOrg?["capabilities"] as? [String],
|
|
||||||
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
|
||||||
let name = String(cap.dropFirst("claude_".count))
|
|
||||||
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (orgId, email, planLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch usage
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let (status, body) = try await urlFetch("/api/organizations/\(orgId)/usage")
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||||
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
var req = URLRequest(url: url)
|
||||||
guard status == 200 else { throw AppError.networkError }
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
|
||||||
|
// 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)
|
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? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
||||||
status == 200,
|
var req = URLRequest(url: url)
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
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)
|
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||||
guard let (status, body) = try? await urlFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
||||||
status == 200,
|
var req = URLRequest(url: url)
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
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)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,11 +245,14 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
private func checkLimitNotifications(for limits: [AgentLimit]) {
|
||||||
let thresholds = [80, 95]
|
let thresholds = [80, 95]
|
||||||
|
|
||||||
for limit in limits {
|
for limit in limits {
|
||||||
let key = limit.window.rawValue
|
let key = limit.window.rawValue
|
||||||
let curr = limit.usedPercent
|
let curr = limit.usedPercent
|
||||||
let prev = previousPercents[key]
|
let prev = previousPercents[key]
|
||||||
var fired = firedThresholds[key] ?? []
|
var fired = firedThresholds[key] ?? []
|
||||||
|
|
||||||
|
// Threshold warnings (80% and 95%)
|
||||||
for t in thresholds {
|
for t in thresholds {
|
||||||
let threshold = Double(t)
|
let threshold = Double(t)
|
||||||
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
|
||||||
@@ -363,6 +263,8 @@ class UsageViewModel: ObservableObject {
|
|||||||
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
userInfo: ["windowName": limit.window.displayName, "percent": t]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset detection: was high, now low
|
||||||
if let prev, prev > 50, curr < 20 {
|
if let prev, prev > 50, curr < 20 {
|
||||||
fired.removeAll()
|
fired.removeAll()
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
@@ -371,6 +273,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
userInfo: ["windowName": limit.window.displayName]
|
userInfo: ["windowName": limit.window.displayName]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
firedThresholds[key] = fired
|
firedThresholds[key] = fired
|
||||||
previousPercents[key] = curr
|
previousPercents[key] = curr
|
||||||
}
|
}
|
||||||
@@ -393,16 +296,13 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError {
|
||||||
case notAuthenticated
|
case notAuthenticated
|
||||||
case networkError
|
case networkError
|
||||||
case detail(String)
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||||
case .networkError: return "Network error fetching usage data."
|
case .networkError: return "Network error fetching usage data."
|
||||||
case .detail(let msg): return msg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,3 +317,4 @@ extension Notification.Name {
|
|||||||
static let limitReset = Notification.Name("limitReset")
|
static let limitReset = Notification.Name("limitReset")
|
||||||
static let openUpdateSheet = Notification.Name("openUpdateSheet")
|
static let openUpdateSheet = Notification.Name("openUpdateSheet")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
[](https://www.apple.com/macos/)
|
[](https://www.apple.com/macos/)
|
||||||
[](https://swift.org)
|
[](https://swift.org)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases)
|
[](https://github.com/superdooper86/claudechecker/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.21) <!-- BETA_BADGE -->
|
<!-- BETA_BADGE -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+9
-17
@@ -1,20 +1,12 @@
|
|||||||
## What's new in v1.2.1-beta.18
|
## What's new in v1.1.3
|
||||||
|
|
||||||
### Bug fixes
|
### UI polish
|
||||||
- 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.
|
- Limit headers now read "5 Hour Limit" and "7 Day Limit"
|
||||||
- API calls are now sequential to share a single WebView for all navigations
|
- Tapping anywhere on the blue update banner opens the update window
|
||||||
- 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
|
|
||||||
|
|
||||||
## What's new in v1.2.1-beta.17
|
### Session Diary fix
|
||||||
|
- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh
|
||||||
|
|
||||||
### Bug fixes
|
### Window sizing fixes
|
||||||
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
|
- The popover now resizes correctly when switching between the main view and settings
|
||||||
- Added diagnostic error messages for JS errors and unexpected responses
|
- Background colour no longer shows through during the resize animation
|
||||||
|
|
||||||
## What's new in v1.2.1
|
|
||||||
|
|
||||||
### Bug fixes
|
|
||||||
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
|
|
||||||
- Fixed login window auto-closing before sign-in completes
|
|
||||||
- Added /api/organizations as a final fallback for org ID resolution
|
|
||||||
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
|
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.21",
|
"version": "1.1.3-beta.5",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.21/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3-beta.5/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"
|
"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",
|
"version": "0.0.0",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
|
"url": "",
|
||||||
"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"
|
"notes": ""
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.0",
|
||||||
|
"url": "",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.0",
|
"version": "1.1.3",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.2.0\n\n### Bug fixes\n- App now works for all users — org ID is fetched dynamically from the API instead of being hardcoded\n- Plan name (e.g. Pro, Max) now updates correctly on every refresh\n\n### Improvements\n- Plan name is read from the API rather than hardcoded\n- Bootstrap API call consolidated — org ID, email, and plan name fetched in a single request per refresh\n- Main panel no longer scrolls — popover auto-sizes to fit content\n- Session Diary card redesigned — sample count and avg burn rate shown left/right above the sparkline, Claude header removed"
|
"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,170 @@
|
|||||||
|
<Application x:Class="ClaudeCheckerWindows.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:po="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
|
||||||
|
ShutdownMode="OnExplicitShutdown">
|
||||||
|
<Application.Resources>
|
||||||
|
<SolidColorBrush x:Key="BackgroundBrush" Color="#F3F3F3" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="CardBrush" Color="#FFFFFF" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#F97316" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="TextBrush" Color="#1C1C1C" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryBrush" Color="#6E6E6E" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="BorderBrush" Color="#E0E0E0" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="BlueBrush" Color="#0078D4" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="ButtonBrush" Color="#FFFFFF" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="SurfaceBrush" Color="#FAFAFA" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="BannerBgBrush" Color="#E8F2FB" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="BannerBorderBrush" Color="#B3D6F5" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="HoverBrush" Color="#EBEBEB" po:Freeze="False"/>
|
||||||
|
<SolidColorBrush x:Key="BadgeBrush" Color="#F0F0F0" po:Freeze="False"/>
|
||||||
|
|
||||||
|
<Style x:Key="HeaderText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="10"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryBrush}"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource BlueBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="14,6"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}" CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#106EBE"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Background" Value="#BDBDBD"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="SmallButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ButtonBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="Padding" Value="10,4"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource HoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource CardBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Padding" Value="8,4"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBox">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="Border"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="4">
|
||||||
|
<Grid>
|
||||||
|
<ContentPresenter x:Name="ContentSite"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
Content="{TemplateBinding SelectionBoxItem}"
|
||||||
|
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Left"/>
|
||||||
|
<TextBlock Text="▾" FontSize="10"
|
||||||
|
Foreground="{DynamicResource SecondaryBrush}"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0" IsHitTestVisible="False"/>
|
||||||
|
<ToggleButton Focusable="False" ClickMode="Press"
|
||||||
|
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
|
||||||
|
RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
Background="Transparent" BorderThickness="0"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Popup x:Name="Popup" Placement="Bottom"
|
||||||
|
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||||
|
AllowsTransparency="True" Focusable="False"
|
||||||
|
PopupAnimation="Slide">
|
||||||
|
<Border SnapsToDevicePixels="True"
|
||||||
|
MinWidth="{TemplateBinding ActualWidth}"
|
||||||
|
MaxHeight="{TemplateBinding MaxDropDownHeight}"
|
||||||
|
Background="{DynamicResource CardBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="4">
|
||||||
|
<ScrollViewer Margin="0,2">
|
||||||
|
<ItemsPresenter/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Setter Property="ItemContainerStyle">
|
||||||
|
<Setter.Value>
|
||||||
|
<Style TargetType="ComboBoxItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource CardBrush}"/>
|
||||||
|
<Setter Property="Padding" Value="10,5"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBoxItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background"
|
||||||
|
Value="{DynamicResource HoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ScrollViewer">
|
||||||
|
<Setter Property="VerticalScrollBarVisibility" Value="Auto"/>
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using Forms = System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public static UsageViewModel ViewModel { get; } = new();
|
||||||
|
public static UpdateManager Updater { get; } = new();
|
||||||
|
|
||||||
|
private Forms.NotifyIcon? _tray;
|
||||||
|
private PopupWindow? _popup;
|
||||||
|
private DispatcherTimer? _timer;
|
||||||
|
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
var logPath = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"ClaudeChecker", "startup_crash.txt");
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
|
||||||
|
System.IO.File.WriteAllText(logPath, ex.ExceptionObject?.ToString() ?? "unknown");
|
||||||
|
|
||||||
|
DispatcherUnhandledException += (_, ex) =>
|
||||||
|
{
|
||||||
|
System.IO.File.WriteAllText(logPath, ex.Exception?.ToString() ?? "unknown");
|
||||||
|
ex.Handled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
base.OnStartup(e);
|
||||||
|
ThemeManager.Initialize();
|
||||||
|
SetupTray();
|
||||||
|
ScheduleTimer(ViewModel.RefreshInterval);
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(1000);
|
||||||
|
await ViewModel.RefreshAsync();
|
||||||
|
await Updater.CheckForUpdatesAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupTray()
|
||||||
|
{
|
||||||
|
_tray = new Forms.NotifyIcon
|
||||||
|
{
|
||||||
|
Text = "ClaudeChecker",
|
||||||
|
Visible = true,
|
||||||
|
Icon = LoadIcon(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_tray.MouseClick += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Button == Forms.MouseButtons.Left)
|
||||||
|
Dispatcher.InvokeAsync(TogglePopup);
|
||||||
|
};
|
||||||
|
|
||||||
|
_tray.ContextMenuStrip = BuildContextMenu();
|
||||||
|
|
||||||
|
ViewModel.PropertyChanged += (_, _) => UpdateTrayText();
|
||||||
|
Updater.PropertyChanged += (_, _) => UpdateTrayText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Drawing.Icon LoadIcon()
|
||||||
|
{
|
||||||
|
try { return new System.Drawing.Icon("Assets/icon.ico"); }
|
||||||
|
catch { return System.Drawing.SystemIcons.Application; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private Forms.ContextMenuStrip BuildContextMenu()
|
||||||
|
{
|
||||||
|
var menu = new Forms.ContextMenuStrip();
|
||||||
|
menu.Items.Add("Show ClaudeChecker", null, (_, _) => Dispatcher.InvokeAsync(ShowPopup));
|
||||||
|
menu.Items.Add(new Forms.ToolStripSeparator());
|
||||||
|
menu.Items.Add("Quit", null, (_, _) => Dispatcher.InvokeAsync(Quit));
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TogglePopup()
|
||||||
|
{
|
||||||
|
if (_popup == null || !_popup.IsVisible)
|
||||||
|
ShowPopup();
|
||||||
|
else
|
||||||
|
_popup.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowPopup()
|
||||||
|
{
|
||||||
|
if (_popup == null)
|
||||||
|
{
|
||||||
|
_popup = new PopupWindow();
|
||||||
|
}
|
||||||
|
_popup.Show();
|
||||||
|
_popup.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ScheduleTimer(int seconds)
|
||||||
|
{
|
||||||
|
_timer?.Stop();
|
||||||
|
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) };
|
||||||
|
_timer.Tick += async (_, _) =>
|
||||||
|
{
|
||||||
|
await ViewModel.RefreshAsync();
|
||||||
|
await Updater.CheckForUpdatesAsync();
|
||||||
|
};
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTrayText()
|
||||||
|
{
|
||||||
|
if (_tray == null) return;
|
||||||
|
var limits = ViewModel.Limits;
|
||||||
|
if (ViewModel.ShowInTaskbar && limits.Count >= 2)
|
||||||
|
{
|
||||||
|
var fh = limits.Find(l => l.Window == WindowKind.FiveHour);
|
||||||
|
var sd = limits.Find(l => l.Window == WindowKind.SevenDay);
|
||||||
|
if (fh != null && sd != null && fh.IsLive)
|
||||||
|
{
|
||||||
|
_tray.Text = $"ClaudeChecker {(int)fh.UsedPercent}% {(int)sd.UsedPercent}%";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_tray.Text = "ClaudeChecker";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Quit()
|
||||||
|
{
|
||||||
|
_tray?.Dispose();
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnExit(ExitEventArgs e)
|
||||||
|
{
|
||||||
|
_tray?.Dispose();
|
||||||
|
base.OnExit(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows;
|
||||||
|
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
public string CookieStore { get; set; } = "";
|
||||||
|
public string BurnHistory { get; set; } = "";
|
||||||
|
public string OrgId { get; set; } = "";
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string UsageJson { get; set; } = "";
|
||||||
|
public int RefreshInterval { get; set; } = 120;
|
||||||
|
public bool ShowInTaskbar { get; set; } = true;
|
||||||
|
public bool BetaChannel { get; set; } = false;
|
||||||
|
|
||||||
|
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,95 @@
|
|||||||
|
using Brush = System.Windows.Media.Brush;
|
||||||
|
using Pen = System.Windows.Media.Pen;
|
||||||
|
using Point = System.Windows.Point;
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows.Controls;
|
||||||
|
|
||||||
|
public class GaugeControl : FrameworkElement
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty PercentProperty =
|
||||||
|
DependencyProperty.Register(nameof(Percent), typeof(double), typeof(GaugeControl),
|
||||||
|
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty AccentProperty =
|
||||||
|
DependencyProperty.Register(nameof(Accent), typeof(Brush), typeof(GaugeControl),
|
||||||
|
new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public double Percent { get => (double)GetValue(PercentProperty); set => SetValue(PercentProperty, value); }
|
||||||
|
public Brush Accent { get => (Brush)GetValue(AccentProperty); set => SetValue(AccentProperty, value); }
|
||||||
|
|
||||||
|
public GaugeControl()
|
||||||
|
{
|
||||||
|
ThemeManager.ThemeChanged += InvalidateVisual;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const double StartAngleDeg = 135;
|
||||||
|
private const double SweepDeg = 270;
|
||||||
|
private const double StrokeWidth = 6;
|
||||||
|
|
||||||
|
protected override void OnRender(DrawingContext dc)
|
||||||
|
{
|
||||||
|
var cx = RenderSize.Width / 2;
|
||||||
|
var cy = RenderSize.Height / 2;
|
||||||
|
var radius = Math.Min(cx, cy) - StrokeWidth / 2 - 1;
|
||||||
|
|
||||||
|
var trackColor = ThemeManager.IsDark
|
||||||
|
? Color.FromArgb(40, 255, 255, 255)
|
||||||
|
: Color.FromArgb(30, 0, 0, 0);
|
||||||
|
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg,
|
||||||
|
new Pen(new SolidColorBrush(trackColor), StrokeWidth));
|
||||||
|
|
||||||
|
if (Percent > 0)
|
||||||
|
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0),
|
||||||
|
new Pen(Accent, StrokeWidth));
|
||||||
|
|
||||||
|
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
|
||||||
|
|
||||||
|
var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C);
|
||||||
|
var subColor = Color.FromRgb(0x9E, 0x9E, 0x9E);
|
||||||
|
|
||||||
|
var pctText = new FormattedText(
|
||||||
|
$"{(int)Math.Round(Percent)}%",
|
||||||
|
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||||
|
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
|
||||||
|
14, new SolidColorBrush(textColor), dpi);
|
||||||
|
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height - 1));
|
||||||
|
|
||||||
|
var sub = new FormattedText(
|
||||||
|
"Updated\nnow",
|
||||||
|
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||||
|
new Typeface("Segoe UI"), 7,
|
||||||
|
new SolidColorBrush(subColor), dpi)
|
||||||
|
{
|
||||||
|
TextAlignment = TextAlignment.Center
|
||||||
|
};
|
||||||
|
dc.DrawText(sub, new Point(cx - sub.Width / 2, cy + 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,105 @@
|
|||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows;
|
||||||
|
|
||||||
|
public partial class LoginWindow : Window
|
||||||
|
{
|
||||||
|
private bool _closing;
|
||||||
|
|
||||||
|
public LoginWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Loaded += async (_, _) => await InitBrowserAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitBrowserAsync()
|
||||||
|
{
|
||||||
|
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
|
||||||
|
System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"ClaudeChecker", "WebView2"));
|
||||||
|
|
||||||
|
await Browser.EnsureCoreWebView2Async(env);
|
||||||
|
Browser.CoreWebView2.Navigate("https://claude.ai");
|
||||||
|
|
||||||
|
Browser.CoreWebView2.NavigationCompleted += async (_, e) =>
|
||||||
|
{
|
||||||
|
if (!e.IsSuccess || _closing) return;
|
||||||
|
var uri = Browser.CoreWebView2.Source;
|
||||||
|
if (!uri.Contains("claude.ai")) return;
|
||||||
|
|
||||||
|
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
|
||||||
|
var signedIn = cookies.Any(c =>
|
||||||
|
c.Name.Equals("sessionKey", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
c.Name.StartsWith("sk-ant", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
DoneButton.IsEnabled = !uri.Contains("/login") && !uri.Contains("/signin");
|
||||||
|
StatusText.Text = signedIn
|
||||||
|
? "Signed in — click Done to continue."
|
||||||
|
: "Complete sign-in, then click Done.";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close once we land on any claude.ai page that isn't the login flow
|
||||||
|
if (!uri.Contains("/login") && !uri.Contains("/signin") && signedIn)
|
||||||
|
await SaveAndClose(cookies);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Done_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_closing) return;
|
||||||
|
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
|
||||||
|
await SaveAndClose(cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAndClose(IReadOnlyList<CoreWebView2Cookie> cookies)
|
||||||
|
{
|
||||||
|
if (_closing) return;
|
||||||
|
_closing = true;
|
||||||
|
|
||||||
|
UsageViewModel.SaveCookies(cookies);
|
||||||
|
|
||||||
|
// Fetch bootstrap + usage from within WebView2 (already authenticated, no header issues)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string script = @"(async()=>{try{
|
||||||
|
const b=await(await fetch('/api/bootstrap',{headers:{accept:'application/json'}})).json();
|
||||||
|
const id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
|
||||||
|
||b?.default_organization?.uuid||null;
|
||||||
|
const e=b?.account?.email_address||b?.account?.email||b?.email||null;
|
||||||
|
if(!id)return{email:e,orgId:null,usage:null};
|
||||||
|
const u=await(await fetch('/api/organizations/'+id+'/usage',{headers:{accept:'application/json'}})).json();
|
||||||
|
return{email:e,orgId:id,usage:u};
|
||||||
|
}catch(ex){return{error:String(ex)};}})()";
|
||||||
|
|
||||||
|
var json = await Browser.CoreWebView2.ExecuteScriptAsync(script);
|
||||||
|
if (json != "null" && !string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String)
|
||||||
|
AppSettings.Default.Email = em.GetString() ?? "";
|
||||||
|
|
||||||
|
if (root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String)
|
||||||
|
AppSettings.Default.OrgId = oi.GetString() ?? "";
|
||||||
|
|
||||||
|
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
|
||||||
|
AppSettings.Default.UsageJson = us.GetRawText();
|
||||||
|
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(() => DialogResult = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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("claude_ai_default_5h")] public WindowData? FiveHour { get; set; }
|
||||||
|
[JsonPropertyName("claude_ai_default")] public WindowData? SevenDay { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WindowData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("utilization")] public double Utilization { get; set; }
|
||||||
|
[JsonPropertyName("resets_at")] public string? ResetsAt { 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,254 @@
|
|||||||
|
<Window x:Class="ClaudeCheckerWindows.PopupWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ctrl="clr-namespace:ClaudeCheckerWindows.Controls"
|
||||||
|
Title="ClaudeChecker"
|
||||||
|
Width="480" Height="660"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{DynamicResource BackgroundBrush}"
|
||||||
|
FontFamily="Segoe UI"
|
||||||
|
ShowInTaskbar="True"
|
||||||
|
ResizeMode="NoResize">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<!-- Main panel -->
|
||||||
|
<DockPanel x:Name="MainPanel">
|
||||||
|
|
||||||
|
<!-- Update banner -->
|
||||||
|
<Border x:Name="UpdateBanner" DockPanel.Dock="Top" Visibility="Collapsed"
|
||||||
|
Background="{DynamicResource BannerBgBrush}"
|
||||||
|
BorderBrush="{DynamicResource BannerBorderBrush}" BorderThickness="0,0,0,1"
|
||||||
|
Cursor="Hand" MouseLeftButtonUp="UpdateBanner_Click">
|
||||||
|
<Grid Margin="16,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="⬇" FontSize="14"
|
||||||
|
Foreground="{DynamicResource BlueBrush}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="1" Margin="10,0">
|
||||||
|
<TextBlock x:Name="UpdateBannerTitle" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<TextBlock Text="Click to view release notes and install."
|
||||||
|
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2" Content="Update" Style="{StaticResource SmallButton}"
|
||||||
|
Foreground="{DynamicResource BlueBrush}" Click="UpdateBanner_Click"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border DockPanel.Dock="Bottom" BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
BorderThickness="0,1,0,0" Background="{DynamicResource SurfaceBrush}">
|
||||||
|
<Grid Margin="12,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<ProgressBar x:Name="LoadingBar" IsIndeterminate="True"
|
||||||
|
Width="12" Height="12" Visibility="Collapsed"
|
||||||
|
Style="{x:Null}" Margin="0,0,6,0"/>
|
||||||
|
<TextBlock x:Name="LastUpdatedText" FontSize="11"
|
||||||
|
Foreground="{DynamicResource SecondaryBrush}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Content="↻ Refresh"
|
||||||
|
Style="{StaticResource SmallButton}" Click="Refresh_Click"/>
|
||||||
|
<Button Grid.Column="3" x:Name="SettingsButton" Content="⚙ Settings"
|
||||||
|
Style="{StaticResource SmallButton}" Click="Settings_Click"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Cards scroll area -->
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel x:Name="CardsPanel" Margin="0,4,0,4"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Settings panel -->
|
||||||
|
<Border x:Name="SettingsPanel" Visibility="Collapsed"
|
||||||
|
Background="{DynamicResource BackgroundBrush}">
|
||||||
|
<DockPanel>
|
||||||
|
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
BorderThickness="0,0,0,1" Background="{DynamicResource SurfaceBrush}" Padding="12,10">
|
||||||
|
<Grid>
|
||||||
|
<Button Content="← Back" HorizontalAlignment="Left"
|
||||||
|
Style="{StaticResource SmallButton}" BorderThickness="0"
|
||||||
|
Foreground="{DynamicResource BlueBrush}" Background="Transparent"
|
||||||
|
Click="BackFromSettings_Click"/>
|
||||||
|
<TextBlock Text="Settings" FontSize="14" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Margin="20,16,20,20">
|
||||||
|
|
||||||
|
<TextBlock Text="AUTHENTICATION" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
Padding="14,12" Margin="0,0,0,16">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel x:Name="AuthPanel" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="Your session is stored locally and persists across restarts."
|
||||||
|
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
|
||||||
|
TextWrapping="Wrap" Margin="0,0,0,10"/>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button x:Name="SignInButton" Content="Sign In"
|
||||||
|
Style="{StaticResource SmallButton}"
|
||||||
|
Click="SignIn_Click" Margin="0,0,8,0"/>
|
||||||
|
<Button x:Name="SignOutButton" Content="Sign Out"
|
||||||
|
Style="{StaticResource SmallButton}"
|
||||||
|
Click="SignOut_Click" Foreground="#C42B1C"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="UPDATE CHANNEL" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
Padding="14,12" Margin="0,0,0,16">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Beta updates" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<TextBlock Text="Receive early access builds — may be less stable"
|
||||||
|
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Grid.Column="1" x:Name="BetaToggle" VerticalAlignment="Center"
|
||||||
|
Checked="BetaToggle_Changed" Unchecked="BetaToggle_Changed"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="AUTO-REFRESH" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
Padding="14,12" Margin="0,0,0,16">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="Refresh interval" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox Grid.Column="1" x:Name="RefreshPicker" Width="100"
|
||||||
|
SelectionChanged="RefreshPicker_Changed"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
Padding="14,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Image Width="36" Height="36" Source="Assets/icon.png"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality" Margin="0,0,12,0"/>
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="ClaudeChecker" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
<Border Background="{DynamicResource BadgeBrush}" CornerRadius="3"
|
||||||
|
Margin="6,0,0,0" Padding="5,1">
|
||||||
|
<TextBlock x:Name="VersionLabel" FontSize="11"
|
||||||
|
Foreground="{DynamicResource SecondaryBrush}"
|
||||||
|
FontFamily="Consolas"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Built by superdooper86 & claude"
|
||||||
|
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="CheckUpdateButton"
|
||||||
|
Style="{StaticResource SmallButton}"
|
||||||
|
Click="CheckUpdate_Click"/>
|
||||||
|
<TextBlock x:Name="UpdateAvailableText" FontSize="10"
|
||||||
|
Foreground="{DynamicResource BlueBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Visibility="Collapsed" Margin="0,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Update panel -->
|
||||||
|
<Border x:Name="UpdatePanel" Visibility="Collapsed"
|
||||||
|
Background="{DynamicResource BackgroundBrush}">
|
||||||
|
<DockPanel>
|
||||||
|
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
BorderThickness="0,0,0,1" Background="{DynamicResource SurfaceBrush}" Padding="12,10">
|
||||||
|
<Grid>
|
||||||
|
<Button Content="← Back" HorizontalAlignment="Left"
|
||||||
|
Style="{StaticResource SmallButton}" BorderThickness="0"
|
||||||
|
Foreground="{DynamicResource BlueBrush}" Background="Transparent"
|
||||||
|
Click="CloseUpdate_Click"/>
|
||||||
|
<TextBlock Text="Update Available" FontSize="14" FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<Button x:Name="NotNowButton" Content="Not Now" HorizontalAlignment="Right"
|
||||||
|
Style="{StaticResource SmallButton}" Click="CloseUpdate_Click"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Margin="20,16">
|
||||||
|
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
Padding="14,12" Margin="0,0,0,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Image Width="44" Height="44" Source="Assets/icon.png"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality" Margin="0,0,14,0"/>
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||||
|
<TextBlock x:Name="CurrentVerLabel" FontSize="12" FontFamily="Consolas"
|
||||||
|
Foreground="{DynamicResource SecondaryBrush}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text=" → " FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="NewVerLabel" FontSize="13" FontWeight="SemiBold"
|
||||||
|
FontFamily="Consolas" Foreground="{DynamicResource BlueBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="ReleaseNotesText" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource SecondaryBrush}" MaxHeight="160"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="UpdateStatusText" Foreground="{DynamicResource SecondaryBrush}"
|
||||||
|
FontSize="11" TextWrapping="Wrap" TextAlignment="Center"
|
||||||
|
Visibility="Collapsed" Margin="0,0,0,8"/>
|
||||||
|
<ProgressBar x:Name="UpdateProgress" Visibility="Collapsed"
|
||||||
|
Height="4" Margin="0,0,0,12" Foreground="{DynamicResource BlueBrush}"/>
|
||||||
|
<TextBlock Text="ClaudeChecker will download, replace itself, and relaunch automatically."
|
||||||
|
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
|
||||||
|
TextWrapping="Wrap" TextAlignment="Center" Margin="0,0,0,12"/>
|
||||||
|
<Button x:Name="InstallButton" Content="⬇ Download & Install"
|
||||||
|
Style="{StaticResource PrimaryButton}" HorizontalAlignment="Stretch"
|
||||||
|
Click="Install_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
using ClaudeCheckerWindows.Controls;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows;
|
||||||
|
|
||||||
|
public partial class PopupWindow : Window
|
||||||
|
{
|
||||||
|
private static readonly UsageViewModel VM = App.ViewModel;
|
||||||
|
private static readonly UpdateManager Updater = App.Updater;
|
||||||
|
private readonly DispatcherTimer _clockTimer;
|
||||||
|
|
||||||
|
|
||||||
|
[DllImport("dwmapi.dll")]
|
||||||
|
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
|
||||||
|
|
||||||
|
private void ApplyTitleBarTheme()
|
||||||
|
{
|
||||||
|
var hwnd = new WindowInteropHelper(this).Handle;
|
||||||
|
if (hwnd == IntPtr.Zero) return;
|
||||||
|
int dark = ThemeManager.IsDark ? 1 : 0;
|
||||||
|
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PopupWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName));
|
||||||
|
Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName));
|
||||||
|
|
||||||
|
_clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||||
|
_clockTimer.Tick += (_, _) => UpdateFooter();
|
||||||
|
_clockTimer.Start();
|
||||||
|
|
||||||
|
InitSettings();
|
||||||
|
RebuildCards();
|
||||||
|
UpdateBannerState();
|
||||||
|
UpdateFooter();
|
||||||
|
|
||||||
|
Closing += (_, e) => { e.Cancel = true; Hide(); };
|
||||||
|
SourceInitialized += (_, _) => ApplyTitleBarTheme();
|
||||||
|
ThemeManager.ThemeChanged += () => { ApplyTitleBarTheme(); RebuildCards(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card rendering ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void RebuildCards()
|
||||||
|
{
|
||||||
|
CardsPanel.Children.Clear();
|
||||||
|
foreach (var limit in VM.Limits)
|
||||||
|
CardsPanel.Children.Add(BuildCard(limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UIElement BuildCard(AgentLimit limit)
|
||||||
|
{
|
||||||
|
var accent = new SolidColorBrush(Color.FromRgb(0xF9, 0x73, 0x16));
|
||||||
|
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
|
||||||
|
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
|
||||||
|
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
|
||||||
|
|
||||||
|
// Gauge
|
||||||
|
var gauge = new GaugeControl { Width = 76, Height = 76, Percent = limit.UsedPercent, Accent = accent };
|
||||||
|
|
||||||
|
// Live badge
|
||||||
|
var liveBadge = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(30, 0, 160, 0)),
|
||||||
|
CornerRadius = new CornerRadius(4),
|
||||||
|
Padding = new Thickness(6, 2, 6, 2),
|
||||||
|
Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
Visibility = limit.IsLive ? Visibility.Visible : Visibility.Collapsed,
|
||||||
|
Child = new TextBlock { Text = "Live", FontSize = 10, FontWeight = FontWeights.Medium,
|
||||||
|
Foreground = new SolidColorBrush(Color.FromRgb(0, 120, 0)) }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage badge
|
||||||
|
var usageBadge = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
|
||||||
|
CornerRadius = new CornerRadius(4),
|
||||||
|
Padding = new Thickness(6, 2, 6, 2),
|
||||||
|
Child = new TextBlock { Text = $"Usg: {limit.UsageLabel}", FontSize = 10,
|
||||||
|
FontWeight = FontWeights.Medium, Foreground = secondary }
|
||||||
|
};
|
||||||
|
|
||||||
|
var badgeStack = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
badgeStack.Children.Add(liveBadge);
|
||||||
|
badgeStack.Children.Add(usageBadge);
|
||||||
|
|
||||||
|
var nameStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
nameStack.Children.Add(new TextBlock { Text = "✦", FontSize = 11, Foreground = accent, VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11,
|
||||||
|
Foreground = secondary, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
|
||||||
|
var headerRow = new Grid();
|
||||||
|
headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
Grid.SetColumn(nameStack, 0);
|
||||||
|
Grid.SetColumn(badgeStack, 1);
|
||||||
|
headerRow.Children.Add(nameStack);
|
||||||
|
headerRow.Children.Add(badgeStack);
|
||||||
|
|
||||||
|
var progress = new ProgressBar
|
||||||
|
{
|
||||||
|
Value = limit.UsedPercent, Maximum = 100, Height = 4,
|
||||||
|
Margin = new Thickness(0, 4, 0, 4),
|
||||||
|
Foreground = accent,
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(20, 0, 0, 0)),
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
var timeStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
timeStack.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
timeStack.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12,
|
||||||
|
FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center });
|
||||||
|
|
||||||
|
var resetText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}",
|
||||||
|
FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
var timeRow = new Grid();
|
||||||
|
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
Grid.SetColumn(timeStack, 0);
|
||||||
|
Grid.SetColumn(resetText, 1);
|
||||||
|
timeRow.Children.Add(timeStack);
|
||||||
|
timeRow.Children.Add(resetText);
|
||||||
|
|
||||||
|
var sparkline = new SparklineControl
|
||||||
|
{
|
||||||
|
Height = 28, Margin = new Thickness(0, 8, 0, 0), LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
|
||||||
|
Data = limit.BurnHistory.Count > 0 ? limit.BurnHistory : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
infoPanel.Children.Add(headerRow);
|
||||||
|
infoPanel.Children.Add(progress);
|
||||||
|
infoPanel.Children.Add(timeRow);
|
||||||
|
infoPanel.Children.Add(sparkline);
|
||||||
|
|
||||||
|
var cardGrid = new Grid { Margin = new Thickness(16, 12, 16, 12) };
|
||||||
|
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||||
|
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16, GridUnitType.Pixel) });
|
||||||
|
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||||
|
Grid.SetColumn(gauge, 0);
|
||||||
|
Grid.SetColumn(infoPanel, 2);
|
||||||
|
cardGrid.Children.Add(gauge);
|
||||||
|
cardGrid.Children.Add(infoPanel);
|
||||||
|
|
||||||
|
var card = new Border
|
||||||
|
{
|
||||||
|
Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
|
||||||
|
CornerRadius = new CornerRadius(6),
|
||||||
|
BorderBrush = border,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Margin = new Thickness(12, 0, 12, 0),
|
||||||
|
Child = cardGrid,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sectionLabel = new TextBlock
|
||||||
|
{
|
||||||
|
Text = limit.WindowLabel,
|
||||||
|
Style = (Style)Application.Current.Resources["HeaderText"],
|
||||||
|
Margin = new Thickness(16, 12, 16, 6),
|
||||||
|
};
|
||||||
|
|
||||||
|
var section = new StackPanel();
|
||||||
|
section.Children.Add(sectionLabel);
|
||||||
|
section.Children.Add(card);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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))
|
||||||
|
{
|
||||||
|
RebuildCards();
|
||||||
|
UpdateFooter();
|
||||||
|
}
|
||||||
|
if (prop is nameof(UsageViewModel.IsSignedIn) or nameof(UsageViewModel.UserEmail))
|
||||||
|
{
|
||||||
|
InitSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUpdaterChanged(string? prop)
|
||||||
|
{
|
||||||
|
if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion))
|
||||||
|
{
|
||||||
|
UpdateBannerState();
|
||||||
|
UpdateAboutSection();
|
||||||
|
}
|
||||||
|
if (prop is nameof(UpdateManager.DownloadProgress))
|
||||||
|
UpdateProgress.Value = Updater.DownloadProgress * 100;
|
||||||
|
if (prop is nameof(UpdateManager.StatusMessage))
|
||||||
|
UpdateStatusText.Text = Updater.StatusMessage;
|
||||||
|
if (prop is nameof(UpdateManager.UpdateComplete) && Updater.UpdateComplete)
|
||||||
|
InstallButton.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAboutSection()
|
||||||
|
{
|
||||||
|
if (Updater.UpdateAvailable)
|
||||||
|
{
|
||||||
|
UpdateAvailableText.Text = $"v{Updater.LatestVersion} available";
|
||||||
|
UpdateAvailableText.Visibility = Visibility.Visible;
|
||||||
|
CheckUpdateButton.Content = "Install";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UpdateAvailableText.Visibility = Visibility.Collapsed;
|
||||||
|
CheckUpdateButton.Content = "Check";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateBannerState()
|
||||||
|
{
|
||||||
|
var visible = Updater.UpdateAvailable;
|
||||||
|
UpdateBanner.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
if (visible) UpdateBannerTitle.Text = $"Update available — v{Updater.LatestVersion}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFooter()
|
||||||
|
{
|
||||||
|
if (VM.LastUpdated.HasValue)
|
||||||
|
{
|
||||||
|
var diff = DateTime.Now - VM.LastUpdated.Value;
|
||||||
|
LastUpdatedText.Text = diff.TotalSeconds < 10 ? "Updated just now"
|
||||||
|
: $"Updated {(int)diff.TotalSeconds}s ago";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LastUpdatedText.Text = "Not yet updated";
|
||||||
|
}
|
||||||
|
LoadingBar.Visibility = VM.IsLoading ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void InitSettings()
|
||||||
|
{
|
||||||
|
VersionLabel.Text = $"v{Updater.CurrentVersion}";
|
||||||
|
BetaToggle.IsChecked = Updater.BetaChannel;
|
||||||
|
UpdateAboutSection();
|
||||||
|
|
||||||
|
var intervals = new[] { ("1 min", 60), ("2 min", 120), ("3 min", 180),
|
||||||
|
("4 min", 240), ("5 min", 300), ("10 min", 600) };
|
||||||
|
RefreshPicker.ItemsSource = intervals;
|
||||||
|
RefreshPicker.DisplayMemberPath = "Item1";
|
||||||
|
var idx = Array.FindIndex(intervals, x => x.Item2 == VM.RefreshInterval);
|
||||||
|
RefreshPicker.SelectedIndex = idx < 0 ? 1 : idx;
|
||||||
|
|
||||||
|
AuthPanel.Children.Clear();
|
||||||
|
var dot = new System.Windows.Shapes.Ellipse
|
||||||
|
{
|
||||||
|
Width = 7, Height = 7, Margin = new Thickness(0, 0, 6, 0),
|
||||||
|
Fill = VM.IsSignedIn ? Brushes.LimeGreen : Brushes.Orange,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
var label = new TextBlock
|
||||||
|
{
|
||||||
|
Text = VM.IsSignedIn ? $"Signed in — {VM.UserEmail}" : "Not signed in",
|
||||||
|
FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = (SolidColorBrush)Application.Current.Resources["TextBrush"],
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
};
|
||||||
|
var authRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||||||
|
authRow.Children.Add(dot);
|
||||||
|
authRow.Children.Add(label);
|
||||||
|
AuthPanel.Children.Add(authRow);
|
||||||
|
|
||||||
|
SignOutButton.Visibility = VM.IsSignedIn ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event handlers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void Settings_Click(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
InitSettings();
|
||||||
|
MainPanel.Visibility = Visibility.Collapsed;
|
||||||
|
SettingsPanel.Visibility = Visibility.Visible;
|
||||||
|
UpdatePanel.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
|
||||||
|
|
||||||
|
private void ShowMain()
|
||||||
|
{
|
||||||
|
MainPanel.Visibility = Visibility.Visible;
|
||||||
|
SettingsPanel.Visibility = Visibility.Collapsed;
|
||||||
|
UpdatePanel.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
|
||||||
|
|
||||||
|
private void UpdateBanner_Click(object s, RoutedEventArgs e) => ShowUpdatePanel();
|
||||||
|
|
||||||
|
private void ShowUpdatePanel()
|
||||||
|
{
|
||||||
|
CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
|
||||||
|
NewVerLabel.Text = $"v{Updater.LatestVersion}";
|
||||||
|
ReleaseNotesText.Text = Updater.ReleaseNotes;
|
||||||
|
MainPanel.Visibility = Visibility.Collapsed;
|
||||||
|
SettingsPanel.Visibility = Visibility.Collapsed;
|
||||||
|
UpdatePanel.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
|
||||||
|
|
||||||
|
private async void Install_Click(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
NotNowButton.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateStatusText.Visibility = Visibility.Visible;
|
||||||
|
UpdateProgress.Visibility = Visibility.Visible;
|
||||||
|
await Updater.DownloadAndInstallAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SignIn_Click(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var login = new LoginWindow { Owner = this };
|
||||||
|
if (login.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
// Brief pause so the LoginWindow WebView2 fully releases its user data folder lock
|
||||||
|
// before RefreshAsync may create another WebView2 on the same folder.
|
||||||
|
await Task.Delay(1500);
|
||||||
|
await VM.RefreshAsync();
|
||||||
|
InitSettings();
|
||||||
|
}
|
||||||
|
Show();
|
||||||
|
Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SignOut_Click(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
await VM.SignOutAsync();
|
||||||
|
InitSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BetaToggle_Changed(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Updater.BetaChannel = BetaToggle.IsChecked == true;
|
||||||
|
_ = Updater.CheckForUpdatesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPicker_Changed(object s, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (RefreshPicker.SelectedItem is ValueTuple<string, int> selected)
|
||||||
|
{
|
||||||
|
VM.RefreshInterval = selected.Item2;
|
||||||
|
((App)Application.Current).ScheduleTimer(selected.Item2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void CheckUpdate_Click(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Updater.UpdateAvailable) { ShowUpdatePanel(); return; }
|
||||||
|
CheckUpdateButton.Content = "…";
|
||||||
|
await Updater.CheckForUpdatesAsync();
|
||||||
|
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
|
## What's new in beta.23
|
||||||
|
|
||||||
|
- About section now shows "vX.X.X available" label when an update is available (not just button label change)
|
||||||
|
- Sign-in: fixed app showing signed out after login window closes — cookies and cached auth now correctly set IsSignedIn=true
|
||||||
|
- Sign-in: added brief delay before refresh so WebView2 user data folder is fully released by LoginWindow before reuse
|
||||||
|
- Wider cookie domain filter (anthropic.com included alongside claude.ai)
|
||||||
|
- Broader JS paths for email and org ID in bootstrap response
|
||||||
@@ -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,461 @@
|
|||||||
|
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 = "Pro";
|
||||||
|
private DateTime? _lastUpdated;
|
||||||
|
private OverageSpendLimit? _overage;
|
||||||
|
private PrepaidCredits? _prepaid;
|
||||||
|
|
||||||
|
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); }
|
||||||
|
|
||||||
|
private int _refreshInterval = 120;
|
||||||
|
public int RefreshInterval
|
||||||
|
{
|
||||||
|
get => _refreshInterval;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Set(ref _refreshInterval, value);
|
||||||
|
AppSettings.Default.RefreshInterval = value;
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _showInTaskbar = true;
|
||||||
|
public bool ShowInTaskbar
|
||||||
|
{
|
||||||
|
get => _showInTaskbar;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Set(ref _showInTaskbar, value);
|
||||||
|
AppSettings.Default.ShowInTaskbar = value;
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UsageViewModel()
|
||||||
|
{
|
||||||
|
_refreshInterval = AppSettings.Default.RefreshInterval > 0
|
||||||
|
? AppSettings.Default.RefreshInterval : 120;
|
||||||
|
_showInTaskbar = AppSettings.Default.ShowInTaskbar;
|
||||||
|
LoadBurnHistory();
|
||||||
|
LoadPlaceholders();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cookies = await GetCookiesAsync();
|
||||||
|
|
||||||
|
// Check if we have any persistent proof that the user authenticated
|
||||||
|
bool hasCookies = cookies.Count > 0;
|
||||||
|
bool hasCachedAuth = !string.IsNullOrEmpty(AppSettings.Default.Email) ||
|
||||||
|
!string.IsNullOrEmpty(AppSettings.Default.OrgId);
|
||||||
|
|
||||||
|
if (!hasCookies && !hasCachedAuth)
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsSignedIn = false;
|
||||||
|
ErrorMessage = "Not signed in — click Sign In to authenticate.";
|
||||||
|
IsLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try HttpClient first (fast path) — may be rejected by server CORS/header checks
|
||||||
|
var (limits, overage, prepaid, email, orgId, ok) = hasCookies
|
||||||
|
? await TryHttpRefreshAsync(cookies)
|
||||||
|
: ([], null, null, null, null, false);
|
||||||
|
|
||||||
|
// Fall back to WebView2 (uses the shared browser session, not HttpClient)
|
||||||
|
if (!ok)
|
||||||
|
(limits, email, orgId) = await TryWebView2RefreshAsync();
|
||||||
|
|
||||||
|
// Fill in any blanks from cached values saved at login time
|
||||||
|
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
|
||||||
|
if (string.IsNullOrEmpty(orgId)) orgId = AppSettings.Default.OrgId;
|
||||||
|
if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cached = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
|
||||||
|
AppSettings.Default.UsageJson, JsonOpts);
|
||||||
|
limits = BuildLimits(cached);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var limit in limits)
|
||||||
|
{
|
||||||
|
var key = limit.Window.ToString();
|
||||||
|
if (!_burnHistory.ContainsKey(key)) _burnHistory[key] = [];
|
||||||
|
_burnHistory[key].Add(limit.UsedPercent);
|
||||||
|
if (_burnHistory[key].Count > MaxHistorySamples)
|
||||||
|
_burnHistory[key].RemoveAt(0);
|
||||||
|
limit.BurnHistory = [.. _burnHistory[key]];
|
||||||
|
}
|
||||||
|
SaveBurnHistory();
|
||||||
|
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
Limits = limits;
|
||||||
|
Overage = overage;
|
||||||
|
Prepaid = prepaid;
|
||||||
|
if (!string.IsNullOrEmpty(email)) UserEmail = email;
|
||||||
|
// Trust that cookies / cached auth mean the user IS signed in,
|
||||||
|
// even if the live fetch failed this cycle.
|
||||||
|
IsSignedIn = true;
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdated = DateTime.Now;
|
||||||
|
IsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ErrorMessage = ex.Message;
|
||||||
|
IsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, string?, string?, bool)>
|
||||||
|
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = BuildClient(cookies);
|
||||||
|
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
|
||||||
|
if (!bootstrapResp.IsSuccessStatusCode)
|
||||||
|
return ([], null, null, null, null, false);
|
||||||
|
|
||||||
|
var (email, orgId) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(orgId))
|
||||||
|
orgId = await FetchOrgIdFromListAsync(http);
|
||||||
|
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
|
||||||
|
orgId = AppSettings.Default.OrgId;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(orgId))
|
||||||
|
return ([], null, null, email, orgId, true);
|
||||||
|
|
||||||
|
AppSettings.Default.OrgId = orgId;
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
|
||||||
|
var ut = FetchAsync<UsageResponse>(http, $"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);
|
||||||
|
|
||||||
|
return (BuildLimits(ut.Result), ot.Result, pt.Result, email, orgId, true);
|
||||||
|
}
|
||||||
|
catch { return ([], null, null, null, null, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string script = @"(async()=>{try{
|
||||||
|
const b=await(await fetch('/api/bootstrap',{headers:{accept:'application/json'}})).json();
|
||||||
|
const id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
|
||||||
|
||b?.default_organization?.uuid||null;
|
||||||
|
const em=b?.account?.email_address||b?.account?.email||b?.email||null;
|
||||||
|
if(!id)return{email:em,orgId:null,usage:null};
|
||||||
|
const u=await(await fetch('/api/organizations/'+id+'/usage',{headers:{accept:'application/json'}})).json();
|
||||||
|
return{email:em,orgId:id,usage:u};
|
||||||
|
}catch(ex){return null;}})()";
|
||||||
|
|
||||||
|
var resultJson = await Application.Current.Dispatcher.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
var host = new WebViewFetchWindow();
|
||||||
|
host.Show();
|
||||||
|
try { return await host.FetchAsync("https://claude.ai", script); }
|
||||||
|
finally { host.Close(); }
|
||||||
|
}).Task.Unwrap();
|
||||||
|
|
||||||
|
if (resultJson == null || resultJson == "null") return ([], null, null);
|
||||||
|
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(resultJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
string? email = root.TryGetProperty("email", out var em) && em.ValueKind == System.Text.Json.JsonValueKind.String
|
||||||
|
? em.GetString() : null;
|
||||||
|
string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == System.Text.Json.JsonValueKind.String
|
||||||
|
? oi.GetString() : null;
|
||||||
|
|
||||||
|
List<AgentLimit> limits = [];
|
||||||
|
if (root.TryGetProperty("usage", out var us) && us.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
var usage = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
|
||||||
|
us.GetRawText(), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
limits = BuildLimits(usage);
|
||||||
|
|
||||||
|
// Cache for next time
|
||||||
|
AppSettings.Default.UsageJson = us.GetRawText();
|
||||||
|
if (!string.IsNullOrEmpty(orgId)) AppSettings.Default.OrgId = orgId;
|
||||||
|
if (!string.IsNullOrEmpty(email)) AppSettings.Default.Email = email;
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (limits, email, orgId);
|
||||||
|
}
|
||||||
|
catch { return ([], null, null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SignOutAsync()
|
||||||
|
{
|
||||||
|
AppSettings.Default.CookieStore = "";
|
||||||
|
AppSettings.Default.OrgId = "";
|
||||||
|
AppSettings.Default.Email = "";
|
||||||
|
AppSettings.Default.UsageJson = "";
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsSignedIn = false;
|
||||||
|
UserEmail = "";
|
||||||
|
ErrorMessage = "Signed out.";
|
||||||
|
Limits = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<List<(string Name, string Value, string Domain, string Path)>> GetCookiesAsync()
|
||||||
|
{
|
||||||
|
var raw = AppSettings.Default.CookieStore;
|
||||||
|
if (string.IsNullOrEmpty(raw)) return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<CookieEntry>>(raw)
|
||||||
|
?.Select(c => (c.Name, c.Value, c.Domain, c.Path))
|
||||||
|
.ToList() ?? [];
|
||||||
|
}
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveCookies(IEnumerable<CoreWebView2Cookie> cookies)
|
||||||
|
{
|
||||||
|
var entries = cookies
|
||||||
|
.Where(c => c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"))
|
||||||
|
.Select(c => new CookieEntry { Name = c.Name, Value = c.Value, Domain = c.Domain, Path = c.Path })
|
||||||
|
.ToList();
|
||||||
|
AppSettings.Default.CookieStore = JsonSerializer.Serialize(entries);
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient BuildClient(List<(string Name, string Value, string Domain, string Path)> cookies)
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler { UseCookies = false };
|
||||||
|
var http = new HttpClient(handler);
|
||||||
|
http.DefaultRequestHeaders.Add("accept", "application/json");
|
||||||
|
http.DefaultRequestHeaders.Add("User-Agent",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
|
||||||
|
var cookieHeader = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
|
||||||
|
http.DefaultRequestHeaders.Add("Cookie", cookieHeader);
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T?> FetchAsync<T>(HttpClient http, string url) where T : class
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await http.GetAsync(url);
|
||||||
|
if (!resp.IsSuccessStatusCode) return null;
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<T>(json, JsonOpts);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID)
|
||||||
|
private static (string? Email, string? OrgId) ParseBootstrap(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
string? email = null;
|
||||||
|
string? orgId = null;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("account", out var acct) &&
|
||||||
|
acct.TryGetProperty("email_address", out var em))
|
||||||
|
email = em.GetString();
|
||||||
|
|
||||||
|
// Path 1: memberships[0].organization.uuid
|
||||||
|
if (root.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var first = mems[0];
|
||||||
|
if (first.TryGetProperty("organization", out var org) &&
|
||||||
|
org.TryGetProperty("uuid", out var uuid))
|
||||||
|
orgId = uuid.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: organizations[0].uuid (flat list on root)
|
||||||
|
if (string.IsNullOrEmpty(orgId) &&
|
||||||
|
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
if (orgs[0].TryGetProperty("uuid", out var uuid))
|
||||||
|
orgId = uuid.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (email, orgId);
|
||||||
|
}
|
||||||
|
catch { return (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 void LoadPlaceholders()
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
Limits =
|
||||||
|
[
|
||||||
|
new() { Window = WindowKind.FiveHour, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddHours(1) },
|
||||||
|
new() { Window = WindowKind.SevenDay, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddDays(7) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,59 @@
|
|||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using Microsoft.Web.WebView2.Wpf;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows;
|
||||||
|
|
||||||
|
// Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
|
||||||
|
// Uses the same user data folder as LoginWindow so the session is shared.
|
||||||
|
internal sealed class WebViewFetchWindow : Window
|
||||||
|
{
|
||||||
|
private readonly WebView2 _wv = new();
|
||||||
|
|
||||||
|
public WebViewFetchWindow()
|
||||||
|
{
|
||||||
|
Width = 1;
|
||||||
|
Height = 1;
|
||||||
|
Left = -9999;
|
||||||
|
Top = -9999;
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
WindowStyle = WindowStyle.None;
|
||||||
|
AllowsTransparency = true;
|
||||||
|
Opacity = 0;
|
||||||
|
Content = _wv;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
|
||||||
|
{
|
||||||
|
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"ClaudeChecker", "WebView2"));
|
||||||
|
|
||||||
|
await _wv.EnsureCoreWebView2Async(env);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<string?>();
|
||||||
|
|
||||||
|
EventHandler<CoreWebView2NavigationCompletedEventArgs>? handler = null;
|
||||||
|
handler = async (_, e) =>
|
||||||
|
{
|
||||||
|
_wv.CoreWebView2.NavigationCompleted -= handler;
|
||||||
|
if (!e.IsSuccess) { tcs.TrySetResult(null); return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _wv.CoreWebView2.ExecuteScriptAsync(script);
|
||||||
|
tcs.TrySetResult(result);
|
||||||
|
}
|
||||||
|
catch { tcs.TrySetResult(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
_wv.CoreWebView2.NavigationCompleted += handler;
|
||||||
|
_wv.CoreWebView2.Navigate(navigateUrl);
|
||||||
|
|
||||||
|
_ = Task.Delay(timeoutMs).ContinueWith(_ => tcs.TrySetResult(null));
|
||||||
|
|
||||||
|
return await tcs.Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user