Add GitHub metadata enrichment for catalog apps

This commit is contained in:
2026-02-28 16:49:13 +02:00
parent f22438d960
commit 848f4e7de7
15 changed files with 3027 additions and 224 deletions

812
src/ui/catalog_detail.rs Normal file
View File

@@ -0,0 +1,812 @@
use adw::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
use gtk::gio;
use crate::core::catalog;
use crate::core::database::{CatalogApp, Database};
use crate::core::github_enrichment;
use crate::core::github_enrichment::AppImageAsset;
use crate::i18n::i18n;
use super::catalog_tile;
use super::detail_view;
use super::widgets;
use crate::config::APP_ID;
/// Build a catalog app detail page for the given CatalogApp.
/// Returns an adw::NavigationPage that can be pushed onto a NavigationView.
pub fn build_catalog_detail_page(
app: &CatalogApp,
db: &Rc<Database>,
toast_overlay: &adw::ToastOverlay,
) -> adw::NavigationPage {
let page_title = app.name.clone();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.build();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(24)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
// --- Header section: icon + name + author + buttons ---
let header_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(18)
.build();
let icon = widgets::app_icon(None, &app.name, 96);
icon.add_css_class("icon-dropshadow");
icon.set_valign(gtk::Align::Start);
header_box.append(&icon);
let info_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.valign(gtk::Align::Center)
.hexpand(true)
.build();
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["title-1"])
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
info_box.append(&name_label);
// Author (from homepage URL domain)
if let Some(ref homepage) = app.homepage {
let author_text = extract_author(homepage);
let author_label = gtk::Label::builder()
.label(&format!("by {}", author_text))
.css_classes(["dim-label"])
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
info_box.append(&author_label);
}
// Button row
let button_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_top(8)
.build();
// Check early if GitHub data needs loading (affects install button behavior)
let has_github = app.github_owner.is_some() && app.github_repo.is_some();
let needs_enrichment = has_github
&& (app.latest_version.is_none() || is_enrichment_stale(app.github_enriched_at.as_deref()));
let awaiting_github = needs_enrichment && app.github_download_url.is_none();
// Check if already installed
let installed_names: std::collections::HashSet<String> = db
.get_all_appimages()
.unwrap_or_default()
.iter()
.filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase()))
.collect();
let is_installed = installed_names.contains(&app.name.to_lowercase());
let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
if is_installed {
let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
installed_badge.set_valign(gtk::Align::Center);
button_box.append(&installed_badge);
} else {
button_box.append(&install_slot);
if awaiting_github {
// GitHub release data not yet loaded - show disabled placeholder
let placeholder = gtk::Button::builder()
.label(&i18n("Loading..."))
.css_classes(["suggested-action", "pill"])
.sensitive(false)
.build();
install_slot.append(&placeholder);
} else {
populate_install_slot(
&install_slot,
&app.name,
&app.download_url,
app.github_download_url.as_deref(),
app.github_release_assets.as_deref(),
toast_overlay,
db,
app.homepage.as_deref(),
);
}
}
// Homepage button
if let Some(ref homepage) = app.homepage {
let homepage_btn = gtk::Button::builder()
.label(&i18n("Homepage"))
.css_classes(["flat", "pill"])
.build();
let hp_clone = homepage.clone();
homepage_btn.connect_clicked(move |btn| {
let launcher = gtk::UriLauncher::new(&hp_clone);
let root = btn.root().and_downcast::<gtk::Window>();
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
});
button_box.append(&homepage_btn);
}
info_box.append(&button_box);
header_box.append(&info_box);
content.append(&header_box);
// --- GitHub stat cards row (between header and screenshots) ---
let stars_value_label = gtk::Label::builder()
.label(app.github_stars.filter(|&s| s > 0).map(|s| format_count(s)).as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.build();
let version_value_label = gtk::Label::builder()
.label(app.latest_version.as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(14)
.build();
let downloads_value_label = gtk::Label::builder()
.label(app.github_downloads.filter(|&d| d > 0).map(|d| format_count(d)).as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.build();
let released_value_label = gtk::Label::builder()
.label(app.release_date.as_deref().map(|d| widgets::relative_time(d)).as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.build();
if has_github {
let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(10)
.homogeneous(true)
.build();
let stars_card = build_stat_card("starred-symbolic", &stars_value_label, &i18n("Stars"));
stars_card.add_css_class("stat-stars");
stats_row.append(&stars_card);
let version_card = build_stat_card("tag-symbolic", &version_value_label, &i18n("Latest"));
version_card.add_css_class("stat-version");
stats_row.append(&version_card);
let downloads_card = build_stat_card("folder-download-symbolic", &downloads_value_label, &i18n("Downloads"));
downloads_card.add_css_class("stat-downloads");
stats_row.append(&downloads_card);
let released_card = build_stat_card("month-symbolic", &released_value_label, &i18n("Released"));
released_card.add_css_class("stat-released");
stats_row.append(&released_card);
content.append(&stats_row);
}
// Enrichment spinner (small, shown next to stats row while loading)
let enrich_spinner = gtk::Spinner::builder()
.spinning(false)
.visible(false)
.halign(gtk::Align::Start)
.width_request(16)
.height_request(16)
.build();
if has_github {
content.append(&enrich_spinner);
}
// On-demand enrichment: fetch release info if stale or missing
if has_github && needs_enrichment {
let app_id = app.id;
let owner = app.github_owner.clone().unwrap_or_default();
let repo = app.github_repo.clone().unwrap_or_default();
let db_ref = db.clone();
let stars_ref = stars_value_label.clone();
let version_ref = version_value_label.clone();
let downloads_ref = downloads_value_label.clone();
let released_ref = released_value_label.clone();
let spinner_ref = enrich_spinner.clone();
let install_slot_ref = install_slot.clone();
let toast_ref = toast_overlay.clone();
spinner_ref.set_visible(true);
spinner_ref.set_spinning(true);
let settings = gio::Settings::new(APP_ID);
let token = settings.string("github-token").to_string();
glib::spawn_future_local(async move {
let owner_c = owner.clone();
let repo_c = repo.clone();
let token_c = token.clone();
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().ok();
if let Some(ref db) = bg_db {
let _ = github_enrichment::enrich_app_release_info(
db, app_id, &owner_c, &repo_c, &token_c,
);
let _ = github_enrichment::enrich_app_repo_info(
db, app_id, &owner_c, &repo_c, &token_c,
);
}
}).await;
spinner_ref.set_spinning(false);
spinner_ref.set_visible(false);
if let Ok(Some(updated)) = db_ref.get_catalog_app(app_id) {
if let Some(stars) = updated.github_stars.filter(|&s| s > 0) {
stars_ref.set_label(&format_count(stars));
}
if let Some(ref ver) = updated.latest_version {
version_ref.set_label(ver);
}
if let Some(downloads) = updated.github_downloads.filter(|&d| d > 0) {
downloads_ref.set_label(&format_count(downloads));
}
if let Some(ref date) = updated.release_date {
released_ref.set_label(&widgets::relative_time(date));
}
// Rebuild install button now that GitHub data is available
if awaiting_github && !is_installed {
populate_install_slot(
&install_slot_ref,
&updated.name,
&updated.download_url,
updated.github_download_url.as_deref(),
updated.github_release_assets.as_deref(),
&toast_ref,
&db_ref,
updated.homepage.as_deref(),
);
}
}
if result.is_err() {
log::warn!("On-demand enrichment thread error");
}
});
}
// --- Screenshots section (click to open lightbox) ---
if let Some(ref screenshots_str) = app.screenshots {
let paths: Vec<&str> = screenshots_str.split(';').filter(|s| !s.is_empty()).collect();
if !paths.is_empty() {
let screenshots_label = gtk::Label::builder()
.label(&i18n("Screenshots"))
.css_classes(["title-2"])
.xalign(0.0)
.halign(gtk::Align::Start)
.margin_top(12)
.build();
content.append(&screenshots_label);
let screenshot_scroll = gtk::ScrolledWindow::builder()
.vscrollbar_policy(gtk::PolicyType::Never)
.hscrollbar_policy(gtk::PolicyType::Automatic)
.height_request(360)
.build();
let screenshot_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.build();
// Store textures for lightbox access
let textures: Rc<RefCell<Vec<Option<gtk::gdk::Texture>>>> =
Rc::new(RefCell::new(vec![None; paths.len()]));
for (i, path) in paths.iter().enumerate() {
let frame = gtk::Frame::new(None);
frame.add_css_class("card");
frame.set_width_request(480);
frame.set_height_request(340);
// Spinner placeholder
let spinner = gtk::Spinner::builder()
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.spinning(true)
.width_request(48)
.height_request(48)
.build();
frame.set_child(Some(&spinner));
screenshot_box.append(&frame);
// Click handler for lightbox
let textures_click = textures.clone();
let click = gtk::GestureClick::new();
let idx = i;
click.connect_released(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
let t = textures_click.borrow();
if t.get(idx).is_some_and(|t| t.is_some()) {
if let Some(widget) = gesture.widget() {
if let Some(root) = gtk::prelude::WidgetExt::root(&widget) {
if let Ok(window) = root.downcast::<gtk::Window>() {
detail_view::show_screenshot_lightbox(
&window,
&textures_click,
idx,
);
}
}
}
}
});
frame.add_controller(click);
// Load screenshot asynchronously
let app_name = app.name.clone();
let screenshot_path = path.to_string();
let frame_ref = frame.clone();
let textures_ref = textures.clone();
glib::spawn_future_local(async move {
let name = app_name.clone();
let spath = screenshot_path.clone();
let load_idx = i;
let result = gio::spawn_blocking(move || {
catalog::cache_screenshot(&name, &spath, load_idx)
.map_err(|e| e.to_string())
}).await;
match result {
Ok(Ok(local_path)) => {
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
let picture = gtk::Picture::builder()
.paintable(&texture)
.content_fit(gtk::ContentFit::Contain)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
frame_ref.set_child(Some(&picture));
textures_ref.borrow_mut()[i] = Some(texture);
}
}
_ => {
let fallback = gtk::Label::builder()
.label("Screenshot unavailable")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
frame_ref.set_child(Some(&fallback));
}
}
});
}
screenshot_scroll.set_child(Some(&screenshot_box));
content.append(&screenshot_scroll);
}
}
// --- About section ---
if let Some(ref desc) = app.description {
if !desc.is_empty() {
let about_group = adw::PreferencesGroup::builder()
.title(&i18n("About"))
.build();
let plain_desc = catalog_tile::strip_html(desc);
let desc_label = gtk::Label::builder()
.label(&plain_desc)
.wrap(true)
.xalign(0.0)
.selectable(true)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build();
let row = adw::ActionRow::new();
row.set_child(Some(&desc_label));
about_group.add(&row);
content.append(&about_group);
}
}
// --- Details section ---
let details_group = adw::PreferencesGroup::builder()
.title(&i18n("Details"))
.build();
if let Some(ref license) = app.license {
if !license.is_empty() {
let row = adw::ActionRow::builder()
.title(&i18n("License"))
.subtitle(license)
.subtitle_selectable(true)
.build();
details_group.add(&row);
}
}
if let Some(ref cats) = app.categories {
if !cats.is_empty() {
let row = adw::ActionRow::builder()
.title(&i18n("Categories"))
.subtitle(cats)
.build();
details_group.add(&row);
}
}
if let Some(ref homepage) = app.homepage {
let row = adw::ActionRow::builder()
.title(&i18n("Homepage"))
.subtitle(homepage)
.subtitle_selectable(true)
.activatable(true)
.build();
let arrow = gtk::Image::from_icon_name("external-link-symbolic");
arrow.set_valign(gtk::Align::Center);
row.add_suffix(&arrow);
let hp = homepage.clone();
row.connect_activated(move |row| {
let launcher = gtk::UriLauncher::new(&hp);
let root = row.root().and_downcast::<gtk::Window>();
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
});
details_group.add(&row);
}
// Download URL
let dl_row = adw::ActionRow::builder()
.title(&i18n("Download"))
.subtitle(&app.download_url)
.subtitle_selectable(true)
.activatable(true)
.build();
let dl_arrow = gtk::Image::from_icon_name("external-link-symbolic");
dl_arrow.set_valign(gtk::Align::Center);
dl_row.add_suffix(&dl_arrow);
let dl_url = app.download_url.clone();
dl_row.connect_activated(move |row| {
let launcher = gtk::UriLauncher::new(&dl_url);
let root = row.root().and_downcast::<gtk::Window>();
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
});
details_group.add(&dl_row);
content.append(&details_group);
// --- Status section ---
let status_group = adw::PreferencesGroup::builder()
.title(&i18n("Status"))
.build();
let status_row = adw::ActionRow::builder()
.title(&i18n("Installed"))
.build();
let status_badge = if is_installed {
widgets::status_badge(&i18n("Yes"), "success")
} else {
widgets::status_badge(&i18n("No"), "neutral")
};
status_badge.set_valign(gtk::Align::Center);
status_row.add_suffix(&status_badge);
status_group.add(&status_row);
content.append(&status_group);
clamp.set_child(Some(&content));
scrolled.set_child(Some(&clamp));
toolbar.set_content(Some(&scrolled));
adw::NavigationPage::builder()
.title(&page_title)
.tag("catalog-detail")
.child(&toolbar)
.build()
}
/// Trigger an install from a URL. Handles async download, DB registration, and UI feedback.
fn do_install(
url: String,
app_name: String,
homepage: Option<String>,
toast_overlay: adw::ToastOverlay,
db: Rc<Database>,
widget: gtk::Widget,
) {
glib::spawn_future_local(async move {
let name = app_name.clone();
let hp = homepage.clone();
let dl_url = url.clone();
let result = gio::spawn_blocking(move || {
let install_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("Applications");
std::fs::create_dir_all(&install_dir).ok();
let cat_app = catalog::CatalogApp {
name,
description: None,
categories: Vec::new(),
latest_version: None,
download_url: dl_url,
icon_url: None,
homepage: hp,
file_size: None,
architecture: None,
screenshots: Vec::new(),
license: None,
github_link: None,
};
catalog::install_from_catalog(&cat_app, &install_dir)
.map_err(|e| e.to_string())
}).await;
match result {
Ok(Ok(path)) => {
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let size = std::fs::metadata(&path)
.map(|m| m.len() as i64)
.unwrap_or(0);
db.upsert_appimage(
&path.to_string_lossy(),
&filename,
Some(2),
size,
true,
None,
).ok();
toast_overlay.add_toast(adw::Toast::new("Installed successfully"));
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
btn.set_label("Installed");
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
split.set_label("Installed");
}
}
Ok(Err(e)) => {
log::error!("Install failed: {}", e);
toast_overlay.add_toast(adw::Toast::new("Install failed"));
widget.set_sensitive(true);
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
btn.set_label("Install");
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
split.set_label("Install");
}
}
Err(_) => {
log::error!("Install thread panicked");
toast_overlay.add_toast(adw::Toast::new("Install failed"));
widget.set_sensitive(true);
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
btn.set_label("Install");
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
split.set_label("Install");
}
}
}
});
}
/// Populate the install button slot with the appropriate button (plain or split).
/// Clears any existing children first, so it can be called to rebuild after enrichment.
fn populate_install_slot(
slot: &gtk::Box,
app_name: &str,
download_url: &str,
github_download_url: Option<&str>,
github_release_assets: Option<&str>,
toast_overlay: &adw::ToastOverlay,
db: &Rc<Database>,
homepage: Option<&str>,
) {
// Clear existing children (e.g. the "Loading..." placeholder)
while let Some(child) = slot.first_child() {
slot.remove(&child);
}
let assets: Vec<AppImageAsset> = github_release_assets
.and_then(|json| serde_json::from_str(json).ok())
.unwrap_or_default();
let default_url = github_download_url
.unwrap_or(download_url)
.to_string();
if assets.len() > 1 {
// Multiple assets available - use SplitButton with dropdown
let menu = gio::Menu::new();
for asset in &assets {
let label = format_asset_label(&asset.name, asset.size);
menu.append(Some(&label), Some(&format!("install.asset::{}", asset.url)));
}
menu.append(
Some(&i18n("AppImageHub (original)")),
Some(&format!("install.asset::{}", download_url)),
);
let split_btn = adw::SplitButton::builder()
.label(&i18n("Install"))
.menu_model(&menu)
.css_classes(["suggested-action", "pill"])
.build();
let url_for_click = default_url;
let name_for_click = app_name.to_string();
let hp_for_click = homepage.map(|s| s.to_string());
let toast_for_click = toast_overlay.clone();
let db_for_click = db.clone();
split_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
btn.set_label("Installing...");
do_install(
url_for_click.clone(),
name_for_click.clone(),
hp_for_click.clone(),
toast_for_click.clone(),
db_for_click.clone(),
btn.upcast_ref::<gtk::Widget>().clone(),
);
});
let action_group = gio::SimpleActionGroup::new();
let asset_action = gio::SimpleAction::new("asset", Some(glib::VariantTy::STRING));
let name_for_asset = app_name.to_string();
let hp_for_asset = homepage.map(|s| s.to_string());
let toast_for_asset = toast_overlay.clone();
let db_for_asset = db.clone();
let split_ref = split_btn.clone();
asset_action.connect_activate(move |_, param| {
if let Some(url) = param.and_then(|p| p.str()) {
split_ref.set_sensitive(false);
split_ref.set_label("Installing...");
do_install(
url.to_string(),
name_for_asset.clone(),
hp_for_asset.clone(),
toast_for_asset.clone(),
db_for_asset.clone(),
split_ref.upcast_ref::<gtk::Widget>().clone(),
);
}
});
action_group.add_action(&asset_action);
split_btn.insert_action_group("install", Some(&action_group));
slot.append(&split_btn);
} else {
// Single asset or no GitHub assets - plain button
let install_btn = gtk::Button::builder()
.label(&i18n("Install"))
.css_classes(["suggested-action", "pill"])
.build();
let url_clone = default_url;
let name_clone = app_name.to_string();
let hp_clone = homepage.map(|s| s.to_string());
let toast_clone = toast_overlay.clone();
let db_clone = db.clone();
install_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
btn.set_label("Installing...");
do_install(
url_clone.clone(),
name_clone.clone(),
hp_clone.clone(),
toast_clone.clone(),
db_clone.clone(),
btn.upcast_ref::<gtk::Widget>().clone(),
);
});
slot.append(&install_btn);
}
}
/// Format an asset filename with size for the dropdown menu.
fn format_asset_label(name: &str, size: i64) -> String {
if size > 0 {
format!("{} ({})", name, widgets::format_size(size))
} else {
name.to_string()
}
}
/// Build a single stat card widget with icon, value label, and description label.
fn build_stat_card(icon_name: &str, value_label: &gtk::Label, label_text: &str) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.hexpand(true)
.build();
card.add_css_class("stat-card");
let icon_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(14);
icon_row.append(&icon);
icon_row.append(value_label);
card.append(&icon_row);
let label = gtk::Label::builder()
.label(label_text)
.css_classes(["stat-label"])
.xalign(0.0)
.build();
card.append(&label);
card
}
/// Check if enrichment data is stale (>24 hours old).
fn is_enrichment_stale(enriched_at: Option<&str>) -> bool {
let Some(ts) = enriched_at else { return true };
let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") else {
return true;
};
let now = chrono::Utc::now().naive_utc();
let elapsed = now.signed_duration_since(parsed);
elapsed.num_hours() >= 24
}
/// Re-export format_count from widgets for use in this module.
fn format_count(n: i64) -> String {
widgets::format_count(n)
}
/// Extract author/org from a URL for display.
/// For GitHub/GitLab URLs, extracts the username/org from the path.
/// For other URLs, returns the domain.
fn extract_author(url: &str) -> String {
let stripped = url.trim_start_matches("https://")
.trim_start_matches("http://");
let parts: Vec<&str> = stripped.splitn(3, '/').collect();
let domain = parts.first().copied().unwrap_or("");
// For GitHub/GitLab, extract the org/user from the first path segment
if domain == "github.com" || domain == "gitlab.com" {
if let Some(org) = parts.get(1) {
if !org.is_empty() {
return org.to_string();
}
}
}
domain.to_string()
}