Implement Driftwood AppImage manager - Phases 1 and 2
Phase 1 - Application scaffolding: - GTK4/libadwaita application window with AdwNavigationView - GSettings-backed window state persistence - GResource-compiled CSS and schema - Library view with grid/list toggle, search, sorting, filtering - Detail view with file info, desktop integration controls - Preferences window with scan directories, theme, behavior settings - CLI with list, scan, integrate, remove, clean, inspect commands - AppImage discovery, metadata extraction, desktop integration - Orphaned desktop entry detection and cleanup - AppImage packaging script Phase 2 - Intelligence layer: - Database schema v2 with migration for status tracking columns - FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher) - Wayland awareness engine (session type, toolkit detection, XWayland) - Update info parsing from AppImage ELF sections (.upd_info) - GitHub/GitLab Releases API integration for update checking - Update download with progress tracking and atomic apply - Launch wrapper with FUSE auto-detection and usage tracking - Duplicate and multi-version detection with recommendations - Dashboard with system health, library stats, disk usage - Update check dialog (single and batch) - Duplicate resolution dialog - Status badges on library cards and detail view - Extended CLI: status, check-updates, duplicates, launch commands 49 tests passing across all modules.
This commit is contained in:
199
src/core/orphan.rs
Normal file
199
src/core/orphan.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
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<String>,
|
||||
}
|
||||
|
||||
pub struct CleanupSummary {
|
||||
pub entries_removed: usize,
|
||||
pub icons_removed: usize,
|
||||
}
|
||||
|
||||
fn applications_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("applications")
|
||||
}
|
||||
|
||||
fn icons_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("icons/hicolor")
|
||||
}
|
||||
|
||||
/// Parse key-value pairs from a .desktop file's [Desktop Entry] section.
|
||||
fn parse_desktop_key(content: &str, key: &str) -> Option<String> {
|
||||
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<OrphanedDesktopEntry> {
|
||||
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<CleanupSummary, std::io::Error> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user