Repository navigation
fix: pick the canonical desktop entry from AppImages #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
| use std::{fs, path::Path}; | ||
|
|
||
| use soar_utils::fs::read_file_signature; | ||
| use squishy::appimage::{AppImage, AppImageEntryKind}; | ||
| use squishy::appimage::{AppImage, AppImageEntry, AppImageEntryKind}; | ||
|
|
||
| use super::{ | ||
| PNG_MAGIC_BYTES, | ||
|
|
@@ -16,6 +16,115 @@ use crate::{ | |
| traits::PackageExt, | ||
| }; | ||
|
|
||
| /// Upper bound on the size of a candidate desktop file, used to skip large | ||
| /// `.desktop`-suffixed binaries without reading them. | ||
| const MAX_DESKTOP_SIZE: u64 = 1 << 20; | ||
|
|
||
| fn is_desktop_entry(bytes: &[u8]) -> bool { | ||
| if bytes.starts_with(b"\x7fELF") { | ||
| return false; | ||
| } | ||
| String::from_utf8_lossy(bytes) | ||
| .lines() | ||
| .map(str::trim) | ||
| .any(|line| line == "[Desktop Entry]") | ||
| } | ||
|
|
||
| fn declares_no_display(bytes: &[u8]) -> bool { | ||
| String::from_utf8_lossy(bytes).lines().any(|line| { | ||
| line.trim() | ||
| .strip_prefix("NoDisplay=") | ||
| .is_some_and(|value| value.trim().eq_ignore_ascii_case("true")) | ||
| }) | ||
| } | ||
|
|
||
| /// How canonical a desktop file's location is, lower being preferred. | ||
| /// | ||
| /// An AppImage ships its entry at the root, while `/usr/share/applications/` | ||
| /// holds both the app's own entry and helper entries nested in subdirectories | ||
| /// such as `kde4/`. An unpacked runtime may carry dozens of those helpers. | ||
| fn location_rank(path: &Path) -> u8 { | ||
| const APPS: &str = "/usr/share/applications/"; | ||
| let path = path.to_string_lossy().to_ascii_lowercase(); | ||
| if let Some(rest) = path.strip_prefix(APPS) { | ||
| if rest.contains('/') { 2 } else { 1 } | ||
| } else if path.trim_start_matches('/').contains('/') { | ||
| 3 | ||
| } else { | ||
| 0 | ||
| } | ||
| } | ||
|
|
||
| fn entry_stem_matches(path: &Path, pkg_name: &str) -> bool { | ||
| path.file_stem() | ||
| .is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(pkg_name)) | ||
| } | ||
|
|
||
| fn resolve_symlink<'a>( | ||
| entries: &'a [AppImageEntry], | ||
| entry: &'a AppImageEntry, | ||
| ) -> Option<&'a AppImageEntry> { | ||
| let mut entry = entry; | ||
| for _ in 0..40 { | ||
| match &entry.kind { | ||
| AppImageEntryKind::Symlink(target) => { | ||
| entry = entries.iter().find(|candidate| candidate.path == *target)?; | ||
| } | ||
| _ => return Some(entry), | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| /// Picks the desktop file that belongs to the application. | ||
| /// | ||
| /// `squishy`'s `find_desktop` prefers any path under `/usr/share/applications/` | ||
| /// and breaks ties by path order, which lets a helper entry nested in a | ||
| /// subdirectory win over the application's own entry. Ranking by location, | ||
| /// visibility, and name keeps the canonical one first. | ||
| fn find_desktop_entry(appimage: &mut AppImage, pkg_name: &str) -> Option<AppImageEntry> { | ||
| let entries: Vec<AppImageEntry> = appimage.entries().collect(); | ||
| let mut candidates: Vec<(&AppImageEntry, bool)> = Vec::new(); | ||
|
|
||
| for entry in &entries { | ||
| let Some(entry) = resolve_symlink(&entries, entry) else { | ||
| continue; | ||
| }; | ||
| if entry.kind != AppImageEntryKind::File | ||
| || entry.size > MAX_DESKTOP_SIZE | ||
| || !entry | ||
| .path | ||
| .to_string_lossy() | ||
| .to_ascii_lowercase() | ||
| .ends_with(".desktop") | ||
| { | ||
| continue; | ||
| } | ||
|
Comment on lines
+89
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| let Ok(bytes) = appimage.read_entry(entry) else { | ||
| continue; | ||
| }; | ||
| if !is_desktop_entry(&bytes) { | ||
| continue; | ||
| } | ||
| candidates.push((entry, declares_no_display(&bytes))); | ||
| } | ||
|
|
||
| candidates.sort_by_key(|(entry, no_display)| { | ||
| ( | ||
| location_rank(&entry.path), | ||
| *no_display, | ||
| !entry_stem_matches(&entry.path, pkg_name), | ||
| entry.path.to_string_lossy().to_ascii_lowercase(), | ||
| ) | ||
| }); | ||
|
|
||
| candidates.first().map(|(entry, _)| AppImageEntry { | ||
| path: entry.path.clone(), | ||
| size: entry.size, | ||
| kind: entry.kind.clone(), | ||
| }) | ||
| } | ||
|
|
||
| /// Integrates an AppImage by extracting its embedded resources. | ||
| /// | ||
| /// This function extracts icons, desktop files, and AppStream metadata from | ||
|
|
@@ -54,17 +163,14 @@ pub async fn integrate_appimage<P: AsRef<Path>, T: PackageExt>( | |
| // The extracted icon is named after the app rather than after the AppImage, | ||
| // whose own name is often a generic word the icon theme already claims, so | ||
| // the desktop file has to be extracted and read first. | ||
| let desktop_path = match (!has_desktop) | ||
| .then(|| appimage.find_desktop()) | ||
| .flatten() | ||
| .filter(|entry| entry.kind == AppImageEntryKind::File) | ||
| { | ||
| Some(entry) => { | ||
| let dest = format!("{}/{}.desktop", install_dir.display(), pkg_name); | ||
| appimage.write_entry(&entry, &dest)?; | ||
| Some(dest) | ||
| } | ||
| None => None, | ||
| let desktop_path = if has_desktop { | ||
| None | ||
| } else if let Some(entry) = find_desktop_entry(&mut appimage, pkg_name) { | ||
| let dest = format!("{}/{}.desktop", install_dir.display(), pkg_name); | ||
| appimage.write_entry(&entry, &dest)?; | ||
| Some(dest) | ||
| } else { | ||
| None | ||
| }; | ||
|
|
||
| let desktop_icon_name = desktop_path | ||
|
|
@@ -119,3 +225,49 @@ pub async fn integrate_appimage<P: AsRef<Path>, T: PackageExt>( | |
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use std::path::Path; | ||
|
|
||
| use super::{declares_no_display, entry_stem_matches, is_desktop_entry, location_rank}; | ||
|
|
||
| #[test] | ||
| fn a_nested_helper_entry_ranks_below_the_canonical_entries() { | ||
| let root = Path::new("/onlyoffice-desktopeditors.desktop"); | ||
| let direct = Path::new("/usr/share/applications/onlyoffice-desktopeditors.desktop"); | ||
| let nested = Path::new("/usr/share/applications/kde4/keditbookmarks.desktop"); | ||
| let elsewhere = Path::new("/usr/share/kde4/services/bookmarks.desktop"); | ||
|
|
||
| assert!(location_rank(root) < location_rank(direct)); | ||
| assert!(location_rank(direct) < location_rank(nested)); | ||
| assert!(location_rank(nested) < location_rank(elsewhere)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_entry_stem_matches_the_package_name_without_case() { | ||
| assert!(entry_stem_matches( | ||
| Path::new("/usr/share/applications/OnlyOffice.desktop"), | ||
| "onlyoffice" | ||
| )); | ||
| assert!(!entry_stem_matches( | ||
| Path::new("/usr/share/applications/other.desktop"), | ||
| "onlyoffice" | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_hidden_entry_is_recognized() { | ||
| assert!(declares_no_display(b"[Desktop Entry]\nNoDisplay=true\n")); | ||
| assert!(declares_no_display(b"[Desktop Entry]\nNoDisplay=TRUE\n")); | ||
| assert!(!declares_no_display(b"[Desktop Entry]\nName=Visible\n")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn only_real_desktop_entries_are_accepted() { | ||
| assert!(is_desktop_entry( | ||
| b"[Desktop Entry]\nName=DesktopEditors\n" | ||
| )); | ||
| assert!(!is_desktop_entry(b"\x7fELF some binary calling itself .desktop")); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolve SquashFS symlink targets with their original path semantics.
resolve_symlinkperforms an exact path comparison.squishyprefixes each target with/, so./app.desktopbecomes/./app.desktopand does not match/app.desktop. A relative target from a nested symlink is also checked from/instead of from the symlink's parent.find_desktop_entrythen drops the valid candidate when resolution returnsNone.Recover whether the prefixed target was originally relative or absolute, normalize
.and.., and resolve relative targets against the current symlink's parent before the exact lookup. Do not combine this correction with ranking or performance changes.