Add app icons, screenshots, and complete AppStream metainfo

- Add GNOME HIG-compliant app icon (scalable SVG) and symbolic variant
- Add 12 screenshots covering all major views and features
- Flesh out metainfo with screenshots, categories, URLs, content
  rating, system requirements, provides, translation, donation
  and contact links
- Update AppImage build script to bundle GTK plugin, symbolic
  icon, and metainfo
- Update meson.build with icon installation rules
- Remove About dialog from application menu
- Remove unused user guide and audit tool
This commit is contained in:
lashman
2026-03-01 14:46:41 +02:00
parent 09ef0f48e0
commit 6beca34f70
21 changed files with 159 additions and 1265 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

@@ -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 <path> # Show AppImage metadata
driftwood integrate <path> # Create .desktop file and icon
driftwood remove <path> # Remove desktop integration
driftwood launch <path> # Launch with tracking
driftwood launch --sandbox <path> # Launch in Firejail
driftwood check-updates # Check all for updates
driftwood duplicates # Find duplicates
driftwood security # Scan all for CVEs
driftwood security <path> # Scan one for CVEs
driftwood footprint <path> # 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

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