Compare commits

..
6 changed files with 190 additions and 39 deletions
+13 -3
View File
@@ -8,8 +8,12 @@ namespace ClaudeCheckerWindows;
public partial class App : Application
{
public static UsageViewModel ViewModel { get; } = new();
public static UpdateManager Updater { get; } = new();
public static UsageViewModel ViewModel { 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 PopupWindow? _popup;
@@ -33,12 +37,18 @@ public partial class App : Application
base.OnStartup(e);
ThemeManager.Initialize();
SetupTray();
ShowPopup();
ScheduleTimer(ViewModel.RefreshInterval);
// Show background browser window on the UI thread before Task.Run
BackgroundBrowser.Show();
_ = Task.Run(async () =>
{
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 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'});
return;
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
window.chrome.webview.postMessage({email:e,orgId:id,planLabel,usage:u,debug:'src:'+orgSrc+'|plan:'+(planLabel||'none')});
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);
@@ -128,6 +132,12 @@ public partial class LoginWindow : Window
&& !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))
+4 -2
View File
@@ -34,8 +34,10 @@ public class WindowData
public class ExtraUsage
{
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; }
[JsonPropertyName("currency")] public string? Currency { get; set; }
[JsonPropertyName("is_enabled")] public bool IsEnabled { 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
+18 -11
View File
@@ -56,12 +56,12 @@ public partial class PopupWindow : Window
foreach (var limit in VM.Limits)
CardsPanel.Children.Add(BuildCard(limit));
if (VM.ExtraUsage?.IsEnabled == true)
CardsPanel.Children.Add(BuildExtraUsageSection());
var fiveHour = VM.Limits.FirstOrDefault(l => l.Window == WindowKind.FiveHour);
if (fiveHour != null && fiveHour.BurnHistory.Count > 0)
CardsPanel.Children.Add(BuildDiarySection(fiveHour));
if (VM.ExtraUsage?.IsEnabled == true)
CardsPanel.Children.Add(BuildExtraUsageSection());
}
private static UIElement BuildCard(AgentLimit limit)
@@ -247,17 +247,23 @@ public partial class PopupWindow : Window
var prepaid = VM.Prepaid;
var currency = VM.ExtraUsage?.Currency ?? overage?.Currency ?? "$";
var spent = overage?.UsedCredits ?? 0;
var limit = overage?.MonthlyCreditLimit ?? 0;
var balance = prepaid?.Amount ?? 0;
// Fall back to extra_usage fields when the overage/prepaid endpoints return nothing.
// 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 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) };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
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(val, 1);
row.Children.Add(lbl);
@@ -266,9 +272,10 @@ public partial class PopupWindow : Window
}
var contentPanel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
contentPanel.Children.Add(MakeRow("Spent", $"{currency}{spent:F2}"));
if (limit > 0) contentPanel.Children.Add(MakeRow("Limit", $"{currency}{limit:F2}"));
if (balance > 0) contentPanel.Children.Add(MakeRow("Balance", $"{currency}{balance:F2}"));
contentPanel.Children.Add(MakeRow("Spent", $"{currency} {spent:F2}", spent > 0));
if (unlimited) contentPanel.Children.Add(MakeRow("Limit", "Unlimited"));
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)
{
+52 -5
View File
@@ -147,7 +147,15 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (limits.Count > 0) { Limits = limits; Overage = overage; Prepaid = prepaid; ExtraUsage = extraUsage; }
if (limits.Count > 0)
{
Limits = limits;
Overage = overage;
Prepaid = prepaid;
// Only clear ExtraUsage if the API explicitly disables it; keep cached value
// if the HTTP endpoint omits the field (it often does for non-browser clients)
if (extraUsage != null) ExtraUsage = extraUsage;
}
if (!string.IsNullOrEmpty(planLabel)) PlanLabel = planLabel;
if (!string.IsNullOrEmpty(email)) UserEmail = email;
IsSignedIn = true;
@@ -260,14 +268,53 @@ public class UsageViewModel : INotifyPropertyChanged
catch { /* best-effort */ }
}
// If HttpClient couldn't reach overage/prepaid endpoints (expired cf_clearance),
// fall back to the persistent background WebView2 which always has live cookies.
var overageResult = ot.Result;
var prepaidResult = pt.Result;
if ((overageResult == null || prepaidResult == null) && orgId != null)
{
try
{
var browser = App.BackgroundBrowser;
if (browser != null)
{
var wvScript = $@"(async()=>{{try{{
const h={{headers:{{accept:'application/json'}}}};
const [ov,pp]=await Promise.all([
fetch('/api/organizations/{orgId}/overage_spend_limit',h).then(r=>r.ok?r.json():null).catch(()=>null),
fetch('/api/organizations/{orgId}/prepaid/credits',h).then(r=>r.ok?r.json():null).catch(()=>null)
]);
window.chrome.webview.postMessage({{overage:ov,prepaid:pp}});
}}catch(ex){{window.chrome.webview.postMessage(null);}}}})()";
var wvJson = await Application.Current.Dispatcher.InvokeAsync(
() => browser.RunScriptAsync(wvScript)).Task.Unwrap();
if (!string.IsNullOrEmpty(wvJson) && wvJson != "null")
{
using var wvDoc = JsonDocument.Parse(wvJson);
var wvRoot = wvDoc.RootElement;
if (overageResult == null && wvRoot.TryGetProperty("overage", out var ovEl)
&& ovEl.ValueKind == JsonValueKind.Object)
overageResult = JsonSerializer.Deserialize<OverageSpendLimit>(ovEl.GetRawText(), JsonOpts);
if (prepaidResult == null && wvRoot.TryGetProperty("prepaid", out var ppEl)
&& ppEl.ValueKind == JsonValueKind.Object)
prepaidResult = JsonSerializer.Deserialize<PrepaidCredits>(ppEl.GetRawText(), JsonOpts);
}
}
}
catch { /* best-effort — never block the refresh */ }
}
// Persist fresh usage so cache reflects live data
AppSettings.Default.UsageJson = usageJson;
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
if (ot.Result != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(ot.Result);
if (pt.Result != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(pt.Result);
if (!string.IsNullOrEmpty(planLabel)) AppSettings.Default.PlanLabel = planLabel;
if (overageResult != null) AppSettings.Default.OverageJson = JsonSerializer.Serialize(overageResult);
if (prepaidResult != null) AppSettings.Default.PrepaidJson = JsonSerializer.Serialize(prepaidResult);
AppSettings.Default.Save();
return (BuildLimits(usage), ot.Result, pt.Result, usage?.ExtraUsage, email, orgId, planLabel, true);
return (BuildLimits(usage), overageResult, prepaidResult, usage?.ExtraUsage, email, orgId, planLabel, true);
}
catch { throw; }
}
+91 -16
View File
@@ -2,42 +2,103 @@ using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace ClaudeCheckerWindows;
// Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
// Uses the same user data folder as LoginWindow so the session is shared.
// Uses the same user data folder as LoginWindow so the session (including
// cf_clearance) is always shared and fresh — equivalent to macOS's WKWebsiteDataStore.
//
// Two usage modes:
// • Persistent (App.BackgroundBrowser): created once at startup, navigates to
// claude.ai once, then scripts run directly on the live page every refresh.
// No re-navigation = no memory accumulation.
// • One-shot (LoginWindow): created, used, closed — same as before.
internal sealed class WebViewFetchWindow : Window
{
private readonly WebView2 _wv = new();
private bool _initialized;
private bool _readyForScript; // true after first navigation to claude.ai completes
private static readonly string UserDataFolder =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2");
public WebViewFetchWindow()
{
Width = 1;
Height = 1;
Left = -9999;
Top = -9999;
ShowInTaskbar = false;
WindowStyle = WindowStyle.None;
Width = 1;
Height = 1;
Left = -9999;
Top = -9999;
ShowInTaskbar = false;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Opacity = 0;
Content = _wv;
Opacity = 0;
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)
{
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await _wv.EnsureCoreWebView2Async(env);
await EnsureInitAsync();
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;
msgHandler = (_, args) =>
{
@@ -65,4 +126,18 @@ internal sealed class WebViewFetchWindow : Window
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;
}
}