Compare commits

..
Author SHA1 Message Date
SuperDooper fca1a7eb60 fix: credit values /100; add after-reset/today badge to cards 2026-05-08 22:17:33 +02:00
SuperDooper 013569ea8b fix: gauge — remove 'used' label, increase pct font to 18 2026-05-08 22:17:32 +02:00
SuperDooper 9ef83c1a1e refactor: LoginWindow only saves cookies; all data fetched by background browser 2026-05-08 22:04:10 +02:00
SuperDooper cabc2b6326 refactor: remove all API cache reads/writes; live-only refresh via browser 2026-05-08 22:04:10 +02:00
SuperDooper b01afc5be7 refactor: strip API-response cache fields from AppSettings 2026-05-08 22:04:08 +02:00
SuperDooper 4b211a17ed refactor: use persistent WebView2 as primary refresh path, HttpClient as fallback 2026-05-08 21:57:55 +02:00
SuperDooper de41ea9dc4 fix: make WebViewFetchWindow public; add IsReady property 2026-05-08 21:57:54 +02:00
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
8 changed files with 314 additions and 341 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();
}); });
+6 -7
View File
@@ -6,18 +6,17 @@ namespace ClaudeCheckerWindows;
public class AppSettings 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; } = ""; public string CookieStore { get; set; } = "";
// Accumulated sparkline data — built up over multiple refreshes.
public string BurnHistory { get; set; } = ""; public string BurnHistory { get; set; } = "";
public string OrgId { get; set; } = "";
public string Email { get; set; } = ""; // User preferences
public string UsageJson { get; set; } = "";
public int RefreshInterval { get; set; } = 120; public int RefreshInterval { get; set; } = 120;
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 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),
+3 -16
View File
@@ -46,29 +46,16 @@ public class GaugeControl : FrameworkElement
DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0), DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0),
new Pen(Accent, StrokeWidth)); new Pen(Accent, StrokeWidth));
var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C); var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C);
var subColor = Color.FromRgb(0x9E, 0x9E, 0x9E);
var pctText = new FormattedText( var pctText = new FormattedText(
$"{(int)Math.Round(Percent)}%", $"{(int)Math.Round(Percent)}%",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal), new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
14, new SolidColorBrush(textColor), dpi); 18, new SolidColorBrush(textColor), dpi);
var sub = new FormattedText(
"used",
CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Segoe UI"), 8,
new SolidColorBrush(subColor), dpi)
{
TextAlignment = TextAlignment.Center
};
var totalHeight = pctText.Height + 2 + sub.Height; dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height / 2));
var startY = cy - totalHeight / 2;
dc.DrawText(pctText, new Point(cx - pctText.Width / 2, startY));
dc.DrawText(sub, new Point(cx - sub.Width / 2, startY + pctText.Height + 2));
} }
private static void DrawArc(DrawingContext dc, double cx, double cy, double r, private static void DrawArc(DrawingContext dc, double cx, double cy, double r,
+6 -85
View File
@@ -1,8 +1,5 @@
using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows; using System.Windows;
@@ -22,7 +19,7 @@ public partial class LoginWindow : Window
{ {
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
System.IO.Path.Combine( System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2")); "ClaudeChecker", "WebView2"));
await Browser.EnsureCoreWebView2Async(env); await Browser.EnsureCoreWebView2Async(env);
@@ -35,9 +32,9 @@ public partial class LoginWindow : Window
if (!uri.Contains("claude.ai")) return; if (!uri.Contains("claude.ai")) return;
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai"); var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
var signedIn = cookies.Any(c => var signedIn = System.Linq.Enumerable.Any(cookies, c =>
c.Name.Equals("sessionKey", StringComparison.OrdinalIgnoreCase) || c.Name.Equals("sessionKey", System.StringComparison.OrdinalIgnoreCase) ||
c.Name.StartsWith("sk-ant", StringComparison.OrdinalIgnoreCase)); c.Name.StartsWith("sk-ant", System.StringComparison.OrdinalIgnoreCase));
await Dispatcher.InvokeAsync(() => await Dispatcher.InvokeAsync(() =>
{ {
@@ -64,86 +61,10 @@ public partial class LoginWindow : Window
if (_closing) return; if (_closing) return;
_closing = true; _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); 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 rawB=JSON.stringify(b).slice(0,600);
let id=b?.account?.memberships?.[0]?.organization?.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;
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,usage:(pu&&!pu.error?pu:null),debug:'no-org|raw:'+rawB});
return;
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
const rawU=JSON.stringify(u).slice(0,400);
window.chrome.webview.postMessage({email:e,orgId:id,usage:u,debug:'src:'+orgSrc+'|ukeys:'+Object.keys(u||{}).join(',')+'|rawU:'+rawU+'|rawB:'+rawB});
}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("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); await Dispatcher.InvokeAsync(() => DialogResult = true);
} }
} }
+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
+55 -25
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)
@@ -124,24 +124,51 @@ public partial class PopupWindow : Window
BorderThickness = new Thickness(0), BorderThickness = new Thickness(0),
}; };
var timeStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; // Left side of time row: "⏱ Xh Ym Resets D MMM YYYY at HH:mm"
timeStack.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center }); var timeLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
timeStack.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12, timeLeft.Children.Add(new TextBlock { Text = "⏱ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
timeLeft.Children.Add(new TextBlock { Text = limit.TimeRemaining, FontSize = 12,
FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center }); FontWeight = FontWeights.Medium, Foreground = text, VerticalAlignment = VerticalAlignment.Center });
timeLeft.Children.Add(new TextBlock
var resetText = new TextBlock
{ {
Text = $"Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}", Text = $" Resets {limit.ResetDate:d MMM yyyy} at {limit.ResetDate:HH:mm}",
FontSize = 11, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
}; });
// Right side: "after reset" when 0%, "today HH:MM" when refreshed today
string? refreshLabel = limit.UsedPercent == 0
? "after reset"
: VM.LastUpdated?.Date == DateTime.Today
? "today " + VM.LastUpdated.Value.ToString("h:mm tt")
: null;
UIElement timeRight;
if (refreshLabel != null)
{
var inner = new StackPanel { Orientation = Orientation.Horizontal };
inner.Children.Add(new TextBlock { Text = "↗ ", FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
inner.Children.Add(new TextBlock { Text = refreshLabel, FontSize = 10, FontWeight = FontWeights.Medium,
Foreground = secondary, VerticalAlignment = VerticalAlignment.Center });
timeRight = new Border
{
Background = new SolidColorBrush(Color.FromArgb(18, 0, 0, 0)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 2, 6, 2),
Child = inner
};
}
else
{
timeRight = new TextBlock(); // empty placeholder
}
var timeRow = new Grid(); var timeRow = new Grid();
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); timeRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
Grid.SetColumn(timeStack, 0); Grid.SetColumn(timeLeft, 0);
Grid.SetColumn(resetText, 1); Grid.SetColumn(timeRight, 1);
timeRow.Children.Add(timeStack); timeRow.Children.Add(timeLeft);
timeRow.Children.Add(resetText); timeRow.Children.Add(timeRight);
var sparkline = new SparklineControl var sparkline = new SparklineControl
{ {
@@ -247,17 +274,21 @@ 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; // All credit values from the API are in cents — divide by 100.
var limit = overage?.MonthlyCreditLimit ?? 0; var spent = (overage?.UsedCredits / 100.0) ?? (VM.ExtraUsage?.UsedCredits / 100.0) ?? 0;
var balance = prepaid?.Amount ?? 0; var limit = (overage?.MonthlyCreditLimit / 100.0) ?? (VM.ExtraUsage?.MonthlyLimit / 100.0) ?? 0;
var balance = (prepaid?.Amount / 100.0) ?? 0;
UIElement MakeRow(string label, string value) bool unlimited = overage?.MonthlyCreditLimit == null && VM.ExtraUsage?.MonthlyLimit == null;
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 +297,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)
{ {
@@ -460,12 +492,10 @@ public partial class PopupWindow : Window
var login = new LoginWindow { Owner = this }; var login = new LoginWindow { Owner = this };
if (login.ShowDialog() == true) if (login.ShowDialog() == true)
{ {
// Show cached data from SaveAndClose immediately (no WebView2 needed)
await VM.LoadFromCacheAsync(); await VM.LoadFromCacheAsync();
InitSettings(); InitSettings();
ShowMain(); ShowMain();
// Full refresh in background after WebView2 folder is definitely released
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await Task.Delay(3000); await Task.Delay(3000);
+133 -186
View File
@@ -79,14 +79,8 @@ public class UsageViewModel : INotifyPropertyChanged
try try
{ {
var cookies = await GetCookiesAsync(); // CookieStore being non-empty is our signal that the user has signed in.
if (string.IsNullOrEmpty(AppSettings.Default.CookieStore))
// Check if we have any persistent proof that the user authenticated
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 +91,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,31 +98,36 @@ 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: 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 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
// Fill in any blanks from cached values saved at login time
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 cookies = await GetCookiesAsync();
if (cookies.Count > 0)
{ {
var cached = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>( try
AppSettings.Default.UsageJson, JsonOpts); {
limits = BuildLimits(cached); (limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(cookies);
}
catch (Exception ex)
{
refreshError = ex.Message;
}
} }
catch { }
} }
foreach (var limit in limits) foreach (var limit in limits)
@@ -147,9 +143,15 @@ 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;
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;
@@ -166,157 +168,115 @@ public class UsageViewModel : INotifyPropertyChanged
} }
} }
// ── 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()
{
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;
UsageResponse? usage = null;
OverageSpendLimit? overage = null;
PrepaidCredits? prepaid = null;
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)
overage = JsonSerializer.Deserialize<OverageSpendLimit>(ov.GetRawText(), JsonOpts);
if (root.TryGetProperty("prepaid", out var pp) && pp.ValueKind == JsonValueKind.Object)
prepaid = JsonSerializer.Deserialize<PrepaidCredits>(pp.GetRawText(), JsonOpts);
return (BuildLimits(usage), overage, prepaid, usage?.ExtraUsage, email, orgId, planLabel);
}
// ── HttpClient refresh (fallback — 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 (email, orgId, planLabel) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync()); var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
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))
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; var ut = http.GetAsync($"https://claude.ai/api/organizations/{orgId}/usage");
AppSettings.Default.Save(); 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);
var usageUrl = $"https://claude.ai/api/organizations/{orgId}/usage"; var usageResp = ut.Result;
var ut = http.GetAsync(usageUrl); if (!usageResp.IsSuccessStatusCode)
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit"); throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(ut, ot, pt);
var usageResp = ut.Result; var usageJson = await usageResp.Content.ReadAsStringAsync();
if (!usageResp.IsSuccessStatusCode) var usage = JsonSerializer.Deserialize<UsageResponse>(usageJson, JsonOpts);
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
var usageJson = await usageResp.Content.ReadAsStringAsync(); return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
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 { throw; }
} }
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync() // ── Startup cache ─────────────────────────────────────────────────────────
{ // Called before the browser is ready. Sets sign-in state and shows placeholders
try // with any previously accumulated burn history so the popup isn't blank.
{
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()
{ {
var email = AppSettings.Default.Email; if (string.IsNullOrEmpty(AppSettings.Default.CookieStore)) return;
var orgId = AppSettings.Default.OrgId;
var cookie = AppSettings.Default.CookieStore;
bool isAuth = !string.IsNullOrEmpty(email) || !string.IsNullOrEmpty(orgId) var placeholders = LoadPlaceholderLimits();
|| !string.IsNullOrEmpty(cookie); foreach (var limit in placeholders)
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 { }
}
// Attach persisted burn history to cached limits
foreach (var limit in limits)
{ {
var key = limit.Window.ToString(); var key = limit.Window.ToString();
if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0) if (_burnHistory.TryGetValue(key, out var hist) && hist.Count > 0)
@@ -325,30 +285,21 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
if (!string.IsNullOrEmpty(email)) UserEmail = email; Limits = placeholders;
if (!string.IsNullOrEmpty(AppSettings.Default.PlanLabel)) PlanLabel = AppSettings.Default.PlanLabel; IsSignedIn = true;
Limits = limits.Count > 0 ? limits : LoadPlaceholderLimits();
Overage = overage;
Prepaid = prepaid;
ExtraUsage = extraUsage;
IsSignedIn = true;
ErrorMessage = null;
LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated;
}); });
} }
public async Task SignOutAsync() public async Task SignOutAsync()
{ {
AppSettings.Default.CookieStore = ""; AppSettings.Default.CookieStore = "";
AppSettings.Default.OrgId = "";
AppSettings.Default.Email = "";
AppSettings.Default.UsageJson = "";
AppSettings.Default.Save(); AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
IsSignedIn = false; IsSignedIn = false;
UserEmail = ""; UserEmail = "";
PlanLabel = "Claude";
ErrorMessage = "Signed out."; ErrorMessage = "Signed out.";
Limits = []; Limits = [];
}); });
@@ -385,14 +336,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;
@@ -410,7 +361,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
@@ -425,7 +375,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)
{ {
@@ -435,7 +384,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)
{ {
@@ -445,7 +393,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)
{ {
@@ -458,7 +405,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;
}
} }