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