//! Application artwork is resolved off the UI thread, then cached by app ID. use std::path::{Path, PathBuf}; use cosmic::widget::icon; use freedesktop_desktop_entry as fde; pub(super) fn fallback() -> icon::Handle { icon::from_svg_bytes(ferese_theme::icons::WINDOW).symbolic(true) } pub(super) fn resolve(entries: &[fde::DesktopEntry], app_id: &str) -> icon::Handle { path(entries, app_id, |name| { icon::from_name(name.to_owned()) .prefer_svg(true) .size(32) // A shortened name could identify a different application. .fallback(None) .path() }) .map(icon::from_path) .unwrap_or_else(fallback) } fn path( entries: &[fde::DesktopEntry], app_id: &str, mut lookup: impl FnMut(&str) -> Option, ) -> Option { let entry = fde::find_app_by_id(entries, fde::unicase::Ascii::new(app_id)); let source = entry .and_then(fde::DesktopEntry::icon) .filter(|s| !s.is_empty()); let declared = source.and_then(|source| { if Path::new(source).is_absolute() { Some(PathBuf::from(source)) } else { lookup(source) } }); declared.filter(|path| path.is_file()).or_else(|| { // Some applications install an app-ID icon without an Icon= entry. // Also try the canonical desktop ID for StartupWMClass aliases. [Some(app_id), entry.map(|entry| entry.appid.as_str())] .into_iter() .flatten() .filter(|id| !id.is_empty() && !id.contains('/')) .find_map(|id| lookup(id).filter(|path| path.is_file())) }) } #[cfg(test)] mod tests { use super::*; fn entry(directory: &Path, id: &str, fields: &str) -> fde::DesktopEntry { let path = directory.join(format!("{id}.desktop")); std::fs::write( &path, format!("[Desktop Entry]\nType=Application\nName=Test\n{fields}\n"), ) .unwrap(); fde::DesktopEntry::from_path(path, Some(&[] as &[&str])).unwrap() } #[test] fn declared_artwork_wins_and_absolute_raster_icons_work() { let dir = tempfile::tempdir().unwrap(); let png = dir.path().join("own.png"); std::fs::write(&png, b"fixture").unwrap(); let entries = [entry( dir.path(), "test.app", &format!("Icon={}", png.display()), )]; assert_eq!( path(&entries, "test.app", |_| panic!( "absolute icon needs no lookup" )), Some(png) ); } #[test] fn missing_or_broken_declarations_use_the_app_id_and_wm_class_alias() { let dir = tempfile::tempdir().unwrap(); let svg = dir.path().join("own.svg"); std::fs::write(&svg, b"fixture").unwrap(); for fields in ["", "Icon=missing-icon", "Icon=/missing/icon.png"] { let entries = [entry( dir.path(), "test.app", &format!("StartupWMClass=Alias\n{fields}"), )]; let mut names = Vec::new(); assert_eq!( path(&entries, "Alias", |name| { names.push(name.to_owned()); (name == "test.app").then(|| svg.clone()) }), Some(svg.clone()) ); assert!(names.iter().any(|name| name == "Alias")); } } #[test] fn unknown_applications_never_use_the_ferese_brand() { assert!(path(&[], "unknown", |_| None).is_none()); let icon = fallback(); assert!(icon.symbolic); match icon.data { icon::Data::Svg(handle) => { assert_ne!( handle, cosmic::widget::svg::Handle::from_memory(ferese_theme::icons::APPLICATION) ); } _ => panic!("fallback must be the bundled window glyph"), } } #[test] #[ignore = "inspect desktop/icon lookup using the caller's XDG environment"] fn inspect_installed_application_icons() { let entries: Vec<_> = fde::Iter::new(fde::default_paths()) .entries::<&str>(Some(&[])) .collect(); let ids = std::env::var("FERESE_TEST_APP_IDS").unwrap(); for id in ids.split(',') { eprintln!("APP_ICON {id}: {:?}", resolve(&entries, id)); } } }