Add dedicated Updates view with check-now and update-all

This commit is contained in:
lashman
2026-02-28 01:26:10 +02:00
parent cf8f072820
commit 3eb15af2c6
2 changed files with 326 additions and 0 deletions

View File

@@ -13,4 +13,5 @@ pub mod permission_dialog;
pub mod preferences; pub mod preferences;
pub mod security_report; pub mod security_report;
pub mod update_dialog; pub mod update_dialog;
pub mod updates_view;
pub mod widgets; pub mod widgets;

325
src/ui/updates_view.rs Normal file
View File

@@ -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<Database>) -> 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<Database>,
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<UpdatesState>) {
// 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);
}
}