diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..6c158124 --- /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 e021482a..312476fa 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9b71b853 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +## Screen capture + +Capture the screen yourself: PowerShell + Win32 P/Invoke — `user32.dll PrintWindow` (by window class name) or `Graphics.CopyFromScreen` (screen region). Examples: `.cr-tmp/HANDOFF.md`. + +## Widget default position: x=0, no exceptions + +The widget's default/leftmost taskbar position is **screen x=0** — the taskbar's own +physical left edge. Literally zero. Not "clear of the Start button," not "left of the +search box," not "the left edge of the icon cluster." x=0. + +This has been re-litigated with more than one agent session already. Every time, an +agent reasoned its way into a *smaller* definition of "leftmost" — usually "as far +left as possible without overlapping existing taskbar chrome/icons" — and presented +that as if it satisfied the requirement. It does not. The owner has explicitly and +repeatedly rejected that substitution. Do not re-derive it, do not re-ask what +"leftmost" means, do not propose it as an "honest tradeoff." If x=0 seems to conflict +with something else (Start button overlap, icon cluster, etc.), that is a bug in the +positioning code to fix (see `native_interop::taskbar_placement_band_left`, which is +hardcoded to return 0 for exactly this reason) — it is never a reason to move the +widget off x=0. + +If a genuinely new constraint makes x=0 impossible on some configuration, stop and +ask the owner directly with the specific conflict — do not silently pick a different +default. diff --git a/Cargo.toml b/Cargo.toml index 9cbc1c6e..e06df804 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ version = "0.58" features = [ "Win32_Foundation", "Win32_Globalization", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Shell", diff --git a/README.md b/README.md index f8b3c2be..ef23d1ca 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 02497307..a73d383d 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 a17bc0b5..853f9800 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ #![windows_subsystem = "windows"] mod diagnose; +mod proof_capture; mod localization; mod models; mod native_interop; mod poller; +mod spend_pace; mod theme; mod tray_icon; mod updater; @@ -23,7 +25,9 @@ fn main() { } } - if let Some(exit_code) = updater::handle_cli_mode(&args) { + if let Some(exit_code) = proof_capture::handle_cli(&args) + .or_else(|| updater::handle_cli_mode(&args)) + { if diagnose_enabled { diagnose::log(format!("cli mode exited with code {exit_code}")); } diff --git a/src/models.rs b/src/models.rs index da49ef10..e2a3906b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,20 +1,54 @@ use std::time::SystemTime; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct UsageSection { pub percentage: f64, pub resets_at: Option, + pub has_bucket: bool, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct UsageData { pub session: UsageSection, pub weekly: UsageSection, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AccountUsage { + pub credit_pct: f64, + pub credit_expiry: Option, + pub spend_used: f64, + pub spend_limit: f64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +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, PartialEq)] +pub struct SpendPaceView { + pub credit_pct: f64, + pub credit_expiry: Option, + pub slots: SpendPaceSlots, +} + +#[derive(Clone, Debug, Default, PartialEq)] 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 c745d087..c33d2ac6 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,9 +1,44 @@ use windows::core::PCWSTR; -use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Foundation::{BOOL, HWND, LPARAM, POINT, RECT}; +use windows::Win32::Globalization::GetLocaleInfoW; +use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_EXCLUDED_FROM_PEEK}; +use windows::Win32::Graphics::Gdi::{ + EnumDisplayMonitors, GetMonitorInfoW, MonitorFromPoint, MonitorFromWindow, HDC, HMONITOR, + MONITORINFO, MONITOR_DEFAULTTONEAREST, +}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static PEEK_LATCH_UNTIL_MS: AtomicU64 = AtomicU64::new(0); + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Extend peek latch only while taskbar thumbnail/preview UI is present. +pub fn refresh_taskbar_peek_latch(_self_hwnd: HWND, _taskbar_hwnd: Option) { + if taskbar_interactive_preview_active() || shell_preview_ui_active() { + let until = now_unix_ms().saturating_add(5000); + let _ = PEEK_LATCH_UNTIL_MS.fetch_max(until, Ordering::Relaxed); + } +} + +pub fn taskbar_peek_latch_active() -> bool { + now_unix_ms() < PEEK_LATCH_UNTIL_MS.load(Ordering::Relaxed) +} + + +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; @@ -11,6 +46,7 @@ pub const WS_CLIPSIBLINGS_STYLE: u32 = 0x04000000; // Win event constants pub const EVENT_OBJECT_LOCATIONCHANGE: u32 = 0x800B; +pub const EVENT_SYSTEM_FOREGROUND: u32 = 0x0003; pub const WINEVENT_OUTOFCONTEXT: u32 = 0x0000; // Timer IDs @@ -18,16 +54,83 @@ 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; +pub const TIMER_FULLSCREEN_CHECK: usize = 7; // Custom messages pub const WM_APP: u32 = 0x8000; pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1; pub const WM_APP_TRAY: u32 = WM_APP + 3; +pub const WM_APP_REQUEST_PROOF: u32 = WM_APP + 7; +pub const WM_APP_FOREGROUND_CHANGED: u32 = WM_APP + 8; #[derive(Clone, Copy, Debug)] pub struct TaskbarWindow { pub hwnd: HWND, pub rect: RECT, + pub is_primary: bool, +} + +fn taskbar_has_notification_area(taskbar_hwnd: HWND) -> bool { + find_descendant_window(taskbar_hwnd, "TrayNotifyWnd") + .or_else(|| find_child_window(taskbar_hwnd, "TrayNotifyWnd")) + .is_some() +} + +pub fn find_taskbar_by_class(class_name: &str) -> Option { + struct Search { + target: String, + found: Option, + } + let mut search = Search { + target: class_name.to_string(), + found: None, + }; + 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 = unsafe { 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) + } + unsafe { + let _ = EnumWindows(Some(enum_proc), LPARAM(&mut search as *mut _ as isize)); + } + search.found +} + +pub fn taskbar_hwnd_for_settings_index(taskbar_index: usize) -> Option { + if taskbar_index > 0 { + if let Some(hwnd) = find_taskbar_by_class("Shell_SecondaryTrayWnd") { + return Some(hwnd); + } + } + let direct = find_taskbar_by_class("Shell_TrayWnd"); + if direct.is_none() { + let taskbars = find_taskbars(); + crate::diagnose::log(format!( + "taskbar_hwnd_for_settings_index: direct Shell_TrayWnd lookup FAILED, falling back. find_taskbars() -> {:?}", + taskbars + .iter() + .map(|t| format!( + "hwnd={:?} is_primary={} rect=({},{},{},{})", + t.hwnd, t.is_primary, t.rect.left, t.rect.top, t.rect.right, t.rect.bottom + )) + .collect::>() + )); + return taskbars + .into_iter() + .find(|taskbar| taskbar_index == 0 || !taskbar.is_primary) + .map(|taskbar| taskbar.hwnd); + } + direct } pub fn find_taskbars() -> Vec { @@ -37,9 +140,29 @@ pub fn find_taskbars() -> Vec { let len = unsafe { GetClassNameW(hwnd, &mut class_name) }; if len > 0 { let class_name = String::from_utf16_lossy(&class_name[..len as usize]); - if class_name == "Shell_TrayWnd" || class_name == "Shell_SecondaryTrayWnd" { + let is_primary = class_name == "Shell_TrayWnd"; + if is_primary || class_name == "Shell_SecondaryTrayWnd" { + let has_tray = taskbar_has_notification_area(hwnd); + if is_primary && !has_tray { + if let (Some(rect), Some(mon)) = + (get_window_rect_safe(hwnd), primary_monitor_rect()) + { + if !rects_overlap(rect, mon) { + crate::diagnose::log(format!( + "find_taskbars: excluding primary hwnd={hwnd:?} rect=({},{},{},{}) - no tray, doesn't overlap primary monitor ({},{},{},{})", + rect.left, rect.top, rect.right, rect.bottom, + mon.left, mon.top, mon.right, mon.bottom + )); + return BOOL(1); + } + } + } if let Some(rect) = get_taskbar_rect(hwnd).or_else(|| get_window_rect_safe(hwnd)) { - taskbars.push(TaskbarWindow { hwnd, rect }); + taskbars.push(TaskbarWindow { + hwnd, + rect, + is_primary, + }); } } } @@ -52,6 +175,7 @@ pub fn find_taskbars() -> Vec { } taskbars.sort_by_key(|taskbar| { ( + !taskbar.is_primary, taskbar.rect.top, taskbar.rect.left, taskbar.rect.bottom, @@ -61,13 +185,156 @@ 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) { + if !rects_overlap(rect, scan.taskbar_rect) { + return BOOL(1); + } + 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 { + band: RECT, + 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 && rects_overlap(rect, scan.band) { + scan.visible_left = if scan.found { + scan.visible_left.min(rect.left) + } else { + rect.left + }; + scan.found = true; + } + } + } + // Task buttons live under ReBarWindow32; avoid full-tree recursion here + // (can deadlock with WinEvent-driven SetWindowPos during EnumChildWindows). + if class == "ReBarWindow32" { + 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 { + band: taskbar_rect, + 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(), ) { @@ -77,31 +344,386 @@ pub fn find_child_window(parent: HWND, class_name: &str) -> Option { } } -/// Get taskbar position via SHAppBarMessage -pub fn get_taskbar_rect(taskbar_hwnd: HWND) -> Option { + +fn rect_fits_monitor(rect: RECT, mon: RECT) -> bool { + rect.left >= mon.left + && rect.top >= mon.top + && rect.right <= mon.right + && rect.bottom <= mon.bottom +} + +fn taskbar_band_height(rect: RECT) -> i32 { + (rect.bottom - rect.top).max(1) +} + +fn is_plausible_taskbar_height(height: i32) -> bool { + (16..=160).contains(&height) +} + +fn primary_monitor_rect() -> Option { + unsafe { + let mut found: Option = None; + unsafe extern "system" fn monitor_proc( + monitor: HMONITOR, + _dc: HDC, + _rect: *mut RECT, + lparam: LPARAM, + ) -> BOOL { + let found = &mut *(lparam.0 as *mut Option); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut info).as_bool() && info.dwFlags & 1 != 0 { + *found = Some(info.rcMonitor); + return BOOL(0); + } + BOOL(1) + } + let _ = EnumDisplayMonitors( + HDC::default(), + None, + Some(monitor_proc), + LPARAM(&mut found as *mut _ as isize), + ); + found + } +} + +fn taskbar_window_class(taskbar_hwnd: HWND) -> Option { unsafe { let mut class_name = [0u16; 64]; let len = GetClassNameW(taskbar_hwnd, &mut class_name); if len > 0 { - let class_name = String::from_utf16_lossy(&class_name[..len as usize]); - if class_name == "Shell_SecondaryTrayWnd" { - return get_window_rect_safe(taskbar_hwnd); + Some(String::from_utf16_lossy(&class_name[..len as usize])) + } else { + None + } + } +} + +fn monitors_all() -> Vec { + unsafe { + let mut monitors: Vec = Vec::new(); + unsafe extern "system" fn monitor_proc( + monitor: HMONITOR, + _dc: HDC, + _rect: *mut RECT, + lparam: LPARAM, + ) -> BOOL { + let monitors = &mut *(lparam.0 as *mut Vec); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut info).as_bool() { + monitors.push(info.rcMonitor); } + BOOL(1) } + let _ = EnumDisplayMonitors( + HDC::default(), + None, + Some(monitor_proc), + LPARAM(&mut monitors as *mut _ as isize), + ); + monitors + } +} + +fn monitors_primary_first() -> Vec { + let mut monitors = monitors_all(); + monitors.sort_by_key(|mon| { + ( + mon.top >= 0, + mon.top, + mon.left, + mon.bottom, + mon.right, + ) + }); + monitors +} +fn monitor_rect_for_taskbar(taskbar_hwnd: HWND) -> Option { + let monitors = monitors_all(); + if monitors.is_empty() { + return primary_monitor_rect(); + } + let class = taskbar_window_class(taskbar_hwnd).unwrap_or_default(); + if class.contains("Secondary") { + return monitors + .iter() + .copied() + .max_by_key(|mon| mon.right - mon.left); + } + monitors + .iter() + .copied() + .min_by_key(|mon| mon.right - mon.left) + .or_else(primary_monitor_rect) +} + +fn clip_taskbar_band_to_monitor(mut band: RECT, mon: RECT) -> RECT { + band.left = band.left.max(mon.left); + band.right = band.right.min(mon.right); + if band.right <= band.left { + band.left = mon.left; + band.right = mon.right; + } + band +} + +fn taskbar_band_for_monitor_edge(mon: RECT, edge: u32, height: i32) -> RECT { + let height = height.max(16); + match edge { + 1 => RECT { + left: mon.left, + top: mon.top, + right: mon.right, + bottom: mon.top.saturating_add(height), + }, + 2 => RECT { + left: mon.right.saturating_sub(height), + top: mon.top, + right: mon.right, + bottom: mon.bottom, + }, + 0 => RECT { + left: mon.left, + top: mon.top, + right: mon.left.saturating_add(height), + bottom: mon.bottom, + }, + _ => RECT { + left: mon.left, + top: mon.bottom.saturating_sub(height), + right: mon.right, + bottom: mon.bottom, + }, + } +} + +fn taskbar_appbar_edge(taskbar_hwnd: HWND) -> Option { + unsafe { let mut abd = APPBARDATA { cbSize: std::mem::size_of::() as u32, hWnd: taskbar_hwnd, ..Default::default() }; - let result = SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd); - if result == 0 { + if SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd) == 0 { return None; } - Some(abd.rc) + Some(abd.uEdge) } } +fn normalize_rect_to_virtual_screen(hwnd: HWND, rect: RECT) -> RECT { + unsafe { + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if !GetMonitorInfoW(monitor, &mut info).as_bool() { + return rect; + } + let mon = info.rcMonitor; + if rect_fits_monitor(rect, mon) { + return rect; + } + // SHAppBarMessage returns monitor-relative coordinates on some setups. + let translated = RECT { + left: mon.left.saturating_add(rect.left), + top: mon.top.saturating_add(rect.top), + right: mon.left.saturating_add(rect.right), + bottom: mon.top.saturating_add(rect.bottom), + }; + if rect_fits_monitor(translated, mon) { + return translated; + } + // Avoid double-translating coords that are already virtual but span monitors. + if rects_overlap(rect, mon) && is_plausible_taskbar_height(taskbar_band_height(rect)) { + return clip_taskbar_band_to_monitor(rect, mon); + } + if rects_overlap(translated, mon) && is_plausible_taskbar_height(taskbar_band_height(translated)) + { + return clip_taskbar_band_to_monitor(translated, mon); + } + rect + } +} + + +/// Taskbar band in virtual-screen coords for the monitor that owns `taskbar_hwnd`. +pub fn screen_taskbar_rect(taskbar_hwnd: HWND, fallback: RECT) -> RECT { + resolved_taskbar_band(taskbar_hwnd, fallback).unwrap_or_else(|| { + normalize_rect_to_virtual_screen(taskbar_hwnd, fallback) + }) +} + +/// True when `widget` sits on the taskbar band for `taskbar_hwnd`. +pub fn widget_on_taskbar_band(taskbar_hwnd: HWND, widget: RECT) -> bool { + let fallback = get_window_rect_safe(taskbar_hwnd).unwrap_or_default(); + let band = match resolved_taskbar_band(taskbar_hwnd, fallback) { + Some(band) => band, + None => return false, + }; + if !is_plausible_taskbar_height(taskbar_band_height(band)) { + return false; + } + if !rects_overlap(widget, band) { + return false; + } + let widget_h = (widget.bottom - widget.top).max(1); + let vertical_gap = (widget.bottom - band.bottom) + .abs() + .min((widget.top - band.top).abs()); + vertical_gap <= (widget_h / 2).max(8) +} + +fn infer_taskbar_edge(raw: RECT, mon: RECT) -> u32 { + let cy = (raw.top + raw.bottom) / 2; + let dist_top = (cy - mon.top).abs(); + let dist_bottom = (mon.bottom - cy).abs(); + let dist_left = (raw.left - mon.left).abs(); + let dist_right = (mon.right - raw.right).abs(); + let min_vert = dist_top.min(dist_bottom); + let min_horiz = dist_left.min(dist_right); + if min_horiz < min_vert { + if dist_left < dist_right { + 0 + } else { + 2 + } + } else if dist_top < dist_bottom { + 1 + } else { + 3 + } +} + +fn resolved_taskbar_band(taskbar_hwnd: HWND, fallback: RECT) -> Option { + let mon = monitor_rect_for_taskbar(taskbar_hwnd)?; + let mut height = taskbar_band_height(fallback); + if !is_plausible_taskbar_height(height) { + height = 48; + } + height = height.clamp(16, 120); + let class = taskbar_window_class(taskbar_hwnd).unwrap_or_default(); + if class.contains("Secondary") || class == "Shell_TrayWnd" { + return Some(RECT { + left: mon.left, + top: mon.bottom.saturating_sub(height), + right: mon.right, + bottom: mon.bottom, + }); + } + let edge = get_window_rect_safe(taskbar_hwnd) + .map(|raw| infer_taskbar_edge(raw, mon)) + .unwrap_or_else(|| taskbar_appbar_edge(taskbar_hwnd).unwrap_or(3)); + Some(taskbar_band_for_monitor_edge(mon, edge, height)) +} + +fn monitor_rect_for_hwnd(hwnd: HWND) -> Option { + unsafe { + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut info).as_bool() { + Some(info.rcMonitor) + } else { + None + } + } +} + +fn monitor_rect_at_point(pt: POINT) -> Option { + unsafe { + let monitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut info).as_bool() { + Some(info.rcMonitor) + } else { + None + } + } +} + +fn monitor_rect_for_tray(tray_rect: RECT) -> Option { + let cy = (tray_rect.top + tray_rect.bottom) / 2; + monitor_rect_at_point(POINT { + x: (tray_rect.left + tray_rect.right) / 2, + y: cy, + }) +} + +fn point_in_rect(pt: POINT, rect: RECT) -> bool { + pt.x >= rect.left && pt.x < rect.right && pt.y >= rect.top && pt.y < rect.bottom +} + +fn rects_overlap(a: RECT, b: RECT) -> bool { + a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom +} + +/// TrayNotifyWnd on the monitor that owns `band` (virtual-screen taskbar rect). +pub fn tray_rect_for_screen_band(taskbar_hwnd: HWND, band: RECT) -> Option { + let band_center = POINT { + x: band.left + (band.right - band.left) / 2, + y: band.top + (band.bottom - band.top) / 2, + }; + let try_tray = |tray_hwnd: HWND| -> Option { + let rect = get_window_rect_safe(tray_hwnd)?; + if rects_overlap(rect, band) { + return Some(rect); + } + // Same monitor but shell reported tray coords on a spanning primary bar. + let band_mon = monitor_rect_at_point(band_center)?; + let tray_mon = monitor_rect_for_tray(rect)?; + if band_mon.left == tray_mon.left && rect.left >= band.left { + Some(rect) + } else { + None + } + }; + + let tray_hwnd = find_child_window(taskbar_hwnd, "TrayNotifyWnd")?; + try_tray(tray_hwnd) +} + +/// Left edge of the notification area for a screen-positioned taskbar band. +pub fn tray_left_for_screen_band(taskbar_hwnd: HWND, band: RECT) -> i32 { + if let Some(rect) = tray_rect_for_screen_band(taskbar_hwnd, band) { + return rect.left; + } + // Typical notification-area width when the shell reports a mismatched tray HWND. + band.right.saturating_sub(176) +} + +/// Taskbar rectangle in virtual-screen coordinates for SetWindowPos. +pub fn get_taskbar_rect(taskbar_hwnd: HWND) -> Option { + let raw = get_window_rect_safe(taskbar_hwnd).or_else(|| { + unsafe { + let mut abd = APPBARDATA { + cbSize: std::mem::size_of::() as u32, + hWnd: taskbar_hwnd, + ..Default::default() + }; + if SHAppBarMessage(ABM_GETTASKBARPOS, &mut abd) == 0 { + return None; + } + Some(abd.rc) + } + })?; + resolved_taskbar_band(taskbar_hwnd, raw) + .or_else(|| Some(normalize_rect_to_virtual_screen(taskbar_hwnd, raw))) +} + /// Get the bounding rectangle of a window pub fn get_window_rect_safe(hwnd: HWND) -> Option { unsafe { @@ -114,17 +736,451 @@ 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) { +/// Hide only for true exclusive fullscreen: borderless foreground covers the +/// monitor and the shell has retracted taskbar chrome. Thumbnail peek must not trigger this. +pub fn should_hide_widget_for_fullscreen(self_hwnd: HWND, taskbar_hwnd: Option) -> bool { + if is_taskbar_peek_context(self_hwnd, taskbar_hwnd) { + return false; + } + foreground_covers_monitor_borderless(self_hwnd, taskbar_hwnd) +} + +/// Thumbnail peek / taskbar hover — only when preview UI is actually active. +pub fn is_taskbar_peek_context(self_hwnd: HWND, _taskbar_hwnd: Option) -> bool { + taskbar_peek_latch_active() + || taskbar_interactive_preview_active() + || shell_preview_ui_active() + || taskbar_thumbnail_preview_present() +} + +fn any_system_taskbar_visible() -> bool { + for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] { + let hwnd = find_top_level_window(class); + if !hwnd.0.is_null() && taskbar_hwnd_is_on_screen(hwnd) { + return true; + } + } + false +} + +/// Cursor in the bottom band of the monitor under the pointer (taskbar + thumbnail zone). +pub fn cursor_in_taskbar_zone(_anchor_hwnd: HWND) -> bool { + unsafe { + let mut pt = POINT::default(); + if GetCursorPos(&mut pt).is_err() { + return false; + } + let monitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if !GetMonitorInfoW(monitor, &mut info).as_bool() { + return false; + } + let mon = info.rcMonitor; + const ZONE_PX: i32 = 320; + pt.y >= mon.bottom - ZONE_PX && pt.x >= mon.left && pt.x <= mon.right + } +} + +fn window_covers_monitor(win_rect: RECT, mon: RECT) -> bool { + const SLACK: i32 = 12; + let cover_w = (win_rect.right.min(mon.right) - win_rect.left.max(mon.left)).max(0); + let cover_h = (win_rect.bottom.min(mon.bottom) - win_rect.top.max(mon.top)).max(0); + let mon_w = mon.right - mon.left; + let mon_h = mon.bottom - mon.top; + cover_w >= mon_w - SLACK && cover_h >= mon_h - SLACK +} + +fn foreground_covers_monitor_borderless(self_hwnd: HWND, taskbar_hwnd: Option) -> bool { + unsafe { + let fg = GetForegroundWindow(); + if fg.0.is_null() || fg == self_hwnd { + return false; + } + + if let Some(class_name) = window_class_name(fg) { + if is_shell_foreground_class(&class_name) { + return false; + } + } + + let Some(win_rect) = get_window_rect_safe(fg) else { + return false; + }; + + let monitor = MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST); + + // Only suppress if the fullscreen-covering window is on the same + // monitor as the widget's own taskbar. A fullscreen app on a second + // monitor must not hide a widget whose taskbar is untouched. + if let Some(taskbar_hwnd) = taskbar_hwnd { + let widget_monitor = MonitorFromWindow(taskbar_hwnd, MONITOR_DEFAULTTONEAREST); + if widget_monitor != monitor { + return false; + } + } + + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut info).as_bool() == false { + return false; + } + + if !window_covers_monitor(win_rect, info.rcMonitor) { + return false; + } + + let work_w = info.rcWork.right - info.rcWork.left; + let work_h = info.rcWork.bottom - info.rcWork.top; + let mon_w = info.rcMonitor.right - info.rcMonitor.left; + let mon_h = info.rcMonitor.bottom - info.rcMonitor.top; + let taskbar_chrome = monitor_shows_taskbar_chrome(work_w, work_h, mon_w, mon_h); + + // Exclusive fullscreen: shell retracted the work area. + if !taskbar_chrome { + return true; + } + + // Browser/player fullscreen can still leave rcWork inset while the HWND + // rect covers the monitor (including over the taskbar band). + const BAND_SLACK: i32 = 4; + win_rect.bottom >= info.rcMonitor.bottom - BAND_SLACK + } +} + +fn window_class_name(hwnd: HWND) -> Option { + unsafe { + let mut class_name = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_name); + if len > 0 { + Some(String::from_utf16_lossy(&class_name[..len as usize])) + } else { + None + } + } +} + +fn is_shell_foreground_class(class_name: &str) -> bool { + matches!( + class_name, + "Progman" + | "WorkerW" + | "Shell_TrayWnd" + | "Shell_SecondaryTrayWnd" + | "Windows.UI.Core.CoreWindow" + | "TaskListThumbnailWnd" + | "CThumbnailWnd" + | "MultitaskingViewFrame" + | "XamlExplorerHostIslandWindow" + | "TopLevelWindowForOverflowXamlIsland" + | "Windows.UI.Composition.DesktopWindowContentBridge" + ) || class_name.starts_with("WindowsInternal") +} + +/// Taskbar hover / thumbnail peek — including when the peeked app has focus. +fn taskbar_interactive_preview_active() -> bool { + taskbar_thumbnail_preview_visible() +} + +/// Visible thumbnail flyout above a taskbar icon. +fn taskbar_thumbnail_preview_visible() -> bool { + thumbnail_preview_visible_for_class("TaskListThumbnailWnd") + || thumbnail_preview_visible_for_class("CThumbnailWnd") +} + +/// Thumbnail flyout visible (ignore helper HWNDs that exist permanently). +fn taskbar_thumbnail_preview_present() -> bool { + taskbar_thumbnail_preview_visible() +} + +fn thumbnail_preview_visible_for_class(class: &str) -> bool { + unsafe { + let hwnd = find_top_level_window(class); + if hwnd.0.is_null() { + return false; + } + IsWindowVisible(hwnd).as_bool() + } +} + +fn thumbnail_preview_exists_for_class(class: &str) -> bool { + unsafe { + let hwnd = find_top_level_window(class); + !hwnd.0.is_null() + } +} + +fn find_top_level_window(class: &str) -> HWND { + unsafe { + let wide: Vec = class.encode_utf16().chain(std::iter::once(0)).collect(); + FindWindowW(PCWSTR(wide.as_ptr()), PCWSTR::null()).unwrap_or_default() + } +} + +fn is_preview_ui_class(class_name: &str) -> bool { + matches!( + class_name, + "TaskListThumbnailWnd" | "CThumbnailWnd" | "TaskListWnd" | "MSTaskSwWClass" + ) +} + +/// Any visible shell preview flyout (taskbar hover / Win11 variants). +pub fn shell_preview_ui_active() -> bool { + struct Scan { + found: bool, + } + + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let scan = &mut *(lparam.0 as *mut Scan); + if scan.found { + return BOOL(1); + } + if !IsWindowVisible(hwnd).as_bool() { + return BOOL(1); + } + if let Some(class_name) = window_class_name(hwnd) { + if is_preview_ui_class(&class_name) { + scan.found = true; + } + } + BOOL(1) + } + + unsafe { + let mut scan = Scan { found: false }; + let _ = EnumWindows(Some(enum_proc), LPARAM(&mut scan as *mut _ as isize)); + scan.found + } +} + +/// Taskbar peek minimizes other windows; foreground is the peeked app. +pub fn desktop_peek_active(self_hwnd: HWND) -> bool { + struct Scan { + fg: HWND, + self_hwnd: HWND, + iconic: u32, + } + + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let scan = &mut *(lparam.0 as *mut Scan); + if hwnd == scan.fg || hwnd == scan.self_hwnd || !IsWindowVisible(hwnd).as_bool() { + return BOOL(1); + } + let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; + if ex_style & WS_EX_TOOLWINDOW.0 != 0 { + return BOOL(1); + } + if let Some(class_name) = window_class_name(hwnd) { + if is_shell_foreground_class(&class_name) { + return BOOL(1); + } + } + if IsIconic(hwnd).as_bool() { + scan.iconic += 1; + } + BOOL(1) + } + + unsafe { + let fg = GetForegroundWindow(); + if fg.0.is_null() || fg == self_hwnd || IsIconic(fg).as_bool() { + return false; + } + let mut scan = Scan { + fg, + self_hwnd, + iconic: 0, + }; + let _ = EnumWindows(Some(enum_proc), LPARAM(&mut scan as *mut _ as isize)); + scan.iconic >= 1 + } +} + +/// Physical taskbar window is mostly visible on its monitor (not auto-hidden off-screen). +pub fn taskbar_hwnd_is_on_screen(taskbar_hwnd: HWND) -> bool { + unsafe { + if taskbar_hwnd.0.is_null() || !IsWindow(taskbar_hwnd).as_bool() { + return false; + } + if !IsWindowVisible(taskbar_hwnd).as_bool() { + return false; + } + let Some(rect) = get_window_rect_safe(taskbar_hwnd) else { + return false; + }; + let monitor = MonitorFromWindow(taskbar_hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if !GetMonitorInfoW(monitor, &mut info).as_bool() { + return false; + } + let mon = info.rcMonitor; + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width <= 0 || height <= 0 { + return false; + } + let intersect_w = (rect.right.min(mon.right) - rect.left.max(mon.left)).max(0); + let intersect_h = (rect.bottom.min(mon.bottom) - rect.top.max(mon.top)).max(0); + let intersect_area = intersect_w * intersect_h; + let window_area = width * height; + intersect_area * 2 >= window_area + } +} + +/// True when the shell still reserves taskbar space on the monitor (work area < monitor). +pub fn taskbar_still_visible_on_monitor(hwnd: HWND) -> bool { + unsafe { + if hwnd.0.is_null() { + return true; + } + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if !GetMonitorInfoW(monitor, &mut info).as_bool() { + return true; + } + let work_w = info.rcWork.right - info.rcWork.left; + let work_h = info.rcWork.bottom - info.rcWork.top; + let mon_w = info.rcMonitor.right - info.rcMonitor.left; + let mon_h = info.rcMonitor.bottom - info.rcMonitor.top; + monitor_shows_taskbar_chrome(work_w, work_h, mon_w, mon_h) + } +} + +fn monitor_shows_taskbar_chrome(work_w: i32, work_h: i32, mon_w: i32, mon_h: i32) -> bool { + const SLACK: i32 = 8; + (mon_w - work_w) > SLACK || (mon_h - work_h) > SLACK +} + +#[cfg(test)] +mod fullscreen_tests { + use super::*; + + #[test] + fn shell_classes_include_taskbar_preview_and_switcher() { + assert!(is_shell_foreground_class("TaskListThumbnailWnd")); + assert!(is_shell_foreground_class("MultitaskingViewFrame")); + assert!(is_shell_foreground_class("WindowsInternal.ComposableShellWindow")); + assert!(!is_shell_foreground_class("Chrome_WidgetWin_1")); + } + + #[test] + fn monitor_shows_taskbar_when_work_area_is_inset() { + assert!(monitor_shows_taskbar_chrome(1920, 1032, 1920, 1080)); + assert!(!monitor_shows_taskbar_chrome(1920, 1080, 1920, 1080)); + } + + #[test] + fn peek_context_includes_work_area_inset() { + // monitor_shows_taskbar_chrome is the rcWork vs rcMonitor proxy used at runtime + assert!(monitor_shows_taskbar_chrome(1920, 1040, 1920, 1080)); + } + + #[test] + fn preview_ui_class_names_are_recognized() { + assert!(is_preview_ui_class("TaskListThumbnailWnd")); + assert!(is_preview_ui_class("CThumbnailWnd")); + assert!(!is_preview_ui_class("SomeAppPreviewHost")); + assert!(!is_preview_ui_class("Chrome_WidgetWin_1")); + } +} + +/// Left edge of the draggable widget band, relative to `taskbar_rect.left`. +/// Secondary taskbars span a full monitor edge; do not treat the pinned-app list +/// right edge as the left bound (that pushed TRAY_OFFSET_LEFTMOST to x≈1174). +/// HARD REQUIREMENT (do not "improve" this): the widget's default/leftmost +/// position is screen x=0 — the taskbar's own physical left edge — always, +/// unconditionally, on every taskbar (primary or secondary). Not "the left +/// edge of the icon cluster," not "clear of Start/Search/Task View chrome." +/// x=0. This has been re-litigated by more than one agent session already; +/// each time, "leftmost" got reinterpreted as "leftmost without overlapping +/// existing taskbar content," which is a *different, smaller* requirement +/// the owner never asked for and has explicitly rejected. If some other +/// caller needs a collision-aware inset for a different purpose, give it a +/// new, differently-named function — do not reintroduce that behavior here. +pub fn taskbar_placement_band_left(_taskbar_hwnd: HWND, _taskbar_rect: RECT) -> i32 { + 0 +} + +/// 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) + .max(0) +} + +/// 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 +} + +/// Tell DWM to never ghost/hide this window during Aero Peek — both the +/// taskbar-thumbnail peek (hovering a taskbar icon's preview) and Show +/// Desktop peek. This is the actual fix for "widget disappears on preview": +/// DWM peek is a compositor-level effect that makes other top-level windows +/// nearly transparent while the peeked window (or the desktop) is shown, and +/// it does this regardless of our own window's visibility/z-order state — no +/// amount of polling GetForegroundWindow, watching for TaskListThumbnailWnd, +/// or reordering z-order after the fact can prevent DWM from ghosting a +/// window it doesn't know should be excluded. `DWMWA_EXCLUDED_FROM_PEEK` is +/// the attribute that opts a window out of that ghosting entirely. Call this +/// once, right after the window is created. +pub fn exclude_from_peek(hwnd: HWND) { + unsafe { + let exclude: BOOL = BOOL(1); + let _ = DwmSetWindowAttribute( + hwnd, + DWMWA_EXCLUDED_FROM_PEEK, + &exclude as *const _ as *const std::ffi::c_void, + std::mem::size_of::() as u32, + ); + } +} + +/// 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 +1190,182 @@ 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, + ); + } +} + +/// Reassert popup z-order (TOPMOST normally; taskbar band during peek). +pub fn raise_above_taskbar(hwnd: HWND, taskbar_hwnd: Option) { + position_popup_zorder(hwnd, taskbar_hwnd, false, 0, 0, 0, 0); +} + +pub fn raise_on_taskbar_band(hwnd: HWND, taskbar_hwnd: Option) { + position_popup_zorder(hwnd, taskbar_hwnd, true, 0, 0, 0, 0); +} + +/// Place the widget directly above the foreground window, then restore topmost. +pub fn raise_above_foreground(hwnd: HWND) { + unsafe { + let fg = GetForegroundWindow(); + if !fg.0.is_null() && fg != hwnd { + let _ = SetWindowPos( + hwnd, + fg, + 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, + ); + } +} + +/// Drop below exclusive-fullscreen apps (real taskbar behaviour) without SW_HIDE. +pub fn lower_below_fullscreen(hwnd: HWND) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + + +/// Place the layered popup above the taskbar (TOPMOST — peek band handled in sync). +pub fn position_above_taskbar(hwnd: HWND, taskbar_hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + position_popup_zorder(hwnd, Some(taskbar_hwnd), false, x, y, w, h); +} + +/// Place/move the popup without HWND_TOPMOST (exclusive fullscreen suppression). +pub fn position_notopmost_popup(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos(hwnd, HWND_NOTOPMOST, x, y, w, h, SWP_NOACTIVATE); + } +} + +fn position_popup_zorder( + hwnd: HWND, + taskbar_hwnd: Option, + use_taskbar_band: bool, + x: i32, + y: i32, + w: i32, + h: i32, +) { + unsafe { + if use_taskbar_band { + if let Some(tb) = taskbar_hwnd { + if !tb.0.is_null() { + if w > 0 && h > 0 { + let _ = SetWindowPos(hwnd, tb, x, y, w, h, SWP_NOACTIVATE); + } else { + let _ = SetWindowPos( + hwnd, + tb, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } + return; + } + } + } + if w > 0 && h > 0 { + let _ = SetWindowPos(hwnd, HWND_NOTOPMOST, x, y, w, h, SWP_NOACTIVATE); + let _ = SetWindowPos(hwnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE); + } else { + 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, + ); + } + } +} + +/// 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 +1373,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, @@ -164,6 +1412,32 @@ pub fn set_tray_event_hook( } } +/// Set up a system-wide WinEvent hook for foreground window changes. Used to +/// force an immediate repaint the instant focus moves to/from a shell/XAML +/// surface (Start menu, Search, task switches), instead of waiting up to +/// TIMER_FULLSCREEN_CHECK's 500ms poll interval to notice DWM dropped this +/// window's composited content during the transition. +pub fn set_foreground_event_hook( + callback: unsafe extern "system" fn(HWINEVENTHOOK, u32, HWND, i32, i32, u32, u32), +) -> Option { + unsafe { + let hook = SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + None, + Some(callback), + 0, + 0, + WINEVENT_OUTOFCONTEXT, + ); + if hook.is_invalid() { + None + } else { + Some(hook) + } + } +} + /// Get the thread ID that owns a window pub fn get_window_thread_id(hwnd: HWND) -> u32 { unsafe { GetWindowThreadProcessId(hwnd, None) } @@ -181,6 +1455,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 a29cd0dc..aed959b0 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -4,17 +4,187 @@ use std::ffi::c_void; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::process::Command; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, 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); + +/// Full poll-result cache: skip redundant HTTP when credentials are unchanged. +static POLL_RESULT_CACHE: Mutex> = Mutex::new(None); +static POLL_CACHE_FORCE_REFRESH: AtomicBool = AtomicBool::new(false); + +/// How long a successful poll result may be reused without hitting the API. +const POLL_RESULT_CACHE_TTL: Duration = Duration::from_secs(15 * 60); + +#[derive(Clone)] +struct PollResultCache { + data: AppUsageData, + fetched_at: Instant, + providers: (bool, bool, bool), + /// Credential-file signatures; changes when Claude/Codex/Antigravity auth updates. + activity_signature: String, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct PollOptions { + /// Bypass the in-memory poll cache (manual refresh, post-reset fast poll, startup). + pub force_refresh: bool, +} + +/// Drop cached poll results so the next poll hits the network. +pub fn invalidate_poll_cache() { + POLL_CACHE_FORCE_REFRESH.store(true, Ordering::Relaxed); +} + +fn poll_activity_signature(show_claude_code: bool, show_codex: bool, show_antigravity: bool) -> String { + let mut parts = Vec::new(); + if show_claude_code { + parts.extend(credential_watch_snapshot(CredentialWatchMode::ActiveSource)); + } + if show_codex { + if let Some(path) = codex_auth_path() { + parts.push(windows_credential_watch_signature(&path)); + } + } + if show_antigravity { + parts.push(antigravity_credential_watch_signature()); + } + parts.sort(); + parts.dedup(); + parts.join("|") +} + +fn try_poll_result_cache( + show_claude_code: bool, + show_codex: bool, + show_antigravity: bool, + options: PollOptions, +) -> Option { + if options.force_refresh || POLL_CACHE_FORCE_REFRESH.swap(false, Ordering::Relaxed) { + return None; + } + + let providers = (show_claude_code, show_codex, show_antigravity); + let activity_signature = poll_activity_signature(show_claude_code, show_codex, show_antigravity); + let cached = POLL_RESULT_CACHE.lock().ok()?.clone()?; + if cached.providers != providers { + return None; + } + if cached.activity_signature != activity_signature { + diagnose::log("poll cache miss: credential activity signature changed"); + return None; + } + if cached.fetched_at.elapsed() > POLL_RESULT_CACHE_TTL { + return None; + } + + diagnose::log("poll cache hit: reusing last successful usage data"); + Some(cached.data) +} + +fn store_poll_result_cache( + data: &AppUsageData, + show_claude_code: bool, + show_codex: bool, + show_antigravity: bool, +) { + let entry = PollResultCache { + data: data.clone(), + fetched_at: Instant::now(), + providers: (show_claude_code, show_codex, show_antigravity), + activity_signature: poll_activity_signature(show_claude_code, show_codex, show_antigravity), + }; + if let Ok(mut cache) = POLL_RESULT_CACHE.lock() { + *cache = Some(entry); + } +} + +#[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 +217,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)] @@ -176,21 +360,46 @@ pub fn poll( show_codex: bool, show_antigravity: bool, ) -> Result { - poll_with( + poll_with_options( + show_claude_code, + show_codex, + show_antigravity, + PollOptions::default(), + ) +} + +pub fn poll_with_options( + show_claude_code: bool, + show_codex: bool, + show_antigravity: bool, + options: PollOptions, +) -> Result { + if let Some(data) = try_poll_result_cache(show_claude_code, show_codex, show_antigravity, options) + { + return Ok(data); + } + + let result = poll_with( show_claude_code, show_codex, show_antigravity, poll_claude_code, poll_codex, poll_antigravity, - ) + ); + if result.is_ok() { + if let Ok(ref data) = result { + store_poll_result_cache(data, show_claude_code, show_codex, show_antigravity); + } + } + result } 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 +409,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 +456,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 +467,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 +522,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,65 +541,380 @@ 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) { diagnose::log(format!( "attempting WSL Claude token refresh in distro {distro}" @@ -484,42 +1027,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 +1161,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 +1175,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 +1186,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 +1221,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 +1429,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 +1622,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 +2196,7 @@ mod tests { session: UsageSection { percentage, resets_at: None, + has_bucket: true, }, weekly: UsageSection::default(), } @@ -1636,7 +2224,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 +2320,175 @@ 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()); + } + + #[test] + fn poll_result_cache_hits_without_network_when_signature_unchanged() { + let sample = AppUsageData { + claude_code: Some(usage_with_session_percent(12.0)), + ..Default::default() + }; + store_poll_result_cache(&sample, true, false, false); + + let cached = try_poll_result_cache(true, false, false, PollOptions::default()) + .expect("cache should serve last successful poll"); + assert_eq!(cached.claude_code.unwrap().session.percentage, 12.0); + + invalidate_poll_cache(); + assert!(try_poll_result_cache(true, false, false, PollOptions::default()).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/proof_capture.rs b/src/proof_capture.rs new file mode 100644 index 00000000..7f0590f5 --- /dev/null +++ b/src/proof_capture.rs @@ -0,0 +1,512 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use windows::Win32::Foundation::{HWND, LPARAM, RECT, WPARAM}; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::Graphics::Gdi::HGDIOBJ; +use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetClassNameW, GetSystemMetrics, GetWindowRect, IsWindowVisible, PostMessageW, + SYSTEM_METRICS_INDEX, +}; + +use crate::diagnose; +use crate::native_interop::{self, WM_APP_REQUEST_PROOF}; + +const DEFAULT_FLAG: &str = + r"C:\Users\oyardena\PycharmProjects\Claude-Code-Usage-Monitor\.cr-tmp\request-proof.flag"; + +#[derive(Serialize)] +struct ProofResult { + pass: bool, + on_taskbar: bool, + hwnd: isize, + hwnd_rect: String, + layered: String, + widget_region: String, + taskbar_band: String, + fullscreen_rect: String, + buffer_nonblack: u32, + desktop_nonblack: u32, + buffer_hash: u64, + desktop_hash: u64, + buffer_matches_desktop: bool, + pixel_match_pct: u32, + buffer_len: usize, + desktop_len: usize, + visible: bool, + files: ProofFiles, +} + +#[derive(Serialize)] +struct ProofFiles { + buffer_bmp: String, + desktop_bmp: String, + fullscreen_bmp: String, + manifest: String, +} + +pub fn flag_pending() -> bool { + PathBuf::from(DEFAULT_FLAG).exists() +} + +pub fn handle_cli(args: &[String]) -> Option { + if args.len() == 3 && args[1] == "--request-proof" { + let out = PathBuf::from(&args[2]); + return Some(match request_proof_blocking(&out, Duration::from_secs(45)) { + Ok(pass) => { + if pass { 0 } else { 1 } + } + Err(error) => { + diagnose::log(format!("request-proof failed: {error}")); + 1 + } + }); + } + None +} + +fn notify_running_instance_proof() { + unsafe { + let class = native_interop::wide_str("ClaudeCodeUsageMonitor"); + let mut target = HWND::default(); + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> windows::Win32::Foundation::BOOL { + let target = &mut *(lparam.0 as *mut HWND); + let mut buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut buf); + if len > 0 { + let name = String::from_utf16_lossy(&buf[..len as usize]); + if name == "ClaudeCodeUsageMonitor" { + *target = hwnd; + return windows::Win32::Foundation::BOOL(0); + } + } + windows::Win32::Foundation::BOOL(1) + } + let _ = EnumWindows(Some(enum_proc), LPARAM(&mut target as *mut _ as isize)); + if !target.0.is_null() { + let _ = PostMessageW(target, WM_APP_REQUEST_PROOF, WPARAM(0), LPARAM(0)); + } + } +} + +pub fn request_proof_blocking(out_dir: &Path, timeout: Duration) -> Result { + fs::create_dir_all(out_dir).map_err(|e| e.to_string())?; + fs::write(DEFAULT_FLAG, out_dir.to_string_lossy().as_bytes()).map_err(|e| e.to_string())?; + notify_running_instance_proof(); + let manifest = out_dir.join("proof.json"); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if manifest.exists() { + let text = fs::read_to_string(&manifest).map_err(|e| e.to_string())?; + let value: serde_json::Value = + serde_json::from_str(&text).map_err(|e| e.to_string())?; + return Ok(value + .get("pass") + .and_then(|v| v.as_bool()) + .unwrap_or(false)); + } + thread::sleep(Duration::from_millis(250)); + } + Err("timed out waiting for proof.json from running instance".to_string()) +} + +pub fn maybe_capture( + hwnd: HWND, + taskbar_hwnd: Option, + layered_x: i32, + layered_y: i32, + width: i32, + height: i32, + buffer: &[u32], +) { + let flag = PathBuf::from(DEFAULT_FLAG); + if !flag.exists() { + return; + } + let out_dir = match fs::read_to_string(&flag) { + Ok(text) => PathBuf::from(text.trim()), + Err(error) => { + diagnose::log(format!("proof flag read failed: {error}")); + let _ = fs::remove_file(flag); + return; + } + }; + let _ = fs::create_dir_all(&out_dir); + let buffer_path = out_dir.join("proof-buffer.bmp"); + let desktop_path = out_dir.join("proof-desktop.bmp"); + let fullscreen_path = out_dir.join("proof-fullscreen.bmp"); + let manifest_path = out_dir.join("proof.json"); + + let buffer_hash = hash_pixels(buffer); + let buffer_nonblack = count_nonblack_bgra(buffer); + if let Err(error) = save_bmp32(&buffer_path, width, height, buffer) { + diagnose::log(format!("proof buffer save failed: {error}")); + } + + let (cap_x, cap_y, cap_w, cap_h) = (layered_x, layered_y, width, height); + let desktop = match capture_desktop_bgra(cap_x, cap_y, cap_w, cap_h) { + Ok(pixels) => pixels, + Err(error) => { + diagnose::log(format!("proof desktop capture failed: {error}")); + Vec::new() + } + }; + let desktop_hash = hash_pixels(&desktop); + let desktop_nonblack = count_nonblack_bgra(&desktop); + if !desktop.is_empty() { + if let Err(error) = save_bmp32(&desktop_path, cap_w, cap_h, &desktop) { + diagnose::log(format!("proof desktop save failed: {error}")); + } + } + + let (fs_x, fs_y, fs_w, fs_h) = virtual_screen_bounds(); + if let Ok(fullscreen) = capture_stitched_fullscreen(fs_x, fs_y, fs_w, fs_h) { + if let Err(error) = save_bmp32(&fullscreen_path, fs_w, fs_h, &fullscreen) { + diagnose::log(format!("proof fullscreen save failed: {error}")); + } + } else { + diagnose::log("proof stitched fullscreen capture failed".to_string()); + } + + let widget_rect = RECT { + left: layered_x, + top: layered_y, + right: layered_x + width, + bottom: layered_y + height, + }; + let (taskbar_band, on_taskbar) = match taskbar_hwnd { + Some(taskbar_hwnd) => { + let fallback = native_interop::get_taskbar_rect(taskbar_hwnd).unwrap_or_default(); + let band = native_interop::screen_taskbar_rect(taskbar_hwnd, fallback); + let on_taskbar = native_interop::widget_on_taskbar_band(taskbar_hwnd, widget_rect); + ( + format!( + "{},{},{},{}", + band.left, band.top, band.right, band.bottom + ), + on_taskbar, + ) + } + None => (String::new(), false), + }; + + let mut hwnd_rect = RECT::default(); + let visible = unsafe { + GetWindowRect(hwnd, &mut hwnd_rect).is_ok() && IsWindowVisible(hwnd).as_bool() + }; + let pixel_match_pct = pixel_match_ratio(buffer, &desktop); + let nonblack_balance = if buffer_nonblack.max(desktop_nonblack) == 0 { + 0 + } else { + (buffer_nonblack.min(desktop_nonblack) * 100) / buffer_nonblack.max(desktop_nonblack) + }; + let pass = on_taskbar && buffer_nonblack > 20 && visible; + + let result = ProofResult { + pass, + on_taskbar, + hwnd: hwnd.0 as isize, + hwnd_rect: format!( + "{},{},{},{}", + hwnd_rect.left, hwnd_rect.top, hwnd_rect.right, hwnd_rect.bottom + ), + layered: format!("{layered_x},{layered_y},{width},{height}"), + widget_region: format!("{cap_x},{cap_y},{cap_w},{cap_h}"), + taskbar_band, + fullscreen_rect: format!("{fs_x},{fs_y},{fs_w},{fs_h}"), + buffer_nonblack, + desktop_nonblack, + buffer_hash, + desktop_hash, + buffer_matches_desktop: buffer_hash == desktop_hash, + pixel_match_pct, + buffer_len: buffer.len(), + desktop_len: desktop.len(), + visible, + files: ProofFiles { + buffer_bmp: buffer_path.to_string_lossy().into_owned(), + desktop_bmp: desktop_path.to_string_lossy().into_owned(), + fullscreen_bmp: fullscreen_path.to_string_lossy().into_owned(), + manifest: manifest_path.to_string_lossy().into_owned(), + }, + }; + + match serde_json::to_string_pretty(&result) { + Ok(json) => { + if let Err(error) = fs::write(&manifest_path, json) { + diagnose::log(format!("proof manifest write failed: {error}")); + } else { + diagnose::log(format!( + "proof written pass={pass} on_taskbar={on_taskbar} buffer_nb={buffer_nonblack} desktop_nb={desktop_nonblack}" + )); + } + } + Err(error) => diagnose::log(format!("proof json encode failed: {error}")), + } + let _ = fs::remove_file(flag); +} + +fn virtual_screen_bounds() -> (i32, i32, i32, i32) { + unsafe { + let mut union = RECT { + left: i32::MAX, + top: i32::MAX, + right: i32::MIN, + bottom: i32::MIN, + }; + unsafe extern "system" fn monitor_proc( + _monitor: HMONITOR, + _dc: HDC, + rect: *mut RECT, + lparam: LPARAM, + ) -> windows::Win32::Foundation::BOOL { + let union = &mut *(lparam.0 as *mut RECT); + let r = *rect; + union.left = union.left.min(r.left); + union.top = union.top.min(r.top); + union.right = union.right.max(r.right); + union.bottom = union.bottom.max(r.bottom); + windows::Win32::Foundation::BOOL(1) + } + let _ = EnumDisplayMonitors( + HDC::default(), + None, + Some(monitor_proc), + LPARAM(&mut union as *mut _ as isize), + ); + if union.left < union.right && union.top < union.bottom { + ( + union.left, + union.top, + union.right - union.left, + union.bottom - union.top, + ) + } else { + let x = GetSystemMetrics(SYSTEM_METRICS_INDEX(76)); + let y = GetSystemMetrics(SYSTEM_METRICS_INDEX(77)); + let w = GetSystemMetrics(SYSTEM_METRICS_INDEX(78)); + let h = GetSystemMetrics(SYSTEM_METRICS_INDEX(79)); + (x, y, w, h) + } + } +} + +struct MonitorCapture { + rect: RECT, + pixels: Vec, +} + +fn capture_stitched_fullscreen( + union_x: i32, + union_y: i32, + union_w: i32, + union_h: i32, +) -> Result, String> { + if union_w <= 0 || union_h <= 0 { + return Err("invalid union dimensions".to_string()); + } + let mut monitors: Vec = Vec::new(); + unsafe { + unsafe extern "system" fn monitor_proc( + _monitor: HMONITOR, + _dc: HDC, + rect: *mut RECT, + lparam: LPARAM, + ) -> windows::Win32::Foundation::BOOL { + let monitors = &mut *(lparam.0 as *mut Vec); + let r = *rect; + let w = r.right - r.left; + let h = r.bottom - r.top; + if w <= 0 || h <= 0 { + return windows::Win32::Foundation::BOOL(1); + } + match capture_desktop_bgra(r.left, r.top, w, h) { + Ok(pixels) => monitors.push(MonitorCapture { rect: r, pixels }), + Err(error) => diagnose::log(format!("monitor capture failed: {error}")), + } + windows::Win32::Foundation::BOOL(1) + } + let _ = EnumDisplayMonitors( + HDC::default(), + None, + Some(monitor_proc), + LPARAM(&mut monitors as *mut _ as isize), + ); + } + if monitors.is_empty() { + return capture_desktop_bgra(union_x, union_y, union_w, union_h); + } + let w = union_w as usize; + let h = union_h as usize; + let mut out = vec![0u32; w * h]; + for mon in monitors { + let mw = (mon.rect.right - mon.rect.left) as usize; + let mh = (mon.rect.bottom - mon.rect.top) as usize; + if mon.pixels.len() != mw * mh { + continue; + } + for row in 0..mh { + let dst_y = mon.rect.top + row as i32 - union_y; + if dst_y < 0 || dst_y >= union_h { + continue; + } + for col in 0..mw { + let dst_x = mon.rect.left + col as i32 - union_x; + if dst_x < 0 || dst_x >= union_w { + continue; + } + let src = row * mw + col; + let dst = dst_y as usize * w + dst_x as usize; + out[dst] = mon.pixels[src]; + } + } + } + Ok(out) +} + +fn count_nonblack_bgra(pixels: &[u32]) -> u32 { + pixels + .iter() + .filter(|px| { + let b = (*px & 0xFF) as u32; + let g = ((*px >> 8) & 0xFF) as u32; + let r = ((*px >> 16) & 0xFF) as u32; + r > 15 || g > 15 || b > 15 + }) + .count() as u32 +} + +fn hash_pixels(pixels: &[u32]) -> u64 { + let mut hash: u64 = 0xcbf29ce484222325; + for px in pixels { + hash ^= *px as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn save_bmp32(path: &Path, width: i32, height: i32, pixels: &[u32]) -> Result<(), String> { + if width <= 0 || height <= 0 { + return Err("invalid dimensions".to_string()); + } + let w = width as usize; + let h = height as usize; + let row_bytes = w * 4; + let pixel_bytes = row_bytes * h; + let file_size = 14 + 40 + pixel_bytes; + let mut out = Vec::with_capacity(file_size); + out.extend_from_slice(b"BM"); + out.extend_from_slice(&(file_size as u32).to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&54u32.to_le_bytes()); + out.extend_from_slice(&40u32.to_le_bytes()); + out.extend_from_slice(&(width as i32).to_le_bytes()); + out.extend_from_slice(&(-(height as i32)).to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&32u16.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&(pixel_bytes as u32).to_le_bytes()); + out.extend_from_slice(&0i32.to_le_bytes()); + out.extend_from_slice(&0i32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + for row in 0..h { + let start = row * w; + let end = start + w; + for px in &pixels[start..end] { + let b = (px & 0xFF) as u8; + let g = ((px >> 8) & 0xFF) as u8; + let r = ((px >> 16) & 0xFF) as u8; + out.push(b); + out.push(g); + out.push(r); + out.push(0); + } + } + fs::write(path, out).map_err(|e| e.to_string()) +} + +fn capture_desktop_bgra(x: i32, y: i32, width: i32, height: i32) -> Result, String> { + if width <= 0 || height <= 0 { + return Err("invalid dimensions".to_string()); + } + unsafe { + let screen_dc = GetDC(HWND::default()); + if screen_dc.is_invalid() { + return Err("GetDC failed".to_string()); + } + let mem_dc = CreateCompatibleDC(screen_dc); + if mem_dc.is_invalid() { + ReleaseDC(HWND::default(), screen_dc); + return Err("CreateCompatibleDC failed".to_string()); + } + let bmp = CreateCompatibleBitmap(screen_dc, width, height); + if bmp.is_invalid() { + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + return Err("CreateCompatibleBitmap failed".to_string()); + } + let old = SelectObject(mem_dc, HGDIOBJ(bmp.0)); + let blt = BitBlt(mem_dc, 0, 0, width, height, screen_dc, x, y, SRCCOPY); + if blt.is_err() { + SelectObject(mem_dc, old); + let _ = DeleteObject(bmp); + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + return Err("BitBlt failed".to_string()); + } + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: 0, + ..Default::default() + }, + ..Default::default() + }; + let count = (width * height) as usize; + let mut pixels = vec![0u32; count]; + let ok = GetDIBits( + mem_dc, + bmp, + 0, + height as u32, + Some(pixels.as_mut_ptr() as *mut _), + &mut bmi, + DIB_RGB_COLORS, + ); + SelectObject(mem_dc, old); + let _ = DeleteObject(bmp); + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + if ok == 0 { + return Err("GetDIBits failed".to_string()); + } + Ok(pixels) + } +} + +fn pixel_match_ratio(buffer: &[u32], desktop: &[u32]) -> u32 { + if buffer.is_empty() || buffer.len() != desktop.len() { + return 0; + } + let mut matched = 0u32; + for (a, b) in buffer.iter().zip(desktop.iter()) { + let ar = ((*a >> 16) & 0xFF) as i32; + let ag = ((*a >> 8) & 0xFF) as i32; + let ab = (*a & 0xFF) as i32; + let br = ((*b >> 16) & 0xFF) as i32; + let bg = ((*b >> 8) & 0xFF) as i32; + let bb = (*b & 0xFF) as i32; + if (ar - br).abs() <= 12 && (ag - bg).abs() <= 12 && (ab - bb).abs() <= 12 { + matched += 1; + } + } + ((matched as u64) * 100 / buffer.len() as u64) as u32 +} diff --git a/src/spend_pace.rs b/src/spend_pace.rs new file mode 100644 index 00000000..7744a123 --- /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 e2502e2c..d2234fa7 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 f6d261ec..53cc7732 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicIsize, AtomicU32, Ordering}; use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -12,18 +12,21 @@ 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::*; use crate::diagnose; +use crate::proof_capture; 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_FULLSCREEN_CHECK, TIMER_POLL, + TIMER_RESET_POLL, TIMER_UPDATE_CHECK, TIMER_WIDGET_KEEPALIVE, + WM_APP_FOREGROUND_CHANGED, WM_APP_REQUEST_PROOF, 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}; @@ -49,6 +52,7 @@ struct AppState { taskbar_hwnd: Option, tray_notify_hwnd: Option, win_event_hook: Option, + foreground_hook: Option, is_dark: bool, embedded: bool, language_override: Option, @@ -57,8 +61,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,7 +107,22 @@ 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, + /// Last popup screen layout applied by position_at_taskbar (skip redundant SetWindowPos/render). + last_layout_x: i32, + last_layout_y: i32, + last_layout_w: i32, + last_layout_h: i32, + last_layout_valid: bool, + widget_visible: bool, + /// True while a fullscreen app has focus and we've hidden the popup for it. + /// Anything that re-asserts window visibility (tray relayout, keepalive) + /// must respect this or it will fight sync_fullscreen_visibility and flicker. + hidden_for_fullscreen: bool, } #[derive(Clone, Debug)] @@ -103,6 +135,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 +149,11 @@ 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. +/// Sentinel: use default placement (near system tray). +const TRAY_OFFSET_DEFAULT: i32 = -1; +#[allow(dead_code)] +const TRAY_OFFSET_LEFTMOST: i32 = TRAY_OFFSET_DEFAULT; const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 40; const IDM_LANG_ENGLISH: u16 = 41; @@ -134,16 +172,84 @@ 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 WM_APP_REPOSITION_TASKBAR: u32 = WM_APP + 6; 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); + +/// Ticks of TIMER_WIDGET_KEEPALIVE (15s each) since the last unconditional +/// repaint. DWM has been observed to silently stop compositing this layered +/// popup (UpdateLayeredWindow keeps returning success) without changing its +/// Win32-visible state, so a cheap "unchanged, skip" check alone cannot +/// recover from that; force a full reassert on a bound regardless of cache. +static KEEPALIVE_TICKS_SINCE_FORCE: AtomicU32 = AtomicU32::new(0); +const FORCE_REPAINT_EVERY_N_TICKS: u32 = 4; // ~60s at the 15s keepalive interval /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) static CURRENT_DPI: AtomicU32 = AtomicU32::new(96); +static STARTUP_LAYOUT_GRACE: Mutex> = Mutex::new(None); + +fn startup_layout_grace_active() -> bool { + let guard = STARTUP_LAYOUT_GRACE.lock().unwrap_or_else(|e| e.into_inner()); + guard + .map(|t| t.elapsed() < std::time::Duration::from_millis(1200)) + .unwrap_or(false) +} + +/// Cached UI font, keyed by its DPI-scaled point size. Calling CreateFontW on +/// every repaint (previously done in paint_content) was found to reliably +/// break this window's UpdateLayeredWindow compositing on this GDI/layered- +/// popup pattern — a fresh HFONT that is never even selected into a DC is +/// enough to trigger it. Reuse a single font across repaints and only +/// recreate it when the DPI-scaled size actually changes. +static CACHED_FONT_HANDLE: AtomicIsize = AtomicIsize::new(0); +static CACHED_FONT_SIZE: AtomicI32 = AtomicI32::new(0); + +fn cached_ui_font(size: i32) -> HFONT { + if CACHED_FONT_SIZE.load(Ordering::Relaxed) == size { + let handle = CACHED_FONT_HANDLE.load(Ordering::Relaxed); + if handle != 0 { + return HFONT(handle as *mut _); + } + } + + let font_name = native_interop::wide_str("Segoe UI"); + let font = unsafe { + CreateFontW( + size, + 0, + 0, + 0, + FW_MEDIUM.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_TT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + CLEARTYPE_QUALITY.0 as u32, + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ) + }; + + let old_handle = CACHED_FONT_HANDLE.swap(font.0 as isize, Ordering::Relaxed); + CACHED_FONT_SIZE.store(size, Ordering::Relaxed); + if old_handle != 0 { + unsafe { + let _ = DeleteObject(HFONT(old_handle as *mut _)); + } + } + font +} /// Scale a base pixel value (designed at 96 DPI) to the current DPI. fn sc(px: i32) -> i32 { @@ -225,6 +331,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 +369,74 @@ 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 { + TASKBAR_RECOVER_FAILURES.store(0, Ordering::Relaxed); + // Only nudge visibility when Windows hid the popup — not every second. + let (hidden_for_fullscreen, visible) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => ( + s.hidden_for_fullscreen, + unsafe { IsWindowVisible(widget_hwnd).as_bool() }, + ), + None => (false, true), + } + }; + if !visible && !hidden_for_fullscreen { + 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 +485,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 +505,42 @@ 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 { + // TRAY_OFFSET_LEFTMOST: 0 = near tray, max_offset = left edge of draggable band. + 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_placement_band_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 +582,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 +603,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 +631,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 +744,29 @@ 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 taskbar_has_tray(taskbar: &native_interop::TaskbarWindow) -> bool { + native_interop::find_descendant_window(taskbar.hwnd, "TrayNotifyWnd") + .or_else(|| native_interop::find_child_window(taskbar.hwnd, "TrayNotifyWnd")) + .is_some() +} + +fn resolve_taskbar_index(requested_index: usize, taskbars: &[native_interop::TaskbarWindow]) -> usize { + if taskbars.is_empty() { + return 0; + } + if requested_index > 0 { + if let Some(index) = taskbars.iter().position(|t| !t.is_primary) { + return index; + } + } else if let Some(index) = taskbars.iter().position(|t| t.is_primary) { + return index; + } + requested_index.min(taskbars.len() - 1) +} + fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { @@ -501,7 +774,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 +794,8 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { native_interop::unhook_win_event(hook); } - native_interop::embed_in_taskbar(hwnd, taskbar.hwnd); + suppress_tray_reposition_for(std::time::Duration::from_millis(800)); + 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 +814,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 } @@ -564,21 +841,35 @@ fn taskbar_at_point(pt: POINT) -> Option<(usize, native_interop::TaskbarWindow)> } fn tray_left_for_taskbar(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { - 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; - } - } - tray_left + native_interop::tray_left_for_screen_band(taskbar_hwnd, taskbar_rect) } -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 + content_left; + let max_x_screen = taskbar_rect.left + max_x; + let x = 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 +878,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 +926,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 +971,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 +1071,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,31 +1455,77 @@ 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; const BAR_RIGHT_MARGIN: i32 = 4; -const TEXT_WIDTH: i32 = 62; +const TEXT_WIDTH: i32 = 76; 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 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) -> bool { +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 +1575,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") } @@ -1169,7 +1627,6 @@ pub fn run() { // Enable Per-Monitor DPI Awareness V2 for crisp rendering at any scale factor unsafe { let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); - CURRENT_DPI.store(GetDpiForSystem(), Ordering::Relaxed); } diagnose::log("window::run started"); @@ -1238,6 +1695,13 @@ pub fn run() { let language = localization::resolve_language(language_override); let install_channel = updater::current_install_channel(); + // Pre-warm the cached UI font before the layered window exists. Calling + // CreateFontW for the first time during/after this window's first-ever + // paint was found to permanently break its UpdateLayeredWindow + // compositing on this GDI/layered-popup pattern; calling it before the + // window is created avoids the issue entirely. + let _ = cached_ui_font(sc(-12)); + // Create as layered popup (will be reparented into taskbar) let title = native_interop::wide_str(language.strings().window_title); let initial_model_count = active_model_count( @@ -1252,8 +1716,8 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count), - sc(WIDGET_HEIGHT), + 1, + 1, HWND::default(), HMENU::default(), hinstance, @@ -1261,6 +1725,14 @@ pub fn run() { ) .unwrap(); + native_interop::exclude_from_peek(hwnd); + + refresh_dpi(); + { + let mut grace = STARTUP_LAYOUT_GRACE.lock().unwrap_or_else(|e| e.into_inner()); + *grace = Some(std::time::Instant::now()); + } + if !large_icon.is_invalid() { let _ = SendMessageW( hwnd, @@ -1290,6 +1762,7 @@ pub fn run() { taskbar_hwnd: None, tray_notify_hwnd: None, win_event_hook: None, + foreground_hook: None, is_dark, embedded: false, language_override, @@ -1297,8 +1770,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 +1812,37 @@ 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, + last_layout_x: 0, + last_layout_y: 0, + last_layout_w: 0, + last_layout_h: 0, + last_layout_valid: false, widget_visible: settings.widget_visible, + hidden_for_fullscreen: false, }); } - // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { - embedded = true; + let foreground_hook = native_interop::set_foreground_event_hook(on_foreground_changed); + if foreground_hook.is_some() { + diagnose::log("foreground event hook installed"); + } else { + diagnose::log("foreground event hook could not be installed"); } + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.foreground_hook = foreground_hook; + } + } + + // Try to embed in taskbar + 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 +1856,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 +1881,8 @@ pub fn run() { .unwrap_or(POLL_15_MIN) }; SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None); + SetTimer(hwnd, TIMER_WIDGET_KEEPALIVE, 15_000, None); + SetTimer(hwnd, TIMER_FULLSCREEN_CHECK, 500, 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 @@ -1383,7 +1895,7 @@ pub fn run() { let send_hwnd = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { diagnose::log("initial poll thread started"); - do_poll(send_hwnd); + do_poll(send_hwnd, true); }); schedule_auto_update_check(hwnd); @@ -1418,12 +1930,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 +1949,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 +1970,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 +1985,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 +2003,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 +2029,7 @@ fn render_layered() { }; unsafe { - let screen_dc = GetDC(hwnd); + let screen_dc = GetDC(HWND::default()); let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { @@ -1518,7 +2051,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 +2073,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 +2088,54 @@ 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 layered popups/embedded children often lies; prefer coords + // stored by 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), + _ => { + drop(state); + 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 +2151,7 @@ fn render_layered() { let _ = UpdateLayeredWindow( hwnd, screen_dc, - None, + Some(&pt_dest), Some(&sz), mem_dc, Some(&pt_src), @@ -1595,11 +2160,39 @@ 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); + } + } + + let buffer_copy: Vec = pixel_data.to_vec(); + let taskbar_hwnd = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); + proof_capture::maybe_capture( + hwnd, + taskbar_hwnd, + layered_x, + layered_y, + width, + height, + &buffer_copy, + ); + // Cleanup SelectObject(mem_dc, old_bmp); let _ = DeleteObject(dib); let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); + ReleaseDC(HWND::default(), screen_dc); } } @@ -1613,11 +2206,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 +2224,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,38 +2288,73 @@ 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())); - let font_name = native_interop::wide_str("Segoe UI"); - let font = CreateFontW( - sc(-12), - 0, - 0, - 0, - FW_MEDIUM.0 as i32, - 0, - 0, - 0, - DEFAULT_CHARSET.0 as u32, - OUT_TT_PRECIS.0 as u32, - CLIP_DEFAULT_PRECIS.0 as u32, - CLEARTYPE_QUALITY.0 as u32, - (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, - PCWSTR::from_raw(font_name.as_ptr()), - ); + let font = cached_ui_font(sc(-12)); 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 +2364,7 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_session_accent, codex_accent, antigravity_accent, track, @@ -1731,10 +2372,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,19 +2385,41 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_weekly_accent, codex_accent, antigravity_accent, track, ); - - SelectObject(hdc, old_font); - let _ = DeleteObject(font); - } -} - -fn do_poll(send_hwnd: SendHwnd) { - let hwnd = send_hwnd.to_hwnd(); + 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); + } +} + +fn do_poll(send_hwnd: SendHwnd, force_refresh: bool) { + let hwnd = send_hwnd.to_hwnd(); let (show_claude_code, show_codex, show_antigravity) = { let state = lock_state(); state @@ -1765,8 +2428,24 @@ fn do_poll(send_hwnd: SendHwnd) { .unwrap_or((true, false, false)) }; - match poller::poll(show_claude_code, show_codex, show_antigravity) { + match poller::poll_with_options( + show_claude_code, + show_codex, + show_antigravity, + poller::PollOptions { force_refresh }, + ) { Ok(data) => { + let unchanged = { + let state = lock_state(); + state + .as_ref() + .and_then(|s| s.data.as_ref()) + .is_some_and(|prev| prev == &data) + }; + if unchanged { + return; + } + let mut state = lock_state(); if let Some(s) = state.as_mut() { if let Some(claude_code) = data.claude_code.as_ref() { @@ -1820,6 +2499,7 @@ fn do_poll(send_hwnd: SendHwnd) { } } Err(e) => { + poller::invalidate_poll_cache(); let auth_watch = match e { poller::PollError::AuthRequired | poller::PollError::TokenExpired if show_antigravity && !show_claude_code && !show_codex => @@ -1852,35 +2532,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 +2740,396 @@ 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_placement_band_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 popup_layout_unchanged(screen_x: i32, screen_y: i32, width: i32, height: i32) -> bool { + lock_state().as_ref().is_some_and(|s| { + s.last_layout_valid + && s.last_layout_x == screen_x + && s.last_layout_y == screen_y + && s.last_layout_w == width + && s.last_layout_h == height + }) +} + +fn store_popup_layout(screen_x: i32, screen_y: i32, width: i32, height: i32) { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.last_layout_x = screen_x; + s.last_layout_y = screen_y; + s.last_layout_w = width; + s.last_layout_h = height; + s.last_layout_valid = true; + } +} + +fn invalidate_popup_layout() { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.last_layout_valid = false; + } +} + +fn ensure_popup_visible() { + let (visible, dragging, embedded, already_visible, layout_valid, hidden_for_fullscreen) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => ( + s.widget_visible, + s.dragging, + s.embedded, + unsafe { IsWindowVisible(s.hwnd.to_hwnd()).as_bool() }, + s.last_layout_valid, + s.hidden_for_fullscreen, + ), + None => return, + } + }; + if !visible || dragging || embedded || hidden_for_fullscreen { + return; + } + if already_visible && layout_valid { + return; + } + position_at_taskbar(); +} + +/// Bypass the "unchanged, skip" caches on a bound and force a full +/// reassert + repaint. Guards against DWM dropping this window's composited +/// surface while every Win32 call (IsWindowVisible, UpdateLayeredWindow) +/// keeps reporting success — a state the cheap checks in +/// `ensure_popup_visible` cannot detect, only recover from periodically. +fn force_periodic_repaint() { + if KEEPALIVE_TICKS_SINCE_FORCE.fetch_add(1, Ordering::Relaxed) + 1 < FORCE_REPAINT_EVERY_N_TICKS + { + return; + } + KEEPALIVE_TICKS_SINCE_FORCE.store(0, Ordering::Relaxed); + + let (visible, dragging, embedded, hidden_for_fullscreen) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => ( + s.widget_visible, + s.dragging, + s.embedded, + s.hidden_for_fullscreen, + ), + None => return, + } + }; + if !visible || dragging || embedded || hidden_for_fullscreen { + return; + } + invalidate_popup_layout(); + position_at_taskbar(); + render_layered(); +} + +/// The floating popup is HWND_TOPMOST so it can track the real taskbar's tray +/// icons, but that also puts it above fullscreen apps/videos, which the real +/// taskbar never does. Hide it while a fullscreen app has focus, matching +/// "only shown when the taskbar would be shown". +fn sync_fullscreen_visibility(hwnd: HWND) { + use std::sync::atomic::{AtomicBool, Ordering}; + + static PEEK_WAS_ACTIVE: AtomicBool = AtomicBool::new(false); + + 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; + } + let taskbar_hwnd = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); + native_interop::refresh_taskbar_peek_latch(hwnd, taskbar_hwnd); + + let peek_active = native_interop::taskbar_peek_latch_active(); + let was_peek = PEEK_WAS_ACTIVE.swap(peek_active, Ordering::Relaxed); + + if peek_active { + if !was_peek { + diagnose::log("taskbar peek: switching to taskbar-band z-order"); + } + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.hidden_for_fullscreen = false; + } + } + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + native_interop::raise_on_taskbar_band(hwnd, taskbar_hwnd); + if !was_peek { + invalidate_popup_layout(); + position_at_taskbar(); + render_layered(); + } + return; + } + + if was_peek { + diagnose::log("taskbar peek ended: restoring topmost"); + invalidate_popup_layout(); + position_at_taskbar(); + render_layered(); + } + + // Exclusive fullscreen: hide while a monitor-filling app has retracted the taskbar. + let should_suppress = native_interop::should_hide_widget_for_fullscreen(hwnd, taskbar_hwnd); + let was_suppressed = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + + if should_suppress { + if !was_suppressed { + diagnose::log("fullscreen suppress: hide + lower z-order"); + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.hidden_for_fullscreen = true; + } + unsafe { + let _ = ShowWindow(hwnd, SW_HIDE); + } + native_interop::lower_below_fullscreen(hwnd); + } + return; + } + + if was_suppressed { + diagnose::log("fullscreen ended: restoring widget"); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.hidden_for_fullscreen = false; + } + } + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + invalidate_popup_layout(); + 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,74 +3141,224 @@ 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"); - return; + (s.hwnd.to_hwnd(), s.tray_offset, s.taskbar_index) + }; + + let taskbar_hwnd = { + let selected = native_interop::taskbar_hwnd_for_settings_index(taskbar_index) + .or_else(|| { + let taskbars = native_interop::find_taskbars(); + if taskbars.is_empty() { + return None; + } + let index = resolve_taskbar_index(taskbar_index, &taskbars); + Some(taskbars[index].hwnd) + }); + let Some(selected) = selected else { + diagnose::log("position_at_taskbar skipped: no taskbar found"); + return; + }; + let rebound = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if s.taskbar_hwnd != Some(selected) { + s.taskbar_hwnd = Some(selected); + s.embedded = false; + s.layered_position_valid = false; + true + } else { + false + } + } else { + false } }; - - (s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd) + if rebound { + invalidate_popup_layout(); + diagnose::log(format!( + "position_at_taskbar: bound taskbar hwnd={:?}", + selected + )); + } + selected }; + if let Some(tray_hwnd) = native_interop::find_descendant_window(taskbar_hwnd, "TrayNotifyWnd") + .or_else(|| native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd")) + { + if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) { + diagnose::log(format!( + "TrayNotifyWnd rect=({},{},{},{})", + tray_rect.left, tray_rect.top, tray_rect.right, tray_rect.bottom + )); + } else { + diagnose::log("TrayNotifyWnd rect query failed"); + } + } else { + diagnose::log("TrayNotifyWnd not found during position"); + } + let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { - Some(r) => r, + Some(raw) => { + let resolved = native_interop::screen_taskbar_rect(taskbar_hwnd, raw); + diagnose::log(format!( + "taskbar_rect raw=({},{},{},{}) screen=({},{},{},{})", + raw.left, raw.top, raw.right, raw.bottom, + resolved.left, resolved.top, resolved.right, resolved.bottom + )); + resolved + } None => { diagnose::log("position_at_taskbar skipped: unable to query taskbar rect"); return; } }; + // HWND starts at (0,0) on the primary monitor; read DPI from the taskbar monitor + // before sizing so the first layout is not 2x on multi-monitor setups. + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + taskbar_rect.left, + taskbar_rect.top, + 0, + 0, + SWP_NOSIZE | SWP_NOACTIVATE, + ); + } + refresh_dpi(); + let taskbar_height = taskbar_rect.bottom - taskbar_rect.top; - let mut tray_left = taskbar_rect.right; + let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); + diagnose::log(format!("tray_left={tray_left} for band left={}", taskbar_rect.left)); let anchor_top = taskbar_rect.top; let anchor_height = taskbar_height; - 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(); - 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_placement_band_left(taskbar_hwnd, taskbar_rect); + diagnose::log(format!("content_left={content_left}")); + 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 = content_left + max_offset - tray_offset; + x = x.clamp(content_left, 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); + if popup_layout_unchanged(screen_x, screen_y, widget_width, widget_height) { + return; + } + { + 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); + store_popup_layout(screen_x, screen_y, 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, + ); + if popup_layout_unchanged(x, y, widget_width, widget_height) { + return; + } + { + 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; + } + } + let suppressed = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + diagnose::log(format!( + "popup layout x={x} y={y} w={widget_width} h={widget_height} content_left={content_left} max_x={max_x} suppressed={suppressed}" + )); + if suppressed { + native_interop::position_notopmost_popup(hwnd, x, y, widget_width, widget_height); + } else { + native_interop::position_above_taskbar( + hwnd, + taskbar_hwnd, + x, + y, + widget_width, + widget_height, + ); + } + store_popup_layout(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} content_left={content_left} suppressed={suppressed}" )); } + if widget_visible { + let suppressed = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + if suppressed { + unsafe { + let _ = ShowWindow(hwnd, SW_HIDE); + } + } else { + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + render_layered(); + } + } } fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 { @@ -2191,9 +3406,62 @@ unsafe extern "system" fn on_tray_location_changed( } }; if should_reposition { - position_at_taskbar(); - render_layered(); + let widget_hwnd = lock_state() + .as_ref() + .map(|s| s.hwnd.to_hwnd()) + .unwrap_or(HWND::default()); + if !widget_hwnd.0.is_null() { + // Never reposition synchronously from WinEvent — EnumChildWindows in + // position_at_taskbar can deadlock with SetWindowPos on the shell thread. + let _ = PostMessageW(widget_hwnd, WM_APP_REPOSITION_TASKBAR, WPARAM(0), LPARAM(0)); + } + } + } +} + +/// WinEvent callback for system-wide foreground window changes. Forces a +/// repaint shortly after focus moves, to counteract DWM dropping this +/// window's composited layered content during a shell/XAML surface +/// transition (Start menu, Search, task switches) — see +/// set_foreground_event_hook for why this needs to be event-driven rather +/// than left to the 500ms TIMER_FULLSCREEN_CHECK poll. +unsafe extern "system" fn on_foreground_changed( + _hook: HWINEVENTHOOK, + _event: u32, + _hwnd: HWND, + _id_object: i32, + _id_child: i32, + _thread: u32, + _time: u32, +) { + static LAST_FOREGROUND_REPAINT: Mutex> = Mutex::new(None); + + let should_repaint = { + let mut last = LAST_FOREGROUND_REPAINT + .lock() + .unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + if last + .map(|t| now.duration_since(t).as_millis() > 100) + .unwrap_or(true) + { + *last = Some(now); + true + } else { + false } + }; + if !should_repaint { + return; + } + + let widget_hwnd = lock_state() + .as_ref() + .map(|s| s.hwnd.to_hwnd()) + .unwrap_or(HWND::default()); + if !widget_hwnd.0.is_null() { + // Never repaint synchronously from WinEvent — see on_tray_location_changed. + let _ = PostMessageW(widget_hwnd, WM_APP_FOREGROUND_CHANGED, WPARAM(0), LPARAM(0)); } } @@ -2206,35 +3474,46 @@ 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); + if new_dpi > 0 { + CURRENT_DPI.store(new_dpi, Ordering::Relaxed); + } } if msg == WM_SETTINGCHANGE { check_theme_change(); check_language_change(); } refresh_dpi(); + if startup_layout_grace_active() { + return LRESULT(0); + } position_at_taskbar(); render_layered(); LRESULT(0) @@ -2243,19 +3522,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 +3549,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, false); + }); } TIMER_COUNTDOWN => { update_display(); @@ -2297,13 +3573,49 @@ unsafe extern "system" fn wnd_proc( if should_poll { let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { - do_poll(sh); + do_poll(sh, true); }); } } TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_WIDGET_KEEPALIVE => { + ensure_popup_visible(); + if proof_capture::flag_pending() { + render_layered(); + } + force_periodic_repaint(); + } + TIMER_FULLSCREEN_CHECK => { + sync_fullscreen_visibility(hwnd); + } + 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 +3623,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 +3636,40 @@ 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) + } + msg if msg == WM_APP_REPOSITION_TASKBAR => { + position_at_taskbar(); + LRESULT(0) + } + msg if msg == WM_APP_FOREGROUND_CHANGED => { + render_layered(); + LRESULT(0) + } + msg if msg == WM_APP_REQUEST_PROOF => { + invalidate_popup_layout(); + if let Some(s) = lock_state().as_mut() { + s.layered_position_valid = false; + } + position_at_taskbar(); + render_layered(); + 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 +3679,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,15 +3741,24 @@ 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; } } render_layered(); + poller::invalidate_poll_cache(); let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { - do_poll(sh); + do_poll(sh, true); }); } IDM_VERSION_ACTION => { @@ -2554,22 +3794,29 @@ unsafe extern "system" fn wnd_proc( } } 2 => { - let hook = { + let (hook, fg_hook) = { let state = lock_state(); - state.as_ref().and_then(|s| s.win_event_hook) + state + .as_ref() + .map(|s| (s.win_event_hook, s.foreground_hook)) + .unwrap_or((None, None)) }; if let Some(h) = hook { native_interop::unhook_win_event(h); } + if let Some(h) = fg_hook { + native_interop::unhook_win_event(h); + } PostQuitMessage(0); } IDM_RESET_POSITION => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.tray_offset = 0; + s.tray_offset = TRAY_OFFSET_LEFTMOST; } } + invalidate_popup_layout(); save_state_settings(); position_at_taskbar(); } @@ -2618,6 +3865,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(); @@ -2628,9 +3877,10 @@ unsafe extern "system" fn wnd_proc( position_at_taskbar(); render_layered(); sync_tray_icons(hwnd); + poller::invalidate_poll_cache(); let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { - do_poll(sh); + do_poll(sh, true); }); } IDM_LANG_SYSTEM @@ -2687,13 +3937,19 @@ unsafe extern "system" fn wnd_proc( LRESULT(0) } WM_DESTROY => { - let hook = { + let (hook, fg_hook) = { let state = lock_state(); - state.as_ref().and_then(|s| s.win_event_hook) + state + .as_ref() + .map(|s| (s.win_event_hook, s.foreground_hook)) + .unwrap_or((None, None)) }; if let Some(h) = hook { native_interop::unhook_win_event(h); } + if let Some(h) = fg_hook { + native_interop::unhook_win_event(h); + } tray_icon::remove_all(hwnd); PostQuitMessage(0); LRESULT(0) @@ -2974,8 +4230,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 +4245,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 +4264,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 +4279,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 +4347,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 +4362,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, );