use super::{LaunchItem, Provider, ProviderType}; use crate::paths; use freedesktop_desktop_entry::{DesktopEntry, Iter}; use log::{debug, warn}; /// Clean desktop file field codes from command string. /// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes /// while preserving quoted arguments and %% (literal percent). /// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html fn clean_desktop_exec_field(cmd: &str) -> String { let mut result = String::with_capacity(cmd.len()); let mut chars = cmd.chars().peekable(); let mut in_single_quote = false; let mut in_double_quote = false; while let Some(c) = chars.next() { match c { '\'' if !in_double_quote => { in_single_quote = !in_single_quote; result.push(c); } '"' if !in_single_quote => { in_double_quote = !in_double_quote; result.push(c); } '%' if !in_single_quote => { // Check the next character for field code if let Some(&next) = chars.peek() { match next { // Standard field codes to remove (with following space if present) 'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v' | 'm' => { chars.next(); // consume the field code letter // Skip trailing whitespace after the field code while chars.peek() == Some(&' ') { chars.next(); } } // %% is escaped percent, output single % '%' => { chars.next(); result.push('%'); } // Unknown % sequence, keep as-is _ => { result.push('%'); } } } else { // % at end of string, keep it result.push('%'); } } _ => { result.push(c); } } } // Clean up any double spaces that may have resulted from removing field codes let mut cleaned = result.trim().to_string(); while cleaned.contains(" ") { cleaned = cleaned.replace(" ", " "); } cleaned } pub struct ApplicationProvider { items: Vec, } impl ApplicationProvider { pub fn new() -> Self { Self { items: Vec::new() } } fn get_application_dirs() -> Vec { paths::system_data_dirs() } } impl Provider for ApplicationProvider { fn name(&self) -> &str { "Applications" } fn provider_type(&self) -> ProviderType { ProviderType::Application } fn refresh(&mut self) { self.items.clear(); let dirs = Self::get_application_dirs(); debug!("Scanning application directories: {:?}", dirs); // Empty locale list for default locale let locales: &[&str] = &[]; for path in Iter::new(dirs.into_iter()) { let content = match std::fs::read_to_string(&path) { Ok(c) => c, Err(e) => { warn!("Failed to read {:?}: {}", path, e); continue; } }; let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) { Ok(e) => e, Err(e) => { warn!("Failed to parse {:?}: {}", path, e); continue; } }; // Skip entries marked as hidden or no-display if desktop_entry.no_display() || desktop_entry.hidden() { continue; } // Only include Application type entries if desktop_entry.type_() != Some("Application") { continue; } let name = match desktop_entry.name(locales) { Some(n) => n.to_string(), None => continue, }; let run_cmd = match desktop_entry.exec() { Some(e) => clean_desktop_exec_field(e), None => continue, }; // Extract categories as tags (lowercase for consistency) let tags: Vec = desktop_entry .categories() .map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect()) .unwrap_or_default(); let item = LaunchItem { id: path.to_string_lossy().to_string(), name, description: desktop_entry.comment(locales).map(|s| s.to_string()), icon: desktop_entry.icon().map(|s| s.to_string()), provider: ProviderType::Application, command: run_cmd, terminal: desktop_entry.terminal(), tags, }; self.items.push(item); } debug!("Found {} applications", self.items.len()); // Sort alphabetically by name self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); } fn items(&self) -> &[LaunchItem] { &self.items } } #[cfg(test)] mod tests { use super::*; #[test] fn test_clean_desktop_exec_simple() { assert_eq!(clean_desktop_exec_field("firefox"), "firefox"); assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox"); assert_eq!(clean_desktop_exec_field("code %F"), "code"); } #[test] fn test_clean_desktop_exec_multiple_placeholders() { assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app"); assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other"); } #[test] fn test_clean_desktop_exec_preserves_quotes() { // Double quotes preserve spacing but field codes are still processed assert_eq!( clean_desktop_exec_field(r#"bash -c "echo hello""#), r#"bash -c "echo hello""# ); // Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior, // but practical implementations strip them) assert_eq!( clean_desktop_exec_field(r#"bash -c "test %u value""#), r#"bash -c "test value""# ); } #[test] fn test_clean_desktop_exec_escaped_percent() { assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%"); } #[test] fn test_clean_desktop_exec_single_quotes() { assert_eq!( clean_desktop_exec_field("bash -c 'echo %u'"), "bash -c 'echo %u'" ); } }