use std::collections::HashMap; use std::fs; 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, pub name: Option, pub summary: Option, pub description: Option, pub developer: Option, pub project_license: Option, pub project_group: Option, pub urls: HashMap, pub keywords: Vec, pub categories: Vec, pub content_rating_summary: Option, pub releases: Vec, pub mime_types: Vec, pub screenshot_urls: Vec, } #[derive(Debug, Clone)] pub struct ReleaseInfo { pub version: String, pub date: Option, pub description: Option, } /// Parse an AppStream metainfo XML file and extract all metadata. pub fn parse_appstream_file(path: &Path) -> Option { 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 { 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 = Vec::new(); let mut release_desc_parts: Vec = 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 in_screenshots = false; let mut in_screenshot_image = 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(); } "screenshots" if in_component => { in_screenshots = true; } "image" if in_screenshots => { // Prefer "source" type, but accept any in_screenshot_image = true; current_tag = "screenshot_image".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; } "screenshots" => { in_screenshots = false; } "image" if in_screenshot_image => { in_screenshot_image = 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); } "screenshot_image" => { if text.starts_with("http") && meta.screenshot_urls.len() < 10 { meta.screenshot_urls.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 { let records = db.get_all_appimages() .map_err(|e| AppStreamError::Database(e.to_string()))?; let mut xml = String::from("\n"); xml.push_str("\n"); for record in &records { let app_name = record.app_name.as_deref().unwrap_or(&record.filename); let app_id = make_component_id(app_name); let description = record.description.as_deref().unwrap_or(""); xml.push_str(" \n"); xml.push_str(&format!(" appimage.{}\n", xml_escape(&app_id))); xml.push_str(&format!(" {}\n", xml_escape(app_name))); if !description.is_empty() { xml.push_str(&format!(" {}\n", xml_escape(description))); } xml.push_str(&format!(" {}\n", xml_escape(&record.filename))); if let Some(version) = &record.app_version { xml.push_str(" \n"); xml.push_str(&format!( " \n", xml_escape(version), )); xml.push_str(" \n"); } if let Some(categories) = &record.categories { xml.push_str(" \n"); for cat in categories.split(';').filter(|c| !c.is_empty()) { xml.push_str(&format!(" {}\n", xml_escape(cat.trim()))); } xml.push_str(" \n"); } // Provide hint about source xml.push_str(" \n"); xml.push_str(" driftwood\n"); xml.push_str(&format!( " {}\n", xml_escape(&record.path), )); xml.push_str(" \n"); xml.push_str(" \n"); } xml.push_str("\n"); Ok(xml) } /// Install the AppStream catalog to the local swcatalog directory. /// GNOME Software reads from `~/.local/share/swcatalog/xml/`. pub fn install_catalog(db: &Database) -> Result { let catalog_xml = generate_catalog(db)?; let catalog_dir = dirs::data_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("swcatalog") .join("xml"); fs::create_dir_all(&catalog_dir) .map_err(|e| AppStreamError::Io(e.to_string()))?; let catalog_path = catalog_dir.join("driftwood.xml"); fs::write(&catalog_path, &catalog_xml) .map_err(|e| AppStreamError::Io(e.to_string()))?; Ok(catalog_path) } /// Remove the AppStream catalog from the local swcatalog directory. pub fn uninstall_catalog() -> Result<(), AppStreamError> { let catalog_path = dirs::data_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("swcatalog") .join("xml") .join("driftwood.xml"); if catalog_path.exists() { fs::remove_file(&catalog_path) .map_err(|e| AppStreamError::Io(e.to_string()))?; } Ok(()) } /// Check if the AppStream catalog is currently installed. pub fn is_catalog_installed() -> bool { let catalog_path = dirs::data_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("swcatalog") .join("xml") .join("driftwood.xml"); catalog_path.exists() } // --- Utility functions --- fn make_component_id(name: &str) -> String { name.chars() .map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' }) .collect::() .trim_matches('_') .to_string() } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } // --- Error types --- #[derive(Debug)] pub enum AppStreamError { Database(String), Io(String), } impl std::fmt::Display for AppStreamError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Database(e) => write!(f, "Database error: {}", e), Self::Io(e) => write!(f, "I/O error: {}", e), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_make_component_id() { assert_eq!(make_component_id("Firefox"), "firefox"); assert_eq!(make_component_id("My App 2.0"), "my_app_2.0"); assert_eq!(make_component_id("GIMP"), "gimp"); } #[test] fn test_xml_escape() { assert_eq!(xml_escape("hello & world"), "hello & world"); assert_eq!(xml_escape(""), "<tag>"); assert_eq!(xml_escape("it's \"quoted\""), "it's "quoted""); } #[test] fn test_generate_catalog_empty() { let db = crate::core::database::Database::open_in_memory().unwrap(); let xml = generate_catalog(&db).unwrap(); assert!(xml.contains("")); // No individual component entries in an empty DB assert!(!xml.contains("test.AppImage")); assert!(xml.contains("managed-by")); } #[test] fn test_appstream_error_display() { let err = AppStreamError::Database("db error".to_string()); assert!(format!("{}", err).contains("db error")); let err = AppStreamError::Io("write failed".to_string()); assert!(format!("{}", err).contains("write failed")); } #[test] fn test_parse_minimal_appstream() { let xml = r#" org.example.TestApp Test App A test application CC0-1.0 MIT "#; 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#" org.kde.krita Krita Krita Digital Painting, Creative Freedom KDE Community GPL-3.0-or-later KDE

Krita is a creative painting application.

Features include:

  • Layer support
  • Brush engines
https://krita.org https://bugs.kde.org https://krita.org/support-us/donations paint draw none mild

Major update with new features.

image/png image/jpeg
"#; 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#" org.test.App Test Test app Old Developer Tag "#; 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#""#; 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" ); } }