Files
driftwood/src/cli.rs

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
}