Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,28 @@ pub struct Config {
pub app_rule: Vec<AppRule>,
}

/// 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 {
Expand All @@ -161,6 +178,7 @@ impl Config {
return Some(ResolvedRule {
timeout_mins: rule.timeout_mins,
action: rule.action.clone(),
source: RuleSource::AppRule,
});
} else {
return None;
Expand All @@ -178,6 +196,7 @@ impl Config {
return Some(ResolvedRule {
timeout_mins: bucket.timeout_mins,
action: bucket.action.clone(),
source: RuleSource::Bucket,
});
}
}
Expand Down Expand Up @@ -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(())
}
Expand Down
55 changes: 52 additions & 3 deletions src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
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).
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
17 changes: 11 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading