use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; use std::process::Command; #[derive(Debug, Serialize, Clone)] pub struct WindowInfo { pub exe_name: String, pub exe_path: String, pub title: String, pub display_name: String, pub icon: Option, } /// Get system idle time in seconds via D-Bus. /// /// Tries (in order): /// 1. org.gnome.Mutter.IdleMonitor.GetIdletime (returns milliseconds) /// 2. org.freedesktop.ScreenSaver.GetSessionIdleTime (returns seconds) /// 3. Falls back to 0 pub fn get_system_idle_seconds() -> u64 { // Try GNOME Mutter IdleMonitor (returns milliseconds) if let Some(ms) = gdbus_call_u64( "org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core", "org.gnome.Mutter.IdleMonitor", "GetIdletime", ) { return ms / 1000; } // Try freedesktop ScreenSaver (returns seconds) if let Some(secs) = gdbus_call_u64( "org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver", "GetSessionIdleTime", ) { return secs; } 0 } /// Call a D-Bus method that returns a single uint64/uint32 value via gdbus. /// Parses the `(uint64 12345,)` or `(uint32 12345,)` output format. fn gdbus_call_u64(dest: &str, object: &str, interface: &str, method: &str) -> Option { let output = Command::new("gdbus") .args([ "call", "--session", "--dest", dest, "--object-path", object, "--method", &format!("{}.{}", interface, method), ]) .output() .ok()?; if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); // Format: "(uint64 12345,)" or "(uint32 12345,)" // Extract the number after "uint64 " or "uint32 " let s = stdout.trim(); for prefix in &["uint64 ", "uint32 "] { if let Some(pos) = s.find(prefix) { let after = &s[pos + prefix.len()..]; let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); return num_str.parse().ok(); } } None } /// Get the current user's UID from /proc/self/status. fn get_current_uid() -> u32 { if let Ok(status) = fs::read_to_string("/proc/self/status") { for line in status.lines() { if let Some(rest) = line.strip_prefix("Uid:") { if let Some(uid_str) = rest.split_whitespace().next() { if let Ok(uid) = uid_str.parse::() { return uid; } } } } } u32::MAX // Unlikely fallback - won't match any process } /// Enumerate running processes from /proc. /// /// Reads /proc/[pid]/exe, /proc/[pid]/comm, and /proc/[pid]/status to build /// a list of user-space processes. Filters out kernel threads, zombies, and /// common system daemons. Deduplicates by exe path. pub fn enumerate_running_processes() -> Vec { let my_uid = get_current_uid(); let mut seen_paths = HashSet::new(); let mut results = Vec::new(); let Ok(entries) = fs::read_dir("/proc") else { return results; }; for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); // Only numeric directories (PIDs) if !name_str.chars().all(|c| c.is_ascii_digit()) { continue; } let pid_path = entry.path(); // Check process belongs to current user if let Ok(status) = fs::read_to_string(pid_path.join("status")) { let mut is_our_uid = false; let mut ppid: u32 = 1; for line in status.lines() { if let Some(rest) = line.strip_prefix("Uid:") { if let Some(uid_str) = rest.split_whitespace().next() { if let Ok(uid) = uid_str.parse::() { is_our_uid = uid == my_uid; } } } if let Some(rest) = line.strip_prefix("PPid:") { if let Ok(p) = rest.trim().parse::() { ppid = p; } } } if !is_our_uid { continue; } // Skip kernel threads (ppid 0 or 2) if ppid == 0 || ppid == 2 { continue; } } else { continue; } // Skip zombies (empty cmdline) if let Ok(cmdline) = fs::read(pid_path.join("cmdline")) { if cmdline.is_empty() { continue; } } // Get exe path via symlink let exe_path = match fs::read_link(pid_path.join("exe")) { Ok(p) => { let ps = p.to_string_lossy().to_string(); // Skip deleted executables if ps.contains(" (deleted)") { continue; } ps } Err(_) => continue, }; // Deduplicate by exe path if !seen_paths.insert(exe_path.clone()) { continue; } // Get comm (short process name) let comm = fs::read_to_string(pid_path.join("comm")) .unwrap_or_default() .trim() .to_string(); // Skip common system daemons / background services if is_system_daemon(&comm, &exe_path) { continue; } let exe_name = std::path::Path::new(&exe_path) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| comm.clone()); let display_name = prettify_name(&exe_name); results.push(WindowInfo { exe_name, exe_path, title: String::new(), display_name, icon: None, }); } // Resolve icons from .desktop files let icon_map = build_desktop_icon_map(); for info in &mut results { if let Some(icon_name) = icon_map.get(&info.exe_name) { if let Some(data_url) = resolve_icon_to_data_url(icon_name) { info.icon = Some(data_url); } } } results.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase())); results } /// On Linux we cannot detect window visibility (Wayland security model). /// Return running processes filtered to likely-GUI apps as a best-effort /// approximation - the timer will pause only when the tracked process exits. pub fn enumerate_visible_windows() -> Vec { enumerate_running_processes() .into_iter() .filter(|w| is_likely_gui_app(&w.exe_path, &w.exe_name)) .collect() } /// Heuristic: returns true for processes that are likely background daemons. fn is_system_daemon(comm: &str, exe_path: &str) -> bool { const DAEMON_NAMES: &[&str] = &[ "systemd", "dbus-daemon", "dbus-broker", "pipewire", "pipewire-pulse", "wireplumber", "pulseaudio", "xdg-desktop-portal", "xdg-document-portal", "xdg-permission-store", "gvfsd", "gvfs-udisks2-volume-monitor", "at-spi-bus-launcher", "at-spi2-registryd", "gnome-keyring-daemon", "ssh-agent", "gpg-agent", "polkitd", "gsd-", "evolution-data-server", "evolution-calendar-factory", "evolution-addressbook-factory", "tracker-miner-fs", "tracker-extract", "ibus-daemon", "ibus-x11", "ibus-portal", "ibus-extension-gtk3", "fcitx5", "goa-daemon", "goa-identity-service", "xdg-desktop-portal-gnome", "xdg-desktop-portal-gtk", "xdg-desktop-portal-kde", "xdg-desktop-portal-wlr", ]; // Exact match if DAEMON_NAMES.contains(&comm) { return true; } // Prefix match (e.g. gsd-*) for prefix in &["gsd-", "gvfs", "xdg-desktop-portal"] { if comm.starts_with(prefix) { return true; } } // System paths if exe_path.starts_with("/usr/libexec/") || exe_path.starts_with("/usr/lib/systemd/") || exe_path.starts_with("/usr/lib/polkit") { return true; } false } /// Heuristic: returns true for processes that are likely GUI applications. fn is_likely_gui_app(exe_path: &str, exe_name: &str) -> bool { // Common GUI app locations let gui_paths = ["/usr/bin/", "/usr/local/bin/", "/opt/", "/snap/", "/flatpak/"]; let in_gui_path = gui_paths.iter().any(|p| exe_path.starts_with(p)) || exe_path.contains("/AppRun") || exe_path.contains(".AppImage"); // Home directory apps (Electron, AppImage, etc.) let in_home = exe_path.contains("/.local/") || exe_path.contains("/home/"); if !in_gui_path && !in_home { return false; } // Exclude known CLI-only tools const CLI_TOOLS: &[&str] = &[ "bash", "sh", "zsh", "fish", "dash", "csh", "tcsh", "cat", "ls", "grep", "find", "sed", "awk", "sort", "cut", "curl", "wget", "ssh", "scp", "rsync", "git", "make", "cmake", "cargo", "npm", "node", "python", "python3", "ruby", "perl", "java", "javac", "top", "htop", "btop", "tmux", "screen", "sudo", "su", "pkexec", "journalctl", "systemctl", ]; if CLI_TOOLS.contains(&exe_name) { return false; } true } /// Build a map from exe_name → icon_name by scanning .desktop files. fn build_desktop_icon_map() -> HashMap { let mut map = HashMap::new(); let dirs = [ "/usr/share/applications", "/usr/local/share/applications", "/var/lib/flatpak/exports/share/applications", "/var/lib/snapd/desktop/applications", ]; // Also check ~/.local/share/applications let home_apps = std::env::var("HOME") .map(|h| format!("{h}/.local/share/applications")) .unwrap_or_default(); for dir in dirs.iter().chain(std::iter::once(&home_apps.as_str())) { if dir.is_empty() { continue; } let Ok(entries) = fs::read_dir(dir) else { continue }; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("desktop") { continue; } if let Ok(content) = fs::read_to_string(&path) { parse_desktop_entry(&content, &mut map); } } } map } /// Parse a .desktop file and extract exe_name → icon_name mappings. fn parse_desktop_entry(content: &str, map: &mut HashMap) { let mut icon = None; let mut exec = None; let mut in_desktop_entry = false; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') { in_desktop_entry = trimmed == "[Desktop Entry]"; continue; } if !in_desktop_entry { continue; } if let Some(val) = trimmed.strip_prefix("Icon=") { icon = Some(val.trim().to_string()); } else if let Some(val) = trimmed.strip_prefix("Exec=") { // Extract the binary name from the Exec line // Exec can be: /usr/bin/foo, env VAR=val foo, flatpak run ..., etc. let val = val.trim(); // Strip env prefix let cmd_part = if val.starts_with("env ") { // Skip env KEY=VAL pairs val.split_whitespace() .skip(1) .find(|s| !s.contains('=')) .unwrap_or("") } else { val.split_whitespace().next().unwrap_or("") }; // Get just the filename if let Some(name) = Path::new(cmd_part).file_name() { exec = Some(name.to_string_lossy().to_string()); } } } if let (Some(exe_name), Some(icon_name)) = (exec, icon) { if !exe_name.is_empty() && !icon_name.is_empty() { map.entry(exe_name).or_insert(icon_name); } } } /// Resolve an icon name to a base64 data URL. /// Handles both absolute paths and theme icon names. fn resolve_icon_to_data_url(icon_name: &str) -> Option { // If it's an absolute path, read directly if icon_name.starts_with('/') { return read_icon_file(Path::new(icon_name)); } // Search hicolor theme at standard sizes (prefer smaller for 20x20 display) let sizes = ["32x32", "48x48", "24x24", "64x64", "scalable", "128x128", "256x256"]; let theme_dirs = [ "/usr/share/icons/hicolor", "/usr/share/pixmaps", ]; // Also check user theme let home_icons = std::env::var("HOME") .map(|h| format!("{h}/.local/share/icons/hicolor")) .unwrap_or_default(); // Try hicolor theme with size variants for base in theme_dirs.iter().map(|s| s.to_string()).chain( if home_icons.is_empty() { None } else { Some(home_icons.clone()) } ) { if base.ends_with("pixmaps") { // /usr/share/pixmaps has flat layout for ext in &["png", "svg", "xpm"] { let path = format!("{base}/{icon_name}.{ext}"); if let Some(url) = read_icon_file(Path::new(&path)) { return Some(url); } } } else { for size in &sizes { for ext in &["png", "svg"] { let path = format!("{base}/{size}/apps/{icon_name}.{ext}"); if let Some(url) = read_icon_file(Path::new(&path)) { return Some(url); } } } } } None } /// Read an icon file and return it as a base64 data URL. /// Supports PNG and SVG. fn read_icon_file(path: &Path) -> Option { if !path.exists() { return None; } let ext = path.extension()?.to_str()?; let data = fs::read(path).ok()?; if data.is_empty() { return None; } let mime = match ext { "png" => "image/png", "svg" => "image/svg+xml", "xpm" => return None, // XPM isn't useful as a data URL _ => return None, }; let mut b64 = String::new(); base64_encode(&data, &mut b64); Some(format!("data:{mime};base64,{b64}")) } /// Base64 encode bytes into a string (no padding variants, standard alphabet). fn base64_encode(input: &[u8], output: &mut String) { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut i = 0; let len = input.len(); output.reserve((len + 2) / 3 * 4); while i + 2 < len { let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8 | input[i + 2] as u32; output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char); output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char); output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char); output.push(ALPHABET[(n & 0x3F) as usize] as char); i += 3; } match len - i { 1 => { let n = (input[i] as u32) << 16; output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char); output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char); output.push('='); output.push('='); } 2 => { let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8; output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char); output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char); output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char); output.push('='); } _ => {} } } /// Convert an exe name like "gnome-terminal-server" into "Gnome Terminal Server". fn prettify_name(name: &str) -> String { // Strip common suffixes let stripped = name .strip_suffix("-bin") .or_else(|| name.strip_suffix(".bin")) .unwrap_or(name); stripped .split(|c: char| c == '-' || c == '_') .filter(|s| !s.is_empty()) .map(|word| { let mut chars = word.chars(); match chars.next() { Some(first) => { let mut s = first.to_uppercase().to_string(); s.extend(chars); s } None => String::new(), } }) .collect::>() .join(" ") }