Files
driftwood/src/ui/preferences.rs
lashman c311fb27c3 Add background update checks with configurable interval
Schedule automatic update checks on startup based on elapsed time
since last check. Add interval SpinRow to preferences and show
'Last checked' timestamp on the dashboard updates section.
2026-02-27 23:52:28 +02:00

492 lines
17 KiB
Rust

use adw::prelude::*;
use gtk::gio;
use crate::config::APP_ID;
use crate::core::appstream;
use crate::core::database::Database;
use crate::i18n::i18n;
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
let dialog = adw::PreferencesDialog::new();
dialog.set_title(&i18n("Preferences"));
let settings = gio::Settings::new(APP_ID);
dialog.add(&build_general_page(&settings, &dialog));
dialog.add(&build_behavior_page(&settings));
dialog.add(&build_security_page(&settings));
dialog.present(Some(parent));
}
// --- General page ---
fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title(&i18n("General"))
.icon_name("emblem-system-symbolic")
.build();
// Appearance group
let appearance_group = adw::PreferencesGroup::builder()
.title(&i18n("Appearance"))
.description(&i18n("Visual preferences for the application"))
.build();
let theme_row = adw::ComboRow::builder()
.title(&i18n("Color Scheme"))
.subtitle(&i18n("Choose light, dark, or follow system preference"))
.build();
let model = gtk::StringList::new(&[&i18n("Follow System"), &i18n("Light"), &i18n("Dark")]);
theme_row.set_model(Some(&model));
let current = settings.string("color-scheme");
theme_row.set_selected(match current.as_str() {
"force-light" => 1,
"force-dark" => 2,
_ => 0,
});
let settings_clone = settings.clone();
theme_row.connect_selected_notify(move |row| {
let value = match row.selected() {
1 => "force-light",
2 => "force-dark",
_ => "default",
};
settings_clone.set_string("color-scheme", value).ok();
});
appearance_group.add(&theme_row);
let view_row = adw::ComboRow::builder()
.title(&i18n("Default View"))
.subtitle(&i18n("Library display mode"))
.build();
let view_model = gtk::StringList::new(&[&i18n("Grid"), &i18n("List")]);
view_row.set_model(Some(&view_model));
let current_view = settings.string("view-mode");
view_row.set_selected(if current_view.as_str() == "list" { 1 } else { 0 });
let settings_view = settings.clone();
view_row.connect_selected_notify(move |row| {
let value = if row.selected() == 1 { "list" } else { "grid" };
settings_view.set_string("view-mode", value).ok();
});
appearance_group.add(&view_row);
page.add(&appearance_group);
// Scan Locations group
let scan_group = adw::PreferencesGroup::builder()
.title(&i18n("Scan Locations"))
.description(&i18n("Directories to scan for AppImage files"))
.build();
let dirs = settings.strv("scan-directories");
let dir_list_box = gtk::ListBox::new();
dir_list_box.add_css_class("boxed-list");
dir_list_box.set_selection_mode(gtk::SelectionMode::None);
dir_list_box.update_property(&[
gtk::accessible::Property::Label(&i18n("Scan directories")),
]);
for dir in &dirs {
add_directory_row(&dir_list_box, &dir, settings);
}
scan_group.add(&dir_list_box);
// Add location button
let add_button = gtk::Button::builder()
.label(&i18n("Add Location"))
.build();
add_button.add_css_class("flat");
add_button.update_property(&[
gtk::accessible::Property::Label(&i18n("Add scan directory")),
]);
let settings_add = settings.clone();
let list_box_ref = dir_list_box.clone();
let dialog_weak = dialog.downgrade();
add_button.connect_clicked(move |_| {
let file_dialog = gtk::FileDialog::builder()
.title(i18n("Choose a directory"))
.modal(true)
.build();
let settings_ref = settings_add.clone();
let list_ref = list_box_ref.clone();
let dlg = dialog_weak.upgrade();
let parent_window: Option<gtk::Window> = dlg
.as_ref()
.and_then(|d| d.root())
.and_then(|r| r.downcast::<gtk::Window>().ok());
file_dialog.select_folder(
parent_window.as_ref(),
None::<&gio::Cancellable>,
move |result| {
if let Ok(file) = result {
if let Some(path) = file.path() {
let path_str = path.to_string_lossy().to_string();
let mut current_dirs: Vec<String> = settings_ref
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
if !current_dirs.contains(&path_str) {
current_dirs.push(path_str.clone());
let refs: Vec<&str> =
current_dirs.iter().map(|s| s.as_str()).collect();
settings_ref.set_strv("scan-directories", refs).ok();
add_directory_row(&list_ref, &path_str, &settings_ref);
}
}
}
},
);
});
scan_group.add(&add_button);
page.add(&scan_group);
// Desktop Integration group - AppStream catalog for GNOME Software/Discover
let integration_group = adw::PreferencesGroup::builder()
.title(&i18n("Desktop Integration"))
.description(&i18n(
"Make your AppImages visible in GNOME Software and KDE Discover",
))
.build();
let catalog_row = adw::SwitchRow::builder()
.title(&i18n("AppStream catalog"))
.subtitle(&i18n(
"Generate a local catalog so software centers can list your AppImages",
))
.active(appstream::is_catalog_installed())
.build();
catalog_row.connect_active_notify(|row| {
let enable = row.is_active();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
if enable {
let db = Database::open().expect("Failed to open database");
appstream::install_catalog(&db)
.map(|p| log::info!("AppStream catalog installed: {}", p.display()))
.map_err(|e| e.to_string())
} else {
appstream::uninstall_catalog()
.map(|()| log::info!("AppStream catalog removed"))
.map_err(|e| e.to_string())
}
})
.await;
if let Ok(Err(e)) = result {
log::warn!("AppStream catalog toggle failed: {}", e);
}
});
});
integration_group.add(&catalog_row);
page.add(&integration_group);
page
}
// --- Behavior page ---
fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title(&i18n("Behavior"))
.icon_name("preferences-other-symbolic")
.build();
// Automation group
let automation_group = adw::PreferencesGroup::builder()
.title(&i18n("Automation"))
.description(&i18n("What Driftwood does automatically"))
.build();
let auto_scan_row = adw::SwitchRow::builder()
.title(&i18n("Scan on startup"))
.subtitle(&i18n("Automatically scan for new AppImages when the app starts"))
.active(settings.boolean("auto-scan-on-startup"))
.build();
let settings_scan = settings.clone();
auto_scan_row.connect_active_notify(move |row| {
settings_scan.set_boolean("auto-scan-on-startup", row.is_active()).ok();
});
automation_group.add(&auto_scan_row);
let auto_update_row = adw::SwitchRow::builder()
.title(&i18n("Check for updates"))
.subtitle(&i18n("Periodically check if newer versions of your AppImages are available"))
.active(settings.boolean("auto-check-updates"))
.build();
let settings_upd = settings.clone();
auto_update_row.connect_active_notify(move |row| {
settings_upd.set_boolean("auto-check-updates", row.is_active()).ok();
});
automation_group.add(&auto_update_row);
let interval_row = adw::SpinRow::builder()
.title(&i18n("Update check interval"))
.subtitle(&i18n("Hours between automatic update checks"))
.build();
let interval_adj = gtk::Adjustment::new(
settings.int("update-check-interval-hours") as f64,
1.0,
168.0,
1.0,
6.0,
0.0,
);
interval_row.set_adjustment(Some(&interval_adj));
let settings_interval = settings.clone();
interval_row.connect_value_notify(move |row| {
settings_interval.set_int("update-check-interval-hours", row.value() as i32).ok();
});
automation_group.add(&interval_row);
let auto_integrate_row = adw::SwitchRow::builder()
.title(&i18n("Auto-integrate new AppImages"))
.subtitle(&i18n("Automatically add newly discovered AppImages to the desktop menu"))
.active(settings.boolean("auto-integrate"))
.build();
let settings_int = settings.clone();
auto_integrate_row.connect_active_notify(move |row| {
settings_int.set_boolean("auto-integrate", row.is_active()).ok();
});
automation_group.add(&auto_integrate_row);
page.add(&automation_group);
// Backup group
let backup_group = adw::PreferencesGroup::builder()
.title(&i18n("Backups"))
.description(&i18n("Config and data backup settings for updates"))
.build();
let auto_backup_row = adw::SwitchRow::builder()
.title(&i18n("Auto-backup before update"))
.subtitle(&i18n("Back up config and data files before updating an AppImage"))
.active(settings.boolean("auto-backup-before-update"))
.build();
let settings_backup = settings.clone();
auto_backup_row.connect_active_notify(move |row| {
settings_backup.set_boolean("auto-backup-before-update", row.is_active()).ok();
});
backup_group.add(&auto_backup_row);
let retention_row = adw::SpinRow::builder()
.title(&i18n("Backup retention"))
.subtitle(&i18n("Days to keep config backups before auto-cleanup"))
.build();
let adjustment = gtk::Adjustment::new(
settings.int("backup-retention-days") as f64,
1.0,
365.0,
1.0,
7.0,
0.0,
);
retention_row.set_adjustment(Some(&adjustment));
let settings_ret = settings.clone();
retention_row.connect_value_notify(move |row| {
settings_ret.set_int("backup-retention-days", row.value() as i32).ok();
});
backup_group.add(&retention_row);
page.add(&backup_group);
// Safety group
let safety_group = adw::PreferencesGroup::builder()
.title(&i18n("Safety"))
.description(&i18n("Confirmation and cleanup behavior"))
.build();
let confirm_row = adw::SwitchRow::builder()
.title(&i18n("Confirm before delete"))
.subtitle(&i18n("Show a confirmation dialog before deleting files or cleaning up"))
.active(settings.boolean("confirm-before-delete"))
.build();
let settings_confirm = settings.clone();
confirm_row.connect_active_notify(move |row| {
settings_confirm.set_boolean("confirm-before-delete", row.is_active()).ok();
});
safety_group.add(&confirm_row);
let cleanup_row = adw::ComboRow::builder()
.title(&i18n("After updating an AppImage"))
.subtitle(&i18n("What to do with the old version after a successful update"))
.build();
let cleanup_model = gtk::StringList::new(&[&i18n("Ask each time"), &i18n("Remove old version"), &i18n("Keep backup")]);
cleanup_row.set_model(Some(&cleanup_model));
let current_cleanup = settings.string("update-cleanup");
cleanup_row.set_selected(match current_cleanup.as_str() {
"always" => 1,
"never" => 2,
_ => 0,
});
let settings_cleanup = settings.clone();
cleanup_row.connect_selected_notify(move |row| {
let value = match row.selected() {
1 => "always",
2 => "never",
_ => "ask",
};
settings_cleanup.set_string("update-cleanup", value).ok();
});
safety_group.add(&cleanup_row);
page.add(&safety_group);
page
}
// --- Security page ---
fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title(&i18n("Security"))
.icon_name("security-medium-symbolic")
.build();
let scan_group = adw::PreferencesGroup::builder()
.title(&i18n("Vulnerability Scanning"))
.description(&i18n("Check bundled libraries for known CVEs via OSV.dev"))
.build();
let auto_security_row = adw::SwitchRow::builder()
.title(&i18n("Auto-scan new AppImages"))
.subtitle(&i18n("Automatically run a security scan on newly discovered AppImages"))
.active(settings.boolean("auto-security-scan"))
.build();
let settings_sec = settings.clone();
auto_security_row.connect_active_notify(move |row| {
settings_sec.set_boolean("auto-security-scan", row.is_active()).ok();
});
scan_group.add(&auto_security_row);
let info_row = adw::ActionRow::builder()
.title(&i18n("Data source"))
.subtitle(&i18n("OSV.dev - Open Source Vulnerability database"))
.build();
scan_group.add(&info_row);
page.add(&scan_group);
// Notification settings
let notify_group = adw::PreferencesGroup::builder()
.title(&i18n("Notifications"))
.description(&i18n("Desktop notification settings for security alerts"))
.build();
let notify_row = adw::SwitchRow::builder()
.title(&i18n("Security notifications"))
.subtitle(&i18n("Send desktop notifications when new CVEs are found"))
.active(settings.boolean("security-notifications"))
.build();
let settings_notify = settings.clone();
notify_row.connect_active_notify(move |row| {
settings_notify.set_boolean("security-notifications", row.is_active()).ok();
});
notify_group.add(&notify_row);
let threshold_row = adw::ComboRow::builder()
.title(&i18n("Notification threshold"))
.subtitle(&i18n("Minimum severity to trigger a notification"))
.build();
let threshold_model = gtk::StringList::new(&[&i18n("Critical"), &i18n("High"), &i18n("Medium"), &i18n("Low")]);
threshold_row.set_model(Some(&threshold_model));
let current_threshold = settings.string("security-notification-threshold");
threshold_row.set_selected(match current_threshold.as_str() {
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
_ => 1,
});
let settings_threshold = settings.clone();
threshold_row.connect_selected_notify(move |row| {
let value = match row.selected() {
0 => "critical",
2 => "medium",
3 => "low",
_ => "high",
};
settings_threshold.set_string("security-notification-threshold", value).ok();
});
notify_group.add(&threshold_row);
page.add(&notify_group);
// About security scanning
let about_group = adw::PreferencesGroup::builder()
.title(&i18n("How It Works"))
.description(&i18n("Understanding Driftwood's security scanning"))
.build();
let about_row = adw::ActionRow::builder()
.title(&i18n("Bundled library detection"))
.subtitle(&i18n("Driftwood extracts the list of shared libraries (.so files) bundled inside each AppImage and checks them against the OSV vulnerability database."))
.build();
about_group.add(&about_row);
let limits_row = adw::ActionRow::builder()
.title(&i18n("Limitations"))
.subtitle(&i18n("Not all bundled libraries can be identified. Version detection uses heuristics and may not always be accurate. Results should be treated as advisory."))
.build();
about_group.add(&limits_row);
page.add(&about_group);
page
}
fn add_directory_row(list_box: &gtk::ListBox, dir: &str, settings: &gio::Settings) {
let row = adw::ActionRow::builder()
.title(dir)
.build();
let remove_btn = gtk::Button::builder()
.icon_name("edit-delete-symbolic")
.valign(gtk::Align::Center)
.tooltip_text(&i18n("Remove"))
.build();
remove_btn.add_css_class("flat");
remove_btn.update_property(&[
gtk::accessible::Property::Label(&format!("{} {}", i18n("Remove directory"), dir)),
]);
let list_ref = list_box.clone();
let settings_ref = settings.clone();
let dir_str = dir.to_string();
let row_ref = row.clone();
remove_btn.connect_clicked(move |_| {
let current_dirs: Vec<String> = settings_ref
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.filter(|s| s != &dir_str)
.collect();
let refs: Vec<&str> = current_dirs.iter().map(|s| s.as_str()).collect();
settings_ref.set_strv("scan-directories", refs).ok();
list_ref.remove(&row_ref);
});
row.add_suffix(&remove_btn);
list_box.append(&row);
}