1use std::collections::{HashMap, HashSet};
5use std::ffi::{OsStr, OsString};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use super::exec::{self, Fields};
10use super::keyfile::{self, Group};
11use super::program::{find_program, is_executable};
12use super::{MimeDb, XdgDirs, read_small, warn};
13use crate::diagnostics::Diagnostic;
14
15#[cfg(test)]
16mod tests;
17
18const DEEPEST: usize = 8;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DesktopApp {
25 pub id: String,
28 pub name: String,
30 pub exec: String,
32 pub terminal: bool,
34 pub mime_types: Vec<String>,
36 pub path: PathBuf,
38 pub icon: Option<String>,
40}
41
42impl DesktopApp {
43 #[must_use]
48 pub fn command(&self, file: &Path) -> Option<Vec<OsString>> {
49 exec::expand(&self.exec, &Fields { file, name: &self.name, icon: self.icon.as_deref(), entry: &self.path })
50 }
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct Apps {
56 apps: Vec<DesktopApp>,
58 lists: Vec<MimeAppsList>,
60 diagnostics: Vec<Diagnostic>,
62}
63
64impl Apps {
65 #[must_use]
79 pub fn load(dirs: &XdgDirs, lang: &str, path_var: Option<&OsStr>) -> Self {
80 let mut seen = HashSet::new();
81 let mut apps = Vec::new();
82 let mut diagnostics = Vec::new();
83 for dir in dirs.data() {
84 let root = dir.join("applications");
85 let mut found = Vec::new();
86 entries(&root, &root, 0, &mut found);
87 found.sort();
88 for (id, path) in found {
89 if seen.insert(id.clone())
90 && let Some(app) = read_entry(id, path, lang, path_var, &mut diagnostics)
91 {
92 apps.push(app);
93 }
94 }
95 }
96 let mut lists = Vec::new();
97 for path in list_paths(dirs) {
98 if let Some(bytes) = read_small(&path, &mut diagnostics) {
99 lists.push(MimeAppsList::parse(&bytes, &path, &mut diagnostics));
100 }
101 }
102 Self { apps, lists, diagnostics }
103 }
104
105 #[must_use]
107 pub fn diagnostics(&self) -> &[Diagnostic] {
108 &self.diagnostics
109 }
110
111 #[must_use]
114 pub fn all(&self) -> &[DesktopApp] {
115 &self.apps
116 }
117
118 #[must_use]
120 pub fn get(&self, id: &str) -> Option<&DesktopApp> {
121 self.apps.iter().find(|app| app.id == id)
122 }
123
124 #[must_use]
135 pub fn for_mime(&self, db: &MimeDb, mime: &str) -> Vec<&DesktopApp> {
136 let mut out: Vec<&DesktopApp> = Vec::new();
137 for kind in db.ancestors(mime) {
138 let listed = self.listed(&kind, Section::Default).into_iter().chain(self.listed(&kind, Section::Added));
139 let declared = self.apps.iter().filter(|app| {
140 app.mime_types.iter().any(|declared| declared == &kind || db.canonical(declared) == kind)
141 && !self.removed_before(self.lists.len(), &kind, &app.id)
142 });
143 for app in listed.chain(declared) {
144 if !out.iter().any(|known| known.id == app.id) {
145 out.push(app);
146 }
147 }
148 }
149 out
150 }
151
152 #[must_use]
156 pub fn default_for(&self, db: &MimeDb, mime: &str) -> Option<&DesktopApp> {
157 db.ancestors(mime)
158 .iter()
159 .find_map(|kind| self.listed(kind, Section::Default).into_iter().next())
160 .or_else(|| self.for_mime(db, mime).into_iter().next())
161 }
162
163 fn listed(&self, kind: &str, section: Section) -> Vec<&DesktopApp> {
166 self.lists
167 .iter()
168 .enumerate()
169 .flat_map(|(at, list)| {
170 list.section(section)
171 .get(kind)
172 .into_iter()
173 .flatten()
174 .filter(move |id| !self.removed_before(at, kind, id))
175 })
176 .filter_map(|id| self.get(id))
177 .collect()
178 }
179
180 fn removed_before(&self, count: usize, kind: &str, id: &str) -> bool {
185 self.lists[..count]
186 .iter()
187 .any(|list| list.removed.get(kind).is_some_and(|ids| ids.iter().any(|removed| removed == id)))
188 }
189}
190
191#[derive(Debug, Clone, Copy)]
193enum Section {
194 Default,
195 Added,
196}
197
198#[derive(Debug, Clone, Default)]
200struct MimeAppsList {
201 defaults: HashMap<String, Vec<String>>,
202 added: HashMap<String, Vec<String>>,
203 removed: HashMap<String, Vec<String>>,
204}
205
206impl MimeAppsList {
207 fn parse(bytes: &[u8], path: &Path, diagnostics: &mut Vec<Diagnostic>) -> Self {
208 let mut list = Self::default();
209 for group in keyfile::parse(bytes, path, diagnostics) {
210 let section = match group.name.as_str() {
211 "Default Applications" => &mut list.defaults,
212 "Added Associations" => &mut list.added,
213 "Removed Associations" => &mut list.removed,
214 _ => continue,
215 };
216 for (kind, ids) in group.entries() {
217 let ids = keyfile::list(ids);
218 let known = section.entry(kind.to_owned()).or_default();
219 for id in ids {
220 if !known.contains(&id) {
221 known.push(id);
222 }
223 }
224 }
225 }
226 list
227 }
228
229 fn section(&self, section: Section) -> &HashMap<String, Vec<String>> {
230 match section {
231 Section::Default => &self.defaults,
232 Section::Added => &self.added,
233 }
234 }
235}
236
237fn list_paths(dirs: &XdgDirs) -> Vec<PathBuf> {
239 let desktops: Vec<&String> =
241 dirs.desktops.iter().filter(|desktop| !desktop.is_empty() && !desktop.contains('/')).collect();
242 let folders = dirs.config().cloned().chain(dirs.data().map(|dir| dir.join("applications")));
243 let mut out = Vec::new();
244 for folder in folders {
245 for desktop in &desktops {
246 out.push(folder.join(format!("{desktop}-mimeapps.list")));
247 }
248 out.push(folder.join("mimeapps.list"));
249 }
250 out
251}
252
253fn entries(root: &Path, folder: &Path, depth: usize, out: &mut Vec<(String, PathBuf)>) {
255 let Ok(read) = fs::read_dir(folder) else {
256 return;
257 };
258 for entry in read.flatten() {
259 let path = entry.path();
260 let Ok(meta) = fs::metadata(&path) else {
261 continue;
262 };
263 if meta.is_dir() {
264 if depth < DEEPEST {
265 entries(root, &path, depth + 1, out);
266 }
267 } else if meta.is_file()
268 && path.extension().is_some_and(|ext| ext == "desktop")
269 && let Some(id) = path.strip_prefix(root).ok().and_then(Path::to_str).map(|rel| rel.replace('/', "-"))
270 {
271 out.push((id, path));
272 }
273 }
274}
275
276fn read_entry(
278 id: String,
279 path: PathBuf,
280 lang: &str,
281 path_var: Option<&OsStr>,
282 diagnostics: &mut Vec<Diagnostic>,
283) -> Option<DesktopApp> {
284 let bytes = read_small(&path, diagnostics)?;
285 let groups = keyfile::parse(&bytes, &path, diagnostics);
286 let Some(entry) = groups.iter().find(|group| group.name == "Desktop Entry") else {
287 warn(diagnostics, &path, 1, "the file has no [Desktop Entry] group, so it is no program");
288 return None;
289 };
290 let flag = |key: &str| entry.get(key).is_some_and(|value| value.trim() == "true");
291 if entry.get("Type").map(str::trim) != Some("Application") || flag("Hidden") {
292 return None;
293 }
294 if let Some(program) = entry.get("TryExec").map(keyfile::string)
295 && find_program(program.trim(), path_var, is_executable).is_none()
296 {
297 return None;
298 }
299 let Some(name) = localized(entry, "Name", lang).filter(|name| !name.trim().is_empty()) else {
300 warn(diagnostics, &path, entry.line, "an application needs a Name; the program is skipped");
301 return None;
302 };
303 let Some(exec) = entry.get("Exec").map(keyfile::string) else {
304 warn(diagnostics, &path, entry.line, "an application needs an Exec line; the program is skipped");
305 return None;
306 };
307 let app = DesktopApp {
308 id,
309 name,
310 exec,
311 terminal: flag("Terminal"),
312 mime_types: entry.get("MimeType").map(keyfile::list).unwrap_or_default(),
313 path,
314 icon: entry.get("Icon").map(keyfile::string).filter(|icon| !icon.trim().is_empty()),
315 };
316 if app.command(Path::new("file")).is_none() {
319 let line = entry.line_of("Exec").unwrap_or(entry.line);
320 warn(diagnostics, &app.path, line, "the Exec line gives no command; the program is skipped");
321 return None;
322 }
323 Some(app)
324}
325
326fn localized(entry: &Group, key: &str, lang: &str) -> Option<String> {
329 let (base, modifier) = match lang.split_once('@') {
330 Some((base, modifier)) => (base, Some(modifier)),
331 None => (lang, None),
332 };
333 let base = base.split('.').next().unwrap_or_default();
334 let (language, country) = match base.split_once('_') {
335 Some((language, country)) => (language, Some(country)),
336 None => (base, None),
337 };
338 let mut locales = Vec::new();
339 if !language.is_empty() && language != "C" && language != "POSIX" {
340 if let (Some(country), Some(modifier)) = (country, modifier) {
341 locales.push(format!("{language}_{country}@{modifier}"));
342 }
343 if let Some(country) = country {
344 locales.push(format!("{language}_{country}"));
345 }
346 if let Some(modifier) = modifier {
347 locales.push(format!("{language}@{modifier}"));
348 }
349 locales.push(language.to_owned());
350 }
351 locales
352 .iter()
353 .find_map(|locale| entry.get(&format!("{key}[{locale}]")))
354 .or_else(|| entry.get(key))
355 .map(keyfile::string)
356}