Compare commits

..
Author SHA1 Message Date
SuperDooper 5a799899ef Installer: use userdesktop instead of commondesktop (no admin needed) 2026-05-11 21:41:14 +02:00
SuperDooper f318713ba5 Fix installer OutputDir: use . relative to SourceDir 2026-05-11 21:34:23 +02:00
SuperDooper b9d2c8a5f3 CI: build Inno Setup installer instead of zip 2026-05-11 21:27:59 +02:00
SuperDooper 8883000098 CI: build Inno Setup installer instead of zip 2026-05-11 21:27:57 +02:00
SuperDooper a8acf734ed Updater: use Inno Setup installer for auto-update 2026-05-11 21:27:46 +02:00
SuperDooper c432355be1 Add Inno Setup installer script 2026-05-11 21:27:36 +02:00
SuperDooper 9d3a06bf7a Diagnostics: retry clipboard on COMException to fix crash 2026-05-11 21:20:04 +02:00
SuperDooper 6cc4c1dc2f Diagnostics: update privacy note 2026-05-11 21:07:53 +02:00
SuperDooper e2fff371ee Diagnostics: show (none) for empty last error in copy 2026-05-11 21:06:53 +02:00
SuperDooper 872cdbc684 Diagnostics: clean up orphaned AddSection brackets 2026-05-11 20:51:56 +02:00
SuperDooper 0f78931599 Diagnostics: remove stale Cookie Store section 2026-05-11 20:51:36 +02:00
SuperDooper a84b2645fc Diagnostics: add privacy note 2026-05-11 20:48:25 +02:00
SuperDooper 6152371b16 CI: handle inline WIN_STABLE_BADGE placeholder on first stable release 2026-05-11 20:46:58 +02:00
SuperDooper 60acaf3eea CI: update Win Stable badge and clear Win Beta on stable release 2026-05-11 20:45:03 +02:00
SuperDooper e064e14425 CI: update Windows beta badge label to Win_Beta 2026-05-11 20:43:58 +02:00
SuperDooper 33e35243c4 CI: update Windows beta badge in README on release 2026-05-11 20:41:59 +02:00
SuperDooper e16bfa59d0 Diagnostics: remove email, redact IDs on copy 2026-05-11 20:28:26 +02:00
SuperDooper b0542d7291 fix projected hit badge on Windows — show after-reset or hit time, not last-refresh time 2026-05-11 15:07:47 +02:00
SuperDooper c90eb7be69 port multi-org fix and add diagnostics panel to Windows app
Read lastActiveOrg cookie in both the browser JS path and HttpClient
fallback, then prefer the matching org in ParseBootstrap for both
org ID and plan label selection — same fix as macOS v1.3.0.

