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
This commit is contained in:
lashman
2026-03-01 12:44:21 +02:00
parent abb69dc753
commit 7e55d5796f
23 changed files with 2758 additions and 472 deletions

View File

@@ -1,12 +1,15 @@
use adw::prelude::*;
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::io::Read as _;
use std::rc::Rc;
use gtk::gio;
use crate::core::backup;
use crate::core::catalog;
use crate::core::database::{AppImageRecord, Database};
use crate::i18n::{i18n, i18n_f};
use crate::core::footprint;
use crate::core::fuse::{self, FuseStatus};
use crate::core::integrator;
@@ -35,7 +38,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Build tab pages (2-tab layout: About + Details)
let about_page = build_overview_tab(record, db);
view_stack.add_titled(&about_page, Some("about"), "About");
view_stack.add_titled(&about_page, Some("about"), &i18n("About"));
view_stack.page(&about_page).set_icon_name(Some("info-symbolic"));
// Details tab combines System + Security + Storage
@@ -50,7 +53,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
details_page.append(&security_content);
details_page.append(&storage_content);
view_stack.add_titled(&details_page, Some("details"), "Details");
view_stack.add_titled(&details_page, Some("details"), &i18n("Details"));
view_stack.page(&details_page).set_icon_name(Some("applications-system-symbolic"));
// Restore last-used tab from GSettings (map old tab names to new ones)
@@ -514,6 +517,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
spinner.update_property(&[gtk::accessible::Property::Label("Loading screenshot")]);
overlay.add_overlay(&spinner);
// Don't let overlays steal focus (prevents scroll jump on dialog close)
@@ -533,6 +537,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
&window,
&textures_click,
idx,
None,
None,
);
}
}
@@ -573,6 +579,36 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
});
}
// Keyboard activation: Enter/Space opens lightbox from the carousel
carousel.set_focusable(true);
carousel.update_property(&[gtk::accessible::Property::Label("App screenshots")]);
let textures_key = textures.clone();
let carousel_key = carousel.clone();
let key_ctrl = gtk::EventControllerKey::new();
key_ctrl.connect_key_pressed(move |_ctrl, key, _code, _mods| {
if matches!(key, gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space) {
let idx = carousel_key.position().round() as usize;
let textures_ref = textures_key.borrow();
if textures_ref.iter().any(|t| t.is_some()) {
if let Some(root) = gtk::prelude::WidgetExt::root(&carousel_key) {
if let Ok(window) = root.downcast::<gtk::Window>() {
show_screenshot_lightbox(
&window,
&textures_key,
idx,
None,
None,
);
}
}
}
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
carousel.add_controller(key_ctrl);
let carousel_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
@@ -624,12 +660,14 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let icon = gtk::Image::from_icon_name("external-link-symbolic");
icon.set_valign(gtk::Align::Center);
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_suffix(&icon);
// Start with the fallback icon, then try to load favicon
let prefix_icon = gtk::Image::from_icon_name(*icon_name);
prefix_icon.set_valign(gtk::Align::Center);
prefix_icon.set_pixel_size(16);
prefix_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_prefix(&prefix_icon);
fetch_favicon_async(url, &prefix_icon);
@@ -785,7 +823,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if let Some(desc_text) = desc {
let row = adw::ExpanderRow::builder()
.title(&title)
.subtitle("Click to see changes")
.subtitle("Expand to see changes")
.build();
let label = gtk::Label::builder()
@@ -1032,13 +1070,12 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
label.add_css_class("caption");
chip.append(&label);
let remove_btn = gtk::Button::builder()
.icon_name("window-close-symbolic")
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.build();
remove_btn.set_width_request(20);
remove_btn.set_height_request(20);
let remove_btn = widgets::accessible_icon_button(
"window-close-symbolic",
&format!("Remove tag {}", tag_text),
&format!("Remove tag {}", tag_text),
);
remove_btn.add_css_class("circular");
let tag_to_remove = tag_text.clone();
let state_r = state.clone();
@@ -1077,12 +1114,12 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
}
// "+" add button
let add_btn = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.tooltip_text("Add tag")
.build();
let add_btn = widgets::accessible_icon_button(
"list-add-symbolic",
"Add tag",
"Add a new tag to this app",
);
add_btn.add_css_class("circular");
let state_a = state.clone();
let db_a = db_ref.clone();
@@ -1093,6 +1130,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.placeholder_text("New tag")
.width_chars(12)
.build();
entry.update_property(&[gtk::accessible::Property::Label("New tag name")]);
let parent = tag_box_a.clone();
parent.remove(btn);
parent.append(&entry);
@@ -1122,13 +1160,13 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let badge = widgets::status_badge(t, "info");
parent_e.append(&badge);
}
// We lose the add button here but it refreshes on detail reopen
let new_add = gtk::Button::builder()
.icon_name("list-add-symbolic")
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.tooltip_text("Add tag")
.build();
// Re-add the "+" button (placeholder; fully functional on detail reopen)
let new_add = widgets::accessible_icon_button(
"list-add-symbolic",
"Add tag",
"Add a new tag to this app",
);
new_add.add_css_class("circular");
parent_e.append(&new_add);
});
});
@@ -1290,16 +1328,16 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
match integrator::enable_autostart(&db_autostart, &record_autostart) {
Ok(path) => {
log::info!("Autostart enabled: {}", path.display());
toast_autostart.add_toast(adw::Toast::new("Will start at login"));
toast_autostart.add_toast(widgets::info_toast(&i18n("Will start at login")));
}
Err(e) => {
log::error!("Failed to enable autostart: {}", e);
toast_autostart.add_toast(adw::Toast::new("Failed to enable autostart"));
toast_autostart.add_toast(widgets::error_toast(&i18n("Failed to enable autostart")));
}
}
} else {
integrator::disable_autostart(&db_autostart, record_id_as).ok();
toast_autostart.add_toast(adw::Toast::new("Autostart disabled"));
toast_autostart.add_toast(widgets::info_toast(&i18n("Autostart disabled")));
}
});
integration_group.add(&autostart_row);
@@ -1317,10 +1355,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let text = row.text().to_string();
let value = if text.is_empty() { None } else { Some(text.as_str()) };
match db_wm.set_startup_wm_class(record_id_wm, value) {
Ok(()) => toast_wm.add_toast(adw::Toast::new("WM class updated")),
Ok(()) => toast_wm.add_toast(widgets::info_toast(&i18n("WM class updated"))),
Err(e) => {
log::error!("Failed to set WM class: {}", e);
toast_wm.add_toast(adw::Toast::new("Failed to update WM class"));
toast_wm.add_toast(widgets::error_toast(&i18n("Failed to update WM class")));
}
}
});
@@ -1356,22 +1394,22 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if row.is_active() {
match integrator::install_system_wide(&record_sw, &db_sw) {
Ok(()) => {
toast_sw.add_toast(adw::Toast::new("Installed system-wide"));
toast_sw.add_toast(widgets::info_toast(&i18n("Installed system-wide")));
}
Err(e) => {
log::error!("System-wide install failed: {}", e);
toast_sw.add_toast(adw::Toast::new("System-wide install failed"));
toast_sw.add_toast(widgets::error_toast(&i18n("System-wide install failed")));
row.set_active(false);
}
}
} else {
match integrator::remove_system_wide(&db_sw, record_id_sw) {
Ok(()) => {
toast_sw.add_toast(adw::Toast::new("System-wide install removed"));
toast_sw.add_toast(widgets::info_toast(&i18n("System-wide install removed")));
}
Err(e) => {
log::error!("Failed to remove system-wide install: {}", e);
toast_sw.add_toast(adw::Toast::new("Failed to remove system-wide install"));
toast_sw.add_toast(widgets::error_toast(&i18n("Failed to remove system-wide install")));
row.set_active(true);
}
}
@@ -1398,7 +1436,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.label("Rollback")
.valign(gtk::Align::Center)
.css_classes(["destructive-action"])
.tooltip_text("Roll back to the previous version")
.build();
rollback_btn.update_property(&[gtk::accessible::Property::Description(
"Roll back to the previous version",
)]);
rollback_row.add_suffix(&rollback_btn);
let current_path = record.path.clone();
@@ -1417,12 +1459,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
match result {
Ok(()) => {
db_rb.set_previous_version(record_id_rb, Some(&prev_path_owned)).ok();
toast_rb.add_toast(adw::Toast::new("Rolled back to previous version"));
toast_rb.add_toast(widgets::info_toast(&i18n("Rolled back to previous version")));
btn.set_sensitive(false);
}
Err(e) => {
log::error!("Rollback failed: {}", e);
toast_rb.add_toast(adw::Toast::new("Rollback failed"));
toast_rb.add_toast(widgets::error_toast(&i18n("Rollback failed")));
}
}
});
@@ -1432,10 +1474,36 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
}
}
// File type associations group
// File type associations group (grouped by category)
if let Some(ref mime_str) = record.mime_types {
let types: Vec<&str> = mime_str.split(';').filter(|s| !s.is_empty()).collect();
if !types.is_empty() {
// Group MIME types by category prefix (audio/, video/, etc.)
let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
for mime_type in &types {
let category = mime_type
.split('/')
.next()
.unwrap_or("other")
.to_string();
groups
.entry(category)
.or_default()
.push(mime_type.to_string());
}
// Sort MIME types within each group
for mimes in groups.values_mut() {
mimes.sort();
}
// Check which MIME types are already set as default by this app
let existing_mods = db.get_modifications(record.id).unwrap_or_default();
let already_set: std::collections::HashSet<String> = existing_mods
.iter()
.filter(|m| m.mod_type == "mime_default")
.map(|m| m.file_path.clone())
.collect();
let mime_group = adw::PreferencesGroup::builder()
.title("Opens these file types")
.description("File types this app can handle. Set as default to always open them with this app.")
@@ -1445,43 +1513,225 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
record.app_name.as_deref().unwrap_or(&record.filename),
);
for mime_type in &types {
let row = adw::ActionRow::builder()
.title(*mime_type)
// Shared list of all buttons so "Set/Unset All" can toggle everything
let all_buttons: Rc<RefCell<Vec<gtk::Button>>> = Rc::new(RefCell::new(Vec::new()));
// "Set Default for All" / "Unset Default for All" header button
let all_already_set = types.iter().all(|m| already_set.contains(*m));
let all_btn = gtk::Button::builder()
.label(if all_already_set { "Unset Default for All" } else { "Set Default for All" })
.valign(gtk::Align::Center)
.build();
all_btn.add_css_class("flat");
all_btn.update_property(&[gtk::accessible::Property::Description("Set or unset default handler for all file types")]);
all_buttons.borrow_mut().push(all_btn.clone());
let all_mimes: Vec<String> = types.iter().map(|s| s.to_string()).collect();
let db_all = db.clone();
let record_id_all = record.id;
let app_id_all = app_id.clone();
let toast_all = toast_overlay.clone();
let all_buttons_ref = all_buttons.clone();
all_btn.connect_clicked(move |btn| {
let setting = btn.label().map_or(true, |l| l.as_str().starts_with("Set"));
let mut success_count: usize = 0;
if setting {
for mime in &all_mimes {
if integrator::set_mime_default(
&db_all, record_id_all, &app_id_all, mime,
).is_ok() {
success_count += 1;
}
}
toast_all.add_toast(widgets::info_toast(
&i18n_f(
"Set as default for {count} file types",
&[("{count}", &success_count.to_string())],
),
));
for b in all_buttons_ref.borrow().iter() {
if b.label().map_or(false, |l| l.as_str() == "Set Default for All") {
b.set_label("Unset Default for All");
} else {
b.set_label("Unset Default");
}
}
} else {
for mime in &all_mimes {
if integrator::unset_mime_default(
&db_all, record_id_all, mime,
).is_ok() {
success_count += 1;
}
}
toast_all.add_toast(widgets::info_toast(
&i18n_f(
"Unset default for {count} file types",
&[("{count}", &success_count.to_string())],
),
));
for b in all_buttons_ref.borrow().iter() {
if b.label().map_or(false, |l| l.as_str() == "Unset Default for All") {
b.set_label("Set Default for All");
} else {
b.set_label("Set Default");
}
}
}
});
mime_group.set_header_suffix(Some(&all_btn));
// Build per-category expander rows
for (category, mimes) in &groups {
let cat_label = {
let mut chars = category.chars();
match chars.next() {
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
None => category.clone(),
}
};
let expander = adw::ExpanderRow::builder()
.title(&cat_label)
.subtitle(&i18n_f("{count} file types", &[("{count}", &mimes.len().to_string())]))
.build();
let set_btn = gtk::Button::builder()
.label("Set Default")
// Category button - check if all types in this category are already set
let cat_already_set = mimes.iter().all(|m| already_set.contains(m));
let cat_btn = gtk::Button::builder()
.label(if cat_already_set { "Unset Default" } else { "Set Default" })
.valign(gtk::Align::Center)
.build();
set_btn.add_css_class("flat");
cat_btn.add_css_class("flat");
cat_btn.update_property(&[gtk::accessible::Property::Description(&format!("Set or unset default for {} file types", cat_label))]);
all_buttons.borrow_mut().push(cat_btn.clone());
let db_mime = db.clone();
let record_id = record.id;
let app_id_clone = app_id.clone();
let mime = mime_type.to_string();
let toast_mime = toast_overlay.clone();
set_btn.connect_clicked(move |btn| {
match integrator::set_mime_default(
&db_mime, record_id, &app_id_clone, &mime,
) {
Ok(()) => {
toast_mime.add_toast(adw::Toast::new(
&format!("Set as default for {}", mime),
));
btn.set_sensitive(false);
btn.set_label("Default");
// Collect buttons for this category so the category handler can toggle them
let cat_buttons: Rc<RefCell<Vec<gtk::Button>>> = Rc::new(RefCell::new(Vec::new()));
cat_buttons.borrow_mut().push(cat_btn.clone());
// Individual MIME type rows
for mime in mimes {
let row = adw::ActionRow::builder()
.title(mime)
.build();
let ind_already_set = already_set.contains(mime);
let set_btn = gtk::Button::builder()
.label(if ind_already_set { "Unset Default" } else { "Set Default" })
.valign(gtk::Align::Center)
.build();
set_btn.add_css_class("flat");
set_btn.update_property(&[gtk::accessible::Property::Description(&format!("Set or unset default for {}", mime))]);
all_buttons.borrow_mut().push(set_btn.clone());
cat_buttons.borrow_mut().push(set_btn.clone());
let db_ind = db.clone();
let record_id_ind = record.id;
let app_id_ind = app_id.clone();
let mime_ind = mime.clone();
let toast_ind = toast_overlay.clone();
set_btn.connect_clicked(move |btn| {
let setting = btn.label().map_or(true, |l| l.as_str().starts_with("Set"));
if setting {
match integrator::set_mime_default(
&db_ind, record_id_ind, &app_id_ind, &mime_ind,
) {
Ok(()) => {
toast_ind.add_toast(widgets::info_toast(
&i18n_f("Set as default for {type}", &[("{type}", &mime_ind)]),
));
btn.set_label("Unset Default");
}
Err(e) => {
log::error!("Failed to set MIME default: {}", e);
toast_ind.add_toast(widgets::error_toast(
&i18n("Failed to set default"),
));
}
}
} else {
match integrator::unset_mime_default(
&db_ind, record_id_ind, &mime_ind,
) {
Ok(()) => {
toast_ind.add_toast(widgets::info_toast(
&i18n_f("Unset default for {type}", &[("{type}", &mime_ind)]),
));
btn.set_label("Set Default");
}
Err(e) => {
log::error!("Failed to unset MIME default: {}", e);
toast_ind.add_toast(widgets::error_toast(
&i18n("Failed to unset default"),
));
}
}
}
Err(e) => {
log::error!("Failed to set MIME default: {}", e);
toast_mime.add_toast(adw::Toast::new("Failed to set default"));
});
row.add_suffix(&set_btn);
expander.add_row(&row);
}
// Category button click handler
let cat_mimes: Vec<String> = mimes.clone();
let db_cat = db.clone();
let record_id_cat = record.id;
let app_id_cat = app_id.clone();
let toast_cat = toast_overlay.clone();
let cat_label_toast = cat_label.clone();
let cat_buttons_ref = cat_buttons.clone();
cat_btn.connect_clicked(move |btn| {
let setting = btn.label().map_or(true, |l| l.as_str().starts_with("Set"));
let mut success_count: usize = 0;
if setting {
for mime in &cat_mimes {
if integrator::set_mime_default(
&db_cat, record_id_cat, &app_id_cat, mime,
).is_ok() {
success_count += 1;
}
}
toast_cat.add_toast(widgets::info_toast(
&i18n_f(
"Set as default for {count} {category} file types",
&[
("{count}", &success_count.to_string()),
("{category}", &cat_label_toast),
],
),
));
for b in cat_buttons_ref.borrow().iter() {
b.set_label("Unset Default");
}
} else {
for mime in &cat_mimes {
if integrator::unset_mime_default(
&db_cat, record_id_cat, mime,
).is_ok() {
success_count += 1;
}
}
toast_cat.add_toast(widgets::info_toast(
&i18n_f(
"Unset default for {count} {category} file types",
&[
("{count}", &success_count.to_string()),
("{category}", &cat_label_toast),
],
),
));
for b in cat_buttons_ref.borrow().iter() {
b.set_label("Set Default");
}
}
});
row.add_suffix(&set_btn);
mime_group.add(&row);
expander.add_suffix(&cat_btn);
mime_group.add(&expander);
}
inner.append(&mime_group);
}
}
@@ -1511,6 +1761,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.valign(gtk::Align::Center)
.build();
set_btn.add_css_class("flat");
set_btn.update_property(&[gtk::accessible::Property::Description(
&format!("Set as default for {}", cap.label()),
)]);
let db_def = db.clone();
let record_id = record.id;
@@ -1522,15 +1775,16 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
&db_def, record_id, &app_id_clone, &cap_clone,
) {
Ok(()) => {
toast_def.add_toast(adw::Toast::new(
&format!("Set as default {}", cap_clone.label().to_lowercase()),
toast_def.add_toast(widgets::info_toast(
&i18n_f("Set as default {capability}", &[("{capability}", &cap_clone.label().to_lowercase())]),
));
btn.set_sensitive(false);
btn.set_label("Default");
btn.update_property(&[gtk::accessible::Property::Label("Default (already set)")]);
}
Err(e) => {
log::error!("Failed to set default app: {}", e);
toast_def.add_toast(adw::Toast::new("Failed to set default"));
toast_def.add_toast(widgets::error_toast(&i18n("Failed to set default")));
}
}
});
@@ -1582,9 +1836,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
your display system."
)
.build();
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
analyze_icon.set_valign(gtk::Align::Center);
analyze_row.add_suffix(&analyze_icon);
analyze_row.add_suffix(&widgets::accessible_suffix_icon("system-search-symbolic", "Analyze"));
let record_path_wayland = record.path.clone();
analyze_row.connect_activated(move |row| {
@@ -1606,13 +1858,13 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
Ok(analysis) => {
let toolkit_label = analysis.toolkit.label();
let _lib_count = analysis.libraries_found.len();
row_clone.set_subtitle(&format!(
"Built with: {}",
toolkit_label,
));
let msg = format!("Built with: {}", toolkit_label);
row_clone.set_subtitle(&msg);
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), true, &format!("Analysis complete: {}", toolkit_label));
}
Err(_) => {
row_clone.set_subtitle("Analysis failed - could not read the app's contents");
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Framework analysis failed");
}
}
});
@@ -1830,6 +2082,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.valign(gtk::Align::Center)
.css_classes(["flat"])
.build();
reset_btn.update_property(&[gtk::accessible::Property::Label("Reset sandbox profile to default")]);
let db_reset = db.clone();
let reset_name = app_name_for_sandbox.clone();
reset_btn.connect_clicked(move |_btn| {
@@ -1982,6 +2235,8 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let label = status.label();
row_ref.set_title(&format!("Verify SHA256 - {}", label));
db_ref.set_verification_status(record_id_v, status.as_str()).ok();
let success = status.as_str() == "verified";
widgets::announce_result(row_ref.upcast_ref::<gtk::Widget>(), success, &format!("SHA-256 verification: {}", label));
}
});
});
@@ -1993,7 +2248,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.subtitle("Verify GPG signature if present")
.activatable(true)
.build();
let sig_arrow = gtk::Image::from_icon_name("go-next-symbolic");
let sig_arrow = widgets::accessible_suffix_icon("go-next-symbolic", "Check signature");
sig_arrow.set_valign(gtk::Align::Center);
check_sig_row.add_suffix(&sig_arrow);
let record_path_sig = record.path.clone();
@@ -2002,6 +2257,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
check_sig_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Checking...");
row.update_state(&[gtk::accessible::State::Busy(true)]);
let path = record_path_sig.clone();
let db_ref = db_sig.clone();
let row_ref = row.clone();
@@ -2013,19 +2269,21 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.await;
if let Ok(status) = result {
row_ref.set_subtitle(&status.label());
let label = status.label();
row_ref.set_subtitle(&label);
db_ref.set_verification_status(record_id_sig, status.as_str()).ok();
let result_badge = widgets::status_badge(
match status.badge_class() {
"success" => "Verified",
"error" => "Failed",
_ => "Unknown",
},
status.badge_class(),
);
let badge_text = match status.badge_class() {
"success" => "Verified",
"error" => "Failed",
_ => "Unknown",
};
let result_badge = widgets::status_badge(badge_text, status.badge_class());
result_badge.set_valign(gtk::Align::Center);
row_ref.add_suffix(&result_badge);
let success = status.badge_class() == "success";
widgets::announce_result(row_ref.upcast_ref::<gtk::Widget>(), success, &format!("Signature check: {}", badge_text));
}
row_ref.update_state(&[gtk::accessible::State::Busy(false)]);
row_ref.set_sensitive(true);
});
});
@@ -2103,9 +2361,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
of known security issues to see if any are outdated or vulnerable."
)
.build();
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
scan_icon.set_valign(gtk::Align::Center);
scan_row.add_suffix(&scan_icon);
scan_row.add_suffix(&widgets::accessible_suffix_icon("security-medium-symbolic", "Scan"));
let record_id = record.id;
let record_path = record.path.clone();
@@ -2130,16 +2386,19 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let total = scan_result.total_cves();
if total == 0 {
row_clone.set_subtitle("No vulnerabilities found - looking good!");
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), true, "Security scan complete: no vulnerabilities found");
} else {
row_clone.set_subtitle(&format!(
"Found {} known issue{}. Check for app updates.",
total,
if total == 1 { "" } else { "s" },
));
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, &format!("Security scan complete: {} issues found", total));
}
}
Err(_) => {
row_clone.set_subtitle("Check failed - could not read the app's contents");
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Security scan failed");
}
}
});
@@ -2284,15 +2543,14 @@ fn build_storage_tab(
.subtitle("Search for files this app has saved")
.activatable(true)
.build();
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
discover_icon.set_valign(gtk::Align::Center);
discover_row.add_suffix(&discover_icon);
discover_row.add_suffix(&widgets::accessible_suffix_icon("folder-saved-search-symbolic", "Search"));
let record_clone = record.clone();
let record_id = record.id;
discover_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Searching...");
row.update_state(&[gtk::accessible::State::Busy(true)]);
let row_clone = row.clone();
let rec = record_clone.clone();
glib::spawn_future_local(async move {
@@ -2304,6 +2562,7 @@ fn build_storage_tab(
.await;
row_clone.set_sensitive(true);
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
match result {
Ok(fp) => {
let count = fp.paths.len();
@@ -2336,6 +2595,7 @@ fn build_storage_tab(
.build();
let icon = gtk::Image::from_icon_name(dp.path_type.icon_name());
icon.set_pixel_size(16);
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_prefix(&icon);
let conf_badge = widgets::status_badge(
dp.confidence.as_str(),
@@ -2351,12 +2611,11 @@ fn build_storage_tab(
row.add_suffix(&size_label);
// Open folder button
let open_btn = gtk::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open in file manager")
.valign(gtk::Align::Center)
.build();
open_btn.add_css_class("flat");
let open_btn = widgets::accessible_icon_button(
"folder-open-symbolic",
"Open in file manager",
"Open in file manager",
);
let path_str = dp.path.to_string_lossy().to_string();
open_btn.connect_clicked(move |_| {
let file = gio::File::for_path(&path_str);
@@ -2493,6 +2752,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
let empty_icon = gtk::Image::from_icon_name("document-open-symbolic");
empty_icon.set_valign(gtk::Align::Center);
empty_icon.add_css_class("dim-label");
empty_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
empty_row.add_prefix(&empty_icon);
group.add(&empty_row);
} else {
@@ -2521,6 +2781,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
.build();
let icon = gtk::Image::from_icon_name("emblem-ok-symbolic");
icon.add_css_class("success");
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let label = gtk::Label::new(Some("Exists"));
label.add_css_class("caption");
label.add_css_class("success");
@@ -2535,6 +2796,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
.build();
let icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
icon.add_css_class("warning");
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let label = gtk::Label::new(Some("Missing"));
label.add_css_class("caption");
label.add_css_class("warning");
@@ -2553,6 +2815,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
.build();
let restore_icon = gtk::Image::from_icon_name("edit-undo-symbolic");
restore_icon.set_valign(gtk::Align::Center);
restore_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
restore_row.add_prefix(&restore_icon);
restore_row.update_property(&[
gtk::accessible::Property::Label("Restore this backup"),
@@ -2592,7 +2855,8 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
if res.paths_restored == 1 { "" } else { "s" },
skip_note,
);
toast.add_toast(adw::Toast::new(&toast_msg));
toast.add_toast(widgets::info_toast(&toast_msg));
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), true, &toast_msg);
log::info!(
"Backup restored: app={}, paths_restored={}, paths_skipped={}",
res.manifest.app_name, res.paths_restored, res.paths_skipped,
@@ -2600,7 +2864,8 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
}
_ => {
row_clone.set_subtitle("Restore failed");
toast.add_toast(adw::Toast::new("Failed to restore backup"));
toast.add_toast(widgets::error_toast(&i18n("Failed to restore backup")));
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Restore failed");
}
}
});
@@ -2616,6 +2881,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
.build();
let delete_icon = gtk::Image::from_icon_name("edit-delete-symbolic");
delete_icon.set_valign(gtk::Align::Center);
delete_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
delete_row.add_prefix(&delete_icon);
delete_row.update_property(&[
gtk::accessible::Property::Label("Delete this backup"),
@@ -2642,12 +2908,13 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
match result {
Ok(Ok(())) => {
group_del.remove(&expander_del);
toast.add_toast(adw::Toast::new("Backup deleted"));
toast.add_toast(widgets::info_toast(&i18n("Backup deleted")));
}
_ => {
row_clone.set_sensitive(true);
row_clone.set_subtitle("Delete failed");
toast.add_toast(adw::Toast::new("Failed to delete backup"));
toast.add_toast(widgets::error_toast(&i18n("Failed to delete backup")));
widgets::announce_result(row_clone.upcast_ref::<gtk::Widget>(), false, "Delete failed");
}
}
});
@@ -2667,6 +2934,7 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
.build();
let create_icon = gtk::Image::from_icon_name("list-add-symbolic");
create_icon.set_valign(gtk::Align::Center);
create_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
create_row.add_prefix(&create_icon);
create_row.update_property(&[
gtk::accessible::Property::Label("Create a new backup"),
@@ -2692,15 +2960,15 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
.and_then(|n| n.to_str())
.unwrap_or("backup");
row_clone.set_subtitle(&format!("Created {}", filename));
toast.add_toast(adw::Toast::new("Backup created"));
toast.add_toast(widgets::info_toast(&i18n("Backup created")));
}
Ok(Err(backup::BackupError::NoPaths)) => {
row_clone.set_subtitle("Try discovering app data first");
toast.add_toast(adw::Toast::new("No data paths found to back up"));
toast.add_toast(widgets::error_toast(&i18n("No data paths found to back up")));
}
_ => {
row_clone.set_subtitle("Backup failed");
toast.add_toast(adw::Toast::new("Failed to create backup"));
toast.add_toast(widgets::error_toast(&i18n("Failed to create backup")));
}
}
});
@@ -2762,10 +3030,14 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
/// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
/// Uses a separate gtk::Window to avoid parent scroll position interference.
/// Pass `screenshot_paths` and `app_name` for catalog detail views to preload
/// screenshots that haven't been loaded yet (paged carousel lazy-loading).
pub fn show_screenshot_lightbox(
parent: &gtk::Window,
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
initial_index: usize,
screenshot_paths: Option<&Rc<Vec<String>>>,
app_name: Option<&str>,
) {
let current = Rc::new(std::cell::Cell::new(initial_index));
let textures = textures.clone();
@@ -2777,6 +3049,7 @@ pub fn show_screenshot_lightbox(
.transient_for(parent)
.modal(true)
.decorated(false)
.title("Screenshot viewer")
.default_width(parent.width())
.default_height(parent.height())
.build();
@@ -2791,6 +3064,7 @@ pub fn show_screenshot_lightbox(
.margin_end(72)
.margin_top(56)
.margin_bottom(56)
.alternative_text(&format!("Screenshot {} of {}", initial_index + 1, count))
.build();
picture.set_can_shrink(true);
@@ -2802,6 +3076,43 @@ pub fn show_screenshot_lightbox(
}
}
// Preload unloaded screenshots (for paged carousel that lazy-loads)
if let (Some(paths), Some(name)) = (screenshot_paths, app_name) {
let textures_preload = textures.clone();
let picture_preload = picture.clone();
let current_preload = current.clone();
let paths = paths.clone();
let name = name.to_string();
for i in 0..count {
let is_none = textures_preload.borrow().get(i).is_some_and(|t| t.is_none());
if !is_none {
continue;
}
let tex_ref = textures_preload.clone();
let pic_ref = picture_preload.clone();
let cur_ref = current_preload.clone();
let path = paths[i].clone();
let app = name.clone();
let idx = i;
glib::spawn_future_local(async move {
let p = path.clone();
let a = app.clone();
let result = gio::spawn_blocking(move || {
catalog::cache_screenshot(&a, &p, idx)
.map_err(|e| e.to_string())
}).await;
if let Ok(Ok(local_path)) = result {
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
tex_ref.borrow_mut()[idx] = Some(texture.clone());
if cur_ref.get() == idx {
pic_ref.set_paintable(Some(&texture));
}
}
}
});
}
}
// Navigation buttons
let prev_btn = gtk::Button::builder()
.icon_name("go-previous-symbolic")
@@ -2809,7 +3120,9 @@ pub fn show_screenshot_lightbox(
.valign(gtk::Align::Center)
.halign(gtk::Align::Start)
.margin_start(16)
.tooltip_text("Previous screenshot")
.build();
prev_btn.update_property(&[gtk::accessible::Property::Label("Previous screenshot")]);
let next_btn = gtk::Button::builder()
.icon_name("go-next-symbolic")
@@ -2817,15 +3130,18 @@ pub fn show_screenshot_lightbox(
.valign(gtk::Align::Center)
.halign(gtk::Align::End)
.margin_end(16)
.tooltip_text("Next screenshot")
.build();
next_btn.update_property(&[gtk::accessible::Property::Label("Next screenshot")]);
// Counter label (e.g. "2 / 5")
// Counter label (e.g. "2 / 5") - Status role for live region announcements
let counter = gtk::Label::builder()
.label(&format!("{} / {}", initial_index + 1, count))
.css_classes(["lightbox-counter"])
.halign(gtk::Align::Center)
.valign(gtk::Align::End)
.margin_bottom(16)
.accessible_role(gtk::AccessibleRole::Status)
.build();
// Close button (top-right)
@@ -2837,6 +3153,7 @@ pub fn show_screenshot_lightbox(
.margin_top(16)
.margin_end(16)
.build();
close_btn.update_property(&[gtk::accessible::Property::Label("Close lightbox")]);
// Build overlay: picture as child, buttons + counter as overlays
let overlay = gtk::Overlay::builder()
@@ -3091,8 +3408,8 @@ pub fn show_uninstall_dialog_with_callback(
// Show undo toast - file deletion is deferred until toast dismisses
let toast = adw::Toast::builder()
.title(&format!("{} uninstalled", name))
.button_label("Undo")
.title(i18n_f("{name} uninstalled", &[("{name}", &name)]))
.button_label(i18n("Undo"))
.timeout(7)
.build();
@@ -3117,7 +3434,7 @@ pub fn show_uninstall_dialog_with_callback(
if let Some(ref cb) = on_complete_undo {
cb();
}
toast_undo.add_toast(adw::Toast::new(&format!("{} restored", name_undo)));
toast_undo.add_toast(widgets::info_toast(&i18n_f("{name} restored", &[("{name}", &name_undo)])));
});
}
@@ -3219,11 +3536,7 @@ fn do_launch(
}
Ok(launcher::LaunchResult::Failed(msg)) => {
log::error!("Failed to launch: {}", msg);
let toast = adw::Toast::builder()
.title(&format!("Could not launch: {}", msg))
.timeout(5)
.build();
toast_ref.add_toast(toast);
toast_ref.add_toast(widgets::error_toast(&i18n_f("Could not launch: {error}", &[("{error}", &msg)])));
}
Err(_) => {
log::error!("Launch task panicked");