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:
lashman
2026-02-27 18:31:07 +02:00
parent 39b773fed5
commit 1bb7a3bdc0
13 changed files with 1239 additions and 24 deletions

View File

@@ -1,8 +1,388 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use quick_xml::events::Event;
use quick_xml::Reader;
use super::database::Database;
// ---------------------------------------------------------------------------
// AppStream metainfo XML parser - reads metadata FROM AppImages
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default)]
pub struct AppStreamMetadata {
pub id: Option<String>,
pub name: Option<String>,
pub summary: Option<String>,
pub description: Option<String>,
pub developer: Option<String>,
pub project_license: Option<String>,
pub project_group: Option<String>,
pub urls: HashMap<String, String>,
pub keywords: Vec<String>,
pub categories: Vec<String>,
pub content_rating_summary: Option<String>,
pub releases: Vec<ReleaseInfo>,
pub mime_types: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ReleaseInfo {
pub version: String,
pub date: Option<String>,
pub description: Option<String>,
}
/// Parse an AppStream metainfo XML file and extract all metadata.
pub fn parse_appstream_file(path: &Path) -> Option<AppStreamMetadata> {
let content = std::fs::read_to_string(path).ok()?;
parse_appstream_xml(&content)
}
/// Parse AppStream XML from a string.
pub fn parse_appstream_xml(xml: &str) -> Option<AppStreamMetadata> {
let mut reader = Reader::from_str(xml);
let mut meta = AppStreamMetadata::default();
let mut buf = Vec::new();
let mut current_tag = String::new();
let mut in_component = false;
let mut in_description = false;
let mut in_release = false;
let mut in_release_description = false;
let mut in_provides = false;
let mut in_keywords = false;
let mut in_categories = false;
let mut description_parts: Vec<String> = Vec::new();
let mut release_desc_parts: Vec<String> = Vec::new();
let mut current_url_type = String::new();
let mut current_release_version = String::new();
let mut current_release_date = String::new();
let mut content_rating_attrs: Vec<(String, String)> = Vec::new();
let mut in_content_rating = false;
let mut current_content_attr_id = String::new();
let mut in_developer = false;
let mut depth = 0u32;
let mut description_depth = 0u32;
let mut release_desc_depth = 0u32;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
depth += 1;
match tag_name.as_str() {
"component" => {
in_component = true;
}
"description" if in_component && !in_release => {
in_description = true;
description_depth = depth;
description_parts.clear();
}
"description" if in_release => {
in_release_description = true;
release_desc_depth = depth;
release_desc_parts.clear();
}
"p" if in_description && !in_release_description => {
current_tag = "description_p".to_string();
}
"li" if in_description && !in_release_description => {
current_tag = "description_li".to_string();
}
"p" if in_release_description => {
current_tag = "release_desc_p".to_string();
}
"li" if in_release_description => {
current_tag = "release_desc_li".to_string();
}
"url" if in_component => {
current_url_type = String::new();
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"type" {
current_url_type =
String::from_utf8_lossy(&attr.value).to_string();
}
}
current_tag = "url".to_string();
}
"release" if in_component => {
in_release = true;
current_release_version.clear();
current_release_date.clear();
release_desc_parts.clear();
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"version" => {
current_release_version =
String::from_utf8_lossy(&attr.value).to_string();
}
b"date" => {
current_release_date =
String::from_utf8_lossy(&attr.value).to_string();
}
_ => {}
}
}
}
"content_rating" if in_component => {
in_content_rating = true;
content_rating_attrs.clear();
}
"content_attribute" if in_content_rating => {
current_content_attr_id.clear();
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"id" {
current_content_attr_id =
String::from_utf8_lossy(&attr.value).to_string();
}
}
current_tag = "content_attribute".to_string();
}
"provides" if in_component => {
in_provides = true;
}
"mediatype" if in_provides => {
current_tag = "mediatype".to_string();
}
"keywords" if in_component => {
in_keywords = true;
}
"keyword" if in_keywords => {
current_tag = "keyword".to_string();
}
"categories" if in_component => {
in_categories = true;
}
"category" if in_categories => {
current_tag = "category".to_string();
}
"developer" if in_component => {
in_developer = true;
}
"developer_name" if in_component && !in_developer => {
current_tag = "developer_name".to_string();
}
"name" if in_developer => {
current_tag = "developer_child_name".to_string();
}
"id" if in_component && depth == 2 => {
current_tag = "id".to_string();
}
"name" if in_component && !in_developer && depth == 2 => {
let has_lang = e.attributes().flatten().any(|a| {
a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang"
});
if !has_lang {
current_tag = "name".to_string();
}
}
"summary" if in_component && depth == 2 => {
let has_lang = e.attributes().flatten().any(|a| {
a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang"
});
if !has_lang {
current_tag = "summary".to_string();
}
}
"project_license" if in_component => {
current_tag = "project_license".to_string();
}
"project_group" if in_component => {
current_tag = "project_group".to_string();
}
_ => {}
}
}
Ok(Event::End(ref e)) => {
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
match tag_name.as_str() {
"component" => {
in_component = false;
}
"description"
if in_release_description && depth == release_desc_depth =>
{
in_release_description = false;
}
"description" if in_description && depth == description_depth => {
in_description = false;
if !description_parts.is_empty() {
meta.description = Some(description_parts.join("\n\n"));
}
}
"release" => {
if !current_release_version.is_empty()
&& meta.releases.len() < 10
{
let desc = if release_desc_parts.is_empty() {
None
} else {
Some(release_desc_parts.join("\n"))
};
meta.releases.push(ReleaseInfo {
version: current_release_version.clone(),
date: if current_release_date.is_empty() {
None
} else {
Some(current_release_date.clone())
},
description: desc,
});
}
in_release = false;
in_release_description = false;
}
"content_rating" => {
in_content_rating = false;
meta.content_rating_summary =
Some(summarize_content_rating(&content_rating_attrs));
}
"provides" => {
in_provides = false;
}
"keywords" => {
in_keywords = false;
}
"categories" => {
in_categories = false;
}
"developer" => {
in_developer = false;
}
_ => {}
}
current_tag.clear();
depth = depth.saturating_sub(1);
}
Ok(Event::Text(ref e)) => {
let text = e.unescape().unwrap_or_default().trim().to_string();
if text.is_empty() {
continue;
}
match current_tag.as_str() {
"id" => meta.id = Some(text),
"name" => meta.name = Some(text),
"summary" => meta.summary = Some(text),
"project_license" => meta.project_license = Some(text),
"project_group" => meta.project_group = Some(text),
"url" if !current_url_type.is_empty() => {
meta.urls.insert(current_url_type.clone(), text);
}
"description_p" => {
description_parts.push(text);
}
"description_li" => {
description_parts.push(format!(" - {}", text));
}
"release_desc_p" => {
release_desc_parts.push(text);
}
"release_desc_li" => {
release_desc_parts.push(format!(" - {}", text));
}
"content_attribute" if !current_content_attr_id.is_empty() => {
content_rating_attrs
.push((current_content_attr_id.clone(), text));
}
"mediatype" => {
meta.mime_types.push(text);
}
"keyword" => {
meta.keywords.push(text);
}
"category" => {
meta.categories.push(text);
}
"developer_name" => {
if meta.developer.is_none() {
meta.developer = Some(text);
}
}
"developer_child_name" => {
meta.developer = Some(text);
}
_ => {}
}
}
Ok(Event::Empty(ref e)) => {
let tag_name =
String::from_utf8_lossy(e.name().as_ref()).to_string();
if tag_name == "release" && in_component {
let mut ver = String::new();
let mut date = String::new();
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"version" => {
ver = String::from_utf8_lossy(&attr.value)
.to_string();
}
b"date" => {
date = String::from_utf8_lossy(&attr.value)
.to_string();
}
_ => {}
}
}
if !ver.is_empty() && meta.releases.len() < 10 {
meta.releases.push(ReleaseInfo {
version: ver,
date: if date.is_empty() { None } else { Some(date) },
description: None,
});
}
}
}
Ok(Event::Eof) => break,
Err(e) => {
log::warn!("AppStream XML parse error: {}", e);
break;
}
_ => {}
}
buf.clear();
}
if meta.id.is_some() || meta.name.is_some() || meta.summary.is_some() {
Some(meta)
} else {
None
}
}
/// Summarize OARS content rating attributes into a human-readable level.
fn summarize_content_rating(attrs: &[(String, String)]) -> String {
let max_level = attrs
.iter()
.map(|(_, v)| match v.as_str() {
"intense" => 3,
"moderate" => 2,
"mild" => 1,
_ => 0,
})
.max()
.unwrap_or(0);
match max_level {
0 => "All ages".to_string(),
1 => "Mild content".to_string(),
2 => "Moderate content".to_string(),
3 => "Mature content".to_string(),
_ => "Unknown".to_string(),
}
}
// ---------------------------------------------------------------------------
// AppStream catalog generation - writes catalog XML for GNOME Software/Discover
// ---------------------------------------------------------------------------
/// Generate an AppStream catalog XML from the Driftwood database.
/// This allows GNOME Software / KDE Discover to see locally managed AppImages.
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
@@ -206,4 +586,135 @@ mod tests {
let err = AppStreamError::Io("write failed".to_string());
assert!(format!("{}", err).contains("write failed"));
}
#[test]
fn test_parse_minimal_appstream() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.example.TestApp</id>
<name>Test App</name>
<summary>A test application</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
</component>"#;
let meta = parse_appstream_xml(xml).unwrap();
assert_eq!(meta.id.as_deref(), Some("org.example.TestApp"));
assert_eq!(meta.name.as_deref(), Some("Test App"));
assert_eq!(meta.summary.as_deref(), Some("A test application"));
assert_eq!(meta.project_license.as_deref(), Some("MIT"));
}
#[test]
fn test_parse_full_appstream() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.kde.krita</id>
<name>Krita</name>
<name xml:lang="de">Krita</name>
<summary>Digital Painting, Creative Freedom</summary>
<developer>
<name>KDE Community</name>
</developer>
<project_license>GPL-3.0-or-later</project_license>
<project_group>KDE</project_group>
<description>
<p>Krita is a creative painting application.</p>
<p>Features include:</p>
<ul>
<li>Layer support</li>
<li>Brush engines</li>
</ul>
</description>
<url type="homepage">https://krita.org</url>
<url type="bugtracker">https://bugs.kde.org</url>
<url type="donation">https://krita.org/support-us/donations</url>
<keywords>
<keyword>paint</keyword>
<keyword>draw</keyword>
</keywords>
<content_rating type="oars-1.1">
<content_attribute id="violence-cartoon">none</content_attribute>
<content_attribute id="language-humor">mild</content_attribute>
</content_rating>
<releases>
<release version="5.2.0" date="2024-01-15">
<description>
<p>Major update with new features.</p>
</description>
</release>
<release version="5.1.0" date="2023-09-01"/>
</releases>
<provides>
<mediatype>image/png</mediatype>
<mediatype>image/jpeg</mediatype>
</provides>
</component>"#;
let meta = parse_appstream_xml(xml).unwrap();
assert_eq!(meta.id.as_deref(), Some("org.kde.krita"));
assert_eq!(meta.name.as_deref(), Some("Krita"));
assert_eq!(meta.developer.as_deref(), Some("KDE Community"));
assert_eq!(meta.project_license.as_deref(), Some("GPL-3.0-or-later"));
assert_eq!(meta.project_group.as_deref(), Some("KDE"));
assert_eq!(
meta.urls.get("homepage").map(|s| s.as_str()),
Some("https://krita.org")
);
assert_eq!(
meta.urls.get("bugtracker").map(|s| s.as_str()),
Some("https://bugs.kde.org")
);
assert_eq!(meta.keywords, vec!["paint", "draw"]);
assert_eq!(
meta.content_rating_summary.as_deref(),
Some("Mild content")
);
assert_eq!(meta.releases.len(), 2);
assert_eq!(meta.releases[0].version, "5.2.0");
assert_eq!(meta.releases[0].date.as_deref(), Some("2024-01-15"));
assert!(meta.releases[0].description.is_some());
assert_eq!(meta.releases[1].version, "5.1.0");
assert_eq!(meta.mime_types, vec!["image/png", "image/jpeg"]);
assert!(meta
.description
.unwrap()
.contains("Krita is a creative painting application"));
}
#[test]
fn test_parse_legacy_developer_name() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>org.test.App</id>
<name>Test</name>
<summary>Test app</summary>
<developer_name>Old Developer Tag</developer_name>
</component>"#;
let meta = parse_appstream_xml(xml).unwrap();
assert_eq!(meta.developer.as_deref(), Some("Old Developer Tag"));
}
#[test]
fn test_parse_empty_returns_none() {
let xml = r#"<?xml version="1.0"?><component></component>"#;
assert!(parse_appstream_xml(xml).is_none());
}
#[test]
fn test_content_rating_levels() {
assert_eq!(summarize_content_rating(&[]), "All ages");
assert_eq!(
summarize_content_rating(&[
("violence-cartoon".to_string(), "none".to_string()),
("language-humor".to_string(), "mild".to_string()),
]),
"Mild content"
);
assert_eq!(
summarize_content_rating(&[(
"violence-realistic".to_string(),
"intense".to_string(),
)]),
"Mature content"
);
}
}