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, Clone)]
10pub enum ExecuteError {
11    NotExecutable(String),
12    TerminalNotFound,
13    InvalidCommand(String),
14    IoError(String),
15    ValidationFailed(String),
16}
17
18pub fn application_entry_paths() -> Vec<PathBuf> {
19    freedesktop_core::base_directories()
20        .iter()
21        .map(|path| path.join("applications"))
22        .filter(|path| path.exists())
23        .collect()
24}
25
26#[derive(Debug)]
27#[derive(Default)]
28pub struct ApplicationEntry {
29    inner: DesktopEntry,
30}
31
32
33impl ApplicationEntry {
34    /// Get the application name
35    pub fn name(&self) -> Option<String> {
36        self.get_string("Name")
37    }
38
39    /// Get the desktop file ID according to the freedesktop specification
40    /// 
41    /// The desktop file ID is computed by making the file path relative to the
42    /// XDG_DATA_DIRS component, removing "applications/" prefix, and converting
43    /// '/' to '-'. For example: /usr/share/applications/foo/bar.desktop → foo-bar.desktop
44    pub fn id(&self) -> Option<String> {
45        let file_path = &self.inner.path;
46        
47        // Check if this file is within any applications directory
48        if let Some(apps_pos) = file_path.to_string_lossy().find("/applications/") {
49            let after_apps = &file_path.to_string_lossy()[apps_pos + "/applications/".len()..];
50            if let Some(desktop_entry_path) = after_apps.strip_suffix(".desktop") {
51                // Convert path separators to dashes for subdirectories
52                return Some(desktop_entry_path.replace('/', "-"));
53            }
54        }
55        
56        // Fallback: just use filename without extension
57        file_path.file_stem()
58            .map(|name| name.to_string_lossy().to_string())
59    }
60
61    /// Get the executable command
62    pub fn exec(&self) -> Option<String> {
63        self.get_string("Exec")
64    }
65
66    /// Get the icon name or path
67    pub fn icon(&self) -> Option<String> {
68        self.get_string("Icon")
69    }
70
71    /// Get a string value from the Desktop Entry group
72    pub fn get_string(&self, key: &str) -> Option<String> {
73        self.inner
74            .get_desktop_entry_group()
75            .and_then(|group| group.get_field(key))
76            .and_then(|value| match value {
77                ValueType::String(s) | ValueType::LocaleString(s) | ValueType::IconString(s) => {
78                    Some(s.clone())
79                }
80                _ => None,
81            })
82    }
83
84    /// Get a localized string value from the Desktop Entry group
85    pub fn get_localized_string(&self, key: &str, locale: Option<&str>) -> Option<String> {
86        self.inner
87            .get_desktop_entry_group()
88            .and_then(|group| group.get_localized_field(key, locale))
89            .and_then(|value| match value {
90                ValueType::String(s) | ValueType::LocaleString(s) | ValueType::IconString(s) => {
91                    Some(s.clone())
92                }
93                _ => None,
94            })
95    }
96
97    /// Get a boolean value from the Desktop Entry group
98    pub fn get_bool(&self, key: &str) -> Option<bool> {
99        self.inner
100            .get_desktop_entry_group()
101            .and_then(|group| group.get_field(key))
102            .and_then(|value| match value {
103                ValueType::Boolean(b) => Some(*b),
104                _ => None,
105            })
106    }
107
108    /// Get a numeric value from the Desktop Entry group
109    pub fn get_numeric(&self, key: &str) -> Option<f64> {
110        self.inner
111            .get_desktop_entry_group()
112            .and_then(|group| group.get_field(key))
113            .and_then(|value| match value {
114                ValueType::Numeric(n) => Some(*n),
115                _ => None,
116            })
117    }
118
119    /// Get a vector of strings from the Desktop Entry group
120    pub fn get_vec(&self, key: &str) -> Option<Vec<String>> {
121        self.inner
122            .get_desktop_entry_group()
123            .and_then(|group| group.get_field(key))
124            .and_then(|value| match value {
125                ValueType::StringList(list) | ValueType::LocaleStringList(list) => {
126                    Some(list.clone())
127                }
128                _ => None,
129            })
130    }
131
132    /// Get the file path of this desktop entry
133    pub fn path(&self) -> &Path {
134        &self.inner.path
135    }
136
137    /// Get the entry type (Application, Link, Directory)
138    pub fn entry_type(&self) -> Option<String> {
139        self.get_string("Type")
140    }
141
142    /// Get generic name (e.g., "Web Browser")
143    pub fn generic_name(&self) -> Option<String> {
144        self.get_string("GenericName")
145    }
146
147    /// Get comment/description
148    pub fn comment(&self) -> Option<String> {
149        self.get_string("Comment")
150    }
151
152    pub fn should_show(&self) -> bool {
153        !self.is_hidden() && !self.no_display()
154    }
155
156    /// Check if entry should be hidden
157    pub fn is_hidden(&self) -> bool {
158        self.get_bool("Hidden").unwrap_or(false)
159    }
160
161    /// Check if entry should not be displayed in menus
162    pub fn no_display(&self) -> bool {
163        self.get_bool("NoDisplay").unwrap_or(false)
164    }
165
166    /// Get supported MIME types
167    pub fn mime_types(&self) -> Option<Vec<String>> {
168        self.get_vec("MimeType")
169    }
170
171    /// Get categories
172    pub fn categories(&self) -> Option<Vec<String>> {
173        self.get_vec("Categories")
174    }
175
176    /// Get keywords for searching
177    pub fn keywords(&self) -> Option<Vec<String>> {
178        self.get_vec("Keywords")
179    }
180
181    /// Check if application runs in terminal
182    pub fn terminal(&self) -> bool {
183        self.get_bool("Terminal").unwrap_or(false)
184    }
185
186    /// Get working directory
187    pub fn path_dir(&self) -> Option<String> {
188        self.get_string("Path")
189    }
190
191    /// Execute this application with no files
192    pub fn execute(&self) -> Result<(), ExecuteError> {
193        self.execute_with_files(&[])
194    }
195
196    /// Execute this application with the given files
197    pub fn execute_with_files(&self, files: &[&str]) -> Result<(), ExecuteError> {
198        self.execute_internal(files, &[])
199    }
200
201    /// Execute this application with the given URLs
202    pub fn execute_with_urls(&self, urls: &[&str]) -> Result<(), ExecuteError> {
203        self.execute_internal(&[], urls)
204    }
205
206    /// Prepare the command for execution without actually executing it (for testing)
207    pub fn prepare_command(&self, files: &[&str], urls: &[&str]) -> Result<(String, Vec<String>), ExecuteError> {
208        // Validate the application can be executed
209        self.validate_executable()?;
210
211        // Get the command and arguments
212        let (program, args) = self.parse_exec_command(files, urls)?;
213
214        // Handle terminal applications
215        let (final_program, final_args) = if self.terminal() {
216            self.wrap_with_terminal(&program, &args)?
217        } else {
218            (program, args)
219        };
220
221        Ok((final_program, final_args))
222    }
223
224    fn execute_internal(&self, files: &[&str], urls: &[&str]) -> Result<(), ExecuteError> {
225        // Validate the application can be executed
226        self.validate_executable()?;
227
228        // Get the command and arguments
229        let (program, args) = self.parse_exec_command(files, urls)?;
230
231        // Handle terminal applications
232        let (final_program, final_args) = if self.terminal() {
233            self.wrap_with_terminal(&program, &args)?
234        } else {
235            (program, args)
236        };
237
238        // Set working directory if specified
239        let working_dir = self.path_dir();
240        
241        // Spawn the process detached
242        spawn_detached_with_env(&final_program, &final_args, working_dir.as_deref())
243            .map_err(|e| ExecuteError::IoError(format!("Failed to spawn process: {}", e)))
244    }
245
246    fn validate_executable(&self) -> Result<(), ExecuteError> {
247        // Check if we have an Exec key
248        let exec = self.exec().ok_or_else(|| {
249            ExecuteError::NotExecutable("No Exec key found".to_string())
250        })?;
251
252        if exec.trim().is_empty() {
253            return Err(ExecuteError::NotExecutable("Exec key is empty".to_string()));
254        }
255
256        // Check TryExec if present
257        if let Some(try_exec) = self.get_string("TryExec") {
258            if !is_executable_available(&try_exec) {
259                return Err(ExecuteError::ValidationFailed(
260                    format!("TryExec '{}' not found or not executable", try_exec)
261                ));
262            }
263        }
264
265        Ok(())
266    }
267
268    fn parse_exec_command(&self, files: &[&str], urls: &[&str]) -> Result<(String, Vec<String>), ExecuteError> {
269        let exec = self.exec().unwrap(); // Already validated in validate_executable
270        
271        // Expand field codes
272        let expanded = self.expand_field_codes(&exec, files, urls);
273        
274        // Parse the command line
275        parse_command_line(&expanded)
276    }
277
278    fn expand_field_codes(&self, exec: &str, files: &[&str], urls: &[&str]) -> String {
279        let mut result = String::new();
280        let mut chars = exec.chars().peekable();
281
282        while let Some(ch) = chars.next() {
283            if ch == '%' {
284                if let Some(&next_ch) = chars.peek() {
285                    chars.next(); // consume the next character
286                    match next_ch {
287                        '%' => result.push('%'),
288                        'f' => {
289                            if let Some(file) = files.first() {
290                                result.push_str(&shell_escape(file));
291                            }
292                        },
293                        'F' => {
294                            for (i, file) in files.iter().enumerate() {
295                                if i > 0 { result.push(' '); }
296                                result.push_str(&shell_escape(file));
297                            }
298                        },
299                        'u' => {
300                            if let Some(url) = urls.first() {
301                                result.push_str(&shell_escape(url));
302                            }
303                        },
304                        'U' => {
305                            for (i, url) in urls.iter().enumerate() {
306                                if i > 0 { result.push(' '); }
307                                result.push_str(&shell_escape(url));
308                            }
309                        },
310                        'i' => {
311                            if let Some(icon) = self.icon() {
312                                result.push_str("--icon ");
313                                result.push_str(&shell_escape(&icon));
314                            }
315                        },
316                        'c' => {
317                            if let Some(name) = self.name() {
318                                result.push_str(&shell_escape(&name));
319                            }
320                        },
321                        'k' => {
322                            let path = self.path().to_string_lossy();
323                            result.push_str(&shell_escape(&path));
324                        },
325                        // Deprecated field codes - ignore
326                        'd' | 'D' | 'n' | 'N' | 'v' | 'm' => {},
327                        // Unknown field code - this is an error per spec
328                        _ => {
329                            return format!("{}%{}{}", result, next_ch, chars.collect::<String>());
330                        }
331                    }
332                } else {
333                    result.push(ch);
334                }
335            } else {
336                result.push(ch);
337            }
338        }
339
340        result
341    }
342
343    fn wrap_with_terminal(&self, program: &str, args: &[String]) -> Result<(String, Vec<String>), ExecuteError> {
344        let terminal = find_terminal().ok_or(ExecuteError::TerminalNotFound)?;
345        
346        // Build the command to run in terminal
347        let mut terminal_args = vec!["-e".to_string()];
348        terminal_args.push(program.to_string());
349        terminal_args.extend(args.iter().cloned());
350        
351        Ok((terminal, terminal_args))
352    }
353}
354
355impl ApplicationEntry {
356    /// Get all application entries from standard directories
357    pub fn all() -> Vec<ApplicationEntry> {
358        let mut entries: Vec<ApplicationEntry> = Vec::new();
359        for p in application_entry_paths() {
360            if let Ok(dir_entries) = std::fs::read_dir(p) {
361                for entry in dir_entries.filter_map(|e| e.ok()) {
362                    if entry.path().extension().is_some_and(|ext| ext == "desktop") {
363                        if let Ok(app_entry) = ApplicationEntry::try_from_path(entry.path()) {
364                            entries.push(app_entry);
365                        }
366                    }
367                }
368            }
369        }
370        entries
371    }
372
373    /// Create an ApplicationEntry from a path, panicking on error (for compatibility)
374    pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
375        Self::try_from_path(path).unwrap_or_else(|_| {
376            // Return empty entry if parsing fails to maintain compatibility
377            ApplicationEntry::default()
378        })
379    }
380
381    /// Try to create an ApplicationEntry from a path, returning Result
382    pub fn try_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
383        let desktop_entry = DesktopEntry::from_path(path)?;
384        Ok(ApplicationEntry {
385            inner: desktop_entry,
386        })
387    }
388}
389
390/// Spawn a process completely detached from the current process
391fn spawn_detached_with_env(program: &str, args: &[String], working_dir: Option<&str>) -> Result<(), std::io::Error> {
392    use std::process::{Command, Stdio};
393    
394    #[cfg(unix)]
395    {
396        use std::os::unix::process::CommandExt;
397        
398        let mut cmd = Command::new(program);
399        cmd.args(args)
400            .stdin(Stdio::null())
401            .stdout(Stdio::null())
402            .stderr(Stdio::null());
403
404        // Set working directory if provided
405        if let Some(dir) = working_dir {
406            cmd.current_dir(dir);
407        }
408
409        unsafe {
410            cmd.pre_exec(|| {
411                // Start new session - this detaches from parent's process group
412                libc::setsid();
413                Ok(())
414            });
415        }
416
417        cmd.spawn()?;
418        Ok(())
419    }
420    
421    #[cfg(not(unix))]
422    {
423        let mut cmd = Command::new(program);
424        cmd.args(args)
425            .stdin(Stdio::null())
426            .stdout(Stdio::null())
427            .stderr(Stdio::null());
428        
429        // Set working directory if provided
430        if let Some(dir) = working_dir {
431            cmd.current_dir(dir);
432        }
433        
434        cmd.spawn()?;
435        Ok(())
436    }
437}
438
439/// Check if an executable is available in PATH or as absolute path
440fn is_executable_available(executable: &str) -> bool {
441    use std::path::Path;
442    
443    if Path::new(executable).is_absolute() {
444        // Absolute path - check if file exists and is executable
445        Path::new(executable).exists()
446    } else {
447        // Relative path - check in PATH
448        which_command(executable).is_some()
449    }
450}
451
452/// Find an executable in PATH (simple implementation)
453fn which_command(executable: &str) -> Option<String> {
454    if let Ok(path_var) = std::env::var("PATH") {
455        for path_dir in path_var.split(':') {
456            let full_path = format!("{}/{}", path_dir, executable);
457            if std::path::Path::new(&full_path).exists() {
458                return Some(full_path);
459            }
460        }
461    }
462    None
463}
464
465/// Find an available terminal emulator
466fn find_terminal() -> Option<String> {
467    // First check TERMINAL environment variable
468    if let Ok(terminal) = std::env::var("TERMINAL") {
469        if is_executable_available(&terminal) {
470            return Some(terminal);
471        }
472    }
473    
474    // Try common terminal emulators
475    let terminals = [
476        "x-terminal-emulator",  // Debian/Ubuntu alternative
477        "gnome-terminal",
478        "konsole",
479        "xfce4-terminal", 
480        "mate-terminal",
481        "lxterminal",
482        "rxvt-unicode",
483        "rxvt",
484        "xterm",
485    ];
486    
487    for terminal in &terminals {
488        if is_executable_available(terminal) {
489            return Some(terminal.to_string());
490        }
491    }
492    
493    None
494}
495
496/// Escape a string for safe shell usage
497fn shell_escape(s: &str) -> String {
498    if s.chars().any(|c| " \t\n'\"\\$`()[]{}?*~&|;<>".contains(c)) {
499        format!("'{}'", s.replace('\'', "'\"'\"'"))
500    } else {
501        s.to_string()
502    }
503}
504
505/// Parse a command line into program and arguments, handling quotes
506fn parse_command_line(command: &str) -> Result<(String, Vec<String>), ExecuteError> {
507    let mut parts = Vec::new();
508    let mut current = String::new();
509    let mut in_quotes = false;
510    let mut quote_char = '"';
511    let mut chars = command.chars().peekable();
512    
513    while let Some(ch) = chars.next() {
514        match ch {
515            '"' | '\'' if !in_quotes => {
516                in_quotes = true;
517                quote_char = ch;
518            },
519            ch if ch == quote_char && in_quotes => {
520                in_quotes = false;
521            },
522            '\\' if in_quotes => {
523                // Handle escape sequences in quotes
524                if let Some(&next_ch) = chars.peek() {
525                    chars.next();
526                    match next_ch {
527                        '"' | '\'' | '\\' | '$' | '`' => current.push(next_ch),
528                        _ => {
529                            current.push('\\');
530                            current.push(next_ch);
531                        }
532                    }
533                } else {
534                    current.push('\\');
535                }
536            },
537            ' ' | '\t' if !in_quotes => {
538                if !current.is_empty() {
539                    parts.push(current);
540                    current = String::new();
541                }
542                // Skip multiple spaces
543                while chars.peek() == Some(&' ') || chars.peek() == Some(&'\t') {
544                    chars.next();
545                }
546            },
547            _ => current.push(ch),
548        }
549    }
550    
551    if !current.is_empty() {
552        parts.push(current);
553    }
554    
555    if in_quotes {
556        return Err(ExecuteError::InvalidCommand("Unterminated quote".to_string()));
557    }
558    
559    if parts.is_empty() {
560        return Err(ExecuteError::InvalidCommand("Empty command".to_string()));
561    }
562    
563    let program = parts.remove(0);
564    Ok((program, parts))
565}