diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 2000caf..888003d 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -5,6 +5,7 @@ use std::rc::Rc; use gtk::gio; +use crate::core::backup; use crate::core::database::{AppImageRecord, Database}; use crate::core::footprint; use crate::core::fuse::{self, FuseStatus}; @@ -23,10 +24,12 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav // Toast overlay for copy actions let toast_overlay = adw::ToastOverlay::new(); - // ViewStack for tabbed content with crossfade transitions + // ViewStack for tabbed content with crossfade transitions. + // vhomogeneous=false so the stack sizes to the visible child only, + // preventing shorter tabs from having excess scrollable empty space. let view_stack = adw::ViewStack::new(); - view_stack.set_enable_transitions(true); - view_stack.set_transition_duration(200); + view_stack.set_vhomogeneous(false); + view_stack.set_enable_transitions(false); // Build tab pages let overview_page = build_overview_tab(record, db); @@ -1506,6 +1509,9 @@ fn build_storage_tab( } inner.append(&paths_group); + // Backups group + inner.append(&build_backup_group(record.id, toast_overlay)); + // File location group let location_group = adw::PreferencesGroup::builder() .title("File Location") @@ -1552,6 +1558,223 @@ fn build_storage_tab( tab } +fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Backups") + .description("Save and restore this app's settings and data files") + .build(); + + // Fetch existing backups + let db = Database::open().ok(); + let backups = db.as_ref() + .map(|d| backup::list_backups(d, Some(record_id))) + .unwrap_or_default(); + + if backups.is_empty() { + let empty_row = adw::ActionRow::builder() + .title("No backups yet") + .subtitle("Create a backup to save this app's settings and data") + .build(); + let empty_icon = gtk::Image::from_icon_name("document-open-symbolic"); + empty_icon.set_valign(gtk::Align::Center); + empty_icon.add_css_class("dim-label"); + empty_row.add_prefix(&empty_icon); + group.add(&empty_row); + } else { + for b in &backups { + let expander = adw::ExpanderRow::builder() + .title(&b.created_at) + .subtitle(&format!( + "v{} - {} - {} file{}", + b.app_version.as_deref().unwrap_or("unknown"), + widgets::format_size(b.archive_size), + b.path_count, + if b.path_count == 1 { "" } else { "s" }, + )) + .build(); + + // Exists/missing badge using icon + text (not color-only) + let badge = if b.exists { + let bx = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .valign(gtk::Align::Center) + .build(); + let icon = gtk::Image::from_icon_name("emblem-ok-symbolic"); + icon.add_css_class("success"); + let label = gtk::Label::new(Some("Exists")); + label.add_css_class("caption"); + label.add_css_class("success"); + bx.append(&icon); + bx.append(&label); + bx + } else { + let bx = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .valign(gtk::Align::Center) + .build(); + let icon = gtk::Image::from_icon_name("dialog-warning-symbolic"); + icon.add_css_class("warning"); + let label = gtk::Label::new(Some("Missing")); + label.add_css_class("caption"); + label.add_css_class("warning"); + bx.append(&icon); + bx.append(&label); + bx + }; + expander.add_suffix(&badge); + + // Restore row + let restore_row = adw::ActionRow::builder() + .title("Restore") + .subtitle("Restore settings and data from this backup") + .activatable(true) + .tooltip_text("Overwrite current settings with this backup") + .build(); + let restore_icon = gtk::Image::from_icon_name("edit-undo-symbolic"); + restore_icon.set_valign(gtk::Align::Center); + restore_row.add_prefix(&restore_icon); + restore_row.update_property(&[ + gtk::accessible::Property::Label("Restore this backup"), + ]); + + let archive_path = b.archive_path.clone(); + let toast_restore = toast_overlay.clone(); + restore_row.connect_activated(move |row| { + row.set_sensitive(false); + row.set_subtitle("Restoring..."); + let row_clone = row.clone(); + let path = archive_path.clone(); + let toast = toast_restore.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + backup::restore_backup(std::path::Path::new(&path)) + }) + .await; + + row_clone.set_sensitive(true); + match result { + Ok(Ok(res)) => { + row_clone.set_subtitle(&format!( + "Restored {} path{}", + res.paths_restored, + if res.paths_restored == 1 { "" } else { "s" }, + )); + toast.add_toast(adw::Toast::new("Backup restored")); + } + _ => { + row_clone.set_subtitle("Restore failed"); + toast.add_toast(adw::Toast::new("Failed to restore backup")); + } + } + }); + }); + expander.add_row(&restore_row); + + // Delete row + let delete_row = adw::ActionRow::builder() + .title("Delete") + .subtitle("Permanently remove this backup") + .activatable(true) + .tooltip_text("Delete this backup archive from disk") + .build(); + let delete_icon = gtk::Image::from_icon_name("edit-delete-symbolic"); + delete_icon.set_valign(gtk::Align::Center); + delete_row.add_prefix(&delete_icon); + delete_row.update_property(&[ + gtk::accessible::Property::Label("Delete this backup"), + ]); + + let backup_id = b.id; + let toast_delete = toast_overlay.clone(); + let group_ref = group.clone(); + let expander_ref = expander.clone(); + delete_row.connect_activated(move |row| { + row.set_sensitive(false); + row.set_subtitle("Deleting..."); + let row_clone = row.clone(); + let toast = toast_delete.clone(); + let group_del = group_ref.clone(); + let expander_del = expander_ref.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + backup::delete_backup(&bg_db, backup_id) + }) + .await; + + match result { + Ok(Ok(())) => { + group_del.remove(&expander_del); + toast.add_toast(adw::Toast::new("Backup deleted")); + } + _ => { + row_clone.set_sensitive(true); + row_clone.set_subtitle("Delete failed"); + toast.add_toast(adw::Toast::new("Failed to delete backup")); + } + } + }); + }); + expander.add_row(&delete_row); + + group.add(&expander); + } + } + + // Create backup row (always shown at bottom) + let create_row = adw::ActionRow::builder() + .title("Create backup") + .subtitle("Save a snapshot of this app's settings and data") + .activatable(true) + .tooltip_text("Create a new backup of this app's configuration files") + .build(); + let create_icon = gtk::Image::from_icon_name("list-add-symbolic"); + create_icon.set_valign(gtk::Align::Center); + create_row.add_prefix(&create_icon); + create_row.update_property(&[ + gtk::accessible::Property::Label("Create a new backup"), + ]); + + let toast_create = toast_overlay.clone(); + create_row.connect_activated(move |row| { + row.set_sensitive(false); + row.set_subtitle("Creating backup..."); + let row_clone = row.clone(); + let toast = toast_create.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + backup::create_backup(&bg_db, record_id) + }) + .await; + + row_clone.set_sensitive(true); + match result { + Ok(Ok(path)) => { + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("backup"); + row_clone.set_subtitle(&format!("Created {}", filename)); + toast.add_toast(adw::Toast::new("Backup created")); + } + Ok(Err(backup::BackupError::NoPaths)) => { + row_clone.set_subtitle("Try discovering app data first"); + toast.add_toast(adw::Toast::new("No data paths found to back up")); + } + _ => { + row_clone.set_subtitle("Backup failed"); + toast.add_toast(adw::Toast::new("Failed to create backup")); + } + } + }); + }); + group.add(&create_row); + + group +} + // --------------------------------------------------------------------------- // User-friendly explanations // --------------------------------------------------------------------------- diff --git a/src/ui/security_report.rs b/src/ui/security_report.rs index 6d03de8..062eb7f 100644 --- a/src/ui/security_report.rs +++ b/src/ui/security_report.rs @@ -2,7 +2,10 @@ use adw::prelude::*; use gtk::gio; use std::rc::Rc; +use crate::config::APP_ID; use crate::core::database::Database; +use crate::core::notification; +use crate::core::report; use crate::core::security; use super::widgets; @@ -16,8 +19,19 @@ pub fn build_security_report_page(db: &Rc) -> adw::NavigationPage { let initial_content = build_report_content(db); content_stack.add_named(&initial_content, Some("content")); - // Header bar with scan button + // Header bar with scan and export buttons let header = adw::HeaderBar::new(); + + // Export button + let export_button = gtk::Button::builder() + .label("Export") + .tooltip_text("Save this report as HTML, JSON, or CSV") + .build(); + export_button.update_property(&[ + gtk::accessible::Property::Label("Export security report as HTML, JSON, or CSV"), + ]); + + // Scan button let scan_button = gtk::Button::builder() .label("Scan All") .tooltip_text("Scan all AppImages for vulnerabilities") @@ -56,19 +70,126 @@ pub fn build_security_report_page(db: &Rc) -> adw::NavigationPage { } stack_refresh.add_named(&new_content, Some("content")); stack_refresh.set_visible_child_name("content"); + + // 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; + }); + } } }); }); header.pack_end(&scan_button); + header.pack_end(&export_button); + + // Toast overlay wraps the toolbar for feedback + let toast_overlay = adw::ToastOverlay::new(); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(&content_stack)); + toast_overlay.set_child(Some(&toolbar)); + + // Wire export button + let db_export = db.clone(); + let toast_ref = toast_overlay.clone(); + export_button.connect_clicked(move |btn| { + let html_filter = gtk::FileFilter::new(); + html_filter.set_name(Some("HTML (*.html)")); + html_filter.add_pattern("*.html"); + html_filter.add_pattern("*.htm"); + + let json_filter = gtk::FileFilter::new(); + json_filter.set_name(Some("JSON (*.json)")); + json_filter.add_pattern("*.json"); + + let csv_filter = gtk::FileFilter::new(); + csv_filter.set_name(Some("CSV (*.csv)")); + csv_filter.add_pattern("*.csv"); + + let filters = gio::ListStore::new::(); + filters.append(&html_filter); + filters.append(&json_filter); + filters.append(&csv_filter); + + let dialog = gtk::FileDialog::builder() + .title("Export Security Report") + .initial_name("driftwood-security-report.html") + .filters(&filters) + .default_filter(&html_filter) + .modal(true) + .build(); + + let btn_clone = btn.clone(); + let db_for_save = db_export.clone(); + let toast_for_save = toast_ref.clone(); + let window = btn.root().and_downcast::(); + + dialog.save(window.as_ref(), None::<&gio::Cancellable>, move |result| { + let Ok(file) = result else { return }; + let Some(path) = file.path() else { return }; + + // Detect format from extension + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or("html") + .to_lowercase(); + + let format = match ext.as_str() { + "json" => report::ReportFormat::Json, + "csv" => report::ReportFormat::Csv, + _ => report::ReportFormat::Html, + }; + + btn_clone.set_sensitive(false); + btn_clone.set_label("Exporting..."); + let btn_done = btn_clone.clone(); + let toast_done = toast_for_save.clone(); + let db_bg = db_for_save.clone(); + + glib::spawn_future_local(async move { + let path_clone = path.clone(); + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + // Use the records from the bg_db since Rc isn't Send + let _ = db_bg; // acknowledge capture but use bg_db + let report_data = report::build_report(&bg_db, None); + let content = report::render(&report_data, format); + std::fs::write(&path_clone, content) + }) + .await; + + btn_done.set_sensitive(true); + btn_done.set_label("Export"); + + match result { + Ok(Ok(())) => { + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("report"); + toast_done.add_toast( + adw::Toast::new(&format!("Report saved as {}", filename)), + ); + } + _ => { + toast_done.add_toast(adw::Toast::new("Failed to export report")); + } + } + }); + }); + }); adw::NavigationPage::builder() .title("Security Report") .tag("security-report") - .child(&toolbar) + .child(&toast_overlay) .build() } diff --git a/src/window.rs b/src/window.rs index 987d4ed..cd4edb4 100644 --- a/src/window.rs +++ b/src/window.rs @@ -11,9 +11,11 @@ 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; @@ -37,6 +39,7 @@ mod imp { pub database: OnceCell>, pub drop_overlay: OnceCell, pub drop_revealer: OnceCell, + pub watcher_handle: std::cell::RefCell>, } impl Default for DriftwoodWindow { @@ -49,6 +52,7 @@ mod imp { database: OnceCell::new(), drop_overlay: OnceCell::new(), drop_revealer: OnceCell::new(), + watcher_handle: std::cell::RefCell::new(None), } } } @@ -705,6 +709,19 @@ impl DriftwoodWindow { 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")), } @@ -816,6 +833,9 @@ impl DriftwoodWindow { // Always scan on startup to discover new AppImages and complete pending analyses self.trigger_scan(); + // Start watching scan directories for new AppImage files + self.start_file_watcher(); + // Check for orphaned desktop entries in the background let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); glib::spawn_future_local(async move { @@ -1019,6 +1039,44 @@ impl DriftwoodWindow { }); } + 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| { + 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 + let window_weak = self.downgrade(); + glib::timeout_add_local(std::time::Duration::from_secs(1), move || { + if changed.swap(false, std::sync::atomic::Ordering::Relaxed) { + if let Some(window) = window_weak.upgrade() { + window.trigger_scan(); + } + } + glib::ControlFlow::Continue + }); + } + } + fn show_shortcuts_dialog(&self) { let dialog = adw::Dialog::builder() .title("Keyboard Shortcuts")