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.
1724 lines
59 KiB
Markdown
1724 lines
59 KiB
Markdown
# 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<String>,
|
|
pub name: Option<String>,
|
|
pub summary: Option<String>,
|
|
pub description: Option<String>,
|
|
pub developer: Option<String>,
|
|
pub project_license: Option<String>,
|
|
pub project_group: Option<String>,
|
|
pub urls: HashMap<String, String>,
|
|
pub keywords: Vec<String>,
|
|
pub categories: Vec<String>,
|
|
pub content_rating_summary: Option<String>,
|
|
pub releases: Vec<ReleaseInfo>,
|
|
pub mime_types: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ReleaseInfo {
|
|
pub version: String,
|
|
pub date: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// Parse an AppStream metainfo XML file and extract all metadata.
|
|
///
|
|
/// 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<AppStreamMetadata> {
|
|
let content = std::fs::read_to_string(path).ok()?;
|
|
parse_appstream_xml(&content)
|
|
}
|
|
|
|
/// Parse AppStream XML from a string.
|
|
pub fn parse_appstream_xml(xml: &str) -> Option<AppStreamMetadata> {
|
|
let mut reader = Reader::from_str(xml);
|
|
let mut meta = AppStreamMetadata::default();
|
|
let mut buf = Vec::new();
|
|
|
|
// 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<String> = Vec::new();
|
|
let mut release_desc_parts: Vec<String> = Vec::new();
|
|
let mut current_url_type = String::new();
|
|
let mut current_release_version = String::new();
|
|
let mut current_release_date = String::new();
|
|
let mut content_rating_attrs: Vec<(String, String)> = Vec::new();
|
|
let mut in_content_rating = false;
|
|
let mut current_content_attr_id = String::new();
|
|
let mut in_developer = false;
|
|
let mut depth = 0u32;
|
|
let mut description_depth = 0u32;
|
|
let mut release_desc_depth = 0u32;
|
|
|
|
loop {
|
|
match reader.read_event_into(&mut buf) {
|
|
Ok(Event::Start(ref e)) => {
|
|
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
depth += 1;
|
|
|
|
match tag_name.as_str() {
|
|
"component" => {
|
|
in_component = true;
|
|
}
|
|
"description" if in_component && !in_release => {
|
|
in_description = true;
|
|
description_depth = depth;
|
|
description_parts.clear();
|
|
}
|
|
"description" if in_release => {
|
|
in_release_description = true;
|
|
release_desc_depth = depth;
|
|
release_desc_parts.clear();
|
|
}
|
|
"p" if in_description && !in_release_description => {
|
|
current_tag = "description_p".to_string();
|
|
}
|
|
"li" if in_description && !in_release_description => {
|
|
current_tag = "description_li".to_string();
|
|
}
|
|
"p" if in_release_description => {
|
|
current_tag = "release_desc_p".to_string();
|
|
}
|
|
"li" if in_release_description => {
|
|
current_tag = "release_desc_li".to_string();
|
|
}
|
|
"url" if in_component => {
|
|
current_url_type = String::new();
|
|
for attr in e.attributes().flatten() {
|
|
if attr.key.as_ref() == b"type" {
|
|
current_url_type =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
}
|
|
current_tag = "url".to_string();
|
|
}
|
|
"release" if in_component => {
|
|
in_release = true;
|
|
current_release_version.clear();
|
|
current_release_date.clear();
|
|
release_desc_parts.clear();
|
|
for attr in e.attributes().flatten() {
|
|
match attr.key.as_ref() {
|
|
b"version" => {
|
|
current_release_version =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
b"date" => {
|
|
current_release_date =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
"content_rating" if in_component => {
|
|
in_content_rating = true;
|
|
content_rating_attrs.clear();
|
|
}
|
|
"content_attribute" if in_content_rating => {
|
|
current_content_attr_id.clear();
|
|
for attr in e.attributes().flatten() {
|
|
if attr.key.as_ref() == b"id" {
|
|
current_content_attr_id =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
}
|
|
current_tag = "content_attribute".to_string();
|
|
}
|
|
"provides" if in_component => {
|
|
in_provides = true;
|
|
}
|
|
"mediatype" if in_provides => {
|
|
current_tag = "mediatype".to_string();
|
|
}
|
|
"keywords" if in_component => {
|
|
in_keywords = true;
|
|
}
|
|
"keyword" if in_keywords => {
|
|
current_tag = "keyword".to_string();
|
|
}
|
|
"categories" if in_component => {
|
|
in_categories = true;
|
|
}
|
|
"category" if in_categories => {
|
|
current_tag = "category".to_string();
|
|
}
|
|
"developer" if in_component => {
|
|
in_developer = true;
|
|
}
|
|
"developer_name" if in_component && !in_developer => {
|
|
// Legacy <developer_name> 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 <name> (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 <developer><name>...</name></developer>
|
|
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 <release version="1.0" date="2025-01-01"/>
|
|
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#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<component type="desktop-application">
|
|
<id>org.example.TestApp</id>
|
|
<name>Test App</name>
|
|
<summary>A test application</summary>
|
|
<metadata_license>CC0-1.0</metadata_license>
|
|
<project_license>MIT</project_license>
|
|
</component>"#;
|
|
let meta = parse_appstream_xml(xml).unwrap();
|
|
assert_eq!(meta.id.as_deref(), Some("org.example.TestApp"));
|
|
assert_eq!(meta.name.as_deref(), Some("Test App"));
|
|
assert_eq!(meta.summary.as_deref(), Some("A test application"));
|
|
assert_eq!(meta.project_license.as_deref(), Some("MIT"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_full_appstream() {
|
|
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<component type="desktop-application">
|
|
<id>org.kde.krita</id>
|
|
<name>Krita</name>
|
|
<name xml:lang="de">Krita</name>
|
|
<summary>Digital Painting, Creative Freedom</summary>
|
|
<developer>
|
|
<name>KDE Community</name>
|
|
</developer>
|
|
<project_license>GPL-3.0-or-later</project_license>
|
|
<project_group>KDE</project_group>
|
|
<description>
|
|
<p>Krita is a creative painting application.</p>
|
|
<p>Features include:</p>
|
|
<ul>
|
|
<li>Layer support</li>
|
|
<li>Brush engines</li>
|
|
</ul>
|
|
</description>
|
|
<url type="homepage">https://krita.org</url>
|
|
<url type="bugtracker">https://bugs.kde.org</url>
|
|
<url type="donation">https://krita.org/support-us/donations</url>
|
|
<keywords>
|
|
<keyword>paint</keyword>
|
|
<keyword>draw</keyword>
|
|
</keywords>
|
|
<content_rating type="oars-1.1">
|
|
<content_attribute id="violence-cartoon">none</content_attribute>
|
|
<content_attribute id="language-humor">mild</content_attribute>
|
|
</content_rating>
|
|
<releases>
|
|
<release version="5.2.0" date="2024-01-15">
|
|
<description>
|
|
<p>Major update with new features.</p>
|
|
</description>
|
|
</release>
|
|
<release version="5.1.0" date="2023-09-01"/>
|
|
</releases>
|
|
<provides>
|
|
<mediatype>image/png</mediatype>
|
|
<mediatype>image/jpeg</mediatype>
|
|
</provides>
|
|
</component>"#;
|
|
let meta = parse_appstream_xml(xml).unwrap();
|
|
assert_eq!(meta.id.as_deref(), Some("org.kde.krita"));
|
|
assert_eq!(meta.name.as_deref(), Some("Krita"));
|
|
assert_eq!(meta.developer.as_deref(), Some("KDE Community"));
|
|
assert_eq!(meta.project_license.as_deref(), Some("GPL-3.0-or-later"));
|
|
assert_eq!(meta.project_group.as_deref(), Some("KDE"));
|
|
assert_eq!(meta.urls.get("homepage").map(|s| s.as_str()), Some("https://krita.org"));
|
|
assert_eq!(meta.urls.get("bugtracker").map(|s| s.as_str()), Some("https://bugs.kde.org"));
|
|
assert_eq!(meta.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#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<component type="desktop-application">
|
|
<id>org.test.App</id>
|
|
<name>Test</name>
|
|
<summary>Test app</summary>
|
|
<developer_name>Old Developer Tag</developer_name>
|
|
</component>"#;
|
|
let meta = parse_appstream_xml(xml).unwrap();
|
|
assert_eq!(meta.developer.as_deref(), Some("Old Developer Tag"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_empty_returns_none() {
|
|
let xml = r#"<?xml version="1.0"?><component></component>"#;
|
|
assert!(parse_appstream_xml(xml).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_rating_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<String>,
|
|
pub appstream_description: Option<String>,
|
|
pub generic_name: Option<String>,
|
|
pub license: Option<String>,
|
|
pub homepage_url: Option<String>,
|
|
pub bugtracker_url: Option<String>,
|
|
pub donation_url: Option<String>,
|
|
pub help_url: Option<String>,
|
|
pub vcs_url: Option<String>,
|
|
pub keywords: Option<String>,
|
|
pub mime_types: Option<String>,
|
|
pub content_rating: Option<String>,
|
|
pub project_group: Option<String>,
|
|
pub release_history: Option<String>,
|
|
pub desktop_actions: Option<String>,
|
|
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<String>,
|
|
icon: Option<String>,
|
|
comment: Option<String>,
|
|
categories: Vec<String>,
|
|
exec: Option<String>,
|
|
version: Option<String>,
|
|
generic_name: Option<String>,
|
|
keywords: Vec<String>,
|
|
mime_types: Vec<String>,
|
|
terminal: bool,
|
|
x_appimage_name: Option<String>,
|
|
actions: Vec<String>,
|
|
}
|
|
```
|
|
|
|
**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<String>,
|
|
pub app_version: Option<String>,
|
|
pub description: Option<String>,
|
|
pub developer: Option<String>,
|
|
pub icon_name: Option<String>,
|
|
pub categories: Vec<String>,
|
|
pub desktop_entry_content: String,
|
|
pub architecture: Option<String>,
|
|
pub cached_icon_path: Option<PathBuf>,
|
|
// Extended metadata from AppStream XML and desktop entry
|
|
pub appstream_id: Option<String>,
|
|
pub appstream_description: Option<String>,
|
|
pub generic_name: Option<String>,
|
|
pub license: Option<String>,
|
|
pub homepage_url: Option<String>,
|
|
pub bugtracker_url: Option<String>,
|
|
pub donation_url: Option<String>,
|
|
pub help_url: Option<String>,
|
|
pub vcs_url: Option<String>,
|
|
pub keywords: Vec<String>,
|
|
pub mime_types: Vec<String>,
|
|
pub content_rating: Option<String>,
|
|
pub project_group: Option<String>,
|
|
pub releases: Vec<crate::core::appstream::ReleaseInfo>,
|
|
pub desktop_actions: Vec<String>,
|
|
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<PathBuf> {
|
|
// 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<serde_json::Value> = 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<Database>) -> 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<String>)] = &[
|
|
("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::<gtk::Window>().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::<Vec<serde_json::Value>>(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::<Vec<String>>(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"
|
|
```
|