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

663
src/cli.rs Normal file
View File

@@ -0,0 +1,663 @@
use clap::{Parser, Subcommand};
use glib::ExitCode;
use gtk::prelude::*;
use std::time::Instant;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::duplicates;
use crate::core::fuse;
use crate::core::inspector;
use crate::core::integrator;
use crate::core::launcher;
use crate::core::orphan;
use crate::core::updater;
use crate::core::wayland;
#[derive(Parser)]
#[command(name = "driftwood", version, about = "Modern AppImage manager for GNOME desktops")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// List all known AppImages
List {
/// Output format: table or json
#[arg(long, default_value = "table")]
format: String,
},
/// Scan for AppImages in configured directories
Scan,
/// Integrate an AppImage (create .desktop and install icon)
Integrate {
/// Path to the AppImage
path: String,
},
/// Remove integration for an AppImage
Remove {
/// Path to the AppImage
path: String,
},
/// Clean orphaned desktop entries
Clean,
/// Inspect an AppImage and show its metadata
Inspect {
/// Path to the AppImage
path: String,
},
/// Show system status (FUSE, Wayland, desktop environment)
Status,
/// Check all AppImages for updates
CheckUpdates,
/// Find duplicate and multi-version AppImages
Duplicates,
/// Launch an AppImage (with tracking and FUSE detection)
Launch {
/// Path to the AppImage
path: String,
},
}
pub fn run_command(command: Commands) -> ExitCode {
let db = match Database::open() {
Ok(db) => db,
Err(e) => {
eprintln!("Error: Failed to open database: {}", e);
return ExitCode::FAILURE;
}
};
match command {
Commands::List { format } => cmd_list(&db, &format),
Commands::Scan => cmd_scan(&db),
Commands::Integrate { path } => cmd_integrate(&db, &path),
Commands::Remove { path } => cmd_remove(&db, &path),
Commands::Clean => cmd_clean(),
Commands::Inspect { path } => cmd_inspect(&path),
Commands::Status => cmd_status(),
Commands::CheckUpdates => cmd_check_updates(&db),
Commands::Duplicates => cmd_duplicates(&db),
Commands::Launch { path } => cmd_launch(&db, &path),
}
}
fn cmd_list(db: &Database, format: &str) -> ExitCode {
let records = match db.get_all_appimages() {
Ok(r) => r,
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::FAILURE;
}
};
if records.is_empty() {
println!("No AppImages found. Run 'driftwood scan' first.");
return ExitCode::SUCCESS;
}
if format == "json" {
// Simple JSON output
println!("[");
for (i, r) in records.iter().enumerate() {
let comma = if i + 1 < records.len() { "," } else { "" };
println!(
" {{\"name\": \"{}\", \"version\": \"{}\", \"path\": \"{}\", \"size\": {}, \"integrated\": {}}}{}",
r.app_name.as_deref().unwrap_or(&r.filename),
r.app_version.as_deref().unwrap_or(""),
r.path,
r.size_bytes,
r.integrated,
comma,
);
}
println!("]");
return ExitCode::SUCCESS;
}
// Table output
let name_width = records
.iter()
.map(|r| r.app_name.as_deref().unwrap_or(&r.filename).len())
.max()
.unwrap_or(4)
.max(4)
.min(30);
let ver_width = records
.iter()
.map(|r| r.app_version.as_deref().unwrap_or("").len())
.max()
.unwrap_or(7)
.max(7)
.min(15);
println!(
" {:<name_w$} {:<ver_w$} {:>10} {}",
"Name", "Version", "Size", "Integrated",
name_w = name_width,
ver_w = ver_width,
);
println!(
" {:-<name_w$} {:-<ver_w$} {:->10} ----------",
"", "", "",
name_w = name_width,
ver_w = ver_width,
);
let mut integrated_count = 0;
for r in &records {
let name = r.app_name.as_deref().unwrap_or(&r.filename);
let display_name = if name.len() > name_width {
&name[..name_width]
} else {
name
};
let version = r.app_version.as_deref().unwrap_or("");
let size = humansize::format_size(r.size_bytes as u64, humansize::BINARY);
let status = if r.integrated { "Yes" } else { "No" };
if r.integrated {
integrated_count += 1;
}
println!(
" {:<name_w$} {:<ver_w$} {:>10} {}",
display_name, version, size, status,
name_w = name_width,
ver_w = ver_width,
);
}
println!();
println!(
" {} AppImages found, {} integrated",
records.len(),
integrated_count,
);
ExitCode::SUCCESS
}
fn cmd_scan(db: &Database) -> ExitCode {
let settings = gtk::gio::Settings::new(crate::config::APP_ID);
let dirs: Vec<String> = settings
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
println!("Scanning directories:");
for d in &dirs {
let expanded = discovery::expand_tilde(d);
println!(" {}", expanded.display());
}
let start = Instant::now();
let discovered = discovery::scan_directories(&dirs);
let total = discovered.len();
let mut new_count = 0;
for d in &discovered {
let existing = db
.get_appimage_by_path(&d.path.to_string_lossy())
.ok()
.flatten();
let modified = d.modified_time
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
let id = db.upsert_appimage(
&d.path.to_string_lossy(),
&d.filename,
Some(d.appimage_type.as_i32()),
d.size_bytes as i64,
d.is_executable,
modified.as_deref(),
).unwrap_or(0);
if existing.is_none() {
new_count += 1;
println!(" [NEW] {}", d.filename);
}
let needs_metadata = existing
.as_ref()
.map(|r| r.app_name.is_none())
.unwrap_or(true);
if needs_metadata {
print!(" Inspecting {}... ", d.filename);
match inspector::inspect_appimage(&d.path, &d.appimage_type) {
Ok(metadata) => {
let categories = if metadata.categories.is_empty() {
None
} else {
Some(metadata.categories.join(";"))
};
db.update_metadata(
id,
metadata.app_name.as_deref(),
metadata.app_version.as_deref(),
metadata.description.as_deref(),
metadata.developer.as_deref(),
categories.as_deref(),
metadata.architecture.as_deref(),
metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(),
Some(&metadata.desktop_entry_content),
).ok();
println!(
"{}",
metadata.app_name.as_deref().unwrap_or("(no name)")
);
}
Err(e) => {
println!("failed: {}", e);
}
}
}
}
let duration = start.elapsed();
db.log_scan(
"cli",
&dirs,
total as i32,
new_count,
0,
duration.as_millis() as i64,
).ok();
println!();
println!(
"Scan complete: {} found, {} new ({:.1}s)",
total,
new_count,
duration.as_secs_f64(),
);
ExitCode::SUCCESS
}
fn cmd_integrate(db: &Database, path: &str) -> ExitCode {
let record = match db.get_appimage_by_path(path) {
Ok(Some(r)) => r,
Ok(None) => {
eprintln!("Error: '{}' is not in the database. Run 'driftwood scan' first.", path);
return ExitCode::FAILURE;
}
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::FAILURE;
}
};
if record.integrated {
println!("{} is already integrated.", record.app_name.as_deref().unwrap_or(&record.filename));
return ExitCode::SUCCESS;
}
match integrator::integrate(&record) {
Ok(result) => {
db.set_integrated(
record.id,
true,
Some(&result.desktop_file_path.to_string_lossy()),
).ok();
println!(
"Integrated {} -> {}",
record.app_name.as_deref().unwrap_or(&record.filename),
result.desktop_file_path.display(),
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::FAILURE
}
}
}
fn cmd_remove(db: &Database, path: &str) -> ExitCode {
let record = match db.get_appimage_by_path(path) {
Ok(Some(r)) => r,
Ok(None) => {
eprintln!("Error: '{}' is not in the database.", path);
return ExitCode::FAILURE;
}
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::FAILURE;
}
};
if !record.integrated {
println!("{} is not integrated.", record.app_name.as_deref().unwrap_or(&record.filename));
return ExitCode::SUCCESS;
}
match integrator::remove_integration(&record) {
Ok(()) => {
db.set_integrated(record.id, false, None).ok();
println!(
"Removed integration for {}",
record.app_name.as_deref().unwrap_or(&record.filename),
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: {}", e);
ExitCode::FAILURE
}
}
}
fn cmd_clean() -> ExitCode {
let orphans = orphan::detect_orphans();
if orphans.is_empty() {
println!("No orphaned desktop entries found.");
return ExitCode::SUCCESS;
}
println!("Found {} orphaned entries:", orphans.len());
for entry in &orphans {
println!(
" {} (was: {})",
entry.app_name.as_deref().unwrap_or("Unknown"),
entry.original_appimage_path,
);
}
match orphan::clean_all_orphans() {
Ok(summary) => {
println!(
"Cleaned {} desktop entries, {} icons",
summary.entries_removed,
summary.icons_removed,
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error during cleanup: {}", e);
ExitCode::FAILURE
}
}
}
fn cmd_inspect(path: &str) -> ExitCode {
let file_path = std::path::Path::new(path);
if !file_path.exists() {
eprintln!("Error: file not found: {}", path);
return ExitCode::FAILURE;
}
// Detect AppImage type
let discovered = discovery::scan_directories(&[path.to_string()]);
if discovered.is_empty() {
// Try scanning the parent directory and finding by path
let parent = file_path.parent().unwrap_or(std::path::Path::new("."));
let all = discovery::scan_directories(&[parent.to_string_lossy().to_string()]);
let found = all.iter().find(|d| d.path == file_path);
if let Some(d) = found {
return do_inspect(file_path, &d.appimage_type);
}
eprintln!("Error: '{}' does not appear to be an AppImage", path);
return ExitCode::FAILURE;
}
let d = &discovered[0];
do_inspect(file_path, &d.appimage_type)
}
fn cmd_status() -> ExitCode {
println!("System Status");
println!("=============");
println!();
// Display server
let session = wayland::detect_session_type();
println!(" Display server: {}", session.label());
// Desktop environment
let de = wayland::detect_desktop_environment();
println!(" Desktop: {}", de);
// XWayland
println!(
" XWayland: {}",
if wayland::has_xwayland() {
"running"
} else {
"not detected"
}
);
println!();
// FUSE
let fuse_info = fuse::detect_system_fuse();
println!(" FUSE status: {}", fuse_info.status.label());
println!(" libfuse2: {}", if fuse_info.has_libfuse2 { "yes" } else { "no" });
println!(" libfuse3: {}", if fuse_info.has_libfuse3 { "yes" } else { "no" });
println!(" fusermount: {}", fuse_info.fusermount_path.as_deref().unwrap_or("not found"));
println!(" /dev/fuse: {}", if fuse_info.has_dev_fuse { "present" } else { "missing" });
if let Some(ref hint) = fuse_info.install_hint {
println!();
println!(" Fix: {}", hint);
}
// AppImageLauncher
if let Some(version) = fuse::detect_appimagelauncher() {
println!();
println!(" WARNING: AppImageLauncher v{} detected (may conflict)", version);
}
println!();
// AppImageUpdate tool
println!(
" AppImageUpdate: {}",
if updater::has_appimage_update_tool() {
"available (delta updates enabled)"
} else {
"not found (full downloads only)"
}
);
ExitCode::SUCCESS
}
fn cmd_check_updates(db: &Database) -> ExitCode {
let records = match db.get_all_appimages() {
Ok(r) => r,
Err(e) => {
eprintln!("Error: {}", e);
return ExitCode::FAILURE;
}
};
if records.is_empty() {
println!("No AppImages found. Run 'driftwood scan' first.");
return ExitCode::SUCCESS;
}
println!("Checking {} AppImages for updates...", records.len());
println!();
let mut updates_found = 0;
for record in &records {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let appimage_path = std::path::Path::new(&record.path);
if !appimage_path.exists() {
continue;
}
print!(" {} ... ", name);
let (type_label, raw_info, check_result) = updater::check_appimage_for_update(
appimage_path,
record.app_version.as_deref(),
);
// Store update info
if raw_info.is_some() || type_label.is_some() {
db.update_update_info(record.id, raw_info.as_deref(), type_label.as_deref()).ok();
}
match check_result {
Some(result) if result.update_available => {
let latest = result.latest_version.as_deref().unwrap_or("unknown");
println!(
"UPDATE AVAILABLE ({} -> {})",
record.app_version.as_deref().unwrap_or("?"),
latest,
);
db.set_update_available(record.id, Some(latest), result.download_url.as_deref()).ok();
updates_found += 1;
}
Some(_) => {
println!("up to date");
db.clear_update_available(record.id).ok();
}
None => {
if raw_info.is_none() {
println!("no update info");
} else {
println!("check failed");
}
}
}
}
println!();
if updates_found == 0 {
println!("All AppImages are up to date.");
} else {
println!("{} update{} available.", updates_found, if updates_found == 1 { "" } else { "s" });
}
ExitCode::SUCCESS
}
fn cmd_duplicates(db: &Database) -> ExitCode {
let groups = duplicates::detect_duplicates(db);
if groups.is_empty() {
println!("No duplicate or multi-version AppImages found.");
return ExitCode::SUCCESS;
}
let summary = duplicates::summarize_duplicates(&groups);
println!(
"Found {} duplicate groups ({} exact, {} multi-version)",
summary.total_groups,
summary.exact_duplicates,
summary.multi_version,
);
println!(
"Potential savings: {}",
humansize::format_size(summary.total_potential_savings, humansize::BINARY),
);
println!();
for group in &groups {
println!(" {} ({})", group.app_name, group.match_reason.label());
for member in &group.members {
let r = &member.record;
let version = r.app_version.as_deref().unwrap_or("?");
let size = humansize::format_size(r.size_bytes as u64, humansize::BINARY);
let rec = member.recommendation.label();
println!(" {} v{} ({}) - {}", r.path, version, size, rec);
}
if group.potential_savings > 0 {
println!(
" Savings: {}",
humansize::format_size(group.potential_savings, humansize::BINARY),
);
}
println!();
}
ExitCode::SUCCESS
}
fn cmd_launch(db: &Database, path: &str) -> ExitCode {
let file_path = std::path::Path::new(path);
if !file_path.exists() {
eprintln!("Error: file not found: {}", path);
return ExitCode::FAILURE;
}
// Try to find in database for tracking
let record = db.get_appimage_by_path(path).ok().flatten();
if let Some(ref record) = record {
match launcher::launch_appimage(db, record.id, file_path, "cli", &[], &[]) {
launcher::LaunchResult::Started { method, .. } => {
println!(
"Launched {} ({})",
record.app_name.as_deref().unwrap_or(&record.filename),
method.as_str(),
);
ExitCode::SUCCESS
}
launcher::LaunchResult::Failed(msg) => {
eprintln!("Error: {}", msg);
ExitCode::FAILURE
}
}
} else {
// Not in database - launch without tracking
match launcher::launch_appimage_simple(file_path, &[]) {
launcher::LaunchResult::Started { method, .. } => {
println!("Launched {} ({})", path, method.as_str());
ExitCode::SUCCESS
}
launcher::LaunchResult::Failed(msg) => {
eprintln!("Error: {}", msg);
ExitCode::FAILURE
}
}
}
}
fn do_inspect(path: &std::path::Path, appimage_type: &discovery::AppImageType) -> ExitCode {
println!("Inspecting: {}", path.display());
println!("Type: {:?}", appimage_type);
match inspector::inspect_appimage(path, appimage_type) {
Ok(metadata) => {
println!("Name: {}", metadata.app_name.as_deref().unwrap_or("(unknown)"));
println!("Version: {}", metadata.app_version.as_deref().unwrap_or("(unknown)"));
if let Some(ref desc) = metadata.description {
println!("Description: {}", desc);
}
if let Some(ref arch) = metadata.architecture {
println!("Architecture: {}", arch);
}
if !metadata.categories.is_empty() {
println!("Categories: {}", metadata.categories.join(", "));
}
if let Some(ref icon) = metadata.cached_icon_path {
println!("Icon: {}", icon.display());
}
if !metadata.desktop_entry_content.is_empty() {
println!();
println!("--- Desktop Entry ---");
println!("{}", metadata.desktop_entry_content);
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Inspection failed: {}", e);
ExitCode::FAILURE
}
}
}