538 lines
16 KiB
Rust
538 lines
16 KiB
Rust
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<String>,
|
|
}
|
|
|
|
/// 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<u64> {
|
|
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::<u32>() {
|
|
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<WindowInfo> {
|
|
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::<u32>() {
|
|
is_our_uid = uid == my_uid;
|
|
}
|
|
}
|
|
}
|
|
if let Some(rest) = line.strip_prefix("PPid:") {
|
|
if let Ok(p) = rest.trim().parse::<u32>() {
|
|
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<WindowInfo> {
|
|
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<String, String> {
|
|
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<String, String>) {
|
|
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<String> {
|
|
// 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<String> {
|
|
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::<Vec<_>>()
|
|
.join(" ")
|
|
}
|