freedesktop_desktop_entry/
lib.rs

1// Copyright 2021 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4mod decoder;
5mod exec;
6mod iter;
7
8pub use self::iter::Iter;
9pub use decoder::DecodeError;
10pub use exec::ExecError;
11use std::borrow::Cow;
12use std::collections::BTreeMap;
13use std::fmt::{self, Display, Formatter};
14use std::hash::{Hash, Hasher};
15use std::path::{Path, PathBuf};
16pub use unicase;
17use unicase::Ascii;
18use xdg::BaseDirectories;
19
20/// Read all desktop entries on disk into a Vec, with only the given locales retained.
21pub fn desktop_entries(locales: &[String]) -> Vec<DesktopEntry> {
22    Iter::new(default_paths())
23        .filter_map(|p| DesktopEntry::from_path(p, Some(&locales)).ok())
24        .collect::<Vec<_>>()
25}
26
27/// Case-insensitive search of desktop entries for the given app ID.
28///
29/// Requires using the `unicase` crate for its `Ascii` case support.
30///
31/// Searches by name if an ID match could not be found.
32pub fn find_app_by_id<'a>(
33    entries: &'a [DesktopEntry],
34    app_id: Ascii<&str>,
35) -> Option<&'a DesktopEntry> {
36    // NOTE: Use `cargo run --example find_appid {{wm_app_id}}` to check if the match works.
37
38    // Prefer desktop entries whose startup wm class is a perfect match.
39    let match_by_wm_class = entries.iter().find(|entry| entry.matches_wm_class(app_id));
40
41    match_by_wm_class
42        // If no suitable wm class was found, search by entry file name.
43        .or_else(|| entries.iter().find(|entry| entry.matches_id(app_id)))
44        // Otherwise by name specified in the desktop entry.
45        .or_else(|| entries.iter().find(|entry| entry.matches_name(app_id)))
46        // Or match by the exact exec command
47        .or_else(|| {
48            entries
49                .iter()
50                .find(|entry| entry.exec().is_some_and(|exec| exec == app_id))
51        })
52        // Or match by the first command in the exec
53        .or_else(|| {
54            entries.iter().find(|entry| {
55                entry.exec().is_some_and(|exec| {
56                    exec.split_ascii_whitespace()
57                        .next()
58                        .is_some_and(|exec| exec == app_id)
59                })
60            })
61        })
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct Groups(pub BTreeMap<GroupName, Group>);
66pub type GroupName = String;
67
68impl Groups {
69    #[inline]
70    pub fn desktop_entry(&self) -> Option<&Group> {
71        self.0.get("Desktop Entry")
72    }
73
74    #[inline]
75    pub fn group(&self, key: &str) -> Option<&Group> {
76        self.0.get(key)
77    }
78}
79
80pub type Key = String;
81#[derive(Debug, Clone, Default)]
82pub struct Group(pub BTreeMap<Key, (Value, LocaleMap)>);
83
84impl Group {
85    pub fn localized_entry<L: AsRef<str>>(&self, key: &str, locales: &[L]) -> Option<&str> {
86        #[inline(never)]
87        fn inner<'a>(
88            this: &'a Group,
89            key: &str,
90            locales: &mut dyn Iterator<Item = &str>,
91        ) -> Option<&'a str> {
92            let (default_value, locale_map) = this.0.get(key)?;
93
94            for locale in locales {
95                match locale_map.get(locale) {
96                    Some(value) => return Some(value),
97                    None => {
98                        if let Some(pos) = memchr::memchr(b'_', locale.as_bytes()) {
99                            if let Some(value) = locale_map.get(&locale[..pos]) {
100                                return Some(value);
101                            }
102                        }
103                    }
104                }
105            }
106
107            Some(default_value)
108        }
109
110        inner(self, key, &mut locales.iter().map(AsRef::as_ref))
111    }
112
113    #[inline]
114    pub fn entry(&self, key: &str) -> Option<&str> {
115        self.0.get(key).map(|key| key.0.as_ref())
116    }
117
118    #[inline]
119    pub fn entry_bool(&self, key: &str) -> Option<bool> {
120        match self.entry(key)? {
121            "true" => Some(true),
122            "false" => Some(false),
123            _ => None,
124        }
125    }
126}
127
128pub type Locale = String;
129pub type LocaleMap = BTreeMap<Locale, Value>;
130pub type Value = String;
131
132#[derive(Debug, Clone)]
133pub struct DesktopEntry {
134    pub appid: String,
135    pub groups: Groups,
136    pub path: PathBuf,
137    pub ubuntu_gettext_domain: Option<String>,
138}
139
140impl Ord for DesktopEntry {
141    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
142        (&self.path, &self.appid).cmp(&(&other.path, &other.appid))
143    }
144}
145
146impl PartialOrd for DesktopEntry {
147    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
148        Some(self.path.cmp(&other.path))
149    }
150}
151
152impl PartialEq for DesktopEntry {
153    fn eq(&self, other: &Self) -> bool {
154        (&self.path, &self.appid) == (&other.path, &other.appid)
155    }
156}
157
158impl Eq for DesktopEntry {}
159
160impl Hash for DesktopEntry {
161    fn hash<H: Hasher>(&self, state: &mut H) {
162        self.appid.hash(state);
163    }
164}
165
166impl DesktopEntry {
167    /// Construct a new [`DesktopEntry`] from an appid. The name field will be
168    /// set to that appid.
169    #[inline]
170    pub fn from_appid(appid: String) -> DesktopEntry {
171        let name = appid.split('.').next_back().unwrap_or(&appid).to_string();
172
173        let mut de = DesktopEntry {
174            appid,
175            groups: Groups::default(),
176            path: PathBuf::from(""),
177            ubuntu_gettext_domain: None,
178        };
179        de.add_desktop_entry("Name".to_string(), name);
180        de
181    }
182
183    /// Entries with a matching `StartupWMClass` should be preferred over those that do not.
184    #[inline]
185    pub fn matches_wm_class(&self, id: Ascii<&str>) -> bool {
186        self.startup_wm_class()
187            .is_some_and(|wm_class| wm_class == id)
188    }
189
190    /// Match entry by desktop entry file name
191    #[inline]
192    pub fn matches_id(&self, id: Ascii<&str>) -> bool {
193        // If the desktop entry appid matches
194        id == self.id()
195            // or the path itself matches
196            || self.path.file_stem()
197                .and_then(|os_str| os_str.to_str())
198                .is_some_and(|name| {
199                    name == id
200                        // Or match by last part of app ID
201                        || id.split('.').rev().next().is_some_and(|id| id == name)
202                })
203    }
204
205    // Match by name specified in desktop entry, which should only be used if a match by ID failed.
206    #[inline]
207    pub fn matches_name(&self, name: Ascii<&str>) -> bool {
208        self.name::<&str>(&[])
209            .map(|n| n.as_ref() == name)
210            .unwrap_or_default()
211    }
212}
213
214impl DesktopEntry {
215    #[inline]
216    pub fn id(&self) -> &str {
217        self.appid.as_ref()
218    }
219
220    /// A desktop entry field if any field under the `[Desktop Entry]` section.
221    #[inline]
222    pub fn desktop_entry(&self, key: &str) -> Option<&str> {
223        self.groups.desktop_entry()?.entry(key)
224    }
225
226    #[inline]
227    pub fn desktop_entry_localized<'a, L: AsRef<str>>(
228        &'a self,
229        key: &str,
230        locales: &[L],
231    ) -> Option<Cow<'a, str>> {
232        Self::localized_entry(
233            self.ubuntu_gettext_domain.as_deref(),
234            self.groups.desktop_entry(),
235            key,
236            &mut locales.iter().map(AsRef::as_ref),
237        )
238    }
239
240    /// Insert a new field to this [`DesktopEntry`], in the `[Desktop Entry]` section, removing
241    /// the previous value and locales in any.
242    pub fn add_desktop_entry(&mut self, key: String, value: String) {
243        let action_key = "Desktop Entry";
244        let value = (value, LocaleMap::default());
245
246        match self.groups.0.get_mut(action_key) {
247            Some(keymap) => {
248                keymap.0.insert(key, value);
249            }
250            None => {
251                let mut keymap = Group::default();
252                keymap.0.insert(key, value);
253                self.groups.0.insert(action_key.to_string(), keymap);
254            }
255        }
256    }
257
258    #[inline]
259    pub fn name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
260        self.desktop_entry_localized("Name", locales)
261    }
262
263    #[inline]
264    pub fn generic_name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
265        self.desktop_entry_localized("GenericName", locales)
266    }
267
268    /// Get the full name of an application, and fall back to the name if that fails.
269    #[inline]
270    pub fn full_name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
271        self.desktop_entry_localized("X-GNOME-FullName", locales)
272            .filter(|name| !name.as_ref().is_empty())
273            .or_else(|| self.name(locales))
274    }
275
276    #[inline]
277    pub fn icon(&self) -> Option<&str> {
278        self.desktop_entry("Icon")
279    }
280
281    /// This is an human readable description of the desktop file.
282    #[inline]
283    pub fn comment<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<str>> {
284        self.desktop_entry_localized("Comment", locales)
285    }
286
287    #[inline]
288    pub fn exec(&self) -> Option<&str> {
289        self.desktop_entry("Exec")
290    }
291
292    /// Path or name of an executable to check if app is really installed
293    #[inline]
294    pub fn try_exec(&self) -> Option<&str> {
295        self.desktop_entry("TryExec")
296    }
297
298    #[inline]
299    pub fn dbus_activatable(&self) -> bool {
300        self.desktop_entry_bool("DBusActivatable")
301    }
302
303    /// Return categories
304    #[inline]
305    pub fn categories(&self) -> Option<Vec<&str>> {
306        self.desktop_entry("Categories")
307            .map(|e| e.split(';').collect())
308    }
309
310    /// Return keywords
311    #[inline]
312    pub fn keywords<L: AsRef<str>>(&self, locales: &[L]) -> Option<Vec<Cow<str>>> {
313        self.localized_entry_splitted(self.groups.desktop_entry(), "Keywords", locales)
314    }
315
316    /// Return mime types
317    #[inline]
318    pub fn mime_type(&self) -> Option<Vec<&str>> {
319        self.desktop_entry("MimeType")
320            .map(|e| e.split(';').collect())
321    }
322
323    /// List of D-Bus interfaces supported by this application
324    #[inline]
325    pub fn implements(&self) -> Option<Vec<&str>> {
326        self.desktop_entry("Implements")
327            .map(|e| e.split(';').collect())
328    }
329
330    /// Application exists but shouldn't be shown in menus
331    #[inline]
332    pub fn no_display(&self) -> bool {
333        self.desktop_entry_bool("NoDisplay")
334    }
335
336    /// Desktop environments that should display this application
337    #[inline]
338    pub fn only_show_in(&self) -> Option<Vec<&str>> {
339        self.desktop_entry("OnlyShowIn")
340            .map(|e| e.split(';').collect())
341    }
342
343    /// Desktop environments that should not display this application
344    #[inline]
345    pub fn not_show_in(&self) -> Option<Vec<&str>> {
346        self.desktop_entry("NotShowIn")
347            .map(|e| e.split(';').collect())
348    }
349
350    /// Treat application as if it does not exist
351    #[inline]
352    pub fn hidden(&self) -> bool {
353        self.desktop_entry_bool("Hidden")
354    }
355
356    #[inline]
357    pub fn flatpak(&self) -> Option<&str> {
358        self.desktop_entry("X-Flatpak")
359    }
360
361    #[inline]
362    pub fn prefers_non_default_gpu(&self) -> bool {
363        self.desktop_entry_bool("PrefersNonDefaultGPU")
364    }
365
366    #[inline]
367    pub fn startup_notify(&self) -> bool {
368        self.desktop_entry_bool("StartupNotify")
369    }
370
371    #[inline]
372    pub fn startup_wm_class(&self) -> Option<&str> {
373        self.desktop_entry("StartupWMClass")
374    }
375
376    #[inline]
377    pub fn terminal(&self) -> bool {
378        self.desktop_entry_bool("Terminal")
379    }
380
381    /// The app has a single main window only
382    #[inline]
383    pub fn single_main_window(&self) -> bool {
384        self.desktop_entry_bool("SingleMainWindow")
385    }
386
387    /// Working directory to run program in
388    #[inline]
389    pub fn path(&self) -> Option<&str> {
390        self.desktop_entry("Path")
391    }
392
393    #[inline]
394    pub fn type_(&self) -> Option<&str> {
395        self.desktop_entry("Type")
396    }
397
398    /// URL to access if entry type is Link
399    pub fn url(&self) -> Option<&str> {
400        self.desktop_entry("URL")
401    }
402    /// Supported version of the Desktop Entry Specification
403    pub fn version(&self) -> Option<&str> {
404        self.desktop_entry("Version")
405    }
406
407    #[inline]
408    pub fn actions(&self) -> Option<Vec<&str>> {
409        self.desktop_entry("Actions")
410            .map(|e| e.split(';').collect())
411    }
412
413    /// An action is defined as `[Desktop Action actions-name]` where `action-name`
414    /// is defined in the `Actions` field of `[Desktop Entry]`.
415    /// Example: to get the `Name` field of this `new-window` action
416    /// ```txt
417    /// [Desktop Action new-window]
418    /// Name=Open a New Window
419    /// ```
420    /// you will need to call
421    /// ```ignore
422    /// entry.action_entry("new-window", "Name")
423    /// ```
424    #[inline]
425    pub fn action_entry(&self, action: &str, key: &str) -> Option<&str> {
426        self.groups
427            .group(["Desktop Action ", action].concat().as_str())?
428            .entry(key)
429    }
430
431    pub fn action_entry_localized<L: AsRef<str>>(
432        &self,
433        action: &str,
434        key: &str,
435        locales: &[L],
436    ) -> Option<Cow<'_, str>> {
437        #[inline(never)]
438        fn inner<'a>(
439            this: &'a DesktopEntry,
440            action: &str,
441            key: &str,
442            locales: &mut dyn Iterator<Item = &str>,
443        ) -> Option<Cow<'a, str>> {
444            let group = this
445                .groups
446                .group(["Desktop Action ", action].concat().as_str());
447
448            DesktopEntry::localized_entry(
449                this.ubuntu_gettext_domain.as_deref(),
450                group,
451                key,
452                locales,
453            )
454        }
455
456        inner(self, action, key, &mut locales.iter().map(AsRef::as_ref))
457    }
458
459    #[inline]
460    pub fn action_name<L: AsRef<str>>(&self, action: &str, locales: &[L]) -> Option<Cow<str>> {
461        self.action_entry_localized(action, "Name", locales)
462    }
463
464    #[inline]
465    pub fn action_exec(&self, action: &str) -> Option<&str> {
466        self.action_entry(action, "Exec")
467    }
468
469    #[inline]
470    fn desktop_entry_bool(&self, key: &str) -> bool {
471        self.desktop_entry(key).map_or(false, |v| v == "true")
472    }
473
474    #[inline(never)]
475    pub(crate) fn localized_entry<'a>(
476        ubuntu_gettext_domain: Option<&str>,
477        group: Option<&'a Group>,
478        key: &str,
479        locales: &mut dyn Iterator<Item = &str>,
480    ) -> Option<Cow<'a, str>> {
481        let (default_value, locale_map) = group?.0.get(key)?;
482
483        for locale in locales {
484            match locale_map.get(locale) {
485                Some(value) => return Some(Cow::Borrowed(value)),
486                None => {
487                    if let Some(pos) = memchr::memchr(b'_', locale.as_bytes()) {
488                        if let Some(value) = locale_map.get(&locale[..pos]) {
489                            return Some(Cow::Borrowed(value));
490                        }
491                    }
492                }
493            }
494        }
495        if let Some(domain) = ubuntu_gettext_domain {
496            return Some(Cow::Owned(dgettext(domain, default_value)));
497        }
498        Some(Cow::Borrowed(default_value))
499    }
500
501    #[inline(never)]
502    pub fn localized_entry_splitted<'a, L: AsRef<str>>(
503        &'a self,
504        group: Option<&'a Group>,
505        key: &str,
506        locales: &[L],
507    ) -> Option<Vec<Cow<'a, str>>> {
508        #[inline(never)]
509        fn inner<'a>(
510            this: &'a DesktopEntry,
511            group: Option<&'a Group>,
512            key: &str,
513            locales: &mut dyn Iterator<Item = &str>,
514        ) -> Option<Vec<Cow<'a, str>>> {
515            let (default_value, locale_map) = group?.0.get(key)?;
516
517            for locale in locales {
518                match locale_map.get(locale) {
519                    Some(value) => {
520                        return Some(value.split(';').map(Cow::Borrowed).collect());
521                    }
522                    None => {
523                        if let Some(pos) = memchr::memchr(b'_', locale.as_bytes()) {
524                            if let Some(value) = locale_map.get(&locale[..pos]) {
525                                return Some(value.split(';').map(Cow::Borrowed).collect());
526                            }
527                        }
528                    }
529                }
530            }
531            if let Some(domain) = &this.ubuntu_gettext_domain {
532                return Some(
533                    dgettext(domain, default_value)
534                        .split(';')
535                        .map(|e| Cow::Owned(e.to_string()))
536                        .collect(),
537                );
538            }
539
540            Some(default_value.split(';').map(Cow::Borrowed).collect())
541        }
542
543        inner(self, group, key, &mut locales.iter().map(AsRef::as_ref))
544    }
545}
546
547impl Display for DesktopEntry {
548    fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
549        for (group_name, group) in &self.groups.0 {
550            let _ = writeln!(formatter, "[{}]", group_name);
551
552            for (key, (value, localizations)) in &group.0 {
553                let _ = writeln!(formatter, "{}={}", key, value);
554                for (locale, localized) in localizations {
555                    let _ = writeln!(formatter, "{}[{}]={}", key, locale, localized);
556                }
557            }
558            writeln!(formatter)?;
559        }
560
561        Ok(())
562    }
563}
564
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum IconSource {
567    Name(String),
568    Path(PathBuf),
569}
570
571impl IconSource {
572    pub fn from_unknown(icon: &str) -> Self {
573        let icon_path = Path::new(icon);
574        if icon_path.is_absolute() && icon_path.exists() {
575            Self::Path(icon_path.into())
576        } else {
577            Self::Name(icon.into())
578        }
579    }
580}
581
582impl Default for IconSource {
583    #[inline]
584    fn default() -> Self {
585        Self::Name("application-default".to_string())
586    }
587}
588
589#[derive(Debug, Clone, Hash, PartialEq, Eq)]
590pub enum PathSource {
591    Local,
592    LocalDesktop,
593    LocalFlatpak,
594    LocalNix,
595    Nix,
596    System,
597    SystemLocal,
598    SystemFlatpak,
599    SystemSnap,
600    Other(String),
601}
602
603impl PathSource {
604    /// Attempts to determine the PathSource for a given Path.
605    /// Note that this is a best-effort guesting function, and its results should be treated as
606    /// such (e.g.: non-canonical).
607    pub fn guess_from(path: &Path) -> PathSource {
608        let base_dirs = BaseDirectories::new().unwrap();
609        let data_home = base_dirs.get_data_home();
610
611        if path.starts_with("/usr/share") {
612            PathSource::System
613        } else if path.starts_with("/usr/local/share") {
614            PathSource::SystemLocal
615        } else if path.starts_with("/var/lib/flatpak") {
616            PathSource::SystemFlatpak
617        } else if path.starts_with("/var/lib/snapd") {
618            PathSource::SystemSnap
619        } else if path.starts_with("/nix/var/nix/profiles/default")
620            || path.starts_with("/nix/store")
621        {
622            PathSource::Nix
623        } else if path.to_string_lossy().contains("/flatpak/") {
624            PathSource::LocalFlatpak
625        } else if path.starts_with(data_home.as_path()) {
626            PathSource::Local
627        } else if path.starts_with("/nix/var/nix/profiles/per-user")
628            || path.to_string_lossy().contains(".nix")
629        {
630            PathSource::LocalNix
631        } else {
632            PathSource::Other(String::from("unknown"))
633        }
634    }
635}
636
637/// Returns the default paths in which desktop entries should be searched for based on the current
638/// environment.
639/// Paths are sorted by priority.
640///
641/// Panics in case determining the current home directory fails.
642#[cold]
643pub fn default_paths() -> impl Iterator<Item = PathBuf> {
644    let base_dirs = BaseDirectories::new().unwrap();
645    let mut data_dirs: Vec<PathBuf> = vec![];
646    data_dirs.push(base_dirs.get_data_home());
647    data_dirs.append(&mut base_dirs.get_data_dirs());
648
649    data_dirs.into_iter().map(|d| d.join("applications"))
650}
651
652#[inline]
653pub(crate) fn dgettext(domain: &str, message: &str) -> String {
654    use gettextrs::{setlocale, LocaleCategory};
655    setlocale(LocaleCategory::LcAll, "");
656    gettextrs::dgettext(domain, message)
657}
658
659/// Get the configured user language env variables.
660/// See https://wiki.archlinux.org/title/Locale#LANG:_default_locale for more information
661#[cold]
662pub fn get_languages_from_env() -> Vec<String> {
663    let mut l = Vec::new();
664
665    if let Ok(lang) = std::env::var("LANG") {
666        l.push(lang);
667    }
668
669    if let Ok(lang) = std::env::var("LANGUAGES") {
670        lang.split(':').for_each(|lang| {
671            l.push(lang.to_owned());
672        })
673    }
674
675    l
676}
677
678pub fn current_desktop() -> Option<Vec<String>> {
679    std::env::var("XDG_CURRENT_DESKTOP").ok().map(|x| {
680        let x = x.to_ascii_lowercase();
681        if x == "unity" {
682            vec!["gnome".to_string()]
683        } else {
684            x.split(':').map(|e| e.to_string()).collect()
685        }
686    })
687}
688
689#[test]
690fn add_field() {
691    let appid = "appid";
692    let de = DesktopEntry::from_appid(appid.to_string());
693
694    assert_eq!(de.appid, appid);
695    assert_eq!(de.name(&[] as &[&str]).unwrap(), appid);
696
697    let s = get_languages_from_env();
698
699    println!("{:?}", s);
700}
701
702#[test]
703fn env_with_locale() {
704    let locales = &["fr_FR"];
705
706    let de = DesktopEntry::from_path(
707        PathBuf::from("tests_entries/org.mozilla.firefox.desktop"),
708        Some(locales),
709    )
710    .unwrap();
711
712    assert_eq!(de.generic_name(locales).unwrap(), "Navigateur Web");
713
714    let locales = &["nb"];
715
716    assert_eq!(de.generic_name(locales).unwrap(), "Web Browser");
717}