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, } #[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!( " {:10} {}", "Name", "Version", "Size", "Integrated", name_w = name_width, ver_w = ver_width, ); println!( " {:-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!( " {: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 = 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 } } }