Skip to main content

qframe/desktop/
apps.rs

1//! The programs installed, from their desktop entries, and which of them open which kind, from
2//! the entries and from `mimeapps.list`.
3
4use 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
18/// How deep below `applications` desktop entries are looked for. Real trees are one or two
19/// levels deep; the limit keeps a folder link pointing back up from being followed forever.
20const DEEPEST: usize = 8;
21
22/// A program, as its desktop entry describes it.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DesktopApp {
25    /// The desktop file id (`org.gnome.TextEditor.desktop`): the entry's path below
26    /// `applications`, folders joined with `-`. `mimeapps.list` names programs by it.
27    pub id: String,
28    /// The program's name in the person's language when the entry has one.
29    pub name: String,
30    /// The `Exec` line, with the key file's escapes resolved.
31    pub exec: String,
32    /// Whether the program runs inside a terminal.
33    pub terminal: bool,
34    /// The kinds the program says it opens.
35    pub mime_types: Vec<String>,
36    /// Where the desktop entry is.
37    pub path: PathBuf,
38    /// The program's icon name, when it has one.
39    pub icon: Option<String>,
40}
41
42impl DesktopApp {
43    /// The command that opens `file` with this program, program first: the `Exec` line with its
44    /// field codes filled in. `None` when the line is empty or malformed.
45    ///
46    /// No shell is involved; the file is always one argument, whatever its name holds.
47    #[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/// The installed programs and the person's and the system's choices of which opens what.
54#[derive(Debug, Clone, Default)]
55pub struct Apps {
56    /// Every program, in the order found: the person's own folder first.
57    apps: Vec<DesktopApp>,
58    /// Every `mimeapps.list` found, the most important first.
59    lists: Vec<MimeAppsList>,
60    /// The problems found while reading, in the order the files were read.
61    diagnostics: Vec<Diagnostic>,
62}
63
64impl Apps {
65    /// Reads the desktop entries below `applications` in every data folder and every
66    /// `mimeapps.list`.
67    ///
68    /// Of two entries with the same id the first found wins, so the person's own copy overrides
69    /// the system's, and an entry marked `Hidden` hides the system's copy as well as itself. Only
70    /// `Type=Application` entries count. An entry whose `TryExec` program is not installed is
71    /// dropped: an absolute path must be an executable file, a bare name is looked for in the
72    /// folders of `path_var`. Entries marked `NoDisplay` are kept, since they are left out of
73    /// menus but still open files. `lang` (`tr_TR.UTF-8`) picks the name: `Name[tr_TR]`, then
74    /// `Name[tr]`, then `Name`. An application entry without a name, without an `Exec` line or
75    /// with one that cannot be split is skipped with a [`Diagnostic`], as is a line that cannot
76    /// be read; its id stays taken, so a broken copy of the person's never brings back the
77    /// system's.
78    #[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    /// The problems found while reading, in the order the files were read.
106    #[must_use]
107    pub fn diagnostics(&self) -> &[Diagnostic] {
108        &self.diagnostics
109    }
110
111    /// Every installed program, in the order found: the person's own folder first, each folder
112    /// in name order. A launcher leaves out the ones whose entry says `NoDisplay`.
113    #[must_use]
114    pub fn all(&self) -> &[DesktopApp] {
115        &self.apps
116    }
117
118    /// The program with this desktop file id, when it is installed.
119    #[must_use]
120    pub fn get(&self, id: &str) -> Option<&DesktopApp> {
121        self.apps.iter().find(|app| app.id == id)
122    }
123
124    /// The programs that open a file of kind `mime`, the most fitting first, each once.
125    ///
126    /// For `mime` and then each kind it is a special case of ([`MimeDb::ancestors`]): the
127    /// defaults `mimeapps.list` names for it, then its `[Added Associations]`, then the programs
128    /// whose entries list it. A program in the `[Removed Associations]` of a kind is left out for
129    /// that kind, unless a more important file adds it back. The `mimeapps.list` files are read
130    /// in this order, the first the most important: `<desktop>-mimeapps.list` for each running
131    /// desktop and then `mimeapps.list`, in the person's configuration folder, in each system
132    /// configuration folder, then below `applications` in the person's data folder and in each
133    /// system data folder. A listed id that is not installed is passed over.
134    #[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    /// The program a file of kind `mime` opens with when nothing else is asked for: the one
153    /// `[Default Applications]` names for the kind or, failing that, for the nearest kind it is a
154    /// special case of; else the first of [`for_mime`](Self::for_mime); else `None`.
155    #[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    /// The installed programs one section of the `mimeapps.list` files names for `kind`, in
164    /// order, less those a more important file removed.
165    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    /// Whether one of the first `count` `mimeapps.list` files removes program `id` for `kind`.
181    ///
182    /// A file's own additions are made before its removals, so it only removes what less
183    /// important files and the entries themselves say.
184    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/// The sections of `mimeapps.list` that name programs to use.
192#[derive(Debug, Clone, Copy)]
193enum Section {
194    Default,
195    Added,
196}
197
198/// One `mimeapps.list`: for each kind, the program ids of each section.
199#[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
237/// Every `mimeapps.list` that may exist, the most important first.
238fn list_paths(dirs: &XdgDirs) -> Vec<PathBuf> {
239    // A desktop name is joined into a file name; one holding a `/` could reach outside the folder.
240    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
253/// Collects the desktop entries below `folder` as `(id, path)`.
254fn 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
276/// The program a desktop entry describes, or `None` when it describes none that may be used.
277fn 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    // A line that gives no command for a file gives none for any: an unclosed quote, a field code
317    // the standard does not know, nothing to run. Such a program is never offered.
318    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
326/// A value in the person's language: `key[lang_COUNTRY@MODIFIER]`, `key[lang_COUNTRY]`,
327/// `key[lang@MODIFIER]`, `key[lang]`, then `key`, as the desktop entry standard orders them.
328fn 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}