app_rummage/lib.rs
1use std::collections::HashMap;
2use std::num::NonZeroU32;
3use std::path::PathBuf;
4use std::rc::Rc;
5use std::sync::OnceLock;
6
7use nix::sys::inotify::{InitFlags, Inotify};
8
9use cache::{drain_inotify_events, ensure_watches, AppsCache, APPS_CACHE};
10use cgroup::running_xdg_conformant_apps;
11use desktop::installed_apps_impl;
12use matching::running_apps_by_process;
13
14mod cache;
15mod cgroup;
16mod desktop;
17mod env;
18mod exec;
19mod matching;
20
21fn app_id_replacement() -> &'static HashMap<&'static str, &'static str> {
22 static APP_ID_REPLACEMENT: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
23
24 APP_ID_REPLACEMENT
25 .get_or_init(|| HashMap::from([("gnome-system-monitor-kde", "org.gnome.SystemMonitor")]))
26}
27
28fn normalize_app_id(id: &str) -> &str {
29 if let Some(real_id) = app_id_replacement().get(id) {
30 real_id
31 } else {
32 id
33 }
34}
35
36/// A running process
37///
38/// Used to identify running applications. ApplicationEntry's exec field is compared against the
39/// process's executable path to determine if the process is an application.
40pub trait Process {
41 /// The process's PID
42 fn pid(&self) -> NonZeroU32;
43 /// The process's executable path
44 ///
45 /// It is expected that the path is canonical
46 fn executable_path(&self) -> Option<PathBuf>;
47 /// The process's name
48 ///
49 /// Essentially the name of the process as seen in `ps`
50 fn name(&self) -> &str;
51 /// The process's full argv as read from `/proc/<pid>/cmdline`
52 ///
53 /// Used to disambiguate processes that share an executable between multiple
54 /// applications (e.g. Chromium-based PWAs, custom launcher shortcuts for the
55 /// same binary with different args). Returning an empty slice disables argv
56 /// disambiguation for this process; matching degrades to "binary path only",
57 /// so processes in shared-binary buckets will be dropped rather than attributed
58 /// arbitrarily.
59 fn cmdline(&self) -> &[String] {
60 &[]
61 }
62}
63
64/// And data structure representing an application
65///
66/// It contains a subset of the fields from an XDG desktop entry file
67#[derive(Clone, Debug)]
68pub struct ApplicationEntry {
69 /// The application's id
70 ///
71 /// The filename of the desktop entry file without the `.desktop` extension and is used
72 /// to uniquely identify an application
73 pub id: Rc<str>,
74 /// The application's name
75 ///
76 /// The name that is displayed to the user in the application menu, taskbar, etc.
77 pub name: Rc<str>,
78 /// The application's executable
79 ///
80 /// The absolute path to the application's executable, obtained from the `Exec` field in
81 /// the desktop entry file
82 pub exec: Option<Rc<str>>,
83 /// The application's argv as declared in the desktop entry
84 ///
85 /// Tokens of the `Exec=` line starting from the primary binary (launcher prefixes such
86 /// as `flatpak`/`snap`/`env` are stripped).
87 ///
88 /// Used to tell apart applications that share an `exec` path. For example Chromium PWAs
89 /// (distinguished by `--app-id=`/`--app=`) or user-made launcher shortcuts for the
90 /// same binary with different args.
91 pub exec_args: Vec<Rc<str>>,
92 /// The application's icon
93 ///
94 /// If available, taken verbatim from the `Icon` field in the desktop entry file
95 pub icon: Option<Rc<str>>,
96}
97
98/// Returns a map of all applications available to the current user
99///
100/// Reads all desktop entry files in `$XDG_DATA_DIRS` and `$HOME/.local/share/applications`
101/// and returns a map of the applications found.
102///
103/// The result is cached per-thread and invalidated lazily through inotify: each call
104/// drains pending events on the watched `applications/` directories and only re-scans
105/// when a `.desktop` file was created, deleted, modified, or moved. The first call
106/// from a given thread does the initial scan and sets up the watches.
107pub fn installed_apps() -> HashMap<Rc<str>, ApplicationEntry> {
108 APPS_CACHE.with(|cell| {
109 let mut slot = cell.borrow_mut();
110
111 if let Some(cache) = slot.as_mut() {
112 let dirty = drain_inotify_events(cache);
113 ensure_watches(cache, env::xdg_data_dirs());
114 if dirty {
115 cache.apps = installed_apps_impl(env::xdg_data_dirs(), env::path());
116 }
117 return cache.apps.clone();
118 }
119
120 let apps = installed_apps_impl(env::xdg_data_dirs(), env::path());
121 match Inotify::init(InitFlags::IN_NONBLOCK | InitFlags::IN_CLOEXEC) {
122 Ok(inotify) => {
123 let mut cache = AppsCache {
124 apps: apps.clone(),
125 inotify,
126 watched: HashMap::new(),
127 };
128 ensure_watches(&mut cache, env::xdg_data_dirs());
129 *slot = Some(cache);
130 }
131 Err(e) => {
132 log::warn!("inotify init failed ({e}); installed_apps() will not be cached");
133 }
134 }
135 apps
136 })
137}
138
139/// Returns a list of running applications
140///
141/// Requires a list of available applications and a list of running processes. The list of available
142/// applications can be obtained using `installed_apps`.
143///
144/// Returns a list of running applications along with the PIDs for each running app.
145///
146/// **Example:**
147/// ```
148/// use app_rummage::{installed_apps, running_apps};
149/// # struct MyProcess { pid: std::num::NonZeroU32, exe: Option<std::rc::Rc<str>> }
150/// # impl app_rummage::Process for MyProcess { fn pid(&self) -> std::num::NonZeroU32 { self.pid } fn executable_path(&self) -> Option<std::path::PathBuf> { self.exe.as_ref().map(|e| std::path::Path::new(e.as_ref()).to_owned()) } fn name(&self) -> &str { "" } }
151/// # fn running_processes() -> Vec<MyProcess> { vec![] }
152///
153/// let available_apps = installed_apps();
154/// let processes = running_processes();
155/// let running_apps = running_apps(&available_apps, &processes);
156/// for (app, pids) in running_apps {
157/// println!("{}: {:?}", app.name, pids);
158/// }
159/// ```
160pub fn running_apps<'a, P: Process + 'a>(
161 available_applications: &'a HashMap<Rc<str>, ApplicationEntry>,
162 processes: impl IntoIterator<Item = &'a P>,
163) -> Vec<(&'a ApplicationEntry, Vec<NonZeroU32>)> {
164 let mut running_apps = running_xdg_conformant_apps(available_applications);
165
166 let mut apps_by_proc = running_apps_by_process(available_applications, processes);
167 for (app_id, (app, pids)) in apps_by_proc.drain() {
168 let normalized_id = normalize_app_id(app_id.as_ref());
169 if !running_apps.contains_key(normalized_id) {
170 let key: Rc<str> = if normalized_id == app_id.as_ref() {
171 app_id
172 } else {
173 Rc::from(normalized_id)
174 };
175 running_apps.insert(key, (app, pids));
176 }
177 }
178
179 running_apps
180 .values_mut()
181 .map(|(app, pids)| (*app, std::mem::take(pids)))
182 .collect()
183}