From 3eb15af2c652b90f8059bcfd2bc0e29cd1830412 Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 28 Feb 2026 01:26:10 +0200 Subject: [PATCH] Add dedicated Updates view with check-now and update-all --- src/ui/mod.rs | 1 + src/ui/updates_view.rs | 325 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/ui/updates_view.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0a37497..642a686 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,4 +13,5 @@ pub mod permission_dialog; pub mod preferences; pub mod security_report; pub mod update_dialog; +pub mod updates_view; pub mod widgets; diff --git a/src/ui/updates_view.rs b/src/ui/updates_view.rs new file mode 100644 index 0000000..75470fb --- /dev/null +++ b/src/ui/updates_view.rs @@ -0,0 +1,325 @@ +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; +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 = gtk::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text(&i18n("Check for updates")) + .build(); + 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(); + 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); + + 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(adw::Toast::new(&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( + adw::Toast::new(&format!("{} apps updated", count)), + ); + } + populate_update_list(&state_c); + }); + }); + } + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&header); + toolbar_view.set_content(Some(&toast_overlay)); + + 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); + state.title.set_subtitle(&format!("{} updates available", updatable.len())); + + for record in &updatable { + let name = record.app_name.as_deref().unwrap_or(&record.filename); + + let row = adw::ActionRow::builder() + .title(name) + .activatable(false) + .build(); + + // Show version info: current -> latest + let current = record.app_version.as_deref().unwrap_or("unknown"); + let latest = record.latest_version.as_deref().unwrap_or("unknown"); + row.set_subtitle(&format!("{} -> {}", current, latest)); + + // App icon + let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); + row.add_prefix(&icon); + + // 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(); + + 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( + adw::Toast::new(&i18n("Update complete")), + ); + } else { + state_c.toast_overlay.add_toast( + adw::Toast::new(&i18n("Update failed")), + ); + } + populate_update_list(&state_c); + }); + }); + + row.add_suffix(&update_btn); + state.list_box.append(&row); + } +}