diff --git a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs index d56c437..43f9a21 100644 --- a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs @@ -1,34 +1,6 @@ //! Functions for dynamic analysis, contains: //! - environment analysis //! - wayland app id lookup -use std::{ - env, fs, path::{Path, PathBuf}, time::{Duration, Instant} -}; - -use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::UnixStream -}; - -#[derive(serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum Desktop { - Niri, - Gnome, - Plasma, - Cosmic, -} - -impl Desktop { - fn from_str(s: &str) -> Option { - match s.to_lowercase().as_str() { - "niri" => Some(Desktop::Niri), - "gnome" => Some(Desktop::Gnome), - "plasma" => Some(Desktop::Plasma), - "cosmic" => Some(Desktop::Cosmic), - _ => None, - } - } -} pub fn get_steam_app_id(environ: &[u8]) -> Option { let prefix = b"SteamAppId="; @@ -58,94 +30,6 @@ pub fn check_env(env_var: &str, environ: &[u8]) -> Option { None } -/// How long a reported pid keeps getting retried before falling back to the -/// process name -pub const APP_ID_LOOKUP_TIMEOUT: Duration = Duration::from_millis(2000); - -/// pid to wayland app id, needs to be async to wait -pub async fn get_app_id_wayland(pid: u32) -> Option { - let desktop_str: String = match env::var("XDG_CURRENT_DESKTOP") { - Ok(value) => value, - Err(_) => return None, - }; - let desktop: Desktop = Desktop::from_str(&desktop_str)?; - - #[allow(clippy::single_match)] - match desktop { - // We use the niri ipc to get the window real name - Desktop::Niri => { - if let Some(socket_path) = find_niri_socket() { - return query_niri_window(&socket_path, pid).await; - } - } - _ => {} - } - - None -} - -/// Retry `get_app_id_wayland` until the lookup timeout expires, the window -/// of a freshly launched process can take a moment to be mapped by the -/// compositor. Breaks early if the process exits. -pub async fn get_app_id_wayland_with_retry(pid: u32) -> Option { - let deadline = Instant::now() + APP_ID_LOOKUP_TIMEOUT; - let delay = Duration::from_millis(50); - loop { - // The process is gone, we will never find a window for it - if !Path::new(&format!("/proc/{}", pid)).exists() { - return None; - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return None; - } - if let Ok(Some(app_id)) = tokio::time::timeout(remaining, get_app_id_wayland(pid)).await { - return Some(app_id); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return None; - } - tokio::time::sleep(delay.min(remaining)).await; - } -} - -/// Query niri IPC for a window's app_id by pid -/// Returns None on any error -async fn query_niri_window(socket_path: &Path, pid: u32) -> Option { - let mut socket = UnixStream::connect(socket_path).await.ok()?; - socket.write_all(b"{\"Windows\":null}\n").await.ok()?; - socket.flush().await.ok()?; - - let mut reader = BufReader::new(socket); - let mut reply = String::new(); - reader.read_line(&mut reply).await.ok()?; - - let json: serde_json::Value = serde_json::from_str(&reply).ok()?; - json["Ok"]["Windows"] - .as_array()? - .iter() - .find(|w| w["pid"].as_u64() == Some(pid as u64)) - .and_then(|w| w["app_id"].as_str()) - .map(|s| s.to_string()) -} - -fn find_niri_socket() -> Option { - let run_path = Path::new("/run/user"); - for user in run_path.read_dir().ok()? { - if let Ok(user) = user - && let Ok(dir_content) = fs::read_dir(user.path()) - { - for entry in dir_content.flatten() { - if entry.file_name().to_string_lossy().contains("niri.wayland") { - return Some(entry.path()); - } - } - } - } - None -} - #[cfg(test)] mod tests { use super::*; @@ -188,20 +72,4 @@ mod tests { let environ = b"CARDWIRE_ALLOW=x"; assert_eq!(check_env("CARDWIRE_ALLOW", environ), None); } - - #[test] - fn test_desktop_from_str_all_known_variants() { - assert!(matches!(Desktop::from_str("niri"), Some(Desktop::Niri))); - assert!(matches!(Desktop::from_str("gnome"), Some(Desktop::Gnome))); - assert!(matches!(Desktop::from_str("plasma"), Some(Desktop::Plasma))); - assert!(matches!(Desktop::from_str("cosmic"), Some(Desktop::Cosmic))); - } - - #[test] - fn test_desktop_from_str_unknown_returns_none() { - assert!(Desktop::from_str("sway").is_none()); - assert!(Desktop::from_str("hyprland").is_none()); - assert!(Desktop::from_str("i3").is_none()); - assert!(Desktop::from_str("").is_none()); - } } diff --git a/crates/cardwire-daemon/src/analyzer/helpers.rs b/crates/cardwire-daemon/src/analyzer/helpers.rs index a2df52a..48850a9 100644 --- a/crates/cardwire-daemon/src/analyzer/helpers.rs +++ b/crates/cardwire-daemon/src/analyzer/helpers.rs @@ -2,8 +2,7 @@ use std::{fs, path::Path}; -/// Read the real process name from `/proc/{pid}/cmdline`, taking into account -/// wrappers like Wine/Proton, Java, Flatpak and Steam +/// Read the real process name from `/proc/{pid}/cmdline` pub fn get_real_process_name(pid: u32) -> Option { let cmdline_path = format!("/proc/{}/cmdline", pid); let cmdline_bytes = match fs::read(&cmdline_path) { @@ -29,39 +28,27 @@ pub fn parse_cmdline_name(cmdline_bytes: &[u8]) -> Option { let binary = args[0]; // Check Wine/Proton - if binary.contains("wine") || binary.contains("proton") { - for arg in args.iter().skip(1) { - if arg.to_lowercase().ends_with(".exe") { - let file_name = arg.split(&['/', '\\'][..]).next_back().unwrap_or(arg); - return Some(file_name.to_string()); - } - } + if (binary.contains("wine") || binary.contains("proton")) + && let Some(name) = extract_wine_exe(&args) + { + return Some(name); } // Minecraft/Java games, return java instead of the real name to allow Close event bypass - if binary.ends_with(".java") { - for arg in args.iter().skip(1) { - if arg.ends_with(".jar") { - let file_name = arg.split('/').next_back().unwrap_or(arg); - return Some(file_name.to_string()); - } - } + if binary.ends_with(".java") + && let Some(name) = extract_java_bin(&args) + { + return Some(name); } // Fallback, just use the binary name let base_name = binary.split('/').next_back().unwrap_or(binary); // Flatpak/Brwap - if base_name == "flatpak" || base_name == ".flatpak-wrapped" || base_name == "bwrap" { - for arg in args.iter().skip(1) { - if let Some(exec) = arg.strip_prefix("--command=") { - return Some(exec.to_string()); - } - // Extract the flatpak ID - if !arg.starts_with('-') && *arg != "run" && arg.contains('.') { - return Some(arg.to_string()); - } - } + if (base_name == "flatpak" || base_name == ".flatpak-wrapped" || base_name == "bwrap") + && let Some(name) = extract_flatpak_id(&args) + { + return Some(name); } if base_name == "steam" { @@ -73,23 +60,10 @@ pub fn parse_cmdline_name(cmdline_bytes: &[u8]) -> Option { } // Electron apps, the real app name is in the .asar path argument - if base_name == "electron" || base_name.ends_with("-electron") { - for arg in args.iter().skip(1) { - if arg.starts_with('-') { - continue; - } - if arg.ends_with(".asar") || arg.contains("resources/app") { - let path = Path::new(arg); - for component in path.components().rev() { - let part = component.as_os_str().to_string_lossy(); - if part == "app.asar" || part == "resources" || part == "app" || part == "share" - { - continue; - } - return Some(part.to_string()); - } - } - } + if (base_name == "electron" || base_name.ends_with("-electron")) + && let Some(name) = extract_electron_name(&args) + { + return Some(name); } // Fix for discord or other apps: @@ -100,27 +74,84 @@ pub fn parse_cmdline_name(cmdline_bytes: &[u8]) -> Option { Some(base_name.to_string()) } +#[inline(always)] +fn extract_wine_exe(args: &Vec<&str>) -> Option { + for arg in args.iter().skip(1) { + if arg.to_lowercase().contains(".exe") + && let Some(file_name) = arg.split(&['/', '\\'][..]).next_back() + { + return Some(file_name.to_string()); + } + } + None +} + +#[inline(always)] +fn extract_java_bin(args: &Vec<&str>) -> Option { + for arg in args.iter().skip(1) { + if arg.ends_with(".jar") + && let Some(file_name) = arg.split('/').next_back() + { + return Some(file_name.to_string()); + } + } + None +} + +#[inline(always)] +fn extract_flatpak_id(args: &Vec<&str>) -> Option { + for arg in args.iter().skip(1) { + if let Some(exec) = arg.strip_prefix("--command=") { + return Some(exec.to_string()); + } + if !arg.starts_with('-') && *arg != "run" && arg.contains('.') { + return Some(arg.to_string()); + } + } + None +} + +#[inline(always)] +fn extract_electron_name(args: &Vec<&str>) -> Option { + for arg in args.iter().skip(1) { + if arg.starts_with('-') { + continue; + } + if arg.ends_with(".asar") || arg.contains("resources/app") { + let path = Path::new(arg); + for component in path.components().rev() { + let part = component.as_os_str().to_string_lossy(); + if part == "app.asar" || part == "resources" || part == "app" || part == "share" { + continue; + } + return Some(part.to_string()); + } + } + } + None +} + +#[allow(dead_code)] pub fn is_proc_still_alive(pid: u32) -> bool { Path::new(&format!("/proc/{}", pid)).exists() } -/// Unwrap NixOS-style wrapper names into lookups, eg: -/// ".discord-wrapped" -> ["discord-wrapped", "discord"] -/// "steamwebhelper" -> ["steamwebhelper"] -pub fn normalized_candidates(name: &str) -> Vec { +/// Strip the wrap from a nix wrapped binary +pub fn strip_nix_wrap(name: &str) -> String { + // eg: ".discord-wrapped" let trimmed = name.trim_start_matches('.'); - let mut candidates = vec![trimmed.to_string()]; - if let Some(rest) = trimmed.strip_suffix("-wrapped") { - candidates.push(rest.to_string()); - } - candidates + trimmed + .strip_suffix("-wrapped") + .unwrap_or(trimmed) + .to_string() } -/// Decode the 16-byte kernel comm into a String, trimming trailing NULs -pub fn comm_to_string(comm: [u8; 16]) -> String { +/// Decode the 16-byte kernel comm into a String +#[allow(dead_code)] +pub fn comm_to_string(comm: [u8; 16]) -> Option { match String::from_utf8(comm.to_vec()) { - Ok(str) => str.trim_end_matches('\0').to_string(), - Err(_) => "no_comm_err".to_string(), + Ok(str) => Some(str.trim_end_matches('\0').to_string()), + Err(_) => None, } } @@ -179,19 +210,19 @@ mod tests { #[test] fn test_comm_to_string_trims_trailing_nuls() { let comm = *b"bash\0\0\0\0\0\0\0\0\0\0\0\0"; - assert_eq!(comm_to_string(comm), "bash"); + assert!(comm_to_string(comm).is_some_and(|s| s == "bash")); } #[test] fn test_comm_to_string_full_length() { let comm = *b"a-very-long-comm"; - assert_eq!(comm_to_string(comm), "a-very-long-comm"); + assert!(comm_to_string(comm).is_some_and(|s| s == "a-very-long-comm")); } #[test] fn test_comm_to_string_invalid_utf8() { let comm = [0xFFu8; 16]; - assert_eq!(comm_to_string(comm), "no_comm_err"); + assert_eq!(comm_to_string(comm), None); } #[test] @@ -222,18 +253,12 @@ mod tests { #[test] fn test_normalized_candidates_unwraps_nix_wrapper() { - assert_eq!( - normalized_candidates(".discord-wrapped"), - vec!["discord-wrapped".to_string(), "discord".to_string()] - ); + assert_eq!(strip_nix_wrap(".discord-wrapped"), "discord"); } #[test] fn test_normalized_candidates_plain_name_unchanged() { - assert_eq!( - normalized_candidates("steamwebhelper"), - vec!["steamwebhelper".to_string()] - ); - assert_eq!(normalized_candidates("steam"), vec!["steam".to_string()]); + assert_eq!(strip_nix_wrap("steamwebhelper"), "steamwebhelper"); + assert_eq!(strip_nix_wrap("steam"), "steam"); } } diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index 39d5d1a..3222f20 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -1,8 +1,6 @@ use crate::{ Result, analyzer::{ - dynamic_analysis::{check_env, get_app_id_wayland_with_retry, get_steam_app_id}, helpers::{ - comm_to_string, get_real_process_name, is_proc_still_alive, normalized_candidates - }, static_analysis::{self, AppMetadata, watch_fdo_folders} + dynamic_analysis::{check_env, get_steam_app_id}, helpers::{get_real_process_name, strip_nix_wrap}, static_analysis::{self, AppMetadata, watch_fdo_folders} }, file::{DbusAppMetadata, GpuPolicy}, interface::{LogEntry, LoggerInterfaceSignals, SmartPolicyInterface} }; use aya::maps::{HashMap as AyaHashMap, RingBuf}; @@ -13,7 +11,7 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, fs, ptr, sync::{Arc, OnceLock}, time::SystemTime }; use tokio::{ - io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock, Semaphore, mpsc, oneshot}, task, time::Instant + io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock, mpsc, oneshot}, task, time::Instant }; use zbus::object_server::SignalEmitter; #[repr(C)] @@ -26,6 +24,7 @@ pub struct ExecEvent { #[repr(C)] #[derive(Debug, Copy, Clone)] +#[allow(dead_code)] pub struct ReportEvent { pub pid: u32, pub gpu_id: u32, @@ -41,6 +40,7 @@ enum PidType { #[derive(Clone)] pub struct CardwireAnalyzer { exec_ring: Arc>>>, + #[allow(dead_code)] report_ring: Arc>>>, pid_map: Arc>>, forced_map: Arc>>, @@ -50,16 +50,16 @@ pub struct CardwireAnalyzer { db_cache: Arc>>, pending_discoveries: Arc>>, db_tx: mpsc::Sender<(String, AppMetadata, oneshot::Sender)>, + #[allow(dead_code)] report_vec: Arc>>, + #[allow(dead_code)] reported_pids: Arc>>, - report_semaphore: Arc, + #[allow(dead_code)] signal: Arc>>, new_app_signal: Arc>>, } - -// Bound the number of concurrent report tasks -const REPORT_SEMAPHORE_PERMITS: usize = 32; // Max entries kept in the report history +#[allow(dead_code)] const MAX_REPORT_ENTRIES: usize = 4096; impl CardwireAnalyzer { @@ -106,7 +106,6 @@ impl CardwireAnalyzer { db_tx, report_vec, reported_pids: Arc::new(RwLock::new(HashSet::new())), - report_semaphore: Arc::new(Semaphore::new(REPORT_SEMAPHORE_PERMITS)), signal, new_app_signal, }) @@ -144,10 +143,6 @@ impl CardwireAnalyzer { } }); - // spawn the blocked event report in it's own thread - let shared_self_report = Arc::clone(&shared_self); - task::spawn(async move { shared_self_report.report_logger().await }); - loop { if let Ok(mut guard) = exec_ring.ready_mut(Interest::READABLE).await && guard.ready().is_readable() @@ -206,87 +201,6 @@ impl CardwireAnalyzer { } } - async fn report_logger(&self) -> () { - let report_arc = self.report_ring.clone(); - let mut report_ring = report_arc.lock().await; - let report_vec = self.report_vec.clone(); - - // Used to prevent duplicated logs burst - let reported_pids_arc = self.reported_pids.clone(); - let report_semaphore = self.report_semaphore.clone(); - loop { - let mut guard = match report_ring.ready_mut(Interest::READABLE).await { - Ok(guard) => guard, - Err(err) => { - error!("failed to get report logger guard: {}", err); - return; - } - }; - while let Some(item) = guard.get_inner_mut().next() { - if item.len() < std::mem::size_of::() { - warn!("Skipping malformed report event. Size: {}", item.len()); - continue; - } - let event = unsafe { ptr::read_unaligned(item.as_ptr() as *const ReportEvent) }; - // only log if we didn't see the pid recently - { - let mut reported_pids = reported_pids_arc.write().await; - if reported_pids.contains(&event.pid) { - continue; - } else { - reported_pids.insert(event.pid); - } - } - let event_comm_str = comm_to_string(event.comm); - // Bound the number of concurrent report tasks, this prevent exausting the process - // FD limits - if let Ok(permit) = report_semaphore.clone().acquire_owned().await { - // Spawn in another task to prevent blocking the report logger while - // fetching informations about this process - let report_vec = report_vec.clone(); - let signal = self.signal.clone(); - let gpu_id = event.gpu_id; - task::spawn(async move { - let _permit = permit; - if let Some(app_id) = get_app_id_wayland_with_retry(event.pid).await { - report_blocked( - report_vec, - signal, - event.pid, - gpu_id, - event_comm_str, - app_id, - ) - .await; - } else if let Some(process_name) = get_real_process_name(event.pid) { - report_blocked( - report_vec, - signal, - event.pid, - gpu_id, - process_name, - String::new(), - ) - .await; - } else if is_proc_still_alive(event.pid) { - // we check if the proc is still here to not log noise caused by fish - report_blocked( - report_vec, - signal, - event.pid, - gpu_id, - event_comm_str, - String::new(), - ) - .await; - } - }); - } - } - guard.clear_ready(); - } - } - /// Default app are blocked, try to find if it's a game or a gpu intensive app, the u8 is the /// gpu id async fn evaluate_app(&self, pid: u32, comm: &str, mode: u8) -> Option<(bool, PidType, u32)> { @@ -334,14 +248,16 @@ impl CardwireAnalyzer { { let xdg_list = self.xdg_list.read().await; - for candidate in normalized_candidates(&lookup_name) { - if let Some(meta) = xdg_list.get(&candidate) { - let meta = meta.clone(); - drop(xdg_list); - self.discover_app(&lookup_name, meta).await; - return Some((false, PidType::Allowed, 0)); - } + // Strip ".wrapped", normalizing nixos wrapped binaries + let lookup_name = strip_nix_wrap(&lookup_name); + + if let Some(meta) = xdg_list.get(&lookup_name) { + let meta = meta.clone(); + drop(xdg_list); + self.discover_app(&lookup_name, meta).await; + return Some((false, PidType::Allowed, 0)); } + if let Some((_key, meta)) = xdg_list .iter() .find(|(key, _)| key.len() >= 3 && lookup_name.starts_with(key.as_str())) @@ -424,6 +340,7 @@ impl CardwireAnalyzer { } /// Record a blocked process in the report history and notify listeners +#[allow(dead_code)] async fn report_blocked( report_vec: Arc>>, signal: Arc>>, @@ -501,8 +418,6 @@ mod tests { assert_eq!(event.mode, 2); } - // ── ReportEvent ────────────────────────────────────────────────── - #[test] fn test_report_event_deserialization_from_valid_bytes() { // ReportEvent: pid (4 bytes) + gpu_id (4 bytes) + comm (16 bytes) @@ -516,7 +431,7 @@ mod tests { assert_eq!(event.pid, 1337); assert_eq!(event.gpu_id, 1); assert_eq!(&event.comm, b"test_comm\0\0\0\0\0\0\0"); - assert_eq!(comm_to_string(event.comm), "test_comm"); + assert!(comm_to_string(event.comm).is_some_and(|s| s == "test_comm")); } #[test]