use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use super::database::{AppImageRecord, Database}; #[derive(Debug)] pub enum IntegrationError { IoError(std::io::Error), NoAppName, } impl std::fmt::Display for IntegrationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::IoError(e) => write!(f, "I/O error: {}", e), Self::NoAppName => write!(f, "Cannot integrate: no application name"), } } } impl From for IntegrationError { fn from(e: std::io::Error) -> Self { Self::IoError(e) } } /// Escape a string for use inside a double-quoted Exec argument in a .desktop file. /// Per the Desktop Entry spec, `\`, `"`, `` ` ``, and `$` must be escaped with `\`. fn escape_exec_arg(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '\\' | '"' | '`' | '$' => { out.push('\\'); out.push(c); } _ => out.push(c), } } out } pub struct IntegrationResult { pub desktop_file_path: PathBuf, pub icon_install_path: Option, } pub fn applications_dir() -> PathBuf { crate::config::data_dir_fallback() .join("applications") } fn icons_dir() -> PathBuf { crate::config::data_dir_fallback() .join("icons/hicolor") } /// Generate a sanitized app ID. pub fn make_app_id(app_name: &str) -> String { let id: String = app_name .chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' } }) .collect(); id.trim_matches('-').to_string() } /// Integrate an AppImage: create .desktop file and install icon. pub fn integrate(record: &AppImageRecord) -> Result { let app_name = record .app_name .as_deref() .or(Some(&record.filename)) .ok_or(IntegrationError::NoAppName)?; let app_id = make_app_id(app_name); let desktop_filename = format!("driftwood-{}.desktop", app_id); let apps_dir = applications_dir(); fs::create_dir_all(&apps_dir)?; let desktop_path = apps_dir.join(&desktop_filename); // Build the .desktop file content let categories = record .categories .as_deref() .unwrap_or(""); let comment = record .description .as_deref() .unwrap_or(""); let version = record .app_version .as_deref() .unwrap_or(""); let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); let icon_id = format!("driftwood-{}", app_id); let desktop_content = format!("\ [Desktop Entry] Type=Application Name={name} Exec=\"{exec}\" %U Icon={icon} Categories={categories} Comment={comment} Terminal=false X-AppImage-Path={path} X-AppImage-Version={version} X-AppImage-Managed-By=Driftwood X-AppImage-Integrated-Date={date} ", name = app_name, exec = escape_exec_arg(&record.path), icon = icon_id, categories = categories, comment = comment, path = record.path, version = version, date = now, ); fs::write(&desktop_path, &desktop_content)?; // Install icon if we have a cached one let icon_install_path = if let Some(ref cached_icon) = record.icon_path { let cached = Path::new(cached_icon); if cached.exists() { install_icon(cached, &icon_id)? } else { None } } else { None }; // Update desktop database (best effort) update_desktop_database(); Ok(IntegrationResult { desktop_file_path: desktop_path, icon_install_path, }) } /// Install an icon to the hicolor icon theme directory. fn install_icon(source: &Path, icon_id: &str) -> Result, IntegrationError> { let ext = source .extension() .and_then(|e| e.to_str()) .unwrap_or("png"); let (subdir, filename) = if ext == "svg" { ("scalable/apps", format!("{}.svg", icon_id)) } else { ("256x256/apps", format!("{}.png", icon_id)) }; let dest_dir = icons_dir().join(subdir); fs::create_dir_all(&dest_dir)?; let dest = dest_dir.join(&filename); fs::copy(source, &dest)?; Ok(Some(dest)) } /// Remove integration for an AppImage. pub fn remove_integration(record: &AppImageRecord) -> Result<(), IntegrationError> { let app_name = record .app_name .as_deref() .or(Some(&record.filename)) .ok_or(IntegrationError::NoAppName)?; let app_id = make_app_id(app_name); // Remove .desktop file if let Some(ref desktop_file) = record.desktop_file { let path = Path::new(desktop_file); if path.exists() { fs::remove_file(path)?; } } else { // Try the conventional path let desktop_path = applications_dir().join(format!("driftwood-{}.desktop", app_id)); if desktop_path.exists() { fs::remove_file(&desktop_path)?; } } // Remove icon files let icon_id = format!("driftwood-{}", app_id); remove_icon_files(&icon_id); update_desktop_database(); Ok(()) } fn remove_icon_files(icon_id: &str) { let base = icons_dir(); let candidates = [ base.join(format!("256x256/apps/{}.png", icon_id)), base.join(format!("scalable/apps/{}.svg", icon_id)), base.join(format!("128x128/apps/{}.png", icon_id)), base.join(format!("48x48/apps/{}.png", icon_id)), ]; for path in &candidates { if path.exists() { fs::remove_file(path).ok(); } } } /// Integrate and track all created files in the system_modifications table. pub fn integrate_tracked(record: &AppImageRecord, db: &Database) -> Result { let result = integrate(record)?; // Register desktop file db.register_modification( record.id, "desktop_file", &result.desktop_file_path.to_string_lossy(), None, ).ok(); // Register icon file if let Some(ref icon_path) = result.icon_install_path { db.register_modification( record.id, "icon", &icon_path.to_string_lossy(), None, ).ok(); } Ok(result) } pub fn autostart_dir() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) .join("autostart") } pub fn enable_autostart(db: &Database, record: &AppImageRecord) -> Result { let dir = autostart_dir(); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create autostart dir: {}", e))?; let app_id = record.app_name.as_deref() .map(|n| make_app_id(n)) .unwrap_or_else(|| format!("appimage-{}", record.id)); let desktop_filename = format!("driftwood-{}.desktop", app_id); let desktop_path = dir.join(&desktop_filename); let app_name = record.app_name.as_deref().unwrap_or(&record.filename); let icon = record.icon_path.as_deref().unwrap_or("application-x-executable"); let content = format!("\ [Desktop Entry] Type=Application Name={} Exec=\"{}\" %U Icon={} X-GNOME-Autostart-enabled=true X-Driftwood-AppImage-ID={} ", app_name, escape_exec_arg(&record.path), icon, record.id); fs::write(&desktop_path, &content) .map_err(|e| format!("Failed to write autostart file: {}", e))?; db.register_modification(record.id, "autostart", &desktop_path.to_string_lossy(), None) .map_err(|e| format!("Failed to register modification: {}", e))?; db.set_autostart(record.id, true).ok(); Ok(desktop_path) } pub fn disable_autostart(db: &Database, record_id: i64) -> Result<(), String> { let mods = db.get_modifications(record_id).unwrap_or_default(); for m in &mods { if m.mod_type == "autostart" { let path = Path::new(&m.file_path); if path.exists() { fs::remove_file(path).ok(); } db.remove_modification(m.id).ok(); } } db.set_autostart(record_id, false).ok(); Ok(()) } /// Undo all tracked system modifications for an AppImage. pub fn undo_all_modifications(db: &Database, appimage_id: i64) -> Result<(), String> { let mods = db.get_modifications(appimage_id) .map_err(|e| format!("Failed to get modifications: {}", e))?; for m in &mods { match m.mod_type.as_str() { "desktop_file" | "autostart" | "icon" => { let path = Path::new(&m.file_path); if path.exists() { if let Err(e) = fs::remove_file(path) { log::warn!("Failed to remove {}: {}", m.file_path, e); } } } "mime_default" => { if let Some(ref prev) = m.previous_value { let _ = Command::new("xdg-mime") .args(["default", prev, &m.file_path]) .status(); } } "system_desktop" | "system_icon" | "system_binary" => { let _ = Command::new("pkexec") .args(["rm", "-f", &m.file_path]) .status(); } _ => { log::warn!("Unknown modification type: {}", m.mod_type); } } db.remove_modification(m.id).ok(); } // Refresh desktop database and icon cache update_desktop_database(); Ok(()) } fn update_desktop_database() { let apps_dir = applications_dir(); Command::new("update-desktop-database") .arg(&apps_dir) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .ok(); } #[cfg(test)] mod tests { use super::*; #[test] fn test_make_app_id() { assert_eq!(make_app_id("Firefox"), "firefox"); assert_eq!(make_app_id("My Cool App"), "my-cool-app"); assert_eq!(make_app_id(" Spaces "), "spaces"); } #[test] fn test_integrate_creates_desktop_file() { let _dir = tempfile::tempdir().unwrap(); // Override the applications dir for testing by creating the record // with a specific path and testing the desktop content generation let record = AppImageRecord { id: 1, path: "/home/user/Apps/Firefox.AppImage".to_string(), filename: "Firefox.AppImage".to_string(), app_name: Some("Firefox".to_string()), app_version: Some("124.0".to_string()), appimage_type: Some(2), size_bytes: 100_000_000, sha256: None, icon_path: None, desktop_file: None, integrated: false, integrated_at: None, is_executable: true, desktop_entry_content: None, categories: Some("Network;WebBrowser".to_string()), description: Some("Web Browser".to_string()), developer: None, architecture: Some("x86_64".to_string()), first_seen: "2026-01-01".to_string(), last_scanned: "2026-01-01".to_string(), file_modified: None, fuse_status: None, wayland_status: None, update_info: None, update_type: None, latest_version: None, update_checked: None, update_url: None, notes: None, sandbox_mode: None, runtime_wayland_status: None, runtime_wayland_checked: None, analysis_status: None, launch_args: None, 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, screenshot_urls: None, previous_version_path: None, source_url: None, autostart: false, startup_wm_class: None, verification_status: None, first_run_prompted: false, system_wide: false, is_portable: false, mount_point: None, }; // We can't easily test the full integrate() without mocking dirs, // but we can verify make_app_id and the desktop content format let app_id = make_app_id(record.app_name.as_deref().unwrap()); assert_eq!(app_id, "firefox"); assert_eq!(format!("driftwood-{}.desktop", app_id), "driftwood-firefox.desktop"); } }