use adw::prelude::*; use adw::subclass::prelude::*; use gtk::gio; use std::cell::OnceCell; use std::rc::Rc; use std::time::Instant; use crate::config::APP_ID; use crate::core::analysis; use crate::core::database::Database; use crate::core::discovery; use crate::core::integrator; use crate::core::launcher; use crate::core::notification; use crate::core::orphan; use crate::core::security; use crate::core::updater; use crate::core::watcher; use crate::i18n::{i18n, ni18n_f}; use crate::ui::cleanup_wizard; use crate::ui::dashboard; use crate::ui::detail_view; use crate::ui::drop_dialog; use crate::ui::duplicate_dialog; use crate::ui::library_view::{LibraryState, LibraryView}; use crate::ui::preferences; use crate::ui::security_report; use crate::ui::update_dialog; use crate::ui::widgets; mod imp { use super::*; pub struct DriftwoodWindow { pub settings: OnceCell, pub toast_overlay: OnceCell, pub navigation_view: OnceCell, pub library_view: OnceCell, pub database: OnceCell>, pub drop_overlay: OnceCell, pub drop_revealer: OnceCell, pub watcher_handle: std::cell::RefCell>, } impl Default for DriftwoodWindow { fn default() -> Self { Self { settings: OnceCell::new(), toast_overlay: OnceCell::new(), navigation_view: OnceCell::new(), library_view: OnceCell::new(), database: OnceCell::new(), drop_overlay: OnceCell::new(), drop_revealer: OnceCell::new(), watcher_handle: std::cell::RefCell::new(None), } } } #[glib::object_subclass] impl ObjectSubclass for DriftwoodWindow { const NAME: &'static str = "DriftwoodWindow"; type Type = super::DriftwoodWindow; type ParentType = adw::ApplicationWindow; } impl ObjectImpl for DriftwoodWindow { fn constructed(&self) { self.parent_constructed(); let window = self.obj(); window.setup_settings(); window.setup_database(); window.setup_ui(); window.restore_window_state(); window.load_initial_data(); } } impl WidgetImpl for DriftwoodWindow {} impl WindowImpl for DriftwoodWindow { fn close_request(&self) -> glib::Propagation { self.obj().save_window_state(); self.parent_close_request() } } impl ApplicationWindowImpl for DriftwoodWindow {} impl AdwApplicationWindowImpl for DriftwoodWindow {} } glib::wrapper! { pub struct DriftwoodWindow(ObjectSubclass) @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; } fn shortcut_row(accel: &str, description: &str) -> adw::ActionRow { let row = adw::ActionRow::builder() .title(description) .build(); let accel_label = gtk::Label::builder() .label(accel) .css_classes(["monospace", "dimmed"]) .valign(gtk::Align::Center) .build(); row.add_suffix(&accel_label); row } impl DriftwoodWindow { pub fn new(app: &crate::application::DriftwoodApplication) -> Self { glib::Object::builder() .property("application", app) .build() } fn setup_settings(&self) { let settings = gio::Settings::new(APP_ID); self.imp() .settings .set(settings) .expect("Settings already initialized"); } fn settings(&self) -> &gio::Settings { self.imp().settings.get().expect("Settings not initialized") } fn setup_database(&self) { let db = Database::open().expect("Failed to open database"); if self.imp().database.set(Rc::new(db)).is_err() { panic!("Database already initialized"); } } fn database(&self) -> &Rc { self.imp().database.get().expect("Database not initialized") } fn setup_ui(&self) { // Build the hamburger menu model let menu = gio::Menu::new(); menu.append(Some(&i18n("Dashboard")), Some("win.dashboard")); menu.append(Some(&i18n("Preferences")), Some("win.preferences")); let section2 = gio::Menu::new(); section2.append(Some(&i18n("Scan for AppImages")), Some("win.scan")); section2.append(Some(&i18n("Check for Updates")), Some("win.check-updates")); section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates")); section2.append(Some(&i18n("Security Report")), Some("win.security-report")); section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup")); menu.append_section(None, §ion2); let section3 = gio::Menu::new(); section3.append(Some(&i18n("Keyboard Shortcuts")), Some("win.show-shortcuts")); section3.append(Some(&i18n("About Driftwood")), Some("app.about")); menu.append_section(None, §ion3); // Library view (contains header bar, search, grid/list, empty state) let library_view = LibraryView::new(&menu); // Navigation view let navigation_view = adw::NavigationView::new(); navigation_view.push(&library_view.page); // Drop overlay - centered opaque card over a dimmed scrim let drop_overlay_icon = gtk::Image::builder() .icon_name("document-open-symbolic") .pixel_size(64) .halign(gtk::Align::Center) .build(); drop_overlay_icon.add_css_class("drop-zone-icon"); let drop_overlay_title = gtk::Label::builder() .label(&i18n("Add AppImage")) .css_classes(["title-1"]) .halign(gtk::Align::Center) .build(); let drop_overlay_subtitle = gtk::Label::builder() .label(&i18n("Drop a file here or click to browse")) .css_classes(["body", "dimmed"]) .halign(gtk::Align::Center) .build(); // The card itself - acts as a clickable button to open file picker let drop_zone_card = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(16) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .width_request(320) .build(); drop_zone_card.add_css_class("drop-zone-card"); drop_zone_card.set_cursor_from_name(Some("pointer")); drop_zone_card.append(&drop_overlay_icon); drop_zone_card.append(&drop_overlay_title); drop_zone_card.append(&drop_overlay_subtitle); // Click on the card opens file picker (stop propagation so scrim doesn't dismiss) { let window_weak = self.downgrade(); let card_click = gtk::GestureClick::new(); card_click.connect_pressed(move |gesture, _, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); let Some(window) = window_weak.upgrade() else { return }; window.open_file_picker(); }); drop_zone_card.add_controller(card_click); } // Revealer for crossfade animation let drop_revealer = gtk::Revealer::builder() .transition_type(gtk::RevealerTransitionType::Crossfade) .transition_duration(200) .reveal_child(false) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .vexpand(true) .hexpand(true) .build(); drop_revealer.set_child(Some(&drop_zone_card)); // Scrim (dimmed background) that fills the whole window let drop_overlay_content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .halign(gtk::Align::Fill) .valign(gtk::Align::Fill) .hexpand(true) .vexpand(true) .build(); drop_overlay_content.add_css_class("drop-overlay-scrim"); drop_overlay_content.append(&drop_revealer); drop_overlay_content.set_visible(false); // Click on scrim (outside the card) to dismiss { let overlay_ref = drop_overlay_content.clone(); let revealer_ref = drop_revealer.clone(); let click = gtk::GestureClick::new(); click.connect_pressed(move |gesture, _, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); revealer_ref.set_reveal_child(false); let overlay_hide = overlay_ref.clone(); glib::timeout_add_local_once( std::time::Duration::from_millis(200), move || { overlay_hide.set_visible(false); }, ); }); drop_overlay_content.add_controller(click); } // Overlay wraps navigation view so the drop indicator sits on top let overlay = gtk::Overlay::new(); overlay.set_child(Some(&navigation_view)); overlay.add_overlay(&drop_overlay_content); // Toast overlay wraps the overlay let toast_overlay = adw::ToastOverlay::new(); toast_overlay.set_child(Some(&overlay)); // --- Drag-and-drop support --- let drop_target = gtk::DropTarget::new(gio::File::static_type(), gtk::gdk::DragAction::COPY); // Show overlay on drag enter { let drop_indicator = drop_overlay_content.clone(); let revealer_ref = drop_revealer.clone(); drop_target.connect_enter(move |_target, _x, _y| { drop_indicator.set_visible(true); revealer_ref.set_reveal_child(true); gtk::gdk::DragAction::COPY }); } // Hide overlay on drag leave { let drop_indicator = drop_overlay_content.clone(); let revealer_ref = drop_revealer.clone(); drop_target.connect_leave(move |_target| { revealer_ref.set_reveal_child(false); let overlay_hide = drop_indicator.clone(); glib::timeout_add_local_once( std::time::Duration::from_millis(200), move || { overlay_hide.set_visible(false); }, ); }); } // Handle the drop { let drop_indicator = drop_overlay_content.clone(); let revealer_ref = drop_revealer.clone(); let toast_ref = toast_overlay.clone(); let window_weak = self.downgrade(); drop_target.connect_drop(move |_target, value, _x, _y| { revealer_ref.set_reveal_child(false); let overlay_hide = drop_indicator.clone(); glib::timeout_add_local_once( std::time::Duration::from_millis(200), move || { overlay_hide.set_visible(false); }, ); let file = match value.get::() { Ok(f) => f, Err(_) => return false, }; let path = match file.path() { Some(p) => p, None => return false, }; // Validate it's an AppImage via magic bytes if discovery::detect_appimage(&path).is_none() { toast_ref.add_toast(adw::Toast::new(&i18n("Not a valid AppImage file"))); return true; } let Some(window) = window_weak.upgrade() else { return false; }; let db = window.database().clone(); let toast_for_dialog = toast_ref.clone(); let window_weak2 = window.downgrade(); drop_dialog::show_drop_dialog( &window, vec![path], &toast_for_dialog, move || { if let Some(win) = window_weak2.upgrade() { let lib_view = win.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => lib_view.set_state(LibraryState::Empty), } } }, ); true }); } toast_overlay.add_controller(drop_target); self.set_content(Some(&toast_overlay)); // Wire up card/row activation to push detail view { let nav = navigation_view.clone(); let db = self.database().clone(); library_view.connect_grid_activated(move |record_id| { if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let page = detail_view::build_detail_page(&record, &db); nav.push(&page); } }); } { let nav = navigation_view.clone(); let db = self.database().clone(); library_view.connect_list_activated(move |record_id| { if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let page = detail_view::build_detail_page(&record, &db); nav.push(&page); } }); } // Refresh library view when navigating back from detail // (in case integration or other state changed) { let db = self.database().clone(); let window_weak = self.downgrade(); navigation_view.connect_popped(move |_nav, page| { if page.tag().as_deref() == Some("detail") { if let Some(window) = window_weak.upgrade() { // Update window title for accessibility (WCAG 2.4.8) window.set_title(Some("Driftwood")); let lib_view = window.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => lib_view.set_state(LibraryState::Empty), } } } }); } // Update window title when navigating to sub-pages (WCAG 2.4.8 Location) { let window_weak = self.downgrade(); navigation_view.connect_pushed(move |nav| { if let Some(window) = window_weak.upgrade() { if let Some(page) = nav.visible_page() { let page_title = page.title(); if !page_title.is_empty() { window.set_title(Some(&format!("Driftwood - {}", page_title))); } } } }); } // Store references self.imp() .drop_overlay .set(drop_overlay_content) .expect("DropOverlay already set"); self.imp() .drop_revealer .set(drop_revealer) .expect("DropRevealer already set"); self.imp() .toast_overlay .set(toast_overlay) .expect("ToastOverlay already set"); self.imp() .navigation_view .set(navigation_view) .expect("NavigationView already set"); if self.imp().library_view.set(library_view).is_err() { panic!("LibraryView already set"); } // Set up window actions self.setup_window_actions(); } fn setup_window_actions(&self) { let dashboard_action = gio::ActionEntry::builder("dashboard") .activate(|window: &Self, _, _| { let db = window.database().clone(); let nav = window.imp().navigation_view.get().unwrap(); let page = dashboard::build_dashboard_page(&db); nav.push(&page); }) .build(); // Preferences action let preferences_action = gio::ActionEntry::builder("preferences") .activate(|window: &Self, _, _| { preferences::show_preferences_dialog(window); }) .build(); // Scan action - runs real scan let scan_action = gio::ActionEntry::builder("scan") .activate(|window: &Self, _, _| { window.trigger_scan(); }) .build(); // Clean orphans action let clean_toast = self.imp().toast_overlay.get().unwrap().clone(); let clean_action = gio::ActionEntry::builder("clean-orphans") .activate(move |_window: &Self, _, _| { let toast_ref = clean_toast.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(|| { orphan::clean_all_orphans() }) .await; match result { Ok(Ok(summary)) => { let msg = format!( "{} {} {}, {} {}", i18n("Cleaned"), summary.entries_removed, i18n("desktop entries"), summary.icons_removed, i18n("icons"), ); toast_ref.add_toast(adw::Toast::new(&msg)); } _ => { toast_ref.add_toast(adw::Toast::new(&i18n("Failed to clean orphaned entries"))); } } }); }) .build(); // Search action - toggles search bar let search_action = gio::ActionEntry::builder("search") .activate(|window: &Self, _, _| { let lib_view = window.imp().library_view.get().unwrap(); lib_view.toggle_search(); }) .build(); // Check for updates action let updates_toast = self.imp().toast_overlay.get().unwrap().clone(); let check_updates_action = gio::ActionEntry::builder("check-updates") .activate(move |_window: &Self, _, _| { let toast_ref = updates_toast.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); update_dialog::batch_check_updates(&bg_db) }) .await; match result { Ok(0) => { toast_ref.add_toast(adw::Toast::new(&i18n("All AppImages are up to date"))); } Ok(n) => { let msg = format!("{} update{} available", n, if n == 1 { "" } else { "s" }); toast_ref.add_toast(adw::Toast::new(&msg)); } Err(_) => { toast_ref.add_toast(adw::Toast::new(&i18n("Failed to check for updates"))); } } }); }) .build(); // Find duplicates action let find_duplicates_action = gio::ActionEntry::builder("find-duplicates") .activate(|window: &Self, _, _| { let db = window.database().clone(); let toast_overlay = window.imp().toast_overlay.get().unwrap(); duplicate_dialog::show_duplicate_dialog(window, &db, toast_overlay); }) .build(); // Security report action let security_report_action = gio::ActionEntry::builder("security-report") .activate(|window: &Self, _, _| { let db = window.database().clone(); let nav = window.imp().navigation_view.get().unwrap(); let page = security_report::build_security_report_page(&db); nav.push(&page); }) .build(); // Disk cleanup wizard action let cleanup_action = gio::ActionEntry::builder("cleanup") .activate(|window: &Self, _, _| { let db = window.database().clone(); cleanup_wizard::show_cleanup_wizard(window, &db); }) .build(); // Show keyboard shortcuts dialog let shortcuts_action = gio::ActionEntry::builder("show-shortcuts") .activate(|window: &Self, _, _| { window.show_shortcuts_dialog(); }) .build(); // Show drop overlay hint (triggered by "Add app" button) let show_drop_hint_action = gio::ActionEntry::builder("show-drop-hint") .activate(|window: &Self, _, _| { if let Some(overlay) = window.imp().drop_overlay.get() { overlay.set_visible(true); if let Some(revealer) = window.imp().drop_revealer.get() { revealer.set_reveal_child(true); } } }) .build(); self.add_action_entries([ dashboard_action, preferences_action, scan_action, clean_action, search_action, check_updates_action, find_duplicates_action, security_report_action, cleanup_action, shortcuts_action, show_drop_hint_action, ]); // --- Context menu actions (parameterized with record ID) --- let param_type = Some(glib::VariantTy::INT64); // Launch action let launch_action = gio::SimpleAction::new("launch-appimage", param_type); { let window_weak = self.downgrade(); launch_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); let window_ref = window.clone(); let (path_str, app_name, launch_args_raw) = { let db = window.database(); match db.get_appimage_by_id(record_id) { Ok(Some(r)) => { let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone()); (r.path.clone(), name, r.launch_args.clone()) } _ => return, } }; let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref()); glib::spawn_future_local(async move { let path_bg = path_str.clone(); let result = gio::spawn_blocking(move || { let bg_db = crate::core::database::Database::open().expect("DB open"); let appimage_path = std::path::Path::new(&path_bg); launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &launch_args, &[]) }).await; match result { Ok(launcher::LaunchResult::Started { child, method }) => { log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str()); } Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => { log::error!("App crashed (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr); widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr); } Ok(launcher::LaunchResult::Failed(msg)) => { log::error!("Failed to launch: {}", msg); let toast = adw::Toast::builder() .title(&format!("Could not launch: {}", msg)) .timeout(5) .build(); toast_overlay.add_toast(toast); } Err(_) => { log::error!("Launch task panicked"); } } }); }); } self.add_action(&launch_action); // Check for updates action (per-app) let check_update_action = gio::SimpleAction::new("check-update", param_type); { let window_weak = self.downgrade(); check_update_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("DB open failed"); if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) { let appimage_path = std::path::Path::new(&record.path); if !appimage_path.exists() { return false; } let (_type_label, raw_info, check_result) = updater::check_appimage_for_update( appimage_path, record.app_version.as_deref(), ); if raw_info.is_some() { bg_db.update_update_info(record_id, raw_info.as_deref(), None).ok(); } if let Some(result) = check_result { if result.update_available { if let Some(ref version) = result.latest_version { bg_db.set_update_available(record_id, Some(version), result.download_url.as_deref()).ok(); return true; } } else { bg_db.clear_update_available(record_id).ok(); } } } false }).await; match result { Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")), Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")), Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")), } }); }); } self.add_action(&check_update_action); // Scan for vulnerabilities (per-app) let scan_security_action = gio::SimpleAction::new("scan-security", param_type); { let window_weak = self.downgrade(); scan_security_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("DB open failed"); if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) { let appimage_path = std::path::Path::new(&record.path); let scan_result = security::scan_and_store(&bg_db, record_id, appimage_path); return Some(scan_result.total_cves()); } None }).await; match result { Ok(Some(total)) => { if total == 0 { toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found")); } else { let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" }); toast_overlay.add_toast(adw::Toast::new(&msg)); } // Send desktop notifications for new CVE findings if enabled let settings = gio::Settings::new(APP_ID); if settings.boolean("security-notifications") { let threshold = settings.string("security-notification-threshold").to_string(); glib::spawn_future_local(async move { let _ = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); notification::check_and_notify(&bg_db, &threshold); }) .await; }); } } _ => toast_overlay.add_toast(adw::Toast::new("Security scan failed")), } }); }); } self.add_action(&scan_security_action); // Toggle integration let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type); { let window_weak = self.downgrade(); toggle_integration_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let db = window.database().clone(); let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { if record.integrated { integrator::remove_integration(&record).ok(); db.set_integrated(record_id, false, None).ok(); toast_overlay.add_toast(adw::Toast::new("Integration removed")); } else { match integrator::integrate(&record) { Ok(result) => { let desktop_path = result.desktop_file_path.to_string_lossy().to_string(); db.set_integrated(record_id, true, Some(&desktop_path)).ok(); toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu")); } Err(e) => { log::error!("Integration failed: {}", e); toast_overlay.add_toast(adw::Toast::new("Integration failed")); } } } // Refresh library view let lib_view = window.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => {} } } }); } self.add_action(&toggle_integration_action); // Open containing folder let open_folder_action = gio::SimpleAction::new("open-folder", param_type); { let window_weak = self.downgrade(); open_folder_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let db = window.database().clone(); if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let file = gio::File::for_path(&record.path); let file_launcher = gtk::FileLauncher::new(Some(&file)); file_launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); } }); } self.add_action(&open_folder_action); // Copy path to clipboard let copy_path_action = gio::SimpleAction::new("copy-path", param_type); { let window_weak = self.downgrade(); copy_path_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let db = window.database().clone(); let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let display = gtk::prelude::WidgetExt::display(&window); let clipboard = display.clipboard(); clipboard.set_text(&record.path); toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard")); } }); } self.add_action(©_path_action); // Keyboard shortcuts if let Some(app) = self.application() { let gtk_app = app.downcast_ref::().unwrap(); gtk_app.set_accels_for_action("win.scan", &["r", "F5"]); gtk_app.set_accels_for_action("win.search", &["f"]); gtk_app.set_accels_for_action("win.preferences", &["comma"]); gtk_app.set_accels_for_action("win.dashboard", &["d"]); gtk_app.set_accels_for_action("win.check-updates", &["u"]); gtk_app.set_accels_for_action("win.show-shortcuts", &["question"]); } } fn load_initial_data(&self) { let db = self.database(); let library_view = self.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) if !records.is_empty() => { library_view.populate(records); } _ => { // Empty database - show empty state library_view.set_state(LibraryState::Empty); } } // Scan on startup if enabled in preferences if self.settings().boolean("auto-scan-on-startup") { self.trigger_scan(); } // Start watching scan directories for new AppImage files self.start_file_watcher(); // Auto-cleanup old backups based on retention setting let retention_days = self.settings().int("backup-retention-days") as u32; glib::spawn_future_local(async move { let _ = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); match crate::core::backup::auto_cleanup_old_backups(&bg_db, retention_days) { Ok(removed) if removed > 0 => { log::info!("Auto-cleaned {} old backup(s) (retention: {} days)", removed, retention_days); } Err(e) => log::warn!("Backup auto-cleanup failed: {}", e), _ => {} } }) .await; }); // Run background security scan and notify if auto-security-scan is enabled let settings_sec = self.settings().clone(); if settings_sec.boolean("auto-security-scan") { let threshold = settings_sec.string("security-notification-threshold").to_string(); glib::spawn_future_local(async move { let _ = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); let notifications = notification::scan_and_notify(&bg_db, &threshold); for n in ¬ifications { log::info!( "CVE notification sent: app={} (id={}), severity={}, count={}", n.app_name, n.appimage_id, n.severity, n.cve_count, ); } }) .await; }); } // Check for orphaned desktop entries in the background let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(|| { orphan::detect_orphans().len() }) .await; if let Ok(count) = result { if count > 0 { let msg = ni18n_f( "{} orphaned desktop entry found. Use 'Clean' to remove it.", "{} orphaned desktop entries found. Use 'Clean' to remove them.", count as u32, &[("{}", &count.to_string())], ); let toast = adw::Toast::builder() .title(&msg) .timeout(5) .button_label(&i18n("Clean")) .action_name("win.clean-orphans") .build(); toast_overlay.add_toast(toast); } } }); } fn trigger_scan(&self) { let library_view = self.imp().library_view.get().unwrap(); library_view.set_state(LibraryState::Loading); let settings = self.settings(); let mut dirs: Vec = settings .strv("scan-directories") .iter() .map(|s| s.to_string()) .collect(); // Include system-wide AppImage directory if it exists let system_dir = crate::config::SYSTEM_APPIMAGE_DIR; if std::path::Path::new(system_dir).is_dir() && !dirs.iter().any(|d| d == system_dir) { dirs.push(system_dir.to_string()); } let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); let window_weak = self.downgrade(); // Two-phase scan: // Phase 1 (fast): discover files, upsert into DB, mark pending analysis // Phase 2 (background): heavy analysis per file glib::spawn_future_local(async move { // Phase 1: Fast registration let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database for scan"); let start = Instant::now(); let discovered = discovery::scan_directories(&dirs); let mut new_count = 0i32; let total = discovered.len() as i32; // Clean stale DB records for files that no longer exist let removed = bg_db.remove_missing_appimages().unwrap_or_default(); let removed_count = removed.len() as i32; let mut skipped_count = 0i32; let mut needs_analysis: Vec<(i64, std::path::PathBuf, discovery::AppImageType)> = Vec::new(); for d in &discovered { let existing = bg_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()) }); // Skip re-processing unchanged files that are fully analyzed. // Trust analysis_status as the primary signal - some AppImages // genuinely don't have app_name or other optional fields. if let Some(ref ex) = existing { let size_unchanged = ex.size_bytes == d.size_bytes as i64; let mtime_unchanged = modified.as_deref() == ex.file_modified.as_deref(); let analysis_done = ex.analysis_status.as_deref() == Some("complete"); let has_icon = ex.icon_path.is_some(); if size_unchanged && mtime_unchanged && analysis_done && has_icon { skipped_count += 1; continue; } } let id = bg_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; } // Mark for background analysis bg_db.update_analysis_status(id, "pending").ok(); needs_analysis.push((id, d.path.clone(), d.appimage_type.clone())); } log::info!( "Scan phase 1: {} files, {} new, {} removed, {} skipped (unchanged), took {}ms", total, new_count, removed_count, skipped_count, start.elapsed().as_millis() ); let duration = start.elapsed().as_millis() as i64; bg_db.log_scan( "manual", &dirs.iter().map(|s| s.to_string()).collect::>(), total, new_count, removed_count, duration, ).ok(); (total, new_count, needs_analysis) }) .await; if let Ok((total, new_count, needs_analysis)) = result { // Refresh the library view immediately (apps appear with "Analyzing..." badge) let window_weak2 = window_weak.clone(); if let Some(window) = window_weak.upgrade() { let db = window.database(); let lib_view = window.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => lib_view.set_state(LibraryState::Empty), } } let msg = match new_count { 0 if total == 0 => i18n("No AppImages found"), 0 => format!("{} {}", total, i18n("AppImages up to date")), 1 => i18n("Found 1 new AppImage"), n => format!("{} {} {}", i18n("Found"), n, i18n("new AppImages")), }; toast_overlay.add_toast(adw::Toast::new(&msg)); // Phase 2: Background analysis per file with debounced UI refresh let running = analysis::running_count(); if running > 0 { log::info!("Analyzing {} AppImage(s) in background ({} already running)", needs_analysis.len(), running); } if !needs_analysis.is_empty() { let pending = Rc::new(std::cell::Cell::new(needs_analysis.len())); let refresh_timer: Rc>> = Rc::new(std::cell::Cell::new(None)); for (id, path, appimage_type) in needs_analysis { let window_weak3 = window_weak2.clone(); let pending = pending.clone(); let refresh_timer = refresh_timer.clone(); glib::spawn_future_local(async move { let _ = gio::spawn_blocking(move || { analysis::run_background_analysis(id, path, appimage_type, false); }) .await; let remaining = pending.get().saturating_sub(1); pending.set(remaining); // Debounced refresh: wait 300ms before refreshing UI if let Some(source_id) = refresh_timer.take() { source_id.remove(); } let window_weak4 = window_weak3.clone(); let refresh_timer_clear = refresh_timer.clone(); let timer_id = glib::timeout_add_local_once( std::time::Duration::from_millis(300), move || { // Clear the stored SourceId so nobody tries to remove a fired timer refresh_timer_clear.set(None); if let Some(window) = window_weak4.upgrade() { let db = window.database(); let lib_view = window.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => {} } } }, ); refresh_timer.set(Some(timer_id)); }); } } } }); } fn start_file_watcher(&self) { let settings = self.settings(); let dirs: Vec = settings .strv("scan-directories") .iter() .map(|s| discovery::expand_tilde(&s.to_string())) .collect(); if dirs.is_empty() { return; } // Use an atomic flag to communicate across the thread boundary. // The watcher callback (on a background thread) sets the flag, // and a glib timer on the main thread polls and dispatches. let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let changed_watcher = changed.clone(); let handle = watcher::start_watcher(dirs, move |event| { match &event { watcher::WatchEvent::Changed(paths) => { log::info!("File watcher: {} path(s) changed: {:?}", paths.len(), paths); } } changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed); }); if let Some(h) = handle { self.imp().watcher_handle.replace(Some(h)); // Poll the flag every second from the main thread. // Returns Break when the window is gone to stop the timer. let window_weak = self.downgrade(); glib::timeout_add_local(std::time::Duration::from_secs(1), move || { let Some(window) = window_weak.upgrade() else { return glib::ControlFlow::Break; }; if changed.swap(false, std::sync::atomic::Ordering::Relaxed) { window.trigger_scan(); } glib::ControlFlow::Continue }); } } fn show_shortcuts_dialog(&self) { let dialog = adw::Dialog::builder() .title("Keyboard Shortcuts") .content_width(400) .content_height(420) .build(); let toolbar = adw::ToolbarView::new(); let header = adw::HeaderBar::new(); toolbar.add_top_bar(&header); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(18) .margin_top(18) .margin_bottom(18) .margin_start(18) .margin_end(18) .build(); // Navigation group let nav_group = adw::PreferencesGroup::builder() .title("Navigation") .build(); nav_group.add(&shortcut_row("Ctrl+F", "Search")); nav_group.add(&shortcut_row("Ctrl+D", "Dashboard")); nav_group.add(&shortcut_row("Ctrl+,", "Preferences")); content.append(&nav_group); // Actions group let actions_group = adw::PreferencesGroup::builder() .title("Actions") .build(); actions_group.add(&shortcut_row("Ctrl+R / F5", "Scan for AppImages")); actions_group.add(&shortcut_row("Ctrl+U", "Check for updates")); content.append(&actions_group); // Application group let app_group = adw::PreferencesGroup::builder() .title("Application") .build(); app_group.add(&shortcut_row("Ctrl+?", "Keyboard shortcuts")); app_group.add(&shortcut_row("Ctrl+Q", "Quit")); content.append(&app_group); scrolled.set_child(Some(&content)); toolbar.set_content(Some(&scrolled)); dialog.set_child(Some(&toolbar)); dialog.present(Some(self)); } fn dismiss_drop_overlay(&self) { if let Some(revealer) = self.imp().drop_revealer.get() { revealer.set_reveal_child(false); } if let Some(overlay) = self.imp().drop_overlay.get() { let overlay = overlay.clone(); glib::timeout_add_local_once( std::time::Duration::from_millis(200), move || { overlay.set_visible(false); }, ); } } fn open_file_picker(&self) { self.dismiss_drop_overlay(); let filter = gtk::FileFilter::new(); filter.set_name(Some("AppImage files")); filter.add_pattern("*.AppImage"); filter.add_pattern("*.appimage"); // Also accept any file (AppImages don't always have the extension) let all_filter = gtk::FileFilter::new(); all_filter.set_name(Some("All files")); all_filter.add_pattern("*"); let filters = gio::ListStore::new::(); filters.append(&filter); filters.append(&all_filter); let dialog = gtk::FileDialog::builder() .title(i18n("Choose an AppImage")) .filters(&filters) .default_filter(&filter) .modal(true) .build(); let window_weak = self.downgrade(); dialog.open(Some(self), None::<&gio::Cancellable>, move |result| { let Ok(file) = result else { return }; let Some(path) = file.path() else { return }; let Some(window) = window_weak.upgrade() else { return }; // Validate it's an AppImage via magic bytes if discovery::detect_appimage(&path).is_none() { let toast_overlay = window.imp().toast_overlay.get().unwrap(); toast_overlay.add_toast(adw::Toast::new(&i18n("Not a valid AppImage file"))); return; } let db = window.database().clone(); let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); let window_weak2 = window.downgrade(); drop_dialog::show_drop_dialog( &window, vec![path], &toast_overlay, move || { if let Some(win) = window_weak2.upgrade() { let lib_view = win.imp().library_view.get().unwrap(); match db.get_all_appimages() { Ok(records) => lib_view.populate(records), Err(_) => lib_view.set_state(LibraryState::Empty), } } }, ); }); } fn save_window_state(&self) { let settings = self.settings(); let (width, height) = (self.width(), self.height()); settings .set_int("window-width", width) .expect("Failed to save window width"); settings .set_int("window-height", height) .expect("Failed to save window height"); settings .set_boolean("window-maximized", self.is_maximized()) .expect("Failed to save maximized state"); } fn restore_window_state(&self) { let settings = self.settings(); let width = settings.int("window-width"); let height = settings.int("window-height"); let maximized = settings.boolean("window-maximized"); self.set_default_size(width, height); if maximized { self.maximize(); } } }