Files
driftwood/src/ui/updates_view.rs
lashman 7e55d5796f Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool
- 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
2026-03-01 12:44:21 +02:00

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])
}
}