Skip to main content

cosmic/
desktop.rs

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