From a8be4f8038fbf355ccd82dda4e7bf9ba31a1370c Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sat, 20 Jun 2026 18:48:05 +0300 Subject: [PATCH 01/10] feat: display enterprise account credit and spend usage rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For users on an enterprise Claude plan (no five-hour / seven-day rate-limit buckets), the widget now shows two dedicated rows: Cr 9%·20/6 — credit remaining (%) and expiry date in system locale format Sp $12/$50 — spend used / spend limit For personal (Pro/Free) accounts the rows continue to show the normal 5h/7d rate-limit bars. Implementation notes: - Parses `cinder_cove` and `spend` fields from the usage endpoint response - Locale-aware date format via GetLocaleInfoW (respects separator and D/M order) - Disk cache at %APPDATA%\ClaudeCodeUsageMonitor\account_cache.json survives widget restarts; cleared when plan has no enterprise fields (prevents stale enterprise rows appearing for Pro users after a 429) - Tray tooltip uses dynamic row labels ("Cr"/"Sp" vs "5h"/"7d") - tray_icon.rs: guard empty text before DrawTextW to avoid GDI crash --- src/models.rs | 10 ++ src/native_interop.rs | 40 ++++++ src/poller.rs | 296 +++++++++++++++++++++++++++++++++++++++--- src/tray_icon.rs | 14 +- src/window.rs | 66 +++++++++- 5 files changed, 397 insertions(+), 29 deletions(-) diff --git a/src/models.rs b/src/models.rs index da49ef10..34f10b32 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,6 +4,7 @@ use std::time::SystemTime; pub struct UsageSection { pub percentage: f64, pub resets_at: Option, + pub has_bucket: bool, } #[derive(Clone, Debug, Default)] @@ -12,9 +13,18 @@ pub struct UsageData { pub weekly: UsageSection, } +#[derive(Clone, Debug, Default)] +pub struct AccountUsage { + pub credit_pct: f64, + pub credit_expiry: Option, + pub spend_used: f64, + pub spend_limit: f64, +} + #[derive(Clone, Debug, Default)] pub struct AppUsageData { pub claude_code: Option, pub codex: Option, pub antigravity: Option, + pub account: Option, } diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d087..4a50dce3 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,9 +1,14 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Globalization::GetLocaleInfoW; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; +const LOCALE_USER_DEFAULT: u32 = 0x0400; +// Short date format pattern (e.g. "M/d/yyyy") +const LOCALE_SSHORTDATE: u32 = 0x001F; + // Window style constants pub const WS_POPUP_STYLE: u32 = 0x80000000; pub const WS_CHILD_STYLE: u32 = 0x40000000; @@ -181,6 +186,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..087aa97d 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -6,12 +6,86 @@ use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::os::windows::process::CommandExt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use crate::diagnose; use crate::localization::Strings; -use crate::models::{AppUsageData, UsageData, UsageSection}; +use crate::models::{AccountUsage, AppUsageData, UsageData, UsageSection}; + +// In-memory cache: survives transient 429s within a single session. +static LAST_KNOWN_ACCOUNT: Mutex> = Mutex::new(None); +// Ensures disk cache is read at most once per process lifetime. +static DISK_CACHE_LOADED: AtomicBool = AtomicBool::new(false); + +#[derive(Serialize, Deserialize, Default)] +struct CachedAccountDisk { + credit_pct: f64, + credit_expiry_unix: Option, + spend_used: f64, + spend_limit: f64, +} + +fn account_cache_path() -> PathBuf { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(appdata) + .join("ClaudeCodeUsageMonitor") + .join("account_cache.json") +} + +fn save_account_to_disk(account: &AccountUsage) { + let path = account_cache_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let credit_expiry_unix = account + .credit_expiry + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + let disk = CachedAccountDisk { + credit_pct: account.credit_pct, + credit_expiry_unix, + spend_used: account.spend_used, + spend_limit: account.spend_limit, + }; + if let Ok(json) = serde_json::to_string(&disk) { + let _ = std::fs::write(&path, json); + } +} + +fn load_account_from_disk() -> Option { + let content = std::fs::read_to_string(account_cache_path()).ok()?; + let disk: CachedAccountDisk = serde_json::from_str(&content).ok()?; + let credit_expiry = disk.credit_expiry_unix.filter(|&s| s > 0).map(|secs| { + UNIX_EPOCH + Duration::from_secs(secs) + }); + Some(AccountUsage { + credit_pct: disk.credit_pct, + credit_expiry, + spend_used: disk.spend_used, + spend_limit: disk.spend_limit, + }) +} + +/// Pre-populate in-memory cache from disk on first call (no-op afterwards). +fn ensure_disk_cache_loaded() { + if DISK_CACHE_LOADED.swap(true, Ordering::Relaxed) { + return; + } + if let Some(account) = load_account_from_disk() { + if let Ok(mut cached) = LAST_KNOWN_ACCOUNT.lock() { + if cached.is_none() { + diagnose::log(format!( + "loaded account cache from disk credit_pct={}", + account.credit_pct + )); + *cached = Some(account); + } + } + } +} const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; @@ -47,6 +121,20 @@ pub type CredentialWatchSnapshot = Vec; struct UsageResponse { five_hour: Option, seven_day: Option, + cinder_cove: Option, + spend: Option, +} + +#[derive(Deserialize)] +struct SpendData { + used: SpendAmount, + limit: SpendAmount, +} + +#[derive(Deserialize)] +struct SpendAmount { + amount_minor: i64, + exponent: u32, } #[derive(Deserialize)] @@ -190,7 +278,7 @@ fn poll_with( show_claude_code: bool, show_codex: bool, show_antigravity: bool, - mut poll_claude_code: impl FnMut() -> Result, + mut poll_claude_code: impl FnMut() -> Result<(UsageData, Option), PollError>, mut poll_codex: impl FnMut() -> Result, mut poll_antigravity: impl FnMut() -> Result, ) -> Result { @@ -200,7 +288,10 @@ 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; + } Err(error) => { if active_provider_count > 1 { diagnose::log(format!("Claude Code usage poll failed: {error:?}")); @@ -241,7 +332,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 => { @@ -654,10 +745,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 +759,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 +770,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 +805,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; + } + + 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); - Ok(Some(data)) + 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 +1013,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 +1206,7 @@ fn antigravity_section_from_quota(quota: AntigravityQuotaInfo) -> 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"]; @@ -1733,3 +1902,92 @@ mod tests { assert!(usage.session.resets_at.is_some()); } } + +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/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..22f7f7f6 100644 --- a/src/window.rs +++ b/src/window.rs @@ -57,8 +57,10 @@ struct AppState { session_percent: f64, session_text: String, + session_label: String, weekly_percent: f64, weekly_text: String, + weekly_label: String, codex_session_percent: f64, codex_session_text: String, codex_weekly_percent: f64, @@ -405,9 +407,11 @@ fn tray_icon_data_from_state() -> Vec { kind: tray_icon::TrayIconKind::Claude, percent: Some(s.session_percent), tooltip: format!( - "{} 5h: {} | 7d: {}", + "{} {}: {} | {}: {}", s.language.strings().claude_code_model, + s.session_label, s.session_text, + s.weekly_label, s.weekly_text ), }); @@ -644,9 +648,39 @@ 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(); + 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(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(); @@ -1297,8 +1331,10 @@ 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(), codex_session_percent: 0.0, codex_session_text: "--".to_string(), codex_weekly_percent: 0.0, @@ -1350,10 +1386,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); } @@ -1422,8 +1462,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, @@ -1445,8 +1487,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, @@ -1540,8 +1584,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, @@ -1613,11 +1659,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, @@ -1713,7 +1761,7 @@ fn paint_content( row1_y, is_dark, text_color, - strings.session_window, + session_label, session_pct, session_text, codex_session_pct, @@ -1734,7 +1782,7 @@ fn paint_content( row2_y, is_dark, text_color, - strings.weekly_window, + weekly_label, weekly_pct, weekly_text, codex_weekly_pct, @@ -1857,6 +1905,8 @@ fn do_poll(send_hwnd: SendHwnd) { s.auth_watch_snapshot = watch_snapshot; s.session_text = "!".to_string(); s.weekly_text = "!".to_string(); + s.session_label = s.language.strings().session_window.to_string(); + s.weekly_label = s.language.strings().weekly_window.to_string(); s.codex_session_text = "!".to_string(); s.codex_weekly_text = "!".to_string(); s.antigravity_session_text = "!".to_string(); @@ -2510,6 +2560,8 @@ unsafe extern "system" fn wnd_proc( if let Some(s) = state.as_mut() { s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); + s.session_label = s.language.strings().session_window.to_string(); + s.weekly_label = s.language.strings().weekly_window.to_string(); s.codex_session_text = "...".to_string(); s.codex_weekly_text = "...".to_string(); s.force_notify_auth_error = true; @@ -2974,8 +3026,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, @@ -2995,8 +3049,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, @@ -3058,8 +3114,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, From 9bc92c854ae1057257aed1bd659a0e34a400f9b5 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Mon, 29 Jun 2026 15:29:22 +0300 Subject: [PATCH 02/10] fix: embedded-mode drag and taskbar-recovery improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native_interop: add move_window_async (SetWindowPos + SWP_ASYNCWINDOWPOS) to reposition WS_CHILD windows in Explorer without cross-process blocking; add detach_from_taskbar, raise_above_taskbar, TIMER_DRAG constant - window: fix drag in embedded mode — replace early-return + move_window with move_window_async; poll mouse via TIMER_DRAG (16 ms) instead of SetCapture (SetCapture on a taskbar child freezes Explorer) - window: widen drag-handle hit target from 3 px to 10 logical px (LEFT_DIVIDER_HIT_W) without changing the visual divider width; this makes the handle reliably hittable at high DPI (e.g. 20 physical px at 200% DPI) - window: add verbose diagnose logging to WM_LBUTTONDOWN / is_drag_handle_point / start_drag_reposition / TIMER_DRAG / update_drag_reposition_from_cursor - window: spawn_taskbar_watchdog improvements — detect stale embeds via window parentage check (GetParent) rather than HWND equality; post WM_APP_RECOVER_TASKBAR to re-attach without relaunching on transient failures; relaunch only after TASKBAR_RECOVER_MAX_ATTEMPTS consecutive failures - spend_pace: new module; .cargo/config.toml: linker config for MinGW cross-build --- .cargo/config.toml | 3 + .gitignore | 3 + src/main.rs | 1 + src/models.rs | 24 ++ src/native_interop.rs | 52 +++ src/poller.rs | 3 + src/spend_pace.rs | 353 +++++++++++++++ src/window.rs | 975 ++++++++++++++++++++++++++++++++---------- 8 files changed, 1191 insertions(+), 223 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 src/spend_pace.rs 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/src/main.rs b/src/main.rs index a17bc0b5..cef58d1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod localization; mod models; mod native_interop; mod poller; +mod spend_pace; mod theme; mod tray_icon; mod updater; diff --git a/src/models.rs b/src/models.rs index 34f10b32..98062b40 100644 --- a/src/models.rs +++ b/src/models.rs @@ -21,10 +21,34 @@ pub struct AccountUsage { pub spend_limit: f64, } +#[derive(Clone, Debug, Default)] +pub struct SpendPaceSlots { + pub month_actual: f64, + pub month_cap: f64, + pub month_expected: f64, + pub month_level: u8, + pub week_actual: f64, + pub week_cap: f64, + pub week_expected: f64, + pub week_level: u8, + pub day_actual: f64, + pub day_cap: f64, + pub day_expected: f64, + pub day_level: u8, +} + +#[derive(Clone, Debug)] +pub struct SpendPaceView { + pub credit_pct: f64, + pub credit_expiry: Option, + pub slots: SpendPaceSlots, +} + #[derive(Clone, Debug, Default)] pub struct AppUsageData { pub claude_code: Option, pub codex: Option, pub antigravity: Option, pub account: Option, + pub spend_pace: Option, } diff --git a/src/native_interop.rs b/src/native_interop.rs index 4a50dce3..5635b715 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -23,6 +23,7 @@ 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; // Custom messages pub const WM_APP: u32 = 0x8000; @@ -139,6 +140,41 @@ 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, + ); + } +} + +/// Re-assert HWND_TOPMOST so the window sits above Shell_TrayWnd (which is also topmost). +/// MoveWindow preserves z-order but doesn't lift us to the front of the topmost band. +pub fn raise_above_taskbar(hwnd: HWND) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + /// Move the window pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { unsafe { @@ -146,6 +182,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, diff --git a/src/poller.rs b/src/poller.rs index 087aa97d..e2701fb7 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -291,6 +291,9 @@ fn poll_with( 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 { diff --git a/src/spend_pace.rs b/src/spend_pace.rs new file mode 100644 index 00000000..4969670f --- /dev/null +++ b/src/spend_pace.rs @@ -0,0 +1,353 @@ +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 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(); + + if spend_used + 0.01 < anchors.last_spend { + anchors = SpendAnchorsDisk { + day_key: day_key.clone(), + day_spend_start: spend_used, + week_key: week_key.clone(), + week_spend_start: spend_used, + last_spend: spend_used, + }; + save_anchors(&anchors); + return anchors; + } + + if anchors.day_key != day_key { + anchors.day_key = day_key; + anchors.day_spend_start = anchors.last_spend; + } + 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 { + // Credits are consumed first; dollar spend only applies after credits are exhausted. + if account.spend_limit <= 0.0 || account.credit_pct < 100.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"); + } +} diff --git a/src/window.rs b/src/window.rs index 22f7f7f6..90bb2e9e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -12,7 +12,7 @@ use windows::Win32::System::Registry::*; use windows::Win32::System::Threading::{CreateMutexW, WaitForSingleObject}; use windows::Win32::UI::Accessibility::HWINEVENTHOOK; use windows::Win32::UI::HiDpi::*; -use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture}; +use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, ReleaseCapture, SetCapture, VK_LBUTTON}; use windows::Win32::UI::Shell::ExtractIconExW; use windows::Win32::UI::WindowsAndMessaging::*; @@ -20,10 +20,11 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, + self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + 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}; @@ -61,6 +62,17 @@ struct AppState { 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, @@ -118,6 +130,8 @@ const IDM_FREQ_15MIN: u16 = 12; const IDM_FREQ_1HOUR: u16 = 13; const IDM_START_WITH_WINDOWS: u16 = 20; const IDM_RESET_POSITION: u16 = 30; +/// Persisted in settings.json; resolved to max_offset at layout time. +const TRAY_OFFSET_LEFTMOST: i32 = -1; const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 40; const IDM_LANG_ENGLISH: u16 = 41; @@ -136,13 +150,16 @@ 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 TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// How often the watchdog thread polls for an explorer.exe restart (which /// recreates the taskbar and wipes our tray-icon registration). -const TASKBAR_WATCH_INTERVAL_SECS: u64 = 2; +const TASKBAR_WATCH_INTERVAL_SECS: u64 = 1; +const TASKBAR_RECOVER_MAX_ATTEMPTS: u32 = 3; static SUPPRESS_TRAY_REPOSITION_UNTIL: Mutex> = Mutex::new(None); +static TASKBAR_RECOVER_FAILURES: AtomicU32 = AtomicU32::new(0); /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) static CURRENT_DPI: AtomicU32 = AtomicU32::new(96); @@ -227,6 +244,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 @@ -236,22 +282,50 @@ 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 !embedded || !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; + } + 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; + } + + 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)); } }); } @@ -320,10 +394,18 @@ struct SettingsFile { show_antigravity: bool, } +fn resolve_tray_offset(stored: i32, max_offset: i32) -> i32 { + if stored < 0 { + max_offset + } else { + stored.clamp(0, 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, @@ -403,17 +485,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!( + 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 { @@ -498,6 +598,28 @@ fn toggle_widget_visibility(hwnd: HWND) { } } +/// Pick a taskbar that actually hosts the notification area. On multi-monitor +/// setups Windows can expose a spanning primary bar (often at a virtual top +/// edge) that has no TrayNotifyWnd; embedding there hides the widget. +fn resolve_taskbar_index(requested_index: usize, taskbars: &[native_interop::TaskbarWindow]) -> usize { + if taskbars.is_empty() { + return 0; + } + let capped = requested_index.min(taskbars.len() - 1); + if native_interop::find_child_window(taskbars[capped].hwnd, "TrayNotifyWnd").is_some() { + return capped; + } + for (index, taskbar) in taskbars.iter().enumerate() { + if native_interop::find_child_window(taskbar.hwnd, "TrayNotifyWnd").is_some() { + diagnose::log(format!( + "taskbar index {requested_index} has no TrayNotifyWnd; using index {index}" + )); + return index; + } + } + capped +} + fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { @@ -505,7 +627,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=({}, {}, {}, {})", @@ -544,14 +666,18 @@ 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 = true; + s.dragging = false; + } } + save_state_settings(); true } @@ -651,6 +777,12 @@ fn refresh_usage_texts(state: &mut AppState) { // 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; @@ -663,8 +795,42 @@ fn refresh_usage_texts(state: &mut AppState) { diagnose::log(format!("refresh_usage_texts: has_rate_limit={has_rate_limit} account={}", data.account.is_some())); if !has_rate_limit { - if let Some(account) = data.account.as_ref() { - diagnose::log(format!("refresh_usage_texts: setting Cr/Sp rows credit_pct={}", account.credit_pct)); + 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 = account.credit_pct < 100.0; + 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); @@ -1085,6 +1251,8 @@ const SEGMENT_COUNT: i32 = 10; const CORNER_RADIUS: i32 = 2; const LEFT_DIVIDER_W: i32 = 3; +/// Hit-test width for the drag handle — wider than the visual divider for usability. +const LEFT_DIVIDER_HIT_W: i32 = 10; const DIVIDER_RIGHT_MARGIN: i32 = 10; const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; @@ -1093,23 +1261,62 @@ const TEXT_WIDTH: i32 = 62; const MODEL_RIGHT_MARGIN: i32 = 3; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; +const WIDGET_HEIGHT_PACE: i32 = 48; +const WIDGET_HEIGHT_PACE_CREDIT: i32 = 88; + +fn widget_height(account_pace_mode: bool, show_credit_row: bool) -> i32 { + if account_pace_mode { + if show_credit_row { + WIDGET_HEIGHT_PACE_CREDIT + } else { + WIDGET_HEIGHT_PACE + } + } else { + WIDGET_HEIGHT + } +} + +fn widget_height_for_state(state: &AppState) -> i32 { + widget_height(state.account_pace_mode, state.show_credit_row) +} + +fn is_drag_handle_point(client_x: i32, client_y: i32, state: &AppState) -> bool { + is_drag_handle_point_inner(client_x, client_y, state, false) +} -fn is_drag_handle_point(client_x: i32, client_y: i32) -> bool { +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 { 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) } } @@ -1159,6 +1366,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") } @@ -1335,6 +1550,17 @@ pub fn run() { 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, @@ -1477,6 +1703,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() { @@ -1502,6 +1739,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, } @@ -1509,16 +1757,7 @@ 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; - } - - 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); @@ -1599,17 +1838,28 @@ 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). + // Embedded: background pixels nearly invisible (blends with taskbar), content fully opaque. + // Popup: all pixels fully opaque (solid standalone window). let bg_bgr = bg_color.to_colorref(); 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 { + if embedded && rgb == bg_bgr { *px = 0x01000000; } else { *px = rgb | 0xFF000000; @@ -1677,6 +1927,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, ) { @@ -1730,8 +1991,35 @@ fn paint_content( let _ = DeleteObject(right_brush); let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN); - let row2_y = height - sc(5) - sc(SEGMENT_H); - let row1_y = row2_y - sc(10) - sc(SEGMENT_H); + let bottom_y = height - sc(5) - sc(SEGMENT_H); + let (mo_y, wk_y, dy_y, credit_y) = if account_pace_mode { + let row_gap = sc(1); + let dy_y = bottom_y; + let wk_y = dy_y - row_gap - sc(SEGMENT_H); + let mo_y = wk_y - row_gap - sc(SEGMENT_H); + let credit_y = if show_credit_row { + Some(mo_y - row_gap - sc(SEGMENT_H)) + } else { + None + }; + (mo_y, wk_y, dy_y, credit_y) + } else { + let wk_y = bottom_y; + let mo_y = wk_y - sc(10) - sc(SEGMENT_H); + (mo_y, wk_y, bottom_y, None) + }; + + let claude_session_accent = if account_pace_mode { + spend_pace::pace_accent(session_pace_level) + } else { + *accent + }; + let claude_weekly_accent = if account_pace_mode { + spend_pace::pace_accent(weekly_pace_level) + } else { + *accent + }; + let claude_day_accent = spend_pace::pace_accent(day_pace_level); let _ = SetBkMode(hdc, TRANSPARENT); let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -1755,10 +2043,34 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); + if let Some(credit_y) = credit_y { + draw_row( + hdc, + content_x, + credit_y, + is_dark, + text_color, + credit_label, + credit_pct, + credit_text, + codex_session_pct, + codex_session_text, + antigravity_session_pct, + antigravity_session_text, + show_claude_code, + show_codex, + show_antigravity, + accent, + codex_accent, + antigravity_accent, + track, + ); + } + draw_row( hdc, content_x, - row1_y, + mo_y, is_dark, text_color, session_label, @@ -1771,7 +2083,7 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_session_accent, codex_accent, antigravity_accent, track, @@ -1779,7 +2091,7 @@ fn paint_content( draw_row( hdc, content_x, - row2_y, + wk_y, is_dark, text_color, weekly_label, @@ -1792,11 +2104,34 @@ fn paint_content( show_claude_code, show_codex, show_antigravity, - accent, + &claude_weekly_accent, codex_accent, antigravity_accent, track, ); + if account_pace_mode { + draw_row( + hdc, + content_x, + dy_y, + is_dark, + text_color, + day_label, + day_pct, + day_text, + codex_weekly_pct, + codex_weekly_text, + antigravity_weekly_pct, + antigravity_weekly_text, + show_claude_code, + show_codex, + show_antigravity, + &claude_day_accent, + codex_accent, + antigravity_accent, + track, + ); + } SelectObject(hdc, old_font); let _ = DeleteObject(font); @@ -1905,8 +2240,14 @@ fn do_poll(send_hwnd: SendHwnd) { s.auth_watch_snapshot = watch_snapshot; s.session_text = "!".to_string(); s.weekly_text = "!".to_string(); - s.session_label = s.language.strings().session_window.to_string(); - s.weekly_label = s.language.strings().weekly_window.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.antigravity_session_text = "!".to_string(); @@ -1927,6 +2268,8 @@ fn do_poll(send_hwnd: SendHwnd) { s.auth_watch_snapshot.clear(); 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(); @@ -2110,6 +2453,187 @@ 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, + }; + + 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 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(s.account_pace_mode, s.show_credit_row)); + let y = anchor_top + anchor_height - widget_height; + let mut x = if embedded { + tray_left - taskbar_rect.left - widget_width - new_offset + } else { + tray_left - widget_width - new_offset + }; + 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)); + if embedded { + let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); + x = x.clamp(0, max_x); + } + 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 { + native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); + native_interop::raise_above_taskbar(hwnd_val); + } + } +} + +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() { + s.dragging = true; + s.drag_start_mouse_x = pt.x; + s.drag_start_client_x = client_x; + s.drag_start_offset = s.tray_offset; + 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 { + let embedded = lock_state() + .as_ref() + .map(|s| s.embedded) + .unwrap_or(false); + if let Some((target_index, target_taskbar)) = taskbar_at_point(pt) { + if target_index != current_taskbar_index { + let new_offset = if embedded { + 0 + } else { + 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 position_at_taskbar() { refresh_dpi(); // Drop the app-state lock before any Win32 call that may synchronously @@ -2156,44 +2680,83 @@ fn position_at_taskbar() { } } - let widget_width = total_widget_width(); + 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 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 stored_tray_offset = tray_offset; + let tray_offset = resolve_tray_offset(tray_offset, max_offset); + let y = anchor_top + anchor_height - widget_height; + let widget_visible = lock_state() + .as_ref() + .map(|s| s.widget_visible) + .unwrap_or(true); + + // If the widget is taller than the taskbar, it cannot be fully shown as a child window + // (child windows are clipped to the parent's client area). Detach to popup mode so all + // rows remain visible — the popup path already positions correctly above the taskbar. + let was_embedded = embedded; + let embedded = 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; + s.tray_offset = 0; // place adjacent to clock; stale LEFTMOST offset gives x=0 } - } else { - false } + diagnose::log(format!( + "detached from taskbar: widget_height={widget_height} > taskbar_height={taskbar_height}" + )); + false + } else { + embedded + }; + // When not embedded due to height overflow (pace mode), place the popup adjacent to the + // tray clock by using offset=0. The LEFTMOST sentinel (-1) resolves to max_offset which + // clamps popup x to the left edge of the screen, not where the widget should appear. + let tray_offset = if !embedded && widget_height > taskbar_height { + 0 + } else if embedded && stored_tray_offset < 0 { + // LEFTMOST sentinel in embedded mode: reset to adjacent-to-clock so widget doesn't park at x=0 + { let mut state = lock_state(); if let Some(s) = state.as_mut() { s.tray_offset = 0; } } + 0 + } else { + tray_offset }; - if offset_changed { - save_state_settings(); - } - let widget_height = sc(WIDGET_HEIGHT); - 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 = tray_left - taskbar_rect.left - widget_width - tray_offset; + let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); + x = x.clamp(0, max_x); + let y_child = compute_anchor_y(anchor_top, anchor_height, widget_height) - anchor_top; + native_interop::move_window(hwnd, x, y_child, widget_width, widget_height); diagnose::log(format!( - "positioned embedded widget at x={x} y={} w={widget_width} h={widget_height}", + "positioned embedded widget at x={x} y={y_child} w={widget_width} h={widget_height} (raw_y={})", y - taskbar_rect.top )); } else { - // Topmost popup: screen coordinates - let x = tray_left - widget_width - tray_offset; + // Topmost popup: screen coordinates, aligned flush with taskbar bottom (overlapping). + // Re-assert HWND_TOPMOST after MoveWindow so we appear above Shell_TrayWnd. + let mut x = tray_left - widget_width - tray_offset; + let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); + x = (x - taskbar_rect.left).clamp(0, max_x) + taskbar_rect.left; native_interop::move_window(hwnd, x, y, widget_width, widget_height); + native_interop::raise_above_taskbar(hwnd); diagnose::log(format!( "positioned fallback widget at x={x} y={y} w={widget_width} h={widget_height}" )); } + if widget_visible { + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + render_layered(); + } } fn compute_anchor_y(anchor_top: i32, anchor_height: i32, widget_height: i32) -> i32 { @@ -2354,6 +2917,26 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_DRAG => { + let (dragging, tray_offset) = { + let state = lock_state(); + state.as_ref().map(|s| (s.dragging, s.tray_offset)).unwrap_or((false, 0)) + }; + if !dragging { + 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) @@ -2361,6 +2944,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( @@ -2373,17 +2957,19 @@ unsafe extern "system" fn wnd_proc( schedule_auto_update_check(hwnd); LRESULT(0) } + WM_APP_RECOVER_TASKBAR => { + recover_taskbar_embed(hwnd); + LRESULT(0) + } WM_SETCURSOR => { - let is_dragging = { + let (is_dragging, embedded) = { 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, 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); @@ -2393,160 +2979,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) @@ -2560,8 +3041,14 @@ unsafe extern "system" fn wnd_proc( if let Some(s) = state.as_mut() { s.session_text = "...".to_string(); s.weekly_text = "...".to_string(); - s.session_label = s.language.strings().session_window.to_string(); - s.weekly_label = s.language.strings().weekly_window.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; @@ -2619,7 +3106,7 @@ unsafe extern "system" fn wnd_proc( { let mut state = lock_state(); if let Some(s) = state.as_mut() { - s.tray_offset = 0; + s.tray_offset = TRAY_OFFSET_LEFTMOST; } } save_state_settings(); @@ -2670,6 +3157,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(); @@ -3041,6 +3530,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() { @@ -3064,11 +3564,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(); @@ -3129,6 +3647,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, ); From 5f0f1bb38158f68f04703cd0b792b8c735db0f2b Mon Sep 17 00:00:00 2001 From: Ori Yardenay Date: Tue, 14 Jul 2026 09:29:00 +0300 Subject: [PATCH 03/10] fix: self-recover stuck usage widget after auth failures Keep last-good Mo/Wk/Dy instead of painting !, retry auth polls on a timer rather than only credential-file mtime changes, and refresh Claude tokens on 401. Also repair stuck spend anchors and leftmost tray defaults. --- src/poller.rs | 18 +++- src/spend_pace.rs | 88 +++++++++++++++++-- src/window.rs | 213 +++++++++++++++++++++++++++------------------- 3 files changed, 220 insertions(+), 99 deletions(-) diff --git a/src/poller.rs b/src/poller.rs index e2701fb7..58943ef8 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -346,7 +346,20 @@ fn poll_claude_code() -> Result<(UsageData, Option), PollError> { 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 CLI token refresh and retry"); + cli_refresh_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 { @@ -1781,6 +1794,7 @@ mod tests { session: UsageSection { percentage, resets_at: None, + has_bucket: true, }, weekly: UsageSection::default(), } @@ -1808,7 +1822,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"), ) diff --git a/src/spend_pace.rs b/src/spend_pace.rs index 4969670f..06dc9557 100644 --- a/src/spend_pace.rs +++ b/src/spend_pace.rs @@ -251,40 +251,70 @@ fn format_usd(amount: f64) -> String { } } +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; + } + // 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; + } +} + 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_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: spend_used, + day_spend_start: 0.0, week_key: week_key.clone(), - week_spend_start: spend_used, + week_spend_start: 0.0, last_spend: spend_used, }; save_anchors(&anchors); return anchors; } - if anchors.day_key != day_key { + if anchors.day_key.is_empty() { + anchors.day_key = day_key.clone(); + anchors.day_spend_start = 0.0; + } else if anchors.day_key != day_key { anchors.day_key = day_key; anchors.day_spend_start = anchors.last_spend; } - if anchors.week_key != week_key { + + if anchors.week_key.is_empty() { + anchors.week_key = week_key.clone(); + anchors.week_spend_start = 0.0; + } 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 { - // Credits are consumed first; dollar spend only applies after credits are exhausted. - if account.spend_limit <= 0.0 || account.credit_pct < 100.0 { + if account.spend_limit <= 0.0 { return None; } @@ -347,7 +377,49 @@ mod tests { #[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"); + 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_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/window.rs b/src/window.rs index 90bb2e9e..ee585561 100644 --- a/src/window.rs +++ b/src/window.rs @@ -117,6 +117,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; @@ -374,7 +375,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, @@ -394,6 +395,10 @@ struct SettingsFile { show_antigravity: bool, } +fn default_tray_offset() -> i32 { + TRAY_OFFSET_LEFTMOST +} + fn resolve_tray_offset(stored: i32, max_offset: i32) -> i32 { if stored < 0 { max_offset @@ -402,6 +407,16 @@ fn resolve_tray_offset(stored: i32, max_offset: i32) -> i32 { } } +fn resolved_tray_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + stored: 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); + resolve_tray_offset(stored, max_offset) +} + impl Default for SettingsFile { fn default() -> Self { Self { @@ -764,6 +779,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; @@ -820,7 +870,7 @@ fn refresh_usage_texts(state: &mut AppState) { state.day_pace_level = slots.day_level; if let Some(account) = data.account.as_ref() { - state.show_credit_row = account.credit_pct < 100.0; + 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); @@ -848,16 +898,21 @@ fn refresh_usage_texts(state: &mut AppState) { } } } 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() { @@ -869,8 +924,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(); + } } } @@ -2235,45 +2292,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(); - 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.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.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(); - 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), @@ -2548,7 +2595,13 @@ fn start_drag_reposition(hwnd: HWND, pt: POINT, client_x: i32) { s.dragging = true; s.drag_start_mouse_x = pt.x; s.drag_start_client_x = client_x; - s.drag_start_offset = s.tray_offset; + 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; @@ -2579,22 +2632,14 @@ fn finalize_drag_reposition(hwnd: HWND, pt: POINT) { } }; if let Some((current_taskbar_index, drag_start_client_x)) = drag_result { - let embedded = lock_state() - .as_ref() - .map(|s| s.embedded) - .unwrap_or(false); if let Some((target_index, target_taskbar)) = taskbar_at_point(pt) { if target_index != current_taskbar_index { - let new_offset = if embedded { - 0 - } else { - offset_for_drop_point( - target_taskbar.hwnd, - target_taskbar.rect, - pt, - drag_start_client_x, - ) - }; + 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() { @@ -2698,14 +2743,12 @@ fn position_at_taskbar() { // If the widget is taller than the taskbar, it cannot be fully shown as a child window // (child windows are clipped to the parent's client area). Detach to popup mode so all // rows remain visible — the popup path already positions correctly above the taskbar. - let was_embedded = embedded; let embedded = 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; - s.tray_offset = 0; // place adjacent to clock; stale LEFTMOST offset gives x=0 } } diagnose::log(format!( @@ -2715,18 +2758,13 @@ fn position_at_taskbar() { } else { embedded }; - // When not embedded due to height overflow (pace mode), place the popup adjacent to the - // tray clock by using offset=0. The LEFTMOST sentinel (-1) resolves to max_offset which - // clamps popup x to the left edge of the screen, not where the widget should appear. - let tray_offset = if !embedded && widget_height > taskbar_height { - 0 - } else if embedded && stored_tray_offset < 0 { - // LEFTMOST sentinel in embedded mode: reset to adjacent-to-clock so widget doesn't park at x=0 - { let mut state = lock_state(); if let Some(s) = state.as_mut() { s.tray_offset = 0; } } - 0 - } else { - tray_offset - }; + if embedded && stored_tray_offset < 0 { + // Persist the resolved max_offset so drags start from the correct position. + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.tray_offset = tray_offset; + } + } if embedded { // Child window: coordinates relative to parent (taskbar) @@ -2856,19 +2894,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() { @@ -2878,21 +2921,13 @@ unsafe extern "system" fn wnd_proc( s.auth_watch_snapshot = current_snapshot; } } - drop(state); - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); } } - Some((false, _, _)) => { - let sh = SendHwnd::from_hwnd(hwnd); - std::thread::spawn(move || { - do_poll(sh); - }); - } - None => {} } + let sh = SendHwnd::from_hwnd(hwnd); + std::thread::spawn(move || { + do_poll(sh); + }); } TIMER_COUNTDOWN => { update_display(); @@ -2962,12 +2997,12 @@ unsafe extern "system" fn wnd_proc( LRESULT(0) } WM_SETCURSOR => { - let (is_dragging, embedded) = { + let is_dragging = { let state = lock_state(); state .as_ref() - .map(|s| (s.dragging, s.embedded)) - .unwrap_or((false, false)) + .map(|s| s.dragging) + .unwrap_or(false) }; if is_dragging || cursor_is_on_drag_handle(hwnd) { let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); From eacd4eba1b0af5da22de76c8eeb69ab469b4ba78 Mon Sep 17 00:00:00 2001 From: Ori Yardenay Date: Tue, 21 Jul 2026 12:24:11 +0300 Subject: [PATCH 04/10] fix: stabilize taskbar widget visibility and usage refresh on Windows Use HTTP OAuth refresh instead of claude -p on Windows, repair spend anchors so Wk/Dy reflect period deltas, and anchor the layered popup to visible taskbar chrome with TOPMOST z-order. Add keepalive and display- change debouncing so the widget survives sleep/DPI/taskbar moves. --- README.md | 2 +- src/localization/english.rs | 2 +- src/native_interop.rs | 258 +++++++++++++++++- src/poller.rs | 509 ++++++++++++++++++++++++++++++------ src/spend_pace.rs | 53 +++- src/window.rs | 434 ++++++++++++++++++++++-------- 6 files changed, 1058 insertions(+), 200 deletions(-) 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/native_interop.rs b/src/native_interop.rs index 5635b715..d6cc0eca 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -24,6 +24,7 @@ pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; pub const TIMER_UPDATE_CHECK: usize = 4; pub const TIMER_DRAG: usize = 5; +pub const TIMER_WIDGET_KEEPALIVE: usize = 6; // Custom messages pub const WM_APP: u32 = 0x8000; @@ -67,13 +68,149 @@ pub fn find_taskbars() -> Vec { taskbars } -/// Find a child window by class name +/// Find a child window by class name (direct children only). pub fn find_child_window(parent: HWND, class_name: &str) -> Option { + find_next_child_window(parent, HWND::default(), class_name) +} + +/// Find a descendant window by class name anywhere under `parent`. +pub fn find_descendant_window(parent: HWND, class_name: &str) -> Option { + struct Search { + target: String, + found: Option, + } + + unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let search = &mut *(lparam.0 as *mut Search); + let mut class_buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_buf); + if len > 0 { + let class = String::from_utf16_lossy(&class_buf[..len as usize]); + if class == search.target { + search.found = Some(hwnd); + return BOOL(0); + } + } + BOOL(1) + } + + let mut search = Search { + target: class_name.to_string(), + found: None, + }; + unsafe { + let _ = EnumChildWindows(parent, Some(enum_proc), LPARAM(&mut search as *mut _ as isize)); + } + search.found +} + +struct TaskbarBandScan { + taskbar_rect: RECT, + content_left: i32, + pin_right: i32, +} + +unsafe extern "system" fn scan_taskbar_band_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let scan = &mut *(lparam.0 as *mut TaskbarBandScan); + let mut class_buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_buf); + if len <= 0 { + return BOOL(1); + } + let class = String::from_utf16_lossy(&class_buf[..len as usize]); + if class != "MSTaskListWClass" && class != "MSTaskSwWClass" { + return BOOL(1); + } + if let Some(rect) = get_window_rect_safe(hwnd) { + scan.pin_right = scan.pin_right.max(rect.right); + let relative_right = rect.right.saturating_sub(scan.taskbar_rect.left); + let relative_left = rect.left.saturating_sub(scan.taskbar_rect.left); + let taskbar_width = scan.taskbar_rect.right - scan.taskbar_rect.left; + if relative_right > relative_left && relative_right < taskbar_width { + scan.content_left = scan.content_left.max(relative_right); + } + } + BOOL(1) +} + + +struct VisibleLeftScan { + visible_left: i32, + found: bool, +} + +unsafe extern "system" fn scan_visible_left_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + let scan = &mut *(lparam.0 as *mut VisibleLeftScan); + let mut class_buf = [0u16; 64]; + let len = GetClassNameW(hwnd, &mut class_buf); + if len > 0 { + let class = String::from_utf16_lossy(&class_buf[..len as usize]); + let is_chrome = class.contains("Start") + || class == "MSTaskListWClass" + || class == "MSTaskSwWClass" + || class == "ReBarWindow32" + || class == "ToolbarWindow32"; + if is_chrome { + if let Some(rect) = get_window_rect_safe(hwnd) { + if rect.right > rect.left { + scan.visible_left = if scan.found { + scan.visible_left.min(rect.left) + } else { + rect.left + }; + scan.found = true; + } + } + } + } + unsafe { + let _ = EnumChildWindows(hwnd, Some(scan_visible_left_proc), lparam); + } + BOOL(1) +} + +fn taskbar_visible_left(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + let mut scan = VisibleLeftScan { + visible_left: taskbar_rect.left, + found: false, + }; + unsafe { + let _ = EnumChildWindows( + taskbar_hwnd, + Some(scan_visible_left_proc), + LPARAM(&mut scan as *mut _ as isize), + ); + } + if scan.found { + scan.visible_left + } else { + taskbar_rect.left + } +} + +fn scan_taskbar_band(taskbar_hwnd: HWND, taskbar_rect: RECT) -> (i32, i32) { + let mut scan = TaskbarBandScan { + taskbar_rect, + content_left: 0, + pin_right: 0, + }; + unsafe { + let _ = EnumChildWindows( + taskbar_hwnd, + Some(scan_taskbar_band_proc), + LPARAM(&mut scan as *mut _ as isize), + ); + } + (scan.content_left, scan.pin_right) +} + +/// Find the next sibling child window matching `class_name`. +pub fn find_next_child_window(parent: HWND, after: HWND, class_name: &str) -> Option { unsafe { let class = wide_str(class_name); match FindWindowExW( parent, - HWND::default(), + after, PCWSTR::from_raw(class.as_ptr()), PCWSTR::null(), ) { @@ -120,17 +257,51 @@ pub fn get_window_rect_safe(hwnd: HWND) -> Option { } } -/// Embed our window as a child of the taskbar -pub fn embed_in_taskbar(hwnd: HWND, taskbar_hwnd: HWND) { +/// Left edge of visible taskbar chrome (relative to taskbar rect). +pub fn taskbar_content_left(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + taskbar_visible_left_screen(taskbar_hwnd, taskbar_rect).saturating_sub(taskbar_rect.left) +} + +/// Left edge of visible taskbar chrome in screen coordinates. +pub fn taskbar_visible_left_screen(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + taskbar_visible_left(taskbar_hwnd, taskbar_rect) +} + +/// Right edge of the pinned-app band in screen coordinates. +pub fn pin_band_right(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { + scan_taskbar_band(taskbar_hwnd, taskbar_rect).1 +} + +/// Ensure WS_EX_LAYERED is set so UpdateLayeredWindow can push pixels. +pub fn ensure_layered_style(hwnd: HWND) { unsafe { - // Preserve existing extended style, add tool window + no activate 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 { + 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; @@ -159,10 +330,18 @@ pub fn detach_from_taskbar(hwnd: HWND) { } } -/// Re-assert HWND_TOPMOST so the window sits above Shell_TrayWnd (which is also topmost). -/// MoveWindow preserves z-order but doesn't lift us to the front of the topmost band. -pub fn raise_above_taskbar(hwnd: HWND) { +/// Place the popup widget above Shell_TrayWnd in the topmost z-order band. +pub fn raise_above_taskbar(hwnd: HWND, _taskbar_hwnd: Option) { unsafe { + let _ = SetWindowPos( + hwnd, + HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); let _ = SetWindowPos( hwnd, HWND_TOPMOST, @@ -175,6 +354,67 @@ pub fn raise_above_taskbar(hwnd: HWND) { } } +/// Place the layered popup immediately above the taskbar in Z-order. +pub fn position_above_taskbar(hwnd: HWND, _taskbar_hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_NOTOPMOST, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + } +} + +/// Fallback when no taskbar handle is available yet. +pub fn position_topmost_popup(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + unsafe { + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + } +} + +/// Place a popup layered widget in the taskbar band (screen coords), just above the taskbar z-order. +pub fn position_on_taskbar_band( + hwnd: HWND, + taskbar_hwnd: HWND, + x: i32, + y: i32, + w: i32, + h: i32, +) { + unsafe { + let _ = SetWindowPos( + hwnd, + taskbar_hwnd, + x, + y, + w, + h, + SWP_NOACTIVATE, + ); + } +} + /// Move the window pub fn move_window(hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { unsafe { diff --git a/src/poller.rs b/src/poller.rs index 58943ef8..0bf1d0a2 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -89,6 +89,12 @@ fn ensure_disk_cache_loaded() { 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] = &[ @@ -349,8 +355,8 @@ fn poll_claude_code() -> Result<(UsageData, Option), PollError> { match fetch_usage_with_fallback(&creds.access_token) { Ok(result) => Ok(result), Err(PollError::AuthRequired) => { - diagnose::log("Claude usage auth error; attempting CLI token refresh and retry"); - cli_refresh_token(&creds.source); + 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); @@ -401,7 +407,7 @@ fn refresh_or_fallback(mut creds: Credentials) -> Result } let source = creds.source.clone(); - cli_refresh_token(&source); + refresh_claude_token(&source); match read_credentials_from_source(&source) { Some(refreshed) if !is_token_expired(refreshed.expires_at) => return Ok(refreshed), @@ -420,63 +426,378 @@ fn refresh_or_fallback(mut creds: Credentials) -> Result } } -/// Invoke the Claude CLI with a minimal prompt to force its internal -/// OAuth token refresh. -fn cli_refresh_token(source: &CredentialSource) { +/// Refresh Claude OAuth credentials without invoking the Claude CLI (no model spend). +fn refresh_claude_token(source: &CredentialSource) { + diagnose::log(format!("attempting Claude OAuth refresh via HTTP for {source:?}")); + if http_refresh_claude_token(source) { + diagnose::log("Claude OAuth refresh via HTTP succeeded"); + return; + } match source { - CredentialSource::Windows(_) => cli_refresh_windows_token(), - CredentialSource::Wsl { distro } => cli_refresh_wsl_token(distro), + CredentialSource::Wsl { distro } => { + diagnose::log( + "Claude OAuth HTTP refresh failed for WSL; falling back to Claude CLI (may incur usage charges)", + ); + cli_refresh_wsl_token(distro); + } + CredentialSource::Windows(_) => diagnose::log( + "Claude OAuth HTTP refresh failed; run 'claude auth login' if usage polling stays unauthorized", + ), } } -fn cli_refresh_windows_token() { - let claude_path = resolve_windows_claude_path(); - let is_cmd = claude_path.to_lowercase().ends_with(".cmd"); - diagnose::log(format!( - "attempting Windows Claude token refresh via {claude_path}" - )); +#[derive(Deserialize)] +struct OauthRefreshResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, + refresh_token_expires_in: Option, + scope: Option, +} + +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 args: &[&str] = &["-p", "."]; + 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; + } + }; - 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 response = match request_oauth_refresh(&refresh_token, &scopes) { + Some(value) => value, + None => return false, }; - cmd.env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") + + 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) { @@ -591,42 +912,6 @@ fn wait_for_refresh(child: &mut std::process::Child) { } } -/// Resolve the full path to the `claude` CLI executable. -fn resolve_windows_claude_path() -> String { - for name in &["claude.cmd", "claude"] { - if Command::new(name) - .arg("--version") - .creation_flags(CREATE_NO_WINDOW) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok() - { - return name.to_string(); - } - } - - for name in &["claude.cmd", "claude"] { - if let Ok(output) = Command::new("where.exe") - .arg(name) - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(first_line) = stdout.lines().next() { - let path = first_line.trim().to_string(); - if !path.is_empty() { - return path; - } - } - } - } - } - - "claude.cmd".to_string() -} - fn resolve_windows_codex_path() -> String { for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] { if Command::new(name) @@ -1515,7 +1800,9 @@ fn parse_credentials(content: &str, source: CredentialSource) -> Option) -> String { diff --git a/src/spend_pace.rs b/src/spend_pace.rs index 06dc9557..7744a123 100644 --- a/src/spend_pace.rs +++ b/src/spend_pace.rs @@ -261,6 +261,9 @@ 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) @@ -271,13 +274,29 @@ fn repair_stuck_anchors(anchors: &mut SpendAnchorsDisk, spend_used: f64) { } } +/// 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_stuck_anchors(&mut anchors, spend_used); + 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 { @@ -294,7 +313,7 @@ fn update_anchors(spend_used: f64) -> SpendAnchorsDisk { if anchors.day_key.is_empty() { anchors.day_key = day_key.clone(); - anchors.day_spend_start = 0.0; + 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; @@ -302,7 +321,7 @@ fn update_anchors(spend_used: f64) -> SpendAnchorsDisk { if anchors.week_key.is_empty() { anchors.week_key = week_key.clone(); - anchors.week_spend_start = 0.0; + 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; @@ -409,6 +428,34 @@ mod tests { 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 { diff --git a/src/window.rs b/src/window.rs index ee585561..b30dfaa4 100644 --- a/src/window.rs +++ b/src/window.rs @@ -21,6 +21,7 @@ use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + TIMER_WIDGET_KEEPALIVE, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; @@ -104,6 +105,11 @@ struct AppState { drag_start_client_x: i32, drag_start_offset: i32, + /// Screen origin for UpdateLayeredWindow when embedded (GetWindowRect lies on WS_CHILD). + layered_screen_x: i32, + layered_screen_y: i32, + layered_position_valid: bool, + widget_visible: bool, } @@ -152,6 +158,7 @@ const IDM_MODEL_ANTIGRAVITY: u16 = 62; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; const WM_APP_RECOVER_TASKBAR: u32 = WM_APP + 4; +const WM_APP_ENSURE_VISIBLE: u32 = WM_APP + 5; const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// How often the watchdog thread polls for an explorer.exe restart (which @@ -295,14 +302,24 @@ fn spawn_taskbar_watchdog() { None => continue, } }; - if !embedded || !widget_visible { + if !widget_visible { continue; } - 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; + if embedded { + let intact = old_taskbar + .is_some_and(|taskbar| is_widget_embedded_in_taskbar(widget_hwnd, taskbar)); + if intact { + TASKBAR_RECOVER_FAILURES.store(0, Ordering::Relaxed); + continue; + } + } else { + let taskbar_ok = old_taskbar.is_some_and(|taskbar| unsafe { IsWindow(taskbar).as_bool() }); + if taskbar_ok { + unsafe { + let _ = PostMessageW(widget_hwnd, WM_APP_ENSURE_VISIBLE, WPARAM(0), LPARAM(0)); + } + continue; + } } let widget_alive = unsafe { IsWindow(widget_hwnd).as_bool() }; @@ -407,13 +424,22 @@ fn resolve_tray_offset(stored: i32, max_offset: i32) -> i32 { } } +fn max_tray_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + widget_width: i32, +) -> i32 { + let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); + let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + (tray_left - taskbar_rect.left - widget_width - content_left).max(0) +} + fn resolved_tray_offset_for_taskbar( taskbar_hwnd: HWND, taskbar_rect: RECT, stored: i32, ) -> i32 { - let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); - let max_offset = (tray_left - taskbar_rect.left - total_widget_width()).max(0); + let max_offset = max_tray_offset_for_taskbar(taskbar_hwnd, taskbar_rect, total_widget_width()); resolve_tray_offset(stored, max_offset) } @@ -462,6 +488,11 @@ fn load_settings() -> SettingsFile { if !settings.show_claude_code && !settings.show_codex && !settings.show_antigravity { settings.show_claude_code = true; } + // Older builds clobbered leftmost placement by persisting tray_offset=0 on embed. + if settings.tray_offset == 0 { + settings.tray_offset = TRAY_OFFSET_LEFTMOST; + settings.taskbar_index = 0; + } settings } @@ -478,8 +509,14 @@ fn save_settings(settings: &SettingsFile) { fn save_state_settings() { let state = lock_state(); if let Some(s) = state.as_ref() { + // Persist the user's placement intent (-1 = leftmost), not the resolved pixel offset. + let tray_offset = if s.tray_offset < 0 { + TRAY_OFFSET_LEFTMOST + } else { + s.tray_offset + }; save_settings(&SettingsFile { - tray_offset: s.tray_offset, + tray_offset, taskbar_index: s.taskbar_index, poll_interval_ms: s.poll_interval_ms, language: s @@ -621,11 +658,11 @@ fn resolve_taskbar_index(requested_index: usize, taskbars: &[native_interop::Tas return 0; } let capped = requested_index.min(taskbars.len() - 1); - if native_interop::find_child_window(taskbars[capped].hwnd, "TrayNotifyWnd").is_some() { + if native_interop::find_descendant_window(taskbars[capped].hwnd, "TrayNotifyWnd").is_some() { return capped; } for (index, taskbar) in taskbars.iter().enumerate() { - if native_interop::find_child_window(taskbar.hwnd, "TrayNotifyWnd").is_some() { + if native_interop::find_descendant_window(taskbar.hwnd, "TrayNotifyWnd").is_some() { diagnose::log(format!( "taskbar index {requested_index} has no TrayNotifyWnd; using index {index}" )); @@ -662,7 +699,7 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { native_interop::unhook_win_event(hook); } - native_interop::embed_in_taskbar(hwnd, taskbar.hwnd); + native_interop::raise_above_taskbar(hwnd, Some(taskbar.hwnd)); let tray_notify = native_interop::find_child_window(taskbar.hwnd, "TrayNotifyWnd"); if tray_notify.is_some() { @@ -688,11 +725,10 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { s.tray_notify_hwnd = tray_notify; s.win_event_hook = hook; s.taskbar_index = index; - s.embedded = true; + s.embedded = false; s.dragging = false; } } - save_state_settings(); true } @@ -718,12 +754,36 @@ fn tray_left_for_taskbar(taskbar_hwnd: HWND, taskbar_rect: RECT) -> i32 { tray_left } -fn clamp_offset_for_taskbar(taskbar_hwnd: HWND, taskbar_rect: RECT, offset: i32) -> i32 { - let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); - let max_offset = (tray_left - taskbar_rect.left - total_widget_width()).max(0); +fn clamp_offset_for_taskbar( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + offset: i32, + widget_width: i32, +) -> i32 { + let max_offset = max_tray_offset_for_taskbar(taskbar_hwnd, taskbar_rect, widget_width); offset.clamp(0, max_offset) } +/// Screen X for the layered popup widget. +fn popup_screen_x( + stored_tray_offset: i32, + resolved_tray_offset: i32, + taskbar_hwnd: HWND, + taskbar_rect: RECT, + content_left: i32, + max_offset: i32, + max_x: i32, +) -> i32 { + let min_x = native_interop::taskbar_visible_left_screen(taskbar_hwnd, taskbar_rect); + let max_x_screen = taskbar_rect.left + max_x; + let x = if stored_tray_offset < 0 { + min_x + } else { + content_left + max_offset - resolved_tray_offset + taskbar_rect.left + }; + x.clamp(min_x, max_x_screen) +} + fn offset_for_drop_point( taskbar_hwnd: HWND, taskbar_rect: RECT, @@ -732,8 +792,9 @@ fn offset_for_drop_point( ) -> i32 { let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); let desired_left = pt.x - taskbar_rect.left - drag_start_client_x; - let offset = tray_left - taskbar_rect.left - total_widget_width() - desired_left; - clamp_offset_for_taskbar(taskbar_hwnd, taskbar_rect, offset) + let widget_width = total_widget_width(); + let offset = tray_left - taskbar_rect.left - widget_width - desired_left; + clamp_offset_for_taskbar(taskbar_hwnd, taskbar_rect, offset, widget_width) } fn now_unix_secs() -> u64 { @@ -780,7 +841,7 @@ fn schedule_auto_update_check(hwnd: HWND) { } fn waiting_usage_text() -> String { - "...".to_string() + "--".to_string() } /// When a poll fails, keep the last successful values on screen when we have them. @@ -1346,6 +1407,11 @@ fn is_drag_handle_point_verbose(client_x: i32, client_y: i32, state: &AppState) } 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 widget_h = sc(widget_height_for_state(state)); let divider_top = (widget_h - divider_h) / 2; @@ -1645,17 +1711,18 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_client_x: 0, drag_start_offset: 0, + layered_screen_x: 0, + layered_screen_y: 0, + layered_position_valid: false, widget_visible: settings.widget_visible, }); } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { - embedded = true; - } + let attached = attach_to_taskbar(hwnd, settings.taskbar_index); - // If not embedded, fall back to topmost popup with SetLayeredWindowAttributes - if !embedded { + // Popup layered window (anchored to taskbar when attach succeeds) + if !attached { let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA); let _ = SetWindowPos( hwnd, @@ -1694,6 +1761,7 @@ pub fn run() { .unwrap_or(POLL_15_MIN) }; SetTimer(hwnd, TIMER_POLL, initial_poll_ms, None); + SetTimer(hwnd, TIMER_WIDGET_KEEPALIVE, 15_000, None); // Watch for explorer.exe restarts so we can re-embed and re-add the tray // icon (the shell discards tray registrations when it restarts). This @@ -1741,7 +1809,7 @@ fn render_layered() { let ( hwnd_val, is_dark, - embedded, + _embedded, strings, session_pct, session_text, @@ -1814,6 +1882,10 @@ fn render_layered() { let hwnd = hwnd_val.to_hwnd(); + unsafe { + native_interop::ensure_layered_style(hwnd); + } + let (width, height) = resolved_widget_size(account_pace_mode, show_credit_row); let accent = claude_accent_color(); @@ -1836,7 +1908,7 @@ fn render_layered() { }; unsafe { - let screen_dc = GetDC(hwnd); + let screen_dc = GetDC(HWND::default()); let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { @@ -1858,7 +1930,7 @@ fn render_layered() { if dib.is_invalid() || bits.is_null() { let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); + ReleaseDC(HWND::default(), screen_dc); return; } @@ -1910,20 +1982,40 @@ fn render_layered() { &antigravity_accent, ); - // Embedded: background pixels nearly invisible (blends with taskbar), content fully opaque. - // Popup: all pixels fully opaque (solid standalone window). - 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 embedded && rgb == bg_bgr { - *px = 0x01000000; - } else { - *px = rgb | 0xFF000000; - } + *px = rgb | 0xFF000000; } - // Push to window via UpdateLayeredWindow + // Push to window via UpdateLayeredWindow — always use explicit screen coords. + // GetWindowRect on WS_CHILD layered windows embedded in Shell_TrayWnd returns bogus + // screen Y (often thousands of pixels off); use coords stored in position_at_taskbar. + let (layered_x, layered_y) = { + let state = lock_state(); + match state.as_ref() { + Some(s) if s.layered_position_valid => { + (s.layered_screen_x, s.layered_screen_y) + } + _ => { + let mut window_rect = RECT::default(); + if GetWindowRect(hwnd, &mut window_rect).is_err() { + SelectObject(mem_dc, old_bmp); + let _ = DeleteObject(dib); + let _ = DeleteDC(mem_dc); + ReleaseDC(HWND::default(), screen_dc); + return; + } + (window_rect.left, window_rect.top) + } + } + }; + let pt_dest = POINT { + x: layered_x, + y: layered_y, + }; let pt_src = POINT { x: 0, y: 0 }; let sz = SIZE { cx: width, @@ -1939,7 +2031,7 @@ fn render_layered() { let _ = UpdateLayeredWindow( hwnd, screen_dc, - None, + Some(&pt_dest), Some(&sz), mem_dc, Some(&pt_src), @@ -1948,11 +2040,27 @@ fn render_layered() { ULW_ALPHA, ); + if !_embedded { + let taskbar_hwnd = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); + if let Some(taskbar_hwnd) = taskbar_hwnd { + native_interop::position_above_taskbar( + hwnd, + taskbar_hwnd, + layered_x, + layered_y, + width, + height, + ); + } else { + native_interop::position_topmost_popup(hwnd, layered_x, layered_y, width, height); + } + } + // Cleanup SelectObject(mem_dc, old_bmp); let _ = DeleteObject(dib); let _ = DeleteDC(mem_dc); - ReleaseDC(hwnd, screen_dc); + ReleaseDC(HWND::default(), screen_dc); } } @@ -2515,6 +2623,9 @@ fn update_drag_reposition_from_cursor() { 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; @@ -2530,7 +2641,9 @@ fn update_drag_reposition_from_cursor() { 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 max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); + let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let max_x = (tray_left - taskbar_rect.left - widget_width).max(content_left); + let max_offset = (max_x - content_left).max(0); if new_offset > max_offset { new_offset = max_offset; } @@ -2541,17 +2654,21 @@ fn update_drag_reposition_from_cursor() { 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 = anchor_top + anchor_height - widget_height; - let mut x = if embedded { - tray_left - taskbar_rect.left - widget_width - new_offset + 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 { - tray_left - widget_width - new_offset + 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)); - if embedded { - let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); - x = x.clamp(0, max_x); - } Some(( hwnd_val, embedded, @@ -2582,8 +2699,26 @@ fn update_drag_reposition_from_cursor() { widget_height, ); } else { - native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); - native_interop::raise_above_taskbar(hwnd_val); + { + 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); + } } } } @@ -2592,6 +2727,9 @@ 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; @@ -2679,11 +2817,26 @@ fn finish_drag_reposition() -> bool { was_dragging } +fn ensure_popup_visible() { + let (visible, dragging, embedded) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.widget_visible, s.dragging, s.embedded), + None => return, + } + }; + if !visible || dragging || embedded { + return; + } + position_at_taskbar(); + render_layered(); +} + fn position_at_taskbar() { refresh_dpi(); // Drop the app-state lock before any Win32 call that may synchronously // re-enter our window procedure. - let (hwnd, embedded, tray_offset, taskbar_hwnd) = { + let (hwnd, tray_offset, taskbar_index) = { let state = lock_state(); let s = match state.as_ref() { Some(s) => s, @@ -2695,15 +2848,36 @@ fn position_at_taskbar() { return; } - let taskbar_hwnd = match s.taskbar_hwnd { - Some(h) => h, - None => { - diagnose::log("position_at_taskbar skipped: no taskbar handle"); + (s.hwnd.to_hwnd(), s.tray_offset, s.taskbar_index) + }; + + let taskbar_hwnd = { + let current = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); + let valid = current.is_some_and(|h| unsafe { IsWindow(h).as_bool() }); + if valid { + current.unwrap() + } else { + let taskbars = native_interop::find_taskbars(); + if taskbars.is_empty() { + diagnose::log("position_at_taskbar skipped: no taskbar found"); return; } - }; - - (s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd) + let index = resolve_taskbar_index(taskbar_index, &taskbars); + let selected = taskbars[index].hwnd; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.taskbar_hwnd = Some(selected); + s.taskbar_index = index; + s.embedded = false; + } + } + diagnose::log(format!( + "position_at_taskbar: re-bound taskbar index={index} hwnd={:?}", + selected + )); + selected + } }; let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { @@ -2731,19 +2905,22 @@ fn position_at_taskbar() { .unwrap_or((false, false)); let (widget_width, widget_height) = resolved_widget_size(account_pace_mode.0, account_pace_mode.1); - let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); + let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let max_x = (tray_left - taskbar_rect.left - widget_width).max(content_left); + let max_offset = (max_x - content_left).max(0); let stored_tray_offset = tray_offset; - let tray_offset = resolve_tray_offset(tray_offset, max_offset); - let y = anchor_top + anchor_height - widget_height; + 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); - // If the widget is taller than the taskbar, it cannot be fully shown as a child window - // (child windows are clipped to the parent's client area). Detach to popup mode so all - // rows remain visible — the popup path already positions correctly above the taskbar. - let embedded = if embedded && widget_height > taskbar_height { + 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(); @@ -2754,39 +2931,61 @@ fn position_at_taskbar() { diagnose::log(format!( "detached from taskbar: widget_height={widget_height} > taskbar_height={taskbar_height}" )); - false - } else { - embedded - }; - if embedded && stored_tray_offset < 0 { - // Persist the resolved max_offset so drags start from the correct position. - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.tray_offset = tray_offset; - } } + 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 mut x = tray_left - taskbar_rect.left - widget_width - tray_offset; - let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); - x = x.clamp(0, max_x); + 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); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.layered_screen_x = screen_x; + s.layered_screen_y = screen_y; + } + } native_interop::move_window(hwnd, x, y_child, widget_width, widget_height); diagnose::log(format!( - "positioned embedded widget at x={x} y={y_child} w={widget_width} h={widget_height} (raw_y={})", - 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, aligned flush with taskbar bottom (overlapping). - // Re-assert HWND_TOPMOST after MoveWindow so we appear above Shell_TrayWnd. - let mut x = tray_left - widget_width - tray_offset; - let max_x = (tray_left - taskbar_rect.left - widget_width).max(0); - x = (x - taskbar_rect.left).clamp(0, max_x) + taskbar_rect.left; - native_interop::move_window(hwnd, x, y, widget_width, widget_height); - native_interop::raise_above_taskbar(hwnd); + let x = popup_screen_x( + stored_tray_offset, + tray_offset, + taskbar_hwnd, + taskbar_rect, + content_left, + max_offset, + max_x, + ); + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.layered_screen_x = x; + s.layered_screen_y = y; + s.layered_position_valid = true; + } + } + native_interop::position_above_taskbar( + hwnd, + taskbar_hwnd, + x, + y, + widget_width, + widget_height, + ); diagnose::log(format!( - "positioned fallback widget at x={x} y={y} w={widget_width} h={widget_height}" + "positioned popup widget at x={x} y={y} w={widget_width} h={widget_height} pin_right={} content_left={content_left}", + native_interop::pin_band_right(taskbar_hwnd, taskbar_rect) )); } if widget_visible { @@ -2857,26 +3056,32 @@ unsafe extern "system" fn wnd_proc( ) -> LRESULT { match msg { WM_PAINT => { - // For non-embedded fallback, paint normally - let embedded = { - let state = lock_state(); - state.as_ref().map(|s| s.embedded).unwrap_or(false) - }; - if embedded { - // Layered windows don't use WM_PAINT; just validate the region - let mut ps = PAINTSTRUCT::default(); - let _ = BeginPaint(hwnd, &mut ps); - let _ = EndPaint(hwnd, &ps); - } else { - let mut ps = PAINTSTRUCT::default(); - let hdc = BeginPaint(hwnd, &mut ps); - paint(hdc, hwnd); - let _ = EndPaint(hwnd, &ps); - } + // Layered windows render via UpdateLayeredWindow; validate the region only. + let mut ps = PAINTSTRUCT::default(); + let _ = BeginPaint(hwnd, &mut ps); + let _ = EndPaint(hwnd, &mut ps); LRESULT(0) } WM_ERASEBKGND => LRESULT(1), WM_DISPLAYCHANGE | WM_DPICHANGED_MSG | WM_SETTINGCHANGE => { + static LAST_DISPLAY_REPOSITION: Mutex> = + Mutex::new(None); + let should_reposition = { + let mut last = LAST_DISPLAY_REPOSITION.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + if last + .map(|t| now.duration_since(t).as_millis() > 500) + .unwrap_or(true) + { + *last = Some(now); + true + } else { + false + } + }; + if !should_reposition { + return LRESULT(0); + } if msg == WM_DPICHANGED_MSG { let new_dpi = (wparam.0 & 0xFFFF) as u32; CURRENT_DPI.store(new_dpi, Ordering::Relaxed); @@ -2952,12 +3157,21 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_WIDGET_KEEPALIVE => { + ensure_popup_visible(); + } TIMER_DRAG => { - let (dragging, tray_offset) = { + let (dragging, embedded, tray_offset) = { let state = lock_state(); - state.as_ref().map(|s| (s.dragging, s.tray_offset)).unwrap_or((false, 0)) + state + .as_ref() + .map(|s| (s.dragging, s.embedded, s.tray_offset)) + .unwrap_or((false, false, 0)) }; - if !dragging { + if !dragging || embedded { + if dragging && embedded { + finish_drag_reposition(); + } unsafe { let _ = KillTimer(hwnd, TIMER_DRAG); } @@ -2996,6 +3210,10 @@ unsafe extern "system" fn wnd_proc( recover_taskbar_embed(hwnd); LRESULT(0) } + msg if msg == WM_APP_ENSURE_VISIBLE => { + ensure_popup_visible(); + LRESULT(0) + } WM_SETCURSOR => { let is_dragging = { let state = lock_state(); From 9100128e023ff4c71c0bb2c22f9739fc0988c534 Mon Sep 17 00:00:00 2001 From: Ori Yardenay Date: Tue, 21 Jul 2026 18:01:38 +0300 Subject: [PATCH 05/10] fix: place leftmost widget at taskbar physical left edge tray_offset -1 used visible-chrome left instead of taskbar_rect.left. Align embedded leftmost mode to x=0 as well. --- src/window.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/window.rs b/src/window.rs index b30dfaa4..eba353d3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -768,13 +768,13 @@ fn clamp_offset_for_taskbar( fn popup_screen_x( stored_tray_offset: i32, resolved_tray_offset: i32, - taskbar_hwnd: HWND, + _taskbar_hwnd: HWND, taskbar_rect: RECT, content_left: i32, max_offset: i32, max_x: i32, ) -> i32 { - let min_x = native_interop::taskbar_visible_left_screen(taskbar_hwnd, taskbar_rect); + let min_x = taskbar_rect.left; let max_x_screen = taskbar_rect.left + max_x; let x = if stored_tray_offset < 0 { min_x @@ -2941,8 +2941,12 @@ fn position_at_taskbar() { let y = compute_anchor_y(anchor_top, anchor_height, widget_height); if embedded { - let mut x = content_left + max_offset - tray_offset; - x = x.clamp(content_left, max_x); + let mut x = if stored_tray_offset < 0 { + 0 + } else { + content_left + max_offset - tray_offset + }; + x = x.clamp(0, max_x); let y_child = compute_anchor_y(anchor_top, anchor_height, widget_height) - anchor_top; let screen_x = taskbar_rect.left + x; let screen_y = compute_anchor_y(anchor_top, anchor_height, widget_height); From cde7ef039e16ac4154c8d4539008f8c1d2081072 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Mon, 17 Aug 2026 13:20:39 +0300 Subject: [PATCH 06/10] fix: widen taskbar text column and hide widget over fullscreen apps Mo/Wk/Dy labels were clipped at a fixed 62px column, cutting $2000 down to $200 for 4-digit limits. Widen TEXT_WIDTH to fit. The floating popup is HWND_TOPMOST to track the taskbar's tray icons, which also put it above fullscreen video/games. Poll the foreground window every 500ms and hide the widget when it's genuinely fullscreen (no title bar, covers its monitor, excluding shell overlay classes like Windows.UI.Core.CoreWindow that are always full-monitor-sized). Gate the other periodic re-show paths (tray relayout, keepalive) on that same state so they stop fighting the hide and causing flicker. --- src/native_interop.rs | 61 ++++++++++++++++++++++++++++++++++++++ src/window.rs | 68 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index d6cc0eca..95746c49 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,6 +1,9 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; use windows::Win32::Globalization::GetLocaleInfoW; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, 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::*; @@ -25,6 +28,7 @@ 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; @@ -257,6 +261,63 @@ pub fn get_window_rect_safe(hwnd: HWND) -> Option { } } +/// True when the foreground window covers its entire monitor with no chrome, +/// i.e. the same condition Windows itself uses to auto-hide the real taskbar. +pub fn foreground_window_is_fullscreen(self_hwnd: HWND) -> bool { + unsafe { + let fg = GetForegroundWindow(); + if fg.0.is_null() || fg == self_hwnd { + return false; + } + + let mut class_name = [0u16; 64]; + let len = GetClassNameW(fg, &mut class_name); + if len > 0 { + let class_name = String::from_utf16_lossy(&class_name[..len as usize]); + // Desktop/shell windows are never "fullscreen apps" in this sense. + // Windows.UI.Core.CoreWindow in particular backs several always-present, + // full-monitor-sized shell overlays (Search/Widgets/Action Center) that + // can be reported as foreground without anything visible on screen. + if matches!( + class_name.as_str(), + "Progman" + | "WorkerW" + | "Shell_TrayWnd" + | "Shell_SecondaryTrayWnd" + | "Windows.UI.Core.CoreWindow" + ) { + return false; + } + } + + // Real fullscreen apps (video players, games) drop their title bar/border; + // an ordinary maximized window keeps WS_CAPTION and still leaves the real + // taskbar visible, so it must not hide this widget either. + let style = GetWindowLongW(fg, GWL_STYLE) as u32; + if style & WS_CAPTION.0 != 0 { + return false; + } + + let Some(win_rect) = get_window_rect_safe(fg) else { + return false; + }; + + let monitor = MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut info).as_bool() == false { + return false; + } + + win_rect.left <= info.rcMonitor.left + && win_rect.top <= info.rcMonitor.top + && win_rect.right >= info.rcMonitor.right + && win_rect.bottom >= info.rcMonitor.bottom + } +} + /// 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) diff --git a/src/window.rs b/src/window.rs index eba353d3..253db576 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,8 +20,8 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, - TIMER_WIDGET_KEEPALIVE, + self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_FULLSCREEN_CHECK, TIMER_POLL, + TIMER_RESET_POLL, TIMER_UPDATE_CHECK, TIMER_WIDGET_KEEPALIVE, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; @@ -111,6 +111,10 @@ struct AppState { layered_position_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)] @@ -1375,7 +1379,7 @@ 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; @@ -1715,6 +1719,7 @@ pub fn run() { layered_screen_y: 0, layered_position_valid: false, widget_visible: settings.widget_visible, + hidden_for_fullscreen: false, }); } @@ -1762,6 +1767,7 @@ pub fn run() { }; 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 @@ -2829,7 +2835,44 @@ fn ensure_popup_visible() { return; } position_at_taskbar(); - render_layered(); + let hidden_for_fullscreen = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + if !hidden_for_fullscreen { + 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) { + 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 is_fullscreen = unsafe { native_interop::foreground_window_is_fullscreen(hwnd) }; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.hidden_for_fullscreen = is_fullscreen; + } + } + unsafe { + if is_fullscreen { + let _ = ShowWindow(hwnd, SW_HIDE); + } else { + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + } } fn position_at_taskbar() { @@ -2992,7 +3035,11 @@ fn position_at_taskbar() { native_interop::pin_band_right(taskbar_hwnd, taskbar_rect) )); } - if widget_visible { + let hidden_for_fullscreen = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + if widget_visible && !hidden_for_fullscreen { unsafe { let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); } @@ -3046,7 +3093,13 @@ unsafe extern "system" fn on_tray_location_changed( }; if should_reposition { position_at_taskbar(); - render_layered(); + let hidden_for_fullscreen = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + if !hidden_for_fullscreen { + render_layered(); + } } } } @@ -3164,6 +3217,9 @@ unsafe extern "system" fn wnd_proc( TIMER_WIDGET_KEEPALIVE => { ensure_popup_visible(); } + TIMER_FULLSCREEN_CHECK => { + sync_fullscreen_visibility(hwnd); + } TIMER_DRAG => { let (dragging, embedded, tray_offset) = { let state = lock_state(); From 23675c77183990e12797f8e51bd33e6a45a12942 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Wed, 26 Aug 2026 17:04:51 +0300 Subject: [PATCH 07/10] fix: cache poll results and skip redundant repaints Track the last applied popup layout and skip position_at_taskbar / render_layered when nothing moved, instead of re-rendering on every 15s keepalive tick. Cache successful poll results (keyed on a credential-file activity signature) for up to 15 minutes so idle ticks reuse the last usage data instead of hitting the network. Debounce sync_fullscreen_visibility and the taskbar watchdog so they only act on real state transitions, and invalidate both caches on manual refresh, tray icon rebuild, and language/reset actions so those still force an immediate repoll and repaint. --- src/models.rs | 12 ++--- src/poller.rs | 137 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/window.rs | 129 ++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 250 insertions(+), 28 deletions(-) diff --git a/src/models.rs b/src/models.rs index 98062b40..e2a3906b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,19 +1,19 @@ 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, @@ -21,7 +21,7 @@ pub struct AccountUsage { pub spend_limit: f64, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct SpendPaceSlots { pub month_actual: f64, pub month_cap: f64, @@ -37,14 +37,14 @@ pub struct SpendPaceSlots { pub day_level: u8, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct SpendPaceView { pub credit_pct: f64, pub credit_expiry: Option, pub slots: SpendPaceSlots, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct AppUsageData { pub claude_code: Option, pub codex: Option, diff --git a/src/poller.rs b/src/poller.rs index 0bf1d0a2..aed959b0 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -4,7 +4,7 @@ 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, Serialize}; use std::os::windows::process::CommandExt; @@ -20,6 +20,96 @@ 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, @@ -270,14 +360,39 @@ 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( @@ -2271,6 +2386,22 @@ mod tests { ) .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 { diff --git a/src/window.rs b/src/window.rs index 253db576..22cf5e70 100644 --- a/src/window.rs +++ b/src/window.rs @@ -109,6 +109,12 @@ struct AppState { 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. @@ -319,8 +325,22 @@ fn spawn_taskbar_watchdog() { } else { let taskbar_ok = old_taskbar.is_some_and(|taskbar| unsafe { IsWindow(taskbar).as_bool() }); if taskbar_ok { - unsafe { - let _ = PostMessageW(widget_hwnd, WM_APP_ENSURE_VISIBLE, WPARAM(0), LPARAM(0)); + 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; } @@ -1718,6 +1738,11 @@ pub fn run() { 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, }); @@ -1780,7 +1805,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); @@ -2309,7 +2334,7 @@ fn paint_content( } } -fn do_poll(send_hwnd: SendHwnd) { +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(); @@ -2319,8 +2344,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() { @@ -2374,6 +2415,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 => @@ -2823,25 +2865,56 @@ fn finish_drag_reposition() -> bool { 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) = { + let (visible, dragging, embedded, hidden_for_fullscreen, already_visible, layout_valid) = { let state = lock_state(); match state.as_ref() { - Some(s) => (s.widget_visible, s.dragging, s.embedded), + Some(s) => ( + s.widget_visible, + s.dragging, + s.embedded, + s.hidden_for_fullscreen, + unsafe { IsWindowVisible(s.hwnd.to_hwnd()).as_bool() }, + s.last_layout_valid, + ), None => return, } }; - if !visible || dragging || embedded { + if !visible || dragging || embedded || hidden_for_fullscreen { return; } - position_at_taskbar(); - let hidden_for_fullscreen = lock_state() - .as_ref() - .map(|s| s.hidden_for_fullscreen) - .unwrap_or(false); - if !hidden_for_fullscreen { - render_layered(); + if already_visible && layout_valid { + return; } + position_at_taskbar(); } /// The floating popup is HWND_TOPMOST so it can track the real taskbar's tray @@ -2860,6 +2933,13 @@ fn sync_fullscreen_visibility(hwnd: HWND) { return; } let is_fullscreen = unsafe { native_interop::foreground_window_is_fullscreen(hwnd) }; + let was_hidden = lock_state() + .as_ref() + .map(|s| s.hidden_for_fullscreen) + .unwrap_or(false); + if is_fullscreen == was_hidden { + return; + } { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -2993,6 +3073,9 @@ fn position_at_taskbar() { 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() { @@ -3001,6 +3084,7 @@ fn position_at_taskbar() { } } 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={y_child} screen=({screen_x},{screen_y}) w={widget_width} h={widget_height} content_left={content_left}" )); @@ -3014,6 +3098,9 @@ fn position_at_taskbar() { 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() { @@ -3030,6 +3117,7 @@ fn position_at_taskbar() { widget_width, widget_height, ); + store_popup_layout(x, y, widget_width, widget_height); diagnose::log(format!( "positioned popup widget at x={x} y={y} w={widget_width} h={widget_height} pin_right={} content_left={content_left}", native_interop::pin_band_right(taskbar_hwnd, taskbar_rect) @@ -3188,7 +3276,7 @@ unsafe extern "system" fn wnd_proc( } let sh = SendHwnd::from_hwnd(hwnd); std::thread::spawn(move || { - do_poll(sh); + do_poll(sh, false); }); } TIMER_COUNTDOWN => { @@ -3207,7 +3295,7 @@ 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); }); } } @@ -3368,9 +3456,10 @@ unsafe extern "system" fn wnd_proc( } } 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 => { @@ -3422,6 +3511,7 @@ unsafe extern "system" fn wnd_proc( s.tray_offset = TRAY_OFFSET_LEFTMOST; } } + invalidate_popup_layout(); save_state_settings(); position_at_taskbar(); } @@ -3482,9 +3572,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 From 7c4056edc8d4097fc4846b15f9ef634b48b4ae18 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Mon, 7 Sep 2026 15:19:21 +0300 Subject: [PATCH 08/10] fix: widget vanishing during peek, off-monitor fullscreen suppress, and non-leftmost default Three positioning/visibility bugs, root-caused after finding they were never actually fixed in prior sessions despite repeated attempts: - Fullscreen suppression checked only the foreground window's own monitor, never which monitor the widget's own taskbar lives on. A maximized/ fullscreen window on a secondary monitor was hiding the widget on the primary taskbar even though nothing there was fullscreen. should_hide_widget_for_fullscreen now scopes the check to the widget's taskbar monitor. - Default/leftmost position stopped at the left edge of the taskbar's icon cluster instead of the taskbar's own physical left edge (x=0), even when that space was genuinely empty. taskbar_placement_band_left is now hardcoded to 0, unconditionally, with a doc comment recording that this is a deliberate, non-negotiable requirement so it doesn't get "corrected" back to icon-cluster-avoidance by a future session. - Widget disappearing when hovering a taskbar thumbnail preview (Aero Peek): the existing fix was reactive (polling for TaskListThumbnailWnd/ CThumbnailWnd, a 5s latch, z-order raise), which only reacts after the fact to a compositor-level effect. Added the actual fix: exclude the window from DWM's peek ghosting via DwmSetWindowAttribute(DWMWA_EXCLUDED_FROM_PEEK), set once at window creation. Needs the Win32_Graphics_Dwm feature, added to Cargo.toml. Also commits src/proof_capture.rs and CLAUDE.md, which were present in the working tree but untracked. --- CLAUDE.md | 24 + Cargo.toml | 1 + src/main.rs | 5 +- src/native_interop.rs | 1014 ++++++++++++++++++++++++++++++++++++++--- src/proof_capture.rs | 512 +++++++++++++++++++++ src/window.rs | 481 +++++++++++++------ 6 files changed, 1836 insertions(+), 201 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/proof_capture.rs 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/src/main.rs b/src/main.rs index cef58d1c..853f9800 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![windows_subsystem = "windows"] mod diagnose; +mod proof_capture; mod localization; mod models; mod native_interop; @@ -24,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/native_interop.rs b/src/native_interop.rs index 95746c49..3bc37420 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,13 +1,40 @@ 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::{ - GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, + 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; @@ -34,11 +61,61 @@ pub const TIMER_FULLSCREEN_CHECK: usize = 7; 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; #[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); + } + } + find_taskbar_by_class("Shell_TrayWnd").or_else(|| { + find_taskbars() + .into_iter() + .find(|taskbar| taskbar_index == 0 || !taskbar.is_primary) + .map(|taskbar| taskbar.hwnd) + }) } pub fn find_taskbars() -> Vec { @@ -48,9 +125,24 @@ 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) { + 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, + }); } } } @@ -63,6 +155,7 @@ pub fn find_taskbars() -> Vec { } taskbars.sort_by_key(|taskbar| { ( + !taskbar.is_primary, taskbar.rect.top, taskbar.rect.left, taskbar.rect.bottom, @@ -126,6 +219,9 @@ unsafe extern "system" fn scan_taskbar_band_proc(hwnd: HWND, lparam: LPARAM) -> 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); @@ -139,6 +235,7 @@ unsafe extern "system" fn scan_taskbar_band_proc(hwnd: HWND, lparam: LPARAM) -> struct VisibleLeftScan { + band: RECT, visible_left: i32, found: bool, } @@ -156,7 +253,7 @@ unsafe extern "system" fn scan_visible_left_proc(hwnd: HWND, lparam: LPARAM) -> || class == "ToolbarWindow32"; if is_chrome { if let Some(rect) = get_window_rect_safe(hwnd) { - if rect.right > rect.left { + if rect.right > rect.left && rects_overlap(rect, scan.band) { scan.visible_left = if scan.found { scan.visible_left.min(rect.left) } else { @@ -166,15 +263,18 @@ unsafe extern "system" fn scan_visible_left_proc(hwnd: HWND, lparam: LPARAM) -> } } } - } - unsafe { - let _ = EnumChildWindows(hwnd, Some(scan_visible_left_proc), lparam); + // 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, }; @@ -224,31 +324,386 @@ pub fn find_next_child_window(parent: HWND, after: HWND, class_name: &str) -> Op } } -/// 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 { @@ -261,48 +716,92 @@ pub fn get_window_rect_safe(hwnd: HWND) -> Option { } } -/// True when the foreground window covers its entire monitor with no chrome, -/// i.e. the same condition Windows itself uses to auto-hide the real taskbar. -pub fn foreground_window_is_fullscreen(self_hwnd: HWND) -> bool { +/// 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; } - let mut class_name = [0u16; 64]; - let len = GetClassNameW(fg, &mut class_name); - if len > 0 { - let class_name = String::from_utf16_lossy(&class_name[..len as usize]); - // Desktop/shell windows are never "fullscreen apps" in this sense. - // Windows.UI.Core.CoreWindow in particular backs several always-present, - // full-monitor-sized shell overlays (Search/Widgets/Action Center) that - // can be reported as foreground without anything visible on screen. - if matches!( - class_name.as_str(), - "Progman" - | "WorkerW" - | "Shell_TrayWnd" - | "Shell_SecondaryTrayWnd" - | "Windows.UI.Core.CoreWindow" - ) { + if let Some(class_name) = window_class_name(fg) { + if is_shell_foreground_class(&class_name) { return false; } } - // Real fullscreen apps (video players, games) drop their title bar/border; - // an ordinary maximized window keeps WS_CAPTION and still leaves the real - // taskbar visible, so it must not hide this widget either. - let style = GetWindowLongW(fg, GWL_STYLE) as u32; - if style & WS_CAPTION.0 != 0 { - 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() @@ -311,16 +810,292 @@ pub fn foreground_window_is_fullscreen(self_hwnd: HWND) -> bool { return false; } - win_rect.left <= info.rcMonitor.left - && win_rect.top <= info.rcMonitor.top - && win_rect.right >= info.rcMonitor.right - && win_rect.bottom >= info.rcMonitor.bottom + 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) + taskbar_visible_left_screen(taskbar_hwnd, taskbar_rect) + .saturating_sub(taskbar_rect.left) + .max(0) } /// Left edge of visible taskbar chrome in screen coordinates. @@ -333,6 +1108,29 @@ 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 { @@ -391,21 +1189,48 @@ pub fn detach_from_taskbar(hwnd: HWND) { } } -/// Place the popup widget above Shell_TrayWnd in the topmost z-order band. -pub fn raise_above_taskbar(hwnd: HWND, _taskbar_hwnd: Option) { +/// 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_NOTOPMOST, + 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_TOPMOST, + HWND_NOTOPMOST, 0, 0, 0, @@ -415,27 +1240,72 @@ pub fn raise_above_taskbar(hwnd: HWND, _taskbar_hwnd: Option) { } } -/// Place the layered popup immediately above the taskbar in Z-order. -pub fn position_above_taskbar(hwnd: HWND, _taskbar_hwnd: HWND, x: i32, y: i32, w: i32, h: i32) { + +/// 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, - ); - let _ = SetWindowPos( - hwnd, - HWND_TOPMOST, - x, - y, - w, - h, - SWP_NOACTIVATE, - ); + 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, + ); + } } } 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/window.rs b/src/window.rs index 22cf5e70..cc4bb831 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}; @@ -17,12 +17,13 @@ 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_DRAG, TIMER_FULLSCREEN_CHECK, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, TIMER_WIDGET_KEEPALIVE, - WM_APP_TRAY, WM_APP_USAGE_UPDATED, + WM_APP_REQUEST_PROOF, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; use crate::spend_pace; @@ -148,7 +149,10 @@ const IDM_FREQ_1HOUR: u16 = 13; const IDM_START_WITH_WINDOWS: u16 = 20; const IDM_RESET_POSITION: u16 = 30; /// Persisted in settings.json; resolved to max_offset at layout time. -const TRAY_OFFSET_LEFTMOST: i32 = -1; +/// 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; @@ -169,6 +173,7 @@ 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 @@ -179,8 +184,71 @@ 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 { @@ -442,6 +510,7 @@ fn default_tray_offset() -> i32 { 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) @@ -454,7 +523,7 @@ fn max_tray_offset_for_taskbar( widget_width: i32, ) -> i32 { let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); - let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let content_left = native_interop::taskbar_placement_band_left(taskbar_hwnd, taskbar_rect); (tray_left - taskbar_rect.left - widget_width - content_left).max(0) } @@ -677,23 +746,24 @@ 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; } - let capped = requested_index.min(taskbars.len() - 1); - if native_interop::find_descendant_window(taskbars[capped].hwnd, "TrayNotifyWnd").is_some() { - return capped; - } - for (index, taskbar) in taskbars.iter().enumerate() { - if native_interop::find_descendant_window(taskbar.hwnd, "TrayNotifyWnd").is_some() { - diagnose::log(format!( - "taskbar index {requested_index} has no TrayNotifyWnd; using index {index}" - )); + 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; } - capped + requested_index.min(taskbars.len() - 1) } fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { @@ -723,6 +793,7 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { native_interop::unhook_win_event(hook); } + 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"); @@ -769,13 +840,7 @@ 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( @@ -790,7 +855,7 @@ fn clamp_offset_for_taskbar( /// Screen X for the layered popup widget. fn popup_screen_x( - stored_tray_offset: i32, + _stored_tray_offset: i32, resolved_tray_offset: i32, _taskbar_hwnd: HWND, taskbar_rect: RECT, @@ -798,13 +863,9 @@ fn popup_screen_x( max_offset: i32, max_x: i32, ) -> i32 { - let min_x = taskbar_rect.left; + let min_x = taskbar_rect.left + content_left; let max_x_screen = taskbar_rect.left + max_x; - let x = if stored_tray_offset < 0 { - min_x - } else { - content_left + max_offset - resolved_tray_offset + taskbar_rect.left - }; + let x = content_left + max_offset - resolved_tray_offset + taskbar_rect.left; x.clamp(min_x, max_x_screen) } @@ -1565,7 +1626,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"); @@ -1634,6 +1694,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( @@ -1648,8 +1715,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, @@ -1657,6 +1724,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, @@ -2022,15 +2097,14 @@ fn render_layered() { } // Push to window via UpdateLayeredWindow — always use explicit screen coords. - // GetWindowRect on WS_CHILD layered windows embedded in Shell_TrayWnd returns bogus - // screen Y (often thousands of pixels off); use coords stored in position_at_taskbar. + // 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) - } + 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); @@ -2087,6 +2161,18 @@ fn render_layered() { } } + 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); @@ -2220,23 +2306,7 @@ fn paint_content( 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 { @@ -2330,7 +2400,6 @@ fn paint_content( } SelectObject(hdc, old_font); - let _ = DeleteObject(font); } } @@ -2689,7 +2758,7 @@ fn update_drag_reposition_from_cursor() { if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { let tray_left = tray_left_for_taskbar(taskbar_hwnd, taskbar_rect); let widget_width = total_widget_width_for_state(s); - let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let 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 { @@ -2894,16 +2963,16 @@ fn invalidate_popup_layout() { } fn ensure_popup_visible() { - let (visible, dragging, embedded, hidden_for_fullscreen, already_visible, layout_valid) = { + 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, - s.hidden_for_fullscreen, unsafe { IsWindowVisible(s.hwnd.to_hwnd()).as_bool() }, s.last_layout_valid, + s.hidden_for_fullscreen, ), None => return, } @@ -2917,11 +2986,47 @@ fn ensure_popup_visible() { 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() { @@ -2932,31 +3037,79 @@ fn sync_fullscreen_visibility(hwnd: HWND) { if !visible || dragging || embedded { return; } - let is_fullscreen = unsafe { native_interop::foreground_window_is_fullscreen(hwnd) }; - let was_hidden = lock_state() + 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 is_fullscreen == was_hidden { + + 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 = is_fullscreen; + s.hidden_for_fullscreen = false; } - } - unsafe { - if is_fullscreen { - let _ = ShowWindow(hwnd, SW_HIDE); - } else { + 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, tray_offset, taskbar_index) = { @@ -2975,60 +3128,97 @@ fn position_at_taskbar() { }; let taskbar_hwnd = { - let current = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); - let valid = current.is_some_and(|h| unsafe { IsWindow(h).as_bool() }); - if valid { - current.unwrap() - } else { - let taskbars = native_interop::find_taskbars(); - if taskbars.is_empty() { - diagnose::log("position_at_taskbar skipped: no taskbar found"); - return; - } - let index = resolve_taskbar_index(taskbar_index, &taskbars); - let selected = taskbars[index].hwnd; - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { + 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 mut state = lock_state(); + if let Some(s) = state.as_mut() { + if s.taskbar_hwnd != Some(selected) { s.taskbar_hwnd = Some(selected); - s.taskbar_index = index; s.embedded = false; + s.layered_position_valid = false; + 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!( - "position_at_taskbar: re-bound taskbar index={index} hwnd={:?}", - selected + "TrayNotifyWnd rect=({},{},{},{})", + tray_rect.left, tray_rect.top, tray_rect.right, tray_rect.bottom )); - selected + } 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 account_pace_mode = lock_state() .as_ref() .map(|s| (s.account_pace_mode, s.show_credit_row)) .unwrap_or((false, false)); let (widget_width, widget_height) = resolved_widget_size(account_pace_mode.0, account_pace_mode.1); - let content_left = native_interop::taskbar_content_left(taskbar_hwnd, taskbar_rect); + let 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; @@ -3064,12 +3254,8 @@ fn position_at_taskbar() { let y = compute_anchor_y(anchor_top, anchor_height, widget_height); if embedded { - let mut x = if stored_tray_offset < 0 { - 0 - } else { - content_left + max_offset - tray_offset - }; - x = x.clamp(0, max_x); + 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); @@ -3109,29 +3295,45 @@ fn position_at_taskbar() { s.layered_position_valid = true; } } - native_interop::position_above_taskbar( - hwnd, - taskbar_hwnd, - x, - y, - widget_width, - widget_height, - ); + 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 popup widget at x={x} y={y} w={widget_width} h={widget_height} pin_right={} content_left={content_left}", - native_interop::pin_band_right(taskbar_hwnd, taskbar_rect) + "positioned popup widget at x={x} y={y} w={widget_width} h={widget_height} content_left={content_left} suppressed={suppressed}" )); } - let hidden_for_fullscreen = lock_state() - .as_ref() - .map(|s| s.hidden_for_fullscreen) - .unwrap_or(false); - if widget_visible && !hidden_for_fullscreen { - unsafe { - let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + 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(); } - render_layered(); } } @@ -3180,13 +3382,14 @@ unsafe extern "system" fn on_tray_location_changed( } }; if should_reposition { - position_at_taskbar(); - let hidden_for_fullscreen = lock_state() + let widget_hwnd = lock_state() .as_ref() - .map(|s| s.hidden_for_fullscreen) - .unwrap_or(false); - if !hidden_for_fullscreen { - render_layered(); + .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)); } } } @@ -3229,13 +3432,18 @@ unsafe extern "system" fn wnd_proc( } 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) @@ -3304,6 +3512,10 @@ unsafe extern "system" fn wnd_proc( } TIMER_WIDGET_KEEPALIVE => { ensure_popup_visible(); + if proof_capture::flag_pending() { + render_layered(); + } + force_periodic_repaint(); } TIMER_FULLSCREEN_CHECK => { sync_fullscreen_visibility(hwnd); @@ -3362,6 +3574,19 @@ unsafe extern "system" fn wnd_proc( ensure_popup_visible(); LRESULT(0) } + msg if msg == WM_APP_REPOSITION_TASKBAR => { + position_at_taskbar(); + 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(); From cb740c1834f5461dfd9c40129f9eda0257622fe8 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Mon, 7 Sep 2026 16:24:14 +0300 Subject: [PATCH 09/10] fix: deadlock in sync_fullscreen_visibility and position_at_taskbar Both held the app state Mutex guard across a call to a function that locks the same (non-reentrant) mutex again on the same thread: - sync_fullscreen_visibility's "fullscreen ended: restoring widget" branch held the guard while calling position_at_taskbar(), which locks state itself. This is what froze the widget process solid (confirmed via diagnose log going silent exactly at that line, and Get-Process reporting Responding=False) once the earlier monitor- scoping fix made the suppress -> restore transition actually fire for real instead of being masked by the old bug. - position_at_taskbar()'s own taskbar-rebind branch called invalidate_popup_layout() from inside a state.as_mut() block on the same guard, for the same reason. Triggers on taskbar rebinding (Explorer restart, monitor/dock changes, first launch). Both fixed by scoping the lock to just the state mutation, matching the pattern already used correctly everywhere else in this file. --- src/window.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/window.rs b/src/window.rs index cc4bb831..b471658f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -3096,9 +3096,11 @@ fn sync_fullscreen_visibility(hwnd: HWND) { 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; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.hidden_for_fullscreen = false; + } } unsafe { let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); @@ -3141,20 +3143,27 @@ fn position_at_taskbar() { 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; - invalidate_popup_layout(); - diagnose::log(format!( - "position_at_taskbar: bound taskbar hwnd={:?}", - selected - )); + true + } else { + false } + } else { + false } + }; + if rebound { + invalidate_popup_layout(); + diagnose::log(format!( + "position_at_taskbar: bound taskbar hwnd={:?}", + selected + )); } selected }; From 855536be2c678a732a333cb34b97134116cd63ee Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Thu, 10 Sep 2026 18:08:30 +0300 Subject: [PATCH 10/10] fix: react to foreground-window changes immediately instead of on a 500ms poll sync_fullscreen_visibility only checked TIMER_FULLSCREEN_CHECK's 500ms tick, so it noticed well after the fact that DWM had dropped this window's composited layered content during a shell/XAML surface transition (Start menu, Search, task switches) - captured live via diagnose logging: pixel dropout correlated with fg_class becoming Windows.UI.Core.CoreWindow or an empty foreground state, lasting 300-620ms, while IsWindowVisible stayed true throughout (confirming this is a DWM compositing gap, not our own suppress logic hiding the window). Added a system-wide EVENT_SYSTEM_FOREGROUND WinEvent hook that posts a message (never calls back synchronously - would deadlock the same way the tray location hook's comment already warns about) to force an immediate repaint the instant focus changes, instead of waiting up to 500ms to notice. Also added diagnose logging to taskbar_hwnd_for_settings_index's fallback path and find_taskbars' primary-exclusion branch, from chasing an earlier, separate taskbar-rebind report that turned out not to reproduce again. --- src/native_interop.rs | 54 +++++++++++++++++++++++++-- src/window.rs | 87 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index 3bc37420..c33d2ac6 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -46,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 @@ -62,6 +63,7 @@ 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 { @@ -110,12 +112,25 @@ pub fn taskbar_hwnd_for_settings_index(taskbar_index: usize) -> Option { return Some(hwnd); } } - find_taskbar_by_class("Shell_TrayWnd").or_else(|| { - find_taskbars() + 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) - }) + .map(|taskbar| taskbar.hwnd); + } + direct } pub fn find_taskbars() -> Vec { @@ -133,6 +148,11 @@ pub fn find_taskbars() -> Vec { (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); } } @@ -1392,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) } diff --git a/src/window.rs b/src/window.rs index b471658f..53cc7732 100644 --- a/src/window.rs +++ b/src/window.rs @@ -23,7 +23,7 @@ use crate::models::AppUsageData; use crate::native_interop::{ self, Color, TIMER_COUNTDOWN, TIMER_DRAG, TIMER_FULLSCREEN_CHECK, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, TIMER_WIDGET_KEEPALIVE, - WM_APP_REQUEST_PROOF, WM_APP_TRAY, WM_APP_USAGE_UPDATED, + WM_APP_FOREGROUND_CHANGED, WM_APP_REQUEST_PROOF, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; use crate::spend_pace; @@ -52,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, @@ -1761,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, @@ -1823,6 +1825,19 @@ pub fn run() { }); } + 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); @@ -3404,6 +3419,52 @@ unsafe extern "system" fn on_tray_location_changed( } } +/// 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)); + } +} + /// Main window procedure unsafe extern "system" fn wnd_proc( hwnd: HWND, @@ -3587,6 +3648,10 @@ unsafe extern "system" fn wnd_proc( 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() { @@ -3729,13 +3794,19 @@ 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 => { @@ -3866,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)