1094 lines
33 KiB
Rust
1094 lines
33 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::verification;
|
|
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,
|
|
},
|
|
/// Update all AppImages that have available updates
|
|
UpdateAll,
|
|
/// Enable or disable autostart for an AppImage
|
|
Autostart {
|
|
/// Path to the AppImage
|
|
path: String,
|
|
/// Enable autostart
|
|
#[arg(long, conflicts_with = "disable")]
|
|
enable: bool,
|
|
/// Disable autostart
|
|
#[arg(long)]
|
|
disable: bool,
|
|
},
|
|
/// Remove all system modifications made by Driftwood
|
|
Purge,
|
|
/// Verify an AppImage's integrity (signature or SHA256)
|
|
Verify {
|
|
/// Path to the AppImage
|
|
path: String,
|
|
/// Expected SHA256 hash (if not provided, checks embedded signature)
|
|
#[arg(long)]
|
|
sha256: Option<String>,
|
|
},
|
|
/// Export app library to a JSON file
|
|
Export {
|
|
/// Output file path (default: stdout)
|
|
#[arg(long)]
|
|
output: Option<String>,
|
|
},
|
|
/// Import app library from a JSON file
|
|
Import {
|
|
/// Path to the JSON file to import
|
|
file: 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),
|
|
Commands::UpdateAll => cmd_update_all(&db),
|
|
Commands::Autostart { path, enable, disable } => cmd_autostart(&db, &path, enable, disable),
|
|
Commands::Purge => cmd_purge(&db),
|
|
Commands::Verify { path, sha256 } => cmd_verify(&path, sha256.as_deref()),
|
|
Commands::Export { output } => cmd_export(&db, output.as_deref()),
|
|
Commands::Import { file } => cmd_import(&db, &file),
|
|
}
|
|
}
|
|
|
|
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" {
|
|
let items: Vec<serde_json::Value> = records
|
|
.iter()
|
|
.map(|r| {
|
|
serde_json::json!({
|
|
"name": r.app_name.as_deref().unwrap_or(&r.filename),
|
|
"version": r.app_version.as_deref().unwrap_or(""),
|
|
"path": r.path,
|
|
"size": r.size_bytes,
|
|
"integrated": r.integrated,
|
|
})
|
|
})
|
|
.collect();
|
|
println!("{}", serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".into()));
|
|
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_tracked(&record, &db) {
|
|
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;
|
|
}
|
|
|
|
integrator::undo_all_modifications(&db, record.id).ok();
|
|
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();
|
|
|
|
let launch_result = if let Some(ref record) = record {
|
|
launcher::launch_appimage(db, record.id, file_path, "cli", &[], &[])
|
|
} else {
|
|
launcher::launch_appimage_simple(file_path, &[])
|
|
};
|
|
|
|
match launch_result {
|
|
launcher::LaunchResult::Started { pid, method } => {
|
|
let name = record.as_ref()
|
|
.and_then(|r| r.app_name.as_deref())
|
|
.unwrap_or(path);
|
|
println!("Launched {} (PID: {}, {})", name, pid, method.as_str());
|
|
ExitCode::SUCCESS
|
|
}
|
|
launcher::LaunchResult::Crashed { exit_code, stderr, method } => {
|
|
let name = record.as_ref()
|
|
.and_then(|r| r.app_name.as_deref())
|
|
.unwrap_or(path);
|
|
eprintln!(
|
|
"{} crashed on launch (exit code: {}, method: {})\n{}",
|
|
name,
|
|
exit_code.map(|c: i32| c.to_string()).unwrap_or_else(|| "unknown".into()),
|
|
method.as_str(),
|
|
stderr,
|
|
);
|
|
ExitCode::FAILURE
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_update_all(db: &Database) -> ExitCode {
|
|
let records = match db.get_all_appimages() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
let updatable: Vec<_> = records
|
|
.iter()
|
|
.filter(|r| r.latest_version.is_some())
|
|
.collect();
|
|
|
|
if updatable.is_empty() {
|
|
println!("No updates available. Run 'driftwood check-updates' first.");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
println!("Updating {} AppImages...", updatable.len());
|
|
let mut success_count = 0u32;
|
|
let mut fail_count = 0u32;
|
|
|
|
for record in &updatable {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
let latest = record.latest_version.as_deref().unwrap_or("?");
|
|
print!(" {} -> {} ... ", name, latest);
|
|
|
|
let appimage_path = std::path::Path::new(&record.path);
|
|
match updater::perform_update(appimage_path, record.update_url.as_deref(), true, None) {
|
|
Ok(result) => {
|
|
println!("done ({})", result.new_path.display());
|
|
// Store rollback path
|
|
if let Some(ref backup) = result.old_path_backup {
|
|
db.set_previous_version(record.id, Some(&backup.to_string_lossy())).ok();
|
|
}
|
|
db.clear_update_available(record.id).ok();
|
|
// Record in update history
|
|
db.record_update(
|
|
record.id,
|
|
record.app_version.as_deref(),
|
|
Some(latest),
|
|
Some("cli"),
|
|
None,
|
|
true,
|
|
).ok();
|
|
success_count += 1;
|
|
}
|
|
Err(e) => {
|
|
println!("FAILED: {}", e);
|
|
fail_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!(
|
|
"Updated {} of {} AppImages ({} failed)",
|
|
success_count,
|
|
updatable.len(),
|
|
fail_count,
|
|
);
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn cmd_autostart(db: &Database, path: &str, enable: bool, disable: bool) -> 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;
|
|
}
|
|
};
|
|
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
|
|
if enable {
|
|
match integrator::enable_autostart(db, &record) {
|
|
Ok(desktop_path) => {
|
|
println!("Autostart enabled for {} ({})", name, desktop_path.display());
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
} else if disable {
|
|
match integrator::disable_autostart(db, record.id) {
|
|
Ok(()) => {
|
|
println!("Autostart disabled for {}", name);
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
} else {
|
|
// Show current status
|
|
println!("{}: autostart {}", name, if record.autostart { "enabled" } else { "disabled" });
|
|
ExitCode::SUCCESS
|
|
}
|
|
}
|
|
|
|
fn cmd_purge(db: &Database) -> ExitCode {
|
|
let records = match db.get_all_appimages() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
let mut total_mods = 0u32;
|
|
for record in &records {
|
|
let mods = db.get_modifications(record.id).unwrap_or_default();
|
|
if !mods.is_empty() {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
println!(" Undoing {} modifications for {}", mods.len(), name);
|
|
total_mods += mods.len() as u32;
|
|
integrator::undo_all_modifications(db, record.id).ok();
|
|
}
|
|
}
|
|
|
|
if total_mods == 0 {
|
|
println!("No system modifications to undo.");
|
|
} else {
|
|
println!("Removed {} system modifications.", total_mods);
|
|
}
|
|
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn cmd_verify(path: &str, expected_sha256: Option<&str>) -> ExitCode {
|
|
let file_path = std::path::Path::new(path);
|
|
if !file_path.exists() {
|
|
eprintln!("Error: file not found: {}", path);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
|
|
if let Some(expected) = expected_sha256 {
|
|
// SHA256 verification
|
|
println!("Verifying SHA256 for {}...", path);
|
|
let status = verification::verify_sha256(file_path, expected);
|
|
println!(" {}", status.label());
|
|
match status {
|
|
verification::VerificationStatus::ChecksumMatch => ExitCode::SUCCESS,
|
|
_ => ExitCode::FAILURE,
|
|
}
|
|
} else {
|
|
// Embedded signature check
|
|
println!("Checking embedded signature for {}...", path);
|
|
let status = verification::check_embedded_signature(file_path);
|
|
println!(" {}", status.label());
|
|
|
|
// Also compute and display SHA256
|
|
println!();
|
|
match verification::compute_sha256(file_path) {
|
|
Ok(hash) => {
|
|
println!(" SHA256: {}", hash);
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(e) => {
|
|
eprintln!(" SHA256 computation failed: {}", e);
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Export/Import library ---
|
|
|
|
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
|
|
let records = match db.get_all_appimages() {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
let appimages: Vec<serde_json::Value> = records
|
|
.iter()
|
|
.map(|r| {
|
|
serde_json::json!({
|
|
"path": r.path,
|
|
"app_name": r.app_name,
|
|
"app_version": r.app_version,
|
|
"integrated": r.integrated,
|
|
"notes": r.notes,
|
|
"categories": r.categories,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let export_data = serde_json::json!({
|
|
"version": 1,
|
|
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
|
"appimages": appimages,
|
|
});
|
|
|
|
let json_str = match serde_json::to_string_pretty(&export_data) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Error serializing export data: {}", e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
if let Some(path) = output {
|
|
if let Err(e) = std::fs::write(path, &json_str) {
|
|
eprintln!("Error writing to {}: {}", path, e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
} else {
|
|
println!("{}", json_str);
|
|
}
|
|
|
|
eprintln!("Exported {} AppImages", records.len());
|
|
ExitCode::SUCCESS
|
|
}
|
|
|
|
fn cmd_import(db: &Database, file: &str) -> ExitCode {
|
|
let content = match std::fs::read_to_string(file) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("Error reading {}: {}", file, e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
let data: serde_json::Value = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Error parsing JSON: {}", e);
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
let entries = match data.get("appimages").and_then(|a| a.as_array()) {
|
|
Some(arr) => arr,
|
|
None => {
|
|
eprintln!("Error: JSON missing 'appimages' array");
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
let total = entries.len();
|
|
let mut imported = 0u32;
|
|
let mut skipped = 0u32;
|
|
|
|
for entry in entries {
|
|
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
|
|
Some(p) => p,
|
|
None => {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let file_path = std::path::Path::new(path_str);
|
|
if !file_path.exists() {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
// Validate that the file is actually an AppImage
|
|
let appimage_type = match discovery::detect_appimage(file_path) {
|
|
Some(t) => t,
|
|
None => {
|
|
eprintln!(" Skipping {} - not a valid AppImage", path_str);
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let metadata = std::fs::metadata(file_path);
|
|
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
|
|
let is_executable = metadata
|
|
.as_ref()
|
|
.map(|m| {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
m.permissions().mode() & 0o111 != 0
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
let filename = file_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
|
|
let file_modified = metadata
|
|
.as_ref()
|
|
.ok()
|
|
.and_then(|m| m.modified().ok())
|
|
.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 = match db.upsert_appimage(
|
|
path_str,
|
|
&filename,
|
|
Some(appimage_type.as_i32()),
|
|
size_bytes,
|
|
is_executable,
|
|
file_modified.as_deref(),
|
|
) {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
eprintln!(" Error registering {}: {}", path_str, e);
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Restore metadata fields from the export
|
|
let app_name = entry.get("app_name").and_then(|v| v.as_str());
|
|
let app_version = entry.get("app_version").and_then(|v| v.as_str());
|
|
let categories = entry.get("categories").and_then(|v| v.as_str());
|
|
|
|
if app_name.is_some() || app_version.is_some() {
|
|
db.update_metadata(
|
|
id,
|
|
app_name,
|
|
app_version,
|
|
None,
|
|
None,
|
|
categories,
|
|
None,
|
|
None,
|
|
None,
|
|
).ok();
|
|
}
|
|
|
|
// Restore notes if present
|
|
if let Some(notes_str) = entry.get("notes").and_then(|v| v.as_str()) {
|
|
db.update_notes(id, Some(notes_str)).ok();
|
|
}
|
|
|
|
// If it was integrated in the export, integrate it now
|
|
let was_integrated = entry
|
|
.get("integrated")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
if was_integrated {
|
|
// Need the full record to integrate
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
|
|
if !record.integrated {
|
|
match integrator::integrate_tracked(&record, &db) {
|
|
Ok(result) => {
|
|
db.set_integrated(
|
|
id,
|
|
true,
|
|
Some(&result.desktop_file_path.to_string_lossy()),
|
|
).ok();
|
|
}
|
|
Err(e) => {
|
|
eprintln!(" Warning: could not integrate {}: {}", path_str, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
imported += 1;
|
|
}
|
|
|
|
eprintln!(
|
|
"Imported {} of {} AppImages ({} skipped - file not found)",
|
|
imported,
|
|
total,
|
|
skipped,
|
|
);
|
|
|
|
ExitCode::SUCCESS
|
|
}
|