From 693d31d134ca95f076a00eaa4f03f73939f80988 Mon Sep 17 00:00:00 2001 From: luytan Date: Sat, 8 Aug 2026 12:59:00 +0200 Subject: [PATCH] feat(cardwired): refresh the internal xdg apps list when an app is installed --- Cargo.lock | 23 ++ Cargo.toml | 1 + crates/cardwire-daemon/Cargo.toml | 1 + crates/cardwire-daemon/src/analyzer/models.rs | 17 +- .../src/analyzer/static_analysis.rs | 207 +++++++++++++----- 5 files changed, 187 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87b00f77..46340275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,7 @@ dependencies = [ "cardwire-ebpf-userspace", "env_logger", "freedesktop-desktop-entry", + "inotify", "khronos-egl", "log", "rusqlite", @@ -1949,6 +1950,28 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.1", + "futures-util", + "inotify-sys", + "libc", + "tokio", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "io-lifetimes" version = "1.0.11" diff --git a/Cargo.toml b/Cargo.toml index f5afdb61..eb924779 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ iced_aw = "0.14.1" strum = { version = "0.28", features = ["derive"] } ksni = "0.3.6" chrono = "0.4.45" +inotify = "0.11.4" [profile.dev] overflow-checks = true diff --git a/crates/cardwire-daemon/Cargo.toml b/crates/cardwire-daemon/Cargo.toml index a384ead4..9aca703a 100644 --- a/crates/cardwire-daemon/Cargo.toml +++ b/crates/cardwire-daemon/Cargo.toml @@ -28,6 +28,7 @@ aya-log.workspace = true vulkano.workspace = true khronos-egl.workspace = true rusqlite.workspace = true +inotify.workspace = true [[bin]] name = "cardwired" diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index 88101f5f..35d60549 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -12,7 +12,7 @@ use zbus::object_server::SignalEmitter; use crate::{ 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}, static_analysis::{self, AppMetadata} + 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}, static_analysis::{self, AppMetadata, watch_fdo_folders} }, file::GpuPolicy, interface::{LogEntry, LoggerInterfaceSignals} }; #[repr(C)] @@ -43,6 +43,7 @@ pub struct CardwireAnalyzer { forced_map: Arc>>, ebpf_logger: Arc>>>, xdg_list: Arc>>, + xdg_folders: Vec, db_cache: Arc>>, pending_discoveries: Arc>>, db_tx: mpsc::Sender<(String, AppMetadata, oneshot::Sender)>, @@ -81,7 +82,11 @@ impl CardwireAnalyzer { let ebpf_logger: Arc>>> = Arc::new(Mutex::new(ebpf_logger)); - let xdg_list = Arc::new(RwLock::new(static_analysis::get_fdo_apps().await?)); + let xdg_res = static_analysis::get_fdo_apps().await?; + + let xdg_list = Arc::new(RwLock::new(xdg_res.0)); + + let xdg_folders: Vec = xdg_res.1; Ok(CardwireAnalyzer { exec_ring, @@ -90,6 +95,7 @@ impl CardwireAnalyzer { forced_map, ebpf_logger, xdg_list, + xdg_folders, db_cache, pending_discoveries: Arc::new(Mutex::new(HashSet::new())), db_tx, @@ -109,6 +115,13 @@ impl CardwireAnalyzer { let shared_self = Arc::new(self); + // Spawn a thread that will watch the xdg folders and update the list when a new app is + // installed + let cloned_xdg_list = shared_self.xdg_list.clone(); + let cloned_xdg_folders = shared_self.xdg_folders.clone(); + + task::spawn(async move { watch_fdo_folders(cloned_xdg_folders, cloned_xdg_list).await }); + // spawn the ebpf-logger in it's own thread task::spawn(async move { let mut ebpf_logger = logger_arc.lock().await; diff --git a/crates/cardwire-daemon/src/analyzer/static_analysis.rs b/crates/cardwire-daemon/src/analyzer/static_analysis.rs index a29246ef..3c6577ea 100644 --- a/crates/cardwire-daemon/src/analyzer/static_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/static_analysis.rs @@ -1,9 +1,12 @@ //! Functions for static analysis, contains: //! - FDO desktop entries analysis use freedesktop_desktop_entry::{DesktopEntry, get_languages_from_env}; +use inotify::{EventMask, Inotify, StreamExt, WatchDescriptor, WatchMask}; +use log::error; use std::{ - collections::HashMap, fs, path::{Path, PathBuf} + collections::HashMap, fs, path::{Path, PathBuf}, sync::Arc }; +use tokio::sync::RwLock; use xdg::BaseDirectories; #[derive(Clone, Debug)] @@ -14,7 +17,7 @@ pub struct AppMetadata { } /// Return a list of fdo apps present in the system -pub async fn get_fdo_apps() -> anyhow::Result> { +pub async fn get_fdo_apps() -> anyhow::Result<(HashMap, Vec)> { let mut app_directories: Vec = Vec::new(); // get from ENV let xdg_dir = BaseDirectories::new(); @@ -78,66 +81,150 @@ pub async fn get_fdo_apps() -> anyhow::Result> { && let Ok(app_fdo) = DesktopEntry::from_path(&path, Some(&locales)) && let Some(name) = app_fdo.name(&locales) { - // Push both lowercase and normal name to the hashmap - // the RPCS3 .desktop contain the name `RPCS3` but the comm is `rpcs3`, so - // we need to lowercase it On the other, Ryujinx - // .desktop's name is `Ryujinx` and the comm is `Ryujinx`, so we also push - // the default name - let display_name = name.to_string(); - - let icon_name = app_fdo.icon().map(|icon| icon.to_string()); - - let desktop_file_id = path - .file_name() - .map(|s| s.to_string_lossy().trim_end_matches(".desktop").to_string()); - - let meta = AppMetadata { - display_name, - desktop_file_id, - icon_name, - }; - - // Push both lowercase and normal name as fallbacks - app_list.insert(name.to_ascii_lowercase(), meta.clone()); - app_list.insert(meta.display_name.clone(), meta.clone()); - - // Also insert the flatpak ID, lowercased since lookups are lowercased - if let Some(flatpak_id) = app_fdo.flatpak() { - app_list.insert(flatpak_id.to_ascii_lowercase(), meta.clone()); - } - if let Some(exec_str) = app_fdo.exec() { - let exec_parts: Vec<&str> = exec_str.split_whitespace().collect(); - - // Scan all parts for a steam URI before applying the wrapper-binary - // stop condition, so `Exec=steam steam://rungameid/` still maps - // to the steam app metadata - if let Some(uri_part) = exec_parts - .iter() - .find(|part| part.starts_with("steam://rungameid/")) - { - let app_id = uri_part - .trim_start_matches("steam://rungameid/") - .trim_matches('/'); - app_list.insert(format!("steam_app_{}", app_id), meta.clone()); - } else { - for part in exec_parts { - if part == "env" || part.contains('=') { - continue; - } - let binary = part.split('/').next_back().unwrap_or(part); - if ["flatpak", "steam", "sh", "bash", "bwrap"].contains(&binary) { - break; - } - if !binary.is_empty() { - app_list.insert(binary.to_lowercase(), meta.clone()); - } - break; - } - } - } + let new_app_map = parse_fdo_app(&app_fdo, &name, &path); + app_list.extend(new_app_map); } } } } - Ok(app_list) + Ok((app_list, app_directories)) +} + +/// Create a inotify for each folders, watch for changes and update the xdg-list +pub async fn watch_fdo_folders( + xdg_folders: Vec, + xdg_list: Arc>>, +) { + if xdg_folders.is_empty() { + error!("xdg_folder is empty, exiting notify task..."); + return; + } + + let inotify = match Inotify::init() { + Ok(v) => v, + Err(err) => { + error!("Couldn't init inotify: {}", err); + return; + } + }; + let mut watched_dirs: HashMap = HashMap::new(); + let watch_mask = + WatchMask::CREATE | WatchMask::MODIFY | WatchMask::MOVED_TO | WatchMask::CLOSE_WRITE; + + for folder in xdg_folders { + match inotify.watches().add(&folder, watch_mask) { + Ok(wd) => { + watched_dirs.insert(wd, folder); + } + + Err(err) => { + error!("Cannot watch {}: {err}", folder.display()); + } + } + } + + let mut buffer = [0; 4096]; + let mut stream = match inotify.into_event_stream(&mut buffer) { + Ok(s) => s, + Err(err) => { + error!("Couldn't convert inotify into a stream: {}", err); + return; + } + }; + let locales = get_languages_from_env(); + + loop { + while let Some(event_result) = stream.next().await { + let event = match event_result { + Ok(event) => event, + Err(err) => { + error!("Error reading inotify event: {err}"); + continue; + } + }; + + let Some(name) = event.name else { + if event.mask.contains(EventMask::Q_OVERFLOW) { + error!("inotify queue overflowed"); + } + + continue; + }; + + let Some(folder) = watched_dirs.get(&event.wd) else { + error!("Received event for unknown watch descriptor"); + continue; + }; + + // `name` is relative to the watched directory. + let path = folder.join(name); + + if let Some(ext) = path.extension() + && ext == "desktop" + && let Ok(app_fdo) = DesktopEntry::from_path(&path, Some(&locales)) + && let Some(name) = app_fdo.name(&locales) + { + let new_app_map = parse_fdo_app(&app_fdo, &name, &path); + let mut xdg_list = xdg_list.write().await; + xdg_list.extend(new_app_map); + } + } + } +} + +fn parse_fdo_app(app_fdo: &DesktopEntry, name: &str, path: &Path) -> HashMap { + let mut app_list: HashMap = HashMap::new(); + + let display_name = name.to_string(); + let icon_name = app_fdo.icon().map(|icon| icon.to_string()); + + let desktop_file_id = path + .file_name() + .map(|s| s.to_string_lossy().trim_end_matches(".desktop").to_string()); + + let meta = AppMetadata { + display_name, + desktop_file_id, + icon_name, + }; + + // Push both lowercase and normal name as fallbacks + app_list.insert(name.to_ascii_lowercase(), meta.clone()); + app_list.insert(meta.display_name.clone(), meta.clone()); + + if let Some(flatpak_id) = app_fdo.flatpak() { + app_list.insert(flatpak_id.to_ascii_lowercase(), meta.clone()); + } + if let Some(exec_str) = app_fdo.exec() { + let exec_parts: Vec<&str> = exec_str.split_whitespace().collect(); + + // Scan all parts for a steam URI before applying the wrapper-binary + // stop condition, so `Exec=steam steam://rungameid/` still maps + // to the steam app metadata + if let Some(uri_part) = exec_parts + .iter() + .find(|part| part.starts_with("steam://rungameid/")) + { + let app_id = uri_part + .trim_start_matches("steam://rungameid/") + .trim_matches('/'); + app_list.insert(format!("steam_app_{}", app_id), meta.clone()); + } else { + for part in exec_parts { + if part == "env" || part.contains('=') { + continue; + } + let binary = part.split('/').next_back().unwrap_or(part); + if ["flatpak", "steam", "sh", "bash", "bwrap"].contains(&binary) { + break; + } + if !binary.is_empty() { + app_list.insert(binary.to_lowercase(), meta.clone()); + } + break; + } + } + } + + app_list }