1use common::Result;
2use std::collections::HashMap;
3use std::fs;
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7#[derive(Clone)]
9pub struct DesktopApp {
10 pub name: String,
11 pub exec: String,
12 pub is_cli: bool,
13}
14
15fn 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
30fn 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
48pub 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
65pub fn names_by_program() -> HashMap<String, String> {
68 names_from_entries(&desktop_entries())
69}
70
71fn 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
109fn 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
116fn 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
129fn 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
140fn 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
171pub fn exec_program(value: &str) -> Option<String> {
174 program_of(&exec_args(value)?)
175}
176
177fn 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
188fn 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
209struct Entry {
212 name: String,
213 exec: String,
214 stem: String,
215 wm_class: Option<String>,
216}
217
218fn 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
272pub 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
295fn 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 "-u" | "--unset" | "-C" | "--chdir" => {
304 args.next();
305 }
306 "-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
318fn 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
344fn 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
388fn 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
409fn 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
423pub 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 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 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 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 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}