Compare commits

...
5 changed files with 298 additions and 221 deletions
+13 -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,12 +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 ViewModel.LoadFromCacheAsync(); await ViewModel.LoadFromCacheAsync();
await Task.Delay(1000); // 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();
}); });
+12 -2
View File
@@ -100,8 +100,12 @@ public partial class LoginWindow : Window
window.chrome.webview.postMessage({email:e,orgId:null,planLabel,usage:(pu&&!pu.error?pu:null),debug:'no-org'}); 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([
window.chrome.webview.postMessage({email:e,orgId:id,planLabel,usage:u,debug:'src:'+orgSrc+'|plan:'+(planLabel||'none')}); fetch('/api/organizations/'+id+'/usage',h).then(r=>r.json()).catch(()=>null),
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);
@@ -128,6 +132,12 @@ public partial class LoginWindow : Window
&& !string.IsNullOrEmpty(pl.GetString())) && !string.IsNullOrEmpty(pl.GetString()))
AppSettings.Default.PlanLabel = 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))
+16 -10
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,18 +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 ?? "$";
// Fall back to extra_usage fields when the overage/prepaid endpoints return nothing // Fall back to extra_usage fields when the overage/prepaid endpoints return nothing.
var spent = overage?.UsedCredits ?? VM.ExtraUsage?.UsedCredits ?? 0; // extra_usage.used_credits is in cents (e.g. 5231 = EUR 52.31); overage endpoint
// 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 limit = overage?.MonthlyCreditLimit ?? VM.ExtraUsage?.MonthlyLimit ?? 0;
var balance = prepaid?.Amount ?? 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);
@@ -267,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)
{ {
+163 -189
View File
@@ -79,14 +79,12 @@ public class UsageViewModel : INotifyPropertyChanged
try try
{ {
var cookies = await GetCookiesAsync(); // Check whether we have any evidence of a prior successful sign-in
bool hasCachedAuth = !string.IsNullOrEmpty(AppSettings.Default.Email) ||
!string.IsNullOrEmpty(AppSettings.Default.OrgId) ||
!string.IsNullOrEmpty(AppSettings.Default.CookieStore);
// Check if we have any persistent proof that the user authenticated if (!hasCachedAuth)
bool hasCookies = cookies.Count > 0;
bool hasCachedAuth = !string.IsNullOrEmpty(AppSettings.Default.Email) ||
!string.IsNullOrEmpty(AppSettings.Default.OrgId);
if (!hasCookies && !hasCachedAuth)
{ {
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
@@ -97,9 +95,6 @@ public class UsageViewModel : INotifyPropertyChanged
return; return;
} }
// 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.
List<AgentLimit> limits = []; List<AgentLimit> limits = [];
OverageSpendLimit? overage = null; OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null; PrepaidCredits? prepaid = null;
@@ -107,27 +102,46 @@ public class UsageViewModel : INotifyPropertyChanged
string? email = null, orgId = null, planLabel = null; string? email = null, orgId = null, planLabel = null;
string? refreshError = null; string? refreshError = null;
if (hasCookies) // Primary path: persistent background WebView2 (always-live cookies, same as macOS).
// Falls back to HttpClient with saved cookies only if the browser isn't ready yet
// (i.e., the very first refresh that races with InitAsync completing).
if (App.BackgroundBrowser.IsReady)
{ {
try try
{ {
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) = (limits, overage, prepaid, extraUsage, email, orgId, planLabel) =
await TryHttpRefreshAsync(cookies); await TryBrowserRefreshAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
refreshError = ex.Message; refreshError = ex.Message;
} }
} }
else
{
var cookies = await GetCookiesAsync();
if (cookies.Count > 0)
{
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 blanks from cache
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email; if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
if (string.IsNullOrEmpty(orgId)) orgId = AppSettings.Default.OrgId; if (string.IsNullOrEmpty(orgId)) orgId = AppSettings.Default.OrgId;
if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson)) if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{ {
try try
{ {
var cached = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>( var cached = JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts); AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached); limits = BuildLimits(cached);
} }
@@ -152,12 +166,11 @@ public class UsageViewModel : INotifyPropertyChanged
Limits = limits; Limits = limits;
Overage = overage; Overage = overage;
Prepaid = prepaid; Prepaid = prepaid;
// Only clear ExtraUsage if the API explicitly disables it; keep cached value // Only replace ExtraUsage when the response explicitly includes it
// if the HTTP endpoint omits the field (it often does for non-browser clients)
if (extraUsage != null) ExtraUsage = extraUsage; if (extraUsage != null) ExtraUsage = extraUsage;
} }
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel; if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email; if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true; IsSignedIn = true;
ErrorMessage = refreshError; ErrorMessage = refreshError;
LastUpdated = DateTime.Now; LastUpdated = DateTime.Now;
@@ -174,172 +187,138 @@ public class UsageViewModel : INotifyPropertyChanged
} }
} }
// ── Browser-based refresh (primary path) ─────────────────────────────────
// Runs JS on the persistent claude.ai page — same live cookies as macOS WKWebView.
// Fetches bootstrap (orgId/email/planLabel) + usage + overage + prepaid in one shot.
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
TryBrowserRefreshAsync()
{
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
if(b?.error_type==='authentication_error'){window.chrome.webview.postMessage({authError:true});return;}
const org0=b?.account?.memberships?.[0]?.organization;
let id=org0?.uuid||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid||null;
const email=b?.account?.email_address||b?.account?.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;
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,usage:u,overage:ov,prepaid:pp});
}catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
var json = await Application.Current.Dispatcher.InvokeAsync(
() => App.BackgroundBrowser.RunScriptAsync(script)).Task.Unwrap();
if (string.IsNullOrEmpty(json) || json == "null")
throw new Exception("Browser refresh returned no data.");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("authError", out _))
throw new Exception("Session expired — please re-authenticate.");
if (root.TryGetProperty("error", out var errEl))
throw new Exception("Browser script error: " + errEl.GetString());
if (root.TryGetProperty("noOrg", out _))
throw new Exception("Could not determine organization ID.");
string? email = root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String ? em.GetString() : null;
string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String ? oi.GetString() : null;
string? planLabel = root.TryGetProperty("planLabel", out var pl) && pl.ValueKind == JsonValueKind.String ? pl.GetString() : null;
string? usageJson = null;
UsageResponse? usage = null;
string? overageJson = null;
OverageSpendLimit? overage = null;
string? prepaidJson = null;
PrepaidCredits? prepaid = null;
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
{
usageJson = us.GetRawText();
usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
}
if (root.TryGetProperty("overage", out var ov) && ov.ValueKind == JsonValueKind.Object)
{
overageJson = ov.GetRawText();
overage = JsonSerializer.Deserialize<OverageSpendLimit>(overageJson, JsonOpts);
}
if (root.TryGetProperty("prepaid", out var pp) && pp.ValueKind == JsonValueKind.Object)
{
prepaidJson = pp.GetRawText();
prepaid = JsonSerializer.Deserialize<PrepaidCredits>(prepaidJson, JsonOpts);
}
// Persist fresh data to cache
if (usageJson != null) AppSettings.Default.UsageJson = usageJson;
if (!string.IsNullOrEmpty(email)) AppSettings.Default.Email = email!;
if (!string.IsNullOrEmpty(orgId)) AppSettings.Default.OrgId = orgId!;
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel!;
if (overageJson != null) AppSettings.Default.OverageJson = overageJson;
if (prepaidJson != null) AppSettings.Default.PrepaidJson = prepaidJson;
AppSettings.Default.Save();
return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel);
}
// ── HttpClient refresh (fallback — used only before browser is ready) ────
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, 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 using var http = BuildClient(cookies);
{ var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
using var http = BuildClient(cookies); if ((int)bootstrapResp.StatusCode is 401 or 403)
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap"); throw new Exception("Session expired — please re-authenticate.");
if ((int)bootstrapResp.StatusCode is 401 or 403) if (!bootstrapResp.IsSuccessStatusCode)
throw new Exception("Session expired — please re-authenticate."); throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
if (!bootstrapResp.IsSuccessStatusCode)
throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync(); var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
// Targeted debug: log org property names + capabilities value so we can see var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson);
// 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);
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId)) if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId; orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId)) if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, planLabel, true); return ([], null, null, null, email, orgId, planLabel, true);
AppSettings.Default.OrgId = orgId; AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save(); AppSettings.Default.Save();
var usageUrl = $"https://claude.ai/api/organizations/{orgId}/usage"; var ut = http.GetAsync($"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);
// 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);
var usageResp = ut.Result; var usageResp = ut.Result;
if (!usageResp.IsSuccessStatusCode) if (!usageResp.IsSuccessStatusCode)
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode})."); throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
var usageJson = await usageResp.Content.ReadAsStringAsync(); var usageJson = await usageResp.Content.ReadAsStringAsync();
var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts); var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
var overage = ot.Result;
var prepaid = pt.Result;
// Try org endpoint for capabilities if bootstrap didn't return them AppSettings.Default.UsageJson = usageJson;
if (string.IsNullOrEmpty(planLabel) && odt.Result.IsSuccessStatusCode) if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
{ if (overage != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(overage);
try if (prepaid != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(prepaid);
{ AppSettings.Default.Save();
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 */ }
}
// Persist fresh usage so cache reflects live data return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel, true);
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 { throw; }
}
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
{
try
{
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
let id=b?.account?.memberships?.[0]?.organization?.uuid
||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
||b?.default_organization?.uuid||null;
const em=b?.account?.email_address||b?.account?.email||b?.email||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){
let pu=null;
try{pu=await(await fetch('/api/usage',h)).json();}catch(e3){}
window.chrome.webview.postMessage({email:em,orgId:null,usage:(pu&&!pu.error?pu:null)});
return;
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
window.chrome.webview.postMessage({email:em,orgId:id,usage:u});
}catch(ex){window.chrome.webview.postMessage(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.Unwrap();
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); }
} }
public async Task LoadFromCacheAsync() public async Task LoadFromCacheAsync()
@@ -379,7 +358,6 @@ public class UsageViewModel : INotifyPropertyChanged
catch { } catch { }
} }
// Attach persisted burn history to cached limits
foreach (var limit in limits) foreach (var limit in limits)
{ {
var key = limit.Window.ToString(); var key = limit.Window.ToString();
@@ -389,7 +367,7 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
if (!string.IsNullOrEmpty(email)) UserEmail = email; if (!string.IsNullOrEmpty(email)) UserEmail = email;
if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel; if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel;
Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits(); Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits();
Overage = overage; Overage = overage;
@@ -449,14 +427,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("Origin", "https://claude.ai");
http.DefaultRequestHeaders.Add("Referer", "https://claude.ai/"); http.DefaultRequestHeaders.Add("Referer", "https://claude.ai/");
http.DefaultRequestHeaders.Add("sec-fetch-dest", "empty"); http.DefaultRequestHeaders.Add("sec-fetch-dest", "empty");
http.DefaultRequestHeaders.Add("sec-fetch-mode", "cors"); http.DefaultRequestHeaders.Add("sec-fetch-mode", "cors");
http.DefaultRequestHeaders.Add("sec-fetch-site", "same-origin"); 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", "\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\"");
http.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0"); http.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0");
http.DefaultRequestHeaders.Add("sec-ch-ua-platform", "\"Windows\""); 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;
@@ -474,7 +452,6 @@ 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)
private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json) private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json)
{ {
try try
@@ -489,7 +466,6 @@ 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: account.memberships[0].organization.uuid (Claude.ai personal/pro accounts)
if (root.TryGetProperty("account", out var acctNode) && if (root.TryGetProperty("account", out var acctNode) &&
acctNode.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0) acctNode.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0)
{ {
@@ -499,7 +475,6 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = uuid.GetString(); orgId = uuid.GetString();
} }
// Path 2: memberships[0].organization.uuid (root-level, older API shape)
if (string.IsNullOrEmpty(orgId) && if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("memberships", out var rootMems) && rootMems.GetArrayLength() > 0) root.TryGetProperty("memberships", out var rootMems) && rootMems.GetArrayLength() > 0)
{ {
@@ -509,7 +484,6 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = uuid.GetString(); 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)
{ {
@@ -522,7 +496,7 @@ public class UsageViewModel : INotifyPropertyChanged
acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 && acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 &&
plMems[0].TryGetProperty("organization", out var plOrg) && plMems[0].TryGetProperty("organization", out var plOrg) &&
plOrg.TryGetProperty("capabilities", out var caps) && plOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == System.Text.Json.JsonValueKind.Array) caps.ValueKind == JsonValueKind.Array)
{ {
foreach (var cap in caps.EnumerateArray()) foreach (var cap in caps.EnumerateArray())
{ {
+94 -17
View File
@@ -2,42 +2,105 @@ 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;
} }
// ── 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 +128,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;
}
} }