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:
lashman
2026-02-27 17:16:41 +02:00
parent a7ed3742fb
commit 423323d5a9
51 changed files with 10583 additions and 481 deletions

209
src/core/appstream.rs Normal file
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
// --- 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 &amp; world");
assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
assert_eq!(xml_escape("it's \"quoted\""), "it&apos;s &quot;quoted&quot;");
}
#[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"));
}
}