Skip to main content

rlmctl_core/
desktop.rs

1use common::Result;
2use std::collections::HashMap;
3use std::fs;
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7/// Desktop application entry
8#[derive(Clone)]
9pub struct DesktopApp {
10    pub name: String,
11    pub exec: String,
12    pub is_cli: bool,
13}
14
15/// Directories searched for `.desktop` files: the system ones, then the
16/// user's own.
17fn desktop_dirs() -> Vec<PathBuf> {
18    let mut dirs: Vec<PathBuf> = [
19        "/usr/share/applications",
20        "/usr/local/share/applications",
21        "/var/lib/flatpak/exports/share/applications",
22    ]
23    .iter()
24    .map(PathBuf::from)
25    .collect();
26    dirs.extend(dirs::data_dir().map(|d| d.join("applications")));
27    dirs
28}
29
30/// Every shown application entry in [`desktop_dirs`].
31fn desktop_entries() -> Vec<Entry> {
32    let mut out = Vec::new();
33    for dir in desktop_dirs() {
34        if let Ok(entries) = fs::read_dir(&dir) {
35            for entry in entries.flatten() {
36                let path = entry.path();
37                if path.extension().is_some_and(|e| e == "desktop") {
38                    if let Some(e) = read_entry(&path) {
39                        out.push(e);
40                    }
41                }
42            }
43        }
44    }
45    out
46}
47
48/// List installed applications from .desktop files
49pub fn list_applications() -> Result<Vec<DesktopApp>> {
50    let mut apps: Vec<DesktopApp> = desktop_entries()
51        .into_iter()
52        .filter_map(|e| {
53            Some(DesktopApp {
54                exec: exec_command(&e.exec)?,
55                name: e.name,
56                is_cli: false,
57            })
58        })
59        .collect();
60    apps.sort_by_key(|a| a.name.to_lowercase());
61    apps.dedup_by(|a, b| a.name == b.name);
62    Ok(apps)
63}
64
65/// Installed application names keyed by the basename of the program their
66/// `Exec` runs (`firefox` to `Firefox`). See [`names_from_entries`].
67pub fn names_by_program() -> HashMap<String, String> {
68    names_from_entries(&desktop_entries())
69}
70
71/// Program basename to app name, from `entries`. Entries that run an
72/// interpreter or launcher, or that pass arguments launching something else
73/// (a web app, a game shortcut, a terminal command), are skipped: their
74/// `Name` is not the program's. When the remaining entries for one program
75/// disagree on the name, only those whose file stem or `StartupWMClass`
76/// matches the program count; if they still disagree, the program gets no
77/// name here.
78fn names_from_entries(entries: &[Entry]) -> HashMap<String, String> {
79    let mut by_program: HashMap<String, Vec<&Entry>> = HashMap::new();
80    for e in entries {
81        let Some(args) = exec_args(&e.exec) else {
82            continue;
83        };
84        let Some(program) = program_of(&args) else {
85            continue;
86        };
87        if is_launcher(&program) || args.iter().any(|a| launches_something_else(a)) {
88            continue;
89        }
90        by_program.entry(program).or_default().push(e);
91    }
92    let mut names = HashMap::new();
93    for (program, entries) in by_program {
94        let name = single_name(entries.iter().copied()).or_else(|| {
95            single_name(
96                entries
97                    .iter()
98                    .copied()
99                    .filter(|e| entry_matches(e, &program)),
100            )
101        });
102        if let Some(name) = name {
103            names.insert(program, name);
104        }
105    }
106    names
107}
108
109/// The one name all `entries` share, `None` if there are none or several.
110fn single_name<'a>(entries: impl Iterator<Item = &'a Entry>) -> Option<String> {
111    let mut names = entries.map(|e| e.name.as_str());
112    let first = names.next()?;
113    names.all(|n| n == first).then(|| first.to_string())
114}
115
116/// Whether an entry is the program's own: its file stem (or the last part
117/// of a reverse-DNS stem such as `org.mozilla.firefox`) or its
118/// `StartupWMClass` is the program name, ignoring case.
119fn entry_matches(e: &Entry, program: &str) -> bool {
120    let stem = e.stem.to_lowercase();
121    let program = program.to_lowercase();
122    stem == program
123        || stem.rsplit('.').next() == Some(program.as_str())
124        || e.wm_class
125            .as_deref()
126            .is_some_and(|c| c.eq_ignore_ascii_case(&program))
127}
128
129/// An Exec argument that makes the program open some other app or a URL:
130/// a browser web app, a Steam or Lutris game, or a terminal command.
131fn launches_something_else(arg: &str) -> bool {
132    arg == "-e"
133        || arg.starts_with("--app-id")
134        || arg.starts_with("--app=")
135        || arg.starts_with("--application-mode")
136        || arg.starts_with("steam://")
137        || arg.starts_with("lutris:")
138}
139
140/// Interpreters and launchers: an entry that runs one of these names some
141/// other app, so its `Name` must not label every process of that program.
142fn is_launcher(program: &str) -> bool {
143    const EXACT: &[&str] = &[
144        "sh",
145        "bash",
146        "dash",
147        "zsh",
148        "fish",
149        "env",
150        "java",
151        "node",
152        "nodejs",
153        "electron",
154        "flatpak",
155        "snap",
156        "gjs",
157        "gjs-console",
158        "perl",
159        "ruby",
160        "php",
161        "mono",
162        "dotnet",
163        "wine",
164        "wine64",
165        "xdg-open",
166        "gio",
167    ];
168    EXACT.contains(&program) || program.starts_with("python")
169}
170
171/// The basename of the program a raw desktop file `Exec` value runs, looking
172/// past an `env VAR=value` wrapper. `None` if there is none.
173pub fn exec_program(value: &str) -> Option<String> {
174    program_of(&exec_args(value)?)
175}
176
177/// A raw desktop file `Exec` value split into arguments, field codes
178/// removed. `None` when a quote is left open.
179fn exec_args(value: &str) -> Option<Vec<String>> {
180    Some(
181        split_exec(&unescape_value(value))?
182            .into_iter()
183            .filter_map(expand_field_codes)
184            .collect(),
185    )
186}
187
188/// The basename of the program `args` run, past an `env` wrapper.
189fn program_of(args: &[String]) -> Option<String> {
190    let mut args = args.iter();
191    let mut program = args.next()?;
192    if program == "env" {
193        program = loop {
194            let arg = args.next()?;
195            match arg.as_str() {
196                "-u" | "--unset" | "-C" | "--chdir" => {
197                    args.next();
198                }
199                "--" => break args.next()?,
200                a if a.starts_with('-') || a.contains('=') => {}
201                _ => break arg,
202            }
203        };
204    }
205    let base = program.rsplit('/').next().unwrap_or(program);
206    (!base.is_empty()).then(|| base.to_string())
207}
208
209/// A shown application entry: its `Name`, raw `Exec` value, file name
210/// without `.desktop`, and `StartupWMClass`.
211struct Entry {
212    name: String,
213    exec: String,
214    stem: String,
215    wm_class: Option<String>,
216}
217
218/// Read the `[Desktop Entry]` group of one file. `None` for a hidden entry,
219/// a non-application, or one without a usable `Name` and `Exec`.
220fn read_entry(path: &Path) -> Option<Entry> {
221    let content = fs::read_to_string(path).ok()?;
222    let mut name = None;
223    let mut exec = None;
224    let mut wm_class = None;
225    let mut no_display = false;
226    let mut in_desktop_entry = false;
227
228    for line in content.lines() {
229        let line = line.trim();
230
231        if line.starts_with('[') {
232            in_desktop_entry = line == "[Desktop Entry]";
233            continue;
234        }
235
236        if !in_desktop_entry {
237            continue;
238        }
239
240        if let Some(value) = line.strip_prefix("Name=") {
241            if name.is_none() && !value.trim().is_empty() {
242                name = Some(value.to_string());
243            }
244        } else if let Some(value) = line.strip_prefix("Exec=") {
245            if exec.is_none() && exec_command(value).is_some() {
246                exec = Some(value.to_string());
247            }
248        } else if let Some(value) = line.strip_prefix("StartupWMClass=") {
249            wm_class = Some(value.trim().to_string());
250        } else if line == "NoDisplay=true" || line == "Hidden=true" {
251            no_display = true;
252        } else if line.starts_with("Type=") && line != "Type=Application" {
253            return None;
254        }
255    }
256
257    if no_display {
258        return None;
259    }
260
261    Some(Entry {
262        name: name?,
263        exec: exec?,
264        stem: path
265            .file_stem()
266            .map(|s| s.to_string_lossy().into_owned())
267            .unwrap_or_default(),
268        wm_class,
269    })
270}
271
272/// Turn a desktop file `Exec` value into a shell-quoted command line: field
273/// codes (%u, %F, ...) removed and each argument quoted when it holds spaces
274/// or shell characters, so a shell-style split gives back the same
275/// arguments. An `env VAR=value app` wrapper is kept whole, with `env` as
276/// the program, so the app still gets its variables. `None` if nothing is
277/// left, an `env` wrapper names no program, or a quote is not closed.
278pub fn exec_command(value: &str) -> Option<String> {
279    let args = split_exec(&unescape_value(value))?;
280    let args: Vec<String> = args.into_iter().filter_map(expand_field_codes).collect();
281    if args.is_empty() {
282        return None;
283    }
284    if args[0] == "env" && !env_runs_a_program(&args[1..]) {
285        return None;
286    }
287    Some(
288        args.iter()
289            .map(|a| shell_quote(a))
290            .collect::<Vec<_>>()
291            .join(" "),
292    )
293}
294
295/// Whether the arguments after `env` name a program to run, rather than only
296/// settings (`VAR=value`, `-u NAME`, `-i`, ...), in which case `env` would
297/// just print the environment.
298fn env_runs_a_program(args: &[String]) -> bool {
299    let mut args = args.iter();
300    while let Some(arg) = args.next() {
301        match arg.as_str() {
302            // These take the next argument as their value.
303            "-u" | "--unset" | "-C" | "--chdir" => {
304                args.next();
305            }
306            // The string holds the command line itself.
307            "-S" | "--split-string" => return args.next().is_some(),
308            "--" => return args.next().is_some(),
309            a if a.starts_with("-S") && a.len() > 2 => return true,
310            a if a.starts_with('-') => {}
311            a if a.contains('=') => {}
312            _ => return true,
313        }
314    }
315    false
316}
317
318/// Undo the escapes every desktop file string value may use: \s, \n, \t,
319/// \r and \\.
320fn unescape_value(value: &str) -> String {
321    let mut out = String::with_capacity(value.len());
322    let mut chars = value.chars();
323    while let Some(c) = chars.next() {
324        if c != '\\' {
325            out.push(c);
326            continue;
327        }
328        match chars.next() {
329            Some('s') => out.push(' '),
330            Some('n') => out.push('\n'),
331            Some('t') => out.push('\t'),
332            Some('r') => out.push('\r'),
333            Some('\\') => out.push('\\'),
334            Some(other) => {
335                out.push('\\');
336                out.push(other);
337            }
338            None => out.push('\\'),
339        }
340    }
341    out
342}
343
344/// Split an Exec value into arguments. Double quotes group an argument;
345/// inside them a backslash escapes `"`, `` ` ``, `$` and `\`. `None` when
346/// a quote is left open.
347fn split_exec(value: &str) -> Option<Vec<String>> {
348    let mut args = Vec::new();
349    let mut current = String::new();
350    let mut in_arg = false;
351    let mut chars = value.chars();
352    while let Some(c) = chars.next() {
353        match c {
354            '"' => {
355                in_arg = true;
356                loop {
357                    match chars.next()? {
358                        '"' => break,
359                        '\\' => match chars.next()? {
360                            e @ ('"' | '`' | '$' | '\\') => current.push(e),
361                            other => {
362                                current.push('\\');
363                                current.push(other);
364                            }
365                        },
366                        other => current.push(other),
367                    }
368                }
369            }
370            c if c.is_whitespace() => {
371                if in_arg {
372                    args.push(std::mem::take(&mut current));
373                    in_arg = false;
374                }
375            }
376            c => {
377                in_arg = true;
378                current.push(c);
379            }
380        }
381    }
382    if in_arg {
383        args.push(current);
384    }
385    Some(args)
386}
387
388/// Remove field codes from one argument: an argument that is only a code
389/// (such as %U) is dropped, `%%` becomes `%`, and codes inside an argument
390/// are removed.
391fn expand_field_codes(arg: String) -> Option<String> {
392    if arg.len() == 2 && arg.starts_with('%') && arg != "%%" {
393        return None;
394    }
395    let mut out = String::with_capacity(arg.len());
396    let mut chars = arg.chars();
397    while let Some(c) = chars.next() {
398        if c != '%' {
399            out.push(c);
400        } else if let Some(next) = chars.next() {
401            if next == '%' {
402                out.push('%');
403            }
404        }
405    }
406    Some(out)
407}
408
409/// Quote `arg` for a POSIX shell-style split, leaving plain words as they
410/// are.
411fn shell_quote(arg: &str) -> String {
412    let plain = !arg.is_empty()
413        && arg
414            .chars()
415            .all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
416    if plain {
417        arg.to_string()
418    } else {
419        format!("'{}'", arg.replace('\'', r"'\''"))
420    }
421}
422
423/// Search PATH for executables matching a query
424pub fn search_cli_apps(query: &str) -> Vec<DesktopApp> {
425    if query.len() < 2 {
426        return Vec::new();
427    }
428
429    let query_lower = query.to_lowercase();
430    let mut apps = Vec::new();
431
432    if let Ok(path_var) = std::env::var("PATH") {
433        for dir in path_var.split(':') {
434            let dir_path = Path::new(dir);
435            if let Ok(entries) = fs::read_dir(dir_path) {
436                for entry in entries.flatten() {
437                    let name = entry.file_name().to_string_lossy().to_string();
438                    if !name.to_lowercase().contains(&query_lower) {
439                        continue;
440                    }
441
442                    // Check if executable
443                    if let Ok(meta) = entry.metadata() {
444                        if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
445                            apps.push(DesktopApp {
446                                name: format!("{} (CLI)", name),
447                                // Quoted, since a file name may hold spaces or
448                                // shell characters and the command is split
449                                // like a shell line.
450                                exec: shell_quote(&name),
451                                is_cli: true,
452                            });
453                        }
454                    }
455                }
456            }
457        }
458    }
459
460    apps.sort_by(|a, b| a.name.cmp(&b.name));
461    apps.dedup_by(|a, b| a.exec == b.exec);
462    apps
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn exec_drops_field_codes_and_keeps_arguments() {
471        assert_eq!(exec_command("firefox %u").as_deref(), Some("firefox"));
472        assert_eq!(
473            exec_command("code --new-window %F").as_deref(),
474            Some("code --new-window")
475        );
476        assert_eq!(
477            exec_command("app --pct=100%%").as_deref(),
478            Some("app --pct=100%")
479        );
480        assert_eq!(
481            exec_command("app --file=%f").as_deref(),
482            Some("app --file=")
483        );
484    }
485
486    #[test]
487    fn exec_keeps_quoted_arguments_together() {
488        assert_eq!(
489            exec_command(r#""/opt/My App/app" --flag %U"#).as_deref(),
490            Some("'/opt/My App/app' --flag")
491        );
492        assert_eq!(
493            exec_command(r#"sh -c "echo hi; sleep 1""#).as_deref(),
494            Some("sh -c 'echo hi; sleep 1'")
495        );
496        // Inside quotes a backslash escapes ", `, $ and \. The file itself
497        // writes a backslash as \\, so \\\\ in the file is one backslash.
498        assert_eq!(
499            exec_command(r#"app "say \\"hi\\"" "c:\\\\dir""#).as_deref(),
500            Some(r#"app 'say "hi"' 'c:\dir'"#)
501        );
502        assert_eq!(
503            exec_command("app \"it's\"").as_deref(),
504            Some(r"app 'it'\''s'")
505        );
506        // \s is unescaped before splitting, so it separates words.
507        assert_eq!(exec_command(r"my\sapp").as_deref(), Some("my app"));
508    }
509
510    #[test]
511    fn exec_keeps_env_wrappers_and_their_variables() {
512        assert_eq!(
513            exec_command("env FOO=1 BAR=\"a b\" app --x %f").as_deref(),
514            Some("env FOO=1 'BAR=a b' app --x")
515        );
516        assert_eq!(
517            exec_command("env -u GTK_THEME app").as_deref(),
518            Some("env -u GTK_THEME app")
519        );
520        assert_eq!(
521            exec_command("env -i -- app").as_deref(),
522            Some("env -i -- app")
523        );
524        assert_eq!(exec_command("env -u X"), None);
525        assert_eq!(exec_command("env -i"), None);
526    }
527
528    #[test]
529    fn cli_app_names_are_quoted() {
530        assert_eq!(shell_quote("my tool"), "'my tool'");
531        assert_eq!(shell_quote("rg"), "rg");
532        assert_eq!(shell_quote("a$b"), "'a$b'");
533    }
534
535    #[test]
536    fn exec_rejects_empty_or_unterminated_lines() {
537        assert_eq!(exec_command("%U"), None);
538        assert_eq!(exec_command("   "), None);
539        assert_eq!(exec_command("env A=1"), None);
540        assert_eq!(exec_command(r#"app "open"#), None);
541    }
542
543    #[test]
544    fn exec_program_is_the_basename_past_env() {
545        assert_eq!(exec_program("firefox %u").as_deref(), Some("firefox"));
546        assert_eq!(
547            exec_program("/usr/bin/google-chrome-stable %U").as_deref(),
548            Some("google-chrome-stable")
549        );
550        assert_eq!(
551            exec_program(r#""/opt/My App/app" --flag"#).as_deref(),
552            Some("app")
553        );
554        assert_eq!(
555            exec_program("env FOO=1 -u X /snap/bin/code --new-window").as_deref(),
556            Some("code")
557        );
558        assert_eq!(exec_program("env -i -- app").as_deref(), Some("app"));
559        assert_eq!(exec_program("env A=1"), None);
560        assert_eq!(exec_program("%U"), None);
561    }
562
563    #[test]
564    fn an_empty_name_is_ignored() {
565        let dir = tempfile::tempdir().unwrap();
566        let path = dir.path().join("x.desktop");
567        fs::write(&path, "[Desktop Entry]\nType=Application\nName=\nExec=x\n").unwrap();
568        assert!(read_entry(&path).is_none());
569        fs::write(&path, "[Desktop Entry]\nType=Application\nName=X\nExec=x\n").unwrap();
570        assert_eq!(read_entry(&path).map(|e| e.name).as_deref(), Some("X"));
571    }
572
573    fn entry(stem: &str, name: &str, exec: &str, wm_class: Option<&str>) -> Entry {
574        Entry {
575            name: name.into(),
576            exec: exec.into(),
577            stem: stem.into(),
578            wm_class: wm_class.map(Into::into),
579        }
580    }
581
582    #[test]
583    fn a_single_entry_names_its_program() {
584        let names = names_from_entries(&[entry("firefox", "Firefox", "firefox %u", None)]);
585        assert_eq!(names.get("firefox").map(String::as_str), Some("Firefox"));
586        let names = names_from_entries(&[entry(
587            "org.mozilla.firefox",
588            "Firefox",
589            "/usr/bin/firefox %u",
590            None,
591        )]);
592        assert_eq!(names.get("firefox").map(String::as_str), Some("Firefox"));
593    }
594
595    #[test]
596    fn a_web_app_does_not_rename_its_browser() {
597        let names = names_from_entries(&[
598            entry(
599                "chrome-abc-Default",
600                "YouTube",
601                "/usr/bin/chromium --profile-directory=Default --app-id=abc",
602                None,
603            ),
604            entry("chromium", "Chromium", "/usr/bin/chromium %U", None),
605        ]);
606        assert_eq!(names.get("chromium").map(String::as_str), Some("Chromium"));
607        let names = names_from_entries(&[entry(
608            "chrome-abc-Default",
609            "YouTube",
610            "chromium --app=https://youtube.com",
611            None,
612        )]);
613        assert_eq!(names.get("chromium"), None);
614    }
615
616    #[test]
617    fn a_game_shortcut_does_not_rename_steam() {
618        let names = names_from_entries(&[
619            entry("Hades", "Hades", "steam steam://rungameid/1145360", None),
620            entry("steam", "Steam", "/usr/bin/steam %U", None),
621        ]);
622        assert_eq!(names.get("steam").map(String::as_str), Some("Steam"));
623    }
624
625    #[test]
626    fn a_terminal_command_does_not_rename_the_terminal() {
627        let names = names_from_entries(&[
628            entry("btop", "btop++", "kitty -e btop", None),
629            entry("kitty", "kitty", "kitty", None),
630        ]);
631        assert_eq!(names.get("kitty").map(String::as_str), Some("kitty"));
632        assert_eq!(names.get("btop"), None, "btop is not what runs");
633    }
634
635    #[test]
636    fn disagreeing_entries_need_a_matching_stem_or_class() {
637        let names = names_from_entries(&[
638            entry("code", "Visual Studio Code", "/usr/bin/code %F", None),
639            entry("my-project", "My Project", "/usr/bin/code /home/me/p", None),
640        ]);
641        assert_eq!(
642            names.get("code").map(String::as_str),
643            Some("Visual Studio Code")
644        );
645        let names = names_from_entries(&[
646            entry("a", "A", "tool", Some("Tool")),
647            entry("b", "B", "tool", None),
648        ]);
649        assert_eq!(names.get("tool").map(String::as_str), Some("A"));
650        let names =
651            names_from_entries(&[entry("a", "A", "tool", None), entry("b", "B", "tool", None)]);
652        assert_eq!(names.get("tool"), None, "still ambiguous");
653    }
654
655    #[test]
656    fn interpreters_do_not_take_an_apps_name() {
657        assert!(is_launcher("python3.14"));
658        assert!(is_launcher("flatpak"));
659        assert!(!is_launcher("firefox"));
660    }
661}