1use std::path::{Path, PathBuf};
2
3mod parser;
4use parser::{DesktopEntry, ValueType};
5
6pub use parser::ParseError;
8
9#[derive(Debug)]
10pub enum FindError {
11 NotFound(String), ParseError(ParseError), IoError(std::io::Error), }
15
16impl std::fmt::Display for FindError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 FindError::NotFound(msg) => write!(f, "Desktop entry not found: {}", msg),
20 FindError::ParseError(err) => write!(f, "Parse error: {}", err),
21 FindError::IoError(err) => write!(f, "IO error: {}", err),
22 }
23 }
24}
25
26impl std::error::Error for FindError {}
27
28impl From<ParseError> for FindError {
29 fn from(err: ParseError) -> Self {
30 FindError::ParseError(err)
31 }
32}
33
34impl From<std::io::Error> for FindError {
35 fn from(err: std::io::Error) -> Self {
36 FindError::IoError(err)
37 }
38}
39
40#[derive(Debug, Clone)]
41pub enum ExecuteError {
42 NotExecutable(String),
43 TerminalNotFound,
44 InvalidCommand(String),
45 IoError(String),
46 ValidationFailed(String),
47}
48
49impl std::fmt::Display for ExecuteError {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 ExecuteError::NotExecutable(msg) => write!(f, "Application not executable: {}", msg),
53 ExecuteError::TerminalNotFound => write!(f, "No terminal emulator found"),
54 ExecuteError::InvalidCommand(msg) => write!(f, "Invalid command: {}", msg),
55 ExecuteError::IoError(msg) => write!(f, "I/O error: {}", msg),
56 ExecuteError::ValidationFailed(msg) => write!(f, "Validation failed: {}", msg),
57 }
58 }
59}
60
61impl std::error::Error for ExecuteError {}
62
63pub fn application_entry_paths() -> Vec<PathBuf> {
64 freedesktop_core::base_directories()
65 .iter()
66 .map(|path| path.join("applications"))
67 .filter(|path| path.exists())
68 .collect()
69}
70
71#[derive(Debug)]
72#[derive(Default)]
73pub struct ApplicationEntry {
74 inner: DesktopEntry,
75}
76
77
78impl ApplicationEntry {
79 pub fn name(&self) -> Option<String> {
81 self.get_string("Name")
82 }
83
84 pub fn id(&self) -> Option<String> {
90 let file_path = &self.inner.path;
91
92 if let Some(apps_pos) = file_path.to_string_lossy().find("/applications/") {
94 let after_apps = &file_path.to_string_lossy()[apps_pos + "/applications/".len()..];
95 if let Some(desktop_entry_path) = after_apps.strip_suffix(".desktop") {
96 return Some(desktop_entry_path.replace('/', "-"));
98 }
99 }
100
101 file_path.file_stem()
103 .map(|name| name.to_string_lossy().to_string())
104 }
105
106 pub fn exec(&self) -> Option<String> {
108 self.get_string("Exec")
109 }
110
111 pub fn icon(&self) -> Option<String> {
113 self.get_string("Icon")
114 }
115
116 pub fn get_string(&self, key: &str) -> Option<String> {
118 self.inner
119 .get_desktop_entry_group()
120 .and_then(|group| group.get_field(key))
121 .and_then(|value| match value {
122 ValueType::String(s) | ValueType::LocaleString(s) | ValueType::IconString(s) => {
123 Some(s.clone())
124 }
125 _ => None,
126 })
127 }
128
129 pub fn get_localized_string(&self, key: &str, locale: Option<&str>) -> Option<String> {
131 self.inner
132 .get_desktop_entry_group()
133 .and_then(|group| group.get_localized_field(key, locale))
134 .and_then(|value| match value {
135 ValueType::String(s) | ValueType::LocaleString(s) | ValueType::IconString(s) => {
136 Some(s.clone())
137 }
138 _ => None,
139 })
140 }
141
142 pub fn get_bool(&self, key: &str) -> Option<bool> {
144 self.inner
145 .get_desktop_entry_group()
146 .and_then(|group| group.get_field(key))
147 .and_then(|value| match value {
148 ValueType::Boolean(b) => Some(*b),
149 _ => None,
150 })
151 }
152
153 pub fn get_numeric(&self, key: &str) -> Option<f64> {
155 self.inner
156 .get_desktop_entry_group()
157 .and_then(|group| group.get_field(key))
158 .and_then(|value| match value {
159 ValueType::Numeric(n) => Some(*n),
160 _ => None,
161 })
162 }
163
164 pub fn get_vec(&self, key: &str) -> Option<Vec<String>> {
166 self.inner
167 .get_desktop_entry_group()
168 .and_then(|group| group.get_field(key))
169 .and_then(|value| match value {
170 ValueType::StringList(list) | ValueType::LocaleStringList(list) => {
171 Some(list.clone())
172 }
173 _ => None,
174 })
175 }
176
177 pub fn path(&self) -> &Path {
179 &self.inner.path
180 }
181
182 pub fn entry_type(&self) -> Option<String> {
184 self.get_string("Type")
185 }
186
187 pub fn generic_name(&self) -> Option<String> {
189 self.get_string("GenericName")
190 }
191
192 pub fn comment(&self) -> Option<String> {
194 self.get_string("Comment")
195 }
196
197 pub fn should_show(&self) -> bool {
198 !self.is_hidden() && !self.no_display()
199 }
200
201 pub fn is_hidden(&self) -> bool {
203 self.get_bool("Hidden").unwrap_or(false)
204 }
205
206 pub fn no_display(&self) -> bool {
208 self.get_bool("NoDisplay").unwrap_or(false)
209 }
210
211 pub fn mime_types(&self) -> Option<Vec<String>> {
213 self.get_vec("MimeType")
214 }
215
216 pub fn categories(&self) -> Option<Vec<String>> {
218 self.get_vec("Categories")
219 }
220
221 pub fn keywords(&self) -> Option<Vec<String>> {
223 self.get_vec("Keywords")
224 }
225
226 pub fn terminal(&self) -> bool {
228 self.get_bool("Terminal").unwrap_or(false)
229 }
230
231 pub fn path_dir(&self) -> Option<String> {
233 self.get_string("Path")
234 }
235
236 pub fn execute(&self) -> Result<(), ExecuteError> {
238 self.execute_with_files(&[])
239 }
240
241 pub fn execute_with_files(&self, files: &[&str]) -> Result<(), ExecuteError> {
243 self.execute_internal(files, &[])
244 }
245
246 pub fn execute_with_urls(&self, urls: &[&str]) -> Result<(), ExecuteError> {
248 self.execute_internal(&[], urls)
249 }
250
251 pub fn prepare_command(&self, files: &[&str], urls: &[&str]) -> Result<(String, Vec<String>), ExecuteError> {
253 self.validate_executable()?;
255
256 let (program, args) = self.parse_exec_command(files, urls)?;
258
259 let (final_program, final_args) = if self.terminal() {
261 self.wrap_with_terminal(&program, &args)?
262 } else {
263 (program, args)
264 };
265
266 Ok((final_program, final_args))
267 }
268
269 fn execute_internal(&self, files: &[&str], urls: &[&str]) -> Result<(), ExecuteError> {
270 self.validate_executable()?;
272
273 let (program, args) = self.parse_exec_command(files, urls)?;
275
276 let (final_program, final_args) = if self.terminal() {
278 self.wrap_with_terminal(&program, &args)?
279 } else {
280 (program, args)
281 };
282
283 let working_dir = self.path_dir();
285
286 spawn_detached_with_env(&final_program, &final_args, working_dir.as_deref())
288 .map_err(|e| ExecuteError::IoError(format!("Failed to spawn process: {}", e)))
289 }
290
291 fn validate_executable(&self) -> Result<(), ExecuteError> {
292 let exec = self.exec().ok_or_else(|| {
294 ExecuteError::NotExecutable("No Exec key found".to_string())
295 })?;
296
297 if exec.trim().is_empty() {
298 return Err(ExecuteError::NotExecutable("Exec key is empty".to_string()));
299 }
300
301 if let Some(try_exec) = self.get_string("TryExec") {
303 if !is_executable_available(&try_exec) {
304 return Err(ExecuteError::ValidationFailed(
305 format!("TryExec '{}' not found or not executable", try_exec)
306 ));
307 }
308 }
309
310 Ok(())
311 }
312
313 fn parse_exec_command(&self, files: &[&str], urls: &[&str]) -> Result<(String, Vec<String>), ExecuteError> {
314 let exec = self.exec().unwrap(); let expanded = self.expand_field_codes(&exec, files, urls);
318
319 parse_command_line(&expanded)
321 }
322
323 fn expand_field_codes(&self, exec: &str, files: &[&str], urls: &[&str]) -> String {
324 let mut result = String::new();
325 let mut chars = exec.chars().peekable();
326
327 while let Some(ch) = chars.next() {
328 if ch == '%' {
329 if let Some(&next_ch) = chars.peek() {
330 chars.next(); match next_ch {
332 '%' => result.push('%'),
333 'f' => {
334 if let Some(file) = files.first() {
335 result.push_str(&shell_escape(file));
336 }
337 },
338 'F' => {
339 for (i, file) in files.iter().enumerate() {
340 if i > 0 { result.push(' '); }
341 result.push_str(&shell_escape(file));
342 }
343 },
344 'u' => {
345 if let Some(url) = urls.first() {
346 result.push_str(&shell_escape(url));
347 }
348 },
349 'U' => {
350 for (i, url) in urls.iter().enumerate() {
351 if i > 0 { result.push(' '); }
352 result.push_str(&shell_escape(url));
353 }
354 },
355 'i' => {
356 if let Some(icon) = self.icon() {
357 result.push_str("--icon ");
358 result.push_str(&shell_escape(&icon));
359 }
360 },
361 'c' => {
362 if let Some(name) = self.name() {
363 result.push_str(&shell_escape(&name));
364 }
365 },
366 'k' => {
367 let path = self.path().to_string_lossy();
368 result.push_str(&shell_escape(&path));
369 },
370 'd' | 'D' | 'n' | 'N' | 'v' | 'm' => {},
372 _ => {
374 return format!("{}%{}{}", result, next_ch, chars.collect::<String>());
375 }
376 }
377 } else {
378 result.push(ch);
379 }
380 } else {
381 result.push(ch);
382 }
383 }
384
385 result
386 }
387
388 fn wrap_with_terminal(&self, program: &str, args: &[String]) -> Result<(String, Vec<String>), ExecuteError> {
389 let terminal = find_terminal().ok_or(ExecuteError::TerminalNotFound)?;
390
391 let mut terminal_args = vec!["-e".to_string()];
393 terminal_args.push(program.to_string());
394 terminal_args.extend(args.iter().cloned());
395
396 Ok((terminal, terminal_args))
397 }
398}
399
400impl ApplicationEntry {
401 pub fn all() -> Vec<ApplicationEntry> {
403 let mut entries: Vec<ApplicationEntry> = Vec::new();
404 for p in application_entry_paths() {
405 if let Ok(dir_entries) = std::fs::read_dir(p) {
406 for entry in dir_entries.filter_map(|e| e.ok()) {
407 if entry.path().extension().is_some_and(|ext| ext == "desktop") {
408 if let Ok(app_entry) = ApplicationEntry::from_path(entry.path()) {
409 entries.push(app_entry);
410 }
411 }
412 }
413 }
414 }
415 entries
416 }
417
418 pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
420 Self::try_from_path(path)
421 }
422
423 pub fn try_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
425 let desktop_entry = DesktopEntry::from_path(path)?;
426 Ok(ApplicationEntry {
427 inner: desktop_entry,
428 })
429 }
430
431 pub fn from_id(id: &str) -> Result<Self, FindError> {
438 let path_with_slashes = id.replace('-', "/");
440
441 let candidates = if path_with_slashes != id {
443 vec![path_with_slashes, id.to_string()]
444 } else {
445 vec![id.to_string()]
446 };
447
448 for app_dir in application_entry_paths() {
449 for candidate in &candidates {
450 let desktop_file = if candidate.ends_with(".desktop") {
452 candidate.clone()
453 } else {
454 format!("{}.desktop", candidate)
455 };
456
457 let full_path = app_dir.join(&desktop_file);
458 if full_path.exists() {
459 return Self::from_path(&full_path).map_err(FindError::from);
460 }
461 }
462 }
463
464 Err(FindError::NotFound(format!("No desktop entry found for ID: {}", id)))
465 }
466}
467
468fn spawn_detached_with_env(program: &str, args: &[String], working_dir: Option<&str>) -> Result<(), std::io::Error> {
470 use std::process::{Command, Stdio};
471
472 #[cfg(unix)]
473 {
474 use std::os::unix::process::CommandExt;
475
476 let mut cmd = Command::new(program);
477 cmd.args(args)
478 .stdin(Stdio::null())
479 .stdout(Stdio::null())
480 .stderr(Stdio::null());
481
482 if let Some(dir) = working_dir {
484 cmd.current_dir(dir);
485 }
486
487 if let Ok(wayland_display) = std::env::var("WAYLAND_DISPLAY") {
489 cmd.env("WAYLAND_DISPLAY", wayland_display);
490 }
491 if let Ok(display) = std::env::var("DISPLAY") {
492 cmd.env("DISPLAY", display);
493 }
494 if let Ok(xdg_runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
495 cmd.env("XDG_RUNTIME_DIR", xdg_runtime_dir);
496 }
497 if let Ok(xdg_session_type) = std::env::var("XDG_SESSION_TYPE") {
498 cmd.env("XDG_SESSION_TYPE", xdg_session_type);
499 }
500 if let Ok(xdg_current_desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
501 cmd.env("XDG_CURRENT_DESKTOP", xdg_current_desktop);
502 }
503
504 unsafe {
505 cmd.pre_exec(|| {
506 libc::setpgid(0, 0);
509 Ok(())
510 });
511 }
512
513 cmd.spawn()?;
514 Ok(())
515 }
516
517 #[cfg(not(unix))]
518 {
519 let mut cmd = Command::new(program);
520 cmd.args(args)
521 .stdin(Stdio::null())
522 .stdout(Stdio::null())
523 .stderr(Stdio::null());
524
525 if let Some(dir) = working_dir {
527 cmd.current_dir(dir);
528 }
529
530 cmd.spawn()?;
531 Ok(())
532 }
533}
534
535fn is_executable_available(executable: &str) -> bool {
537 use std::path::Path;
538
539 if Path::new(executable).is_absolute() {
540 Path::new(executable).exists()
542 } else {
543 which_command(executable).is_some()
545 }
546}
547
548fn which_command(executable: &str) -> Option<String> {
550 if let Ok(path_var) = std::env::var("PATH") {
551 for path_dir in path_var.split(':') {
552 let full_path = format!("{}/{}", path_dir, executable);
553 if std::path::Path::new(&full_path).exists() {
554 return Some(full_path);
555 }
556 }
557 }
558 None
559}
560
561fn find_terminal() -> Option<String> {
563 if let Ok(terminal) = std::env::var("TERMINAL") {
565 if is_executable_available(&terminal) {
566 return Some(terminal);
567 }
568 }
569
570 let terminals = [
572 "x-terminal-emulator", "gnome-terminal",
574 "konsole",
575 "xfce4-terminal",
576 "mate-terminal",
577 "lxterminal",
578 "rxvt-unicode",
579 "rxvt",
580 "xterm",
581 ];
582
583 for terminal in &terminals {
584 if is_executable_available(terminal) {
585 return Some(terminal.to_string());
586 }
587 }
588
589 None
590}
591
592fn shell_escape(s: &str) -> String {
594 if s.chars().any(|c| " \t\n'\"\\$`()[]{}?*~&|;<>".contains(c)) {
595 format!("'{}'", s.replace('\'', "'\"'\"'"))
596 } else {
597 s.to_string()
598 }
599}
600
601fn parse_command_line(command: &str) -> Result<(String, Vec<String>), ExecuteError> {
603 let mut parts = Vec::new();
604 let mut current = String::new();
605 let mut in_quotes = false;
606 let mut quote_char = '"';
607 let mut chars = command.chars().peekable();
608
609 while let Some(ch) = chars.next() {
610 match ch {
611 '"' | '\'' if !in_quotes => {
612 in_quotes = true;
613 quote_char = ch;
614 },
615 ch if ch == quote_char && in_quotes => {
616 in_quotes = false;
617 },
618 '\\' if in_quotes => {
619 if let Some(&next_ch) = chars.peek() {
621 chars.next();
622 match next_ch {
623 '"' | '\'' | '\\' | '$' | '`' => current.push(next_ch),
624 _ => {
625 current.push('\\');
626 current.push(next_ch);
627 }
628 }
629 } else {
630 current.push('\\');
631 }
632 },
633 ' ' | '\t' if !in_quotes => {
634 if !current.is_empty() {
635 parts.push(current);
636 current = String::new();
637 }
638 while chars.peek() == Some(&' ') || chars.peek() == Some(&'\t') {
640 chars.next();
641 }
642 },
643 _ => current.push(ch),
644 }
645 }
646
647 if !current.is_empty() {
648 parts.push(current);
649 }
650
651 if in_quotes {
652 return Err(ExecuteError::InvalidCommand("Unterminated quote".to_string()));
653 }
654
655 if parts.is_empty() {
656 return Err(ExecuteError::InvalidCommand("Empty command".to_string()));
657 }
658
659 let program = parts.remove(0);
660 Ok((program, parts))
661}