Compare commits

...
Author SHA1 Message Date
SuperDooper e16bfa59d0 Diagnostics: remove email, redact IDs on copy 2026-05-11 20:28:26 +02:00
SuperDooper b0542d7291 fix projected hit badge on Windows — show after-reset or hit time, not last-refresh time 2026-05-11 15:07:47 +02:00
SuperDooper c90eb7be69 port multi-org fix and add diagnostics panel to Windows app
Read lastActiveOrg cookie in both the browser JS path and HttpClient
fallback, then prefer the matching org in ParseBootstrap for both
org ID and plan label selection — same fix as macOS v1.3.0.

Add Settings → Diagnostics panel showing app state, last request
details, cookie store counts/domains, and stored cookie names.
2026-05-11 14:18:59 +02:00
SuperDooper 2fc7e8f9eb chore: update release notes for beta.49 2026-05-08 22:52:11 +02:00
SuperDooper 877d59bb26 fix: restore PopupWindow; 1s footer timer; Xm Ys ago format 2026-05-08 22:45:27 +02:00
SuperDooper 8a69e740ee fix: footer timer 1s; format Xm Ys ago when over 60s 2026-05-08 22:42:40 +02:00
SuperDooper d4f284d829 fix: remove sparkline from limit cards (belongs only in Session Diary) 2026-05-08 22:37:32 +02:00
SuperDooper 0ed83be9eb fix: ToggleButton transparent template; IsPressed states; IsSelected ComboBoxItem 2026-05-08 22:37:31 +02:00
SuperDooper 4cc29292f1 fix: ComboBox items use ComboBoxItem; diary stats inside card 2026-05-08 22:27:34 +02:00
SuperDooper fca1a7eb60 fix: credit values /100; add after-reset/today badge to cards 2026-05-08 22:17:33 +02:00
SuperDooper 013569ea8b fix: gauge — remove 'used' label, increase pct font to 18 2026-05-08 22:17:32 +02:00
SuperDooper 9ef83c1a1e refactor: LoginWindow only saves cookies; all data fetched by background browser 2026-05-08 22:04:10 +02:00
SuperDooper cabc2b6326 refactor: remove all API cache reads/writes; live-only refresh via browser 2026-05-08 22:04:10 +02:00
SuperDooper b01afc5be7 refactor: strip API-response cache fields from AppSettings 2026-05-08 22:04:08 +02:00
SuperDooper 4b211a17ed refactor: use persistent WebView2 as primary refresh path, HttpClient as fallback 2026-05-08 21:57:55 +02:00
SuperDooper de41ea9dc4 fix: make WebViewFetchWindow public; add IsReady property 2026-05-08 21:57:54 +02:00
SuperDooper 8e71671abb fix: persistent background WebView2 for live overage/prepaid on every refresh 2026-05-08 21:47:28 +02:00
SuperDooper af36a817fb fix: persistent background WebView2 for live overage/prepaid on every refresh 2026-05-08 21:47:27 +02:00
SuperDooper e908d71eb6 fix: persistent background WebView2 for live overage/prepaid on every refresh 2026-05-08 21:47:26 +02:00
SuperDooper ee6b1c4183 fix: session diary at bottom; show popup on launch 2026-05-08 21:40:44 +02:00
SuperDooper 4377c9a735 fix: session diary at bottom; show popup on launch 2026-05-08 21:40:43 +02:00
SuperDooper 1cf3f90fd6 fix: correct extra_usage credit unit (/100), add space in currency, Unlimited label, fetch overage/prepaid at login 2026-05-08 21:37:32 +02:00
SuperDooper 3d8482cf21 fix: correct extra_usage credit unit (/100), add space in currency, Unlimited label, fetch overage/prepaid at login 2026-05-08 21:37:32 +02:00
SuperDooper 44b7feb3c0 fix: show extra usage credits from extra_usage fallback; preserve on refresh 2026-05-08 21:32:19 +02:00
SuperDooper 236990ca45 fix: show extra usage credits from extra_usage fallback; preserve on refresh 2026-05-08 21:32:18 +02:00
SuperDooper 8d523cbb87 fix: show extra usage credits from extra_usage fallback; preserve on refresh 2026-05-08 21:32:17 +02:00
SuperDooper da270de6d6 fix: extract plan label from capabilities in SaveAndClose; try org endpoint on refresh 2026-05-08 21:16:00 +02:00
SuperDooper f4bed1b585 fix: extract plan label from capabilities in SaveAndClose; try org endpoint on refresh 2026-05-08 21:15:59 +02:00
SuperDooper ae7a7db37e debug: log org property names and capabilities value instead of truncated raw JSON 2026-05-08 21:05:59 +02:00
SuperDooper dec6a6eb75 debug: save raw bootstrap JSON to DebugInfo to diagnose plan label 2026-05-08 20:55:40 +02:00
SuperDooper 7a8350b578 Update release notes for beta.36 2026-05-08 19:38:29 +02:00
SuperDooper 222ee55189 Load cached state immediately on startup before background refresh 2026-05-08 19:38:19 +02:00
SuperDooper ec7260bd0d Fix auth failure handling, cache plan/overage/prepaid, restore all from cache on startup 2026-05-08 19:38:18 +02:00
SuperDooper 6d34567d85 Add PlanLabel, OverageJson, PrepaidJson cache fields 2026-05-08 19:38:16 +02:00
SuperDooper 4395ae0594 Update release notes for beta.10 2026-05-08 19:19:51 +02:00
SuperDooper 4ff2337b4d Fix compile errors, surface HTTP errors, save live UsageJson on successful fetch 2026-05-08 19:18:03 +02:00
SuperDooper 5db9321ac4 Parse plan label from capabilities array instead of plan_type 2026-05-08 19:14:55 +02:00
SuperDooper 8c14f59c42 Parse plan label from bootstrap API instead of hardcoding 2026-05-08 19:09:33 +02:00
SuperDooper a7dc3d553c Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:19:07 +02:00
SuperDooper 264db5127d Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:18:58 +02:00
SuperDooper c71e63905b Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:18:57 +02:00
SuperDooper 0fc124ac22 Add Session Diary and Extra Usage Credits sections to Windows app 2026-05-08 17:18:56 +02:00
SuperDooper 6858798b14 Release notes for beta.33 2026-05-08 17:01:42 +02:00
SuperDooper c3ad730677 Remove debug banner references from PopupWindow code-behind (beta.33) 2026-05-08 17:01:37 +02:00
SuperDooper 0dcfcbfb6f Remove debug banner from main panel (beta.33) 2026-05-08 17:01:31 +02:00
SuperDooper 31789df35c Fix gauge text centering (beta.33) 2026-05-08 17:01:25 +02:00
SuperDooper 8e45241ddc chore: release notes for beta.32 2026-05-08 16:49:56 +02:00
SuperDooper 78e9004624 fix: correct usage response field names — five_hour/seven_day not claude_ai_default_5h/claude_ai_default 2026-05-08 16:48:46 +02:00
SuperDooper 2dcea91a63 chore: release notes for beta.31 2026-05-08 16:46:17 +02:00
SuperDooper d240b543eb fix: show placeholder cards when signed in but no cached usage data 2026-05-08 16:46:04 +02:00
SuperDooper 1a35ec064c debug: include raw usage API response in debug output 2026-05-08 16:46:03 +02:00
SuperDooper 204691772c chore: release notes for beta.30 2026-05-08 16:35:48 +02:00
SuperDooper 15e3f1dd29 fix: remove WebView2 from periodic refresh (memory), add CORS headers to HttpClient 2026-05-08 16:35:35 +02:00
SuperDooper bcd208684b chore: release notes for beta.29 2026-05-08 16:29:03 +02:00
SuperDooper 2bdbe4943d fix: same org ID path fix in ParseBootstrap and WebView2 refresh script 2026-05-08 16:28:48 +02:00
SuperDooper 1c365a55a8 fix: org ID is at b.account.memberships[0].organization.uuid, not b.memberships 2026-05-08 16:28:47 +02:00
SuperDooper 8b367ee539 chore: release notes for beta.28 2026-05-08 16:24:42 +02:00
SuperDooper 9134b8945d fix: don't wipe existing limits when refresh returns no data 2026-05-08 16:24:31 +02:00
SuperDooper 19b1751e78 debug: wire DebugBanner in InitSettings 2026-05-08 16:24:30 +02:00
SuperDooper a20956cde2 debug: show debug banner on main panel so it's always visible 2026-05-08 16:24:29 +02:00
SuperDooper 659171bf0a debug: capture raw bootstrap JSON in debug field, try account.uuid as org ID fallback 2026-05-08 16:24:28 +02:00
SuperDooper f5b3accc13 chore: release notes for beta.27 2026-05-08 16:15:17 +02:00
SuperDooper 22e9096f7c fix: post plain objects in WebView2 refresh script 2026-05-08 16:15:07 +02:00
SuperDooper 6aee17fad2 fix: use WebMessageAsJson 2026-05-08 16:15:05 +02:00
SuperDooper b594889239 fix: use WebMessageAsJson and post plain objects (not JSON strings) 2026-05-08 16:15:04 +02:00
SuperDooper 0300e4a72e chore: release notes for beta.26 2026-05-08 16:12:06 +02:00
SuperDooper 7febb0384f fix: update WebView2 refresh script to use postMessage 2026-05-08 16:11:55 +02:00
SuperDooper 55e1f754c5 fix: switch WebViewFetchWindow to WebMessageReceived for script results 2026-05-08 16:11:54 +02:00
SuperDooper 03676a980f fix: use WebMessageReceived instead of ExecuteScriptAsync return value for async script results 2026-05-08 16:11:53 +02:00
SuperDooper a74d0cece5 chore: release notes for beta.25 2026-05-08 16:06:16 +02:00
SuperDooper b5cd7b1bc9 debug: show DebugInfo in settings panel after login 2026-05-08 16:06:06 +02:00
SuperDooper 3249dbd4aa debug: add DebugText block in settings panel 2026-05-08 16:06:05 +02:00
SuperDooper 189c513677 debug: capture bootstrap keys and save to DebugInfo 2026-05-08 16:06:04 +02:00
SuperDooper 05e66b22d1 feat: add DebugInfo field to AppSettings 2026-05-08 16:06:03 +02:00
SuperDooper 48e6f64c9a chore: release notes for beta.24 2026-05-08 16:00:28 +02:00
SuperDooper d55a92623c fix: add LoadFromCacheAsync, same JS fallbacks in WebView2 refresh path 2026-05-08 16:00:04 +02:00
SuperDooper c70a090983 fix: load from cache immediately after login, background refresh after 3s 2026-05-08 16:00:01 +02:00
SuperDooper cc0270326e fix: add /api/organizations + personal /api/usage fallbacks in login JS 2026-05-08 16:00:00 +02:00
SuperDooper 7601452f93 chore: release notes for beta.23 2026-05-08 15:50:04 +02:00
SuperDooper 5a78a021cb fix: trust cookies/cached auth for IsSignedIn, wider cookie domain filter 2026-05-08 15:49:47 +02:00
SuperDooper 7d465c13d8 fix: show update label in About, delay before refresh after login 2026-05-08 15:49:47 +02:00
SuperDooper ca67039e3e feat: add update-available label in About section 2026-05-08 15:49:46 +02:00
SuperDooper 1e4cdc09b3 fix: broaden JS orgId/email paths, URL-only auto-close 2026-05-08 15:49:45 +02:00
SuperDooper 4e154bcaa7 chore: release notes for beta.22 2026-05-08 15:30:34 +02:00
SuperDooper ea784384c1 fix: remove unused _dialogOpen field 2026-05-08 15:30:27 +02:00
SuperDooper 4e813995c9 fix: Unwrap Task<Task<string>> from Dispatcher.InvokeAsync with async lambda 2026-05-08 15:30:26 +02:00
11 changed files with 1006 additions and 319 deletions
+65 -37
View File
@@ -15,7 +15,8 @@
<SolidColorBrush x:Key="SurfaceBrush" Color="#FAFAFA" po:Freeze="False"/> <SolidColorBrush x:Key="SurfaceBrush" Color="#FAFAFA" po:Freeze="False"/>
<SolidColorBrush x:Key="BannerBgBrush" Color="#E8F2FB" po:Freeze="False"/> <SolidColorBrush x:Key="BannerBgBrush" Color="#E8F2FB" po:Freeze="False"/>
<SolidColorBrush x:Key="BannerBorderBrush" Color="#B3D6F5" po:Freeze="False"/> <SolidColorBrush x:Key="BannerBorderBrush" Color="#B3D6F5" po:Freeze="False"/>
<SolidColorBrush x:Key="HoverBrush" Color="#EBEBEB" po:Freeze="False"/> <SolidColorBrush x:Key="HoverBrush" Color="#EBEBEB" po:Freeze="False"/>
<SolidColorBrush x:Key="PressedBrush" Color="#D6D6D6" po:Freeze="False"/>
<SolidColorBrush x:Key="BadgeBrush" Color="#F0F0F0" po:Freeze="False"/> <SolidColorBrush x:Key="BadgeBrush" Color="#F0F0F0" po:Freeze="False"/>
<Style x:Key="HeaderText" TargetType="TextBlock"> <Style x:Key="HeaderText" TargetType="TextBlock">
@@ -26,27 +27,31 @@
</Style> </Style>
<Style x:Key="PrimaryButton" TargetType="Button"> <Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource BlueBrush}"/> <Setter Property="Background" Value="{DynamicResource BlueBrush}"/>
<Setter Property="Foreground" Value="White"/> <Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/> <Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,6"/> <Setter Property="Padding" Value="14,6"/>
<Setter Property="FontSize" Value="12"/> <Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontFamily" Value="Segoe UI"/> <Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="4" <Border x:Name="Bd" Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"> CornerRadius="4" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border> </Border>
<ControlTemplate.Triggers> <ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"> <Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#106EBE"/> <Setter TargetName="Bd" Property="Background" Value="#106EBE"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#005A9E"/>
</Trigger> </Trigger>
<Trigger Property="IsEnabled" Value="False"> <Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="#BDBDBD"/> <Setter TargetName="Bd" Property="Background" Value="#BDBDBD"/>
</Trigger> </Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
@@ -55,18 +60,20 @@
</Style> </Style>
<Style x:Key="SmallButton" TargetType="Button"> <Style x:Key="SmallButton" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource ButtonBrush}"/> <Setter Property="Background" Value="{DynamicResource ButtonBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Padding" Value="10,4"/> <Setter Property="Padding" Value="10,4"/>
<Setter Property="FontSize" Value="12"/> <Setter Property="FontSize" Value="12"/>
<Setter Property="FontFamily" Value="Segoe UI"/> <Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" <Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4" Padding="{TemplateBinding Padding}"> CornerRadius="4" Padding="{TemplateBinding Padding}">
@@ -74,7 +81,10 @@
</Border> </Border>
<ControlTemplate.Triggers> <ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"> <Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource HoverBrush}"/> <Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource PressedBrush}"/>
</Trigger> </Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
@@ -83,18 +93,19 @@
</Style> </Style>
<Style TargetType="ComboBox"> <Style TargetType="ComboBox">
<Setter Property="FontFamily" Value="Segoe UI"/> <Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="12"/> <Setter Property="FontSize" Value="12"/>
<Setter Property="Background" Value="{DynamicResource CardBrush}"/> <Setter Property="Background" Value="{DynamicResource CardBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,4"/> <Setter Property="Padding" Value="8,4"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="ComboBox"> <ControlTemplate TargetType="ComboBox">
<Grid> <Grid>
<Border x:Name="Border" <Border x:Name="Bd"
Background="{TemplateBinding Background}" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" BorderThickness="{TemplateBinding BorderThickness}"
@@ -106,14 +117,21 @@
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="{TemplateBinding Padding}" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" HorizontalAlignment="Left"/> VerticalAlignment="Center" HorizontalAlignment="Left"/>
<TextBlock Text="" FontSize="10" <TextBlock Text="&#x25BE;" FontSize="10"
Foreground="{DynamicResource SecondaryBrush}" Foreground="{DynamicResource SecondaryBrush}"
HorizontalAlignment="Right" VerticalAlignment="Center" HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,8,0" IsHitTestVisible="False"/> Margin="0,0,8,0" IsHitTestVisible="False"/>
<!-- Fully transparent toggle — no system chrome bleeds through -->
<ToggleButton Focusable="False" ClickMode="Press" <ToggleButton Focusable="False" ClickMode="Press"
FocusVisualStyle="{x:Null}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}" RelativeSource={RelativeSource TemplatedParent}}">
Background="Transparent" BorderThickness="0"/> <ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent"/>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</Grid> </Grid>
</Border> </Border>
<Popup x:Name="Popup" Placement="Bottom" <Popup x:Name="Popup" Placement="Bottom"
@@ -132,15 +150,21 @@
</Border> </Border>
</Popup> </Popup>
</Grid> </Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource HoverBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
</Setter> </Setter>
<Setter Property="ItemContainerStyle"> <Setter Property="ItemContainerStyle">
<Setter.Value> <Setter.Value>
<Style TargetType="ComboBoxItem"> <Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/> <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Background" Value="{DynamicResource CardBrush}"/> <Setter Property="Background" Value="{DynamicResource CardBrush}"/>
<Setter Property="Padding" Value="10,5"/> <Setter Property="Padding" Value="10,5"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template"> <Setter Property="Template">
<Setter.Value> <Setter.Value>
<ControlTemplate TargetType="ComboBoxItem"> <ControlTemplate TargetType="ComboBoxItem">
@@ -154,6 +178,10 @@
<Setter TargetName="Bd" Property="Background" <Setter TargetName="Bd" Property="Background"
Value="{DynamicResource HoverBrush}"/> Value="{DynamicResource HoverBrush}"/>
</Trigger> </Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{DynamicResource HoverBrush}"/>
</Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
+14 -3
View File
@@ -8,8 +8,12 @@ namespace ClaudeCheckerWindows;
public partial class App : Application public partial class App : Application
{ {
public static UsageViewModel ViewModel { get; } = new(); public static UsageViewModel ViewModel { get; } = new();
public static UpdateManager Updater { 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 Forms.NotifyIcon? _tray;
private PopupWindow? _popup; private PopupWindow? _popup;
@@ -33,11 +37,18 @@ public partial class App : Application
base.OnStartup(e); base.OnStartup(e);
ThemeManager.Initialize(); ThemeManager.Initialize();
SetupTray(); SetupTray();
ShowPopup();
ScheduleTimer(ViewModel.RefreshInterval); ScheduleTimer(ViewModel.RefreshInterval);
// Show background browser window on the UI thread before Task.Run
BackgroundBrowser.Show();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await Task.Delay(1000); 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 ViewModel.RefreshAsync();
await Updater.CheckForUpdatesAsync(); await Updater.CheckForUpdatesAsync();
}); });
+6 -3
View File
@@ -6,11 +6,14 @@ namespace ClaudeCheckerWindows;
public class AppSettings 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; } = ""; public string CookieStore { get; set; } = "";
// Accumulated sparkline data — built up over multiple refreshes.
public string BurnHistory { get; set; } = ""; public string BurnHistory { get; set; } = "";
public string OrgId { get; set; } = "";
public string Email { get; set; } = ""; // User preferences
public string UsageJson { get; set; } = "";
public int RefreshInterval { get; set; } = 120; public int RefreshInterval { get; set; } = 120;
public bool ShowInTaskbar { get; set; } = true; public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false; public bool BetaChannel { get; set; } = false;
+3 -14
View File
@@ -46,27 +46,16 @@ public class GaugeControl : FrameworkElement
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0), DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0),
new Pen(Accent, StrokeWidth)); new Pen(Accent, StrokeWidth));
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C); var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C);
var subColor = Color.FromRgb(0x9E, 0x9E, 0x9E);
var pctText = new FormattedText( var pctText = new FormattedText(
$"{(int)Math.Round(Percent)}%", $"{(int)Math.Round(Percent)}%",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal), new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
14, new SolidColorBrush(textColor), dpi); 18, new SolidColorBrush(textColor), dpi);
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height - 1));
var sub = new FormattedText( dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height / 2));
"Updated\nnow",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), 7,
new SolidColorBrush(subColor), dpi)
{
TextAlignment = TextAlignment.Center
};
dc.DrawText(sub, new Point(cx - sub.Width / 2, cy + 2));
} }
private static void DrawArc(DrawingContext dc, double cx, double cy, double r, private static void DrawArc(DrawingContext dc, double cx, double cy, double r,
+7 -41
View File
@@ -1,8 +1,5 @@
using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
@@ -22,7 +19,7 @@ public partial class LoginWindow : Window
{ {
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
System.IO.Path.Combine( System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2")); "ClaudeChecker", "WebView2"));
await Browser.EnsureCoreWebView2Async(env); await Browser.EnsureCoreWebView2Async(env);
@@ -35,9 +32,9 @@ public partial class LoginWindow : Window
if (!uri.Contains("claude.ai")) return; if (!uri.Contains("claude.ai")) return;
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai"); var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
var signedIn = cookies.Any(c => var signedIn = System.Linq.Enumerable.Any(cookies, c =>
c.Name.Equals("sessionKey", StringComparison.OrdinalIgnoreCase) || c.Name.Equals("sessionKey", System.StringComparison.OrdinalIgnoreCase) ||
c.Name.StartsWith("sk-ant", StringComparison.OrdinalIgnoreCase)); c.Name.StartsWith("sk-ant", System.StringComparison.OrdinalIgnoreCase));
await Dispatcher.InvokeAsync(() => await Dispatcher.InvokeAsync(() =>
{ {
@@ -47,8 +44,7 @@ public partial class LoginWindow : Window
: "Complete sign-in, then click Done."; : "Complete sign-in, then click Done.";
}); });
// Auto-close as soon as we detect sign-in on any claude.ai page if (!uri.Contains("/login") && !uri.Contains("/signin") && signedIn)
if (signedIn && !uri.Contains("/login") && !uri.Contains("/signin"))
await SaveAndClose(cookies); await SaveAndClose(cookies);
}; };
} }
@@ -65,40 +61,10 @@ public partial class LoginWindow : Window
if (_closing) return; if (_closing) return;
_closing = true; _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); UsageViewModel.SaveCookies(cookies);
// Fetch bootstrap + usage from within WebView2 (already authenticated, no header issues)
try
{
const string script = @"(async()=>{try{
const b=await(await fetch('/api/bootstrap',{headers:{accept:'application/json'}})).json();
const id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid||null;
const e=b?.account?.email_address||null;
if(!id)return{email:e,orgId:null,usage:null};
const u=await(await fetch('/api/organizations/'+id+'/usage',{headers:{accept:'application/json'}})).json();
return{email:e,orgId:id,usage:u};
}catch(ex){return{error:String(ex)};}})()";
var json = await Browser.CoreWebView2.ExecuteScriptAsync(script);
if (json != "null" && !string.IsNullOrEmpty(json))
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String)
AppSettings.Default.Email = em.GetString() ?? "";
if (root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String)
AppSettings.Default.OrgId = oi.GetString() ?? "";
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
AppSettings.Default.UsageJson = us.GetRawText();
AppSettings.Default.Save();
}
}
catch { }
await Dispatcher.InvokeAsync(() => DialogResult = true); await Dispatcher.InvokeAsync(() => DialogResult = true);
} }
} }
+11 -2
View File
@@ -21,8 +21,9 @@ public class AgentLimit
public class UsageResponse public class UsageResponse
{ {
[JsonPropertyName("claude_ai_default_5h")] public WindowData? FiveHour { get; set; } [JsonPropertyName("five_hour")] public WindowData? FiveHour { get; set; }
[JsonPropertyName("claude_ai_default")] public WindowData? SevenDay { get; set; } [JsonPropertyName("seven_day")] public WindowData? SevenDay { get; set; }
[JsonPropertyName("extra_usage")] public ExtraUsage? ExtraUsage { get; set; }
} }
public class WindowData public class WindowData
@@ -31,6 +32,14 @@ public class WindowData
[JsonPropertyName("resets_at")] public string? ResetsAt { 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 public class BootstrapResponse
{ {
[JsonPropertyName("account")] public AccountInfo? Account { get; set; } [JsonPropertyName("account")] public AccountInfo? Account { get; set; }
+48 -4
View File
@@ -150,7 +150,7 @@
<TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/> <TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{DynamicResource CardBrush}" CornerRadius="6" <Border Background="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Padding="14,12"> Padding="14,12" Margin="0,0,0,16">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@@ -173,12 +173,31 @@
<TextBlock Text="Built by superdooper86 &amp; claude" <TextBlock Text="Built by superdooper86 &amp; claude"
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/> FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
</StackPanel> </StackPanel>
<Button Grid.Column="2" x:Name="CheckUpdateButton" <StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
Style="{StaticResource SmallButton}" <Button x:Name="CheckUpdateButton"
Click="CheckUpdate_Click" VerticalAlignment="Center"/> Style="{StaticResource SmallButton}"
Click="CheckUpdate_Click"/>
<TextBlock x:Name="UpdateAvailableText" FontSize="10"
Foreground="{DynamicResource BlueBrush}"
HorizontalAlignment="Center"
Visibility="Collapsed" Margin="0,4,0,0"/>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
<TextBlock Text="DIAGNOSTICS" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Padding="14,12">
<StackPanel>
<TextBlock Text="View app state, last request details, and stored cookies."
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<Button Content="Open diagnostics" Style="{StaticResource SmallButton}"
HorizontalAlignment="Left" Click="Diagnostics_Click"/>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
@@ -244,5 +263,30 @@
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</Border> </Border>
<!-- Diagnostics panel -->
<Border x:Name="DiagnosticsPanel" Visibility="Collapsed"
Background="{DynamicResource BackgroundBrush}">
<DockPanel>
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="0,0,0,1" Background="{DynamicResource SurfaceBrush}" Padding="12,10">
<Grid>
<Button Content="← Back" HorizontalAlignment="Left"
Style="{StaticResource SmallButton}" BorderThickness="0"
Foreground="{DynamicResource BlueBrush}" Background="Transparent"
Click="BackFromDiag_Click"/>
<TextBlock Text="Diagnostics" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Button x:Name="CopyDiagButton" Content="Copy All" HorizontalAlignment="Right"
Style="{StaticResource SmallButton}" Click="CopyDiag_Click"/>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="DiagContentPanel" Margin="16,4,16,16"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid> </Grid>
</Window> </Window>
+445 -53
View File
@@ -1,6 +1,11 @@
using ClaudeCheckerWindows.Controls; using ClaudeCheckerWindows.Controls;
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Interop; using System.Windows.Interop;
@@ -14,7 +19,6 @@ public partial class PopupWindow : Window
private static readonly UsageViewModel VM = App.ViewModel; private static readonly UsageViewModel VM = App.ViewModel;
private static readonly UpdateManager Updater = App.Updater; private static readonly UpdateManager Updater = App.Updater;
private readonly DispatcherTimer _clockTimer; private readonly DispatcherTimer _clockTimer;
private bool _dialogOpen;
[DllImport("dwmapi.dll")] [DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
@@ -34,7 +38,7 @@ public partial class PopupWindow : Window
VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName)); VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName));
Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName)); Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName));
_clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_clockTimer.Tick += (_, _) => UpdateFooter(); _clockTimer.Tick += (_, _) => UpdateFooter();
_clockTimer.Start(); _clockTimer.Start();
@@ -55,6 +59,13 @@ public partial class PopupWindow : Window
CardsPanel.Children.Clear(); CardsPanel.Children.Clear();
foreach (var limit in VM.Limits) foreach (var limit in VM.Limits)
CardsPanel.Children.Add(BuildCard(limit)); 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) private static UIElement BuildCard(AgentLimit limit)
@@ -64,10 +75,8 @@ public partial class PopupWindow : Window
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"]; var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"]; var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
// Gauge
var gauge = new GaugeControl { Width = 76, Height = 76, Percent = limit.UsedPercent, Accent = accent }; var gauge = new GaugeControl { Width = 76, Height = 76, Percent = limit.UsedPercent, Accent = accent };
// Live badge
var liveBadge = new Border var liveBadge = new Border
{ {
Background = new SolidColorBrush(Color.FromArgb(30, 0, 160, 0)), Background = new SolidColorBrush(Color.FromArgb(30, 0, 160, 0)),
@@ -79,7 +88,6 @@ public partial class PopupWindow : Window
Foreground = new SolidColorBrush(Color.FromRgb(0, 120, 0)) } Foreground = new SolidColorBrush(Color.FromRgb(0, 120, 0)) }
}; };
// Usage badge
var usageBadge = new Border var usageBadge = new Border
{ {
Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)), Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
@@ -94,7 +102,7 @@ public partial class PopupWindow : Window
badgeStack.Children.Add(usageBadge); badgeStack.Children.Add(usageBadge);
var nameStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; 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 = "", FontSize = 11, Foreground = accent, VerticalAlignment = VerticalAlignment.Center });
nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold, nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }); Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11, nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11,
@@ -117,36 +125,64 @@ public partial class PopupWindow : Window
BorderThickness = new Thickness(0), BorderThickness = new Thickness(0),
}; };
var timeStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; // Left: time remaining + reset date inline
timeStack.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center }); var timeLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
timeStack.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12, 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 }); FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center });
timeLeft.Children.Add(new TextBlock
var resetText = new TextBlock
{ {
Text = $"Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}", Text = $" Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}",
FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center 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(); var timeRow = new Grid();
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetColumn(timeStack, 0); Grid.SetColumn(timeLeft, 0);
Grid.SetColumn(resetText, 1); Grid.SetColumn(timeRight, 1);
timeRow.Children.Add(timeStack); timeRow.Children.Add(timeLeft);
timeRow.Children.Add(resetText); timeRow.Children.Add(timeRight);
var sparkline = new SparklineControl
{
Height = 28, Margin = new Thickness(0, 8, 0, 0), LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
Data = limit.BurnHistory.Count > 0 ? limit.BurnHistory : null,
};
var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
infoPanel.Children.Add(headerRow); infoPanel.Children.Add(headerRow);
infoPanel.Children.Add(progress); infoPanel.Children.Add(progress);
infoPanel.Children.Add(timeRow); infoPanel.Children.Add(timeRow);
infoPanel.Children.Add(sparkline);
var cardGrid = new Grid { Margin = new Thickness(16, 12, 16, 12) }; 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 = GridLength.Auto });
@@ -180,12 +216,145 @@ public partial class PopupWindow : Window
return section; 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 ──────────────────────────────────────────────── // ── State updates ────────────────────────────────────────────────
private void OnVmChanged(string? prop) private void OnVmChanged(string? prop)
{ {
if (prop is nameof(UsageViewModel.Limits) or nameof(UsageViewModel.IsLoading) if (prop is nameof(UsageViewModel.Limits) or nameof(UsageViewModel.IsLoading)
or nameof(UsageViewModel.LastUpdated) or nameof(UsageViewModel.ErrorMessage)) or nameof(UsageViewModel.LastUpdated) or nameof(UsageViewModel.ErrorMessage)
or nameof(UsageViewModel.ExtraUsage) or nameof(UsageViewModel.Overage) or nameof(UsageViewModel.Prepaid))
{ {
RebuildCards(); RebuildCards();
UpdateFooter(); UpdateFooter();
@@ -199,7 +368,10 @@ public partial class PopupWindow : Window
private void OnUpdaterChanged(string? prop) private void OnUpdaterChanged(string? prop)
{ {
if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion)) if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion))
{
UpdateBannerState(); UpdateBannerState();
UpdateAboutSection();
}
if (prop is nameof(UpdateManager.DownloadProgress)) if (prop is nameof(UpdateManager.DownloadProgress))
UpdateProgress.Value = Updater.DownloadProgress * 100; UpdateProgress.Value = Updater.DownloadProgress * 100;
if (prop is nameof(UpdateManager.StatusMessage)) if (prop is nameof(UpdateManager.StatusMessage))
@@ -208,6 +380,21 @@ public partial class PopupWindow : Window
InstallButton.Visibility = Visibility.Collapsed; 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() private void UpdateBannerState()
{ {
var visible = Updater.UpdateAvailable; var visible = Updater.UpdateAvailable;
@@ -220,8 +407,14 @@ public partial class PopupWindow : Window
if (VM.LastUpdated.HasValue) if (VM.LastUpdated.HasValue)
{ {
var diff = DateTime.Now - VM.LastUpdated.Value; var diff = DateTime.Now - VM.LastUpdated.Value;
LastUpdatedText.Text = diff.TotalSeconds < 10 ? "Updated just now" string age;
: $"Updated {(int)diff.TotalSeconds}s ago"; 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 else
{ {
@@ -232,18 +425,33 @@ public partial class PopupWindow : Window
// ── Settings ───────────────────────────────────────────────────── // ── 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() private void InitSettings()
{ {
VersionLabel.Text = $"v{Updater.CurrentVersion}"; VersionLabel.Text = $"v{Updater.CurrentVersion}";
BetaToggle.IsChecked = Updater.BetaChannel; BetaToggle.IsChecked = Updater.BetaChannel;
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check"; UpdateAboutSection();
var intervals = new[] { ("1 min", 60), ("2 min", 120), ("3 min", 180), RefreshPicker.SelectionChanged -= RefreshPicker_Changed;
("4 min", 240), ("5 min", 300), ("10 min", 600) }; RefreshPicker.Items.Clear();
RefreshPicker.ItemsSource = intervals; int selectIdx = 1;
RefreshPicker.DisplayMemberPath = "Item1"; for (int i = 0; i < RefreshIntervals.Length; i++)
var idx = Array.FindIndex(intervals, x => x.Item2 == VM.RefreshInterval); {
RefreshPicker.SelectedIndex = idx < 0 ? 1 : idx; 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(); AuthPanel.Children.Clear();
var dot = new System.Windows.Shapes.Ellipse var dot = new System.Windows.Shapes.Ellipse
@@ -252,38 +460,215 @@ public partial class PopupWindow : Window
Fill = VM.IsSignedIn ? Brushes.LimeGreen : Brushes.Orange, Fill = VM.IsSignedIn ? Brushes.LimeGreen : Brushes.Orange,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
}; };
var label = new TextBlock var label2 = new TextBlock
{ {
Text = VM.IsSignedIn ? $"Signed in — {VM.UserEmail}" : "Not signed in", Text = VM.IsSignedIn ? $"Signed in — {VM.UserEmail}" : "Not signed in",
FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = (SolidColorBrush)Application.Current.Resources["TextBrush"], FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = (SolidColorBrush)Application.Current.Resources["TextBrush"],
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
}; };
var authRow = new StackPanel { Orientation = Orientation.Horizontal }; var authRow = new StackPanel { Orientation = Orientation.Horizontal };
authRow.Children.Add(dot); authRow.Children.Add(dot);
authRow.Children.Add(label); authRow.Children.Add(label2);
AuthPanel.Children.Add(authRow); AuthPanel.Children.Add(authRow);
SignOutButton.Visibility = VM.IsSignedIn ? Visibility.Visible : Visibility.Collapsed; SignOutButton.Visibility = VM.IsSignedIn ? Visibility.Visible : Visibility.Collapsed;
SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In"; SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In";
} }
// ── Diagnostics ──────────────────────────────────────────────────
private void Diagnostics_Click(object s, RoutedEventArgs e)
{
RebuildDiag();
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Visible;
}
private void BackFromDiag_Click(object s, RoutedEventArgs e)
{
DiagnosticsPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
}
private void CopyDiag_Click(object s, RoutedEventArgs e)
{
Clipboard.SetText(BuildDiagText());
CopyDiagButton.Content = "Copied! (IDs redacted)";
var t = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
t.Tick += (_, _) => { CopyDiagButton.Content = "Copy All"; t.Stop(); };
t.Start();
}
private void RebuildDiag()
{
DiagContentPanel.Children.Clear();
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
FrameworkElement MakeRow(string label, string value)
{
var grid = new Grid { Margin = new Thickness(0, 3, 0, 3) };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var lbl = new TextBlock
{
Text = label, FontSize = 11, Foreground = secondary,
VerticalAlignment = VerticalAlignment.Top,
};
var val = new TextBlock
{
Text = value, FontSize = 11, FontFamily = new FontFamily("Consolas"),
Foreground = text, TextWrapping = TextWrapping.Wrap,
};
Grid.SetColumn(lbl, 0);
Grid.SetColumn(val, 1);
grid.Children.Add(lbl);
grid.Children.Add(val);
return grid;
}
void AddSection(string title, IEnumerable<FrameworkElement> rows)
{
DiagContentPanel.Children.Add(new TextBlock
{
Text = title, Style = (Style)Application.Current.Resources["HeaderText"],
Margin = new Thickness(0, 12, 0, 6),
});
var panel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
foreach (var row in rows) panel.Children.Add(row);
DiagContentPanel.Children.Add(new Border
{
Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
CornerRadius = new CornerRadius(6),
BorderBrush = border, BorderThickness = new Thickness(1),
Child = panel,
});
}
// APP
AddSection("APP",
[
MakeRow("Version", Updater.CurrentVersion),
MakeRow("Signed in", VM.IsSignedIn ? "Yes" : "No"),
MakeRow("Org ID", string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId),
MakeRow("lastActiveOrg", string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg),
MakeRow("Error", VM.ErrorMessage ?? "(none)"),
]);
// LAST REQUEST
var reqRows = new List<FrameworkElement>
{
MakeRow("Path", string.IsNullOrEmpty(VM.DiagLastPath) ? "(none)" : VM.DiagLastPath),
MakeRow("Status", VM.DiagLastStatus == 0 ? "(none)" : VM.DiagLastStatus.ToString()),
MakeRow("Error", string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError),
};
if (VM.DiagLastFetch.HasValue)
reqRows.Add(MakeRow("Time", VM.DiagLastFetch.Value.ToString("HH:mm:ss")));
AddSection("LAST REQUEST", reqRows);
// COOKIE STORE
AddSection("COOKIE STORE",
[
MakeRow("Total cookies", VM.DiagCookieCount.ToString()),
MakeRow("Claude cookies", VM.DiagClaudeCookieCount.ToString()),
MakeRow("All domains", VM.DiagCookieDomains.Count == 0
? "(none)" : string.Join(", ", VM.DiagCookieDomains)),
]);
// STORED COOKIE NAMES
var storedNames = GetStoredCookieNames();
var nameRows = new List<FrameworkElement>();
if (storedNames.Count == 0)
{
nameRows.Add(MakeRow("(none)", ""));
}
else
{
var claudeC = storedNames.Where(c =>
c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com")).ToList();
var otherC = storedNames.Where(c =>
!c.Domain.Contains("claude.ai") && !c.Domain.Contains("anthropic.com")).ToList();
foreach (var c in claudeC)
nameRows.Add(MakeRow(c.Domain, c.Name));
if (otherC.Count > 0)
nameRows.Add(MakeRow($"other ({otherC.Count})",
string.Join(", ", otherC.Take(5).Select(c => c.Name))));
}
AddSection("STORED COOKIE NAMES", nameRows);
}
private static List<(string Name, string Domain)> GetStoredCookieNames()
{
try
{
var raw = AppSettings.Default.CookieStore;
if (string.IsNullOrEmpty(raw)) return [];
using var doc = JsonDocument.Parse(raw);
var list = new List<(string Name, string Domain)>();
foreach (var elem in doc.RootElement.EnumerateArray())
{
var name = elem.TryGetProperty("Name", out var n) ? n.GetString() ?? "" : "";
var domain = elem.TryGetProperty("Domain", out var d) ? d.GetString() ?? "" : "";
list.Add((name, domain));
}
return list;
}
catch { return []; }
}
private static string BuildDiagText()
{
var storedNames = GetStoredCookieNames();
var sb = new StringBuilder();
sb.AppendLine("=== ClaudeChecker Diagnostics (Windows) ===");
sb.AppendLine($"Version: {Updater.CurrentVersion}");
sb.AppendLine($"Signed in: {(VM.IsSignedIn ? "Yes" : "No")}");
sb.AppendLine($"Org ID: {(string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId)}");
sb.AppendLine($"lastActiveOrg: {(string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg)}");
sb.AppendLine($"Error: {VM.ErrorMessage ?? "(none)"}");
sb.AppendLine();
sb.AppendLine($"Last path: {VM.DiagLastPath}");
sb.AppendLine($"Last status: {VM.DiagLastStatus}");
sb.AppendLine($"Last error: {VM.DiagLastError}");
if (VM.DiagLastFetch.HasValue)
sb.AppendLine($"Last fetch: {VM.DiagLastFetch.Value:HH:mm:ss}");
sb.AppendLine();
sb.AppendLine($"Total cookies: {VM.DiagCookieCount}");
sb.AppendLine($"Claude cookies: {VM.DiagClaudeCookieCount}");
sb.AppendLine($"Domains: {string.Join(", ", VM.DiagCookieDomains)}");
sb.AppendLine();
sb.AppendLine("Stored cookies:");
foreach (var c in storedNames)
sb.AppendLine($" {c.Domain} {c.Name}");
return 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 ─────────────────────────────────────────────── // ── Event handlers ───────────────────────────────────────────────
private void Settings_Click(object s, RoutedEventArgs e) private void Settings_Click(object s, RoutedEventArgs e)
{ {
InitSettings(); InitSettings();
MainPanel.Visibility = Visibility.Collapsed; MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible; SettingsPanel.Visibility = Visibility.Visible;
UpdatePanel.Visibility = Visibility.Collapsed; UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
} }
private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain(); private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
private void ShowMain() private void ShowMain()
{ {
MainPanel.Visibility = Visibility.Visible; MainPanel.Visibility = Visibility.Visible;
SettingsPanel.Visibility = Visibility.Collapsed; SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed; UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
} }
private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync(); private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
@@ -295,9 +680,10 @@ public partial class PopupWindow : Window
CurrentVerLabel.Text = $"v{Updater.CurrentVersion}"; CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
NewVerLabel.Text = $"v{Updater.LatestVersion}"; NewVerLabel.Text = $"v{Updater.LatestVersion}";
ReleaseNotesText.Text = Updater.ReleaseNotes; ReleaseNotesText.Text = Updater.ReleaseNotes;
MainPanel.Visibility = Visibility.Collapsed; MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed; SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Visible; UpdatePanel.Visibility = Visibility.Visible;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
} }
private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain(); private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
@@ -312,14 +698,20 @@ public partial class PopupWindow : Window
private async void SignIn_Click(object s, RoutedEventArgs e) private async void SignIn_Click(object s, RoutedEventArgs e)
{ {
_dialogOpen = true;
var login = new LoginWindow { Owner = this }; var login = new LoginWindow { Owner = this };
if (login.ShowDialog() == true) if (login.ShowDialog() == true)
{ {
await VM.RefreshAsync(); await VM.LoadFromCacheAsync();
InitSettings(); InitSettings();
ShowMain();
_ = Task.Run(async () =>
{
await Task.Delay(3000);
await VM.RefreshAsync();
await Application.Current.Dispatcher.InvokeAsync(InitSettings);
});
} }
_dialogOpen = false;
Show(); Show();
Activate(); Activate();
} }
@@ -338,10 +730,10 @@ public partial class PopupWindow : Window
private void RefreshPicker_Changed(object s, SelectionChangedEventArgs e) private void RefreshPicker_Changed(object s, SelectionChangedEventArgs e)
{ {
if (RefreshPicker.SelectedItem is ValueTuple<string, int> selected) if (RefreshPicker.SelectedItem is ComboBoxItem item && item.Tag is int seconds)
{ {
VM.RefreshInterval = selected.Item2; VM.RefreshInterval = seconds;
((App)Application.Current).ScheduleTimer(selected.Item2); ((App)Application.Current).ScheduleTimer(seconds);
} }
} }
+22 -5
View File
@@ -1,6 +1,23 @@
## What's new in beta.21 ## What's new in beta.49
- Fixed: clicking Sign In now auto-closes as soon as you are detected as signed in (no need to click Done) ### Architecture
- Fixed: login now fetches and caches your data directly via the browser session (no more authentication failures) - All data (usage, plan, overage, prepaid credits) now fetched via the persistent background WebView2 — the same always-live session as the macOS app, so Cloudflare cookies never expire between refreshes
- Fixed: periodic refresh falls back to browser-session API calls when HttpClient is rejected - HttpClient path kept only as a brief startup fallback before the browser is ready
- Fixed: cached usage from login is shown immediately while refreshing - Settings file simplified: only stores session signal, burn history, and user preferences — no more cached API responses
### Bug fixes
- Plan label (e.g. "Pro") now updates on every refresh, not just at login
- Extra Usage Credits values now correct (were 100× too large)
- Limit and balance now update live on every refresh
- Refresh interval dropdown now shows "1 min", "2 min" etc. correctly
- ComboBox no longer flashes bright blue when clicked
- Buttons now show a visible pressed state
- Footer "Updated X ago" counter now ticks every second; shows "X min, Y sec ago" past 60 s
- Removed stray sparkline bars from the 5 h and 7 d limit cards
- App window now shown on launch
### UI improvements
- Gauge percentage larger, "used" label removed
- Each limit card now shows an "after reset" or "today HH:MM" badge
- Session Diary card redesigned to match macOS layout (stats row + sparkline, no Claude header)
- Reset date moved inline with time remaining
+270 -128
View File
@@ -20,10 +20,11 @@ public class UsageViewModel : INotifyPropertyChanged
private string? _errorMessage; private string? _errorMessage;
private bool _isSignedIn; private bool _isSignedIn;
private string _userEmail = ""; private string _userEmail = "";
private string _planLabel = "Pro"; private string _planLabel = "Claude";
private DateTime? _lastUpdated; private DateTime? _lastUpdated;
private OverageSpendLimit? _overage; private OverageSpendLimit? _overage;
private PrepaidCredits? _prepaid; private PrepaidCredits? _prepaid;
private ExtraUsage? _extraUsage;
private readonly Dictionary<string, List<double>> _burnHistory = []; private readonly Dictionary<string, List<double>> _burnHistory = [];
private const int MaxHistorySamples = 24; private const int MaxHistorySamples = 24;
@@ -37,6 +38,18 @@ public class UsageViewModel : INotifyPropertyChanged
public DateTime? LastUpdated { get => _lastUpdated; set => Set(ref _lastUpdated, value); } public DateTime? LastUpdated { get => _lastUpdated; set => Set(ref _lastUpdated, value); }
public OverageSpendLimit? Overage { get => _overage; set => Set(ref _overage, value); } public OverageSpendLimit? Overage { get => _overage; set => Set(ref _overage, value); }
public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); } public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); }
public ExtraUsage? ExtraUsage { get => _extraUsage; set => Set(ref _extraUsage, value); }
// Diagnostics — updated after each refresh, read by DiagnosticsPanel on demand
public string DiagLastActiveOrg { get; private set; } = "";
public string DiagOrgId { get; private set; } = "";
public string DiagLastPath { get; private set; } = "";
public int DiagLastStatus { get; private set; }
public string DiagLastError { get; private set; } = "";
public int DiagCookieCount { get; private set; }
public int DiagClaudeCookieCount { get; private set; }
public List<string> DiagCookieDomains { get; private set; } = [];
public DateTime? DiagLastFetch { get; private set; }
private int _refreshInterval = 120; private int _refreshInterval = 120;
public int RefreshInterval public int RefreshInterval
@@ -75,10 +88,13 @@ public class UsageViewModel : INotifyPropertyChanged
{ {
await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true); await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true);
// Snapshot stored cookies once — used for both diag and HttpClient fallback path.
var storedCookies = await GetCookiesAsync();
try try
{ {
var cookies = await GetCookiesAsync(); // CookieStore being non-empty is our signal that the user has signed in.
if (cookies.Count == 0) if (string.IsNullOrEmpty(AppSettings.Default.CookieStore))
{ {
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
@@ -89,42 +105,42 @@ public class UsageViewModel : INotifyPropertyChanged
return; return;
} }
// Try HttpClient first (fast path) List<AgentLimit> limits = [];
var (limits, overage, prepaid, email, orgId, ok) = await TryHttpRefreshAsync(cookies); OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
ExtraUsage? extraUsage = null;
string? email = null, orgId = null, planLabel = null;
string? refreshError = null;
// If HttpClient failed, fall back to WebView2 (uses existing browser session) // Primary: persistent background WebView2 — always-live cookies, same as macOS.
if (!ok) // Fallback: HttpClient with saved cookies, only if the browser isn't ready yet
(limits, email, orgId) = await TryWebView2RefreshAsync(); // (the brief window while InitAsync is still navigating at startup).
if (App.BackgroundBrowser.IsReady)
// Still no org ID? Use cached values from last successful login
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(email) && !string.IsNullOrEmpty(AppSettings.Default.Email))
email = AppSettings.Default.Email;
// If we have cached usage from login and still got nothing live, use that
if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{ {
try try
{ {
var cached = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>( (limits, overage, prepaid, extraUsage, email, orgId, planLabel) =
AppSettings.Default.UsageJson, JsonOpts); await TryBrowserRefreshAsync();
limits = BuildLimits(cached);
} }
catch { } catch (Exception ex)
}
bool isAuth = !string.IsNullOrEmpty(email) || !string.IsNullOrEmpty(orgId) || limits.Count > 0;
if (!isAuth && cookies.Count > 0)
{
// Cookies exist but nothing worked — session likely expired
await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
IsSignedIn = false; refreshError = ex.Message;
ErrorMessage = "Session expired — click Sign In to re-authenticate."; }
IsLoading = false; }
}); else
return; {
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) foreach (var limit in limits)
@@ -140,14 +156,29 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
Limits = limits; if (limits.Count > 0)
Overage = overage; {
Prepaid = prepaid; Limits = limits;
if (!string.IsNullOrEmpty(email)) UserEmail = email; Overage = overage;
Prepaid = prepaid;
if (extraUsage != null) ExtraUsage = extraUsage;
}
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true; IsSignedIn = true;
ErrorMessage = null; ErrorMessage = refreshError;
LastUpdated = DateTime.Now; LastUpdated = DateTime.Now;
IsLoading = false; 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) catch (Exception ex)
@@ -160,101 +191,157 @@ public class UsageViewModel : INotifyPropertyChanged
} }
} }
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, string?, string?, bool)> // ── Browser-based refresh (primary) ───────────────────────────────────────
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies) // Runs JS on the persistent claude.ai page. Same live cookies as macOS WKWebView.
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
TryBrowserRefreshAsync()
{ {
try // 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
using var http = BuildClient(cookies); // would return 403 on usage endpoints if the paid plan is on a different org.
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap"); const string script = @"(async()=>{try{
if (!bootstrapResp.IsSuccessStatusCode) const h={headers:{accept:'application/json'}};
return ([], null, null, null, null, false); 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 (email, orgId) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync()); var json = await Application.Current.Dispatcher.InvokeAsync(
() => App.BackgroundBrowser.RunScriptAsync(script)).Task.Unwrap();
if (string.IsNullOrEmpty(orgId)) if (string.IsNullOrEmpty(json) || json == "null")
orgId = await FetchOrgIdFromListAsync(http); throw new Exception("Browser refresh returned no data.");
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId)) using var doc = JsonDocument.Parse(json);
return ([], null, null, email, orgId, true); var root = doc.RootElement;
AppSettings.Default.OrgId = orgId; if (root.TryGetProperty("authError", out _))
AppSettings.Default.Save(); 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.");
var ut = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{orgId}/usage"); string? email = root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String ? em.GetString() : null;
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit"); string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String ? oi.GetString() : null;
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits"); string? planLabel = root.TryGetProperty("planLabel", out var pl) && pl.ValueKind == JsonValueKind.String ? pl.GetString() : null;
await Task.WhenAll(ut, ot, pt); string? lao = root.TryGetProperty("lastActiveOrg", out var laoProp) && laoProp.ValueKind == JsonValueKind.String ? laoProp.GetString() : null;
return (BuildLimits(ut.Result), ot.Result, pt.Result, email, orgId, true); DiagLastActiveOrg = lao ?? "";
} DiagLastPath = $"/api/bootstrap + /api/organizations/…/usage (browser)";
catch { return ([], null, null, null, null, false); } DiagLastStatus = 200;
UsageResponse? usage = null;
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
usage = JsonSerializer.Deserialize<UsageResponse>(us.GetRawText(), JsonOpts);
if (root.TryGetProperty("overage", out var ov) && ov.ValueKind == JsonValueKind.Object)
overage = JsonSerializer.Deserialize<OverageSpendLimit>(ov.GetRawText(), JsonOpts);
if (root.TryGetProperty("prepaid", out var pp) && pp.ValueKind == JsonValueKind.Object)
prepaid = JsonSerializer.Deserialize<PrepaidCredits>(pp.GetRawText(), JsonOpts);
return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel);
} }
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync() // ── HttpClient refresh (fallback — before browser is ready) ──────────────
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{ {
try // Prefer the org the user last had active, not necessarily memberships[0].
var lastActiveOrg = cookies.FirstOrDefault(c => c.Name == "lastActiveOrg").Value;
DiagLastActiveOrg = lastActiveOrg ?? "";
using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
DiagLastPath = "/api/bootstrap";
DiagLastStatus = (int)bootstrapResp.StatusCode;
if ((int)bootstrapResp.StatusCode is 401 or 403)
throw new Exception("Session expired — please re-authenticate.");
if (!bootstrapResp.IsSuccessStatusCode)
throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson, lastActiveOrg);
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, planLabel, true);
var ut = http.GetAsync($"https://claude.ai/api/organizations/{orgId}/usage");
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(ut, ot, pt);
var usageResp = ut.Result;
DiagLastPath = $"/api/organizations/{orgId}/usage";
DiagLastStatus = (int)usageResp.StatusCode;
if (!usageResp.IsSuccessStatusCode)
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
var usageJson = await usageResp.Content.ReadAsStringAsync();
var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
}
// ── Startup cache ─────────────────────────────────────────────────────────
// Called before the browser is ready. Sets sign-in state and shows placeholders
// with any previously accumulated burn history so the popup isn't blank.
public async Task LoadFromCacheAsync()
{
if (string.IsNullOrEmpty(AppSettings.Default.CookieStore)) return;
var placeholders = LoadPlaceholderLimits();
foreach (var limit in placeholders)
{ {
const string script = @"(async()=>{try{ var key = limit.Window.ToString();
const b=await(await fetch('/api/bootstrap',{headers:{accept:'application/json'}})).json(); if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
const id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid||null; limit.BurnHistory = [.. hist];
const em=b?.account?.email_address||null;
if(!id)return{email:em,orgId:null,usage:null};
const u=await(await fetch('/api/organizations/'+id+'/usage',{headers:{accept:'application/json'}})).json();
return{email:em,orgId:id,usage:u};
}catch(ex){return null;}})()";
var resultJson = await Application.Current.Dispatcher.InvokeAsync(async () =>
{
var host = new WebViewFetchWindow();
host.Show();
try { return await host.FetchAsync("https://claude.ai", script); }
finally { host.Close(); }
}).Task;
if (resultJson == null || resultJson == "null") return ([], null, null);
using var doc = System.Text.Json.JsonDocument.Parse(resultJson);
var root = doc.RootElement;
string? email = root.TryGetProperty("email", out var em) && em.ValueKind == System.Text.Json.JsonValueKind.String
? em.GetString() : null;
string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == System.Text.Json.JsonValueKind.String
? oi.GetString() : null;
List<AgentLimit> limits = [];
if (root.TryGetProperty("usage", out var us) && us.ValueKind == System.Text.Json.JsonValueKind.Object)
{
var usage = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
us.GetRawText(), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
limits = BuildLimits(usage);
// Cache for next time
AppSettings.Default.UsageJson = us.GetRawText();
if (!string.IsNullOrEmpty(orgId)) AppSettings.Default.OrgId = orgId;
if (!string.IsNullOrEmpty(email)) AppSettings.Default.Email = email;
AppSettings.Default.Save();
}
return (limits, email, orgId);
} }
catch { return ([], null, null); }
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Limits = placeholders;
IsSignedIn = true;
});
} }
public async Task SignOutAsync() public async Task SignOutAsync()
{ {
AppSettings.Default.CookieStore = ""; AppSettings.Default.CookieStore = "";
AppSettings.Default.OrgId = "";
AppSettings.Default.Email = "";
AppSettings.Default.UsageJson = "";
AppSettings.Default.Save(); AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
IsSignedIn = false; IsSignedIn = false;
UserEmail = ""; UserEmail = "";
PlanLabel = "Claude";
ErrorMessage = "Signed out."; ErrorMessage = "Signed out.";
Limits = []; Limits = [];
}); });
@@ -276,7 +363,7 @@ public class UsageViewModel : INotifyPropertyChanged
public static void SaveCookies(IEnumerable<CoreWebView2Cookie> cookies) public static void SaveCookies(IEnumerable<CoreWebView2Cookie> cookies)
{ {
var entries = cookies var entries = cookies
.Where(c => c.Domain.Contains("claude.ai")) .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 }) .Select(c => new CookieEntry { Name = c.Name, Value = c.Value, Domain = c.Domain, Path = c.Path })
.ToList(); .ToList();
AppSettings.Default.CookieStore = JsonSerializer.Serialize(entries); AppSettings.Default.CookieStore = JsonSerializer.Serialize(entries);
@@ -291,6 +378,14 @@ public class UsageViewModel : INotifyPropertyChanged
http.DefaultRequestHeaders.Add("User-Agent", http.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/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}")); var cookieHeader = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
http.DefaultRequestHeaders.Add("Cookie", cookieHeader); http.DefaultRequestHeaders.Add("Cookie", cookieHeader);
return http; return http;
@@ -308,8 +403,11 @@ public class UsageViewModel : INotifyPropertyChanged
catch { return null; } catch { return null; }
} }
// Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID) // Parses bootstrap JSON and returns email, org ID, and plan label.
private static (string? Email, string? OrgId) ParseBootstrap(string json) // 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 try
{ {
@@ -317,32 +415,74 @@ public class UsageViewModel : INotifyPropertyChanged
var root = doc.RootElement; var root = doc.RootElement;
string? email = null; string? email = null;
string? orgId = null;
if (root.TryGetProperty("account", out var acct) && if (root.TryGetProperty("account", out var acct) &&
acct.TryGetProperty("email_address", out var em)) acct.TryGetProperty("email_address", out var em))
email = em.GetString(); email = em.GetString();
// Path 1: memberships[0].organization.uuid // Collect all org objects from memberships
if (root.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0) var allOrgs = new List<JsonElement>();
if (root.TryGetProperty("account", out var acctNode) &&
acctNode.TryGetProperty("memberships", out var mems))
{ {
var first = mems[0]; foreach (var mem in mems.EnumerateArray())
if (first.TryGetProperty("organization", out var org) && if (mem.TryGetProperty("organization", out var org))
org.TryGetProperty("uuid", out var uuid)) allOrgs.Add(org);
orgId = uuid.GetString(); }
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);
} }
// Path 2: organizations[0].uuid (flat list on root) // 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) && if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0) root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
{ {
if (orgs[0].TryGetProperty("uuid", out var uuid)) if (orgs[0].TryGetProperty("uuid", out var u))
orgId = uuid.GetString(); orgId = u.GetString();
} }
return (email, orgId); 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); } catch { return (null, null, null); }
} }
private static async Task<string?> FetchOrgIdFromListAsync(HttpClient http) private static async Task<string?> FetchOrgIdFromListAsync(HttpClient http)
@@ -420,16 +560,18 @@ public class UsageViewModel : INotifyPropertyChanged
return $"{diff.Hours}h {diff.Minutes}m"; return $"{diff.Hours}h {diff.Minutes}m";
} }
private void LoadPlaceholders() private static List<AgentLimit> LoadPlaceholderLimits()
{ {
var now = DateTime.Now; var now = DateTime.Now;
Limits = return
[ [
new() { Window = WindowKind.FiveHour, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddHours(1) }, new() { Window = WindowKind.FiveHour, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddHours(1) },
new() { Window = WindowKind.SevenDay, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddDays(7) }, new() { Window = WindowKind.SevenDay, UsedPercent = 0, TimeRemaining = "—", ResetDate = now.AddDays(7) },
]; ];
} }
private void LoadPlaceholders() => Limits = LoadPlaceholderLimits();
private void LoadBurnHistory() private void LoadBurnHistory()
{ {
try try
+115 -29
View File
@@ -2,58 +2,144 @@ using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf; using Microsoft.Web.WebView2.Wpf;
using System; using System;
using System.IO; using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
namespace ClaudeCheckerWindows; namespace ClaudeCheckerWindows;
// Invisible 1×1 window that hosts a WebView2 for authenticated API calls. // Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
// Uses the same user data folder as LoginWindow so the session is shared. // Uses the same user data folder as LoginWindow so the session (including
internal sealed class WebViewFetchWindow : Window // 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 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() public WebViewFetchWindow()
{ {
Width = 1; Width = 1;
Height = 1; Height = 1;
Left = -9999; Left = -9999;
Top = -9999; Top = -9999;
ShowInTaskbar = false; ShowInTaskbar = false;
WindowStyle = WindowStyle.None; WindowStyle = WindowStyle.None;
AllowsTransparency = true; AllowsTransparency = true;
Opacity = 0; Opacity = 0;
Content = _wv; Content = _wv;
} }
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000) // ── 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()
{ {
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: await EnsureInitAsync();
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await _wv.EnsureCoreWebView2Async(env); var navDone = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, e) => { _wv.CoreWebView2.NavigationCompleted -= h; navDone.TrySetResult(e.IsSuccess); };
_wv.CoreWebView2.NavigationCompleted += h;
_wv.CoreWebView2.Navigate("https://claude.ai");
var tcs = new TaskCompletionSource<string?>(); // Wait up to 15 s for initial navigation
await Task.WhenAny(navDone.Task, Task.Delay(15000));
_readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
}
EventHandler<CoreWebView2NavigationCompletedEventArgs>? handler = null; // Run a JS script on the already-loaded claude.ai page.
handler = async (_, e) => // The script must call window.chrome.webview.postMessage(result).
public async Task<string?> RunScriptAsync(string script, int timeoutMs = 10000)
{
if (!_readyForScript) return null;
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) =>
{ {
_wv.CoreWebView2.NavigationCompleted -= handler; _wv.CoreWebView2.WebMessageReceived -= msgHandler;
if (!e.IsSuccess) { tcs.TrySetResult(null); return; } tcs.TrySetResult(args.WebMessageAsJson);
try
{
var result = await _wv.CoreWebView2.ExecuteScriptAsync(script);
tcs.TrySetResult(result);
}
catch { tcs.TrySetResult(null); }
}; };
_wv.CoreWebView2.WebMessageReceived += msgHandler;
_wv.CoreWebView2.NavigationCompleted += handler; try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
_wv.CoreWebView2.Navigate(navigateUrl); catch
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
return null;
}
_ = Task.Delay(timeoutMs).ContinueWith(_ => tcs.TrySetResult(null)); _ = Task.Delay(timeoutMs).ContinueWith(_ =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(null);
});
return await tcs.Task; return await tcs.Task;
} }
// ── One-shot mode (LoginWindow) ───────────────────────────────────────────
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
{
await EnsureInitAsync();
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(args.WebMessageAsJson);
};
_wv.CoreWebView2.WebMessageReceived += msgHandler;
EventHandler<CoreWebView2NavigationCompletedEventArgs>? navHandler = null;
navHandler = async (_, e) =>
{
_wv.CoreWebView2.NavigationCompleted -= navHandler;
if (!e.IsSuccess) { tcs.TrySetResult(null); return; }
try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
catch { tcs.TrySetResult(null); }
};
_wv.CoreWebView2.NavigationCompleted += navHandler;
_wv.CoreWebView2.Navigate(navigateUrl);
_ = Task.Delay(timeoutMs).ContinueWith(_ =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(null);
});
return await tcs.Task;
}
// ── Shared init ───────────────────────────────────────────────────────────
private int _initGuard;
private async Task EnsureInitAsync()
{
if (_initialized) return;
if (Interlocked.Exchange(ref _initGuard, 1) != 0) return;
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: UserDataFolder);
await _wv.EnsureCoreWebView2Async(env);
_initialized = true;
}
} }