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