cosmic/
desktop.rs

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))]
164/// Resolve a DesktopEntry for a running toplevel, applying heuristics over
165/// app_id, identifier, and title. Includes Proton/Wine handling: Proton can
166/// open games as `steam_app_X` (often `steam_app_default`), and Wine windows
167/// may use an `.exe` app_id. In those cases we match the localized name
168/// against the toplevel title and, for Proton default, restrict matches to
169/// entries with `Game` in Categories.
170pub 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    // Chromium/CRX heuristic: scan exec/wmclass/icon for a CRX id match.
207    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            // SAFETY: `is_crx_bytes` guarantees the window is ASCII.
339            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))]
448// proton opens games as steam_app_X, where X is either the steam appid or
449// "default". Games with a steam appid can have a desktop entry generated
450// elsewhere; this specifically handles non-steam games opened under Proton.
451// In addition, try to match WINE entries whose app_id is the full name of
452// the executable (including `.exe`).
453fn 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    // Chromium/Chrome PWA heuristics: favorites may store a short id like
574    // "chrome-<crx>-Default" while the actual desktop id is
575    // "org.chromium.Chromium.flextop.chrome-<crx>-Default" (Flatpak Chromium)
576    // or sometimes "org.chromium.Chromium.chrome-<crx>-Default". Expand those
577    // candidates so we can match cached entries.
578    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            // Older identifiers may be crx_<id>; expand similarly
593            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// Create an iterator which filters desktop entries by app IDs.
632#[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            // Search by ID first
662            app_ids
663                .borrow()
664                .iter()
665                .position(|id| de.matches_id(fde::unicase::Ascii::new(*id)))
666                // Then fall back to search by name
667                .or_else(|| {
668                    app_ids
669                        .borrow()
670                        .iter()
671                        .position(|id| de.matches_name(fde::unicase::Ascii::new(*id)))
672                })
673                // Remove the app ID if found
674                .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        // check if absolute path exists and otherwise treat it as a name
717        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        // TODO handle "%" args here if necessary?
809        if !arg.starts_with('%') {
810            cmd.arg(arg);
811        }
812    }
813
814    cmd.envs(env_vars);
815
816    // https://systemd.io/DESKTOP_ENVIRONMENTS
817    //
818    // Similar to what Gnome sets, for now.
819    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            // std::env::{set_var, remove_var} are unsafe on newer toolchains;
887            // we limit scope here to the test helper that toggles a single key.
888            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        // Ensure directory stays alive until after parsing
909        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"; // 32 chars a..p
1083        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        // Embedded
1093        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}