- Bring all UI widgets to WCAG 2.2 AAA conformance across all views - Add accessible labels, roles, descriptions, and announcements - Bump focus outlines to 3px, target sizes to 44px AAA minimum - Fix announce()/announce_result() to walk widget tree via parent() - Add AT-SPI accessibility audit script (tools/a11y-audit.py) that checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8, 2.4.9, 2.4.10, 2.1.3 with JSON report output for CI - Clean up project structure, archive old plan documents
457 lines
16 KiB
Rust
457 lines
16 KiB
Rust
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<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 = 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<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);
|
|
let count = updatable.len();
|
|
state.title.set_subtitle(&format!("{} updates available", count));
|
|
widgets::announce(state.list_box.upcast_ref::<gtk::Widget>(), &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<String> {
|
|
// 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<String> {
|
|
let releases: Vec<serde_json::Value> = 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])
|
|
}
|
|
}
|