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::fuse; 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, 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 (prefer OCS personid, then homepage URL domain) let author_text = app.ocs_personid.as_deref() .filter(|p| !p.is_empty()) .map(|p| p.to_string()) .or_else(|| app.homepage.as_deref().map(|h| extract_author(h))); if let Some(author) = author_text { let author_label = gtk::Label::builder() .label(&format!("by {}", author)) .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 (map name -> record id for launching) let installed_map: std::collections::HashMap = db .get_all_appimages() .unwrap_or_default() .iter() .filter_map(|r| r.app_name.as_ref().map(|n| (n.to_lowercase(), r.id))) .collect(); let installed_record_id = installed_map.get(&app.name.to_lowercase()).copied(); let is_installed = installed_record_id.is_some(); let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0); // Shared OCS download files, populated by async fetch let ocs_files_shared: Rc>> = Rc::new(RefCell::new(Vec::new())); // Whether we need to load OCS download files let has_ocs = app.ocs_id.is_some(); let awaiting_ocs = has_ocs && !is_installed; if is_installed { // Show Launch button for installed apps if let Some(record_id) = installed_record_id { let launch_btn = gtk::Button::builder() .label(&i18n("Launch")) .css_classes(["suggested-action", "pill"]) .build(); launch_btn.set_action_name(Some("win.launch-appimage")); launch_btn.set_action_target_value(Some(&record_id.to_variant())); widgets::set_pointer_cursor(&launch_btn); button_box.append(&launch_btn); } 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 || awaiting_ocs { // 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(), app.ocs_id, &[], ); } } // 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::(); launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); }); button_box.append(&homepage_btn); } info_box.append(&button_box); // "What you'll get" info and compatibility check for install if !is_installed { let size_hint = app.ocs_downloadsize.filter(|&s| s > 0) .map(|s| format!(" ({})", widgets::format_size(s))) .unwrap_or_default(); let install_info = gtk::Label::builder() .label(&format!( "Downloads to ~/Applications and adds to your app launcher{}", size_hint )) .css_classes(["caption", "dim-label"]) .wrap(true) .xalign(0.0) .halign(gtk::Align::Start) .build(); info_box.append(&install_info); // System compatibility check let fuse_info = fuse::detect_system_fuse(); let (compat_text, compat_class) = if fuse_info.status.is_functional() { ("Works with your system", "success") } else { ("May need additional setup to run", "warning") }; let compat_badge = widgets::status_badge(compat_text, compat_class); compat_badge.set_halign(gtk::Align::Start); compat_badge.set_margin_top(2); info_box.append(&compat_badge); } header_box.append(&info_box); content.append(&header_box); // --- Stat cards row (between header and screenshots) --- // Prefer OCS data where available, fall back to GitHub data let display_downloads = app.ocs_downloads.filter(|&d| d > 0) .or(app.github_downloads.filter(|&d| d > 0)); let display_version = app.ocs_version.as_deref() .filter(|v| !v.is_empty()) .or(app.latest_version.as_deref()); let has_ocs = app.ocs_id.is_some(); let has_stats = has_github || has_ocs; 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(display_version.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(display_downloads.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_stats { let stats_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(10) .homogeneous(true) .build(); // Show stars card if we have GitHub data, or score if we have OCS data if has_github { 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); } else if let Some(score) = app.ocs_score.filter(|&s| s > 0) { let score_label = gtk::Label::builder() .label(&format!("{}%", score)) .css_classes(["stat-value"]) .xalign(0.0) .build(); let score_card = build_stat_card("starred-symbolic", &score_label, &i18n("Score")); score_card.add_css_class("stat-stars"); stats_row.append(&score_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); if has_github { 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); } else if let Some(ref changed) = app.ocs_changed { if !changed.is_empty() { let changed_label = gtk::Label::builder() .label(&widgets::relative_time(changed)) .css_classes(["stat-value"]) .xalign(0.0) .build(); let changed_card = build_stat_card("month-symbolic", &changed_label, &i18n("Updated")); changed_card.add_css_class("stat-released"); stats_row.append(&changed_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 && needs_enrichment { content.append(&enrich_spinner); } // --- About section container (created early so enrichment callback can update it) --- let about_container = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(0) .build(); { // Prefer long description for detail page (ocs_description is full HTML with // features, changelog etc.; ocs_summary and description are short one-liners) let display_desc = app.ocs_description.as_deref() .filter(|d| !d.is_empty()) .or(app.github_description.as_deref().filter(|d| !d.is_empty())) .or(app.description.as_deref().filter(|d| !d.is_empty())); if let Some(desc) = display_desc { let about_group = adw::PreferencesGroup::builder() .title(&i18n("About")) .build(); let plain_desc = catalog_tile::html_to_description(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); about_container.append(&about_group); } } // --- README section container (created early so enrichment callback can update it) --- let readme_container = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(8) .build(); if let Some(ref readme) = app.github_readme { if !readme.is_empty() { let readme_label = gtk::Label::builder() .label(&i18n("README")) .css_classes(["title-2"]) .xalign(0.0) .halign(gtk::Align::Start) .build(); readme_container.append(&readme_label); let rendered = widgets::build_markdown_view(readme); readme_container.append(&rendered); } } // 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(); let about_ref = about_container.clone(); let readme_ref = readme_container.clone(); let ocs_files_ref = ocs_files_shared.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, ); let _ = github_enrichment::enrich_app_readme( 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 { let files = ocs_files_ref.borrow(); 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(), updated.ocs_id, &files, ); } // Update about description if GitHub description is now available if let Some(ref desc) = updated.github_description { if !desc.is_empty() && about_ref.first_child().is_none() { let about_group = adw::PreferencesGroup::builder() .title(&i18n("About")) .build(); let desc_label = gtk::Label::builder() .label(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); about_ref.append(&about_group); } } // Populate README if now available if let Some(ref readme) = updated.github_readme { if !readme.is_empty() && readme_ref.first_child().is_none() { let readme_heading = gtk::Label::builder() .label(&i18n("README")) .css_classes(["title-2"]) .xalign(0.0) .halign(gtk::Align::Start) .build(); readme_ref.append(&readme_heading); let rendered = widgets::build_markdown_view(readme); readme_ref.append(&rendered); } } } if result.is_err() { log::warn!("On-demand enrichment thread error"); } }); } // On-demand OCS download files fetch: get all available versions for the install dropdown if awaiting_ocs { let ocs_id_val = app.ocs_id.unwrap(); let install_slot_ocs = install_slot.clone(); let toast_ocs = toast_overlay.clone(); let db_ocs = db.clone(); let app_name_ocs = app.name.clone(); let dl_url_ocs = app.download_url.clone(); let gh_dl_url = app.github_download_url.clone(); let gh_assets = app.github_release_assets.clone(); let hp_ocs = app.homepage.clone(); let ocs_id_opt = app.ocs_id; let ocs_files_ocs = ocs_files_shared.clone(); glib::spawn_future_local(async move { let id = ocs_id_val; let result = gio::spawn_blocking(move || { catalog::fetch_ocs_download_files(id) }).await; match result { Ok(Ok(files)) => { *ocs_files_ocs.borrow_mut() = files.clone(); populate_install_slot( &install_slot_ocs, &app_name_ocs, &dl_url_ocs, gh_dl_url.as_deref(), gh_assets.as_deref(), &toast_ocs, &db_ocs, hp_ocs.as_deref(), ocs_id_opt, &files, ); } _ => { // Fetch failed - populate with basic button (no OCS file data) populate_install_slot( &install_slot_ocs, &app_name_ocs, &dl_url_ocs, gh_dl_url.as_deref(), gh_assets.as_deref(), &toast_ocs, &db_ocs, hp_ocs.as_deref(), ocs_id_opt, &[], ); } } }); } // --- Screenshots section (paged carousel with arrows, click for lightbox) --- // Use screenshots field (populated from either OCS preview pics or AppImageHub) // Fall back to ocs_preview_url if screenshots is empty let screenshots_source = app.screenshots.as_deref() .filter(|s| !s.is_empty()) .or(app.ocs_preview_url.as_deref().filter(|s| !s.is_empty())); if let Some(screenshots_str) = screenshots_source { let paths: Vec = screenshots_str.split(';') .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .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); const SCREENSHOTS_PER_PAGE: usize = 2; let total_screenshots = paths.len(); let max_page = total_screenshots.saturating_sub(1) / SCREENSHOTS_PER_PAGE; // Store all textures for lightbox (across all pages) let textures: Rc>>> = Rc::new(RefCell::new(vec![None; total_screenshots])); // Store all frame refs so we can show/hide pages let all_paths: Rc> = Rc::new(paths); let screenshot_page: Rc> = Rc::new(std::cell::Cell::new(0)); // Stack for crossfade page transitions let ss_stack = gtk::Stack::builder() .transition_type(gtk::StackTransitionType::Crossfade) .transition_duration(200) .hexpand(true) .height_request(340) .build(); // Navigation arrows let ss_left = gtk::Button::builder() .icon_name("go-previous-symbolic") .css_classes(["circular", "osd"]) .valign(gtk::Align::Center) .sensitive(false) .build(); let ss_right = gtk::Button::builder() .icon_name("go-next-symbolic") .css_classes(["circular", "osd"]) .valign(gtk::Align::Center) .sensitive(max_page > 0) .build(); // Carousel row: [<] [stack] [>] let ss_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(8) .build(); ss_row.append(&ss_left); ss_row.append(&ss_stack); ss_row.append(&ss_right); // Flip state for crossfade (alternates between "a" and "b" stack children) let ss_flip: Rc> = Rc::new(std::cell::Cell::new(false)); // Build and show a page of screenshots let build_ss_page = { let textures_ref = textures.clone(); let paths_ref = all_paths.clone(); let app_name = app.name.clone(); Rc::new(move |page: usize, stack: >k::Stack, flip: &Rc>, left: >k::Button, right: >k::Button| { let start = page * SCREENSHOTS_PER_PAGE; let end = (start + SCREENSHOTS_PER_PAGE).min(paths_ref.len()); left.set_sensitive(page > 0); right.set_sensitive(page < max_page); let page_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) .homogeneous(true) .hexpand(true) .build(); for i in start..end { let frame = gtk::Frame::new(None); frame.add_css_class("card"); frame.set_height_request(320); let spinner = gtk::Spinner::builder() .halign(gtk::Align::Center) .valign(gtk::Align::Center) .spinning(true) .width_request(32) .height_request(32) .build(); frame.set_child(Some(&spinner)); widgets::set_pointer_cursor(&frame); // Click handler for lightbox let textures_click = textures_ref.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::() { detail_view::show_screenshot_lightbox( &window, &textures_click, idx, ); } } } } }); frame.add_controller(click); // Load screenshot asynchronously let name = app_name.clone(); let spath = paths_ref[i].clone(); let frame_ref = frame.clone(); let tex_ref = textures_ref.clone(); glib::spawn_future_local(async move { let n = name.clone(); let sp = spath.clone(); let load_idx = i; let result = gio::spawn_blocking(move || { catalog::cache_screenshot(&n, &sp, 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)); tex_ref.borrow_mut()[load_idx] = 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)); } } }); page_box.append(&frame); } // Crossfade: alternate between "a" and "b" children let current_flip = flip.get(); let child_name = if current_flip { "ss_b" } else { "ss_a" }; stack.add_named(&page_box, Some(child_name)); stack.set_visible_child_name(child_name); // Remove the old child let old_name = if current_flip { "ss_a" } else { "ss_b" }; if let Some(old_child) = stack.child_by_name(old_name) { stack.remove(&old_child); } flip.set(!current_flip); }) }; // Show initial page build_ss_page(0, &ss_stack, &ss_flip, &ss_left, &ss_right); // Wire arrow clicks { let page_ref = screenshot_page.clone(); let stack_ref = ss_stack.clone(); let flip_ref = ss_flip.clone(); let left_ref = ss_left.clone(); let right_ref = ss_right.clone(); let build_ref = build_ss_page.clone(); ss_left.connect_clicked(move |_| { let page = page_ref.get(); if page > 0 { page_ref.set(page - 1); build_ref(page - 1, &stack_ref, &flip_ref, &left_ref, &right_ref); } }); } { let page_ref = screenshot_page.clone(); let stack_ref = ss_stack.clone(); let flip_ref = ss_flip.clone(); let left_ref = ss_left.clone(); let right_ref = ss_right.clone(); let build_ref = build_ss_page.clone(); ss_right.connect_clicked(move |_| { let page = page_ref.get(); if page < max_page { page_ref.set(page + 1); build_ref(page + 1, &stack_ref, &flip_ref, &left_ref, &right_ref); } }); } content.append(&ss_row); } } content.append(&about_container); content.append(&readme_container); // --- Details section --- let details_group = adw::PreferencesGroup::builder() .title(&i18n("Details")) .build(); // Type/Category from OCS if let Some(ref typename) = app.ocs_typename { if !typename.is_empty() { let escaped = typename.replace('&', "&"); let row = adw::ActionRow::builder() .title(&i18n("Type")) .subtitle(&escaped) .build(); details_group.add(&row); } } if let Some(ref cats) = app.categories { if !cats.is_empty() { let escaped_cats = cats.replace('&', "&"); let row = adw::ActionRow::builder() .title(&i18n("Categories")) .subtitle(&escaped_cats) .build(); details_group.add(&row); } } 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); } } // Architecture if let Some(ref arch) = app.ocs_arch { if !arch.is_empty() { let row = adw::ActionRow::builder() .title(&i18n("Architecture")) .subtitle(arch) .build(); details_group.add(&row); } } // File name if let Some(ref dlname) = app.ocs_downloadname { if !dlname.is_empty() { let row = adw::ActionRow::builder() .title(&i18n("Filename")) .subtitle(dlname) .subtitle_selectable(true) .build(); details_group.add(&row); } } // File size if let Some(size_kb) = app.ocs_downloadsize { if size_kb > 0 { let row = adw::ActionRow::builder() .title(&i18n("File size")) .subtitle(&widgets::format_size(size_kb * 1024)) .build(); details_group.add(&row); } } // MD5 checksum if let Some(ref md5) = app.ocs_md5sum { if !md5.is_empty() { let row = adw::ActionRow::builder() .title(&i18n("MD5")) .subtitle(md5) .subtitle_selectable(true) .build(); details_group.add(&row); } } if let Some(ref tags) = app.ocs_tags { if !tags.is_empty() { // Format tags nicely with commas let formatted = tags.split(',') .map(|t| t.trim()) .filter(|t| !t.is_empty()) .collect::>() .join(", ") .replace('&', "&"); let row = adw::ActionRow::builder() .title(&i18n("Tags")) .subtitle(&formatted) .subtitle_selectable(true) .build(); details_group.add(&row); } } // Created date if let Some(ref created) = app.ocs_created { if !created.is_empty() { let row = adw::ActionRow::builder() .title(&i18n("Published")) .subtitle(&widgets::relative_time(created)) .build(); details_group.add(&row); } } // Comments count if let Some(comments) = app.ocs_comments { if comments > 0 { let row = adw::ActionRow::builder() .title(&i18n("Comments")) .subtitle(&format!("{}", comments)) .build(); details_group.add(&row); } } content.append(&details_group); // --- Links section --- let links_group = adw::PreferencesGroup::builder() .title(&i18n("Links")) .build(); // GitHub link (extracted from description or already known) if let Some(ref owner) = app.github_owner { if let Some(ref repo) = app.github_repo { let gh_url = format!("https://github.com/{}/{}", owner, repo); let row = adw::ActionRow::builder() .title(&i18n("GitHub")) .subtitle(&gh_url) .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 url = gh_url; row.connect_activated(move |row| { let launcher = gtk::UriLauncher::new(&url); let root = row.root().and_downcast::(); launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); }); links_group.add(&row); } } // AppImageHub.com detail page if let Some(ref detailpage) = app.ocs_detailpage { if !detailpage.is_empty() { let row = adw::ActionRow::builder() .title(&i18n("AppImageHub")) .subtitle(detailpage) .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 dp = detailpage.clone(); row.connect_activated(move |row| { let launcher = gtk::UriLauncher::new(&dp); let root = row.root().and_downcast::(); launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); }); links_group.add(&row); } } // Homepage (non-OCS apps or if we have a real homepage) if let Some(ref homepage) = app.homepage { // Don't duplicate if it's the same as the detailpage let is_detailpage = app.ocs_detailpage.as_deref() == Some(homepage.as_str()); if !is_detailpage { 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::(); launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); }); links_group.add(&row); } } content.append(&links_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)); widgets::apply_pointer_cursors(&toolbar); 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. /// `ocs_slot` is the 1-based OCS download file slot (default 1). fn do_install( url: String, app_name: String, homepage: Option, toast_overlay: adw::ToastOverlay, db: Rc, widget: gtk::Widget, ocs_id: Option, ocs_slot: u32, ) { 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_with_ocs(&cat_app, &install_dir, ocs_id, ocs_slot) .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::() { btn.set_label("Installed"); } else if let Some(split) = widget.downcast_ref::() { 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::() { btn.set_label("Install"); } else if let Some(split) = widget.downcast_ref::() { 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::() { btn.set_label("Install"); } else if let Some(split) = widget.downcast_ref::() { 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. /// `ocs_files` - if provided, OCS download files to show in the dropdown. fn populate_install_slot( slot: >k::Box, app_name: &str, download_url: &str, github_download_url: Option<&str>, github_release_assets: Option<&str>, toast_overlay: &adw::ToastOverlay, db: &Rc, homepage: Option<&str>, ocs_id: Option, ocs_files: &[catalog::OcsDownloadFile], ) { // Clear existing children (e.g. the "Loading..." placeholder) while let Some(child) = slot.first_child() { slot.remove(&child); } let assets: Vec = 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(); // Determine the default OCS slot (first file = newest) let default_ocs_slot = ocs_files.first().map(|f| f.slot).unwrap_or(1); // Combine GitHub assets and OCS files into dropdown options let has_gh_assets = assets.len() > 1; let has_ocs_files = ocs_files.len() > 1; if has_gh_assets || has_ocs_files { let menu = gio::Menu::new(); // OCS version files (each needs slot-based install) for file in ocs_files { let label = format_ocs_file_label(file); // Encode ocs_id and slot as action param: "ocs:{ocs_id}:{slot}" menu.append(Some(&label), Some(&format!("install.ocs::{}:{}", file.ocs_id, file.slot))); } // GitHub release assets for asset in &assets { let label = format_asset_label(&asset.name, asset.size); menu.append(Some(&label), Some(&format!("install.gh::{}", asset.url))); } // Fallback to original catalog URL if has_gh_assets || has_ocs_files { menu.append( Some(&i18n("Direct download (original)")), Some(&format!("install.gh::{}", download_url)), ); } let split_btn = adw::SplitButton::builder() .label(&i18n("Install")) .menu_model(&menu) .css_classes(["suggested-action", "pill"]) .build(); // Default click: install the default version 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(); let ocs_id_click = ocs_id; let default_slot = default_ocs_slot; 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::().clone(), ocs_id_click, default_slot, ); }); let action_group = gio::SimpleActionGroup::new(); // OCS slot-based install action let ocs_action = gio::SimpleAction::new("ocs", Some(glib::VariantTy::STRING)); let name_ocs = app_name.to_string(); let hp_ocs = homepage.map(|s| s.to_string()); let toast_ocs = toast_overlay.clone(); let db_ocs = db.clone(); let split_ocs = split_btn.clone(); let dl_url_ocs = download_url.to_string(); ocs_action.connect_activate(move |_, param| { if let Some(param_str) = param.and_then(|p| p.str()) { // Parse "ocs_id:slot" let parts: Vec<&str> = param_str.splitn(2, ':').collect(); if let (Some(id_str), Some(slot_str)) = (parts.first(), parts.get(1)) { let ocs_id = id_str.parse::().ok(); let slot = slot_str.parse::().unwrap_or(1); split_ocs.set_sensitive(false); split_ocs.set_label("Installing..."); do_install( dl_url_ocs.clone(), name_ocs.clone(), hp_ocs.clone(), toast_ocs.clone(), db_ocs.clone(), split_ocs.upcast_ref::().clone(), ocs_id, slot, ); } } }); action_group.add_action(&ocs_action); // GitHub URL-based install action let gh_action = gio::SimpleAction::new("gh", Some(glib::VariantTy::STRING)); let name_gh = app_name.to_string(); let hp_gh = homepage.map(|s| s.to_string()); let toast_gh = toast_overlay.clone(); let db_gh = db.clone(); let split_gh = split_btn.clone(); gh_action.connect_activate(move |_, param| { if let Some(url) = param.and_then(|p| p.str()) { split_gh.set_sensitive(false); split_gh.set_label("Installing..."); do_install( url.to_string(), name_gh.clone(), hp_gh.clone(), toast_gh.clone(), db_gh.clone(), split_gh.upcast_ref::().clone(), None, 1, ); } }); action_group.add_action(&gh_action); split_btn.insert_action_group("install", Some(&action_group)); widgets::set_pointer_cursor(&split_btn); slot.append(&split_btn); } else { // Single asset - plain button let install_btn = gtk::Button::builder() .label(&i18n("Install")) .css_classes(["suggested-action", "pill"]) .build(); widgets::set_pointer_cursor(&install_btn); 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(); let ocs_id_plain = ocs_id; let slot_plain = default_ocs_slot; 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::().clone(), ocs_id_plain, slot_plain, ); }); slot.append(&install_btn); } } /// Format an OCS download file label for the dropdown menu. fn format_ocs_file_label(file: &catalog::OcsDownloadFile) -> String { let mut parts = Vec::new(); if !file.version.is_empty() { parts.push(format!("v{}", file.version)); } if let Some(ref arch) = file.arch { parts.push(arch.clone()); } if !file.filename.is_empty() { parts.push(file.filename.clone()); } if let Some(ref pkg_type) = file.pkg_type { if pkg_type != "appimage" { parts.push(format!("[{}]", pkg_type)); } } if let Some(size_kb) = file.size_kb { if size_kb > 0 { parts.push(format!("({})", widgets::format_size(size_kb * 1024))); } } if parts.is_empty() { format!("File {}", file.slot) } else { parts.join(" - ") } } /// 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: >k::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() }