Add comprehensive AppImage metadata extraction, display, and bug fixes
- Add AppStream XML parser (quick-xml) to extract rich metadata from bundled metainfo/appdata files: description, developer, license, URLs, keywords, categories, content rating, release history, and MIME types - Database migration v9: 16 new columns for extended metadata storage - Extended inspector to parse AppStream XML, desktop entry extended fields, and detect binary signatures without executing AppImages - Redesigned detail view overview tab with 8 conditional groups: About, Description, Links, Release History, Usage, Capabilities, File Info - Fix crash on exit caused by stale GLib SourceId removal in debounce timers - Fix wayland.rs executing AppImages directly to detect squashfs offset, replaced with safe binary scan via find_squashfs_offset_for() - Fix scan skipping re-analysis of apps missing new metadata fields
This commit is contained in:
@@ -43,6 +43,23 @@ pub struct AppImageMetadata {
|
||||
pub desktop_entry_content: String,
|
||||
pub architecture: Option<String>,
|
||||
pub cached_icon_path: Option<PathBuf>,
|
||||
// Extended metadata from AppStream XML and desktop entry
|
||||
pub appstream_id: Option<String>,
|
||||
pub appstream_description: Option<String>,
|
||||
pub generic_name: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub homepage_url: Option<String>,
|
||||
pub bugtracker_url: Option<String>,
|
||||
pub donation_url: Option<String>,
|
||||
pub help_url: Option<String>,
|
||||
pub vcs_url: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
pub mime_types: Vec<String>,
|
||||
pub content_rating: Option<String>,
|
||||
pub project_group: Option<String>,
|
||||
pub releases: Vec<crate::core::appstream::ReleaseInfo>,
|
||||
pub desktop_actions: Vec<String>,
|
||||
pub has_signature: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -53,6 +70,12 @@ struct DesktopEntryFields {
|
||||
categories: Vec<String>,
|
||||
exec: Option<String>,
|
||||
version: Option<String>,
|
||||
generic_name: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
mime_types: Vec<String>,
|
||||
terminal: bool,
|
||||
x_appimage_name: Option<String>,
|
||||
actions: Vec<String>,
|
||||
}
|
||||
|
||||
fn icons_cache_dir() -> PathBuf {
|
||||
@@ -74,6 +97,12 @@ fn has_unsquashfs() -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Public wrapper for binary squashfs offset detection.
|
||||
/// Used by other modules (e.g. wayland) to avoid executing the AppImage.
|
||||
pub fn find_squashfs_offset_for(path: &Path) -> Option<u64> {
|
||||
find_squashfs_offset(path).ok()
|
||||
}
|
||||
|
||||
/// Find the squashfs offset by scanning for a valid superblock in the binary.
|
||||
/// This avoids executing the AppImage, which can hang for apps with custom AppRun scripts.
|
||||
fn find_squashfs_offset(path: &Path) -> Result<u64, InspectorError> {
|
||||
@@ -274,6 +303,30 @@ fn parse_desktop_entry(content: &str) -> DesktopEntryFields {
|
||||
}
|
||||
"Exec" => fields.exec = Some(value.to_string()),
|
||||
"X-AppImage-Version" => fields.version = Some(value.to_string()),
|
||||
"GenericName" => fields.generic_name = Some(value.to_string()),
|
||||
"Keywords" => {
|
||||
fields.keywords = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
"MimeType" => {
|
||||
fields.mime_types = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
"Terminal" => fields.terminal = value == "true",
|
||||
"X-AppImage-Name" => fields.x_appimage_name = Some(value.to_string()),
|
||||
"Actions" => {
|
||||
fields.actions = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -400,6 +453,41 @@ fn find_icon_recursive(dir: &Path, name: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Find an AppStream metainfo XML file in the extract directory.
|
||||
fn find_appstream_file(extract_dir: &Path) -> Option<PathBuf> {
|
||||
// Check modern path first
|
||||
let metainfo_dir = extract_dir.join("usr/share/metainfo");
|
||||
if let Ok(entries) = fs::read_dir(&metainfo_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("xml") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check legacy path
|
||||
let appdata_dir = extract_dir.join("usr/share/appdata");
|
||||
if let Ok(entries) = fs::read_dir(&appdata_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("xml") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name.
|
||||
fn detect_signature(path: &Path) -> bool {
|
||||
let data = match fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = b".sha256_sig";
|
||||
data.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Cache an icon file to the driftwood icons directory.
|
||||
fn cache_icon(source: &Path, app_id: &str) -> Option<PathBuf> {
|
||||
let ext = source
|
||||
@@ -482,6 +570,31 @@ pub fn inspect_appimage(
|
||||
let desktop_content = fs::read_to_string(&desktop_path)?;
|
||||
let fields = parse_desktop_entry(&desktop_content);
|
||||
|
||||
// Parse AppStream metainfo XML if available
|
||||
let appstream = find_appstream_file(&extract_dir)
|
||||
.and_then(|p| crate::core::appstream::parse_appstream_file(&p));
|
||||
|
||||
// Merge: AppStream takes priority for overlapping fields
|
||||
let final_name = appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.name.clone())
|
||||
.or(fields.name);
|
||||
let final_description = appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.description.clone())
|
||||
.or(appstream.as_ref().and_then(|a| a.summary.clone()))
|
||||
.or(fields.comment);
|
||||
let final_developer = appstream.as_ref().and_then(|a| a.developer.clone());
|
||||
let final_categories = if let Some(ref a) = appstream {
|
||||
if !a.categories.is_empty() {
|
||||
a.categories.clone()
|
||||
} else {
|
||||
fields.categories
|
||||
}
|
||||
} else {
|
||||
fields.categories
|
||||
};
|
||||
|
||||
// Determine version (desktop entry > filename heuristic)
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let version = fields
|
||||
@@ -491,7 +604,7 @@ pub fn inspect_appimage(
|
||||
// Find and cache icon
|
||||
let icon = find_icon(&extract_dir, fields.icon.as_deref());
|
||||
let app_id = make_app_id(
|
||||
fields.name.as_deref().unwrap_or(
|
||||
final_name.as_deref().unwrap_or(
|
||||
filename
|
||||
.strip_suffix(".AppImage")
|
||||
.unwrap_or(filename),
|
||||
@@ -499,16 +612,71 @@ pub fn inspect_appimage(
|
||||
);
|
||||
let cached_icon = icon.and_then(|icon_path| cache_icon(&icon_path, &app_id));
|
||||
|
||||
// Merge keywords from both sources
|
||||
let mut all_keywords = fields.keywords;
|
||||
if let Some(ref a) = appstream {
|
||||
for kw in &a.keywords {
|
||||
if !all_keywords.contains(kw) {
|
||||
all_keywords.push(kw.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge MIME types from both sources
|
||||
let mut all_mime_types = fields.mime_types;
|
||||
if let Some(ref a) = appstream {
|
||||
for mt in &a.mime_types {
|
||||
if !all_mime_types.contains(mt) {
|
||||
all_mime_types.push(mt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let has_sig = detect_signature(path);
|
||||
|
||||
Ok(AppImageMetadata {
|
||||
app_name: fields.name,
|
||||
app_name: final_name,
|
||||
app_version: version,
|
||||
description: fields.comment,
|
||||
developer: None,
|
||||
description: final_description,
|
||||
developer: final_developer,
|
||||
icon_name: fields.icon,
|
||||
categories: fields.categories,
|
||||
categories: final_categories,
|
||||
desktop_entry_content: desktop_content,
|
||||
architecture: detect_architecture(path),
|
||||
cached_icon_path: cached_icon,
|
||||
appstream_id: appstream.as_ref().and_then(|a| a.id.clone()),
|
||||
appstream_description: appstream.as_ref().and_then(|a| a.description.clone()),
|
||||
generic_name: fields
|
||||
.generic_name
|
||||
.or_else(|| appstream.as_ref().and_then(|a| a.summary.clone())),
|
||||
license: appstream.as_ref().and_then(|a| a.project_license.clone()),
|
||||
homepage_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("homepage").cloned()),
|
||||
bugtracker_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("bugtracker").cloned()),
|
||||
donation_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("donation").cloned()),
|
||||
help_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("help").cloned()),
|
||||
vcs_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("vcs-browser").cloned()),
|
||||
keywords: all_keywords,
|
||||
mime_types: all_mime_types,
|
||||
content_rating: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.content_rating_summary.clone()),
|
||||
project_group: appstream.as_ref().and_then(|a| a.project_group.clone()),
|
||||
releases: appstream
|
||||
.as_ref()
|
||||
.map(|a| a.releases.clone())
|
||||
.unwrap_or_default(),
|
||||
desktop_actions: fields.actions,
|
||||
has_signature: has_sig,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user