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) { 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_updates_page(&settings)); super::widgets::apply_pointer_cursors(&dialog); 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")) .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 = dlg .as_ref() .and_then(|d| d.root()) .and_then(|r| r.downcast::().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 = 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); // Automation group let automation_group = adw::PreferencesGroup::builder() .title(&i18n("Automation")) .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_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); let removable_row = adw::SwitchRow::builder() .title(&i18n("Watch removable media")) .subtitle(&i18n("Scan USB drives and other removable media for AppImages")) .active(settings.boolean("watch-removable-media")) .build(); let settings_rem = settings.clone(); removable_row.connect_active_notify(move |row| { settings_rem.set_boolean("watch-removable-media", row.is_active()).ok(); }); automation_group.add(&removable_row); 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(); }); automation_group.add(&confirm_row); page.add(&automation_group); // Desktop Integration group 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 } // --- Updates page --- fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage { let page = adw::PreferencesPage::builder() .title(&i18n("Updates")) .icon_name("software-update-available-symbolic") .build(); // Update Checking group let checking_group = adw::PreferencesGroup::builder() .title(&i18n("Update Checking")) .build(); 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(); }); checking_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(); }); checking_group.add(&interval_row); page.add(&checking_group); // Update Behavior group let behavior_group = adw::PreferencesGroup::builder() .title(&i18n("Update Behavior")) .build(); 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(); }); behavior_group.add(&cleanup_row); 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(); }); behavior_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(); }); behavior_group.add(&retention_row); page.add(&behavior_group); // Security Scanning group let security_group = adw::PreferencesGroup::builder() .title(&i18n("Security 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(); }); security_group.add(&auto_security_row); 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(); }); security_group.add(¬ify_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(); }); security_group.add(&threshold_row); page.add(&security_group); // Catalog Enrichment group let enrichment_group = adw::PreferencesGroup::builder() .title(&i18n("Catalog Enrichment")) .description(&i18n("Fetch GitHub metadata (stars, version, downloads) for catalog apps")) .build(); let auto_enrich_row = adw::SwitchRow::builder() .title(&i18n("Auto-enrich catalog apps")) .subtitle(&i18n("Fetch metadata from GitHub in the background")) .active(settings.boolean("catalog-auto-enrich")) .build(); let settings_enrich = settings.clone(); auto_enrich_row.connect_active_notify(move |row| { settings_enrich.set_boolean("catalog-auto-enrich", row.is_active()).ok(); }); enrichment_group.add(&auto_enrich_row); let token_row = adw::PasswordEntryRow::builder() .title(&i18n("GitHub token")) .build(); let current_token = settings.string("github-token"); if !current_token.is_empty() { token_row.set_text(¤t_token); } let settings_token = settings.clone(); token_row.connect_changed(move |row| { settings_token.set_string("github-token", &row.text()).ok(); }); enrichment_group.add(&token_row); let token_hint = adw::ActionRow::builder() .title(&i18n("Optional - increases rate limit from 60 to 5,000 requests per hour")) .css_classes(["dim-label"]) .build(); enrichment_group.add(&token_hint); page.add(&enrichment_group); page } fn add_directory_row(list_box: >k::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 = 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); }