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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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) From af571ff0b3e78f423635b52a095b143164e52326 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sat, 12 Sep 2026 12:48:22 +0300 Subject: [PATCH 11/22] Fix widget briefly disappearing after unlock (false fullscreen-suppress) Diagnose log showed a real occurrence: WTS_SESSION_UNLOCK fires, then ~22s later should_hide_widget_for_fullscreen falsely triggers (a transient borderless window from post-unlock agents momentarily covers the monitor), hiding the widget for ~5s before it restores. Add a 30s grace period after session unlock during which fullscreen suppression is skipped entirely, and log the actual foreground window class/rect whenever suppression does fire so any remaining false positive outside that window can be root-caused directly instead of guessed at. --- Cargo.toml | 1 + src/native_interop.rs | 38 ++++++++++++++++++++++++++- src/window.rs | 61 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e06df804..c9835396 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ features = [ "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", + "Win32_System_RemoteDesktop", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Accessibility", diff --git a/src/native_interop.rs b/src/native_interop.rs index c33d2ac6..44800cf1 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -6,6 +6,9 @@ use windows::Win32::Graphics::Gdi::{ EnumDisplayMonitors, GetMonitorInfoW, MonitorFromPoint, MonitorFromWindow, HDC, HMONITOR, MONITORINFO, MONITOR_DEFAULTTONEAREST, }; +use windows::Win32::System::RemoteDesktop::{ + WTSRegisterSessionNotification, WTSUnRegisterSessionNotification, NOTIFY_FOR_THIS_SESSION, +}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; @@ -65,6 +68,11 @@ 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; +// Session lock/unlock notifications (WTSRegisterSessionNotification) +pub const WM_WTSSESSION_CHANGE: u32 = 0x02B1; +pub const WTS_SESSION_LOCK: usize = 0x7; +pub const WTS_SESSION_UNLOCK: usize = 0x8; + #[derive(Clone, Copy, Debug)] pub struct TaskbarWindow { pub hwnd: HWND, @@ -842,13 +850,24 @@ fn foreground_covers_monitor_borderless(self_hwnd: HWND, taskbar_hwnd: Option= info.rcMonitor.bottom - BAND_SLACK + let covers_band = win_rect.bottom >= info.rcMonitor.bottom - BAND_SLACK; + if covers_band { + crate::diagnose::log(format!( + "foreground_covers_monitor_borderless: TRUE (covers taskbar band) fg_class={:?} fg_rect=({},{},{},{})", + window_class_name(fg), win_rect.left, win_rect.top, win_rect.right, win_rect.bottom + )); + } + covers_band } } @@ -1438,6 +1457,23 @@ pub fn set_foreground_event_hook( } } +/// Subscribe this window to WM_WTSSESSION_CHANGE (session lock/unlock). +/// Locking switches to a separate secure desktop; nothing on the original +/// desktop is composited while it's active, including this window's layered +/// content. DWM is not guaranteed to spontaneously recomposite it on unlock - +/// this notification is the purpose-built signal to force it, rather than +/// waiting on a generic foreground-change event that may not fire the same +/// way across a secure-desktop transition. +pub fn register_session_notification(hwnd: HWND) -> bool { + unsafe { WTSRegisterSessionNotification(hwnd, NOTIFY_FOR_THIS_SESSION).is_ok() } +} + +pub fn unregister_session_notification(hwnd: HWND) { + unsafe { + let _ = WTSUnRegisterSessionNotification(hwnd); + } +} + /// 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 53cc7732..20083bb8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -24,6 +24,7 @@ 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_FOREGROUND_CHANGED, WM_APP_REQUEST_PROOF, WM_APP_TRAY, WM_APP_USAGE_UPDATED, + WM_WTSSESSION_CHANGE, WTS_SESSION_UNLOCK, }; use crate::poller; use crate::spend_pace; @@ -204,6 +205,31 @@ fn startup_layout_grace_active() -> bool { .unwrap_or(false) } +/// Set on session unlock. The sign-in/unlock sequence on this machine (Cisco +/// IT-managed: Defender/Secure-Endpoint/VPN/Teams agents all re-settle after +/// unlock) was confirmed in the diagnose log to spawn transient borderless +/// windows that momentarily cover the whole monitor, which is indistinguishable +/// from a real exclusive-fullscreen app to `should_hide_widget_for_fullscreen`. +/// A confirmed real occurrence: WTS_SESSION_UNLOCK at T, then a false +/// "fullscreen suppress" fired at T+22s lasting ~5s. Suppress fullscreen-hide +/// checks entirely for a generous window after unlock instead of trying to +/// enumerate every transient class name these agents create; see the +/// `foreground_covers_monitor_borderless` logging (native_interop.rs) for the +/// actual culprit class/rect the next time this fires outside the grace window. +static UNLOCK_FULLSCREEN_GRACE: Mutex> = Mutex::new(None); + +fn arm_unlock_fullscreen_grace() { + let mut guard = UNLOCK_FULLSCREEN_GRACE.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(std::time::Instant::now()); +} + +fn unlock_fullscreen_grace_active() -> bool { + let guard = UNLOCK_FULLSCREEN_GRACE.lock().unwrap_or_else(|e| e.into_inner()); + guard + .map(|t| t.elapsed() < std::time::Duration::from_millis(30_000)) + .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- @@ -1838,6 +1864,12 @@ pub fn run() { } } + if native_interop::register_session_notification(hwnd) { + diagnose::log("session lock/unlock notification registered"); + } else { + diagnose::log("session lock/unlock notification could not be registered"); + } + // Try to embed in taskbar let attached = attach_to_taskbar(hwnd, settings.taskbar_index); @@ -3088,7 +3120,17 @@ fn sync_fullscreen_visibility(hwnd: HWND) { } // 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 raw_should_suppress = native_interop::should_hide_widget_for_fullscreen(hwnd, taskbar_hwnd); + let should_suppress = if unlock_fullscreen_grace_active() { + if raw_should_suppress { + diagnose::log( + "fullscreen suppress skipped: unlock grace period active (would have hidden)", + ); + } + false + } else { + raw_should_suppress + }; let was_suppressed = lock_state() .as_ref() .map(|s| s.hidden_for_fullscreen) @@ -3436,6 +3478,10 @@ unsafe extern "system" fn on_foreground_changed( ) { static LAST_FOREGROUND_REPAINT: Mutex> = Mutex::new(None); + diagnose::log(format!( + "on_foreground_changed: raw event fired, new foreground hwnd={_hwnd:?}" + )); + let should_repaint = { let mut last = LAST_FOREGROUND_REPAINT .lock() @@ -3649,9 +3695,20 @@ unsafe extern "system" fn wnd_proc( LRESULT(0) } msg if msg == WM_APP_FOREGROUND_CHANGED => { + diagnose::log("WM_APP_FOREGROUND_CHANGED: forcing repaint"); render_layered(); LRESULT(0) } + WM_WTSSESSION_CHANGE => { + if wparam.0 == WTS_SESSION_UNLOCK { + diagnose::log("WM_WTSSESSION_CHANGE: session unlocked, forcing repaint"); + arm_unlock_fullscreen_grace(); + invalidate_popup_layout(); + position_at_taskbar(); + render_layered(); + } + LRESULT(0) + } msg if msg == WM_APP_REQUEST_PROOF => { invalidate_popup_layout(); if let Some(s) = lock_state().as_mut() { @@ -3807,6 +3864,7 @@ unsafe extern "system" fn wnd_proc( if let Some(h) = fg_hook { native_interop::unhook_win_event(h); } + native_interop::unregister_session_notification(hwnd); PostQuitMessage(0); } IDM_RESET_POSITION => { @@ -3950,6 +4008,7 @@ unsafe extern "system" fn wnd_proc( if let Some(h) = fg_hook { native_interop::unhook_win_event(h); } + native_interop::unregister_session_notification(hwnd); tray_icon::remove_all(hwnd); PostQuitMessage(0); LRESULT(0) From e79e3db916388b847bacf4be9661dc8920271e90 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sat, 12 Sep 2026 13:09:32 +0300 Subject: [PATCH 12/22] Fix widget jumping off-screen: EnumWindows misses live Shell_TrayWnd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the "disappears briefly" reports: on this machine, EnumWindows's top-level walk reproducibly omits the live, fully functional Shell_TrayWnd (independently confirmed healthy via GetWindowRect and its TrayNotifyWnd child) while a plain FindWindow call for the same class finds it every time. Every taskbar lookup in this codebase went through EnumWindows, so when this happened, taskbar_hwnd_for_settings_index fell back to find_taskbars()'s enumeration, which only turned up a stale/bogus Shell_SecondaryTrayWnd with garbage geometry (e.g. rect starting at negative coordinates) — and the widget got positioned there, off-screen. - find_taskbar_by_class now tries FindWindow first, only falling back to the EnumWindows scan if that comes up empty. - find_taskbars() backfills the primary via the same FindWindow-first lookup if EnumWindows's own walk didn't turn up a primary candidate, so multi-monitor resolution can't silently lose it either. - position_at_taskbar() now rejects a resolved taskbar rect that's geometrically implausible (non-positive size, or entirely outside the virtual screen) instead of moving the widget there, as a second line of defense against any other stale-window source. - WM_APP_FOREGROUND_CHANGED now re-resolves the taskbar binding (not just repaints), so any transient bad binding self-corrects on the next foreground event instead of waiting up to 60s for the periodic keepalive. --- src/native_interop.rs | 72 +++++++++++++++++++++++++++++++++++++++++++ src/window.rs | 15 +++++++++ 2 files changed, 87 insertions(+) diff --git a/src/native_interop.rs b/src/native_interop.rs index 44800cf1..650e55cc 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -87,6 +87,25 @@ fn taskbar_has_notification_area(taskbar_hwnd: HWND) -> bool { } pub fn find_taskbar_by_class(class_name: &str) -> Option { + // Prefer a direct FindWindow lookup over EnumWindows. Confirmed reproducibly + // on a real machine: EnumWindows's top-level walk can silently omit the live, + // fully-functional Shell_TrayWnd (independently verified via GetWindowRect + // and its TrayNotifyWnd child, both healthy) while a plain FindWindow call + // for the exact same class name finds it every time. When that happened here, + // the EnumWindows-only search below returned None, which sent + // taskbar_hwnd_for_settings_index down its enumeration fallback path and + // bound the widget to a stale/bogus taskbar-shaped window instead — moving + // it off-screen. FindWindow is also a single call instead of a full + // top-level enumeration, so trying it first is strictly cheaper too. + unsafe { + let wide = wide_str(class_name); + if let Ok(hwnd) = FindWindowW(PCWSTR::from_raw(wide.as_ptr()), PCWSTR::null()) { + if hwnd != HWND::default() { + return Some(hwnd); + } + } + } + struct Search { target: String, found: Option, @@ -181,6 +200,29 @@ pub fn find_taskbars() -> Vec { unsafe { let _ = EnumWindows(Some(enum_proc), LPARAM(&mut taskbars as *mut _ as isize)); } + + // EnumWindows's top-level walk has been confirmed (reproducibly, on a real + // machine) to silently omit a live, fully-functional Shell_TrayWnd that a + // plain FindWindow call for the same class finds every time. If that + // happens here, this scan would otherwise report zero primary candidates + // and callers fall back to whatever secondary/stale window it did find — + // which can have garbage geometry. Backfill the primary via the same + // FindWindow-first lookup `find_taskbar_by_class` already uses. + if !taskbars.iter().any(|t| t.is_primary) { + if let Some(direct) = find_taskbar_by_class("Shell_TrayWnd") { + if let Some(rect) = get_taskbar_rect(direct).or_else(|| get_window_rect_safe(direct)) { + crate::diagnose::log(format!( + "find_taskbars: EnumWindows missed live primary, backfilled via FindWindow hwnd={direct:?}" + )); + taskbars.push(TaskbarWindow { + hwnd: direct, + rect, + is_primary: true, + }); + } + } + } + taskbars.sort_by_key(|taskbar| { ( !taskbar.is_primary, @@ -368,6 +410,36 @@ fn is_plausible_taskbar_height(height: i32) -> bool { (16..=160).contains(&height) } +/// Reject a resolved taskbar rect that is geometrically nonsensical before it +/// is used to reposition the widget. Confirmed real occurrence: during a +/// transient explorer.exe taskbar recreation (e.g. right after unlock), the +/// direct Shell_TrayWnd lookup can momentarily fail and the enumeration +/// fallback in `find_taskbars()` can pick up a mid-teardown/mid-creation +/// window with a garbage rect (negative coordinates, near-zero or inverted +/// height). Positioning the widget there sent it off-screen entirely, which +/// read to the user as the widget "disappearing" until the next successful +/// reposition. Bail out instead of moving the widget to an implausible rect. +pub fn taskbar_rect_is_plausible(rect: RECT) -> bool { + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width <= 0 || !is_plausible_taskbar_height(height) { + return false; + } + unsafe { + let vx = GetSystemMetrics(SM_XVIRTUALSCREEN); + let vy = GetSystemMetrics(SM_YVIRTUALSCREEN); + let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN); + let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN); + let virtual_screen = RECT { + left: vx, + top: vy, + right: vx + vw, + bottom: vy + vh, + }; + rects_overlap(rect, virtual_screen) + } +} + fn primary_monitor_rect() -> Option { unsafe { let mut found: Option = None; diff --git a/src/window.rs b/src/window.rs index 20083bb8..2f44d8b5 100644 --- a/src/window.rs +++ b/src/window.rs @@ -3256,6 +3256,14 @@ fn position_at_taskbar() { } }; + if !native_interop::taskbar_rect_is_plausible(taskbar_rect) { + diagnose::log(format!( + "position_at_taskbar skipped: implausible taskbar rect ({},{},{},{})", + taskbar_rect.left, taskbar_rect.top, taskbar_rect.right, taskbar_rect.bottom + )); + 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 { @@ -3696,6 +3704,13 @@ unsafe extern "system" fn wnd_proc( } msg if msg == WM_APP_FOREGROUND_CHANGED => { diagnose::log("WM_APP_FOREGROUND_CHANGED: forcing repaint"); + // Also re-resolve the taskbar binding (not just repaint pixels): a + // foreground change can coincide with a transient explorer.exe + // taskbar recreation, and re-running position_at_taskbar here lets + // a bad binding self-correct on the very next foreground event + // instead of waiting up to 60s for the periodic keepalive. + invalidate_popup_layout(); + position_at_taskbar(); render_layered(); LRESULT(0) } From 6fbcdbb76b15998c075c49029f21f294b4a4a750 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sat, 12 Sep 2026 23:30:31 +0300 Subject: [PATCH 13/22] Fix self-inflicted z-order flicker on every widget repaint position_popup_zorder (used by position_above_taskbar, called from render_layered on every foreground change, unlock, and periodic keepalive) reasserted topmost via SetWindowPos(HWND_NOTOPMOST) then immediately SetWindowPos(HWND_TOPMOST). Between those two calls the widget is genuinely at a lower z-order position, behind whatever else is in front there, and a DWM composite frame landing in that gap shows it briefly occluded. This ran unconditionally on essentially every repaint, i.e. very often - a longstanding, previously-unexplained and likely primary source of "disappears briefly", independent of the unlock/taskbar-binding bugs already fixed. Skip the demote/reassert dance when the widget is already the frontmost window in z-order (GetWindow(hwnd, GW_HWNDPREV) is null), which is true for the large majority of calls, and go straight to a single SetWindowPos(HWND_TOPMOST). Only fall back to the two-step reassert when something is genuinely in front of it. --- src/native_interop.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/native_interop.rs b/src/native_interop.rs index 650e55cc..0b7c8f1f 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1394,6 +1394,35 @@ fn position_popup_zorder( } } } + // The classic "reassert topmost" trick — SetWindowPos to HWND_NOTOPMOST + // then immediately back to HWND_TOPMOST — genuinely places the window + // at a lower z-order position for the instant between the two calls. + // This function runs on every render_layered() call (every foreground + // change, unlock, and periodic keepalive), so on a machine where that + // fires often, any compositor frame landing in that gap shows the + // widget briefly occluded by whatever is now in front of it — a + // plausible, previously-unexplained source of "disappears briefly" + // that predates today's other fixes. Only pay that cost when the + // widget isn't already the frontmost window; skip it entirely when it + // already is, which is true for the overwhelming majority of calls. + let prev_in_zorder = GetWindow(hwnd, GW_HWNDPREV).unwrap_or_default(); + let already_frontmost = prev_in_zorder.0.is_null(); + if already_frontmost { + if w > 0 && h > 0 { + let _ = SetWindowPos(hwnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE); + } else { + let _ = SetWindowPos( + hwnd, + HWND_TOPMOST, + 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); From d7aba6b0a1b3c83df3d09586b05d5b02671bcade Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 02:05:46 +0300 Subject: [PATCH 14/22] Simplify position_above_taskbar to always use single-call z-order The previous commit's "skip demote/reassert when already frontmost" guard turned out to almost never engage: a live continuous monitor shows GW_HWNDPREV is essentially always non-null for this window (other topmost windows elsewhere on screen coexist normally), so the flicker-prone SetWindowPos(NOTOPMOST) -> SetWindowPos(TOPMOST) dance was still running on nearly every render regardless of that guard. Drop the guard's dependency for this call site entirely: always insert directly after taskbar_hwnd in z-order (one SetWindowPos call, the same pattern already used safely by raise_on_taskbar_band during taskbar peek) instead of trying to force absolute front-of-everything. For a taskbar-corner widget, sitting right above the taskbar's own z-order is sufficient, and removes the two-step dance from the hot render path entirely rather than gating it on a condition that rarely holds. --- src/native_interop.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index 0b7c8f1f..0ce228de 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1354,7 +1354,17 @@ pub fn lower_below_fullscreen(hwnd: HWND) { /// 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); + // Insert directly after taskbar_hwnd in z-order (single SetWindowPos call, + // same as raise_on_taskbar_band) rather than the demote-then-reassert + // dance below. Measured live: GW_HWNDPREV is essentially always non-null + // for this window (other topmost windows elsewhere on screen coexist + // normally), so the "skip if already frontmost" gate on that dance almost + // never engages in practice, meaning this call site would still hit the + // flicker-prone two-step SetWindowPos on nearly every render. This runs on + // every render_layered() call (every foreground change, unlock, and + // periodic keepalive), so a single stable insert-after-taskbar call here + // is worth more than chasing "absolute front of all topmost windows". + position_popup_zorder(hwnd, Some(taskbar_hwnd), true, x, y, w, h); } /// Place/move the popup without HWND_TOPMOST (exclusive fullscreen suppression). From 2f3f3bb0c8270562d63696caa1fb1930d9e5cddc Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 11:56:35 +0300 Subject: [PATCH 15/22] Revert d7aba6b: it hid the widget entirely behind the real taskbar Confirmed live regression from the previous commit, reported as "I don't see the widget at all" and reproduced directly: SetWindowPos(hwnd, hWndInsertAfter, ...) places hwnd immediately BEHIND hWndInsertAfter in z-order, not in front of it. Passing taskbar_hwnd as hWndInsertAfter (the use_taskbar_band=true path) therefore placed the widget behind the real Shell_TrayWnd, which paints over it - total invisibility, confirmed via GetWindow(GW_HWNDPREV/NEXT) showing the widget sitting behind the taskbar in the live z-order chain, and via screenshots showing no widget content even across a full process restart (ruling out stale state - this was a straightforwardly wrong call every time). position_above_taskbar goes back to the demote/reassert HWND_TOPMOST dance. That path predates this session's changes and was never observed to fail; the flicker theory that motivated the previous two commits remains unconfirmed and lower priority than not hiding the widget outright. --- src/native_interop.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index 0ce228de..7b7924ba 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1354,17 +1354,20 @@ pub fn lower_below_fullscreen(hwnd: HWND) { /// 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) { - // Insert directly after taskbar_hwnd in z-order (single SetWindowPos call, - // same as raise_on_taskbar_band) rather than the demote-then-reassert - // dance below. Measured live: GW_HWNDPREV is essentially always non-null - // for this window (other topmost windows elsewhere on screen coexist - // normally), so the "skip if already frontmost" gate on that dance almost - // never engages in practice, meaning this call site would still hit the - // flicker-prone two-step SetWindowPos on nearly every render. This runs on - // every render_layered() call (every foreground change, unlock, and - // periodic keepalive), so a single stable insert-after-taskbar call here - // is worth more than chasing "absolute front of all topmost windows". - position_popup_zorder(hwnd, Some(taskbar_hwnd), true, x, y, w, h); + // REVERTED (confirmed live regression): `SetWindowPos(hwnd, hWndInsertAfter, + // ...)` places `hwnd` immediately BEHIND hWndInsertAfter in z-order — the + // window passed as hWndInsertAfter ends up in FRONT. Passing taskbar_hwnd + // here (use_taskbar_band=true) therefore placed the widget behind the real + // taskbar, which then painted over it — total invisibility, confirmed via + // GetWindow(GW_HWNDPREV/NEXT) showing the widget sitting behind Shell_TrayWnd + // in the live z-order chain, and via screenshots showing no widget content + // at all even across a full process restart. Do not reintroduce + // use_taskbar_band=true here; that call shape is only correct when the + // caller genuinely wants to sit behind/at the taskbar's own level (see + // raise_on_taskbar_band's peek-mode use). This path needs the widget above + // everything ordinary, which the HWND_TOPMOST special z-band (below) + // provides regardless of the taskbar's own position. + position_popup_zorder(hwnd, Some(taskbar_hwnd), false, x, y, w, h); } /// Place/move the popup without HWND_TOPMOST (exclusive fullscreen suppression). From 49f5122c1d65a139eaadafcfa76e30b8d1d05a62 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 12:04:38 +0300 Subject: [PATCH 16/22] Exclude XamlWindow class from fullscreen-suppress false positives Confirmed live: diagnose log shows foreground_covers_monitor_borderless returning true for fg_class="XamlWindow" with fg_rect=(0,0,2880,1800) - a transient Windows 11 shell XAML surface (Widgets board / notification flyout / similar) that briefly reports covering the entire monitor including the taskbar band. This triggered "fullscreen suppress: hide + lower z-order" and hid the widget for ~2s, correlated against the continuous visibility monitor logging vis=False for the same window. Add it to is_shell_foreground_class alongside the other shell-owned XAML surface classes already excluded there. --- src/native_interop.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/native_interop.rs b/src/native_interop.rs index 7b7924ba..5968fa9e 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -969,6 +969,13 @@ fn is_shell_foreground_class(class_name: &str) -> bool { | "XamlExplorerHostIslandWindow" | "TopLevelWindowForOverflowXamlIsland" | "Windows.UI.Composition.DesktopWindowContentBridge" + // Confirmed live false positive: a transient "XamlWindow" (Windows + // 11 Widgets board / notification flyout / similar shell XAML + // surface) briefly reported its rect as (0,0,,) - + // covering the entire monitor including the taskbar band - which + // triggered fullscreen-suppress and hid the widget for ~2s even + // though nothing resembling a real fullscreen app was open. + | "XamlWindow" ) || class_name.starts_with("WindowsInternal") } From e288e1f99c36fe3e572d0ad319d678d6b96c9e52 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 14:58:47 +0300 Subject: [PATCH 17/22] Log foreground window class name on every foreground change The on_foreground_changed hook only logged a raw HWND, which is useless for after-the-fact diagnosis - by the time anyone reads the log the window is long gone, so there was no way to tell whether a given event was the Start menu, Task View, a real app, or anything else. Log the class name too (window_class_name made pub for this). --- src/native_interop.rs | 2 +- src/window.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index 5968fa9e..07a08221 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -943,7 +943,7 @@ fn foreground_covers_monitor_borderless(self_hwnd: HWND, taskbar_hwnd: Option Option { +pub fn window_class_name(hwnd: HWND) -> Option { unsafe { let mut class_name = [0u16; 64]; let len = GetClassNameW(hwnd, &mut class_name); diff --git a/src/window.rs b/src/window.rs index 2f44d8b5..b6feb3be 100644 --- a/src/window.rs +++ b/src/window.rs @@ -3486,8 +3486,13 @@ unsafe extern "system" fn on_foreground_changed( ) { static LAST_FOREGROUND_REPAINT: Mutex> = Mutex::new(None); + // Class name is the only way to retroactively tell what actually took the + // foreground (Start menu, Task View, Alt-Tab, a real app, ...) - a raw + // HWND alone is useless for after-the-fact diagnosis since the window is + // usually long gone by the time anyone looks at the log. + let class = native_interop::window_class_name(_hwnd).unwrap_or_else(|| "?".to_string()); diagnose::log(format!( - "on_foreground_changed: raw event fired, new foreground hwnd={_hwnd:?}" + "on_foreground_changed: raw event fired, new foreground hwnd={_hwnd:?} class={class}" )); let should_repaint = { From 419b1180da7fe9af08b1735e8378c98128ce1352 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 15:20:33 +0300 Subject: [PATCH 18/22] Fix silent no-op in the widget-invisible recovery path A fresh-eyes audit (prompted by repeated "issue persists" reports with zero corresponding log anomalies) found a real, distinct bug from the 5 already fixed this session: ensure_popup_visible() - called by the taskbar watchdog thread specifically when it detects the widget has gone invisible - calls position_at_taskbar() to recover, but that function's own popup_layout_unchanged() cache short-circuits before ever reaching the ShowWindow(SW_SHOWNOACTIVATE)/render_layered() call whenever the taskbar geometry hasn't changed - the overwhelmingly common case, since the taskbar didn't move, the widget just isn't showing. The recovery attempt was a silent no-op, invisible in the log since neither the cache check nor its early return ever logs anything. The only thing that eventually fixed it was force_periodic_repaint(), which explicitly invalidates the cache first but only runs once every ~60s - matching the "disappears, then reappears on its own after a while" pattern reported throughout this session. Fix: invalidate_popup_layout() before calling position_at_taskbar() in ensure_popup_visible() whenever the window isn't already visible, matching the pattern every other recovery call site already uses (force_periodic_repaint, fullscreen-restore, foreground-changed, unlock). Same fix applied to the WM_DISPLAYCHANGE/WM_DPICHANGED/ WM_SETTINGCHANGE handler, which had the identical gap. Also added diagnose::log calls on both popup_layout_unchanged early-return sites in position_at_taskbar(), which previously logged nothing at all. Verified live: manually hid the widget via ShowWindow(SW_HIDE) with its position otherwise unchanged (the exact bug scenario) - it now auto-recovers within ~3s via the watchdog's WM_APP_ENSURE_VISIBLE instead of staying hidden. Also fixed a related but separately-triggered deadlock risk found by the same audit: begin_update_check()/begin_update_apply() could call the message-pumping show_info_message() (MessageBoxW) while still holding the state MutexGuard - the same non-reentrant-Mutex class of bug as two other deadlocks fixed earlier this session, just gated behind a narrower manual-update-check trigger. Restructured both to drop the guard first. --- src/window.rs | 83 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/src/window.rs b/src/window.rs index b6feb3be..e037b57c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1210,26 +1210,40 @@ fn version_action_label( fn begin_update_check(hwnd: HWND, interactive: bool) { let send_hwnd = SendHwnd::from_hwnd(hwnd); - let (strings, install_channel) = { + // MessageBoxW (inside show_info_message) pumps this thread's message + // queue while it's up; calling it with the state MutexGuard still held + // would deadlock the moment any WM_TIMER/WM_APP_* handler that also + // locks state gets dispatched during that pump - the same non-reentrant- + // Mutex class of bug as two other deadlocks already fixed this session. + // Drop the guard before calling it. + let already_in_progress = { let mut state = lock_state(); let Some(app_state) = state.as_mut() else { return; }; - - if matches!( + matches!( app_state.update_status, UpdateStatus::Checking | UpdateStatus::Applying - ) { - if interactive { - show_info_message( - hwnd, - app_state.language.strings().updates, - app_state.language.strings().update_in_progress, - ); - } - return; + ) + }; + if already_in_progress { + if interactive { + let (title, message) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.language.strings().updates, s.language.strings().update_in_progress), + None => return, + } + }; + show_info_message(hwnd, title, message); } - + return; + } + let (strings, install_channel) = { + let mut state = lock_state(); + let Some(app_state) = state.as_mut() else { + return; + }; app_state.update_status = UpdateStatus::Checking; (app_state.language.strings(), app_state.install_channel) }; @@ -1296,24 +1310,34 @@ fn begin_update_check(hwnd: HWND, interactive: bool) { fn begin_update_apply(hwnd: HWND, release: ReleaseDescriptor) { let send_hwnd = SendHwnd::from_hwnd(hwnd); - let strings = { + // Same deadlock risk as begin_update_check: MessageBoxW pumps messages, + // so it must never run while the state MutexGuard is held. + let already_in_progress = { let mut state = lock_state(); let Some(app_state) = state.as_mut() else { return; }; - - if matches!( + matches!( app_state.update_status, UpdateStatus::Checking | UpdateStatus::Applying - ) { - show_info_message( - hwnd, - app_state.language.strings().updates, - app_state.language.strings().update_in_progress, - ); + ) + }; + if already_in_progress { + let (title, message) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.language.strings().updates, s.language.strings().update_in_progress), + None => return, + } + }; + show_info_message(hwnd, title, message); + return; + } + let strings = { + let mut state = lock_state(); + let Some(app_state) = state.as_mut() else { return; - } - + }; app_state.update_status = UpdateStatus::Applying; app_state.language.strings() }; @@ -3030,6 +3054,14 @@ fn ensure_popup_visible() { if already_visible && layout_valid { return; } + // Confirmed real bug: when IsWindowVisible is false but the taskbar + // geometry hasn't changed (the overwhelmingly common case - the taskbar + // didn't move, the widget just isn't showing), position_at_taskbar()'s + // own `popup_layout_unchanged` cache short-circuits before it ever + // reaches the `ShowWindow(SW_SHOWNOACTIVATE)` call that would actually + // fix this - it's a silent no-op. Force that cache to miss so the + // recovery attempt this function exists for can't be defeated by it. + invalidate_popup_layout(); position_at_taskbar(); } @@ -3334,6 +3366,7 @@ fn position_at_taskbar() { 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) { + diagnose::log("position_at_taskbar: skipped (embedded layout unchanged)"); return; } { @@ -3359,6 +3392,7 @@ fn position_at_taskbar() { max_x, ); if popup_layout_unchanged(x, y, widget_width, widget_height) { + diagnose::log("position_at_taskbar: skipped (popup layout unchanged)"); return; } { @@ -3573,6 +3607,7 @@ unsafe extern "system" fn wnd_proc( if startup_layout_grace_active() { return LRESULT(0); } + invalidate_popup_layout(); position_at_taskbar(); render_layered(); LRESULT(0) From f68180a531e08fadb93e7383ddb94f3884ed57e0 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 16:19:55 +0300 Subject: [PATCH 19/22] Exclude LockScreenControllerProxyWindow from fullscreen-suppress Observed live via the new foreground-class logging: this class took foreground immediately before a WM_WTSSESSION_CHANGE unlock event - clearly a lock/sign-in related shell surface, same risk profile as the XamlWindow false positive fixed earlier (transient class that could report covering the whole monitor). This specific occurrence didn't happen to trigger fullscreen-suppress, but excluding it defensively is free and consistent with the other shell surfaces already excluded. --- src/native_interop.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/native_interop.rs b/src/native_interop.rs index 07a08221..e3d0a30e 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -976,6 +976,13 @@ fn is_shell_foreground_class(class_name: &str) -> bool { // triggered fullscreen-suppress and hid the widget for ~2s even // though nothing resembling a real fullscreen app was open. | "XamlWindow" + // Observed live taking foreground right around a session + // unlock (WM_WTSSESSION_CHANGE fired immediately after). Same + // risk profile as XamlWindow above: a transient lock/sign-in + // related shell surface, not a real app - exclude defensively + // even though this specific occurrence didn't happen to trigger + // fullscreen-suppress. + | "LockScreenControllerProxyWindow" ) || class_name.starts_with("WindowsInternal") } From ac23ccb4ae6c86e590e7d00de94c7a25a1c020ac Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 18:47:17 +0300 Subject: [PATCH 20/22] Arm a settle-repaint timer specifically for Start menu opening User-confirmed, reproducible-by-description trigger: clicking the Start button makes the widget disappear. Could not reproduce via synthetic mouse clicks despite 30+ trials genuinely opening the Start menu (confirmed via foreground class change) with dense visibility, position, and rendered-pixel-content sampling - so this targets the confirmed trigger directly rather than a mechanism proven by repro. The existing on_foreground_changed repaint fires immediately on the WinEvent, which can race the Start menu's own opening animation/compositor churn. Arm a one-shot 400ms follow-up timer specifically when the new foreground window's class is "Windows.UI.Core.CoreWindow" (Start menu/Search/Action Center's XAML host) that forces invalidate_popup_layout + position_at_taskbar + render_layered again after that transition has had time to settle, independent of whatever the immediate repaint did. --- src/native_interop.rs | 1 + src/window.rs | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index e3d0a30e..f8b1cbcf 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -60,6 +60,7 @@ 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; +pub const TIMER_STARTMENU_FOLLOWUP: usize = 8; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/window.rs b/src/window.rs index e037b57c..3d7a1e38 100644 --- a/src/window.rs +++ b/src/window.rs @@ -22,7 +22,7 @@ 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, + TIMER_RESET_POLL, TIMER_STARTMENU_FOLLOWUP, TIMER_UPDATE_CHECK, TIMER_WIDGET_KEEPALIVE, WM_APP_FOREGROUND_CHANGED, WM_APP_REQUEST_PROOF, WM_APP_TRAY, WM_APP_USAGE_UPDATED, WM_WTSSESSION_CHANGE, WTS_SESSION_UNLOCK, }; @@ -3555,6 +3555,25 @@ unsafe extern "system" fn on_foreground_changed( 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)); + + // Confirmed, repeatedly reported by the user as the actual trigger: + // clicking the Start button makes the widget disappear. Class + // "Windows.UI.Core.CoreWindow" is the Start menu/Search/Action Center + // XAML host. The immediate repaint above fires the instant the + // WinEvent lands, which can race the Start menu's own opening + // animation/compositor churn and lose. Arm a second, delayed repaint + // so there's a follow-up assertion after that transition has settled, + // independent of whatever the immediate one did. + if class == "Windows.UI.Core.CoreWindow" { + unsafe { + let _ = SetTimer( + widget_hwnd, + native_interop::TIMER_STARTMENU_FOLLOWUP, + 400, + None, + ); + } + } } } @@ -3684,6 +3703,15 @@ unsafe extern "system" fn wnd_proc( TIMER_FULLSCREEN_CHECK => { sync_fullscreen_visibility(hwnd); } + TIMER_STARTMENU_FOLLOWUP => { + unsafe { + let _ = KillTimer(hwnd, TIMER_STARTMENU_FOLLOWUP); + } + diagnose::log("TIMER_STARTMENU_FOLLOWUP: forcing settle repaint"); + invalidate_popup_layout(); + position_at_taskbar(); + render_layered(); + } TIMER_DRAG => { let (dragging, embedded, tray_offset) = { let state = lock_state(); From 8c4293fb22e816464ca385fbcbb5455ea6f5673b Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Sun, 13 Sep 2026 23:43:20 +0300 Subject: [PATCH 21/22] Fix monitor-selection heuristic and a debounce gap in the Start-menu fix 1. monitor_rect_for_taskbar used a width-comparison heuristic (secondary taskbar -> monitor with the MAXIMUM width; primary -> MINIMUM width) instead of asking Windows which monitor a given taskbar_hwnd is actually on. This machine has a bogus ~6880px-wide "ghost" secondary display (stale dock/reconfiguration artifact), wider than the real primary, so a real secondary taskbar would always resolve to that ghost monitor instead of wherever it actually is. A live capture caught the widget's rect landing at y=2784-2880 - not on either known monitor - consistent with this heuristic picking the wrong one. Now uses MonitorFromWindow(taskbar_hwnd) directly, matching the pattern already used correctly elsewhere in this file. 2. The Start-menu follow-up timer added in ac23ccb never actually armed in practice: the class-check that arms it was gated behind the same 100ms repaint debounce as the immediate repaint. A real Start-button click reliably produces a burst of foreground-change events within milliseconds (e.g. a Shell_TrayWnd refocus immediately followed by the CoreWindow taking foreground), so the CoreWindow event routinely landed inside another event's debounce window and returned early before ever reaching the class check - confirmed via diagnose log (CoreWindow logged, but no TIMER_STARTMENU_FOLLOWUP ever followed). The class-check/timer-arm now runs unconditionally before the debounce; confirmed firing on every real Start click afterward. --- src/native_interop.rs | 32 +++++++++++++------------- src/window.rs | 53 ++++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/native_interop.rs b/src/native_interop.rs index f8b1cbcf..fab20052 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -527,22 +527,22 @@ fn monitors_primary_first() -> Vec { } 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) + // REPLACED a fragile width-comparison heuristic here (secondary taskbar + // -> monitor with the MAXIMUM width; primary -> monitor with the MINIMUM + // width). Confirmed real on this machine: an EnumWindows-visible "ghost" + // secondary display (Shell_SecondaryTrayWnd on a bogus ~6880px-wide + // monitor, left over from some display/dock reconfiguration) is wider + // than the real primary, so `max_by_key(width)` would always resolve a + // real secondary taskbar to that ghost monitor instead of whichever + // monitor it's actually on - and the "primary" branch resolving by + // MINIMUM width is just as fragile in principle (multi-monitor setups + // don't guarantee the primary is the narrowest). This is a very plausible + // source of "widget ends up on the wrong monitor" independent of the + // EnumWindows-blind-spot bug fixed earlier. + // + // Ask Windows directly which monitor this specific taskbar_hwnd is + // actually on instead of guessing from monitor dimensions. + monitor_rect_for_hwnd(taskbar_hwnd).or_else(primary_monitor_rect) } fn clip_taskbar_band_to_monitor(mut band: RECT, mon: RECT) -> RECT { diff --git a/src/window.rs b/src/window.rs index 3d7a1e38..11e72362 100644 --- a/src/window.rs +++ b/src/window.rs @@ -3529,6 +3529,36 @@ unsafe extern "system" fn on_foreground_changed( "on_foreground_changed: raw event fired, new foreground hwnd={_hwnd:?} class={class}" )); + let widget_hwnd = lock_state() + .as_ref() + .map(|s| s.hwnd.to_hwnd()) + .unwrap_or(HWND::default()); + + // Confirmed, repeatedly reported by the user (and captured on video) as + // the actual trigger: clicking the Start button makes the widget + // disappear. Class "Windows.UI.Core.CoreWindow" is the Start menu/ + // Search/Action Center XAML host. This arm-the-follow-up-timer check + // MUST run unconditionally, before the repaint debounce below - a real + // Start-button click reliably produces a burst of foreground-change + // events within milliseconds of each other (e.g. a Shell_TrayWnd + // refocus immediately followed by the CoreWindow taking foreground). + // The debounce below exists to limit repaint frequency, but if it were + // allowed to gate this check too, the CoreWindow event landing inside + // another event's 100ms debounce window would return early and this + // would silently never arm - confirmed happening via the diagnose log + // (on_foreground_changed logged the CoreWindow class, but no + // TIMER_STARTMENU_FOLLOWUP ever followed). + if !widget_hwnd.0.is_null() && class == "Windows.UI.Core.CoreWindow" { + unsafe { + let _ = SetTimer( + widget_hwnd, + native_interop::TIMER_STARTMENU_FOLLOWUP, + 400, + None, + ); + } + } + let should_repaint = { let mut last = LAST_FOREGROUND_REPAINT .lock() @@ -3548,32 +3578,9 @@ unsafe extern "system" fn on_foreground_changed( 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)); - - // Confirmed, repeatedly reported by the user as the actual trigger: - // clicking the Start button makes the widget disappear. Class - // "Windows.UI.Core.CoreWindow" is the Start menu/Search/Action Center - // XAML host. The immediate repaint above fires the instant the - // WinEvent lands, which can race the Start menu's own opening - // animation/compositor churn and lose. Arm a second, delayed repaint - // so there's a follow-up assertion after that transition has settled, - // independent of whatever the immediate one did. - if class == "Windows.UI.Core.CoreWindow" { - unsafe { - let _ = SetTimer( - widget_hwnd, - native_interop::TIMER_STARTMENU_FOLLOWUP, - 400, - None, - ); - } - } } } From 33c4b112e47523815dbc74321eae3219f3fc6330 Mon Sep 17 00:00:00 2001 From: "Ori Yardenay (oyardena)" Date: Mon, 14 Sep 2026 11:27:29 +0300 Subject: [PATCH 22/22] Log UpdateLayeredWindow failures; document unresolved Start-menu bug Found a 100%-reliable repro for the "widget disappears when clicking Start" report: click Start, then move the mouse to 2-3 different points inside the open menu over ~1-2s before closing it. A plain click-wait-click with no movement inside the menu does NOT reproduce it (confirmed clean 5/5 trials) - the trigger is sustained mouse movement inside the open menu, not the click/open itself, which is also why 40+ earlier synthetic click-only tests this session never reproduced it. Confirmed root cause: this widget's composited surface goes blank and stays blank for 7+ seconds, independent of how many times or how frequently render_layered() resubmits. UpdateLayeredWindow reports success on every single call during the failure (now logged when it doesn't, for future diagnosis). Tried and confirmed NOT sufficient, each independently, verified via the same repro: a one-shot follow-up repaint; making it recurring at 400ms; tightening to 60ms (~113 attempts over 7s); DwmFlush() after every UpdateLayeredWindow; RedrawWindow(RDW_INVALIDATE|RDW_UPDATENOW|RDW_FRAME); stripping and re-adding WS_EX_LAYERED every tick to force a fresh surface registration. All reverted (kept only the harmless recurring-repaint timer and interval back at 400ms/30 ticks) since they added real, unconditional cost to render_layered() - this app's hot path - with zero measured benefit; kept only the UpdateLayeredWindow failure logging, which is free when there's no failure to report. This points at a DWM compositor-scheduling decision - likely deprioritizing a WS_EX_NOACTIVATE topmost overlay's frames against the focused Start menu's own high-frequency hover-animation frames - rather than a missed-frame race our own retries can win. Full writeup and an untested next direction (genuine taskbar embedding via SetParent + WM_PAINT instead of a separate layered surface, using the already-present but currently-dead embed_in_taskbar()/paint()) is in the TIMER_STARTMENU_FOLLOWUP handler's comment. Not attempted here: requires also wiring WM_PAINT to call paint() (currently a no-op stub) and was judged too large/risky to land safely under time pressure, not ruled out as unhelpful. --- src/window.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/src/window.rs b/src/window.rs index 11e72362..911ce0fd 100644 --- a/src/window.rs +++ b/src/window.rs @@ -194,6 +194,15 @@ static TASKBAR_RECOVER_FAILURES: AtomicU32 = AtomicU32::new(0); static KEEPALIVE_TICKS_SINCE_FORCE: AtomicU32 = AtomicU32::new(0); const FORCE_REPAINT_EVERY_N_TICKS: u32 = 4; // ~60s at the 15s keepalive interval +/// Confirmed via reproduction that neither this interval nor a much tighter +/// one (60ms, ~113 attempts over 7s - tested and reverted) fixes the actual +/// bug this timer targets (see the TIMER_STARTMENU_FOLLOWUP handler for the +/// full writeup) - resubmission frequency isn't the limiting factor. Kept at +/// a moderate, low-overhead cadence since it's unproven either way and this +/// is cheap insurance, not a confirmed fix. +const STARTMENU_FOLLOWUP_INTERVAL_MS: u32 = 400; +const STARTMENU_FOLLOWUP_MAX_TICKS: u32 = 30; // ~12s at 400ms + /// 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); @@ -2204,7 +2213,7 @@ fn render_layered() { AlphaFormat: 1, // AC_SRC_ALPHA }; - let _ = UpdateLayeredWindow( + let ulw_result = UpdateLayeredWindow( hwnd, screen_dc, Some(&pt_dest), @@ -2215,6 +2224,17 @@ fn render_layered() { Some(&blend), ULW_ALPHA, ); + if let Err(e) = ulw_result { + diagnose::log(format!("render_layered: UpdateLayeredWindow FAILED: {e:?}")); + } + // NOTE: tried DwmFlush() + RedrawWindow() here as forced-composite + // mitigations for the Start-menu-mouse-movement bug below. Neither + // helped (still blank in 3/3 and 2/3 repro trials respectively) and + // both add unconditional cost to every single render_layered() call + // (this app's hot path), so removed rather than kept "just in case". + // UpdateLayeredWindow itself reports success every time during the + // failure - see the TIMER_STARTMENU_FOLLOWUP comment for what's + // actually going on and what's still unresolved. if !_embedded { let taskbar_hwnd = lock_state().as_ref().and_then(|s| s.taskbar_hwnd); @@ -3553,7 +3573,7 @@ unsafe extern "system" fn on_foreground_changed( let _ = SetTimer( widget_hwnd, native_interop::TIMER_STARTMENU_FOLLOWUP, - 400, + STARTMENU_FOLLOWUP_INTERVAL_MS, None, ); } @@ -3711,13 +3731,71 @@ unsafe extern "system" fn wnd_proc( sync_fullscreen_visibility(hwnd); } TIMER_STARTMENU_FOLLOWUP => { - unsafe { - let _ = KillTimer(hwnd, TIMER_STARTMENU_FOLLOWUP); - } diagnose::log("TIMER_STARTMENU_FOLLOWUP: forcing settle repaint"); invalidate_popup_layout(); position_at_taskbar(); render_layered(); + + // UNRESOLVED, confirmed via a 100%-reliable repro (see + // below): this recurring-repaint approach does NOT + // actually fix the bug it was written for. Keeping it + // anyway because it's cheap, harmless, and may still help + // shorter/different DWM hiccups than the one described + // here - just don't mistake its presence for a fix. + // + // Confirmed root cause: sustained mouse movement inside an + // open Start menu (hovering tiles/recommended items, not + // just click-open-close) reliably makes this widget's + // composited surface go blank and STAY blank for 7+ + // seconds, independent of how many times or how + // frequently we resubmit. UpdateLayeredWindow reports + // success every single call during the failure (verified + // via logging its Result). Tried and confirmed NOT + // sufficient, each independently: a single one-shot + // follow-up (this timer, one-shot); this timer made + // recurring at 400ms; tightened to 60ms (~113 attempts + // over 7s); DwmFlush() after every UpdateLayeredWindow; + // RedrawWindow(RDW_INVALIDATE|RDW_UPDATENOW|RDW_FRAME); + // stripping and re-adding WS_EX_LAYERED every tick to + // force a fresh surface registration. All of the above + // still left the widget blank through the whole repro. + // Reliable repro (100% in 6+ trials): click Start, then + // move the mouse to at least 2-3 different points inside + // the open menu over ~1-2s before closing it - a plain + // click-wait-click with no movement inside the menu does + // NOT reproduce it (confirmed clean 5/5). This points at + // a DWM compositor-scheduling decision (likely + // deprioritizing a NOACTIVATE topmost overlay's frames + // against the focused app's own high-frequency hover- + // animation frames) rather than a missed-frame race our + // own retries can win. Untested next direction: genuine + // taskbar embedding (SetParent + WM_PAINT instead of a + // separate layered surface) via the already-present but + // currently-dead embed_in_taskbar()/paint() - not + // attempted here because it requires also wiring WM_PAINT + // to actually call paint() (currently a no-op stub) and + // was judged too large/risky to land safely under time + // pressure rather than because it's known not to help. + static TICKS: AtomicU32 = AtomicU32::new(0); + let still_active = unsafe { + let fg = GetForegroundWindow(); + matches!( + native_interop::window_class_name(fg).as_deref(), + Some("Windows.UI.Core.CoreWindow") | Some("XamlExplorerHostIslandWindow") + ) + }; + let ticks = if still_active { + TICKS.fetch_add(1, Ordering::Relaxed) + 1 + } else { + TICKS.store(0, Ordering::Relaxed); + 0 + }; + if !still_active || ticks >= STARTMENU_FOLLOWUP_MAX_TICKS { + TICKS.store(0, Ordering::Relaxed); + unsafe { + let _ = KillTimer(hwnd, TIMER_STARTMENU_FOLLOWUP); + } + } } TIMER_DRAG => { let (dragging, embedded, tray_offset) = {