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, files: Vec, 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::>() .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, 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 = 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 = 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) }