freedesktop_apps/
lib.rs

1use std::path::{Path, PathBuf};
2
3mod parser;
4use parser::{DesktopEntry, ValueType};
5
6// Re-export the ParseError from parser
7pub use parser::ParseError;
8
9#[derive(Debug)]
10pub enum FindError {
11    NotFound(String),        // Desktop entry ID not found
12    ParseError(ParseError),  // Failed to parse the desktop file
13    IoError(std::io::Error), // IO error during search
14}
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    /// Get the application name
80    pub fn name(&self) -> Option<String> {
81        self.get_string("Name")
82    }
83
84    /// Get the desktop file ID according to the freedesktop specification
85    /// 
86    /// The desktop file ID is computed by making the file path relative to the
87    /// XDG_DATA_DIRS component, removing "applications/" prefix, and converting
88    /// '/' to '-'. For example: /usr/share/applications/foo/bar.desktop → foo-bar.desktop
89    pub fn id(&self) -> Option<String> {
90        let file_path = &self.inner.path;
91        
92        // Check if this file is within any applications directory
93        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                // Convert path separators to dashes for subdirectories
97                return Some(desktop_entry_path.replace('/', "-"));
98            }
99        }
100        
101        // Fallback: just use filename without extension
102        file_path.file_stem()
103            .map(|name| name.to_string_lossy().to_string())
104    }
105
106    /// Get the executable command
107    pub fn exec(&self) -> Option<String> {
108        self.get_string("Exec")
109    }
110
111    /// Get the icon name or path
112    pub fn icon(&self) -> Option<String> {
113        self.get_string("Icon")
114    }
115
116    /// Get a string value from the Desktop Entry group
117    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    /// Get a localized string value from the Desktop Entry group
130    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    /// Get a boolean value from the Desktop Entry group
143    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    /// Get a numeric value from the Desktop Entry group
154    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    /// Get a vector of strings from the Desktop Entry group
165    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    /// Get the file path of this desktop entry
178    pub fn path(&self) -> &Path {
179        &self.inner.path
180    }
181
182    /// Get the entry type (Application, Link, Directory)
183    pub fn entry_type(&self) -> Option<String> {
184        self.get_string("Type")
185    }
186
187    /// Get generic name (e.g., "Web Browser")
188    pub fn generic_name(&self) -> Option<String> {
189        self.get_string("GenericName")
190    }
191
192    /// Get comment/description
193    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    /// Check if entry should be hidden
202    pub fn is_hidden(&self) -> bool {
203        self.get_bool("Hidden").unwrap_or(false)
204    }
205
206    /// Check if entry should not be displayed in menus
207    pub fn no_display(&self) -> bool {
208        self.get_bool("NoDisplay").unwrap_or(false)
209    }
210
211    /// Get supported MIME types
212    pub fn mime_types(&self) -> Option<Vec<String>> {
213        self.get_vec("MimeType")
214    }
215
216    /// Get categories
217    pub fn categories(&self) -> Option<Vec<String>> {
218        self.get_vec("Categories")
219    }
220
221    /// Get keywords for searching
222    pub fn keywords(&self) -> Option<Vec<String>> {
223        self.get_vec("Keywords")
224    }
225
226    /// Check if application runs in terminal
227    pub fn terminal(&self) -> bool {
228        self.get_bool("Terminal").unwrap_or(false)
229    }
230
231    /// Get working directory
232    pub fn path_dir(&self) -> Option<String> {
233        self.get_string("Path")
234    }
235
236    /// Execute this application with no files
237    pub fn execute(&self) -> Result<(), ExecuteError> {
238        self.execute_with_files(&[])
239    }
240
241    /// Execute this application with the given files
242    pub fn execute_with_files(&self, files: &[&str]) -> Result<(), ExecuteError> {
243        self.execute_internal(files, &[])
244    }
245
246    /// Execute this application with the given URLs
247    pub fn execute_with_urls(&self, urls: &[&str]) -> Result<(), ExecuteError> {
248        self.execute_internal(&[], urls)
249    }
250
251    /// Prepare the command for execution without actually executing it (for testing)
252    pub fn prepare_command(&self, files: &[&str], urls: &[&str]) -> Result<(String, Vec<String>), ExecuteError> {
253        // Validate the application can be executed
254        self.validate_executable()?;
255
256        // Get the command and arguments
257        let (program, args) = self.parse_exec_command(files, urls)?;
258
259        // Handle terminal applications
260        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        // Validate the application can be executed
271        self.validate_executable()?;
272
273        // Get the command and arguments
274        let (program, args) = self.parse_exec_command(files, urls)?;
275
276        // Handle terminal applications
277        let (final_program, final_args) = if self.terminal() {
278            self.wrap_with_terminal(&program, &args)?
279        } else {
280            (program, args)
281        };
282
283        // Set working directory if specified
284        let working_dir = self.path_dir();
285        
286        // Spawn the process detached
287        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        // Check if we have an Exec key
293        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        // Check TryExec if present
302        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(); // Already validated in validate_executable
315        
316        // Expand field codes
317        let expanded = self.expand_field_codes(&exec, files, urls);
318        
319        // Parse the command line
320        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(); // consume the next character
331                    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                        // Deprecated field codes - ignore
371                        'd' | 'D' | 'n' | 'N' | 'v' | 'm' => {},
372                        // Unknown field code - this is an error per spec
373                        _ => {
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        // Build the command to run in terminal
392        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    /// Get all application entries from standard directories
402    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    /// Create an ApplicationEntry from a path
419    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
420        Self::try_from_path(path)
421    }
422
423    /// Try to create an ApplicationEntry from a path, returning Result
424    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    /// Find an ApplicationEntry by its desktop file ID
432    /// 
433    /// The desktop file ID follows the freedesktop specification format.
434    /// For example: "foo-bar.desktop" would look for files like:
435    /// - /usr/share/applications/foo/bar.desktop
436    /// - /usr/share/applications/foo-bar.desktop
437    pub fn from_id(id: &str) -> Result<Self, FindError> {
438        // Convert dashes back to path separators for subdirectories
439        let path_with_slashes = id.replace('-', "/");
440        
441        // Try both the converted path and the original ID as filename
442        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                // Ensure .desktop extension
451                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
468/// Spawn a process completely detached from the current process while preserving display environment
469fn 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        // Set working directory if provided
483        if let Some(dir) = working_dir {
484            cmd.current_dir(dir);
485        }
486
487        // Explicitly preserve important environment variables
488        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                // Start new process group but don't create new session
507                // This allows detachment while preserving session environment
508                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        // Set working directory if provided
526        if let Some(dir) = working_dir {
527            cmd.current_dir(dir);
528        }
529        
530        cmd.spawn()?;
531        Ok(())
532    }
533}
534
535/// Check if an executable is available in PATH or as absolute path
536fn is_executable_available(executable: &str) -> bool {
537    use std::path::Path;
538    
539    if Path::new(executable).is_absolute() {
540        // Absolute path - check if file exists and is executable
541        Path::new(executable).exists()
542    } else {
543        // Relative path - check in PATH
544        which_command(executable).is_some()
545    }
546}
547
548/// Find an executable in PATH (simple implementation)
549fn 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
561/// Find an available terminal emulator
562fn find_terminal() -> Option<String> {
563    // First check TERMINAL environment variable
564    if let Ok(terminal) = std::env::var("TERMINAL") {
565        if is_executable_available(&terminal) {
566            return Some(terminal);
567        }
568    }
569    
570    // Try common terminal emulators
571    let terminals = [
572        "x-terminal-emulator",  // Debian/Ubuntu alternative
573        "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
592/// Escape a string for safe shell usage
593fn 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
601/// Parse a command line into program and arguments, handling quotes
602fn 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                // Handle escape sequences in quotes
620                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                // Skip multiple spaces
639                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}