use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use super::database::AppImageRecord; #[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, } 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(); } } } 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, }; // 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"); } }