feat(desktop): add DesktopEntryCache and unit tests for known problematic entries
This commit is contained in:
parent
2296e8e94d
commit
690f1d331d
2 changed files with 816 additions and 9 deletions
|
|
@ -219,12 +219,7 @@ exclude = ["iced"]
|
|||
[workspace.dependencies]
|
||||
dirs = "6.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
|
||||
[patch."https://github.com/pop-os/libcosmic"]
|
||||
libcosmic = { path = "./" }
|
||||
|
||||
# FIXME update winit deps where necessary to use this
|
||||
# [patch.crates-io]
|
||||
# [patch."https://github.com/pop-os/winit.git"]
|
||||
# winit = { git = "https://github.com/pop-os/winit.git//", branch = "xdg-toplevel" }
|
||||
# winit = { path = "../winit" }
|
||||
|
|
|
|||
816
src/desktop.rs
816
src/desktop.rs
|
|
@ -2,9 +2,9 @@
|
|||
pub use freedesktop_desktop_entry as fde;
|
||||
#[cfg(not(windows))]
|
||||
pub use mime::Mime;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(windows))]
|
||||
use std::{borrow::Cow, ffi::OsStr};
|
||||
use std::{borrow::Cow, collections::HashSet, ffi::OsStr};
|
||||
|
||||
pub trait IconSourceExt {
|
||||
fn as_cosmic_icon(&self) -> crate::widget::icon::Icon;
|
||||
|
|
@ -51,6 +51,557 @@ pub struct DesktopEntryData {
|
|||
pub terminal: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopEntryCache {
|
||||
locales: Vec<String>,
|
||||
entries: Vec<fde::DesktopEntry>,
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
impl DesktopEntryCache {
|
||||
pub fn new(locales: Vec<String>) -> Self {
|
||||
Self {
|
||||
locales,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_entries(locales: Vec<String>, entries: Vec<fde::DesktopEntry>) -> Self {
|
||||
Self { locales, entries }
|
||||
}
|
||||
|
||||
pub fn ensure_loaded(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
self.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, entry: fde::DesktopEntry) {
|
||||
if self
|
||||
.entries
|
||||
.iter()
|
||||
.any(|existing| existing.id() == entry.id())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.entries.push(entry);
|
||||
}
|
||||
|
||||
pub fn locales(&self) -> &[String] {
|
||||
&self.locales
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[fde::DesktopEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn entries_mut(&mut self) -> &mut [fde::DesktopEntry] {
|
||||
&mut self.entries
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
impl Default for DesktopEntryCache {
|
||||
fn default() -> Self {
|
||||
Self::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopLookupContext<'a> {
|
||||
pub app_id: Cow<'a, str>,
|
||||
pub identifier: Option<Cow<'a, str>>,
|
||||
pub title: Option<Cow<'a, str>>,
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
impl<'a> DesktopLookupContext<'a> {
|
||||
pub fn new(app_id: impl Into<Cow<'a, str>>) -> Self {
|
||||
Self {
|
||||
app_id: app_id.into(),
|
||||
identifier: None,
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_identifier(mut self, identifier: impl Into<Cow<'a, str>>) -> Self {
|
||||
self.identifier = Some(identifier.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopResolveOptions {
|
||||
pub include_no_display: bool,
|
||||
pub xdg_current_desktop: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
impl Default for DesktopResolveOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_no_display: false,
|
||||
xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
/// Resolve a DesktopEntry for a running toplevel, applying heuristics over
|
||||
/// app_id, identifier, and title. Includes Proton/Wine handling: Proton can
|
||||
/// open games as `steam_app_X` (often `steam_app_default`), and Wine windows
|
||||
/// may use an `.exe` app_id. In those cases we match the localized name
|
||||
/// against the toplevel title and, for Proton default, restrict matches to
|
||||
/// entries with `Game` in Categories.
|
||||
pub fn resolve_desktop_entry(
|
||||
cache: &mut DesktopEntryCache,
|
||||
context: &DesktopLookupContext<'_>,
|
||||
options: &DesktopResolveOptions,
|
||||
) -> fde::DesktopEntry {
|
||||
let app_id = fde::unicase::Ascii::new(context.app_id.as_ref());
|
||||
|
||||
if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) {
|
||||
return entry.clone();
|
||||
}
|
||||
|
||||
cache.refresh();
|
||||
if let Some(entry) = fde::find_app_by_id(cache.entries(), app_id) {
|
||||
return entry.clone();
|
||||
}
|
||||
|
||||
let candidate_ids = candidate_desktop_ids(context);
|
||||
|
||||
if let Some(entry) = try_match_cached(cache.entries(), &candidate_ids) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if let Some(entry) = load_entry_via_app_ids(
|
||||
cache,
|
||||
&candidate_ids,
|
||||
options.include_no_display,
|
||||
options.xdg_current_desktop.as_deref(),
|
||||
) {
|
||||
cache.insert(entry.clone());
|
||||
return entry;
|
||||
}
|
||||
|
||||
if let Some(entry) = match_startup_wm_class(cache.entries(), context) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Chromium/CRX heuristic: scan exec/wmclass/icon for a CRX id match.
|
||||
if let Some(entry) = match_crx_id(cache.entries(), context) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if let Some(entry) = match_exec_basename(cache.entries(), &candidate_ids) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if let Some(entry) = proton_or_wine_fallback(cache, context) {
|
||||
cache.insert(entry.clone());
|
||||
entry
|
||||
} else {
|
||||
let fallback = fallback_entry(context);
|
||||
cache.insert(fallback.clone());
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn try_match_cached(
|
||||
entries: &[fde::DesktopEntry],
|
||||
candidate_ids: &[String],
|
||||
) -> Option<fde::DesktopEntry> {
|
||||
candidate_ids.iter().find_map(|candidate| {
|
||||
fde::find_app_by_id(entries, fde::unicase::Ascii::new(candidate.as_str())).cloned()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn load_entry_via_app_ids(
|
||||
cache: &DesktopEntryCache,
|
||||
candidate_ids: &[String],
|
||||
include_no_display: bool,
|
||||
xdg_current_desktop: Option<&str>,
|
||||
) -> Option<fde::DesktopEntry> {
|
||||
if candidate_ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidate_refs: Vec<&str> = candidate_ids.iter().map(String::as_str).collect();
|
||||
let locales = cache.locales().to_vec();
|
||||
let iter_locales = locales.clone();
|
||||
|
||||
let desktop_iter = fde::Iter::new(fde::default_paths())
|
||||
.filter_map(move |path| fde::DesktopEntry::from_path(path, Some(&iter_locales)).ok());
|
||||
|
||||
let app_iter = load_applications_for_app_ids(
|
||||
desktop_iter,
|
||||
&locales,
|
||||
candidate_refs,
|
||||
false,
|
||||
include_no_display,
|
||||
xdg_current_desktop,
|
||||
);
|
||||
|
||||
let locales_for_load = cache.locales().to_vec();
|
||||
for app in app_iter {
|
||||
if let Some(path) = app.path {
|
||||
if let Ok(entry) = fde::DesktopEntry::from_path(path, Some(&locales_for_load)) {
|
||||
return Some(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn match_startup_wm_class(
|
||||
entries: &[fde::DesktopEntry],
|
||||
context: &DesktopLookupContext<'_>,
|
||||
) -> Option<fde::DesktopEntry> {
|
||||
let mut candidates = Vec::new();
|
||||
candidates.push(context.app_id.as_ref());
|
||||
if let Some(identifier) = context.identifier.as_deref() {
|
||||
candidates.push(identifier);
|
||||
}
|
||||
if let Some(title) = context.title.as_deref() {
|
||||
candidates.push(title);
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
let Some(wm_class) = entry.startup_wm_class() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(wm_class))
|
||||
{
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_crx_id(candidate: &str) -> bool {
|
||||
is_crx_bytes(candidate.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_crx_bytes(bytes: &[u8]) -> bool {
|
||||
bytes.len() == 32 && bytes.iter().all(|b| matches!(b, b'a'..=b'p'))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn extract_crx_id(value: &str) -> Option<String> {
|
||||
if let Some(rest) = value.strip_prefix("chrome-") {
|
||||
if let Some(first) = rest.split(&['-', '_'][..]).next() {
|
||||
if is_crx_id(first) {
|
||||
return Some(first.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("crx_") {
|
||||
let token = rest
|
||||
.split(|c: char| !c.is_ascii_lowercase())
|
||||
.next()
|
||||
.unwrap_or(rest);
|
||||
if is_crx_id(token) {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
}
|
||||
if is_crx_id(value) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
|
||||
for window in value.as_bytes().windows(32) {
|
||||
if is_crx_bytes(window) {
|
||||
// SAFETY: `is_crx_bytes` guarantees the window is ASCII.
|
||||
let slice = std::str::from_utf8(window).expect("ASCII window");
|
||||
return Some(slice.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn match_crx_id(
|
||||
entries: &[fde::DesktopEntry],
|
||||
context: &DesktopLookupContext<'_>,
|
||||
) -> Option<fde::DesktopEntry> {
|
||||
let crx = extract_crx_id(context.app_id.as_ref())
|
||||
.or_else(|| context.identifier.as_deref().and_then(extract_crx_id))?;
|
||||
|
||||
for entry in entries {
|
||||
if let Some(exec) = entry.exec() {
|
||||
if exec.contains(&format!("--app-id={}", crx)) {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
if let Some(wm) = entry.startup_wm_class() {
|
||||
if wm.eq_ignore_ascii_case(&format!("crx_{}", crx)) {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
if let Some(icon) = entry.icon() {
|
||||
if icon.contains(&crx) {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn match_exec_basename(
|
||||
entries: &[fde::DesktopEntry],
|
||||
candidate_ids: &[String],
|
||||
) -> Option<fde::DesktopEntry> {
|
||||
fn normalize_candidate(candidate: &str) -> String {
|
||||
candidate
|
||||
.trim_matches(|c: char| c == '"' || c == '\'')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
let mut normalized: Vec<String> = candidate_ids
|
||||
.iter()
|
||||
.map(|c| normalize_candidate(c))
|
||||
.collect();
|
||||
normalized.retain(|c| !c.is_empty());
|
||||
|
||||
for entry in entries {
|
||||
let Some(exec) = entry.exec() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let command = exec
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|token| token.trim_matches(|c: char| c == '"' || c == '\''))
|
||||
.filter(|token| !token.is_empty());
|
||||
|
||||
let Some(command) = command else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let command = Path::new(command);
|
||||
let basename = command
|
||||
.file_stem()
|
||||
.or_else(|| command.file_name())
|
||||
.and_then(|s| s.to_str());
|
||||
|
||||
let Some(basename) = basename else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let basename_lower = basename.to_ascii_lowercase();
|
||||
|
||||
if normalized
|
||||
.iter()
|
||||
.any(|candidate| candidate == &basename_lower)
|
||||
{
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn fallback_entry(context: &DesktopLookupContext<'_>) -> fde::DesktopEntry {
|
||||
let mut entry = fde::DesktopEntry {
|
||||
appid: context.app_id.to_string(),
|
||||
groups: Default::default(),
|
||||
path: Default::default(),
|
||||
ubuntu_gettext_domain: None,
|
||||
};
|
||||
|
||||
let name = context
|
||||
.title
|
||||
.as_ref()
|
||||
.map(|title| title.to_string())
|
||||
.unwrap_or_else(|| context.app_id.to_string());
|
||||
entry.add_desktop_entry("Name".to_string(), name);
|
||||
entry
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
// proton opens games as steam_app_X, where X is either the steam appid or
|
||||
// "default". Games with a steam appid can have a desktop entry generated
|
||||
// elsewhere; this specifically handles non-steam games opened under Proton.
|
||||
// In addition, try to match WINE entries whose app_id is the full name of
|
||||
// the executable (including `.exe`).
|
||||
fn proton_or_wine_fallback(
|
||||
cache: &DesktopEntryCache,
|
||||
context: &DesktopLookupContext<'_>,
|
||||
) -> Option<fde::DesktopEntry> {
|
||||
let app_id = context.app_id.as_ref();
|
||||
let is_proton_game = app_id == "steam_app_default";
|
||||
let is_wine_entry = app_id.ends_with(".exe");
|
||||
|
||||
if !is_proton_game && !is_wine_entry {
|
||||
return None;
|
||||
}
|
||||
|
||||
let title = context.title.as_deref()?;
|
||||
|
||||
for entry in cache.entries() {
|
||||
let localized_name_matches = entry
|
||||
.name(cache.locales())
|
||||
.is_some_and(|name| name == title);
|
||||
|
||||
if !localized_name_matches {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_proton_game && !entry.categories().unwrap_or_default().contains(&"Game") {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Some(entry.clone());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn candidate_desktop_ids(context: &DesktopLookupContext<'_>) -> Vec<String> {
|
||||
const SUFFIXES: &[&str] = &[".desktop", ".Desktop", ".DESKTOP"];
|
||||
let mut ordered = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
fn push_candidate(seen: &mut HashSet<String>, ordered: &mut Vec<String>, candidate: &str) {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = trimmed.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
ordered.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn add_variants(
|
||||
seen: &mut HashSet<String>,
|
||||
ordered: &mut Vec<String>,
|
||||
value: Option<&str>,
|
||||
suffixes: &[&str],
|
||||
) {
|
||||
let Some(value) = value else {
|
||||
return;
|
||||
};
|
||||
|
||||
let stripped_quotes = value.trim_matches(|c: char| c == '"' || c == '\'');
|
||||
let trimmed = stripped_quotes.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
push_candidate(seen, ordered, trimmed);
|
||||
if stripped_quotes != trimmed {
|
||||
push_candidate(seen, ordered, stripped_quotes.trim());
|
||||
}
|
||||
|
||||
for suffix in suffixes {
|
||||
if trimmed.ends_with(suffix) {
|
||||
let cut = &trimmed[..trimmed.len() - suffix.len()];
|
||||
push_candidate(seen, ordered, cut);
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.contains('.') {
|
||||
if let Some(last) = trimmed.rsplit('.').next() {
|
||||
if last.len() >= 2 {
|
||||
push_candidate(seen, ordered, last);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.contains('-') {
|
||||
push_candidate(seen, ordered, &trimmed.replace('-', "_"));
|
||||
}
|
||||
if trimmed.contains('_') {
|
||||
push_candidate(seen, ordered, &trimmed.replace('_', "-"));
|
||||
}
|
||||
|
||||
for token in trimmed.split(|c: char| matches!(c, '.' | '-' | '_' | '@' | ' ')) {
|
||||
if token.len() >= 2 && token != trimmed {
|
||||
push_candidate(seen, ordered, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add_variants(
|
||||
&mut seen,
|
||||
&mut ordered,
|
||||
Some(context.app_id.as_ref()),
|
||||
SUFFIXES,
|
||||
);
|
||||
add_variants(
|
||||
&mut seen,
|
||||
&mut ordered,
|
||||
context.identifier.as_deref(),
|
||||
SUFFIXES,
|
||||
);
|
||||
add_variants(&mut seen, &mut ordered, context.title.as_deref(), &[]);
|
||||
|
||||
// Chromium/Chrome PWA heuristics: favorites may store a short id like
|
||||
// "chrome-<crx>-Default" while the actual desktop id is
|
||||
// "org.chromium.Chromium.flextop.chrome-<crx>-Default" (Flatpak Chromium)
|
||||
// or sometimes "org.chromium.Chromium.chrome-<crx>-Default". Expand those
|
||||
// candidates so we can match cached entries.
|
||||
if let Some(app_id) = Some(context.app_id.as_ref()) {
|
||||
if let Some(rest) = app_id.strip_prefix("chrome-") {
|
||||
if rest.ends_with("-Default") {
|
||||
let crx = rest.trim_end_matches("-Default");
|
||||
let variants = [
|
||||
format!("org.chromium.Chromium.flextop.chrome-{}-Default", crx),
|
||||
format!("org.chromium.Chromium.chrome-{}-Default", crx),
|
||||
];
|
||||
for v in variants {
|
||||
push_candidate(&mut seen, &mut ordered, &v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rest) = app_id.strip_prefix("crx_") {
|
||||
// Older identifiers may be crx_<id>; expand similarly
|
||||
let crx = rest;
|
||||
let variants = [
|
||||
format!("org.chromium.Chromium.flextop.chrome-{}-Default", crx),
|
||||
format!("org.chromium.Chromium.chrome-{}-Default", crx),
|
||||
];
|
||||
for v in variants {
|
||||
push_candidate(&mut seen, &mut ordered, &v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ordered
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn load_applications<'a>(
|
||||
locales: &'a [String],
|
||||
|
|
@ -315,3 +866,264 @@ trait SystemdManger {
|
|||
aux: &[(String, Vec<(String, zbus::zvariant::OwnedValue)>)],
|
||||
) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(windows)))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{env, fs, path::Path, path::PathBuf};
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let original = env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref original) = self.original {
|
||||
std::env::set_var(self.key, original);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_entry(file_name: &str, contents: &str, locales: &[String]) -> fde::DesktopEntry {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join(file_name);
|
||||
fs::write(&path, contents).expect("write desktop file");
|
||||
let entry = fde::DesktopEntry::from_path(path, Some(locales)).expect("load desktop file");
|
||||
// Ensure directory stays alive until after parsing
|
||||
temp.close().expect("close tempdir");
|
||||
entry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_generation_covers_common_variants() {
|
||||
let ctx = DesktopLookupContext::new("com.example.App.desktop")
|
||||
.with_identifier("com-example-App")
|
||||
.with_title("Example App");
|
||||
let candidates = candidate_desktop_ids(&ctx);
|
||||
|
||||
assert_eq!(candidates.first().unwrap(), "com.example.App.desktop");
|
||||
assert!(candidates.contains(&"com.example.App".to_string()));
|
||||
assert!(candidates.contains(&"com-example-App".to_string()));
|
||||
assert!(candidates.contains(&"com_example_App".to_string()));
|
||||
assert!(candidates.contains(&"Example App".to_string()));
|
||||
assert!(candidates.contains(&"Example".to_string()));
|
||||
assert!(candidates.contains(&"App".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_wm_class_matching_detects_flatpak_chrome_apps() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let apps_dir = temp.path().join("applications");
|
||||
fs::create_dir_all(&apps_dir).expect("create applications dir");
|
||||
|
||||
let desktop_contents = "\
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Proton Mail
|
||||
Exec=chromium --app-id=jnpecgipniidlgicjocehkhajgdnjekh
|
||||
Icon=chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default
|
||||
StartupWMClass=crx_jnpecgipniidlgicjocehkhajgdnjekh
|
||||
";
|
||||
let desktop_path = apps_dir.join(
|
||||
"org.chromium.Chromium.flextop.chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default.desktop",
|
||||
);
|
||||
fs::write(desktop_path, desktop_contents).expect("write desktop file");
|
||||
|
||||
let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
|
||||
|
||||
let locales = vec!["en_US.UTF-8".to_string()];
|
||||
let mut cache = DesktopEntryCache::new(locales.clone());
|
||||
cache.refresh();
|
||||
|
||||
let ctx = DesktopLookupContext::new("crx_jnpecgipniidlgicjocehkhajgdnjekh");
|
||||
let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default());
|
||||
|
||||
assert_eq!(
|
||||
resolved.id(),
|
||||
"org.chromium.Chromium.flextop.chrome-jnpecgipniidlgicjocehkhajgdnjekh-Default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_basename_matching_handles_vmware() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let apps_dir = temp.path().join("applications");
|
||||
fs::create_dir_all(&apps_dir).expect("create applications dir");
|
||||
|
||||
let desktop_contents = "\
|
||||
[Desktop Entry]\n\
|
||||
Version=1.0\n\
|
||||
Type=Application\n\
|
||||
Name=VMware Workstation\n\
|
||||
Exec=/usr/bin/vmware %U\n\
|
||||
Icon=vmware-workstation\n\
|
||||
";
|
||||
let desktop_path = apps_dir.join("vmware-workstation.desktop");
|
||||
fs::write(desktop_path, desktop_contents).expect("write desktop file");
|
||||
|
||||
let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
|
||||
|
||||
let locales = vec!["en_US.UTF-8".to_string()];
|
||||
let mut cache = DesktopEntryCache::new(locales.clone());
|
||||
cache.refresh();
|
||||
|
||||
let ctx = DesktopLookupContext::new("vmware").with_title("Library — VMware Workstation");
|
||||
|
||||
let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default());
|
||||
|
||||
assert_eq!(resolved.id(), "vmware-workstation.desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proton_fallback_prefers_game_entries() {
|
||||
let locales = vec!["en_US.UTF-8".to_string()];
|
||||
let entry = load_entry(
|
||||
"proton.desktop",
|
||||
"[Desktop Entry]\nType=Application\nName=Proton Game\nCategories=Game;Utility;\nExec=proton-game\n",
|
||||
&locales,
|
||||
);
|
||||
let cache = DesktopEntryCache::from_entries(locales.clone(), vec![entry]);
|
||||
let ctx = DesktopLookupContext::new("steam_app_default").with_title("Proton Game");
|
||||
|
||||
let resolved = proton_or_wine_fallback(&cache, &ctx).expect("expected proton match");
|
||||
let name = resolved
|
||||
.name(&locales)
|
||||
.expect("name available")
|
||||
.into_owned();
|
||||
|
||||
assert_eq!(name, "Proton Game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proton_fallback_skips_non_games() {
|
||||
let locales = vec!["en_US.UTF-8".to_string()];
|
||||
let entry = load_entry(
|
||||
"tool.desktop",
|
||||
"[Desktop Entry]\nType=Application\nName=Proton Tool\nCategories=Utility;\nExec=proton-tool\n",
|
||||
&locales,
|
||||
);
|
||||
let cache = DesktopEntryCache::from_entries(locales, vec![entry]);
|
||||
let ctx = DesktopLookupContext::new("steam_app_default").with_title("Proton Tool");
|
||||
|
||||
assert!(proton_or_wine_fallback(&cache, &ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wine_fallback_matches_executable_titles() {
|
||||
let locales = vec!["en_US.UTF-8".to_string()];
|
||||
let entry = load_entry(
|
||||
"wine.desktop",
|
||||
"[Desktop Entry]\nType=Application\nName=Wine Game\nExec=wine-game\n",
|
||||
&locales,
|
||||
);
|
||||
let cache = DesktopEntryCache::from_entries(locales.clone(), vec![entry]);
|
||||
let ctx = DesktopLookupContext::new("WINEGAME.EXE").with_title("Wine Game");
|
||||
|
||||
let resolved = proton_or_wine_fallback(&cache, &ctx).expect("expected wine match");
|
||||
let name = resolved
|
||||
.name(&locales)
|
||||
.expect("name available")
|
||||
.into_owned();
|
||||
assert_eq!(name, "Wine Game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_entry_uses_title_when_available() {
|
||||
let ctx = DesktopLookupContext::new("unknown-app").with_title("Unknown App");
|
||||
let entry = fallback_entry(&ctx);
|
||||
|
||||
assert_eq!(entry.id(), "unknown-app");
|
||||
assert_eq!(
|
||||
entry.name(&["en_US".to_string()]),
|
||||
Some(Cow::Owned("Unknown App".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_entry_data_prefers_localized_name() {
|
||||
let locales = vec!["fr".to_string(), "en_US".to_string()];
|
||||
let entry = load_entry(
|
||||
"localized.desktop",
|
||||
"[Desktop Entry]\nType=Application\nName=Default\nName[fr]=Localisé\nExec=localized\n",
|
||||
&locales,
|
||||
);
|
||||
let data = DesktopEntryData::from_desktop_entry(&locales, entry);
|
||||
|
||||
assert_eq!(data.name, "Localisé");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crx_id_extraction_variants() {
|
||||
let id = "cadlkienfkclaiaibeoongdcgmdikeeg"; // 32 chars a..p
|
||||
assert_eq!(
|
||||
super::extract_crx_id(&format!("chrome-{}-Default", id)),
|
||||
Some(id.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
super::extract_crx_id(&format!("crx_{}", id)),
|
||||
Some(id.to_string())
|
||||
);
|
||||
assert_eq!(super::extract_crx_id(id), Some(id.to_string()));
|
||||
// Embedded
|
||||
let embedded = format!("org.chromium.Chromium.flextop.chrome-{}-Default", id);
|
||||
assert_eq!(super::extract_crx_id(&embedded), Some(id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crx_matcher_by_exec_and_wmclass() {
|
||||
use std::fs;
|
||||
let id = "cadlkienfkclaiaibeoongdcgmdikeeg";
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let apps_dir = temp.path().join("applications");
|
||||
fs::create_dir_all(&apps_dir).expect("create applications dir");
|
||||
let desktop_contents = format!(
|
||||
"[Desktop Entry]\nType=Application\nName=ChatGPT\nExec=chromium --app-id={} --profile-directory=Default\nStartupWMClass=crx_{}\nIcon=chrome-{}-Default\n",
|
||||
id, id, id
|
||||
);
|
||||
let desktop_path = apps_dir.join(
|
||||
"org.chromium.Chromium.flextop.chrome-cadlkienfkclaiaibeoongdcgmdikeeg-Default.desktop",
|
||||
);
|
||||
fs::write(&desktop_path, desktop_contents).expect("write desktop file");
|
||||
|
||||
let _guard = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
|
||||
let locales = vec!["en_US.UTF-8".to_string()];
|
||||
let mut cache = DesktopEntryCache::new(locales.clone());
|
||||
cache.refresh();
|
||||
|
||||
let short_id = format!("chrome-{}-Default", id);
|
||||
let ctx = DesktopLookupContext::new(short_id);
|
||||
let resolved = resolve_desktop_entry(&mut cache, &ctx, &DesktopResolveOptions::default());
|
||||
assert!(resolved.icon().is_some());
|
||||
assert!(resolved.exec().is_some());
|
||||
assert_eq!(resolved.startup_wm_class(), Some(&format!("crx_{}", id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crx_extraction_handles_utf8_prefixes() {
|
||||
let id = "cadlkienfkclaiaibeoongdcgmdikeeg";
|
||||
let prefixed = format!("å{}", id);
|
||||
assert_eq!(super::extract_crx_id(&prefixed), Some(id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crx_extraction_ignores_non_ascii_sequences() {
|
||||
let id = "cadlkienfkclaiaibeoongdcgmdikeeg";
|
||||
let embedded = format!("{id}æøå");
|
||||
|
||||
assert_eq!(super::extract_crx_id(&embedded), Some(id.to_string()));
|
||||
assert_eq!(super::extract_crx_id("æøå"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue