Skip to main content

rlmctl_core/
appname.rs

1//! Friendly app names for people: "Google Chrome" for `chrome`, "Claude"
2//! for a versioned binary named `2.1.284`, "Firefox" for `firefox`.
3//!
4//! The GUI, the CLI and the guard's notifications share these rules. The
5//! installed desktop entries are read once per process, on first use, and
6//! kept.
7
8use std::collections::HashMap;
9use std::sync::OnceLock;
10
11/// A friendly name for an app key (an exe basename, or
12/// `<basename>@<cgroup leaf>` as the guard uses for runtimes). In order: the
13/// `Name` of an installed desktop entry that runs this program (`desktop`
14/// maps program basename to name), else the process name `comm` when the
15/// basename has no letters (a versioned binary such as `2.1.283`), else the
16/// basename with its first letter upper-cased. The `@leaf` suffix is never
17/// shown.
18pub fn display_name(key: &str, desktop: &HashMap<String, String>, comm: Option<&str>) -> String {
19    let base = key.split('@').next().unwrap_or(key);
20    let program = program_name(base, comm);
21    if let Some(name) = desktop.get(program).or_else(|| desktop.get(base)) {
22        return name.clone();
23    }
24    if program.is_empty() {
25        return "An app".to_string();
26    }
27    let mut chars = program.chars();
28    match chars.next() {
29        Some(first) => first.to_uppercase().chain(chars).collect(),
30        None => String::new(),
31    }
32}
33
34/// The name a program goes by: its executable basename `exe`, or the
35/// process name `comm` when the basename has no letters (a versioned binary
36/// such as `2.1.284` whose process calls itself `claude`).
37pub fn program_name<'a>(exe: &'a str, comm: Option<&'a str>) -> &'a str {
38    let has_letters = |s: &str| s.chars().any(char::is_alphabetic);
39    match comm {
40        Some(c) if !has_letters(exe) && has_letters(c) => c.trim(),
41        _ => exe,
42    }
43}
44
45/// Installed application names keyed by program basename, read from the
46/// desktop entries on first use and cached for the life of the process.
47pub fn desktop_names() -> &'static HashMap<String, String> {
48    static NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();
49    NAMES.get_or_init(crate::desktop::names_by_program)
50}
51
52/// [`display_name`] of the program `exe` (a basename) whose process name is
53/// `comm`, using the cached [`desktop_names`].
54pub fn friendly_name(exe: &str, comm: Option<&str>) -> String {
55    display_name(exe, desktop_names(), comm)
56}
57
58/// The executable basename of `pid`: the target of `/proc/<pid>/exe`
59/// without the ` (deleted)` suffix, else the basename of the first
60/// argument in `/proc/<pid>/cmdline`. `None` when neither can be read.
61pub fn exe_of_pid(pid: u32) -> Option<String> {
62    if pid == 0 {
63        return None;
64    }
65    if let Some(exe) = crate::guard::cgfs::exe_basename(pid).filter(|e| !e.is_empty()) {
66        return Some(exe);
67    }
68    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
69    argv0_basename(&cmdline)
70}
71
72/// Basename of the first NUL-separated argument of a raw cmdline.
73fn argv0_basename(cmdline: &[u8]) -> Option<String> {
74    let first = cmdline.split(|b| *b == 0).next()?;
75    let first = std::str::from_utf8(first).ok()?;
76    let base = first.rsplit('/').next()?.trim();
77    (!base.is_empty()).then(|| base.to_string())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn display_names() {
86        let mut desktop = HashMap::new();
87        desktop.insert("code".to_string(), "Visual Studio Code".to_string());
88        desktop.insert("claude".to_string(), "Claude".to_string());
89        assert_eq!(display_name("code", &desktop, None), "Visual Studio Code");
90        assert_eq!(display_name("firefox", &desktop, None), "Firefox");
91        assert_eq!(display_name("node@app-x.scope", &desktop, None), "Node");
92        assert_eq!(
93            display_name("python3@run-u12.service", &desktop, None),
94            "Python3"
95        );
96        assert_eq!(
97            display_name("2.1.283", &HashMap::new(), Some("claude")),
98            "Claude"
99        );
100        assert_eq!(display_name("2.1.283", &desktop, Some("claude")), "Claude");
101        assert_eq!(display_name("2.1.283", &desktop, None), "2.1.283");
102        assert_eq!(
103            display_name("firefox", &desktop, Some("Isolated Web Co")),
104            "Firefox",
105            "comm is only used when the basename has no letters"
106        );
107        assert_eq!(display_name("élan", &desktop, None), "Élan");
108        assert_eq!(display_name("", &desktop, None), "An app");
109    }
110
111    #[test]
112    fn program_names() {
113        assert_eq!(program_name("chrome", Some("chrome")), "chrome");
114        assert_eq!(
115            program_name("gnome-calculator", Some("gnome-calculato")),
116            "gnome-calculator"
117        );
118        assert_eq!(program_name("2.1.284", Some("claude")), "claude");
119        assert_eq!(program_name("2.1.284", Some("1.0")), "2.1.284");
120        assert_eq!(program_name("2.1.284", None), "2.1.284");
121    }
122
123    #[test]
124    fn argv0_is_the_basename_of_the_first_argument() {
125        assert_eq!(
126            argv0_basename(b"/usr/bin/gnome-calculator\0--flag\0").as_deref(),
127            Some("gnome-calculator")
128        );
129        assert_eq!(
130            argv0_basename(b"python3\0x.py\0").as_deref(),
131            Some("python3")
132        );
133        assert_eq!(argv0_basename(b""), None);
134        assert_eq!(argv0_basename(b"\0"), None);
135    }
136
137    #[test]
138    fn our_own_exe_is_found() {
139        let me = exe_of_pid(std::process::id()).expect("own exe");
140        assert!(!me.is_empty());
141        assert_eq!(exe_of_pid(0), None);
142    }
143}