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
8pub use crate::desktop::DesktopNames;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12/// A friendly name for an app key (an exe basename, or
13/// `<basename>@<cgroup leaf>` as the guard uses for runtimes). In order: the
14/// `Name` of an installed desktop entry that runs this program, else of the
15/// app installed in `exe_dir` (the directory of the process's executable;
16/// not for an interpreter or runtime such as `java`, which runs any app),
17/// else the process name `comm` when the basename has no letters (a
18/// versioned binary such as `2.1.283`), else the basename with its first
19/// letter upper-cased. The `@leaf` suffix is never shown.
20pub fn display_name(
21    key: &str,
22    desktop: &DesktopNames,
23    comm: Option<&str>,
24    exe_dir: Option<&Path>,
25) -> String {
26    let base = key.split('@').next().unwrap_or(key);
27    let program = program_name(base, comm);
28    let name = desktop
29        .programs
30        .get(program)
31        .or_else(|| desktop.programs.get(base))
32        .or_else(|| {
33            let runtime = crate::desktop::is_launcher(program);
34            exe_dir
35                .filter(|_| !runtime)
36                .and_then(|d| desktop.dirs.get(d))
37        });
38    if let Some(name) = name {
39        return name.clone();
40    }
41    if program.is_empty() {
42        return "An app".to_string();
43    }
44    let mut chars = program.chars();
45    match chars.next() {
46        Some(first) => first.to_uppercase().chain(chars).collect(),
47        None => String::new(),
48    }
49}
50
51/// The name a program goes by: its executable basename `exe`, or the
52/// process name `comm` when the basename has no letters (a versioned binary
53/// such as `2.1.284` whose process calls itself `claude`).
54pub fn program_name<'a>(exe: &'a str, comm: Option<&'a str>) -> &'a str {
55    let has_letters = |s: &str| s.chars().any(char::is_alphabetic);
56    match comm {
57        Some(c) if !has_letters(exe) && has_letters(c) => c.trim(),
58        _ => exe,
59    }
60}
61
62static NAMES: OnceLock<DesktopNames> = OnceLock::new();
63
64/// Installed application names, read from the desktop entries on first use
65/// and cached for the life of the process. The first call waits for the
66/// read.
67pub fn desktop_names() -> &'static DesktopNames {
68    NAMES.get_or_init(crate::desktop::installed_names)
69}
70
71/// The cached [`desktop_names`] if they have been read, without waiting:
72/// `None` until the first [`desktop_names`] call has finished.
73pub fn loaded_desktop_names() -> Option<&'static DesktopNames> {
74    NAMES.get()
75}
76
77/// [`display_name`] of the program `exe` (a basename, run from `exe_dir`)
78/// whose process name is `comm`, using the cached [`desktop_names`]. Never
79/// waits for them: until they are read, the name falls back to the basename
80/// rules.
81pub fn friendly_name(exe: &str, comm: Option<&str>, exe_dir: Option<&Path>) -> String {
82    let desktop = loaded_desktop_names();
83    display_name(
84        exe,
85        desktop.unwrap_or(&DesktopNames::default()),
86        comm,
87        exe_dir,
88    )
89}
90
91/// The executable basename of `pid`: the target of `/proc/<pid>/exe`
92/// without the ` (deleted)` suffix, else the basename of the first
93/// argument in `/proc/<pid>/cmdline`. `None` when neither can be read.
94pub fn exe_of_pid(pid: u32) -> Option<String> {
95    if pid == 0 {
96        return None;
97    }
98    if let Some(exe) = crate::guard::cgfs::exe_basename(pid).filter(|e| !e.is_empty()) {
99        return Some(exe);
100    }
101    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
102    argv0_basename(&cmdline)
103}
104
105/// The directory of the executable of `pid`, from `/proc/<pid>/exe`.
106pub fn exe_dir_of_pid(pid: u32) -> Option<PathBuf> {
107    if pid == 0 {
108        return None;
109    }
110    let exe = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
111    exe.parent().map(Path::to_path_buf)
112}
113
114/// Basename of the first NUL-separated argument of a raw cmdline.
115fn argv0_basename(cmdline: &[u8]) -> Option<String> {
116    let first = cmdline.split(|b| *b == 0).next()?;
117    let first = std::str::from_utf8(first).ok()?;
118    let base = first.rsplit('/').next()?.trim();
119    (!base.is_empty()).then(|| base.to_string())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    use std::collections::HashMap;
127
128    fn programs(pairs: &[(&str, &str)]) -> DesktopNames {
129        DesktopNames {
130            programs: pairs
131                .iter()
132                .map(|(p, n)| (p.to_string(), n.to_string()))
133                .collect(),
134            dirs: HashMap::new(),
135        }
136    }
137
138    #[test]
139    fn display_names() {
140        let desktop = programs(&[("code", "Visual Studio Code"), ("claude", "Claude")]);
141        let none = DesktopNames::default();
142        let name = |key: &str, desktop: &DesktopNames, comm| display_name(key, desktop, comm, None);
143        assert_eq!(name("code", &desktop, None), "Visual Studio Code");
144        assert_eq!(name("firefox", &desktop, None), "Firefox");
145        assert_eq!(name("node@app-x.scope", &desktop, None), "Node");
146        assert_eq!(name("python3@run-u12.service", &desktop, None), "Python3");
147        assert_eq!(name("2.1.283", &none, Some("claude")), "Claude");
148        assert_eq!(name("2.1.283", &desktop, Some("claude")), "Claude");
149        assert_eq!(name("2.1.283", &desktop, None), "2.1.283");
150        assert_eq!(
151            name("firefox", &desktop, Some("Isolated Web Co")),
152            "Firefox",
153            "comm is only used when the basename has no letters"
154        );
155        assert_eq!(name("élan", &desktop, None), "Élan");
156        assert_eq!(name("", &desktop, None), "An app");
157    }
158
159    #[test]
160    fn an_app_is_named_by_its_install_directory_after_its_program() {
161        // google-chrome.desktop runs /usr/bin/google-chrome-stable, which
162        // leads to /opt/google/chrome; the browser process is .../chrome.
163        let mut desktop = programs(&[
164            ("google-chrome-stable", "Google Chrome"),
165            ("chrome-tool", "Chrome Tool"),
166        ]);
167        desktop
168            .dirs
169            .insert(PathBuf::from("/opt/google/chrome"), "Google Chrome".into());
170        let chrome = Some(Path::new("/opt/google/chrome"));
171        assert_eq!(
172            display_name("chrome", &desktop, Some("chrome"), chrome),
173            "Google Chrome"
174        );
175        assert_eq!(
176            display_name("chrome_crashpad_handler", &desktop, None, chrome),
177            "Google Chrome"
178        );
179        assert_eq!(
180            display_name("chrome-tool", &desktop, None, chrome),
181            "Chrome Tool",
182            "a program's own entry comes first"
183        );
184        assert_eq!(display_name("chrome", &desktop, None, None), "Chrome");
185        // A runtime's directory names no app, even if an entry claimed it.
186        desktop.dirs.insert(
187            PathBuf::from("/usr/lib/jvm/java-17/bin"),
188            "OpenJDK 17 Monitoring & Management Console".into(),
189        );
190        let jvm = Some(Path::new("/usr/lib/jvm/java-17/bin"));
191        assert_eq!(display_name("java", &desktop, Some("java"), jvm), "Java");
192        assert_eq!(
193            display_name(
194                "electron30",
195                &desktop,
196                None,
197                Some(Path::new("/opt/google/chrome"))
198            ),
199            "Electron30"
200        );
201        assert_eq!(
202            display_name("chrome", &desktop, None, Some(Path::new("/opt/other"))),
203            "Chrome"
204        );
205    }
206
207    #[test]
208    fn program_names() {
209        assert_eq!(program_name("chrome", Some("chrome")), "chrome");
210        assert_eq!(
211            program_name("gnome-calculator", Some("gnome-calculato")),
212            "gnome-calculator"
213        );
214        assert_eq!(program_name("2.1.284", Some("claude")), "claude");
215        assert_eq!(program_name("2.1.284", Some("1.0")), "2.1.284");
216        assert_eq!(program_name("2.1.284", None), "2.1.284");
217    }
218
219    #[test]
220    fn argv0_is_the_basename_of_the_first_argument() {
221        assert_eq!(
222            argv0_basename(b"/usr/bin/gnome-calculator\0--flag\0").as_deref(),
223            Some("gnome-calculator")
224        );
225        assert_eq!(
226            argv0_basename(b"python3\0x.py\0").as_deref(),
227            Some("python3")
228        );
229        assert_eq!(argv0_basename(b""), None);
230        assert_eq!(argv0_basename(b"\0"), None);
231    }
232
233    #[test]
234    fn our_own_exe_is_found() {
235        let me = exe_of_pid(std::process::id()).expect("own exe");
236        assert!(!me.is_empty());
237        assert_eq!(exe_of_pid(0), None);
238    }
239}