From 39b773fed54721ee3566c4fdd6fcb2dff983433b Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 18:12:33 +0200 Subject: [PATCH] Add implementation plan for comprehensive AppImage metadata extraction Seven tasks covering: quick-xml dependency, AppStream XML parser, DB migration v9, extended desktop entry parsing, analysis pipeline updates, and redesigned overview tab with 8 metadata groups. --- ...-02-27-appimage-metadata-implementation.md | 1723 +++++++++++++++++ 1 file changed, 1723 insertions(+) create mode 100644 docs/plans/2026-02-27-appimage-metadata-implementation.md diff --git a/docs/plans/2026-02-27-appimage-metadata-implementation.md b/docs/plans/2026-02-27-appimage-metadata-implementation.md new file mode 100644 index 0000000..3a82771 --- /dev/null +++ b/docs/plans/2026-02-27-appimage-metadata-implementation.md @@ -0,0 +1,1723 @@ +# AppImage Comprehensive Metadata Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extract all available metadata from AppImage files (AppStream XML, extended desktop entry fields, ELF signature detection) and display it comprehensively in the overview tab. + +**Architecture:** Parse AppStream XML and extended desktop entry fields during background analysis, store everything in the database (migration v9 with 16 new columns), and render a redesigned overview tab with 8 groups that gracefully degrade when data is missing. + +**Tech Stack:** Rust, quick-xml 0.37, GTK4/libadwaita, SQLite (rusqlite) + +--- + +### Task 1: Add quick-xml dependency + +**Files:** +- Modify: `Cargo.toml` + +**Step 1: Add quick-xml to dependencies** + +In `Cargo.toml`, add after the existing `tempfile` dependency: + +```toml +quick-xml = "0.37" +``` + +**Step 2: Verify it compiles** + +Run: `cargo check` +Expected: Success with existing warnings only + +**Step 3: Commit** + +```bash +git add Cargo.toml +git commit -m "Add quick-xml dependency for AppStream XML parsing" +``` + +--- + +### Task 2: Create AppStream XML parser module + +**Files:** +- Create: `src/core/appstream.rs` +- Modify: `src/core/mod.rs` (line 1, add `pub mod appstream;`) + +**Step 1: Create the appstream module** + +Create `src/core/appstream.rs` with the full parser: + +```rust +use std::collections::HashMap; +use std::path::Path; + +use quick_xml::events::Event; +use quick_xml::Reader; + +#[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. +/// +/// Returns None if the file cannot be read or parsed. +/// Handles both `*.appdata.xml` and `*.metainfo.xml` files. +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(); + + // State tracking + 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 => { + // Legacy tag (deprecated but common) + 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 => { + // Only capture unlocalized (no xml:lang attribute) + 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" => { + // Modern ... + meta.developer = Some(text); + } + _ => {} + } + } + Ok(Event::Empty(ref e)) => { + let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + // Handle self-closing tags like + 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(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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.urls.get("donation").map(|s| s.as_str()), Some("https://krita.org/support-us/donations")); + 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_summary() { + 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" + ); + } +} +``` + +**Step 2: Wire the module in mod.rs** + +In `src/core/mod.rs`, add at line 1 (alphabetical order): + +```rust +pub mod appstream; +``` + +**Step 3: Verify compilation and tests** + +Run: `cargo test core::appstream` +Expected: All 5 tests pass + +**Step 4: Commit** + +```bash +git add src/core/appstream.rs src/core/mod.rs +git commit -m "Add AppStream XML parser module with comprehensive metadata extraction" +``` + +--- + +### Task 3: Database migration v9 - new metadata columns + +**Files:** +- Modify: `src/core/database.rs` + +**Step 1: Add the migration function** + +After the existing `migrate_to_v8()` function (around line 680), add: + +```rust +fn migrate_to_v9(conn: &Connection) -> 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 conn.execute(&sql, []) { + Ok(_) => {} + Err(e) => { + let msg = e.to_string(); + if !msg.contains("duplicate column") { + return Err(e); + } + } + } + } + conn.execute("UPDATE schema_version SET version = 9", [])?; + Ok(()) +} +``` + +**Step 2: Call migrate_to_v9 in the migration chain** + +In the `ensure_schema()` method, after the `migrate_to_v8` call, add: + +```rust +if version < 9 { + migrate_to_v9(&self.conn)?; +} +``` + +**Step 3: Update APPIMAGE_COLUMNS constant** + +Replace the existing constant (line ~776) with: + +```rust +const APPIMAGE_COLUMNS: &str = + "id, path, filename, app_name, app_version, appimage_type, \ + size_bytes, sha256, icon_path, desktop_file, integrated, \ + integrated_at, is_executable, desktop_entry_content, \ + categories, description, developer, architecture, \ + first_seen, last_scanned, file_modified, \ + 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, \ + 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"; +``` + +**Step 4: Add fields to AppImageRecord struct** + +After the existing `avg_startup_ms` field (line ~52), add: + +```rust + // 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, +``` + +**Step 5: Update row_to_record() to read new columns** + +After the existing `avg_startup_ms` mapping (index 36), add mappings for indices 37-52: + +```rust + 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), +``` + +**Step 6: Add update_appstream_metadata() method** + +Add a new method to the `impl Database` block: + +```rust + 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(()) + } +``` + +**Step 7: Verify compilation** + +Run: `cargo check` +Expected: Success (warnings about unused fields are OK for now) + +**Step 8: Commit** + +```bash +git add src/core/database.rs +git commit -m "Add database migration v9 with 16 new metadata columns" +``` + +--- + +### Task 4: Extend desktop entry parser and inspector metadata struct + +**Files:** +- Modify: `src/core/inspector.rs` + +**Step 1: Extend DesktopEntryFields struct** + +Replace the existing `DesktopEntryFields` struct (line ~48) with: + +```rust +#[derive(Debug, Default)] +struct DesktopEntryFields { + name: Option, + icon: Option, + comment: Option, + categories: Vec, + exec: Option, + version: Option, + generic_name: Option, + keywords: Vec, + mime_types: Vec, + terminal: bool, + x_appimage_name: Option, + actions: Vec, +} +``` + +**Step 2: Extend parse_desktop_entry() to capture new fields** + +In `parse_desktop_entry()`, add new cases in the match block (after the existing `"X-AppImage-Version"` case around line 276): + +```rust + "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(); + } +``` + +**Step 3: Extend AppImageMetadata struct** + +Replace the existing struct (line ~36) with: + +```rust +#[derive(Debug, Clone, Default)] +pub struct AppImageMetadata { + pub app_name: Option, + pub app_version: Option, + pub description: Option, + pub developer: Option, + pub icon_name: Option, + pub categories: Vec, + 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, +} +``` + +**Step 4: Add signature detection function** + +Add this function after `detect_architecture()`: + +```rust +/// Check if an AppImage has a GPG signature in its ELF sections. +/// Reads the ELF section headers to find .sha256_sig section. +fn detect_signature(path: &Path) -> bool { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => return false, + }; + // Simple check: look for the section name ".sha256_sig" in the binary + // and verify there's non-zero content nearby + let needle = b".sha256_sig"; + for window in data.windows(needle.len()) { + if window == needle { + return true; + } + } + false +} +``` + +**Step 5: Add AppStream file finder** + +Add this function after `find_icon_recursive()`: + +```rust +/// 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) = std::fs::read_dir(&metainfo_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ext == "xml" { + return Some(path); + } + } + } + } + // Check legacy path + let appdata_dir = extract_dir.join("usr/share/appdata"); + if let Ok(entries) = std::fs::read_dir(&appdata_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ext == "xml" { + return Some(path); + } + } + } + } + None +} +``` + +**Step 6: Update inspect_appimage() to use AppStream and extended fields** + +In `inspect_appimage()`, after the desktop entry parsing section (around line 500), before the final `Ok(AppImageMetadata { ... })`, add AppStream parsing and merge logic. Replace the final return block with: + +```rust + // 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 + .version + .or_else(|| extract_version_from_filename(filename)); + + // Find and cache icon + let icon = find_icon(&extract_dir, fields.icon.as_deref()); + let app_id = make_app_id( + final_name.as_deref().unwrap_or( + filename + .strip_suffix(".AppImage") + .unwrap_or(filename), + ), + ); + let cached_icon = icon.and_then(|icon_path| cache_icon(&icon_path, &app_id)); + + // Collect keywords: merge desktop entry and AppStream + 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()); + } + } + } + + // Collect MIME types: merge desktop entry and AppStream + 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()); + } + } + } + + // Check for signature + let has_sig = detect_signature(path); + + Ok(AppImageMetadata { + app_name: final_name, + app_version: version, + description: final_description, + developer: final_developer, + icon_name: fields.icon, + 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, + }) +``` + +Note: This replaces the existing final section of `inspect_appimage()` starting from the "Determine version" comment through the final `Ok(...)`. + +**Step 7: Update tests** + +Update the existing `test_parse_desktop_entry` test to verify new fields: + +```rust + #[test] + fn test_parse_desktop_entry_extended() { + let content = "[Desktop Entry] +Type=Application +Name=Test App +GenericName=Testing Tool +Icon=test-icon +Comment=A test application +Categories=Utility;Development; +Exec=test %U +X-AppImage-Version=1.2.3 +Keywords=test;debug; +MimeType=text/plain;application/json; +Terminal=false +Actions=NewWindow;Quit; + +[Desktop Action NewWindow] +Name=New Window +Exec=test --new-window + +[Desktop Action Quit] +Name=Quit +Exec=test --quit +"; + let fields = parse_desktop_entry(content); + assert_eq!(fields.generic_name.as_deref(), Some("Testing Tool")); + assert_eq!(fields.keywords, vec!["test", "debug"]); + assert_eq!(fields.mime_types, vec!["text/plain", "application/json"]); + assert!(!fields.terminal); + assert_eq!(fields.actions, vec!["NewWindow", "Quit"]); + } +``` + +**Step 8: Verify compilation and tests** + +Run: `cargo test inspector` +Expected: All tests pass + +**Step 9: Commit** + +```bash +git add src/core/inspector.rs +git commit -m "Extend inspector with AppStream XML parsing and comprehensive metadata extraction" +``` + +--- + +### Task 5: Update analysis pipeline to store new metadata + +**Files:** +- Modify: `src/core/analysis.rs` + +**Step 1: Update run_background_analysis() to store extended metadata** + +In `run_background_analysis()`, after the existing metadata update block (around line 86), add the AppStream metadata storage. Replace the existing `if let Ok(meta) = inspector::inspect_appimage(...)` block with: + +```rust + // 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 + } else { + Some(meta.categories.join(";")) + }; + if let Err(e) = db.update_metadata( + id, + meta.app_name.as_deref(), + meta.app_version.as_deref(), + meta.description.as_deref(), + meta.developer.as_deref(), + categories.as_deref(), + meta.architecture.as_deref(), + meta.cached_icon_path + .as_ref() + .map(|p| p.to_string_lossy()) + .as_deref(), + Some(&meta.desktop_entry_content), + ) { + 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); + } + } +``` + +**Step 2: Add serde_json import** + +At the top of `analysis.rs`, add: + +```rust +use serde_json; +``` + +**Step 3: Verify compilation** + +Run: `cargo check` +Expected: Success + +**Step 4: Commit** + +```bash +git add src/core/analysis.rs +git commit -m "Store comprehensive AppStream and desktop entry metadata during analysis" +``` + +--- + +### Task 6: Redesign overview tab with all metadata groups + +**Files:** +- Modify: `src/ui/detail_view.rs` + +**Step 1: Replace build_overview_tab() function** + +Replace the entire `build_overview_tab()` function with the new version that includes all 8 groups. The function starts at line 263 and ends at line 447. + +```rust +fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { + let tab = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(18) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .build(); + + // ----------------------------------------------------------------------- + // About section (new - shows identity and provenance) + // ----------------------------------------------------------------------- + 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 (new - full AppStream description) + // ----------------------------------------------------------------------- + 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 (new - clickable URLs) + // ----------------------------------------------------------------------- + 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", "heart-filled-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 (existing - unchanged) + // ----------------------------------------------------------------------- + let updates_group = adw::PreferencesGroup::builder() + .title("Updates") + .description("Keep this app up to date by checking for new versions.") + .build(); + + if let Some(ref update_type) = record.update_type { + let display_label = updater::parse_update_info(update_type) + .map(|ut| ut.type_label_display()) + .unwrap_or("Unknown format"); + let row = adw::ActionRow::builder() + .title("Update method") + .subtitle(&format!( + "This app checks for updates using: {}", + display_label + )) + .tooltip_text( + "AppImages can include built-in update information that tells Driftwood \ + where to check for newer versions. Common methods include GitHub releases, \ + zsync (efficient delta updates), and direct download URLs." + ) + .build(); + updates_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title("Update method") + .subtitle( + "This app does not include update information. \ + You will need to check for new versions manually." + ) + .tooltip_text( + "AppImages can include built-in update information that tells Driftwood \ + where to check for newer versions. This one doesn't have any, so you'll \ + need to download updates yourself from wherever you got the app." + ) + .build(); + let badge = widgets::status_badge("Manual only", "neutral"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + updates_group.add(&row); + } + + if let Some(ref latest) = record.latest_version { + let is_newer = record + .app_version + .as_deref() + .map(|current| crate::core::updater::version_is_newer(latest, current)) + .unwrap_or(true); + + if is_newer { + let subtitle = format!( + "A newer version is available: {} (you have {})", + latest, + record.app_version.as_deref().unwrap_or("unknown"), + ); + let row = adw::ActionRow::builder() + .title("Update available") + .subtitle(&subtitle) + .build(); + let badge = widgets::status_badge("Update", "info"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + updates_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title("Version status") + .subtitle("You are running the latest version.") + .build(); + let badge = widgets::status_badge("Latest", "success"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + updates_group.add(&row); + } + } + + if let Some(ref checked) = record.update_checked { + let row = adw::ActionRow::builder() + .title("Last checked") + .subtitle(checked) + .build(); + updates_group.add(&row); + } + inner.append(&updates_group); + + // ----------------------------------------------------------------------- + // Release History section (new) + // ----------------------------------------------------------------------- + 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 (existing - unchanged) + // ----------------------------------------------------------------------- + let usage_group = adw::PreferencesGroup::builder() + .title("Usage") + .build(); + + let stats = launcher::get_launch_stats(db, record.id); + + let launches_row = adw::ActionRow::builder() + .title("Total launches") + .subtitle(&stats.total_launches.to_string()) + .build(); + usage_group.add(&launches_row); + + if let Some(ref last) = stats.last_launched { + let row = adw::ActionRow::builder() + .title("Last launched") + .subtitle(last) + .build(); + usage_group.add(&row); + } + inner.append(&usage_group); + + // ----------------------------------------------------------------------- + // Capabilities section (new - keywords, MIME types, content rating, actions) + // ----------------------------------------------------------------------- + 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 info section (existing - extended with signature) + // ----------------------------------------------------------------------- + let info_group = adw::PreferencesGroup::builder() + .title("File Information") + .build(); + + let type_str = match record.appimage_type { + Some(1) => "Type 1 (ISO 9660) - older format, still widely supported", + Some(2) => "Type 2 (SquashFS) - modern format, most common today", + _ => "Unknown type", + }; + let type_row = adw::ActionRow::builder() + .title("AppImage format") + .subtitle(type_str) + .tooltip_text( + "AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \ + (older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \ + files). Type 2 is the standard today and is what most AppImage tools \ + produce." + ) + .build(); + info_group.add(&type_row); + + let exec_row = adw::ActionRow::builder() + .title("Executable") + .subtitle(if record.is_executable { + "Yes - this file has execute permission" + } else { + "No - execute permission is missing. It will be set automatically when launched." + }) + .build(); + info_group.add(&exec_row); + + // 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) + .build(); + info_group.add(&seen_row); + + let scanned_row = adw::ActionRow::builder() + .title("Last scanned") + .subtitle(&record.last_scanned) + .build(); + info_group.add(&scanned_row); + + if let Some(ref notes) = record.notes { + if !notes.is_empty() { + let row = adw::ActionRow::builder() + .title("Notes") + .subtitle(notes) + .build(); + info_group.add(&row); + } + } + inner.append(&info_group); + + clamp.set_child(Some(&inner)); + tab.append(&clamp); + tab +} +``` + +**Step 2: Add serde_json import at top of file** + +Add at the top of `detail_view.rs`: + +```rust +use serde_json; +``` + +**Step 3: Verify compilation** + +Run: `cargo check` +Expected: Success + +**Step 4: Commit** + +```bash +git add src/ui/detail_view.rs +git commit -m "Redesign overview tab with About, Description, Links, Release History, and Capabilities sections" +``` + +--- + +### Task 7: Build, test, and verify + +**Step 1: Full build** + +Run: `cargo build 2>&1` +Expected: Compiles with zero errors (existing warnings OK) + +**Step 2: Run tests** + +Run: `cargo test` +Expected: All tests pass including new AppStream parser tests + +**Step 3: Manual verification** + +Run: `cargo run` +Expected: +- App launches without crashes +- Click on an AppImage - overview tab shows new sections if data is available +- AppImages without AppStream XML gracefully show only the existing sections +- URL links are clickable and open in browser +- Release history entries expand when clicked + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "Comprehensive AppImage metadata extraction and display" +```