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