Add comprehensive AppImage metadata extraction, display, and bug fixes
- Add AppStream XML parser (quick-xml) to extract rich metadata from bundled metainfo/appdata files: description, developer, license, URLs, keywords, categories, content rating, release history, and MIME types - Database migration v9: 16 new columns for extended metadata storage - Extended inspector to parse AppStream XML, desktop entry extended fields, and detect binary signatures without executing AppImages - Redesigned detail view overview tab with 8 conditional groups: About, Description, Links, Release History, Usage, Capabilities, File Info - Fix crash on exit caused by stale GLib SourceId removal in debounce timers - Fix wayland.rs executing AppImages directly to detect squashfs offset, replaced with safe binary scan via find_squashfs_offset_for() - Fix scan skipping re-analysis of apps missing new metadata fields
This commit is contained in:
@@ -61,7 +61,7 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy
|
||||
log::warn!("Failed to set analysis status to 'analyzing' for id {}: {}", id, e);
|
||||
}
|
||||
|
||||
// Inspect metadata (app name, version, icon, desktop entry, etc.)
|
||||
// Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.)
|
||||
if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) {
|
||||
let categories = if meta.categories.is_empty() {
|
||||
None
|
||||
@@ -84,6 +84,61 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy
|
||||
) {
|
||||
log::warn!("Failed to update metadata for id {}: {}", id, e);
|
||||
}
|
||||
|
||||
// Store extended metadata from AppStream XML and desktop entry
|
||||
let keywords = if meta.keywords.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(meta.keywords.join(","))
|
||||
};
|
||||
let mime_types = if meta.mime_types.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(meta.mime_types.join(";"))
|
||||
};
|
||||
let release_json = if meta.releases.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let releases: Vec<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);
|
||||
}
|
||||
}
|
||||
|
||||
// FUSE status
|
||||
|
||||
@@ -1,8 +1,388 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::Reader;
|
||||
|
||||
use super::database::Database;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStream metainfo XML parser - reads metadata FROM AppImages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppStreamMetadata {
|
||||
pub id: Option<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.
|
||||
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();
|
||||
|
||||
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 => {
|
||||
current_tag = "developer_name".to_string();
|
||||
}
|
||||
"name" if in_developer => {
|
||||
current_tag = "developer_child_name".to_string();
|
||||
}
|
||||
"id" if in_component && depth == 2 => {
|
||||
current_tag = "id".to_string();
|
||||
}
|
||||
"name" if in_component && !in_developer && depth == 2 => {
|
||||
let has_lang = e.attributes().flatten().any(|a| {
|
||||
a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang"
|
||||
});
|
||||
if !has_lang {
|
||||
current_tag = "name".to_string();
|
||||
}
|
||||
}
|
||||
"summary" if in_component && depth == 2 => {
|
||||
let has_lang = e.attributes().flatten().any(|a| {
|
||||
a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang"
|
||||
});
|
||||
if !has_lang {
|
||||
current_tag = "summary".to_string();
|
||||
}
|
||||
}
|
||||
"project_license" if in_component => {
|
||||
current_tag = "project_license".to_string();
|
||||
}
|
||||
"project_group" if in_component => {
|
||||
current_tag = "project_group".to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
|
||||
match tag_name.as_str() {
|
||||
"component" => {
|
||||
in_component = false;
|
||||
}
|
||||
"description"
|
||||
if in_release_description && depth == release_desc_depth =>
|
||||
{
|
||||
in_release_description = false;
|
||||
}
|
||||
"description" if in_description && depth == description_depth => {
|
||||
in_description = false;
|
||||
if !description_parts.is_empty() {
|
||||
meta.description = Some(description_parts.join("\n\n"));
|
||||
}
|
||||
}
|
||||
"release" => {
|
||||
if !current_release_version.is_empty()
|
||||
&& meta.releases.len() < 10
|
||||
{
|
||||
let desc = if release_desc_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(release_desc_parts.join("\n"))
|
||||
};
|
||||
meta.releases.push(ReleaseInfo {
|
||||
version: current_release_version.clone(),
|
||||
date: if current_release_date.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_release_date.clone())
|
||||
},
|
||||
description: desc,
|
||||
});
|
||||
}
|
||||
in_release = false;
|
||||
in_release_description = false;
|
||||
}
|
||||
"content_rating" => {
|
||||
in_content_rating = false;
|
||||
meta.content_rating_summary =
|
||||
Some(summarize_content_rating(&content_rating_attrs));
|
||||
}
|
||||
"provides" => {
|
||||
in_provides = false;
|
||||
}
|
||||
"keywords" => {
|
||||
in_keywords = false;
|
||||
}
|
||||
"categories" => {
|
||||
in_categories = false;
|
||||
}
|
||||
"developer" => {
|
||||
in_developer = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
current_tag.clear();
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
Ok(Event::Text(ref e)) => {
|
||||
let text = e.unescape().unwrap_or_default().trim().to_string();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match current_tag.as_str() {
|
||||
"id" => meta.id = Some(text),
|
||||
"name" => meta.name = Some(text),
|
||||
"summary" => meta.summary = Some(text),
|
||||
"project_license" => meta.project_license = Some(text),
|
||||
"project_group" => meta.project_group = Some(text),
|
||||
"url" if !current_url_type.is_empty() => {
|
||||
meta.urls.insert(current_url_type.clone(), text);
|
||||
}
|
||||
"description_p" => {
|
||||
description_parts.push(text);
|
||||
}
|
||||
"description_li" => {
|
||||
description_parts.push(format!(" - {}", text));
|
||||
}
|
||||
"release_desc_p" => {
|
||||
release_desc_parts.push(text);
|
||||
}
|
||||
"release_desc_li" => {
|
||||
release_desc_parts.push(format!(" - {}", text));
|
||||
}
|
||||
"content_attribute" if !current_content_attr_id.is_empty() => {
|
||||
content_rating_attrs
|
||||
.push((current_content_attr_id.clone(), text));
|
||||
}
|
||||
"mediatype" => {
|
||||
meta.mime_types.push(text);
|
||||
}
|
||||
"keyword" => {
|
||||
meta.keywords.push(text);
|
||||
}
|
||||
"category" => {
|
||||
meta.categories.push(text);
|
||||
}
|
||||
"developer_name" => {
|
||||
if meta.developer.is_none() {
|
||||
meta.developer = Some(text);
|
||||
}
|
||||
}
|
||||
"developer_child_name" => {
|
||||
meta.developer = Some(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Empty(ref e)) => {
|
||||
let tag_name =
|
||||
String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
if tag_name == "release" && in_component {
|
||||
let mut ver = String::new();
|
||||
let mut date = String::new();
|
||||
for attr in e.attributes().flatten() {
|
||||
match attr.key.as_ref() {
|
||||
b"version" => {
|
||||
ver = String::from_utf8_lossy(&attr.value)
|
||||
.to_string();
|
||||
}
|
||||
b"date" => {
|
||||
date = String::from_utf8_lossy(&attr.value)
|
||||
.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !ver.is_empty() && meta.releases.len() < 10 {
|
||||
meta.releases.push(ReleaseInfo {
|
||||
version: ver,
|
||||
date: if date.is_empty() { None } else { Some(date) },
|
||||
description: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => {
|
||||
log::warn!("AppStream XML parse error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
if meta.id.is_some() || meta.name.is_some() || meta.summary.is_some() {
|
||||
Some(meta)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize OARS content rating attributes into a human-readable level.
|
||||
fn summarize_content_rating(attrs: &[(String, String)]) -> String {
|
||||
let max_level = attrs
|
||||
.iter()
|
||||
.map(|(_, v)| match v.as_str() {
|
||||
"intense" => 3,
|
||||
"moderate" => 2,
|
||||
"mild" => 1,
|
||||
_ => 0,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
match max_level {
|
||||
0 => "All ages".to_string(),
|
||||
1 => "Mild content".to_string(),
|
||||
2 => "Moderate content".to_string(),
|
||||
3 => "Mature content".to_string(),
|
||||
_ => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppStream catalog generation - writes catalog XML for GNOME Software/Discover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate an AppStream catalog XML from the Driftwood database.
|
||||
/// This allows GNOME Software / KDE Discover to see locally managed AppImages.
|
||||
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
|
||||
@@ -206,4 +586,135 @@ mod tests {
|
||||
let err = AppStreamError::Io("write failed".to_string());
|
||||
assert!(format!("{}", err).contains("write failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_appstream() {
|
||||
let xml = r#"<?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.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_levels() {
|
||||
assert_eq!(summarize_content_rating(&[]), "All ages");
|
||||
assert_eq!(
|
||||
summarize_content_rating(&[
|
||||
("violence-cartoon".to_string(), "none".to_string()),
|
||||
("language-humor".to_string(), "mild".to_string()),
|
||||
]),
|
||||
"Mild content"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_content_rating(&[(
|
||||
"violence-realistic".to_string(),
|
||||
"intense".to_string(),
|
||||
)]),
|
||||
"Mature content"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,23 @@ pub struct AppImageRecord {
|
||||
pub tags: Option<String>,
|
||||
pub pinned: bool,
|
||||
pub avg_startup_ms: Option<i64>,
|
||||
// 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -343,6 +360,10 @@ impl Database {
|
||||
self.migrate_to_v8()?;
|
||||
}
|
||||
|
||||
if current_version < 9 {
|
||||
self.migrate_to_v9()?;
|
||||
}
|
||||
|
||||
// Ensure all expected columns exist (repairs DBs where a migration
|
||||
// was updated after it had already run on this database)
|
||||
self.ensure_columns()?;
|
||||
@@ -682,6 +703,44 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v9(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"appstream_id TEXT",
|
||||
"appstream_description TEXT",
|
||||
"generic_name TEXT",
|
||||
"license TEXT",
|
||||
"homepage_url TEXT",
|
||||
"bugtracker_url TEXT",
|
||||
"donation_url TEXT",
|
||||
"help_url TEXT",
|
||||
"vcs_url TEXT",
|
||||
"keywords TEXT",
|
||||
"mime_types TEXT",
|
||||
"content_rating TEXT",
|
||||
"project_group TEXT",
|
||||
"release_history TEXT",
|
||||
"desktop_actions TEXT",
|
||||
"has_signature INTEGER NOT NULL DEFAULT 0",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col);
|
||||
match self.conn.execute(&sql, []) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column") {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![9],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_appimage(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -747,6 +806,68 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_appstream_metadata(
|
||||
&self,
|
||||
id: i64,
|
||||
appstream_id: Option<&str>,
|
||||
appstream_description: Option<&str>,
|
||||
generic_name: Option<&str>,
|
||||
license: Option<&str>,
|
||||
homepage_url: Option<&str>,
|
||||
bugtracker_url: Option<&str>,
|
||||
donation_url: Option<&str>,
|
||||
help_url: Option<&str>,
|
||||
vcs_url: Option<&str>,
|
||||
keywords: Option<&str>,
|
||||
mime_types: Option<&str>,
|
||||
content_rating: Option<&str>,
|
||||
project_group: Option<&str>,
|
||||
release_history: Option<&str>,
|
||||
desktop_actions: Option<&str>,
|
||||
has_signature: bool,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET
|
||||
appstream_id = ?2,
|
||||
appstream_description = ?3,
|
||||
generic_name = ?4,
|
||||
license = ?5,
|
||||
homepage_url = ?6,
|
||||
bugtracker_url = ?7,
|
||||
donation_url = ?8,
|
||||
help_url = ?9,
|
||||
vcs_url = ?10,
|
||||
keywords = ?11,
|
||||
mime_types = ?12,
|
||||
content_rating = ?13,
|
||||
project_group = ?14,
|
||||
release_history = ?15,
|
||||
desktop_actions = ?16,
|
||||
has_signature = ?17
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
appstream_id,
|
||||
appstream_description,
|
||||
generic_name,
|
||||
license,
|
||||
homepage_url,
|
||||
bugtracker_url,
|
||||
donation_url,
|
||||
help_url,
|
||||
vcs_url,
|
||||
keywords,
|
||||
mime_types,
|
||||
content_rating,
|
||||
project_group,
|
||||
release_history,
|
||||
desktop_actions,
|
||||
has_signature,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_sha256(&self, id: i64, sha256: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET sha256 = ?2 WHERE id = ?1",
|
||||
@@ -782,7 +903,11 @@ impl Database {
|
||||
fuse_status, wayland_status, update_info, update_type,
|
||||
latest_version, update_checked, update_url, notes, sandbox_mode,
|
||||
runtime_wayland_status, runtime_wayland_checked, analysis_status,
|
||||
launch_args, tags, pinned, avg_startup_ms";
|
||||
launch_args, tags, pinned, avg_startup_ms,
|
||||
appstream_id, appstream_description, generic_name, license,
|
||||
homepage_url, bugtracker_url, donation_url, help_url, vcs_url,
|
||||
keywords, mime_types, content_rating, project_group,
|
||||
release_history, desktop_actions, has_signature";
|
||||
|
||||
fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<AppImageRecord> {
|
||||
Ok(AppImageRecord {
|
||||
@@ -823,6 +948,22 @@ impl Database {
|
||||
tags: row.get(34).unwrap_or(None),
|
||||
pinned: row.get::<_, bool>(35).unwrap_or(false),
|
||||
avg_startup_ms: row.get(36).unwrap_or(None),
|
||||
appstream_id: row.get(37).unwrap_or(None),
|
||||
appstream_description: row.get(38).unwrap_or(None),
|
||||
generic_name: row.get(39).unwrap_or(None),
|
||||
license: row.get(40).unwrap_or(None),
|
||||
homepage_url: row.get(41).unwrap_or(None),
|
||||
bugtracker_url: row.get(42).unwrap_or(None),
|
||||
donation_url: row.get(43).unwrap_or(None),
|
||||
help_url: row.get(44).unwrap_or(None),
|
||||
vcs_url: row.get(45).unwrap_or(None),
|
||||
keywords: row.get(46).unwrap_or(None),
|
||||
mime_types: row.get(47).unwrap_or(None),
|
||||
content_rating: row.get(48).unwrap_or(None),
|
||||
project_group: row.get(49).unwrap_or(None),
|
||||
release_history: row.get(50).unwrap_or(None),
|
||||
desktop_actions: row.get(51).unwrap_or(None),
|
||||
has_signature: row.get::<_, bool>(52).unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1696,13 +1837,13 @@ mod tests {
|
||||
fn test_fresh_database_creates_at_latest_version() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
// Verify schema_version is at the latest (8)
|
||||
// Verify schema_version is at the latest (9)
|
||||
let version: i32 = db.conn.query_row(
|
||||
"SELECT version FROM schema_version LIMIT 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(version, 8);
|
||||
assert_eq!(version, 9);
|
||||
|
||||
// All tables that should exist after the full v1-v7 migration chain
|
||||
let expected_tables = [
|
||||
|
||||
@@ -413,6 +413,22 @@ mod tests {
|
||||
tags: None,
|
||||
pinned: false,
|
||||
avg_startup_ms: None,
|
||||
appstream_id: None,
|
||||
appstream_description: None,
|
||||
generic_name: None,
|
||||
license: None,
|
||||
homepage_url: None,
|
||||
bugtracker_url: None,
|
||||
donation_url: None,
|
||||
help_url: None,
|
||||
vcs_url: None,
|
||||
keywords: None,
|
||||
mime_types: None,
|
||||
content_rating: None,
|
||||
project_group: None,
|
||||
release_history: None,
|
||||
desktop_actions: None,
|
||||
has_signature: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -43,6 +43,23 @@ pub struct AppImageMetadata {
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -53,6 +70,12 @@ struct DesktopEntryFields {
|
||||
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>,
|
||||
}
|
||||
|
||||
fn icons_cache_dir() -> PathBuf {
|
||||
@@ -74,6 +97,12 @@ fn has_unsquashfs() -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Public wrapper for binary squashfs offset detection.
|
||||
/// Used by other modules (e.g. wayland) to avoid executing the AppImage.
|
||||
pub fn find_squashfs_offset_for(path: &Path) -> Option<u64> {
|
||||
find_squashfs_offset(path).ok()
|
||||
}
|
||||
|
||||
/// Find the squashfs offset by scanning for a valid superblock in the binary.
|
||||
/// This avoids executing the AppImage, which can hang for apps with custom AppRun scripts.
|
||||
fn find_squashfs_offset(path: &Path) -> Result<u64, InspectorError> {
|
||||
@@ -274,6 +303,30 @@ fn parse_desktop_entry(content: &str) -> DesktopEntryFields {
|
||||
}
|
||||
"Exec" => fields.exec = Some(value.to_string()),
|
||||
"X-AppImage-Version" => fields.version = Some(value.to_string()),
|
||||
"GenericName" => fields.generic_name = Some(value.to_string()),
|
||||
"Keywords" => {
|
||||
fields.keywords = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
"MimeType" => {
|
||||
fields.mime_types = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
"Terminal" => fields.terminal = value == "true",
|
||||
"X-AppImage-Name" => fields.x_appimage_name = Some(value.to_string()),
|
||||
"Actions" => {
|
||||
fields.actions = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -400,6 +453,41 @@ fn find_icon_recursive(dir: &Path, name: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 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) = fs::read_dir(&metainfo_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("xml") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check legacy path
|
||||
let appdata_dir = extract_dir.join("usr/share/appdata");
|
||||
if let Ok(entries) = fs::read_dir(&appdata_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("xml") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name.
|
||||
fn detect_signature(path: &Path) -> bool {
|
||||
let data = match fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = b".sha256_sig";
|
||||
data.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Cache an icon file to the driftwood icons directory.
|
||||
fn cache_icon(source: &Path, app_id: &str) -> Option<PathBuf> {
|
||||
let ext = source
|
||||
@@ -482,6 +570,31 @@ pub fn inspect_appimage(
|
||||
let desktop_content = fs::read_to_string(&desktop_path)?;
|
||||
let fields = parse_desktop_entry(&desktop_content);
|
||||
|
||||
// Parse AppStream metainfo XML if available
|
||||
let appstream = find_appstream_file(&extract_dir)
|
||||
.and_then(|p| crate::core::appstream::parse_appstream_file(&p));
|
||||
|
||||
// Merge: AppStream takes priority for overlapping fields
|
||||
let final_name = appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.name.clone())
|
||||
.or(fields.name);
|
||||
let final_description = appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.description.clone())
|
||||
.or(appstream.as_ref().and_then(|a| a.summary.clone()))
|
||||
.or(fields.comment);
|
||||
let final_developer = appstream.as_ref().and_then(|a| a.developer.clone());
|
||||
let final_categories = if let Some(ref a) = appstream {
|
||||
if !a.categories.is_empty() {
|
||||
a.categories.clone()
|
||||
} else {
|
||||
fields.categories
|
||||
}
|
||||
} else {
|
||||
fields.categories
|
||||
};
|
||||
|
||||
// Determine version (desktop entry > filename heuristic)
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let version = fields
|
||||
@@ -491,7 +604,7 @@ pub fn inspect_appimage(
|
||||
// Find and cache icon
|
||||
let icon = find_icon(&extract_dir, fields.icon.as_deref());
|
||||
let app_id = make_app_id(
|
||||
fields.name.as_deref().unwrap_or(
|
||||
final_name.as_deref().unwrap_or(
|
||||
filename
|
||||
.strip_suffix(".AppImage")
|
||||
.unwrap_or(filename),
|
||||
@@ -499,16 +612,71 @@ pub fn inspect_appimage(
|
||||
);
|
||||
let cached_icon = icon.and_then(|icon_path| cache_icon(&icon_path, &app_id));
|
||||
|
||||
// Merge keywords from both sources
|
||||
let mut all_keywords = fields.keywords;
|
||||
if let Some(ref a) = appstream {
|
||||
for kw in &a.keywords {
|
||||
if !all_keywords.contains(kw) {
|
||||
all_keywords.push(kw.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge MIME types from both sources
|
||||
let mut all_mime_types = fields.mime_types;
|
||||
if let Some(ref a) = appstream {
|
||||
for mt in &a.mime_types {
|
||||
if !all_mime_types.contains(mt) {
|
||||
all_mime_types.push(mt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let has_sig = detect_signature(path);
|
||||
|
||||
Ok(AppImageMetadata {
|
||||
app_name: fields.name,
|
||||
app_name: final_name,
|
||||
app_version: version,
|
||||
description: fields.comment,
|
||||
developer: None,
|
||||
description: final_description,
|
||||
developer: final_developer,
|
||||
icon_name: fields.icon,
|
||||
categories: fields.categories,
|
||||
categories: final_categories,
|
||||
desktop_entry_content: desktop_content,
|
||||
architecture: detect_architecture(path),
|
||||
cached_icon_path: cached_icon,
|
||||
appstream_id: appstream.as_ref().and_then(|a| a.id.clone()),
|
||||
appstream_description: appstream.as_ref().and_then(|a| a.description.clone()),
|
||||
generic_name: fields
|
||||
.generic_name
|
||||
.or_else(|| appstream.as_ref().and_then(|a| a.summary.clone())),
|
||||
license: appstream.as_ref().and_then(|a| a.project_license.clone()),
|
||||
homepage_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("homepage").cloned()),
|
||||
bugtracker_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("bugtracker").cloned()),
|
||||
donation_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("donation").cloned()),
|
||||
help_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("help").cloned()),
|
||||
vcs_url: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.urls.get("vcs-browser").cloned()),
|
||||
keywords: all_keywords,
|
||||
mime_types: all_mime_types,
|
||||
content_rating: appstream
|
||||
.as_ref()
|
||||
.and_then(|a| a.content_rating_summary.clone()),
|
||||
project_group: appstream.as_ref().and_then(|a| a.project_group.clone()),
|
||||
releases: appstream
|
||||
.as_ref()
|
||||
.map(|a| a.releases.clone())
|
||||
.unwrap_or_default(),
|
||||
desktop_actions: fields.actions,
|
||||
has_signature: has_sig,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,22 @@ mod tests {
|
||||
tags: None,
|
||||
pinned: false,
|
||||
avg_startup_ms: None,
|
||||
appstream_id: None,
|
||||
appstream_description: None,
|
||||
generic_name: None,
|
||||
license: None,
|
||||
homepage_url: None,
|
||||
bugtracker_url: None,
|
||||
donation_url: None,
|
||||
help_url: None,
|
||||
vcs_url: None,
|
||||
keywords: None,
|
||||
mime_types: None,
|
||||
content_rating: None,
|
||||
project_group: None,
|
||||
release_history: None,
|
||||
desktop_actions: None,
|
||||
has_signature: false,
|
||||
};
|
||||
|
||||
// We can't easily test the full integrate() without mocking dirs,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analysis;
|
||||
pub mod appstream;
|
||||
pub mod backup;
|
||||
pub mod database;
|
||||
pub mod discovery;
|
||||
|
||||
@@ -130,17 +130,11 @@ pub fn analyze_appimage(appimage_path: &Path) -> WaylandAnalysis {
|
||||
|
||||
/// List shared libraries bundled inside the AppImage squashfs.
|
||||
fn list_bundled_libraries(appimage_path: &Path) -> Vec<String> {
|
||||
// First get the squashfs offset
|
||||
let offset_output = Command::new(appimage_path)
|
||||
.arg("--appimage-offset")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
.output();
|
||||
|
||||
let offset = match offset_output {
|
||||
Ok(out) if out.status.success() => {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
// Get the squashfs offset using binary scan (never execute the AppImage -
|
||||
// some apps like Affinity have custom AppRun scripts that ignore flags)
|
||||
let offset = match crate::core::inspector::find_squashfs_offset_for(appimage_path) {
|
||||
Some(o) => o.to_string(),
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
// Use unsquashfs to list files (just filenames, no extraction)
|
||||
|
||||
Reference in New Issue
Block a user