diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
deleted file mode 100644
index 09f4c4b..0000000
--- a/.github/workflows/release-beta.yml
+++ /dev/null
@@ -1,79 +0,0 @@
-name: Release Beta
-
-on:
- push:
- tags:
- - 'v*-beta*'
-
-jobs:
- release-beta:
- runs-on: macos-latest
- permissions:
- contents: write
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Get version from tag
- id: version
- run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
-
- - name: Build
- run: |
- xcodebuild \
- -project ClaudeChecker.xcodeproj \
- -scheme ClaudeChecker \
- -configuration Release \
- -derivedDataPath build \
- clean build
-
- - name: Zip app
- run: |
- ditto -c -k --keepParent \
- build/Build/Products/Release/ClaudeChecker.app \
- ClaudeChecker.zip
-
- - name: Create GitHub pre-release
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
- --title "$GITHUB_REF_NAME" \
- --prerelease \
- --notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Beta release ${{ steps.version.outputs.version }}')"
-
- - name: Update version-beta.json
- run: |
- notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Beta release ${{ steps.version.outputs.version }}")
- jq -n \
- --arg version "${{ steps.version.outputs.version }}" \
- --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
- --arg notes "$notes" \
- '{version: $version, url: $url, notes: $notes}' > version-beta.json
-
- - name: Update beta badge in README
- run: |
- shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g')
- tag="${{ github.ref_name }}"
- export BADGE="[](https://github.com/${{ github.repository }}/releases/tag/${tag}) "
- python3 -c "
- import os
- badge = os.environ['BADGE']
- lines = open('README.md').readlines()
- out = []
- for line in lines:
- out.append(badge + chr(10) if '' in line else line)
- open('README.md', 'w').writelines(out)
- "
-
- - name: Commit version-beta.json and README
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git add version-beta.json README.md
- git commit -m "Beta release ${{ github.ref_name }}"
- git pull --rebase origin main
- git push origin HEAD:main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 33a3d8d..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,111 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - 'v*'
-
-jobs:
- release:
- if: ${{ !contains(github.ref_name, '-') }}
- runs-on: macos-latest
- permissions:
- contents: write
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Get version from tag
- id: version
- run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
-
- - name: Build
- run: |
- xcodebuild \
- -project ClaudeChecker.xcodeproj \
- -scheme ClaudeChecker \
- -configuration Release \
- -derivedDataPath build \
- clean build
-
- - name: Zip app
- run: |
- ditto -c -k --keepParent \
- build/Build/Products/Release/ClaudeChecker.app \
- ClaudeChecker.zip
-
- - name: Compute SHA256
- id: sha
- run: echo "sha256=$(shasum -a 256 ClaudeChecker.zip | awk '{print $1}')" >> $GITHUB_OUTPUT
-
- - name: Create GitHub release
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
- --title "$GITHUB_REF_NAME" \
- --notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')"
-
- - name: Update version.json
- run: |
- notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Release ${{ steps.version.outputs.version }}")
- jq -n \
- --arg version "${{ steps.version.outputs.version }}" \
- --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
- --arg notes "$notes" \
- '{version: $version, url: $url, notes: $notes}' > version.json
-
- - name: Update Homebrew cask
- run: |
- sed -i '' \
- -e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
- -e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
- ../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
-
- - name: Update README badge
- env:
- NEW_VERSION: ${{ steps.version.outputs.version }}
- run: |
- python3 -c "
- import os, re
- v = os.environ['NEW_VERSION']
- content = open('README.md').read()
- content = re.sub(r'version-[0-9][^-]*-orange', 'version-' + v + '-orange', content)
- lines = content.splitlines(keepends=True)
- out = []
- for line in lines:
- if '' in line:
- continue
- out.append(line)
- if 'license-MIT-blue' in line:
- out.append('' + chr(10))
- open('README.md', 'w').writelines(out)
- "
-
- - name: Commit version.json and README
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git add version.json README.md
- git commit -m "Release ${{ github.ref_name }}"
- git pull --rebase origin main
- git push origin HEAD:main
-
- - name: Update Homebrew tap
- env:
- TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
- run: |
- git clone https://x-access-token:${{ secrets.TAP_TOKEN }}@github.com/${{ github.repository_owner }}/homebrew-tap /tmp/homebrew-tap
- sed -i '' \
- -e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
- -e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
- /tmp/homebrew-tap/Casks/claudechecker.rb
- cd /tmp/homebrew-tap
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git add Casks/claudechecker.rb
- git commit -m "Update claudechecker to ${{ github.ref_name }}"
- git push
diff --git a/version-windows-beta.json b/version-windows-beta.json
new file mode 100644
index 0000000..53abb1d
--- /dev/null
+++ b/version-windows-beta.json
@@ -0,0 +1,5 @@
+{
+ "version": "0.0.0",
+ "url": "",
+ "notes": ""
+}
\ No newline at end of file
diff --git a/version-windows.json b/version-windows.json
new file mode 100644
index 0000000..53abb1d
--- /dev/null
+++ b/version-windows.json
@@ -0,0 +1,5 @@
+{
+ "version": "0.0.0",
+ "url": "",
+ "notes": ""
+}
\ No newline at end of file
diff --git a/windows/App.xaml b/windows/App.xaml
new file mode 100644
index 0000000..8d9107c
--- /dev/null
+++ b/windows/App.xaml
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/App.xaml.cs b/windows/App.xaml.cs
new file mode 100644
index 0000000..2fc1f22
--- /dev/null
+++ b/windows/App.xaml.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Threading;
+using Forms = System.Windows.Forms;
+
+namespace ClaudeCheckerWindows;
+
+public partial class App : Application
+{
+ public static UsageViewModel ViewModel { get; } = new();
+ public static UpdateManager Updater { get; } = new();
+ // Persistent hidden WebView2 — shares the same user data folder as LoginWindow
+ // so its cookies (including cf_clearance) are always live. Used on every refresh
+ // for endpoints that need a real browser session (overage, prepaid).
+ public static WebViewFetchWindow BackgroundBrowser { get; } = new();
+
+ private Forms.NotifyIcon? _tray;
+ private PopupWindow? _popup;
+ private DispatcherTimer? _timer;
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ var logPath = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClaudeChecker", "startup_crash.txt");
+
+ AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
+ System.IO.File.WriteAllText(logPath, ex.ExceptionObject?.ToString() ?? "unknown");
+
+ DispatcherUnhandledException += (_, ex) =>
+ {
+ System.IO.File.WriteAllText(logPath, ex.Exception?.ToString() ?? "unknown");
+ ex.Handled = false;
+ };
+
+ base.OnStartup(e);
+ ThemeManager.Initialize();
+ SetupTray();
+ ShowPopup();
+ ScheduleTimer(ViewModel.RefreshInterval);
+
+ // Show background browser window on the UI thread before Task.Run
+ BackgroundBrowser.Show();
+
+ _ = Task.Run(async () =>
+ {
+ await ViewModel.LoadFromCacheAsync();
+ // Initialize the persistent background browser (navigates to claude.ai once)
+ await Application.Current.Dispatcher.InvokeAsync(
+ () => BackgroundBrowser.InitAsync()).Task.Unwrap();
+ await ViewModel.RefreshAsync();
+ await Updater.CheckForUpdatesAsync();
+ });
+ }
+
+ private void SetupTray()
+ {
+ _tray = new Forms.NotifyIcon
+ {
+ Text = "ClaudeChecker",
+ Visible = true,
+ Icon = LoadIcon(),
+ };
+
+ _tray.MouseClick += (_, e) =>
+ {
+ if (e.Button == Forms.MouseButtons.Left)
+ Dispatcher.InvokeAsync(TogglePopup);
+ };
+
+ _tray.ContextMenuStrip = BuildContextMenu();
+
+ ViewModel.PropertyChanged += (_, _) => UpdateTrayText();
+ Updater.PropertyChanged += (_, _) => UpdateTrayText();
+ }
+
+ private static System.Drawing.Icon LoadIcon()
+ {
+ try { return new System.Drawing.Icon("Assets/icon.ico"); }
+ catch { return System.Drawing.SystemIcons.Application; }
+ }
+
+ private Forms.ContextMenuStrip BuildContextMenu()
+ {
+ var menu = new Forms.ContextMenuStrip();
+ menu.Items.Add("Show ClaudeChecker", null, (_, _) => Dispatcher.InvokeAsync(ShowPopup));
+ menu.Items.Add(new Forms.ToolStripSeparator());
+ menu.Items.Add("Quit", null, (_, _) => Dispatcher.InvokeAsync(Quit));
+ return menu;
+ }
+
+ private void TogglePopup()
+ {
+ if (_popup == null || !_popup.IsVisible)
+ ShowPopup();
+ else
+ _popup.Hide();
+ }
+
+ private void ShowPopup()
+ {
+ if (_popup == null)
+ {
+ _popup = new PopupWindow();
+ }
+ _popup.Show();
+ _popup.Activate();
+ }
+
+ public void ScheduleTimer(int seconds)
+ {
+ _timer?.Stop();
+ _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) };
+ _timer.Tick += async (_, _) =>
+ {
+ await ViewModel.RefreshAsync();
+ await Updater.CheckForUpdatesAsync();
+ };
+ _timer.Start();
+ }
+
+ private void UpdateTrayText()
+ {
+ if (_tray == null) return;
+ var limits = ViewModel.Limits;
+ if (ViewModel.ShowInTaskbar && limits.Count >= 2)
+ {
+ var fh = limits.Find(l => l.Window == WindowKind.FiveHour);
+ var sd = limits.Find(l => l.Window == WindowKind.SevenDay);
+ if (fh != null && sd != null && fh.IsLive)
+ {
+ _tray.Text = $"ClaudeChecker {(int)fh.UsedPercent}% {(int)sd.UsedPercent}%";
+ return;
+ }
+ }
+ _tray.Text = "ClaudeChecker";
+ }
+
+ private void Quit()
+ {
+ _tray?.Dispose();
+ Shutdown();
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ _tray?.Dispose();
+ base.OnExit(e);
+ }
+}
diff --git a/windows/AppSettings.cs b/windows/AppSettings.cs
new file mode 100644
index 0000000..bbe4d88
--- /dev/null
+++ b/windows/AppSettings.cs
@@ -0,0 +1,49 @@
+using System;
+using System.IO;
+using System.Text.Json;
+
+namespace ClaudeCheckerWindows;
+
+public class AppSettings
+{
+ // Auth signal — non-empty means the user has completed sign-in at least once.
+ // The WebView2 user-data folder holds the live session; this is only for startup.
+ public string CookieStore { get; set; } = "";
+
+ // Accumulated sparkline data — built up over multiple refreshes.
+ public string BurnHistory { get; set; } = "";
+
+ // User preferences
+ public int RefreshInterval { get; set; } = 120;
+ public bool ShowInTaskbar { get; set; } = true;
+ public bool BetaChannel { get; set; } = false;
+
+ private static readonly string FilePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClaudeChecker", "settings.json");
+
+ private static AppSettings? _instance;
+ public static AppSettings Default => _instance ??= Load();
+
+ private static AppSettings Load()
+ {
+ try
+ {
+ if (File.Exists(FilePath))
+ return JsonSerializer.Deserialize(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 { }
+ }
+}
diff --git a/windows/Assets/icon.ico b/windows/Assets/icon.ico
new file mode 100644
index 0000000..dab4b7c
Binary files /dev/null and b/windows/Assets/icon.ico differ
diff --git a/windows/Assets/icon.png b/windows/Assets/icon.png
new file mode 100644
index 0000000..d950b88
Binary files /dev/null and b/windows/Assets/icon.png differ
diff --git a/windows/ClaudeCheckerWindows.csproj b/windows/ClaudeCheckerWindows.csproj
new file mode 100644
index 0000000..f7b4a47
--- /dev/null
+++ b/windows/ClaudeCheckerWindows.csproj
@@ -0,0 +1,32 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+ ClaudeChecker
+ ClaudeCheckerWindows
+ Assets\icon.ico
+ 1.0.0
+ x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
diff --git a/windows/Controls/GaugeControl.cs b/windows/Controls/GaugeControl.cs
new file mode 100644
index 0000000..0b6a7af
--- /dev/null
+++ b/windows/Controls/GaugeControl.cs
@@ -0,0 +1,84 @@
+using Brush = System.Windows.Media.Brush;
+using Pen = System.Windows.Media.Pen;
+using Point = System.Windows.Point;
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Media;
+
+namespace ClaudeCheckerWindows.Controls;
+
+public class GaugeControl : FrameworkElement
+{
+ public static readonly DependencyProperty PercentProperty =
+ DependencyProperty.Register(nameof(Percent), typeof(double), typeof(GaugeControl),
+ new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public static readonly DependencyProperty AccentProperty =
+ DependencyProperty.Register(nameof(Accent), typeof(Brush), typeof(GaugeControl),
+ new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender));
+
+ public double Percent { get => (double)GetValue(PercentProperty); set => SetValue(PercentProperty, value); }
+ public Brush Accent { get => (Brush)GetValue(AccentProperty); set => SetValue(AccentProperty, value); }
+
+ public GaugeControl()
+ {
+ ThemeManager.ThemeChanged += InvalidateVisual;
+ }
+
+ private const double StartAngleDeg = 135;
+ private const double SweepDeg = 270;
+ private const double StrokeWidth = 6;
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ var cx = RenderSize.Width / 2;
+ var cy = RenderSize.Height / 2;
+ var radius = Math.Min(cx, cy) - StrokeWidth / 2 - 1;
+
+ var trackColor = ThemeManager.IsDark
+ ? Color.FromArgb(40, 255, 255, 255)
+ : Color.FromArgb(30, 0, 0, 0);
+ DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg,
+ new Pen(new SolidColorBrush(trackColor), StrokeWidth));
+
+ if (Percent > 0)
+ DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0),
+ new Pen(Accent, StrokeWidth));
+
+ var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
+ var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C);
+
+ var pctText = new FormattedText(
+ $"{(int)Math.Round(Percent)}%",
+ CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+ new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
+ 18, new SolidColorBrush(textColor), dpi);
+
+ dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height / 2));
+ }
+
+ private static void DrawArc(DrawingContext dc, double cx, double cy, double r,
+ double startDeg, double sweepDeg, Pen pen)
+ {
+ if (sweepDeg <= 0) return;
+ var start = PointOnCircle(cx, cy, r, startDeg);
+ var end = PointOnCircle(cx, cy, r, startDeg + sweepDeg);
+ var isLarge = sweepDeg > 180;
+
+ var geo = new StreamGeometry();
+ using (var ctx = geo.Open())
+ {
+ ctx.BeginFigure(start, false, false);
+ ctx.ArcTo(end, new Size(r, r), 0, isLarge, SweepDirection.Clockwise, true, false);
+ }
+ geo.Freeze();
+ dc.DrawGeometry(null, pen, geo);
+ }
+
+ private static Point PointOnCircle(double cx, double cy, double r, double deg)
+ {
+ var rad = deg * Math.PI / 180;
+ return new Point(cx + r * Math.Cos(rad), cy + r * Math.Sin(rad));
+ }
+}
diff --git a/windows/Controls/SparklineControl.cs b/windows/Controls/SparklineControl.cs
new file mode 100644
index 0000000..f9964d0
--- /dev/null
+++ b/windows/Controls/SparklineControl.cs
@@ -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), 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? Data { get => (IList?)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);
+ }
+}
diff --git a/windows/GlobalUsings.cs b/windows/GlobalUsings.cs
new file mode 100644
index 0000000..e181f0c
--- /dev/null
+++ b/windows/GlobalUsings.cs
@@ -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;
diff --git a/windows/LoginWindow.xaml b/windows/LoginWindow.xaml
new file mode 100644
index 0000000..c0cb4f5
--- /dev/null
+++ b/windows/LoginWindow.xaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/LoginWindow.xaml.cs b/windows/LoginWindow.xaml.cs
new file mode 100644
index 0000000..b5d849b
--- /dev/null
+++ b/windows/LoginWindow.xaml.cs
@@ -0,0 +1,68 @@
+using Microsoft.Web.WebView2.Core;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace ClaudeCheckerWindows;
+
+public partial class LoginWindow : Window
+{
+ private bool _closing;
+
+ public LoginWindow()
+ {
+ InitializeComponent();
+ Loaded += async (_, _) => await InitBrowserAsync();
+ }
+
+ private async Task InitBrowserAsync()
+ {
+ var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
+ System.IO.Path.Combine(
+ System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
+ "ClaudeChecker", "WebView2"));
+
+ await Browser.EnsureCoreWebView2Async(env);
+ Browser.CoreWebView2.Navigate("https://claude.ai");
+
+ Browser.CoreWebView2.NavigationCompleted += async (_, e) =>
+ {
+ if (!e.IsSuccess || _closing) return;
+ var uri = Browser.CoreWebView2.Source;
+ if (!uri.Contains("claude.ai")) return;
+
+ var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
+ var signedIn = System.Linq.Enumerable.Any(cookies, c =>
+ c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"));
+
+ await Dispatcher.InvokeAsync(() =>
+ {
+ StatusText.Text = signedIn && !uri.Contains("/login") && !uri.Contains("/signin")
+ ? "Signed in — click Done to continue."
+ : "Complete sign-in, then click Done.";
+ });
+
+ if (!uri.Contains("/login") && !uri.Contains("/signin") && signedIn)
+ await SaveAndClose(cookies);
+ };
+ }
+
+ private async void Done_Click(object sender, RoutedEventArgs e)
+ {
+ if (_closing) return;
+ var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
+ await SaveAndClose(cookies);
+ }
+
+ private async Task SaveAndClose(IReadOnlyList cookies)
+ {
+ if (_closing) return;
+ _closing = true;
+
+ // Persist cookies so the app knows the user is signed in on next startup.
+ // All live data is fetched by the background WebView2 on the next refresh.
+ UsageViewModel.SaveCookies(cookies);
+
+ await Dispatcher.InvokeAsync(() => DialogResult = true);
+ }
+}
diff --git a/windows/Models.cs b/windows/Models.cs
new file mode 100644
index 0000000..bfbe5d2
--- /dev/null
+++ b/windows/Models.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace ClaudeCheckerWindows;
+
+public enum WindowKind { FiveHour, SevenDay }
+
+public class AgentLimit
+{
+ public WindowKind Window { get; init; }
+ public double UsedPercent { get; set; }
+ public string TimeRemaining { get; set; } = "";
+ public DateTime ResetDate { get; set; }
+ public double BurnRate { get; set; }
+ public List BurnHistory { get; set; } = [];
+ public bool IsLive { get; set; }
+ public string UsageLabel => UsedPercent switch { < 33 => "low", < 66 => "med", _ => "high" };
+ public string WindowLabel => Window == WindowKind.FiveHour ? "5 HOUR LIMIT" : "7 DAY LIMIT";
+}
+
+public class UsageResponse
+{
+ [JsonPropertyName("five_hour")] public WindowData? FiveHour { get; set; }
+ [JsonPropertyName("seven_day")] public WindowData? SevenDay { get; set; }
+ [JsonPropertyName("extra_usage")] public ExtraUsage? ExtraUsage { get; set; }
+}
+
+public class WindowData
+{
+ [JsonPropertyName("utilization")] public double Utilization { get; set; }
+ [JsonPropertyName("resets_at")] public string? ResetsAt { get; set; }
+}
+
+public class ExtraUsage
+{
+ [JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; }
+ [JsonPropertyName("currency")] public string? Currency { get; set; }
+ [JsonPropertyName("used_credits")] public double? UsedCredits { get; set; }
+ [JsonPropertyName("monthly_limit")] public double? MonthlyLimit { get; set; }
+}
+
+public class BootstrapResponse
+{
+ [JsonPropertyName("account")] public AccountInfo? Account { get; set; }
+}
+
+public class AccountInfo
+{
+ [JsonPropertyName("email_address")] public string? EmailAddress { get; set; }
+}
+
+public class OverageSpendLimit
+{
+ [JsonPropertyName("monthly_credit_limit")] public double? MonthlyCreditLimit { get; set; }
+ [JsonPropertyName("used_credits")] public double? UsedCredits { get; set; }
+ [JsonPropertyName("currency")] public string? Currency { get; set; }
+}
+
+public class PrepaidCredits
+{
+ [JsonPropertyName("amount")] public double? Amount { get; set; }
+ [JsonPropertyName("currency")] public string? Currency { get; set; }
+}
+
+public class VersionInfo
+{
+ [JsonPropertyName("version")] public string Version { get; set; } = "";
+ [JsonPropertyName("url")] public string Url { get; set; } = "";
+ [JsonPropertyName("notes")] public string? Notes { get; set; }
+}
diff --git a/windows/PopupWindow.xaml b/windows/PopupWindow.xaml
new file mode 100644
index 0000000..b1d870e
--- /dev/null
+++ b/windows/PopupWindow.xaml
@@ -0,0 +1,295 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/windows/PopupWindow.xaml.cs b/windows/PopupWindow.xaml.cs
new file mode 100644
index 0000000..87009a0
--- /dev/null
+++ b/windows/PopupWindow.xaml.cs
@@ -0,0 +1,750 @@
+using ClaudeCheckerWindows.Controls;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Threading;
+
+namespace ClaudeCheckerWindows;
+
+public partial class PopupWindow : Window
+{
+ private static readonly UsageViewModel VM = App.ViewModel;
+ private static readonly UpdateManager Updater = App.Updater;
+ private readonly DispatcherTimer _clockTimer;
+
+ [DllImport("dwmapi.dll")]
+ private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
+
+ private void ApplyTitleBarTheme()
+ {
+ var hwnd = new WindowInteropHelper(this).Handle;
+ if (hwnd == IntPtr.Zero) return;
+ int dark = ThemeManager.IsDark ? 1 : 0;
+ DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
+ }
+
+ public PopupWindow()
+ {
+ InitializeComponent();
+
+ VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName));
+ Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName));
+
+ _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ _clockTimer.Tick += (_, _) => UpdateFooter();
+ _clockTimer.Start();
+
+ InitSettings();
+ RebuildCards();
+ UpdateBannerState();
+ UpdateFooter();
+
+ Closing += (_, e) => { e.Cancel = true; Hide(); };
+ SourceInitialized += (_, _) => ApplyTitleBarTheme();
+ ThemeManager.ThemeChanged += () => { ApplyTitleBarTheme(); RebuildCards(); };
+ }
+
+ // ── Card rendering ───────────────────────────────────────────────
+
+ private void RebuildCards()
+ {
+ CardsPanel.Children.Clear();
+ foreach (var limit in VM.Limits)
+ CardsPanel.Children.Add(BuildCard(limit));
+
+ if (VM.ExtraUsage?.IsEnabled == true)
+ CardsPanel.Children.Add(BuildExtraUsageSection());
+
+ var fiveHour = VM.Limits.FirstOrDefault(l => l.Window == WindowKind.FiveHour);
+ if (fiveHour != null && fiveHour.BurnHistory.Count > 0)
+ CardsPanel.Children.Add(BuildDiarySection(fiveHour));
+ }
+
+ private static UIElement BuildCard(AgentLimit limit)
+ {
+ var accent = new SolidColorBrush(Color.FromRgb(0xF9, 0x73, 0x16));
+ var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
+ var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
+ var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
+
+ var gauge = new GaugeControl { Width = 76, Height = 76, Percent = limit.UsedPercent, Accent = accent };
+
+ var liveBadge = new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(30, 0, 160, 0)),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(6, 2, 6, 2),
+ Margin = new Thickness(0, 0, 6, 0),
+ Visibility = limit.IsLive ? Visibility.Visible : Visibility.Collapsed,
+ Child = new TextBlock { Text = "Live", FontSize = 10, FontWeight = FontWeights.Medium,
+ Foreground = new SolidColorBrush(Color.FromRgb(0, 120, 0)) }
+ };
+
+ var usageBadge = new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(6, 2, 6, 2),
+ Child = new TextBlock { Text = $"Usg: {limit.UsageLabel}", FontSize = 10,
+ FontWeight = FontWeights.Medium, Foreground = secondary }
+ };
+
+ var badgeStack = new StackPanel { Orientation = Orientation.Horizontal };
+ badgeStack.Children.Add(liveBadge);
+ badgeStack.Children.Add(usageBadge);
+
+ var nameStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
+ nameStack.Children.Add(new TextBlock { Text = "✶", FontSize = 11, Foreground = accent, VerticalAlignment = VerticalAlignment.Center });
+ nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold,
+ Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
+ nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11,
+ Foreground = secondary, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
+
+ var headerRow = new Grid();
+ headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ Grid.SetColumn(nameStack, 0);
+ Grid.SetColumn(badgeStack, 1);
+ headerRow.Children.Add(nameStack);
+ headerRow.Children.Add(badgeStack);
+
+ var progress = new ProgressBar
+ {
+ Value = limit.UsedPercent, Maximum = 100, Height = 4,
+ Margin = new Thickness(0, 4, 0, 4),
+ Foreground = accent,
+ Background = new SolidColorBrush(Color.FromArgb(20, 0, 0, 0)),
+ BorderThickness = new Thickness(0),
+ };
+
+ // Left: time remaining + reset date inline
+ var timeLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
+ timeLeft.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
+ timeLeft.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12,
+ FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center });
+ timeLeft.Children.Add(new TextBlock
+ {
+ Text = $" Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}",
+ FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
+ });
+
+ // Right: projected limit-hit time, or "after reset" if burn rate won't reach 100% before reset.
+ // BurnRate is stored as usedPercent / windowHours, so hoursToFull = remainingPercent / BurnRate.
+ string? refreshLabel;
+ if (limit.BurnRate <= 0)
+ refreshLabel = "after reset";
+ else
+ {
+ var projectedHit = DateTime.Now.AddHours((100.0 - limit.UsedPercent) / limit.BurnRate);
+ refreshLabel = projectedHit > limit.ResetDate
+ ? "after reset"
+ : projectedHit.Date == DateTime.Today
+ ? projectedHit.ToString("h:mm tt")
+ : projectedHit.ToString("d MMM h:mm tt");
+ }
+
+ UIElement timeRight;
+ if (refreshLabel != null)
+ {
+ var inner = new StackPanel { Orientation = Orientation.Horizontal };
+ inner.Children.Add(new TextBlock { Text = "↗ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
+ inner.Children.Add(new TextBlock { Text = refreshLabel, FontSize = 10, FontWeight = FontWeights.Medium,
+ Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
+ timeRight = new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(6, 2, 6, 2),
+ Child = inner
+ };
+ }
+ else
+ {
+ timeRight = new TextBlock();
+ }
+
+ var timeRow = new Grid();
+ timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ Grid.SetColumn(timeLeft, 0);
+ Grid.SetColumn(timeRight, 1);
+ timeRow.Children.Add(timeLeft);
+ timeRow.Children.Add(timeRight);
+
+ var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
+ infoPanel.Children.Add(headerRow);
+ infoPanel.Children.Add(progress);
+ infoPanel.Children.Add(timeRow);
+
+ var cardGrid = new Grid { Margin = new Thickness(16, 12, 16, 12) };
+ cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16, GridUnitType.Pixel) });
+ cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ Grid.SetColumn(gauge, 0);
+ Grid.SetColumn(infoPanel, 2);
+ cardGrid.Children.Add(gauge);
+ cardGrid.Children.Add(infoPanel);
+
+ var card = new Border
+ {
+ Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
+ CornerRadius = new CornerRadius(6),
+ BorderBrush = border,
+ BorderThickness = new Thickness(1),
+ Margin = new Thickness(12, 0, 12, 0),
+ Child = cardGrid,
+ };
+
+ var sectionLabel = new TextBlock
+ {
+ Text = limit.WindowLabel,
+ Style = (Style)Application.Current.Resources["HeaderText"],
+ Margin = new Thickness(16, 12, 16, 6),
+ };
+
+ var section = new StackPanel();
+ section.Children.Add(sectionLabel);
+ section.Children.Add(card);
+ return section;
+ }
+
+ private static UIElement BuildDiarySection(AgentLimit limit)
+ {
+ var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
+ var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
+
+ var statsRow = new Grid { Margin = new Thickness(12, 10, 12, 6) };
+ statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ var samplesText = new TextBlock
+ {
+ Text = $"{limit.BurnHistory.Count} samples",
+ FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
+ };
+ var burnText = new TextBlock
+ {
+ Text = $"Avg burn rate: {limit.BurnRate:F1}%/h",
+ FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetColumn(samplesText, 0);
+ Grid.SetColumn(burnText, 1);
+ statsRow.Children.Add(samplesText);
+ statsRow.Children.Add(burnText);
+
+ var sparkline = new SparklineControl
+ {
+ Height = 40,
+ Margin = new Thickness(8, 0, 8, 8),
+ LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
+ Data = limit.BurnHistory,
+ };
+
+ var cardContent = new StackPanel();
+ cardContent.Children.Add(statsRow);
+ cardContent.Children.Add(sparkline);
+
+ var card = new Border
+ {
+ Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
+ CornerRadius = new CornerRadius(6),
+ BorderBrush = border,
+ BorderThickness = new Thickness(1),
+ Margin = new Thickness(12, 0, 12, 0),
+ Child = cardContent,
+ };
+
+ var sectionLabel = new TextBlock
+ {
+ Text = "SESSION DIARY",
+ Style = (Style)Application.Current.Resources["HeaderText"],
+ Margin = new Thickness(16, 12, 16, 6),
+ };
+
+ var section = new StackPanel();
+ section.Children.Add(sectionLabel);
+ section.Children.Add(card);
+ return section;
+ }
+
+ private UIElement BuildExtraUsageSection()
+ {
+ var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
+ var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
+ var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
+ var accent = new SolidColorBrush(Color.FromRgb(0xF9, 0x73, 0x16));
+
+ var overage = VM.Overage;
+ var prepaid = VM.Prepaid;
+ var currency = VM.ExtraUsage?.Currency ?? overage?.Currency ?? "$";
+
+ var spent = (overage?.UsedCredits / 100.0) ?? (VM.ExtraUsage?.UsedCredits / 100.0) ?? 0;
+ var limit = (overage?.MonthlyCreditLimit / 100.0) ?? (VM.ExtraUsage?.MonthlyLimit / 100.0) ?? 0;
+ var balance = (prepaid?.Amount / 100.0) ?? 0;
+
+ bool unlimited = overage?.MonthlyCreditLimit == null && VM.ExtraUsage?.MonthlyLimit == null;
+
+ UIElement MakeRow(string label, string value, bool highlight = false)
+ {
+ var row = new Grid { Margin = new Thickness(0, 3, 0, 3) };
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ var lbl = new TextBlock { Text = label, FontSize = 12, Foreground = secondary };
+ var val = new TextBlock { Text = value, FontSize = 12, FontWeight = FontWeights.Medium,
+ Foreground = highlight ? new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)) : text };
+ Grid.SetColumn(lbl, 0);
+ Grid.SetColumn(val, 1);
+ row.Children.Add(lbl);
+ row.Children.Add(val);
+ return row;
+ }
+
+ var contentPanel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
+ contentPanel.Children.Add(MakeRow("Spent", $"{currency} {spent:F2}", spent > 0));
+ if (unlimited) contentPanel.Children.Add(MakeRow("Limit", "Unlimited"));
+ else if (limit > 0) contentPanel.Children.Add(MakeRow("Limit", $"{currency} {limit:F2}"));
+ if (balance > 0) contentPanel.Children.Add(MakeRow("Balance", $"{currency} {balance:F2}"));
+
+ if (limit > 0)
+ {
+ var pct = Math.Min(spent / limit * 100, 100);
+ contentPanel.Children.Add(new ProgressBar
+ {
+ Value = pct, Maximum = 100, Height = 4,
+ Margin = new Thickness(0, 6, 0, 0),
+ Foreground = accent,
+ Background = new SolidColorBrush(Color.FromArgb(20, 0, 0, 0)),
+ BorderThickness = new Thickness(0),
+ });
+ }
+
+ var card = new Border
+ {
+ Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
+ CornerRadius = new CornerRadius(6),
+ BorderBrush = border,
+ BorderThickness = new Thickness(1),
+ Margin = new Thickness(12, 0, 12, 0),
+ Child = contentPanel,
+ };
+
+ var sectionLabel = new TextBlock
+ {
+ Text = "EXTRA USAGE CREDITS",
+ Style = (Style)Application.Current.Resources["HeaderText"],
+ Margin = new Thickness(16, 12, 16, 6),
+ };
+
+ var section = new StackPanel();
+ section.Children.Add(sectionLabel);
+ section.Children.Add(card);
+ return section;
+ }
+
+ // ── State updates ────────────────────────────────────────────────
+
+ private void OnVmChanged(string? prop)
+ {
+ if (prop is nameof(UsageViewModel.Limits) or nameof(UsageViewModel.IsLoading)
+ or nameof(UsageViewModel.LastUpdated) or nameof(UsageViewModel.ErrorMessage)
+ or nameof(UsageViewModel.ExtraUsage) or nameof(UsageViewModel.Overage) or nameof(UsageViewModel.Prepaid))
+ {
+ RebuildCards();
+ UpdateFooter();
+ }
+ if (prop is nameof(UsageViewModel.IsSignedIn) or nameof(UsageViewModel.UserEmail))
+ {
+ InitSettings();
+ }
+ }
+
+ private void OnUpdaterChanged(string? prop)
+ {
+ if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion))
+ {
+ UpdateBannerState();
+ UpdateAboutSection();
+ }
+ if (prop is nameof(UpdateManager.DownloadProgress))
+ UpdateProgress.Value = Updater.DownloadProgress * 100;
+ if (prop is nameof(UpdateManager.StatusMessage))
+ UpdateStatusText.Text = Updater.StatusMessage;
+ if (prop is nameof(UpdateManager.UpdateComplete) && Updater.UpdateComplete)
+ InstallButton.Visibility = Visibility.Collapsed;
+ }
+
+ private void UpdateAboutSection()
+ {
+ if (Updater.UpdateAvailable)
+ {
+ UpdateAvailableText.Text = $"v{Updater.LatestVersion} available";
+ UpdateAvailableText.Visibility = Visibility.Visible;
+ CheckUpdateButton.Content = "Install";
+ }
+ else
+ {
+ UpdateAvailableText.Visibility = Visibility.Collapsed;
+ CheckUpdateButton.Content = "Check";
+ }
+ }
+
+ private void UpdateBannerState()
+ {
+ var visible = Updater.UpdateAvailable;
+ UpdateBanner.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+ if (visible) UpdateBannerTitle.Text = $"Update available — v{Updater.LatestVersion}";
+ }
+
+ private void UpdateFooter()
+ {
+ if (VM.LastUpdated.HasValue)
+ {
+ var diff = DateTime.Now - VM.LastUpdated.Value;
+ string age;
+ if (diff.TotalSeconds < 10)
+ age = "just now";
+ else if (diff.TotalMinutes < 1)
+ age = $"{(int)diff.TotalSeconds} sec ago";
+ else
+ age = $"{(int)diff.TotalMinutes} min, {diff.Seconds} sec ago";
+ LastUpdatedText.Text = $"Updated {age}";
+ }
+ else
+ {
+ LastUpdatedText.Text = "Not yet updated";
+ }
+ LoadingBar.Visibility = VM.IsLoading ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ // ── Settings ─────────────────────────────────────────────────────
+
+ private static readonly (string Label, int Seconds)[] RefreshIntervals =
+ [
+ ("1 min", 60),
+ ("2 min", 120),
+ ("3 min", 180),
+ ("4 min", 240),
+ ("5 min", 300),
+ ("10 min", 600),
+ ];
+
+ private void InitSettings()
+ {
+ VersionLabel.Text = $"v{Updater.CurrentVersion}";
+ BetaToggle.IsChecked = Updater.BetaChannel;
+ UpdateAboutSection();
+
+ RefreshPicker.SelectionChanged -= RefreshPicker_Changed;
+ RefreshPicker.Items.Clear();
+ int selectIdx = 1;
+ for (int i = 0; i < RefreshIntervals.Length; i++)
+ {
+ var (label, seconds) = RefreshIntervals[i];
+ RefreshPicker.Items.Add(new ComboBoxItem { Content = label, Tag = seconds });
+ if (seconds == VM.RefreshInterval) selectIdx = i;
+ }
+ RefreshPicker.SelectedIndex = selectIdx;
+ RefreshPicker.SelectionChanged += RefreshPicker_Changed;
+
+ AuthPanel.Children.Clear();
+ var dot = new System.Windows.Shapes.Ellipse
+ {
+ Width = 7, Height = 7, Margin = new Thickness(0, 0, 6, 0),
+ Fill = VM.IsSignedIn ? Brushes.LimeGreen : Brushes.Orange,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ var label2 = new TextBlock
+ {
+ Text = VM.IsSignedIn ? $"Signed in — {VM.UserEmail}" : "Not signed in",
+ FontSize = 13, FontWeight = FontWeights.SemiBold,
+ Foreground = (SolidColorBrush)Application.Current.Resources["TextBrush"],
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ var authRow = new StackPanel { Orientation = Orientation.Horizontal };
+ authRow.Children.Add(dot);
+ authRow.Children.Add(label2);
+ AuthPanel.Children.Add(authRow);
+
+ SignOutButton.Visibility = VM.IsSignedIn ? Visibility.Visible : Visibility.Collapsed;
+ SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In";
+ }
+
+ // ── Diagnostics ──────────────────────────────────────────────────
+
+ private void Diagnostics_Click(object s, RoutedEventArgs e)
+ {
+ RebuildDiag();
+ MainPanel.Visibility = Visibility.Collapsed;
+ SettingsPanel.Visibility = Visibility.Collapsed;
+ UpdatePanel.Visibility = Visibility.Collapsed;
+ DiagnosticsPanel.Visibility = Visibility.Visible;
+ }
+
+ private void BackFromDiag_Click(object s, RoutedEventArgs e)
+ {
+ DiagnosticsPanel.Visibility = Visibility.Collapsed;
+ SettingsPanel.Visibility = Visibility.Visible;
+ }
+
+ private void CopyDiag_Click(object s, RoutedEventArgs e)
+ {
+ try
+ {
+ // SetDataObject(data, false) skips Flush() — avoids CLIPBRD_E_CANT_OPEN crash
+ var diagText = BuildDiagText();
+ for (int i = 0; i < 5; i++)
+ {
+ try { Clipboard.SetDataObject(diagText, false); break; }
+ catch (Exception) { System.Threading.Thread.Sleep(100); }
+ }
+ CopyDiagButton.Content = "Copied! (IDs redacted)";
+ }
+ catch (Exception)
+ {
+ CopyDiagButton.Content = "Copy failed";
+ }
+ var t = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
+ t.Tick += (_, _) => { CopyDiagButton.Content = "Copy All"; t.Stop(); };
+ t.Start();
+ }
+
+ private void RebuildDiag()
+ {
+ DiagContentPanel.Children.Clear();
+ var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
+ var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
+ var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
+
+ FrameworkElement MakeRow(string label, string value)
+ {
+ var grid = new Grid { Margin = new Thickness(0, 3, 0, 3) };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ var lbl = new TextBlock
+ {
+ Text = label, FontSize = 11, Foreground = secondary,
+ VerticalAlignment = VerticalAlignment.Top,
+ };
+ var val = new TextBlock
+ {
+ Text = value, FontSize = 11, FontFamily = new FontFamily("Consolas"),
+ Foreground = text, TextWrapping = TextWrapping.Wrap,
+ };
+ Grid.SetColumn(lbl, 0);
+ Grid.SetColumn(val, 1);
+ grid.Children.Add(lbl);
+ grid.Children.Add(val);
+ return grid;
+ }
+
+ void AddSection(string title, IEnumerable rows)
+ {
+ DiagContentPanel.Children.Add(new TextBlock
+ {
+ Text = title, Style = (Style)Application.Current.Resources["HeaderText"],
+ Margin = new Thickness(0, 12, 0, 6),
+ });
+ var panel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
+ foreach (var row in rows) panel.Children.Add(row);
+ DiagContentPanel.Children.Add(new Border
+ {
+ Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
+ CornerRadius = new CornerRadius(6),
+ BorderBrush = border, BorderThickness = new Thickness(1),
+ Child = panel,
+ });
+ }
+
+ // APP
+ AddSection("APP",
+ [
+ MakeRow("Version", Updater.CurrentVersion),
+ MakeRow("Signed in", VM.IsSignedIn ? "Yes" : "No"),
+ MakeRow("Org ID", string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId),
+ MakeRow("lastActiveOrg", string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg),
+ MakeRow("Error", VM.ErrorMessage ?? "(none)"),
+ ]);
+
+ // LAST REQUEST
+ var reqRows = new List
+ {
+ MakeRow("Path", string.IsNullOrEmpty(VM.DiagLastPath) ? "(none)" : VM.DiagLastPath),
+ MakeRow("Status", VM.DiagLastStatus == 0 ? "(none)" : VM.DiagLastStatus.ToString()),
+ MakeRow("Error", string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError),
+ };
+ if (VM.DiagLastFetch.HasValue)
+ reqRows.Add(MakeRow("Time", VM.DiagLastFetch.Value.ToString("HH:mm:ss")));
+ AddSection("LAST REQUEST", reqRows);
+
+ // STORED COOKIE NAMES
+ var storedNames = GetStoredCookieNames();
+ var nameRows = new List();
+ if (storedNames.Count == 0)
+ {
+ nameRows.Add(MakeRow("(none)", ""));
+ }
+ else
+ {
+ var claudeC = storedNames.Where(c =>
+ c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com")).ToList();
+ var otherC = storedNames.Where(c =>
+ !c.Domain.Contains("claude.ai") && !c.Domain.Contains("anthropic.com")).ToList();
+ foreach (var c in claudeC)
+ nameRows.Add(MakeRow(c.Domain, c.Name));
+ if (otherC.Count > 0)
+ nameRows.Add(MakeRow($"other ({otherC.Count})",
+ string.Join(", ", otherC.Take(5).Select(c => c.Name))));
+ }
+ AddSection("STORED COOKIE NAMES", nameRows);
+ }
+
+ private static List<(string Name, string Domain)> GetStoredCookieNames()
+ {
+ try
+ {
+ var raw = AppSettings.Default.CookieStore;
+ if (string.IsNullOrEmpty(raw)) return [];
+ using var doc = JsonDocument.Parse(raw);
+ var list = new List<(string Name, string Domain)>();
+ foreach (var elem in doc.RootElement.EnumerateArray())
+ {
+ var name = elem.TryGetProperty("Name", out var n) ? n.GetString() ?? "" : "";
+ var domain = elem.TryGetProperty("Domain", out var d) ? d.GetString() ?? "" : "";
+ list.Add((name, domain));
+ }
+ return list;
+ }
+ catch { return []; }
+ }
+
+ private static string BuildDiagText()
+ {
+ var storedNames = GetStoredCookieNames();
+ var sb = new StringBuilder();
+ sb.AppendLine("=== ClaudeChecker Diagnostics (Windows) ===");
+ sb.AppendLine($"Version: {Updater.CurrentVersion}");
+ sb.AppendLine($"Signed in: {(VM.IsSignedIn ? "Yes" : "No")}");
+ sb.AppendLine($"Org ID: {(string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId)}");
+ sb.AppendLine($"lastActiveOrg: {(string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg)}");
+ sb.AppendLine($"Error: {VM.ErrorMessage ?? "(none)"}");
+ sb.AppendLine();
+ sb.AppendLine($"Last path: {VM.DiagLastPath}");
+ sb.AppendLine($"Last status: {VM.DiagLastStatus}");
+ sb.AppendLine($"Last error: {(string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError)}");
+ if (VM.DiagLastFetch.HasValue)
+ sb.AppendLine($"Last fetch: {VM.DiagLastFetch.Value:HH:mm:ss}");
+ sb.AppendLine();
+ sb.AppendLine();
+ sb.AppendLine("Stored cookies:");
+ foreach (var c in storedNames)
+ sb.AppendLine($" {c.Domain} {c.Name}");
+ return RedactUUIDs(sb.ToString());
+ }
+
+ private static string RedactUUIDs(string text) =>
+ Regex.Replace(text, @"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "****");
+
+ // ── Event handlers ───────────────────────────────────────────────
+
+ private void Settings_Click(object s, RoutedEventArgs e)
+ {
+ InitSettings();
+ MainPanel.Visibility = Visibility.Collapsed;
+ SettingsPanel.Visibility = Visibility.Visible;
+ UpdatePanel.Visibility = Visibility.Collapsed;
+ DiagnosticsPanel.Visibility = Visibility.Collapsed;
+ }
+
+ private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
+
+ private void ShowMain()
+ {
+ MainPanel.Visibility = Visibility.Visible;
+ SettingsPanel.Visibility = Visibility.Collapsed;
+ UpdatePanel.Visibility = Visibility.Collapsed;
+ DiagnosticsPanel.Visibility = Visibility.Collapsed;
+ }
+
+ private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
+
+ private void UpdateBanner_Click(object s, RoutedEventArgs e) => ShowUpdatePanel();
+
+ private void ShowUpdatePanel()
+ {
+ CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
+ NewVerLabel.Text = $"v{Updater.LatestVersion}";
+ ReleaseNotesText.Text = Updater.ReleaseNotes;
+ MainPanel.Visibility = Visibility.Collapsed;
+ SettingsPanel.Visibility = Visibility.Collapsed;
+ UpdatePanel.Visibility = Visibility.Visible;
+ DiagnosticsPanel.Visibility = Visibility.Collapsed;
+ }
+
+ private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
+
+ private async void Install_Click(object s, RoutedEventArgs e)
+ {
+ NotNowButton.Visibility = Visibility.Collapsed;
+ UpdateStatusText.Visibility = Visibility.Visible;
+ UpdateProgress.Visibility = Visibility.Visible;
+ await Updater.DownloadAndInstallAsync();
+ }
+
+ private async void SignIn_Click(object s, RoutedEventArgs e)
+ {
+ var login = new LoginWindow { Owner = this };
+ if (login.ShowDialog() == true)
+ {
+ await VM.LoadFromCacheAsync();
+ InitSettings();
+ ShowMain();
+
+ _ = Task.Run(async () =>
+ {
+ // Re-navigate background browser with fresh session, then refresh immediately
+ await Application.Current.Dispatcher.InvokeAsync(
+ () => App.BackgroundBrowser.ReloadAsync()).Task.Unwrap();
+ await VM.RefreshAsync();
+ await Application.Current.Dispatcher.InvokeAsync(InitSettings);
+ });
+ }
+ Show();
+ Activate();
+ }
+
+ private async void SignOut_Click(object s, RoutedEventArgs e)
+ {
+ await VM.SignOutAsync();
+ InitSettings();
+ }
+
+ private void BetaToggle_Changed(object s, RoutedEventArgs e)
+ {
+ Updater.BetaChannel = BetaToggle.IsChecked == true;
+ _ = Updater.CheckForUpdatesAsync();
+ }
+
+ private void RefreshPicker_Changed(object s, SelectionChangedEventArgs e)
+ {
+ if (RefreshPicker.SelectedItem is ComboBoxItem item && item.Tag is int seconds)
+ {
+ VM.RefreshInterval = seconds;
+ ((App)Application.Current).ScheduleTimer(seconds);
+ }
+ }
+
+ private async void CheckUpdate_Click(object s, RoutedEventArgs e)
+ {
+ if (Updater.UpdateAvailable) { ShowUpdatePanel(); return; }
+ CheckUpdateButton.Content = "…";
+ await Updater.CheckForUpdatesAsync();
+ CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
+ }
+}
diff --git a/windows/Properties/Settings.settings b/windows/Properties/Settings.settings
new file mode 100644
index 0000000..a775c89
--- /dev/null
+++ b/windows/Properties/Settings.settings
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 120
+
+
+ True
+
+
+ False
+
+
+
diff --git a/windows/README.md b/windows/README.md
new file mode 100644
index 0000000..9245de6
--- /dev/null
+++ b/windows/README.md
@@ -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.
diff --git a/windows/RELEASE_NOTES.md b/windows/RELEASE_NOTES.md
new file mode 100644
index 0000000..c4d4af0
--- /dev/null
+++ b/windows/RELEASE_NOTES.md
@@ -0,0 +1,5 @@
+## What's new in beta.63
+
+### Bug fixes
+- After signing in, usage data now loads immediately instead of waiting 3 seconds
+- Background browser re-navigates to claude.ai right after login so the live session is ready for the first refresh
diff --git a/windows/ThemeManager.cs b/windows/ThemeManager.cs
new file mode 100644
index 0000000..af7605d
--- /dev/null
+++ b/windows/ThemeManager.cs
@@ -0,0 +1,81 @@
+using Microsoft.Win32;
+using System;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Threading;
+
+namespace ClaudeCheckerWindows;
+
+public static class ThemeManager
+{
+ public static bool IsDark { get; private set; }
+ public static event Action? ThemeChanged;
+
+ public static void Initialize()
+ {
+ IsDark = ReadIsDark();
+ Apply();
+
+ var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
+ timer.Tick += (_, _) =>
+ {
+ var nowDark = ReadIsDark();
+ if (nowDark == IsDark) return;
+ IsDark = nowDark;
+ Apply();
+ ThemeChanged?.Invoke();
+ };
+ timer.Start();
+ }
+
+ private static bool ReadIsDark()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(
+ @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
+ return (int)(key?.GetValue("AppsUseLightTheme") ?? 1) == 0;
+ }
+ catch { return false; }
+ }
+
+ private static void Apply()
+ {
+ var res = Application.Current.Resources;
+ if (IsDark)
+ {
+ Set(res, "BackgroundBrush", Color.FromRgb(0x1C, 0x1C, 0x1E));
+ Set(res, "CardBrush", Color.FromRgb(0x2C, 0x2C, 0x2E));
+ Set(res, "TextBrush", Color.FromRgb(0xF5, 0xF5, 0xF5));
+ Set(res, "SecondaryBrush", Color.FromRgb(0x8A, 0x8A, 0x8E));
+ Set(res, "BorderBrush", Color.FromRgb(0x3A, 0x3A, 0x3C));
+ Set(res, "BlueBrush", Color.FromRgb(0x3B, 0x82, 0xF6));
+ Set(res, "ButtonBrush", Color.FromRgb(0x3A, 0x3A, 0x3C));
+ Set(res, "SurfaceBrush", Color.FromRgb(0x25, 0x25, 0x27));
+ Set(res, "BannerBgBrush", Color.FromRgb(0x1A, 0x31, 0x52));
+ Set(res, "BannerBorderBrush", Color.FromRgb(0x2A, 0x4A, 0x72));
+ Set(res, "HoverBrush", Color.FromRgb(0x4A, 0x4A, 0x4C));
+ Set(res, "BadgeBrush", Color.FromRgb(0x3A, 0x3A, 0x3C));
+ }
+ else
+ {
+ Set(res, "BackgroundBrush", Color.FromRgb(0xF3, 0xF3, 0xF3));
+ Set(res, "CardBrush", Colors.White);
+ Set(res, "TextBrush", Color.FromRgb(0x1C, 0x1C, 0x1C));
+ Set(res, "SecondaryBrush", Color.FromRgb(0x6E, 0x6E, 0x6E));
+ Set(res, "BorderBrush", Color.FromRgb(0xE0, 0xE0, 0xE0));
+ Set(res, "BlueBrush", Color.FromRgb(0x00, 0x78, 0xD4));
+ Set(res, "ButtonBrush", Colors.White);
+ Set(res, "SurfaceBrush", Color.FromRgb(0xFA, 0xFA, 0xFA));
+ Set(res, "BannerBgBrush", Color.FromRgb(0xE8, 0xF2, 0xFB));
+ Set(res, "BannerBorderBrush", Color.FromRgb(0xB3, 0xD6, 0xF5));
+ Set(res, "HoverBrush", Color.FromRgb(0xEB, 0xEB, 0xEB));
+ Set(res, "BadgeBrush", Color.FromRgb(0xF0, 0xF0, 0xF0));
+ }
+ }
+
+ private static void Set(ResourceDictionary res, string key, Color color)
+ {
+ res[key] = new SolidColorBrush(color);
+ }
+}
diff --git a/windows/UpdateManager.cs b/windows/UpdateManager.cs
new file mode 100644
index 0000000..f0b336a
--- /dev/null
+++ b/windows/UpdateManager.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Diagnostics;
+using System.Reflection;
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Collections.Generic;
+using System.Windows;
+
+namespace ClaudeCheckerWindows;
+
+public class UpdateManager : INotifyPropertyChanged
+{
+ private const string StableUrl = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-windows.json";
+ private const string BetaUrl = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-windows-beta.json";
+
+ private bool _updateAvailable;
+ private string _latestVersion = "";
+ private string _releaseNotes = "";
+ private string _downloadUrl = "";
+ private bool _betaAvailable;
+ private string _latestBetaVersion = "";
+ private bool _isDownloading;
+ private double _downloadProgress;
+ private string _statusMessage = "";
+ private string? _updateError;
+ private bool _updateComplete;
+
+ public bool UpdateAvailable { get => _updateAvailable; set => Set(ref _updateAvailable, value); }
+ public string LatestVersion { get => _latestVersion; set => Set(ref _latestVersion, value); }
+ public string ReleaseNotes { get => _releaseNotes; set => Set(ref _releaseNotes, value); }
+ public string DownloadUrl { get => _downloadUrl; set => Set(ref _downloadUrl, value); }
+ public bool BetaAvailable { get => _betaAvailable; set => Set(ref _betaAvailable, value); }
+ public string LatestBetaVersion { get => _latestBetaVersion; set => Set(ref _latestBetaVersion, value); }
+ public bool IsDownloading { get => _isDownloading; set => Set(ref _isDownloading, value); }
+ public double DownloadProgress { get => _downloadProgress; set => Set(ref _downloadProgress, value); }
+ public string StatusMessage { get => _statusMessage; set => Set(ref _statusMessage, value); }
+ public string? UpdateError { get => _updateError; set => Set(ref _updateError, value); }
+ public bool UpdateComplete { get => _updateComplete; set => Set(ref _updateComplete, value); }
+
+ private bool _betaChannel;
+ public bool BetaChannel
+ {
+ get => _betaChannel;
+ set
+ {
+ Set(ref _betaChannel, value);
+ AppSettings.Default.BetaChannel = value;
+ AppSettings.Default.Save();
+ }
+ }
+
+ public string CurrentVersion =>
+ System.Reflection.Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()
+ ?.InformationalVersion.Split('+')[0]
+ ?? "1.0.0";
+
+ private bool IsPreReleaseBuild => CurrentVersion.Contains('-');
+
+ public UpdateManager()
+ {
+ _betaChannel = AppSettings.Default.BetaChannel;
+ }
+
+ public async Task CheckForUpdatesAsync()
+ {
+ using var http = new HttpClient();
+ http.DefaultRequestHeaders.CacheControl =
+ new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true };
+
+ var stable = await FetchVersionAsync(http, StableUrl);
+ var beta = await FetchVersionAsync(http, BetaUrl);
+
+ await Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ if (stable != null && IsNewer(stable.Version, CurrentVersion))
+ {
+ LatestVersion = stable.Version;
+ ReleaseNotes = stable.Notes ?? "";
+ DownloadUrl = stable.Url;
+ UpdateAvailable = true;
+ }
+ else
+ {
+ UpdateAvailable = false;
+ LatestVersion = "";
+ }
+
+ if (beta != null && IsNewer(beta.Version, CurrentVersion))
+ {
+ LatestBetaVersion = beta.Version;
+ BetaAvailable = true;
+
+ // Show beta update if user opted in, or if they're already running a beta
+ if ((BetaChannel || IsPreReleaseBuild) && IsNewer(beta.Version, LatestVersion))
+ {
+ LatestVersion = beta.Version;
+ ReleaseNotes = beta.Notes ?? "";
+ DownloadUrl = beta.Url;
+ UpdateAvailable = true;
+ }
+ }
+ else
+ {
+ BetaAvailable = false;
+ LatestBetaVersion = "";
+ }
+ });
+ }
+
+ private static async Task FetchVersionAsync(HttpClient http, string url)
+ {
+ try
+ {
+ var bust = $"{url}?t={DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
+ var json = await http.GetStringAsync(bust);
+ return JsonSerializer.Deserialize(json,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch { return null; }
+ }
+
+ public async Task DownloadAndInstallAsync()
+ {
+ if (string.IsNullOrEmpty(DownloadUrl)) return;
+
+ IsDownloading = true;
+ UpdateError = null;
+ UpdateComplete = false;
+ StatusMessage = "Downloading…";
+
+ try
+ {
+ var installerPath = Path.Combine(Path.GetTempPath(), $"ClaudeCheckerInstaller_{Guid.NewGuid():N}.exe");
+
+ using var http = new HttpClient();
+ var response = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
+ response.EnsureSuccessStatusCode();
+
+ var total = response.Content.Headers.ContentLength ?? 0;
+ var received = 0L;
+
+ await using (var stream = await response.Content.ReadAsStreamAsync())
+ await using (var file = File.Create(installerPath))
+ {
+ var buffer = new byte[81920];
+ int read;
+ while ((read = await stream.ReadAsync(buffer)) > 0)
+ {
+ await file.WriteAsync(buffer.AsMemory(0, read));
+ received += read;
+ if (total > 0)
+ DownloadProgress = (double)received / total;
+ }
+ }
+
+ DownloadProgress = 1.0;
+ StatusMessage = "Installing…";
+ UpdateComplete = true;
+
+ await Task.Delay(500);
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = installerPath,
+ Arguments = "/SILENT /CLOSEAPPLICATIONS",
+ UseShellExecute = true,
+ });
+
+ Application.Current.Dispatcher.Invoke(() => Application.Current.Shutdown());
+ }
+ catch (Exception ex)
+ {
+ UpdateError = ex.Message;
+ IsDownloading = false;
+ StatusMessage = "";
+ }
+ }
+
+ public static bool IsNewer(string version, string current)
+ {
+ static (int[] Base, int[]? Pre) Parse(string v)
+ {
+ if (string.IsNullOrEmpty(v)) return ([], null);
+ var halves = v.Split('-', 2);
+ var baseP = Array.ConvertAll(halves[0].Split('.'), p => int.TryParse(p, out var n) ? n : 0);
+ int[]? pre = halves.Length > 1
+ ? Array.ConvertAll(halves[1].Split('.'), p => int.TryParse(p, out var n) ? n : 0)
+ : null;
+ return (baseP, pre);
+ }
+
+ var (ab, ap) = Parse(version);
+ var (bb, bp) = Parse(current);
+
+ for (var i = 0; i < Math.Max(ab.Length, bb.Length); i++)
+ {
+ var av = i < ab.Length ? ab[i] : 0;
+ var bv = i < bb.Length ? bb[i] : 0;
+ if (av != bv) return av > bv;
+ }
+
+ if (ap == null && bp != null) return true;
+ if (ap != null && bp == null) return false;
+ if (ap != null && bp != null)
+ {
+ for (var i = 0; i < Math.Max(ap.Length, bp.Length); i++)
+ {
+ var av = i < ap.Length ? ap[i] : 0;
+ var bv = i < bp.Length ? bp[i] : 0;
+ if (av != bv) return av > bv;
+ }
+ }
+ return false;
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void Set(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return;
+ field = value;
+ Application.Current.Dispatcher.InvokeAsync(()
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)));
+ }
+}
diff --git a/windows/UsageViewModel.cs b/windows/UsageViewModel.cs
new file mode 100644
index 0000000..9532275
--- /dev/null
+++ b/windows/UsageViewModel.cs
@@ -0,0 +1,609 @@
+using Microsoft.Web.WebView2.Core;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace ClaudeCheckerWindows;
+
+public class UsageViewModel : INotifyPropertyChanged
+{
+ private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
+
+ private List _limits = [];
+ private bool _isLoading;
+ private string? _errorMessage;
+ private bool _isSignedIn;
+ private string _userEmail = "";
+ private string _planLabel = "Claude";
+ private DateTime? _lastUpdated;
+ private OverageSpendLimit? _overage;
+ private PrepaidCredits? _prepaid;
+ private ExtraUsage? _extraUsage;
+
+ private readonly Dictionary> _burnHistory = [];
+ private const int MaxHistorySamples = 24;
+
+ public List Limits { get => _limits; set => Set(ref _limits, value); }
+ public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); }
+ public string? ErrorMessage { get => _errorMessage; set => Set(ref _errorMessage, value); }
+ public bool IsSignedIn { get => _isSignedIn; set => Set(ref _isSignedIn, value); }
+ public string UserEmail { get => _userEmail; set => Set(ref _userEmail, value); }
+ public string PlanLabel { get => _planLabel; set => Set(ref _planLabel, value); }
+ public DateTime? LastUpdated { get => _lastUpdated; set => Set(ref _lastUpdated, value); }
+ public OverageSpendLimit? Overage { get => _overage; set => Set(ref _overage, value); }
+ public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); }
+ public ExtraUsage? ExtraUsage { get => _extraUsage; set => Set(ref _extraUsage, value); }
+
+ // Diagnostics — updated after each refresh, read by DiagnosticsPanel on demand
+ public string DiagLastActiveOrg { get; private set; } = "";
+ public string DiagOrgId { get; private set; } = "";
+ public string DiagLastPath { get; private set; } = "";
+ public int DiagLastStatus { get; private set; }
+ public string DiagLastError { get; private set; } = "";
+ public int DiagCookieCount { get; private set; }
+ public int DiagClaudeCookieCount { get; private set; }
+ public List DiagCookieDomains { get; private set; } = [];
+ public DateTime? DiagLastFetch { get; private set; }
+
+ private int _refreshInterval = 120;
+ public int RefreshInterval
+ {
+ get => _refreshInterval;
+ set
+ {
+ Set(ref _refreshInterval, value);
+ AppSettings.Default.RefreshInterval = value;
+ AppSettings.Default.Save();
+ }
+ }
+
+ private bool _showInTaskbar = true;
+ public bool ShowInTaskbar
+ {
+ get => _showInTaskbar;
+ set
+ {
+ Set(ref _showInTaskbar, value);
+ AppSettings.Default.ShowInTaskbar = value;
+ AppSettings.Default.Save();
+ }
+ }
+
+ public UsageViewModel()
+ {
+ _refreshInterval = AppSettings.Default.RefreshInterval > 0
+ ? AppSettings.Default.RefreshInterval : 120;
+ _showInTaskbar = AppSettings.Default.ShowInTaskbar;
+ LoadBurnHistory();
+ LoadPlaceholders();
+ }
+
+ public async Task RefreshAsync()
+ {
+ await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true);
+
+ // Snapshot stored cookies once — used for both diag and HttpClient fallback path.
+ var storedCookies = await GetCookiesAsync();
+
+ try
+ {
+ // CookieStore being non-empty is our signal that the user has signed in.
+ if (string.IsNullOrEmpty(AppSettings.Default.CookieStore))
+ {
+ await Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ IsSignedIn = false;
+ ErrorMessage = "Not signed in — click Sign In to authenticate.";
+ IsLoading = false;
+ });
+ return;
+ }
+
+ List limits = [];
+ OverageSpendLimit? overage = null;
+ PrepaidCredits? prepaid = null;
+ ExtraUsage? extraUsage = null;
+ string? email = null, orgId = null, planLabel = null;
+ string? refreshError = null;
+
+ // Primary: persistent background WebView2 — always-live cookies, same as macOS.
+ // Fallback: HttpClient with saved cookies, only if the browser isn't ready yet
+ // (the brief window while InitAsync is still navigating at startup).
+ if (App.BackgroundBrowser.IsReady)
+ {
+ try
+ {
+ (limits, overage, prepaid, extraUsage, email, orgId, planLabel) =
+ await TryBrowserRefreshAsync();
+ }
+ catch (Exception ex)
+ {
+ refreshError = ex.Message;
+ }
+ }
+ else
+ {
+ if (storedCookies.Count > 0)
+ {
+ try
+ {
+ (limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
+ await TryHttpRefreshAsync(storedCookies);
+ }
+ catch (Exception ex)
+ {
+ refreshError = ex.Message;
+ }
+ }
+ }
+
+ foreach (var limit in limits)
+ {
+ var key = limit.Window.ToString();
+ if (!_burnHistory.ContainsKey(key)) _burnHistory[key] = [];
+ _burnHistory[key].Add(limit.UsedPercent);
+ if (_burnHistory[key].Count > MaxHistorySamples)
+ _burnHistory[key].RemoveAt(0);
+ limit.BurnHistory = [.. _burnHistory[key]];
+ }
+ SaveBurnHistory();
+
+ await Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ if (limits.Count > 0)
+ {
+ Limits = limits;
+ Overage = overage;
+ Prepaid = prepaid;
+ if (extraUsage != null) ExtraUsage = extraUsage;
+ }
+ if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
+ if (!string.IsNullOrEmpty(email)) UserEmail = email;
+ IsSignedIn = true;
+ ErrorMessage = refreshError;
+ LastUpdated = DateTime.Now;
+ IsLoading = false;
+
+ // Update diagnostics
+ if (!string.IsNullOrEmpty(orgId)) DiagOrgId = orgId;
+ DiagLastFetch = DateTime.Now;
+ DiagLastError = refreshError ?? "";
+ DiagCookieCount = storedCookies.Count;
+ DiagClaudeCookieCount = storedCookies.Count(c =>
+ c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"));
+ DiagCookieDomains = storedCookies
+ .Select(c => c.Domain).Distinct().OrderBy(d => d).ToList();
+ });
+ }
+ catch (Exception ex)
+ {
+ await Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ ErrorMessage = ex.Message;
+ IsLoading = false;
+ });
+ }
+ }
+
+ // ── Browser-based refresh (primary) ───────────────────────────────────────
+ // Runs JS on the persistent claude.ai page. Same live cookies as macOS WKWebView.
+
+ private async Task<(List, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
+ TryBrowserRefreshAsync()
+ {
+ // Read lastActiveOrg from document.cookie — the only reliable org source when the
+ // user belongs to multiple orgs. memberships[0] is always the "Individual Org" and
+ // would return 403 on usage endpoints if the paid plan is on a different org.
+ const string script = @"(async()=>{try{
+ const h={headers:{accept:'application/json'}};
+ const b=await(await fetch('/api/bootstrap',h)).json();
+ if(b?.error_type==='authentication_error'){window.chrome.webview.postMessage({authError:true});return;}
+ const lastActiveOrg=(document.cookie.split(';').map(c=>c.trim().split('=')).find(p=>p[0]==='lastActiveOrg')||[])[1]||null;
+ const allOrgs=(b?.account?.memberships||b?.memberships||[]).map(m=>m?.organization).filter(Boolean);
+ const activeOrg=(lastActiveOrg?allOrgs.find(o=>o?.uuid===lastActiveOrg):null)||allOrgs[0];
+ let id=activeOrg?.uuid||b?.organizations?.[0]?.uuid||null;
+ const email=b?.account?.email_address||b?.account?.email||null;
+ const caps=activeOrg?.capabilities||[];
+ const capStr=caps.find(c=>typeof c==='string'&&c.startsWith('claude_'))||null;
+ const planLabel=capStr?(capStr.slice(7,8).toUpperCase()+capStr.slice(8).toLowerCase()):null;
+ if(!id){
+ try{const ol=await(await fetch('/api/organizations',h)).json();
+ if(Array.isArray(ol)&&ol.length>0)id=ol[0]?.uuid||null;}catch(e2){}
+ }
+ if(!id){window.chrome.webview.postMessage({noOrg:true,email});return;}
+ const [u,ov,pp]=await Promise.all([
+ fetch('/api/organizations/'+id+'/usage',h).then(r=>r.json()),
+ fetch('/api/organizations/'+id+'/overage_spend_limit',h).then(r=>r.ok?r.json():null).catch(()=>null),
+ fetch('/api/organizations/'+id+'/prepaid/credits',h).then(r=>r.ok?r.json():null).catch(()=>null)
+ ]);
+ window.chrome.webview.postMessage({email,orgId:id,planLabel,lastActiveOrg,usage:u,overage:ov,prepaid:pp});
+ }catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
+
+ var json = await Application.Current.Dispatcher.InvokeAsync(
+ () => App.BackgroundBrowser.RunScriptAsync(script)).Task.Unwrap();
+
+ if (string.IsNullOrEmpty(json) || json == "null")
+ throw new Exception("Browser refresh returned no data.");
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ if (root.TryGetProperty("authError", out _))
+ throw new Exception("Session expired — please re-authenticate.");
+ if (root.TryGetProperty("error", out var errEl))
+ throw new Exception("Browser script error: " + errEl.GetString());
+ if (root.TryGetProperty("noOrg", out _))
+ throw new Exception("Could not determine organization ID.");
+
+ string? email = root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String ? em.GetString() : null;
+ string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String ? oi.GetString() : null;
+ string? planLabel = root.TryGetProperty("planLabel", out var pl) && pl.ValueKind == JsonValueKind.String ? pl.GetString() : null;
+ string? lao = root.TryGetProperty("lastActiveOrg", out var laoProp) && laoProp.ValueKind == JsonValueKind.String ? laoProp.GetString() : null;
+
+ DiagLastActiveOrg = lao ?? "";
+ DiagLastPath = $"/api/bootstrap + /api/organizations/…/usage (browser)";
+ DiagLastStatus = 200;
+
+ UsageResponse? usage = null;
+ OverageSpendLimit? overage = null;
+ PrepaidCredits? prepaid = null;
+
+ if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
+ usage = JsonSerializer.Deserialize(us.GetRawText(), JsonOpts);
+ if (root.TryGetProperty("overage", out var ov) && ov.ValueKind == JsonValueKind.Object)
+ overage = JsonSerializer.Deserialize(ov.GetRawText(), JsonOpts);
+ if (root.TryGetProperty("prepaid", out var pp) && pp.ValueKind == JsonValueKind.Object)
+ prepaid = JsonSerializer.Deserialize(pp.GetRawText(), JsonOpts);
+
+ return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel);
+ }
+
+ // ── HttpClient refresh (fallback — before browser is ready) ──────────────
+
+ private async Task<(List, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
+ TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
+ {
+ // Prefer the org the user last had active, not necessarily memberships[0].
+ var lastActiveOrg = cookies.FirstOrDefault(c => c.Name == "lastActiveOrg").Value;
+ DiagLastActiveOrg = lastActiveOrg ?? "";
+
+ using var http = BuildClient(cookies);
+
+ var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
+ DiagLastPath = "/api/bootstrap";
+ DiagLastStatus = (int)bootstrapResp.StatusCode;
+ if ((int)bootstrapResp.StatusCode is 401 or 403)
+ throw new Exception("Session expired — please re-authenticate.");
+ if (!bootstrapResp.IsSuccessStatusCode)
+ throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
+
+ var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
+ var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson, lastActiveOrg);
+
+ if (string.IsNullOrEmpty(orgId))
+ orgId = await FetchOrgIdFromListAsync(http);
+
+ if (string.IsNullOrEmpty(orgId))
+ return ([], null, null, null, email, orgId, planLabel, true);
+
+ var ut = http.GetAsync($"https://claude.ai/api/organizations/{orgId}/usage");
+ var ot = FetchAsync(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
+ var pt = FetchAsync(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
+ await Task.WhenAll(ut, ot, pt);
+
+ var usageResp = ut.Result;
+ DiagLastPath = $"/api/organizations/{orgId}/usage";
+ DiagLastStatus = (int)usageResp.StatusCode;
+ if (!usageResp.IsSuccessStatusCode)
+ throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
+
+ var usageJson = await usageResp.Content.ReadAsStringAsync();
+ var usage = JsonSerializer.Deserialize(usageJson, JsonOpts);
+
+ return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
+ }
+
+ // ── Startup cache ─────────────────────────────────────────────────────────
+ // Called before the browser is ready. Sets sign-in state and shows placeholders
+ // with any previously accumulated burn history so the popup isn't blank.
+
+ public async Task LoadFromCacheAsync()
+ {
+ if (string.IsNullOrEmpty(AppSettings.Default.CookieStore)) return;
+
+ var placeholders = LoadPlaceholderLimits();
+ foreach (var limit in placeholders)
+ {
+ var key = limit.Window.ToString();
+ if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
+ limit.BurnHistory = [.. hist];
+ }
+
+ await Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ Limits = placeholders;
+ IsSignedIn = true;
+ });
+ }
+
+ public async Task SignOutAsync()
+ {
+ AppSettings.Default.CookieStore = "";
+ AppSettings.Default.Save();
+
+ await Application.Current.Dispatcher.InvokeAsync(() =>
+ {
+ IsSignedIn = false;
+ UserEmail = "";
+ PlanLabel = "Claude";
+ ErrorMessage = "Signed out.";
+ Limits = [];
+ });
+ }
+
+ public static async Task> GetCookiesAsync()
+ {
+ var raw = AppSettings.Default.CookieStore;
+ if (string.IsNullOrEmpty(raw)) return [];
+ try
+ {
+ return JsonSerializer.Deserialize>(raw)
+ ?.Select(c => (c.Name, c.Value, c.Domain, c.Path))
+ .ToList() ?? [];
+ }
+ catch { return []; }
+ }
+
+ public static void SaveCookies(IEnumerable cookies)
+ {
+ var entries = cookies
+ .Where(c => c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"))
+ .Select(c => new CookieEntry { Name = c.Name, Value = c.Value, Domain = c.Domain, Path = c.Path })
+ .ToList();
+ AppSettings.Default.CookieStore = JsonSerializer.Serialize(entries);
+ AppSettings.Default.Save();
+ }
+
+ private static HttpClient BuildClient(List<(string Name, string Value, string Domain, string Path)> cookies)
+ {
+ var handler = new HttpClientHandler { UseCookies = false };
+ var http = new HttpClient(handler);
+ http.DefaultRequestHeaders.Add("accept", "application/json");
+ http.DefaultRequestHeaders.Add("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
+ http.DefaultRequestHeaders.Add("Origin", "https://claude.ai");
+ http.DefaultRequestHeaders.Add("Referer", "https://claude.ai/");
+ http.DefaultRequestHeaders.Add("sec-fetch-dest", "empty");
+ http.DefaultRequestHeaders.Add("sec-fetch-mode", "cors");
+ http.DefaultRequestHeaders.Add("sec-fetch-site", "same-origin");
+ http.DefaultRequestHeaders.Add("sec-ch-ua", "\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\"");
+ http.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0");
+ http.DefaultRequestHeaders.Add("sec-ch-ua-platform", "\"Windows\"");
+ var cookieHeader = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
+ http.DefaultRequestHeaders.Add("Cookie", cookieHeader);
+ return http;
+ }
+
+ private static async Task FetchAsync(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(json, JsonOpts);
+ }
+ catch { return null; }
+ }
+
+ // Parses bootstrap JSON and returns email, org ID, and plan label.
+ // preferredOrgId (from the lastActiveOrg cookie) selects the correct org when the
+ // user belongs to multiple — memberships[0] is always the "Individual Org" which
+ // returns 403 on usage endpoints if the paid plan is on a different org.
+ private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json, string? preferredOrgId = null)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ string? email = null;
+ if (root.TryGetProperty("account", out var acct) &&
+ acct.TryGetProperty("email_address", out var em))
+ email = em.GetString();
+
+ // Collect all org objects from memberships
+ var allOrgs = new List();
+ if (root.TryGetProperty("account", out var acctNode) &&
+ acctNode.TryGetProperty("memberships", out var mems))
+ {
+ foreach (var mem in mems.EnumerateArray())
+ if (mem.TryGetProperty("organization", out var org))
+ allOrgs.Add(org);
+ }
+ if (allOrgs.Count == 0 && root.TryGetProperty("memberships", out var rootMems))
+ {
+ foreach (var mem in rootMems.EnumerateArray())
+ if (mem.TryGetProperty("organization", out var org))
+ allOrgs.Add(org);
+ }
+
+ // Prefer the org matching preferredOrgId, fall back to first
+ JsonElement activeOrg = default;
+ if (!string.IsNullOrEmpty(preferredOrgId))
+ {
+ foreach (var org in allOrgs)
+ {
+ if (org.TryGetProperty("uuid", out var u) && u.GetString() == preferredOrgId)
+ {
+ activeOrg = org;
+ break;
+ }
+ }
+ }
+ if (activeOrg.ValueKind == JsonValueKind.Undefined && allOrgs.Count > 0)
+ activeOrg = allOrgs[0];
+
+ string? orgId = null;
+ if (activeOrg.ValueKind != JsonValueKind.Undefined &&
+ activeOrg.TryGetProperty("uuid", out var uuid))
+ orgId = uuid.GetString();
+
+ if (string.IsNullOrEmpty(orgId) &&
+ root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
+ {
+ if (orgs[0].TryGetProperty("uuid", out var u))
+ orgId = u.GetString();
+ }
+
+ string? planLabel = null;
+ if (activeOrg.ValueKind != JsonValueKind.Undefined &&
+ activeOrg.TryGetProperty("capabilities", out var caps) &&
+ caps.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var cap in caps.EnumerateArray())
+ {
+ var s = cap.GetString() ?? "";
+ if (s.StartsWith("claude_", StringComparison.OrdinalIgnoreCase))
+ {
+ var name = s.Substring("claude_".Length);
+ if (name.Length > 0)
+ planLabel = char.ToUpper(name[0]) + name.Substring(1).ToLower();
+ break;
+ }
+ }
+ }
+ return (email, orgId, planLabel);
+ }
+ catch { return (null, null, null); }
+ }
+
+ private static async Task FetchOrgIdFromListAsync(HttpClient http)
+ {
+ try
+ {
+ var resp = await http.GetAsync("https://claude.ai/api/organizations");
+ if (!resp.IsSuccessStatusCode) return null;
+ var json = await resp.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() > 0)
+ {
+ var first = root[0];
+ if (first.TryGetProperty("uuid", out var uuid))
+ return uuid.GetString();
+ }
+ return null;
+ }
+ catch { return null; }
+ }
+
+ private static List BuildLimits(UsageResponse? usage)
+ {
+ if (usage == null) return [];
+ var now = DateTime.Now;
+ var result = new List();
+
+ if (usage.FiveHour != null)
+ {
+ var reset = ParseDate(usage.FiveHour.ResetsAt, now);
+ var pct = Math.Clamp(usage.FiveHour.Utilization, 0, 100);
+ result.Add(new AgentLimit
+ {
+ Window = WindowKind.FiveHour,
+ UsedPercent = pct,
+ TimeRemaining = TimeLeft(reset, now),
+ ResetDate = reset,
+ BurnRate = pct / 5.0,
+ IsLive = true,
+ });
+ }
+
+ if (usage.SevenDay != null)
+ {
+ var reset = ParseDate(usage.SevenDay.ResetsAt, now);
+ var pct = Math.Clamp(usage.SevenDay.Utilization, 0, 100);
+ result.Add(new AgentLimit
+ {
+ Window = WindowKind.SevenDay,
+ UsedPercent = pct,
+ TimeRemaining = TimeLeft(reset, now),
+ ResetDate = reset,
+ BurnRate = pct / (7 * 24.0),
+ IsLive = true,
+ });
+ }
+
+ return result;
+ }
+
+ private static DateTime ParseDate(string? s, DateTime fallback)
+ {
+ if (s == null) return fallback.AddHours(1);
+ return DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.RoundtripKind, out var d)
+ ? d.ToLocalTime() : fallback.AddHours(1);
+ }
+
+ private static string TimeLeft(DateTime reset, DateTime now)
+ {
+ var diff = reset - now;
+ if (diff <= TimeSpan.Zero) return "resetting...";
+ if (diff.TotalDays >= 1)
+ return $"{(int)diff.TotalDays}d {diff.Hours}h";
+ return $"{diff.Hours}h {diff.Minutes}m";
+ }
+
+ private static List LoadPlaceholderLimits()
+ {
+ var now = DateTime.Now;
+ return
+ [
+ new() { Window = WindowKind.FiveHour, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddHours(1) },
+ new() { Window = WindowKind.SevenDay, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddDays(7) },
+ ];
+ }
+
+ private void LoadPlaceholders() => Limits = LoadPlaceholderLimits();
+
+ private void LoadBurnHistory()
+ {
+ try
+ {
+ var raw = AppSettings.Default.BurnHistory;
+ if (string.IsNullOrEmpty(raw)) return;
+ var saved = JsonSerializer.Deserialize>>(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(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.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; } = "/";
+ }
+}
diff --git a/windows/WebViewFetchWindow.cs b/windows/WebViewFetchWindow.cs
new file mode 100644
index 0000000..d5dfc2f
--- /dev/null
+++ b/windows/WebViewFetchWindow.cs
@@ -0,0 +1,159 @@
+using Microsoft.Web.WebView2.Core;
+using Microsoft.Web.WebView2.Wpf;
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace ClaudeCheckerWindows;
+
+// Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
+// Uses the same user data folder as LoginWindow so the session (including
+// cf_clearance) is always shared and fresh — equivalent to macOS's WKWebsiteDataStore.
+//
+// Two usage modes:
+// • Persistent (App.BackgroundBrowser): created once at startup, navigates to
+// claude.ai once, then scripts run directly on the live page every refresh.
+// No re-navigation = no memory accumulation.
+// • One-shot (LoginWindow): created, used, closed — same as before.
+public sealed class WebViewFetchWindow : Window
+{
+ private readonly WebView2 _wv = new();
+ private bool _initialized;
+ private bool _readyForScript; // true after first navigation to claude.ai completes
+
+ private static readonly string UserDataFolder =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ClaudeChecker", "WebView2");
+
+ public bool IsReady => _readyForScript;
+
+ public WebViewFetchWindow()
+ {
+ Width = 1;
+ Height = 1;
+ Left = -9999;
+ Top = -9999;
+ ShowInTaskbar = false;
+ WindowStyle = WindowStyle.None;
+ AllowsTransparency = true;
+ Opacity = 0;
+ Content = _wv;
+ }
+
+ // ── Persistent-mode init ──────────────────────────────────────────────────
+
+ // Call once at startup. Navigates to claude.ai so the session/cookies are
+ // established and cf_clearance is fresh. Subsequent RunScriptAsync calls
+ // skip navigation and just execute JS on the live page.
+ public async Task InitAsync()
+ {
+ await EnsureInitAsync();
+
+ var navDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? h = null;
+ h = (_, e) => { _wv.CoreWebView2.NavigationCompleted -= h; navDone.TrySetResult(e.IsSuccess); };
+ _wv.CoreWebView2.NavigationCompleted += h;
+ _wv.CoreWebView2.Navigate("https://claude.ai");
+
+ // Wait up to 15 s for initial navigation
+ await Task.WhenAny(navDone.Task, Task.Delay(15000));
+ _readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
+ }
+
+ // Run a JS script on the already-loaded claude.ai page.
+ // The script must call window.chrome.webview.postMessage(result).
+ public async Task RunScriptAsync(string script, int timeoutMs = 10000)
+ {
+ if (!_readyForScript) return null;
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? msgHandler = null;
+ msgHandler = (_, args) =>
+ {
+ _wv.CoreWebView2.WebMessageReceived -= msgHandler;
+ tcs.TrySetResult(args.WebMessageAsJson);
+ };
+ _wv.CoreWebView2.WebMessageReceived += msgHandler;
+
+ try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
+ catch
+ {
+ _wv.CoreWebView2.WebMessageReceived -= msgHandler;
+ return null;
+ }
+
+ _ = Task.Delay(timeoutMs).ContinueWith(_ =>
+ {
+ _wv.CoreWebView2.WebMessageReceived -= msgHandler;
+ tcs.TrySetResult(null);
+ });
+
+ return await tcs.Task;
+ }
+
+ // ── One-shot mode (LoginWindow) ───────────────────────────────────────────
+
+ public async Task FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
+ {
+ await EnsureInitAsync();
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ EventHandler? msgHandler = null;
+ msgHandler = (_, args) =>
+ {
+ _wv.CoreWebView2.WebMessageReceived -= msgHandler;
+ tcs.TrySetResult(args.WebMessageAsJson);
+ };
+ _wv.CoreWebView2.WebMessageReceived += msgHandler;
+
+ EventHandler? navHandler = null;
+ navHandler = async (_, e) =>
+ {
+ _wv.CoreWebView2.NavigationCompleted -= navHandler;
+ if (!e.IsSuccess) { tcs.TrySetResult(null); return; }
+ try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
+ catch { tcs.TrySetResult(null); }
+ };
+ _wv.CoreWebView2.NavigationCompleted += navHandler;
+ _wv.CoreWebView2.Navigate(navigateUrl);
+
+ _ = Task.Delay(timeoutMs).ContinueWith(_ =>
+ {
+ _wv.CoreWebView2.WebMessageReceived -= msgHandler;
+ tcs.TrySetResult(null);
+ });
+
+ return await tcs.Task;
+ }
+
+ // Re-navigate to claude.ai with the current session (call after sign-in).
+ public async Task ReloadAsync()
+ {
+ if (_wv.CoreWebView2 == null) { await InitAsync(); return; }
+ _readyForScript = false;
+ var navDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? h = null;
+ h = (_, e) => { _wv.CoreWebView2.NavigationCompleted -= h; navDone.TrySetResult(e.IsSuccess); };
+ _wv.CoreWebView2.NavigationCompleted += h;
+ _wv.CoreWebView2.Navigate("https://claude.ai");
+ await Task.WhenAny(navDone.Task, Task.Delay(15000));
+ _readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
+ }
+
+ // ── Shared init ───────────────────────────────────────────────────────────
+
+ private int _initGuard;
+
+ private async Task EnsureInitAsync()
+ {
+ if (_initialized) return;
+ if (Interlocked.Exchange(ref _initGuard, 1) != 0) return;
+
+ var env = await CoreWebView2Environment.CreateAsync(userDataFolder: UserDataFolder);
+ await _wv.EnsureCoreWebView2Async(env);
+ _initialized = true;
+ }
+}
diff --git a/windows/installer.iss b/windows/installer.iss
new file mode 100644
index 0000000..c74c399
--- /dev/null
+++ b/windows/installer.iss
@@ -0,0 +1,41 @@
+#ifndef AppVersion
+ #define AppVersion "0.0.1"
+#endif
+
+[Setup]
+AppName=ClaudeChecker
+AppVersion={#AppVersion}
+AppPublisher=superdooper86
+AppPublisherURL=https://github.com/superdooper86/claudechecker
+AppSupportURL=https://github.com/superdooper86/claudechecker/issues
+AppUpdatesURL=https://github.com/superdooper86/claudechecker/releases
+DefaultDirName={localappdata}\ClaudeChecker
+DefaultGroupName=ClaudeChecker
+DisableProgramGroupPage=yes
+PrivilegesRequired=lowest
+OutputDir=.
+OutputBaseFilename=ClaudeChecker-Installer
+Compression=lzma
+SolidCompression=yes
+WizardStyle=modern
+UninstallDisplayIcon={app}\ClaudeChecker.exe
+SourceDir=..
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
+
+[Files]
+Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
+
+[Icons]
+Name: "{group}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"
+Name: "{userdesktop}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"; Tasks: desktopicon
+
+[Run]
+; Silent install (auto-update): launch automatically
+Filename: "{app}\ClaudeChecker.exe"; Flags: nowait; Check: WizardSilent
+; Manual install: show launch checkbox on final page
+Filename: "{app}\ClaudeChecker.exe"; Description: "{cm:LaunchProgram,ClaudeChecker}"; Flags: nowait postinstall skipifsilent