app_rummage/
lib.rs

1use std::{
2    collections::HashMap,
3    io::{self, BufRead, BufReader, Read},
4    num::NonZeroU32,
5    os::unix::ffi::OsStrExt,
6    path::{Path, PathBuf},
7    rc::Rc,
8    sync::OnceLock,
9};
10
11mod env;
12
13// Adapted from Resources (src/utils/app.rs:83)
14fn executable_exceptions() -> &'static HashMap<&'static str, &'static str> {
15    static EXECUTABLE_EXCEPTIONS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
16
17    EXECUTABLE_EXCEPTIONS.get_or_init(|| {
18        HashMap::from([
19            ("firefox-bin", "firefox"),
20            ("oosplash", "libreoffice"),
21            ("soffice.bin", "libreoffice"),
22            ("resources-processes", "resources"),
23            ("gnome-terminal-server", "gnome-terminal"),
24            ("chrome", "google-chrome-stable"),
25        ])
26    })
27}
28
29fn app_id_replacement() -> &'static HashMap<&'static str, &'static str> {
30    static APP_ID_REPLACEMENT: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
31
32    APP_ID_REPLACEMENT
33        .get_or_init(|| HashMap::from([("gnome-system-monitor-kde", "org.gnome.SystemMonitor")]))
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}
52
53/// And data structure representing an application
54///
55/// It contains a subset of the fields from an XDG desktop entry file
56#[derive(Clone, Debug)]
57pub struct ApplicationEntry {
58    /// The application's id
59    ///
60    /// The filename of the desktop entry file without the `.desktop` extension and is used
61    /// to uniquely identify an application
62    pub id: Rc<str>,
63    /// The application's name
64    ///
65    /// The name that is displayed to the user in the application menu, taskbar, etc.
66    pub name: Rc<str>,
67    /// The application's executable
68    ///
69    /// The absolute path to the application's executable, obtained from the `Exec` field in
70    /// the desktop entry file
71    pub exec: Option<Rc<str>>,
72    /// The application's icon
73    ///
74    /// If available, taken verbatim from the `Icon` field in the desktop entry file
75    pub icon: Option<Rc<str>>,
76}
77
78/// Returns a map of all applications available to the current user
79///
80/// Reads all desktop entry files in `$XDG_DATA_DIRS` and `$HOME/.local/share/applications`
81/// and returns a map of the applications found.
82pub fn installed_apps() -> HashMap<Rc<str>, ApplicationEntry> {
83    installed_apps_impl(env::xdg_data_dirs(), env::path())
84}
85
86/// Returns a list of running applications
87///
88/// Requires a list of available applications and a list of running processes. The list of available
89/// applications can be obtained using `installed_apps`.
90///
91/// Returns a list of running applications along with the PIDs for each running app.
92///
93/// **Example:**
94/// ```
95/// use app_rummage::{installed_apps, running_apps};
96/// # struct MyProcess { pid: std::num::NonZeroU32, exe: Option<std::rc::Rc<str>> }
97/// # 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 { "" } }
98/// # fn running_processes() -> Vec<MyProcess> { vec![] }
99///
100/// let available_apps = installed_apps();
101/// let processes = running_processes();
102/// let running_apps = running_apps(&available_apps, &processes);
103/// for (app, pids) in running_apps {
104///    println!("{}: {:?}", app.name, pids);
105/// }
106/// ```
107pub fn running_apps<'a, P: Process + 'a>(
108    available_applications: &'a HashMap<Rc<str>, ApplicationEntry>,
109    processes: impl IntoIterator<Item = &'a P>,
110) -> Vec<(&'a ApplicationEntry, Vec<NonZeroU32>)> {
111    fn normalize_app_id(id: &str) -> &str {
112        if let Some(real_id) = app_id_replacement().get(id) {
113            *real_id
114        } else {
115            id
116        }
117    }
118
119    let mut running_apps = running_xdg_conformant_apps(available_applications);
120
121    let mut apps_by_proc = running_apps_by_process(available_applications, processes);
122    for (app_id, (app, pids)) in apps_by_proc.drain() {
123        let normalized_id = normalize_app_id(app_id.as_ref());
124        if !running_apps.contains_key(normalized_id) {
125            running_apps.insert(app_id, (app, pids));
126        }
127    }
128    running_apps
129        .values_mut()
130        .map(|(app, pids)| (*app, std::mem::take(pids)))
131        .collect()
132}
133
134fn installed_apps_impl(
135    xdg_data_dirs: &[String],
136    env_path: &[String],
137) -> HashMap<Rc<str>, ApplicationEntry> {
138    let mut result = HashMap::new();
139    let mut buffer = String::new();
140
141    for dir in xdg_data_dirs {
142        let dir = Path::new(&dir).join("applications");
143        if dir.exists() {
144            let dir = match dir.read_dir() {
145                Ok(dc) => dc,
146                Err(e) => {
147                    log::warn!("Failed to read directory '{}': {:?}", dir.display(), e);
148                    continue;
149                }
150            };
151
152            for entry in dir {
153                let entry = match entry {
154                    Ok(e) => e,
155                    Err(e) => {
156                        log::warn!("Failed to read entry directory entry: {:?}", e);
157                        continue;
158                    }
159                };
160
161                let path = entry.path();
162                if path
163                    .extension()
164                    .map(|ext| ext == "desktop")
165                    .unwrap_or(false)
166                {
167                    match extract_application_info(&path, env_path, &mut buffer) {
168                        Ok(app) => {
169                            result.insert(app.id.clone(), app);
170                        }
171                        _ => {}
172                    }
173                }
174            }
175        }
176    }
177
178    result
179}
180
181fn extract_application_info(
182    path: &Path,
183    env_path: &[String],
184    buffer: &mut String,
185) -> io::Result<ApplicationEntry> {
186    buffer.clear();
187    std::fs::File::options()
188        .read(true)
189        .open(path)?
190        .read_to_string(buffer)?;
191
192    let desktop_file_content = buffer;
193
194    let mut name = None;
195    let mut exec = None;
196    let mut icon = None;
197
198    let mut desktop_entry_group_found = false;
199    for line in desktop_file_content.split('\n') {
200        if line != "[Desktop Entry]" && !desktop_entry_group_found {
201            continue;
202        }
203
204        if line == "[Desktop Entry]" {
205            desktop_entry_group_found = true;
206            continue;
207        }
208
209        if line.starts_with("NoDisplay=true") {
210            name = None;
211            break;
212        }
213
214        if line.starts_with("Type=") {
215            if line[5..].trim() != "Application" {
216                name = None;
217                break;
218            }
219        }
220
221        if line.starts_with("Name=") {
222            name = Some(&line[5..]);
223        } else if line.starts_with("Exec=") {
224            exec = Some(&line[5..]);
225        } else if line.starts_with("Icon=") {
226            icon = Some(&line[5..]);
227        } else if line.starts_with("[") {
228            break;
229        }
230    }
231
232    if let (Some(name), Some(exec)) = (name, exec) {
233        let Some(file_name) = path.file_name() else {
234            return Err(io::Error::new(
235                io::ErrorKind::NotFound,
236                "The file name of the Desktop file could not be determined",
237            ));
238        };
239        let file_name = file_name.to_string_lossy();
240
241        let app_id = file_name
242            .strip_suffix(".desktop")
243            .map(|id| Rc::<str>::from(id))
244            .unwrap_or(Rc::<str>::from(file_name));
245
246        return Ok(ApplicationEntry {
247            id: app_id,
248            name: Rc::from(name),
249            exec: sanitize_exec(exec, env_path),
250            icon: icon.map(|i| Rc::from(i)),
251        });
252    }
253
254    Err(io::Error::new(
255        io::ErrorKind::InvalidInput,
256        "Desktop file does not describe a valid user-facing application",
257    ))
258}
259
260fn sanitize_exec(exec: &str, env_path: &[String]) -> Option<Rc<str>> {
261    const CMDLINE_PROGRAMS: &[&str] = &[
262        "sh",
263        "ash",
264        "bash",
265        "dash",
266        "fish",
267        "zsh",
268        "powershell",
269        "awk",
270        "ruby",
271        "perl",
272        "lua",
273        "php",
274        "python",
275        "python2",
276        "python2.7",
277        "python3",
278        "node",
279        "nodejs",
280        "java",
281        "dotnet",
282        // coreutils
283        "arch",
284        "cp",
285        "stty",
286        "base32",
287        "date",
288        "base64",
289        "dd",
290        "basename",
291        "df",
292        "basenc",
293        "expr",
294        "cat",
295        "install",
296        "chcon",
297        "join",
298        "chgrp",
299        "ls",
300        "chmod",
301        "more",
302        "chown",
303        "numfmt",
304        "chroot",
305        "od",
306        "cksum",
307        "pr",
308        "comm",
309        "printf",
310        "csplit",
311        "sort",
312        "cut",
313        "split",
314        "dircolors",
315        "tac",
316        "dirname",
317        "tail",
318        "du",
319        "test",
320        "echo",
321        "env",
322        "expand",
323        "factor",
324        "false",
325        "fmt",
326        "fold",
327        "groups",
328        "hashsum",
329        "head",
330        "hostid",
331        "hostname",
332        "id",
333        "kill",
334        "link",
335        "ln",
336        "logname",
337        "md5sum",
338        "sha1sum",
339        "sha224sum",
340        "sha256sum",
341        "sha384sum",
342        "sha512sum",
343        "mkdir",
344        "mkfifo",
345        "mknod",
346        "mktemp",
347        "mv",
348        "nice",
349        "nl",
350        "nohup",
351        "nproc",
352        "paste",
353        "pathchk",
354        "pinky",
355        "printenv",
356        "ptx",
357        "pwd",
358        "readlink",
359        "realpath",
360        "relpath",
361        "rm",
362        "rmdir",
363        "runcon",
364        "seq",
365        "shred",
366        "shuf",
367        "sleep",
368        "stat",
369        "stdbuf",
370        "sum",
371        "sync",
372        "tee",
373        "timeout",
374        "touch",
375        "tr",
376        "true",
377        "truncate",
378        "tsort",
379        "tty",
380        "uname",
381        "unexpand",
382        "uniq",
383        "unlink",
384        "uptime",
385        "users",
386        "wc",
387        "who",
388        "whoami",
389        "yes",
390    ];
391
392    const LAUNCHERS: &[&str] = &[
393        "distrobox",
394        "distrobox-enter",
395        "toolbx",
396        "toolbx-enter",
397        "toolbox",
398        "toolbox-enter",
399        "flatpak",
400        "snap",
401        "env",
402    ];
403
404    for cmd in exec
405        .split_ascii_whitespace()
406        .map(|item| item.trim())
407        .filter(|item| !item.is_empty() && !item.starts_with('-'))
408        .map(|item| {
409            let path = Path::new(item.trim_start_matches('"').trim_end_matches('"'));
410            return if path.is_absolute() {
411                Some(path.to_owned())
412            } else {
413                for dir in env_path.into_iter() {
414                    let path = Path::new(&dir).join(item);
415                    if path.exists() {
416                        return Some(path);
417                    }
418                }
419                None
420            };
421        })
422        .filter_map(|path| path.and_then(|p| p.canonicalize().ok()))
423        .skip_while(|item| {
424            LAUNCHERS.contains(
425                &item
426                    .file_name()
427                    .unwrap_or_default()
428                    .to_string_lossy()
429                    .as_ref(),
430            )
431        })
432    {
433        let file_name = cmd.file_name().unwrap_or_default().to_string_lossy();
434        if CMDLINE_PROGRAMS.contains(&file_name.as_ref()) {
435            return None;
436        }
437
438        return Some(Rc::from(cmd.to_string_lossy()));
439    }
440
441    None
442}
443
444fn find_pids_for_cgroup(cgroup_path: &Path) -> Vec<NonZeroU32> {
445    fn find_pids_for_cgroup(cgroup_path: &Path, result: &mut Vec<NonZeroU32>) {
446        let procs = cgroup_path.join("cgroup.procs");
447        if let Ok(file) = std::fs::File::open(procs) {
448            for line in BufReader::new(file).lines() {
449                if let Ok(pid_str) = line.as_ref().map(|l| l.trim()) {
450                    if let Ok(pid) = pid_str.parse::<u32>() {
451                        if let Some(pid) = NonZeroU32::new(pid) {
452                            result.push(pid);
453                        }
454                    }
455                }
456            }
457        }
458
459        let cgroup_entries = match cgroup_path.read_dir() {
460            Ok(r) => r,
461            Err(_) => {
462                return;
463            }
464        };
465
466        for entry in cgroup_entries.filter_map(|e| e.ok()) {
467            if let Ok(kind) = entry.file_type() {
468                if kind.is_dir() {
469                    find_pids_for_cgroup(&entry.path(), result);
470                }
471            }
472        }
473    }
474
475    let mut result = vec![];
476    find_pids_for_cgroup(cgroup_path, &mut result);
477    result.sort_unstable();
478    result
479}
480
481fn app_id(path: &Path) -> Option<Rc<str>> {
482    // https://systemd.io/DESKTOP_ENVIRONMENTS/#xdg-standardization-for-applications
483
484    let dir_name = path.file_name()?.to_string_lossy();
485
486    if dir_name.starts_with("snap.") {
487        let mut app_id = String::new();
488
489        // snaps cgroups names don't conform to the suggested XDG standard they, instead, look like:
490        // snap.<appname1>.<appname2>-<UUID>.scope
491        //
492        // The app id is the concatenation of appname1 and appname2 separated by an underscore
493        for part in dir_name.split('.').skip(1) {
494            if part == "scope" {
495                break;
496            }
497
498            if !app_id.is_empty() {
499                app_id.push('_');
500            }
501
502            // Try to skip over the uuid part by counting the number of '-' characters
503            // from the end of the part
504            let mut uuid_pos = part.len();
505            let mut counter = 0;
506            for (i, c) in part.as_bytes().iter().enumerate().rev() {
507                if *c == b'-' {
508                    counter += 1;
509                }
510
511                if counter == 5 {
512                    uuid_pos = i;
513                    break;
514                }
515            }
516            app_id.push_str(&part[..uuid_pos]);
517        }
518
519        Some(Rc::from(app_id))
520    } else if dir_name.starts_with("app-") {
521        let extension = path.extension()?.to_string_lossy();
522        // Include the '.' in the extension
523        let extension = &dir_name[dir_name.len() - extension.len() - 1..];
524        let mut app_id: Option<&str> = None;
525
526        for part in dir_name.split('-').skip(1).filter(|p| !p.is_empty()) {
527            if app_id.is_some() && part.ends_with(extension) {
528                break;
529            }
530
531            app_id = Some(part.trim_end_matches(extension));
532        }
533
534        app_id.map(|s| Rc::from(s.replace("\\x2d", "-")))
535    } else {
536        None
537    }
538}
539
540fn running_xdg_conformant_apps(
541    available_apps: &HashMap<Rc<str>, ApplicationEntry>,
542) -> HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> {
543    // We only show running apps for the current user
544    let uid = nix::unistd::getuid();
545    let app_slice_dir =
546        format!("/sys/fs/cgroup/user.slice/user-{uid}.slice/user@{uid}.service/app.slice");
547    let app_slice_dir = match Path::new(&app_slice_dir).read_dir() {
548        Ok(r) => r,
549        Err(e) => {
550            log::warn!(
551                "Error reading cgroup information from {}: {}",
552                app_slice_dir,
553                e
554            );
555            return HashMap::new();
556        }
557    };
558
559    let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
560    result.reserve(available_apps.len());
561
562    for entry in app_slice_dir.filter_map(|e| e.ok()).filter(|e| {
563        let file_name = e.file_name();
564        let file_name = file_name.as_bytes();
565        file_name.ends_with(b".slice")
566            || file_name.ends_with(b".scope")
567            || file_name.ends_with(b".service")
568    }) {
569        let path = entry.path();
570
571        if let Some(app_id) = app_id(&path) {
572            let mut pids = find_pids_for_cgroup(&path);
573            if !pids.is_empty() {
574                if let Some(app) = available_apps.get(&app_id) {
575                    if let Some((_, existing_pids)) = result.get_mut(&app.id) {
576                        existing_pids.extend(pids);
577                        existing_pids.sort_unstable();
578                    } else {
579                        pids.sort_unstable();
580                        result.insert(app.id.clone(), (app, pids));
581                    }
582                }
583            }
584        }
585    }
586
587    result
588}
589
590fn running_apps_by_process<'a, P: Process + 'a>(
591    available_apps: &'a HashMap<Rc<str>, ApplicationEntry>,
592    processes: impl IntoIterator<Item = &'a P>,
593) -> HashMap<Rc<str>, (&'a ApplicationEntry, Vec<NonZeroU32>)> {
594    let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
595    result.reserve(available_apps.len());
596
597    let apps_by_exec = available_apps
598        .values()
599        .filter_map(|app| {
600            app.exec
601                .as_ref()
602                .map(|exec| (Path::new(exec.as_ref()), app))
603        })
604        .collect::<HashMap<&Path, &ApplicationEntry>>();
605
606    for process in processes.into_iter() {
607        let proc_exec = if let Some(proc_exe) = process.executable_path().clone() {
608            proc_exe
609        } else {
610            env::path()
611                .iter()
612                .filter_map(|dir| {
613                    let mut path = Path::new(dir).join(process.name());
614                    if !path.exists() {
615                        if let Some(alternate_name) = executable_exceptions().get(process.name()) {
616                            path = Path::new(dir).join(alternate_name);
617                        }
618                    }
619
620                    if path.exists() {
621                        if let Ok(exec) = path.canonicalize() {
622                            return Some(exec);
623                        }
624                    }
625
626                    None
627                })
628                .next()
629                .unwrap_or(PathBuf::new())
630        };
631
632        if let Some(app) = apps_by_exec.get(&proc_exec.as_path()) {
633            let app_id = &app.id;
634            if let Some((_, pids)) = result.get_mut(app_id) {
635                pids.push(process.pid());
636                pids.sort_unstable();
637            } else {
638                result.insert(app_id.clone(), (app, vec![process.pid()]));
639            }
640        }
641    }
642
643    result
644}
645
646#[cfg(test)]
647mod tests {
648    use crate::env;
649
650    use super::*;
651
652    #[test]
653    fn test_available_applications() {
654        let result = installed_apps_impl(env::xdg_data_dirs(), env::path());
655        dbg!(&result);
656    }
657
658    #[test]
659    fn test_find_pids_for_cgroup() {
660        let result = find_pids_for_cgroup(Path::new("/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice"));
661        dbg!(&result);
662    }
663
664    #[test]
665    fn test_running_applications_xdg() {
666        let available_apps = installed_apps();
667        let result = running_xdg_conformant_apps(&available_apps);
668        dbg!(&result);
669    }
670
671    #[test]
672    fn test_running_applications_process() {
673        #[derive(Debug)]
674        struct MyProcess {
675            pid: NonZeroU32,
676            name: Rc<str>,
677            exe: Option<Rc<str>>,
678        }
679
680        impl Process for MyProcess {
681            fn pid(&self) -> NonZeroU32 {
682                self.pid
683            }
684
685            fn executable_path(&self) -> Option<PathBuf> {
686                self.exe.as_ref().map(|e| Path::new(e.as_ref()).to_owned())
687            }
688
689            fn name(&self) -> &str {
690                self.name.as_ref()
691            }
692        }
693
694        fn running_processes() -> Vec<MyProcess> {
695            let mut result = vec![];
696
697            let readdir = match Path::new("/proc").read_dir() {
698                Ok(r) => r,
699                Err(_) => {
700                    return vec![];
701                }
702            };
703
704            for entry in readdir.filter_map(|e| e.ok()) {
705                let path = entry.path();
706                let mut exe = Rc::<str>::from("");
707                if let Some(pid) = path
708                    .file_name()
709                    .and_then(|f| f.to_str())
710                    .and_then(|f| f.parse().ok())
711                {
712                    let bin_path = path.join("exe");
713                    if let Ok(bin_path) =
714                        std::fs::read_link(&bin_path).and_then(|p| p.canonicalize())
715                    {
716                        if bin_path.exists() {
717                            exe = Rc::from(bin_path.to_string_lossy());
718                        }
719                    } else {
720                        if let Some(bin_path) = std::fs::read_to_string(path.join("cmdline"))
721                            .ok()
722                            .and_then(|s| match s.split('\0').next() {
723                                Some("") => None,
724                                Some(s) => Some(s.to_owned()),
725                                None => None,
726                            })
727                            .map(|s| Path::new(&s).to_owned())
728                            .and_then(|p| p.canonicalize().ok())
729                        {
730                            if bin_path.exists() && bin_path.is_file() && bin_path.is_absolute() {
731                                exe = Rc::from(bin_path.to_string_lossy());
732                            }
733                        }
734                    }
735
736                    let proc_name = path.join("comm");
737                    if let Ok(name) = std::fs::read_to_string(&proc_name) {
738                        result.push(MyProcess {
739                            pid,
740                            exe: Some(exe),
741                            name: Rc::from(name.trim()),
742                        });
743                    }
744                }
745            }
746
747            result
748        }
749
750        let available_apps = installed_apps();
751        let processes = running_processes();
752        let result = running_apps_by_process(&available_apps, &processes);
753        dbg!(&result);
754    }
755}