Add Phase 5 enhancements: security, i18n, analysis, backup, notifications
- Database v8 migration: tags, pinned, avg_startup_ms columns - Security scanning with CVE matching and batch scan - Bundled library extraction and vulnerability reports - Desktop notification system for security alerts - Backup/restore system for AppImage configurations - i18n framework with gettext support - Runtime analysis and Wayland compatibility detection - AppStream metadata and Flatpak-style build support - File watcher module for live directory monitoring - Preferences panel with GSettings integration - CLI interface for headless operation - Detail view: tabbed layout with ViewSwitcher in title bar, health score, sandbox controls, changelog links - Library view: sort dropdown, context menu enhancements - Dashboard: system status, disk usage, launch history - Security report page with scan and export - Packaging: meson build, PKGBUILD, metainfo
This commit is contained in:
209
src/core/appstream.rs
Normal file
209
src/core/appstream.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::database::Database;
|
||||
|
||||
/// 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> {
|
||||
let records = db.get_all_appimages()
|
||||
.map_err(|e| AppStreamError::Database(e.to_string()))?;
|
||||
|
||||
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
xml.push_str("<components version=\"0.16\" origin=\"driftwood\">\n");
|
||||
|
||||
for record in &records {
|
||||
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let app_id = make_component_id(app_name);
|
||||
let description = record.description.as_deref().unwrap_or("");
|
||||
|
||||
xml.push_str(" <component type=\"desktop-application\">\n");
|
||||
xml.push_str(&format!(" <id>appimage.{}</id>\n", xml_escape(&app_id)));
|
||||
xml.push_str(&format!(" <name>{}</name>\n", xml_escape(app_name)));
|
||||
|
||||
if !description.is_empty() {
|
||||
xml.push_str(&format!(" <summary>{}</summary>\n", xml_escape(description)));
|
||||
}
|
||||
|
||||
xml.push_str(&format!(" <pkgname>{}</pkgname>\n", xml_escape(&record.filename)));
|
||||
|
||||
if let Some(version) = &record.app_version {
|
||||
xml.push_str(" <releases>\n");
|
||||
xml.push_str(&format!(
|
||||
" <release version=\"{}\" />\n",
|
||||
xml_escape(version),
|
||||
));
|
||||
xml.push_str(" </releases>\n");
|
||||
}
|
||||
|
||||
if let Some(categories) = &record.categories {
|
||||
xml.push_str(" <categories>\n");
|
||||
for cat in categories.split(';').filter(|c| !c.is_empty()) {
|
||||
xml.push_str(&format!(" <category>{}</category>\n", xml_escape(cat.trim())));
|
||||
}
|
||||
xml.push_str(" </categories>\n");
|
||||
}
|
||||
|
||||
// Provide hint about source
|
||||
xml.push_str(" <metadata>\n");
|
||||
xml.push_str(" <value key=\"managed-by\">driftwood</value>\n");
|
||||
xml.push_str(&format!(
|
||||
" <value key=\"appimage-path\">{}</value>\n",
|
||||
xml_escape(&record.path),
|
||||
));
|
||||
xml.push_str(" </metadata>\n");
|
||||
|
||||
xml.push_str(" </component>\n");
|
||||
}
|
||||
|
||||
xml.push_str("</components>\n");
|
||||
Ok(xml)
|
||||
}
|
||||
|
||||
/// Install the AppStream catalog to the local swcatalog directory.
|
||||
/// GNOME Software reads from `~/.local/share/swcatalog/xml/`.
|
||||
pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
|
||||
let catalog_xml = generate_catalog(db)?;
|
||||
|
||||
let catalog_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("swcatalog")
|
||||
.join("xml");
|
||||
|
||||
fs::create_dir_all(&catalog_dir)
|
||||
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
||||
|
||||
let catalog_path = catalog_dir.join("driftwood.xml");
|
||||
fs::write(&catalog_path, &catalog_xml)
|
||||
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
||||
|
||||
Ok(catalog_path)
|
||||
}
|
||||
|
||||
/// Remove the AppStream catalog from the local swcatalog directory.
|
||||
pub fn uninstall_catalog() -> Result<(), AppStreamError> {
|
||||
let catalog_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("swcatalog")
|
||||
.join("xml")
|
||||
.join("driftwood.xml");
|
||||
|
||||
if catalog_path.exists() {
|
||||
fs::remove_file(&catalog_path)
|
||||
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the AppStream catalog is currently installed.
|
||||
pub fn is_catalog_installed() -> bool {
|
||||
let catalog_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("swcatalog")
|
||||
.join("xml")
|
||||
.join("driftwood.xml");
|
||||
|
||||
catalog_path.exists()
|
||||
}
|
||||
|
||||
// --- Utility functions ---
|
||||
|
||||
fn make_component_id(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' })
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
// --- Error types ---
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppStreamError {
|
||||
Database(String),
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppStreamError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Database(e) => write!(f, "Database error: {}", e),
|
||||
Self::Io(e) => write!(f, "I/O error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_make_component_id() {
|
||||
assert_eq!(make_component_id("Firefox"), "firefox");
|
||||
assert_eq!(make_component_id("My App 2.0"), "my_app_2.0");
|
||||
assert_eq!(make_component_id("GIMP"), "gimp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xml_escape() {
|
||||
assert_eq!(xml_escape("hello & world"), "hello & world");
|
||||
assert_eq!(xml_escape("<tag>"), "<tag>");
|
||||
assert_eq!(xml_escape("it's \"quoted\""), "it's "quoted"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_catalog_empty() {
|
||||
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||
let xml = generate_catalog(&db).unwrap();
|
||||
assert!(xml.contains("<components"));
|
||||
assert!(xml.contains("</components>"));
|
||||
// No individual component entries in an empty DB
|
||||
assert!(!xml.contains("<component "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_catalog_with_app() {
|
||||
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||
db.upsert_appimage(
|
||||
"/tmp/test.AppImage",
|
||||
"test.AppImage",
|
||||
Some(2),
|
||||
1024,
|
||||
true,
|
||||
None,
|
||||
).unwrap();
|
||||
db.update_metadata(
|
||||
1,
|
||||
Some("TestApp"),
|
||||
Some("1.0"),
|
||||
None,
|
||||
None,
|
||||
Some("Utility;"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).ok();
|
||||
|
||||
let xml = generate_catalog(&db).unwrap();
|
||||
assert!(xml.contains("appimage.testapp"));
|
||||
assert!(xml.contains("<pkgname>test.AppImage</pkgname>"));
|
||||
assert!(xml.contains("managed-by"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_appstream_error_display() {
|
||||
let err = AppStreamError::Database("db error".to_string());
|
||||
assert!(format!("{}", err).contains("db error"));
|
||||
let err = AppStreamError::Io("write failed".to_string());
|
||||
assert!(format!("{}", err).contains("write failed"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user