Skip to main content

rlmctl_core/
desktop.rs

1use common::Result;
2use std::fs;
3use std::os::unix::fs::PermissionsExt;
4use std::path::Path;
5
6/// Desktop application entry
7#[derive(Clone)]
8pub struct DesktopApp {
9    pub name: String,
10    pub exec: String,
11    pub is_cli: bool,
12}
13
14/// List installed applications from .desktop files
15pub fn list_applications() -> Result<Vec<DesktopApp>> {
16    let mut apps = Vec::new();
17    let dirs = [
18        "/usr/share/applications",
19        "/usr/local/share/applications",
20        "/var/lib/flatpak/exports/share/applications",
21    ];
22
23    // Also check user's local applications
24    let home_apps = dirs::data_dir().map(|d| d.join("applications"));
25
26    for dir in dirs.iter().map(Path::new).chain(home_apps.as_deref()) {
27        if let Ok(entries) = fs::read_dir(dir) {
28            for entry in entries.flatten() {
29                let path = entry.path();
30                if path.extension().is_some_and(|e| e == "desktop") {
31                    if let Some(app) = parse_desktop_file(&path) {
32                        apps.push(app);
33                    }
34                }
35            }
36        }
37    }
38
39    apps.sort_by_key(|a| a.name.to_lowercase());
40    apps.dedup_by(|a, b| a.name == b.name);
41    Ok(apps)
42}
43
44fn parse_desktop_file(path: &Path) -> Option<DesktopApp> {
45    let content = fs::read_to_string(path).ok()?;
46    let mut name = None;
47    let mut exec = None;
48    let mut no_display = false;
49    let mut in_desktop_entry = false;
50
51    for line in content.lines() {
52        let line = line.trim();
53
54        if line.starts_with('[') {
55            in_desktop_entry = line == "[Desktop Entry]";
56            continue;
57        }
58
59        if !in_desktop_entry {
60            continue;
61        }
62
63        if line.starts_with("Name=") && name.is_none() {
64            name = Some(line[5..].to_string());
65        } else if line.starts_with("Exec=") && exec.is_none() {
66            exec = exec_command(&line[5..]);
67        } else if line == "NoDisplay=true" || line == "Hidden=true" {
68            no_display = true;
69        } else if line.starts_with("Type=") && line != "Type=Application" {
70            return None;
71        }
72    }
73
74    if no_display {
75        return None;
76    }
77
78    Some(DesktopApp {
79        name: name?,
80        exec: exec?,
81        is_cli: false,
82    })
83}
84
85/// Turn a desktop file `Exec` value into a shell-quoted command line: field
86/// codes (%u, %F, ...) removed and each argument quoted when it holds spaces
87/// or shell characters, so a shell-style split gives back the same
88/// arguments. An `env VAR=value app` wrapper is kept whole, with `env` as
89/// the program, so the app still gets its variables. `None` if nothing is
90/// left, an `env` wrapper names no program, or a quote is not closed.
91pub fn exec_command(value: &str) -> Option<String> {
92    let args = split_exec(&unescape_value(value))?;
93    let args: Vec<String> = args.into_iter().filter_map(expand_field_codes).collect();
94    if args.is_empty() {
95        return None;
96    }
97    if args[0] == "env" && !env_runs_a_program(&args[1..]) {
98        return None;
99    }
100    Some(
101        args.iter()
102            .map(|a| shell_quote(a))
103            .collect::<Vec<_>>()
104            .join(" "),
105    )
106}
107
108/// Whether the arguments after `env` name a program to run, rather than only
109/// settings (`VAR=value`, `-u NAME`, `-i`, ...), in which case `env` would
110/// just print the environment.
111fn env_runs_a_program(args: &[String]) -> bool {
112    let mut args = args.iter();
113    while let Some(arg) = args.next() {
114        match arg.as_str() {
115            // These take the next argument as their value.
116            "-u" | "--unset" | "-C" | "--chdir" => {
117                args.next();
118            }
119            // The string holds the command line itself.
120            "-S" | "--split-string" => return args.next().is_some(),
121            "--" => return args.next().is_some(),
122            a if a.starts_with("-S") && a.len() > 2 => return true,
123            a if a.starts_with('-') => {}
124            a if a.contains('=') => {}
125            _ => return true,
126        }
127    }
128    false
129}
130
131/// Undo the escapes every desktop file string value may use: \s, \n, \t,
132/// \r and \\.
133fn unescape_value(value: &str) -> String {
134    let mut out = String::with_capacity(value.len());
135    let mut chars = value.chars();
136    while let Some(c) = chars.next() {
137        if c != '\\' {
138            out.push(c);
139            continue;
140        }
141        match chars.next() {
142            Some('s') => out.push(' '),
143            Some('n') => out.push('\n'),
144            Some('t') => out.push('\t'),
145            Some('r') => out.push('\r'),
146            Some('\\') => out.push('\\'),
147            Some(other) => {
148                out.push('\\');
149                out.push(other);
150            }
151            None => out.push('\\'),
152        }
153    }
154    out
155}
156
157/// Split an Exec value into arguments. Double quotes group an argument;
158/// inside them a backslash escapes `"`, `` ` ``, `$` and `\`. `None` when
159/// a quote is left open.
160fn split_exec(value: &str) -> Option<Vec<String>> {
161    let mut args = Vec::new();
162    let mut current = String::new();
163    let mut in_arg = false;
164    let mut chars = value.chars();
165    while let Some(c) = chars.next() {
166        match c {
167            '"' => {
168                in_arg = true;
169                loop {
170                    match chars.next()? {
171                        '"' => break,
172                        '\\' => match chars.next()? {
173                            e @ ('"' | '`' | '$' | '\\') => current.push(e),
174                            other => {
175                                current.push('\\');
176                                current.push(other);
177                            }
178                        },
179                        other => current.push(other),
180                    }
181                }
182            }
183            c if c.is_whitespace() => {
184                if in_arg {
185                    args.push(std::mem::take(&mut current));
186                    in_arg = false;
187                }
188            }
189            c => {
190                in_arg = true;
191                current.push(c);
192            }
193        }
194    }
195    if in_arg {
196        args.push(current);
197    }
198    Some(args)
199}
200
201/// Remove field codes from one argument: an argument that is only a code
202/// (such as %U) is dropped, `%%` becomes `%`, and codes inside an argument
203/// are removed.
204fn expand_field_codes(arg: String) -> Option<String> {
205    if arg.len() == 2 && arg.starts_with('%') && arg != "%%" {
206        return None;
207    }
208    let mut out = String::with_capacity(arg.len());
209    let mut chars = arg.chars();
210    while let Some(c) = chars.next() {
211        if c != '%' {
212            out.push(c);
213        } else if let Some(next) = chars.next() {
214            if next == '%' {
215                out.push('%');
216            }
217        }
218    }
219    Some(out)
220}
221
222/// Quote `arg` for a POSIX shell-style split, leaving plain words as they
223/// are.
224fn shell_quote(arg: &str) -> String {
225    let plain = !arg.is_empty()
226        && arg
227            .chars()
228            .all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
229    if plain {
230        arg.to_string()
231    } else {
232        format!("'{}'", arg.replace('\'', r"'\''"))
233    }
234}
235
236/// Search PATH for executables matching a query
237pub fn search_cli_apps(query: &str) -> Vec<DesktopApp> {
238    if query.len() < 2 {
239        return Vec::new();
240    }
241
242    let query_lower = query.to_lowercase();
243    let mut apps = Vec::new();
244
245    if let Ok(path_var) = std::env::var("PATH") {
246        for dir in path_var.split(':') {
247            let dir_path = Path::new(dir);
248            if let Ok(entries) = fs::read_dir(dir_path) {
249                for entry in entries.flatten() {
250                    let name = entry.file_name().to_string_lossy().to_string();
251                    if !name.to_lowercase().contains(&query_lower) {
252                        continue;
253                    }
254
255                    // Check if executable
256                    if let Ok(meta) = entry.metadata() {
257                        if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
258                            apps.push(DesktopApp {
259                                name: format!("{} (CLI)", name),
260                                // Quoted, since a file name may hold spaces or
261                                // shell characters and the command is split
262                                // like a shell line.
263                                exec: shell_quote(&name),
264                                is_cli: true,
265                            });
266                        }
267                    }
268                }
269            }
270        }
271    }
272
273    apps.sort_by(|a, b| a.name.cmp(&b.name));
274    apps.dedup_by(|a, b| a.exec == b.exec);
275    apps
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn exec_drops_field_codes_and_keeps_arguments() {
284        assert_eq!(exec_command("firefox %u").as_deref(), Some("firefox"));
285        assert_eq!(
286            exec_command("code --new-window %F").as_deref(),
287            Some("code --new-window")
288        );
289        assert_eq!(
290            exec_command("app --pct=100%%").as_deref(),
291            Some("app --pct=100%")
292        );
293        assert_eq!(
294            exec_command("app --file=%f").as_deref(),
295            Some("app --file=")
296        );
297    }
298
299    #[test]
300    fn exec_keeps_quoted_arguments_together() {
301        assert_eq!(
302            exec_command(r#""/opt/My App/app" --flag %U"#).as_deref(),
303            Some("'/opt/My App/app' --flag")
304        );
305        assert_eq!(
306            exec_command(r#"sh -c "echo hi; sleep 1""#).as_deref(),
307            Some("sh -c 'echo hi; sleep 1'")
308        );
309        // Inside quotes a backslash escapes ", `, $ and \. The file itself
310        // writes a backslash as \\, so \\\\ in the file is one backslash.
311        assert_eq!(
312            exec_command(r#"app "say \\"hi\\"" "c:\\\\dir""#).as_deref(),
313            Some(r#"app 'say "hi"' 'c:\dir'"#)
314        );
315        assert_eq!(
316            exec_command("app \"it's\"").as_deref(),
317            Some(r"app 'it'\''s'")
318        );
319        // \s is unescaped before splitting, so it separates words.
320        assert_eq!(exec_command(r"my\sapp").as_deref(), Some("my app"));
321    }
322
323    #[test]
324    fn exec_keeps_env_wrappers_and_their_variables() {
325        assert_eq!(
326            exec_command("env FOO=1 BAR=\"a b\" app --x %f").as_deref(),
327            Some("env FOO=1 'BAR=a b' app --x")
328        );
329        assert_eq!(
330            exec_command("env -u GTK_THEME app").as_deref(),
331            Some("env -u GTK_THEME app")
332        );
333        assert_eq!(
334            exec_command("env -i -- app").as_deref(),
335            Some("env -i -- app")
336        );
337        assert_eq!(exec_command("env -u X"), None);
338        assert_eq!(exec_command("env -i"), None);
339    }
340
341    #[test]
342    fn cli_app_names_are_quoted() {
343        assert_eq!(shell_quote("my tool"), "'my tool'");
344        assert_eq!(shell_quote("rg"), "rg");
345        assert_eq!(shell_quote("a$b"), "'a$b'");
346    }
347
348    #[test]
349    fn exec_rejects_empty_or_unterminated_lines() {
350        assert_eq!(exec_command("%U"), None);
351        assert_eq!(exec_command("   "), None);
352        assert_eq!(exec_command("env A=1"), None);
353        assert_eq!(exec_command(r#"app "open"#), None);
354    }
355}