Compare commits

..
4 changed files with 137 additions and 21 deletions
+1
View File
@@ -37,6 +37,7 @@ public partial class App : Application
_ = Task.Run(async () =>
{
await ViewModel.LoadFromCacheAsync();
await Task.Delay(1000);
await ViewModel.RefreshAsync();
await Updater.CheckForUpdatesAsync();
+3
View File
@@ -15,6 +15,9 @@ public class AppSettings
public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false;
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(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+8 -3
View File
@@ -1,3 +1,8 @@
## What's new in beta.34
- Add: Session Diary section showing 5h burn rate history sparkline
- Add: Extra Usage Credits section showing spent/limit/balance (when enabled)
## What's new in beta.36
### Bug fixes
- Settings no longer incorrectly shows "Not signed in" when limits are working
- 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
+125 -18
View File
@@ -20,7 +20,7 @@ public class UsageViewModel : INotifyPropertyChanged
private string? _errorMessage;
private bool _isSignedIn;
private string _userEmail = "";
private string _planLabel = "Pro";
private string _planLabel = "Claude";
private DateTime? _lastUpdated;
private OverageSpendLimit? _overage;
private PrepaidCredits? _prepaid;
@@ -100,9 +100,25 @@ public class UsageViewModel : INotifyPropertyChanged
// HttpClient refresh — WebView2 is NOT used here to avoid spawning a heavy
// browser process every refresh cycle (causes memory accumulation).
// SaveAndClose at login time is responsible for fetching live data via WebView2.
var (limits, overage, prepaid, extraUsage, email, orgId, ok) = hasCookies
? await TryHttpRefreshAsync(cookies)
: ([], null, 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;
if (hasCookies)
{
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
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
@@ -132,9 +148,10 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (limits.Count > 0) { Limits = limits; Overage = overage; Prepaid = prepaid; ExtraUsage = extraUsage; }
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true;
ErrorMessage = null;
ErrorMessage = refreshError;
LastUpdated = DateTime.Now;
IsLoading = false;
});
@@ -149,17 +166,47 @@ public class UsageViewModel : INotifyPropertyChanged
}
}
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, 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)
{
try
{
using var http = BuildClient(cookies);
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)
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();
// Targeted debug: log org property names + capabilities value so we can see
// exactly what the HttpClient bootstrap response contains
try
{
using var dbgDoc = System.Text.Json.JsonDocument.Parse(bootstrapJson);
var dbgRoot = dbgDoc.RootElement;
var dbg = $"len:{bootstrapJson.Length}";
if (dbgRoot.TryGetProperty("account", out var dbgAcct) &&
dbgAcct.TryGetProperty("memberships", out var dbgMems) &&
dbgMems.GetArrayLength() > 0 &&
dbgMems[0].TryGetProperty("organization", out var dbgOrg))
{
var keys = string.Join(",", dbgOrg.EnumerateObject().Select(p => p.Name));
dbg += $"|org_keys:{keys}";
if (dbgOrg.TryGetProperty("capabilities", out var dbgCaps))
dbg += $"|caps:{dbgCaps.GetRawText()}";
else
dbg += "|caps:MISSING";
}
else
{
dbg += "|no_memberships";
}
AppSettings.Default.DebugInfo = dbg;
AppSettings.Default.Save();
}
catch { /* debug only — never block refresh */ }
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson);
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
@@ -167,19 +214,34 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, true);
return ([], null, null, null, email, orgId, planLabel, true);
AppSettings.Default.OrgId = orgId;
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 pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(ut, ot, pt);
return (BuildLimits(ut.Result), ot.Result, pt.Result, ut.Result?.ExtraUsage, 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, null, false); }
catch { throw; }
}
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
@@ -255,22 +317,48 @@ public class UsageViewModel : INotifyPropertyChanged
if (!isAuth) return;
List<AgentLimit> limits = [];
ExtraUsage? extraUsage = null;
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
if (!string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{
try
{
var cached = JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached);
limits = BuildLimits(cached);
extraUsage = cached?.ExtraUsage;
}
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(() =>
{
if (!string.IsNullOrEmpty(email)) UserEmail = email;
// Always show cards — real data if available, placeholders if not
if (!string.IsNullOrEmpty(email)) UserEmail = email;
if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel;
Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits();
Overage = overage;
Prepaid = prepaid;
ExtraUsage = extraUsage;
IsSignedIn = true;
ErrorMessage = null;
LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated;
@@ -351,7 +439,7 @@ public class UsageViewModel : INotifyPropertyChanged
}
// 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
{
@@ -393,9 +481,28 @@ public class UsageViewModel : INotifyPropertyChanged
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)