diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6c15812 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-pc-windows-gnu] +linker = "C:/msys64/mingw64/bin/gcc.exe" +ar = "C:/msys64/mingw64/bin/ar.exe" diff --git a/.gitignore b/.gitignore index e021482..312476f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ AGENTS.md # Local WinGet manifest generation output /manifests/ + +# Cursor IDE agent rules - hard-linked, not repo content +.cursor/ diff --git a/README.md b/README.md index f8b3c2b..ef23d1c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ What it does **not** do: Notes: -- If your Claude Code token is expired, the app may ask the local Claude CLI to refresh it in the background +- If your Claude Code token is expired, the app refreshes OAuth directly against `platform.claude.com/v1/oauth/token` and updates `~/.claude/.credentials.json` (no Claude CLI session / no model spend on Windows) - If your Codex token is expired, the app may ask the local Codex CLI to refresh it in the background. The monitor does not write `auth.json` itself; any credential update is handled by the Codex CLI. - If your Antigravity token is expired, open Antigravity and sign in again. The monitor does not write Windows Credential Manager entries itself. - Portable installs can update themselves by downloading the latest release from this repository diff --git a/src/localization/english.rs b/src/localization/english.rs index 0249730..a73d383 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -39,7 +39,7 @@ pub(super) const STRINGS: Strings = Strings { hour_suffix: "h", minute_suffix: "m", token_expired_title: "Claude Code Auth Error", - token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.", + token_expired_body: "Run 'claude auth login' in a terminal and complete sign-in. After that, refresh or restart this app.", codex_token_expired_title: "Codex Auth Error", codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", antigravity_token_expired_title: "Antigravity Auth Error", diff --git a/src/main.rs b/src/main.rs index a17bc0b..cef58d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod localization; mod models; mod native_interop; mod poller; +mod spend_pace; mod theme; mod tray_icon; mod updater; diff --git a/src/models.rs b/src/models.rs index da49ef1..98062b4 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,6 +4,7 @@ use std::time::SystemTime; pub struct UsageSection { pub percentage: f64, pub resets_at: Option, + pub has_bucket: bool, } #[derive(Clone, Debug, Default)] @@ -12,9 +13,42 @@ pub struct UsageData { pub weekly: UsageSection, } +#[derive(Clone, Debug, Default)] +pub struct AccountUsage { + pub credit_pct: f64, + pub credit_expiry: Option, + pub spend_used: f64, + pub spend_limit: f64, +} + +#[derive(Clone, Debug, Default)] +pub struct SpendPaceSlots { + pub month_actual: f64, + pub month_cap: f64, + pub month_expected: f64, + pub month_level: u8, + pub week_actual: f64, + pub week_cap: f64, + pub week_expected: f64, + pub week_level: u8, + pub day_actual: f64, + pub day_cap: f64, + pub day_expected: f64, + pub day_level: u8, +} + +#[derive(Clone, Debug)] +pub struct SpendPaceView { + pub credit_pct: f64, + pub credit_expiry: Option, + pub slots: SpendPaceSlots, +} + #[derive(Clone, Debug, Default)] pub struct AppUsageData { pub claude_code: Option, pub codex: Option, pub antigravity: Option, + pub account: Option, + pub spend_pace: Option, } diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..d6cc0ec 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,9 +1,14 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Globalization::GetLocaleInfoW; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; +const LOCALE_USER_DEFAULT: u32 = 0x0400; +// Short date format pattern (e.g. "M/d/yyyy") +const LOCALE_SSHORTDATE: u32 = 0x001F; + // Window style constants pub const WS_POPUP_STYLE: u32 = 0x80000000; pub const WS_CHILD_STYLE: u32 = 0x40000000; @@ -18,6 +23,8 @@ pub const TIMER_POLL: usize = 1; pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; pub const TIMER_UPDATE_CHECK: usize = 4; +pub const TIMER_DRAG: usize = 5; +pub const TIMER_WIDGET_KEEPALIVE: usize = 6; // Custom messages pub const WM_APP: u32 = 0x8000; @@ -61,13 +68,149 @@ pub fn find_taskbars() -> Vec { taskbars } -/// Find a child window by class name +/// Find a child window by class name (direct children only). pub fn find_child_window(parent: HWND, class_name: &str) -> Option { + find_next_child_window(parent, HWND::default(), class_name) +} + +/// Find a descendant window by class name anywhere under `parent`. +pub fn find_descendant_window(parent: HWND, class_name: &str) -> Option { + struct Search { + target: String, + found: Option, + } + + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let search = &mut *(lparam.0 as *mut Search); + let mut class_buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_buf); + if len > 0 { + let class = String::from_utf16_lossy(&class_buf[..len as usize]); + if class == search.target { + search.found = Some(hwnd); + return BOOL(0); + } + } + BOOL(1) + } + + let mut search = Search { + target: class_name.to_string(), + found: None, + }; + unsafe { + let _ = EnumChildWindows(parent, Some(enum_proc), LPARAM(&mut search as *mut _ as isize)); + } + search.found +} + +struct TaskbarBandScan { + taskbar_rect: RECT, + content_left: i32, + pin_right: i32, +} + +unsafe extern "system" fn scan_taskbar_band_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let scan = &mut *(lparam.0 as *mut TaskbarBandScan); + let mut class_buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_buf); + if len <= 0 { + return BOOL(1); + } + let class = String::from_utf16_lossy(&class_buf[..len as usize]); + if class != "MSTaskListWClass" && class != "MSTaskSwWClass" { + return BOOL(1); + } + if let Some(rect) = get_window_rect_safe(hwnd) { + scan.pin_right = scan.pin_right.max(rect.right); + let relative_right = rect.right.saturating_sub(scan.taskbar_rect.left); + let relative_left = rect.left.saturating_sub(scan.taskbar_rect.left); + let taskbar_width = scan.taskbar_rect.right - scan.taskbar_rect.left; + if relative_right > relative_left && relative_right < taskbar_width { + scan.content_left = scan.content_left.max(relative_right); + } + } + BOOL(1) +} + + +struct VisibleLeftScan { + visible_left: i32, + found: bool, +} + +unsafe extern "system" fn scan_visible_left_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let scan = &mut *(lparam.0 as *mut VisibleLeftScan); + let mut class_buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_buf); + if len > 0 { + let class = String::from_utf16_lossy(&class_buf[..len as usize]); + let is_chrome = class.contains("Start") + || class == "MSTaskListWClass" + || class == "MSTaskSwWClass" + || class == "ReBarWindow32" + || class == "ToolbarWindow32"; + if is_chrome { + if let Some(rect) = get_window_rect_safe(hwnd) { + if rect.right > rect.left { + scan.visible_left = if scan.found { + scan.visible_left.min(rect.left) + } else { + rect.left + }; + scan.found = true; + } + } + } + } + unsafe { + let _ = EnumChildWindows(hwnd, Some(scan_visible_left_proc), lparam); + } + BOOL(1) +} + +fn taskbar_visible_left(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + let mut scan = VisibleLeftScan { + visible_left: taskbar_rect.left, + found: false, + }; + unsafe { + let _ = EnumChildWindows( + taskbar_hwnd, + Some(scan_visible_left_proc), + LPARAM(&mut scan as *mut _ as isize), + ); + } + if scan.found { + scan.visible_left + } else { + taskbar_rect.left + } +} + +fn scan_taskbar_band(taskbar_hwnd: HWND, taskbar_rect: RECT) -> (i32, i32) { + let mut scan = TaskbarBandScan { + taskbar_rect, + content_left: 0, + pin_right: 0, + }; + unsafe { + let _ = EnumChildWindows( + taskbar_hwnd, + Some(scan_taskbar_band_proc), + LPARAM(&mut scan as *mut _ as isize), + ); + } + (scan.content_left, scan.pin_right) +} + +/// Find the next sibling child window matching `class_name`. +pub fn find_next_child_window(parent: HWND, after: HWND, class_name: &str) -> Option { unsafe { let class = wide_str(class_name); match FindWindowExW( parent, - HWND::default(), + after, PCWSTR::from_raw(class.as_ptr()), PCWSTR::null(), ) { @@ -114,17 +257,51 @@ pub fn get_window_rect_safe(hwnd: HWND) -> Option { } } -/// Embed our window as a child of the taskbar -pub fn embed_in_taskbar(hwnd: HWND, taskbar_hwnd: HWND) { +/// Left edge of visible taskbar chrome (relative to taskbar rect). +pub fn taskbar_content_left(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + taskbar_visible_left_screen(taskbar_hwnd, taskbar_rect).saturating_sub(taskbar_rect.left) +} + +/// Left edge of visible taskbar chrome in screen coordinates. +pub fn taskbar_visible_left_screen(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + taskbar_visible_left(taskbar_hwnd, taskbar_rect) +} + +/// Right edge of the pinned-app band in screen coordinates. +pub fn pin_band_right(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + scan_taskbar_band(taskbar_hwnd, taskbar_rect).1 +} + +/// Ensure WS_EX_LAYERED is set so UpdateLayeredWindow can push pixels. +pub fn ensure_layered_style(hwnd: HWND) { + unsafe { + let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE); + if ex_style & (WS_EX_LAYERED.0 as i32) == 0 { + let _ = SetWindowLongW( + hwnd, + GWL_EXSTYLE, + ex_style | WS_EX_LAYERED.0 as i32 | WS_EX_TOOLWINDOW.0 as i32 | WS_EX_NOACTIVATE.0 as i32, + ); + } + } +} + +/// Remove WS_EX_LAYERED so the child paints via normal WM_PAINT inside Shell_TrayWnd. +pub fn strip_layered_style(hwnd: HWND) { unsafe { - // Preserve existing extended style, add tool window + no activate let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE); + let cleared = ex_style & !(WS_EX_LAYERED.0 as i32); let _ = SetWindowLongW( hwnd, GWL_EXSTYLE, - ex_style | WS_EX_TOOLWINDOW.0 as i32 | WS_EX_NOACTIVATE.0 as i32, + cleared | WS_EX_TOOLWINDOW.0 as i32 | WS_EX_NOACTIVATE.0 as i32, ); + } +} +/// Embed our window as a child of the taskbar +pub fn embed_in_taskbar(hwnd: HWND, taskbar_hwnd: HWND) { + unsafe { // Change from popup to child let style = GetWindowLongW(hwnd, GWL_STYLE) as u32; let new_style = (style & !WS_POPUP_STYLE) | WS_CHILD_STYLE | WS_CLIPSIBLINGS_STYLE; @@ -134,6 +311,110 @@ pub fn embed_in_taskbar(hwnd: HWND, taskbar_hwnd: HWND) { } } +/// Detach our window from the taskbar, restoring popup style and topmost z-order +pub fn detach_from_taskbar(hwnd: HWND) { + unsafe { + let style = GetWindowLongW(hwnd, GWL_STYLE) as u32; + let new_style = (style & !(WS_CHILD_STYLE | WS_CLIPSIBLINGS_STYLE)) | WS_POPUP_STYLE; + let _ = SetWindowLongW(hwnd, GWL_STYLE, new_style as i32); + let _ = SetParent(hwnd, None); + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + +/// Place the popup widget above Shell_TrayWnd in the topmost z-order band. +pub fn raise_above_taskbar(hwnd: HWND, _taskbar_hwnd: Option) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + +/// Place the layered popup immediately above the taskbar in Z-order. +pub fn position_above_taskbar(hwnd: HWND, _taskbar_hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_NOTOPMOST, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + } +} + +/// Fallback when no taskbar handle is available yet. +pub fn position_topmost_popup(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + } +} + +/// Place a popup layered widget in the taskbar band (screen coords), just above the taskbar z-order. +pub fn position_on_taskbar_band( + hwnd: HWND, + taskbar_hwnd: HWND, + x: i32, + y: i32, + w: i32, + h: i32, +) { + unsafe { + let _ = SetWindowPos( + hwnd, + taskbar_hwnd, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + } +} + /// Move the window pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { unsafe { @@ -141,6 +422,22 @@ pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { } } +/// Move the window asynchronously — posts a move request to the owning thread's queue +/// instead of blocking cross-process. Required for WS_CHILD windows embedded in Explorer. +pub fn move_window_async(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND::default(), + x, + y, + w, + h, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS, + ); + } +} + /// Set up a WinEvent hook for tray location changes pub fn set_tray_event_hook( thread_id: u32, @@ -181,6 +478,41 @@ pub fn wide_str(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } +/// Format a month/day pair respecting the Windows system locale +/// (separator, and whether day or month comes first). +/// Returns e.g. "9/15" (en-US), "15/9" (en-GB), "15.9" (de-DE). +pub fn format_month_day_locale(month: u8, day: u8) -> String { + if let Some(pattern) = locale_short_date_pattern() { + let lower = pattern.to_lowercase(); + // Find the separator: first non-alphabetic, non-quote character + let sep = lower + .chars() + .find(|c| !c.is_alphabetic() && *c != '\'') + .unwrap_or('/'); + // day-first when 'd' appears before 'm' in the pattern (e.g. "dd/MM/yyyy") + let d_pos = lower.find('d'); + let m_pos = lower.find('m'); + return match (d_pos, m_pos) { + (Some(d), Some(m)) if d < m => format!("{}{}{}", day, sep, month), + (Some(_), Some(_)) => format!("{}{}{}", month, sep, day), + _ => format!("{}/{}", month, day), // malformed pattern — safe fallback + }; + } + format!("{}/{}", month, day) +} + +fn locale_short_date_pattern() -> Option { + unsafe { + let mut buf = [0u16; 256]; + let len = GetLocaleInfoW(LOCALE_USER_DEFAULT, LOCALE_SSHORTDATE, Some(&mut buf)); + if len > 1 && (len as usize) <= buf.len() { + Some(String::from_utf16_lossy(&buf[..len as usize - 1]).to_string()) + } else { + None + } + } +} + /// COLORREF wrapper (RGB packed into u32) pub fn colorref(r: u8, g: u8, b: u8) -> u32 { r as u32 | (g as u32) << 8 | (b as u32) << 16 diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..0bf1d0a 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -6,15 +6,95 @@ use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::os::windows::process::CommandExt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use crate::diagnose; use crate::localization::Strings; -use crate::models::{AppUsageData, UsageData, UsageSection}; +use crate::models::{AccountUsage, AppUsageData, UsageData, UsageSection}; + +// In-memory cache: survives transient 429s within a single session. +static LAST_KNOWN_ACCOUNT: Mutex> = Mutex::new(None); +// Ensures disk cache is read at most once per process lifetime. +static DISK_CACHE_LOADED: AtomicBool = AtomicBool::new(false); + +#[derive(Serialize, Deserialize, Default)] +struct CachedAccountDisk { + credit_pct: f64, + credit_expiry_unix: Option, + spend_used: f64, + spend_limit: f64, +} + +fn account_cache_path() -> PathBuf { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(appdata) + .join("ClaudeCodeUsageMonitor") + .join("account_cache.json") +} + +fn save_account_to_disk(account: &AccountUsage) { + let path = account_cache_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let credit_expiry_unix = account + .credit_expiry + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + let disk = CachedAccountDisk { + credit_pct: account.credit_pct, + credit_expiry_unix, + spend_used: account.spend_used, + spend_limit: account.spend_limit, + }; + if let Ok(json) = serde_json::to_string(&disk) { + let _ = std::fs::write(&path, json); + } +} + +fn load_account_from_disk() -> Option { + let content = std::fs::read_to_string(account_cache_path()).ok()?; + let disk: CachedAccountDisk = serde_json::from_str(&content).ok()?; + let credit_expiry = disk.credit_expiry_unix.filter(|&s| s > 0).map(|secs| { + UNIX_EPOCH + Duration::from_secs(secs) + }); + Some(AccountUsage { + credit_pct: disk.credit_pct, + credit_expiry, + spend_used: disk.spend_used, + spend_limit: disk.spend_limit, + }) +} + +/// Pre-populate in-memory cache from disk on first call (no-op afterwards). +fn ensure_disk_cache_loaded() { + if DISK_CACHE_LOADED.swap(true, Ordering::Relaxed) { + return; + } + if let Some(account) = load_account_from_disk() { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + if cached.is_none() { + diagnose::log(format!( + "loaded account cache from disk credit_pct={}", + account.credit_pct + )); + *cached = Some(account); + } + } + } +} const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; +const OAUTH_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; +const OAUTH_TOKEN_URL_LEGACY: &str = "https://console.anthropic.com/v1/oauth/token"; +/// Client ID used by Claude Code OAuth (not the dynamic-metadata URL). +const CLAUDE_OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const CLAUDE_OAUTH_USER_AGENT: &str = "claude-cli/2.1.0 (external; claude-code)"; +const DEFAULT_ACCESS_TOKEN_TTL_SECS: i64 = 28_800; const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage"; const ANTIGRAVITY_CREDENTIAL_TARGET: &str = "gemini:antigravity"; const ANTIGRAVITY_ENDPOINTS: &[&str] = &[ @@ -47,6 +127,20 @@ pub type CredentialWatchSnapshot = Vec; struct UsageResponse { five_hour: Option, seven_day: Option, + cinder_cove: Option, + spend: Option, +} + +#[derive(Deserialize)] +struct SpendData { + used: SpendAmount, + limit: SpendAmount, +} + +#[derive(Deserialize)] +struct SpendAmount { + amount_minor: i64, + exponent: u32, } #[derive(Deserialize)] @@ -190,7 +284,7 @@ fn poll_with( show_claude_code: bool, show_codex: bool, show_antigravity: bool, - mut poll_claude_code: impl FnMut() -> Result, + mut poll_claude_code: impl FnMut() -> Result<(UsageData, Option), PollError>, mut poll_codex: impl FnMut() -> Result, mut poll_antigravity: impl FnMut() -> Result, ) -> Result { @@ -200,7 +294,13 @@ fn poll_with( if show_claude_code { match poll_claude_code() { - Ok(claude_code) => data.claude_code = Some(claude_code), + Ok((usage, account)) => { + data.claude_code = Some(usage); + data.account = account; + if let Some(ref account) = data.account { + data.spend_pace = crate::spend_pace::compute_spend_pace(account); + } + } Err(error) => { if active_provider_count > 1 { diagnose::log(format!("Claude Code usage poll failed: {error:?}")); @@ -241,7 +341,7 @@ fn poll_with( } } -fn poll_claude_code() -> Result { +fn poll_claude_code() -> Result<(UsageData, Option), PollError> { let creds = match read_first_credentials() { Some(c) => c, None => { @@ -252,7 +352,20 @@ fn poll_claude_code() -> Result { let creds = refresh_or_fallback(creds)?; - fetch_usage_with_fallback(&creds.access_token) + match fetch_usage_with_fallback(&creds.access_token) { + Ok(result) => Ok(result), + Err(PollError::AuthRequired) => { + diagnose::log("Claude usage auth error; attempting OAuth token refresh and retry"); + refresh_claude_token(&creds.source); + if let Some(refreshed) = read_credentials_from_source(&creds.source) { + if let Ok(result) = fetch_usage_with_fallback(&refreshed.access_token) { + return Ok(result); + } + } + Err(PollError::AuthRequired) + } + Err(error) => Err(error), + } } fn poll_codex() -> Result { @@ -294,7 +407,7 @@ fn refresh_or_fallback(mut creds: Credentials) -> Result } let source = creds.source.clone(); - cli_refresh_token(&source); + refresh_claude_token(&source); match read_credentials_from_source(&source) { Some(refreshed) if !is_token_expired(refreshed.expires_at) => return Ok(refreshed), @@ -313,63 +426,378 @@ fn refresh_or_fallback(mut creds: Credentials) -> Result } } -/// Invoke the Claude CLI with a minimal prompt to force its internal -/// OAuth token refresh. -fn cli_refresh_token(source: &CredentialSource) { +/// Refresh Claude OAuth credentials without invoking the Claude CLI (no model spend). +fn refresh_claude_token(source: &CredentialSource) { + diagnose::log(format!("attempting Claude OAuth refresh via HTTP for {source:?}")); + if http_refresh_claude_token(source) { + diagnose::log("Claude OAuth refresh via HTTP succeeded"); + return; + } match source { - CredentialSource::Windows(_) => cli_refresh_windows_token(), - CredentialSource::Wsl { distro } => cli_refresh_wsl_token(distro), + CredentialSource::Wsl { distro } => { + diagnose::log( + "Claude OAuth HTTP refresh failed for WSL; falling back to Claude CLI (may incur usage charges)", + ); + cli_refresh_wsl_token(distro); + } + CredentialSource::Windows(_) => diagnose::log( + "Claude OAuth HTTP refresh failed; run 'claude auth login' if usage polling stays unauthorized", + ), } } -fn cli_refresh_windows_token() { - let claude_path = resolve_windows_claude_path(); - let is_cmd = claude_path.to_lowercase().ends_with(".cmd"); - diagnose::log(format!( - "attempting Windows Claude token refresh via {claude_path}" - )); +#[derive(Deserialize)] +struct OauthRefreshResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, + refresh_token_expires_in: Option, + scope: Option, +} - let args: &[&str] = &["-p", "."]; +fn http_refresh_claude_token(source: &CredentialSource) -> bool { + let (content, expected_mtime) = match read_credentials_file_raw(source) { + Some(value) => value, + None => { + diagnose::log("OAuth HTTP refresh failed: unable to read credentials file"); + return false; + } + }; - let mut cmd = if is_cmd { - let mut c = Command::new("cmd.exe"); - c.arg("/c").arg(&claude_path).args(args); - c - } else { - let mut c = Command::new(&claude_path); - c.args(args); - c + let (refresh_token, scopes) = match parse_oauth_refresh_fields(&content) { + Some(value) => value, + None => { + diagnose::log("OAuth HTTP refresh failed: credentials missing refresh token"); + return false; + } }; - cmd.env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") + + let response = match request_oauth_refresh(&refresh_token, &scopes) { + Some(value) => value, + None => return false, + }; + + let updated = match merge_oauth_refresh_into_credentials(&content, &response) { + Some(value) => value, + None => { + diagnose::log("OAuth HTTP refresh failed: unable to merge refreshed token into credentials"); + return false; + } + }; + + if !credentials_json_is_safe_to_persist(&updated) { + diagnose::log( + "OAuth HTTP refresh refused persist: merged credentials missing access or refresh token", + ); + return false; + } + + write_credentials_file_raw(source, &updated, expected_mtime) +} + +fn read_credentials_file_raw(source: &CredentialSource) -> Option<(String, Option)> { + match source { + CredentialSource::Windows(path) => { + let metadata = std::fs::metadata(path).ok()?; + let modified = metadata.modified().ok(); + let content = std::fs::read_to_string(path).ok()?; + Some((content, modified)) + } + CredentialSource::Wsl { distro } => { + let output = run_with_timeout( + Command::new("wsl.exe") + .arg("-d") + .arg(distro) + .arg("--") + .arg("cat") + .arg("~/.claude/.credentials.json") + .creation_flags(CREATE_NO_WINDOW) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()), + Duration::from_secs(5), + )?; + if !output.status.success() { + return None; + } + Some((decode_wsl_text(&output.stdout), None)) + } + } +} + +fn write_credentials_file_raw( + source: &CredentialSource, + content: &str, + expected_mtime: Option, +) -> bool { + match source { + CredentialSource::Windows(path) => write_windows_credentials_file(path, content, expected_mtime), + CredentialSource::Wsl { distro } => write_wsl_credentials_file(distro, content), + } +} + +fn write_windows_credentials_file( + path: &PathBuf, + content: &str, + expected_mtime: Option, +) -> bool { + if !credentials_json_is_safe_to_persist(content) { + diagnose::log( + "OAuth HTTP refresh refused persist: credentials payload missing access or refresh token", + ); + return false; + } + + if let Some(expected) = expected_mtime { + if let Ok(metadata) = std::fs::metadata(path) { + if let Ok(actual) = metadata.modified() { + if actual != expected { + diagnose::log( + "OAuth HTTP refresh skipped persist: credentials file changed during refresh", + ); + return false; + } + } + } + } + + backup_credentials_file(path); + + let tmp_path = path.with_extension("json.tmp"); + if let Err(error) = std::fs::write(&tmp_path, content) { + diagnose::log_error("OAuth HTTP refresh failed to write temp credentials file", error); + return false; + } + if let Err(error) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + diagnose::log_error("OAuth HTTP refresh failed to replace credentials file", error); + return false; + } + true +} + +fn write_wsl_credentials_file(distro: &str, content: &str) -> bool { + let mut cmd = Command::new("wsl.exe"); + cmd.arg("-d") + .arg(distro) + .arg("--") + .arg("bash") + .arg("-lc") + .arg("cat > ~/.claude/.credentials.json") .creation_flags(CREATE_NO_WINDOW) - .stdin(std::process::Stdio::null()) + .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); let mut child = match cmd.spawn() { - Ok(c) => c, + Ok(child) => child, Err(error) => { - diagnose::log_error("unable to spawn Windows Claude token refresh", error); - return; + diagnose::log_error("OAuth HTTP refresh failed to spawn WSL credentials writer", error); + return false; } }; - // Wait up to 30 seconds — don't block the poll thread forever - let start = std::time::Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) => { - if start.elapsed() > Duration::from_secs(30) { - let _ = child.kill(); - break; - } - std::thread::sleep(Duration::from_millis(500)); + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + if stdin.write_all(content.as_bytes()).is_err() { + let _ = child.kill(); + diagnose::log("OAuth HTTP refresh failed while writing credentials to WSL stdin"); + return false; + } + } + + match child.wait() { + Ok(status) if status.success() => true, + Ok(status) => { + diagnose::log(format!( + "OAuth HTTP refresh WSL credentials writer exited with status {status}" + )); + false + } + Err(error) => { + diagnose::log_error("OAuth HTTP refresh failed waiting for WSL credentials writer", error); + false + } + } +} + +fn parse_oauth_refresh_fields(content: &str) -> Option<(String, Vec)> { + let json: serde_json::Value = serde_json::from_str(content).ok()?; + let oauth = json.get("claudeAiOauth")?; + let refresh_token = oauth.get("refreshToken")?.as_str()?.trim(); + if refresh_token.is_empty() { + return None; + } + let scopes = oauth + .get("scopes") + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + Some((refresh_token.to_string(), scopes)) +} + +fn request_oauth_refresh(refresh_token: &str, scopes: &[String]) -> Option { + let mut body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLAUDE_OAUTH_CLIENT_ID, + }); + if !scopes.is_empty() { + body["scope"] = serde_json::Value::String(scopes.join(" ")); + } + + for (index, url) in [OAUTH_TOKEN_URL, OAUTH_TOKEN_URL_LEGACY] + .into_iter() + .enumerate() + { + let has_fallback = index + 1 < 2; + match post_oauth_refresh(url, &body) { + Ok(response) => return Some(response), + Err(OauthRefreshError::EndpointMoved) if has_fallback => { + diagnose::log(format!( + "OAuth token endpoint {url} unavailable; trying legacy endpoint" + )); + continue; } - Err(_) => break, + Err(error) => { + diagnose::log(format!("OAuth HTTP refresh failed against {url}: {error}")); + return None; + } + } + } + + None +} + +enum OauthRefreshError { + EndpointMoved, + Failed(String), +} + +impl std::fmt::Display for OauthRefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EndpointMoved => write!(f, "token endpoint moved"), + Self::Failed(message) => write!(f, "{message}"), + } + } +} + +fn post_oauth_refresh(url: &str, body: &serde_json::Value) -> Result { + let agent = build_agent().map_err(|_| OauthRefreshError::Failed("HTTP client unavailable".into()))?; + let response = agent + .post(url) + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .set("User-Agent", CLAUDE_OAUTH_USER_AGENT) + .send_json(body) + .map_err(|error| match error { + ureq::Error::Status(code, _) if code == 404 || code == 405 => OauthRefreshError::EndpointMoved, + ureq::Error::Status(code, resp) => { + let detail = resp.into_string().unwrap_or_default(); + OauthRefreshError::Failed(format!("status {code}: {detail}")) + } + ureq::Error::Transport(error) => OauthRefreshError::Failed(error.to_string()), + })?; + + response + .into_json::() + .map_err(|error| OauthRefreshError::Failed(error.to_string())) +} + +fn merge_oauth_refresh_into_credentials( + content: &str, + response: &OauthRefreshResponse, +) -> Option { + let mut root: serde_json::Value = serde_json::from_str(content).ok()?; + let oauth = root.get_mut("claudeAiOauth")?.as_object_mut()?; + if response.access_token.is_empty() { + return None; + } + + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_millis() as i64; + let expires_in = response.expires_in.unwrap_or(DEFAULT_ACCESS_TOKEN_TTL_SECS); + oauth.insert( + "accessToken".into(), + serde_json::Value::String(response.access_token.clone()), + ); + if let Some(refresh_token) = response.refresh_token.as_ref() { + if !refresh_token.is_empty() { + oauth.insert( + "refreshToken".into(), + serde_json::Value::String(refresh_token.clone()), + ); + } + } + oauth.insert( + "expiresAt".into(), + serde_json::Value::Number((now_ms + expires_in * 1000).into()), + ); + if let Some(refresh_token_expires_in) = response.refresh_token_expires_in { + oauth.insert( + "refreshTokenExpiresAt".into(), + serde_json::Value::Number((now_ms + refresh_token_expires_in * 1000).into()), + ); + } + if let Some(scope) = response.scope.as_ref() { + let scopes: Vec = scope + .split_whitespace() + .map(|item| serde_json::Value::String(item.to_string())) + .collect(); + if !scopes.is_empty() { + oauth.insert("scopes".into(), serde_json::Value::Array(scopes)); } } + + serde_json::to_string_pretty(&root).ok() +} + +fn credentials_json_is_safe_to_persist(content: &str) -> bool { + let json: serde_json::Value = match serde_json::from_str(content) { + Ok(value) => value, + Err(_) => return false, + }; + let oauth = match json.get("claudeAiOauth") { + Some(value) => value, + None => return false, + }; + let access_token = oauth + .get("accessToken") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|token| !token.is_empty()); + let refresh_token = oauth + .get("refreshToken") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|token| !token.is_empty()); + access_token.is_some() && refresh_token.is_some() +} + +fn backup_credentials_file(path: &PathBuf) { + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let Some(parent) = path.parent() else { + return; + }; + let backup_dir = parent.join("backups"); + if std::fs::create_dir_all(&backup_dir).is_err() { + return; + } + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let backup_path = backup_dir.join(format!(".credentials.json.backup.{timestamp}")); + let _ = std::fs::write(backup_path, content); } fn cli_refresh_wsl_token(distro: &str) { @@ -484,42 +912,6 @@ fn wait_for_refresh(child: &mut std::process::Child) { } } -/// Resolve the full path to the `claude` CLI executable. -fn resolve_windows_claude_path() -> String { - for name in &["claude.cmd", "claude"] { - if Command::new(name) - .arg("--version") - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok() - { - return name.to_string(); - } - } - - for name in &["claude.cmd", "claude"] { - if let Ok(output) = Command::new("where.exe") - .arg(name) - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(first_line) = stdout.lines().next() { - let path = first_line.trim().to_string(); - if !path.is_empty() { - return path; - } - } - } - } - } - - "claude.cmd".to_string() -} - fn resolve_windows_codex_path() -> String { for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] { if Command::new(name) @@ -654,10 +1046,10 @@ fn wsl_credential_watch_signature(distro: &str) -> Option { Some(format!("wsl:{distro}|{state}")) } -fn fetch_usage_with_fallback(token: &str) -> Result { +fn fetch_usage_with_fallback(token: &str) -> Result<(UsageData, Option), PollError> { // Try the dedicated usage endpoint first match try_usage_endpoint(token)? { - Some(data) => { + Some((data, account)) => { // If reset timers are missing, fill them in from the Messages API if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() { if let Ok(fallback) = fetch_usage_via_messages(token) { @@ -668,10 +1060,10 @@ fn fetch_usage_with_fallback(token: &str) -> Result { if merged.weekly.resets_at.is_none() { merged.weekly.resets_at = fallback.weekly.resets_at; } - return Ok(merged); + return Ok((merged, account)); } } - return Ok(data); + return Ok((data, account)); } None => {} } @@ -679,12 +1071,26 @@ fn fetch_usage_with_fallback(token: &str) -> Result { // Fall back to Messages API with rate limit headers let result = fetch_usage_via_messages(token); if result.is_err() { - diagnose::log("usage endpoint and Messages API fallback both failed"); + diagnose::log("usage endpoint and messages API both unavailable"); + } + // Load disk cache once per process, then use in-memory cache + ensure_disk_cache_loaded(); + let cached_account = LAST_KNOWN_ACCOUNT.lock().ok().and_then(|g| g.clone()); + match result { + Ok(d) => Ok((d, cached_account)), + // Both endpoints down but we have cached enterprise data: return it with empty + // UsageData so refresh_usage_texts can still render the Cr/Sp rows. This keeps + // poll() returning Ok and prevents the transient-error handler from wiping + // session_text / weekly_text with "...". + Err(_) if cached_account.is_some() => { + diagnose::log("using cached account data (usage endpoint unavailable)"); + Ok((UsageData::default(), cached_account)) + } + Err(e) => Err(e), } - result } -fn try_usage_endpoint(token: &str) -> Result, PollError> { +fn try_usage_endpoint(token: &str) -> Result)>, PollError> { let agent = build_agent()?; let resp = match agent @@ -700,26 +1106,79 @@ fn try_usage_endpoint(token: &str) -> Result, PollError> { )); return Err(PollError::AuthRequired); } - Err(_) => return Ok(None), + Err(ureq::Error::Status(code, _)) => { + diagnose::log(format!("usage endpoint returned non-auth error status {code}")); + return Ok(None); + } + Err(e) => { + diagnose::log(format!("usage endpoint request failed: {e}")); + return Ok(None); + } }; let response: UsageResponse = match resp.into_json() { Ok(response) => response, - Err(_) => return Ok(None), + Err(e) => { + diagnose::log(format!("usage endpoint json parse failed: {e}")); + return Ok(None); + } }; + diagnose::log("usage endpoint json parsed ok"); let mut data = UsageData::default(); if let Some(bucket) = &response.five_hour { data.session.percentage = bucket.utilization; data.session.resets_at = parse_iso8601(bucket.resets_at.as_deref()); + data.session.has_bucket = true; } if let Some(bucket) = &response.seven_day { data.weekly.percentage = bucket.utilization; data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref()); + data.weekly.has_bucket = true; } - Ok(Some(data)) + let account = extract_account_usage(&response); + // Update both in-memory cache and disk to reflect the current plan. + // When account is None (non-enterprise plan), clear the disk cache too so + // stale enterprise rows don't reappear after a 429 later in the session. + if let Some(ref a) = account { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + *cached = Some(a.clone()); + } + save_account_to_disk(a); + } else { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + *cached = None; + } + let _ = std::fs::remove_file(account_cache_path()); + } + Ok(Some((data, account))) +} + +fn extract_account_usage(response: &UsageResponse) -> Option { + diagnose::log(format!( + "extract_account_usage: cinder_cove={} spend={}", + response.cinder_cove.is_some(), + response.spend.is_some() + )); + let credit_bucket = response.cinder_cove.as_ref()?; + let spend = response.spend.as_ref()?; + + let used_divisor = 10f64.powi(spend.used.exponent as i32); + let limit_divisor = 10f64.powi(spend.limit.exponent as i32); + + let result = Some(AccountUsage { + credit_pct: credit_bucket.utilization, + credit_expiry: parse_iso8601(credit_bucket.resets_at.as_deref()), + spend_used: spend.used.amount_minor as f64 / used_divisor, + spend_limit: spend.limit.amount_minor as f64 / limit_divisor, + }); + diagnose::log(format!( + "extract_account_usage: returning Some with credit_pct={}", + credit_bucket.utilization + )); + result } fn fetch_usage_via_messages(token: &str) -> Result { @@ -855,6 +1314,7 @@ fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection { UsageSection { percentage: window.used_percent, resets_at: unix_to_system_time(Some(window.reset_at)), + has_bucket: true, } } @@ -1047,6 +1507,7 @@ fn antigravity_section_from_quota(quota: AntigravityQuotaInfo) -> Option Option) -> bool { /// Parse an ISO 8601 timestamp string into a SystemTime. fn parse_iso8601(s: Option<&str>) -> Option { let s = s?; - // Strip timezone offset to get "YYYY-MM-DDTHH:MM:SS" or with fractional seconds - // The API returns formats like "2026-03-05T08:00:00.321598+00:00" - let datetime_part = s.split('+').next().unwrap_or(s); - let datetime_part = datetime_part.split('Z').next().unwrap_or(datetime_part); + // Strip timezone: "2026-03-05T08:00:00.321598+00:00" or "-05:00" + // First strip trailing 'Z', then find +/- timezone offset after the 'T' separator. + let datetime_part = s.split('Z').next().unwrap_or(s); + let datetime_part = if let Some(t_pos) = datetime_part.find('T') { + let after_t = &datetime_part[t_pos + 1..]; + match after_t.find(|c: char| c == '+' || c == '-') { + Some(tz) => &datetime_part[..t_pos + 1 + tz], + None => datetime_part, + } + } else { + datetime_part + }; // Try parsing with and without fractional seconds let formats = ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S"]; @@ -1609,6 +2081,7 @@ mod tests { session: UsageSection { percentage, resets_at: None, + has_bucket: true, }, weekly: UsageSection::default(), } @@ -1636,7 +2109,7 @@ mod tests { true, true, false, - || Ok(usage_with_session_percent(64.0)), + || Ok((usage_with_session_percent(64.0), None)), || Err(PollError::RequestFailed), || unreachable!("antigravity is disabled"), ) @@ -1732,4 +2205,159 @@ mod tests { assert!(usage.weekly.resets_at.is_some()); assert!(usage.session.resets_at.is_some()); } + + #[test] + fn merge_oauth_refresh_preserves_unrelated_credential_fields() { + let original = r#"{ + "mcpOAuth": {}, + "claudeAiOauth": { + "accessToken": "old-access", + "refreshToken": "old-refresh", + "expiresAt": 1, + "refreshTokenExpiresAt": 2, + "scopes": ["user:inference"], + "subscriptionType": "enterprise" + } +}"#; + let response = OauthRefreshResponse { + access_token: "new-access".into(), + refresh_token: Some("new-refresh".into()), + expires_in: Some(28800), + refresh_token_expires_in: Some(2_592_000), + scope: Some("user:inference user:profile".into()), + }; + + let merged = merge_oauth_refresh_into_credentials(original, &response) + .expect("refresh merge should succeed"); + let json: serde_json::Value = serde_json::from_str(&merged).expect("merged json"); + assert_eq!(json["mcpOAuth"], serde_json::json!({})); + assert_eq!(json["claudeAiOauth"]["accessToken"], "new-access"); + assert_eq!(json["claudeAiOauth"]["refreshToken"], "new-refresh"); + assert_eq!( + json["claudeAiOauth"]["subscriptionType"], + serde_json::json!("enterprise") + ); + assert_eq!( + json["claudeAiOauth"]["scopes"], + serde_json::json!(["user:inference", "user:profile"]) + ); + } + + #[test] + fn parse_oauth_refresh_fields_reads_refresh_token_and_scopes() { + let content = r#"{"claudeAiOauth":{"refreshToken":"rt","scopes":["user:inference"]}}"#; + let (refresh_token, scopes) = + parse_oauth_refresh_fields(content).expect("refresh fields should parse"); + assert_eq!(refresh_token, "rt"); + assert_eq!(scopes, vec!["user:inference".to_string()]); + } + + #[test] + fn credentials_json_is_safe_to_persist_rejects_empty_tokens() { + let empty_access = r#"{"claudeAiOauth":{"accessToken":"","refreshToken":"rt"}}"#; + let empty_refresh = r#"{"claudeAiOauth":{"accessToken":"at","refreshToken":""}}"#; + let valid = r#"{"claudeAiOauth":{"accessToken":"at","refreshToken":"rt"}}"#; + assert!(!credentials_json_is_safe_to_persist(empty_access)); + assert!(!credentials_json_is_safe_to_persist(empty_refresh)); + assert!(credentials_json_is_safe_to_persist(valid)); + } + + #[test] + fn parse_credentials_rejects_empty_access_token() { + let content = r#"{"claudeAiOauth":{"accessToken":"","expiresAt":123}}"#; + assert!(parse_credentials( + content, + CredentialSource::Windows(PathBuf::from("dummy")) + ) + .is_none()); + } +} + +pub fn format_credit_text(credit_pct: f64, expiry: Option) -> String { + match expiry { + Some(t) => { + let suffix = format_expiry_locale(t); + if suffix.is_empty() { + // Expiry in the past or invalid — show percentage only + format!("{:.0}%", credit_pct) + } else { + // Drop decimal when expiry suffix present: "NN%·D/M" must fit 62px + format!("{:.0}%\u{00b7}{}", credit_pct, suffix) + } + } + // No expiry: keep one decimal for sub-10% precision + None => { + if credit_pct < 10.0 { + format!("{:.1}%", credit_pct) + } else { + format!("{:.0}%", credit_pct) + } + } + } +} + +pub fn format_spend_text(spend_used: f64, spend_limit: f64) -> String { + if spend_limit <= 0.0 { + return format_usd(spend_used); + } + format!("{}/{}", format_usd(spend_used), format_usd(spend_limit)) +} + +fn format_expiry_locale(t: std::time::SystemTime) -> String { + let secs = match t.duration_since(UNIX_EPOCH) { + Ok(d) => d.as_secs(), + Err(_) => return String::new(), + }; + let (month, day) = unix_secs_to_month_day(secs); + crate::native_interop::format_month_day_locale(month, day) +} + +fn unix_secs_to_month_day(secs: u64) -> (u8, u8) { + let days = secs / 86400; + let mut remaining = days; + let mut year = 1970u32; + loop { + let year_days = if is_leap_year(year) { 366u64 } else { 365u64 }; + if remaining < year_days { + break; + } + remaining -= year_days; + year += 1; + } + let month_lengths: [u64; 12] = [ + 31, if is_leap_year(year) { 29 } else { 28 }, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, + ]; + let mut month = 1u8; + let mut rem = remaining; + for &days_in_month in &month_lengths { + if rem < days_in_month { + break; + } + rem -= days_in_month; + month += 1; + } + let month = month.min(12); + let day = (rem + 1).min(31) as u8; + (month, day) +} + +fn is_leap_year(year: u32) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} + +fn format_usd(amount: f64) -> String { + let dollars = amount as u64; + if dollars >= 10_000 { + format!("${:.0}K", amount / 1000.0) + } else if dollars >= 1_000 { + let k = amount / 1000.0; + if (k - k.floor()).abs() < 0.05 { + format!("${:.0}K", k) + } else { + format!("${:.1}K", k) + } + } else { + format!("${}", dollars) + } } diff --git a/src/spend_pace.rs b/src/spend_pace.rs new file mode 100644 index 0000000..7744a12 --- /dev/null +++ b/src/spend_pace.rs @@ -0,0 +1,472 @@ +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::models::{AccountUsage, SpendPaceSlots, SpendPaceView}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaceLevel { + Ok, + High, + Critical, +} + +impl PaceLevel { + pub fn as_bar_level(self) -> u8 { + match self { + Self::Ok => 0, + Self::High => 1, + Self::Critical => 2, + } + } +} + +#[derive(Serialize, Deserialize, Default)] +struct SpendAnchorsDisk { + day_key: String, + day_spend_start: f64, + week_key: String, + week_spend_start: f64, + last_spend: f64, +} + +fn anchors_path() -> PathBuf { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(appdata) + .join("ClaudeCodeUsageMonitor") + .join("spend_anchors.json") +} + +fn load_anchors() -> SpendAnchorsDisk { + std::fs::read_to_string(anchors_path()) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + .unwrap_or_default() +} + +fn save_anchors(anchors: &SpendAnchorsDisk) { + let path = anchors_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(anchors) { + let _ = std::fs::write(path, json); + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn local_day_key(secs: u64) -> String { + let (year, month, day, _) = unix_to_ymd_hms(secs); + format!("{year:04}-{month:02}-{day:02}") +} + +fn local_week_key(secs: u64) -> String { + let (year, month, day, _) = unix_to_ymd_hms(secs); + let weekday = unix_weekday_monday_zero(secs); + let day = day.saturating_sub(weekday as u32); + let (year, month, day) = normalize_ymd(year, month, day); + format!("{year:04}-{month:02}-{day:02}") +} + +fn unix_weekday_monday_zero(secs: u64) -> u64 { + let days = secs / 86_400; + (days + 3) % 7 +} + +fn normalize_ymd(mut year: u32, mut month: u32, mut day: u32) -> (u32, u32, u32) { + while day == 0 { + month = month.saturating_sub(1); + if month == 0 { + year -= 1; + month = 12; + } + day += days_in_month(year, month); + } + (year, month, day) +} + +fn days_in_month(year: u32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + _ => 28, + } +} + +fn is_leap_year(year: u32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +fn unix_to_ymd_hms(secs: u64) -> (u32, u32, u32, u32) { + let days = secs / 86_400; + let time = secs % 86_400; + let hour = (time / 3600) as u32; + + let mut remaining = days; + let mut year = 1970u32; + loop { + let year_days = if is_leap_year(year) { 366 } else { 365 }; + if remaining < year_days { + break; + } + remaining -= year_days; + year += 1; + } + + let month_lengths = [ + 31, + if is_leap_year(year) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 1u32; + for &len in &month_lengths { + if remaining < len { + break; + } + remaining -= len; + month += 1; + } + (year, month, (remaining + 1) as u32, hour) +} + +fn cycle_bounds(account: &AccountUsage, now: SystemTime) -> (SystemTime, SystemTime) { + if let Some(end) = account.credit_expiry { + let start = end + .checked_sub(Duration::from_secs(30 * 86_400)) + .unwrap_or(UNIX_EPOCH); + return (start, end); + } + + let secs = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (year, month, _, _) = unix_to_ymd_hms(secs); + let start_secs = month_start_unix(year, month); + let next_month = if month == 12 { (year + 1, 1) } else { (year, month + 1) }; + let end_secs = month_start_unix(next_month.0, next_month.1); + ( + UNIX_EPOCH + Duration::from_secs(start_secs), + UNIX_EPOCH + Duration::from_secs(end_secs), + ) +} + +fn month_start_unix(year: u32, month: u32) -> u64 { + let mut days = 0u64; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + for m in 1..month { + days += days_in_month(year, m) as u64; + } + days * 86_400 +} + +pub fn evaluate_pace(actual: f64, expected: f64) -> PaceLevel { + if expected <= 0.0 || actual <= 0.0 { + return PaceLevel::Ok; + } + let ratio = actual / expected; + if ratio <= 1.0 { + PaceLevel::Ok + } else if ratio <= 1.15 { + PaceLevel::High + } else { + PaceLevel::Critical + } +} + +fn elapsed_fraction(now: SystemTime, start: SystemTime, end: SystemTime) -> f64 { + let total = end.duration_since(start).map(|d| d.as_secs_f64()).unwrap_or(1.0); + if total <= 0.0 { + return 1.0; + } + let elapsed = now + .duration_since(start) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) + .clamp(0.0, total); + (elapsed / total).clamp(0.0, 1.0) +} + +fn day_fraction(secs: u64) -> f64 { + let seconds_into_day = secs % 86_400; + let fraction = seconds_into_day as f64 / 86_400.0; + fraction.clamp(1.0 / 24.0, 1.0) +} + +fn week_fraction(secs: u64) -> f64 { + let weekday = unix_weekday_monday_zero(secs); + let seconds_into_day = secs % 86_400; + let elapsed = weekday as f64 * 86_400.0 + seconds_into_day as f64; + let fraction = elapsed / (7.0 * 86_400.0); + fraction.clamp(1.0 / (7.0 * 24.0), 1.0) +} + +pub fn pace_accent(level: u8) -> crate::native_interop::Color { + match level { + 2 => crate::native_interop::Color::from_hex("#ef4444"), + 1 => crate::native_interop::Color::from_hex("#eab308"), + _ => crate::native_interop::Color::from_hex("#22c55e"), + } +} + +pub fn bar_fill_percent(actual: f64, cap: f64) -> f64 { + if cap <= 0.0 { + return 0.0; + } + (actual / cap * 100.0).clamp(0.0, 100.0) +} + +pub fn format_pace_fraction(spent: f64, cap: f64) -> String { + if cap <= 0.0 { + return format_usd(spent); + } + format!("{}/{}", format_usd(spent), format_usd(cap)) +} + +fn format_usd(amount: f64) -> String { + if amount >= 100.0 { + format!("${:.0}", amount.round()) + } else if amount >= 10.0 { + format!("${:.0}", amount) + } else if amount >= 1.0 { + format!("${:.1}", amount) + } else { + format!("${:.2}", amount) + } +} + +fn spend_close(a: f64, b: f64) -> bool { + (a - b).abs() < 0.01 +} + +/// Week/day pace uses cumulative billing spend minus anchors at period start. +/// If both anchors are pinned to the current total, week/day incorrectly read $0. +fn repair_stuck_anchors(anchors: &mut SpendAnchorsDisk, spend_used: f64) { + if spend_used <= 0.0 { + return; + } + if anchors.day_spend_start <= 0.01 && anchors.week_spend_start <= 0.01 { + return; + } + // Both period baselines pinned to the live total => zero delta for week and day. + // Legitimate day rollover only pins day_spend_start, so leave that case alone. + if spend_close(anchors.week_spend_start, spend_used) + && spend_close(anchors.day_spend_start, spend_used) + { + anchors.week_spend_start = 0.0; + anchors.day_spend_start = 0.0; + } +} + +/// Fresh install or anchor reset with zero baselines must not attribute existing +/// billing-cycle spend to today/week. +fn repair_inflated_period_anchors(anchors: &mut SpendAnchorsDisk, spend_used: f64) { + if spend_used <= 0.01 { + return; + } + if anchors.day_spend_start > 0.01 || anchors.week_spend_start > 0.01 { + return; + } + anchors.day_spend_start = spend_used; + anchors.week_spend_start = spend_used; +} + +fn update_anchors(spend_used: f64) -> SpendAnchorsDisk { + let secs = now_secs(); + let day_key = local_day_key(secs); + let week_key = local_week_key(secs); + let mut anchors = load_anchors(); + + repair_inflated_period_anchors(&mut anchors, spend_used); + if !spend_close(anchors.day_spend_start, spend_used) { + repair_stuck_anchors(&mut anchors, spend_used); + } + + // Billing cycle reset: spend dropped — re-anchor from zero, not from the new total. + if spend_used + 0.01 < anchors.last_spend { + anchors = SpendAnchorsDisk { + day_key: day_key.clone(), + day_spend_start: 0.0, + week_key: week_key.clone(), + week_spend_start: 0.0, + last_spend: spend_used, + }; + save_anchors(&anchors); + return anchors; + } + + if anchors.day_key.is_empty() { + anchors.day_key = day_key.clone(); + anchors.day_spend_start = spend_used; + } else if anchors.day_key != day_key { + anchors.day_key = day_key; + anchors.day_spend_start = anchors.last_spend; + } + + if anchors.week_key.is_empty() { + anchors.week_key = week_key.clone(); + anchors.week_spend_start = spend_used; + } else if anchors.week_key != week_key { + anchors.week_key = week_key; + anchors.week_spend_start = anchors.last_spend; + } + + anchors.last_spend = spend_used; + save_anchors(&anchors); + anchors +} + +pub fn compute_spend_pace(account: &AccountUsage) -> Option { + if account.spend_limit <= 0.0 { + return None; + } + + let now = SystemTime::now(); + let secs = now_secs(); + let anchors = update_anchors(account.spend_used); + + let month_actual = account.spend_used; + let week_actual = (account.spend_used - anchors.week_spend_start).max(0.0); + let day_actual = (account.spend_used - anchors.day_spend_start).max(0.0); + + let (cycle_start, cycle_end) = cycle_bounds(account, now); + let cycle_days = cycle_end + .duration_since(cycle_start) + .map(|d| d.as_secs_f64() / 86_400.0) + .unwrap_or(30.0) + .max(1.0); + + let linear_daily = account.spend_limit / cycle_days; + let linear_week = linear_daily * 7.0; + let month_fraction = elapsed_fraction(now, cycle_start, cycle_end); + let month_expected = account.spend_limit * month_fraction; + let week_expected = linear_week * week_fraction(secs); + let day_expected = linear_daily * day_fraction(secs); + + let month_level = evaluate_pace(month_actual, month_expected); + let week_level = evaluate_pace(week_actual, week_expected); + let day_level = evaluate_pace(day_actual, day_expected); + + Some(SpendPaceView { + credit_pct: account.credit_pct, + credit_expiry: account.credit_expiry, + slots: SpendPaceSlots { + month_actual, + month_cap: account.spend_limit, + month_expected, + month_level: month_level.as_bar_level(), + week_actual, + week_cap: linear_week, + week_expected, + week_level: week_level.as_bar_level(), + day_actual, + day_cap: linear_daily, + day_expected, + day_level: day_level.as_bar_level(), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluate_pace_thresholds() { + assert_eq!(evaluate_pace(90.0, 100.0), PaceLevel::Ok); + assert_eq!(evaluate_pace(110.0, 100.0), PaceLevel::High); + assert_eq!(evaluate_pace(120.0, 100.0), PaceLevel::Critical); + } + + #[test] + fn format_pace_fraction_rounds_dollars() { + assert_eq!(format_pace_fraction(57.2, 400.0), "$57/$400"); + assert_eq!(format_pace_fraction(13.4, 13.3), "$13/$13"); + } + + #[test] + fn repair_stuck_anchors_unpins_week_and_day() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-01".to_string(), + day_spend_start: 27.41, + week_key: "2026-06-30".to_string(), + week_spend_start: 27.41, + last_spend: 27.41, + }; + repair_stuck_anchors(&mut anchors, 27.41); + assert_eq!(anchors.week_spend_start, 0.0); + assert_eq!(anchors.day_spend_start, 0.0); + } + + #[test] + fn repair_stuck_anchors_leaves_day_rollover_alone() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-01".to_string(), + day_spend_start: 27.41, + week_key: "2026-06-30".to_string(), + week_spend_start: 0.0, + last_spend: 27.41, + }; + repair_stuck_anchors(&mut anchors, 27.41); + assert_eq!(anchors.week_spend_start, 0.0); + assert_eq!(anchors.day_spend_start, 27.41); + } + + #[test] + fn repair_inflated_anchors_pins_unknown_history() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-20".to_string(), + day_spend_start: 0.0, + week_key: "2026-07-20".to_string(), + week_spend_start: 0.0, + last_spend: 317.0, + }; + repair_inflated_period_anchors(&mut anchors, 317.42); + assert_eq!(anchors.day_spend_start, 317.42); + assert_eq!(anchors.week_spend_start, 317.42); + } + + #[test] + fn repair_inflated_anchors_skips_when_baseline_exists() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-20".to_string(), + day_spend_start: 12.0, + week_key: "2026-07-14".to_string(), + week_spend_start: 5.0, + last_spend: 20.0, + }; + repair_inflated_period_anchors(&mut anchors, 20.0); + assert_eq!(anchors.day_spend_start, 12.0); + assert_eq!(anchors.week_spend_start, 5.0); + } + + #[test] + fn repair_stuck_anchors_without_last_spend_match() { + let mut anchors = SpendAnchorsDisk { + day_key: "2026-07-01".to_string(), + day_spend_start: 27.41, + week_key: "2026-06-30".to_string(), + week_spend_start: 27.41, + last_spend: 26.0, + }; + repair_stuck_anchors(&mut anchors, 27.41); + assert_eq!(anchors.week_spend_start, 0.0); + assert_eq!(anchors.day_spend_start, 0.0); + } +} diff --git a/src/tray_icon.rs b/src/tray_icon.rs index e2502e2..d2234fa 100644 --- a/src/tray_icon.rs +++ b/src/tray_icon.rs @@ -256,12 +256,14 @@ pub fn create_icon(kind: TrayIconKind, percent: Option) -> HICON { bottom: size - margin, }; let mut text_wide: Vec = display_text.encode_utf16().collect(); - let _ = DrawTextW( - mem_dc, - &mut text_wide, - &mut text_rect, - DT_CENTER | DT_VCENTER | DT_SINGLELINE, - ); + if !text_wide.is_empty() { + let _ = DrawTextW( + mem_dc, + &mut text_wide, + &mut text_rect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE, + ); + } SelectObject(mem_dc, old_font); let _ = DeleteObject(font); diff --git a/src/window.rs b/src/window.rs index f6d261e..eba353d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -12,7 +12,7 @@ use windows::Win32::System::Registry::*; use windows::Win32::System::Threading::{CreateMutexW, WaitForSingleObject}; use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture}; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, ReleaseCapture, SetCapture, VK_LBUTTON}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -20,10 +20,12 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, + self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + TIMER_WIDGET_KEEPALIVE, + WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; +use crate::spend_pace; use crate::theme; use crate::tray_icon; use crate::updater::{self, InstallChannel, ReleaseDescriptor, UpdateCheckResult}; @@ -57,8 +59,21 @@ struct AppState { session_percent: f64, session_text: String, + session_label: String, weekly_percent: f64, weekly_text: String, + weekly_label: String, + account_pace_mode: bool, + show_credit_row: bool, + credit_percent: f64, + credit_text: String, + credit_label: String, + session_pace_level: u8, + weekly_pace_level: u8, + day_percent: f64, + day_text: String, + day_label: String, + day_pace_level: u8, codex_session_percent: f64, codex_session_text: String, codex_weekly_percent: f64, @@ -90,6 +105,11 @@ struct AppState { drag_start_client_x: i32, drag_start_offset: i32, + /// Screen origin for UpdateLayeredWindow when embedded (GetWindowRect lies on WS_CHILD). + layered_screen_x: i32, + layered_screen_y: i32, + layered_position_valid: bool, + widget_visible: bool, } @@ -103,6 +123,7 @@ enum UpdateStatus { } const RETRY_BASE_MS: u32 = 30_000; // 30 seconds +const AUTH_RETRY_BASE_MS: u32 = 120_000; // 2 minutes — keep retrying auth recovery in background const POLL_1_MIN: u32 = 60_000; const POLL_5_MIN: u32 = 300_000; @@ -116,6 +137,8 @@ const IDM_FREQ_15MIN: u16 = 12; const IDM_FREQ_1HOUR: u16 = 13; const IDM_START_WITH_WINDOWS: u16 = 20; const IDM_RESET_POSITION: u16 = 30; +/// Persisted in settings.json; resolved to max_offset at layout time. +const TRAY_OFFSET_LEFTMOST: i32 = -1; const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 40; const IDM_LANG_ENGLISH: u16 = 41; @@ -134,13 +157,17 @@ const IDM_MODEL_ANTIGRAVITY: u16 = 62; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; +const WM_APP_RECOVER_TASKBAR: u32 = WM_APP + 4; +const WM_APP_ENSURE_VISIBLE: u32 = WM_APP + 5; const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// How often the watchdog thread polls for an explorer.exe restart (which /// recreates the taskbar and wipes our tray-icon registration). -const TASKBAR_WATCH_INTERVAL_SECS: u64 = 2; +const TASKBAR_WATCH_INTERVAL_SECS: u64 = 1; +const TASKBAR_RECOVER_MAX_ATTEMPTS: u32 = 3; static SUPPRESS_TRAY_REPOSITION_UNTIL: Mutex> = Mutex::new(None); +static TASKBAR_RECOVER_FAILURES: AtomicU32 = AtomicU32::new(0); /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) static CURRENT_DPI: AtomicU32 = AtomicU32::new(96); @@ -225,6 +252,35 @@ fn relaunch_self() { } } +/// True when our widget HWND is still a live child of the given taskbar. +/// HWND reuse after an explorer restart can make stale handle comparisons lie; +/// parentage is the reliable signal. +fn is_widget_embedded_in_taskbar(widget_hwnd: HWND, taskbar_hwnd: HWND) -> bool { + unsafe { + IsWindow(widget_hwnd).as_bool() + && IsWindow(taskbar_hwnd).as_bool() + && GetParent(widget_hwnd).ok() == Some(taskbar_hwnd) + } +} + +fn recover_taskbar_embed(hwnd: HWND) { + let taskbar_index = { + let state = lock_state(); + state.as_ref().map(|s| s.taskbar_index).unwrap_or(0) + }; + diagnose::log("recover_taskbar_embed: re-attaching to taskbar"); + if attach_to_taskbar(hwnd, taskbar_index) { + position_at_taskbar(); + sync_tray_icons(hwnd); + render_layered(); + TASKBAR_RECOVER_FAILURES.store(0, Ordering::Relaxed); + diagnose::log("recover_taskbar_embed: success"); + } else { + diagnose::log("recover_taskbar_embed: attach failed, relaunching"); + relaunch_self(); + } +} + /// Detect explorer.exe restarts and recover from them. /// /// Once explorer destroys the taskbar, our embedded child window is destroyed @@ -234,22 +290,60 @@ fn relaunch_self() { fn spawn_taskbar_watchdog() { std::thread::spawn(move || loop { std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); - let stored = { + let (widget_hwnd, old_taskbar, embedded, widget_visible) = { let state = lock_state(); - state.as_ref().and_then(|s| s.taskbar_hwnd) + match state.as_ref() { + Some(s) => ( + s.hwnd.to_hwnd(), + s.taskbar_hwnd, + s.embedded, + s.widget_visible, + ), + None => continue, + } }; - // Only relevant once we have embedded into a taskbar at least once. - let Some(old) = stored else { + if !widget_visible { continue; - }; - let taskbars = native_interop::find_taskbars(); - if !taskbars.is_empty() && !taskbars.iter().any(|taskbar| taskbar.hwnd == old) { - let new = taskbars[0].hwnd; + } + if embedded { + let intact = old_taskbar + .is_some_and(|taskbar| is_widget_embedded_in_taskbar(widget_hwnd, taskbar)); + if intact { + TASKBAR_RECOVER_FAILURES.store(0, Ordering::Relaxed); + continue; + } + } else { + let taskbar_ok = old_taskbar.is_some_and(|taskbar| unsafe { IsWindow(taskbar).as_bool() }); + if taskbar_ok { + unsafe { + let _ = PostMessageW(widget_hwnd, WM_APP_ENSURE_VISIBLE, WPARAM(0), LPARAM(0)); + } + continue; + } + } + + let widget_alive = unsafe { IsWindow(widget_hwnd).as_bool() }; + diagnose::log(format!( + "watchdog: embed broken widget_alive={widget_alive} taskbar={:?}", + old_taskbar.map(|h| h.0) + )); + + if !widget_alive { + relaunch_self(); + continue; + } + + let failures = TASKBAR_RECOVER_FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + if failures >= TASKBAR_RECOVER_MAX_ATTEMPTS { diagnose::log(format!( - "watchdog: taskbar changed old={:?} new={:?} -> relaunching", - old.0, new.0 + "watchdog: {failures} in-process recoveries failed, relaunching" )); relaunch_self(); + continue; + } + + unsafe { + let _ = PostMessageW(widget_hwnd, WM_APP_RECOVER_TASKBAR, WPARAM(0), LPARAM(0)); } }); } @@ -298,7 +392,7 @@ fn settings_path() -> PathBuf { #[derive(Debug, Serialize, Deserialize)] struct SettingsFile { - #[serde(default)] + #[serde(default = "default_tray_offset")] tray_offset: i32, #[serde(default)] taskbar_index: usize, @@ -318,10 +412,41 @@ struct SettingsFile { show_antigravity: bool, } +fn default_tray_offset() -> i32 { + TRAY_OFFSET_LEFTMOST +} + +fn resolve_tray_offset(stored: i32, max_offset: i32) -> i32 { + if stored < 0 { + max_offset + } else { + stored.clamp(0, max_offset) + } +} + +fn max_tray_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + widget_width: i32, +) -> i32 { + let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); + let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + (tray_left - taskbar_rect.left - widget_width - content_left).max(0) +} + +fn resolved_tray_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + stored: i32, +) -> i32 { + let max_offset = max_tray_offset_for_taskbar(taskbar_hwnd, taskbar_rect, total_widget_width()); + resolve_tray_offset(stored, max_offset) +} + impl Default for SettingsFile { fn default() -> Self { Self { - tray_offset: 0, + tray_offset: TRAY_OFFSET_LEFTMOST, taskbar_index: 0, poll_interval_ms: default_poll_interval(), language: None, @@ -363,6 +488,11 @@ fn load_settings() -> SettingsFile { if !settings.show_claude_code && !settings.show_codex && !settings.show_antigravity { settings.show_claude_code = true; } + // Older builds clobbered leftmost placement by persisting tray_offset=0 on embed. + if settings.tray_offset == 0 { + settings.tray_offset = TRAY_OFFSET_LEFTMOST; + settings.taskbar_index = 0; + } settings } @@ -379,8 +509,14 @@ fn save_settings(settings: &SettingsFile) { fn save_state_settings() { let state = lock_state(); if let Some(s) = state.as_ref() { + // Persist the user's placement intent (-1 = leftmost), not the resolved pixel offset. + let tray_offset = if s.tray_offset < 0 { + TRAY_OFFSET_LEFTMOST + } else { + s.tray_offset + }; save_settings(&SettingsFile { - tray_offset: s.tray_offset, + tray_offset, taskbar_index: s.taskbar_index, poll_interval_ms: s.poll_interval_ms, language: s @@ -401,15 +537,35 @@ fn tray_icon_data_from_state() -> Vec { Some(s) if s.last_poll_ok => { let mut icons = Vec::new(); if s.show_claude_code { - icons.push(tray_icon::TrayIconData { - kind: tray_icon::TrayIconKind::Claude, - percent: Some(s.session_percent), - tooltip: format!( - "{} 5h: {} | 7d: {}", + let tooltip = if s.account_pace_mode { + let credit_pct = s + .data + .as_ref() + .and_then(|d| d.spend_pace.as_ref()) + .map(|p| p.credit_pct) + .unwrap_or(0.0); + format!( + "{} Mo: {} | Wk: {} | Dy: {} | Cr: {:.0}%", s.language.strings().claude_code_model, s.session_text, + s.weekly_text, + s.day_text, + credit_pct + ) + } else { + format!( + "{} {}: {} | {}: {}", + s.language.strings().claude_code_model, + s.session_label, + s.session_text, + s.weekly_label, s.weekly_text - ), + ) + }; + icons.push(tray_icon::TrayIconData { + kind: tray_icon::TrayIconKind::Claude, + percent: Some(s.session_percent), + tooltip, }); } if s.show_codex { @@ -494,6 +650,28 @@ fn toggle_widget_visibility(hwnd: HWND) { } } +/// Pick a taskbar that actually hosts the notification area. On multi-monitor +/// setups Windows can expose a spanning primary bar (often at a virtual top +/// edge) that has no TrayNotifyWnd; embedding there hides the widget. +fn resolve_taskbar_index(requested_index: usize, taskbars: &[native_interop::TaskbarWindow]) -> usize { + if taskbars.is_empty() { + return 0; + } + let capped = requested_index.min(taskbars.len() - 1); + if native_interop::find_descendant_window(taskbars[capped].hwnd, "TrayNotifyWnd").is_some() { + return capped; + } + for (index, taskbar) in taskbars.iter().enumerate() { + if native_interop::find_descendant_window(taskbar.hwnd, "TrayNotifyWnd").is_some() { + diagnose::log(format!( + "taskbar index {requested_index} has no TrayNotifyWnd; using index {index}" + )); + return index; + } + } + capped +} + fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { @@ -501,7 +679,7 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { return false; } - let index = requested_index.min(taskbars.len().saturating_sub(1)); + let index = resolve_taskbar_index(requested_index, &taskbars); let taskbar = taskbars[index]; diagnose::log(format!( "taskbar selected index={index} count={} hwnd={:?} rect=({}, {}, {}, {})", @@ -521,7 +699,7 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { native_interop::unhook_win_event(hook); } - native_interop::embed_in_taskbar(hwnd, taskbar.hwnd); + native_interop::raise_above_taskbar(hwnd, Some(taskbar.hwnd)); let tray_notify = native_interop::find_child_window(taskbar.hwnd, "TrayNotifyWnd"); if tray_notify.is_some() { @@ -540,13 +718,16 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { diagnose::log("tray event hook could not be installed"); } - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.taskbar_hwnd = Some(taskbar.hwnd); - s.tray_notify_hwnd = tray_notify; - s.win_event_hook = hook; - s.taskbar_index = index; - s.embedded = true; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.taskbar_hwnd = Some(taskbar.hwnd); + s.tray_notify_hwnd = tray_notify; + s.win_event_hook = hook; + s.taskbar_index = index; + s.embedded = false; + s.dragging = false; + } } true } @@ -573,12 +754,36 @@ fn tray_left_for_taskbar(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { tray_left } -fn clamp_offset_for_taskbar(taskbar_hwnd: HWND, taskbar_rect: RECT, offset: i32) -> i32 { - let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); - let max_offset = (tray_left - taskbar_rect.left - total_widget_width()).max(0); +fn clamp_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + offset: i32, + widget_width: i32, +) -> i32 { + let max_offset = max_tray_offset_for_taskbar(taskbar_hwnd, taskbar_rect, widget_width); offset.clamp(0, max_offset) } +/// Screen X for the layered popup widget. +fn popup_screen_x( + stored_tray_offset: i32, + resolved_tray_offset: i32, + _taskbar_hwnd: HWND, + taskbar_rect: RECT, + content_left: i32, + max_offset: i32, + max_x: i32, +) -> i32 { + let min_x = taskbar_rect.left; + let max_x_screen = taskbar_rect.left + max_x; + let x = if stored_tray_offset < 0 { + min_x + } else { + content_left + max_offset - resolved_tray_offset + taskbar_rect.left + }; + x.clamp(min_x, max_x_screen) +} + fn offset_for_drop_point( taskbar_hwnd: HWND, taskbar_rect: RECT, @@ -587,8 +792,9 @@ fn offset_for_drop_point( ) -> i32 { let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); let desired_left = pt.x - taskbar_rect.left - drag_start_client_x; - let offset = tray_left - taskbar_rect.left - total_widget_width() - desired_left; - clamp_offset_for_taskbar(taskbar_hwnd, taskbar_rect, offset) + let widget_width = total_widget_width(); + let offset = tray_left - taskbar_rect.left - widget_width - desired_left; + clamp_offset_for_taskbar(taskbar_hwnd, taskbar_rect, offset, widget_width) } fn now_unix_secs() -> u64 { @@ -634,6 +840,41 @@ fn schedule_auto_update_check(hwnd: HWND) { } } +fn waiting_usage_text() -> String { + "--".to_string() +} + +/// When a poll fails, keep the last successful values on screen when we have them. +/// Only show a waiting indicator when there is no cached data yet. +fn apply_poll_failure_display(state: &mut AppState) { + if state.data.is_some() { + return; + } + + let waiting = waiting_usage_text(); + if state.show_claude_code { + state.session_text = waiting.clone(); + state.weekly_text = waiting.clone(); + state.day_text = waiting.clone(); + state.credit_text = waiting.clone(); + } + if state.show_codex { + state.codex_session_text = waiting.clone(); + state.codex_weekly_text = waiting.clone(); + } + if state.show_antigravity { + state.antigravity_session_text = waiting.clone(); + state.antigravity_weekly_text = waiting.clone(); + } +} + +fn auth_retry_delay_ms(retry_count: u32, poll_interval_ms: u32) -> u32 { + let backoff = AUTH_RETRY_BASE_MS.saturating_mul( + 1u32.checked_shl(retry_count.saturating_sub(1)).unwrap_or(1), + ); + backoff.min(poll_interval_ms) +} + fn refresh_usage_texts(state: &mut AppState) { if !state.last_poll_ok { return; @@ -644,20 +885,95 @@ fn refresh_usage_texts(state: &mut AppState) { return; }; + // Reset labels to defaults before potentially overriding below + state.session_label = strings.session_window.to_string(); + state.weekly_label = strings.weekly_window.to_string(); + state.account_pace_mode = false; + state.show_credit_row = false; + state.credit_text.clear(); + state.credit_label.clear(); + state.day_text.clear(); + state.day_label.clear(); + if let Some(claude_code) = data.claude_code.as_ref() { + state.session_percent = claude_code.session.percentage; + state.weekly_percent = claude_code.weekly.percentage; state.session_text = poller::format_line(&claude_code.session, strings); state.weekly_text = poller::format_line(&claude_code.weekly, strings); + + // When the usage endpoint returned no rate-limit buckets (enterprise), show account rows + let has_rate_limit = claude_code.session.has_bucket || claude_code.weekly.has_bucket; + + diagnose::log(format!("refresh_usage_texts: has_rate_limit={has_rate_limit} account={}", data.account.is_some())); + if !has_rate_limit { + if let Some(pace) = data.spend_pace.as_ref() { + let slots = &pace.slots; + state.account_pace_mode = true; + state.session_label = "Mo".to_string(); + state.session_text = + spend_pace::format_pace_fraction(slots.month_actual, slots.month_cap); + state.session_percent = + spend_pace::bar_fill_percent(slots.month_actual, slots.month_cap); + state.session_pace_level = slots.month_level; + + state.weekly_label = "Wk".to_string(); + state.weekly_text = + spend_pace::format_pace_fraction(slots.week_actual, slots.week_cap); + state.weekly_percent = + spend_pace::bar_fill_percent(slots.week_actual, slots.week_cap); + state.weekly_pace_level = slots.week_level; + + state.day_label = "Dy".to_string(); + state.day_text = + spend_pace::format_pace_fraction(slots.day_actual, slots.day_cap); + state.day_percent = + spend_pace::bar_fill_percent(slots.day_actual, slots.day_cap); + state.day_pace_level = slots.day_level; + + if let Some(account) = data.account.as_ref() { + state.show_credit_row = false; // credit shown in tooltip; row omitted to stay within taskbar + state.credit_percent = account.credit_pct; + state.credit_text = + poller::format_credit_text(account.credit_pct, account.credit_expiry); + state.credit_label = "Cr".to_string(); + } + } else if let Some(account) = data.account.as_ref() { + diagnose::log(format!( + "refresh_usage_texts: setting Cr/Sp rows credit_pct={}", + account.credit_pct + )); + state.session_percent = account.credit_pct; + state.session_text = + poller::format_credit_text(account.credit_pct, account.credit_expiry); + state.session_label = "Cr".to_string(); + + let spend_pct = if account.spend_limit > 0.0 { + (account.spend_used / account.spend_limit * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + state.weekly_percent = spend_pct; + state.weekly_text = + poller::format_spend_text(account.spend_used, account.spend_limit); + state.weekly_label = "Sp".to_string(); + } + } } else if state.show_claude_code { - state.session_text = "!".to_string(); - state.weekly_text = "!".to_string(); + // Provider enabled but this poll returned no Claude data — keep prior text if any. + if state.session_text.is_empty() || state.session_text == "!" { + state.session_text = waiting_usage_text(); + state.weekly_text = waiting_usage_text(); + } } if let Some(codex) = data.codex.as_ref() { state.codex_session_text = poller::format_line(&codex.session, strings); state.codex_weekly_text = poller::format_line(&codex.weekly, strings); } else if state.show_codex { - state.codex_session_text = "!".to_string(); - state.codex_weekly_text = "!".to_string(); + if state.codex_session_text.is_empty() || state.codex_session_text == "!" { + state.codex_session_text = waiting_usage_text(); + state.codex_weekly_text = waiting_usage_text(); + } } if let Some(antigravity) = data.antigravity.as_ref() { @@ -669,8 +985,10 @@ fn refresh_usage_texts(state: &mut AppState) { poller::format_line(&antigravity.weekly, strings) }; } else if state.show_antigravity { - state.antigravity_session_text = "!".to_string(); - state.antigravity_weekly_text = "!".to_string(); + if state.antigravity_session_text.is_empty() || state.antigravity_session_text == "!" { + state.antigravity_session_text = waiting_usage_text(); + state.antigravity_weekly_text = waiting_usage_text(); + } } } @@ -1051,6 +1369,8 @@ const SEGMENT_COUNT: i32 = 10; const CORNER_RADIUS: i32 = 2; const LEFT_DIVIDER_W: i32 = 3; +/// Hit-test width for the drag handle — wider than the visual divider for usability. +const LEFT_DIVIDER_HIT_W: i32 = 10; const DIVIDER_RIGHT_MARGIN: i32 = 10; const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; @@ -1059,23 +1379,67 @@ const TEXT_WIDTH: i32 = 62; const MODEL_RIGHT_MARGIN: i32 = 3; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; +const WIDGET_HEIGHT_PACE: i32 = 48; +const WIDGET_HEIGHT_PACE_CREDIT: i32 = 88; + +fn widget_height(account_pace_mode: bool, show_credit_row: bool) -> i32 { + if account_pace_mode { + if show_credit_row { + WIDGET_HEIGHT_PACE_CREDIT + } else { + WIDGET_HEIGHT_PACE + } + } else { + WIDGET_HEIGHT + } +} -fn is_drag_handle_point(client_x: i32, client_y: i32) -> bool { +fn widget_height_for_state(state: &AppState) -> i32 { + widget_height(state.account_pace_mode, state.show_credit_row) +} + +fn is_drag_handle_point(client_x: i32, client_y: i32, state: &AppState) -> bool { + is_drag_handle_point_inner(client_x, client_y, state, false) +} + +fn is_drag_handle_point_verbose(client_x: i32, client_y: i32, state: &AppState) -> bool { + is_drag_handle_point_inner(client_x, client_y, state, true) +} + +fn is_drag_handle_point_inner(client_x: i32, client_y: i32, state: &AppState, verbose: bool) -> bool { + // MoveWindow / SetWindowPos on a taskbar child freezes Explorer; keep embedded + // widgets docked beside the tray (use popup fallback for free positioning). + if state.embedded { + return false; + } let divider_h = sc(25); - let divider_top = (sc(WIDGET_HEIGHT) - divider_h) / 2; + let widget_h = sc(widget_height_for_state(state)); + let divider_top = (widget_h - divider_h) / 2; + let hit_w = sc(LEFT_DIVIDER_HIT_W); + if verbose { + diagnose::log(&format!( + "is_drag_handle_point: client=({},{}) widget_h={} divider_top={} divider_h={} hit_w={} dpi={}", + client_x, client_y, widget_h, divider_top, divider_h, hit_w, + CURRENT_DPI.load(Ordering::Relaxed) + )); + } client_x >= 0 - && client_x < sc(LEFT_DIVIDER_W) + && client_x < hit_w && client_y >= divider_top && client_y < divider_top + divider_h } fn cursor_is_on_drag_handle(hwnd: HWND) -> bool { + let state = lock_state(); + let Some(s) = state.as_ref() else { + return false; + }; unsafe { let mut pt = POINT::default(); if GetCursorPos(&mut pt).is_err() || !ScreenToClient(hwnd, &mut pt).as_bool() { return false; } - is_drag_handle_point(pt.x, pt.y) + is_drag_handle_point(pt.x, pt.y, s) } } @@ -1125,6 +1489,14 @@ fn total_widget_width() -> i32 { total_widget_width_for(active_models) } +/// Width/height used for both MoveWindow and the layered bitmap so they always match. +fn resolved_widget_size(account_pace_mode: bool, show_credit_row: bool) -> (i32, i32) { + refresh_dpi(); + let width = total_widget_width(); + let height = sc(widget_height(account_pace_mode, show_credit_row)); + (width, height) +} + fn claude_accent_color() -> Color { Color::from_hex("#D97757") } @@ -1297,8 +1669,21 @@ pub fn run() { install_channel, session_percent: 0.0, session_text: "--".to_string(), + session_label: language.strings().session_window.to_string(), weekly_percent: 0.0, weekly_text: "--".to_string(), + weekly_label: language.strings().weekly_window.to_string(), + account_pace_mode: false, + show_credit_row: false, + credit_percent: 0.0, + credit_text: String::new(), + credit_label: String::new(), + session_pace_level: 0, + weekly_pace_level: 0, + day_percent: 0.0, + day_text: String::new(), + day_label: String::new(), + day_pace_level: 0, codex_session_percent: 0.0, codex_session_text: "--".to_string(), codex_weekly_percent: 0.0, @@ -1326,17 +1711,18 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_client_x: 0, drag_start_offset: 0, + layered_screen_x: 0, + layered_screen_y: 0, + layered_position_valid: false, widget_visible: settings.widget_visible, }); } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { - embedded = true; - } + let attached = attach_to_taskbar(hwnd, settings.taskbar_index); - // If not embedded, fall back to topmost popup with SetLayeredWindowAttributes - if !embedded { + // Popup layered window (anchored to taskbar when attach succeeds) + if !attached { let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA); let _ = SetWindowPos( hwnd, @@ -1350,10 +1736,14 @@ pub fn run() { } // Register system tray icon(s) + diagnose::log("before sync_tray_icons"); sync_tray_icons(hwnd); + diagnose::log("after sync_tray_icons"); // Position and show (only if widget_visible preference is true) + diagnose::log("before position_at_taskbar"); position_at_taskbar(); + diagnose::log("before ShowWindow"); if settings.widget_visible { let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); } @@ -1371,6 +1761,7 @@ pub fn run() { .unwrap_or(POLL_15_MIN) }; SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None); + SetTimer(hwnd, TIMER_WIDGET_KEEPALIVE, 15_000, None); // Watch for explorer.exe restarts so we can re-embed and re-add the tray // icon (the shell discards tray registrations when it restarts). This @@ -1418,12 +1809,14 @@ fn render_layered() { let ( hwnd_val, is_dark, - embedded, + _embedded, strings, session_pct, session_text, + session_label, weekly_pct, weekly_text, + weekly_label, codex_session_pct, codex_session_text, codex_weekly_pct, @@ -1435,6 +1828,17 @@ fn render_layered() { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + credit_text, + credit_label, + day_pct, + day_text, + day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, ) = { let state = lock_state(); match state.as_ref() { @@ -1445,8 +1849,10 @@ fn render_layered() { s.language.strings(), s.session_percent, s.session_text.clone(), + s.session_label.clone(), s.weekly_percent, s.weekly_text.clone(), + s.weekly_label.clone(), s.codex_session_percent, s.codex_session_text.clone(), s.codex_weekly_percent, @@ -1458,6 +1864,17 @@ fn render_layered() { s.show_claude_code, s.show_codex, s.show_antigravity, + s.account_pace_mode, + s.show_credit_row, + s.credit_percent, + s.credit_text.clone(), + s.credit_label.clone(), + s.day_percent, + s.day_text.clone(), + s.day_label.clone(), + s.session_pace_level, + s.weekly_pace_level, + s.day_pace_level, ), None => return, } @@ -1465,16 +1882,11 @@ fn render_layered() { let hwnd = hwnd_val.to_hwnd(); - // For non-embedded fallback, just invalidate and let WM_PAINT handle it - if !embedded { - unsafe { - let _ = InvalidateRect(hwnd, None, false); - } - return; + unsafe { + native_interop::ensure_layered_style(hwnd); } - let width = total_widget_width(); - let height = sc(WIDGET_HEIGHT); + let (width, height) = resolved_widget_size(account_pace_mode, show_credit_row); let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); @@ -1496,7 +1908,7 @@ fn render_layered() { }; unsafe { - let screen_dc = GetDC(hwnd); + let screen_dc = GetDC(HWND::default()); let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { @@ -1518,7 +1930,7 @@ fn render_layered() { if dib.is_invalid() || bits.is_null() { let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); + ReleaseDC(HWND::default(), screen_dc); return; } @@ -1540,8 +1952,10 @@ fn render_layered() { strings, session_pct, &session_text, + &session_label, weekly_pct, &weekly_text, + &weekly_label, codex_session_pct, &codex_session_text, codex_weekly_pct, @@ -1553,24 +1967,55 @@ fn render_layered() { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + &credit_text, + &credit_label, + day_pct, + &day_text, + &day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, &codex_accent, &antigravity_accent, ); - // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). - // Content pixels → fully opaque (preserves ClearType sub-pixel rendering). - let bg_bgr = bg_color.to_colorref(); + // Embedded: paint an opaque taskbar-coloured background so pinned icons never + // bleed through if UpdateLayeredWindow and MoveWindow ever disagree by a few px. let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); for px in pixel_data.iter_mut() { let rgb = *px & 0x00FFFFFF; - if rgb == bg_bgr { - *px = 0x01000000; - } else { - *px = rgb | 0xFF000000; - } + *px = rgb | 0xFF000000; } - // Push to window via UpdateLayeredWindow + // Push to window via UpdateLayeredWindow — always use explicit screen coords. + // GetWindowRect on WS_CHILD layered windows embedded in Shell_TrayWnd returns bogus + // screen Y (often thousands of pixels off); use coords stored in position_at_taskbar. + let (layered_x, layered_y) = { + let state = lock_state(); + match state.as_ref() { + Some(s) if s.layered_position_valid => { + (s.layered_screen_x, s.layered_screen_y) + } + _ => { + let mut window_rect = RECT::default(); + if GetWindowRect(hwnd, &mut window_rect).is_err() { + SelectObject(mem_dc, old_bmp); + let _ = DeleteObject(dib); + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + return; + } + (window_rect.left, window_rect.top) + } + } + }; + let pt_dest = POINT { + x: layered_x, + y: layered_y, + }; let pt_src = POINT { x: 0, y: 0 }; let sz = SIZE { cx: width, @@ -1586,7 +2031,7 @@ fn render_layered() { let _ = UpdateLayeredWindow( hwnd, screen_dc, - None, + Some(&pt_dest), Some(&sz), mem_dc, Some(&pt_src), @@ -1595,11 +2040,27 @@ fn render_layered() { ULW_ALPHA, ); + if !_embedded { + let taskbar_hwnd = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); + if let Some(taskbar_hwnd) = taskbar_hwnd { + native_interop::position_above_taskbar( + hwnd, + taskbar_hwnd, + layered_x, + layered_y, + width, + height, + ); + } else { + native_interop::position_topmost_popup(hwnd, layered_x, layered_y, width, height); + } + } + // Cleanup SelectObject(mem_dc, old_bmp); let _ = DeleteObject(dib); let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); + ReleaseDC(HWND::default(), screen_dc); } } @@ -1613,11 +2074,13 @@ fn paint_content( text_color: &Color, accent: &Color, track: &Color, - strings: Strings, + _strings: Strings, session_pct: f64, session_text: &str, + session_label: &str, weekly_pct: f64, weekly_text: &str, + weekly_label: &str, codex_session_pct: f64, codex_session_text: &str, codex_weekly_pct: f64, @@ -1629,6 +2092,17 @@ fn paint_content( show_claude_code: bool, show_codex: bool, show_antigravity: bool, + account_pace_mode: bool, + show_credit_row: bool, + credit_pct: f64, + credit_text: &str, + credit_label: &str, + day_pct: f64, + day_text: &str, + day_label: &str, + session_pace_level: u8, + weekly_pace_level: u8, + day_pace_level: u8, codex_accent: &Color, antigravity_accent: &Color, ) { @@ -1682,8 +2156,35 @@ fn paint_content( let _ = DeleteObject(right_brush); let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); - let row2_y = height - sc(5) - sc(SEGMENT_H); - let row1_y = row2_y - sc(10) - sc(SEGMENT_H); + let bottom_y = height - sc(5) - sc(SEGMENT_H); + let (mo_y, wk_y, dy_y, credit_y) = if account_pace_mode { + let row_gap = sc(1); + let dy_y = bottom_y; + let wk_y = dy_y - row_gap - sc(SEGMENT_H); + let mo_y = wk_y - row_gap - sc(SEGMENT_H); + let credit_y = if show_credit_row { + Some(mo_y - row_gap - sc(SEGMENT_H)) + } else { + None + }; + (mo_y, wk_y, dy_y, credit_y) + } else { + let wk_y = bottom_y; + let mo_y = wk_y - sc(10) - sc(SEGMENT_H); + (mo_y, wk_y, bottom_y, None) + }; + + let claude_session_accent = if account_pace_mode { + spend_pace::pace_accent(session_pace_level) + } else { + *accent + }; + let claude_weekly_accent = if account_pace_mode { + spend_pace::pace_accent(weekly_pace_level) + } else { + *accent + }; + let claude_day_accent = spend_pace::pace_accent(day_pace_level); let _ = SetBkMode(hdc, TRANSPARENT); let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -1707,13 +2208,37 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); + if let Some(credit_y) = credit_y { + draw_row( + hdc, + content_x, + credit_y, + is_dark, + text_color, + credit_label, + credit_pct, + credit_text, + codex_session_pct, + codex_session_text, + antigravity_session_pct, + antigravity_session_text, + show_claude_code, + show_codex, + show_antigravity, + accent, + codex_accent, + antigravity_accent, + track, + ); + } + draw_row( hdc, content_x, - row1_y, + mo_y, is_dark, text_color, - strings.session_window, + session_label, session_pct, session_text, codex_session_pct, @@ -1723,7 +2248,7 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_session_accent, codex_accent, antigravity_accent, track, @@ -1731,10 +2256,10 @@ fn paint_content( draw_row( hdc, content_x, - row2_y, + wk_y, is_dark, text_color, - strings.weekly_window, + weekly_label, weekly_pct, weekly_text, codex_weekly_pct, @@ -1744,11 +2269,34 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_weekly_accent, codex_accent, antigravity_accent, track, ); + if account_pace_mode { + draw_row( + hdc, + content_x, + dy_y, + is_dark, + text_color, + day_label, + day_pct, + day_text, + codex_weekly_pct, + codex_weekly_text, + antigravity_weekly_pct, + antigravity_weekly_text, + show_claude_code, + show_codex, + show_antigravity, + &claude_day_accent, + codex_accent, + antigravity_accent, + track, + ); + } SelectObject(hdc, old_font); let _ = DeleteObject(font); @@ -1852,35 +2400,35 @@ fn do_poll(send_hwnd: SendHwnd) { should_notify = true; } s.force_notify_auth_error = false; + // Still watch credential files for fast recovery after re-login, + // but also keep TIMER_POLL actively retrying so a silent token + // refresh (or transient 401) self-heals without waiting for a + // file change — otherwise the widget sticks on "!" until restart. s.auth_error_paused_polling = true; s.auth_watch_mode = watch_mode; s.auth_watch_snapshot = watch_snapshot; - s.session_text = "!".to_string(); - s.weekly_text = "!".to_string(); - s.codex_session_text = "!".to_string(); - s.codex_weekly_text = "!".to_string(); - s.antigravity_session_text = "!".to_string(); - s.antigravity_weekly_text = "!".to_string(); + apply_poll_failure_display(s); s.retry_count = s.retry_count.saturating_add(1); + let retry_ms = + auth_retry_delay_ms(s.retry_count, s.poll_interval_ms); + diagnose::log(format!( + "auth poll failed; keeping last data and retrying in {retry_ms}ms" + )); unsafe { let _ = KillTimer(hwnd, TIMER_POLL); let _ = KillTimer(hwnd, TIMER_RESET_POLL); let _ = KillTimer(hwnd, TIMER_COUNTDOWN); - SetTimer(hwnd, TIMER_POLL, s.poll_interval_ms, None); + SetTimer(hwnd, TIMER_POLL, retry_ms, None); } } _ => { - // Transient network / credential-missing errors: exponential backoff. + // Transient network errors: exponential backoff. + // Keep last good values on screen when available. s.force_notify_auth_error = false; s.auth_error_paused_polling = false; s.auth_watch_mode = poller::CredentialWatchMode::ActiveSource; s.auth_watch_snapshot.clear(); - s.session_text = "...".to_string(); - s.weekly_text = "...".to_string(); - s.codex_session_text = "...".to_string(); - s.codex_weekly_text = "...".to_string(); - s.antigravity_session_text = "...".to_string(); - s.antigravity_weekly_text = "...".to_string(); + apply_poll_failure_display(s); s.retry_count = s.retry_count.saturating_add(1); let backoff = RETRY_BASE_MS.saturating_mul( 1u32.checked_shl(s.retry_count - 1).unwrap_or(u32::MAX), @@ -2060,11 +2608,235 @@ fn tray_reposition_is_suppressed() -> bool { } } +fn drag_button_held() -> bool { + unsafe { (GetAsyncKeyState(VK_LBUTTON.0 as i32) as u16 & 0x8000) != 0 } +} + +fn update_drag_reposition_from_cursor() { + let mut pt = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut pt); + } + let move_target = { + let mut state = lock_state(); + let s = match state.as_mut() { + Some(s) => s, + None => return, + }; + if s.embedded { + return; + } + + let delta = s.drag_start_mouse_x - pt.x; + let mut new_offset = s.drag_start_offset + delta; + if new_offset < 0 { + new_offset = 0; + } + + let taskbar_hwnd = s.taskbar_hwnd; + let embedded = s.embedded; + let hwnd_val = s.hwnd.to_hwnd(); + + if let Some(taskbar_hwnd) = taskbar_hwnd { + if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { + let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); + let widget_width = total_widget_width_for_state(s); + let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let max_x = (tray_left - taskbar_rect.left - widget_width).max(content_left); + let max_offset = (max_x - content_left).max(0); + if new_offset > max_offset { + new_offset = max_offset; + } + + s.tray_offset = new_offset; + + let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; + let anchor_top = taskbar_rect.top; + let anchor_height = taskbar_height; + let widget_height = sc(widget_height(s.account_pace_mode, s.show_credit_row)); + let y = compute_anchor_y(anchor_top, anchor_height, widget_height); + let x = if embedded { + (content_left + max_offset - new_offset).clamp(content_left, max_x) + } else { + popup_screen_x( + s.tray_offset, + new_offset, + taskbar_hwnd, + taskbar_rect, + content_left, + max_offset, + max_x, + ) + }; + diagnose::log(&format!("update_drag: pt.x={} delta={} new_offset={} x={} embedded={}", pt.x, s.drag_start_mouse_x - pt.x, new_offset, x, embedded)); + Some(( + hwnd_val, + embedded, + x, + y, + taskbar_rect.top, + widget_width, + widget_height, + )) + } else { + s.tray_offset = new_offset; + None + } + } else { + s.tray_offset = new_offset; + None + } + }; + + if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) = move_target + { + if embedded { + native_interop::move_window_async( + hwnd_val, + x, + y - taskbar_top, + widget_width, + widget_height, + ); + } else { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.layered_screen_x = x; + s.layered_screen_y = y; + s.layered_position_valid = true; + } + } + if let Some(taskbar_hwnd) = lock_state().as_ref().and_then(|s| s.taskbar_hwnd) { + native_interop::position_above_taskbar( + hwnd_val, + taskbar_hwnd, + x, + y, + widget_width, + widget_height, + ); + } else { + native_interop::position_topmost_popup(hwnd_val, x, y, widget_width, widget_height); + } + } + } +} + +fn start_drag_reposition(hwnd: HWND, pt: POINT, client_x: i32) { + let embedded = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if s.embedded { + return; + } + s.dragging = true; + s.drag_start_mouse_x = pt.x; + s.drag_start_client_x = client_x; + s.drag_start_offset = s + .taskbar_hwnd + .and_then(|taskbar_hwnd| { + native_interop::get_taskbar_rect(taskbar_hwnd) + .map(|rect| resolved_tray_offset_for_taskbar(taskbar_hwnd, rect, s.tray_offset)) + }) + .unwrap_or(0); + s.embedded + } else { + return; + } + }; + diagnose::log(&format!("start_drag_reposition embedded={} pt=({},{}) client_x={}", embedded, pt.x, pt.y, client_x)); + unsafe { + if embedded { + // SetCapture on a taskbar child freezes Explorer; poll via timer instead. + let _ = SetTimer(hwnd, TIMER_DRAG, 16, None); + } else { + let _ = SetCapture(hwnd); + } + } +} + +fn finalize_drag_reposition(hwnd: HWND, pt: POINT) { + let drag_result = { + let state = lock_state(); + if let Some(s) = state.as_ref() { + if s.dragging { + Some((s.taskbar_index, s.drag_start_client_x)) + } else { + None + } + } else { + None + } + }; + if let Some((current_taskbar_index, drag_start_client_x)) = drag_result { + if let Some((target_index, target_taskbar)) = taskbar_at_point(pt) { + if target_index != current_taskbar_index { + let new_offset = offset_for_drop_point( + target_taskbar.hwnd, + target_taskbar.rect, + pt, + drag_start_client_x, + ); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.tray_offset = new_offset; + } + } + if attach_to_taskbar(hwnd, target_index) { + position_at_taskbar(); + render_layered(); + } + } + } + } + finish_drag_reposition(); +} + +fn finish_drag_reposition() -> bool { + let (was_dragging, hwnd) = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let was = s.dragging; + s.dragging = false; + (was, s.hwnd.to_hwnd()) + } else { + (false, HWND::default()) + } + }; + unsafe { + let _ = KillTimer(hwnd, TIMER_DRAG); + let _ = ReleaseCapture(); + } + if was_dragging { + save_state_settings(); + position_at_taskbar(); + render_layered(); + } + was_dragging +} + +fn ensure_popup_visible() { + let (visible, dragging, embedded) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.widget_visible, s.dragging, s.embedded), + None => return, + } + }; + if !visible || dragging || embedded { + return; + } + position_at_taskbar(); + render_layered(); +} + fn position_at_taskbar() { refresh_dpi(); // Drop the app-state lock before any Win32 call that may synchronously // re-enter our window procedure. - let (hwnd, embedded, tray_offset, taskbar_hwnd) = { + let (hwnd, tray_offset, taskbar_index) = { let state = lock_state(); let s = match state.as_ref() { Some(s) => s, @@ -2076,15 +2848,36 @@ fn position_at_taskbar() { return; } - let taskbar_hwnd = match s.taskbar_hwnd { - Some(h) => h, - None => { - diagnose::log("position_at_taskbar skipped: no taskbar handle"); + (s.hwnd.to_hwnd(), s.tray_offset, s.taskbar_index) + }; + + let taskbar_hwnd = { + let current = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); + let valid = current.is_some_and(|h| unsafe { IsWindow(h).as_bool() }); + if valid { + current.unwrap() + } else { + let taskbars = native_interop::find_taskbars(); + if taskbars.is_empty() { + diagnose::log("position_at_taskbar skipped: no taskbar found"); return; } - }; - - (s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd) + let index = resolve_taskbar_index(taskbar_index, &taskbars); + let selected = taskbars[index].hwnd; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.taskbar_hwnd = Some(selected); + s.taskbar_index = index; + s.embedded = false; + } + } + diagnose::log(format!( + "position_at_taskbar: re-bound taskbar index={index} hwnd={:?}", + selected + )); + selected + } }; let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { @@ -2106,44 +2899,105 @@ fn position_at_taskbar() { } } - let widget_width = total_widget_width(); - let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); - let tray_offset = tray_offset.clamp(0, max_offset); - let offset_changed = { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - if s.tray_offset != tray_offset { - s.tray_offset = tray_offset; - true - } else { - false + let account_pace_mode = lock_state() + .as_ref() + .map(|s| (s.account_pace_mode, s.show_credit_row)) + .unwrap_or((false, false)); + let (widget_width, widget_height) = + resolved_widget_size(account_pace_mode.0, account_pace_mode.1); + let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let max_x = (tray_left - taskbar_rect.left - widget_width).max(content_left); + let max_offset = (max_x - content_left).max(0); + let stored_tray_offset = tray_offset; + let tray_offset = resolve_tray_offset(stored_tray_offset, max_offset); + let widget_visible = lock_state() + .as_ref() + .map(|s| s.widget_visible) + .unwrap_or(true); + + let embedded = lock_state() + .as_ref() + .map(|s| s.embedded) + .unwrap_or(false); + + if embedded && widget_height > taskbar_height { + native_interop::detach_from_taskbar(hwnd); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.embedded = false; } - } else { - false } - }; - if offset_changed { - save_state_settings(); + diagnose::log(format!( + "detached from taskbar: widget_height={widget_height} > taskbar_height={taskbar_height}" + )); } - let widget_height = sc(WIDGET_HEIGHT); + let embedded = lock_state() + .as_ref() + .map(|s| s.embedded) + .unwrap_or(false); + let y = compute_anchor_y(anchor_top, anchor_height, widget_height); + if embedded { - // Child window: coordinates relative to parent (taskbar) - let x = tray_left - taskbar_rect.left - widget_width - tray_offset; - native_interop::move_window(hwnd, x, y - taskbar_rect.top, widget_width, widget_height); + let mut x = if stored_tray_offset < 0 { + 0 + } else { + content_left + max_offset - tray_offset + }; + x = x.clamp(0, max_x); + let y_child = compute_anchor_y(anchor_top, anchor_height, widget_height) - anchor_top; + let screen_x = taskbar_rect.left + x; + let screen_y = compute_anchor_y(anchor_top, anchor_height, widget_height); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.layered_screen_x = screen_x; + s.layered_screen_y = screen_y; + } + } + native_interop::move_window(hwnd, x, y_child, widget_width, widget_height); diagnose::log(format!( - "positioned embedded widget at x={x} y={} w={widget_width} h={widget_height}", - y - taskbar_rect.top + "positioned embedded widget at x={x} y={y_child} screen=({screen_x},{screen_y}) w={widget_width} h={widget_height} content_left={content_left}" )); } else { - // Topmost popup: screen coordinates - let x = tray_left - widget_width - tray_offset; - native_interop::move_window(hwnd, x, y, widget_width, widget_height); + let x = popup_screen_x( + stored_tray_offset, + tray_offset, + taskbar_hwnd, + taskbar_rect, + content_left, + max_offset, + max_x, + ); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.layered_screen_x = x; + s.layered_screen_y = y; + s.layered_position_valid = true; + } + } + native_interop::position_above_taskbar( + hwnd, + taskbar_hwnd, + x, + y, + widget_width, + widget_height, + ); diagnose::log(format!( - "positioned fallback widget at x={x} y={y} w={widget_width} h={widget_height}" + "positioned popup widget at x={x} y={y} w={widget_width} h={widget_height} pin_right={} content_left={content_left}", + native_interop::pin_band_right(taskbar_hwnd, taskbar_rect) )); } + if widget_visible { + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + render_layered(); + } } fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 { @@ -2206,26 +3060,32 @@ unsafe extern "system" fn wnd_proc( ) -> LRESULT { match msg { WM_PAINT => { - // For non-embedded fallback, paint normally - let embedded = { - let state = lock_state(); - state.as_ref().map(|s| s.embedded).unwrap_or(false) - }; - if embedded { - // Layered windows don't use WM_PAINT; just validate the region - let mut ps = PAINTSTRUCT::default(); - let _ = BeginPaint(hwnd, &mut ps); - let _ = EndPaint(hwnd, &ps); - } else { - let mut ps = PAINTSTRUCT::default(); - let hdc = BeginPaint(hwnd, &mut ps); - paint(hdc, hwnd); - let _ = EndPaint(hwnd, &ps); - } + // Layered windows render via UpdateLayeredWindow; validate the region only. + let mut ps = PAINTSTRUCT::default(); + let _ = BeginPaint(hwnd, &mut ps); + let _ = EndPaint(hwnd, &mut ps); LRESULT(0) } WM_ERASEBKGND => LRESULT(1), WM_DISPLAYCHANGE | WM_DPICHANGED_MSG | WM_SETTINGCHANGE => { + static LAST_DISPLAY_REPOSITION: Mutex> = + Mutex::new(None); + let should_reposition = { + let mut last = LAST_DISPLAY_REPOSITION.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + if last + .map(|t| now.duration_since(t).as_millis() > 500) + .unwrap_or(true) + { + *last = Some(now); + true + } else { + false + } + }; + if !should_reposition { + return LRESULT(0); + } if msg == WM_DPICHANGED_MSG { let new_dpi = (wparam.0 & 0xFFFF) as u32; CURRENT_DPI.store(new_dpi, Ordering::Relaxed); @@ -2243,19 +3103,24 @@ unsafe extern "system" fn wnd_proc( let timer_id = wparam.0; match timer_id { TIMER_POLL => { - let auth_watch = { - let state = lock_state(); - state.as_ref().map(|s| { - ( - s.auth_error_paused_polling, - s.auth_watch_mode, - s.auth_watch_snapshot.clone(), - ) - }) - }; - match auth_watch { - Some((true, watch_mode, previous_snapshot)) => { - let current_snapshot = poller::credential_watch_snapshot(watch_mode); + // Always poll on the timer — including during auth-error recovery. + // Credential-file watches still update the snapshot when it changes so + // a re-login is detected immediately on the next tick, but we no longer + // skip the poll when the snapshot is unchanged (that left "!" stuck). + { + let auth_watch = { + let state = lock_state(); + state.as_ref().map(|s| { + ( + s.auth_error_paused_polling, + s.auth_watch_mode, + s.auth_watch_snapshot.clone(), + ) + }) + }; + if let Some((true, watch_mode, previous_snapshot)) = auth_watch { + let current_snapshot = + poller::credential_watch_snapshot(watch_mode); if current_snapshot != previous_snapshot { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -2265,21 +3130,13 @@ unsafe extern "system" fn wnd_proc( s.auth_watch_snapshot = current_snapshot; } } - drop(state); - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); } } - Some((false, _, _)) => { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); - } - None => {} } + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); } TIMER_COUNTDOWN => { update_display(); @@ -2304,6 +3161,35 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_WIDGET_KEEPALIVE => { + ensure_popup_visible(); + } + TIMER_DRAG => { + let (dragging, embedded, tray_offset) = { + let state = lock_state(); + state + .as_ref() + .map(|s| (s.dragging, s.embedded, s.tray_offset)) + .unwrap_or((false, false, 0)) + }; + if !dragging || embedded { + if dragging && embedded { + finish_drag_reposition(); + } + unsafe { + let _ = KillTimer(hwnd, TIMER_DRAG); + } + } else if !drag_button_held() { + let mut pt = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut pt); + } + diagnose::log(&format!("TIMER_DRAG: button released, finalizing at offset={}", tray_offset)); + finalize_drag_reposition(hwnd, pt); + } else { + update_drag_reposition_from_cursor(); + } + } _ => {} } LRESULT(0) @@ -2311,6 +3197,7 @@ unsafe extern "system" fn wnd_proc( WM_APP_USAGE_UPDATED => { check_theme_change(); check_language_change(); + position_at_taskbar(); render_layered(); schedule_countdown_timer(); suppress_tray_reposition_for(Duration::from_millis( @@ -2323,17 +3210,23 @@ unsafe extern "system" fn wnd_proc( schedule_auto_update_check(hwnd); LRESULT(0) } + WM_APP_RECOVER_TASKBAR => { + recover_taskbar_embed(hwnd); + LRESULT(0) + } + msg if msg == WM_APP_ENSURE_VISIBLE => { + ensure_popup_visible(); + LRESULT(0) + } WM_SETCURSOR => { let is_dragging = { let state = lock_state(); - state.as_ref().map(|s| s.dragging).unwrap_or(false) + state + .as_ref() + .map(|s| s.dragging) + .unwrap_or(false) }; - if is_dragging { - let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); - SetCursor(cursor); - return LRESULT(1); - } - if cursor_is_on_drag_handle(hwnd) { + if is_dragging || cursor_is_on_drag_handle(hwnd) { let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); SetCursor(cursor); return LRESULT(1); @@ -2343,160 +3236,55 @@ unsafe extern "system" fn wnd_proc( WM_LBUTTONDOWN => { let client_x = (lparam.0 & 0xFFFF) as i16 as i32; let client_y = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32; - if !is_drag_handle_point(client_x, client_y) { - return LRESULT(0); - } - + diagnose::log(&format!("WM_LBUTTONDOWN client_x={} client_y={}", client_x, client_y)); + { + let state = lock_state(); + let Some(s) = state.as_ref() else { + return LRESULT(0); + }; + let on_handle = is_drag_handle_point_verbose(client_x, client_y, s); + diagnose::log(&format!("WM_LBUTTONDOWN on_drag_handle={} embedded={}", on_handle, s.embedded)); + if !on_handle { + return LRESULT(0); + } + } // drop state before start_drag_reposition re-acquires it let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.dragging = true; - s.drag_start_mouse_x = pt.x; - s.drag_start_client_x = client_x; - s.drag_start_offset = s.tray_offset; - } - SetCapture(hwnd); + start_drag_reposition(hwnd, pt, client_x); LRESULT(0) } WM_MOUSEMOVE => { let is_dragging = { let state = lock_state(); - state.as_ref().map(|s| s.dragging).unwrap_or(false) + state + .as_ref() + .map(|s| s.dragging && !s.embedded) + .unwrap_or(false) }; if is_dragging { - let mut pt = POINT::default(); - let _ = GetCursorPos(&mut pt); - let move_target = { - let mut state = lock_state(); - let s = match state.as_mut() { - Some(s) => s, - None => return LRESULT(0), - }; - - // Moving mouse left = positive delta = larger offset (further left) - let delta = s.drag_start_mouse_x - pt.x; - let mut new_offset = s.drag_start_offset + delta; - - // Clamp: offset >= 0 (can't go right of default) - if new_offset < 0 { - new_offset = 0; - } - - let taskbar_hwnd = s.taskbar_hwnd; - let embedded = s.embedded; - let hwnd_val = s.hwnd.to_hwnd(); - - // Clamp: don't go past left edge of taskbar - if let Some(taskbar_hwnd) = taskbar_hwnd { - if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { - let mut tray_left = taskbar_rect.right; - if let Some(tray_hwnd) = - native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") - { - if let Some(tray_rect) = - native_interop::get_window_rect_safe(tray_hwnd) - { - tray_left = tray_rect.left; - } - } - let widget_width = total_widget_width_for_state(s); - let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); - if new_offset > max_offset { - new_offset = max_offset; - } - - s.tray_offset = new_offset; - - let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; - let anchor_top = taskbar_rect.top; - let anchor_height = taskbar_height; - let widget_height = sc(WIDGET_HEIGHT); - let y = compute_anchor_y(anchor_top, anchor_height, widget_height); - let x = if embedded { - tray_left - taskbar_rect.left - widget_width - new_offset - } else { - tray_left - widget_width - new_offset - }; - Some(( - hwnd_val, - embedded, - x, - y, - taskbar_rect.top, - widget_width, - widget_height, - )) - } else { - s.tray_offset = new_offset; - None - } - } else { - s.tray_offset = new_offset; - None - } - }; - - if let Some((hwnd_val, embedded, x, y, taskbar_top, widget_width, widget_height)) = - move_target - { - if embedded { - native_interop::move_window( - hwnd_val, - x, - y - taskbar_top, - widget_width, - widget_height, - ); - } else { - native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); - } - } + update_drag_reposition_from_cursor(); } LRESULT(0) } WM_LBUTTONUP => { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); - let drag_result = { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - if s.dragging { - s.dragging = false; - Some((s.taskbar_index, s.drag_start_client_x)) - } else { - None - } - } else { - None - } + let dragging = { + let state = lock_state(); + state.as_ref().map(|s| s.dragging).unwrap_or(false) }; - if let Some((current_taskbar_index, drag_start_client_x)) = drag_result { - let _ = ReleaseCapture(); - if let Some((target_index, target_taskbar)) = taskbar_at_point(pt) { - if target_index != current_taskbar_index { - let new_offset = offset_for_drop_point( - target_taskbar.hwnd, - target_taskbar.rect, - pt, - drag_start_client_x, - ); - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.tray_offset = new_offset; - } - } - if attach_to_taskbar(hwnd, target_index) { - position_at_taskbar(); - render_layered(); - } - } - } - save_state_settings(); + if dragging { + finalize_drag_reposition(hwnd, pt); } LRESULT(0) } + WM_CAPTURECHANGED => { + let losing = HWND(lparam.0 as *mut _); + if losing == hwnd { + finish_drag_reposition(); + } + DefWindowProcW(hwnd, msg, wparam, lparam) + } WM_RBUTTONUP => { show_context_menu(hwnd); LRESULT(0) @@ -2510,6 +3298,14 @@ unsafe extern "system" fn wnd_proc( if let Some(s) = state.as_mut() { s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); + if !s.account_pace_mode { + s.session_label = + s.language.strings().session_window.to_string(); + s.weekly_label = + s.language.strings().weekly_window.to_string(); + } + s.day_text = "...".to_string(); + s.credit_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); s.force_notify_auth_error = true; @@ -2567,7 +3363,7 @@ unsafe extern "system" fn wnd_proc( { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.tray_offset = 0; + s.tray_offset = TRAY_OFFSET_LEFTMOST; } } save_state_settings(); @@ -2618,6 +3414,8 @@ unsafe extern "system" fn wnd_proc( } s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); + s.day_text = "...".to_string(); + s.credit_text = "...".to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); s.antigravity_session_text = "...".to_string(); @@ -2974,8 +3772,10 @@ fn paint(hdc: HDC, hwnd: HWND) { strings, session_pct, session_text, + session_label, weekly_pct, weekly_text, + weekly_label, codex_session_pct, codex_session_text, codex_weekly_pct, @@ -2987,6 +3787,17 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + credit_text, + credit_label, + day_pct, + day_text, + day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, ) = { let state = lock_state(); match state.as_ref() { @@ -2995,8 +3806,10 @@ fn paint(hdc: HDC, hwnd: HWND) { s.language.strings(), s.session_percent, s.session_text.clone(), + s.session_label.clone(), s.weekly_percent, s.weekly_text.clone(), + s.weekly_label.clone(), s.codex_session_percent, s.codex_session_text.clone(), s.codex_weekly_percent, @@ -3008,11 +3821,29 @@ fn paint(hdc: HDC, hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.account_pace_mode, + s.show_credit_row, + s.credit_percent, + s.credit_text.clone(), + s.credit_label.clone(), + s.day_percent, + s.day_text.clone(), + s.day_label.clone(), + s.session_pace_level, + s.weekly_pace_level, + s.day_pace_level, ), None => return, } }; + let mut rect = RECT::default(); + unsafe { + let _ = GetClientRect(hwnd, &mut rect); + } + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); @@ -3058,8 +3889,10 @@ fn paint(hdc: HDC, hwnd: HWND) { strings, session_pct, &session_text, + &session_label, weekly_pct, &weekly_text, + &weekly_label, codex_session_pct, &codex_session_text, codex_weekly_pct, @@ -3071,6 +3904,17 @@ fn paint(hdc: HDC, hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + account_pace_mode, + show_credit_row, + credit_pct, + &credit_text, + &credit_label, + day_pct, + &day_text, + &day_label, + session_pace_level, + weekly_pace_level, + day_pace_level, &codex_accent, &antigravity_accent, );