1#[cfg(not(windows))]
2pub use cosmic_launch::gpu;
3#[cfg(not(windows))]
4pub use freedesktop_desktop_entry as fde;
5#[cfg(not(windows))]
6pub use mime::Mime;
7use std::path::{Path, PathBuf};
8#[cfg(not(windows))]
9use std::{borrow::Cow, collections::HashSet};
10
11pub trait IconSourceExt {
12 fn as_cosmic_icon(&self) -> crate::widget::icon::Handle;
13}
14
15#[cfg(not(windows))]
16impl IconSourceExt for fde::IconSource {
17 fn as_cosmic_icon(&self) -> crate::widget::icon::Handle {
18 match self {
19 fde::IconSource::Name(name) => crate::widget::icon::from_name(name.as_str())
20 .prefer_svg(true)
21 .size(128)
22 .fallback(Some(crate::widget::icon::IconFallback::Names(vec![
23 "application-default".into(),
24 "application-x-executable".into(),
25 ])))
26 .handle(),
27 fde::IconSource::Path(path) => crate::widget::icon::from_path(path.clone()),
28 }
29 }
30}
31
32#[cfg(not(windows))]
33#[derive(Debug, Clone, PartialEq)]
34pub struct DesktopAction {
35 pub name: String,
36 pub exec: String,
37}
38
39#[cfg(not(windows))]
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct DesktopEntryData {
42 pub id: String,
43 pub name: String,
44 pub wm_class: Option<String>,
45 pub exec: Option<String>,
46 pub icon: fde::IconSource,
47 pub path: Option<PathBuf>,
48 pub categories: Vec<String>,
49 pub desktop_actions: Vec<DesktopAction>,
50 pub mime_types: Vec<Mime>,
51 pub prefers_dgpu: bool,
52 pub working_directory: Option<String>,
53 pub terminal: bool,
54}
55
56#[cfg(not(windows))]
57impl cosmic_launch::Executable for DesktopEntryData {
58 fn name(&self) -> Cow<'_, str> {
59 Cow::Borrowed(&self.name)
60 }
61
62 fn appid(&self) -> Option<&str> {
63 Some(&self.id)
64 }
65
66 fn exec(&self, action: Option<&str>) -> &str {
67 action.map_or_else(
68 || self.exec.as_deref().unwrap_or_default(),
69 |action| {
70 self.desktop_actions
71 .iter()
72 .find(|a| a.name == action)
73 .map(|action| action.exec.as_str())
74 .unwrap_or_default()
75 },
76 )
77 }
78
79 fn path(&self) -> Option<&str> {
80 self.path.as_ref().and_then(|path| path.to_str())
81 }
82
83 fn working_directory(&self) -> Option<&str> {
84 self.working_directory.as_deref()
85 }
86
87 fn is_terminal(&self) -> bool {
88 self.terminal
89 }
90}
91
92#[cfg(not(windows))]
93#[derive(Debug, Clone)]
94pub struct DesktopEntryCache {
95 locales: Vec<String>,
96 entries: Vec<fde::DesktopEntry>,
97}
98
99#[cfg(not(windows))]
100impl DesktopEntryCache {
101 pub fn new(locales: Vec<String>) -> Self {
102 Self {
103 locales,
104 entries: Vec::new(),
105 }
106 }
107
108 pub fn from_entries(locales: Vec<String>, entries: Vec<fde::DesktopEntry>) -> Self {
109 Self { locales, entries }
110 }
111
112 pub fn ensure_loaded(&mut self) {
113 if self.entries.is_empty() {
114 self.refresh();
115 }
116 }
117
118 pub fn refresh(&mut self) {
119 self.entries = desktop_entries_with_precedence(
120 fde::Iter::new(fde::default_paths())
121 .filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok()),
122 )
123 .filter(|entry| !entry.hidden())
124 .collect();
125 }
126
127 pub fn insert(&mut self, entry: fde::DesktopEntry) {
128 if self
129 .entries
130 .iter()
131 .any(|existing| existing.id() == entry.id())
132 {
133 return;
134 }
135
136 self.entries.push(entry);
137 }
138
139 pub fn locales(&self) -> &[String] {
140 &self.locales
141 }
142
143 pub fn entries(&self) -> &[fde::DesktopEntry] {
144 &self.entries
145 }
146
147 pub fn entries_mut(&mut self) -> &mut [fde::DesktopEntry] {
148 &mut self.entries
149 }
150}
151
152#[cfg(not(windows))]
153impl Default for DesktopEntryCache {
154 fn default() -> Self {
155 Self::new(Vec::new())
156 }
157}
158
159#[cfg(not(windows))]
160#[derive(Debug, Clone)]
161pub struct DesktopLookupContext<'a> {
162 pub app_id: Cow<'a, str>,
163 pub identifier: Option<Cow<'a, str>>,
164 pub title: Option<Cow<'a, str>>,
165}
166
167#[cfg(not(windows))]
168impl<'a> DesktopLookupContext<'a> {
169 pub fn new(app_id: impl Into<Cow<'a, str>>) -> Self {
170 Self {
171 app_id: app_id.into(),
172 identifier: None,
173 title: None,
174 }
175 }
176
177 pub fn with_identifier(mut self, identifier: impl Into<Cow<'a, str>>) -> Self {
178 self.identifier = Some(identifier.into());
179 self
180 }
181
182 pub fn with_title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
183 self.title = Some(title.into());
184 self
185 }
186}
187
188#[cfg(not(windows))]
189#[derive(Debug, Clone)]
190pub struct DesktopResolveOptions {
191 pub include_no_display: bool,
192 pub xdg_current_desktop: Option<String>,
193}
194
195#[cfg(not(windows))]
196impl Default for DesktopResolveOptions {
197 fn default() -> Self {
198 Self {
199 include_no_display: false,
200 xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(),
201 }
202 }
203}
204
205#[cfg(not(windows))]
206pub fn resolve_desktop_entry(
213 cache: &mut DesktopEntryCache,
214 context: &DesktopLookupContext<'_>,
215 options: &DesktopResolveOptions,
216) -> fde::DesktopEntry {
217 let app_id = fde::unicase::Ascii::new(context.app_id.as_ref());
218 let resolve = |cache: &mut DesktopEntryCache| {
219 if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) {
220 return Some(entry.clone());
221 }
222
223 let candidate_ids = candidate_desktop_ids(context);
224
225 if let Some(entry) = try_match_cached(cache.entries(), &candidate_ids) {
226 return Some(entry);
227 }
228
229 if let Some(entry) = load_entry_via_app_ids(
230 cache,
231 &candidate_ids,
232 options.include_no_display,
233 options.xdg_current_desktop.as_deref(),
234 ) {
235 cache.insert(entry.clone());
236 return Some(entry);
237 }
238
239 if let Some(entry) = match_startup_wm_class(cache.entries(), context) {
240 return Some(entry);
241 }
242
243 if let Some(entry) = match_crx_id(cache.entries(), context) {
245 return Some(entry);
246 }
247
248 if let Some(entry) = match_exec_basename(cache.entries(), &candidate_ids) {
249 return Some(entry);
250 }
251
252 if let Some(entry) = proton_or_wine_fallback(cache, context) {
253 cache.insert(entry.clone());
254 return Some(entry);
255 }
256
257 None
258 };
259
260 if let Some(entry) = resolve(cache) {
261 return entry.clone();
262 }
263
264 cache.refresh();
265 resolve(cache).unwrap_or_else(|| {
266 let fallback = fallback_entry(context);
267 cache.insert(fallback.clone());
268 fallback
269 })
270}
271
272#[cfg(not(windows))]
273fn try_match_cached(
274 entries: &[fde::DesktopEntry],
275 candidate_ids: &[String],
276) -> Option<fde::DesktopEntry> {
277 candidate_ids.iter().find_map(|candidate| {
278 fde::find_app_by_id(entries, fde::unicase::Ascii::new(candidate.as_str())).cloned()
279 })
280}
281
282#[cfg(not(windows))]
283fn load_entry_via_app_ids(
284 cache: &DesktopEntryCache,
285 candidate_ids: &[String],
286 include_no_display: bool,
287 xdg_current_desktop: Option<&str>,
288) -> Option<fde::DesktopEntry> {
289 if candidate_ids.is_empty() {
290 return None;
291 }
292
293 let candidate_refs: Vec<&str> = candidate_ids.iter().map(String::as_str).collect();
294 let locales = cache.locales().to_vec();
295 let iter_locales = locales.clone();
296
297 let desktop_iter = fde::Iter::new(fde::default_paths())
298 .filter_map(move |path| fde::DesktopEntry::from_path(path, Some(&iter_locales)).ok());
299
300 let app_iter = load_applications_for_app_ids(
301 desktop_iter,
302 &locales,
303 candidate_refs,
304 false,
305 include_no_display,
306 xdg_current_desktop,
307 );
308
309 let locales_for_load = cache.locales().to_vec();
310 for app in app_iter {
311 if let Some(path) = app.path {
312 if let Ok(entry) = fde::DesktopEntry::from_path(path, Some(&locales_for_load)) {
313 return Some(entry);
314 }
315 }
316 }
317
318 None
319}
320
321#[cfg(not(windows))]
322fn match_startup_wm_class(
323 entries: &[fde::DesktopEntry],
324 context: &DesktopLookupContext<'_>,
325) -> Option<fde::DesktopEntry> {
326 let mut candidates = Vec::new();
327 candidates.push(context.app_id.as_ref());
328 if let Some(identifier) = context.identifier.as_deref() {
329 candidates.push(identifier);
330 }
331 if let Some(title) = context.title.as_deref() {
332 candidates.push(title);
333 }
334
335 for entry in entries {
336 let Some(wm_class) = entry.startup_wm_class() else {
337 continue;
338 };
339
340 if candidates
341 .iter()
342 .any(|candidate| candidate.eq_ignore_ascii_case(wm_class))
343 {
344 return Some(entry.clone());
345 }
346 }
347
348 None
349}
350
351#[cfg(not(windows))]
352fn is_crx_id(candidate: &str) -> bool {
353 is_crx_bytes(candidate.as_bytes())
354}
355
356#[cfg(not(windows))]
357fn is_crx_bytes(bytes: &[u8]) -> bool {
358 bytes.len() == 32 && bytes.iter().all(|b| matches!(b, b'a'..=b'p'))
359}
360
361#[cfg(not(windows))]
362pub fn extract_crx_id(value: &str) -> Option<String> {
363 if let Some(rest) = value.strip_prefix("chrome-") {
364 if let Some(first) = rest.split(&['-', '_'][..]).next() {
365 if is_crx_id(first) {
366 return Some(first.to_string());
367 }
368 }
369 }
370 if let Some(rest) = value.strip_prefix("crx_") {
371 let token = rest
372 .split(|c: char| !c.is_ascii_lowercase())
373 .next()
374 .unwrap_or(rest);
375 if is_crx_id(token) {
376 return Some(token.to_string());
377 }
378 }
379 if is_crx_id(value) {
380 return Some(value.to_string());
381 }
382
383 for window in value.as_bytes().windows(32) {
384 if is_crx_bytes(window) {
385 let slice = std::str::from_utf8(window).expect("ASCII window");
387 return Some(slice.to_string());
388 }
389 }
390
391 None
392}
393
394#[cfg(not(windows))]
395fn match_crx_id(
396 entries: &[fde::DesktopEntry],
397 context: &DesktopLookupContext<'_>,
398) -> Option<fde::DesktopEntry> {
399 let crx = extract_crx_id(context.app_id.as_ref())
400 .or_else(|| context.identifier.as_deref().and_then(extract_crx_id))?;
401
402 for entry in entries {
403 if let Some(exec) = entry.exec() {
404 if exec.contains(&format!("--app-id={}", crx)) {
405 return Some(entry.clone());
406 }
407 }
408 if let Some(wm) = entry.startup_wm_class() {
409 if wm.eq_ignore_ascii_case(&format!("crx_{}", crx)) {
410 return Some(entry.clone());
411 }
412 }
413 if let Some(icon) = entry.icon() {
414 if icon.contains(&crx) {
415 return Some(entry.clone());
416 }
417 }
418 }
419
420 None
421}
422
423#[cfg(not(windows))]
424fn match_exec_basename(
425 entries: &[fde::DesktopEntry],
426 candidate_ids: &[String],
427) -> Option<fde::DesktopEntry> {
428 fn normalize_candidate(candidate: &str) -> String {
429 candidate
430 .trim_matches(|c: char| c == '"' || c == '\'')
431 .to_ascii_lowercase()
432 }
433
434 let mut normalized: Vec<String> = candidate_ids
435 .iter()
436 .map(|c| normalize_candidate(c))
437 .collect();
438 normalized.retain(|c| !c.is_empty());
439
440 for entry in entries {
441 let Some(exec) = entry.exec() else {
442 continue;
443 };
444
445 let command = exec
446 .split_whitespace()
447 .next()
448 .map(|token| token.trim_matches(|c: char| c == '"' || c == '\''))
449 .filter(|token| !token.is_empty());
450
451 let Some(command) = command else {
452 continue;
453 };
454
455 let command = Path::new(command);
456 let basename = command
457 .file_stem()
458 .or_else(|| command.file_name())
459 .and_then(|s| s.to_str());
460
461 let Some(basename) = basename else {
462 continue;
463 };
464
465 let basename_lower = basename.to_ascii_lowercase();
466 if normalized
467 .iter()
468 .any(|candidate| candidate == &basename_lower)
469 {
470 return Some(entry.clone());
471 }
472 }
473
474 None
475}
476
477#[cfg(not(windows))]
478fn fallback_entry(context: &DesktopLookupContext<'_>) -> fde::DesktopEntry {
479 let mut entry = fde::DesktopEntry {
480 appid: context.app_id.to_string(),
481 groups: Default::default(),
482 path: Default::default(),
483 ubuntu_gettext_domain: None,
484 };
485
486 let name = context
487 .title
488 .as_ref()
489 .map_or_else(|| context.app_id.to_string(), |title| title.to_string());
490 entry.add_desktop_entry("Name".to_string(), name);
491 entry
492}
493
494#[cfg(not(windows))]
495fn proton_or_wine_fallback(
501 cache: &DesktopEntryCache,
502 context: &DesktopLookupContext<'_>,
503) -> Option<fde::DesktopEntry> {
504 let app_id = context.app_id.as_ref();
505 let is_proton_game = app_id == "steam_app_default";
506 let is_wine_entry = std::path::Path::new(app_id)
507 .extension()
508 .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"));
509
510 if !is_proton_game && !is_wine_entry {
511 return None;
512 }
513
514 let title = context.title.as_deref()?;
515
516 for entry in cache.entries() {
517 let localized_name_matches = entry
518 .name(cache.locales())
519 .is_some_and(|name| name == title);
520
521 if !localized_name_matches {
522 continue;
523 }
524
525 if is_proton_game && !entry.categories().unwrap_or_default().contains(&"Game") {
526 continue;
527 }
528
529 return Some(entry.clone());
530 }
531
532 None
533}
534
535#[cfg(not(windows))]
536fn candidate_desktop_ids(context: &DesktopLookupContext<'_>) -> Vec<String> {
537 fn push_candidate(seen: &mut HashSet<String>, ordered: &mut Vec<String>, candidate: &str) {
538 let trimmed = candidate.trim();
539 if trimmed.is_empty() {
540 return;
541 }
542
543 let key = trimmed.to_ascii_lowercase();
544 if seen.insert(key) {
545 ordered.push(trimmed.to_string());
546 }
547 }
548
549 fn add_variants(
550 seen: &mut HashSet<String>,
551 ordered: &mut Vec<String>,
552 value: Option<&str>,
553 suffixes: &[&str],
554 ) {
555 let Some(value) = value else {
556 return;
557 };
558
559 let stripped_quotes = value.trim_matches(|c: char| c == '"' || c == '\'');
560 let trimmed = stripped_quotes.trim();
561 if trimmed.is_empty() {
562 return;
563 }
564
565 push_candidate(seen, ordered, trimmed);
566 if stripped_quotes != trimmed {
567 push_candidate(seen, ordered, stripped_quotes.trim());
568 }
569
570 for suffix in suffixes {
571 if trimmed.ends_with(suffix) {
572 let cut = &trimmed[..trimmed.len() - suffix.len()];
573 push_candidate(seen, ordered, cut);
574 }
575 }
576
577 if trimmed.contains('.')
578 && let Some(last) = trimmed.rsplit('.').next()
579 {
580 if last.len() >= 2 {
581 push_candidate(seen, ordered, last);
582 }
583 }
584
585 if trimmed.contains('-') {
586 push_candidate(seen, ordered, &trimmed.replace('-', "_"));
587 }
588 if trimmed.contains('_') {
589 push_candidate(seen, ordered, &trimmed.replace('_', "-"));
590 }
591
592 for token in
593 trimmed.split(|c: char| matches!(c, '.' | '-' | '_' | '@') || c.is_whitespace())
594 {
595 if token.len() >= 2 && token != trimmed {
596 push_candidate(seen, ordered, token);
597 }
598 }
599 }
600
601 const SUFFIXES: &[&str] = &[".desktop", ".Desktop", ".DESKTOP"];
602
603 let mut ordered = Vec::new();
604 let mut seen = HashSet::new();
605
606 add_variants(
607 &mut seen,
608 &mut ordered,
609 Some(context.app_id.as_ref()),
610 SUFFIXES,
611 );
612 add_variants(
613 &mut seen,
614 &mut ordered,
615 context.identifier.as_deref(),
616 SUFFIXES,
617 );
618 add_variants(&mut seen, &mut ordered, context.title.as_deref(), &[]);
619
620 if let Some(app_id) = Some(context.app_id.as_ref()) {
626 if let Some(rest) = app_id.strip_prefix("chrome-") {
627 if rest.ends_with("-Default") {
628 let crx = rest.trim_end_matches("-Default");
629 let variants = [
630 format!("org.chromium.Chromium.flextop.chrome-{}-Default", crx),
631 format!("org.chromium.Chromium.chrome-{}-Default", crx),
632 ];
633 for v in variants {
634 push_candidate(&mut seen, &mut ordered, &v);
635 }
636 }
637 }
638 if let Some(rest) = app_id.strip_prefix("crx_") {
639 let crx = rest;
641 let variants = [
642 format!("org.chromium.Chromium.flextop.chrome-{}-Default", crx),
643 format!("org.chromium.Chromium.chrome-{}-Default", crx),
644 ];
645 for v in variants {
646 push_candidate(&mut seen, &mut ordered, &v);
647 }
648 }
649 }
650
651 ordered
652}
653
654#[cfg(not(windows))]
655fn desktop_entries_with_precedence<'a>(
656 entries: impl Iterator<Item = fde::DesktopEntry> + 'a,
657) -> impl Iterator<Item = fde::DesktopEntry> + 'a {
658 let mut seen = HashSet::new();
659
660 entries.filter(move |entry| seen.insert(entry.id().to_owned()))
661}
662
663#[cfg(not(windows))]
664pub fn load_applications<'a>(
665 locales: &'a [String],
666 include_no_display: bool,
667 only_show_in: Option<&'a str>,
668) -> impl Iterator<Item = DesktopEntryData> + 'a {
669 desktop_entries_with_precedence(
670 fde::Iter::new(fde::default_paths())
671 .filter_map(move |p| fde::DesktopEntry::from_path(p, Some(locales)).ok()),
672 )
673 .filter(move |de| {
674 !de.hidden()
675 && (include_no_display || !de.no_display())
676 && only_show_in.zip(de.only_show_in()).is_none_or(
677 |(xdg_current_desktop, only_show_in)| only_show_in.contains(&xdg_current_desktop),
678 )
679 && only_show_in.zip(de.not_show_in()).is_none_or(
680 |(xdg_current_desktop, not_show_in)| !not_show_in.contains(&xdg_current_desktop),
681 )
682 })
683 .map(move |de| DesktopEntryData::from_desktop_entry(locales, de))
684}
685
686#[cfg(not(windows))]
688#[auto_enums::auto_enum(Iterator)]
689pub fn load_applications_for_app_ids<'a>(
690 iter: impl Iterator<Item = fde::DesktopEntry> + 'a,
691 locales: &'a [String],
692 app_ids: Vec<&'a str>,
693 fill_missing_ones: bool,
694 include_no_display: bool,
695 only_show_in: Option<&'a str>,
696) -> impl Iterator<Item = DesktopEntryData> + 'a {
697 let app_ids = std::rc::Rc::new(std::cell::RefCell::new(app_ids));
698 let app_ids_ = app_ids.clone();
699
700 let applications = desktop_entries_with_precedence(iter)
701 .filter(move |de| {
702 let position = {
706 let requested = app_ids.borrow();
707
708 requested
709 .iter()
710 .position(|id| de.matches_id(fde::unicase::Ascii::new(*id)))
711 .or_else(|| {
712 requested
713 .iter()
714 .position(|id| de.matches_name(fde::unicase::Ascii::new(*id)))
715 })
716 };
717
718 let Some(position) = position else {
719 return false;
720 };
721
722 app_ids.borrow_mut().remove(position);
723
724 if de.hidden() {
725 return false;
726 }
727
728 if !include_no_display && de.no_display() {
729 return false;
730 }
731
732 if only_show_in.zip(de.only_show_in()).is_some_and(
733 |(xdg_current_desktop, only_show_in)| !only_show_in.contains(&xdg_current_desktop),
734 ) {
735 return false;
736 }
737
738 if only_show_in.zip(de.not_show_in()).is_some_and(
739 |(xdg_current_desktop, not_show_in)| not_show_in.contains(&xdg_current_desktop),
740 ) {
741 return false;
742 }
743
744 true
745 })
746 .map(move |de| DesktopEntryData::from_desktop_entry(locales, de));
747
748 if fill_missing_ones {
749 applications.chain(
750 std::iter::once_with(move || {
751 std::mem::take(&mut *app_ids_.borrow_mut())
752 .into_iter()
753 .map(|app_id| DesktopEntryData {
754 id: app_id.to_string(),
755 name: app_id.to_string(),
756 icon: fde::IconSource::default(),
757 ..Default::default()
758 })
759 })
760 .flatten(),
761 )
762 } else {
763 applications
764 }
765}
766
767#[cfg(not(windows))]
768pub fn load_desktop_file(locales: &[String], path: PathBuf) -> Option<DesktopEntryData> {
769 fde::DesktopEntry::from_path(path, Some(locales))
770 .ok()
771 .map(|de| DesktopEntryData::from_desktop_entry(locales, de))
772}
773
774#[cfg(not(windows))]
775impl DesktopEntryData {
776 pub fn from_desktop_entry(locales: &[String], de: fde::DesktopEntry) -> DesktopEntryData {
777 let name = de
778 .name(locales)
779 .unwrap_or(Cow::Borrowed(&de.appid))
780 .to_string();
781
782 let icon = fde::IconSource::from_unknown(de.icon().unwrap_or(&de.appid));
784
785 DesktopEntryData {
786 id: de.appid.to_string(),
787 wm_class: de.startup_wm_class().map(ToString::to_string),
788 exec: de.exec().map(ToString::to_string),
789 working_directory: de.path().map(ToString::to_string),
790 name,
791 icon,
792 categories: de
793 .categories()
794 .unwrap_or_default()
795 .into_iter()
796 .map(std::string::ToString::to_string)
797 .collect(),
798 desktop_actions: de
799 .actions()
800 .map(|actions| {
801 actions
802 .into_iter()
803 .filter_map(|action| {
804 let name = de.action_entry_localized(action, "Name", locales);
805 let exec = de.action_entry(action, "Exec");
806 if let (Some(name), Some(exec)) = (name, exec) {
807 Some(DesktopAction {
808 name: name.to_string(),
809 exec: exec.to_string(),
810 })
811 } else {
812 None
813 }
814 })
815 .collect::<Vec<_>>()
816 })
817 .unwrap_or_default(),
818 mime_types: de
819 .mime_type()
820 .map(|mime_types| {
821 mime_types
822 .into_iter()
823 .filter_map(|mime_type| mime_type.parse::<Mime>().ok())
824 .collect::<Vec<_>>()
825 })
826 .unwrap_or_default(),
827 prefers_dgpu: de.prefers_non_default_gpu(),
828 terminal: de.terminal(),
829 path: Some(de.path),
830 }
831 }
832}
833
834#[cfg(all(test, not(windows)))]
835mod tests {
836 use super::*;
837 use std::path::Path;
838 use std::{env, fs};
839 use tempfile::tempdir;
840
841 struct EnvVarGuard {
842 key: &'static str,
843 original: Option<String>,
844 }
845
846 impl EnvVarGuard {
847 fn set(key: &'static str, value: &Path) -> Self {
848 let original = env::var(key).ok();
849 unsafe { std::env::set_var(key, value) };
852 Self { key, original }
853 }
854 }
855
856 impl Drop for EnvVarGuard {
857 fn drop(&mut self) {
858 if let Some(ref original) = self.original {
859 unsafe { std::env::set_var(self.key, original) };
860 } else {
861 unsafe { std::env::remove_var(self.key) };
862 }
863 }
864 }
865
866 fn load_entry(file_name: &str, contents: &str, locales: &[String]) -> fde::DesktopEntry {
867 let temp = tempdir().expect("tempdir");
868 let path = temp.path().join(file_name);
869 fs::write(&path, contents).expect("write desktop file");
870 let entry = fde::DesktopEntry::from_path(path, Some(locales)).expect("load desktop file");
871 temp.close().expect("close tempdir");
873 entry
874 }
875
876 #[test]
877 fn desktop_entry_precedence_prefers_first_entry() {
878 let locales = vec!["en_US.UTF-8".to_string()];
879
880 let user_entry = load_entry(
881 "com.example.App.desktop",
882 "[Desktop Entry]\n\
883 Type=Application\n\
884 Name=User Application\n\
885 Exec=user-application\n",
886 &locales,
887 );
888
889 let system_entry = load_entry(
890 "com.example.App.desktop",
891 "[Desktop Entry]\n\
892 Type=Application\n\
893 Name=System Application\n\
894 Exec=system-application\n",
895 &locales,
896 );
897
898 let entries = desktop_entries_with_precedence(vec![user_entry, system_entry].into_iter())
899 .collect::<Vec<_>>();
900
901 assert_eq!(entries.len(), 1);
902 assert_eq!(entries[0].exec(), Some("user-application"));
903 }
904
905 #[test]
906 fn hidden_override_masks_system_entry_without_fallback() {
907 let locales = vec!["en_US.UTF-8".to_string()];
908
909 let user_entry = load_entry(
910 "com.example.App.desktop",
911 "[Desktop Entry]\n\
912 Type=Application\n\
913 Name=Example Application\n\
914 Hidden=true\n",
915 &locales,
916 );
917
918 let system_entry = load_entry(
919 "com.example.App.desktop",
920 "[Desktop Entry]\n\
921 Type=Application\n\
922 Name=Example Application\n\
923 Exec=system-application\n",
924 &locales,
925 );
926
927 let applications = load_applications_for_app_ids(
928 vec![user_entry, system_entry].into_iter(),
929 &locales,
930 vec!["com.example.App"],
931 true,
932 false,
933 Some("COSMIC"),
934 )
935 .collect::<Vec<_>>();
936
937 assert!(applications.is_empty());
938 }
939
940 #[test]
941 fn no_display_override_masks_system_entry() {
942 let locales = vec!["en_US.UTF-8".to_string()];
943
944 let user_entry = load_entry(
945 "com.example.App.desktop",
946 "[Desktop Entry]\n\
947 Type=Application\n\
948 Name=User Application\n\
949 Exec=user-application\n\
950 NoDisplay=true\n",
951 &locales,
952 );
953
954 let system_entry = load_entry(
955 "com.example.App.desktop",
956 "[Desktop Entry]\n\
957 Type=Application\n\
958 Name=System Application\n\
959 Exec=system-application\n",
960 &locales,
961 );
962
963 let hidden_from_menu = load_applications_for_app_ids(
964 vec![user_entry.clone(), system_entry.clone()].into_iter(),
965 &locales,
966 vec!["com.example.App"],
967 true,
968 false,
969 Some("COSMIC"),
970 )
971 .collect::<Vec<_>>();
972
973 assert!(hidden_from_menu.is_empty());
974
975 let included_when_requested = load_applications_for_app_ids(
976 vec![user_entry, system_entry].into_iter(),
977 &locales,
978 vec!["com.example.App"],
979 true,
980 true,
981 Some("COSMIC"),
982 )
983 .collect::<Vec<_>>();
984
985 assert_eq!(included_when_requested.len(), 1);
986 assert_eq!(
987 included_when_requested[0].exec.as_deref(),
988 Some("user-application")
989 );
990 }
991
992 #[test]
993 fn not_show_in_override_masks_system_entry_for_cosmic() {
994 let locales = vec!["en_US.UTF-8".to_string()];
995
996 let user_entry = load_entry(
997 "com.example.App.desktop",
998 "[Desktop Entry]\n\
999 Type=Application\n\
1000 Name=User Application\n\
1001 Exec=user-application\n\
1002 NotShowIn=COSMIC;\n",
1003 &locales,
1004 );
1005
1006 let system_entry = load_entry(
1007 "com.example.App.desktop",
1008 "[Desktop Entry]\n\
1009 Type=Application\n\
1010 Name=System Application\n\
1011 Exec=system-application\n",
1012 &locales,
1013 );
1014
1015 let hidden_in_cosmic = load_applications_for_app_ids(
1016 vec![user_entry.clone(), system_entry.clone()].into_iter(),
1017 &locales,
1018 vec!["com.example.App"],
1019 true,
1020 false,
1021 Some("COSMIC"),
1022 )
1023 .collect::<Vec<_>>();
1024
1025 assert!(hidden_in_cosmic.is_empty());
1026
1027 let visible_in_gnome = load_applications_for_app_ids(
1028 vec![user_entry, system_entry].into_iter(),
1029 &locales,
1030 vec!["com.example.App"],
1031 true,
1032 false,
1033 Some("GNOME"),
1034 )
1035 .collect::<Vec<_>>();
1036
1037 assert_eq!(visible_in_gnome.len(), 1);
1038 assert_eq!(
1039 visible_in_gnome[0].exec.as_deref(),
1040 Some("user-application")
1041 );
1042 }
1043
1044 #[test]
1045 fn candidate_generation_covers_common_variants() {
1046 let ctx = DesktopLookupContext::new("com.example.App.desktop")
1047 .with_identifier("com-example-App")
1048 .with_title("Example App");
1049 let candidates = candidate_desktop_ids(&ctx);
1050
1051 assert_eq!(candidates.first().unwrap(), "com.example.App.desktop");
1052 for test in [
1053 "com.example.App",
1054 "com-example-App",
1055 "com_example_App",
1056 "Example App",
1057 "Example",
1058 "App",
1059 ] {
1060 assert!(
1061 candidates
1062 .iter()
1063 .any(|c| c.to_ascii_lowercase() == test.to_ascii_lowercase()),
1064 );
1065 }
1066 }
1067
1068 #[test]
1069 fn startup_wm_class_matching_detects_flatpak_chrome_apps() {
1070 let temp = tempdir().expect("tempdir");
1071 let apps_dir = temp.path().join("applications");
1072 fs::create_dir_all(&apps_dir).expect("create applications dir");
1073
1074 let desktop_contents = "\
1075[Desktop Entry]
1076Version=1.0
1077Type=Application
1078Name=Proton Mail
1079Exec=chromium --app-id=jnpecgipniidlgicjocehkhajgdnjekh
1080Icon=chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default
1081StartupWMClass=crx_jnpecgipniidlgicjocehkhajgdnjekh
1082";
1083 let desktop_path = apps_dir.join(
1084 "org.chromium.Chromium.flextop.chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default.desktop",
1085 );
1086 fs::write(desktop_path, desktop_contents).expect("write desktop file");
1087
1088 let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
1089
1090 let locales = vec!["en_US.UTF-8".to_string()];
1091 let mut cache = DesktopEntryCache::new(locales.clone());
1092 cache.refresh();
1093
1094 let ctx = DesktopLookupContext::new("crx_jnpecgipniidlgicjocehkhajgdnjekh");
1095 let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default());
1096
1097 assert_eq!(
1098 resolved.id(),
1099 "org.chromium.Chromium.flextop.chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default"
1100 );
1101 }
1102
1103 #[test]
1104 fn exec_basename_matching_handles_vmware() {
1105 let temp = tempdir().expect("tempdir");
1106 let apps_dir = temp.path().join("applications");
1107 fs::create_dir_all(&apps_dir).expect("create applications dir");
1108
1109 let desktop_contents = r#"[Desktop Entry]
1110Version=1.0
1111Type=Application
1112Name=VMware Workstation
1113Exec=/usr/bin/vmware %U
1114Icon=vmware-workstation
1115"#;
1116 let desktop_path = apps_dir.join("vmware-workstation.desktop");
1117 fs::write(desktop_path, desktop_contents).expect("write desktop file");
1118
1119 let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
1120
1121 let locales = vec!["en_US.UTF-8".to_string()];
1122 let mut cache = DesktopEntryCache::new(locales.clone());
1123 cache.refresh();
1124
1125 let ctx = DesktopLookupContext::new("vmware").with_title("Library — VMware Workstation");
1126
1127 let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default());
1128
1129 assert_eq!(resolved.id(), "vmware-workstation");
1130 }
1131
1132 #[test]
1133 fn proton_fallback_prefers_game_entries() {
1134 let locales = vec!["en_US.UTF-8".to_string()];
1135 let entry = load_entry(
1136 "proton.desktop",
1137 "[Desktop Entry]\nType=Application\nName=Proton Game\nCategories=Game;Utility;\nExec=proton-game\n",
1138 &locales,
1139 );
1140 let cache = DesktopEntryCache::from_entries(locales.clone(), vec![entry]);
1141 let ctx = DesktopLookupContext::new("steam_app_default").with_title("Proton Game");
1142
1143 let resolved = proton_or_wine_fallback(&cache, &ctx).expect("expected proton match");
1144 let name = resolved
1145 .name(&locales)
1146 .expect("name available")
1147 .into_owned();
1148
1149 assert_eq!(name, "Proton Game");
1150 }
1151
1152 #[test]
1153 fn proton_fallback_skips_non_games() {
1154 let locales = vec!["en_US.UTF-8".to_string()];
1155 let entry = load_entry(
1156 "tool.desktop",
1157 "[Desktop Entry]\nType=Application\nName=Proton Tool\nCategories=Utility;\nExec=proton-tool\n",
1158 &locales,
1159 );
1160 let cache = DesktopEntryCache::from_entries(locales, vec![entry]);
1161 let ctx = DesktopLookupContext::new("steam_app_default").with_title("Proton Tool");
1162
1163 assert!(proton_or_wine_fallback(&cache, &ctx).is_none());
1164 }
1165
1166 #[test]
1167 fn wine_fallback_matches_executable_titles() {
1168 let locales = vec!["en_US.UTF-8".to_string()];
1169 let entry = load_entry(
1170 "wine.desktop",
1171 "[Desktop Entry]\nType=Application\nName=Wine Game\nExec=wine-game\n",
1172 &locales,
1173 );
1174 let cache = DesktopEntryCache::from_entries(locales.clone(), vec![entry]);
1175 let ctx = DesktopLookupContext::new("WINEGAME.EXE").with_title("Wine Game");
1176
1177 let resolved = proton_or_wine_fallback(&cache, &ctx).expect("expected wine match");
1178 let name = resolved
1179 .name(&locales)
1180 .expect("name available")
1181 .into_owned();
1182 assert_eq!(name, "Wine Game");
1183 }
1184
1185 #[test]
1186 fn fallback_entry_uses_title_when_available() {
1187 let ctx = DesktopLookupContext::new("unknown-app").with_title("Unknown App");
1188 let entry = fallback_entry(&ctx);
1189
1190 assert_eq!(entry.id(), "unknown-app");
1191 assert_eq!(
1192 entry.name(&["en_US".to_string()]),
1193 Some(Cow::Owned("Unknown App".to_string()))
1194 );
1195 }
1196
1197 #[test]
1198 fn desktop_entry_data_prefers_localized_name() {
1199 let locales = vec!["fr".to_string(), "en_US".to_string()];
1200 let entry = load_entry(
1201 "localized.desktop",
1202 "[Desktop Entry]\nType=Application\nName=Default\nName[fr]=Localisé\nExec=localized\n",
1203 &locales,
1204 );
1205 let data = DesktopEntryData::from_desktop_entry(&locales, entry);
1206
1207 assert_eq!(data.name, "Localisé");
1208 }
1209
1210 #[test]
1211 fn crx_id_extraction_variants() {
1212 let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; assert_eq!(
1214 super::extract_crx_id(&format!("chrome-{}-Default", id)),
1215 Some(id.to_string())
1216 );
1217 assert_eq!(
1218 super::extract_crx_id(&format!("crx_{}", id)),
1219 Some(id.to_string())
1220 );
1221 assert_eq!(super::extract_crx_id(id), Some(id.to_string()));
1222 let embedded = format!("org.chromium.Chromium.flextop.chrome-{}-Default", id);
1224 assert_eq!(super::extract_crx_id(&embedded), Some(id.to_string()));
1225 }
1226
1227 #[test]
1228 fn crx_matcher_by_exec_and_wmclass() {
1229 let locales = vec!["en_US.UTF-8".to_string()];
1230 let id = "cadlkienfkclaiaibeoongdcgmdikeeg";
1231 let mut cache = DesktopEntryCache::new(locales.clone());
1232 cache.insert(fde::DesktopEntry::from_str(
1233 "org.chromium.Chromium.flextop.chrome-cadlkienfkclaiaibeoongdcgmdikeeg-Default.desktop",
1234 &format!(
1235 r#"[Desktop Entry]
1236Type=Application
1237Name=Example
1238Exec=chromium --app-id={id} --profile-directory=Default
1239StartupWMClass=crx_{id}
1240Icon=chrome-{id}-Default
1241"#
1242 ),
1243 Some(&locales),
1244 )
1245 .unwrap());
1246
1247 let short_id = format!("chrome-{}-Default", id);
1248 let ctx = DesktopLookupContext::new(short_id);
1249 let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default());
1250 assert!(resolved.exec().is_some());
1251 assert!(resolved.icon().is_some());
1252 let expected = format!("crx_{}", id);
1253 assert_eq!(resolved.startup_wm_class(), Some(expected.as_str()));
1254 }
1255
1256 #[test]
1257 fn crx_extraction_handles_utf8_prefixes() {
1258 let id = "cadlkienfkclaiaibeoongdcgmdikeeg";
1259 let prefixed = format!("å{}", id);
1260 assert_eq!(super::extract_crx_id(&prefixed), Some(id.to_string()));
1261 }
1262
1263 #[test]
1264 fn crx_extraction_ignores_non_ascii_sequences() {
1265 let id = "cadlkienfkclaiaibeoongdcgmdikeeg";
1266 let embedded = format!("{id}æøå");
1267
1268 assert_eq!(super::extract_crx_id(&embedded), Some(id.to_string()));
1269 assert_eq!(super::extract_crx_id("æøå"), None);
1270 }
1271}