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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user