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