Compare commits

...
Author SHA1 Message Date
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
9 changed files with 298 additions and 74 deletions
+1
View File
@@ -37,6 +37,7 @@ public partial class App : Application
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await ViewModel.LoadFromCacheAsync();
await Task.Delay(1000); await Task.Delay(1000);
await ViewModel.RefreshAsync(); await ViewModel.RefreshAsync();
await Updater.CheckForUpdatesAsync(); await Updater.CheckForUpdatesAsync();
+3
View File
@@ -15,6 +15,9 @@ public class AppSettings
public bool ShowInTaskbar { get; set; } = true; public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false; public bool BetaChannel { get; set; } = false;
public string DebugInfo { get; set; } = ""; public string DebugInfo { get; set; } = "";
public string PlanLabel { get; set; } = "";
public string OverageJson { get; set; } = "";
public string PrepaidJson { get; set; } = "";
private static readonly string FilePath = Path.Combine( private static readonly string FilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+7 -5
View File
@@ -56,17 +56,19 @@ public class GaugeControl : FrameworkElement
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); 14, new SolidColorBrush(textColor), dpi);
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height - 1));
var sub = new FormattedText( var sub = new FormattedText(
"Updated\nnow", "used",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), 7, new Typeface("Segoe UI"), 8,
new SolidColorBrush(subColor), dpi) new SolidColorBrush(subColor), dpi)
{ {
TextAlignment = TextAlignment.Center TextAlignment = TextAlignment.Center
}; };
dc.DrawText(sub, new Point(cx - sub.Width / 2, cy + 2));
var totalHeight = pctText.Height + 2 + sub.Height;
var startY = cy - totalHeight / 2;
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, startY));
dc.DrawText(sub, new Point(cx - sub.Width / 2, startY + pctText.Height + 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,
+5 -4
View File
@@ -83,8 +83,9 @@ public partial class LoginWindow : Window
const h={headers:{accept:'application/json'}}; const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json(); const b=await(await fetch('/api/bootstrap',h)).json();
const rawB=JSON.stringify(b).slice(0,600); const rawB=JSON.stringify(b).slice(0,600);
let id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid let id=b?.account?.memberships?.[0]?.organization?.uuid
||b?.default_organization?.uuid||b?.account?.uuid||null; ||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
||b?.default_organization?.uuid||null;
const e=b?.account?.email_address||b?.account?.email||b?.email||null; const e=b?.account?.email_address||b?.account?.email||b?.email||null;
let orgSrc='bootstrap'; let orgSrc='bootstrap';
if(!id){ if(!id){
@@ -98,8 +99,8 @@ public partial class LoginWindow : Window
return; return;
} }
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json(); const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
const rawU=JSON.stringify(u).slice(0,300); const rawU=JSON.stringify(u).slice(0,400);
window.chrome.webview.postMessage({email:e,orgId:id,usage:u,debug:'src:'+orgSrc+'|ukeys:'+Object.keys(u||{}).join(',')+'|raw:'+rawB}); window.chrome.webview.postMessage({email:e,orgId:id,usage:u,debug:'src:'+orgSrc+'|ukeys:'+Object.keys(u||{}).join(',')+'|rawU:'+rawU+'|rawB:'+rawB});
}catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()"; }catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
await Browser.CoreWebView2.ExecuteScriptAsync(script); await Browser.CoreWebView2.ExecuteScriptAsync(script);
+9 -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,12 @@ 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; }
}
public class BootstrapResponse public class BootstrapResponse
{ {
[JsonPropertyName("account")] public AccountInfo? Account { get; set; } [JsonPropertyName("account")] public AccountInfo? Account { get; set; }
+1 -14
View File
@@ -66,16 +66,7 @@
<!-- Cards scroll area --> <!-- Cards scroll area -->
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="0,4,0,4"> <StackPanel x:Name="CardsPanel" Margin="0,4,0,4"/>
<Border x:Name="DebugBanner" Visibility="Collapsed"
Background="{DynamicResource CardBrush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
CornerRadius="6" Margin="12,4,12,4" Padding="12,8">
<TextBlock x:Name="DebugBannerText" FontSize="10" FontFamily="Consolas"
Foreground="{DynamicResource SecondaryBrush}" TextWrapping="Wrap"/>
</Border>
<StackPanel x:Name="CardsPanel"/>
</StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
@@ -194,10 +185,6 @@
</Grid> </Grid>
</Border> </Border>
<TextBlock x:Name="DebugText" FontSize="10" Foreground="{DynamicResource SecondaryBrush}"
TextWrapping="Wrap" Margin="0,12,0,0" FontFamily="Consolas"
Visibility="Collapsed"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
+128 -15
View File
@@ -55,6 +55,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));
var fiveHour = VM.Limits.FirstOrDefault(l => l.Window == WindowKind.FiveHour);
if (fiveHour != null && fiveHour.BurnHistory.Count > 0)
CardsPanel.Children.Add(BuildDiarySection(fiveHour));
if (VM.ExtraUsage?.IsEnabled == true)
CardsPanel.Children.Add(BuildExtraUsageSection());
} }
private static UIElement BuildCard(AgentLimit limit) private static UIElement BuildCard(AgentLimit limit)
@@ -180,12 +187,132 @@ public partial class PopupWindow : Window
return section; return section;
} }
private static UIElement BuildDiarySection(AgentLimit limit)
{
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var samples = limit.BurnHistory.Count;
var avgBurn = limit.BurnRate;
var subtitle = new TextBlock
{
Text = $"{samples} samples · {avgBurn:F1}%/h avg burn rate",
FontSize = 11,
Foreground = secondary,
Margin = new Thickness(16, 0, 16, 6),
};
var sparkline = new SparklineControl
{
Height = 32,
Margin = new Thickness(8, 8, 8, 8),
LineColor = Color.FromRgb(0xF9, 0x73, 0x16),
Data = limit.BurnHistory,
};
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 = sparkline,
};
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(subtitle);
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 ?? 0;
var limit = overage?.MonthlyCreditLimit ?? 0;
var balance = prepaid?.Amount ?? 0;
UIElement MakeRow(string label, string value)
{
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 = 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}"));
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();
@@ -283,20 +410,6 @@ public partial class PopupWindow : Window
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";
var dbg = AppSettings.Default.DebugInfo;
if (!string.IsNullOrEmpty(dbg))
{
DebugText.Text = dbg;
DebugText.Visibility = Visibility.Visible;
DebugBannerText.Text = dbg;
DebugBanner.Visibility = Visibility.Visible;
}
else
{
DebugText.Visibility = Visibility.Collapsed;
DebugBanner.Visibility = Visibility.Collapsed;
}
} }
// ── Event handlers ─────────────────────────────────────────────── // ── Event handlers ───────────────────────────────────────────────
+7 -4
View File
@@ -1,5 +1,8 @@
## What's new in beta.28 ## What's new in beta.36
- Debug banner now appears on the main panel after sign-in showing raw bootstrap data — paste this here so we can fix org ID parsing ### Bug fixes
- Fix: limits no longer wiped when a refresh cycle returns no data (placeholders stay until real data arrives) - Settings no longer incorrectly shows "Not signed in" when limits are working
- Try account.uuid as org ID fallback when no org found in bootstrap - Session Diary now shows correctly after the first successful refresh
- Extra Usage Credits section now restored from cache on startup
- Plan name, overage, and prepaid credits are now cached and shown immediately on launch
- App now loads cached state instantly on startup before the background refresh completes
+137 -30
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,7 @@ 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); }
private int _refreshInterval = 120; private int _refreshInterval = 120;
public int RefreshInterval public int RefreshInterval
@@ -95,14 +97,28 @@ public class UsageViewModel : INotifyPropertyChanged
return; return;
} }
// Try HttpClient first (fast path) — may be rejected by server CORS/header checks // HttpClient refresh — WebView2 is NOT used here to avoid spawning a heavy
var (limits, overage, prepaid, email, orgId, ok) = hasCookies // browser process every refresh cycle (causes memory accumulation).
? await TryHttpRefreshAsync(cookies) // SaveAndClose at login time is responsible for fetching live data via WebView2.
: ([], null, null, null, null, false); List<AgentLimit> limits = [];
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
ExtraUsage? extraUsage = null;
string? email = null, orgId = null, planLabel = null;
string? refreshError = null;
// Fall back to WebView2 (uses the shared browser session, not HttpClient) if (hasCookies)
if (!ok) {
(limits, email, orgId) = await TryWebView2RefreshAsync(); try
{
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(cookies);
}
catch (Exception ex)
{
refreshError = ex.Message;
}
}
// Fill in any blanks from cached values saved at login time // Fill in any blanks from cached values saved at login time
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email; if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
@@ -131,10 +147,11 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
if (limits.Count > 0) { Limits = limits; Overage = overage; Prepaid = prepaid; } if (limits.Count > 0) { Limits = limits; Overage = overage; Prepaid = prepaid; ExtraUsage = extraUsage; }
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email; if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true; IsSignedIn = true;
ErrorMessage = null; ErrorMessage = refreshError;
LastUpdated = DateTime.Now; LastUpdated = DateTime.Now;
IsLoading = false; IsLoading = false;
}); });
@@ -149,17 +166,24 @@ public class UsageViewModel : INotifyPropertyChanged
} }
} }
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, string?, string?, bool)> private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies) TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{ {
try try
{ {
using var http = BuildClient(cookies); using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap"); var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
if ((int)bootstrapResp.StatusCode is 401 or 403)
throw new Exception("Session expired — please re-authenticate.");
if (!bootstrapResp.IsSuccessStatusCode) if (!bootstrapResp.IsSuccessStatusCode)
return ([], null, null, null, null, false); throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var (email, orgId) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync()); var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
// Save raw bootstrap for diagnostics — lets us see if 'capabilities' is returned
AppSettings.Default.DebugInfo = bootstrapJson.Length > 2000
? bootstrapJson.Substring(0, 2000) : bootstrapJson;
AppSettings.Default.Save();
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson);
if (string.IsNullOrEmpty(orgId)) if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http); orgId = await FetchOrgIdFromListAsync(http);
@@ -167,19 +191,34 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = AppSettings.Default.OrgId; orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId)) if (string.IsNullOrEmpty(orgId))
return ([], null, null, email, orgId, true); return ([], null, null, null, email, orgId, planLabel, true);
AppSettings.Default.OrgId = orgId; AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save(); AppSettings.Default.Save();
var ut = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{orgId}/usage"); var usageUrl = $"https://claude.ai/api/organizations/{orgId}/usage";
var ut = http.GetAsync(usageUrl);
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit"); 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"); var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(ut, ot, pt); await Task.WhenAll(ut, ot, pt);
return (BuildLimits(ut.Result), ot.Result, pt.Result, email, orgId, true); var usageResp = ut.Result;
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);
// Persist fresh usage so cache reflects live data
AppSettings.Default.UsageJson = usageJson;
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
if (ot.Result != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(ot.Result);
if (pt.Result != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(pt.Result);
AppSettings.Default.Save();
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
} }
catch { return ([], null, null, null, null, false); } catch { throw; }
} }
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync() private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
@@ -189,7 +228,8 @@ public class UsageViewModel : INotifyPropertyChanged
const string script = @"(async()=>{try{ const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}}; const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json(); const b=await(await fetch('/api/bootstrap',h)).json();
let id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid let id=b?.account?.memberships?.[0]?.organization?.uuid
||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
||b?.default_organization?.uuid||null; ||b?.default_organization?.uuid||null;
const em=b?.account?.email_address||b?.account?.email||b?.email||null; const em=b?.account?.email_address||b?.account?.email||b?.email||null;
if(!id){ if(!id){
@@ -254,23 +294,50 @@ public class UsageViewModel : INotifyPropertyChanged
if (!isAuth) return; if (!isAuth) return;
List<AgentLimit> limits = []; List<AgentLimit> limits = [];
ExtraUsage? extraUsage = null;
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
if (!string.IsNullOrEmpty(AppSettings.Default.UsageJson)) if (!string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{ {
try try
{ {
var cached = JsonSerializer.Deserialize<UsageResponse>( var cached = JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts); AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached); limits = BuildLimits(cached);
extraUsage = cached?.ExtraUsage;
} }
catch { } catch { }
} }
if (!string.IsNullOrEmpty(AppSettings.Default.OverageJson))
{
try { overage = JsonSerializer.Deserialize<OverageSpendLimit>(AppSettings.Default.OverageJson, JsonOpts); }
catch { }
}
if (!string.IsNullOrEmpty(AppSettings.Default.PrepaidJson))
{
try { prepaid = JsonSerializer.Deserialize<PrepaidCredits>(AppSettings.Default.PrepaidJson, JsonOpts); }
catch { }
}
// Attach persisted burn history to cached limits
foreach (var limit in limits)
{
var key = limit.Window.ToString();
if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
limit.BurnHistory = [.. hist];
}
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
if (!string.IsNullOrEmpty(email)) UserEmail = email; if (!string.IsNullOrEmpty(email)) UserEmail = email;
if (limits.Count > 0) Limits = limits; if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel;
Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits();
Overage = overage;
Prepaid = prepaid;
ExtraUsage = extraUsage;
IsSignedIn = true; IsSignedIn = true;
ErrorMessage = limits.Count == 0 ? "Signed in — refreshing data…" : null; ErrorMessage = null;
LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated; LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated;
}); });
} }
@@ -323,6 +390,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;
@@ -341,7 +416,7 @@ public class UsageViewModel : INotifyPropertyChanged
} }
// Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID) // Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID)
private static (string? Email, string? OrgId) ParseBootstrap(string json) private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json)
{ {
try try
{ {
@@ -355,8 +430,9 @@ public class UsageViewModel : INotifyPropertyChanged
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 // Path 1: account.memberships[0].organization.uuid (Claude.ai personal/pro accounts)
if (root.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0) if (root.TryGetProperty("account", out var acctNode) &&
acctNode.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0)
{ {
var first = mems[0]; var first = mems[0];
if (first.TryGetProperty("organization", out var org) && if (first.TryGetProperty("organization", out var org) &&
@@ -364,7 +440,17 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = uuid.GetString(); orgId = uuid.GetString();
} }
// Path 2: organizations[0].uuid (flat list on root) // Path 2: memberships[0].organization.uuid (root-level, older API shape)
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("memberships", out var rootMems) && rootMems.GetArrayLength() > 0)
{
var first = rootMems[0];
if (first.TryGetProperty("organization", out var org) &&
org.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
}
// Path 3: organizations[0].uuid (flat list on root)
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)
{ {
@@ -372,9 +458,28 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = uuid.GetString(); orgId = uuid.GetString();
} }
return (email, orgId); string? planLabel = null;
if (root.TryGetProperty("account", out var acctPlan) &&
acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 &&
plMems[0].TryGetProperty("organization", out var plOrg) &&
plOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == System.Text.Json.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)
@@ -452,16 +557,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