fix(desktop): XDG desktop entry precedence

Resolve duplicate desktop IDs before applying desktop entry visibility rules

This allows user desktop entries to override matching system entries and ensures Hidden, NoDisplay, and NotShowIn overrides do not reveal lower-priority entries

Signed-off-by: name-is-hai <90458368+name-is-hai@users.noreply.github.com>
This commit is contained in:
name-is-hai 2026-07-13 00:12:46 +07:00 • committed by Michael Murphy
parent 5187dd6d1d
commit 18731e8392

View file

@ -77,9 +77,12 @@ impl DesktopEntryCache {
}
pub fn refresh(&mut self) {
self.entries = fde::Iter::new(fde::default_paths())
.filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok())
.collect();
self.entries = desktop_entries_with_precedence(
fde::Iter::new(fde::default_paths())
.filter_map(|p| fde::DesktopEntry::from_path(p, Some(&self.locales)).ok()),
)
.filter(|entry| !entry.hidden())
.collect();
}
pub fn insert(&mut self, entry: fde::DesktopEntry) {
@ -604,28 +607,36 @@ fn candidate_desktop_ids(context: &DesktopLookupContext<'_>) -> Vec<String> {
ordered
}
#[cfg(not(windows))]
fn desktop_entries_with_precedence<'a>(
entries: impl Iterator<Item = fde::DesktopEntry> + 'a,
) -> impl Iterator<Item = fde::DesktopEntry> + 'a {
let mut seen = HashSet::new();
entries.filter(move |entry| seen.insert(entry.id().to_owned()))
}
#[cfg(not(windows))]
pub fn load_applications<'a>(
locales: &'a [String],
include_no_display: bool,
only_show_in: Option<&'a str>,
) -> impl Iterator<Item = DesktopEntryData> + 'a {
fde::Iter::new(fde::default_paths())
.filter_map(move |p| fde::DesktopEntry::from_path(p, Some(locales)).ok())
.filter(move |de| {
(include_no_display || !de.no_display())
&& only_show_in.zip(de.only_show_in()).is_none_or(
|(xdg_current_desktop, only_show_in)| {
only_show_in.contains(&xdg_current_desktop)
},
)
&& only_show_in.zip(de.not_show_in()).is_none_or(
|(xdg_current_desktop, not_show_in)| {
!not_show_in.contains(&xdg_current_desktop)
},
)
})
.map(move |de| DesktopEntryData::from_desktop_entry(locales, de))
desktop_entries_with_precedence(
fde::Iter::new(fde::default_paths())
.filter_map(move |p| fde::DesktopEntry::from_path(p, Some(locales)).ok()),
)
.filter(move |de| {
!de.hidden()
&& (include_no_display || !de.no_display())
&& only_show_in.zip(de.only_show_in()).is_none_or(
|(xdg_current_desktop, only_show_in)| only_show_in.contains(&xdg_current_desktop),
)
&& only_show_in.zip(de.not_show_in()).is_none_or(
|(xdg_current_desktop, not_show_in)| !not_show_in.contains(&xdg_current_desktop),
)
})
.map(move |de| DesktopEntryData::from_desktop_entry(locales, de))
}
// Create an iterator which filters desktop entries by app IDs.
@ -642,40 +653,51 @@ pub fn load_applications_for_app_ids<'a>(
let app_ids = std::rc::Rc::new(std::cell::RefCell::new(app_ids));
let app_ids_ = app_ids.clone();
let applications = iter
let applications = desktop_entries_with_precedence(iter)
.filter(move |de| {
// Match and consume the requested ID before applying visibility
// filters. This prevents a Hidden or NoDisplay override from being
// recreated by fill_missing_ones.
let position = {
let requested = app_ids.borrow();
requested
.iter()
.position(|id| de.matches_id(fde::unicase::Ascii::new(*id)))
.or_else(|| {
requested
.iter()
.position(|id| de.matches_name(fde::unicase::Ascii::new(*id)))
})
};
let Some(position) = position else {
return false;
};
app_ids.borrow_mut().remove(position);
if de.hidden() {
return false;
}
if !include_no_display && de.no_display() {
return false;
}
if only_show_in.zip(de.only_show_in()).is_some_and(
|(xdg_current_desktop, only_show_in)| !only_show_in.contains(&xdg_current_desktop),
) {
return false;
}
if only_show_in.zip(de.not_show_in()).is_some_and(
|(xdg_current_desktop, not_show_in)| not_show_in.contains(&xdg_current_desktop),
) {
return false;
}
// Search by ID first
app_ids
.borrow()
.iter()
.position(|id| de.matches_id(fde::unicase::Ascii::new(*id)))
// Then fall back to search by name
.or_else(|| {
app_ids
.borrow()
.iter()
.position(|id| de.matches_name(fde::unicase::Ascii::new(*id)))
})
// Remove the app ID if found
.map(|i| {
app_ids.borrow_mut().remove(i);
true
})
.unwrap_or_default()
true
})
.map(move |de| DesktopEntryData::from_desktop_entry(locales, de));
@ -911,6 +933,174 @@ mod tests {
entry
}
#[test]
fn desktop_entry_precedence_prefers_first_entry() {
let locales = vec!["en_US.UTF-8".to_string()];
let user_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=User Application\n\
Exec=user-application\n",
&locales,
);
let system_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=System Application\n\
Exec=system-application\n",
&locales,
);
let entries = desktop_entries_with_precedence(vec![user_entry, system_entry].into_iter())
.collect::<Vec<_>>();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].exec(), Some("user-application"));
}
#[test]
fn hidden_override_masks_system_entry_without_fallback() {
let locales = vec!["en_US.UTF-8".to_string()];
let user_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=Example Application\n\
Hidden=true\n",
&locales,
);
let system_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=Example Application\n\
Exec=system-application\n",
&locales,
);
let applications = load_applications_for_app_ids(
vec![user_entry, system_entry].into_iter(),
&locales,
vec!["com.example.App"],
true,
false,
Some("COSMIC"),
)
.collect::<Vec<_>>();
assert!(applications.is_empty());
}
#[test]
fn no_display_override_masks_system_entry() {
let locales = vec!["en_US.UTF-8".to_string()];
let user_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=User Application\n\
Exec=user-application\n\
NoDisplay=true\n",
&locales,
);
let system_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=System Application\n\
Exec=system-application\n",
&locales,
);
let hidden_from_menu = load_applications_for_app_ids(
vec![user_entry.clone(), system_entry.clone()].into_iter(),
&locales,
vec!["com.example.App"],
true,
false,
Some("COSMIC"),
)
.collect::<Vec<_>>();
assert!(hidden_from_menu.is_empty());
let included_when_requested = load_applications_for_app_ids(
vec![user_entry, system_entry].into_iter(),
&locales,
vec!["com.example.App"],
true,
true,
Some("COSMIC"),
)
.collect::<Vec<_>>();
assert_eq!(included_when_requested.len(), 1);
assert_eq!(
included_when_requested[0].exec.as_deref(),
Some("user-application")
);
}
#[test]
fn not_show_in_override_masks_system_entry_for_cosmic() {
let locales = vec!["en_US.UTF-8".to_string()];
let user_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=User Application\n\
Exec=user-application\n\
NotShowIn=COSMIC;\n",
&locales,
);
let system_entry = load_entry(
"com.example.App.desktop",
"[Desktop Entry]\n\
Type=Application\n\
Name=System Application\n\
Exec=system-application\n",
&locales,
);
let hidden_in_cosmic = load_applications_for_app_ids(
vec![user_entry.clone(), system_entry.clone()].into_iter(),
&locales,
vec!["com.example.App"],
true,
false,
Some("COSMIC"),
)
.collect::<Vec<_>>();
assert!(hidden_in_cosmic.is_empty());
let visible_in_gnome = load_applications_for_app_ids(
vec![user_entry, system_entry].into_iter(),
&locales,
vec!["com.example.App"],
true,
false,
Some("GNOME"),
)
.collect::<Vec<_>>();
assert_eq!(visible_in_gnome.len(), 1);
assert_eq!(
visible_in_gnome[0].exec.as_deref(),
Some("user-application")
);
}
#[test]
fn candidate_generation_covers_common_variants() {
let ctx = DesktopLookupContext::new("com.example.App.desktop")