Add dedicated Updates view with check-now and update-all
This commit is contained in:
@@ -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
325
src/ui/updates_view.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user