use std::fs; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct OrphanedDesktopEntry { pub desktop_file_path: PathBuf, pub original_appimage_path: String, pub app_name: Option, } pub struct CleanupSummary { pub entries_removed: usize, pub icons_removed: usize, } fn applications_dir() -> PathBuf { crate::config::data_dir_fallback() .join("applications") } fn icons_dir() -> PathBuf { crate::config::data_dir_fallback() .join("icons/hicolor") } /// Parse key-value pairs from a .desktop file's [Desktop Entry] section. fn parse_desktop_key(content: &str, key: &str) -> Option { let mut in_section = false; for line in content.lines() { let line = line.trim(); if line == "[Desktop Entry]" { in_section = true; continue; } if line.starts_with('[') { in_section = false; continue; } if !in_section { continue; } if let Some(value) = line.strip_prefix(key).and_then(|rest| rest.strip_prefix('=')) { return Some(value.trim().to_string()); } } None } /// Scan for orphaned desktop entries managed by Driftwood. pub fn detect_orphans() -> Vec { let mut orphans = Vec::new(); let apps_dir = applications_dir(); let entries = match fs::read_dir(&apps_dir) { Ok(entries) => entries, Err(_) => return orphans, }; for entry in entries.flatten() { let path = entry.path(); // Only check driftwood-*.desktop files let _filename = match path.file_name().and_then(|n| n.to_str()) { Some(name) if name.starts_with("driftwood-") && name.ends_with(".desktop") => name, _ => continue, }; // Read and check if managed by Driftwood let content = match fs::read_to_string(&path) { Ok(c) => c, Err(_) => continue, }; let managed = parse_desktop_key(&content, "X-AppImage-Managed-By"); if managed.as_deref() != Some("Driftwood") { continue; } // Check if the referenced AppImage still exists let appimage_path = match parse_desktop_key(&content, "X-AppImage-Path") { Some(p) => p, None => continue, }; if !Path::new(&appimage_path).exists() { let app_name = parse_desktop_key(&content, "Name"); orphans.push(OrphanedDesktopEntry { desktop_file_path: path, original_appimage_path: appimage_path, app_name, }); } } orphans } /// Clean up a specific orphaned desktop entry. pub fn clean_orphan(entry: &OrphanedDesktopEntry) -> Result<(bool, usize), std::io::Error> { let mut icons_removed = 0; // Remove the .desktop file let entry_removed = if entry.desktop_file_path.exists() { fs::remove_file(&entry.desktop_file_path)?; true } else { false }; // Try to determine the icon ID and remove associated icon files if let Some(filename) = entry.desktop_file_path.file_stem().and_then(|n| n.to_str()) { // filename is like "driftwood-firefox" - the icon ID is the same let icon_id = filename; 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)?; icons_removed += 1; } } } Ok((entry_removed, icons_removed)) } /// Clean all detected orphans. pub fn clean_all_orphans() -> Result { let orphans = detect_orphans(); let mut summary = CleanupSummary { entries_removed: 0, icons_removed: 0, }; for entry in &orphans { match clean_orphan(entry) { Ok((removed, icons)) => { if removed { summary.entries_removed += 1; } summary.icons_removed += icons; } Err(e) => { log::warn!( "Failed to clean orphan {}: {}", entry.desktop_file_path.display(), e ); } } } Ok(summary) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_desktop_key() { let content = "[Desktop Entry]\n\ Name=Test App\n\ X-AppImage-Path=/home/user/test.AppImage\n\ X-AppImage-Managed-By=Driftwood\n"; assert_eq!( parse_desktop_key(content, "Name"), Some("Test App".to_string()) ); assert_eq!( parse_desktop_key(content, "X-AppImage-Path"), Some("/home/user/test.AppImage".to_string()) ); assert_eq!( parse_desktop_key(content, "X-AppImage-Managed-By"), Some("Driftwood".to_string()) ); assert_eq!(parse_desktop_key(content, "Missing"), None); } #[test] fn test_parse_desktop_key_ignores_other_sections() { let content = "[Desktop Entry]\n\ Name=App\n\ [Desktop Action New]\n\ Name=Other\n"; assert_eq!( parse_desktop_key(content, "Name"), Some("App".to_string()) ); } }