From 1f69ea5412de53ff8eaae28bcf39d58cfe73cc17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 01:21:14 +0000 Subject: [PATCH 1/7] fix(monitor): skip modal/owned windows and their parents The action loop reached close/minimize for any non-foreground window past its idle timeout, ignoring is_owned on WindowInfo. A parent window could be closed while an owned modal (Save As, auth prompt, etc.) was active. Now skip entries where is_owned is true, and skip any window whose PID has a visible owned window in the current snapshot. --- src/filter.rs | 3 +- src/monitor.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index ab44e89..caa823f 100755 --- a/src/filter.rs +++ b/src/filter.rs @@ -48,8 +48,7 @@ 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 is_owned: bool, // has an owner window (modal/popup/dialog) pub own_pid: bool, // belongs to the fade process } diff --git a/src/monitor.rs b/src/monitor.rs index 8f8b97c..d3954e9 100755 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -229,6 +229,16 @@ impl Monitor { } }; + // PIDs that currently have at least one owned (modal/popup) window visible. + // Acting on the parent of an active modal can interrupt save/auth dialogs, + // so we skip the entire process if any of its windows is owned. + let pids_with_owned: HashSet = self + .current_windows + .iter() + .filter(|e| e.info.is_owned && !filter::is_system_window(&e.info)) + .map(|e| e.pid) + .collect(); + for entry in &self.current_windows { let proc_lower = entry.info.process_name.to_lowercase(); @@ -242,6 +252,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 an owned window visible. + // The owned window may be an active modal dialog; closing the parent + // would tear it down mid-interaction. + if pids_with_owned.contains(&entry.pid) { + log::debug!( + "Skipping {} ({}) — process has an owned/modal window open", + entry.info.process_name, + entry.info.title + ); + continue; + } + // Skip processes that just became managed this poll cycle if newly_managed.contains(&proc_lower) { continue; @@ -952,6 +979,72 @@ 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; + // 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_external_timestamp_update_respected() { // Simulates the foreground hook updating timestamps externally From ecd7a126990dd50bd7fa8ed5711a94dfea1863c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 01:31:06 +0000 Subject: [PATCH 2/7] harden monitor decisions and config durability filter: - add is_cloaked + is_on_current_desktop to WindowInfo; short-circuit in is_system_window so DWM-cloaked surfaces (UWP background, virtual-desktop residue, shell-hidden) never reach the action loop. winapi: - add WindowApi::get_foreground_hwnd for HWND-level checks; default for the trait + null for non-Windows; real impl uses GetForegroundWindow(). - populate is_cloaked via DwmGetWindowAttribute(DWMWA_CLOAKED). The cloaked flag also covers windows on other virtual desktops (DWM_CLOAKED_SHELL), so explicit IVirtualDesktopManager COM is not required for this fix. - broaden is_fullscreen with a near-full coverage path (>=99% of monitor rect) so DXGI/borderless-windowed-fullscreen titles are protected. - cache PID -> process name across a single enumeration cycle. - split unsafe regions in enum_window_callback_v2 and tag each with a brief invariant note. monitor: - HWND-level foreground guard runs immediately before action dispatch, in addition to the existing process-name check. - detect monotonic gaps between polls (>= 5x interval or >= 60s) and rebase every foreground timestamp to now, so resume-from-sleep / lock / hibernation does not fire a wave of closes the second the user unlocks. - record rule source (AppRule vs Bucket) and timeout_mins on every action log entry; surface both in the runtime info log line. - redact window title from the info-level action log (titles may contain sensitive data); preserve them at debug level + in the GUI action log. config: - ResolvedRule now reports source (AppRule | Bucket). - save() fsyncs the temp file before rename and best-effort fsyncs the parent directory after rename so an atomic rename is also durable across power loss. main: - startup GUI hydration no longer unwraps the config / search-state RwLocks (poison would have panicked the tray); fall back to defaults instead. tests: - foreground HWND guard, cloaked-window filter, off-desktop filter. --- src/config.rs | 46 +++++++++++++- src/filter.rs | 36 +++++++++++ src/main.rs | 23 +++++-- src/monitor.rs | 162 ++++++++++++++++++++++++++++++++++++++++--------- src/winapi.rs | 128 +++++++++++++++++++++++++++++--------- 5 files changed, 329 insertions(+), 66 deletions(-) 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 caa823f..b39f6cc 100755 --- a/src/filter.rs +++ b/src/filter.rs @@ -50,6 +50,14 @@ pub struct WindowInfo { pub is_tool_window: bool, // WS_EX_TOOLWINDOW style pub is_owned: bool, // has an owner window (modal/popup/dialog) 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). @@ -59,6 +67,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,9 +120,25 @@ mod tests { is_tool_window: false, is_owned: false, 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..1644a00 100755 --- a/src/main.rs +++ b/src/main.rs @@ -121,12 +121,23 @@ 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 d3954e9..e82640e 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,31 @@ 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 +269,8 @@ impl Monitor { title: String, action: Action, idle_secs: f64, + rule_source: RuleSource, + timeout_mins: u64, } let mut actions: Vec = Vec::new(); @@ -316,7 +371,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())); @@ -330,6 +392,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, }); } @@ -342,35 +406,38 @@ 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 @@ -490,6 +557,8 @@ mod tests { is_tool_window: false, is_owned: false, own_pid: false, + is_cloaked: false, + is_on_current_desktop: true, }, } } @@ -770,6 +839,8 @@ mod tests { is_tool_window: false, is_owned: false, own_pid: false, + is_cloaked: false, + is_on_current_desktop: true, }, }]); timestamps.lock().unwrap().insert( @@ -1045,6 +1116,39 @@ mod tests { 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..3941f75 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,53 +235,76 @@ 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; - - // 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, title, @@ -254,6 +312,11 @@ mod win32_impl { is_tool_window, is_owned, 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 +486,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 +504,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 +536,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() } From 4cba3ad0944868745a76270bd8e69bff3ed0de7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 18:54:53 +0000 Subject: [PATCH 3/7] style: cargo fmt --- src/main.rs | 16 +++++----------- src/monitor.rs | 11 +++-------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1644a00..252ae4c 100755 --- a/src/main.rs +++ b/src/main.rs @@ -125,17 +125,11 @@ fn main() { // 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(); + 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); } diff --git a/src/monitor.rs b/src/monitor.rs index e82640e..de145de 100755 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -160,9 +160,8 @@ impl Monitor { // 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 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, @@ -422,11 +421,7 @@ impl Monitor { action.rule_source.as_str(), action.timeout_mins, ); - log::debug!( - "{verb} hwnd={:#x} title={:?}", - action.hwnd, - action.title - ); + log::debug!("{verb} hwnd={:#x} title={:?}", action.hwnd, action.title); match action.action { Action::Minimize => self.api.minimize_window(action.hwnd), Action::Close => self.api.close_window(action.hwnd), From f73c961c85f4a1b0d968f0c95bf97bb4a4010d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 19:00:44 +0000 Subject: [PATCH 4/7] fix(monitor): shield parent on any owned window, including system-filtered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex P2 feedback on PR #6: pre-filtering pids_with_owned with is_system_window let empty-title or tool-style owned dialogs slip past the guard, leaving their parent eligible for action — exactly the modal flow this check exists to protect. --- src/monitor.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/monitor.rs b/src/monitor.rs index de145de..ad02717 100755 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -285,11 +285,14 @@ impl Monitor { // PIDs that currently have at least one owned (modal/popup) window visible. // Acting on the parent of an active modal can interrupt save/auth dialogs, - // so we skip the entire process if any of its windows is owned. + // so we skip the entire process if any of its windows is owned. We must + // NOT pre-filter with is_system_window here: legitimate dialogs (empty + // title, tool-style frame) frequently look "system-like" but are exactly + // the modal flows this guard exists to protect. let pids_with_owned: HashSet = self .current_windows .iter() - .filter(|e| e.info.is_owned && !filter::is_system_window(&e.info)) + .filter(|e| e.info.is_owned) .map(|e| e.pid) .collect(); From 2a8ed82325eb326b4c0c98576172f60c9b300939 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 19:59:45 +0000 Subject: [PATCH 5/7] Only shield parent from idle actions for true modal dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously any owned window on a process caused fade to skip every window owned by that process. This swept up floating helpers (find / replace, color pickers, tool palettes) that leave the parent fully interactive, so e.g. Notepad with Find open would never be minimized. Windows distinguishes true app-modal dialogs by disabling the owner (IsWindowEnabled returns FALSE on the parent — the grey-out you see behind a Save As). Track that explicitly via disables_owner on WindowInfo and gate the parent-process shield on it. Owned windows themselves are still never direct action targets. --- src/filter.rs | 7 ++++++ src/monitor.rs | 67 ++++++++++++++++++++++++++++++++++++++++---------- src/winapi.rs | 17 ++++++++++--- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index b39f6cc..93b5776 100755 --- a/src/filter.rs +++ b/src/filter.rs @@ -49,6 +49,12 @@ pub struct WindowInfo { pub class_name: String, pub is_tool_window: bool, // WS_EX_TOOLWINDOW style 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, 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 @@ -119,6 +125,7 @@ mod tests { class_name: class.into(), is_tool_window: false, is_owned: false, + disables_owner: false, own_pid: false, is_cloaked: false, is_on_current_desktop: true, diff --git a/src/monitor.rs b/src/monitor.rs index ad02717..584bfdc 100755 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -283,16 +283,17 @@ impl Monitor { } }; - // PIDs that currently have at least one owned (modal/popup) window visible. - // Acting on the parent of an active modal can interrupt save/auth dialogs, - // so we skip the entire process if any of its windows is owned. We must - // NOT pre-filter with is_system_window here: legitimate dialogs (empty - // title, tool-style frame) frequently look "system-like" but are exactly - // the modal flows this guard exists to protect. - let pids_with_owned: HashSet = self + // 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. + let pids_with_modal: HashSet = self .current_windows .iter() - .filter(|e| e.info.is_owned) + .filter(|e| e.info.disables_owner) .map(|e| e.pid) .collect(); @@ -314,12 +315,12 @@ impl Monitor { continue; } - // Skip windows whose process currently has an owned window visible. - // The owned window may be an active modal dialog; closing the parent - // would tear it down mid-interaction. - if pids_with_owned.contains(&entry.pid) { + // 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 owned/modal window open", + "Skipping {} ({}) — process has an active modal dialog", entry.info.process_name, entry.info.title ); @@ -554,6 +555,7 @@ mod tests { class_name: "AppWindow".into(), is_tool_window: false, is_owned: false, + disables_owner: false, own_pid: false, is_cloaked: false, is_on_current_desktop: true, @@ -836,6 +838,7 @@ mod tests { class_name: "DWM".into(), is_tool_window: false, is_owned: false, + disables_owner: false, own_pid: false, is_cloaked: false, is_on_current_desktop: true, @@ -1099,6 +1102,7 @@ mod tests { 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; @@ -1114,6 +1118,43 @@ mod tests { 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 diff --git a/src/winapi.rs b/src/winapi.rs index 3941f75..05917e3 100755 --- a/src/winapi.rs +++ b/src/winapi.rs @@ -298,9 +298,19 @@ mod win32_impl { // 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 is_owned = GetWindow(hwnd, GW_OWNER) - .map(|o| !o.0.is_null()) - .unwrap_or(false); + 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, + }; // SAFETY block 6: DWM cloaked query. Documented to be safe on any HWND. let is_cloaked = is_window_cloaked(hwnd); @@ -311,6 +321,7 @@ mod win32_impl { class_name, is_tool_window, is_owned, + disables_owner, own_pid: pid == ctx.own_pid, is_cloaked, // Virtual-desktop hiding manifests as DWM_CLOAKED_SHELL on Win10/11, From 3ea2e26b6d54ecee9d2ded16fa18e66dea7f37ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 20:25:11 +0000 Subject: [PATCH 6/7] style: cargo fmt --- src/filter.rs | 2 +- src/winapi.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index 93b5776..947ff2d 100755 --- a/src/filter.rs +++ b/src/filter.rs @@ -55,7 +55,7 @@ pub struct WindowInfo { /// 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, - pub own_pid: bool, // belongs to the fade process + 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. diff --git a/src/winapi.rs b/src/winapi.rs index 05917e3..f4aef7d 100755 --- a/src/winapi.rs +++ b/src/winapi.rs @@ -298,9 +298,7 @@ mod win32_impl { // 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 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 From 57acb33a2c953f870eeb5b1864ef9bd0c3b94b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 20:35:27 +0000 Subject: [PATCH 7/7] Shield owner process for out-of-process modals When a modal dialog is hosted in a different process from its owner (shell-hosted picker dialogs, security prompts, UWP modals over a Win32 app), the dialog's PID alone is not enough to protect the parent. Look up the owner window's PID via GetWindowThreadProcessId on the owner HWND and include both PIDs in the shield set. --- src/filter.rs | 7 ++++++ src/monitor.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++----- src/winapi.rs | 17 +++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index 947ff2d..bd1d051 100755 --- a/src/filter.rs +++ b/src/filter.rs @@ -55,6 +55,12 @@ pub struct WindowInfo { /// 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 @@ -126,6 +132,7 @@ mod tests { is_tool_window: false, is_owned: false, disables_owner: false, + owner_pid: None, own_pid: false, is_cloaked: false, is_on_current_desktop: true, diff --git a/src/monitor.rs b/src/monitor.rs index 584bfdc..0eb9577 100755 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -290,12 +290,19 @@ impl Monitor { // 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. - let pids_with_modal: HashSet = self - .current_windows - .iter() - .filter(|e| e.info.disables_owner) - .map(|e| e.pid) - .collect(); + // 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(); @@ -556,6 +563,7 @@ mod tests { is_tool_window: false, is_owned: false, disables_owner: false, + owner_pid: None, own_pid: false, is_cloaked: false, is_on_current_desktop: true, @@ -839,6 +847,7 @@ mod tests { is_tool_window: false, is_owned: false, disables_owner: false, + owner_pid: None, own_pid: false, is_cloaked: false, is_on_current_desktop: true, @@ -1118,6 +1127,44 @@ mod tests { 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, diff --git a/src/winapi.rs b/src/winapi.rs index f4aef7d..f44bf05 100755 --- a/src/winapi.rs +++ b/src/winapi.rs @@ -309,6 +309,22 @@ mod win32_impl { 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 + }; // SAFETY block 6: DWM cloaked query. Documented to be safe on any HWND. let is_cloaked = is_window_cloaked(hwnd); @@ -320,6 +336,7 @@ mod win32_impl { 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,