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:
lashman
2026-02-26 23:04:27 +02:00
parent 588b1b1525
commit fa28955919
33 changed files with 10401 additions and 0 deletions

272
src/core/integrator.rs Normal file
View File

@@ -0,0 +1,272 @@
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<std::io::Error> for IntegrationError {
fn from(e: std::io::Error) -> Self {
Self::IoError(e)
}
}
pub struct IntegrationResult {
pub desktop_file_path: PathBuf,
pub icon_install_path: Option<PathBuf>,
}
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")
}
/// 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<IntegrationResult, 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);
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]\n\
Type=Application\n\
Name={name}\n\
Exec={exec} %U\n\
Icon={icon}\n\
Categories={categories}\n\
Comment={comment}\n\
Terminal=false\n\
X-AppImage-Path={path}\n\
X-AppImage-Version={version}\n\
X-AppImage-Managed-By=Driftwood\n\
X-AppImage-Integrated-Date={date}\n",
name = app_name,
exec = 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<Option<PathBuf>, 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,
};
// 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");
}
}