Files
driftwood/src/ui/preferences.rs
lashman 4b939f044a Add AppImageHub.com OCS API as primary catalog source
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
2026-02-28 20:33:40 +02:00

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(&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();
});
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(&current_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: &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);
}