1use common::Result;
2use std::fs;
3use std::os::unix::fs::PermissionsExt;
4use std::path::Path;
5
6#[derive(Clone)]
8pub struct DesktopApp {
9 pub name: String,
10 pub exec: String,
11 pub is_cli: bool,
12}
13
14pub 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 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
85pub 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
108fn 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 "-u" | "--unset" | "-C" | "--chdir" => {
117 args.next();
118 }
119 "-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
131fn 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
157fn 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
201fn 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
222fn 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
236pub 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 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 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 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 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}