Wire backup, notification, report export, and file watcher modules into UI

- Add export button to security report page (HTML/JSON/CSV via FileDialog)
- Send desktop notifications after security scans when enabled in settings
- Add backup section to storage tab with create/restore/delete and toast feedback
- Start file watcher on launch to auto-refresh library on AppImage changes
- Fix detail view tabs requesting tallest tab height (set vhomogeneous=false)
- Disable tab transitions to avoid visual glitch with variable-height tabs
This commit is contained in:
lashman
2026-02-27 21:03:19 +02:00
parent df3efa3b51
commit c9f032292a
3 changed files with 407 additions and 5 deletions

View File

@@ -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<Database>) -> 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
// ---------------------------------------------------------------------------

View File

@@ -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<Database>) -> 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<Database>) -> 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::<gtk::FileFilter>();
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::<gtk::Window>();
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<Database> 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()
}

View File

@@ -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<Rc<Database>>,
pub drop_overlay: OnceCell<gtk::Box>,
pub drop_revealer: OnceCell<gtk::Revealer>,
pub watcher_handle: std::cell::RefCell<Option<notify::RecommendedWatcher>>,
}
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<std::path::PathBuf> = 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")