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
40        .iter()
41        .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().is_some_and(|wm_class| wm_class == id)
189    }
190
191    /// Match entry by desktop entry file name
192    #[inline]
193    pub fn matches_id(&self, id: Ascii<&str>) -> bool {
194        // If the desktop entry appid matches
195        id == self.id()
196            // or the path itself matches
197            || self.path.file_stem()
198                .and_then(|os_str| os_str.to_str())
199                .is_some_and(|name| {
200                    name == id
201                        // Or match by last part of app ID
202                        || id.split('.').rev().next().is_some_and(|id| id == name)
203                })
204    }
205
206    // Match by name specified in desktop entry, which should only be used if a match by ID failed.
207    #[inline]
208    pub fn matches_name(&self, name: Ascii<&str>) -> bool {
209        self.name::<&str>(&[])
210            .map(|n| n.as_ref() == name)
211            .unwrap_or_default()
212    }
213}
214
215impl DesktopEntry {
216    #[inline]
217    pub fn id(&self) -> &str {
218        self.appid.as_ref()
219    }
220
221    /// A desktop entry field if any field under the `[Desktop Entry]` section.
222    #[inline]
223    pub fn desktop_entry(&self, key: &str) -> Option<&str> {
224        self.groups.desktop_entry()?.entry(key)
225    }
226
227    #[inline]
228    pub fn desktop_entry_localized<'a, L: AsRef<str>>(
229        &'a self,
230        key: &str,
231        locales: &[L],
232    ) -> Option<Cow<'a, str>> {
233        Self::localized_entry(
234            self.ubuntu_gettext_domain.as_deref(),
235            self.groups.desktop_entry(),
236            key,
237            &mut locales.iter().map(AsRef::as_ref),
238        )
239    }
240
241    /// Insert a new field to this [`DesktopEntry`], in the `[Desktop Entry]` section, removing
242    /// the previous value and locales in any.
243    pub fn add_desktop_entry(&mut self, key: String, value: String) {
244        let action_key = "Desktop Entry";
245        let value = (value, LocaleMap::default());
246
247        match self.groups.0.get_mut(action_key) {
248            Some(keymap) => {
249                keymap.0.insert(key, value);
250            }
251            None => {
252                let mut keymap = Group::default();
253                keymap.0.insert(key, value);
254                self.groups.0.insert(action_key.to_string(), keymap);
255            }
256        }
257    }
258
259    #[inline]
260    pub fn name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
261        self.desktop_entry_localized("Name", locales)
262    }
263
264    #[inline]
265    pub fn generic_name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
266        self.desktop_entry_localized("GenericName", locales)
267    }
268
269    /// Get the full name of an application, and fall back to the name if that fails.
270    #[inline]
271    pub fn full_name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
272        self.desktop_entry_localized("X-GNOME-FullName", locales)
273            .filter(|name| !name.as_ref().is_empty())
274            .or_else(|| self.name(locales))
275    }
276
277    #[inline]
278    pub fn icon(&self) -> Option<&str> {
279        self.desktop_entry("Icon")
280    }
281
282    /// This is an human readable description of the desktop file.
283    #[inline]
284    pub fn comment<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<str>> {
285        self.desktop_entry_localized("Comment", locales)
286    }
287
288    #[inline]
289    pub fn exec(&self) -> Option<&str> {
290        self.desktop_entry("Exec")
291    }
292
293    /// Return categories
294    #[inline]
295    pub fn categories(&self) -> Option<Vec<&str>> {
296        self.desktop_entry("Categories")
297            .map(|e| e.split(';').collect())
298    }
299
300    /// Return keywords
301    #[inline]
302    pub fn keywords<L: AsRef<str>>(&self, locales: &[L]) -> Option<Vec<Cow<str>>> {
303        self.localized_entry_splitted(self.groups.desktop_entry(), "Keywords", locales)
304    }
305
306    /// Return mime types
307    #[inline]
308    pub fn mime_type(&self) -> Option<Vec<&str>> {
309        self.desktop_entry("MimeType")
310            .map(|e| e.split(';').collect())
311    }
312
313    #[inline]
314    pub fn no_display(&self) -> bool {
315        self.desktop_entry_bool("NoDisplay")
316    }
317
318    #[inline]
319    pub fn only_show_in(&self) -> Option<Vec<&str>> {
320        self.desktop_entry("OnlyShowIn")
321            .map(|e| e.split(';').collect())
322    }
323
324    #[inline]
325    pub fn not_show_in(&self) -> Option<Vec<&str>> {
326        self.desktop_entry("NotShowIn")
327            .map(|e| e.split(';').collect())
328    }
329
330    #[inline]
331    pub fn flatpak(&self) -> Option<&str> {
332        self.desktop_entry("X-Flatpak")
333    }
334
335    #[inline]
336    pub fn prefers_non_default_gpu(&self) -> bool {
337        self.desktop_entry_bool("PrefersNonDefaultGPU")
338    }
339
340    #[inline]
341    pub fn startup_notify(&self) -> bool {
342        self.desktop_entry_bool("StartupNotify")
343    }
344
345    #[inline]
346    pub fn startup_wm_class(&self) -> Option<&str> {
347        self.desktop_entry("StartupWMClass")
348    }
349
350    #[inline]
351    pub fn terminal(&self) -> bool {
352        self.desktop_entry_bool("Terminal")
353    }
354
355    #[inline]
356    pub fn type_(&self) -> Option<&str> {
357        self.desktop_entry("Type")
358    }
359
360    #[inline]
361    pub fn actions(&self) -> Option<Vec<&str>> {
362        self.desktop_entry("Actions")
363            .map(|e| e.split(';').collect())
364    }
365
366    /// An action is defined as `[Desktop Action actions-name]` where `action-name`
367    /// is defined in the `Actions` field of `[Desktop Entry]`.
368    /// Example: to get the `Name` field of this `new-window` action
369    /// ```txt
370    /// [Desktop Action new-window]
371    /// Name=Open a New Window
372    /// ```
373    /// you will need to call
374    /// ```ignore
375    /// entry.action_entry("new-window", "Name")
376    /// ```
377    #[inline]
378    pub fn action_entry(&self, action: &str, key: &str) -> Option<&str> {
379        self.groups
380            .group(["Desktop Action ", action].concat().as_str())?
381            .entry(key)
382    }
383
384    pub fn action_entry_localized<L: AsRef<str>>(
385        &self,
386        action: &str,
387        key: &str,
388        locales: &[L],
389    ) -> Option<Cow<'_, str>> {
390        #[inline(never)]
391        fn inner<'a>(
392            this: &'a DesktopEntry,
393            action: &str,
394            key: &str,
395            locales: &mut dyn Iterator<Item = &str>,
396        ) -> Option<Cow<'a, str>> {
397            let group = this
398                .groups
399                .group(["Desktop Action ", action].concat().as_str());
400
401            DesktopEntry::localized_entry(
402                this.ubuntu_gettext_domain.as_deref(),
403                group,
404                key,
405                locales,
406            )
407        }
408
409        inner(self, action, key, &mut locales.iter().map(AsRef::as_ref))
410    }
411
412    #[inline]
413    pub fn action_name<L: AsRef<str>>(&self, action: &str, locales: &[L]) -> Option<Cow<str>> {
414        self.action_entry_localized(action, "Name", locales)
415    }
416
417    #[inline]
418    pub fn action_exec(&self, action: &str) -> Option<&str> {
419        self.action_entry(action, "Exec")
420    }
421
422    #[inline]
423    fn desktop_entry_bool(&self, key: &str) -> bool {
424        self.desktop_entry(key).map_or(false, |v| v == "true")
425    }
426
427    #[inline(never)]
428    pub(crate) fn localized_entry<'a>(
429        ubuntu_gettext_domain: Option<&str>,
430        group: Option<&'a Group>,
431        key: &str,
432        locales: &mut dyn Iterator<Item = &str>,
433    ) -> Option<Cow<'a, str>> {
434        let (default_value, locale_map) = group?.0.get(key)?;
435
436        for locale in locales {
437            match locale_map.get(locale) {
438                Some(value) => return Some(Cow::Borrowed(value)),
439                None => {
440                    if let Some(pos) = memchr::memchr(b'_', locale.as_bytes()) {
441                        if let Some(value) = locale_map.get(&locale[..pos]) {
442                            return Some(Cow::Borrowed(value));
443                        }
444                    }
445                }
446            }
447        }
448        if let Some(domain) = ubuntu_gettext_domain {
449            return Some(Cow::Owned(dgettext(domain, default_value)));
450        }
451        Some(Cow::Borrowed(default_value))
452    }
453
454    #[inline(never)]
455    pub fn localized_entry_splitted<'a, L: AsRef<str>>(
456        &'a self,
457        group: Option<&'a Group>,
458        key: &str,
459        locales: &[L],
460    ) -> Option<Vec<Cow<'a, str>>> {
461        #[inline(never)]
462        fn inner<'a>(
463            this: &'a DesktopEntry,
464            group: Option<&'a Group>,
465            key: &str,
466            locales: &mut dyn Iterator<Item = &str>,
467        ) -> Option<Vec<Cow<'a, str>>> {
468            let (default_value, locale_map) = group?.0.get(key)?;
469
470            for locale in locales {
471                match locale_map.get(locale) {
472                    Some(value) => {
473                        return Some(value.split(';').map(Cow::Borrowed).collect());
474                    }
475                    None => {
476                        if let Some(pos) = memchr::memchr(b'_', locale.as_bytes()) {
477                            if let Some(value) = locale_map.get(&locale[..pos]) {
478                                return Some(value.split(';').map(Cow::Borrowed).collect());
479                            }
480                        }
481                    }
482                }
483            }
484            if let Some(domain) = &this.ubuntu_gettext_domain {
485                return Some(
486                    dgettext(domain, default_value)
487                        .split(';')
488                        .map(|e| Cow::Owned(e.to_string()))
489                        .collect(),
490                );
491            }
492
493            Some(default_value.split(';').map(Cow::Borrowed).collect())
494        }
495
496        inner(self, group, key, &mut locales.iter().map(AsRef::as_ref))
497    }
498}
499
500impl Display for DesktopEntry {
501    fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
502        for (group_name, group) in &self.groups.0 {
503            let _ = writeln!(formatter, "[{}]", group_name);
504
505            for (key, (value, localizations)) in &group.0 {
506                let _ = writeln!(formatter, "{}={}", key, value);
507                for (locale, localized) in localizations {
508                    let _ = writeln!(formatter, "{}[{}]={}", key, locale, localized);
509                }
510            }
511            writeln!(formatter)?;
512        }
513
514        Ok(())
515    }
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum IconSource {
520    Name(String),
521    Path(PathBuf),
522}
523
524impl IconSource {
525    pub fn from_unknown(icon: &str) -> Self {
526        let icon_path = Path::new(icon);
527        if icon_path.is_absolute() && icon_path.exists() {
528            Self::Path(icon_path.into())
529        } else {
530            Self::Name(icon.into())
531        }
532    }
533}
534
535impl Default for IconSource {
536    #[inline]
537    fn default() -> Self {
538        Self::Name("application-default".to_string())
539    }
540}
541
542#[derive(Debug, Clone, Hash, PartialEq, Eq)]
543pub enum PathSource {
544    Local,
545    LocalDesktop,
546    LocalFlatpak,
547    LocalNix,
548    Nix,
549    System,
550    SystemLocal,
551    SystemFlatpak,
552    SystemSnap,
553    Other(String),
554}
555
556impl PathSource {
557    /// Attempts to determine the PathSource for a given Path.
558    /// Note that this is a best-effort guesting function, and its results should be treated as
559    /// such (e.g.: non-canonical).
560    pub fn guess_from(path: &Path) -> PathSource {
561        let base_dirs = BaseDirectories::new().unwrap();
562        let data_home = base_dirs.get_data_home();
563
564        if path.starts_with("/usr/share") {
565            PathSource::System
566        } else if path.starts_with("/usr/local/share") {
567            PathSource::SystemLocal
568        } else if path.starts_with("/var/lib/flatpak") {
569            PathSource::SystemFlatpak
570        } else if path.starts_with("/var/lib/snapd") {
571            PathSource::SystemSnap
572        } else if path.starts_with("/nix/var/nix/profiles/default")
573            || path.starts_with("/nix/store")
574        {
575            PathSource::Nix
576        } else if path.to_string_lossy().contains("/flatpak/") {
577            PathSource::LocalFlatpak
578        } else if path.starts_with(data_home.as_path()) {
579            PathSource::Local
580        } else if path.starts_with("/nix/var/nix/profiles/per-user")
581            || path.to_string_lossy().contains(".nix")
582        {
583            PathSource::LocalNix
584        } else {
585            PathSource::Other(String::from("unknown"))
586        }
587    }
588}
589
590/// Returns the default paths in which desktop entries should be searched for based on the current
591/// environment.
592/// Paths are sorted by priority.
593///
594/// Panics in case determining the current home directory fails.
595#[cold]
596pub fn default_paths() -> impl Iterator<Item = PathBuf> {
597    let base_dirs = BaseDirectories::new().unwrap();
598    let mut data_dirs: Vec<PathBuf> = vec![];
599    data_dirs.push(base_dirs.get_data_home());
600    data_dirs.append(&mut base_dirs.get_data_dirs());
601
602    data_dirs.into_iter().map(|d| d.join("applications"))
603}
604
605#[inline]
606pub(crate) fn dgettext(domain: &str, message: &str) -> String {
607    use gettextrs::{setlocale, LocaleCategory};
608    setlocale(LocaleCategory::LcAll, "");
609    gettextrs::dgettext(domain, message)
610}
611
612/// Get the configured user language env variables.
613/// See https://wiki.archlinux.org/title/Locale#LANG:_default_locale for more information
614#[cold]
615pub fn get_languages_from_env() -> Vec<String> {
616    let mut l = Vec::new();
617
618    if let Ok(lang) = std::env::var("LANG") {
619        l.push(lang);
620    }
621
622    if let Ok(lang) = std::env::var("LANGUAGES") {
623        lang.split(':').for_each(|lang| {
624            l.push(lang.to_owned());
625        })
626    }
627
628    l
629}
630
631pub fn current_desktop() -> Option<Vec<String>> {
632    std::env::var("XDG_CURRENT_DESKTOP").ok().map(|x| {
633        let x = x.to_ascii_lowercase();
634        if x == "unity" {
635            vec!["gnome".to_string()]
636        } else {
637            x.split(':').map(|e| e.to_string()).collect()
638        }
639    })
640}
641
642#[test]
643fn add_field() {
644    let appid = "appid";
645    let de = DesktopEntry::from_appid(appid.to_string());
646
647    assert_eq!(de.appid, appid);
648    assert_eq!(de.name(&[] as &[&str]).unwrap(), appid);
649
650    let s = get_languages_from_env();
651
652    println!("{:?}", s);
653}
654
655#[test]
656fn env_with_locale() {
657    let locales = &["fr_FR"];
658
659    let de = DesktopEntry::from_path(
660        PathBuf::from("tests_entries/org.mozilla.firefox.desktop"),
661        Some(locales),
662    )
663    .unwrap();
664
665    assert_eq!(de.generic_name(locales).unwrap(), "Navigateur Web");
666
667    let locales = &["nb"];
668
669    assert_eq!(de.generic_name(locales).unwrap(), "Web Browser");
670}