Skip to main content

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