diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml deleted file mode 100644 index 09f4c4b..0000000 --- a/.github/workflows/release-beta.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Release Beta - -on: - push: - tags: - - 'v*-beta*' - -jobs: - release-beta: - runs-on: macos-latest - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get version from tag - id: version - run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - - - name: Build - run: | - xcodebuild \ - -project ClaudeChecker.xcodeproj \ - -scheme ClaudeChecker \ - -configuration Release \ - -derivedDataPath build \ - clean build - - - name: Zip app - run: | - ditto -c -k --keepParent \ - build/Build/Products/Release/ClaudeChecker.app \ - ClaudeChecker.zip - - - name: Create GitHub pre-release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \ - --title "$GITHUB_REF_NAME" \ - --prerelease \ - --notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Beta release ${{ steps.version.outputs.version }}')" - - - name: Update version-beta.json - run: | - notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Beta release ${{ steps.version.outputs.version }}") - jq -n \ - --arg version "${{ steps.version.outputs.version }}" \ - --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \ - --arg notes "$notes" \ - '{version: $version, url: $url, notes: $notes}' > version-beta.json - - - name: Update beta badge in README - run: | - shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g') - tag="${{ github.ref_name }}" - export BADGE="[![Beta](https://img.shields.io/badge/beta-${shields_version}-orange?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) " - python3 -c " - import os - badge = os.environ['BADGE'] - lines = open('README.md').readlines() - out = [] - for line in lines: - out.append(badge + chr(10) if '' in line else line) - open('README.md', 'w').writelines(out) - " - - - name: Commit version-beta.json and README - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add version-beta.json README.md - git commit -m "Beta release ${{ github.ref_name }}" - git pull --rebase origin main - git push origin HEAD:main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 33a3d8d..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - release: - if: ${{ !contains(github.ref_name, '-') }} - runs-on: macos-latest - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get version from tag - id: version - run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - - - name: Build - run: | - xcodebuild \ - -project ClaudeChecker.xcodeproj \ - -scheme ClaudeChecker \ - -configuration Release \ - -derivedDataPath build \ - clean build - - - name: Zip app - run: | - ditto -c -k --keepParent \ - build/Build/Products/Release/ClaudeChecker.app \ - ClaudeChecker.zip - - - name: Compute SHA256 - id: sha - run: echo "sha256=$(shasum -a 256 ClaudeChecker.zip | awk '{print $1}')" >> $GITHUB_OUTPUT - - - name: Create GitHub release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \ - --title "$GITHUB_REF_NAME" \ - --notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')" - - - name: Update version.json - run: | - notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Release ${{ steps.version.outputs.version }}") - jq -n \ - --arg version "${{ steps.version.outputs.version }}" \ - --arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \ - --arg notes "$notes" \ - '{version: $version, url: $url, notes: $notes}' > version.json - - - name: Update Homebrew cask - run: | - sed -i '' \ - -e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \ - -e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \ - ../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true - - - name: Update README badge - env: - NEW_VERSION: ${{ steps.version.outputs.version }} - run: | - python3 -c " - import os, re - v = os.environ['NEW_VERSION'] - content = open('README.md').read() - content = re.sub(r'version-[0-9][^-]*-orange', 'version-' + v + '-orange', content) - lines = content.splitlines(keepends=True) - out = [] - for line in lines: - if '' in line: - continue - out.append(line) - if 'license-MIT-blue' in line: - out.append('' + chr(10)) - open('README.md', 'w').writelines(out) - " - - - name: Commit version.json and README - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add version.json README.md - git commit -m "Release ${{ github.ref_name }}" - git pull --rebase origin main - git push origin HEAD:main - - - name: Update Homebrew tap - env: - TAP_TOKEN: ${{ secrets.TAP_TOKEN }} - run: | - git clone https://x-access-token:${{ secrets.TAP_TOKEN }}@github.com/${{ github.repository_owner }}/homebrew-tap /tmp/homebrew-tap - sed -i '' \ - -e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \ - -e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \ - /tmp/homebrew-tap/Casks/claudechecker.rb - cd /tmp/homebrew-tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Casks/claudechecker.rb - git commit -m "Update claudechecker to ${{ github.ref_name }}" - git push diff --git a/version-windows-beta.json b/version-windows-beta.json new file mode 100644 index 0000000..53abb1d --- /dev/null +++ b/version-windows-beta.json @@ -0,0 +1,5 @@ +{ + "version": "0.0.0", + "url": "", + "notes": "" +} \ No newline at end of file diff --git a/version-windows.json b/version-windows.json new file mode 100644 index 0000000..53abb1d --- /dev/null +++ b/version-windows.json @@ -0,0 +1,5 @@ +{ + "version": "0.0.0", + "url": "", + "notes": "" +} \ No newline at end of file diff --git a/windows/App.xaml b/windows/App.xaml new file mode 100644 index 0000000..8d9107c --- /dev/null +++ b/windows/App.xaml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/App.xaml.cs b/windows/App.xaml.cs new file mode 100644 index 0000000..2fc1f22 --- /dev/null +++ b/windows/App.xaml.cs @@ -0,0 +1,151 @@ +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using Forms = System.Windows.Forms; + +namespace ClaudeCheckerWindows; + +public partial class App : Application +{ + 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; + private DispatcherTimer? _timer; + + protected override void OnStartup(StartupEventArgs e) + { + var logPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ClaudeChecker", "startup_crash.txt"); + + AppDomain.CurrentDomain.UnhandledException += (_, ex) => + System.IO.File.WriteAllText(logPath, ex.ExceptionObject?.ToString() ?? "unknown"); + + DispatcherUnhandledException += (_, ex) => + { + System.IO.File.WriteAllText(logPath, ex.Exception?.ToString() ?? "unknown"); + ex.Handled = false; + }; + + 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(); + // 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(); + }); + } + + private void SetupTray() + { + _tray = new Forms.NotifyIcon + { + Text = "ClaudeChecker", + Visible = true, + Icon = LoadIcon(), + }; + + _tray.MouseClick += (_, e) => + { + if (e.Button == Forms.MouseButtons.Left) + Dispatcher.InvokeAsync(TogglePopup); + }; + + _tray.ContextMenuStrip = BuildContextMenu(); + + ViewModel.PropertyChanged += (_, _) => UpdateTrayText(); + Updater.PropertyChanged += (_, _) => UpdateTrayText(); + } + + private static System.Drawing.Icon LoadIcon() + { + try { return new System.Drawing.Icon("Assets/icon.ico"); } + catch { return System.Drawing.SystemIcons.Application; } + } + + private Forms.ContextMenuStrip BuildContextMenu() + { + var menu = new Forms.ContextMenuStrip(); + menu.Items.Add("Show ClaudeChecker", null, (_, _) => Dispatcher.InvokeAsync(ShowPopup)); + menu.Items.Add(new Forms.ToolStripSeparator()); + menu.Items.Add("Quit", null, (_, _) => Dispatcher.InvokeAsync(Quit)); + return menu; + } + + private void TogglePopup() + { + if (_popup == null || !_popup.IsVisible) + ShowPopup(); + else + _popup.Hide(); + } + + private void ShowPopup() + { + if (_popup == null) + { + _popup = new PopupWindow(); + } + _popup.Show(); + _popup.Activate(); + } + + public void ScheduleTimer(int seconds) + { + _timer?.Stop(); + _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) }; + _timer.Tick += async (_, _) => + { + await ViewModel.RefreshAsync(); + await Updater.CheckForUpdatesAsync(); + }; + _timer.Start(); + } + + private void UpdateTrayText() + { + if (_tray == null) return; + var limits = ViewModel.Limits; + if (ViewModel.ShowInTaskbar && limits.Count >= 2) + { + var fh = limits.Find(l => l.Window == WindowKind.FiveHour); + var sd = limits.Find(l => l.Window == WindowKind.SevenDay); + if (fh != null && sd != null && fh.IsLive) + { + _tray.Text = $"ClaudeChecker {(int)fh.UsedPercent}% {(int)sd.UsedPercent}%"; + return; + } + } + _tray.Text = "ClaudeChecker"; + } + + private void Quit() + { + _tray?.Dispose(); + Shutdown(); + } + + protected override void OnExit(ExitEventArgs e) + { + _tray?.Dispose(); + base.OnExit(e); + } +} diff --git a/windows/AppSettings.cs b/windows/AppSettings.cs new file mode 100644 index 0000000..bbe4d88 --- /dev/null +++ b/windows/AppSettings.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace ClaudeCheckerWindows; + +public class AppSettings +{ + // Auth signal — non-empty means the user has completed sign-in at least once. + // The WebView2 user-data folder holds the live session; this is only for startup. + public string CookieStore { get; set; } = ""; + + // Accumulated sparkline data — built up over multiple refreshes. + public string BurnHistory { get; set; } = ""; + + // User preferences + public int RefreshInterval { get; set; } = 120; + public bool ShowInTaskbar { get; set; } = true; + public bool BetaChannel { get; set; } = false; + + private static readonly string FilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ClaudeChecker", "settings.json"); + + private static AppSettings? _instance; + public static AppSettings Default => _instance ??= Load(); + + private static AppSettings Load() + { + try + { + if (File.Exists(FilePath)) + return JsonSerializer.Deserialize(File.ReadAllText(FilePath)) ?? new(); + } + catch { } + return new(); + } + + public void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, + new JsonSerializerOptions { WriteIndented = true })); + } + catch { } + } +} diff --git a/windows/Assets/icon.ico b/windows/Assets/icon.ico new file mode 100644 index 0000000..dab4b7c Binary files /dev/null and b/windows/Assets/icon.ico differ diff --git a/windows/Assets/icon.png b/windows/Assets/icon.png new file mode 100644 index 0000000..d950b88 Binary files /dev/null and b/windows/Assets/icon.png differ diff --git a/windows/ClaudeCheckerWindows.csproj b/windows/ClaudeCheckerWindows.csproj new file mode 100644 index 0000000..f7b4a47 --- /dev/null +++ b/windows/ClaudeCheckerWindows.csproj @@ -0,0 +1,32 @@ + + + + WinExe + net8.0-windows + true + enable + enable + ClaudeChecker + ClaudeCheckerWindows + Assets\icon.ico + 1.0.0 + x64 + + + + + + + + + + + + + + PreserveNewest + + + + + diff --git a/windows/Controls/GaugeControl.cs b/windows/Controls/GaugeControl.cs new file mode 100644 index 0000000..0b6a7af --- /dev/null +++ b/windows/Controls/GaugeControl.cs @@ -0,0 +1,84 @@ +using Brush = System.Windows.Media.Brush; +using Pen = System.Windows.Media.Pen; +using Point = System.Windows.Point; +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Media; + +namespace ClaudeCheckerWindows.Controls; + +public class GaugeControl : FrameworkElement +{ + public static readonly DependencyProperty PercentProperty = + DependencyProperty.Register(nameof(Percent), typeof(double), typeof(GaugeControl), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty AccentProperty = + DependencyProperty.Register(nameof(Accent), typeof(Brush), typeof(GaugeControl), + new FrameworkPropertyMetadata(Brushes.Orange, FrameworkPropertyMetadataOptions.AffectsRender)); + + public double Percent { get => (double)GetValue(PercentProperty); set => SetValue(PercentProperty, value); } + public Brush Accent { get => (Brush)GetValue(AccentProperty); set => SetValue(AccentProperty, value); } + + public GaugeControl() + { + ThemeManager.ThemeChanged += InvalidateVisual; + } + + private const double StartAngleDeg = 135; + private const double SweepDeg = 270; + private const double StrokeWidth = 6; + + protected override void OnRender(DrawingContext dc) + { + var cx = RenderSize.Width / 2; + var cy = RenderSize.Height / 2; + var radius = Math.Min(cx, cy) - StrokeWidth / 2 - 1; + + var trackColor = ThemeManager.IsDark + ? Color.FromArgb(40, 255, 255, 255) + : Color.FromArgb(30, 0, 0, 0); + DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg, + new Pen(new SolidColorBrush(trackColor), StrokeWidth)); + + if (Percent > 0) + DrawArc(dc, cx, cy, radius, StartAngleDeg, SweepDeg * (Percent / 100.0), + new Pen(Accent, StrokeWidth)); + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var textColor = ThemeManager.IsDark ? Color.FromRgb(0xF5, 0xF5, 0xF5) : Color.FromRgb(0x1C, 0x1C, 0x1C); + + var pctText = new FormattedText( + $"{(int)Math.Round(Percent)}%", + CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal), + 18, new SolidColorBrush(textColor), dpi); + + dc.DrawText(pctText, new Point(cx - pctText.Width / 2, cy - pctText.Height / 2)); + } + + private static void DrawArc(DrawingContext dc, double cx, double cy, double r, + double startDeg, double sweepDeg, Pen pen) + { + if (sweepDeg <= 0) return; + var start = PointOnCircle(cx, cy, r, startDeg); + var end = PointOnCircle(cx, cy, r, startDeg + sweepDeg); + var isLarge = sweepDeg > 180; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(start, false, false); + ctx.ArcTo(end, new Size(r, r), 0, isLarge, SweepDirection.Clockwise, true, false); + } + geo.Freeze(); + dc.DrawGeometry(null, pen, geo); + } + + private static Point PointOnCircle(double cx, double cy, double r, double deg) + { + var rad = deg * Math.PI / 180; + return new Point(cx + r * Math.Cos(rad), cy + r * Math.Sin(rad)); + } +} diff --git a/windows/Controls/SparklineControl.cs b/windows/Controls/SparklineControl.cs new file mode 100644 index 0000000..f9964d0 --- /dev/null +++ b/windows/Controls/SparklineControl.cs @@ -0,0 +1,60 @@ +using Point = System.Windows.Point; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; + +namespace ClaudeCheckerWindows.Controls; + +public class SparklineControl : FrameworkElement +{ + public static readonly DependencyProperty DataProperty = + DependencyProperty.Register(nameof(Data), typeof(IList), typeof(SparklineControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty LineColorProperty = + DependencyProperty.Register(nameof(LineColor), typeof(Color), typeof(SparklineControl), + new FrameworkPropertyMetadata(Colors.Orange, FrameworkPropertyMetadataOptions.AffectsRender)); + + public IList? Data { get => (IList?)GetValue(DataProperty); set => SetValue(DataProperty, value); } + public Color LineColor { get => (Color)GetValue(LineColorProperty); set => SetValue(LineColorProperty, value); } + + protected override void OnRender(DrawingContext dc) + { + var data = Data; + if (data == null || data.Count < 1) return; + + var plot = data.Count == 1 ? new[] { data[0], data[0] } : [.. data]; + + var w = RenderSize.Width; + var h = RenderSize.Height; + var minV = double.MaxValue; + var maxV = double.MinValue; + foreach (var v in plot) { if (v < minV) minV = v; if (v > maxV) maxV = v; } + var range = System.Math.Max(maxV - minV, 1); + + Point Pt(int i) => new( + i / (double)(plot.Length - 1) * w, + h - (plot[i] - minV) / range * (h - 6) - 3); + + var fill = new StreamGeometry(); + using (var ctx = fill.Open()) + { + ctx.BeginFigure(new Point(0, h), true, true); + for (var i = 0; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false); + ctx.LineTo(new Point(w, h), true, false); + } + fill.Freeze(); + dc.DrawGeometry(new SolidColorBrush(Color.FromArgb(26, LineColor.R, LineColor.G, LineColor.B)), null, fill); + + var line = new StreamGeometry(); + using (var ctx = line.Open()) + { + ctx.BeginFigure(Pt(0), false, false); + for (var i = 1; i < plot.Length; i++) ctx.LineTo(Pt(i), true, false); + } + line.Freeze(); + dc.DrawGeometry(null, + new System.Windows.Media.Pen(new SolidColorBrush(LineColor), 1.5) { LineJoin = PenLineJoin.Round }, + line); + } +} diff --git a/windows/GlobalUsings.cs b/windows/GlobalUsings.cs new file mode 100644 index 0000000..e181f0c --- /dev/null +++ b/windows/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Global aliases to resolve ambiguities between System.Windows.* and System.Drawing.* +global using Application = System.Windows.Application; +global using Brushes = System.Windows.Media.Brushes; +global using Color = System.Windows.Media.Color; +global using FlowDirection = System.Windows.FlowDirection; +global using FontFamily = System.Windows.Media.FontFamily; +global using Orientation = System.Windows.Controls.Orientation; +global using ProgressBar = System.Windows.Controls.ProgressBar; +global using Size = System.Windows.Size; diff --git a/windows/LoginWindow.xaml b/windows/LoginWindow.xaml new file mode 100644 index 0000000..c0cb4f5 --- /dev/null +++ b/windows/LoginWindow.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +