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.
664 lines
20 KiB
Rust
664 lines
20 KiB
Rust
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
|
|
}
|
|
}
|
|
}
|