# 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" ```