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()