- 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
529 lines
18 KiB
Rust
529 lines
18 KiB
Rust
use adw::prelude::*;
|
|
use gtk::gio;
|
|
use std::rc::Rc;
|
|
|
|
use crate::config::APP_ID;
|
|
use crate::core::database::Database;
|
|
use crate::core::duplicates;
|
|
use crate::core::fuse;
|
|
use crate::core::wayland;
|
|
use crate::i18n::{i18n, ni18n};
|
|
use super::widgets;
|
|
|
|
/// Build the dashboard page showing system health and statistics.
|
|
pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(24)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
// FUSE warning banner if not functional
|
|
let fuse_info = fuse::detect_system_fuse();
|
|
if !fuse_info.status.is_functional() {
|
|
let banner = adw::Banner::builder()
|
|
.title(&i18n("FUSE is not working - some AppImages may not launch"))
|
|
.button_label(&i18n("Fix Now"))
|
|
.revealed(true)
|
|
.accessible_role(gtk::AccessibleRole::Alert)
|
|
.build();
|
|
banner.set_action_name(Some("win.fix-fuse"));
|
|
content.append(&banner);
|
|
}
|
|
|
|
// Getting Started checklist (shown for new users)
|
|
let records = db.get_all_appimages().unwrap_or_default();
|
|
let total_count = records.len();
|
|
let integrated_count = records.iter().filter(|r| r.integrated).count();
|
|
if total_count < 3 {
|
|
let started_group = adw::PreferencesGroup::builder()
|
|
.title("Getting Started")
|
|
.description("New to Driftwood? Here are three steps to get you up and running.")
|
|
.build();
|
|
|
|
let scan_row = adw::ActionRow::builder()
|
|
.title("Scan your system for apps")
|
|
.subtitle("Look for AppImage files in your configured folders")
|
|
.activatable(true)
|
|
.build();
|
|
scan_row.set_action_name(Some("win.scan"));
|
|
if total_count > 0 {
|
|
let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success");
|
|
check.set_valign(gtk::Align::Center);
|
|
scan_row.add_suffix(&check);
|
|
}
|
|
started_group.add(&scan_row);
|
|
|
|
let catalog_row = adw::ActionRow::builder()
|
|
.title("Browse the app catalog")
|
|
.subtitle("Discover and install apps from the AppImage ecosystem")
|
|
.activatable(true)
|
|
.build();
|
|
catalog_row.set_action_name(Some("win.catalog"));
|
|
catalog_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Open catalog")));
|
|
started_group.add(&catalog_row);
|
|
|
|
let menu_row = adw::ActionRow::builder()
|
|
.title("Add an app to your launcher")
|
|
.subtitle("Make an app findable in your application menu")
|
|
.build();
|
|
if integrated_count > 0 {
|
|
let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success");
|
|
check.set_valign(gtk::Align::Center);
|
|
menu_row.add_suffix(&check);
|
|
}
|
|
started_group.add(&menu_row);
|
|
|
|
content.append(&started_group);
|
|
}
|
|
|
|
// Section 1: System Status
|
|
content.append(&build_system_status_group());
|
|
|
|
// Section 2: Library Statistics
|
|
content.append(&build_library_stats_group(db));
|
|
|
|
// Section 3: Updates Summary (actionable)
|
|
content.append(&build_updates_summary_group(db));
|
|
|
|
// Section 4: Duplicates Summary (actionable)
|
|
content.append(&build_duplicates_summary_group(db));
|
|
|
|
// Section 5: Disk Usage (actionable)
|
|
content.append(&build_disk_usage_group(db));
|
|
|
|
// Section 6: Activity
|
|
content.append(&build_activity_group(db));
|
|
|
|
// Section 7: Quick Actions
|
|
content.append(&build_quick_actions_group());
|
|
|
|
clamp.set_child(Some(&content));
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.child(&clamp)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
let header = adw::HeaderBar::new();
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(&scrolled));
|
|
widgets::apply_pointer_cursors(&toolbar);
|
|
|
|
adw::NavigationPage::builder()
|
|
.title("Dashboard")
|
|
.tag("dashboard")
|
|
.child(&toolbar)
|
|
.build()
|
|
}
|
|
|
|
fn build_system_status_group() -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("System Status")
|
|
.description("Display server, FUSE, and compatibility information")
|
|
.build();
|
|
|
|
// Session type
|
|
let session = wayland::detect_session_type();
|
|
let session_row = adw::ActionRow::builder()
|
|
.title("Display server")
|
|
.subtitle(session.label())
|
|
.tooltip_text("How your system draws windows on screen")
|
|
.build();
|
|
let session_badge = widgets::status_badge(
|
|
session.label(),
|
|
match session {
|
|
wayland::SessionType::Wayland => "success",
|
|
wayland::SessionType::X11 => "warning",
|
|
wayland::SessionType::Unknown => "neutral",
|
|
},
|
|
);
|
|
session_badge.set_valign(gtk::Align::Center);
|
|
session_row.add_suffix(&session_badge);
|
|
group.add(&session_row);
|
|
|
|
// Desktop environment
|
|
let de = wayland::detect_desktop_environment();
|
|
let de_row = adw::ActionRow::builder()
|
|
.title("Desktop environment")
|
|
.subtitle(&de)
|
|
.tooltip_text("Your desktop interface")
|
|
.build();
|
|
group.add(&de_row);
|
|
|
|
// FUSE status
|
|
let fuse_info = fuse::detect_system_fuse();
|
|
let fuse_row = adw::ActionRow::builder()
|
|
.title("App compatibility")
|
|
.subtitle(&fuse_description(&fuse_info))
|
|
.tooltip_text("Most AppImages need a system component called FUSE to run. This shows whether it is set up correctly.")
|
|
.build();
|
|
let fuse_badge = widgets::status_badge(
|
|
fuse_info.status.label(),
|
|
fuse_info.status.badge_class(),
|
|
);
|
|
fuse_badge.set_valign(gtk::Align::Center);
|
|
fuse_row.add_suffix(&fuse_badge);
|
|
group.add(&fuse_row);
|
|
|
|
// Install hint if FUSE not functional
|
|
if let Some(ref hint) = fuse_info.install_hint {
|
|
let hint_row = adw::ActionRow::builder()
|
|
.title("Fix app compatibility")
|
|
.subtitle(hint)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
hint_row.add_css_class("monospace");
|
|
group.add(&hint_row);
|
|
}
|
|
|
|
// XWayland
|
|
let has_xwayland = wayland::has_xwayland();
|
|
let xwayland_row = adw::ActionRow::builder()
|
|
.title("XWayland")
|
|
.subtitle(if has_xwayland { "Running" } else { "Not detected" })
|
|
.tooltip_text("Compatibility layer that lets older apps run on modern displays")
|
|
.build();
|
|
let xwayland_badge = widgets::status_badge(
|
|
if has_xwayland { "Available" } else { "Unavailable" },
|
|
if has_xwayland { "success" } else { "neutral" },
|
|
);
|
|
xwayland_badge.set_valign(gtk::Align::Center);
|
|
xwayland_row.add_suffix(&xwayland_badge);
|
|
group.add(&xwayland_row);
|
|
|
|
// AppImageLauncher conflict check
|
|
if let Some(version) = fuse::detect_appimagelauncher() {
|
|
let ail_row = adw::ActionRow::builder()
|
|
.title("AppImageLauncher detected")
|
|
.subtitle(&format!(
|
|
"Version {} - may conflict with some AppImage runtimes",
|
|
version
|
|
))
|
|
.build();
|
|
let ail_badge = widgets::status_badge("Conflict", "warning");
|
|
ail_badge.set_valign(gtk::Align::Center);
|
|
ail_row.add_suffix(&ail_badge);
|
|
group.add(&ail_row);
|
|
}
|
|
|
|
group
|
|
}
|
|
|
|
fn fuse_description(info: &fuse::FuseSystemInfo) -> String {
|
|
let mut parts = Vec::new();
|
|
if info.has_libfuse2 {
|
|
parts.push("libfuse2");
|
|
}
|
|
if info.has_libfuse3 {
|
|
parts.push("libfuse3");
|
|
}
|
|
if info.has_fusermount {
|
|
parts.push("fusermount");
|
|
}
|
|
if info.has_dev_fuse {
|
|
parts.push("/dev/fuse");
|
|
}
|
|
if parts.is_empty() {
|
|
"No FUSE components detected".to_string()
|
|
} else {
|
|
format!("Available: {}", parts.join(", "))
|
|
}
|
|
}
|
|
|
|
fn build_library_stats_group(db: &Rc<Database>) -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Library")
|
|
.description("Overview of your AppImage collection")
|
|
.build();
|
|
|
|
let records = db.get_all_appimages().unwrap_or_default();
|
|
|
|
let total = records.len();
|
|
let integrated = records.iter().filter(|r| r.integrated).count();
|
|
let executable = records.iter().filter(|r| r.is_executable).count();
|
|
|
|
let total_row = adw::ActionRow::builder()
|
|
.title(&ni18n("AppImage", "AppImages", total as u32))
|
|
.subtitle(&total.to_string())
|
|
.activatable(true)
|
|
.build();
|
|
total_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View library")));
|
|
total_row.set_action_name(Some("navigation.pop"));
|
|
group.add(&total_row);
|
|
|
|
let integrated_row = adw::ActionRow::builder()
|
|
.title("Integrated")
|
|
.subtitle(&format!("{} of {}", integrated, total))
|
|
.build();
|
|
group.add(&integrated_row);
|
|
|
|
let exec_row = adw::ActionRow::builder()
|
|
.title("Executable")
|
|
.subtitle(&format!("{} of {}", executable, total))
|
|
.build();
|
|
if executable < total {
|
|
let badge = widgets::status_badge(
|
|
&format!("{} not executable", total - executable),
|
|
"warning",
|
|
);
|
|
badge.set_valign(gtk::Align::Center);
|
|
exec_row.add_suffix(&badge);
|
|
}
|
|
group.add(&exec_row);
|
|
|
|
group
|
|
}
|
|
|
|
fn build_updates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Updates")
|
|
.description("AppImage update availability")
|
|
.build();
|
|
|
|
let records = db.get_all_appimages().unwrap_or_default();
|
|
|
|
let with_update_info = records
|
|
.iter()
|
|
.filter(|r| r.update_info.is_some())
|
|
.count();
|
|
let with_updates = records
|
|
.iter()
|
|
.filter(|r| {
|
|
if let (Some(ref latest), Some(ref current)) = (&r.latest_version, &r.app_version) {
|
|
crate::core::updater::version_is_newer(latest, current)
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.count();
|
|
|
|
let info_row = adw::ActionRow::builder()
|
|
.title("With update info")
|
|
.subtitle(&format!("{} of {}", with_update_info, records.len()))
|
|
.build();
|
|
group.add(&info_row);
|
|
|
|
// Actionable updates row -> triggers check-updates
|
|
let updates_row = adw::ActionRow::builder()
|
|
.title("Updates available")
|
|
.subtitle(&with_updates.to_string())
|
|
.activatable(true)
|
|
.build();
|
|
updates_row.set_action_name(Some("win.check-updates"));
|
|
if with_updates > 0 {
|
|
let badge = widgets::status_badge(&format!("{} updates", with_updates), "info");
|
|
badge.set_valign(gtk::Align::Center);
|
|
updates_row.add_suffix(&badge);
|
|
}
|
|
updates_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View updates")));
|
|
group.add(&updates_row);
|
|
|
|
if with_updates > 0 {
|
|
let update_all_row = adw::ActionRow::builder()
|
|
.title("Update All")
|
|
.subtitle(&format!("Apply {} available updates", with_updates))
|
|
.activatable(true)
|
|
.build();
|
|
update_all_row.set_action_name(Some("win.update-all"));
|
|
let update_badge = widgets::status_badge("Go", "suggested");
|
|
update_badge.set_valign(gtk::Align::Center);
|
|
update_all_row.add_suffix(&update_badge);
|
|
update_all_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Update all apps")));
|
|
group.add(&update_all_row);
|
|
}
|
|
|
|
// Last checked timestamp
|
|
let settings = gio::Settings::new(APP_ID);
|
|
let last_check = settings.string("last-update-check");
|
|
let last_label = if last_check.is_empty() {
|
|
"Never".to_string()
|
|
} else {
|
|
widgets::relative_time(&last_check)
|
|
};
|
|
let last_row = adw::ActionRow::builder()
|
|
.title("Last checked")
|
|
.subtitle(&last_label)
|
|
.build();
|
|
group.add(&last_row);
|
|
|
|
group
|
|
}
|
|
|
|
fn build_duplicates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Duplicates")
|
|
.description("Duplicate and multi-version detection")
|
|
.build();
|
|
|
|
let groups = duplicates::detect_duplicates(db);
|
|
let summary = duplicates::summarize_duplicates(&groups);
|
|
|
|
if summary.total_groups == 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title("No duplicates found")
|
|
.subtitle("All AppImages appear unique")
|
|
.build();
|
|
let badge = widgets::status_badge("Clean", "success");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
} else {
|
|
// Actionable row -> opens duplicate dialog
|
|
let groups_row = adw::ActionRow::builder()
|
|
.title("Duplicate groups")
|
|
.subtitle(&summary.total_groups.to_string())
|
|
.activatable(true)
|
|
.build();
|
|
groups_row.set_action_name(Some("win.find-duplicates"));
|
|
groups_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View duplicates")));
|
|
group.add(&groups_row);
|
|
|
|
if summary.total_potential_savings > 0 {
|
|
let savings_row = adw::ActionRow::builder()
|
|
.title("Potential savings")
|
|
.subtitle(&widgets::format_size(summary.total_potential_savings as i64))
|
|
.build();
|
|
let badge = widgets::status_badge("Reclaimable", "warning");
|
|
badge.set_valign(gtk::Align::Center);
|
|
savings_row.add_suffix(&badge);
|
|
group.add(&savings_row);
|
|
}
|
|
}
|
|
|
|
group
|
|
}
|
|
|
|
fn build_disk_usage_group(db: &Rc<Database>) -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Disk Usage")
|
|
.description("Storage used by your AppImages")
|
|
.build();
|
|
|
|
let records = db.get_all_appimages().unwrap_or_default();
|
|
let total_bytes: i64 = records.iter().map(|r| r.size_bytes).sum();
|
|
|
|
// Actionable total row -> opens cleanup wizard
|
|
let total_row = adw::ActionRow::builder()
|
|
.title("Total disk usage")
|
|
.subtitle(&widgets::format_size(total_bytes))
|
|
.activatable(true)
|
|
.build();
|
|
total_row.set_action_name(Some("win.cleanup"));
|
|
total_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Clean up disk")));
|
|
group.add(&total_row);
|
|
|
|
// Largest AppImages
|
|
let mut sorted = records.clone();
|
|
sorted.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
|
|
|
|
for record in sorted.iter().take(3) {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
let row = adw::ActionRow::builder()
|
|
.title(name)
|
|
.subtitle(&widgets::format_size(record.size_bytes))
|
|
.build();
|
|
group.add(&row);
|
|
}
|
|
|
|
group
|
|
}
|
|
|
|
fn build_activity_group(db: &Rc<Database>) -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Activity")
|
|
.description("Launch history and usage statistics")
|
|
.build();
|
|
|
|
let total_launches = db.get_total_launch_count().unwrap_or(0);
|
|
let total_row = adw::ActionRow::builder()
|
|
.title("Total launches")
|
|
.subtitle(&total_launches.to_string())
|
|
.build();
|
|
group.add(&total_row);
|
|
|
|
if let Ok(Some((name, time))) = db.get_last_launch() {
|
|
let last_row = adw::ActionRow::builder()
|
|
.title("Last launched")
|
|
.subtitle(&format!("{} - {}", name, time))
|
|
.build();
|
|
group.add(&last_row);
|
|
}
|
|
|
|
if let Ok(top) = db.get_top_launched(5) {
|
|
for (name, count) in &top {
|
|
let row = adw::ActionRow::builder()
|
|
.title(name)
|
|
.subtitle(&format!("{} launches", count))
|
|
.build();
|
|
group.add(&row);
|
|
}
|
|
}
|
|
|
|
group
|
|
}
|
|
|
|
fn build_quick_actions_group() -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Quick Actions")
|
|
.build();
|
|
|
|
let button_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(12)
|
|
.halign(gtk::Align::Center)
|
|
.margin_top(6)
|
|
.margin_bottom(6)
|
|
.build();
|
|
|
|
let scan_btn = gtk::Button::builder()
|
|
.label("Scan")
|
|
.tooltip_text("Scan for AppImages")
|
|
.build();
|
|
scan_btn.add_css_class("pill");
|
|
scan_btn.add_css_class("suggested-action");
|
|
scan_btn.set_action_name(Some("win.scan"));
|
|
scan_btn.update_property(&[
|
|
gtk::accessible::Property::Description("Scan for AppImages in configured directories"),
|
|
]);
|
|
|
|
let updates_btn = gtk::Button::builder()
|
|
.label("Check Updates")
|
|
.tooltip_text("Check all AppImages for updates")
|
|
.build();
|
|
updates_btn.add_css_class("pill");
|
|
updates_btn.set_action_name(Some("win.check-updates"));
|
|
updates_btn.update_property(&[
|
|
gtk::accessible::Property::Description("Check all AppImages for available updates"),
|
|
]);
|
|
|
|
let clean_btn = gtk::Button::builder()
|
|
.label("Clean Orphans")
|
|
.tooltip_text("Remove orphaned desktop entries")
|
|
.build();
|
|
clean_btn.add_css_class("pill");
|
|
clean_btn.set_action_name(Some("win.clean-orphans"));
|
|
clean_btn.update_property(&[
|
|
gtk::accessible::Property::Description("Remove orphaned desktop entries and icons"),
|
|
]);
|
|
|
|
button_box.append(&scan_btn);
|
|
button_box.append(&updates_btn);
|
|
button_box.append(&clean_btn);
|
|
|
|
// Use an ActionRow to contain the buttons for proper group styling
|
|
let row = adw::ActionRow::new();
|
|
row.set_child(Some(&button_box));
|
|
group.add(&row);
|
|
|
|
group
|
|
}
|