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