using System; using System.Drawing; using System.IO; using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; using System.Windows.Forms; using System.Text.RegularExpressions; using System.Diagnostics; using System.Collections.Generic; using System.Text; namespace qdr.app.tools.claudecodebalancewidget { public partial class MainForm : Form { private NotifyIcon notifyIcon; private System.Windows.Forms.Timer refreshTimer; private HttpClient httpClient; private string sessionToken = ""; private string apiKey = ""; private decimal currentBalance = 0; private bool isVisible = true; private bool isDragging = false; private Point dragStartPoint; private DateTime lastUpdateTime = DateTime.MinValue; private bool isRefreshing = false; private string lastError = ""; private AuthenticationMethod authMethod = AuthenticationMethod.SessionCookie; public enum AuthenticationMethod { SessionCookie, ApiKey, ConsoleOnly // New method for API key users } public MainForm() { InitializeComponent(); InitializeSystemTray(); InitializeHttpClient(); InitializeTimer(); SetupTransparentWindow(); LoadConfiguration(); _ = RefreshBalance(); } private void SetupTransparentWindow() { this.BackColor = Color.Lime; this.TransparencyKey = Color.Lime; this.Opacity = 0.95; this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.SupportsTransparentBackColor, true); this.Size = new Size(300, 100); } private void InitializeSystemTray() { notifyIcon = new NotifyIcon(); // Use the application icon instead of creating a custom one notifyIcon.Icon = this.Icon ?? Icon.ExtractAssociatedIcon(Application.ExecutablePath); notifyIcon.Text = "Claude Balance Monitor"; notifyIcon.Visible = true; var contextMenu = new ContextMenuStrip(); var showHideItem = new ToolStripMenuItem("Show/Hide", null, ShowHide_Click); var refreshItem = new ToolStripMenuItem("Refresh Now", null, RefreshNow_Click); var openConsoleItem = new ToolStripMenuItem("Open Console Billing", null, OpenConsole_Click); var settingsItem = new ToolStripMenuItem("Settings...", null, Settings_Click); var aboutItem = new ToolStripMenuItem("About", null, About_Click); var exitItem = new ToolStripMenuItem("Exit", null, Exit_Click); contextMenu.Items.Add(showHideItem); contextMenu.Items.Add(refreshItem); contextMenu.Items.Add(openConsoleItem); contextMenu.Items.Add(new ToolStripSeparator()); contextMenu.Items.Add(settingsItem); contextMenu.Items.Add(aboutItem); contextMenu.Items.Add(new ToolStripSeparator()); contextMenu.Items.Add(exitItem); notifyIcon.ContextMenuStrip = contextMenu; notifyIcon.DoubleClick += ShowHide_Click; } // Removed CreateIcon method since we're using the application icon now private void InitializeHttpClient() { httpClient = new HttpClient(); httpClient.Timeout = TimeSpan.FromSeconds(15); httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"); httpClient.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.5"); httpClient.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br"); httpClient.DefaultRequestHeaders.Add("DNT", "1"); httpClient.DefaultRequestHeaders.Add("Connection", "keep-alive"); httpClient.DefaultRequestHeaders.Add("Upgrade-Insecure-Requests", "1"); } private void InitializeTimer() { refreshTimer = new System.Windows.Forms.Timer(); refreshTimer.Interval = 300000; // 5 minutes - slower since we're using Console scraping refreshTimer.Tick += async (s, e) => await RefreshBalance(); refreshTimer.Start(); } private void LoadConfiguration() { try { string configPath = GetConfigPath(); string configDir = Path.GetDirectoryName(configPath); if (!Directory.Exists(configDir)) { Directory.CreateDirectory(configDir); } if (File.Exists(configPath)) { var lines = File.ReadAllLines(configPath); foreach (var line in lines) { if (line.StartsWith("SessionToken=")) { sessionToken = line.Substring("SessionToken=".Length).Trim(); } else if (line.StartsWith("ApiKey=")) { apiKey = line.Substring("ApiKey=".Length).Trim(); } else if (line.StartsWith("AuthMethod=")) { if (Enum.TryParse(line.Substring("AuthMethod=".Length).Trim(), out var method)) { authMethod = method; } } } } else { var settingsForm = new SettingsForm(""); if (settingsForm.ShowDialog() == DialogResult.OK) { sessionToken = settingsForm.SessionToken; SaveConfiguration(); } } } catch (Exception ex) { lastError = $"Config error: {ex.Message}"; MessageBox.Show($"Error loading configuration: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private string GetConfigPath() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeBalanceMonitor", "config.txt"); } private void SaveConfiguration() { try { string configPath = GetConfigPath(); Directory.CreateDirectory(Path.GetDirectoryName(configPath)); var lines = new List { $"SessionToken={sessionToken}", $"ApiKey={apiKey}", $"AuthMethod={authMethod}" }; File.WriteAllLines(configPath, lines); } catch (Exception ex) { MessageBox.Show($"Error saving configuration: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private async Task RefreshBalance() { if (isRefreshing) return; try { isRefreshing = true; lastError = ""; decimal balance = -1; switch (authMethod) { case AuthenticationMethod.ApiKey: case AuthenticationMethod.ConsoleOnly: balance = await GetBalanceViaConsoleWithApiKey(); break; case AuthenticationMethod.SessionCookie: balance = await GetBalanceViaSessionCookie(); break; } UpdateBalance(balance, balance >= 0 ? "" : lastError); if (balance >= 0) { lastUpdateTime = DateTime.Now; } } catch (Exception ex) { UpdateBalance(-1, ex.Message); Console.WriteLine($"Error refreshing balance: {ex.Message}"); } finally { isRefreshing = false; } } private async Task GetBalanceViaConsoleWithApiKey() { try { if (string.IsNullOrEmpty(apiKey)) { lastError = "No API key configured"; return -1; } // Since there's no direct API endpoint for balance, we need to: // 1. First verify the API key works // 2. Then try to access the Console billing page // Step 1: Verify API key with a minimal request var isValidApiKey = await VerifyApiKey(); if (!isValidApiKey) { return -1; } // Step 2: Try to access Console billing page // This is tricky because we need to be logged in to Console // For now, we'll show a message that the user needs to check manually lastError = "API key valid - Check Console manually for balance"; return -2; // Special code to indicate "check manually" } catch (Exception ex) { lastError = $"API Error: {ex.Message}"; return -1; } } private async Task VerifyApiKey() { try { // Make a small test request to verify the API key works var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); request.Headers.Add("x-api-key", apiKey); request.Headers.Add("anthropic-version", "2023-06-01"); var testContent = @"{ ""model"": ""claude-3-5-haiku-20241022"", ""max_tokens"": 1, ""messages"": [{ ""role"": ""user"", ""content"": ""Hi"" }] }"; request.Content = new StringContent(testContent, Encoding.UTF8, "application/json"); var response = await httpClient.SendAsync(request); if (response.IsSuccessStatusCode) { return true; } else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { lastError = "Invalid API key"; return false; } else if (response.StatusCode == System.Net.HttpStatusCode.PaymentRequired) { lastError = "No credits available"; return false; } else { lastError = $"API verification failed: {response.StatusCode}"; return false; } } catch (Exception ex) { lastError = $"API verification error: {ex.Message}"; return false; } } private async Task GetBalanceViaSessionCookie() { try { if (string.IsNullOrEmpty(sessionToken)) { lastError = "No session token configured"; return -1; } // Clear existing headers httpClient.DefaultRequestHeaders.Remove("Cookie"); httpClient.DefaultRequestHeaders.Remove("X-Requested-With"); httpClient.DefaultRequestHeaders.Remove("Referer"); // Add session authentication with enhanced headers httpClient.DefaultRequestHeaders.Add("Cookie", $"sessionKey={sessionToken}"); httpClient.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); httpClient.DefaultRequestHeaders.Add("Referer", "https://console.anthropic.com/"); httpClient.DefaultRequestHeaders.Add("Sec-Fetch-Dest", "document"); httpClient.DefaultRequestHeaders.Add("Sec-Fetch-Mode", "navigate"); httpClient.DefaultRequestHeaders.Add("Sec-Fetch-Site", "same-origin"); var response = await httpClient.GetAsync("https://console.anthropic.com/settings/billing"); if (response.IsSuccessStatusCode) { var html = await response.Content.ReadAsStringAsync(); return ExtractBalanceFromHtml(html); } else if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) { lastError = "Forbidden - Session token invalid or requires additional auth"; return -1; } else { lastError = $"HTTP {response.StatusCode}"; return -1; } } catch (Exception ex) { lastError = $"Session Error: {ex.Message}"; return -1; } } private decimal ExtractBalanceFromHtml(string html) { // Enhanced patterns for finding credit balance var patterns = new[] { @"Credit balance[\s\S]*?\$?([\d,]+\.?\d*)", @"Available credit[\s\S]*?\$?([\d,]+\.?\d*)", @"remaining[^>]*>[\s]*\$?([\d,]+\.?\d*)", @"balance[^>]*>[\s]*\$?([\d,]+\.?\d*)", @"credit[^>]*>[\s]*\$?([\d,]+\.?\d*)", @"\$?([\d,]+\.?\d*)[^<]*remaining", @"""credit_balance"":\s*""?\$?([\d,]+\.?\d*)""?", @"""balance"":\s*""?\$?([\d,]+\.?\d*)""?", @"data-balance=""?\$?([\d,]+\.?\d*)""?", @"balance.*?(\d+\.?\d*)", @"Credits?[\s\S]*?\$?([\d,]+\.?\d*)", @"\$?([\d,]+\.?\d*)[\s]*credit" }; foreach (var pattern in patterns) { var matches = Regex.Matches(html, pattern, RegexOptions.IgnoreCase); foreach (Match match in matches) { var balanceStr = match.Groups[1].Value.Replace(",", ""); if (decimal.TryParse(balanceStr, out decimal balance) && balance >= 0) { return balance; } } } lastError = "Could not find balance in Console page"; return -1; } private void UpdateBalance(decimal balance, string error = "") { if (InvokeRequired) { Invoke(new Action(UpdateBalance), balance, error); return; } currentBalance = balance; lastError = error; if (balance >= 0) { notifyIcon.Text = $"Claude Balance: ${balance:F2}\nLast updated: {DateTime.Now:HH:mm:ss}\nMethod: {authMethod}"; } else if (balance == -2) // Special case for "check manually" { notifyIcon.Text = $"Claude API Key Valid\nCheck Console for balance\nRight-click → Open Console"; } else { notifyIcon.Text = $"Claude Balance: Error\n{(string.IsNullOrEmpty(error) ? "Unknown error" : error)}\nMethod: {authMethod}"; } if (isVisible) { Invalidate(); } } private void MainForm_Paint(object sender, PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; // Draw background with rounded corners var rect = new Rectangle(5, 5, Width - 10, Height - 10); using (var path = CreateRoundedRectangle(rect, 12)) { using (var brush = new System.Drawing.Drawing2D.LinearGradientBrush(rect, Color.FromArgb(200, 20, 25, 35), Color.FromArgb(180, 40, 45, 60), System.Drawing.Drawing2D.LinearGradientMode.Vertical)) { g.FillPath(brush, path); } using (var pen = new Pen(Color.FromArgb(100, 255, 255, 255), 1)) { g.DrawPath(pen, path); } } // Draw balance text string balanceText; Color textColor; if (currentBalance >= 0) { balanceText = $"${currentBalance:F2}"; textColor = currentBalance > 5 ? Color.FromArgb(255, 144, 238, 144) : currentBalance > 1 ? Color.FromArgb(255, 255, 215, 0) : Color.FromArgb(255, 255, 140, 140); } else if (currentBalance == -2) // Special case for "check manually" { balanceText = "Check Console"; textColor = Color.FromArgb(255, 100, 149, 237); } else { balanceText = "Error"; textColor = Color.FromArgb(255, 255, 100, 100); } // Main balance text using (var font = new Font("Segoe UI", currentBalance == -2 ? 10 : 13, FontStyle.Bold)) using (var brush = new SolidBrush(textColor)) { var textSize = g.MeasureString(balanceText, font); var x = (Width - textSize.Width) / 2; var y = currentBalance == -2 ? 18 : 12; g.DrawString(balanceText, font, brush, x, y); } // Status text string statusText; if (currentBalance == -2) { statusText = "API Key Valid - Click to open Console"; } else if (!string.IsNullOrEmpty(lastError)) { statusText = lastError.Length > 30 ? lastError.Substring(0, 30) + "..." : lastError; } else if (lastUpdateTime != DateTime.MinValue) { statusText = $"Updated: {lastUpdateTime:HH:mm:ss} ({authMethod})"; } else { statusText = "Loading..."; } using (var font = new Font("Segoe UI", 7.5f)) using (var brush = new SolidBrush(Color.FromArgb(180, 200, 200, 200))) { var textSize = g.MeasureString(statusText, font); var x = (Width - textSize.Width) / 2; var y = Height - 22; g.DrawString(statusText, font, brush, x, y); } } private System.Drawing.Drawing2D.GraphicsPath CreateRoundedRectangle(Rectangle rect, int radius) { var path = new System.Drawing.Drawing2D.GraphicsPath(); var diameter = radius * 2; path.AddArc(rect.X, rect.Y, diameter, diameter, 180, 90); path.AddArc(rect.Right - diameter, rect.Y, diameter, diameter, 270, 90); path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter, diameter, 0, 90); path.AddArc(rect.X, rect.Bottom - diameter, diameter, diameter, 90, 90); path.CloseFigure(); return path; } private async void RefreshNow_Click(object sender, EventArgs e) { await RefreshBalance(); } private void OpenConsole_Click(object sender, EventArgs e) { try { Process.Start(new ProcessStartInfo { FileName = "https://console.anthropic.com/settings/billing", UseShellExecute = true }); } catch (Exception ex) { MessageBox.Show($"Could not open browser: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void About_Click(object sender, EventArgs e) { MessageBox.Show( $"Claude Code Balance Widget v{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}\n\n" + "⚠️ IMPORTANT: Anthropic doesn't provide a balance API endpoint.\n" + "This widget can verify your API key but cannot automatically\n" + "retrieve your balance. You'll need to check the Console manually.\n\n" + "Created by Dalibor Votruba (Quadarax)\n\n" + "Supported Methods:\n" + "• API Key Verification + Manual Console Check\n" + "• Session Cookie (may not work due to security)", "About Claude Balance Monitor", MessageBoxButtons.OK, MessageBoxIcon.Information ); } private void ShowHide_Click(object sender, EventArgs e) { if (isVisible) { Hide(); isVisible = false; } else { Show(); WindowState = FormWindowState.Normal; isVisible = true; } } private void Settings_Click(object sender, EventArgs e) { var settingsForm = new SettingsForm(sessionToken); if (settingsForm.ShowDialog() == DialogResult.OK) { sessionToken = settingsForm.SessionToken; SaveConfiguration(); _ = RefreshBalance(); } } private void Exit_Click(object sender, EventArgs e) { refreshTimer?.Stop(); notifyIcon?.Dispose(); httpClient?.Dispose(); Application.Exit(); } // Mouse event handlers for dragging and clicking private void MainForm_MouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { isDragging = true; dragStartPoint = e.Location; this.Cursor = Cursors.SizeAll; } else if (e.Button == MouseButtons.Right) { notifyIcon.ContextMenuStrip.Show(Cursor.Position); } } private void MainForm_MouseMove(object sender, MouseEventArgs e) { if (isDragging) { Point screenPoint = PointToScreen(e.Location); Location = new Point(screenPoint.X - dragStartPoint.X, screenPoint.Y - dragStartPoint.Y); } else { this.Cursor = currentBalance == -2 ? Cursors.Hand : Cursors.SizeAll; } } private void MainForm_MouseUp(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { if (isDragging) { // Check if this was actually a drag or just a click var dragDistance = Math.Sqrt(Math.Pow(e.X - dragStartPoint.X, 2) + Math.Pow(e.Y - dragStartPoint.Y, 2)); if (dragDistance < 5) // Less than 5 pixels = click, not drag { // This was a click, not a drag if (currentBalance == -2) // If showing "Check Console", open console on click { OpenConsole_Click(sender, e); } } } isDragging = false; this.Cursor = currentBalance == -2 ? Cursors.Hand : Cursors.Default; } } protected override void OnFormClosing(FormClosingEventArgs e) { if (e.CloseReason == CloseReason.UserClosing) { e.Cancel = true; Hide(); isVisible = false; } else { base.OnFormClosing(e); } } protected override void Dispose(bool disposing) { if (disposing) { refreshTimer?.Dispose(); notifyIcon?.Dispose(); httpClient?.Dispose(); } base.Dispose(disposing); } } }