Fast icon extraction pulls .DirIcon from the squashfs without full analysis. Single-file drops show a 64px preview in the dialog while the user chooses an import option.
345 lines
12 KiB
Rust
345 lines
12 KiB
Rust
use adw::prelude::*;
|
|
use gtk::gio;
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
|
|
use crate::core::analysis;
|
|
use crate::core::database::Database;
|
|
use crate::core::discovery;
|
|
use crate::core::inspector;
|
|
use crate::i18n::{i18n, ni18n_f};
|
|
|
|
/// Registered file info returned by the fast registration phase.
|
|
struct RegisteredFile {
|
|
id: i64,
|
|
path: PathBuf,
|
|
appimage_type: discovery::AppImageType,
|
|
}
|
|
|
|
/// Show a dialog offering to add dropped AppImage files to the library.
|
|
///
|
|
/// `files` should already be validated as AppImages (magic bytes checked).
|
|
/// `toast_overlay` is used to show result toasts.
|
|
/// `on_complete` is called after files are registered to refresh the UI.
|
|
pub fn show_drop_dialog(
|
|
parent: &impl IsA<gtk::Widget>,
|
|
files: Vec<PathBuf>,
|
|
toast_overlay: &adw::ToastOverlay,
|
|
on_complete: impl Fn() + 'static,
|
|
) {
|
|
if files.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let count = files.len();
|
|
|
|
// Build heading and body
|
|
let heading = if count == 1 {
|
|
let name = files[0]
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| "AppImage".to_string());
|
|
crate::i18n::i18n_f("Add {name}?", &[("{name}", &name)])
|
|
} else {
|
|
ni18n_f(
|
|
"Add {} app?",
|
|
"Add {} apps?",
|
|
count as u32,
|
|
&[("{}", &count.to_string())],
|
|
)
|
|
};
|
|
|
|
let body = if count == 1 {
|
|
files[0].to_string_lossy().to_string()
|
|
} else {
|
|
files
|
|
.iter()
|
|
.filter_map(|f| f.file_name().map(|n| n.to_string_lossy().into_owned()))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
};
|
|
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(&heading)
|
|
.body(&body)
|
|
.build();
|
|
|
|
// For single file, try fast icon extraction for a preview
|
|
if count == 1 {
|
|
let file_path = files[0].clone();
|
|
let dialog_ref = dialog.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
inspector::extract_icon_fast(&file_path)
|
|
})
|
|
.await;
|
|
|
|
if let Ok(Some(icon_path)) = result {
|
|
let image = gtk::Image::builder()
|
|
.file(icon_path.to_string_lossy().as_ref())
|
|
.pixel_size(64)
|
|
.halign(gtk::Align::Center)
|
|
.margin_top(12)
|
|
.build();
|
|
dialog_ref.set_extra_child(Some(&image));
|
|
}
|
|
});
|
|
}
|
|
|
|
dialog.add_response("cancel", &i18n("Cancel"));
|
|
dialog.add_response("keep-in-place", &i18n("Keep in place"));
|
|
dialog.add_response("copy-only", &i18n("Copy to Applications"));
|
|
dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu"));
|
|
|
|
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
|
|
dialog.set_default_response(Some("copy-and-integrate"));
|
|
dialog.set_close_response("cancel");
|
|
|
|
let toast_ref = toast_overlay.clone();
|
|
let on_complete = Rc::new(on_complete);
|
|
dialog.connect_response(None, move |_dialog, response| {
|
|
if response == "cancel" {
|
|
return;
|
|
}
|
|
|
|
let copy = response != "keep-in-place";
|
|
let integrate = response == "copy-and-integrate";
|
|
let files = files.clone();
|
|
let toast_ref = toast_ref.clone();
|
|
let on_complete_ref = on_complete.clone();
|
|
|
|
glib::spawn_future_local(async move {
|
|
// Phase 1: Fast registration (copy + DB upsert only)
|
|
let result = gio::spawn_blocking(move || {
|
|
register_dropped_files(&files, copy)
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(registered)) => {
|
|
let added = registered.len();
|
|
|
|
// Refresh UI immediately - apps appear with "Analyzing..." badge
|
|
on_complete_ref();
|
|
|
|
// Show toast
|
|
if added == 1 {
|
|
toast_ref.add_toast(adw::Toast::new(&i18n("Added to your apps")));
|
|
} else if added > 0 {
|
|
let msg = ni18n_f(
|
|
"Added {} app",
|
|
"Added {} apps",
|
|
added as u32,
|
|
&[("{}", &added.to_string())],
|
|
);
|
|
toast_ref.add_toast(adw::Toast::new(&msg));
|
|
}
|
|
|
|
// Phase 2: Background analysis for each file
|
|
let on_complete_bg = on_complete_ref.clone();
|
|
for reg in registered {
|
|
let on_complete_inner = on_complete_bg.clone();
|
|
glib::spawn_future_local(async move {
|
|
let _ = gio::spawn_blocking(move || {
|
|
analysis::run_background_analysis(
|
|
reg.id,
|
|
reg.path,
|
|
reg.appimage_type,
|
|
integrate,
|
|
);
|
|
})
|
|
.await;
|
|
|
|
// Refresh UI when each analysis completes
|
|
on_complete_inner();
|
|
});
|
|
}
|
|
}
|
|
Ok(Err(e)) => {
|
|
log::error!("Drop processing failed: {}", e);
|
|
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
|
|
}
|
|
Err(e) => {
|
|
log::error!("Drop task failed: {:?}", e);
|
|
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
dialog.present(Some(parent));
|
|
}
|
|
|
|
/// Fast registration of dropped files - copies to target dir and inserts into DB.
|
|
/// Returns a list of registered files for background analysis.
|
|
fn register_dropped_files(
|
|
files: &[PathBuf],
|
|
copy_to_target: bool,
|
|
) -> Result<Vec<RegisteredFile>, String> {
|
|
let db = Database::open().map_err(|e| format!("Failed to open database: {}", e))?;
|
|
|
|
let settings = gio::Settings::new(crate::config::APP_ID);
|
|
let scan_dirs: Vec<String> = settings
|
|
.strv("scan-directories")
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
|
|
// Target directory: first scan directory (default ~/Applications)
|
|
let target_dir = scan_dirs
|
|
.first()
|
|
.map(|d| discovery::expand_tilde(d))
|
|
.unwrap_or_else(|| {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join("Applications")
|
|
});
|
|
|
|
// Ensure target directory exists
|
|
std::fs::create_dir_all(&target_dir)
|
|
.map_err(|e| format!("Failed to create {}: {}", target_dir.display(), e))?;
|
|
|
|
// Expand scan dirs for checking if file is already in a scan location
|
|
let expanded_scan_dirs: Vec<PathBuf> = scan_dirs
|
|
.iter()
|
|
.map(|d| discovery::expand_tilde(d))
|
|
.collect();
|
|
|
|
let mut registered = Vec::new();
|
|
|
|
for file in files {
|
|
// Determine if the file is already in a scan directory
|
|
let in_scan_dir = expanded_scan_dirs.iter().any(|scan_dir| {
|
|
file.parent()
|
|
.and_then(|p| p.canonicalize().ok())
|
|
.and_then(|parent| {
|
|
scan_dir.canonicalize().ok().map(|sd| parent == sd)
|
|
})
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
let final_path = if in_scan_dir || !copy_to_target {
|
|
// Keep the file where it is; just ensure it's executable
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Ok(meta) = std::fs::metadata(file) {
|
|
if meta.permissions().mode() & 0o111 == 0 {
|
|
let perms = std::fs::Permissions::from_mode(0o755);
|
|
if let Err(e) = std::fs::set_permissions(file, perms) {
|
|
log::warn!("Failed to set executable on {}: {}", file.display(), e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
file.clone()
|
|
} else {
|
|
let filename = file
|
|
.file_name()
|
|
.ok_or_else(|| "No filename".to_string())?;
|
|
let dest = target_dir.join(filename);
|
|
|
|
// Don't overwrite existing files - generate a unique name
|
|
let dest = if dest.exists() && dest != *file {
|
|
let stem = dest
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("app");
|
|
let ext = dest
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("AppImage");
|
|
let mut counter = 1;
|
|
loop {
|
|
let candidate = target_dir.join(format!("{}-{}.{}", stem, counter, ext));
|
|
if !candidate.exists() {
|
|
break candidate;
|
|
}
|
|
counter += 1;
|
|
}
|
|
} else {
|
|
dest
|
|
};
|
|
|
|
std::fs::copy(file, &dest)
|
|
.map_err(|e| format!("Failed to copy {}: {}", file.display(), e))?;
|
|
|
|
// Make executable
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = std::fs::Permissions::from_mode(0o755);
|
|
if let Err(e) = std::fs::set_permissions(&dest, perms) {
|
|
log::warn!("Failed to set executable permissions on {}: {}", dest.display(), e);
|
|
}
|
|
}
|
|
|
|
dest
|
|
};
|
|
|
|
// Validate it's actually an AppImage
|
|
let appimage_type = match discovery::detect_appimage(&final_path) {
|
|
Some(t) => t,
|
|
None => {
|
|
log::warn!("Not a valid AppImage: {}", final_path.display());
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let filename = final_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
|
|
let metadata = std::fs::metadata(&final_path).ok();
|
|
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
|
|
let modified = metadata
|
|
.as_ref()
|
|
.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 is_executable = {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
metadata
|
|
.as_ref()
|
|
.map(|m| m.permissions().mode() & 0o111 != 0)
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
true
|
|
}
|
|
};
|
|
|
|
// Register in database with pending analysis status
|
|
let id = db
|
|
.upsert_appimage(
|
|
&final_path.to_string_lossy(),
|
|
&filename,
|
|
Some(appimage_type.as_i32()),
|
|
size_bytes,
|
|
is_executable,
|
|
modified.as_deref(),
|
|
)
|
|
.map_err(|e| format!("Database error: {}", e))?;
|
|
|
|
if let Err(e) = db.update_analysis_status(id, "pending") {
|
|
log::warn!("Failed to set analysis status to 'pending' for id {}: {}", id, e);
|
|
}
|
|
|
|
registered.push(RegisteredFile {
|
|
id,
|
|
path: final_path,
|
|
appimage_type,
|
|
});
|
|
}
|
|
|
|
Ok(registered)
|
|
}
|