feat: Windows app (WPF + WebView2) #1

Open
superdooper86 wants to merge 196 commits from feature/windows-app into main
3 changed files with 317 additions and 38 deletions
Showing only changes of commit c90eb7be69 - Show all commits
+39 -1
View File
@@ -150,7 +150,7 @@
<TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Padding="14,12">
Padding="14,12" Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -185,6 +185,19 @@
</Grid>
</Border>
<TextBlock Text="DIAGNOSTICS" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Padding="14,12">
<StackPanel>
<TextBlock Text="View app state, last request details, and stored cookies."
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<Button Content="Open diagnostics" Style="{StaticResource SmallButton}"
HorizontalAlignment="Left" Click="Diagnostics_Click"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
@@ -250,5 +263,30 @@
</StackPanel>
</DockPanel>
</Border>
<!-- Diagnostics panel -->
<Border x:Name="DiagnosticsPanel" Visibility="Collapsed"
Background="{DynamicResource BackgroundBrush}">
<DockPanel>
<Border DockPanel.Dock="Top" BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="0,0,0,1" Background="{DynamicResource SurfaceBrush}" Padding="12,10">
<Grid>
<Button Content="← Back" HorizontalAlignment="Left"
Style="{StaticResource SmallButton}" BorderThickness="0"
Foreground="{DynamicResource BlueBrush}" Background="Transparent"
Click="BackFromDiag_Click"/>
<TextBlock Text="Diagnostics" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Button x:Name="CopyDiagButton" Content="Copy All" HorizontalAlignment="Right"
Style="{StaticResource SmallButton}" Click="CopyDiag_Click"/>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="DiagContentPanel" Margin="16,4,16,16"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Window>
+189 -9
View File
@@ -1,6 +1,10 @@
using ClaudeCheckerWindows.Controls;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
@@ -463,23 +467,198 @@ public partial class PopupWindow : Window
SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In";
}
// ── Diagnostics ──────────────────────────────────────────────────
private void Diagnostics_Click(object s, RoutedEventArgs e)
{
RebuildDiag();
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Visible;
}
private void BackFromDiag_Click(object s, RoutedEventArgs e)
{
DiagnosticsPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
}
private void CopyDiag_Click(object s, RoutedEventArgs e)
{
Clipboard.SetText(BuildDiagText());
CopyDiagButton.Content = "Copied!";
var t = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1.5) };
t.Tick += (_, _) => { CopyDiagButton.Content = "Copy All"; t.Stop(); };
t.Start();
}
private void RebuildDiag()
{
DiagContentPanel.Children.Clear();
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
var text = (SolidColorBrush)Application.Current.Resources["TextBrush"];
FrameworkElement MakeRow(string label, string value)
{
var grid = new Grid { Margin = new Thickness(0, 3, 0, 3) };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(110) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var lbl = new TextBlock
{
Text = label, FontSize = 11, Foreground = secondary,
VerticalAlignment = VerticalAlignment.Top,
};
var val = new TextBlock
{
Text = value, FontSize = 11, FontFamily = new FontFamily("Consolas"),
Foreground = text, TextWrapping = TextWrapping.Wrap,
};
Grid.SetColumn(lbl, 0);
Grid.SetColumn(val, 1);
grid.Children.Add(lbl);
grid.Children.Add(val);
return grid;
}
void AddSection(string title, IEnumerable<FrameworkElement> rows)
{
DiagContentPanel.Children.Add(new TextBlock
{
Text = title, Style = (Style)Application.Current.Resources["HeaderText"],
Margin = new Thickness(0, 12, 0, 6),
});
var panel = new StackPanel { Margin = new Thickness(12, 10, 12, 10) };
foreach (var row in rows) panel.Children.Add(row);
DiagContentPanel.Children.Add(new Border
{
Background = (SolidColorBrush)Application.Current.Resources["CardBrush"],
CornerRadius = new CornerRadius(6),
BorderBrush = border, BorderThickness = new Thickness(1),
Child = panel,
});
}
// APP
AddSection("APP",
[
MakeRow("Version", Updater.CurrentVersion),
MakeRow("Signed in", VM.IsSignedIn ? "Yes" : "No"),
MakeRow("Email", string.IsNullOrEmpty(VM.UserEmail) ? "(none)" : VM.UserEmail),
MakeRow("Org ID", string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId),
MakeRow("lastActiveOrg", string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg),
MakeRow("Error", VM.ErrorMessage ?? "(none)"),
]);
// LAST REQUEST
var reqRows = new List<FrameworkElement>
{
MakeRow("Path", string.IsNullOrEmpty(VM.DiagLastPath) ? "(none)" : VM.DiagLastPath),
MakeRow("Status", VM.DiagLastStatus == 0 ? "(none)" : VM.DiagLastStatus.ToString()),
MakeRow("Error", string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError),
};
if (VM.DiagLastFetch.HasValue)
reqRows.Add(MakeRow("Time", VM.DiagLastFetch.Value.ToString("HH:mm:ss")));
AddSection("LAST REQUEST", reqRows);
// COOKIE STORE
AddSection("COOKIE STORE",
[
MakeRow("Total cookies", VM.DiagCookieCount.ToString()),
MakeRow("Claude cookies", VM.DiagClaudeCookieCount.ToString()),
MakeRow("All domains", VM.DiagCookieDomains.Count == 0
? "(none)" : string.Join(", ", VM.DiagCookieDomains)),
]);
// STORED COOKIE NAMES
var storedNames = GetStoredCookieNames();
var nameRows = new List<FrameworkElement>();
if (storedNames.Count == 0)
{
nameRows.Add(MakeRow("(none)", ""));
}
else
{
var claudeC = storedNames.Where(c =>
c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com")).ToList();
var otherC = storedNames.Where(c =>
!c.Domain.Contains("claude.ai") && !c.Domain.Contains("anthropic.com")).ToList();
foreach (var c in claudeC)
nameRows.Add(MakeRow(c.Domain, c.Name));
if (otherC.Count > 0)
nameRows.Add(MakeRow($"other ({otherC.Count})",
string.Join(", ", otherC.Take(5).Select(c => c.Name))));
}
AddSection("STORED COOKIE NAMES", nameRows);
}
private static List<(string Name, string Domain)> GetStoredCookieNames()
{
try
{
var raw = AppSettings.Default.CookieStore;
if (string.IsNullOrEmpty(raw)) return [];
using var doc = JsonDocument.Parse(raw);
var list = new List<(string Name, string Domain)>();
foreach (var elem in doc.RootElement.EnumerateArray())
{
var name = elem.TryGetProperty("Name", out var n) ? n.GetString() ?? "" : "";
var domain = elem.TryGetProperty("Domain", out var d) ? d.GetString() ?? "" : "";
list.Add((name, domain));
}
return list;
}
catch { return []; }
}
private static string BuildDiagText()
{
var storedNames = GetStoredCookieNames();
var sb = new StringBuilder();
sb.AppendLine("=== ClaudeChecker Diagnostics (Windows) ===");
sb.AppendLine($"Version: {Updater.CurrentVersion}");
sb.AppendLine($"Signed in: {(VM.IsSignedIn ? "Yes" : "No")}");
sb.AppendLine($"Email: {(string.IsNullOrEmpty(VM.UserEmail) ? "(none)" : VM.UserEmail)}");
sb.AppendLine($"Org ID: {(string.IsNullOrEmpty(VM.DiagOrgId) ? "(none)" : VM.DiagOrgId)}");
sb.AppendLine($"lastActiveOrg: {(string.IsNullOrEmpty(VM.DiagLastActiveOrg) ? "(not read)" : VM.DiagLastActiveOrg)}");
sb.AppendLine($"Error: {VM.ErrorMessage ?? "(none)"}");
sb.AppendLine();
sb.AppendLine($"Last path: {VM.DiagLastPath}");
sb.AppendLine($"Last status: {VM.DiagLastStatus}");
sb.AppendLine($"Last error: {VM.DiagLastError}");
if (VM.DiagLastFetch.HasValue)
sb.AppendLine($"Last fetch: {VM.DiagLastFetch.Value:HH:mm:ss}");
sb.AppendLine();
sb.AppendLine($"Total cookies: {VM.DiagCookieCount}");
sb.AppendLine($"Claude cookies: {VM.DiagClaudeCookieCount}");
sb.AppendLine($"Domains: {string.Join(", ", VM.DiagCookieDomains)}");
sb.AppendLine();
sb.AppendLine("Stored cookies:");
foreach (var c in storedNames)
sb.AppendLine($" {c.Domain} {c.Name}");
return sb.ToString();
}
// ── Event handlers ───────────────────────────────────────────────
private void Settings_Click(object s, RoutedEventArgs e)
{
InitSettings();
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
UpdatePanel.Visibility = Visibility.Collapsed;
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
}
private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
private void ShowMain()
{
MainPanel.Visibility = Visibility.Visible;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed;
MainPanel.Visibility = Visibility.Visible;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
}
private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
@@ -491,9 +670,10 @@ public partial class PopupWindow : Window
CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
NewVerLabel.Text = $"v{Updater.LatestVersion}";
ReleaseNotesText.Text = Updater.ReleaseNotes;
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Visible;
MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Visible;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
}
private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
+89 -28
View File
@@ -40,6 +40,17 @@ public class UsageViewModel : INotifyPropertyChanged
public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); }
public ExtraUsage? ExtraUsage { get => _extraUsage; set => Set(ref _extraUsage, value); }
// Diagnostics — updated after each refresh, read by DiagnosticsPanel on demand
public string DiagLastActiveOrg { get; private set; } = "";
public string DiagOrgId { get; private set; } = "";
public string DiagLastPath { get; private set; } = "";
public int DiagLastStatus { get; private set; }
public string DiagLastError { get; private set; } = "";
public int DiagCookieCount { get; private set; }
public int DiagClaudeCookieCount { get; private set; }
public List<string> DiagCookieDomains { get; private set; } = [];
public DateTime? DiagLastFetch { get; private set; }
private int _refreshInterval = 120;
public int RefreshInterval
{
@@ -77,6 +88,9 @@ public class UsageViewModel : INotifyPropertyChanged
{
await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true);
// Snapshot stored cookies once — used for both diag and HttpClient fallback path.
var storedCookies = await GetCookiesAsync();
try
{
// CookieStore being non-empty is our signal that the user has signed in.
@@ -115,13 +129,12 @@ public class UsageViewModel : INotifyPropertyChanged
}
else
{
var cookies = await GetCookiesAsync();
if (cookies.Count > 0)
if (storedCookies.Count > 0)
{
try
{
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(cookies);
await TryHttpRefreshAsync(storedCookies);
}
catch (Exception ex)
{
@@ -156,6 +169,16 @@ public class UsageViewModel : INotifyPropertyChanged
ErrorMessage = refreshError;
LastUpdated = DateTime.Now;
IsLoading = false;
// Update diagnostics
if (!string.IsNullOrEmpty(orgId)) DiagOrgId = orgId;
DiagLastFetch = DateTime.Now;
DiagLastError = refreshError ?? "";
DiagCookieCount = storedCookies.Count;
DiagClaudeCookieCount = storedCookies.Count(c =>
c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"));
DiagCookieDomains = storedCookies
.Select(c => c.Domain).Distinct().OrderBy(d => d).ToList();
});
}
catch (Exception ex)
@@ -174,14 +197,19 @@ public class UsageViewModel : INotifyPropertyChanged
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
TryBrowserRefreshAsync()
{
// Read lastActiveOrg from document.cookie — the only reliable org source when the
// user belongs to multiple orgs. memberships[0] is always the "Individual Org" and
// would return 403 on usage endpoints if the paid plan is on a different org.
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 lastActiveOrg=(document.cookie.split(';').map(c=>c.trim().split('=')).find(p=>p[0]==='lastActiveOrg')||[])[1]||null;
const allOrgs=(b?.account?.memberships||b?.memberships||[]).map(m=>m?.organization).filter(Boolean);
const activeOrg=(lastActiveOrg?allOrgs.find(o=>o?.uuid===lastActiveOrg):null)||allOrgs[0];
let id=activeOrg?.uuid||b?.organizations?.[0]?.uuid||null;
const email=b?.account?.email_address||b?.account?.email||null;
const caps=org0?.capabilities||[];
const caps=activeOrg?.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){
@@ -194,7 +222,7 @@ public class UsageViewModel : INotifyPropertyChanged
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});
window.chrome.webview.postMessage({email,orgId:id,planLabel,lastActiveOrg,usage:u,overage:ov,prepaid:pp});
}catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
var json = await Application.Current.Dispatcher.InvokeAsync(
@@ -216,6 +244,11 @@ public class UsageViewModel : INotifyPropertyChanged
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? lao = root.TryGetProperty("lastActiveOrg", out var laoProp) && laoProp.ValueKind == JsonValueKind.String ? laoProp.GetString() : null;
DiagLastActiveOrg = lao ?? "";
DiagLastPath = $"/api/bootstrap + /api/organizations/…/usage (browser)";
DiagLastStatus = 200;
UsageResponse? usage = null;
OverageSpendLimit? overage = null;
@@ -236,15 +269,22 @@ public class UsageViewModel : INotifyPropertyChanged
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{
// Prefer the org the user last had active, not necessarily memberships[0].
var lastActiveOrg = cookies.FirstOrDefault(c => c.Name == "lastActiveOrg").Value;
DiagLastActiveOrg = lastActiveOrg ?? "";
using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
DiagLastPath = "/api/bootstrap";
DiagLastStatus = (int)bootstrapResp.StatusCode;
if ((int)bootstrapResp.StatusCode is 401 or 403)
throw new Exception("Session expired — please re-authenticate.");
if (!bootstrapResp.IsSuccessStatusCode)
throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson);
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson, lastActiveOrg);
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
@@ -258,6 +298,8 @@ public class UsageViewModel : INotifyPropertyChanged
await Task.WhenAll(ut, ot, pt);
var usageResp = ut.Result;
DiagLastPath = $"/api/organizations/{orgId}/usage";
DiagLastStatus = (int)usageResp.StatusCode;
if (!usageResp.IsSuccessStatusCode)
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
@@ -361,7 +403,11 @@ public class UsageViewModel : INotifyPropertyChanged
catch { return null; }
}
private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json)
// Parses bootstrap JSON and returns email, org ID, and plan label.
// preferredOrgId (from the lastActiveOrg cookie) selects the correct org when the
// user belongs to multiple — memberships[0] is always the "Individual Org" which
// returns 403 on usage endpoints if the paid plan is on a different org.
private static (string? Email, string? OrgId, string? PlanLabel) ParseBootstrap(string json, string? preferredOrgId = null)
{
try
{
@@ -369,42 +415,57 @@ public class UsageViewModel : INotifyPropertyChanged
var root = doc.RootElement;
string? email = null;
string? orgId = null;
if (root.TryGetProperty("account", out var acct) &&
acct.TryGetProperty("email_address", out var em))
email = em.GetString();
// Collect all org objects from memberships
var allOrgs = new List<JsonElement>();
if (root.TryGetProperty("account", out var acctNode) &&
acctNode.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0)
acctNode.TryGetProperty("memberships", out var mems))
{
var first = mems[0];
if (first.TryGetProperty("organization", out var org) &&
org.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
foreach (var mem in mems.EnumerateArray())
if (mem.TryGetProperty("organization", out var org))
allOrgs.Add(org);
}
if (allOrgs.Count == 0 && root.TryGetProperty("memberships", out var rootMems))
{
foreach (var mem in rootMems.EnumerateArray())
if (mem.TryGetProperty("organization", out var org))
allOrgs.Add(org);
}
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("memberships", out var rootMems) && rootMems.GetArrayLength() > 0)
// Prefer the org matching preferredOrgId, fall back to first
JsonElement activeOrg = default;
if (!string.IsNullOrEmpty(preferredOrgId))
{
var first = rootMems[0];
if (first.TryGetProperty("organization", out var org) &&
org.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
foreach (var org in allOrgs)
{
if (org.TryGetProperty("uuid", out var u) && u.GetString() == preferredOrgId)
{
activeOrg = org;
break;
}
}
}
if (activeOrg.ValueKind == JsonValueKind.Undefined && allOrgs.Count > 0)
activeOrg = allOrgs[0];
string? orgId = null;
if (activeOrg.ValueKind != JsonValueKind.Undefined &&
activeOrg.TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
{
if (orgs[0].TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
if (orgs[0].TryGetProperty("uuid", out var u))
orgId = u.GetString();
}
string? planLabel = null;
if (root.TryGetProperty("account", out var acctPlan) &&
acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 &&
plMems[0].TryGetProperty("organization", out var plOrg) &&
plOrg.TryGetProperty("capabilities", out var caps) &&
if (activeOrg.ValueKind != JsonValueKind.Undefined &&
activeOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == JsonValueKind.Array)
{
foreach (var cap in caps.EnumerateArray())