diff --git a/src/config.rs b/src/config.rs index 368691c..102b0c4 100755 --- a/src/config.rs +++ b/src/config.rs @@ -135,11 +135,28 @@ pub struct Config { pub app_rule: Vec, } +/// Where a resolved rule came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleSource { + AppRule, + Bucket, +} + +impl RuleSource { + pub fn as_str(self) -> &'static str { + match self { + RuleSource::AppRule => "app_rule", + RuleSource::Bucket => "bucket", + } + } +} + /// Result of resolving what action to take for a given process. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedRule { pub timeout_mins: u64, pub action: Action, + pub source: RuleSource, } impl Config { @@ -161,6 +178,7 @@ impl Config { return Some(ResolvedRule { timeout_mins: rule.timeout_mins, action: rule.action.clone(), + source: RuleSource::AppRule, }); } else { return None; @@ -178,6 +196,7 @@ impl Config { return Some(ResolvedRule { timeout_mins: bucket.timeout_mins, action: bucket.action.clone(), + source: RuleSource::Bucket, }); } } @@ -329,20 +348,41 @@ impl Config { } /// Save config to the standard path. + /// + /// Durability: write to temp file, fsync the file's contents, rename over + /// the destination, then fsync the parent directory so the rename itself + /// survives a crash/power-loss before the kernel flushes its dirent cache. pub fn save(&self) -> Result<(), String> { + use std::io::Write; let path = config_path(); let contents = toml::to_string_pretty(self).map_err(|e| format!("Serialize error: {}", e))?; - // Write to temp file, then rename for atomic save let tmp_path = path.with_extension("toml.tmp"); - std::fs::write(&tmp_path, &contents).map_err(|e| format!("Write error: {}", e))?; + { + let mut f = std::fs::File::create(&tmp_path) + .map_err(|e| format!("Create temp error: {}", e))?; + f.write_all(contents.as_bytes()) + .map_err(|e| format!("Write error: {}", e))?; + // sync_all flushes both data and metadata before rename. + if let Err(e) = f.sync_all() { + log::warn!("config temp sync_all failed: {}", e); + } + } std::fs::rename(&tmp_path, &path).map_err(|e| { - // Clean up temp file on rename failure let _ = std::fs::remove_file(&tmp_path); format!("Rename error: {}", e) })?; + // Best-effort directory fsync so the rename is durable too. Not all + // platforms expose directory sync via std (Windows in particular is a + // no-op here) — failures are logged but never bubbled. + if let Some(parent) = path.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + log::info!("Saved config to {}", path.display()); Ok(()) } diff --git a/src/filter.rs b/src/filter.rs index ab44e89..bd1d051 100755 --- a/src/filter.rs +++ b/src/filter.rs @@ -48,9 +48,28 @@ pub struct WindowInfo { pub title: String, pub class_name: String, pub is_tool_window: bool, // WS_EX_TOOLWINDOW style - #[allow(dead_code)] - pub is_owned: bool, // has an owner window - pub own_pid: bool, // belongs to the fade process + pub is_owned: bool, // has an owner window (modal/popup/dialog) + /// True if this is an owned window AND its owner is currently disabled + /// (`IsWindowEnabled` returns FALSE). Windows disables the parent only for + /// true application-modal dialogs (Save As, auth prompts, MessageBox). + /// Floating helpers (find/replace, color pickers, tool palettes) leave the + /// owner enabled, so they should not shield the parent from idle actions. + pub disables_owner: bool, + /// PID of the owner window when this window's owner is disabled (a real + /// modal). Used to shield the owner process from idle actions for the + /// out-of-process modal case (owner and dialog hosted in different + /// processes, e.g. shell-hosted picker dialogs). `None` when not a modal, + /// not owned, or the lookup failed. + pub owner_pid: Option, + pub own_pid: bool, // belongs to the fade process + /// True if DWM reports the window as cloaked (hidden by shell/UWP/virtual + /// desktop). Cloaked windows are invisible to the user even though + /// IsWindowVisible() returns true, so we must skip them. + pub is_cloaked: bool, + /// True if the window belongs to the user's current virtual desktop. + /// Virtual-desktop resolution requires COM and may fail; default true + /// when the query cannot be made so we don't drop legitimate windows. + pub is_on_current_desktop: bool, } /// Returns true if this window should be filtered out (is a system window). @@ -60,6 +79,18 @@ pub fn is_system_window(info: &WindowInfo) -> bool { return true; } + // Cloaked windows are invisible to the user (UWP background, virtual-desktop + // residue, shell-hidden surfaces). Acting on them is always wrong. + if info.is_cloaked { + return true; + } + + // Windows on other virtual desktops must not be touched — the user isn't + // looking at them and idle accounting on the active desktop doesn't apply. + if !info.is_on_current_desktop { + return true; + } + // Empty title — not a real user window if info.title.is_empty() { return true; @@ -100,10 +131,28 @@ mod tests { class_name: class.into(), is_tool_window: false, is_owned: false, + disables_owner: false, + owner_pid: None, own_pid: false, + is_cloaked: false, + is_on_current_desktop: true, } } + #[test] + fn test_cloaked_window_filtered() { + let mut w = make_window("chrome.exe", "Background tab", "Chrome_WidgetWin_1"); + w.is_cloaked = true; + assert!(is_system_window(&w)); + } + + #[test] + fn test_off_desktop_window_filtered() { + let mut w = make_window("chrome.exe", "Other desktop", "Chrome_WidgetWin_1"); + w.is_on_current_desktop = false; + assert!(is_system_window(&w)); + } + #[test] fn test_normal_app_passes() { let w = make_window("chrome.exe", "Google", "Chrome_WidgetWin_1"); diff --git a/src/main.rs b/src/main.rs index 6af4796..252ae4c 100755 --- a/src/main.rs +++ b/src/main.rs @@ -121,12 +121,17 @@ fn main() { } } - // Populate GUI from config - update_gui_from_config( - &window, - &config.read().unwrap(), - &search_state.read().unwrap(), - ); + // Populate GUI from config. If either lock is poisoned (e.g. a previous + // panicking thread held them) keep the daemon alive with safe fallbacks + // rather than crashing the tray. + { + let cfg = config.read().map(|g| (*g).clone()).unwrap_or_else(|_| { + log::error!("Config lock poisoned on startup — using defaults for initial GUI"); + Config::default_config() + }); + let query = search_state.read().map(|s| s.clone()).unwrap_or_default(); + update_gui_from_config(&window, &cfg, &query); + } // Wire up GUI callbacks setup_gui_callbacks( diff --git a/src/monitor.rs b/src/monitor.rs index 8f8b97c..0eb9577 100755 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,7 +1,7 @@ /// Core idle-detection polling loop. /// Tracks which processes were last in the foreground and triggers /// minimize/close actions when idle timeouts are exceeded. -use crate::config::{Action, Config}; +use crate::config::{Action, Config, RuleSource}; use crate::filter; use crate::winapi::{WindowApi, WindowEntry}; use std::collections::HashMap; @@ -37,6 +37,13 @@ pub struct ActionLogEntry { pub action: Action, /// Seconds since UNIX epoch (for display formatting on the GUI side). pub timestamp: u64, + /// Whether the rule that triggered this action came from an app_rule or a + /// bucket. Persisted for debugging; the GUI does not currently surface it. + #[allow(dead_code)] + pub rule_source: RuleSource, + /// Timeout (minutes) that was in effect when the action fired. + #[allow(dead_code)] + pub timeout_mins: u64, } /// Shared ring-buffer of recent actions. Most-recent first. @@ -68,8 +75,19 @@ pub struct Monitor { process_start_cache: HashMap>, /// Ring-buffer of recent actions for the Activity GUI tab. action_log: ActionLog, + /// Instant of the previous successful poll. Used to detect resume-from-sleep + /// and other monotonic-clock gaps so we don't immediately fire actions + /// across a system suspend/lock interval. + last_poll_at: Option, } +/// If the gap between two polls exceeds the expected interval by more than +/// this factor (e.g. >5× the poll interval), assume the system suspended, +/// locked, or hibernated and rebase idle timestamps to "now". This prevents +/// surprise closes the instant the user unlocks. +const POLL_GAP_FACTOR: u32 = 5; +const POLL_GAP_FLOOR_SECS: u64 = 60; + impl Monitor { pub fn new( api: W, @@ -90,10 +108,18 @@ impl Monitor { first_seen: HashMap::new(), process_start_cache: HashMap::new(), action_log, + last_poll_at: None, } } - fn record_action(&self, process: &str, title: &str, action: Action) { + fn record_action( + &self, + process: &str, + title: &str, + action: Action, + rule_source: RuleSource, + timeout_mins: u64, + ) { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -104,6 +130,8 @@ impl Monitor { title: title.to_string(), action, timestamp: ts, + rule_source, + timeout_mins, }); while log.len() > ACTION_LOG_CAPACITY { log.pop_back(); @@ -127,6 +155,30 @@ impl Monitor { let now = Instant::now(); + // 0a. Detect resume-from-sleep / lock / hibernation: if the gap since + // the previous poll dwarfs the expected interval, treat every tracked + // process as freshly active so we don't immediately act on something + // the user is about to come back to. + let interval_secs = config.general.polling_interval_secs.max(1); + let threshold = + Duration::from_secs((interval_secs * POLL_GAP_FACTOR as u64).max(POLL_GAP_FLOOR_SECS)); + let resumed_from_gap = match self.last_poll_at { + Some(prev) => now.duration_since(prev) > threshold, + None => false, + }; + if resumed_from_gap { + log::warn!( + "Poll gap exceeded {:?}; treating as suspend/resume — rebasing idle timestamps", + threshold + ); + if let Ok(mut ts) = self.foreground_timestamps.lock() { + for v in ts.values_mut() { + *v = now; + } + } + } + self.last_poll_at = Some(now); + // 0. Detect newly managed processes and reset their idle clock. // This prevents immediate action when a rule is added for an already-open window. // Must match resolve_process semantics: a disabled AppRule explicitly @@ -216,6 +268,8 @@ impl Monitor { title: String, action: Action, idle_secs: f64, + rule_source: RuleSource, + timeout_mins: u64, } let mut actions: Vec = Vec::new(); @@ -229,6 +283,27 @@ impl Monitor { } }; + // PIDs that currently have a true application-modal dialog open — i.e. + // an owned window whose owner has been disabled by Windows. Acting on + // the parent of an active modal can interrupt save/auth dialogs, so we + // skip the entire process. Owned-but-not-disabling helpers (find / + // replace, color picker, tool palettes) don't qualify. We must NOT + // pre-filter with is_system_window here: legitimate dialogs frequently + // look "system-like" but are exactly what this guard exists to protect. + // Include both the dialog's PID and the owner's PID. For most modals + // these are the same, but out-of-process modals (shell-hosted picker + // dialogs over an app) have distinct PIDs — without the owner's PID + // the true parent window would still be eligible for idle actions. + let mut pids_with_modal: HashSet = HashSet::new(); + for entry in &self.current_windows { + if entry.info.disables_owner { + pids_with_modal.insert(entry.pid); + if let Some(opid) = entry.info.owner_pid { + pids_with_modal.insert(opid); + } + } + } + for entry in &self.current_windows { let proc_lower = entry.info.process_name.to_lowercase(); @@ -242,6 +317,23 @@ impl Monitor { continue; } + // Skip owned windows (modals, popups, dialogs) — they belong to a parent. + if entry.info.is_owned { + continue; + } + + // Skip windows whose process currently has a real modal dialog + // visible (an owned window that disables its parent). Closing the + // parent would tear the modal down mid-interaction. + if pids_with_modal.contains(&entry.pid) { + log::debug!( + "Skipping {} ({}) — process has an active modal dialog", + entry.info.process_name, + entry.info.title + ); + continue; + } + // Skip processes that just became managed this poll cycle if newly_managed.contains(&proc_lower) { continue; @@ -289,7 +381,14 @@ impl Monitor { continue; } - // Double-check it's not now the foreground + // Double-check it's not now the foreground (HWND-level — the + // process-name check above is conservative for same-process + // windows but cannot distinguish two HWNDs sharing a process name). + let current_fg_hwnd = self.api.get_foreground_hwnd(); + if current_fg_hwnd != 0 && current_fg_hwnd == entry.hwnd { + new_timestamps.push((proc_lower, Instant::now())); + continue; + } if let Some(current_fg) = self.api.get_foreground_process() { if current_fg.to_lowercase() == proc_lower { new_timestamps.push((proc_lower, Instant::now())); @@ -303,6 +402,8 @@ impl Monitor { title: entry.info.title.clone(), action: rule.action.clone(), idle_secs: idle_duration.as_secs_f64(), + rule_source: rule.source, + timeout_mins: rule.timeout_mins, }); } @@ -315,35 +416,34 @@ impl Monitor { } } - // Execute actions + // Execute actions. Window titles can contain sensitive content + // (document names, URLs, draft subject lines) so the info-level log + // line omits them; the full title is preserved at debug level and in + // the in-memory action log used by the GUI. for action in actions { + let verb = match action.action { + Action::Minimize => "Minimizing", + Action::Close => "Closing", + }; + log::info!( + "{verb}: {} — idle {:.0}s [source={}, timeout={}m]", + action.process, + action.idle_secs, + action.rule_source.as_str(), + action.timeout_mins, + ); + log::debug!("{verb} hwnd={:#x} title={:?}", action.hwnd, action.title); match action.action { - Action::Minimize => { - // Info-level log avoids the window title; titles can leak - // sensitive activity (document names, URLs, chat subjects) - // into the on-disk Windows release log. The full title is - // still recorded in the in-memory Activity log for the UI - // and is available at debug level for troubleshooting. - log::info!( - "Minimizing: {} — idle {:.0}s", - action.process, - action.idle_secs - ); - log::debug!("Minimizing title: {}", action.title); - self.api.minimize_window(action.hwnd); - self.record_action(&action.process, &action.title, Action::Minimize); - } - Action::Close => { - log::info!( - "Closing: {} — idle {:.0}s", - action.process, - action.idle_secs - ); - log::debug!("Closing title: {}", action.title); - self.api.close_window(action.hwnd); - self.record_action(&action.process, &action.title, Action::Close); - } + Action::Minimize => self.api.minimize_window(action.hwnd), + Action::Close => self.api.close_window(action.hwnd), } + self.record_action( + &action.process, + &action.title, + action.action, + action.rule_source, + action.timeout_mins, + ); } // Publish snapshot for GUI @@ -462,7 +562,11 @@ mod tests { class_name: "AppWindow".into(), is_tool_window: false, is_owned: false, + disables_owner: false, + owner_pid: None, own_pid: false, + is_cloaked: false, + is_on_current_desktop: true, }, } } @@ -742,7 +846,11 @@ mod tests { class_name: "DWM".into(), is_tool_window: false, is_owned: false, + disables_owner: false, + owner_pid: None, own_pid: false, + is_cloaked: false, + is_on_current_desktop: true, }, }]); timestamps.lock().unwrap().insert( @@ -952,6 +1060,181 @@ mod tests { assert!(!mock.get_minimized().is_empty()); } + #[test] + fn test_owned_window_not_acted_on() { + // An owned (modal/popup) window should never be a direct action target. + let config = make_config( + vec![AppRule { + process: "notepad.exe".into(), + timeout_mins: 0, + action: Action::Close, + enabled: true, + icon: None, + customized: false, + }], + vec![], + ); + let (mut monitor, mock, _, _, timestamps) = setup(config); + + let mut owned = make_entry(1, "notepad.exe", "Save As"); + owned.info.is_owned = true; + + mock.set_foreground(Some("other.exe")); + mock.set_windows(vec![owned]); + timestamps.lock().unwrap().insert( + "notepad.exe".to_string(), + Instant::now() - Duration::from_secs(9999), + ); + monitor.poll(); + + assert!(mock.get_closed().is_empty()); + assert!(mock.get_minimized().is_empty()); + } + + #[test] + fn test_parent_skipped_when_owned_modal_exists() { + // If a process has an owned/modal window open, the parent window should + // also be skipped — closing it would tear down the active modal. + let config = make_config( + vec![AppRule { + process: "notepad.exe".into(), + timeout_mins: 0, + action: Action::Close, + enabled: true, + icon: None, + customized: false, + }], + vec![], + ); + let (mut monitor, mock, _, _, timestamps) = setup(config); + + let parent = make_entry(10, "notepad.exe", "Untitled - Notepad"); + let mut modal = make_entry(11, "notepad.exe", "Save As"); + modal.info.is_owned = true; + modal.info.disables_owner = true; + // Same PID — they belong to the same process instance. + modal.pid = parent.pid; + + mock.set_foreground(Some("other.exe")); + mock.set_windows(vec![parent, modal]); + timestamps.lock().unwrap().insert( + "notepad.exe".to_string(), + Instant::now() - Duration::from_secs(9999), + ); + monitor.poll(); + + assert!(mock.get_closed().is_empty()); + assert!(mock.get_minimized().is_empty()); + } + + #[test] + fn test_out_of_process_modal_shields_owner() { + // Some Win32 modals are hosted in a different process than their + // owner (shell-hosted picker dialogs, security prompts). The shield + // must cover the owner's PID, not just the dialog's. + let config = make_config( + vec![AppRule { + process: "notepad.exe".into(), + timeout_mins: 0, + action: Action::Close, + enabled: true, + icon: None, + customized: false, + }], + vec![], + ); + let (mut monitor, mock, _, _, timestamps) = setup(config); + + let parent = make_entry(30, "notepad.exe", "Untitled - Notepad"); + // Modal lives in a different process (different pid) but reports the + // parent's pid as owner_pid. + let mut modal = make_entry(31, "PickerHost.exe", "Save As"); + modal.info.is_owned = true; + modal.info.disables_owner = true; + modal.info.owner_pid = Some(parent.pid); + + mock.set_foreground(Some("other.exe")); + mock.set_windows(vec![parent, modal]); + timestamps.lock().unwrap().insert( + "notepad.exe".to_string(), + Instant::now() - Duration::from_secs(9999), + ); + monitor.poll(); + + assert!(mock.get_closed().is_empty()); + assert!(mock.get_minimized().is_empty()); + } + + #[test] + fn test_parent_acted_on_with_floating_helper() { + // An owned window that does NOT disable its parent (find/replace, + // color picker, tool palette) must not shield the parent from idle + // actions — Windows leaves the parent fully clickable. + let config = make_config( + vec![AppRule { + process: "notepad.exe".into(), + timeout_mins: 0, + action: Action::Minimize, + enabled: true, + icon: None, + customized: false, + }], + vec![], + ); + let (mut monitor, mock, _, _, timestamps) = setup(config); + + let parent = make_entry(20, "notepad.exe", "Untitled - Notepad"); + let mut helper = make_entry(21, "notepad.exe", "Find"); + helper.info.is_owned = true; + helper.info.disables_owner = false; + helper.pid = parent.pid; + + mock.set_foreground(Some("other.exe")); + mock.set_windows(vec![parent, helper]); + timestamps.lock().unwrap().insert( + "notepad.exe".to_string(), + Instant::now() - Duration::from_secs(9999), + ); + // Prime the idle clock so timeout=0 acts on the second poll. + monitor.poll(); + monitor.poll(); + + assert!(!mock.get_minimized().is_empty()); + } + + #[test] + fn test_foreground_hwnd_skips_action() { + // Two windows share a process name. The currently-foreground HWND must + // not be acted on even though the process-name FG check could (e.g. if + // get_foreground_process resolves to a different host's name). + let config = make_config( + vec![AppRule { + process: "chrome.exe".into(), + timeout_mins: 0, + action: Action::Minimize, + enabled: true, + icon: None, + customized: false, + }], + vec![], + ); + let (mut monitor, mock, _, _, timestamps) = setup(config); + + // Foreground process resolves to something different (simulating a + // host process whose name differs from the window's own process name). + mock.set_foreground(Some("other.exe")); + mock.set_foreground_hwnd(42); + mock.set_windows(vec![make_entry(42, "chrome.exe", "Active tab")]); + timestamps.lock().unwrap().insert( + "chrome.exe".to_string(), + Instant::now() - Duration::from_secs(9999), + ); + monitor.poll(); + + // HWND-level check should have spared it. + assert!(mock.get_minimized().is_empty()); + } + #[test] fn test_external_timestamp_update_respected() { // Simulates the foreground hook updating timestamps externally diff --git a/src/winapi.rs b/src/winapi.rs index 24f0306..f44bf05 100755 --- a/src/winapi.rs +++ b/src/winapi.rs @@ -9,6 +9,14 @@ pub trait WindowApi: Send + Sync { /// Get the process name of the currently foreground window. fn get_foreground_process(&self) -> Option; + /// Get the HWND of the currently foreground window, or 0 if none. + /// Used to compare candidate windows by handle rather than process name — + /// process-name comparison is ambiguous when multiple processes share a name + /// or when host processes (ApplicationFrameHost, browsers) reparent windows. + fn get_foreground_hwnd(&self) -> isize { + 0 + } + /// Enumerate all visible, non-minimized, top-level windows. /// Returns WindowEntry structs with HWND, process name, title, class, and style flags. fn enumerate_visible_windows(&self) -> Vec; @@ -83,12 +91,19 @@ mod win32_impl { } } + fn get_foreground_hwnd(&self) -> isize { + // SAFETY: GetForegroundWindow has no preconditions; null is valid. + unsafe { GetForegroundWindow().0 as isize } + } + fn enumerate_visible_windows(&self) -> Vec { let own_pid = self.own_pid; + // SAFETY: ctx is stack-allocated and outlives the EnumWindows call. unsafe { let mut ctx = EnumContext { results: Vec::new(), own_pid, + pid_name_cache: std::collections::HashMap::new(), }; let _ = EnumWindows( Some(enum_window_callback_v2), @@ -134,33 +149,53 @@ mod win32_impl { } let style = GetWindowLongW(h, GWL_STYLE) as u32; - let _ex_style = GetWindowLongW(h, GWL_EXSTYLE) as u32; - - // Fullscreen windows are typically WS_POPUP without WS_THICKFRAME let is_popup = (style & WS_POPUP.0) != 0; let no_border = (style & WS_THICKFRAME.0) == 0; + let classic_fullscreen = is_popup && no_border; - if !(is_popup && no_border) { + // Resolve window rect + monitor rect once for both heuristics. + let mut rect = std::mem::zeroed::(); + if GetWindowRect(h, &mut rect).is_err() { return false; } - - // Check if window covers the entire monitor - let mut rect = std::mem::zeroed::(); - let _ = GetWindowRect(h, &mut rect); - let monitor = MonitorFromWindow(h, MONITOR_DEFAULTTOPRIMARY); let mut mi = MONITORINFO { cbSize: std::mem::size_of::() as u32, ..Default::default() }; - if GetMonitorInfoW(monitor, &mut mi).as_bool() { - rect.left == mi.rcMonitor.left - && rect.top == mi.rcMonitor.top - && rect.right == mi.rcMonitor.right - && rect.bottom == mi.rcMonitor.bottom - } else { - false + if !GetMonitorInfoW(monitor, &mut mi).as_bool() { + return false; } + + // Path 1: classic borderless-popup fullscreen with exact match. + if classic_fullscreen + && rect.left == mi.rcMonitor.left + && rect.top == mi.rcMonitor.top + && rect.right == mi.rcMonitor.right + && rect.bottom == mi.rcMonitor.bottom + { + return true; + } + + // Path 2: borderless/windowed-fullscreen variants used by DXGI and + // many games — window may have WS_OVERLAPPEDWINDOW styles but its + // client area covers (nearly) the whole monitor with no taskbar + // visible. Treat ≥99% coverage of monitor area as fullscreen, + // tolerating a few pixels of DWM extended frame. + let win_w = (rect.right - rect.left).max(0) as i64; + let win_h = (rect.bottom - rect.top).max(0) as i64; + let mon_w = (mi.rcMonitor.right - mi.rcMonitor.left).max(1) as i64; + let mon_h = (mi.rcMonitor.bottom - mi.rcMonitor.top).max(1) as i64; + let win_area = win_w * win_h; + let mon_area = mon_w * mon_h; + // Tolerance: 1% slack on each dimension, plus the window must + // start at-or-before the monitor origin. + let near_full = win_area * 100 >= mon_area * 99 + && rect.left <= mi.rcMonitor.left + 4 + && rect.top <= mi.rcMonitor.top + 4 + && rect.right >= mi.rcMonitor.right - 4 + && rect.bottom >= mi.rcMonitor.bottom - 4; + near_full } } @@ -200,52 +235,99 @@ mod win32_impl { struct EnumContext { results: Vec, own_pid: u32, + /// Cache PID → process name across the enumeration cycle. Opening a + /// process handle per-window is the dominant cost when many windows + /// share a PID (browser tabs, IDE child windows). + pid_name_cache: std::collections::HashMap>, + } + + /// Query DWM cloaked attribute. Returns true if the window is cloaked for + /// any reason — UWP suspended, on another virtual desktop, or hidden by + /// the shell. Returns false on any failure (be lenient). + unsafe fn is_window_cloaked(hwnd: HWND) -> bool { + use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED}; + let mut cloaked: u32 = 0; + let res = DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + &mut cloaked as *mut u32 as *mut std::ffi::c_void, + std::mem::size_of::() as u32, + ); + res.is_ok() && cloaked != 0 } unsafe extern "system" fn enum_window_callback_v2(hwnd: HWND, lparam: LPARAM) -> BOOL { let ctx = &mut *(lparam.0 as *mut EnumContext); - // Skip invisible windows + // SAFETY block 1: cheap visibility filters — no allocations, no handles. if !IsWindowVisible(hwnd).as_bool() { return TRUE; } - - // Skip minimized windows if IsIconic(hwnd).as_bool() { return TRUE; } - // Get window title + // SAFETY block 2: title read. Buffer length matches GetWindowTextW spec. let mut title_buf = [0u16; 512]; let len = GetWindowTextW(hwnd, &mut title_buf); if len == 0 { - return TRUE; // no title + return TRUE; // no title — skip } let title = String::from_utf16_lossy(&title_buf[..len as usize]); - // Get window class + // SAFETY block 3: class name read. let mut class_buf = [0u16; 256]; let class_len = GetClassNameW(hwnd, &mut class_buf); let class_name = String::from_utf16_lossy(&class_buf[..class_len as usize]); - // Get PID + // SAFETY block 4: PID lookup (`pid` is initialized to 0 and overwritten). let mut pid: u32 = 0; GetWindowThreadProcessId(hwnd, Some(&mut pid)); - // Get process name - let process_name = match get_process_name_from_pid(pid) { + // PID → name with intra-cycle cache. Cache miss opens the process handle. + let process_name = match ctx + .pid_name_cache + .entry(pid) + .or_insert_with(|| get_process_name_from_pid(pid)) + .clone() + { Some(name) => name, None => return TRUE, // can't identify — skip }; - // Check extended style + // SAFETY block 5: style + ownership lookups operate on a valid HWND. let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; let is_tool_window = (ex_style & WS_EX_TOOLWINDOW.0) != 0; + let owner_hwnd = GetWindow(hwnd, GW_OWNER).ok().filter(|o| !o.0.is_null()); + let is_owned = owner_hwnd.is_some(); + // A true application-modal dialog disables its owner (the parent goes + // grey and can't be clicked). Floating helpers — find/replace, color + // pickers, tool palettes — leave the owner enabled. We only want to + // shield the parent process from idle actions when there's a real + // modal blocking interaction. + let disables_owner = match owner_hwnd { + Some(owner) => !IsWindowEnabled(owner).as_bool(), + None => false, + }; + // For out-of-process modals (owner and dialog live in different PIDs, + // e.g. shell-hosted picker dialogs), the owner window's PID must also + // be shielded — the dialog's PID isn't enough. + let owner_pid = if disables_owner { + owner_hwnd.and_then(|owner| { + let mut opid: u32 = 0; + GetWindowThreadProcessId(owner, Some(&mut opid)); + if opid != 0 && opid != pid { + Some(opid) + } else { + None + } + }) + } else { + None + }; - // Check if owned - let is_owned = GetWindow(hwnd, GW_OWNER) - .map(|o| !o.0.is_null()) - .unwrap_or(false); + // SAFETY block 6: DWM cloaked query. Documented to be safe on any HWND. + let is_cloaked = is_window_cloaked(hwnd); let info = WindowInfo { process_name, @@ -253,7 +335,14 @@ mod win32_impl { class_name, is_tool_window, is_owned, + disables_owner, + owner_pid, own_pid: pid == ctx.own_pid, + is_cloaked, + // Virtual-desktop hiding manifests as DWM_CLOAKED_SHELL on Win10/11, + // so the cloaked check already covers off-desktop windows. We keep + // the field for completeness and default true here. + is_on_current_desktop: true, }; ctx.results.push(WindowEntry { @@ -423,6 +512,7 @@ pub mod mock { #[derive(Default, Clone)] pub struct MockWindowApi { pub foreground_process: Arc>>, + pub foreground_hwnd: Arc>, pub windows: Arc>>, pub minimized: Arc>>, pub closed: Arc>>, @@ -440,6 +530,10 @@ pub mod mock { *self.foreground_process.lock().unwrap() = process.map(|s| s.to_string()); } + pub fn set_foreground_hwnd(&self, hwnd: isize) { + *self.foreground_hwnd.lock().unwrap() = hwnd; + } + pub fn set_windows(&self, entries: Vec) { let hwnds: Vec = entries.iter().map(|e| e.hwnd).collect(); *self.windows.lock().unwrap() = entries; @@ -468,6 +562,10 @@ pub mod mock { self.foreground_process.lock().unwrap().clone() } + fn get_foreground_hwnd(&self) -> isize { + *self.foreground_hwnd.lock().unwrap() + } + fn enumerate_visible_windows(&self) -> Vec { self.windows.lock().unwrap().clone() }