Add Settings → Diagnostics panel showing app state, last request
details, cookie store counts/domains, and stored cookie names.
2026-05-11 14:18:59 +02:00
SuperDooper 2fc7e8f9eb chore: update release notes for beta.49 2026-05-08 22:52:11 +02:00
SuperDooper 877d59bb26 fix: restore PopupWindow; 1s footer timer; Xm Ys ago format 2026-05-08 22:45:27 +02:00
SuperDooper 8a69e740ee fix: footer timer 1s; format Xm Ys ago when over 60s 2026-05-08 22:42:40 +02:00
8 changed files with 481 additions and 112 deletions
+29 -4
View File
@@ -34,11 +34,11 @@ jobs:
-p:Version=${{ steps.version.outputs.version }} ` -p:Version=${{ steps.version.outputs.version }} `
-o publish -o publish
- name: Zip - name: Build installer
shell: pwsh shell: pwsh
run: | run: |
Copy-Item -Recurse windows/Assets publish/Assets Copy-Item -Recurse windows/Assets publish/Assets
Compress-Archive -Path publish/* -DestinationPath ClaudeChecker-Windows.zip & "C:\Program Files (x86)\Inno Setup 6\iscc.exe" /DAppVersion="${{ steps.version.outputs.version }}" windows\installer.iss
- name: Create GitHub pre-release - name: Create GitHub pre-release
env: env:
@@ -46,7 +46,7 @@ jobs:
shell: bash shell: bash
run: | run: |
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}") notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
gh release create "$GITHUB_REF_NAME" ClaudeChecker-Windows.zip \ gh release create "$GITHUB_REF_NAME" ClaudeChecker-Installer.exe \
--title "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \
--prerelease \ --prerelease \
--notes "$notes" --notes "$notes"
@@ -59,7 +59,7 @@ jobs:
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}") notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows beta ${{ steps.version.outputs.version }}")
content=$(jq -n \ content=$(jq -n \
--arg version "${{ steps.version.outputs.version }}" \ --arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Windows.zip" \ --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Installer.exe" \
--arg notes "$notes" \ --arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' | base64 -w0) '{version: $version, url: $url, notes: $notes}' | base64 -w0)
sha=$(gh api "repos/${{ github.repository }}/contents/version-windows-beta.json?ref=main" --jq .sha 2>/dev/null || echo "") sha=$(gh api "repos/${{ github.repository }}/contents/version-windows-beta.json?ref=main" --jq .sha 2>/dev/null || echo "")
@@ -75,3 +75,28 @@ jobs:
--field "content=$content" \ --field "content=$content" \
--field "branch=main" --field "branch=main"
fi fi
- name: Update Windows beta badge in README on main
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
version="${{ steps.version.outputs.version }}"
tag="${{ github.ref_name }}"
shields_version=$(echo "$version" | sed 's/-/--/g')
export WIN_BADGE="[![Win Beta](https://img.shields.io/badge/Win_Beta-${shields_version}-blue?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- WIN_BETA_BADGE -->"
sha=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .sha)
content=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .content | python3 -c "
import sys, base64, os
raw = sys.stdin.read().strip()
text = base64.b64decode(raw).decode()
badge = os.environ['WIN_BADGE']
lines = text.splitlines(keepends=True)
out = [badge + chr(10) if '<!-- WIN_BETA_BADGE -->' in line else line for line in lines]
print(base64.b64encode(''.join(out).encode()).decode(), end='')
")
gh api --method PUT "repos/${{ github.repository }}/contents/README.md" \
--field "message=Windows beta badge $tag" \
--field "content=$content" \
--field "branch=main" \
--field "sha=$sha"
+41 -4
View File
@@ -35,11 +35,11 @@ jobs:
-p:Version=${{ steps.version.outputs.version }} ` -p:Version=${{ steps.version.outputs.version }} `
-o publish -o publish
- name: Zip - name: Build installer
shell: pwsh shell: pwsh
run: | run: |
Copy-Item -Recurse windows/Assets publish/Assets Copy-Item -Recurse windows/Assets publish/Assets
Compress-Archive -Path publish/* -DestinationPath ClaudeChecker-Windows.zip & "C:\Program Files (x86)\Inno Setup 6\iscc.exe" /DAppVersion="${{ steps.version.outputs.version }}" windows\installer.iss
- name: Create GitHub release - name: Create GitHub release
env: env:
@@ -47,7 +47,7 @@ jobs:
shell: bash shell: bash
run: | run: |
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}") notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
gh release create "$GITHUB_REF_NAME" ClaudeChecker-Windows.zip \ gh release create "$GITHUB_REF_NAME" ClaudeChecker-Installer.exe \
--title "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \
--notes "$notes" --notes "$notes"
@@ -59,7 +59,7 @@ jobs:
notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}") notes=$(cat windows/RELEASE_NOTES.md 2>/dev/null || echo "Windows release ${{ steps.version.outputs.version }}")
content=$(jq -n \ content=$(jq -n \
--arg version "${{ steps.version.outputs.version }}" \ --arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Windows.zip" \ --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker-Installer.exe" \
--arg notes "$notes" \ --arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' | base64 -w0) '{version: $version, url: $url, notes: $notes}' | base64 -w0)
sha=$(gh api "repos/${{ github.repository }}/contents/version-windows.json?ref=main" --jq .sha 2>/dev/null || echo "") sha=$(gh api "repos/${{ github.repository }}/contents/version-windows.json?ref=main" --jq .sha 2>/dev/null || echo "")
@@ -75,3 +75,40 @@ jobs:
--field "content=$content" \ --field "content=$content" \
--field "branch=main" --field "branch=main"
fi fi
- name: Update Windows stable badge in README on main
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
version="${{ steps.version.outputs.version }}"
tag="${{ github.ref_name }}"
shields_version=$(echo "$version" | sed 's/-/--/g')
export WIN_STABLE_BADGE="[![Win Stable](https://img.shields.io/badge/Win_Stable-${shields_version}-blue?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- WIN_STABLE_BADGE -->"
sha=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .sha)
content=$(gh api "repos/${{ github.repository }}/contents/README.md?ref=main" --jq .content | python3 -c "
import sys, base64, os
raw = sys.stdin.read().strip()
text = base64.b64decode(raw).decode()
stable_badge = os.environ['WIN_STABLE_BADGE']
lines = text.splitlines(keepends=True)
out = []
for line in lines:
if '<!-- WIN_STABLE_BADGE -->' in line:
out.append(stable_badge + chr(10))
rest = line.replace(' <!-- WIN_STABLE_BADGE -->', '').replace('<!-- WIN_STABLE_BADGE -->', '')
if rest.strip() and '<!-- WIN_BETA_BADGE -->' in rest:
out.append('<!-- WIN_BETA_BADGE -->' + chr(10))
elif rest.strip():
out.append(rest)
elif '<!-- WIN_BETA_BADGE -->' in line:
out.append('<!-- WIN_BETA_BADGE -->' + chr(10))
else:
out.append(line)
print(base64.b64encode(''.join(out).encode()).decode(), end='')
")
gh api --method PUT "repos/${{ github.repository }}/contents/README.md" \
--field "message=Windows stable badge $tag" \
--field "content=$content" \
--field "branch=main" \
--field "sha=$sha"
+42 -1
View File
@@ -150,7 +150,7 @@
<TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/> <TextBlock Text="ABOUT" Style="{StaticResource HeaderText}" Margin="0,0,0,8"/>
<Border Background="{DynamicResource CardBrush}" CornerRadius="6" <Border Background="{DynamicResource CardBrush}" CornerRadius="6"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Padding="14,12"> Padding="14,12" Margin="0,0,0,16">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@@ -185,6 +185,19 @@
</Grid> </Grid>
</Border> </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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
@@ -250,5 +263,33 @@
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
</Border> </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>
<TextBlock DockPanel.Dock="Top" Text="Org IDs are redacted when copied."
FontSize="10" Foreground="{DynamicResource SecondaryBrush}"
Margin="14,6,14,6"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="DiagContentPanel" Margin="16,4,16,16"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid> </Grid>
</Window> </Window>
+209 -24
View File
@@ -1,6 +1,11 @@
using ClaudeCheckerWindows.Controls; using ClaudeCheckerWindows.Controls;
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Interop; using System.Windows.Interop;
@@ -33,7 +38,7 @@ public partial class PopupWindow : Window
VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName)); VM.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnVmChanged(e.PropertyName));
Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName)); Updater.PropertyChanged += (_, e) => Dispatcher.InvokeAsync(() => OnUpdaterChanged(e.PropertyName));
_clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_clockTimer.Tick += (_, _) => UpdateFooter(); _clockTimer.Tick += (_, _) => UpdateFooter();
_clockTimer.Start(); _clockTimer.Start();
@@ -97,7 +102,7 @@ public partial class PopupWindow : Window
badgeStack.Children.Add(usageBadge); badgeStack.Children.Add(usageBadge);
var nameStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; var nameStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
nameStack.Children.Add(new TextBlock { Text = "", FontSize = 11, Foreground = accent, VerticalAlignment = VerticalAlignment.Center }); nameStack.Children.Add(new TextBlock { Text = "", FontSize = 11, Foreground = accent, VerticalAlignment = VerticalAlignment.Center });
nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold, nameStack.Children.Add(new TextBlock { Text = "Claude", FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }); Foreground = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11, nameStack.Children.Add(new TextBlock { Text = $"· {VM.PlanLabel}", FontSize = 11,
@@ -131,12 +136,20 @@ public partial class PopupWindow : Window
FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center FontSize = 10, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center
}); });
// Right: "after reset" when 0%, "today HH:MM" when refreshed today // Right: projected limit-hit time, or "after reset" if burn rate won't reach 100% before reset.
string? refreshLabel = limit.UsedPercent == 0 // BurnRate is stored as usedPercent / windowHours, so hoursToFull = remainingPercent / BurnRate.
? "after reset" string? refreshLabel;
: VM.LastUpdated?.Date == DateTime.Today if (limit.BurnRate <= 0)
? "today " + VM.LastUpdated.Value.ToString("h:mm tt") refreshLabel = "after reset";
: null; else
{
var projectedHit = DateTime.Now.AddHours((100.0 - limit.UsedPercent) / limit.BurnRate);
refreshLabel = projectedHit > limit.ResetDate
? "after reset"
: projectedHit.Date == DateTime.Today
? projectedHit.ToString("h:mm tt")
: projectedHit.ToString("d MMM h:mm tt");
}
UIElement timeRight; UIElement timeRight;
if (refreshLabel != null) if (refreshLabel != null)
@@ -208,7 +221,6 @@ public partial class PopupWindow : Window
var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"]; var secondary = (SolidColorBrush)Application.Current.Resources["SecondaryBrush"];
var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"]; var border = (SolidColorBrush)Application.Current.Resources["BorderBrush"];
// Stats row inside the card (mirrors macOS layout without the Claude icon/header)
var statsRow = new Grid { Margin = new Thickness(12, 10, 12, 6) }; var statsRow = new Grid { Margin = new Thickness(12, 10, 12, 6) };
statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); statsRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -273,7 +285,6 @@ 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 ?? "$";
// All credit values from the API are in cents — divide by 100.
var spent = (overage?.UsedCredits / 100.0) ?? (VM.ExtraUsage?.UsedCredits / 100.0) ?? 0; var spent = (overage?.UsedCredits / 100.0) ?? (VM.ExtraUsage?.UsedCredits / 100.0) ?? 0;
var limit = (overage?.MonthlyCreditLimit / 100.0) ?? (VM.ExtraUsage?.MonthlyLimit / 100.0) ?? 0; var limit = (overage?.MonthlyCreditLimit / 100.0) ?? (VM.ExtraUsage?.MonthlyLimit / 100.0) ?? 0;
var balance = (prepaid?.Amount / 100.0) ?? 0; var balance = (prepaid?.Amount / 100.0) ?? 0;
@@ -396,8 +407,14 @@ public partial class PopupWindow : Window
if (VM.LastUpdated.HasValue) if (VM.LastUpdated.HasValue)
{ {
var diff = DateTime.Now - VM.LastUpdated.Value; var diff = DateTime.Now - VM.LastUpdated.Value;
LastUpdatedText.Text = diff.TotalSeconds < 10 ? "Updated just now" string age;
: $"Updated {(int)diff.TotalSeconds}s ago"; if (diff.TotalSeconds < 10)
age = "just now";
else if (diff.TotalMinutes < 1)
age = $"{(int)diff.TotalSeconds} sec ago";
else
age = $"{(int)diff.TotalMinutes} min, {diff.Seconds} sec ago";
LastUpdatedText.Text = $"Updated {age}";
} }
else else
{ {
@@ -424,11 +441,9 @@ public partial class PopupWindow : Window
BetaToggle.IsChecked = Updater.BetaChannel; BetaToggle.IsChecked = Updater.BetaChannel;
UpdateAboutSection(); UpdateAboutSection();
// Populate ComboBox with ComboBoxItem — DisplayMemberPath doesn't bind reliably
// on ValueTuple at runtime so we build items explicitly.
RefreshPicker.SelectionChanged -= RefreshPicker_Changed; RefreshPicker.SelectionChanged -= RefreshPicker_Changed;
RefreshPicker.Items.Clear(); RefreshPicker.Items.Clear();
int selectIdx = 1; // default: 2 min int selectIdx = 1;
for (int i = 0; i < RefreshIntervals.Length; i++) for (int i = 0; i < RefreshIntervals.Length; i++)
{ {
var (label, seconds) = RefreshIntervals[i]; var (label, seconds) = RefreshIntervals[i];
@@ -461,23 +476,192 @@ public partial class PopupWindow : Window
SignInButton.Content = VM.IsSignedIn ? "Re-authenticate" : "Sign In"; 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)
{
var diagText = BuildDiagText();
for (int i = 0; i < 5; i++)
{
try { Clipboard.SetText(diagText); break; }
catch (Exception) { System.Threading.Thread.Sleep(100); }
}
CopyDiagButton.Content = "Copied! (IDs redacted)";
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("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);
// 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($"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: {(string.IsNullOrEmpty(VM.DiagLastError) ? "(none)" : VM.DiagLastError)}");
if (VM.DiagLastFetch.HasValue)
sb.AppendLine($"Last fetch: {VM.DiagLastFetch.Value:HH:mm:ss}");
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("Stored cookies:");
foreach (var c in storedNames)
sb.AppendLine($" {c.Domain} {c.Name}");
return RedactUUIDs(sb.ToString());
}
private static string RedactUUIDs(string text) =>
Regex.Replace(text, @"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "****");
// ── Event handlers ─────────────────────────────────────────────── // ── Event handlers ───────────────────────────────────────────────
private void Settings_Click(object s, RoutedEventArgs e) private void Settings_Click(object s, RoutedEventArgs e)
{ {
InitSettings(); InitSettings();
MainPanel.Visibility = Visibility.Collapsed; MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible; SettingsPanel.Visibility = Visibility.Visible;
UpdatePanel.Visibility = Visibility.Collapsed; UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
} }
private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain(); private void BackFromSettings_Click(object s, RoutedEventArgs e) => ShowMain();
private void ShowMain() private void ShowMain()
{ {
MainPanel.Visibility = Visibility.Visible; MainPanel.Visibility = Visibility.Visible;
SettingsPanel.Visibility = Visibility.Collapsed; SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Collapsed; UpdatePanel.Visibility = Visibility.Collapsed;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
} }
private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync(); private async void Refresh_Click(object s, RoutedEventArgs e) => await VM.RefreshAsync();
@@ -489,9 +673,10 @@ public partial class PopupWindow : Window
CurrentVerLabel.Text = $"v{Updater.CurrentVersion}"; CurrentVerLabel.Text = $"v{Updater.CurrentVersion}";
NewVerLabel.Text = $"v{Updater.LatestVersion}"; NewVerLabel.Text = $"v{Updater.LatestVersion}";
ReleaseNotesText.Text = Updater.ReleaseNotes; ReleaseNotesText.Text = Updater.ReleaseNotes;
MainPanel.Visibility = Visibility.Collapsed; MainPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Collapsed; SettingsPanel.Visibility = Visibility.Collapsed;
UpdatePanel.Visibility = Visibility.Visible; UpdatePanel.Visibility = Visibility.Visible;
DiagnosticsPanel.Visibility = Visibility.Collapsed;
} }
private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain(); private void CloseUpdate_Click(object s, RoutedEventArgs e) => ShowMain();
+21 -6
View File
@@ -1,8 +1,23 @@
## What's new in beta.36 ## What's new in beta.49
### Architecture
- All data (usage, plan, overage, prepaid credits) now fetched via the persistent background WebView2 — the same always-live session as the macOS app, so Cloudflare cookies never expire between refreshes
- HttpClient path kept only as a brief startup fallback before the browser is ready
- Settings file simplified: only stores session signal, burn history, and user preferences — no more cached API responses
### Bug fixes ### Bug fixes
- Settings no longer incorrectly shows "Not signed in" when limits are working - Plan label (e.g. "Pro") now updates on every refresh, not just at login
- Session Diary now shows correctly after the first successful refresh - Extra Usage Credits values now correct (were 100× too large)
- Extra Usage Credits section now restored from cache on startup - Limit and balance now update live on every refresh
- Plan name, overage, and prepaid credits are now cached and shown immediately on launch - Refresh interval dropdown now shows "1 min", "2 min" etc. correctly
- App now loads cached state instantly on startup before the background refresh completes - ComboBox no longer flashes bright blue when clicked
- Buttons now show a visible pressed state
- Footer "Updated X ago" counter now ticks every second; shows "X min, Y sec ago" past 60 s
- Removed stray sparkline bars from the 5 h and 7 d limit cards
- App window now shown on launch
### UI improvements
- Gauge percentage larger, "used" label removed
- Each limit card now shows an "after reset" or "today HH:MM" badge
- Session Diary card redesigned to match macOS layout (stats row + sparkline, no Claude header)
- Reset date moved inline with time remaining
+8 -45
View File
@@ -2,7 +2,6 @@ using System;
using System.Diagnostics; using System.Diagnostics;
using System.Reflection; using System.Reflection;
using System.IO; using System.IO;
using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -136,9 +135,7 @@ public class UpdateManager : INotifyPropertyChanged
try try
{ {
var tmpDir = Path.Combine(Path.GetTempPath(), $"CCUpdate_{Guid.NewGuid():N}"); var installerPath = Path.Combine(Path.GetTempPath(), $"ClaudeCheckerInstaller_{Guid.NewGuid():N}.exe");
Directory.CreateDirectory(tmpDir);
var zipPath = Path.Combine(tmpDir, "ClaudeChecker.zip");
using var http = new HttpClient(); using var http = new HttpClient();
var response = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead); var response = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
@@ -148,7 +145,7 @@ public class UpdateManager : INotifyPropertyChanged
var received = 0L; var received = 0L;
await using (var stream = await response.Content.ReadAsStreamAsync()) await using (var stream = await response.Content.ReadAsStreamAsync())
await using (var file = File.Create(zipPath)) await using (var file = File.Create(installerPath))
{ {
var buffer = new byte[81920]; var buffer = new byte[81920];
int read; int read;
@@ -157,48 +154,21 @@ public class UpdateManager : INotifyPropertyChanged
await file.WriteAsync(buffer.AsMemory(0, read)); await file.WriteAsync(buffer.AsMemory(0, read));
received += read; received += read;
if (total > 0) if (total > 0)
DownloadProgress = (double)received / total * 0.8; DownloadProgress = (double)received / total;
} }
} }
StatusMessage = "Unpacking…";
DownloadProgress = 0.85;
var extractDir = Path.Combine(tmpDir, "extracted");
ZipFile.ExtractToDirectory(zipPath, extractDir, overwriteFiles: true);
// Find the installer exe
var newExe = FindExe(extractDir);
if (newExe == null) throw new Exception("ClaudeChecker.exe not found in update package.");
DownloadProgress = 0.95;
StatusMessage = "Installing…";
// Write a batch script to replace the exe after we exit
var currentExe = Process.GetCurrentProcess().MainModule!.FileName;
var script = $"""
@echo off
timeout /t 2 /nobreak > nul
copy /Y "{newExe}" "{currentExe}"
start "" "{currentExe}"
rmdir /S /Q "{tmpDir}"
""";
var scriptPath = Path.Combine(Path.GetTempPath(), "claudechecker_update.bat");
await File.WriteAllTextAsync(scriptPath, script);
DownloadProgress = 1.0; DownloadProgress = 1.0;
StatusMessage = "Installed! Relaunching…"; StatusMessage = "Installing…";
UpdateComplete = true; UpdateComplete = true;
await Task.Delay(800); await Task.Delay(500);
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = "cmd.exe", FileName = installerPath,
Arguments = $"/C \"{scriptPath}\"", Arguments = "/SILENT /CLOSEAPPLICATIONS",
CreateNoWindow = true, UseShellExecute = true,
UseShellExecute = false,
}); });
Application.Current.Dispatcher.Invoke(() => Application.Current.Shutdown()); Application.Current.Dispatcher.Invoke(() => Application.Current.Shutdown());
@@ -211,13 +181,6 @@ public class UpdateManager : INotifyPropertyChanged
} }
} }
private static string? FindExe(string dir)
{
foreach (var f in Directory.EnumerateFiles(dir, "ClaudeChecker.exe", SearchOption.AllDirectories))
return f;
return null;
}
public static bool IsNewer(string version, string current) public static bool IsNewer(string version, string current)
{ {
static (int[] Base, int[]? Pre) Parse(string v) static (int[] Base, int[]? Pre) Parse(string v)
+89 -28
View File
@@ -40,6 +40,17 @@ public class UsageViewModel : INotifyPropertyChanged
public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); } public PrepaidCredits? Prepaid { get => _prepaid; set => Set(ref _prepaid, value); }
public ExtraUsage? ExtraUsage { get => _extraUsage; set => Set(ref _extraUsage, 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; private int _refreshInterval = 120;
public int RefreshInterval public int RefreshInterval
{ {
@@ -77,6 +88,9 @@ public class UsageViewModel : INotifyPropertyChanged
{ {
await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true); await Application.Current.Dispatcher.InvokeAsync(() => IsLoading = true);
// Snapshot stored cookies once — used for both diag and HttpClient fallback path.
var storedCookies = await GetCookiesAsync();
try try
{ {
// CookieStore being non-empty is our signal that the user has signed in. // CookieStore being non-empty is our signal that the user has signed in.
@@ -115,13 +129,12 @@ public class UsageViewModel : INotifyPropertyChanged
} }
else else
{ {
var cookies = await GetCookiesAsync(); if (storedCookies.Count > 0)
if (cookies.Count > 0)
{ {
try try
{ {
(limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) = (limits, overage, prepaid, extraUsage, email, orgId, planLabel, _) =
await TryHttpRefreshAsync(cookies); await TryHttpRefreshAsync(storedCookies);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,6 +169,16 @@ public class UsageViewModel : INotifyPropertyChanged
ErrorMessage = refreshError; ErrorMessage = refreshError;
LastUpdated = DateTime.Now; LastUpdated = DateTime.Now;
IsLoading = false; 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) catch (Exception ex)
@@ -174,14 +197,19 @@ public class UsageViewModel : INotifyPropertyChanged
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)> private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, ExtraUsage?, string?, string?, string?)>
TryBrowserRefreshAsync() 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 string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}}; const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json(); const b=await(await fetch('/api/bootstrap',h)).json();
if(b?.error_type==='authentication_error'){window.chrome.webview.postMessage({authError:true});return;} if(b?.error_type==='authentication_error'){window.chrome.webview.postMessage({authError:true});return;}
const org0=b?.account?.memberships?.[0]?.organization; const lastActiveOrg=(document.cookie.split(';').map(c=>c.trim().split('=')).find(p=>p[0]==='lastActiveOrg')||[])[1]||null;
let id=org0?.uuid||b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid||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 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 capStr=caps.find(c=>typeof c==='string'&&c.startsWith('claude_'))||null;
const planLabel=capStr?(capStr.slice(7,8).toUpperCase()+capStr.slice(8).toLowerCase()):null; const planLabel=capStr?(capStr.slice(7,8).toUpperCase()+capStr.slice(8).toLowerCase()):null;
if(!id){ 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+'/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) 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)});}})()"; }catch(ex){window.chrome.webview.postMessage({error:String(ex)});}})()";
var json = await Application.Current.Dispatcher.InvokeAsync( 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? 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? 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? 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; UsageResponse? usage = null;
OverageSpendLimit? overage = null; OverageSpendLimit? overage = null;
@@ -236,15 +269,22 @@ public class UsageViewModel : INotifyPropertyChanged
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)
{ {
// 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); using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap"); 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) if ((int)bootstrapResp.StatusCode is 401 or 403)
throw new Exception("Session expired — please re-authenticate."); throw new Exception("Session expired — please re-authenticate.");
if (!bootstrapResp.IsSuccessStatusCode) if (!bootstrapResp.IsSuccessStatusCode)
throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode})."); throw new Exception($"Bootstrap failed ({(int)bootstrapResp.StatusCode}).");
var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync(); var bootstrapJson = await bootstrapResp.Content.ReadAsStringAsync();
var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson); var (email, orgId, planLabel) = ParseBootstrap(bootstrapJson, lastActiveOrg);
if (string.IsNullOrEmpty(orgId)) if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http); orgId = await FetchOrgIdFromListAsync(http);
@@ -258,6 +298,8 @@ public class UsageViewModel : INotifyPropertyChanged
await Task.WhenAll(ut, ot, pt); await Task.WhenAll(ut, ot, pt);
var usageResp = ut.Result; var usageResp = ut.Result;
DiagLastPath = $"/api/organizations/{orgId}/usage";
DiagLastStatus = (int)usageResp.StatusCode;
if (!usageResp.IsSuccessStatusCode) if (!usageResp.IsSuccessStatusCode)
throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode})."); throw new Exception($"Usage fetch failed ({(int)usageResp.StatusCode}).");
@@ -361,7 +403,11 @@ public class UsageViewModel : INotifyPropertyChanged
catch { return null; } 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 try
{ {
@@ -369,42 +415,57 @@ public class UsageViewModel : INotifyPropertyChanged
var root = doc.RootElement; var root = doc.RootElement;
string? email = null; string? email = null;
string? orgId = null;
if (root.TryGetProperty("account", out var acct) && if (root.TryGetProperty("account", out var acct) &&
acct.TryGetProperty("email_address", out var em)) acct.TryGetProperty("email_address", out var em))
email = em.GetString(); email = em.GetString();
// Collect all org objects from memberships
var allOrgs = new List<JsonElement>();
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))
{ {
var first = mems[0]; foreach (var mem in mems.EnumerateArray())
if (first.TryGetProperty("organization", out var org) && if (mem.TryGetProperty("organization", out var org))
org.TryGetProperty("uuid", out var uuid)) allOrgs.Add(org);
orgId = uuid.GetString(); }
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) && // Prefer the org matching preferredOrgId, fall back to first
root.TryGetProperty("memberships", out var rootMems) && rootMems.GetArrayLength() > 0) JsonElement activeOrg = default;
if (!string.IsNullOrEmpty(preferredOrgId))
{ {
var first = rootMems[0]; foreach (var org in allOrgs)
if (first.TryGetProperty("organization", out var org) && {
org.TryGetProperty("uuid", out var uuid)) if (org.TryGetProperty("uuid", out var u) && u.GetString() == preferredOrgId)
orgId = uuid.GetString(); {
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) && if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0) root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
{ {
if (orgs[0].TryGetProperty("uuid", out var uuid)) if (orgs[0].TryGetProperty("uuid", out var u))
orgId = uuid.GetString(); orgId = u.GetString();
} }
string? planLabel = null; string? planLabel = null;
if (root.TryGetProperty("account", out var acctPlan) && if (activeOrg.ValueKind != JsonValueKind.Undefined &&
acctPlan.TryGetProperty("memberships", out var plMems) && plMems.GetArrayLength() > 0 && activeOrg.TryGetProperty("capabilities", out var caps) &&
plMems[0].TryGetProperty("organization", out var plOrg) &&
plOrg.TryGetProperty("capabilities", out var caps) &&
caps.ValueKind == JsonValueKind.Array) caps.ValueKind == JsonValueKind.Array)
{ {
foreach (var cap in caps.EnumerateArray()) foreach (var cap in caps.EnumerateArray())
+42
View File
@@ -0,0 +1,42 @@
#ifndef AppVersion
#define AppVersion "0.0.1"
#endif
[Setup]
AppName=ClaudeChecker
AppVersion={#AppVersion}
AppPublisher=superdooper86
AppPublisherURL=https://github.com/superdooper86/claudechecker
AppSupportURL=https://github.com/superdooper86/claudechecker/issues
AppUpdatesURL=https://github.com/superdooper86/claudechecker/releases
DefaultDirName={localappdata}\ClaudeChecker
DefaultGroupName=ClaudeChecker
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=.
OutputBaseFilename=ClaudeChecker-Installer
Compression=lzma
SolidCompression=yes
WizardStyle=modern
UninstallDisplayIcon={app}\ClaudeChecker.exe
SourceDir=..
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "publish\ClaudeChecker.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "publish\Assets\*"; DestDir: "{app}\Assets"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"
Name: "{userdesktop}\ClaudeChecker"; Filename: "{app}\ClaudeChecker.exe"; Tasks: desktopicon
[Run]
; Silent install (auto-update): launch automatically
Filename: "{app}\ClaudeChecker.exe"; Flags: nowait; Check: WizardSilent
; Manual install: show launch checkbox on final page
Filename: "{app}\ClaudeChecker.exe"; Description: "{cm:LaunchProgram,ClaudeChecker}"; Flags: nowait postinstall skipifsilent