Add app icons, screenshots, and complete AppStream metainfo

This commit is contained in:
2026-03-01 14:46:41 +02:00
parent 292b91d612
commit edaf98b10d
20 changed files with 159 additions and 1019 deletions

View File

@@ -23,24 +23,105 @@
<li>Duplicate detection and disk space analysis</li> <li>Duplicate detection and disk space analysis</li>
<li>Firejail sandboxing support</li> <li>Firejail sandboxing support</li>
<li>Orphaned configuration cleanup</li> <li>Orphaned configuration cleanup</li>
<li>Browse and install from the AppImageHub catalog</li>
</ul> </ul>
</description> </description>
<launchable type="desktop-id">app.driftwood.Driftwood.desktop</launchable> <icon type="stock">app.driftwood.Driftwood</icon>
<url type="homepage">https://github.com/driftwood-app/driftwood</url> <launchable type="desktop-id">app.driftwood.Driftwood.desktop</launchable>
<url type="bugtracker">https://github.com/driftwood-app/driftwood/issues</url>
<developer id="app.driftwood"> <developer id="app.driftwood">
<name>Driftwood Contributors</name> <name>Driftwood Contributors</name>
</developer> </developer>
<url type="homepage">https://git.lashman.live/lashman/driftwood</url>
<url type="bugtracker">https://git.lashman.live/lashman/driftwood/issues</url>
<url type="vcs-browser">https://git.lashman.live/lashman/driftwood</url>
<url type="donation">https://ko-fi.com/lashman</url>
<url type="contact">mailto:lashman@robotbrush.com</url>
<url type="contribute">https://git.lashman.live/lashman/driftwood</url>
<update_contact>lashman@robotbrush.com</update_contact>
<branding> <branding>
<color type="primary" scheme_preference="light">#8ff0a4</color> <color type="primary" scheme_preference="light">#8ff0a4</color>
<color type="primary" scheme_preference="dark">#26a269</color> <color type="primary" scheme_preference="dark">#26a269</color>
</branding> </branding>
<content_rating type="oars-1.1" /> <screenshots>
<screenshot type="default">
<caption>Library grid view showing installed AppImages</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/02-library-grid.png</image>
</screenshot>
<screenshot>
<caption>Library list view with app details</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/03-library-list.png</image>
</screenshot>
<screenshot>
<caption>App detail view with description and compatibility badges</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/04-app-detail-about.png</image>
</screenshot>
<screenshot>
<caption>App screenshots, links, and update information</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/05-app-detail-screenshots.png</image>
</screenshot>
<screenshot>
<caption>Desktop integration and file type management</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/06-app-detail-integration.png</image>
</screenshot>
<screenshot>
<caption>System dashboard with FUSE, Wayland, and library status</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/07-dashboard.png</image>
</screenshot>
<screenshot>
<caption>Preferences with scan locations and automation settings</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/08-preferences.png</image>
</screenshot>
<screenshot>
<caption>Drag-and-drop to add AppImage files</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/09-drag-and-drop.png</image>
</screenshot>
<screenshot>
<caption>App catalog with featured apps and category browsing</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/10-catalog-browse.png</image>
</screenshot>
<screenshot>
<caption>Catalog app detail with install button and statistics</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/11-catalog-app-detail.png</image>
</screenshot>
<screenshot>
<caption>Catalog category view with sorting and filtering</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/12-catalog-category.png</image>
</screenshot>
<screenshot>
<caption>Catalog refresh with download progress</caption>
<image type="source" width="1151" height="864">https://git.lashman.live/lashman/driftwood/raw/branch/main/data/screenshots/01-catalog-loading.png</image>
</screenshot>
</screenshots>
<categories>
<category>System</category>
<category>PackageManager</category>
<category>GTK</category>
</categories>
<keywords>
<keyword>AppImage</keyword>
<keyword>Application</keyword>
<keyword>Manager</keyword>
<keyword>Package</keyword>
<keyword>FUSE</keyword>
<keyword>Wayland</keyword>
<keyword>Security</keyword>
<keyword>Sandbox</keyword>
<keyword>Catalog</keyword>
<keyword>Integration</keyword>
</keywords>
<content_rating type="oars-1.1">
<content_attribute id="social-info">mild</content_attribute>
</content_rating>
<requires> <requires>
<display_length compare="ge">360</display_length> <display_length compare="ge">360</display_length>
@@ -51,18 +132,18 @@
<control>pointing</control> <control>pointing</control>
</recommends> </recommends>
<keywords> <supports>
<keyword>AppImage</keyword> <internet>first-run</internet>
<keyword>Application</keyword> </supports>
<keyword>Manager</keyword>
<keyword>Package</keyword> <provides>
<keyword>FUSE</keyword> <binary>driftwood</binary>
<keyword>Wayland</keyword> </provides>
<keyword>Security</keyword>
</keywords> <translation type="gettext">app.driftwood.Driftwood</translation>
<releases> <releases>
<release version="0.1.0" date="2026-02-26"> <release version="0.1.0" date="2026-02-26" type="stable">
<description> <description>
<p>Initial release of Driftwood with core features:</p> <p>Initial release of Driftwood with core features:</p>
<ul> <ul>
@@ -75,6 +156,7 @@
<li>Firejail sandbox support</li> <li>Firejail sandbox support</li>
<li>Orphan cleanup and disk reclamation wizard</li> <li>Orphan cleanup and disk reclamation wizard</li>
<li>CLI interface with scan, list, launch, and inspect commands</li> <li>CLI interface with scan, list, launch, and inspect commands</li>
<li>AppImageHub catalog browsing and one-click install</li>
</ul> </ul>
</description> </description>
</release> </release>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Chin/depth layer (GNOME Green 5) -->
<rect x="10" y="14" width="108" height="104" rx="24" fill="#26a269" />
<!-- Main face (GNOME Green 4) -->
<rect x="10" y="10" width="108" height="104" rx="24" fill="#2ec27e" />
<!-- Driftwood symbol -->
<g transform="translate(-8, -10) scale(1.44)">
<path fill="none" stroke="white" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M52.104,56.311c0-3.775,3.061-6.836,6.836-6.836c3.777,0,6.836,3.061,6.836,6.836 M62.293,55.785c0-1.652-1.34-2.99-2.994-2.99c-1.65,0-2.988,1.338-2.988,2.99 M48.948,56.311h19.983 M59.789,46.319l-13.996-1.577c-3.155-1.053-4.207-7.363-4.207-7.363l-3.154,2.104c0,0,2.103,4.207-1.053,4.207l-5.784-0.525c-3.194,0-5.784,2.59-5.784,5.785l23.137,7.361c0-5.519,4.474-9.991,9.991-9.991c5.52,0,9.992,4.473,9.992,9.991 M45.793,50l-6.311-3.154l-9.465-1.053 M35.276,48.949L39.482,50 M37.379,59.466l10.518,3.155h10.517 M74.189,56.311H80.5l-8.414-3.155 M64.725,59.466H53.156 M31.068,57.362l-6.31-2.104 M19.5,50l18.932,6.311" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="15 33 70 34">
<path fill="none" stroke="#241f31" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M52.104,56.311c0-3.775,3.061-6.836,6.836-6.836c3.777,0,6.836,3.061,6.836,6.836 M62.293,55.785c0-1.652-1.34-2.99-2.994-2.99c-1.65,0-2.988,1.338-2.988,2.99 M48.948,56.311h19.983 M59.789,46.319l-13.996-1.577c-3.155-1.053-4.207-7.363-4.207-7.363l-3.154,2.104c0,0,2.103,4.207-1.053,4.207l-5.784-0.525c-3.194,0-5.784,2.59-5.784,5.785l23.137,7.361c0-5.519,4.474-9.991,9.991-9.991c5.52,0,9.992,4.473,9.992,9.991 M45.793,50l-6.311-3.154l-9.465-1.053 M35.276,48.949L39.482,50 M37.379,59.466l10.518,3.155h10.517 M74.189,56.311H80.5l-8.414-3.155 M64.725,59.466H53.156 M31.068,57.362l-6.31-2.104 M19.5,50l18.932,6.311" />
</svg>

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -2,7 +2,7 @@ project(
'driftwood', 'driftwood',
'rust', 'rust',
version: '0.1.0', version: '0.1.0',
license: 'GPL-3.0-or-later', license: 'CC0-1.0',
meson_version: '>= 0.62.0', meson_version: '>= 0.62.0',
) )
@@ -28,12 +28,25 @@ install_data(
install_dir: datadir / 'metainfo', 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 # Compile and install GSettings schema
install_data( install_data(
'data' / app_id + '.gschema.xml', 'data' / app_id + '.gschema.xml',
install_dir: datadir / 'glib-2.0' / 'schemas', 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 # Build the Rust binary via Cargo
cargo = find_program('cargo') cargo = find_program('cargo')

View File

@@ -29,6 +29,8 @@ mkdir -p "$APPDIR/usr/bin"
mkdir -p "$APPDIR/usr/share/applications" mkdir -p "$APPDIR/usr/share/applications"
mkdir -p "$APPDIR/usr/share/glib-2.0/schemas" 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/scalable/apps"
mkdir -p "$APPDIR/usr/share/icons/hicolor/symbolic/apps"
mkdir -p "$APPDIR/usr/share/metainfo"
# Binary # Binary
cp "target/release/driftwood" "$APPDIR/usr/bin/driftwood" 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" 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/" glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas/"
# Icon - use a placeholder SVG if no real icon exists yet # Icon
ICON_FILE="data/icons/$APP_ID.svg" ICON_FILE="data/icons/hicolor/scalable/apps/$APP_ID.svg"
if [ -f "$ICON_FILE" ]; then if [ -f "$ICON_FILE" ]; then
cp "$ICON_FILE" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" cp "$ICON_FILE" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg"
else else
@@ -56,6 +58,18 @@ else
SVGEOF SVGEOF
fi 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 # Check for linuxdeploy
LINUXDEPLOY="${LINUXDEPLOY:-linuxdeploy}" LINUXDEPLOY="${LINUXDEPLOY:-linuxdeploy}"
if ! command -v "$LINUXDEPLOY" &>/dev/null; then if ! command -v "$LINUXDEPLOY" &>/dev/null; then
@@ -77,20 +91,20 @@ if ! command -v "$LINUXDEPLOY" &>/dev/null; then
fi fi
# Check for GTK plugin # Check for GTK plugin
GTK_PLUGIN="${LINUXDEPLOY_PLUGIN_GTK:-}" GTK_PLUGIN_ARG=""
if [ -z "$GTK_PLUGIN" ]; then if [ -x "./linuxdeploy-plugin-gtk.sh" ]; then
if [ -x "./linuxdeploy-plugin-gtk.sh" ]; then export DEPLOY_GTK_VERSION=4
export DEPLOY_GTK_VERSION=4 GTK_PLUGIN_ARG="--plugin gtk"
else echo "GTK4 plugin found - libraries will be bundled."
echo "" else
echo "Warning: linuxdeploy-plugin-gtk not found." echo ""
echo "GTK4 libraries will not be bundled." echo "Warning: linuxdeploy-plugin-gtk not found."
echo "The AppImage may only work on systems with GTK4 and libadwaita installed." echo "GTK4 libraries will not be bundled."
echo "" echo "The AppImage may only work on systems with GTK4 and libadwaita installed."
echo "Download the plugin from:" echo ""
echo " https://github.com/nickvdp/linuxdeploy-plugin-gtk" echo "Download the plugin from:"
echo "" echo " https://github.com/linuxdeploy/linuxdeploy-plugin-gtk"
fi echo ""
fi fi
echo "=== Building AppImage ===" echo "=== Building AppImage ==="
@@ -102,6 +116,7 @@ export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas"
--appdir "$APPDIR" \ --appdir "$APPDIR" \
--desktop-file "$APPDIR/usr/share/applications/$APP_ID.desktop" \ --desktop-file "$APPDIR/usr/share/applications/$APP_ID.desktop" \
--icon-file "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" \ --icon-file "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" \
$GTK_PLUGIN_ARG \
--output appimage --output appimage
echo "" echo ""

View File

@@ -3,7 +3,7 @@ use adw::subclass::prelude::*;
use gtk::gio; use gtk::gio;
use std::cell::OnceCell; use std::cell::OnceCell;
use crate::config::{APP_ID, VERSION}; use crate::config::APP_ID;
use crate::window::DriftwoodWindow; use crate::window::DriftwoodWindow;
mod imp { mod imp {
@@ -118,28 +118,8 @@ impl DriftwoodApplication {
}) })
.build(); .build();
// About action self.add_action_entries([quit_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.set_accels_for_action("app.quit", &["<Control>q"]); self.set_accels_for_action("app.quit", &["<Control>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());
}
} }

View File

@@ -184,7 +184,6 @@ impl DriftwoodWindow {
let section3 = gio::Menu::new(); let section3 = gio::Menu::new();
section3.append(Some(&i18n("Preferences")), Some("win.preferences")); section3.append(Some(&i18n("Preferences")), Some("win.preferences"));
section3.append(Some(&i18n("Keyboard Shortcuts")), Some("win.show-shortcuts")); section3.append(Some(&i18n("Keyboard Shortcuts")), Some("win.show-shortcuts"));
section3.append(Some(&i18n("About Driftwood")), Some("app.about"));
menu.append_section(None, &section3); menu.append_section(None, &section3);
// Library view (contains header bar, search, grid/list, empty state) // Library view (contains header bar, search, grid/list, empty state)

View File

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