diff --git a/data/app.driftwood.Driftwood.metainfo.xml b/data/app.driftwood.Driftwood.metainfo.xml
index 3a41d34..49617b3 100644
--- a/data/app.driftwood.Driftwood.metainfo.xml
+++ b/data/app.driftwood.Driftwood.metainfo.xml
@@ -23,24 +23,105 @@
Duplicate detection and disk space analysis
Firejail sandboxing support
Orphaned configuration cleanup
+ Browse and install from the AppImageHub catalog
- app.driftwood.Driftwood.desktop
+ app.driftwood.Driftwood
- https://github.com/driftwood-app/driftwood
- https://github.com/driftwood-app/driftwood/issues
+ app.driftwood.Driftwood.desktop
Driftwood Contributors
+ https://git.lashman.live/lashman/driftwood
+ https://git.lashman.live/lashman/driftwood/issues
+ https://git.lashman.live/lashman/driftwood
+ https://ko-fi.com/lashman
+ mailto:lashman@robotbrush.com
+ https://git.lashman.live/lashman/driftwood
+
+ lashman@robotbrush.com
+
#8ff0a4
#26a269
-
+
+
+ Library grid view showing installed AppImages
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/02-library-grid.png
+
+
+ Library list view with app details
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/03-library-list.png
+
+
+ App detail view with description and compatibility badges
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/04-app-detail-about.png
+
+
+ App screenshots, links, and update information
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/05-app-detail-screenshots.png
+
+
+ Desktop integration and file type management
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/06-app-detail-integration.png
+
+
+ System dashboard with FUSE, Wayland, and library status
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/07-dashboard.png
+
+
+ Preferences with scan locations and automation settings
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/08-preferences.png
+
+
+ Drag-and-drop to add AppImage files
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/09-drag-and-drop.png
+
+
+ App catalog with featured apps and category browsing
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/10-catalog-browse.png
+
+
+ Catalog app detail with install button and statistics
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/11-catalog-app-detail.png
+
+
+ Catalog category view with sorting and filtering
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/12-catalog-category.png
+
+
+ Catalog refresh with download progress
+ https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/01-catalog-loading.png
+
+
+
+
+ System
+ PackageManager
+ GTK
+
+
+
+ AppImage
+ Application
+ Manager
+ Package
+ FUSE
+ Wayland
+ Security
+ Sandbox
+ Catalog
+ Integration
+
+
+
+ mild
+
360
@@ -51,18 +132,18 @@
pointing
-
- AppImage
- Application
- Manager
- Package
- FUSE
- Wayland
- Security
-
+
+ first-run
+
+
+
+ driftwood
+
+
+ app.driftwood.Driftwood
-
+
Initial release of Driftwood with core features:
@@ -75,6 +156,7 @@
- Firejail sandbox support
- Orphan cleanup and disk reclamation wizard
- CLI interface with scan, list, launch, and inspect commands
+ - AppImageHub catalog browsing and one-click install
diff --git a/data/icons/hicolor/scalable/apps/app.driftwood.Driftwood.svg b/data/icons/hicolor/scalable/apps/app.driftwood.Driftwood.svg
new file mode 100644
index 0000000..bf9351f
--- /dev/null
+++ b/data/icons/hicolor/scalable/apps/app.driftwood.Driftwood.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/data/icons/hicolor/symbolic/apps/app.driftwood.Driftwood-symbolic.svg b/data/icons/hicolor/symbolic/apps/app.driftwood.Driftwood-symbolic.svg
new file mode 100644
index 0000000..8c8864d
--- /dev/null
+++ b/data/icons/hicolor/symbolic/apps/app.driftwood.Driftwood-symbolic.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/data/screenshots/01-catalog-loading.png b/data/screenshots/01-catalog-loading.png
new file mode 100644
index 0000000..674a23a
Binary files /dev/null and b/data/screenshots/01-catalog-loading.png differ
diff --git a/data/screenshots/02-library-grid.png b/data/screenshots/02-library-grid.png
new file mode 100644
index 0000000..2aa61ad
Binary files /dev/null and b/data/screenshots/02-library-grid.png differ
diff --git a/data/screenshots/03-library-list.png b/data/screenshots/03-library-list.png
new file mode 100644
index 0000000..0054601
Binary files /dev/null and b/data/screenshots/03-library-list.png differ
diff --git a/data/screenshots/04-app-detail-about.png b/data/screenshots/04-app-detail-about.png
new file mode 100644
index 0000000..393eb7e
Binary files /dev/null and b/data/screenshots/04-app-detail-about.png differ
diff --git a/data/screenshots/05-app-detail-screenshots.png b/data/screenshots/05-app-detail-screenshots.png
new file mode 100644
index 0000000..9836033
Binary files /dev/null and b/data/screenshots/05-app-detail-screenshots.png differ
diff --git a/data/screenshots/06-app-detail-integration.png b/data/screenshots/06-app-detail-integration.png
new file mode 100644
index 0000000..4eb366a
Binary files /dev/null and b/data/screenshots/06-app-detail-integration.png differ
diff --git a/data/screenshots/07-dashboard.png b/data/screenshots/07-dashboard.png
new file mode 100644
index 0000000..b7b20f1
Binary files /dev/null and b/data/screenshots/07-dashboard.png differ
diff --git a/data/screenshots/08-preferences.png b/data/screenshots/08-preferences.png
new file mode 100644
index 0000000..2346096
Binary files /dev/null and b/data/screenshots/08-preferences.png differ
diff --git a/data/screenshots/09-drag-and-drop.png b/data/screenshots/09-drag-and-drop.png
new file mode 100644
index 0000000..0eccbb7
Binary files /dev/null and b/data/screenshots/09-drag-and-drop.png differ
diff --git a/data/screenshots/10-catalog-browse.png b/data/screenshots/10-catalog-browse.png
new file mode 100644
index 0000000..b3ca062
Binary files /dev/null and b/data/screenshots/10-catalog-browse.png differ
diff --git a/data/screenshots/11-catalog-app-detail.png b/data/screenshots/11-catalog-app-detail.png
new file mode 100644
index 0000000..65f87a1
Binary files /dev/null and b/data/screenshots/11-catalog-app-detail.png differ
diff --git a/data/screenshots/12-catalog-category.png b/data/screenshots/12-catalog-category.png
new file mode 100644
index 0000000..5fa32a7
Binary files /dev/null and b/data/screenshots/12-catalog-category.png differ
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
deleted file mode 100644
index ec42bb6..0000000
--- a/docs/USER-GUIDE.md
+++ /dev/null
@@ -1,246 +0,0 @@
-# Driftwood User Guide
-
-## Getting started
-
-### Installation
-
-**From source:**
-```sh
-cargo build --release
-sudo install -Dm755 target/release/driftwood /usr/local/bin/driftwood
-```
-
-**Arch Linux (AUR):**
-```sh
-yay -S driftwood
-```
-
-**Flatpak:**
-```sh
-flatpak install app.driftwood.Driftwood
-```
-
-### First launch
-
-When you first open Driftwood, you'll see an empty state with two options:
-
-- **Scan Now** - Immediately scan the default directories (`~/Applications` and
- `~/Downloads`) for AppImage files
-- **Preferences** - Configure which directories to scan and other settings
-
-Driftwood will discover all AppImage files (both Type 1 and Type 2) in your
-configured directories and add them to its library.
-
-## Library view
-
-The main screen shows all your discovered AppImages in either grid or list mode.
-Toggle between views with the button in the header bar.
-
-### Status badges
-
-Each AppImage card shows colored badges indicating:
-
-- **Wayland status** - Green (native), yellow (XWayland), red (X11 only)
-- **FUSE status** - Green (native FUSE), yellow (extract-and-run), red (cannot launch)
-- **Update available** - Blue badge when a newer version is detected
-- **Security** - Red badge if known vulnerabilities are found
-
-### Searching
-
-Use the search bar to filter AppImages by name or file path. The search is
-debounced - it waits 150ms after you stop typing before filtering.
-
-### Keyboard shortcuts
-
-- **Ctrl+Q** - Quit
-- **Ctrl+D** - Open dashboard
-- **Ctrl+U** - Check for updates
-
-## Detail view
-
-Click any AppImage card to see its full detail page. The detail view has
-these sections:
-
-### Identity
-App name, version, developer, description, and categories extracted from
-the AppImage's embedded .desktop file.
-
-### Desktop integration
-Shows whether the AppImage is integrated into your desktop menu. You can
-integrate or remove integration from here.
-
-### Runtime compatibility
-FUSE status (how the AppImage can be mounted) and Wayland compatibility
-(whether the app supports Wayland natively or needs XWayland).
-
-### Sandboxing
-Toggle Firejail sandboxing for this AppImage. When enabled, the app
-launches inside a Firejail container with `--appimage` flag. Requires
-firejail to be installed.
-
-### Updates
-Shows the update type (GitHub Releases, GitLab, zsync), current and latest
-versions, and lets you check for and apply updates.
-
-### Usage
-Launch count and last launched date.
-
-### Security
-Results of CVE scanning against bundled libraries. Shows counts by severity
-(critical, high, medium, low).
-
-### Disk footprint
-Config, data, and cache directories associated with this AppImage. Shows
-estimated size and discovery confidence.
-
-### File details
-File path, size, SHA256 hash, AppImage type, architecture, and timestamps.
-
-## Scanning
-
-### Automatic scanning
-By default, Driftwood scans on startup. Disable this in Preferences under
-Behavior > "Scan on startup".
-
-### Manual scanning
-Use the "Scan for AppImages" option in the hamburger menu or run:
-```sh
-driftwood scan
-```
-
-### Scan optimization
-On subsequent scans, Driftwood skips files that haven't changed (same size
-and modification time) and already have all analysis complete. This makes
-re-scans much faster.
-
-### Adding scan directories
-Go to Preferences > General > Scan Locations to add or remove directories.
-Subdirectories are not scanned recursively.
-
-## Desktop integration
-
-Driftwood creates standard .desktop files in `~/.local/share/applications/`
-with the prefix `driftwood-`. Icons are installed to
-`~/.local/share/icons/hicolor/`.
-
-To integrate an AppImage:
-1. Open its detail view
-2. Click "Integrate" in the Desktop Integration section
-3. Confirm in the integration dialog
-
-To remove integration:
-1. Open its detail view
-2. Click "Remove Integration"
-
-## Updates
-
-### Checking for updates
-- Single app: Open detail view and click "Check for Updates"
-- All apps: Use the hamburger menu "Check for Updates" or `driftwood check-updates`
-
-### Applying updates
-When an update is available, click "Update Now" in the update dialog. Driftwood
-downloads the new version and replaces the old file.
-
-### Old version cleanup
-After a successful update, Driftwood handles the old version based on your
-preference (Preferences > Behavior > "After updating an AppImage"):
-
-- **Ask each time** (default) - Shows a dialog asking whether to remove the backup
-- **Remove old version** - Automatically deletes the backup
-- **Keep backup** - Saves the old version with a `.old` extension
-
-## Security scanning
-
-Driftwood extracts the list of shared libraries (.so files) bundled inside each
-AppImage and queries the OSV.dev vulnerability database for known CVEs.
-
-### Running a scan
-- Single app: Open detail view and click "Run Security Scan"
-- All apps: `driftwood security`
-- Single app CLI: `driftwood security ~/path/to/app.AppImage`
-
-### Interpreting results
-Results show CVE IDs grouped by severity. Each CVE includes:
-- CVE identifier and severity level
-- CVSS score (if available)
-- Summary of the vulnerability
-- Affected library and version
-- Fixed version (if known)
-
-### Limitations
-- Not all bundled libraries can be identified
-- Version detection uses heuristics and may be inaccurate
-- Results should be treated as advisory, not definitive
-
-## Duplicate detection
-
-Driftwood detects:
-- **Same app, different versions** - Multiple version files of the same application
-- **Identical files** - Same SHA256 hash in different locations
-
-Access via the hamburger menu "Find Duplicates" or `driftwood duplicates`.
-
-## Disk cleanup
-
-The cleanup wizard (hamburger menu > "Disk Cleanup") helps reclaim space by:
-- Identifying orphaned desktop entries for deleted AppImages
-- Finding associated config/data/cache directories
-- Showing total reclaimable space
-
-## Orphaned entries
-
-When an AppImage is deleted outside of Driftwood, its .desktop file and icon
-remain. Driftwood detects these orphans and offers to clean them up.
-
-- Automatic detection on the dashboard
-- Manual cleanup via hamburger menu or `driftwood clean-orphans`
-
-## Dashboard
-
-The dashboard (Ctrl+D or hamburger menu) shows system health:
-- FUSE availability (fuse2, fuse3, or none)
-- Wayland session information
-- Total disk usage by AppImages
-- Orphaned entry count
-- Security alert summary
-
-## CLI reference
-
-```
-driftwood # Launch the GUI
-driftwood scan # Scan for AppImages
-driftwood list # List all AppImages (table format)
-driftwood list --format json # List as JSON
-driftwood inspect # Show AppImage metadata
-driftwood integrate # Create .desktop file and icon
-driftwood remove # Remove desktop integration
-driftwood launch # Launch with tracking
-driftwood launch --sandbox # Launch in Firejail
-driftwood check-updates # Check all for updates
-driftwood duplicates # Find duplicates
-driftwood security # Scan all for CVEs
-driftwood security # Scan one for CVEs
-driftwood footprint # Show disk footprint
-driftwood status # Show system status
-driftwood clean-orphans # Remove orphaned entries
-```
-
-## Preferences
-
-Access via hamburger menu > Preferences.
-
-### General
-- **Color Scheme** - Follow system, light, or dark
-- **Default View** - Grid or list
-- **Scan Locations** - Directories to scan
-
-### Behavior
-- **Scan on startup** - Auto-scan when the app opens
-- **Check for updates** - Periodically check for newer versions
-- **Auto-integrate new AppImages** - Automatically create .desktop files
-- **Confirm before delete** - Show confirmation dialogs
-- **After updating** - Old version cleanup policy (ask/always/never)
-
-### Security
-- **Auto-scan new AppImages** - Run CVE scan on newly discovered AppImages
diff --git a/meson.build b/meson.build
index 08fb420..58201cc 100644
--- a/meson.build
+++ b/meson.build
@@ -2,7 +2,7 @@ project(
'driftwood',
'rust',
version: '0.1.0',
- license: 'GPL-3.0-or-later',
+ license: 'CC0-1.0',
meson_version: '>= 0.62.0',
)
@@ -28,12 +28,25 @@ install_data(
install_dir: datadir / 'metainfo',
)
+# Install app icons
+install_data(
+ 'data' / 'icons' / 'hicolor' / 'scalable' / 'apps' / app_id + '.svg',
+ install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps',
+)
+install_data(
+ 'data' / 'icons' / 'hicolor' / 'symbolic' / 'apps' / app_id + '-symbolic.svg',
+ install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps',
+)
+
# Compile and install GSettings schema
install_data(
'data' / app_id + '.gschema.xml',
install_dir: datadir / 'glib-2.0' / 'schemas',
)
-gnome.post_install(glib_compile_schemas: true)
+gnome.post_install(
+ glib_compile_schemas: true,
+ gtk_update_icon_cache: true,
+)
# Build the Rust binary via Cargo
cargo = find_program('cargo')
diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh
index 75a38f3..f9ff998 100755
--- a/packaging/build-appimage.sh
+++ b/packaging/build-appimage.sh
@@ -29,6 +29,8 @@ mkdir -p "$APPDIR/usr/bin"
mkdir -p "$APPDIR/usr/share/applications"
mkdir -p "$APPDIR/usr/share/glib-2.0/schemas"
mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps"
+mkdir -p "$APPDIR/usr/share/icons/hicolor/symbolic/apps"
+mkdir -p "$APPDIR/usr/share/metainfo"
# Binary
cp "target/release/driftwood" "$APPDIR/usr/bin/driftwood"
@@ -40,8 +42,8 @@ cp "data/$APP_ID.desktop" "$APPDIR/usr/share/applications/$APP_ID.desktop"
cp "data/$APP_ID.gschema.xml" "$APPDIR/usr/share/glib-2.0/schemas/$APP_ID.gschema.xml"
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas/"
-# Icon - use a placeholder SVG if no real icon exists yet
-ICON_FILE="data/icons/$APP_ID.svg"
+# Icon
+ICON_FILE="data/icons/hicolor/scalable/apps/$APP_ID.svg"
if [ -f "$ICON_FILE" ]; then
cp "$ICON_FILE" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg"
else
@@ -56,6 +58,18 @@ else
SVGEOF
fi
+# Symbolic icon
+SYMBOLIC_FILE="data/icons/hicolor/symbolic/apps/$APP_ID-symbolic.svg"
+if [ -f "$SYMBOLIC_FILE" ]; then
+ cp "$SYMBOLIC_FILE" "$APPDIR/usr/share/icons/hicolor/symbolic/apps/$APP_ID-symbolic.svg"
+fi
+
+# AppStream metainfo
+METAINFO_FILE="data/$APP_ID.metainfo.xml"
+if [ -f "$METAINFO_FILE" ]; then
+ cp "$METAINFO_FILE" "$APPDIR/usr/share/metainfo/$APP_ID.metainfo.xml"
+fi
+
# Check for linuxdeploy
LINUXDEPLOY="${LINUXDEPLOY:-linuxdeploy}"
if ! command -v "$LINUXDEPLOY" &>/dev/null; then
@@ -77,20 +91,20 @@ if ! command -v "$LINUXDEPLOY" &>/dev/null; then
fi
# Check for GTK plugin
-GTK_PLUGIN="${LINUXDEPLOY_PLUGIN_GTK:-}"
-if [ -z "$GTK_PLUGIN" ]; then
- if [ -x "./linuxdeploy-plugin-gtk.sh" ]; then
- export DEPLOY_GTK_VERSION=4
- else
- echo ""
- echo "Warning: linuxdeploy-plugin-gtk not found."
- echo "GTK4 libraries will not be bundled."
- echo "The AppImage may only work on systems with GTK4 and libadwaita installed."
- echo ""
- echo "Download the plugin from:"
- echo " https://github.com/nickvdp/linuxdeploy-plugin-gtk"
- echo ""
- fi
+GTK_PLUGIN_ARG=""
+if [ -x "./linuxdeploy-plugin-gtk.sh" ]; then
+ export DEPLOY_GTK_VERSION=4
+ GTK_PLUGIN_ARG="--plugin gtk"
+ echo "GTK4 plugin found - libraries will be bundled."
+else
+ echo ""
+ echo "Warning: linuxdeploy-plugin-gtk not found."
+ echo "GTK4 libraries will not be bundled."
+ echo "The AppImage may only work on systems with GTK4 and libadwaita installed."
+ echo ""
+ echo "Download the plugin from:"
+ echo " https://github.com/linuxdeploy/linuxdeploy-plugin-gtk"
+ echo ""
fi
echo "=== Building AppImage ==="
@@ -102,6 +116,7 @@ export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas"
--appdir "$APPDIR" \
--desktop-file "$APPDIR/usr/share/applications/$APP_ID.desktop" \
--icon-file "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" \
+ $GTK_PLUGIN_ARG \
--output appimage
echo ""
diff --git a/src/application.rs b/src/application.rs
index 7633696..fdc9493 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -3,7 +3,7 @@ use adw::subclass::prelude::*;
use gtk::gio;
use std::cell::OnceCell;
-use crate::config::{APP_ID, VERSION};
+use crate::config::APP_ID;
use crate::window::DriftwoodWindow;
mod imp {
@@ -118,28 +118,8 @@ impl DriftwoodApplication {
})
.build();
- // About action
- let about_action = gio::ActionEntry::builder("about")
- .activate(|app: &Self, _, _| {
- app.show_about_dialog();
- })
- .build();
-
- self.add_action_entries([quit_action, about_action]);
+ self.add_action_entries([quit_action]);
self.set_accels_for_action("app.quit", &["q"]);
}
- fn show_about_dialog(&self) {
- let dialog = adw::AboutDialog::builder()
- .application_name("Driftwood")
- .application_icon(APP_ID)
- .version(VERSION)
- .developer_name("Driftwood Contributors")
- .license_type(gtk::License::Gpl30)
- .comments("A modern AppImage manager for GNOME desktops")
- .website("https://github.com/driftwood-app/driftwood")
- .build();
-
- dialog.present(self.active_window().as_ref());
- }
}
diff --git a/src/window.rs b/src/window.rs
index cedddae..e8337c1 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -184,7 +184,6 @@ impl DriftwoodWindow {
let section3 = gio::Menu::new();
section3.append(Some(&i18n("Preferences")), Some("win.preferences"));
section3.append(Some(&i18n("Keyboard Shortcuts")), Some("win.show-shortcuts"));
- section3.append(Some(&i18n("About Driftwood")), Some("app.about"));
menu.append_section(None, §ion3);
// Library view (contains header bar, search, grid/list, empty state)
diff --git a/tools/a11y-audit.py b/tools/a11y-audit.py
deleted file mode 100644
index 26c662f..0000000
--- a/tools/a11y-audit.py
+++ /dev/null
@@ -1,964 +0,0 @@
-#!/usr/bin/env python3
-"""
-Automated WCAG 2.2 AAA accessibility audit for Driftwood via AT-SPI.
-
-Launches the app, waits for it to register on the accessibility bus,
-then walks the entire widget tree checking for violations at A, AA, and AAA
-levels:
-
- Level A:
- - SC 1.1.1: Images with no name and no decorative marking
- - SC 1.3.1: Headings missing a level property
- - SC 2.1.1: Interactive widgets not keyboard-focusable
- - SC 4.1.2: Interactive widgets with no accessible name
-
- Level AA:
- - SC 2.5.8: Target size below 24x24px minimum
- - SC 4.1.2: Progress bars / spinners without labels
-
- Level AAA:
- - SC 2.1.3: All interactive widgets must be keyboard accessible (no traps)
- - SC 2.4.8: Window must have a meaningful title (location)
- - SC 2.4.9: Link purpose from link text alone
- - SC 2.4.10: Section headings exist in content regions
- - SC 2.5.5: Target size 44x44px (AAA level)
- - SC 3.2.5: Change on request - no auto-refresh detection
- - SC 3.3.9: Accessible authentication - no cognitive function tests
-
- Structural / informational:
- - Live regions (alert/status/log/timer roles) inventory
- - Keyboard focus traversal test across all views
- - Tab order validation (focusable widgets reachable)
-
-Usage:
- python3 tools/a11y-audit.py [--no-launch] [--level aa|aaa] [--verbose]
-
- --no-launch Attach to an already-running Driftwood instance
- --level Minimum conformance level to check (default: aaa)
- --verbose Show informational messages and live region inventory
-"""
-
-import gi
-gi.require_version("Atspi", "2.0")
-from gi.repository import Atspi
-import subprocess
-import sys
-import time
-import signal
-import os
-import json
-
-# --- Configuration ---
-AUDIT_LEVEL = "aaa" # Default: check everything up to AAA
-VERBOSE = False
-
-# Roles that MUST have an accessible name
-INTERACTIVE_ROLES = {
- Atspi.Role.PUSH_BUTTON,
- Atspi.Role.TOGGLE_BUTTON,
- Atspi.Role.CHECK_BOX,
- Atspi.Role.RADIO_BUTTON,
- Atspi.Role.MENU_ITEM,
- Atspi.Role.ENTRY,
- Atspi.Role.SPIN_BUTTON,
- Atspi.Role.SLIDER,
- Atspi.Role.COMBO_BOX,
- Atspi.Role.LINK,
- Atspi.Role.SPLIT_BUTTON if hasattr(Atspi.Role, "SPLIT_BUTTON") else None,
-}
-INTERACTIVE_ROLES.discard(None)
-
-# Roles where missing name is just a warning (container-like)
-CONTAINER_ROLES = {
- Atspi.Role.PANEL,
- Atspi.Role.FILLER,
- Atspi.Role.SCROLL_PANE,
- Atspi.Role.VIEWPORT,
- Atspi.Role.FRAME,
- Atspi.Role.SECTION,
- Atspi.Role.BLOCK_QUOTE,
- Atspi.Role.REDUNDANT_OBJECT,
- Atspi.Role.SEPARATOR,
-}
-
-# Roles considered "image-like"
-IMAGE_ROLES = {
- Atspi.Role.IMAGE,
- Atspi.Role.ICON,
- Atspi.Role.ANIMATION,
-}
-
-# Roles that indicate live regions (for inventory)
-LIVE_REGION_ROLES = {
- Atspi.Role.ALERT,
- Atspi.Role.NOTIFICATION if hasattr(Atspi.Role, "NOTIFICATION") else None,
- Atspi.Role.STATUS_BAR,
- Atspi.Role.LOG if hasattr(Atspi.Role, "LOG") else None,
- Atspi.Role.TIMER if hasattr(Atspi.Role, "TIMER") else None,
- Atspi.Role.MARQUEE if hasattr(Atspi.Role, "MARQUEE") else None,
-}
-LIVE_REGION_ROLES.discard(None)
-
-# Roles that represent content sections (for SC 2.4.10 heading check)
-CONTENT_REGION_ROLES = {
- Atspi.Role.SCROLL_PANE,
- Atspi.Role.PANEL,
- Atspi.Role.SECTION,
- Atspi.Role.DOCUMENT_FRAME,
-}
-
-# Decorative roles that don't need names
-DECORATIVE_ROLES = set()
-# GTK maps AccessibleRole::Presentation to ROLE_REDUNDANT_OBJECT
-# but some versions map it differently; we check name == "" as well
-
-
-class Issue:
- def __init__(self, severity, criterion, level, path, role, message):
- self.severity = severity # "error", "warning", "info"
- self.criterion = criterion
- self.level = level # "A", "AA", "AAA"
- self.path = path
- self.role = role
- self.message = message
-
- def __str__(self):
- return (f"[{self.severity.upper()}] SC {self.criterion} ({self.level}) "
- f"| {self.role} | {self.path}\n {self.message}")
-
-
-# --- Stats tracking ---
-class AuditStats:
- def __init__(self):
- self.total_interactive = 0
- self.total_focusable = 0
- self.total_images = 0
- self.total_headings = 0
- self.total_links = 0
- self.total_live_regions = 0
- self.windows_with_titles = 0
- self.windows_total = 0
- self.focus_chain = [] # ordered list of focusable widget paths
- self.content_sections = 0
- self.sections_with_headings = 0
-
-stats = AuditStats()
-
-
-# --- Helper functions ---
-
-def get_name(node):
- """Get accessible name, handling exceptions."""
- try:
- return node.get_name() or ""
- except Exception:
- return ""
-
-
-def get_role(node):
- """Get role, handling exceptions."""
- try:
- return node.get_role()
- except Exception:
- return Atspi.Role.INVALID
-
-
-def get_role_name(node):
- """Get role name string."""
- try:
- return node.get_role_name() or "unknown"
- except Exception:
- return "unknown"
-
-
-def get_description(node):
- """Get accessible description."""
- try:
- return node.get_description() or ""
- except Exception:
- return ""
-
-
-def get_states(node):
- """Get state set."""
- try:
- return node.get_state_set()
- except Exception:
- return None
-
-
-def get_child_count(node):
- try:
- return node.get_child_count()
- except Exception:
- return 0
-
-
-def get_child(node, idx):
- try:
- return node.get_child_at_index(idx)
- except Exception:
- return None
-
-
-def get_attributes_dict(node):
- """Get node attributes as a dict, handling various AT-SPI return formats."""
- try:
- attrs = node.get_attributes()
- if attrs is None:
- return {}
- if isinstance(attrs, dict):
- return attrs
- # Some versions return a list of "key:value" strings
- result = {}
- for attr in attrs:
- if isinstance(attr, str) and ":" in attr:
- k, _, v = attr.partition(":")
- result[k.strip()] = v.strip()
- return result
- except Exception:
- return {}
-
-
-def build_path(node, max_depth=6):
- """Build a human-readable path like 'window > box > button'."""
- parts = []
- current = node
- for _ in range(max_depth):
- if current is None:
- break
- name = get_name(current)
- role = get_role_name(current)
- if name:
- parts.append(f"{role}({name[:30]})")
- else:
- parts.append(role)
- try:
- current = current.get_parent()
- except Exception:
- break
- parts.reverse()
- return " > ".join(parts)
-
-
-def get_size(node):
- """Get component size if available."""
- try:
- comp = node.get_component_iface()
- if comp:
- rect = comp.get_extents(Atspi.CoordType.SCREEN)
- return (rect.width, rect.height)
- except Exception:
- pass
- return None
-
-
-def has_heading_descendant(node, max_depth=5):
- """Check if a node has any HEADING descendant (for SC 2.4.10)."""
- if max_depth <= 0:
- return False
- n = get_child_count(node)
- for i in range(n):
- child = get_child(node, i)
- if child is None:
- continue
- if get_role(child) == Atspi.Role.HEADING:
- return True
- if has_heading_descendant(child, max_depth - 1):
- return True
- return False
-
-
-def count_children_deep(node, max_depth=3):
- """Count total descendants to gauge if a container has real content."""
- if max_depth <= 0:
- return 0
- total = 0
- n = get_child_count(node)
- for i in range(n):
- child = get_child(node, i)
- if child:
- total += 1 + count_children_deep(child, max_depth - 1)
- return total
-
-
-def check_level(target):
- """Check if the given WCAG level should be audited."""
- levels = {"a": 1, "aa": 2, "aaa": 3}
- return levels.get(target.lower(), 3) <= levels.get(AUDIT_LEVEL.lower(), 3)
-
-
-def _has_text_descendant(node, max_depth=1):
- """Check if a node has a child (or grandchild) providing text content."""
- if max_depth <= 0:
- return False
- for i in range(get_child_count(node)):
- child = get_child(node, i)
- if child:
- child_name = get_name(child)
- child_role = get_role(child)
- if child_name.strip() or child_role == Atspi.Role.LABEL:
- return True
- if max_depth > 1 and _has_text_descendant(child, max_depth - 1):
- return True
- return False
-
-
-# --- Main audit function ---
-
-def audit_node(node, issues, visited, depth=0, path_prefix=""):
- """Recursively audit a single AT-SPI node and its children."""
- if node is None or depth > 50:
- return
-
- # Build a unique key from the tree position to avoid cycles
- # without relying on object identity (which Python GC can reuse)
- role_name = get_role_name(node)
- name = get_name(node)
- node_key = f"{path_prefix}/{depth}:{role_name}:{name[:20]}"
- if node_key in visited:
- return
- visited.add(node_key)
-
- role = get_role(node)
- name = get_name(node)
- desc = get_description(node)
- role_name = get_role_name(node)
- path = build_path(node)
- states = get_states(node)
-
- # Skip dead/invalid nodes
- if role == Atspi.Role.INVALID:
- return
-
- is_visible = states and states.contains(Atspi.StateType.VISIBLE)
- is_showing = states and states.contains(Atspi.StateType.SHOWING)
- is_focusable = states and states.contains(Atspi.StateType.FOCUSABLE)
-
- # =================================================================
- # Level A checks
- # =================================================================
-
- # --- SC 4.1.2 (A): Interactive widgets must have a name ---
- if role in INTERACTIVE_ROLES:
- stats.total_interactive += 1
-
- if is_visible and not name.strip():
- # GTK menu section separators appear as MENU_ITEM with no name
- # and no children - these are decorative, not interactive.
- # Also, GMenu-backed popover items don't expose names via AT-SPI
- # until the popover is opened (they have children but no name
- # in their not-SHOWING state), so skip those too.
- if role == Atspi.Role.MENU_ITEM and (
- get_child_count(node) == 0 or not is_showing
- ):
- pass # Section separator or closed popover item
- else:
- # Check if it has children that provide text content.
- # For MENU_ITEM, check 2 levels deep because GTK4
- # structures them as menu_item > box > label, and the
- # AT-SPI bridge may not expose the name on closed popovers.
- max_check_depth = 2 if role == Atspi.Role.MENU_ITEM else 1
- has_text_child = _has_text_descendant(node, max_check_depth)
-
- if not has_text_child:
- issues.append(Issue(
- "error", "4.1.2", "A",
- path, role_name,
- "Interactive widget has no accessible name"
- ))
-
- # --- SC 1.1.1 (A): Images need name or Presentation role ---
- if role in IMAGE_ROLES:
- stats.total_images += 1
- if is_visible and not name.strip() and not desc.strip():
- # Check if parent is a button that has its own label
- try:
- parent = node.get_parent()
- parent_role = get_role(parent) if parent else None
- parent_name = get_name(parent) if parent else ""
- except Exception:
- parent_role = None
- parent_name = ""
-
- if parent_role in INTERACTIVE_ROLES and parent_name.strip():
- # Image inside a labeled button is functionally decorative.
- # GTK4's AT-SPI bridge still reports it as ROLE_IMAGE even when
- # AccessibleRole::Presentation is set, so this is cosmetic.
- pass
- elif parent_role == Atspi.Role.PANEL:
- # Check grandparent - image might be inside panel inside button
- try:
- grandparent = parent.get_parent()
- gp_role = get_role(grandparent) if grandparent else None
- gp_name = get_name(grandparent) if grandparent else ""
- except Exception:
- gp_role = None
- gp_name = ""
- if gp_role in INTERACTIVE_ROLES and gp_name.strip():
- pass # Decorative inside labeled interactive widget
- else:
- issues.append(Issue(
- "error", "1.1.1", "A",
- path, role_name,
- "Image has no accessible name and is not marked decorative"
- ))
- else:
- issues.append(Issue(
- "error", "1.1.1", "A",
- path, role_name,
- "Image has no accessible name and is not marked decorative"
- ))
-
- # --- SC 1.3.1 (A): Headings should have a level ---
- if role == Atspi.Role.HEADING:
- stats.total_headings += 1
- attrs = get_attributes_dict(node)
- level = attrs.get("level")
- if level is None:
- # Also try get_attribute directly
- try:
- level = node.get_attribute("level")
- except Exception:
- level = None
-
- if not level:
- issues.append(Issue(
- "warning", "1.3.1", "A",
- path, role_name,
- "Heading has no level attribute"
- ))
-
- # --- SC 2.1.1 (A): Interactive widgets must be keyboard-focusable ---
- if role in INTERACTIVE_ROLES and is_visible and is_showing:
- if not is_focusable:
- # Exclude menu items (handled by menu keyboard nav)
- if role == Atspi.Role.MENU_ITEM:
- pass
- else:
- # Check if this is a composite widget (SplitButton/MenuButton)
- # whose child toggle button IS focusable - not a real issue
- has_focusable_child = False
- for i in range(get_child_count(node)):
- child = get_child(node, i)
- if child:
- cr = get_role(child)
- cs = get_states(child)
- if (cr in {Atspi.Role.TOGGLE_BUTTON, Atspi.Role.PUSH_BUTTON}
- and cs and cs.contains(Atspi.StateType.FOCUSABLE)):
- has_focusable_child = True
- break
- if not has_focusable_child:
- issues.append(Issue(
- "warning", "2.1.1", "A",
- path, role_name,
- "Interactive widget is visible but not keyboard-focusable"
- ))
-
- # =================================================================
- # Level AA checks
- # =================================================================
-
- # --- SC 4.1.2 (AA): Progress bars / spinners need labels ---
- if role == Atspi.Role.PROGRESS_BAR:
- if not name.strip() and not desc.strip():
- issues.append(Issue(
- "error", "4.1.2", "AA",
- path, role_name,
- "Progress bar has no accessible name or description"
- ))
-
- # --- SC 2.5.8 (AA): Target size minimum 24x24 ---
- # Exclude window control buttons (Minimize, Maximize, Close, More Options)
- # as these are managed by the toolkit/window manager, not the app.
- WINDOW_CONTROL_NAMES = {"minimize", "maximize", "close", "more options"}
- is_window_control = name.strip().lower() in WINDOW_CONTROL_NAMES
-
- if role in INTERACTIVE_ROLES and not is_window_control:
- size = get_size(node)
- if size and is_visible and is_showing:
- w, h = size
- if 0 < w < 24 or 0 < h < 24:
- issues.append(Issue(
- "warning", "2.5.8", "AA",
- path, role_name,
- f"Interactive element is {w}x{h}px - below 24px AA minimum target size"
- ))
-
- # --- SC 4.1.2 (AA): Focusable non-interactive widgets should have a name ---
- if role not in INTERACTIVE_ROLES and role not in CONTAINER_ROLES and role not in IMAGE_ROLES:
- if is_focusable and is_visible and not name.strip():
- # Only flag if it's not a generic container
- if role not in {Atspi.Role.APPLICATION, Atspi.Role.WINDOW,
- Atspi.Role.PAGE_TAB_LIST, Atspi.Role.PAGE_TAB,
- Atspi.Role.SCROLL_PANE, Atspi.Role.LIST,
- Atspi.Role.LIST_ITEM, Atspi.Role.TABLE_CELL,
- Atspi.Role.TREE_TABLE, Atspi.Role.TREE_ITEM,
- Atspi.Role.LABEL, Atspi.Role.TEXT,
- Atspi.Role.DOCUMENT_FRAME, Atspi.Role.TOOL_BAR,
- Atspi.Role.STATUS_BAR, Atspi.Role.MENU_BAR,
- Atspi.Role.INTERNAL_FRAME}:
- issues.append(Issue(
- "warning", "4.1.2", "AA",
- path, role_name,
- "Focusable widget has no accessible name"
- ))
-
- # =================================================================
- # Level AAA checks
- # =================================================================
-
- if check_level("aaa"):
-
- # --- SC 2.5.5 (AAA): Target size 44x44px ---
- if role in INTERACTIVE_ROLES and not is_window_control:
- size = get_size(node)
- if size and is_visible and is_showing:
- w, h = size
- # Already flagged at 24px for AA; flag at 44px for AAA
- if 24 <= w < 44 or 24 <= h < 44:
- issues.append(Issue(
- "info", "2.5.5", "AAA",
- path, role_name,
- f"Interactive element is {w}x{h}px - below 44px AAA target size"
- ))
-
- # --- SC 2.4.8 (AAA): Location - windows must have meaningful titles ---
- if role == Atspi.Role.WINDOW or role == Atspi.Role.FRAME:
- stats.windows_total += 1
- if name.strip():
- stats.windows_with_titles += 1
- else:
- issues.append(Issue(
- "warning", "2.4.8", "AAA",
- path, role_name,
- "Window/frame has no title - users cannot determine their location"
- ))
-
- # --- SC 2.4.9 (AAA): Link purpose from link text alone ---
- if role == Atspi.Role.LINK and is_visible:
- stats.total_links += 1
- link_text = name.strip().lower()
- # Flag generic link text that doesn't convey purpose
- vague_texts = {
- "click here", "here", "more", "read more", "link",
- "learn more", "details", "info", "this",
- }
- if link_text in vague_texts:
- issues.append(Issue(
- "warning", "2.4.9", "AAA",
- path, role_name,
- f"Link text '{name.strip()}' is too vague - "
- "purpose should be clear from link text alone"
- ))
-
- # --- SC 2.4.10 (AAA): Section headings ---
- # Large content regions (with many children) should have at least one heading
- if role in CONTENT_REGION_ROLES and is_visible:
- child_count = count_children_deep(node, 2)
- if child_count > 10: # Non-trivial content region
- stats.content_sections += 1
- if has_heading_descendant(node, 4):
- stats.sections_with_headings += 1
-
- # =================================================================
- # Informational checks (all levels)
- # =================================================================
-
- # --- Live region inventory ---
- if role in LIVE_REGION_ROLES:
- stats.total_live_regions += 1
- if VERBOSE:
- issues.append(Issue(
- "info", "4.1.3", "A",
- path, role_name,
- f"Live region found: {role_name} - name='{name}'"
- ))
-
- # --- Track focus chain ---
- if is_focusable and is_visible:
- stats.total_focusable += 1
- stats.focus_chain.append(path)
-
- # --- Recurse into children ---
- n_children = get_child_count(node)
- for i in range(n_children):
- child = get_child(node, i)
- if child is not None:
- audit_node(child, issues, visited, depth + 1, f"{node_key}/{i}")
-
-
-def run_keyboard_focus_test(app):
- """
- Test keyboard focus traversal by checking that focusable widgets
- are reachable via the focus chain.
-
- SC 2.1.3 (AAA): No keyboard trap - all focusable widgets should
- have a logical tab order without traps.
-
- Returns a list of issues found.
- """
- issues = []
-
- # Walk the tree and verify that focusable interactive widgets
- # actually have the FOCUSABLE state set (basic trap detection)
- def check_focus_trap(node, seen, depth=0):
- if node is None or depth > 50:
- return
- key = f"focus/{depth}:{get_role_name(node)}:{get_name(node)[:20]}"
- if key in seen:
- return
- seen.add(key)
-
- role = get_role(node)
- states = get_states(node)
- is_visible = states and states.contains(Atspi.StateType.VISIBLE)
- is_showing = states and states.contains(Atspi.StateType.SHOWING)
- is_focused = states and states.contains(Atspi.StateType.FOCUSED)
-
- # A widget that is focused but not focusable is a bug
- if is_focused and not (states and states.contains(Atspi.StateType.FOCUSABLE)):
- issues.append(Issue(
- "warning", "2.1.3", "AAA",
- build_path(node), get_role_name(node),
- "Widget has FOCUSED state but not FOCUSABLE - potential keyboard trap"
- ))
-
- # Check for modal dialogs without focusable children (trap)
- if role == Atspi.Role.DIALOG and is_visible and is_showing:
- has_focusable = False
- for i in range(get_child_count(node)):
- child = get_child(node, i)
- if child:
- cs = get_states(child)
- if cs and cs.contains(Atspi.StateType.FOCUSABLE):
- has_focusable = True
- break
- if not has_focusable and get_child_count(node) > 0:
- issues.append(Issue(
- "warning", "2.1.3", "AAA",
- build_path(node), get_role_name(node),
- "Visible dialog has no focusable children - potential keyboard trap"
- ))
-
- for i in range(get_child_count(node)):
- child = get_child(node, i)
- check_focus_trap(child, seen, depth + 1)
-
- if check_level("aaa"):
- check_focus_trap(app, set())
-
- return issues
-
-
-def find_driftwood_app():
- """Find the Driftwood application on the AT-SPI bus."""
- desktop = Atspi.get_desktop(0)
- n = get_child_count(desktop)
- for i in range(n):
- app = get_child(desktop, i)
- if app is None:
- continue
- app_name = get_name(app)
- # Match exact app name (not substring) to avoid matching editors
- # that have "driftwood" in their window title
- if app_name and app_name.lower() == "driftwood":
- return app
- return None
-
-
-def print_stats():
- """Print audit statistics summary."""
- print(f"\n--- AUDIT STATISTICS ---")
- print(f" Interactive widgets: {stats.total_interactive}")
- print(f" Focusable widgets: {stats.total_focusable}")
- print(f" Images: {stats.total_images}")
- print(f" Headings: {stats.total_headings}")
- print(f" Links: {stats.total_links}")
- print(f" Live regions: {stats.total_live_regions}")
- print(f" Windows with titles: {stats.windows_with_titles}/{stats.windows_total}")
- if check_level("aaa") and stats.content_sections > 0:
- print(f" Content sections with headings: "
- f"{stats.sections_with_headings}/{stats.content_sections}")
- print()
-
-
-def main():
- global AUDIT_LEVEL, VERBOSE
-
- no_launch = "--no-launch" in sys.argv
- if "--level" in sys.argv:
- idx = sys.argv.index("--level")
- if idx + 1 < len(sys.argv):
- AUDIT_LEVEL = sys.argv[idx + 1].lower()
- if "--verbose" in sys.argv:
- VERBOSE = True
-
- print(f"WCAG audit level: {AUDIT_LEVEL.upper()}")
-
- proc = None
- if not no_launch:
- # Build first
- print("Building Driftwood...")
- build = subprocess.run(
- ["cargo", "build"],
- cwd="/media/lashman/DATA1/gdfhbfgdbnbdfbdf/driftwood",
- capture_output=True, text=True,
- )
- if build.returncode != 0:
- print("Build failed:")
- print(build.stderr)
- sys.exit(1)
-
- print("Launching Driftwood...")
- env = os.environ.copy()
- env["GTK_A11Y"] = "atspi"
- proc = subprocess.Popen(
- ["./target/debug/driftwood"],
- cwd="/media/lashman/DATA1/gdfhbfgdbnbdfbdf/driftwood",
- env=env,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
- )
-
- # Wait for app to appear on AT-SPI bus
- print("Waiting for Driftwood to appear on AT-SPI bus...")
- app = None
- for attempt in range(30):
- time.sleep(1)
- app = find_driftwood_app()
- if app:
- break
- if proc.poll() is not None:
- print("App exited prematurely")
- sys.exit(1)
- if not app:
- print("Timed out waiting for Driftwood on AT-SPI bus")
- if proc:
- proc.terminate()
- sys.exit(1)
- else:
- print("Looking for running Driftwood instance...")
- app = find_driftwood_app()
- if not app:
- print("No running Driftwood instance found on AT-SPI bus")
- sys.exit(1)
-
- print(f"Found Driftwood: {get_name(app)}")
- print(f"Windows: {get_child_count(app)}")
-
- # Give UI time to fully render (scan, load cards, etc.)
- time.sleep(8)
-
- issues = []
- visited = set()
-
- # Audit all views by activating GActions via gdbus
- def activate_action(action_name):
- """Activate a GAction on the Driftwood app via D-Bus."""
- try:
- subprocess.run(
- ["gdbus", "call", "--session",
- "--dest", "io.github.driftwood",
- "--object-path", "/io/github/driftwood",
- "--method", "org.gtk.Actions.Activate",
- action_name, "[]", "{}"],
- timeout=3, capture_output=True,
- )
- return True
- except Exception:
- return False
-
- # --- Phase 1: Audit installed view (default) ---
- print("\nPhase 1: Auditing Installed view...")
- audit_node(app, issues, visited, path_prefix="installed")
- print(f" Nodes so far: {len(visited)}")
-
- # --- Phase 2: Switch to Catalog tab ---
- if activate_action("show-catalog"):
- time.sleep(5) # Wait for catalog to load
- print("Phase 2: Auditing Catalog view...")
- audit_node(app, issues, visited, path_prefix="catalog")
- print(f" Nodes so far: {len(visited)}")
- else:
- print(" Skipping catalog view (could not activate action)")
-
- # --- Phase 3: Switch to Updates tab ---
- if activate_action("show-updates"):
- time.sleep(3)
- print("Phase 3: Auditing Updates view...")
- audit_node(app, issues, visited, path_prefix="updates")
- print(f" Nodes so far: {len(visited)}")
- else:
- print(" Skipping updates view (could not activate action)")
-
- # --- Phase 4: Keyboard focus traversal test ---
- if check_level("aaa"):
- print("Phase 4: Running keyboard focus traversal test...")
- activate_action("show-installed")
- time.sleep(1)
- focus_issues = run_keyboard_focus_test(app)
- issues.extend(focus_issues)
- print(f" Focus issues: {len(focus_issues)}")
- else:
- activate_action("show-installed")
- time.sleep(1)
-
- print()
-
- # Sort: errors first, then warnings, then info
- severity_order = {"error": 0, "warning": 1, "info": 2}
- level_order = {"A": 0, "AA": 1, "AAA": 2}
- issues.sort(key=lambda i: (
- severity_order.get(i.severity, 3),
- level_order.get(i.level, 3),
- ))
-
- # Print report
- errors = [i for i in issues if i.severity == "error"]
- warnings = [i for i in issues if i.severity == "warning"]
- infos = [i for i in issues if i.severity == "info"]
-
- # Group by WCAG level
- a_issues = [i for i in issues if i.level == "A" and i.severity != "info"]
- aa_issues = [i for i in issues if i.level == "AA" and i.severity != "info"]
- aaa_issues = [i for i in issues if i.level == "AAA" and i.severity != "info"]
-
- print("=" * 70)
- print("WCAG 2.2 ACCESSIBILITY AUDIT REPORT")
- print(f"Conformance target: {AUDIT_LEVEL.upper()}")
- print("=" * 70)
- print(f"Total nodes visited: {len(visited)}")
- print(f"Issues found: {len(errors)} errors, {len(warnings)} warnings, {len(infos)} info")
- print(f" Level A: {len(a_issues)} issues")
- print(f" Level AA: {len(aa_issues)} issues")
- print(f" Level AAA: {len(aaa_issues)} issues")
- print()
-
- # Print stats
- print_stats()
-
- if errors:
- print(f"--- ERRORS ({len(errors)}) ---")
- for issue in errors:
- print(issue)
- print()
-
- if warnings:
- print(f"--- WARNINGS ({len(warnings)}) ---")
- for issue in warnings:
- print(issue)
- print()
-
- if VERBOSE and infos:
- print(f"--- INFO ({len(infos)}) ---")
- for issue in infos:
- print(issue)
- print()
-
- if not errors and not warnings:
- print("No accessibility issues found!")
-
- # Conformance summary
- print()
- print("=" * 70)
- print("CONFORMANCE SUMMARY")
- print("=" * 70)
- if not a_issues:
- print(" Level A: PASS")
- else:
- print(f" Level A: FAIL ({len(a_issues)} issues)")
- if not aa_issues:
- print(" Level AA: PASS")
- else:
- print(f" Level AA: FAIL ({len(aa_issues)} issues)")
- if check_level("aaa"):
- if not aaa_issues:
- print(" Level AAA: PASS")
- else:
- print(f" Level AAA: FAIL ({len(aaa_issues)} issues)")
-
- # Note about manual checks required for full AAA
- print()
- print("NOTE: The following AAA criteria require manual review:")
- print(" - SC 1.4.6: Enhanced contrast (7:1 ratio) - use a color contrast tool")
- print(" - SC 1.4.7: Low or no background audio")
- print(" - SC 1.4.8: Visual presentation (line length, spacing)")
- print(" - SC 1.4.9: Images of text (no exceptions)")
- print(" - SC 2.2.3: No timing (verify no timed interactions)")
- print(" - SC 2.2.4: Interruptions can be postponed")
- print(" - SC 2.4.13: Focus appearance (3px outline, area ratio)")
- print(" - SC 3.1.3: Unusual words")
- print(" - SC 3.1.4: Abbreviations")
- print(" - SC 3.1.5: Reading level")
- print(" - SC 3.1.6: Pronunciation")
- print(" - SC 3.2.5: Change on request")
- print(" - SC 3.3.9: Accessible authentication (enhanced)")
- print("=" * 70)
-
- # Write JSON report for CI integration
- report = {
- "audit_level": AUDIT_LEVEL.upper(),
- "nodes_visited": len(visited),
- "stats": {
- "interactive_widgets": stats.total_interactive,
- "focusable_widgets": stats.total_focusable,
- "images": stats.total_images,
- "headings": stats.total_headings,
- "links": stats.total_links,
- "live_regions": stats.total_live_regions,
- "windows_with_titles": stats.windows_with_titles,
- "windows_total": stats.windows_total,
- },
- "summary": {
- "errors": len(errors),
- "warnings": len(warnings),
- "info": len(infos),
- "level_a": len(a_issues),
- "level_aa": len(aa_issues),
- "level_aaa": len(aaa_issues),
- },
- "conformance": {
- "level_a": len(a_issues) == 0,
- "level_aa": len(aa_issues) == 0,
- "level_aaa": len(aaa_issues) == 0 if check_level("aaa") else None,
- },
- "issues": [
- {
- "severity": i.severity,
- "criterion": i.criterion,
- "level": i.level,
- "role": i.role,
- "path": i.path,
- "message": i.message,
- }
- for i in issues
- ],
- }
-
- report_path = os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- "..", "a11y-report.json",
- )
- report_path = os.path.normpath(report_path)
- try:
- with open(report_path, "w") as f:
- json.dump(report, f, indent=2)
- print(f"\nJSON report written to: {report_path}")
- except Exception as e:
- print(f"\nCould not write JSON report: {e}")
-
- # Cleanup
- if proc:
- print("\nTerminating Driftwood...")
- proc.terminate()
- try:
- proc.wait(timeout=5)
- except subprocess.TimeoutExpired:
- proc.kill()
-
- sys.exit(1 if errors else 0)
-
-
-if __name__ == "__main__":
- main()