Skip to main content

cosmic/
desktop.rs

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