use std::path::Path; use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath}; use crate::humanize_category; pub struct FsDesktopEntrySource; impl FsDesktopEntrySource { pub fn new() -> Self { Self } } impl Default for FsDesktopEntrySource { fn default() -> Self { Self::new() } } impl DesktopEntrySource for FsDesktopEntrySource { fn entries(&self) -> Vec { let mut dirs = Vec::new(); let xdg = xdg::BaseDirectories::new(); if let Some(data_home) = xdg.get_data_home() { dirs.push(data_home.join("applications")); } for d in xdg.get_data_dirs() { dirs.push(d.join("applications")); } let mut entries = Vec::new(); for dir in &dirs { if let Ok(read_dir) = std::fs::read_dir(dir) { for entry in read_dir.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("desktop") { continue; } if let Some(de) = parse_desktop_file(&path) { entries.push(de); } } } } entries } } pub(crate) fn clean_exec(exec: &str) -> String { // Tokenize respecting double-quoted strings, then filter field codes. let mut tokens: Vec = Vec::new(); let mut chars = exec.chars().peekable(); while let Some(&ch) = chars.peek() { if ch.is_whitespace() { chars.next(); continue; } if ch == '"' { // Consume opening quote chars.next(); let mut token = String::from('"'); while let Some(&c) = chars.peek() { chars.next(); if c == '"' { token.push('"'); break; } token.push(c); } // Strip embedded field codes like %f inside the quoted string // (between the quotes, before re-assembling) let inner = &token[1..token.len().saturating_sub(1)]; let cleaned_inner: String = inner .split_whitespace() .filter(|s| !is_field_code(s)) .collect::>() .join(" "); tokens.push(format!("\"{cleaned_inner}\"")); } else { let mut token = String::new(); while let Some(&c) = chars.peek() { if c.is_whitespace() { break; } chars.next(); token.push(c); } if !is_field_code(&token) { tokens.push(token); } } } tokens.join(" ") } fn is_field_code(s: &str) -> bool { let b = s.as_bytes(); b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic() } pub fn resolve_icon_path(name: &str) -> Option { if name.starts_with('/') && Path::new(name).exists() { return Some(name.to_string()); } // Try linicon freedesktop theme traversal let themes = ["hicolor", "Adwaita", "breeze", "Papirus"]; for theme in &themes { if let Some(icon_path) = linicon::lookup_icon(name) .from_theme(theme) .with_size(48) .find_map(|r| r.ok()) { return Some(icon_path.path.to_string_lossy().into_owned()); } } // Fallback to pixmaps let candidates = [ format!("/usr/share/pixmaps/{name}.png"), format!("/usr/share/pixmaps/{name}.svg"), ]; candidates.into_iter().find(|p| Path::new(p).exists()) } fn parse_desktop_file(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; let mut in_section = false; let mut name: Option = None; let mut exec: Option = None; let mut icon: Option = None; let mut category: Option = None; let mut keywords: Vec = Vec::new(); let mut is_application = false; let mut no_display = false; for line in content.lines() { let line = line.trim(); if line == "[Desktop Entry]" { in_section = true; continue; } if line.starts_with('[') { in_section = false; continue; } if !in_section || line.starts_with('#') || line.is_empty() { continue; } if let Some((key, value)) = line.split_once('=') { match key.trim() { "Name" if name.is_none() => name = Some(value.trim().to_string()), "Exec" if exec.is_none() => exec = Some(value.trim().to_string()), "Icon" if icon.is_none() => icon = Some(value.trim().to_string()), "Type" if !is_application => is_application = value.trim() == "Application", "NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"), "Categories" if category.is_none() => { category = value.trim() .split(';') .find(|s| !s.is_empty()) .map(|s| humanize_category(s.trim())); } "Keywords" if keywords.is_empty() => { keywords = value.trim() .split(';') .filter(|s| !s.is_empty()) .map(|s| s.trim().to_string()) .collect(); } _ => {} } } } if !is_application || no_display { return None; } let exec_clean: String = clean_exec(&exec?); Some(DesktopEntry { name: AppName::new(name?), exec: ExecCommand::new(exec_clean), icon: icon.map(IconPath::new), category, keywords, }) } #[cfg(test)] mod exec_tests { use super::clean_exec; #[test] fn strips_bare_field_code() { assert_eq!(clean_exec("app --file %f"), "app --file"); } #[test] fn strips_multiple_field_codes() { assert_eq!(clean_exec("app %U --flag"), "app --flag"); } #[test] fn preserves_quoted_value() { assert_eq!(clean_exec(r#"app --arg="value" %U"#), r#"app --arg="value""#); } #[test] fn handles_plain_exec() { assert_eq!(clean_exec("firefox"), "firefox"); } }