Add GitHub metadata enrichment for catalog apps

Enrich catalog apps with GitHub API data (stars, version, downloads,
release date) via two strategies: background drip for repo-level info
and on-demand fetch when opening a detail page.

- Add github_enrichment module with API calls, asset filtering, and
  architecture auto-detection for AppImage downloads
- DB migrations v14/v15 for GitHub metadata and release asset columns
- Extract github_owner/repo from feed links during catalog sync
- Display colored stat cards (stars, version, downloads, released) on
  detail pages with on-demand enrichment
- Show stars and version on browse tiles and featured carousel cards
- Replace install button with SplitButton dropdown when multiple arch
  assets available, preferring detected architecture
- Disable install button until enrichment completes to prevent stale
  AppImageHub URL downloads
- Keep enrichment banner visible on catalog page until truly complete,
  showing paused state when rate-limited
- Add GitHub token and auto-enrich toggle to preferences
This commit is contained in:
lashman
2026-02-28 16:49:13 +02:00
parent 92c51dc39e
commit f89aafca6a
15 changed files with 3027 additions and 224 deletions

View File

@@ -9,6 +9,7 @@ use crate::config::APP_ID;
use crate::core::analysis;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::footprint;
use crate::core::integrator;
use crate::core::launcher;
use crate::core::notification;
@@ -43,6 +44,7 @@ mod imp {
pub drop_overlay: OnceCell<gtk::Box>,
pub drop_revealer: OnceCell<gtk::Revealer>,
pub watcher_handle: std::cell::RefCell<Option<notify::RecommendedWatcher>>,
pub enrichment_banner: OnceCell<gtk::Box>,
}
impl Default for DriftwoodWindow {
@@ -57,6 +59,7 @@ mod imp {
drop_overlay: OnceCell::new(),
drop_revealer: OnceCell::new(),
watcher_handle: std::cell::RefCell::new(None),
enrichment_banner: OnceCell::new(),
}
}
}
@@ -99,6 +102,22 @@ glib::wrapper! {
gtk::Native, gtk::Root, gtk::ShortcutManager;
}
/// Find a child widget by its widget name (breadth-first).
fn find_child_by_name(parent: &impl IsA<gtk::Widget>, name: &str) -> Option<gtk::Widget> {
let parent_widget = parent.upcast_ref::<gtk::Widget>();
let mut child = parent_widget.first_child();
while let Some(c) = child {
if c.widget_name() == name {
return Some(c);
}
if let Some(found) = find_child_by_name(&c, name) {
return Some(found);
}
child = c.next_sibling();
}
None
}
fn shortcut_row(accel: &str, description: &str) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(description)
@@ -166,8 +185,8 @@ impl DriftwoodWindow {
let installed_nav = adw::NavigationView::new();
installed_nav.push(&library_view.page);
// Catalog view
let catalog_page = catalog_view::build_catalog_page(self.database());
// Catalog view (has its own internal NavigationView for drill-down)
let (catalog_nav, enrichment_banner) = catalog_view::build_catalog_page(self.database());
// Updates view
let updates_toolbar = updates_view::build_updates_view(self.database());
@@ -179,7 +198,7 @@ impl DriftwoodWindow {
let installed_vs_page = view_stack.add_titled(&installed_nav, Some("installed"), &i18n("Installed"));
installed_vs_page.set_icon_name(Some("view-grid-symbolic"));
let catalog_vs_page = view_stack.add_titled(&catalog_page, Some("catalog"), &i18n("Catalog"));
let catalog_vs_page = view_stack.add_titled(&catalog_nav, Some("catalog"), &i18n("Catalog"));
catalog_vs_page.set_icon_name(Some("system-software-install-symbolic"));
let updates_vs_page = view_stack.add_titled(&updates_toolbar, Some("updates"), &i18n("Updates"));
@@ -481,6 +500,10 @@ impl DriftwoodWindow {
if self.imp().library_view.set(library_view).is_err() {
panic!("LibraryView already set");
}
self.imp()
.enrichment_banner
.set(enrichment_banner)
.expect("EnrichmentBanner already set");
// Set up window actions
self.setup_window_actions();
@@ -1010,6 +1033,48 @@ impl DriftwoodWindow {
}
self.add_action(&copy_path_action);
// Uninstall action (from right-click context menu)
let uninstall_action = gio::SimpleAction::new("uninstall-appimage", param_type);
{
let window_weak = self.downgrade();
uninstall_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() else { return };
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
let db = window.database().clone();
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
let fp = footprint::get_footprint(&db, record.id, record.size_bytes as u64);
let fp_paths: Vec<(String, String, u64)> = fp.paths.iter()
.filter(|p| p.exists)
.map(|p| (
p.path.to_string_lossy().to_string(),
p.path_type.label().to_string(),
p.size_bytes,
))
.collect();
let is_integrated = record.integrated;
let window_ref = window.clone();
let db_refresh = db.clone();
detail_view::show_uninstall_dialog_with_callback(
&toast_overlay,
&record,
&db,
is_integrated,
&fp_paths,
Some(Box::new(move || {
// Refresh the library view after uninstall
if let Some(lib_view) = window_ref.imp().library_view.get() {
if let Ok(records) = db_refresh.get_all_appimages() {
lib_view.populate(records);
}
}
})),
);
}
});
}
self.add_action(&uninstall_action);
// View switching actions for keyboard shortcuts
let show_installed_action = gio::SimpleAction::new("show-installed", None);
{
@@ -1185,6 +1250,134 @@ impl DriftwoodWindow {
// Update badge on Updates tab
self.refresh_update_badge();
// Background GitHub enrichment
self.start_background_enrichment();
}
fn start_background_enrichment(&self) {
let settings = self.settings().clone();
if !settings.boolean("catalog-auto-enrich") {
return;
}
let db = self.database().clone();
// Check if there are unenriched apps
let (enriched, total) = db.catalog_enrichment_progress().unwrap_or((0, 0));
if total == 0 || enriched >= total {
return;
}
let banner = self.imp().enrichment_banner.get().cloned();
let view_stack = self.imp().view_stack.get().cloned();
// Show banner initially
if let Some(ref b) = banner {
b.set_visible(true);
if let Some(label) = find_child_by_name(b, "enrich-label") {
if let Ok(l) = label.downcast::<gtk::Label>() {
l.set_label(&format!("Enriching app data from GitHub ({}/{})...", enriched, total));
}
}
}
// Channel for progress updates
let (tx, rx) = std::sync::mpsc::channel::<(i64, i64, bool)>();
let token = settings.string("github-token").to_string();
// Background thread: runs batch enrichment
glib::spawn_future_local(async move {
let tx_c = tx.clone();
gio::spawn_blocking(move || {
let bg_db = match crate::core::database::Database::open() {
Ok(db) => db,
Err(_) => return,
};
loop {
let result = crate::core::github_enrichment::background_enrich_batch(
&bg_db,
&token,
20,
&|done, total| {
tx_c.send((done, total, false)).ok();
},
);
match result {
Ok((count, should_continue)) => {
if count == 0 || !should_continue {
// Done or rate limited
if let Ok((done, total)) = bg_db.catalog_enrichment_progress() {
tx_c.send((done, total, true)).ok();
}
break;
}
}
Err(e) => {
log::warn!("Background enrichment error: {}", e);
tx_c.send((0, 0, true)).ok();
break;
}
}
}
}).await.ok();
});
// Poll progress on the UI thread.
// Timer keeps running until enrichment is truly complete (all apps enriched).
// When rate-limited, shows a paused message but keeps the banner visible.
let banner_ref = banner;
glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
while let Ok((done, total, finished)) = rx.try_recv() {
if let Some(ref b) = banner_ref {
if finished {
if total == 0 || done >= total {
// Truly done - all apps enriched (or error with no data)
b.set_visible(false);
return glib::ControlFlow::Break;
}
// Rate limited or paused - keep banner visible with updated text
if let Some(spinner) = find_child_by_name(b, "enrich-spinner") {
if let Ok(s) = spinner.downcast::<gtk::Spinner>() {
s.set_spinning(false);
}
}
if let Some(label) = find_child_by_name(b, "enrich-label") {
if let Ok(l) = label.downcast::<gtk::Label>() {
l.set_label(&format!(
"Enriching paused - rate limit ({}/{} enriched)", done, total,
));
}
}
} else {
// Active progress update
if let Some(label) = find_child_by_name(b, "enrich-label") {
if let Ok(l) = label.downcast::<gtk::Label>() {
l.set_label(&format!(
"Enriching app data from GitHub ({}/{})...", done, total,
));
}
}
}
}
}
// Show banner only when on the catalog tab
if let Some(ref vs) = view_stack {
let on_catalog = vs.visible_child_name()
.map(|n| n == "catalog")
.unwrap_or(false);
if let Some(ref b) = banner_ref {
b.set_visible(on_catalog);
}
}
glib::ControlFlow::Continue
});
}
fn trigger_scan(&self) {