diff --git a/Cargo.lock b/Cargo.lock index de8c4fe..9bc4082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,7 @@ dependencies = [ "log", "notify", "notify-rust", + "quick-xml", "rusqlite", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 859c460..5f3b19b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ env_logger = "0.11" # Temp directories (for AppImage extraction) tempfile = "3" +# XML parsing (for AppStream metainfo) +quick-xml = "0.37" + # Desktop notifications notify-rust = "4" diff --git a/src/core/analysis.rs b/src/core/analysis.rs index f11dbc7..23ade51 100644 --- a/src/core/analysis.rs +++ b/src/core/analysis.rs @@ -61,7 +61,7 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy log::warn!("Failed to set analysis status to 'analyzing' for id {}: {}", id, e); } - // Inspect metadata (app name, version, icon, desktop entry, etc.) + // Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.) if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) { let categories = if meta.categories.is_empty() { None @@ -84,6 +84,61 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy ) { log::warn!("Failed to update metadata for id {}: {}", id, e); } + + // Store extended metadata from AppStream XML and desktop entry + let keywords = if meta.keywords.is_empty() { + None + } else { + Some(meta.keywords.join(",")) + }; + let mime_types = if meta.mime_types.is_empty() { + None + } else { + Some(meta.mime_types.join(";")) + }; + let release_json = if meta.releases.is_empty() { + None + } else { + let releases: Vec = meta + .releases + .iter() + .map(|r| { + serde_json::json!({ + "version": r.version, + "date": r.date, + "description": r.description, + }) + }) + .collect(); + Some(serde_json::to_string(&releases).unwrap_or_default()) + }; + let actions_json = if meta.desktop_actions.is_empty() { + None + } else { + Some(serde_json::to_string(&meta.desktop_actions).unwrap_or_default()) + }; + + if let Err(e) = db.update_appstream_metadata( + id, + meta.appstream_id.as_deref(), + meta.appstream_description.as_deref(), + meta.generic_name.as_deref(), + meta.license.as_deref(), + meta.homepage_url.as_deref(), + meta.bugtracker_url.as_deref(), + meta.donation_url.as_deref(), + meta.help_url.as_deref(), + meta.vcs_url.as_deref(), + keywords.as_deref(), + mime_types.as_deref(), + meta.content_rating.as_deref(), + meta.project_group.as_deref(), + release_json.as_deref(), + actions_json.as_deref(), + meta.has_signature, + ) { + log::warn!("Failed to update appstream metadata for id {}: {}", id, e); + } } // FUSE status diff --git a/src/core/appstream.rs b/src/core/appstream.rs index 789919f..54fa460 100644 --- a/src/core/appstream.rs +++ b/src/core/appstream.rs @@ -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, + 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, +} + +#[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 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 { @@ -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#" + + 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" + ); + } } diff --git a/src/core/database.rs b/src/core/database.rs index ba05303..056e1da 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -50,6 +50,23 @@ pub struct AppImageRecord { pub tags: Option, pub pinned: bool, pub avg_startup_ms: Option, + // Phase 9 fields - comprehensive metadata + pub appstream_id: Option, + pub appstream_description: Option, + pub generic_name: Option, + pub license: Option, + pub homepage_url: Option, + pub bugtracker_url: Option, + pub donation_url: Option, + pub help_url: Option, + pub vcs_url: Option, + pub keywords: Option, + pub mime_types: Option, + pub content_rating: Option, + pub project_group: Option, + pub release_history: Option, + pub desktop_actions: Option, + pub has_signature: bool, } #[derive(Debug, Clone)] @@ -343,6 +360,10 @@ impl Database { self.migrate_to_v8()?; } + if current_version < 9 { + self.migrate_to_v9()?; + } + // Ensure all expected columns exist (repairs DBs where a migration // was updated after it had already run on this database) self.ensure_columns()?; @@ -682,6 +703,44 @@ impl Database { Ok(()) } + fn migrate_to_v9(&self) -> SqlResult<()> { + let new_columns = [ + "appstream_id TEXT", + "appstream_description TEXT", + "generic_name TEXT", + "license TEXT", + "homepage_url TEXT", + "bugtracker_url TEXT", + "donation_url TEXT", + "help_url TEXT", + "vcs_url TEXT", + "keywords TEXT", + "mime_types TEXT", + "content_rating TEXT", + "project_group TEXT", + "release_history TEXT", + "desktop_actions TEXT", + "has_signature INTEGER NOT NULL DEFAULT 0", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col); + match self.conn.execute(&sql, []) { + Ok(_) => {} + Err(e) => { + let msg = e.to_string(); + if !msg.contains("duplicate column") { + return Err(e); + } + } + } + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![9], + )?; + Ok(()) + } + pub fn upsert_appimage( &self, path: &str, @@ -747,6 +806,68 @@ impl Database { Ok(()) } + pub fn update_appstream_metadata( + &self, + id: i64, + appstream_id: Option<&str>, + appstream_description: Option<&str>, + generic_name: Option<&str>, + license: Option<&str>, + homepage_url: Option<&str>, + bugtracker_url: Option<&str>, + donation_url: Option<&str>, + help_url: Option<&str>, + vcs_url: Option<&str>, + keywords: Option<&str>, + mime_types: Option<&str>, + content_rating: Option<&str>, + project_group: Option<&str>, + release_history: Option<&str>, + desktop_actions: Option<&str>, + has_signature: bool, + ) -> SqlResult<()> { + self.conn.execute( + "UPDATE appimages SET + appstream_id = ?2, + appstream_description = ?3, + generic_name = ?4, + license = ?5, + homepage_url = ?6, + bugtracker_url = ?7, + donation_url = ?8, + help_url = ?9, + vcs_url = ?10, + keywords = ?11, + mime_types = ?12, + content_rating = ?13, + project_group = ?14, + release_history = ?15, + desktop_actions = ?16, + has_signature = ?17 + WHERE id = ?1", + params![ + id, + appstream_id, + appstream_description, + generic_name, + license, + homepage_url, + bugtracker_url, + donation_url, + help_url, + vcs_url, + keywords, + mime_types, + content_rating, + project_group, + release_history, + desktop_actions, + has_signature, + ], + )?; + Ok(()) + } + pub fn update_sha256(&self, id: i64, sha256: &str) -> SqlResult<()> { self.conn.execute( "UPDATE appimages SET sha256 = ?2 WHERE id = ?1", @@ -782,7 +903,11 @@ impl Database { fuse_status, wayland_status, update_info, update_type, latest_version, update_checked, update_url, notes, sandbox_mode, runtime_wayland_status, runtime_wayland_checked, analysis_status, - launch_args, tags, pinned, avg_startup_ms"; + launch_args, tags, pinned, avg_startup_ms, + appstream_id, appstream_description, generic_name, license, + homepage_url, bugtracker_url, donation_url, help_url, vcs_url, + keywords, mime_types, content_rating, project_group, + release_history, desktop_actions, has_signature"; fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result { Ok(AppImageRecord { @@ -823,6 +948,22 @@ impl Database { tags: row.get(34).unwrap_or(None), pinned: row.get::<_, bool>(35).unwrap_or(false), avg_startup_ms: row.get(36).unwrap_or(None), + appstream_id: row.get(37).unwrap_or(None), + appstream_description: row.get(38).unwrap_or(None), + generic_name: row.get(39).unwrap_or(None), + license: row.get(40).unwrap_or(None), + homepage_url: row.get(41).unwrap_or(None), + bugtracker_url: row.get(42).unwrap_or(None), + donation_url: row.get(43).unwrap_or(None), + help_url: row.get(44).unwrap_or(None), + vcs_url: row.get(45).unwrap_or(None), + keywords: row.get(46).unwrap_or(None), + mime_types: row.get(47).unwrap_or(None), + content_rating: row.get(48).unwrap_or(None), + project_group: row.get(49).unwrap_or(None), + release_history: row.get(50).unwrap_or(None), + desktop_actions: row.get(51).unwrap_or(None), + has_signature: row.get::<_, bool>(52).unwrap_or(false), }) } @@ -1696,13 +1837,13 @@ mod tests { fn test_fresh_database_creates_at_latest_version() { let db = Database::open_in_memory().unwrap(); - // Verify schema_version is at the latest (8) + // Verify schema_version is at the latest (9) let version: i32 = db.conn.query_row( "SELECT version FROM schema_version LIMIT 1", [], |row| row.get(0), ).unwrap(); - assert_eq!(version, 8); + assert_eq!(version, 9); // All tables that should exist after the full v1-v7 migration chain let expected_tables = [ diff --git a/src/core/duplicates.rs b/src/core/duplicates.rs index 910ca49..61c085a 100644 --- a/src/core/duplicates.rs +++ b/src/core/duplicates.rs @@ -413,6 +413,22 @@ mod tests { tags: None, pinned: false, avg_startup_ms: None, + appstream_id: None, + appstream_description: None, + generic_name: None, + license: None, + homepage_url: None, + bugtracker_url: None, + donation_url: None, + help_url: None, + vcs_url: None, + keywords: None, + mime_types: None, + content_rating: None, + project_group: None, + release_history: None, + desktop_actions: None, + has_signature: false, }; assert_eq!( diff --git a/src/core/inspector.rs b/src/core/inspector.rs index 9e08e2f..75b1cdb 100644 --- a/src/core/inspector.rs +++ b/src/core/inspector.rs @@ -43,6 +43,23 @@ pub struct AppImageMetadata { pub desktop_entry_content: String, pub architecture: Option, pub cached_icon_path: Option, + // Extended metadata from AppStream XML and desktop entry + pub appstream_id: Option, + pub appstream_description: Option, + pub generic_name: Option, + pub license: Option, + pub homepage_url: Option, + pub bugtracker_url: Option, + pub donation_url: Option, + pub help_url: Option, + pub vcs_url: Option, + pub keywords: Vec, + pub mime_types: Vec, + pub content_rating: Option, + pub project_group: Option, + pub releases: Vec, + pub desktop_actions: Vec, + pub has_signature: bool, } #[derive(Debug, Default)] @@ -53,6 +70,12 @@ struct DesktopEntryFields { categories: Vec, exec: Option, version: Option, + generic_name: Option, + keywords: Vec, + mime_types: Vec, + terminal: bool, + x_appimage_name: Option, + actions: Vec, } 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 { + 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 { @@ -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 { None } +/// Find an AppStream metainfo XML file in the extract directory. +fn find_appstream_file(extract_dir: &Path) -> Option { + // 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 { 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, }) } diff --git a/src/core/integrator.rs b/src/core/integrator.rs index 47b87a3..4b1d92c 100644 --- a/src/core/integrator.rs +++ b/src/core/integrator.rs @@ -269,6 +269,22 @@ mod tests { tags: None, pinned: false, avg_startup_ms: None, + appstream_id: None, + appstream_description: None, + generic_name: None, + license: None, + homepage_url: None, + bugtracker_url: None, + donation_url: None, + help_url: None, + vcs_url: None, + keywords: None, + mime_types: None, + content_rating: None, + project_group: None, + release_history: None, + desktop_actions: None, + has_signature: false, }; // We can't easily test the full integrate() without mocking dirs, diff --git a/src/core/mod.rs b/src/core/mod.rs index 507ec58..3de4e76 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod analysis; +pub mod appstream; pub mod backup; pub mod database; pub mod discovery; diff --git a/src/core/wayland.rs b/src/core/wayland.rs index ece7ed2..35acb3b 100644 --- a/src/core/wayland.rs +++ b/src/core/wayland.rs @@ -130,17 +130,11 @@ pub fn analyze_appimage(appimage_path: &Path) -> WaylandAnalysis { /// List shared libraries bundled inside the AppImage squashfs. fn list_bundled_libraries(appimage_path: &Path) -> Vec { - // First get the squashfs offset - let offset_output = Command::new(appimage_path) - .arg("--appimage-offset") - .env("APPIMAGE_EXTRACT_AND_RUN", "1") - .output(); - - let offset = match offset_output { - Ok(out) if out.status.success() => { - String::from_utf8_lossy(&out.stdout).trim().to_string() - } - _ => return Vec::new(), + // Get the squashfs offset using binary scan (never execute the AppImage - + // some apps like Affinity have custom AppRun scripts that ignore flags) + let offset = match crate::core::inspector::find_squashfs_offset_for(appimage_path) { + Some(o) => o.to_string(), + None => return Vec::new(), }; // Use unsquashfs to list files (just filenames, no extraction) diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 9ed47be..7292b84 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -257,7 +257,8 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box { } // --------------------------------------------------------------------------- -// Tab 1: Overview - updates, usage, basic file info +// Tab 1: Overview - about, description, links, updates, releases, usage, +// capabilities, file info // --------------------------------------------------------------------------- fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { @@ -280,7 +281,151 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { .spacing(24) .build(); + // ----------------------------------------------------------------------- + // About section + // ----------------------------------------------------------------------- + let has_about_data = record.appstream_id.is_some() + || record.generic_name.is_some() + || record.developer.is_some() + || record.license.is_some() + || record.project_group.is_some(); + + if has_about_data { + let about_group = adw::PreferencesGroup::builder() + .title("About") + .build(); + + if let Some(ref id) = record.appstream_id { + let row = adw::ActionRow::builder() + .title("App ID") + .subtitle(id) + .subtitle_selectable(true) + .build(); + about_group.add(&row); + } + + if let Some(ref gn) = record.generic_name { + let row = adw::ActionRow::builder() + .title("Type") + .subtitle(gn) + .build(); + about_group.add(&row); + } + + if let Some(ref dev) = record.developer { + let row = adw::ActionRow::builder() + .title("Developer") + .subtitle(dev) + .build(); + about_group.add(&row); + } + + if let Some(ref lic) = record.license { + let row = adw::ActionRow::builder() + .title("License") + .subtitle(lic) + .tooltip_text("SPDX license identifier for this application") + .build(); + about_group.add(&row); + } + + if let Some(ref pg) = record.project_group { + let row = adw::ActionRow::builder() + .title("Project group") + .subtitle(pg) + .build(); + about_group.add(&row); + } + + inner.append(&about_group); + } + + // ----------------------------------------------------------------------- + // Description section + // ----------------------------------------------------------------------- + if let Some(ref desc) = record.appstream_description { + if !desc.is_empty() { + let desc_group = adw::PreferencesGroup::builder() + .title("Description") + .build(); + + let label = gtk::Label::builder() + .label(desc) + .wrap(true) + .xalign(0.0) + .css_classes(["body"]) + .selectable(true) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + desc_group.add(&label); + + inner.append(&desc_group); + } + } + + // ----------------------------------------------------------------------- + // Links section + // ----------------------------------------------------------------------- + let has_links = record.homepage_url.is_some() + || record.bugtracker_url.is_some() + || record.donation_url.is_some() + || record.help_url.is_some() + || record.vcs_url.is_some(); + + if has_links { + let links_group = adw::PreferencesGroup::builder() + .title("Links") + .build(); + + let link_entries: &[(&str, &str, &Option)] = &[ + ("Homepage", "web-browser-symbolic", &record.homepage_url), + ("Bug tracker", "bug-symbolic", &record.bugtracker_url), + ("Source code", "code-symbolic", &record.vcs_url), + ("Documentation", "help-browser-symbolic", &record.help_url), + ("Donate", "emblem-favorite-symbolic", &record.donation_url), + ]; + + for (title, icon_name, url_opt) in link_entries { + if let Some(ref url) = url_opt { + let row = adw::ActionRow::builder() + .title(*title) + .subtitle(url) + .activatable(true) + .build(); + + let icon = gtk::Image::from_icon_name("external-link-symbolic"); + icon.set_valign(gtk::Align::Center); + row.add_suffix(&icon); + + let prefix_icon = gtk::Image::from_icon_name(*icon_name); + prefix_icon.set_valign(gtk::Align::Center); + row.add_prefix(&prefix_icon); + + let url_clone = url.clone(); + row.connect_activated(move |row| { + let launcher = gtk::UriLauncher::new(&url_clone); + let window = row + .root() + .and_then(|r| r.downcast::().ok()); + launcher.launch( + window.as_ref(), + None::<>k::gio::Cancellable>, + |_| {}, + ); + }); + links_group.add(&row); + } + } + + inner.append(&links_group); + } + + // ----------------------------------------------------------------------- // Updates section + // ----------------------------------------------------------------------- let updates_group = adw::PreferencesGroup::builder() .title("Updates") .description("Keep this app up to date by checking for new versions.") @@ -364,7 +509,71 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { } inner.append(&updates_group); + // ----------------------------------------------------------------------- + // Release History section + // ----------------------------------------------------------------------- + if let Some(ref release_json) = record.release_history { + if let Ok(releases) = serde_json::from_str::>(release_json) { + if !releases.is_empty() { + let release_group = adw::PreferencesGroup::builder() + .title("Release History") + .description("Recent versions of this application.") + .build(); + + for release in releases.iter().take(10) { + let version = release + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let date = release + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let desc = release.get("description").and_then(|v| v.as_str()); + + let title = if date.is_empty() { + format!("v{}", version) + } else { + format!("v{} - {}", version, date) + }; + + if let Some(desc_text) = desc { + let row = adw::ExpanderRow::builder() + .title(&title) + .subtitle("Click to see changes") + .build(); + + let label = gtk::Label::builder() + .label(desc_text) + .wrap(true) + .xalign(0.0) + .css_classes(["body"]) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + let label_row = adw::ActionRow::new(); + label_row.set_child(Some(&label)); + row.add_row(&label_row); + + release_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title(&title) + .build(); + release_group.add(&row); + } + } + + inner.append(&release_group); + } + } + } + + // ----------------------------------------------------------------------- // Usage section + // ----------------------------------------------------------------------- let usage_group = adw::PreferencesGroup::builder() .title("Usage") .build(); @@ -386,7 +595,72 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { } inner.append(&usage_group); - // File info section + // ----------------------------------------------------------------------- + // Capabilities section + // ----------------------------------------------------------------------- + let has_capabilities = record.keywords.is_some() + || record.mime_types.is_some() + || record.content_rating.is_some() + || record.desktop_actions.is_some(); + + if has_capabilities { + let cap_group = adw::PreferencesGroup::builder() + .title("Capabilities") + .build(); + + if let Some(ref kw) = record.keywords { + if !kw.is_empty() { + let row = adw::ActionRow::builder() + .title("Keywords") + .subtitle(kw) + .build(); + cap_group.add(&row); + } + } + + if let Some(ref mt) = record.mime_types { + if !mt.is_empty() { + let row = adw::ActionRow::builder() + .title("Supported file types") + .subtitle(mt) + .build(); + cap_group.add(&row); + } + } + + if let Some(ref cr) = record.content_rating { + let row = adw::ActionRow::builder() + .title("Content rating") + .subtitle(cr) + .tooltip_text( + "Content rating based on the OARS (Open Age Ratings Service) system", + ) + .build(); + cap_group.add(&row); + } + + if let Some(ref actions_json) = record.desktop_actions { + if let Ok(actions) = serde_json::from_str::>(actions_json) { + if !actions.is_empty() { + let row = adw::ActionRow::builder() + .title("Desktop actions") + .subtitle(&actions.join(", ")) + .tooltip_text( + "Additional actions available from the right-click menu \ + when this app is integrated into the desktop", + ) + .build(); + cap_group.add(&row); + } + } + } + + inner.append(&cap_group); + } + + // ----------------------------------------------------------------------- + // File Information section + // ----------------------------------------------------------------------- let info_group = adw::PreferencesGroup::builder() .title("File Information") .build(); @@ -418,6 +692,28 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { .build(); info_group.add(&exec_row); + // Digital signature status + let sig_row = adw::ActionRow::builder() + .title("Digital signature") + .subtitle(if record.has_signature { + "This AppImage contains a GPG signature" + } else { + "Not signed" + }) + .tooltip_text( + "AppImages can be digitally signed by their author using GPG. \ + A signature helps verify that the file hasn't been tampered with." + ) + .build(); + let sig_badge = if record.has_signature { + widgets::status_badge("Signed", "success") + } else { + widgets::status_badge("Unsigned", "neutral") + }; + sig_badge.set_valign(gtk::Align::Center); + sig_row.add_suffix(&sig_badge); + info_group.add(&sig_row); + let seen_row = adw::ActionRow::builder() .title("First seen") .subtitle(&record.first_seen) diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index 503077e..b2a4c38 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -342,9 +342,13 @@ impl LibraryView { let view_mode_d = view_mode_ref.clone(); let search_empty_d = search_empty_ref.clone(); + let debounce_clear = debounce_source.clone(); let source_id = glib::timeout_add_local_once( std::time::Duration::from_millis(150), move || { + // Clear the stored SourceId so nobody tries to remove a fired timer + debounce_clear.set(None); + let recs = records_d.borrow(); let match_flags: Vec = recs .iter() diff --git a/src/window.rs b/src/window.rs index ca8f335..f56cde0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -876,7 +876,12 @@ impl DriftwoodWindow { let mtime_unchanged = modified.as_deref() == ex.file_modified.as_deref(); let analysis_done = ex.analysis_status.as_deref() == Some("complete"); let has_icon = ex.icon_path.is_some(); - if size_unchanged && mtime_unchanged && analysis_done && has_icon { + // Also re-analyze if AppStream metadata was never extracted + // (covers upgrades from older schema versions) + let has_appstream = ex.appstream_id.is_some() + || ex.generic_name.is_some() + || ex.has_signature; + if size_unchanged && mtime_unchanged && analysis_done && has_icon && has_appstream { skipped_count += 1; continue; } @@ -965,9 +970,13 @@ impl DriftwoodWindow { } let window_weak4 = window_weak3.clone(); + let refresh_timer_clear = refresh_timer.clone(); let timer_id = glib::timeout_add_local_once( std::time::Duration::from_millis(300), move || { + // Clear the stored SourceId so nobody tries to remove a fired timer + refresh_timer_clear.set(None); + if let Some(window) = window_weak4.upgrade() { let db = window.database(); let lib_view = window.imp().library_view.get().unwrap();