use adw::prelude::*; use std::path::Path; use std::rc::Rc; use gtk::gio; use crate::config::APP_ID; use crate::core::database::Database; use crate::core::updater; use crate::i18n::{i18n, ni18n_f}; use crate::ui::update_dialog; use crate::ui::widgets; /// Build the Updates top-level view. /// Returns a ToolbarView that can be added to the ViewStack. pub fn build_updates_view(db: &Rc) -> adw::ToolbarView { let toast_overlay = adw::ToastOverlay::new(); let header = adw::HeaderBar::new(); let title = adw::WindowTitle::builder() .title(&i18n("Updates")) .build(); header.set_title_widget(Some(&title)); // Check Now button let check_btn = widgets::accessible_icon_button( "view-refresh-symbolic", "Check for updates", &i18n("Check for updates (Ctrl+U)"), ); header.pack_end(&check_btn); // Update All button (only visible when updates exist) let update_all_btn = gtk::Button::builder() .label(&i18n("Update All")) .css_classes(["suggested-action", "pill"]) .visible(false) .build(); header.pack_start(&update_all_btn); // Content stack: empty state vs checking vs update list let stack = gtk::Stack::new(); // Empty state - all up to date let empty_page = adw::StatusPage::builder() .icon_name("emblem-ok-symbolic") .title(&i18n("Everything is Up to Date")) .description(&i18n("All your AppImages are running the latest version")) .build(); stack.add_named(&empty_page, Some("empty")); // Checking state let checking_page = adw::StatusPage::builder() .icon_name("emblem-synchronizing-symbolic") .title(&i18n("Checking for Updates")) .description(&i18n("Looking for new versions of your AppImages...")) .build(); let spinner = gtk::Spinner::builder() .spinning(true) .width_request(32) .height_request(32) .halign(gtk::Align::Center) .build(); spinner.update_property(&[gtk::accessible::Property::Label("Checking for updates")]); checking_page.set_child(Some(&spinner)); stack.add_named(&checking_page, Some("checking")); // Update list let list_box = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) .margin_start(18) .margin_end(18) .margin_top(12) .margin_bottom(24) .build(); let last_checked_label = gtk::Label::builder() .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Start) .margin_start(18) .margin_top(6) .build(); let updates_content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(6) .build(); updates_content.append(&last_checked_label); // "What will happen" explanation let explanation = gtk::Label::builder() .label(&i18n("Each app will be downloaded fresh. Your settings and data are kept. You can choose to keep old versions as backup in Preferences.")) .css_classes(["caption", "dim-label"]) .wrap(true) .xalign(0.0) .margin_start(18) .margin_end(18) .margin_top(6) .build(); updates_content.append(&explanation); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .child(&list_box) .build(); updates_content.append(&clamp); let scrolled = gtk::ScrolledWindow::builder() .child(&updates_content) .vexpand(true) .build(); stack.add_named(&scrolled, Some("updates")); toast_overlay.set_child(Some(&stack)); // Shared state for refresh let state = Rc::new(UpdatesState { db: db.clone(), list_box: list_box.clone(), stack: stack.clone(), update_all_btn: update_all_btn.clone(), title: title.clone(), last_checked_label: last_checked_label.clone(), toast_overlay: toast_overlay.clone(), }); // Initial population populate_update_list(&state); // Check Now handler { let state_ref = state.clone(); check_btn.connect_clicked(move |btn| { btn.set_sensitive(false); state_ref.stack.set_visible_child_name("checking"); let state_c = state_ref.clone(); let btn_c = btn.clone(); glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let _result = gio::spawn_blocking(move || { if let Some(ref db) = db_bg { update_dialog::batch_check_updates(db); } }).await; btn_c.set_sensitive(true); // Update last-checked timestamp let settings = gio::Settings::new(APP_ID); let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); settings.set_string("last-update-check", &now).ok(); // Reload from main DB let fresh_db = state_c.db.clone(); // Re-read records from the shared db so UI picks up changes from the bg thread drop(fresh_db); populate_update_list(&state_c); state_c.toast_overlay.add_toast(widgets::info_toast(&i18n("Update check complete"))); }); }); } // Update All handler { let state_ref = state.clone(); update_all_btn.connect_clicked(move |btn| { btn.set_sensitive(false); let state_c = state_ref.clone(); let btn_c = btn.clone(); glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let result = gio::spawn_blocking(move || { let mut updated = 0u32; if let Some(ref db) = db_bg { let updatable = db.get_appimages_with_updates().unwrap_or_default(); for rec in &updatable { let path = Path::new(&rec.path); if path.exists() { if updater::perform_update( path, rec.update_url.as_deref(), true, None, ).is_ok() { db.clear_update_available(rec.id).ok(); updated += 1; } } } } updated }).await; btn_c.set_sensitive(true); let count = result.unwrap_or(0); if count > 0 { state_c.toast_overlay.add_toast( widgets::info_toast(&ni18n_f("{} app updated", "{} apps updated", count, &[("{}", &count.to_string())])), ); } populate_update_list(&state_c); }); }); } let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); toolbar_view.set_content(Some(&toast_overlay)); widgets::apply_pointer_cursors(&toolbar_view); toolbar_view } struct UpdatesState { db: Rc, list_box: gtk::ListBox, stack: gtk::Stack, update_all_btn: gtk::Button, title: adw::WindowTitle, last_checked_label: gtk::Label, toast_overlay: adw::ToastOverlay, } fn populate_update_list(state: &Rc) { // Clear existing rows while let Some(row) = state.list_box.row_at_index(0) { state.list_box.remove(&row); } // Re-read from database to pick up changes from background threads let db_fresh = Database::open().ok(); let updatable = db_fresh .as_ref() .and_then(|db| db.get_appimages_with_updates().ok()) .unwrap_or_default(); // Update last checked label let settings = gio::Settings::new(APP_ID); let last_check = settings.string("last-update-check"); if last_check.is_empty() || last_check.as_str() == "never" { state.last_checked_label.set_label(&i18n("Never checked")); } else { state.last_checked_label.set_label( &format!("Last checked: {}", widgets::relative_time(&last_check)), ); } if updatable.is_empty() { state.stack.set_visible_child_name("empty"); state.update_all_btn.set_visible(false); state.title.set_subtitle(&i18n("All up to date")); return; } state.stack.set_visible_child_name("updates"); state.update_all_btn.set_visible(true); let count = updatable.len(); state.title.set_subtitle(&format!("{} updates available", count)); widgets::announce(state.list_box.upcast_ref::(), &format!("{} updates available", count)); for record in &updatable { let name = record.app_name.as_deref().unwrap_or(&record.filename); // Show version info: current -> latest (with size if available) let current = record.app_version.as_deref().unwrap_or("unknown"); let latest = record.latest_version.as_deref().unwrap_or("unknown"); let subtitle = if record.size_bytes > 0 { format!("{} -> {} ({})", current, latest, widgets::format_size(record.size_bytes)) } else { format!("{} -> {}", current, latest) }; // Try to find changelog from the app's own release_history or from catalog let changelog = find_changelog_for_version( &state.db, record, latest, ); // Use ExpanderRow if we have changelog, otherwise plain ActionRow let row: adw::ExpanderRow = adw::ExpanderRow::builder() .title(name) .subtitle(&subtitle) .show_enable_switch(false) .expanded(false) .build(); // App icon (decorative - row title already names the app) let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); icon.set_accessible_role(gtk::AccessibleRole::Presentation); row.add_prefix(&icon); // "What's new" content inside the expander let changelog_text = match &changelog { Some(text) => text.clone(), None => i18n("Release notes not available"), }; let changelog_label = gtk::Label::builder() .label(&changelog_text) .wrap(true) .xalign(0.0) .css_classes(if changelog.is_some() { vec!["body"] } else { vec!["dim-label", "caption"] }) .margin_start(12) .margin_end(12) .margin_top(8) .margin_bottom(8) .selectable(true) .build(); let changelog_row = adw::ActionRow::builder() .activatable(false) .child(&changelog_label) .build(); row.add_row(&changelog_row); // Individual update button let update_btn = gtk::Button::builder() .icon_name("software-update-available-symbolic") .valign(gtk::Align::Center) .tooltip_text(&i18n("Update this app")) .css_classes(["flat"]) .build(); update_btn.update_property(&[gtk::accessible::Property::Label(&format!("Update {}", name))]); let app_id = record.id; let update_url = record.update_url.clone(); let app_path = record.path.clone(); let state_ref = state.clone(); update_btn.connect_clicked(move |btn| { btn.set_sensitive(false); let state_c = state_ref.clone(); let path = app_path.clone(); let url = update_url.clone(); let btn_c = btn.clone(); let aid = app_id; glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let result = gio::spawn_blocking(move || { let p = Path::new(&path); if p.exists() { let ok = updater::perform_update( p, url.as_deref(), true, None, ).is_ok(); if ok { if let Some(ref db) = db_bg { db.clear_update_available(aid).ok(); } } ok } else { false } }).await; btn_c.set_sensitive(true); if result.unwrap_or(false) { state_c.toast_overlay.add_toast( widgets::info_toast(&i18n("Update complete")), ); } else { state_c.toast_overlay.add_toast( widgets::error_toast(&i18n("Update failed")), ); } populate_update_list(&state_c); }); }); row.add_suffix(&update_btn); state.list_box.append(&row); } } /// Try to find changelog/release notes for a specific version. /// Checks the installed app's release_history first, then falls back /// to the catalog app's release_history (populated by GitHub enrichment). fn find_changelog_for_version( db: &Database, record: &crate::core::database::AppImageRecord, target_version: &str, ) -> Option { // First check the installed app's own release_history if let Some(ref history_json) = record.release_history { if let Some(text) = extract_version_notes(history_json, target_version) { return Some(text); } } // Fall back to catalog app's release_history let app_name = record.app_name.as_deref().unwrap_or(&record.filename); if let Ok(Some(ref history_json)) = db.get_catalog_release_history_by_name(app_name) { if let Some(text) = extract_version_notes(history_json, target_version) { return Some(text); } } None } /// Parse release_history JSON and extract the description for a given version. /// Format: [{"version": "1.0.0", "date": "2026-01-01", "description": "..."}] fn extract_version_notes(history_json: &str, target_version: &str) -> Option { let releases: Vec = serde_json::from_str(history_json).ok()?; // Try exact match first for release in &releases { if let Some(ver) = release.get("version").and_then(|v| v.as_str()) { if ver == target_version { return release.get("description") .and_then(|d| d.as_str()) .map(|s| truncate_changelog(s)); } } } // If no exact match, try matching without "v" prefix on both sides let clean_target = target_version.strip_prefix('v').unwrap_or(target_version); for release in &releases { if let Some(ver) = release.get("version").and_then(|v| v.as_str()) { let clean_ver = ver.strip_prefix('v').unwrap_or(ver); if clean_ver == clean_target { return release.get("description") .and_then(|d| d.as_str()) .map(|s| truncate_changelog(s)); } } } // No matching version found - show the latest entry's notes as a fallback // (the first entry is typically the newest release) if let Some(first) = releases.first() { if let Some(desc) = first.get("description").and_then(|d| d.as_str()) { let ver = first.get("version").and_then(|v| v.as_str()).unwrap_or("?"); return Some(format!("(v{}) {}", ver, truncate_changelog(desc))); } } None } /// Truncate long changelog text to keep the UI compact. fn truncate_changelog(text: &str) -> String { let max_len = 500; let trimmed = text.trim(); if trimmed.len() <= max_len { trimmed.to_string() } else { format!("{}...", &trimmed[..max_len]) } }