freedesktop_desktop_entry/
lib.rs

1// Copyright 2021 System76 <info@system76.com>
2// SPDX-License-Identifier: MPL-2.0
3
4mod decoder;
5mod iter;
6
7mod exec;
8use cached::proc_macro::cached;
9pub use exec::ExecError;
10
11pub mod matching;
12pub use decoder::DecodeError;
13
14pub use self::iter::Iter;
15use std::borrow::Cow;
16use std::collections::BTreeMap;
17use std::hash::{Hash, Hasher};
18
19use std::path::{Path, PathBuf};
20use xdg::BaseDirectories;
21
22pub type GroupName = String;
23#[derive(Debug, Clone, Default)]
24pub struct Groups(pub BTreeMap<GroupName, Group>);
25
26impl Groups {
27    pub fn desktop_entry(&self) -> Option<&Group> {
28        self.0.get("Desktop Entry")
29    }
30
31    pub fn group(&self, key: &str) -> Option<&Group> {
32        self.0.get(key)
33    }
34}
35
36pub type Key = String;
37#[derive(Debug, Clone, Default)]
38pub struct Group(pub BTreeMap<Key, (Value, LocaleMap)>);
39
40impl Group {
41    pub fn localized_entry<L: AsRef<str>>(&self, key: &str, locales: &[L]) -> Option<&str> {
42        let (default_value, locale_map) = self.0.get(key)?;
43
44        for locale in locales {
45            match locale_map.get(locale.as_ref()) {
46                Some(value) => return Some(value),
47                None => {
48                    if let Some(pos) = memchr::memchr(b'_', locale.as_ref().as_bytes()) {
49                        if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) {
50                            return Some(value);
51                        }
52                    }
53                }
54            }
55        }
56
57        Some(default_value)
58    }
59
60    pub fn entry(&self, key: &str) -> Option<&str> {
61        self.0.get(key).map(|key| key.0.as_ref())
62    }
63
64    pub fn entry_bool(&self, key: &str) -> Option<bool> {
65        match self.entry(key)? {
66            "true" => Some(true),
67            "false" => Some(false),
68            _ => None,
69        }
70    }
71}
72
73pub type Locale = String;
74pub type LocaleMap = BTreeMap<Locale, Value>;
75pub type Value = String;
76
77#[derive(Debug, Clone)]
78pub struct DesktopEntry {
79    pub appid: String,
80    pub groups: Groups,
81    pub path: PathBuf,
82    pub ubuntu_gettext_domain: Option<String>,
83}
84
85impl Eq for DesktopEntry {}
86
87impl Hash for DesktopEntry {
88    fn hash<H: Hasher>(&self, state: &mut H) {
89        self.appid.hash(state);
90    }
91}
92
93impl PartialEq for DesktopEntry {
94    fn eq(&self, other: &Self) -> bool {
95        self.appid == other.appid
96    }
97}
98
99impl DesktopEntry {
100    /// Construct a new [`DesktopEntry`] from an appid. The name field will be
101    /// set to that appid.
102    pub fn from_appid(appid: String) -> DesktopEntry {
103        let name = appid.split('.').next_back().unwrap_or(&appid).to_string();
104
105        let mut de = DesktopEntry {
106            appid,
107            groups: Groups::default(),
108            path: PathBuf::from(""),
109            ubuntu_gettext_domain: None,
110        };
111        de.add_desktop_entry("Name".to_string(), name);
112        de
113    }
114}
115
116impl DesktopEntry {
117    pub fn id(&self) -> &str {
118        self.appid.as_ref()
119    }
120
121    /// A desktop entry field if any field under the `[Desktop Entry]` section.
122    pub fn desktop_entry(&self, key: &str) -> Option<&str> {
123        self.groups.desktop_entry()?.entry(key)
124    }
125
126    pub fn desktop_entry_localized<'a, L: AsRef<str>>(
127        &'a self,
128        key: &str,
129        locales: &[L],
130    ) -> Option<Cow<'a, str>> {
131        Self::localized_entry(
132            self.ubuntu_gettext_domain.as_deref(),
133            self.groups.desktop_entry(),
134            key,
135            locales,
136        )
137    }
138
139    /// Insert a new field to this [`DesktopEntry`], in the `[Desktop Entry]` section, removing
140    /// the previous value and locales in any.
141    pub fn add_desktop_entry(&mut self, key: String, value: String) {
142        let action_key = "Desktop Entry";
143        let value = (value, LocaleMap::default());
144
145        match self.groups.0.get_mut(action_key) {
146            Some(keymap) => {
147                keymap.0.insert(key, value);
148            }
149            None => {
150                let mut keymap = Group::default();
151                keymap.0.insert(key, value);
152                self.groups.0.insert(action_key.to_string(), keymap);
153            }
154        }
155    }
156
157    pub fn name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
158        self.desktop_entry_localized("Name", locales)
159    }
160
161    pub fn generic_name<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<'_, str>> {
162        self.desktop_entry_localized("GenericName", locales)
163    }
164
165    pub fn icon(&self) -> Option<&str> {
166        self.desktop_entry("Icon")
167    }
168
169    /// This is an human readable description of the desktop file.
170    pub fn comment<L: AsRef<str>>(&self, locales: &[L]) -> Option<Cow<str>> {
171        self.desktop_entry_localized("Comment", locales)
172    }
173
174    pub fn exec(&self) -> Option<&str> {
175        self.desktop_entry("Exec")
176    }
177
178    /// Return categories
179    pub fn categories(&self) -> Option<Vec<&str>> {
180        self.desktop_entry("Categories")
181            .map(|e| e.split(';').collect())
182    }
183
184    /// Return keywords
185    pub fn keywords<L: AsRef<str>>(&self, locales: &[L]) -> Option<Vec<Cow<str>>> {
186        self.localized_entry_splitted(self.groups.desktop_entry(), "Keywords", locales)
187    }
188
189    /// Return mime types
190    pub fn mime_type(&self) -> Option<Vec<&str>> {
191        self.desktop_entry("MimeType")
192            .map(|e| e.split(';').collect())
193    }
194
195    pub fn no_display(&self) -> bool {
196        self.desktop_entry_bool("NoDisplay")
197    }
198
199    pub fn only_show_in(&self) -> Option<Vec<&str>> {
200        self.desktop_entry("OnlyShowIn")
201            .map(|e| e.split(';').collect())
202    }
203
204    pub fn not_show_in(&self) -> Option<Vec<&str>> {
205        self.desktop_entry("NotShowIn")
206            .map(|e| e.split(';').collect())
207    }
208
209    pub fn flatpak(&self) -> Option<&str> {
210        self.desktop_entry("X-Flatpak")
211    }
212
213    pub fn prefers_non_default_gpu(&self) -> bool {
214        self.desktop_entry_bool("PrefersNonDefaultGPU")
215    }
216
217    pub fn startup_notify(&self) -> bool {
218        self.desktop_entry_bool("StartupNotify")
219    }
220
221    pub fn startup_wm_class(&self) -> Option<&str> {
222        self.desktop_entry("StartupWMClass")
223    }
224
225    pub fn terminal(&self) -> bool {
226        self.desktop_entry_bool("Terminal")
227    }
228
229    pub fn type_(&self) -> Option<&str> {
230        self.desktop_entry("Type")
231    }
232
233    pub fn actions(&self) -> Option<Vec<&str>> {
234        self.desktop_entry("Actions")
235            .map(|e| e.split(';').collect())
236    }
237
238    /// An action is defined as `[Desktop Action actions-name]` where `action-name`
239    /// is defined in the `Actions` field of `[Desktop Entry]`.
240    /// Example: to get the `Name` field of this `new-window` action
241    /// ```txt
242    /// [Desktop Action new-window]
243    /// Name=Open a New Window
244    /// ```
245    /// you will need to call
246    /// ```ignore
247    /// entry.action_entry("new-window", "Name")
248    /// ```
249    pub fn action_entry(&self, action: &str, key: &str) -> Option<&str> {
250        self.groups
251            .group(["Desktop Action ", action].concat().as_str())?
252            .entry(key)
253    }
254
255    pub fn action_entry_localized<L: AsRef<str>>(
256        &self,
257        action: &str,
258        key: &str,
259        locales: &[L],
260    ) -> Option<Cow<'_, str>> {
261        let group = self
262            .groups
263            .group(["Desktop Action ", action].concat().as_str());
264
265        Self::localized_entry(self.ubuntu_gettext_domain.as_deref(), group, key, locales)
266    }
267
268    pub fn action_name<L: AsRef<str>>(&self, action: &str, locales: &[L]) -> Option<Cow<str>> {
269        self.action_entry_localized(action, "Name", locales)
270    }
271
272    pub fn action_exec(&self, action: &str) -> Option<&str> {
273        self.action_entry(action, "Exec")
274    }
275
276    fn desktop_entry_bool(&self, key: &str) -> bool {
277        self.desktop_entry(key).map_or(false, |v| v == "true")
278    }
279
280    pub(crate) fn localized_entry<'a, L: AsRef<str>>(
281        ubuntu_gettext_domain: Option<&str>,
282        group: Option<&'a Group>,
283        key: &str,
284        locales: &[L],
285    ) -> Option<Cow<'a, str>> {
286        let (default_value, locale_map) = group?.0.get(key)?;
287
288        for locale in locales {
289            match locale_map.get(locale.as_ref()) {
290                Some(value) => return Some(Cow::Borrowed(value)),
291                None => {
292                    if let Some(pos) = memchr::memchr(b'_', locale.as_ref().as_bytes()) {
293                        if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) {
294                            return Some(Cow::Borrowed(value));
295                        }
296                    }
297                }
298            }
299        }
300        if let Some(domain) = ubuntu_gettext_domain {
301            return Some(Cow::Owned(dgettext(domain, default_value)));
302        }
303        Some(Cow::Borrowed(default_value))
304    }
305
306    pub fn localized_entry_splitted<'a, L: AsRef<str>>(
307        &self,
308        group: Option<&'a Group>,
309        key: &str,
310        locales: &[L],
311    ) -> Option<Vec<Cow<'a, str>>> {
312        let (default_value, locale_map) = group?.0.get(key)?;
313
314        for locale in locales {
315            match locale_map.get(locale.as_ref()) {
316                Some(value) => {
317                    return Some(value.split(';').map(Cow::Borrowed).collect());
318                }
319                None => {
320                    if let Some(pos) = memchr::memchr(b'_', locale.as_ref().as_bytes()) {
321                        if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) {
322                            return Some(value.split(';').map(Cow::Borrowed).collect());
323                        }
324                    }
325                }
326            }
327        }
328        if let Some(domain) = &self.ubuntu_gettext_domain {
329            return Some(
330                dgettext(domain, default_value)
331                    .split(';')
332                    .map(|e| Cow::Owned(e.to_string()))
333                    .collect(),
334            );
335        }
336
337        Some(default_value.split(';').map(Cow::Borrowed).collect())
338    }
339}
340
341use std::fmt::{self, Display, Formatter};
342
343impl Display for DesktopEntry {
344    fn fmt(&self, formatter: &mut Formatter) -> fmt::Result {
345        for (group_name, group) in &self.groups.0 {
346            let _ = writeln!(formatter, "[{}]", group_name);
347
348            for (key, (value, localizations)) in &group.0 {
349                let _ = writeln!(formatter, "{}={}", key, value);
350                for (locale, localized) in localizations {
351                    let _ = writeln!(formatter, "{}[{}]={}", key, locale, localized);
352                }
353            }
354            writeln!(formatter)?;
355        }
356
357        Ok(())
358    }
359}
360
361#[derive(Debug, Clone, Hash, PartialEq, Eq)]
362pub enum PathSource {
363    Local,
364    LocalDesktop,
365    LocalFlatpak,
366    LocalNix,
367    Nix,
368    System,
369    SystemLocal,
370    SystemFlatpak,
371    SystemSnap,
372    Other(String),
373}
374
375impl PathSource {
376    /// Attempts to determine the PathSource for a given Path.
377    /// Note that this is a best-effort guesting function, and its results should be treated as
378    /// such (e.g.: non-canonical).
379    pub fn guess_from(path: &Path) -> PathSource {
380        let base_dirs = BaseDirectories::new().unwrap();
381        let data_home = base_dirs.get_data_home();
382
383        if path.starts_with("/usr/share") {
384            PathSource::System
385        } else if path.starts_with("/usr/local/share") {
386            PathSource::SystemLocal
387        } else if path.starts_with("/var/lib/flatpak") {
388            PathSource::SystemFlatpak
389        } else if path.starts_with("/var/lib/snapd") {
390            PathSource::SystemSnap
391        } else if path.starts_with("/nix/var/nix/profiles/default")
392            || path.starts_with("/nix/store")
393        {
394            PathSource::Nix
395        } else if path.to_string_lossy().contains("/flatpak/") {
396            PathSource::LocalFlatpak
397        } else if path.starts_with(data_home.as_path()) {
398            PathSource::Local
399        } else if path.starts_with("/nix/var/nix/profiles/per-user")
400            || path.to_string_lossy().contains(".nix")
401        {
402            PathSource::LocalNix
403        } else {
404            PathSource::Other(String::from("unknown"))
405        }
406    }
407}
408
409/// Returns the default paths in which desktop entries should be searched for based on the current
410/// environment.
411/// Paths are sorted by priority.
412///
413/// Panics in case determining the current home directory fails.
414pub fn default_paths() -> impl Iterator<Item = PathBuf> {
415    let base_dirs = BaseDirectories::new().unwrap();
416    let mut data_dirs: Vec<PathBuf> = vec![];
417    data_dirs.push(base_dirs.get_data_home());
418    data_dirs.append(&mut base_dirs.get_data_dirs());
419
420    data_dirs.into_iter().map(|d| d.join("applications"))
421}
422
423pub(crate) fn dgettext(domain: &str, message: &str) -> String {
424    use gettextrs::{setlocale, LocaleCategory};
425    setlocale(LocaleCategory::LcAll, "");
426    gettextrs::dgettext(domain, message)
427}
428
429/// Get the configured user language env variables.
430/// See https://wiki.archlinux.org/title/Locale#LANG:_default_locale for more information
431#[cached]
432pub fn get_languages_from_env() -> Vec<String> {
433    let mut l = Vec::new();
434
435    if let Ok(lang) = std::env::var("LANG") {
436        l.push(lang);
437    }
438
439    if let Ok(lang) = std::env::var("LANGUAGES") {
440        lang.split(':').for_each(|lang| {
441            l.push(lang.to_owned());
442        })
443    }
444
445    l
446}
447
448pub fn current_desktop() -> Option<Vec<String>> {
449    std::env::var("XDG_CURRENT_DESKTOP").ok().map(|x| {
450        let x = x.to_ascii_lowercase();
451        if x == "unity" {
452            vec!["gnome".to_string()]
453        } else {
454            x.split(':').map(|e| e.to_string()).collect()
455        }
456    })
457}
458
459#[test]
460fn add_field() {
461    let appid = "appid";
462    let de = DesktopEntry::from_appid(appid.to_string());
463
464    assert_eq!(de.appid, appid);
465    assert_eq!(de.name(&[] as &[&str]).unwrap(), appid);
466
467    let s = get_languages_from_env();
468
469    println!("{:?}", s);
470}
471
472#[test]
473fn env_with_locale() {
474    let locales = &["fr_FR"];
475
476    let de = DesktopEntry::from_path(
477        PathBuf::from("tests_entries/org.mozilla.firefox.desktop"),
478        Some(locales),
479    )
480    .unwrap();
481
482    assert_eq!(de.generic_name(locales).unwrap(), "Navigateur Web");
483
484    let locales = &["nb"];
485
486    assert_eq!(de.generic_name(locales).unwrap(), "Web Browser");
487}