Compare commits

...
3 changed files with 35 additions and 221 deletions
+6 -7
View File
@@ -6,18 +6,17 @@ namespace ClaudeCheckerWindows;
public class AppSettings
{
// Auth signal — non-empty means the user has completed sign-in at least once.
// The WebView2 user-data folder holds the live session; this is only for startup.
public string CookieStore { get; set; } = "";
// Accumulated sparkline data — built up over multiple refreshes.
public string BurnHistory { get; set; } = "";
public string OrgId { get; set; } = "";
public string Email { get; set; } = "";
public string UsageJson { get; set; } = "";
// User preferences
public int RefreshInterval { get; set; } = 120;
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),
+6 -100
View File
@@ -1,8 +1,5 @@
using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
@@ -22,7 +19,7 @@ public partial class LoginWindow : Window
{
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await Browser.EnsureCoreWebView2Async(env);
@@ -35,9 +32,9 @@ public partial class LoginWindow : Window
if (!uri.Contains("claude.ai")) return;
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
var signedIn = cookies.Any(c =>
c.Name.Equals("sessionKey", StringComparison.OrdinalIgnoreCase) ||
c.Name.StartsWith("sk-ant", StringComparison.OrdinalIgnoreCase));
var signedIn = System.Linq.Enumerable.Any(cookies, c =>
c.Name.Equals("sessionKey", System.StringComparison.OrdinalIgnoreCase) ||
c.Name.StartsWith("sk-ant", System.StringComparison.OrdinalIgnoreCase));
await Dispatcher.InvokeAsync(() =>
{
@@ -64,101 +61,10 @@ public partial class LoginWindow : Window
if (_closing) return;
_closing = true;
// Persist cookies so the app knows the user is signed in on next startup.
// All live data is fetched by the background WebView2 on the next refresh.
UsageViewModel.SaveCookies(cookies);
try
{
// Use WebMessageReceived so the async script can post back without relying
// on Promise-awaiting support in the WebView2 runtime version.
var tcs = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2WebMessageReceivedEventArgs>? msgHandler = null;
msgHandler = (_, args) =>
{
Browser.CoreWebView2.WebMessageReceived -= msgHandler;
tcs.TrySetResult(args.WebMessageAsJson);
};
Browser.CoreWebView2.WebMessageReceived += msgHandler;
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
const org0=b?.account?.memberships?.[0]?.organization;
let id=org0?.uuid||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 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';
if(!id){
try{const ol=await(await fetch('/api/organizations',h)).json();
if(Array.isArray(ol)&&ol.length>0){id=ol[0]?.uuid||null;orgSrc='orgs-list';}}catch(e2){}
}
if(!id){
let pu=null;
try{pu=await(await fetch('/api/usage',h)).json();}catch(e3){}
window.chrome.webview.postMessage({email:e,orgId:null,planLabel,usage:(pu&&!pu.error?pu:null),debug:'no-org'});
return;
}
const [u,ov,pp]=await Promise.all([
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)});}})()";
await Browser.CoreWebView2.ExecuteScriptAsync(script);
// Wait up to 15 s for the script to post its message
var completed = await Task.WhenAny(tcs.Task, Task.Delay(15000));
var json = completed == tcs.Task ? tcs.Task.Result : null;
if (!string.IsNullOrEmpty(json))
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String)
AppSettings.Default.Email = em.GetString() ?? "";
if (root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String)
AppSettings.Default.OrgId = oi.GetString() ?? "";
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
AppSettings.Default.UsageJson = us.GetRawText();
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)
AppSettings.Default.DebugInfo = dbg.GetString() ?? "";
else if (root.TryGetProperty("error", out var err))
AppSettings.Default.DebugInfo = "JS error: " + err.GetRawText();
else
AppSettings.Default.DebugInfo = "timeout or empty message";
AppSettings.Default.Save();
}
else
{
AppSettings.Default.DebugInfo = json == null ? "script timeout (15s)" : "empty message";
AppSettings.Default.Save();
}
}
catch (Exception ex)
{
AppSettings.Default.DebugInfo = "exception: " + ex.Message;
AppSettings.Default.Save();
}
await Dispatcher.InvokeAsync(() => DialogResult = true);
}
}
+23 -114
View File
@@ -79,12 +79,8 @@ public class UsageViewModel : INotifyPropertyChanged
try
{
// 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);
if (!hasCachedAuth)
// CookieStore being non-empty is our signal that the user has signed in.
if (string.IsNullOrEmpty(AppSettings.Default.CookieStore))
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
@@ -102,9 +98,9 @@ public class UsageViewModel : INotifyPropertyChanged
string? email = null, orgId = null, planLabel = null;
string? refreshError = null;
// 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).
// Primary: persistent background WebView2 always-live cookies, same as macOS.
// Fallback: HttpClient with saved cookies, only if the browser isn't ready yet
// (the brief window while InitAsync is still navigating at startup).
if (App.BackgroundBrowser.IsReady)
{
try
@@ -134,20 +130,6 @@ public class UsageViewModel : INotifyPropertyChanged
}
}
// Fill in blanks from cache
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
if (string.IsNullOrEmpty(orgId)) orgId = AppSettings.Default.OrgId;
if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{
try
{
var cached = JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached);
}
catch { }
}
foreach (var limit in limits)
{
var key = limit.Window.ToString();
@@ -166,7 +148,6 @@ public class UsageViewModel : INotifyPropertyChanged
Limits = limits;
Overage = overage;
Prepaid = prepaid;
// Only replace ExtraUsage when the response explicitly includes it
if (extraUsage != null) ExtraUsage = extraUsage;
}
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
@@ -187,9 +168,8 @@ 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.
// ── Browser-based refresh (primary) ───────────────────────────────────────
// Runs JS on the persistent claude.ai page. Same live cookies as macOS WKWebView.
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
TryBrowserRefreshAsync()
@@ -237,42 +217,21 @@ public class UsageViewModel : INotifyPropertyChanged
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("usage", out var us) && us.ValueKind == JsonValueKind.Object)
usage = JsonSerializer.Deserialize<UsageResponse>(us.GetRawText(), JsonOpts);
if (root.TryGetProperty("overage", out var ov) && ov.ValueKind == JsonValueKind.Object)
{
overageJson = ov.GetRawText();
overage = JsonSerializer.Deserialize<OverageSpendLimit>(overageJson, JsonOpts);
}
overage = JsonSerializer.Deserialize<OverageSpendLimit>(ov.GetRawText(), 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();
prepaid = JsonSerializer.Deserialize<PrepaidCredits>(pp.GetRawText(), JsonOpts);
return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel);
}
// ── HttpClient refresh (fallback — used only before browser is ready) ────
// ── HttpClient refresh (fallback — before browser is ready) ──────────────
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
@@ -289,15 +248,10 @@ public class UsageViewModel : INotifyPropertyChanged
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId))
return ([], null, null, null, email, orgId, planLabel, true);
AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save();
var ut = http.GetAsync($"https://claude.ai/api/organizations/{orgId}/usage");
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
@@ -309,56 +263,20 @@ public class UsageViewModel : INotifyPropertyChanged
var usageJson = await usageResp.Content.ReadAsStringAsync();
var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
var overage = ot.Result;
var prepaid = pt.Result;
AppSettings.Default.UsageJson = usageJson;
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
if (overage != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(overage);
if (prepaid != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(prepaid);
AppSettings.Default.Save();
return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel, true);
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
}
// ── Startup cache ─────────────────────────────────────────────────────────
// Called before the browser is ready. Sets sign-in state and shows placeholders
// with any previously accumulated burn history so the popup isn't blank.
public async Task LoadFromCacheAsync()
{
var email = AppSettings.Default.Email;
var orgId = AppSettings.Default.OrgId;
var cookie = AppSettings.Default.CookieStore;
if (string.IsNullOrEmpty(AppSettings.Default.CookieStore)) return;
bool isAuth = !string.IsNullOrEmpty(email) || !string.IsNullOrEmpty(orgId)
|| !string.IsNullOrEmpty(cookie);
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);
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 { }
}
foreach (var limit in limits)
var placeholders = LoadPlaceholderLimits();
foreach (var limit in placeholders)
{
var key = limit.Window.ToString();
if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
@@ -367,30 +285,21 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() =>
{
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;
Limits = placeholders;
IsSignedIn = true;
});
}
public async Task SignOutAsync()
{
AppSettings.Default.CookieStore = "";
AppSettings.Default.OrgId = "";
AppSettings.Default.Email = "";
AppSettings.Default.UsageJson = "";
AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() =>
{
IsSignedIn = false;
UserEmail = "";
PlanLabel = "Claude";
ErrorMessage = "Signed out.";
Limits = [];
});