Compare commits

..
Author SHA1 Message Date
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
8 changed files with 365 additions and 65 deletions
+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();
}); });
+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),
+23 -8
View File
@@ -82,11 +82,13 @@ public partial class LoginWindow : Window
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();
const rawB=JSON.stringify(b).slice(0,600); const org0=b?.account?.memberships?.[0]?.organization;
let id=b?.account?.memberships?.[0]?.organization?.uuid let id=org0?.uuid||b?.memberships?.[0]?.organization?.uuid
||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid ||b?.organizations?.[0]?.uuid||b?.default_organization?.uuid||null;
||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;
const caps=org0?.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;
let orgSrc='bootstrap'; let orgSrc='bootstrap';
if(!id){ if(!id){
try{const ol=await(await fetch('/api/organizations',h)).json(); try{const ol=await(await fetch('/api/organizations',h)).json();
@@ -95,12 +97,15 @@ public partial class LoginWindow : Window
if(!id){ if(!id){
let pu=null; let pu=null;
try{pu=await(await fetch('/api/usage',h)).json();}catch(e3){} try{pu=await(await fetch('/api/usage',h)).json();}catch(e3){}
window.chrome.webview.postMessage({email:e,orgId:null,usage:(pu&&!pu.error?pu:null),debug:'no-org|raw:'+rawB}); window.chrome.webview.postMessage({email:e,orgId:null,planLabel,usage:(pu&&!pu.error?pu:null),debug:'no-org'});
return; return;
} }
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json(); const [u,ov,pp]=await Promise.all([
const rawU=JSON.stringify(u).slice(0,400); fetch('/api/organizations/'+id+'/usage',h).then(r=>r.json()).catch(()=>null),
window.chrome.webview.postMessage({email:e,orgId:id,usage:u,debug:'src:'+orgSrc+'|ukeys:'+Object.keys(u||{}).join(',')+'|rawU:'+rawU+'|rawB:'+rawB}); 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:e,orgId:id,planLabel,usage:u,overage:ov,prepaid:pp,debug:'src:'+orgSrc+'|plan:'+(planLabel||'none')});
}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);
@@ -123,6 +128,16 @@ public partial class LoginWindow : Window
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object) if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
AppSettings.Default.UsageJson = us.GetRawText(); AppSettings.Default.UsageJson = us.GetRawText();
if (root.TryGetProperty("planLabel", out var pl) && pl.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(pl.GetString()))
AppSettings.Default.PlanLabel = pl.GetString()!;
if (root.TryGetProperty("overage", out var ov) && ov.ValueKind == JsonValueKind.Object)
AppSettings.Default.OverageJson = ov.GetRawText();
if (root.TryGetProperty("prepaid", out var pp) && pp.ValueKind == JsonValueKind.Object)
AppSettings.Default.PrepaidJson = pp.GetRawText();
if (root.TryGetProperty("debug", out var dbg) && dbg.ValueKind == JsonValueKind.String) if (root.TryGetProperty("debug", out var dbg) && dbg.ValueKind == JsonValueKind.String)
AppSettings.Default.DebugInfo = dbg.GetString() ?? ""; AppSettings.Default.DebugInfo = dbg.GetString() ?? "";
else if (root.TryGetProperty("error", out var err)) else if (root.TryGetProperty("error", out var err))
+4 -2
View File
@@ -34,8 +34,10 @@ public class WindowData
public class ExtraUsage public class ExtraUsage
{ {
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; } [JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; }
[JsonPropertyName("currency")] public string? Currency { 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
+18 -11
View File
@@ -56,12 +56,12 @@ public partial class PopupWindow : Window
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); var fiveHour = VM.Limits.FirstOrDefault(l => l.Window == WindowKind.FiveHour);
if (fiveHour != null && fiveHour.BurnHistory.Count > 0) if (fiveHour != null && fiveHour.BurnHistory.Count > 0)
CardsPanel.Children.Add(BuildDiarySection(fiveHour)); 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)
@@ -247,17 +247,23 @@ public partial class PopupWindow : Window
var prepaid = VM.Prepaid; var prepaid = VM.Prepaid;
var currency = VM.ExtraUsage?.Currency ?? overage?.Currency ?? "$"; var currency = VM.ExtraUsage?.Currency ?? overage?.Currency ?? "$";
var spent = overage?.UsedCredits ?? 0; // Fall back to extra_usage fields when the overage/prepaid endpoints return nothing.
var limit = overage?.MonthlyCreditLimit ?? 0; // extra_usage.used_credits is in cents (e.g. 5231 = EUR 52.31); overage endpoint
var balance = prepaid?.Amount ?? 0; // already returns the value in currency units, so only divide for the fallback path.
var spent = overage?.UsedCredits ?? (VM.ExtraUsage?.UsedCredits / 100.0) ?? 0;
var limit = overage?.MonthlyCreditLimit ?? VM.ExtraUsage?.MonthlyLimit ?? 0;
var balance = prepaid?.Amount ?? 0;
// Show "Unlimited" when no limit is configured (null monthly_limit)
bool unlimited = overage?.MonthlyCreditLimit == null && VM.ExtraUsage?.MonthlyLimit == null;
UIElement MakeRow(string label, string value) UIElement MakeRow(string label, string value, bool highlight = false)
{ {
var row = new Grid { Margin = new Thickness(0, 3, 0, 3) }; 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 = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var lbl = new TextBlock { Text = label, FontSize = 12, Foreground = secondary }; var lbl = new TextBlock { Text = label, FontSize = 12, Foreground = secondary };
var val = new TextBlock { Text = value, FontSize = 12, FontWeight = FontWeights.Medium, Foreground = text }; 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(lbl, 0);
Grid.SetColumn(val, 1); Grid.SetColumn(val, 1);
row.Children.Add(lbl); row.Children.Add(lbl);
@@ -266,9 +272,10 @@ public partial class PopupWindow : Window
} }
var contentPanel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) }; var contentPanel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
contentPanel.Children.Add(MakeRow("Spent", $"{currency}{spent:F2}")); contentPanel.Children.Add(MakeRow("Spent", $"{currency} {spent:F2}", spent > 0));
if (limit > 0) contentPanel.Children.Add(MakeRow("Limit", $"{currency}{limit:F2}")); if (unlimited) contentPanel.Children.Add(MakeRow("Limit", "Unlimited"));
if (balance > 0) contentPanel.Children.Add(MakeRow("Balance", $"{currency}{balance:F2}")); 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) if (limit > 0)
{ {
+8 -3
View File
@@ -1,3 +1,8 @@
## What's new in beta.34 ## What's new in beta.36
- Add: Session Diary section showing 5h burn rate history sparkline
- Add: Extra Usage Credits section showing spent/limit/balance (when enabled) ### 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
+204 -22
View File
@@ -20,7 +20,7 @@ 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;
@@ -100,9 +100,25 @@ public class UsageViewModel : INotifyPropertyChanged
// HttpClient refresh — WebView2 is NOT used here to avoid spawning a heavy // HttpClient refresh — WebView2 is NOT used here to avoid spawning a heavy
// browser process every refresh cycle (causes memory accumulation). // browser process every refresh cycle (causes memory accumulation).
// SaveAndClose at login time is responsible for fetching live data via WebView2. // SaveAndClose at login time is responsible for fetching live data via WebView2.
var (limits, overage, prepaid, extraUsage, email, orgId, ok) = hasCookies List<AgentLimit> limits = [];
? await TryHttpRefreshAsync(cookies) OverageSpendLimit? overage = null;
: ([], null, null, null, null, null, false); 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 // 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,19 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
if (limits.Count > 0) { Limits = limits; Overage = overage; Prepaid = prepaid; ExtraUsage = extraUsage; } if (limits.Count > 0)
{
Limits = limits;
Overage = overage;
Prepaid = prepaid;
// Only clear ExtraUsage if the API explicitly disables it; keep cached value
// if the HTTP endpoint omits the field (it often does for non-browser clients)
if (extraUsage != null) 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 +174,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) 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();
// 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)) if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http); orgId = await FetchOrgIdFromListAsync(http);
@@ -167,19 +222,101 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = AppSettings.Default.OrgId; orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(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.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 ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit"); var ut = http.GetAsync(usageUrl);
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits"); var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
await Task.WhenAll(ut, ot, pt); var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
// Fetch org details as a fallback source for capabilities (bootstrap omits them via HttpClient)
var odt = http.GetAsync($"https://claude.ai/api/organizations/{orgId}");
await Task.WhenAll(ut, ot, pt, odt);
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);
// Try org endpoint for capabilities if bootstrap didn't return them
if (string.IsNullOrEmpty(planLabel) && odt.Result.IsSuccessStatusCode)
{
try
{
var orgJson = await odt.Result.Content.ReadAsStringAsync();
using var orgDoc = JsonDocument.Parse(orgJson);
if (orgDoc.RootElement.TryGetProperty("capabilities", out var orgCaps) &&
orgCaps.ValueKind == JsonValueKind.Array)
{
foreach (var cap in orgCaps.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;
}
}
}
}
catch { /* best-effort */ }
}
// If HttpClient couldn't reach overage/prepaid endpoints (expired cf_clearance),
// fall back to the persistent background WebView2 which always has live cookies.
var overageResult = ot.Result;
var prepaidResult = pt.Result;
if ((overageResult == null || prepaidResult == null) && orgId != null)
{
try
{
var browser = App.BackgroundBrowser;
if (browser != null)
{
var wvScript = $@"(async()=>{{try{{
const h={{headers:{{accept:'application/json'}}}};
const [ov,pp]=await Promise.all([
fetch('/api/organizations/{orgId}/overage_spend_limit',h).then(r=>r.ok?r.json():null).catch(()=>null),
fetch('/api/organizations/{orgId}/prepaid/credits',h).then(r=>r.ok?r.json():null).catch(()=>null)
]);
window.chrome.webview.postMessage({{overage:ov,prepaid:pp}});
}}catch(ex){{window.chrome.webview.postMessage(null);}}}})()";
var wvJson = await Application.Current.Dispatcher.InvokeAsync(
() => browser.RunScriptAsync(wvScript)).Task.Unwrap();
if (!string.IsNullOrEmpty(wvJson) && wvJson != "null")
{
using var wvDoc = JsonDocument.Parse(wvJson);
var wvRoot = wvDoc.RootElement;
if (overageResult == null && wvRoot.TryGetProperty("overage", out var ovEl)
&& ovEl.ValueKind == JsonValueKind.Object)
overageResult = JsonSerializer.Deserialize<OverageSpendLimit>(ovEl.GetRawText(), JsonOpts);
if (prepaidResult == null && wvRoot.TryGetProperty("prepaid", out var ppEl)
&& ppEl.ValueKind == JsonValueKind.Object)
prepaidResult = JsonSerializer.Deserialize<PrepaidCredits>(ppEl.GetRawText(), JsonOpts);
}
}
}
catch { /* best-effort — never block the refresh */ }
}
// Persist fresh usage so cache reflects live data
AppSettings.Default.UsageJson = usageJson;
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
if (overageResult != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(overageResult);
if (prepaidResult != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(prepaidResult);
AppSettings.Default.Save();
return (BuildLimits(usage), overageResult, prepaidResult, 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() private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
@@ -255,22 +392,48 @@ 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;
// Always show cards — real data if available, placeholders if not if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel;
Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits(); Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits();
Overage = overage;
Prepaid = prepaid;
ExtraUsage = extraUsage;
IsSignedIn = true; IsSignedIn = true;
ErrorMessage = null; ErrorMessage = null;
LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated; LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated;
@@ -351,7 +514,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
{ {
@@ -393,9 +556,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)
+91 -16
View File
@@ -2,42 +2,103 @@ 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
// 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.
internal sealed class WebViewFetchWindow : Window internal 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 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;
} }
// ── Persistent-mode init ──────────────────────────────────────────────────
// Call once at startup. Navigates to claude.ai so the session/cookies are
// established and cf_clearance is fresh. Subsequent RunScriptAsync calls
// skip navigation and just execute JS on the live page.
public async Task InitAsync()
{
await EnsureInitAsync();
var navDone = new TaskCompletionSource<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");
// Wait up to 15 s for initial navigation
await Task.WhenAny(navDone.Task, Task.Delay(15000));
_readyForScript = navDone.Task.IsCompletedSuccessfully && navDone.Task.Result;
}
// Run a JS script on the already-loaded claude.ai page.
// The script must call window.chrome.webview.postMessage(result).
public async Task<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.WebMessageReceived -= msgHandler;
tcs.TrySetResult(args.WebMessageAsJson);
};
_wv.CoreWebView2.WebMessageReceived += msgHandler;
try { await _wv.CoreWebView2.ExecuteScriptAsync(script); }
catch
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
return null;
}
_ = Task.Delay(timeoutMs).ContinueWith(_ =>
{
_wv.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(null);
});
return await tcs.Task;
}
// ── One-shot mode (LoginWindow) ───────────────────────────────────────────
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000) public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
{ {
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: await EnsureInitAsync();
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await _wv.EnsureCoreWebView2Async(env);
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
// Use WebMessageReceived so the script can post back asynchronously without
// relying on Promise-awaiting support in the WebView2 runtime version.
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null; EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) => msgHandler = (_, args) =>
{ {
@@ -65,4 +126,18 @@ internal sealed class WebViewFetchWindow : Window
return await tcs.Task; 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;
}
} }