1mod 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
20pub 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
27pub fn find_app_by_id<'a>(
33 entries: &'a [DesktopEntry],
34 app_id: Ascii<&str>,
35) -> Option<&'a DesktopEntry> {
36 let match_by_wm_class = entries.iter().find(|entry| entry.matches_wm_class(app_id));
40
41 match_by_wm_class
42 .or_else(|| entries.iter().find(|entry| entry.matches_id(app_id)))
44 .or_else(|| entries.iter().find(|entry| entry.matches_name(app_id)))
46 .or_else(|| {
48 entries
49 .iter()
50 .find(|entry| entry.exec().is_some_and(|exec| exec == app_id))
51 })
52 .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 #[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 #[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 #[inline]
192 pub fn matches_id(&self, id: Ascii<&str>) -> bool {
193 id == self.id()
195 || self.path.file_stem()
197 .and_then(|os_str| os_str.to_str())
198 .is_some_and(|name| {
199 name == id
200 || id.split('.').rev().next().is_some_and(|id| id == name)
202 })
203 }
204
205 #[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 #[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 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 #[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 #[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 #[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 #[inline]
305 pub fn categories(&self) -> Option<Vec<&str>> {
306 self.desktop_entry("Categories")
307 .map(|e| e.split(';').collect())
308 }
309
310 #[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 #[inline]
318 pub fn mime_type(&self) -> Option<Vec<&str>> {
319 self.desktop_entry("MimeType")
320 .map(|e| e.split(';').collect())
321 }
322
323 #[inline]
325 pub fn implements(&self) -> Option<Vec<&str>> {
326 self.desktop_entry("Implements")
327 .map(|e| e.split(';').collect())
328 }
329
330 #[inline]
332 pub fn no_display(&self) -> bool {
333 self.desktop_entry_bool("NoDisplay")
334 }
335
336 #[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 #[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 #[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 #[inline]
383 pub fn single_main_window(&self) -> bool {
384 self.desktop_entry_bool("SingleMainWindow")
385 }
386
387 #[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 pub fn url(&self) -> Option<&str> {
400 self.desktop_entry("URL")
401 }
402 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 #[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 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#[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#[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}