Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,71 @@
|
|||||||
|
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.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git fetch origin main
|
||||||
|
git checkout main
|
||||||
|
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}' > version-windows-beta.json
|
||||||
|
git add version-windows-beta.json
|
||||||
|
git commit -m "Windows beta release ${{ github.ref_name }}"
|
||||||
|
git push origin main
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Release Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'win-v*'
|
||||||
|
- '!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.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git fetch origin main
|
||||||
|
git checkout main
|
||||||
|
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}' > version-windows.json
|
||||||
|
git add version-windows.json
|
||||||
|
git commit -m "Windows release ${{ github.ref_name }}"
|
||||||
|
git push origin main
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -911,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)
|
||||||
|
|||||||
@@ -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.18</string>
|
<string>1.1.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>66</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,16 +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>] = [:]
|
||||||
|
|
||||||
// Background WKWebView for API calls via WebKit navigation.
|
|
||||||
// Hosted in a hidden NSWindow to keep the WebKit process active.
|
|
||||||
private var apiWebView: WKWebView?
|
|
||||||
private var apiWindow: NSWindow?
|
|
||||||
private var currentFetchDelegate: APIFetchDelegate?
|
|
||||||
|
|
||||||
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
|
||||||
@@ -44,68 +41,14 @@ class UsageViewModel: ObservableObject {
|
|||||||
burnHistoryStore = saved
|
burnHistoryStore = saved
|
||||||
}
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
setupAPIWebView()
|
|
||||||
Task { await checkInitialSignInState() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Background API WebView
|
|
||||||
|
|
||||||
private func setupAPIWebView() {
|
|
||||||
let config = WKWebViewConfiguration()
|
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
|
||||||
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
|
||||||
apiWebView = wv
|
|
||||||
|
|
||||||
// Hosting in a 1×1 transparent window keeps the WebKit process active.
|
|
||||||
let window = NSWindow(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
|
|
||||||
styleMask: .borderless,
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false)
|
|
||||||
window.alphaValue = 0.0
|
|
||||||
window.ignoresMouseEvents = true
|
|
||||||
window.isReleasedWhenClosed = false
|
|
||||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
|
||||||
window.contentView?.addSubview(wv)
|
|
||||||
window.orderFrontRegardless()
|
|
||||||
apiWindow = window
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called after login: adopt the login WebView (proven authenticated) and refresh.
|
|
||||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
|
||||||
loginWebView.removeFromSuperview()
|
|
||||||
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
|
|
||||||
apiWindow?.contentView?.addSubview(loginWebView)
|
|
||||||
apiWebView = loginWebView
|
|
||||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches an API path by navigating the WebView to the URL and reading the response.
|
|
||||||
// Navigation lets WebKit send full browser headers and cookies automatically —
|
|
||||||
// more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors.
|
|
||||||
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
|
|
||||||
guard let wv = apiWebView else { throw AppError.detail("no api webview") }
|
|
||||||
return try await withCheckedThrowingContinuation { cont in
|
|
||||||
let delegate = APIFetchDelegate(continuation: cont)
|
|
||||||
currentFetchDelegate = delegate
|
|
||||||
wv.navigationDelegate = delegate
|
|
||||||
wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
@@ -114,7 +57,6 @@ class UsageViewModel: ObservableObject {
|
|||||||
extraUsage = nil
|
extraUsage = nil
|
||||||
prepaidCredits = nil
|
prepaidCredits = nil
|
||||||
overageSpendLimit = nil
|
overageSpendLimit = nil
|
||||||
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -124,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 }
|
|
||||||
|
|
||||||
// Sequential — each call navigates the shared WebView to the next API URL.
|
|
||||||
let usage = try await fetchUsage(orgId: orgId)
|
|
||||||
let prepaid = try? await fetchPrepaidCredits(orgId: orgId)
|
|
||||||
let overage = try? await fetchOverageSpendLimit(orgId: orgId)
|
|
||||||
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
|
||||||
|
|
||||||
@@ -162,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 {
|
||||||
@@ -180,71 +110,70 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap
|
|
||||||
|
|
||||||
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
|
||||||
let (status, body) = try await webViewFetch("/api/bootstrap")
|
|
||||||
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
|
||||||
guard status == 200 else { throw AppError.detail("bootstrap \(status): \(body.prefix(80))") }
|
|
||||||
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
|
|
||||||
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
|
||||||
throw AppError.notAuthenticated
|
|
||||||
}
|
|
||||||
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 webViewFetch("/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 webViewFetch("/api/organizations/\(orgId)/usage")
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||||
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
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 webViewFetch("/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 webViewFetch("/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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,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 }
|
||||||
@@ -331,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(
|
||||||
@@ -339,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
|
||||||
}
|
}
|
||||||
@@ -361,68 +296,13 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - API Fetch Delegate
|
|
||||||
|
|
||||||
// Captures the HTTP status code and response body from a WebView navigation.
|
|
||||||
// Used by webViewFetch to turn a navigation into an async (statusCode, body) result.
|
|
||||||
private class APIFetchDelegate: NSObject, WKNavigationDelegate {
|
|
||||||
private let continuation: CheckedContinuation<(Int, String), Error>
|
|
||||||
private var capturedStatus = 0
|
|
||||||
private var finished = false
|
|
||||||
|
|
||||||
init(continuation: CheckedContinuation<(Int, String), Error>) {
|
|
||||||
self.continuation = continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
private func complete(_ result: Result<(Int, String), Error>) {
|
|
||||||
guard !finished else { return }
|
|
||||||
finished = true
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
|
|
||||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
|
||||||
if let http = response.response as? HTTPURLResponse {
|
|
||||||
capturedStatus = http.statusCode
|
|
||||||
}
|
|
||||||
decisionHandler(.allow)
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
||||||
let status = capturedStatus
|
|
||||||
// Detect auth redirect: if WebKit followed a 302 to /login, finalURL changes.
|
|
||||||
let finalURL = webView.url?.absoluteString ?? ""
|
|
||||||
if finalURL.contains("/login") || finalURL.contains("/auth") {
|
|
||||||
complete(.success((401, "redirected:\(finalURL)")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
webView.evaluateJavaScript("document.body.innerText ?? ''") { [weak self] result, error in
|
|
||||||
if let body = result as? String {
|
|
||||||
self?.complete(.success((status, body)))
|
|
||||||
} else {
|
|
||||||
self?.complete(.failure(AppError.detail("body read: \(error?.localizedDescription ?? "nil")")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
||||||
complete(.failure(AppError.detail("nav: \(error.localizedDescription.prefix(80))")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
||||||
complete(.failure(AppError.detail("prov: \(error.localizedDescription.prefix(80))")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,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.17) <!-- 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
|
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.17",
|
"version": "1.1.3-beta.5",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/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.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires\n- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
|
"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,102 @@
|
|||||||
|
<Application x:Class="ClaudeCheckerWindows.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
ShutdownMode="OnExplicitShutdown">
|
||||||
|
<Application.Resources>
|
||||||
|
<SolidColorBrush x:Key="BackgroundBrush" Color="#1C1C1E"/>
|
||||||
|
<SolidColorBrush x:Key="CardBrush" Color="#2C2C2E"/>
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#F97316"/>
|
||||||
|
<SolidColorBrush x:Key="SecondaryBrush" Color="#888888"/>
|
||||||
|
<SolidColorBrush x:Key="BorderBrush" Color="#38383A"/>
|
||||||
|
<SolidColorBrush x:Key="BlueBrush" Color="#3B82F6"/>
|
||||||
|
|
||||||
|
<Style x:Key="HeaderText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="10"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SecondaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="IconButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderBrush" Value="#33FFFFFF"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0.5"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SecondaryBrush}"/>
|
||||||
|
<Setter Property="Width" Value="26"/>
|
||||||
|
<Setter Property="Height" Value="26"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="5">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1AFFFFFF"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{StaticResource 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="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}" CornerRadius="6"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#2563EB"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="SmallButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderBrush" Value="#33FFFFFF"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0.5"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SecondaryBrush}"/>
|
||||||
|
<Setter Property="Padding" Value="8,4"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1AFFFFFF"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ScrollViewer">
|
||||||
|
<Setter Property="VerticalScrollBarVisibility" Value="Hidden"/>
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
base.OnStartup(e);
|
||||||
|
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.Closed += (_, _) => _popup = null;
|
||||||
|
}
|
||||||
|
PositionPopup();
|
||||||
|
_popup.Show();
|
||||||
|
_popup.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PositionPopup()
|
||||||
|
{
|
||||||
|
if (_popup == null) return;
|
||||||
|
var workArea = SystemParameters.WorkArea;
|
||||||
|
_popup.Left = workArea.Right - _popup.Width - 8;
|
||||||
|
_popup.Top = workArea.Bottom - _popup.Height - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,43 @@
|
|||||||
|
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 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,34 @@
|
|||||||
|
<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>
|
||||||
|
<None Update="Assets\icon.png">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Brush = System.Windows.Media.Brush;
|
||||||
|
using Pen = System.Windows.Media.Pen;
|
||||||
|
using Point = System.Windows.Point;
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows.Controls;
|
||||||
|
|
||||||
|
public class GaugeControl : FrameworkElement
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty PercentProperty =
|
||||||
|
DependencyProperty.Register(nameof(Percent), typeof(double), typeof(GaugeControl),
|
||||||
|
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty AccentProperty =
|
||||||
|
DependencyProperty.Register(nameof(Accent), typeof(Brush), typeof(GaugeControl),
|
||||||
|
new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||||
|
|
||||||
|
public double Percent { get => (double)GetValue(PercentProperty); set => SetValue(PercentProperty, value); }
|
||||||
|
public Brush Accent { get => (Brush)GetValue(AccentProperty); set => SetValue(AccentProperty, value); }
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg,
|
||||||
|
new Pen(new SolidColorBrush(Color.FromArgb(28, 255, 255, 255)), 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 pctText = new FormattedText(
|
||||||
|
$"{(int)Math.Round(Percent)}%",
|
||||||
|
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||||
|
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
|
||||||
|
14, Brushes.White, 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(Color.FromRgb(0x88, 0x88, 0x88)), 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,53 @@
|
|||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeCheckerWindows;
|
||||||
|
|
||||||
|
public partial class LoginWindow : Window
|
||||||
|
{
|
||||||
|
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) return;
|
||||||
|
var uri = Browser.CoreWebView2.Source;
|
||||||
|
if (uri.Contains("claude.ai") && !uri.Contains("/login"))
|
||||||
|
{
|
||||||
|
// Likely signed in — enable done button
|
||||||
|
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
|
||||||
|
var hasSess = cookies.Any(c => c.Name.Contains("session") || c.Name.Contains("sk-"));
|
||||||
|
await Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
DoneButton.IsEnabled = hasSess || uri.Contains("/chats") || uri.Contains("/new");
|
||||||
|
StatusText.Text = DoneButton.IsEnabled
|
||||||
|
? "Signed in! Click Done to continue."
|
||||||
|
: "Waiting for sign-in…";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Done_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
|
||||||
|
UsageViewModel.SaveCookies(cookies);
|
||||||
|
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,251 @@
|
|||||||
|
<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"
|
||||||
|
SizeToContent="Height"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Background="Transparent"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Topmost="True"
|
||||||
|
ResizeMode="NoResize">
|
||||||
|
|
||||||
|
<Border Background="{StaticResource BackgroundBrush}"
|
||||||
|
CornerRadius="12"
|
||||||
|
BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="0.5">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect BlurRadius="24" ShadowDepth="4" Opacity="0.5" Color="Black"/>
|
||||||
|
</Border.Effect>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<!-- Main panel -->
|
||||||
|
<StackPanel x:Name="MainPanel">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Grid Margin="16,11">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="6"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Image Grid.Column="0" Width="24" Height="24"
|
||||||
|
Source="Assets/icon.png" RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Text="ClaudeChecker"
|
||||||
|
FontSize="14" FontWeight="SemiBold" Foreground="White"
|
||||||
|
VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||||
|
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource IconButton}"
|
||||||
|
Click="Settings_Click" x:Name="SettingsButton">
|
||||||
|
<TextBlock Text="⚙" FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="4" Style="{StaticResource IconButton}"
|
||||||
|
Click="Close_Click">
|
||||||
|
<TextBlock Text="✕" FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Rectangle Height="0.5" Fill="{StaticResource BorderBrush}" Opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Update banner -->
|
||||||
|
<Border x:Name="UpdateBanner" Visibility="Collapsed"
|
||||||
|
Background="#0A3B82F6" 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="{StaticResource BlueBrush}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Grid.Column="1" Margin="10,0">
|
||||||
|
<TextBlock x:Name="UpdateBannerTitle" FontSize="12" FontWeight="SemiBold" Foreground="White"/>
|
||||||
|
<TextBlock Text="Tap to view what's new & install."
|
||||||
|
FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2" Content="Update"
|
||||||
|
Style="{StaticResource SmallButton}"
|
||||||
|
Background="{StaticResource BlueBrush}"
|
||||||
|
Foreground="White" Click="UpdateBanner_Click"/>
|
||||||
|
</Grid>
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect ShadowDepth="0" BlurRadius="0" Opacity="0"/>
|
||||||
|
</Border.Effect>
|
||||||
|
</Border>
|
||||||
|
<Rectangle x:Name="UpdateBannerDiv" Height="0.5" Fill="{StaticResource BorderBrush}"
|
||||||
|
Opacity="0.3" Visibility="Collapsed"/>
|
||||||
|
|
||||||
|
<!-- Limit cards -->
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Hidden" MaxHeight="600">
|
||||||
|
<StackPanel x:Name="CardsPanel" Margin="0,0,0,4"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Rectangle Height="0.5" Fill="{StaticResource BorderBrush}" Opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Grid Margin="16,9">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock x:Name="LastUpdatedText" Grid.Column="0"
|
||||||
|
FontSize="10" Foreground="#60888888" VerticalAlignment="Center"/>
|
||||||
|
<ProgressBar x:Name="LoadingBar" Grid.Column="1"
|
||||||
|
IsIndeterminate="True" Width="14" Height="14"
|
||||||
|
Visibility="Collapsed" Style="{x:Null}"/>
|
||||||
|
<Button Grid.Column="3" Content="↻ Refresh"
|
||||||
|
Style="{StaticResource SmallButton}" Click="Refresh_Click"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Settings panel -->
|
||||||
|
<Border x:Name="SettingsPanel" Visibility="Collapsed"
|
||||||
|
Background="{StaticResource BackgroundBrush}" CornerRadius="12">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="700">
|
||||||
|
<StackPanel Margin="16">
|
||||||
|
|
||||||
|
<Button Content="← Back" HorizontalAlignment="Left"
|
||||||
|
Style="{StaticResource SmallButton}"
|
||||||
|
BorderThickness="0" Foreground="{StaticResource BlueBrush}"
|
||||||
|
Click="BackFromSettings_Click" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Settings" FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="White" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- Auth -->
|
||||||
|
<TextBlock Text="🔐 Authentication" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<StackPanel x:Name="AuthPanel" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock Text="Your session is stored locally and persists across restarts."
|
||||||
|
FontSize="11" Foreground="{StaticResource SecondaryBrush}"
|
||||||
|
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||||
|
<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="#EF4444" Visibility="Collapsed"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Rectangle Height="0.5" Fill="{StaticResource BorderBrush}" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- Beta channel -->
|
||||||
|
<TextBlock Text="📡 Update channel" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<Grid Margin="0,0,0,16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Beta updates" FontSize="12" Foreground="White"/>
|
||||||
|
<TextBlock Text="Receive early access builds — may be less stable"
|
||||||
|
FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Grid.Column="1" x:Name="BetaToggle" VerticalAlignment="Center"
|
||||||
|
Checked="BetaToggle_Changed" Unchecked="BetaToggle_Changed"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Rectangle Height="0.5" Fill="{StaticResource BorderBrush}" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- Refresh interval -->
|
||||||
|
<TextBlock Text="⏱ Auto-refresh interval" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
|
||||||
|
<ComboBox x:Name="RefreshPicker" SelectionChanged="RefreshPicker_Changed"
|
||||||
|
Margin="0,0,0,16" FontSize="12"/>
|
||||||
|
|
||||||
|
<Rectangle Height="0.5" Fill="{StaticResource BorderBrush}" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<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="White"/>
|
||||||
|
<Border Background="#0FFFFFFF" CornerRadius="4" Margin="6,0,0,0" Padding="5,1">
|
||||||
|
<TextBlock x:Name="VersionLabel" FontSize="11"
|
||||||
|
Foreground="{StaticResource SecondaryBrush}"
|
||||||
|
FontFamily="Consolas"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Built by superdooper86 & claude"
|
||||||
|
FontSize="11" Foreground="{StaticResource SecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="2" x:Name="CheckUpdateButton"
|
||||||
|
Style="{StaticResource SmallButton}" Click="CheckUpdate_Click"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Update sheet -->
|
||||||
|
<Border x:Name="UpdatePanel" Visibility="Collapsed"
|
||||||
|
Background="{StaticResource BackgroundBrush}" CornerRadius="12">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Margin="16,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="Update ClaudeChecker" FontSize="14" FontWeight="SemiBold"
|
||||||
|
Foreground="White" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="Not Now" Style="{StaticResource SmallButton}"
|
||||||
|
Click="CloseUpdate_Click" x:Name="NotNowButton"/>
|
||||||
|
</Grid>
|
||||||
|
<Rectangle Height="0.5" Fill="{StaticResource BorderBrush}"/>
|
||||||
|
<StackPanel Margin="20">
|
||||||
|
<Grid Margin="0,0,0,16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Image Width="48" Height="48" Source="Assets/icon.png"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality" Margin="0,0,14,0"/>
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock x:Name="CurrentVerLabel" FontSize="12" FontFamily="Consolas"
|
||||||
|
Foreground="{StaticResource SecondaryBrush}"/>
|
||||||
|
<TextBlock Text=" → " FontSize="10"
|
||||||
|
Foreground="{StaticResource SecondaryBrush}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="NewVerLabel" FontSize="13" FontWeight="SemiBold"
|
||||||
|
FontFamily="Consolas" Foreground="{StaticResource BlueBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="ReleaseNotesText" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource SecondaryBrush}" Margin="0,4,0,0"
|
||||||
|
MaxHeight="180"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock x:Name="UpdateStatusText" Foreground="{StaticResource SecondaryBrush}"
|
||||||
|
FontSize="11" TextWrapping="Wrap" TextAlignment="Center"
|
||||||
|
Visibility="Collapsed" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<ProgressBar x:Name="UpdateProgress" Visibility="Collapsed"
|
||||||
|
Height="4" Margin="0,0,0,12" Foreground="{StaticResource BlueBrush}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="ClaudeChecker will download the update, replace itself, and relaunch automatically."
|
||||||
|
FontSize="11" Foreground="{StaticResource SecondaryBrush}"
|
||||||
|
TextWrapping="Wrap" TextAlignment="Center" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<Button x:Name="InstallButton" Content="⬇ Install Update"
|
||||||
|
Style="{StaticResource PrimaryButton}" HorizontalAlignment="Stretch"
|
||||||
|
Click="Install_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
using ClaudeCheckerWindows.Controls;
|
||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
Deactivated += (_, _) => Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
|
||||||
|
|
||||||
|
// 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(26, 0, 200, 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(Colors.LightGreen) }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage badge
|
||||||
|
var usageBadge = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(15, 255, 255, 255)),
|
||||||
|
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 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header row: Claude · Pro | [Live] [Usg]
|
||||||
|
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 = Brushes.White, 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);
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
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(18, 255, 255, 255)),
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Time row
|
||||||
|
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 = Brushes.White, 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);
|
||||||
|
|
||||||
|
// Sparkline
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Info panel
|
||||||
|
var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
|
||||||
|
infoPanel.Children.Add(headerRow);
|
||||||
|
infoPanel.Children.Add(progress);
|
||||||
|
infoPanel.Children.Add(timeRow);
|
||||||
|
infoPanel.Children.Add(sparkline);
|
||||||
|
|
||||||
|
// Card content grid
|
||||||
|
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(8),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.FromArgb(20, 255, 255, 255)),
|
||||||
|
BorderThickness = new Thickness(0.5),
|
||||||
|
Margin = new Thickness(8, 0, 8, 0),
|
||||||
|
Child = cardGrid,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sectionLabel = new TextBlock
|
||||||
|
{
|
||||||
|
Text = limit.WindowLabel,
|
||||||
|
Style = (Style)Application.Current.Resources["HeaderText"],
|
||||||
|
Margin = new Thickness(16, 10, 16, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUpdaterChanged(string? prop)
|
||||||
|
{
|
||||||
|
if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion))
|
||||||
|
UpdateBannerState();
|
||||||
|
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 UpdateBannerState()
|
||||||
|
{
|
||||||
|
var visible = Updater.UpdateAvailable;
|
||||||
|
UpdateBanner.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
UpdateBannerDiv.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;
|
||||||
|
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
|
||||||
|
|
||||||
|
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 = Brushes.White,
|
||||||
|
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 void Close_Click(object s, RoutedEventArgs e) => Hide();
|
||||||
|
|
||||||
|
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 void SignIn_Click(object s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var login = new LoginWindow { Owner = this };
|
||||||
|
if (login.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
_ = VM.RefreshAsync();
|
||||||
|
InitSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,3 @@
|
|||||||
|
## What's new
|
||||||
|
|
||||||
|
Initial Windows release.
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
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()
|
||||||
|
.GetName().Version?.ToString(3) ?? "1.0.0";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (BetaChannel && 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,310 @@
|
|||||||
|
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 string OrgId = "daf626a9-4924-4ff3-ba98-23b523062f8e";
|
||||||
|
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();
|
||||||
|
if (cookies.Count == 0)
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsSignedIn = false;
|
||||||
|
ErrorMessage = "Not signed in — click Sign In to authenticate.";
|
||||||
|
IsLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var http = BuildClient(cookies);
|
||||||
|
|
||||||
|
var usageTask = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{OrgId}/usage");
|
||||||
|
var overageTask = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{OrgId}/overage_spend_limit");
|
||||||
|
var prepaidTask = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{OrgId}/prepaid/credits");
|
||||||
|
var emailTask = FetchEmailAsync(http);
|
||||||
|
|
||||||
|
await Task.WhenAll(usageTask, overageTask, prepaidTask, emailTask);
|
||||||
|
|
||||||
|
var usage = usageTask.Result;
|
||||||
|
var limits = BuildLimits(usage);
|
||||||
|
|
||||||
|
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 = overageTask.Result;
|
||||||
|
Prepaid = prepaidTask.Result;
|
||||||
|
UserEmail = emailTask.Result ?? UserEmail;
|
||||||
|
IsSignedIn = true;
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdated = DateTime.Now;
|
||||||
|
IsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ErrorMessage = ex.Message;
|
||||||
|
IsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SignOutAsync()
|
||||||
|
{
|
||||||
|
AppSettings.Default.CookieStore = "";
|
||||||
|
AppSettings.Default.Save();
|
||||||
|
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsSignedIn = false;
|
||||||
|
UserEmail = "";
|
||||||
|
ErrorMessage = "Signed out.";
|
||||||
|
Limits = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie store: we persist cookies as JSON in settings after login
|
||||||
|
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"))
|
||||||
|
.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");
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> FetchEmailAsync(HttpClient http)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await http.GetAsync("https://claude.ai/api/bootstrap");
|
||||||
|
if (!resp.IsSuccessStatusCode) return null;
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var boot = JsonSerializer.Deserialize<BootstrapResponse>(json, JsonOpts);
|
||||||
|
return boot?.Account?.EmailAddress;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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; } = "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user