Integrate the Pling/OCS REST API (appimagehub.com) as the primary catalog source with richer metadata than the existing appimage.github.io feed. Backend: - Add OCS API fetch with pagination, lenient JSON deserializers for loosely typed numeric fields, and non-AppImage file filtering (.dmg, .exe, etc.) - Database migration v17 adds OCS-specific columns (ocs_id, downloads, score, typename, personid, description, summary, version, tags, etc.) - Deduplicate secondary source apps against OCS entries - Shrink OCS CDN icon URLs from 770x540 to 100x100 for faster loading - Clear stale screenshot and icon caches on sync - Extract GitHub repo links from OCS HTML descriptions - Add fetch_ocs_download_files() to get all version files for an app - Resolve fresh JWT download URLs per slot at install time Detail page: - Fetch OCS download files on page open and populate install SplitButton with version dropdown (newest first, filtered for AppImage only) - Show OCS metadata: downloads, score, author, typename, tags, comments, created/updated dates, architecture, filename, file size, MD5 - Prefer ocs_description (full HTML with features/changelog) over short summary for the About section - Add html_to_description() to preserve formatting (lists, paragraphs) - Remove redundant Download link from Links section - Escape ampersands in Pango markup subtitles (categories, typename, tags) Catalog view: - OCS source syncs first as primary, appimage.github.io as secondary - Featured apps consider OCS download counts alongside GitHub stars UI: - Add pulldown-cmark for GitHub README markdown rendering in detail pages - Add build_markdown_view() widget for rendered markdown content
493 lines
17 KiB
Rust
493 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_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<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);
|
|
|
|
// 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<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);
|
|
}
|