Compare commits

...

13 Commits

Author SHA1 Message Date
lashman
6beca34f70 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
2026-03-01 14:46:41 +02:00
lashman
09ef0f48e0 Rewrite README with better header and project framing 2026-03-01 13:12:45 +02:00
lashman
58d8b44866 Rewrite README with comprehensive feature documentation 2026-03-01 12:54:55 +02:00
lashman
99f60b8529 Change license to CC0-1.0 2026-03-01 12:49:17 +02:00
lashman
da9568df61 Remove archived plan documents from tracking
Moved to .trash/ for reference - no longer needed in source tree.
2026-03-01 12:44:35 +02:00
lashman
7e55d5796f Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool
- Bring all UI widgets to WCAG 2.2 AAA conformance across all views
- Add accessible labels, roles, descriptions, and announcements
- Bump focus outlines to 3px, target sizes to 44px AAA minimum
- Fix announce()/announce_result() to walk widget tree via parent()
- Add AT-SPI accessibility audit script (tools/a11y-audit.py) that
  checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8,
  2.4.9, 2.4.10, 2.1.3 with JSON report output for CI
- Clean up project structure, archive old plan documents
2026-03-01 12:44:21 +02:00
lashman
abb69dc753 Add tags, export/import, and changelog features
- Tag editor in detail view with add/remove pill chips
- Tag filter chips in library view for filtering by tag
- Shared backup module for app list export/import (JSON v2)
- CLI export/import refactored to use shared module
- GUI export/import via file picker dialogs in hamburger menu
- GitHub release history enrichment for catalog apps
- Changelog preview in updates view with expandable rows
- DB migration v19 for catalog release_history column
2026-03-01 01:01:43 +02:00
lashman
79519c500a Add design doc for tags, export/import, and changelog improvements 2026-03-01 00:47:14 +02:00
lashman
d11546efc6 Add UX enhancements: carousel, filter chips, command palette, and more
- Replace featured section Stack with AdwCarousel + indicator dots
- Convert category grid to horizontal scrollable filter chips
- Add grid/list view toggle for catalog with compact row layout
- Add quick launch button on library list rows
- Add stale catalog banner when data is older than 7 days
- Add command palette (Ctrl+K) for quick app search and launch
- Show specific app names in update notifications
- Add per-app auto-update toggle (skip updates switch)
- Add keyboard shortcut hints to button tooltips
- Add source trust badges (AppImageHub/Community) on catalog tiles
- Add undo-based uninstall with toast and record restoration
- Add type-to-search in library view
- Use human-readable catalog source labels
- Show Launch button for installed apps in catalog detail
- Replace external browser link with inline AppImage explainer dialog
2026-03-01 00:39:43 +02:00
lashman
4b939f044a Add AppImageHub.com OCS API as primary catalog source
Integrate the Pling/OCS REST API (appimagehub.com) as the primary catalog
source with richer metadata than the existing appimage.github.io feed.

Backend:
- Add OCS API fetch with pagination, lenient JSON deserializers for loosely
  typed numeric fields, and non-AppImage file filtering (.dmg, .exe, etc.)
- Database migration v17 adds OCS-specific columns (ocs_id, downloads, score,
  typename, personid, description, summary, version, tags, etc.)
- Deduplicate secondary source apps against OCS entries
- Shrink OCS CDN icon URLs from 770x540 to 100x100 for faster loading
- Clear stale screenshot and icon caches on sync
- Extract GitHub repo links from OCS HTML descriptions
- Add fetch_ocs_download_files() to get all version files for an app
- Resolve fresh JWT download URLs per slot at install time

Detail page:
- Fetch OCS download files on page open and populate install SplitButton
  with version dropdown (newest first, filtered for AppImage only)
- Show OCS metadata: downloads, score, author, typename, tags, comments,
  created/updated dates, architecture, filename, file size, MD5
- Prefer ocs_description (full HTML with features/changelog) over short
  summary for the About section
- Add html_to_description() to preserve formatting (lists, paragraphs)
- Remove redundant Download link from Links section
- Escape ampersands in Pango markup subtitles (categories, typename, tags)

Catalog view:
- OCS source syncs first as primary, appimage.github.io as secondary
- Featured apps consider OCS download counts alongside GitHub stars

UI:
- Add pulldown-cmark for GitHub README markdown rendering in detail pages
- Add build_markdown_view() widget for rendered markdown content
2026-02-28 20:33:40 +02:00
lashman
f89aafca6a Add GitHub metadata enrichment for catalog apps
Enrich catalog apps with GitHub API data (stars, version, downloads,
release date) via two strategies: background drip for repo-level info
and on-demand fetch when opening a detail page.

- Add github_enrichment module with API calls, asset filtering, and
  architecture auto-detection for AppImage downloads
- DB migrations v14/v15 for GitHub metadata and release asset columns
- Extract github_owner/repo from feed links during catalog sync
- Display colored stat cards (stars, version, downloads, released) on
  detail pages with on-demand enrichment
- Show stars and version on browse tiles and featured carousel cards
- Replace install button with SplitButton dropdown when multiple arch
  assets available, preferring detected architecture
- Disable install button until enrichment completes to prevent stale
  AppImageHub URL downloads
- Keep enrichment banner visible on catalog page until truly complete,
  showing paused state when rate-limited
- Add GitHub token and auto-enrich toggle to preferences
2026-02-28 16:49:13 +02:00
lashman
92c51dc39e Add Launch and Uninstall buttons to detail view header 2026-02-28 01:56:21 +02:00
lashman
86b047572a Use relative time for update_checked in detail view 2026-02-28 01:54:51 +02:00
70 changed files with 9635 additions and 11437 deletions

41
Cargo.lock generated
View File

@@ -559,6 +559,7 @@ dependencies = [
"log", "log",
"notify", "notify",
"notify-rust", "notify-rust",
"pulldown-cmark",
"quick-xml", "quick-xml",
"rusqlite", "rusqlite",
"serde", "serde",
@@ -876,6 +877,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1804,6 +1814,25 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags 2.11.0",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -2335,12 +2364,24 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"

View File

@@ -2,7 +2,7 @@
name = "driftwood" name = "driftwood"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "CC0-1.0"
[dependencies] [dependencies]
gtk = { version = "0.11", package = "gtk4", features = ["v4_16"] } gtk = { version = "0.11", package = "gtk4", features = ["v4_16"] }
@@ -51,5 +51,8 @@ notify-rust = "4"
# File system watching (inotify) # File system watching (inotify)
notify = "7" notify = "7"
# Markdown parsing (for GitHub README rendering)
pulldown-cmark = "0.12"
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"

121
LICENSE Normal file
View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

481
README.md
View File

@@ -1,100 +1,437 @@
# Driftwood <div align="center">
A modern GTK4/libadwaita AppImage manager for GNOME desktops. # :evergreen_tree: Driftwood
Driftwood discovers, inspects, integrates, updates, and audits AppImage files **Your apps. Your computer. No middleman.**
with a clean GNOME-native interface built for the Wayland era.
## Features A friendly, modern AppImage manager for Linux desktops -
built by the community, owned by no one.
- **Library management** - Scan directories to discover AppImages, view them in <br />
grid or list mode with status badges for FUSE, Wayland, and update status
- **Desktop integration** - Create .desktop files and install icons with one click
- **FUSE and Wayland detection** - Automatically detect compatibility and suggest
launch methods (direct, extract-and-run, or sandboxed)
- **Update checking** - Read embedded update information (GitHub Releases, GitLab,
zsync) and check for newer versions
- **Security scanning** - Extract bundled shared libraries and check them against
the OSV.dev vulnerability database
- **Duplicate detection** - Find AppImages that are different versions of the same
app or identical files in different locations
- **Disk footprint analysis** - Discover config, data, and cache files associated
with each AppImage
- **Sandboxing** - Optional Firejail sandbox support per-app
- **Orphan cleanup** - Detect and remove .desktop files for AppImages that no
longer exist
- **CLI interface** - Full command-line access to all core features
## Requirements ![License: CC0-1.0](https://img.shields.io/badge/license-CC0--1.0-bb4444?style=flat-square)
![Built with Rust](https://img.shields.io/badge/built%20with-Rust-d4652a?style=flat-square)
![GTK4 + libadwaita](https://img.shields.io/badge/GTK4-libadwaita-4a86cf?style=flat-square)
![WCAG 2.2 AAA](https://img.shields.io/badge/WCAG%202.2-AAA-228833?style=flat-square)
![No Telemetry](https://img.shields.io/badge/telemetry-none-778899?style=flat-square)
- GTK 4.16+ <br />
- libadwaita 1.6+
- SQLite 3
- gettext
Optional: *No accounts. No tracking. No data harvested. No profit motive.*
- firejail (for sandboxed launches) *Just a tool that helps you manage your apps, and gets out of the way.*
- fuse2/fuse3 (for AppImage FUSE mounting)
- appimageupdate (for delta updates)
## Building from source </div>
---
## :sparkles: What is Driftwood?
**Driftwood is a graphical app manager for AppImages.** AppImages are portable
Linux applications that come as a single file - no installation needed, no
package manager required, no root access, no corporate app store standing between
you and the software you want to run.
But managing AppImages by hand can be confusing. Making them show up in your app
menu, checking for updates, knowing if they're safe - that's a lot of work,
especially if you're new to Linux.
Driftwood handles all of that for you with a clean, easy-to-use interface. It
runs entirely on your machine - nothing is sent anywhere, nothing is collected,
nothing is monetized.
---
## :raising_hand: Who is this for?
- **People new to Linux** who want a simple, welcoming way to manage their apps
- **Anyone who downloads AppImages** and wants them organized, updated, and
integrated into their desktop without friction
- **People who care about privacy** and want to know exactly what their apps
contain and whether any bundled libraries have known security issues
- **People who believe software should be accessible to everyone** - Driftwood
meets WCAG 2.2 AAA accessibility standards
- **Power users** who want a full command-line interface alongside the GUI
---
## :framed_picture: What does it look like?
Driftwood uses GTK4 and libadwaita, so it looks and feels like a native GNOME
app. It has three main views:
| View | What it does |
|------|-------------|
| **Installed** | Shows all your AppImages in a grid or list with status badges |
| **Catalog** | Browse and discover new AppImage apps from the community |
| **Updates** | See which apps have newer versions and update them |
Click any app to see its full details - version info, security status, disk
usage, screenshots, and more.
---
## :star2: Features
### :package: App Library
- **Automatic scanning** - Driftwood finds all AppImage files in your chosen
folders (like `~/Applications` or `~/Downloads`)
- **Grid and list views** - See your apps as cards with icons, or as a detailed
list - whichever you prefer
- **Search and sort** - Find apps by name, sort by name, size, or when you added
them
- **Tags** - Label your apps with custom tags and filter by them
- **Selection mode** - Select multiple apps at once for batch actions
- **Drag and drop** - Drop an AppImage file onto the window to add it
### :globe_with_meridians: App Catalog
- **Browse community apps** - Discover AppImage apps shared by developers and
communities - no walled garden, no curation fees, no gatekeeping
- **Category filters** - Browse by Audio, Games, Graphics, Development, and more
- **Featured apps** - A rotating carousel of highlighted apps
- **Search** - Find apps by name across the entire catalog
- **App details** - See descriptions, screenshots, star counts, and download
numbers
- **One-click install** - Download and add apps directly from the catalog
### :arrow_up: Updates
- **Check for updates** - Scan all your apps for newer versions with one click
- **Update all at once** - Or update apps individually, your choice
- **Release notes** - See what changed in each new version before you decide
- **Automatic backups** - Optionally save old versions before updating, so you
can roll back if something breaks
- **Multiple update sources** - Works with GitHub Releases, GitLab Releases,
zsync delta updates, and OCS feeds
### :rocket: Desktop Integration
- **Add to app menu** - Make any AppImage show up in your desktop's application
menu with one click
- **Icon installation** - Automatically extracts and installs the app's icon
- **Autostart** - Set apps to launch automatically when you log in
- **Custom launch options** - Add command-line arguments or environment variables
per app
- **Fully reversible** - Remove any app from your menu just as easily as you
added it. Every change Driftwood makes to your system is tracked and can be
undone
### :shield: Security
Your apps shouldn't be a black box. Driftwood helps you see what's inside.
- **Vulnerability scanning** - Extracts the shared libraries bundled inside each
AppImage and checks them against the OSV.dev vulnerability database
- **CVE details** - Shows the severity (Critical, High, Medium, Low), CVSS
scores, and which library versions are affected
- **Security report** - Generate a full report of all your apps and export it as
HTML, JSON, or CSV
- **Desktop notifications** - Get notified when a new vulnerability is found in
one of your apps
- **Signature verification** - Check GPG signatures and SHA256 checksums
- **Configurable thresholds** - Choose whether to be notified about only critical
issues or everything
### :jigsaw: FUSE and Wayland Compatibility
- **FUSE detection** - Checks whether your system can mount AppImages and tells
you exactly what to install if it can't
- **Guided FUSE setup** - A step-by-step wizard that shows you the right install
command for your distribution - no searching forums, no guesswork
- **Wayland compatibility** - Detects whether you're running Wayland or X11 and
flags any compatibility concerns
- **Fallback launching** - If FUSE isn't available, Driftwood can extract and run
AppImages directly - you're never locked out of your own software
### :broom: Cleanup and Disk Management
- **Duplicate finder** - Detects when you have multiple copies of the same app
or different versions taking up space, and shows how much you'd save
- **Disk footprint** - Shows config, cache, and data folders each app has created
on your system - no hidden clutter
- **Cleanup wizard** - A guided walkthrough that finds orphaned desktop entries,
leftover caches, and duplicates, then lets you choose what to remove
- **Orphan cleanup** - Finds and removes menu entries for AppImages that no
longer exist on disk
### :bar_chart: Dashboard
- **System overview** - A quick health check showing FUSE status, Wayland status,
total apps, storage used, and apps needing updates
- **At-a-glance stats** - Everything about your AppImage setup in one place
### :gear: Preferences
- **Appearance** - Follow your system's light/dark theme, or pick one
- **Scan directories** - Choose which folders Driftwood watches for AppImages
- **Update settings** - Configure automatic update checking, backup behavior,
and how long to keep old versions
- **Security settings** - Enable automatic scanning, notifications, and set a
GitHub token for higher API rate limits
- **Catalog settings** - Toggle automatic metadata enrichment and removable media
watching
### :lock: Sandboxing
- **Firejail support** - Launch apps inside a Firejail sandbox for extra
isolation
- **Per-app profiles** - Each app can have its own sandbox configuration
- **Profile management** - Use local, community, or default Firejail profiles
### :wheelchair: Accessibility
Software that only works for some people doesn't really work. Driftwood is built
to meet WCAG 2.2 AAA accessibility standards:
- **Full keyboard navigation** - Every single feature is reachable without a
mouse
- **Screen reader support** - All buttons, images, and status changes are
announced to screen readers like Orca
- **Focus management** - Clear, visible focus indicators (3px outlines) on all
interactive elements
- **44px minimum target sizes** - All buttons meet the AAA touch/click target
standard
- **High contrast support** - Enhanced borders and colors when your system uses
high contrast mode
- **No timing requirements** - Nothing in the app requires fast reactions or
races against a clock
- **Semantic roles** - Headings, alerts, status regions, and live announcements
are all properly marked up for assistive technology
- **Automated testing** - Ships with an AT-SPI audit tool that walks the live
accessibility tree and checks for violations
### :keyboard: Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+1` | Switch to Installed view |
| `Ctrl+2` | Switch to Catalog view |
| `Ctrl+3` | Switch to Updates view |
| `Ctrl+F` | Search |
| `Ctrl+R` or `F5` | Scan for AppImages |
| `Ctrl+U` | Check for updates |
| `Ctrl+K` | Open command palette |
| `Ctrl+D` | Open dashboard |
| `Ctrl+,` | Open preferences |
| `Ctrl+?` | Show keyboard shortcuts |
| `Ctrl+Q` | Quit |
### :desktop_computer: Command Line Interface
Every feature is also available from the terminal. The GUI and CLI share the same
database, so they always stay in sync.
```sh ```sh
# Development build (uses cargo directly) driftwood scan # Find AppImages in your configured folders
driftwood list # Show all known AppImages
driftwood list --format json # Output as JSON
driftwood inspect ~/Apps/MyApp.AppImage # Show app metadata
driftwood integrate ~/Apps/MyApp.AppImage # Add to desktop menu
driftwood remove ~/Apps/MyApp.AppImage # Remove from desktop menu
driftwood launch ~/Apps/MyApp.AppImage # Run the app
driftwood launch --sandbox ~/Apps/MyApp.AppImage # Run in sandbox
driftwood check-updates # Check all apps for updates
driftwood update-all # Update everything
driftwood verify ~/Apps/MyApp.AppImage # Check integrity
driftwood verify ~/Apps/MyApp.AppImage --sha256 abc123... # Verify hash
driftwood security # Scan all apps for vulnerabilities
driftwood duplicates # Find duplicate AppImages
driftwood footprint ~/Apps/MyApp.AppImage # Show disk usage
driftwood status # System compatibility check
driftwood clean-orphans # Remove stale menu entries
driftwood export # Export app list to JSON
driftwood export --output ~/backup.json
driftwood import ~/backup.json # Import app list
driftwood autostart ~/Apps/MyApp.AppImage --enable # Autostart on login
driftwood autostart ~/Apps/MyApp.AppImage --disable
driftwood purge # Remove ALL Driftwood system modifications
```
---
## :penguin: Which Linux distributions does it work on?
Driftwood works on any Linux distribution that has GTK 4.16+ and libadwaita 1.6+.
In practice, that means most modern distributions released from 2024 onward.
| Distribution | Status | Notes |
|-------------|--------|-------|
| **Ubuntu 24.04+** | :white_check_mark: Works | GTK4 and libadwaita in default repos |
| **Fedora 40+** | :white_check_mark: Works | Excellent GTK4 support out of the box |
| **Arch Linux** | :white_check_mark: Works | Rolling release, always has the latest |
| **Manjaro** | :white_check_mark: Works | Same packages as Arch |
| **openSUSE Tumbleweed** | :white_check_mark: Works | Rolling release with current GTK4 |
| **Debian Testing/Sid** | :white_check_mark: Works | Stable may have older GTK versions |
| **Linux Mint 22+** | :white_check_mark: Works | Based on Ubuntu 24.04 |
| **Pop!_OS 24.04+** | :white_check_mark: Works | Based on Ubuntu |
| **elementary OS 8+** | :white_check_mark: Works | GTK4-based Pantheon desktop |
| **Gentoo** | :white_check_mark: Works | Build from source |
| **Alpine** | :white_check_mark: Works | GTK4 in community repos |
| **NixOS** | :white_check_mark: Works | Package GTK4 + libadwaita via Nix |
**Desktop environment**: Driftwood is designed for GNOME but works on any desktop
that supports GTK4 applications (KDE Plasma, Cinnamon, XFCE, Budgie, Sway, etc.).
It looks best on GNOME and GNOME-based desktops because it uses libadwaita for
styling.
---
## :wrench: System Requirements
### What you need
| Requirement | Version | What it is |
|------------|---------|-----------|
| **GTK** | 4.16 or newer | The toolkit that draws the interface |
| **libadwaita** | 1.6 or newer | GNOME's design library for modern-looking apps |
| **SQLite** | 3 | A small, local database engine (stores your app library) |
| **gettext** | any | Handles translations for different languages |
### Optional (but recommended)
| Package | What it does |
|---------|-------------|
| **libfuse2** or **libfuse3** | Lets AppImages mount themselves (most AppImages need this to run) |
| **firejail** | Runs apps in a security sandbox for extra isolation |
| **appimageupdate** | Enables fast delta updates (downloads only what changed, not the whole file) |
### For building from source
| Package | What it is |
|---------|-----------|
| **Rust 1.75+** and **Cargo** | The programming language and build tool |
| **Meson** | Build system (only for system-wide installation) |
| **libgtk-4-dev** | GTK4 development headers |
| **libadwaita-1-dev** | libadwaita development headers |
| **libsqlite3-dev** | SQLite development headers |
| **libglib2.0-dev-bin** | Provides `glib-compile-resources` and `glib-compile-schemas` |
> Package names vary between distributions. On Fedora, use `-devel` instead
> of `-dev` (for example, `gtk4-devel` instead of `libgtk-4-dev`). On Arch,
> the packages are `gtk4`, `libadwaita`, and `sqlite`.
---
## :building_construction: Building from Source
### Quick development build
```sh
git clone https://git.lashman.live/lashman/driftwood.git
cd driftwood
cargo build cargo build
cargo run cargo run
```
# System installation (uses meson) ### System-wide installation
```sh
meson setup build --prefix=/usr meson setup build --prefix=/usr
meson compile -C build meson compile -C build
sudo meson install -C build sudo meson install -C build
``` ```
## CLI usage After installing, Driftwood will appear in your application menu.
---
## :package: Packaging
Packaging files are included so anyone can build packages for their distribution:
| Format | Location | Notes |
|--------|----------|-------|
| **Flatpak** | `build-aux/app.driftwood.Driftwood.json` | Flatpak manifest |
| **Arch Linux (AUR)** | `packaging/PKGBUILD` | For makepkg |
| **AppImage** | `packaging/build-appimage.sh` | Build script |
If you package Driftwood for a distribution not listed here, let us know - we'd
love to include it.
---
## :open_file_folder: Where Driftwood Stores Things
Driftwood keeps everything local. Nothing leaves your machine.
| What | Location |
|------|----------|
| App database | `~/.local/share/driftwood/driftwood.db` |
| Cached icons | `~/.local/share/driftwood/icons/` |
| Desktop entries | `~/.local/share/applications/driftwood-*.desktop` |
| Installed icons | `~/.local/share/icons/hicolor/` |
| Autostart entries | `~/.config/autostart/` |
| Settings | GSettings (`app.driftwood.Driftwood`) |
| Backups | `~/.local/share/driftwood/backups/` |
Every modification Driftwood makes to your system is recorded in its database.
You can undo all of it at any time with `driftwood purge` or through the UI.
Your system, your rules.
---
## :test_tube: Accessibility Testing
Driftwood ships with an automated accessibility audit tool that uses AT-SPI (the
Linux accessibility bus) to walk the live widget tree and check for WCAG 2.2
violations:
```sh ```sh
# Scan configured directories for AppImages # Full AAA-level audit (builds and launches automatically)
driftwood scan python3 tools/a11y-audit.py --level aaa
# List all known AppImages # Attach to an already-running instance
driftwood list python3 tools/a11y-audit.py --no-launch --level aaa
driftwood list --format json
# Inspect a specific AppImage # AA level only
driftwood inspect ~/Applications/Firefox.AppImage python3 tools/a11y-audit.py --level aa
# Integrate into desktop menu # Verbose output with live region inventory
driftwood integrate ~/Applications/Firefox.AppImage python3 tools/a11y-audit.py --level aaa --verbose
# Check for updates
driftwood check-updates
# Run a security scan
driftwood security
driftwood security ~/Applications/Firefox.AppImage
# Launch with tracking
driftwood launch ~/Applications/Firefox.AppImage
driftwood launch --sandbox ~/Applications/Firefox.AppImage
# Find duplicates
driftwood duplicates
# Show disk footprint
driftwood footprint ~/Applications/Firefox.AppImage
# System status
driftwood status
# Clean orphaned entries
driftwood clean-orphans
``` ```
## Packaging The tool checks for:
- **Flatpak**: See `build-aux/app.driftwood.Driftwood.json` - Interactive widgets without accessible names (SC 4.1.2)
- **Arch Linux (AUR)**: See `packaging/PKGBUILD` - Images without alt text or decorative marking (SC 1.1.1)
- Headings without level attributes (SC 1.3.1)
- Widgets that aren't keyboard-focusable (SC 2.1.1)
- Target sizes below 24px (AA) and 44px (AAA)
- Windows without titles (SC 2.4.8)
- Vague link text (SC 2.4.9)
- Content regions without headings (SC 2.4.10)
- Keyboard traps (SC 2.1.3)
## License Results are printed to the terminal and saved as `a11y-report.json` for CI
integration.
GPL-3.0-or-later ---
## :raised_hands: Contributing
Driftwood is a community project. There's no company behind it, no investors,
no roadmap driven by profit - just people building something useful together.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute code,
translations, bug reports, or ideas. Every contribution matters, and everyone
who helps is a co-owner of this project.
---
## :page_facing_up: License
**CC0-1.0 - Public Domain**
Driftwood belongs to everyone. It is released into the public domain under the
[CC0 1.0 Universal](LICENSE) license. You can copy, modify, distribute, and use
it for any purpose - personal, educational, commercial, anything - without
asking permission, without paying anyone, without restrictions.
Knowledge and tools should be shared freely. This is ours, and it's yours.

View File

@@ -36,7 +36,7 @@
<choice value='recently-added'/> <choice value='recently-added'/>
<choice value='size'/> <choice value='size'/>
</choices> </choices>
<default>'name'</default> <default>'recently-added'</default>
<summary>Library sort mode</summary> <summary>Library sort mode</summary>
<description>How to sort the library: name, recently-added, or size.</description> <description>How to sort the library: name, recently-added, or size.</description>
</key> </key>
@@ -142,5 +142,15 @@
<summary>Watch removable media</summary> <summary>Watch removable media</summary>
<description>Scan removable drives for AppImages when mounted.</description> <description>Scan removable drives for AppImages when mounted.</description>
</key> </key>
<key name="github-token" type="s">
<default>''</default>
<summary>GitHub personal access token</summary>
<description>Optional GitHub token for higher API rate limits (5,000 vs 60 requests per hour).</description>
</key>
<key name="catalog-auto-enrich" type="b">
<default>true</default>
<summary>Auto-enrich catalog apps</summary>
<description>Automatically fetch GitHub metadata (stars, version, downloads) for catalog apps in the background.</description>
</key>
</schema> </schema>
</schemalist> </schemalist>

View File

@@ -2,7 +2,7 @@
<component type="desktop-application"> <component type="desktop-application">
<id>app.driftwood.Driftwood</id> <id>app.driftwood.Driftwood</id>
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license> <project_license>CC0-1.0</project_license>
<name>Driftwood</name> <name>Driftwood</name>
<summary>Modern AppImage manager for GNOME desktops</summary> <summary>Modern AppImage manager for GNOME desktops</summary>
@@ -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

View File

@@ -81,7 +81,7 @@
.drop-zone-icon { .drop-zone-icon {
color: @accent_bg_color; color: @accent_bg_color;
opacity: 0.7; opacity: 0.85;
} }
/* ===== Card View (using libadwaita .card) ===== */ /* ===== Card View (using libadwaita .card) ===== */
@@ -90,17 +90,42 @@
} }
flowboxchild:focus-visible .card { flowboxchild:focus-visible .card {
outline: 2px solid @accent_bg_color; outline: 3px solid @accent_bg_color;
outline-offset: 3px; outline-offset: 3px;
} }
/* ===== Selection Mode Highlight ===== */
flowboxchild.selected .card {
outline: 3px solid @accent_bg_color;
outline-offset: -3px;
background: alpha(@accent_bg_color, 0.12);
}
row.selected {
background: alpha(@accent_bg_color, 0.15);
outline: 3px solid @accent_bg_color;
outline-offset: -3px;
}
@media (prefers-contrast: more) {
flowboxchild.selected .card {
outline-width: 4px;
background: alpha(@accent_bg_color, 0.2);
}
row.selected {
outline-width: 4px;
background: alpha(@accent_bg_color, 0.25);
}
}
/* App card status indicators */ /* App card status indicators */
.status-ok { .status-ok {
border: 1px solid alpha(@success_bg_color, 0.4); border: 1px solid alpha(@success_bg_color, 0.6);
} }
.status-attention { .status-attention {
border: 1px solid alpha(@warning_bg_color, 0.4); border: 1px dashed alpha(@warning_bg_color, 0.6);
} }
/* Rounded icon clipping for list view */ /* Rounded icon clipping for list view */
@@ -108,7 +133,7 @@ flowboxchild:focus-visible .card {
border-radius: 8px; border-radius: 8px;
} }
/* ===== WCAG AAA Focus Indicators ===== */ /* ===== WCAG AAA Focus Indicators (3px for enhanced visibility) ===== */
button:focus-visible, button:focus-visible,
togglebutton:focus-visible, togglebutton:focus-visible,
menubutton:focus-visible, menubutton:focus-visible,
@@ -117,13 +142,13 @@ switch:focus-visible,
entry:focus-visible, entry:focus-visible,
searchentry:focus-visible, searchentry:focus-visible,
spinbutton:focus-visible { spinbutton:focus-visible {
outline: 2px solid @accent_bg_color; outline: 3px solid @accent_bg_color;
outline-offset: 2px; outline-offset: 2px;
} }
row:focus-visible { row:focus-visible {
outline: 2px solid @accent_bg_color; outline: 3px solid @accent_bg_color;
outline-offset: -2px; outline-offset: 2px;
} }
/* Letter-circle fallback icon */ /* Letter-circle fallback icon */
@@ -139,8 +164,8 @@ row:focus-visible {
color: @success_fg_color; color: @success_fg_color;
border-radius: 50%; border-radius: 50%;
padding: 2px; padding: 2px;
min-width: 16px; min-width: 24px;
min-height: 16px; min-height: 24px;
} }
/* ===== Detail View Banner ===== */ /* ===== Detail View Banner ===== */
@@ -157,10 +182,10 @@ row:focus-visible {
/* ===== Compatibility Warning Banner ===== */ /* ===== Compatibility Warning Banner ===== */
.compat-warning-banner { .compat-warning-banner {
background: alpha(@warning_bg_color, 0.15); background: alpha(@warning_bg_color, 0.22);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
border: 1px solid alpha(@warning_bg_color, 0.3); border: 1px solid alpha(@warning_bg_color, 0.5);
} }
/* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */ /* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */
@@ -168,11 +193,171 @@ row:focus-visible {
Reduced motion is handled by the GTK toolkit settings instead Reduced motion is handled by the GTK toolkit settings instead
(gtk-enable-animations). */ (gtk-enable-animations). */
/* ===== Minimum Target Size (WCAG 2.5.8) ===== */ /* ===== Minimum Target Size (WCAG 2.5.5 AAA - 44x44px) ===== */
button.flat.circular, button.flat.circular,
button.flat:not(.pill):not(.suggested-action):not(.destructive-action) { button.flat:not(.pill):not(.suggested-action):not(.destructive-action) {
min-width: 24px; min-width: 44px;
min-height: 24px; min-height: 44px;
}
/* Accessible icon button minimum target size (WCAG 2.5.5 AAA) */
.accessible-icon-btn {
min-width: 44px;
min-height: 44px;
}
/* Header bar buttons: ensure icon-only buttons meet AAA 44px target */
headerbar button.flat,
headerbar button.image-button,
headerbar menubutton > button,
headerbar splitbutton > button,
headerbar splitbutton > menubutton > button {
min-width: 44px;
min-height: 44px;
}
/* ===== Category Filter Tiles ===== */
.category-tile {
padding: 14px 18px;
min-height: 48px;
border-radius: 12px;
border: none;
font-weight: 600;
font-size: 0.9em;
color: white;
}
.category-tile image {
color: white;
opacity: 0.9;
}
/* Colored backgrounds per category - darkened for WCAG AAA 7:1 contrast with white text */
.cat-accent { background: color-mix(in srgb, @accent_bg_color 80%, black 20%); }
.cat-purple { background: color-mix(in srgb, @purple_3 75%, black 25%); }
.cat-red { background: color-mix(in srgb, @red_3 70%, black 30%); }
.cat-green { background: color-mix(in srgb, @success_bg_color 65%, black 35%); }
.cat-orange { background: color-mix(in srgb, @orange_3 75%, black 25%); }
.cat-blue { background: color-mix(in srgb, @blue_3 70%, black 30%); }
.cat-amber { background: color-mix(in srgb, @warning_bg_color 70%, black 30%); }
.cat-neutral { background: color-mix(in srgb, @window_fg_color 55%, @window_bg_color 45%); }
.cat-teal { background: color-mix(in srgb, color-mix(in srgb, @blue_3 50%, @success_bg_color 50%) 70%, black 30%); }
.cat-brown { background: color-mix(in srgb, color-mix(in srgb, @orange_3 60%, @red_3 40%) 60%, black 40%); }
.cat-lime { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 70%, @warning_bg_color 30%) 65%, black 35%); }
.cat-slate { background: color-mix(in srgb, color-mix(in srgb, @blue_3 30%, @window_fg_color 70%) 45%, black 55%); }
.cat-pink { background: color-mix(in srgb, color-mix(in srgb, @red_3 50%, @purple_3 50%) 75%, black 25%); }
.cat-emerald { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 80%, @blue_3 20%) 70%, black 30%); }
.cat-crimson { background: color-mix(in srgb, @red_3 60%, black 40%); }
.cat-indigo { background: color-mix(in srgb, color-mix(in srgb, @blue_3 70%, @purple_3 30%) 70%, black 30%); }
.cat-coral { background: color-mix(in srgb, color-mix(in srgb, @red_3 60%, @orange_3 40%) 75%, black 25%); }
.cat-violet { background: color-mix(in srgb, color-mix(in srgb, @purple_3 70%, @red_3 30%) 70%, black 30%); }
.cat-mint { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 60%, @blue_3 40%) 65%, black 35%); }
/* Hover: intensify the background */
.cat-accent:hover { background: color-mix(in srgb, @accent_bg_color 95%, black 5%); }
.cat-purple:hover { background: color-mix(in srgb, @purple_3 90%, black 10%); }
.cat-red:hover { background: color-mix(in srgb, @red_3 85%, black 15%); }
.cat-green:hover { background: color-mix(in srgb, @success_bg_color 80%, black 20%); }
.cat-orange:hover { background: color-mix(in srgb, @orange_3 90%, black 10%); }
.cat-blue:hover { background: color-mix(in srgb, @blue_3 85%, black 15%); }
.cat-amber:hover { background: color-mix(in srgb, @warning_bg_color 85%, black 15%); }
.cat-neutral:hover { background: color-mix(in srgb, @window_fg_color 65%, @window_bg_color 35%); }
.cat-teal:hover { background: color-mix(in srgb, color-mix(in srgb, @blue_3 50%, @success_bg_color 50%) 85%, black 15%); }
.cat-brown:hover { background: color-mix(in srgb, color-mix(in srgb, @orange_3 60%, @red_3 40%) 75%, black 25%); }
.cat-lime:hover { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 70%, @warning_bg_color 30%) 80%, black 20%); }
.cat-slate:hover { background: color-mix(in srgb, color-mix(in srgb, @blue_3 30%, @window_fg_color 70%) 60%, black 40%); }
.cat-pink:hover { background: color-mix(in srgb, color-mix(in srgb, @red_3 50%, @purple_3 50%) 90%, black 10%); }
.cat-emerald:hover { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 80%, @blue_3 20%) 85%, black 15%); }
.cat-crimson:hover { background: color-mix(in srgb, @red_3 75%, black 25%); }
.cat-indigo:hover { background: color-mix(in srgb, color-mix(in srgb, @blue_3 70%, @purple_3 30%) 85%, black 15%); }
.cat-coral:hover { background: color-mix(in srgb, color-mix(in srgb, @red_3 60%, @orange_3 40%) 90%, black 10%); }
.cat-violet:hover { background: color-mix(in srgb, color-mix(in srgb, @purple_3 70%, @red_3 30%) 85%, black 15%); }
.cat-mint:hover { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 60%, @blue_3 40%) 80%, black 20%); }
/* Checked: slightly darkened for WCAG AAA contrast with white text */
.cat-accent:checked { background: color-mix(in srgb, @accent_bg_color 85%, black 15%); }
.cat-purple:checked { background: color-mix(in srgb, @purple_3 85%, black 15%); }
.cat-red:checked { background: color-mix(in srgb, @red_3 85%, black 15%); }
.cat-green:checked { background: color-mix(in srgb, @success_bg_color 85%, black 15%); }
.cat-orange:checked { background: color-mix(in srgb, @orange_3 85%, black 15%); }
.cat-blue:checked { background: color-mix(in srgb, @blue_3 85%, black 15%); }
.cat-amber:checked { background: color-mix(in srgb, @warning_bg_color 85%, black 15%); }
.cat-neutral:checked { background: alpha(@window_fg_color, 0.45); }
.cat-teal:checked { background: color-mix(in srgb, color-mix(in srgb, @blue_3 50%, @success_bg_color 50%) 85%, black 15%); }
.cat-brown:checked { background: color-mix(in srgb, color-mix(in srgb, @orange_3 60%, @red_3 40%) 85%, black 15%); }
.cat-lime:checked { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 70%, @warning_bg_color 30%) 85%, black 15%); }
.cat-slate:checked { background: color-mix(in srgb, color-mix(in srgb, @blue_3 30%, @window_fg_color 70%) 85%, black 15%); }
.cat-pink:checked { background: color-mix(in srgb, color-mix(in srgb, @red_3 50%, @purple_3 50%) 85%, black 15%); }
.cat-emerald:checked { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 80%, @blue_3 20%) 85%, black 15%); }
.cat-crimson:checked { background: color-mix(in srgb, @red_3 75%, black 25%); }
.cat-indigo:checked { background: color-mix(in srgb, color-mix(in srgb, @blue_3 70%, @purple_3 30%) 85%, black 15%); }
.cat-coral:checked { background: color-mix(in srgb, color-mix(in srgb, @red_3 60%, @orange_3 40%) 85%, black 15%); }
.cat-violet:checked { background: color-mix(in srgb, color-mix(in srgb, @purple_3 70%, @red_3 30%) 85%, black 15%); }
.cat-mint:checked { background: color-mix(in srgb, color-mix(in srgb, @success_bg_color 60%, @blue_3 40%) 85%, black 15%); }
/* Focus indicator on the tile itself */
flowboxchild:focus-visible .category-tile {
outline: 3px solid @accent_bg_color;
outline-offset: 2px;
}
/* Focus indicators for catalog items */
.catalog-tile:focus-visible,
.catalog-featured-card:focus-visible {
outline: 3px solid @accent_bg_color;
outline-offset: 2px;
}
.destructive-context-item:focus-visible {
outline: 3px solid @accent_bg_color;
outline-offset: 2px;
}
/* ===== Catalog Tile Cards ===== */
.catalog-tile {
border: 1px solid alpha(@window_fg_color, 0.12);
border-radius: 12px;
}
.catalog-tile:hover {
border-color: alpha(@accent_bg_color, 0.5);
}
/* ===== Featured Banner Cards ===== */
.catalog-featured-card {
border-radius: 12px;
border: 1px solid alpha(@window_fg_color, 0.15);
padding: 0;
}
.catalog-featured-card:hover {
border-color: alpha(@accent_bg_color, 0.5);
}
/* Screenshot area inside featured card */
.catalog-featured-screenshot {
border-radius: 11px 11px 0 0;
border: none;
background: alpha(@window_fg_color, 0.04);
}
.catalog-featured-screenshot picture {
border-radius: 11px 11px 0 0;
}
/* ===== Destructive Context Menu Item ===== */
.destructive-context-item {
color: @error_fg_color;
background: alpha(@error_bg_color, 0.85);
border: none;
box-shadow: none;
padding: 6px 12px;
border-radius: 6px;
min-height: 28px;
}
.destructive-context-item:hover {
background: @error_bg_color;
} }
/* ===== Screenshot Lightbox ===== */ /* ===== Screenshot Lightbox ===== */
@@ -182,7 +367,7 @@ window.lightbox {
} }
window.lightbox .lightbox-counter { window.lightbox .lightbox-counter {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.78);
color: white; color: white;
border-radius: 12px; border-radius: 12px;
padding: 4px 12px; padding: 4px 12px;
@@ -193,3 +378,164 @@ window.lightbox .lightbox-nav {
min-width: 48px; min-width: 48px;
min-height: 48px; min-height: 48px;
} }
/* ===== Catalog Tile Stats Row ===== */
.catalog-stats-row {
font-size: 0.8em;
color: alpha(@window_fg_color, 0.87);
}
.catalog-stats-row image {
opacity: 0.85;
}
/* ===== Detail Page Stat Cards ===== */
.stat-card {
background: alpha(@window_fg_color, 0.06);
border-radius: 12px;
padding: 14px 16px;
border: 1px solid alpha(@window_fg_color, 0.08);
}
.stat-card.stat-stars {
background: alpha(@warning_bg_color, 0.12);
border-color: alpha(@warning_bg_color, 0.2);
}
.stat-card.stat-stars image {
color: @warning_bg_color;
opacity: 0.85;
}
.stat-card.stat-version {
background: alpha(@accent_bg_color, 0.1);
border-color: alpha(@accent_bg_color, 0.18);
}
.stat-card.stat-version image {
color: @accent_bg_color;
opacity: 0.85;
}
.stat-card.stat-downloads {
background: alpha(@success_bg_color, 0.1);
border-color: alpha(@success_bg_color, 0.18);
}
.stat-card.stat-downloads image {
color: @success_bg_color;
opacity: 0.85;
}
.stat-card.stat-released {
background: alpha(@purple_3, 0.12);
border-color: alpha(@purple_3, 0.2);
}
.stat-card.stat-released image {
color: @purple_3;
opacity: 0.85;
}
.stat-card .stat-value {
font-weight: 700;
font-size: 1.15em;
}
.stat-card .stat-label {
font-size: 0.8em;
color: alpha(@window_fg_color, 0.87);
}
.stat-card image {
opacity: 0.78;
}
/* ===== Catalog Row (compact list view) ===== */
.catalog-row {
border: 1px solid alpha(@window_fg_color, 0.08);
border-radius: 8px;
padding: 0;
}
.catalog-row:hover {
border-color: alpha(@accent_bg_color, 0.4);
}
/* ===== Skeleton Loading Placeholder ===== */
.skeleton-card {
background: alpha(@card_bg_color, 0.5);
border-radius: 12px;
min-height: 180px;
min-width: 140px;
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
/* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */
@media (prefers-reduced-motion: reduce) {
.skeleton-card {
animation: none;
opacity: 0.5;
}
}
/* ===== High Contrast Mode (WCAG AAA) ===== */
@media (prefers-contrast: more) {
.catalog-tile,
.catalog-featured-card,
.catalog-row {
border-width: 2px;
}
.stat-card {
border-width: 2px;
background: alpha(@window_fg_color, 0.1);
}
.status-badge,
.status-badge-with-icon {
border: 1px solid currentColor;
}
.drop-zone-card {
border-width: 3px;
}
.status-ok,
.status-attention {
border-width: 2px;
}
.category-tile {
border: 2px solid white;
}
.stat-card .stat-label {
color: @window_fg_color;
}
.stat-card image {
opacity: 1.0;
}
.compat-warning-banner {
border-width: 2px;
}
window.lightbox .lightbox-counter {
background: rgba(0, 0, 0, 0.9);
}
.catalog-stats-row image {
opacity: 1.0;
}
.category-tile image {
opacity: 1.0;
}
}

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

File diff suppressed because it is too large Load Diff

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

@@ -1,31 +0,0 @@
# 20 Improvements Plan
## Batch 1: Low-risk code quality (no behavior change)
1. Wrap all hardcoded English strings in i18n()
2. Replace OnceCell.get().expect() with safe getters
3. Extract common async-toast-refresh helper
4. Log silently swallowed errors
## Batch 2: Performance
6. Async database initialization with loading screen
7. Batch CSS provider registration for letter-circle icons
8. Lazy-load detail view tabs
18. Rate-limit background analysis spawns
## Batch 3: UX
9. Progress indicator during background analysis
10. Multi-file drop and file picker support
12. Sort options in library view
15. Keyboard shortcut Ctrl+O for Add app
17. Validate scan directories exist before scanning
## Batch 4: Robustness
5. Add database migration tests
13. Confirmation before closing during active analysis
16. Graceful handling of corrupt/locked database
## Batch 5: Accessibility & Features
11. Remember detail view active tab
14. Announce analysis completion to screen readers
19. Custom launch arguments
20. Export/import app library

View File

@@ -1,183 +0,0 @@
# AppImage Comprehensive Metadata Extraction and Display
## Goal
Extract ALL available metadata from AppImage files - from the oldest Type 1 format to the newest Type 2 with AppStream XML - and display it comprehensively in the overview tab of the detail view.
## Background: All AppImage Metadata Sources
### 1. ELF Binary Header (Type 1 and Type 2)
- Magic bytes at offset 8: `AI\x01` (Type 1) or `AI\x02` (Type 2)
- Architecture from `e_machine` at offset 18
### 2. Type 1: ISO 9660 Volume Descriptor
- Update info at fixed offset 33651 (512 bytes)
### 3. Type 2: ELF Sections
- `.upd_info` (1024 bytes) - update transport (zsync, GitHub releases, etc.)
- `.sha256_sig` (1024 bytes) - GPG digital signature
- `.sig_key` (8192 bytes) - public key for signature verification
### 4. Desktop Entry File (.desktop)
Standard freedesktop fields:
- `Name`, `GenericName`, `Comment`, `Icon`, `Exec`, `Categories`
- `Keywords`, `MimeType`, `StartupWMClass`, `Terminal`
- `Actions` with `[Desktop Action <name>]` sections
- AppImage-specific: `X-AppImage-Version`, `X-AppImage-Name`, `X-AppImage-Arch`
### 5. AppStream / AppData XML (richest source)
Located at `usr/share/metainfo/*.xml` or `usr/share/appdata/*.xml`:
- `<id>` - reverse-DNS identifier
- `<name>` - localized app name
- `<summary>` - one-line description
- `<description>` - full rich-text description
- `<developer>` - developer/organization
- `<project_license>` - SPDX license
- `<project_group>` - umbrella project (GNOME, KDE, etc.)
- `<url type="...">` - homepage, bugtracker, donation, help, vcs-browser, contribute
- `<keywords>` - search terms
- `<categories>` - menu categories
- `<screenshots>` - screenshot URLs with captions
- `<releases>` - version history with dates and changelogs
- `<content_rating type="oars-1.1">` - age rating
- `<provides>` - MIME types, binaries, D-Bus interfaces
- `<branding>` - theme colors
### 6. Icons (already handled)
- `.DirIcon`, root-level PNG/SVG, `usr/share/icons/` hierarchy
### 7. Digital Signatures (Type 2)
- GPG signature in `.sha256_sig` ELF section
- Verifiable with embedded public key
## Approach
Parse everything at analysis time and store in the database (Approach A). This matches the existing architecture where `run_background_analysis()` populates the DB and the UI reads from `AppImageRecord`.
## Database Schema (Migration v9)
16 new columns on `appimages`:
| Column | Type | Default | Description |
|--------|------|---------|-------------|
| `appstream_id` | TEXT | NULL | Reverse-DNS ID (e.g. `org.kde.krita`) |
| `appstream_description` | TEXT | NULL | Rich description (paragraphs joined with newlines) |
| `generic_name` | TEXT | NULL | Generic descriptor ("Web Browser") |
| `license` | TEXT | NULL | SPDX license expression |
| `homepage_url` | TEXT | NULL | Project website |
| `bugtracker_url` | TEXT | NULL | Bug reporting URL |
| `donation_url` | TEXT | NULL | Donation page |
| `help_url` | TEXT | NULL | Documentation URL |
| `vcs_url` | TEXT | NULL | Source code URL |
| `keywords` | TEXT | NULL | Comma-separated keywords |
| `mime_types` | TEXT | NULL | Semicolon-separated MIME types |
| `content_rating` | TEXT | NULL | Summarized OARS rating |
| `project_group` | TEXT | NULL | Umbrella project |
| `release_history` | TEXT | NULL | JSON array of recent releases |
| `desktop_actions` | TEXT | NULL | JSON array of desktop actions |
| `has_signature` | INTEGER | 0 | Whether AppImage has GPG signature |
## New Module: AppStream XML Parser
**File:** `src/core/appstream.rs`
Uses `quick-xml` crate (pure Rust, lightweight).
Key types:
```
AppStreamMetadata
id: Option<String>
name: Option<String>
summary: Option<String>
description: Option<String>
developer: Option<String>
project_license: Option<String>
project_group: Option<String>
urls: HashMap<String, String>
keywords: Vec<String>
categories: Vec<String>
content_rating_summary: Option<String>
releases: Vec<ReleaseInfo>
mime_types: Vec<String>
ReleaseInfo
version: String
date: Option<String>
description: Option<String>
```
Parser function: `parse_appstream_file(path: &Path) -> Option<AppStreamMetadata>`
- Walks XML events, extracts all fields
- Strips HTML from `<description>` (joins `<p>` with newlines, `<li>` with bullets)
- Caps releases at 10 most recent
- Summarizes OARS content rating into a single label
## Extended Desktop Entry Parsing
Update `DesktopEntryFields` in `inspector.rs`:
- Add `generic_name`, `keywords`, `mime_types`, `terminal`, `actions`, `x_appimage_name`
- Parse `[Desktop Action <name>]` sections for action names and exec commands
## Inspector + Analysis Pipeline
1. `AppImageMetadata` struct gains all new fields
2. `inspect_appimage()` looks for AppStream XML after extraction, parses it, merges into metadata (AppStream takes priority for overlapping fields)
3. `run_background_analysis()` stores new fields via `db.update_appstream_metadata()`
4. Signature detection: read ELF `.sha256_sig` section, check if non-empty
## Overview Tab UI Layout
Groups in order (each only shown when data exists):
### About (new)
- App ID, Generic name, Developer, License, Project group
### Description (new)
- Full multi-paragraph AppStream description
### Links (new)
- Homepage, Bug tracker, Source code, Documentation, Donate
- Each row clickable via `gtk::UriLauncher`
### Updates (existing, unchanged)
### Release History (new)
- Recent releases with version, date, description
- Uses `adw::ExpanderRow` for entries with descriptions
### Usage (existing, unchanged)
### Capabilities (new)
- Keywords, MIME types, Content rating, Desktop actions
### File Information (existing, extended)
- Add "Signature: Signed / Not signed" row
## Dependencies
Add to `Cargo.toml`:
```toml
quick-xml = "0.37"
```
## Files Modified
| File | Changes |
|------|---------|
| `Cargo.toml` | Add `quick-xml` |
| `src/core/mod.rs` | Add `pub mod appstream;` |
| `src/core/appstream.rs` | **New** - AppStream XML parser |
| `src/core/database.rs` | Migration v9, new columns, `update_appstream_metadata()` |
| `src/core/inspector.rs` | Extended desktop parsing, AppStream integration, signature detection |
| `src/core/analysis.rs` | Store new metadata fields |
| `src/ui/detail_view.rs` | Redesigned overview tab with all new groups |
## Verification
1. `cargo build` compiles without errors
2. AppImages with AppStream XML show full metadata (developer, license, URLs, releases)
3. AppImages without AppStream XML still show desktop entry fields (graceful degradation)
4. URL links open in browser
5. Release history is scrollable/expandable
6. Empty groups are hidden
7. Re-scanning an app picks up newly available metadata

File diff suppressed because it is too large Load Diff

View File

@@ -1,107 +0,0 @@
# Audit Fixes Design
## Goal
Fix all 29 findings from the full codebase audit, organized by severity tier with build verification between tiers.
## Approach
Fix by severity tier (Critical -> High -> Medium -> Low). Run `cargo build` after each tier to catch regressions early.
## Tier 1: Critical (5 items)
### #1 - security.rs: Fix unsquashfs argument order
`detect_version_from_binary` passes `appimage_path` after the extract pattern. unsquashfs expects the archive before patterns. Move `appimage_path` before the file pattern, remove the `-e` flag.
### #2 - integrator.rs: Quote Exec path in .desktop files
`Exec={exec} %U` breaks for paths with spaces. Change to `Exec="{exec}" %U`.
### #3 - duplicates.rs: Fix compare_versions total order
`compare_versions("1.0", "v1.0")` returns `Less` both ways (violates antisymmetry). Use `clean_version()` on both inputs for the equality check.
### #4 - inspector.rs: Chunk-based signature detection
`detect_signature` reads entire files (1.5GB+) into memory. Replace with `BufReader` reading 64KB chunks, scanning each for the signature bytes.
### #5 - updater.rs: Read only first 12 bytes in verify_appimage
Replace `fs::read(path)` with `File::open` + `read_exact` for just the ELF/AI magic bytes.
## Tier 2: High (6 items)
### #6 - database.rs: Handle NULL severity in CVE summaries
`get_cve_summary` and `get_all_cve_summary` fail on NULL severity. Change to `Option<String>`, default `None` to `"MEDIUM"`.
### #7 - inspector.rs: Fix deadlock in extract_metadata_files
Piped stderr + `.status()` can deadlock. Change to `Stdio::null()` since we don't use stderr.
### #8 - updater.rs: Fix glob_match edge case
After matching the last part with `ends_with`, reduce the search text before checking middle parts.
### #9 - backup.rs: Prevent archive filename collisions
Use relative paths from home directory instead of bare filenames, so two dirs with the same leaf name don't collide.
### #10 - launcher.rs: Async crash detection
Remove the 1.5s blocking sleep from `execute_appimage`. Return `Started` immediately with the `Child`. Callers (already async) handle crash detection by polling the child after a delay.
### #11 - launcher.rs: Drop stderr pipe on success
After returning `Started`, either drop `child.stderr` or use `Stdio::null()` for stderr to prevent pipe buffer deadlock on long-running apps.
## Tier 3: Medium (9 items)
### #12 - window.rs: Gate scan on auto-scan-on-startup
Wrap `self.trigger_scan()` in `if self.settings().boolean("auto-scan-on-startup")`.
### #13 - window.rs: Fix window size persistence
Change `self.default_size()` to `(self.width(), self.height())`.
### #14 - widgets.rs: Fix announce() for any container
Change `announce()` to not require a `gtk::Box` - use a more generic approach or fix callers to pass the correct widget type.
### #15 - detail_view.rs: Claim gesture in lightbox
Add `gesture.set_state(gtk::EventSequenceState::Claimed)` in the picture click handler.
### #16 - cli.rs: Use serde_json for JSON output
Replace hand-crafted `format!` JSON with `serde_json::json!()`.
### #17 - style.css: Remove dead @media blocks
Delete `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)` blocks. libadwaita named colors already adapt.
### #18 - gschema.xml + detail_view.rs: Wire detail-tab persistence
Save active tab on switch, restore on open.
### #19 - metainfo.xml: Remove invalid categories
Delete `<categories>` block (already in .desktop file, invalid in metainfo per AppStream spec).
### #20 - fuse.rs: Byte-level search
Replace `String::from_utf8_lossy().to_lowercase()` with direct byte-level case-insensitive search using `windows()`.
## Tier 4: Low (9 items)
### #21 - wayland.rs: Tighten env var detection
Remove `WAYLAND_DISPLAY` from fallback heuristic. Keep only `GDK_BACKEND` and `QT_QPA_PLATFORM`.
### #22 - inspector.rs: Add ELF magic validation
Check `\x7fELF` magic and endianness byte before parsing `e_machine`.
### #23 - updater.rs: Add timeout to extract_update_info_runtime
Add 5-second timeout to prevent indefinite blocking.
### #24 - launcher.rs: Handle quoted args
Use a shell-like tokenizer that respects double-quoted strings in `parse_launch_args`.
### #25 - (merged with #20)
### #26 - window.rs: Stop watcher timer on window destroy
Return `glib::ControlFlow::Break` when `window_weak.upgrade()` returns `None`.
### #27 - gschema.xml: Add choices/range constraints
Add `<choices>` to enumerated string keys, `<range>` to backup-retention-days.
### #28 - style.css: Remove unused CSS classes
Delete `.quick-action-pill`, `.badge-row`, `.detail-view-switcher`, base `.letter-icon`.
### #29 - style.css/app_card.rs: Fix status-ok/status-attention
Define CSS rules for these classes or remove the class additions from code.
## Verification
After each tier: `cargo build` with zero errors and zero warnings. After all tiers: manual app launch test.

File diff suppressed because it is too large Load Diff

View File

@@ -1,127 +0,0 @@
# Beginner-Friendly Copy Overhaul - Design
**Goal:** Rewrite all technical jargon in the detail view into plain language that newcomers can understand, while keeping technical details available in tooltips.
**Approach:** Friendly titles/subtitles for everyone, technical details on hover. Terminal commands stay copyable but with softer framing ("Install with one command" instead of showing the raw command as a subtitle).
**Scope:** detail_view.rs (all 4 tabs), security_report.rs, fuse_user_explanation(), wayland_user_explanation()
---
## Overview Tab
### About section
- "SPDX license identifier for this application" tooltip -> "The license that governs how this app can be used and shared."
- "Project group" title -> "Project"
- "Bug tracker" link label -> "Report a problem"
### File Information section
- "AppImage format" title -> "Package format"
- "Type 1 (ISO 9660) - older format, still widely supported" -> "Type 1 - older format, still widely supported"
- "Type 2 (SquashFS) - modern format, most common today" -> "Type 2 - modern, compressed format"
- Tooltip rewrite: "AppImages come in two formats. Type 1 is the older format. Type 2 is the current standard - it uses compression for smaller files and faster loading."
- "Executable" title -> "Ready to run"
- "Yes - this file has execute permission" -> "Yes - this file is ready to launch"
- "No - execute permission is missing. It will be set automatically when launched." -> "No - will be fixed automatically when launched"
- Tooltip: "Whether the file has the permissions needed to run. If not, Driftwood sets this up automatically the first time you launch it."
- "This AppImage contains a GPG signature" subtitle -> "Signed by the developer"
- Signature tooltip: "This app was signed by its developer, which helps verify it hasn't been tampered with since it was published."
- "Last scanned" -> "Last checked"
### Capabilities section
- Section title: "Capabilities" -> "Features"
- "Desktop actions" -> "Quick actions"
- Tooltip: "Additional actions available from the right-click menu when this app is added to your app menu."
- Content rating tooltip: "An age rating for the app's content, similar to game ratings."
### Updates section
- Tooltip about zsync/delta updates -> "Driftwood can check for newer versions of this app automatically. The developer has included information about where updates are published."
---
## System Tab
### Desktop Integration section
- Section title: "Desktop Integration" -> "App Menu"
- Description -> "Add this app to your launcher so you can find it like any other installed app."
- Switch subtitle: "Creates a .desktop entry and installs the app icon" -> "Creates a shortcut and installs the app icon"
- Switch tooltip: "This makes the app appear in your Activities menu and app launcher, just like a regular installed app. It creates a shortcut file and copies the app's icon to your system."
- "Desktop file" row title -> "Shortcut file"
### Compatibility section
- Description -> "How well this app works with your system. Most issues can be fixed with a quick install."
- "Wayland display" -> "Display compatibility"
- Wayland tooltip: "Wayland is the modern display system on Linux. Apps built for the older system (X11) still work, but native Wayland apps look sharper, especially on high-resolution screens."
- "Analyze toolkit" -> "Detect app framework"
- Subtitle: "Inspect bundled libraries to detect which UI toolkit this app uses" -> "Check which technology this app is built with"
- Tooltip: "Apps are built with different frameworks (like GTK, Qt, or Electron). Knowing the framework helps predict how well the app works with your display system."
- Post-analysis subtitle: "Detected: {toolkit} ({count} libraries scanned)" -> "Built with: {toolkit}"
- Error subtitle: "Analysis failed - the AppImage may not be mountable" -> "Analysis failed - could not read the app's contents"
- "Last observed protocol" -> "Last display mode"
- Tooltip: "How the app connected to your display the last time it was launched."
- "FUSE (filesystem)" -> "App mounting"
- FUSE tooltip: "FUSE lets apps like AppImages run directly without unpacking first. Without it, apps still work but take a little longer to start."
- "Launch method" -> "Startup method"
- Launch tooltip: "AppImages can start two ways: mounting (fast, instant startup) or unpacking to a temporary folder first (slower, but works everywhere). The method is chosen automatically based on your system."
### Wayland explanations (wayland_user_explanation)
- Native: "Fully compatible - the best experience on your system."
- XWayland: "Works through a compatibility layer. May appear slightly blurry on high-resolution screens."
- Possible: "Might work well. Try launching it to find out."
- X11Only: "Built for an older display system. It will run automatically, but you may notice minor visual quirks."
- Unknown: "Not yet determined. Launch the app or use 'Detect app framework' to check."
### FUSE explanations (fuse_user_explanation)
- FullyFunctional: "Everything is set up - apps start instantly."
- Fuse3Only: "A small system component is missing. Most apps will still work, but some may need it. Copy the install command to fix this."
- NoFusermount: "A system component is missing, so apps will take a little longer to start. They'll still work fine."
- NoDevFuse: "Your system doesn't support instant app mounting. Apps will unpack before starting, which takes a bit longer."
- MissingLibfuse2: "A small system component is needed for fast startup. Copy the install command to fix this."
### Sandboxing section
- Section title: "Sandboxing" -> "App Isolation"
- Description -> "Restrict what this app can access on your system for extra security."
- Switch title: "Firejail sandbox" -> "Isolate this app"
- Switch tooltip: "Sandboxing restricts what an app can access - files, network, devices, etc. Even if an app has a security issue, it can't freely access your personal data."
- Subtitle when available: "Isolate this app using Firejail. Current mode: {mode}" -> "Currently: {mode}"
- Subtitle when missing: "Firejail is not installed. Use the row below to copy the install command." -> "Not available yet. Install with one command using the button below."
- Install row: "Install Firejail" / "sudo apt install firejail" -> "Install app isolation" / "Install with one command"
---
## Security Tab
### Vulnerability Scanning section
- Section title: "Vulnerability Scanning" -> "Security Check"
- Description -> "Check this app for known security issues."
- "Bundled libraries" -> "Included components"
- Subtitle: "{N} libraries detected inside this AppImage" -> "{N} components found inside this app"
- Tooltip: "Apps bundle their own copies of system components (libraries). These can sometimes contain known security issues if they're outdated."
- Clean subtitle: "No known security issues found in the bundled libraries." -> "No known security issues found."
- Scan row: "Run security scan" / "Check bundled libraries against known CVE databases" -> "Run security check" / "Check for known security issues in this app"
- Scan tooltip: "This checks the components inside this app against a public database of known security issues to see if any are outdated or vulnerable."
- Error: "Scan failed - the AppImage may not be mountable" -> "Check failed - could not read the app's contents"
### Integrity section
- "SHA256 checksum" -> "File fingerprint"
- Tooltip: "A unique code (SHA256 checksum) generated from the file's contents. If the file changes in any way, this code changes too. You can compare it against the developer's published fingerprint to verify nothing was altered."
---
## Storage Tab
- Group description: "Config, cache, and data directories this app may have created." -> "Settings, cache, and data this app may have saved to your system."
- Search subtitle: "Search for config, cache, and data directories" -> "Search for files this app has saved"
- Empty result: "No associated data directories found" -> "No saved data found"
- "Path" -> "File location"
---
## Security Report page (security_report.rs)
- "Run a security scan to check bundled libraries for known vulnerabilities." -> "Run a security check to look for known issues in your apps."
- "No known vulnerabilities found in any bundled libraries." -> "No known security issues found in any of your apps."
- "Overall security status across all AppImages" -> "Overall security status across all your apps"
- Tooltip: "Common Vulnerabilities and Exposures found in bundled libraries" -> "Known security issues found in the components bundled inside your apps."
- Per-app description: "{N} CVE (vulnerability) records found" -> "{N} known security issues found"
- Individual CVE/library expander titles: keep as-is (technical detail layer)

View File

@@ -1,679 +0,0 @@
# Driftwood Feature Roadmap - Design Document
**Goal:** Add 26 features to make Driftwood the definitive AppImage manager for Linux newcomers coming from Windows, covering the full lifecycle from discovery to clean uninstall.
**Architecture:** All features build on the existing GTK4/libadwaita/Rust stack. Every system modification (desktop files, icons, MIME associations, autostart entries) is tracked in a central `system_modifications` table and fully reversed on app removal. Features are ordered from simplest to most complex.
**Tech Stack:** Rust, gtk4-rs, libadwaita-rs, rusqlite, gio, notify crate, pkexec/polkit for privileged operations, XDG specs (Desktop Entry, Autostart, MIME Applications), AppImage feed.json catalog.
---
## Core Architecture: System Modification Tracking
Every feature that touches system files MUST use this tracking system. No exceptions.
### New Database Table
```sql
CREATE TABLE IF NOT EXISTS system_modifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
mod_type TEXT NOT NULL,
file_path TEXT NOT NULL,
previous_value TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_system_mods_appimage
ON system_modifications(appimage_id);
```
**mod_type values:** `desktop_file`, `icon`, `autostart`, `mime_default`, `mime_entry`, `system_desktop`, `system_icon`, `system_binary`
### Core Functions (src/core/integrator.rs)
```rust
pub fn register_modification(db: &Database, appimage_id: i64, mod_type: &str, file_path: &str, previous_value: Option<&str>) -> Result<()>
pub fn undo_modification(db: &Database, mod_id: i64) -> Result<()>
pub fn undo_all_modifications(db: &Database, appimage_id: i64) -> Result<()>
```
### Uninstall Flow
1. Query `system_modifications` for the appimage_id
2. For each modification (in reverse order):
- `desktop_file` / `autostart` / `icon`: delete the file
- `mime_default`: restore previous_value via `xdg-mime default {previous_value} {mime_type}`
- `system_*`: delete via `pkexec rm`
3. Run `update-desktop-database ~/.local/share/applications/`
4. Run `gtk-update-icon-cache ~/.local/share/icons/hicolor/` (if icons were removed)
5. Optionally delete app data paths (with user confirmation)
6. Delete the AppImage file
7. Delete DB record (CASCADE handles system_modifications rows)
### CLI Purge Command
`driftwood purge` - removes ALL system modifications for ALL managed apps, for when Driftwood itself is being removed from the system.
---
## Feature Specifications (Sorted: Easiest to Most Complex)
---
### F1. Auto-Set Executable Permission on Existing Files
**Complexity:** Trivial (15 min)
**Problem:** Files already in scan directories skip chmod during drag-and-drop. Non-executable AppImages found during scan aren't fixed.
**Files:** `src/ui/drop_dialog.rs`, `src/core/discovery.rs`
**Changes:**
- `drop_dialog.rs`: In the `in_scan_dir` branch of `register_dropped_files()`, add permission check and fix:
```rust
if !std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 != 0 {
std::fs::set_permissions(&final_path, std::fs::Permissions::from_mode(0o755))?;
}
```
- `discovery.rs`: After scan finds a non-executable AppImage, auto-fix permissions and log it
---
### F2. Move-or-Copy Option for Drag-and-Drop (+ Keep in Place)
**Complexity:** Easy (30 min)
**Problem:** Always copies file, wasting disk. No option to keep in place.
**Files:** `src/ui/drop_dialog.rs`
**Changes:**
- Replace 3-button dialog with 4 response options:
- `"cancel"` - Cancel
- `"keep-in-place"` - Keep in place (register at current location, set executable, no copy)
- `"copy-only"` - Copy to Applications (current "Just add" behavior)
- `"copy-and-integrate"` - Copy & add to menu (current "Add to app menu", suggested/default)
- `register_dropped_files()` receives a new `copy_mode` enum: `KeepInPlace`, `CopyOnly`, `CopyAndIntegrate`
- `KeepInPlace`: set executable on original path, register in DB at original location, optionally integrate
- Dialog text updated: "Where should this AppImage live?"
---
### F3. Version Rollback
**Complexity:** Easy (1 hr)
**Problem:** No way to go back if an update breaks things.
**Files:** `src/core/updater.rs`, `src/ui/detail_view.rs`, `src/core/database.rs`
**Changes:**
- New DB column: `previous_version_path TEXT` on appimages
- In `updater.rs` update flow: before replacing, rename old to `{path}.prev` and store path in `previous_version_path`
- Detail view system tab: "Rollback to previous version" button (visible only when `previous_version_path` is set)
- Rollback: swap current and .prev files, update DB fields, re-run analysis
- Cleanup: delete .prev file when user explicitly confirms or after configurable retention
---
### F4. Source Tracking
**Complexity:** Easy (1 hr)
**Problem:** Users can't tell where an AppImage was downloaded from.
**Files:** `src/ui/detail_view.rs`, `src/core/database.rs`, `src/ui/drop_dialog.rs`
**Changes:**
- New DB column: `source_url TEXT` on appimages
- Auto-detect from `update_info` field (already stored): parse GitHub/GitLab URLs
- Display "Source: github.com/obsidianmd/obsidian" on detail view overview tab
- Drop dialog: optional "Where did you download this?" text field (pre-filled if detected)
- Catalog installs (F26): automatically set source_url from catalog entry
---
### F5. Launch Statistics Dashboard
**Complexity:** Easy (1-2 hrs)
**Problem:** Launch data is tracked but never shown.
**Files:** `src/ui/dashboard.rs`, `src/core/database.rs`
**Changes:**
- New DB queries:
- `get_top_launched(limit: i32) -> Vec<(String, u64)>` - most launched apps
- `get_launch_count_since(since: &str) -> u64` - total launches since date
- `get_recent_launches(limit: i32) -> Vec<(String, String)>` - recent launch events with timestamps
- Dashboard: new "Activity" section showing:
- Top 5 most-launched apps with launch counts
- "X launches this week" summary stat
- "Last launched: AppName, 2 hours ago"
---
### F6. Batch Operations
**Complexity:** Medium (2-3 hrs)
**Problem:** Can't select multiple AppImages for bulk actions.
**Files:** `src/ui/library_view.rs`, `src/ui/app_card.rs`, `src/window.rs`
**Changes:**
- Library view header: "Select" toggle button
- When active: app cards show checkboxes, bottom action bar slides up
- Action bar buttons: "Integrate" / "Remove Integration" / "Delete" / "Export"
- Each action confirms with count: "Integrate 5 AppImages?"
- Delete uses the full uninstall flow (F14/system_modifications cleanup)
- Selection state stored in a `HashSet<i64>` of record IDs on the LibraryView
---
### F7. Automatic Desktop Integration on Scan
**Complexity:** Easy (30 min)
**Problem:** Users forget to integrate after scanning.
**Files:** `src/window.rs`, `src/ui/preferences.rs`
**Changes:**
- GSettings key `auto-integrate` already exists (default false)
- Wire it up: after scan completes in `window.rs`, if setting is true, iterate newly discovered apps and call `integrator::integrate()` for each
- Register all created files via `register_modification()`
- Preferences: add toggle in Behavior page (already may be there, verify)
---
### F8. Autostart Manager
**Complexity:** Medium (2 hrs)
**Problem:** No way to set AppImages to start at login.
**Spec:** XDG Autostart - `.desktop` file in `~/.config/autostart/`
**Files:** `src/core/integrator.rs`, `src/ui/detail_view.rs`, `src/core/database.rs`
**Changes:**
- New DB column: `autostart INTEGER NOT NULL DEFAULT 0`
- New functions in `integrator.rs`:
```rust
pub fn enable_autostart(db: &Database, record: &AppImageRecord) -> Result<PathBuf>
pub fn disable_autostart(db: &Database, record_id: i64) -> Result<()>
```
- `enable_autostart`: creates `~/.config/autostart/driftwood-{id}.desktop` with:
```ini
[Desktop Entry]
Type=Application
Name={app_name}
Exec={appimage_path}
Icon={icon_path}
X-GNOME-Autostart-enabled=true
X-Driftwood-AppImage-ID={id}
```
- Registers modification with mod_type `autostart`
- Detail view system tab: "Start at login" switch row
- `disable_autostart`: deletes the file, removes from system_modifications
- Uninstall flow: handled by `undo_all_modifications()`
---
### F9. System Notification Integration
**Complexity:** Medium (2 hrs)
**Problem:** Toasts vanish, important events get missed.
**Files:** `src/core/notification.rs`, `src/window.rs`
**Changes:**
- Use `gio::Application::send_notification(id, notification)` for:
- App crash on launch (high priority)
- Updates available after background check (normal priority)
- Security vulnerabilities found (high priority if critical/high severity)
- Keep toasts for minor confirmations (copied, integrated, etc.)
- Notification click opens Driftwood and navigates to relevant view
- `notification.rs` already has the logic for CVE notifications - extend to use gio::Notification instead of/in addition to libnotify
---
### F10. Storage Dashboard per App
**Complexity:** Medium (2 hrs)
**Problem:** Users don't know total disk usage per app.
**Files:** `src/ui/detail_view.rs`, `src/core/footprint.rs`, `src/core/database.rs`
**Changes:**
- `footprint.rs`: new function `get_total_footprint(db: &Database, record_id: i64) -> FootprintSummary`
```rust
pub struct FootprintSummary {
pub binary_size: u64,
pub config_size: u64,
pub cache_size: u64,
pub data_size: u64,
pub state_size: u64,
pub total_size: u64,
}
```
- Detail view storage tab: visual breakdown with labeled size bars
- Each category shows path and size: "Config (~/.config/MyApp) - 12 MB"
- "Clean cache" button per category (deletes cache paths only)
- Library list view: optional "Total size" column
---
### F11. Background Update Checks
**Complexity:** Medium (2-3 hrs)
**Problem:** No automatic update awareness.
**Files:** `src/window.rs`, `src/ui/dashboard.rs`, `src/ui/library_view.rs`, GSettings schema
**Changes:**
- New GSettings key: `update-check-interval-hours` type i, default 24, range 1-168
- New GSettings key: `last-update-check` type s, default ''
- On startup: if `auto-check-updates` is true and enough time has passed, spawn background check
- Background check: iterate all apps with update_info, call `check_appimage_for_update()` per app
- Results: update `update_available` column in DB, send gio::Notification if updates found
- Dashboard: show "X updates available" with timestamp "Last checked: 2h ago"
- Library view: badge on apps with updates
- Preferences: toggle + interval dropdown
---
### F12. One-Click Update All
**Complexity:** Medium (3-4 hrs)
**Problem:** Can only update one app at a time.
**Files:** `src/ui/dashboard.rs`, new `src/ui/batch_update_dialog.rs`, `src/core/updater.rs`
**Changes:**
- Dashboard: "Update All (N)" button when updates are available
- Opens batch update dialog showing list of apps to update with checkboxes
- Progress: per-app progress bar, overall progress bar
- Each update: download new version, save old as .prev (F3 rollback), replace, re-analyze
- On completion: summary toast "Updated 5 apps successfully, 1 failed"
- Failed updates: show error per app, keep old version
- Cancel: stops remaining updates, already-updated apps stay updated
---
### F13. Full Uninstall with Data Cleanup
**Complexity:** Medium (3 hrs)
**Problem:** Deleting AppImage leaves config/cache/data behind.
**Files:** `src/ui/detail_view.rs`, new confirmation dialog logic, `src/core/footprint.rs`
**Changes:**
- Delete button in detail view triggers new uninstall flow:
1. Show dialog with FootprintSummary (from F10):
- "Delete MyApp?" with breakdown:
- [x] AppImage file (245 MB)
- [x] Configuration (~/.config/MyApp) - 12 MB
- [x] Cache (~/.cache/MyApp) - 89 MB
- [x] Data (~/.local/share/MyApp) - 1.2 GB
- Total: 1.5 GB will be freed
2. All checked by default, user can uncheck to keep data
3. On confirm:
- Call `undo_all_modifications()` (removes .desktop, icons, autostart, MIME defaults)
- Delete selected data paths
- Delete the AppImage file
- Remove DB record
- Batch delete (F6) uses same flow with aggregated summary
---
### F14. Theme/Icon Preview in Drop Dialog
**Complexity:** Medium (2 hrs)
**Problem:** Users don't see what the app looks like before integrating.
**Files:** `src/ui/drop_dialog.rs`, `src/core/inspector.rs`
**Changes:**
- New function in inspector: `extract_icon_fast(path: &Path) -> Option<PathBuf>`
- Runs `unsquashfs -l` to find icon file, then extracts just that one file
- Much faster than full inspection
- Drop dialog: after registration, show preview card:
- App icon (from fast extraction)
- App name (from filename initially, updated after full analysis)
- "This is how it will appear in your app menu"
- If icon extraction fails, show default AppImage icon
---
### F15. FUSE Fix Wizard
**Complexity:** Significant (4-5 hrs)
**Problem:** #1 support issue - FUSE not installed, AppImages won't run.
**Files:** `src/core/fuse.rs`, new `src/ui/fuse_wizard.rs`, `src/ui/dashboard.rs`, new `data/app.driftwood.Driftwood.policy`
**Changes:**
- New distro detection in `fuse.rs`:
```rust
pub struct DistroInfo {
pub id: String, // "ubuntu", "fedora", "arch", etc.
pub id_like: Vec<String>,
pub version_id: String,
}
pub fn detect_distro() -> Option<DistroInfo> // parses /etc/os-release
pub fn get_fuse_install_command(distro: &DistroInfo) -> Option<String>
```
- Install commands by distro family:
- Debian/Ubuntu: `apt install -y libfuse2t64` (24.04+) or `apt install -y libfuse2` (older)
- Fedora/RHEL: `dnf install -y fuse-libs`
- Arch/Manjaro: `pacman -S --noconfirm fuse2`
- openSUSE: `zypper install -y libfuse2`
- Polkit policy file (`data/app.driftwood.Driftwood.policy`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"https://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<action id="app.driftwood.Driftwood.install-fuse">
<description>Install FUSE library for AppImage support</description>
<message>Authentication is required to install the FUSE library</message>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
</action>
</policyconfig>
```
- Wizard dialog (NavigationPage-style multi-step):
1. "FUSE is not installed" - explanation of what FUSE is and why it's needed
2. "We detected {distro}. Install with: `{command}`" - shows exact command
3. "Install now" button runs `pkexec sh -c "{command}"` and shows output
4. Re-check: calls `detect_system_fuse()` and shows success/failure
- Dashboard: yellow banner when FUSE is missing with "Fix now" button
---
### F16. File Type Association Manager
**Complexity:** Significant (4 hrs)
**Problem:** AppImages don't register MIME types, so files don't open with them.
**Spec:** XDG MIME Applications - `~/.local/share/applications/mimeapps.list`
**Files:** `src/core/integrator.rs`, `src/ui/detail_view.rs`
**Changes:**
- `inspector.rs` already extracts desktop entry content including MimeType= field
- New function: `parse_mime_types(desktop_entry: &str) -> Vec<String>` - extracts MimeType= values
- When integrating: include `MimeType=` in generated .desktop file
- Detail view overview tab: "Supported file types" section listing MIME types
- Per-type toggle: "Set as default for .png files"
- Setting default:
1. Query current default: `xdg-mime query default {mime_type}`
2. Store in `system_modifications` with `previous_value` = current default
3. Set new: `xdg-mime default driftwood-{id}.desktop {mime_type}`
- Removing: restore previous default from `previous_value`
- Uninstall: handled by `undo_all_modifications()`
---
### F17. Taskbar/Panel Icon Fix (StartupWMClass)
**Complexity:** Significant (3 hrs)
**Problem:** Running AppImages show wrong/generic icon in taskbar.
**Files:** `src/core/integrator.rs`, `src/core/wayland.rs`, `src/ui/detail_view.rs`
**Changes:**
- During inspection: extract `StartupWMClass=` from embedded .desktop entry
- Store in new DB column: `startup_wm_class TEXT`
- When generating .desktop file for integration, include StartupWMClass if available
- After launch + Wayland analysis: read `/proc/{pid}/environ` for `GDK_BACKEND`, check window list via `xdotool` or `xprop` for WM_CLASS
- If WM_CLASS doesn't match StartupWMClass: log warning, offer to patch .desktop file
- Detail view system tab: show detected WM_CLASS, allow manual override
---
### F18. Download Verification Helper
**Complexity:** Significant (4 hrs)
**Problem:** No way to verify AppImage authenticity.
**Files:** New `src/core/verification.rs`, `src/ui/detail_view.rs`, `src/ui/drop_dialog.rs`
**Changes:**
- AppImage signature spec: GPG signature embedded at offset stored in ELF section
- New module `verification.rs`:
```rust
pub enum VerificationStatus {
SignedValid { signer: String },
SignedInvalid { reason: String },
Unsigned,
ChecksumMatch,
ChecksumMismatch,
NotChecked,
}
pub fn check_embedded_signature(path: &Path) -> VerificationStatus
pub fn verify_sha256(path: &Path, expected: &str) -> VerificationStatus
```
- `check_embedded_signature`: runs `unsquashfs -l` to check for `.sha256` or signature files, or uses the AppImage --appimage-signature flag
- Drop dialog: after adding, show verification badge
- Detail view: "Verification: Signed by ..." or "Unsigned - verify manually"
- Manual verification: paste SHA256 hash, compare with computed hash
- New DB column: `verification_status TEXT` on appimages
---
### F19. First-Run Permission Summary
**Complexity:** Medium (3 hrs)
**Problem:** Users don't know what an AppImage can access before running it.
**Files:** New `src/ui/permission_dialog.rs`, `src/ui/detail_view.rs`
**Changes:**
- Before first launch of any AppImage (check `launch_events` count = 0):
1. Show permission dialog:
- "MyApp will run with full access to your files and system"
- Icon + app name
- If `has_firejail()`: offer sandbox options
- "Run without restrictions" (default for now)
- "Run in Firejail sandbox (recommended)"
- "Don't show this again" checkbox (stored per-app in DB)
2. If user chooses Firejail: set `sandbox_mode = 'firejail'` in DB
- New DB column: `first_run_prompted INTEGER NOT NULL DEFAULT 0`
- Skip dialog if `first_run_prompted = 1`
- Preferences: global toggle to disable first-run prompts
---
### F20. Default App Selector
**Complexity:** Medium (3 hrs)
**Problem:** Can't set AppImage as default browser, mail client, etc.
**Files:** `src/ui/detail_view.rs`, `src/core/integrator.rs`
**Changes:**
- Detect app capabilities from categories:
- `WebBrowser` -> can be default web browser
- `Email` -> can be default email client
- `FileManager` -> can be default file manager
- `TerminalEmulator` -> can be default terminal
- Detail view overview tab: "Set as default" section (only shown for applicable categories)
- Setting defaults:
- Browser: `xdg-settings set default-web-browser driftwood-{id}.desktop`
- Email: `xdg-mime default driftwood-{id}.desktop x-scheme-handler/mailto`
- File manager: `xdg-mime default driftwood-{id}.desktop inode/directory`
- Before setting: query current default, store in `system_modifications` with `previous_value`
- Uninstall: restore previous defaults via `undo_all_modifications()`
- Requirements: app must be integrated first (needs .desktop file)
---
### F21. Multi-User / System-Wide Install
**Complexity:** Significant (4 hrs)
**Problem:** Can't install for all users on a shared computer.
**Files:** `src/core/integrator.rs`, `src/ui/detail_view.rs`, polkit policy
**Changes:**
- Reuses polkit policy from F15 (add new action `app.driftwood.Driftwood.system-install`)
- "Install system-wide" option in detail view (requires app to be integrated first)
- Flow:
1. `pkexec cp {appimage} /opt/driftwood-apps/{filename}`
2. `pkexec chmod 755 /opt/driftwood-apps/{filename}`
3. Generate system .desktop in `/usr/share/applications/driftwood-{id}.desktop`
4. `pkexec cp {desktop_file} /usr/share/applications/`
5. Copy icon to `/usr/share/icons/hicolor/` via pkexec
6. `pkexec update-desktop-database /usr/share/applications/`
- Register all paths as `system_desktop`, `system_icon`, `system_binary` in system_modifications
- Uninstall system-wide: `pkexec rm` for each tracked path
- DB flag: `system_wide INTEGER NOT NULL DEFAULT 0`
---
### F22. CLI Enhancements
**Complexity:** Medium (3 hrs)
**Problem:** Missing install-from-URL and update-all commands.
**Files:** `src/cli.rs`
**New commands:**
- `driftwood install <url>` - Download from URL, validate, move to ~/Applications, set executable, register, optionally integrate
- `driftwood update --all` - Check all apps, download and apply available updates
- `driftwood autostart <path> --enable/--disable` - Toggle autostart for an AppImage
- `driftwood purge` - Remove ALL system modifications for all managed apps (for Driftwood removal)
- `driftwood verify <path>` - Check embedded signature or compare SHA256
---
### F23. Portable Mode / USB Drive Support
**Complexity:** Major (6-8 hrs)
**Problem:** Can't manage AppImages on removable media.
**Files:** New `src/core/portable.rs`, `src/ui/library_view.rs`, `src/window.rs`, `src/core/database.rs`
**Changes:**
- New GSettings key: `watch-removable-media` type b, default false
- `portable.rs`:
```rust
pub fn detect_removable_mounts() -> Vec<MountInfo>
pub fn scan_mount_for_appimages(mount: &MountInfo) -> Vec<DiscoveredAppImage>
pub fn is_path_on_removable(path: &Path) -> bool
```
- Detection: parse `/proc/mounts` for removable media (type vfat, exfat, ntfs on /media/ or /run/media/)
- Alternative: use `gio::VolumeMonitor` to watch for mount/unmount events
- DB changes:
- New column: `is_portable INTEGER DEFAULT 0`
- New column: `mount_point TEXT`
- Library view: "Portable" filter/section showing apps on removable media
- When drive unmounts: grey out those apps, mark as unavailable
- When drive mounts: re-scan and refresh
- Portable apps skip the "copy to ~/Applications" step - they stay on the drive
- Integration: .desktop files use the full path (may break when unmounted - show warning)
---
### F24. "Similar to..." Recommendations
**Complexity:** Medium (3 hrs, depends on F26)
**Problem:** No app discovery within the tool.
**Files:** `src/ui/detail_view.rs`, `src/core/database.rs`
**Changes:**
- Requires catalog data from F26 (catalog_apps table populated)
- New DB query: `find_similar_apps(categories: &[String], exclude_id: i64, limit: i32) -> Vec<CatalogApp>`
- Matches on shared categories, weighted by specificity
- Detail view overview tab: "You might also like" section at the bottom
- Shows up to 5 catalog apps with icon, name, one-line description
- Click opens catalog detail page (F26)
- Falls back to nothing if catalog is empty
---
### F25. AppImageHub In-App Catalog Browser
**Complexity:** Major (8-12 hrs)
**Problem:** No way to discover and install new AppImages from within the app.
**Data source:** `https://appimage.github.io/feed.json` (~1500 apps)
**Files:** New `src/ui/catalog_view.rs`, new `src/ui/catalog_detail.rs`, `src/core/database.rs`, `src/window.rs`
**Architecture:**
#### Data Layer
- Fetch `feed.json` and parse into `catalog_apps` table (already exists in DB schema)
- Store in `catalog_sources` as source record
- Fields mapped from feed.json:
- `name` -> `name`
- `description` -> `description`
- `categories` -> `categories` (joined with ;)
- `authors[0].name` -> `author`
- `license` -> `license`
- `links` (type=GitHub) -> `repository_url`
- `links` (type=Download) -> `download_url`
- `icons[0]` -> `icon_url` (prefix with `https://appimage.github.io/database/`)
- `screenshots` -> `screenshots` (JSON array)
- Refresh: on first open, then daily if enabled
- New GSettings key: `catalog-last-refreshed` type s, default ''
#### Catalog Browse View (NavigationPage)
- Header: search bar + category filter chips
- Categories from feed: AudioVideo, Development, Education, Game, Graphics, Network, Office, Science, System, Utility
- Grid of catalog app cards (reuse app_card pattern):
- Icon (fetched from URL, cached locally)
- App name
- Short description (first line)
- Category badge
- Pagination: load 50 at a time, "Load more" button
- Search: filters by name and description (client-side, data is local)
#### Catalog App Detail (NavigationPage pushed on click)
- App icon (large)
- Name, author, license
- Full description
- Screenshots carousel (if available)
- "Install" button (suggested style)
- Source link (opens GitHub/website in browser)
#### Install Flow
1. User clicks "Install"
2. Resolve download URL:
- If `download_url` points to GitHub releases page: fetch latest release via GitHub API (`https://api.github.com/repos/{owner}/{repo}/releases/latest`), find .AppImage asset
- If direct link: use as-is
3. Download with progress bar (reqwest or gio file download)
4. Validate: check AppImage magic bytes
5. Move to ~/Applications, set executable
6. Register in DB with `source_url` set
7. Run full analysis pipeline
8. Optionally integrate (based on `auto-integrate` setting)
9. Navigate to the app's detail view in library
#### Navigation
- Main sidebar/navigation: add "Catalog" entry alongside Dashboard and Library
- Or: floating action button on library view "Browse catalog"
---
## New GSettings Keys Summary
| Key | Type | Default | Range/Choices | Feature |
|-----|------|---------|---------------|---------|
| `update-check-interval-hours` | i | 24 | 1-168 | F11 |
| `last-update-check` | s | '' | - | F11 |
| `catalog-last-refreshed` | s | '' | - | F25 |
| `watch-removable-media` | b | false | - | F23 |
| `show-first-run-prompt` | b | true | - | F19 |
## New Database Columns Summary
| Table | Column | Type | Default | Feature |
|-------|--------|------|---------|---------|
| appimages | previous_version_path | TEXT | NULL | F3 |
| appimages | source_url | TEXT | NULL | F4 |
| appimages | autostart | INTEGER | 0 | F8 |
| appimages | startup_wm_class | TEXT | NULL | F17 |
| appimages | verification_status | TEXT | NULL | F18 |
| appimages | first_run_prompted | INTEGER | 0 | F19 |
| appimages | system_wide | INTEGER | 0 | F21 |
| appimages | is_portable | INTEGER | 0 | F23 |
| appimages | mount_point | TEXT | NULL | F23 |
## New Files Summary
| File | Feature | Purpose |
|------|---------|---------|
| `src/ui/fuse_wizard.rs` | F15 | FUSE installation wizard dialog |
| `src/ui/batch_update_dialog.rs` | F12 | Batch update progress dialog |
| `src/ui/permission_dialog.rs` | F19 | First-run permission summary |
| `src/ui/catalog_view.rs` | F25 | Catalog browse/search page |
| `src/ui/catalog_detail.rs` | F25 | Catalog app detail page |
| `src/core/verification.rs` | F18 | Signature and checksum verification |
| `src/core/portable.rs` | F23 | Removable media detection and management |
| `data/app.driftwood.Driftwood.policy` | F15, F21 | Polkit policy for privileged operations |
## Implementation Order
The features are numbered F1-F25 in order of complexity. Implement sequentially - some later features depend on earlier ones:
- F25 (Catalog) depends on nothing but is largest
- F24 (Similar to) depends on F25
- F12 (Update All) depends on F11 (Background checks)
- F13 (Full Uninstall) depends on system_modifications table (core architecture)
- F20 (Default App) depends on F16 (MIME associations)
**Critical path:** Core architecture (system_modifications) -> F1-F7 quick wins -> F8-F14 medium features -> F15-F22 significant features -> F23-F25 major features

File diff suppressed because it is too large Load Diff

View File

@@ -1,289 +0,0 @@
# Driftwood UI/UX Overhaul Design
## Context
Driftwood is a GTK4/libadwaita AppImage manager. The current UI is functional but visually plain - cards look like basic boxes, the list view resembles a settings page, and the detail view is a wall of ActionRows with no hierarchy. This design overhauls all three views plus adds a right-click context menu to make Driftwood feel like a first-class GNOME app.
## Design Principles
- Use libadwaita's built-in style classes wherever possible instead of custom CSS
- Follow GNOME HIG spacing (6px grid, 12px padding baseline, 14px grid gaps)
- Only show the most important information at each level (card -> list -> detail = progressive disclosure)
- Actions belong in context menus and detail headers, not crammed into cards
---
## 1. Card View (Grid)
### Current State
- 180px wide cards with 64px icons
- All badges shown (Wayland, FUSE, Update, Integration)
- Custom `.app-card` CSS duplicating libadwaita's `.card` behavior
- FlowBox allows up to 6 columns (cards get too small)
### New Design
```
+----------------------------------+
| |
| [72px icon] |
| (icon-dropshadow) |
| |
| App Name |
| (.title-3) |
| |
| 1.2.3 - 45 MiB |
| (.caption .dimmed .numeric) |
| |
| [single most important badge] |
+----------------------------------+
200px wide, 14px internal padding
```
### Changes
- **Card width: 200px** (from 180px) for better breathing room
- **Icon size: 72px** (from 64px) with `.icon-dropshadow` class
- **App name: `.title-3`** (from `.heading`) for more visual weight
- **Version + size on one combined line** using `.caption .dimmed .numeric`
- **Single badge only** - show the most important status using priority: Update > FUSE issue > Wayland issue. Integration is already shown via the icon corner emblem
- **Replace custom `.app-card` CSS with libadwaita `.card` + `.activatable`** - native hover, active, dark mode, and contrast states for free
- **FlowBox max 4 columns** (from 6) so cards stay readable
- **14px row and column spacing** (matching GNOME Software)
- **Right-click context menu** on each card (see Section 5)
### Files Modified
- `src/ui/app_card.rs` - card construction, badge logic, CSS classes
- `data/resources/style.css` - remove `.app-card` rules, add new sizing
- `src/ui/library_view.rs` - FlowBox max_children_per_line, context menu wiring
---
## 2. List View
### Current State
- 40px icons, standard ActionRow
- Subtitle mashes version + size + description into one hyphenated string
- All badges shown in a suffix box
- Standard ListBox (no `.rich-list`)
### New Design
```
+--[48px icon]--+--Title--------------------------+--[badge]--[>]--+
| (rounded | App Name | |
| 8px clip) | Description or path (.dimmed) | [Update] |
| | 1.2.3 - 45 MiB (.caption) | |
+---------------+----------------------------------+---------------+
```
### Changes
- **Icon size: 48px** (from 40px) with `border-radius: 8px` and `overflow: hidden` for rounded clipping
- **Subtitle structured as two lines:**
- Line 1: Description snippet or file path (dimmed)
- Line 2: Version + size (caption, dimmed, numeric)
- Use `subtitle-lines(2)` on ActionRow to allow the two-line subtitle
- **`.rich-list` style class** on the ListBox for taller rows
- **Single badge suffix** - same priority logic as cards
- **Remove integration badge from suffix** - redundant with icon emblem
- **Right-click context menu** - same menu as card view
- **Navigate arrow stays** as rightmost suffix
### Files Modified
- `src/ui/library_view.rs` - list row construction, ListBox class, context menu
- `data/resources/style.css` - icon rounding class
---
## 3. Detail View (Tabbed)
### Current State
- 64px icon in banner
- Single scrolling page with 3 PreferencesGroups
- 20+ rows all visible at once
- No visual hierarchy between sections
### New Design
```
+--[< back]------- App Name --------[Update][Launch]--+
| |
| +--[96px icon]---+ App Name (.title-1) |
| | (icon- | 1.2.3 - x86_64 (.dimmed) |
| | dropshadow) | Short description (.body) |
| +----------------+ [Integrated] [Native Wayland] |
| |
| +---[Overview]--[System]--[Security]--[Storage]--+ |
| | | |
| | (active tab content below) | |
| | | |
| + + |
+------------------------------------------------------+
```
### Hero Banner
- **96px icon** (from 64px) with `.icon-dropshadow`
- **App name in `.title-1`** (stays as-is)
- **Subtle gradient background:** `linear-gradient(to bottom, alpha(@accent_bg_color, 0.08), transparent)` behind the banner area
- **Key badges inline** (stays as-is)
### Tab System
- **`adw::ViewStack`** contains four pages
- **`adw::ViewSwitcher`** with `.inline` style, placed between the banner and tab content (not in the header bar)
- Header bar stays clean with just back button, app name title, Update button, Launch button
### Tab 1: Overview (default)
Shows the most commonly needed information at a glance.
- Update method (update type or "no automatic updates")
- Update status (available/up-to-date) with version info
- Last checked date
- Total launches + last launched
- AppImage type + executable status
- File path with copy + open folder buttons
- First seen / last scanned dates
- Notes (if any)
### Tab 2: System
All system integration and compatibility information.
- Desktop integration switch
- Desktop file path (if integrated)
- Wayland compatibility row + badge
- Analyze toolkit button
- Runtime display protocol (if available)
- FUSE status + badge
- Launch method
- Firejail sandbox switch + install hint
### Tab 3: Security
Vulnerability scanning and integrity.
- Bundled libraries count
- Vulnerability summary with severity badge
- Scan button (with busy state)
- SHA256 checksum with copy button
### Tab 4: Storage
Disk usage and data discovery.
- AppImage file size
- Total disk footprint (if discovered)
- Discover data paths button
- Individual discovered paths with type icons, confidence badges, sizes
### Files Modified
- `src/ui/detail_view.rs` - major restructure: banner upgrade, ViewStack/ViewSwitcher, redistribute rows across 4 tab pages
- `data/resources/style.css` - banner gradient, ViewSwitcher positioning
---
## 4. CSS & Visual Polish
### Remove
- `.app-card` and `.app-card:hover` and `.app-card:active` rules (replaced by libadwaita `.card`)
- Dark mode `.app-card` overrides (handled by `.card` automatically)
- High contrast `.app-card` overrides (handled by `.card` automatically)
### Add
```css
/* Rounded icon clipping for list view */
.icon-rounded {
border-radius: 8px;
overflow: hidden;
}
/* Detail banner gradient wash */
.detail-banner {
padding: 18px 0;
background-image: linear-gradient(
to bottom,
alpha(@accent_bg_color, 0.08),
transparent
);
border-radius: 12px;
margin-bottom: 6px;
}
```
### Keep (unchanged)
- All status badge styling
- Integration emblem styling
- Letter-circle fallback icons
- All WCAG AAA styles (focus indicators, high contrast, reduced motion, target sizes)
- Compatibility warning banner
- Quick action pill styling
### Style Classes Used (libadwaita built-in)
- `.card` + `.activatable` on FlowBoxChild card boxes
- `.icon-dropshadow` on icons 48px+
- `.rich-list` on list view ListBox
- `.numeric` on version/size labels
- `.title-3` on card app names
- `.inline` on the detail ViewSwitcher
- `.property` on key-value ActionRows where subtitle is the main content (path, SHA256)
---
## 5. Right-Click Context Menu
### Design
A `GtkPopoverMenu` built from a `gio::Menu` model, attached to each FlowBoxChild (card) and ListBox row. Triggered by secondary click (button 3) or long-press on touch.
```
+---------------------------+
| Launch |
+---------------------------+
| Check for Updates |
| Scan for Vulnerabilities |
+---------------------------+
| Integrate / Remove |
| Open Containing Folder |
+---------------------------+
| Copy Path |
+---------------------------+
```
### Menu Items
| Label | Action | Notes |
|-------|--------|-------|
| Launch | `app.launch-appimage(id)` | Launches the AppImage |
| Check for Updates | `app.check-update(id)` | Triggers update check, shows toast with result |
| Scan for Vulnerabilities | `app.scan-security(id)` | Triggers security scan, shows toast |
| Integrate / Remove Integration | `app.toggle-integration(id)` | Label changes based on current state |
| Open Containing Folder | `app.open-folder(id)` | Opens file manager to the directory |
| Copy Path | `app.copy-path(id)` | Copies full path, shows toast |
### Implementation Approach
- Define actions at the window level with the record ID as parameter
- Build a `gio::Menu` with sections (separators between groups)
- Attach `GtkPopoverMenu` to each card/row
- Wire `GtkGestureClick` for button 3 (right-click) and `GtkGestureLongPress` for touch
- Update the "Integrate/Remove" label dynamically based on `record.integrated`
### Files Modified
- `src/window.rs` - define parameterized actions
- `src/ui/library_view.rs` - create menu model, attach to cards and rows
- `src/ui/app_card.rs` - gesture attachment on FlowBoxChild
---
## 6. Files Modified Summary
| File | Changes |
|------|---------|
| `src/ui/app_card.rs` | 72px icon, .title-3 name, single badge, .card class, gesture for context menu |
| `src/ui/library_view.rs` | FlowBox max 4 cols, .rich-list on ListBox, list row restructure, context menu creation and attachment |
| `src/ui/detail_view.rs` | 96px icon, ViewStack/ViewSwitcher tabs, redistribute rows into 4 tab pages, banner gradient |
| `src/window.rs` | Parameterized actions for context menu (launch, update, scan, integrate, open-folder, copy-path) |
| `data/resources/style.css` | Remove .app-card rules, add .icon-rounded, update .detail-banner with gradient, keep all WCAG styles |
| `src/ui/widgets.rs` | Minor - ensure icon helper supports .icon-dropshadow |
## Verification
After implementation:
1. `cargo build` - zero errors, zero warnings
2. `cargo test` - all 128+ tests pass
3. Visual verification of all three views in light + dark mode
4. Right-click context menu works on cards and list rows
5. Detail view tabs switch correctly, content is correctly distributed
6. Keyboard navigation: Tab through cards, Enter to open, Escape to go back
7. All WCAG AAA compliance preserved

File diff suppressed because it is too large Load Diff

View File

@@ -1,246 +0,0 @@
# WCAG 2.2 AAA Compliance Design for Driftwood
## Context
Driftwood is a GTK4/libadwaita AppImage manager. The app already uses semantic libadwaita widgets (ActionRow, PreferencesGroup, SwitchRow, NavigationView) and has partial accessibility support: ~11 accessible labels, status badges with text+color, keyboard shortcuts, and reduced-motion CSS. However, it falls short of full WCAG 2.2 AAA compliance.
This design covers every change needed to achieve AAA compliance across all four WCAG principles: Perceivable, Operable, Understandable, and Robust.
## Approach: Hybrid Helpers + Direct Properties
Create centralized accessibility helper functions in `widgets.rs` for patterns that repeat (labeled buttons, described badges, live announcements), then add direct accessible properties for unique cases in each UI file. This keeps the code DRY while ensuring nothing is missed.
## Scope
**In scope:** All UI code in `src/ui/`, `src/window.rs`, and `data/resources/style.css`.
**Out of scope:** CLI (`src/cli.rs`), core backend modules, build system.
---
## 1. Perceivable (WCAG 1.x)
### 1.1 Non-text Content (1.1.1 - Level A)
**Current state:** 8 icon-only buttons, some with accessible labels, some without.
**Changes needed:**
- Add `update_property(&[AccessibleProperty::Label(...)])` to every icon-only button:
- `library_view.rs`: menu button, search button, grid button, list button
- `duplicate_dialog.rs`: delete button per row
- `widgets.rs`: copy button
- `preferences.rs`: remove directory button
- Add accessible descriptions to integration emblem overlay in `app_card.rs`
- Add accessible labels to all `gtk::Image` icons used as prefixes in rows (check icons in integration_dialog, category icons in cleanup_wizard)
### 1.2 Time-based Media - N/A (no audio/video)
### 1.3 Adaptable
**1.3.1 Info and Relationships (A):**
- Add `AccessibleRole::Group` to badge boxes in detail_view, library_view list rows, and app_card
- Add `AccessibleProperty::Label` to ListBox containers in library_view (list view), preferences (directory list, cleanup wizard lists)
- Add `AccessibleRelation::LabelledBy` connecting PreferencesGroup titles to their child row containers where applicable
**1.3.6 Identify Purpose (AAA):**
- Set `AccessibleRole::Banner` on the detail view banner
- Set `AccessibleRole::Navigation` on the NavigationView wrapper
- Set `AccessibleRole::Search` on the search bar
- Set `AccessibleRole::Status` on status badges
- Set `AccessibleRole::Main` on the main content area
### 1.4 Distinguishable
**1.4.6 Enhanced Contrast (AAA - 7:1 ratio):**
- Add a `prefers-contrast: more` media query in `style.css` for high-contrast mode
- In high-contrast mode: solid opaque borders on app cards (no alpha), bolder status badge colors, thicker focus rings (3px)
- Verify that libadwaita theme variables meet 7:1 in both light and dark mode (they do - libadwaita's named colors are designed for WCAG AA, and the `prefers-contrast: more` variant handles AAA)
**1.4.8 Visual Presentation (AAA):**
- Text is already relative-sized (em units)
- libadwaita handles line height and spacing according to system preferences
- No changes needed beyond ensuring we do not override user-configured text spacing
**1.4.11 Non-text Contrast (AA):**
- Increase focus ring width to 3px (currently 2px) in high-contrast mode
- Ensure status badge borders are visible at 3:1 ratio against their background
---
## 2. Operable (WCAG 2.x)
### 2.1 Keyboard Accessible
**2.1.3 Keyboard No Exception (AAA):**
- Verify all dashboard actionable rows are keyboard-activatable (they use `activatable: true` + `action_name`)
- Verify cleanup wizard checkboxes are keyboard-toggleable via `activatable_widget`
- Add keyboard shortcut Ctrl+Q for quit (already exists in GNOME via app.quit)
- Ensure delete button in duplicate_dialog can be reached via Tab
### 2.2 Enough Time
**2.2.4 Interruptions (AAA):**
- Toast notifications already have timeouts and can be dismissed
- No other auto-updating content exists
### 2.3 Seizures and Physical Reactions
**2.3.3 Animation from Interactions (AAA):**
- Expand `prefers-reduced-motion` CSS to cover ALL transitions:
- `navigation view` slide transitions (currently only covers `stack`)
- `adw::Dialog` presentation animations
- `flowboxchild` hover/active transitions
- Search bar reveal animation
- Spinner animations (already respected by adw::Spinner)
### 2.4 Navigable
**2.4.7 Focus Visible (AA) + 2.4.13 Focus Appearance (AAA):**
- Add focus-visible styles for ALL focusable elements, not just app cards:
- `button:focus-visible` - 3px solid outline with accent color
- `row:focus-visible` - highlight with outline
- `switch:focus-visible`
- `checkbutton:focus-visible`
- `searchentry:focus-visible`
- `comborow:focus-visible`
- `spinrow:focus-visible`
- `expander:focus-visible`
- Focus indicator must be at least 2px thick and have 3:1 contrast (AAA requires this)
**2.4.8 Location (AAA):**
- Update the window title dynamically to reflect current page: "Driftwood - Dashboard", "Driftwood - Security Report", "Driftwood - {App Name}"
- This is already partially done via NavigationPage titles; ensure the window's actual title property updates
**2.5.8 Target Size (AA):**
- Audit all clickable targets for minimum 24x24px
- The copy button, delete button (duplicate dialog), and remove directory button need `min-width: 24px; min-height: 24px` in CSS
- Status badges in actionable rows are not click targets (the row is), so this is fine
---
## 3. Understandable (WCAG 3.x)
### 3.1 Readable
**3.1.1 Language of Page (A):**
- The GTK accessible layer reads the locale from the system. No explicit action needed for a native desktop app.
**3.1.3 Unusual Words (AAA):**
- Add `tooltip_text` explanations for technical terms displayed in the UI:
- "FUSE" -> tooltip: "Filesystem in Userspace - required for mounting AppImages"
- "XWayland" -> tooltip: "X11 compatibility layer for Wayland desktops"
- "AppImage Type 1/2" -> tooltip: "Type 1 uses ISO9660, Type 2 uses SquashFS"
- "CVE" -> tooltip: "Common Vulnerabilities and Exposures - security vulnerability identifier"
- "SHA256" -> tooltip: "Cryptographic hash for verifying file integrity"
- "Firejail" -> tooltip: "Linux application sandboxing tool"
- "zsync" -> tooltip: "Efficient delta-update download protocol"
- "fusermount" -> tooltip: "User-space filesystem mount utility"
**3.1.4 Abbreviations (AAA):**
- Expand "CVE" to "CVE (vulnerability)" on first use in security report
- Expand "SHA256" to "SHA256 checksum" in detail view
**3.1.5 Reading Level (AAA):**
- Review all user-facing strings for plain language (most are already simple)
- Replace "No update information embedded" with "This app cannot check for updates automatically"
- Replace "AppImage does not contain update information" with "No automatic update support"
### 3.2 Predictable - Already compliant (no unexpected changes)
### 3.3 Input Assistance
**3.3.5 Help (AAA):**
- Add contextual descriptions to all PreferencesGroup widgets (already mostly done)
- Add `description` text to any PreferencesGroup missing it
**3.3.6 Error Prevention - All (AAA):**
- Destructive actions already have confirmation (confirm-before-delete setting, alert dialogs)
- Add confirmation to bulk "Remove All Suggested" in duplicate dialog (currently executes immediately)
- Add confirmation to "Clean Selected" in cleanup wizard (currently executes immediately)
---
## 4. Robust (WCAG 4.x)
### 4.1.2 Name, Role, Value (A)
**Accessible Names (all interactive elements):**
Every button, toggle, switch, row, and input must have an accessible name. The full list of items needing labels:
| Widget | File | Label to add |
|--------|------|-------------|
| Menu button | library_view.rs | "Main menu" |
| Search toggle | library_view.rs | "Toggle search" |
| Grid view toggle | library_view.rs | "Switch to grid view" |
| List view toggle | library_view.rs | "Switch to list view" |
| Copy button | widgets.rs | "Copy to clipboard" |
| Delete button | duplicate_dialog.rs | "Delete this AppImage" |
| Remove directory | preferences.rs | "Remove scan directory" |
| Close button | cleanup_wizard.rs | "Close dialog" |
| Clean Selected | cleanup_wizard.rs | "Clean selected items" |
| Remove All Suggested | duplicate_dialog.rs | "Remove all suggested duplicates" |
| Add Location | preferences.rs | "Add scan directory" |
| Scan Now | library_view.rs | "Scan for AppImages" |
| Preferences | library_view.rs | "Open preferences" |
**Accessible Roles:**
- `FlowBox` -> already has label
- `ListBox` (list view) -> add `AccessibleProperty::Label("AppImage library list")`
- `ListBox` (preferences directory list) -> add label "Scan directories"
- `ListBox` (cleanup items) -> add label per category
- `ListBox` (integration dialog identity) -> add label "Application details"
- `ListBox` (integration dialog actions) -> add label "Integration actions"
- Status badges -> add `AccessibleRole::Status` (or use `update_property` with `AccessibleProperty::Label`)
**Accessible States:**
- Switch rows -> GTK handles this automatically via `SwitchRow`
- Scan button -> add `AccessibleState::Busy` while scanning is in progress
- Security scan row -> add `AccessibleState::Busy` during scan
- Analyze toolkit row -> add `AccessibleState::Busy` during analysis
### 4.1.3 Status Messages (AA)
**Live region announcements for async operations:**
Create a helper function `announce(text: &str)` in widgets.rs that uses a hidden GTK Label with `AccessibleRole::Alert` to broadcast status changes to screen readers.
Operations needing announcements:
- Scan start ("Scanning for AppImages...")
- Scan complete ("{n} AppImages found, {m} new")
- Update check start/complete
- Security scan start/complete
- Cleanup analysis start/complete
- Search results count change ("{n} results" or "No results")
---
## Files Modified
| File | Changes |
|------|---------|
| `data/resources/style.css` | Focus indicators for all widgets, high-contrast media query, reduced-motion expansion, target size minimums |
| `src/ui/widgets.rs` | New `announce()` live region helper, accessible label on copy_button, accessible role on status badges |
| `src/ui/library_view.rs` | Accessible labels on all header buttons, list box label, search results announcement, accessible roles |
| `src/ui/app_card.rs` | Accessible description for emblem overlay |
| `src/ui/detail_view.rs` | Accessible roles on banner, busy states on async rows, tooltips for technical terms, plain-language rewrites |
| `src/ui/dashboard.rs` | Tooltips for technical terms, accessible labels on any unlabeled elements |
| `src/ui/duplicate_dialog.rs` | Accessible label on delete buttons, confirmation before bulk remove, list box labels |
| `src/ui/cleanup_wizard.rs` | Accessible labels on buttons, confirmation before cleanup, list box labels, busy announcement |
| `src/ui/preferences.rs` | Accessible label on remove button and add button, list box labels |
| `src/ui/security_report.rs` | Accessible labels, tooltips for CVE terms |
| `src/ui/integration_dialog.rs` | List box labels, accessible descriptions |
| `src/ui/update_dialog.rs` | Plain-language rewrites |
| `src/window.rs` | Window title updates per page, live announcements for scan/update/clean operations |
## Verification
After implementation:
1. `cargo build` - zero errors, zero warnings
2. `cargo test` - all tests pass
3. Run with Orca screen reader - verify every element is announced correctly
4. Tab through entire app - verify all elements have visible focus indicators
5. Set `GTK_THEME=Adwaita:dark` - verify dark mode focus/contrast
6. Set high-contrast theme - verify enhanced contrast mode
7. Set `GTK_DEBUG=interactive` - inspect accessible tree
8. Keyboard-only navigation test through every screen
9. Verify all targets meet 24x24px minimum
10. Verify reduced-motion disables all animations

File diff suppressed because it is too large Load Diff

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

@@ -3,6 +3,7 @@ use glib::ExitCode;
use gtk::prelude::*; use gtk::prelude::*;
use std::time::Instant; use std::time::Instant;
use crate::core::backup;
use crate::core::database::Database; use crate::core::database::Database;
use crate::core::discovery; use crate::core::discovery;
use crate::core::duplicates; use crate::core::duplicates;
@@ -885,209 +886,39 @@ fn cmd_verify(path: &str, expected_sha256: Option<&str>) -> ExitCode {
// --- Export/Import library --- // --- Export/Import library ---
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode { fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
let records = match db.get_all_appimages() { let path = output.unwrap_or("driftwood-apps.json");
Ok(r) => r, let export_path = std::path::Path::new(path);
match backup::export_app_list(db, export_path) {
Ok(count) => {
eprintln!("Exported {} AppImages to {}", count, path);
ExitCode::SUCCESS
}
Err(e) => { Err(e) => {
eprintln!("Error: {}", e); eprintln!("Error: {}", e);
return ExitCode::FAILURE; ExitCode::FAILURE
} }
};
let appimages: Vec<serde_json::Value> = records
.iter()
.map(|r| {
serde_json::json!({
"path": r.path,
"app_name": r.app_name,
"app_version": r.app_version,
"integrated": r.integrated,
"notes": r.notes,
"categories": r.categories,
})
})
.collect();
let export_data = serde_json::json!({
"version": 1,
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"appimages": appimages,
});
let json_str = match serde_json::to_string_pretty(&export_data) {
Ok(s) => s,
Err(e) => {
eprintln!("Error serializing export data: {}", e);
return ExitCode::FAILURE;
}
};
if let Some(path) = output {
if let Err(e) = std::fs::write(path, &json_str) {
eprintln!("Error writing to {}: {}", path, e);
return ExitCode::FAILURE;
}
} else {
println!("{}", json_str);
} }
eprintln!("Exported {} AppImages", records.len());
ExitCode::SUCCESS
} }
fn cmd_import(db: &Database, file: &str) -> ExitCode { fn cmd_import(db: &Database, file: &str) -> ExitCode {
let content = match std::fs::read_to_string(file) { let import_path = std::path::Path::new(file);
Ok(c) => c,
match backup::import_app_list(db, import_path) {
Ok(result) => {
eprintln!("Matched and merged metadata for {} apps", result.matched);
if !result.missing.is_empty() {
eprintln!(
"{} apps not found in library: {}",
result.missing.len(),
result.missing.join(", ")
);
}
ExitCode::SUCCESS
}
Err(e) => { Err(e) => {
eprintln!("Error reading {}: {}", file, e); eprintln!("Error: {}", e);
return ExitCode::FAILURE; ExitCode::FAILURE
} }
};
let data: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("Error parsing JSON: {}", e);
return ExitCode::FAILURE;
}
};
let entries = match data.get("appimages").and_then(|a| a.as_array()) {
Some(arr) => arr,
None => {
eprintln!("Error: JSON missing 'appimages' array");
return ExitCode::FAILURE;
}
};
let total = entries.len();
let mut imported = 0u32;
let mut skipped = 0u32;
for entry in entries {
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
Some(p) => p,
None => {
skipped += 1;
continue;
}
};
let file_path = std::path::Path::new(path_str);
if !file_path.exists() {
skipped += 1;
continue;
}
// Validate that the file is actually an AppImage
let appimage_type = match discovery::detect_appimage(file_path) {
Some(t) => t,
None => {
eprintln!(" Skipping {} - not a valid AppImage", path_str);
skipped += 1;
continue;
}
};
let metadata = std::fs::metadata(file_path);
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let is_executable = metadata
.as_ref()
.map(|m| {
use std::os::unix::fs::PermissionsExt;
m.permissions().mode() & 0o111 != 0
})
.unwrap_or(false);
let filename = file_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let file_modified = metadata
.as_ref()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
let id = match db.upsert_appimage(
path_str,
&filename,
Some(appimage_type.as_i32()),
size_bytes,
is_executable,
file_modified.as_deref(),
) {
Ok(id) => id,
Err(e) => {
eprintln!(" Error registering {}: {}", path_str, e);
skipped += 1;
continue;
}
};
// Restore metadata fields from the export
let app_name = entry.get("app_name").and_then(|v| v.as_str());
let app_version = entry.get("app_version").and_then(|v| v.as_str());
let categories = entry.get("categories").and_then(|v| v.as_str());
if app_name.is_some() || app_version.is_some() {
db.update_metadata(
id,
app_name,
app_version,
None,
None,
categories,
None,
None,
None,
).ok();
}
// Restore notes if present
if let Some(notes_str) = entry.get("notes").and_then(|v| v.as_str()) {
db.update_notes(id, Some(notes_str)).ok();
}
// If it was integrated in the export, integrate it now
let was_integrated = entry
.get("integrated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if was_integrated {
// Need the full record to integrate
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
if !record.integrated {
match integrator::integrate_tracked(&record, &db) {
Ok(result) => {
db.set_integrated(
id,
true,
Some(&result.desktop_file_path.to_string_lossy()),
).ok();
}
Err(e) => {
eprintln!(" Warning: could not integrate {}: {}", path_str, e);
}
}
}
}
}
imported += 1;
} }
eprintln!(
"Imported {} of {} AppImages ({} skipped - file not found)",
imported,
total,
skipped,
);
ExitCode::SUCCESS
} }

View File

@@ -13,7 +13,6 @@ pub fn data_dir_fallback() -> PathBuf {
} }
/// Return the XDG config directory with a proper $HOME-based fallback. /// Return the XDG config directory with a proper $HOME-based fallback.
#[allow(dead_code)]
pub fn config_dir_fallback() -> PathBuf { pub fn config_dir_fallback() -> PathBuf {
dirs::config_dir().unwrap_or_else(|| home_dir().join(".config")) dirs::config_dir().unwrap_or_else(|| home_dir().join(".config"))
} }

View File

@@ -407,6 +407,197 @@ fn read_manifest(archive_path: &Path) -> Result<BackupManifest, BackupError> {
.map_err(|e| BackupError::Io(format!("Invalid manifest: {}", e))) .map_err(|e| BackupError::Io(format!("Invalid manifest: {}", e)))
} }
// ===== App list export/import (JSON v2) =====
/// Result of importing an app list.
#[derive(Debug)]
pub struct ImportResult {
pub matched: usize,
pub missing: Vec<String>,
}
/// Export the app list to a JSON file (v2 format with extended fields).
pub fn export_app_list(db: &Database, path: &Path) -> Result<usize, BackupError> {
let records = db.get_all_appimages()
.map_err(|e| BackupError::Database(e.to_string()))?;
let appimages: Vec<serde_json::Value> = records
.iter()
.map(|r| {
serde_json::json!({
"path": r.path,
"app_name": r.app_name,
"app_version": r.app_version,
"integrated": r.integrated,
"notes": r.notes,
"categories": r.categories,
"tags": r.tags,
"pinned": r.pinned,
"launch_args": r.launch_args,
"sandbox_mode": r.sandbox_mode,
"autostart": r.autostart,
})
})
.collect();
let count = appimages.len();
let export_data = serde_json::json!({
"version": 2,
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"appimages": appimages,
});
let json_str = serde_json::to_string_pretty(&export_data)
.map_err(|e| BackupError::Io(e.to_string()))?;
fs::write(path, &json_str)
.map_err(|e| BackupError::Io(e.to_string()))?;
Ok(count)
}
/// Import an app list from a JSON file (supports both v1 and v2 formats).
/// Matches apps by path. Merges metadata for matched apps (only fills empty fields).
pub fn import_app_list(db: &Database, path: &Path) -> Result<ImportResult, BackupError> {
let content = fs::read_to_string(path)
.map_err(|e| BackupError::Io(e.to_string()))?;
let data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| BackupError::Io(format!("Invalid JSON: {}", e)))?;
let entries = data.get("appimages")
.and_then(|a| a.as_array())
.ok_or_else(|| BackupError::Io("Missing 'appimages' array".to_string()))?;
let mut matched = 0usize;
let mut missing = Vec::new();
for entry in entries {
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
Some(p) => p,
None => continue,
};
// Try to find this app in the database by path
let existing = db.get_appimage_by_path(path_str)
.ok()
.flatten();
let record = match existing {
Some(r) => r,
None => {
// App not in DB - check if file exists on disk
if !Path::new(path_str).exists() {
missing.push(path_str.to_string());
continue;
}
missing.push(path_str.to_string());
continue;
}
};
let id = record.id;
// Merge metadata: only overwrite if existing is empty/None and import has a value
let app_name = merge_str_field(record.app_name.as_deref(), entry, "app_name");
let app_version = merge_str_field(record.app_version.as_deref(), entry, "app_version");
let categories = merge_str_field(record.categories.as_deref(), entry, "categories");
if app_name.is_some() || app_version.is_some() || categories.is_some() {
db.update_metadata(
id,
app_name.as_deref().or(record.app_name.as_deref()),
app_version.as_deref().or(record.app_version.as_deref()),
None,
None,
categories.as_deref().or(record.categories.as_deref()),
None,
None,
None,
).ok();
}
// Merge notes
if record.notes.as_ref().map_or(true, |n| n.is_empty()) {
if let Some(notes) = entry.get("notes").and_then(|v| v.as_str()) {
if !notes.is_empty() {
db.update_notes(id, Some(notes)).ok();
}
}
}
// Merge tags (union of existing + imported, no duplicates)
if let Some(import_tags) = entry.get("tags").and_then(|v| v.as_str()) {
if !import_tags.is_empty() {
let mut tag_set: std::collections::BTreeSet<String> = record
.tags
.as_deref()
.unwrap_or("")
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
for tag in import_tags.split(',') {
let trimmed = tag.trim();
if !trimmed.is_empty() {
tag_set.insert(trimmed.to_string());
}
}
let merged = tag_set.into_iter().collect::<Vec<_>>().join(",");
db.update_tags(id, Some(&merged)).ok();
}
}
// Merge pinned (only set if imported says true and current is false)
if !record.pinned {
if let Some(true) = entry.get("pinned").and_then(|v| v.as_bool()) {
db.set_pinned(id, true).ok();
}
}
// Merge launch_args
if record.launch_args.as_ref().map_or(true, |a| a.is_empty()) {
if let Some(args) = entry.get("launch_args").and_then(|v| v.as_str()) {
if !args.is_empty() {
db.update_launch_args(id, Some(args)).ok();
}
}
}
// Merge sandbox_mode
if record.sandbox_mode.is_none() {
if let Some(mode) = entry.get("sandbox_mode").and_then(|v| v.as_str()) {
if !mode.is_empty() {
db.update_sandbox_mode(id, Some(mode)).ok();
}
}
}
// Merge autostart (only set if imported says true and current is false)
if !record.autostart {
if let Some(true) = entry.get("autostart").and_then(|v| v.as_bool()) {
db.set_autostart(id, true).ok();
}
}
matched += 1;
}
Ok(ImportResult { matched, missing })
}
/// Helper: returns Some(imported_value) only if existing is empty and import has a non-empty value.
fn merge_str_field(existing: Option<&str>, entry: &serde_json::Value, key: &str) -> Option<String> {
if existing.map_or(false, |s| !s.is_empty()) {
return None; // existing has data, don't overwrite
}
entry.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,526 @@
use super::database::Database;
// --- API response structs ---
#[derive(Debug, serde::Deserialize)]
pub struct GitHubRepoInfo {
pub stargazers_count: i64,
pub pushed_at: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct GitHubReleaseInfo {
pub tag_name: String,
pub published_at: Option<String>,
pub assets: Vec<GitHubReleaseAsset>,
}
#[derive(Debug, serde::Deserialize)]
pub struct GitHubReleaseAsset {
pub name: String,
pub browser_download_url: String,
pub download_count: i64,
pub size: i64,
}
// --- URL parsing ---
/// Extract (owner, repo) from a GitHub URL.
/// Tries download_url first (most reliable for GitHub releases), then homepage.
pub fn extract_github_repo(homepage: Option<&str>, download_url: &str) -> Option<(String, String)> {
// Try download URL first - most AppImageHub entries point to GitHub releases
if let Some(pair) = parse_github_url(download_url) {
return Some(pair);
}
// Fallback to homepage
if let Some(hp) = homepage {
if let Some(pair) = parse_github_url(hp) {
return Some(pair);
}
}
None
}
/// Parse `github.com/{owner}/{repo}` from a URL, stripping .git suffix if present.
fn parse_github_url(url: &str) -> Option<(String, String)> {
let stripped = url.trim_start_matches("https://")
.trim_start_matches("http://");
if !stripped.starts_with("github.com/") {
return None;
}
let path = stripped.strip_prefix("github.com/")?;
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() < 2 {
return None;
}
let owner = parts[0];
let repo = parts[1]
.trim_end_matches(".git")
.split('?').next().unwrap_or(parts[1]);
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((owner.to_string(), repo.to_string()))
}
// --- API calls ---
fn github_get(url: &str, token: &str) -> Result<(String, u32), String> {
let mut req = ureq::get(url)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Driftwood-AppImage-Manager");
if !token.is_empty() {
req = req.header("Authorization", &format!("Bearer {}", token));
}
let mut response = req.call()
.map_err(|e| format!("GitHub API error: {}", e))?;
// Parse rate limit header
let remaining: u32 = response.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let body = response.body_mut().read_to_string()
.map_err(|e| format!("Read error: {}", e))?;
Ok((body, remaining))
}
pub fn fetch_repo_info(owner: &str, repo: &str, token: &str) -> Result<(GitHubRepoInfo, u32), String> {
let url = format!("https://api.github.com/repos/{}/{}", owner, repo);
let (body, remaining) = github_get(&url, token)?;
let info: GitHubRepoInfo = serde_json::from_str(&body)
.map_err(|e| format!("Parse error: {}", e))?;
Ok((info, remaining))
}
pub fn fetch_release_info(owner: &str, repo: &str, token: &str) -> Result<(GitHubReleaseInfo, u32), String> {
let url = format!("https://api.github.com/repos/{}/{}/releases/latest", owner, repo);
let (body, remaining) = github_get(&url, token)?;
let info: GitHubReleaseInfo = serde_json::from_str(&body)
.map_err(|e| format!("Parse error: {}", e))?;
Ok((info, remaining))
}
#[derive(Debug, serde::Deserialize)]
struct GitHubReadmeResponse {
content: String,
#[serde(default)]
encoding: String,
}
/// Fetch the README content for a repo (decoded from base64).
pub fn fetch_readme(owner: &str, repo: &str, token: &str) -> Result<(String, u32), String> {
let url = format!("https://api.github.com/repos/{}/{}/readme", owner, repo);
let (body, remaining) = github_get(&url, token)?;
let resp: GitHubReadmeResponse = serde_json::from_str(&body)
.map_err(|e| format!("Parse error: {}", e))?;
if resp.encoding != "base64" {
return Err(format!("Unexpected encoding: {}", resp.encoding));
}
// GitHub returns base64 with newlines; strip them before decoding
let clean = resp.content.replace('\n', "");
let decoded = base64_decode(&clean)
.map_err(|e| format!("Base64 decode error: {}", e))?;
let text = String::from_utf8(decoded)
.map_err(|e| format!("UTF-8 error: {}", e))?;
Ok((text, remaining))
}
/// Simple base64 decoder (standard alphabet, no padding required).
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = Vec::with_capacity(input.len() * 3 / 4);
let mut buf = 0u32;
let mut bits = 0u32;
for &b in input.as_bytes() {
if b == b'=' { break; }
let val = TABLE.iter().position(|&c| c == b)
.ok_or_else(|| format!("Invalid base64 char: {}", b as char))? as u32;
buf = (buf << 6) | val;
bits += 6;
if bits >= 8 {
bits -= 8;
output.push((buf >> bits) as u8);
buf &= (1 << bits) - 1;
}
}
Ok(output)
}
// --- AppImage asset filtering ---
/// A simplified release asset for storage (JSON-serializable).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AppImageAsset {
pub name: String,
pub url: String,
pub size: i64,
}
/// Filter release assets to only AppImage files.
pub fn filter_appimage_assets(assets: &[GitHubReleaseAsset]) -> Vec<AppImageAsset> {
assets.iter()
.filter(|a| {
let lower = a.name.to_lowercase();
lower.ends_with(".appimage") || lower.ends_with(".appimage.zsync")
})
.filter(|a| !a.name.to_lowercase().ends_with(".zsync"))
.map(|a| AppImageAsset {
name: a.name.clone(),
url: a.browser_download_url.clone(),
size: a.size,
})
.collect()
}
/// Detect the current system architecture string as used in AppImage filenames.
pub fn detect_arch() -> &'static str {
#[cfg(target_arch = "x86_64")]
{ "x86_64" }
#[cfg(target_arch = "aarch64")]
{ "aarch64" }
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
{ std::env::consts::ARCH }
}
/// Pick the best AppImage asset for the current architecture.
/// Returns the matching asset, or the first one if no arch match.
pub fn pick_best_asset(assets: &[AppImageAsset]) -> Option<&AppImageAsset> {
if assets.is_empty() {
return None;
}
let arch = detect_arch();
// Prefer exact arch match in filename
let arch_match = assets.iter().find(|a| {
let lower = a.name.to_lowercase();
lower.contains(&arch.to_lowercase())
});
arch_match.or(assets.first())
}
// --- Enrichment logic ---
/// Enrich a catalog app with repo-level info (stars, pushed_at, description).
pub fn enrich_app_repo_info(
db: &Database,
app_id: i64,
owner: &str,
repo: &str,
token: &str,
) -> Result<u32, String> {
let (info, remaining) = fetch_repo_info(owner, repo, token)?;
db.update_catalog_app_github_metadata(
app_id, info.stargazers_count, info.pushed_at.as_deref(), info.description.as_deref(),
).map_err(|e| format!("DB error: {}", e))?;
Ok(remaining)
}
/// Enrich a catalog app with release info (version, date, downloads, assets).
pub fn enrich_app_release_info(
db: &Database,
app_id: i64,
owner: &str,
repo: &str,
token: &str,
) -> Result<u32, String> {
let (info, remaining) = fetch_release_info(owner, repo, token)?;
// Clean version string (strip leading "v")
let version = info.tag_name.strip_prefix('v')
.unwrap_or(&info.tag_name)
.to_string();
// Sum download counts across all assets
let total_downloads: i64 = info.assets.iter().map(|a| a.download_count).sum();
// Extract AppImage assets and pick the best download URL
let appimage_assets = filter_appimage_assets(&info.assets);
let best_url = pick_best_asset(&appimage_assets).map(|a| a.url.as_str());
let assets_json = if appimage_assets.is_empty() {
None
} else {
serde_json::to_string(&appimage_assets).ok()
};
db.update_catalog_app_release_info(
app_id,
Some(&version),
info.published_at.as_deref(),
if total_downloads > 0 { Some(total_downloads) } else { None },
best_url,
assets_json.as_deref(),
).map_err(|e| format!("DB error: {}", e))?;
Ok(remaining)
}
/// A GitHub release with body text for changelog display.
#[derive(Debug, serde::Deserialize)]
struct GitHubRelease {
tag_name: String,
published_at: Option<String>,
body: Option<String>,
}
/// Fetch up to 10 recent releases for a repo.
fn fetch_recent_releases(owner: &str, repo: &str, token: &str) -> Result<(Vec<GitHubRelease>, u32), String> {
let url = format!("https://api.github.com/repos/{}/{}/releases?per_page=10", owner, repo);
let (body, remaining) = github_get(&url, token)?;
let releases: Vec<GitHubRelease> = serde_json::from_str(&body)
.map_err(|e| format!("Parse error: {}", e))?;
Ok((releases, remaining))
}
/// Enrich a catalog app with release history (version, date, description for last 10 releases).
/// Only populates if the existing release_history is empty.
pub fn enrich_app_release_history(
db: &Database,
app_id: i64,
owner: &str,
repo: &str,
token: &str,
) -> Result<u32, String> {
// Check if release_history is already populated (from AppStream or prior enrichment)
if let Ok(Some(app)) = db.get_catalog_app(app_id) {
if app.release_history.as_ref().is_some_and(|h| !h.is_empty()) {
return Ok(u32::MAX); // already has data, skip
}
}
let (releases, remaining) = fetch_recent_releases(owner, repo, token)?;
if releases.is_empty() {
return Ok(remaining);
}
// Convert to the same JSON format used by AppStream: [{version, date, description}]
let history: Vec<serde_json::Value> = releases.iter().map(|r| {
let version = r.tag_name.strip_prefix('v')
.unwrap_or(&r.tag_name)
.to_string();
let date = r.published_at.as_deref()
.and_then(|d| d.split('T').next())
.unwrap_or("");
let mut obj = serde_json::json!({
"version": version,
"date": date,
});
if let Some(ref body) = r.body {
if !body.is_empty() {
obj["description"] = serde_json::Value::String(body.clone());
}
}
obj
}).collect();
let json = serde_json::to_string(&history)
.map_err(|e| format!("JSON error: {}", e))?;
db.update_catalog_app_release_history(app_id, &json)
.map_err(|e| format!("DB error: {}", e))?;
Ok(remaining)
}
/// Fetch and store the README for a catalog app.
pub fn enrich_app_readme(
db: &Database,
app_id: i64,
owner: &str,
repo: &str,
token: &str,
) -> Result<u32, String> {
let (readme, remaining) = fetch_readme(owner, repo, token)?;
db.update_catalog_app_readme(app_id, &readme)
.map_err(|e| format!("DB error: {}", e))?;
Ok(remaining)
}
/// Background enrichment: process a batch of unenriched apps.
/// Returns (count_enriched, should_continue).
pub fn background_enrich_batch(
db: &Database,
token: &str,
batch_size: i32,
on_progress: &dyn Fn(i64, i64),
) -> Result<(u32, bool), String> {
let apps = db.get_unenriched_catalog_apps(batch_size)
.map_err(|e| format!("DB error: {}", e))?;
if apps.is_empty() {
return Ok((0, false));
}
let mut enriched = 0u32;
for app in &apps {
let owner = match app.github_owner.as_deref() {
Some(o) => o,
None => continue,
};
let repo = match app.github_repo.as_deref() {
Some(r) => r,
None => continue,
};
match enrich_app_repo_info(db, app.id, owner, repo, token) {
Ok(remaining) => {
enriched += 1;
// Also fetch release history (changelog data)
let _ = enrich_app_release_history(db, app.id, owner, repo, token);
// Report progress
if let Ok((done, total)) = db.catalog_enrichment_progress() {
on_progress(done, total);
}
// Stop if rate limit is getting low
if remaining < 5 {
log::info!("GitHub rate limit low ({}), pausing enrichment", remaining);
return Ok((enriched, false));
}
}
Err(e) => {
log::warn!("Failed to enrich {}/{}: {}", owner, repo, e);
// Mark as enriched anyway so we don't retry forever
db.update_catalog_app_github_metadata(app.id, 0, None, None).ok();
}
}
// Sleep between calls to be respectful
std::thread::sleep(std::time::Duration::from_secs(1));
}
Ok((enriched, enriched > 0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_github_repo_from_download() {
let result = extract_github_repo(
None,
"https://github.com/nickvdp/deno-spreadsheets/releases/download/v0.3.0/app.AppImage",
);
assert_eq!(result, Some(("nickvdp".to_string(), "deno-spreadsheets".to_string())));
}
#[test]
fn test_extract_github_repo_from_homepage() {
let result = extract_github_repo(
Some("https://github.com/nickvdp/deno-spreadsheets"),
"https://example.com/download.AppImage",
);
assert_eq!(result, Some(("nickvdp".to_string(), "deno-spreadsheets".to_string())));
}
#[test]
fn test_extract_github_repo_with_git_suffix() {
let result = extract_github_repo(
Some("https://github.com/user/repo.git"),
"https://example.com/download.AppImage",
);
assert_eq!(result, Some(("user".to_string(), "repo".to_string())));
}
#[test]
fn test_extract_github_repo_non_github() {
let result = extract_github_repo(
Some("https://gitlab.com/user/repo"),
"https://sourceforge.net/download.AppImage",
);
assert_eq!(result, None);
}
#[test]
fn test_parse_github_url_empty() {
assert_eq!(parse_github_url(""), None);
assert_eq!(parse_github_url("https://github.com/"), None);
assert_eq!(parse_github_url("https://github.com/user"), None);
}
#[test]
fn test_filter_appimage_assets() {
let assets = vec![
GitHubReleaseAsset {
name: "app-x86_64.AppImage".to_string(),
browser_download_url: "https://github.com/u/r/releases/download/v1/app-x86_64.AppImage".to_string(),
download_count: 100,
size: 50_000_000,
},
GitHubReleaseAsset {
name: "app-aarch64.AppImage".to_string(),
browser_download_url: "https://github.com/u/r/releases/download/v1/app-aarch64.AppImage".to_string(),
download_count: 20,
size: 48_000_000,
},
GitHubReleaseAsset {
name: "app-x86_64.AppImage.zsync".to_string(),
browser_download_url: "https://github.com/u/r/releases/download/v1/app-x86_64.AppImage.zsync".to_string(),
download_count: 5,
size: 1000,
},
GitHubReleaseAsset {
name: "source.tar.gz".to_string(),
browser_download_url: "https://github.com/u/r/releases/download/v1/source.tar.gz".to_string(),
download_count: 10,
size: 2_000_000,
},
];
let filtered = filter_appimage_assets(&assets);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].name, "app-x86_64.AppImage");
assert_eq!(filtered[1].name, "app-aarch64.AppImage");
}
#[test]
fn test_pick_best_asset_prefers_arch() {
let assets = vec![
AppImageAsset {
name: "app-aarch64.AppImage".to_string(),
url: "https://example.com/aarch64".to_string(),
size: 48_000_000,
},
AppImageAsset {
name: "app-x86_64.AppImage".to_string(),
url: "https://example.com/x86_64".to_string(),
size: 50_000_000,
},
];
let best = pick_best_asset(&assets).unwrap();
// On x86_64 systems this should pick x86_64, on aarch64 it picks aarch64
let arch = detect_arch();
assert!(best.name.contains(arch));
}
#[test]
fn test_pick_best_asset_empty() {
let assets: Vec<AppImageAsset> = vec![];
assert!(pick_best_asset(&assets).is_none());
}
#[test]
fn test_pick_best_asset_single() {
let assets = vec![
AppImageAsset {
name: "app.AppImage".to_string(),
url: "https://example.com/app".to_string(),
size: 50_000_000,
},
];
let best = pick_best_asset(&assets).unwrap();
assert_eq!(best.name, "app.AppImage");
}
}

View File

@@ -19,7 +19,7 @@ impl std::fmt::Display for InspectorError {
match self { match self {
Self::IoError(e) => write!(f, "I/O error: {}", e), Self::IoError(e) => write!(f, "I/O error: {}", e),
Self::NoOffset => write!(f, "Could not determine squashfs offset"), Self::NoOffset => write!(f, "Could not determine squashfs offset"),
Self::UnsquashfsNotFound => write!(f, "unsquashfs not found - install squashfs-tools"), Self::UnsquashfsNotFound => write!(f, "A system tool needed to read app contents is missing. Install it by running: sudo apt install squashfs-tools"),
Self::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg), Self::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg),
Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"), Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
} }

View File

@@ -417,6 +417,29 @@ pub fn set_mime_default(
Ok(()) Ok(())
} }
/// Unset this AppImage as the default handler for a MIME type.
/// Restores the previous default if one was recorded, then removes the tracking record.
pub fn unset_mime_default(
db: &Database,
appimage_id: i64,
mime_type: &str,
) -> Result<(), String> {
let mods = db.get_modifications(appimage_id).unwrap_or_default();
for m in &mods {
if m.mod_type == "mime_default" && m.file_path == mime_type {
if let Some(ref prev) = m.previous_value {
Command::new("xdg-mime")
.args(["default", prev, mime_type])
.status()
.map_err(|e| format!("xdg-mime failed: {}", e))?;
}
db.remove_modification(m.id).ok();
return Ok(());
}
}
Err("No tracked default to unset".to_string())
}
/// A system-level default application type that an AppImage can serve as. /// A system-level default application type that an AppImage can serve as.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum DefaultAppType { pub enum DefaultAppType {

View File

@@ -113,7 +113,26 @@ pub fn launch_appimage(
method method
}; };
let result = execute_appimage(appimage_path, &method, extra_args, extra_env); // When sandboxed, ensure a profile exists and resolve its path
let profile_path = if method == LaunchMethod::Sandboxed {
let app_name = appimage_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
// Auto-generate a default profile if none exists yet
if super::sandbox::profile_path_for_app(&app_name).is_none() {
let profile = super::sandbox::generate_default_profile(&app_name);
if let Err(e) = super::sandbox::save_profile(db, &profile) {
log::warn!("Failed to create default sandbox profile: {}", e);
}
}
super::sandbox::profile_path_for_app(&app_name)
} else {
None
};
let result = execute_appimage(appimage_path, &method, extra_args, extra_env, profile_path.as_deref());
// Record the launch event regardless of success // Record the launch event regardless of success
if let Err(e) = db.record_launch(record_id, source) { if let Err(e) = db.record_launch(record_id, source) {
@@ -141,7 +160,7 @@ pub fn launch_appimage_simple(
} }
}; };
execute_appimage(appimage_path, &method, extra_args, &[]) execute_appimage(appimage_path, &method, extra_args, &[], None)
} }
/// Execute the AppImage process with the given method. /// Execute the AppImage process with the given method.
@@ -150,6 +169,7 @@ fn execute_appimage(
method: &LaunchMethod, method: &LaunchMethod,
args: &[String], args: &[String],
extra_env: &[(&str, &str)], extra_env: &[(&str, &str)],
sandbox_profile: Option<&Path>,
) -> LaunchResult { ) -> LaunchResult {
let mut cmd = match method { let mut cmd = match method {
LaunchMethod::Direct => { LaunchMethod::Direct => {
@@ -165,6 +185,9 @@ fn execute_appimage(
} }
LaunchMethod::Sandboxed => { LaunchMethod::Sandboxed => {
let mut c = Command::new("firejail"); let mut c = Command::new("firejail");
if let Some(profile) = sandbox_profile {
c.arg(format!("--profile={}", profile.display()));
}
c.arg("--appimage"); c.arg("--appimage");
c.arg(appimage_path); c.arg(appimage_path);
c.args(args); c.args(args);

View File

@@ -4,6 +4,7 @@ pub mod backup;
pub mod catalog; pub mod catalog;
pub mod database; pub mod database;
pub mod discovery; pub mod discovery;
pub mod github_enrichment;
pub mod duplicates; pub mod duplicates;
pub mod footprint; pub mod footprint;
pub mod fuse; pub mod fuse;
@@ -14,6 +15,7 @@ pub mod notification;
pub mod orphan; pub mod orphan;
pub mod portable; pub mod portable;
pub mod report; pub mod report;
pub mod sandbox;
pub mod security; pub mod security;
pub mod updater; pub mod updater;
pub mod verification; pub mod verification;

View File

@@ -116,18 +116,19 @@ pub fn list_profiles(db: &Database) -> Vec<SandboxProfile> {
/// Search the community registry for sandbox profiles matching an app name. /// Search the community registry for sandbox profiles matching an app name.
/// Uses the GitHub-based registry approach (fetches a JSON index). /// Uses the GitHub-based registry approach (fetches a JSON index).
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SanboxError> { #[allow(dead_code)] // Community registry UI not yet wired
pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<Vec<CommunityProfileEntry>, SandboxError> {
let index_url = format!("{}/index.json", registry_url.trim_end_matches('/')); let index_url = format!("{}/index.json", registry_url.trim_end_matches('/'));
let response = ureq::get(&index_url) let response = ureq::get(&index_url)
.call() .call()
.map_err(|e| SanboxError::Network(e.to_string()))?; .map_err(|e| SandboxError::Network(e.to_string()))?;
let body = response.into_body().read_to_string() let body = response.into_body().read_to_string()
.map_err(|e| SanboxError::Network(e.to_string()))?; .map_err(|e| SandboxError::Network(e.to_string()))?;
let index: CommunityIndex = serde_json::from_str(&body) let index: CommunityIndex = serde_json::from_str(&body)
.map_err(|e| SanboxError::Parse(e.to_string()))?; .map_err(|e| SandboxError::Parse(e.to_string()))?;
let query = app_name.to_lowercase(); let query = app_name.to_lowercase();
let matches: Vec<CommunityProfileEntry> = index.profiles let matches: Vec<CommunityProfileEntry> = index.profiles
@@ -139,16 +140,17 @@ pub fn search_community_profiles(registry_url: &str, app_name: &str) -> Result<V
} }
/// Download a community profile by its URL and save it locally. /// Download a community profile by its URL and save it locally.
#[allow(dead_code)] // Community registry UI not yet wired
pub fn download_community_profile( pub fn download_community_profile(
db: &Database, db: &Database,
entry: &CommunityProfileEntry, entry: &CommunityProfileEntry,
) -> Result<SandboxProfile, SanboxError> { ) -> Result<SandboxProfile, SandboxError> {
let response = ureq::get(&entry.url) let response = ureq::get(&entry.url)
.call() .call()
.map_err(|e| SanboxError::Network(e.to_string()))?; .map_err(|e| SandboxError::Network(e.to_string()))?;
let content = response.into_body().read_to_string() let content = response.into_body().read_to_string()
.map_err(|e| SanboxError::Network(e.to_string()))?; .map_err(|e| SandboxError::Network(e.to_string()))?;
let profile = SandboxProfile { let profile = SandboxProfile {
id: None, id: None,
@@ -163,7 +165,7 @@ pub fn download_community_profile(
}; };
save_profile(db, &profile) save_profile(db, &profile)
.map_err(|e| SanboxError::Io(e.to_string()))?; .map_err(|e| SandboxError::Io(e.to_string()))?;
Ok(profile) Ok(profile)
} }
@@ -221,11 +223,13 @@ pub fn profile_path_for_app(app_name: &str) -> Option<PathBuf> {
// --- Community registry types --- // --- Community registry types ---
#[allow(dead_code)] // Used by community registry search
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityIndex { pub struct CommunityIndex {
pub profiles: Vec<CommunityProfileEntry>, pub profiles: Vec<CommunityProfileEntry>,
} }
#[allow(dead_code)] // Used by community registry search/download
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityProfileEntry { pub struct CommunityProfileEntry {
pub id: String, pub id: String,
@@ -240,16 +244,12 @@ pub struct CommunityProfileEntry {
// --- Error types --- // --- Error types ---
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)] // Network + Parse variants used by community registry functions
pub enum SandboxError { pub enum SandboxError {
Io(String), Io(String),
Database(String), Database(String),
}
#[derive(Debug)]
pub enum SanboxError {
Network(String), Network(String),
Parse(String), Parse(String),
Io(String),
} }
impl std::fmt::Display for SandboxError { impl std::fmt::Display for SandboxError {
@@ -257,16 +257,8 @@ impl std::fmt::Display for SandboxError {
match self { match self {
Self::Io(e) => write!(f, "I/O error: {}", e), Self::Io(e) => write!(f, "I/O error: {}", e),
Self::Database(e) => write!(f, "Database error: {}", e), Self::Database(e) => write!(f, "Database error: {}", e),
}
}
}
impl std::fmt::Display for SanboxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Network(e) => write!(f, "Network error: {}", e), Self::Network(e) => write!(f, "Network error: {}", e),
Self::Parse(e) => write!(f, "Parse error: {}", e), Self::Parse(e) => write!(f, "Parse error: {}", e),
Self::Io(e) => write!(f, "I/O error: {}", e),
} }
} }
} }
@@ -379,6 +371,10 @@ mod tests {
assert!(format!("{}", err).contains("permission denied")); assert!(format!("{}", err).contains("permission denied"));
let err = SandboxError::Database("db locked".to_string()); let err = SandboxError::Database("db locked".to_string());
assert!(format!("{}", err).contains("db locked")); assert!(format!("{}", err).contains("db locked"));
let err = SandboxError::Network("connection refused".to_string());
assert!(format!("{}", err).contains("connection refused"));
let err = SandboxError::Parse("invalid json".to_string());
assert!(format!("{}", err).contains("invalid json"));
} }
#[test] #[test]

View File

@@ -24,23 +24,31 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
); );
icon_widget.add_css_class("icon-dropshadow"); icon_widget.add_css_class("icon-dropshadow");
if record.integrated { let icon_overlay = gtk::Overlay::new();
let overlay = gtk::Overlay::new(); icon_overlay.set_child(Some(&icon_widget));
overlay.set_child(Some(&icon_widget));
if record.integrated {
let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic"); let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic");
emblem.set_pixel_size(16); emblem.set_pixel_size(16);
emblem.add_css_class("integration-emblem"); emblem.add_css_class("integration-emblem");
emblem.set_halign(gtk::Align::End); emblem.set_halign(gtk::Align::End);
emblem.set_valign(gtk::Align::End); emblem.set_valign(gtk::Align::End);
emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]); emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]);
overlay.add_overlay(&emblem); icon_overlay.add_overlay(&emblem);
card.append(&overlay);
} else {
card.append(&icon_widget);
} }
if record.sandbox_mode.as_deref() == Some("firejail") {
let shield = gtk::Image::from_icon_name("security-high-symbolic");
shield.set_pixel_size(16);
shield.set_halign(gtk::Align::Start);
shield.set_valign(gtk::Align::End);
shield.set_tooltip_text(Some("This app runs with security restrictions (Firejail sandbox)"));
shield.update_property(&[AccessibleProperty::Label("Sandboxed with Firejail")]);
icon_overlay.add_overlay(&shield);
}
card.append(&icon_overlay);
// App name // App name
let name_label = gtk::Label::builder() let name_label = gtk::Label::builder()
.label(name) .label(name)
@@ -101,6 +109,7 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
.child(&card) .child(&card)
.build(); .build();
child.add_css_class("activatable"); child.add_css_class("activatable");
super::widgets::set_pointer_cursor(&child);
// Accessible label for screen readers // Accessible label for screen readers
let accessible_name = build_accessible_label(record); let accessible_name = build_accessible_label(record);
@@ -132,16 +141,25 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
"native_fuse" | "static_runtime" | "fully_functional" | "extract_and_run" "native_fuse" | "static_runtime" | "fully_functional" | "extract_and_run"
); );
if !is_ok { if !is_ok {
return Some(widgets::status_badge("Needs setup", "warning")); let badge = widgets::status_badge("Needs setup", "warning");
badge.set_tooltip_text(Some("FUSE (Filesystem in Userspace) is required to run this AppImage"));
return Some(badge);
} }
} }
// 3. Portable / removable media // 3. Portable / removable media
if record.is_portable { if record.is_portable {
return Some(widgets::status_badge("Portable", "info")); let badge = widgets::status_badge("Portable", "info");
badge.set_tooltip_text(Some("This app is stored on removable media"));
return Some(badge);
} }
None // 4. Fallback: integration status
if record.integrated {
return Some(widgets::status_badge("Ready", "success"));
}
Some(widgets::status_badge("Not in menu", "neutral"))
} }
/// Build a descriptive accessible label for screen readers. /// Build a descriptive accessible label for screen readers.

View File

@@ -6,6 +6,7 @@ use std::rc::Rc;
use crate::core::database::Database; use crate::core::database::Database;
use crate::core::updater; use crate::core::updater;
use crate::i18n::{i18n, i18n_f}; use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// Show a dialog to update all AppImages that have updates available. /// Show a dialog to update all AppImages that have updates available.
pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database>) { pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database>) {
@@ -64,12 +65,16 @@ pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database
.show_text(true) .show_text(true)
.text(&i18n("Ready")) .text(&i18n("Ready"))
.build(); .build();
overall_progress.update_property(&[gtk::accessible::Property::Label("Overall update progress")]);
content.append(&overall_progress); content.append(&overall_progress);
// List of apps to update // List of apps to update
let list_box = gtk::ListBox::new(); let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list"); list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None); list_box.set_selection_mode(gtk::SelectionMode::None);
list_box.update_property(&[
gtk::accessible::Property::Label(&i18n("Apps to update")),
]);
let mut row_data: Vec<(i64, String, String, String, adw::ActionRow, gtk::Label)> = Vec::new(); let mut row_data: Vec<(i64, String, String, String, adw::ActionRow, gtk::Label)> = Vec::new();
@@ -86,6 +91,7 @@ pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database
let status_badge = gtk::Label::builder() let status_badge = gtk::Label::builder()
.label(&i18n("Pending")) .label(&i18n("Pending"))
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.accessible_role(gtk::AccessibleRole::Status)
.build(); .build();
status_badge.add_css_class("dim-label"); status_badge.add_css_class("dim-label");
row.add_suffix(&status_badge); row.add_suffix(&status_badge);
@@ -260,6 +266,7 @@ pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database
status.set_label(&summary); status.set_label(&summary);
progress_bar.set_text(Some(&i18n("Complete"))); progress_bar.set_text(Some(&i18n("Complete")));
progress_bar.set_fraction(1.0); progress_bar.set_fraction(1.0);
widgets::announce(progress_bar.upcast_ref::<gtk::Widget>(), &summary);
// Change cancel to Close // Change cancel to Close
if let Some(d) = dialog_weak.upgrade() { if let Some(d) = dialog_weak.upgrade() {

1429
src/ui/catalog_detail.rs Normal file

File diff suppressed because it is too large Load Diff

591
src/ui/catalog_tile.rs Normal file
View File

@@ -0,0 +1,591 @@
use gtk::prelude::*;
use gtk::accessible::Property as AccessibleProperty;
use crate::core::database::CatalogApp;
use super::widgets;
/// Build a catalog tile for the browse grid.
/// Left-aligned layout: icon (48px) at top, name, description, category badge.
/// Card fills its entire FlowBoxChild cell.
/// If `installed` is true, an "Installed" badge is shown on the card.
pub fn build_catalog_tile(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
card.add_css_class("card");
card.add_css_class("catalog-tile");
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.vexpand(true)
.build();
// Icon (48px) - left aligned
let icon = widgets::app_icon(None, &app.name, 48);
icon.add_css_class("icon-dropshadow");
icon.set_halign(gtk::Align::Start);
inner.append(&icon);
// App name - left aligned
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
inner.append(&name_label);
// Description (always 2 lines for uniform height) - prefer OCS summary, then GitHub description
let plain = app.ocs_summary.as_deref()
.filter(|d| !d.is_empty())
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
.or(app.description.as_deref().filter(|d| !d.is_empty()))
.map(|d| strip_html(d))
.unwrap_or_default();
let snippet: String = plain.chars().take(80).collect();
let text = if plain.is_empty() {
// Non-breaking space placeholder to reserve 2 lines
"\u{00a0}".to_string()
} else if snippet.len() < plain.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
let desc_label = gtk::Label::builder()
.label(&text)
.css_classes(["caption", "dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(2)
.wrap(true)
.xalign(0.0)
.max_width_chars(24)
.halign(gtk::Align::Start)
.build();
// Force 2-line height
desc_label.set_height_request(desc_label.preferred_size().1.height().max(36));
inner.append(&desc_label);
// Stats row (downloads + stars + version) - only if data exists
let has_downloads = app.ocs_downloads.is_some_and(|d| d > 0);
let has_stars = app.github_stars.is_some_and(|s| s > 0);
let has_version = app.latest_version.is_some();
if has_downloads || has_stars || has_version {
let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Start)
.build();
stats_row.add_css_class("catalog-stats-row");
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
let dl_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let dl_icon = gtk::Image::from_icon_name("folder-download-symbolic");
dl_icon.set_pixel_size(12);
dl_icon.update_property(&[AccessibleProperty::Label("Downloads")]);
dl_box.append(&dl_icon);
let dl_label = gtk::Label::new(Some(&widgets::format_count(downloads)));
dl_label.add_css_class("caption");
dl_label.add_css_class("dim-label");
dl_box.append(&dl_label);
dl_box.set_tooltip_text(Some(&format!("{} downloads", downloads)));
stats_row.append(&dl_box);
}
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let star_icon = gtk::Image::from_icon_name("starred-symbolic");
star_icon.set_pixel_size(12);
star_icon.update_property(&[AccessibleProperty::Label("Stars")]);
star_box.append(&star_icon);
let star_label = gtk::Label::new(Some(&widgets::format_count(stars)));
star_box.append(&star_label);
stats_row.append(&star_box);
}
if let Some(ref ver) = app.latest_version {
let ver_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let ver_icon = gtk::Image::from_icon_name("tag-symbolic");
ver_icon.set_pixel_size(12);
ver_icon.update_property(&[AccessibleProperty::Label("Version")]);
ver_box.append(&ver_icon);
let ver_label = gtk::Label::builder()
.label(ver.as_str())
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(12)
.build();
ver_box.append(&ver_label);
stats_row.append(&ver_box);
}
inner.append(&stats_row);
}
// Category badge - left aligned
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';')
.next()
.or_else(|| cats.split(',').next())
.unwrap_or("")
.trim()
.to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "neutral");
badge.set_halign(gtk::Align::Start);
badge.set_margin_top(2);
inner.append(&badge);
}
}
// Installed badge
if installed {
let installed_badge = widgets::status_badge("Installed", "success");
installed_badge.set_halign(gtk::Align::Start);
installed_badge.set_margin_top(4);
inner.append(&installed_badge);
}
card.append(&inner);
let child = gtk::FlowBoxChild::builder()
.child(&card)
.build();
child.add_css_class("activatable");
widgets::set_pointer_cursor(&child);
// Accessible label for screen readers
let mut a11y_parts = vec![app.name.clone()];
if !plain.is_empty() {
a11y_parts.push(plain.chars().take(80).collect());
}
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
a11y_parts.push(format!("{} downloads", downloads));
}
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
a11y_parts.push(format!("{} stars", stars));
}
if let Some(ref cats) = app.categories {
if let Some(cat) = cats.split(';').next().or_else(|| cats.split(',').next()) {
let cat = cat.trim();
if !cat.is_empty() {
a11y_parts.push(format!("category: {}", cat));
}
}
}
if installed {
a11y_parts.push("installed".to_string());
}
child.update_property(&[AccessibleProperty::Label(&a11y_parts.join(", "))]);
child
}
/// Build a compact list-row tile for the browse grid in list mode.
/// Horizontal layout: icon (32px) | name | description snippet | stats.
pub fn build_catalog_row(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild {
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Fill)
.hexpand(true)
.build();
row.add_css_class("card");
row.add_css_class("catalog-row");
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.hexpand(true)
.build();
// Icon (32px)
let icon = widgets::app_icon(None, &app.name, 32);
icon.set_valign(gtk::Align::Center);
inner.append(&icon);
// Name
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(18)
.xalign(0.0)
.width_chars(14)
.build();
inner.append(&name_label);
// Description (single line)
let plain = app.ocs_summary.as_deref()
.filter(|d| !d.is_empty())
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
.or(app.description.as_deref().filter(|d| !d.is_empty()))
.map(|d| strip_html(d))
.unwrap_or_default();
let desc_label = gtk::Label::builder()
.label(&plain)
.css_classes(["dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.hexpand(true)
.xalign(0.0)
.build();
inner.append(&desc_label);
// Stats (compact)
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
let dl_label = gtk::Label::builder()
.label(&format!("{} dl", widgets::format_count(downloads)))
.css_classes(["caption", "dim-label"])
.build();
inner.append(&dl_label);
} else if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_label = gtk::Label::builder()
.label(&format!("{} stars", widgets::format_count(stars)))
.css_classes(["caption", "dim-label"])
.build();
inner.append(&star_label);
}
// Installed badge
if installed {
let badge = widgets::status_badge("Installed", "success");
badge.set_valign(gtk::Align::Center);
inner.append(&badge);
}
row.append(&inner);
let child = gtk::FlowBoxChild::builder()
.child(&row)
.build();
child.add_css_class("activatable");
widgets::set_pointer_cursor(&child);
// Accessible label for screen readers
let mut a11y_parts = vec![app.name.clone()];
if !plain.is_empty() {
a11y_parts.push(plain.chars().take(60).collect());
}
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
a11y_parts.push(format!("{} downloads", downloads));
} else if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
a11y_parts.push(format!("{} stars", stars));
}
if installed {
a11y_parts.push("installed".to_string());
}
child.update_property(&[AccessibleProperty::Label(&a11y_parts.join(", "))]);
child
}
/// Build a featured banner card for the carousel.
/// Layout: screenshot preview on top, then icon + name + description + badge below.
/// Width is set dynamically by the carousel layout.
pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
card.add_css_class("card");
card.add_css_class("catalog-featured-card");
card.add_css_class("activatable");
widgets::set_pointer_cursor(&card);
card.set_widget_name(&format!("featured-{}", app.id));
// Accessible label for screen readers
let mut a11y_parts = vec![app.name.clone()];
if let Some(desc) = app.ocs_summary.as_deref()
.filter(|d| !d.is_empty())
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
{
a11y_parts.push(strip_html(desc).chars().take(60).collect());
}
if let Some(ref cats) = app.categories {
if let Some(cat) = cats.split(';').next().or_else(|| cats.split(',').next()) {
let cat = cat.trim();
if !cat.is_empty() {
a11y_parts.push(format!("category: {}", cat));
}
}
}
card.update_property(&[AccessibleProperty::Label(&a11y_parts.join(", "))]);
// Screenshot preview area (top)
let screenshot_frame = gtk::Frame::new(None);
screenshot_frame.add_css_class("catalog-featured-screenshot");
screenshot_frame.set_height_request(160);
screenshot_frame.set_hexpand(true);
// Spinner placeholder until image loads
let spinner = gtk::Spinner::builder()
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.spinning(true)
.width_request(32)
.height_request(32)
.build();
spinner.update_property(&[gtk::accessible::Property::Label("Loading screenshot")]);
screenshot_frame.set_child(Some(&spinner));
card.append(&screenshot_frame);
// Info section below screenshot
let info_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(10)
.margin_bottom(10)
.margin_start(12)
.margin_end(12)
.build();
// Icon (48px)
let icon = widgets::app_icon(None, &app.name, 48);
icon.add_css_class("icon-dropshadow");
icon.set_valign(gtk::Align::Start);
info_box.append(&icon);
// Text column
let text_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(2)
.valign(gtk::Align::Center)
.hexpand(true)
.build();
// App name
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(28)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
text_box.append(&name_label);
// Description (1 line in featured since space is tight) - prefer OCS summary
let feat_desc = app.ocs_summary.as_deref()
.filter(|d| !d.is_empty())
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
.or(app.description.as_deref().filter(|d| !d.is_empty()));
if let Some(desc) = feat_desc {
let plain = strip_html(desc);
let snippet: String = plain.chars().take(60).collect();
let text = if snippet.len() < plain.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
let desc_label = gtk::Label::builder()
.label(&text)
.css_classes(["caption", "dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(1)
.xalign(0.0)
.max_width_chars(35)
.halign(gtk::Align::Start)
.build();
text_box.append(&desc_label);
}
// Badge row: category + downloads/stars
let badge_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_top(2)
.build();
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';')
.next()
.or_else(|| cats.split(',').next())
.unwrap_or("")
.trim()
.to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "info");
badge.set_halign(gtk::Align::Start);
badge_row.append(&badge);
}
}
if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) {
let dl_badge = widgets::status_badge_with_icon(
"folder-download-symbolic",
&widgets::format_count(downloads),
"neutral",
);
dl_badge.set_halign(gtk::Align::Start);
badge_row.append(&dl_badge);
} else if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_badge = widgets::status_badge_with_icon(
"starred-symbolic",
&widgets::format_count(stars),
"neutral",
);
star_badge.set_halign(gtk::Align::Start);
badge_row.append(&star_badge);
}
text_box.append(&badge_row);
info_box.append(&text_box);
card.append(&info_box);
card
}
/// Convert HTML to readable formatted plain text, preserving paragraph breaks,
/// line breaks, and list structure. Suitable for detail page descriptions.
pub fn html_to_description(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut tag_buf = String::new();
for ch in html.chars() {
match ch {
'<' => {
in_tag = true;
tag_buf.clear();
}
'>' if in_tag => {
in_tag = false;
let tag = tag_buf.trim().to_lowercase();
let tag_name = tag.split_whitespace().next().unwrap_or("");
match tag_name {
"br" | "br/" => result.push('\n'),
"/p" => result.push_str("\n\n"),
"li" => result.push_str("\n - "),
"/ul" | "/ol" => result.push('\n'),
s if s.starts_with("/h") => result.push_str("\n\n"),
_ => {}
}
}
_ if in_tag => tag_buf.push(ch),
_ => result.push(ch),
}
}
// Decode HTML entities
let decoded = result
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ");
// Clean up: trim lines, collapse multiple blank lines
let trimmed: Vec<&str> = decoded.lines().map(|l| l.trim()).collect();
let mut cleaned = String::new();
let mut prev_blank = false;
for line in &trimmed {
if line.is_empty() {
if !prev_blank && !cleaned.is_empty() {
cleaned.push('\n');
prev_blank = true;
}
} else {
if prev_blank {
cleaned.push('\n');
}
cleaned.push_str(line);
cleaned.push('\n');
prev_blank = false;
}
}
cleaned.trim().to_string()
}
/// Strip all HTML tags and collapse whitespace. Suitable for short descriptions on tiles.
pub fn strip_html(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
// Collapse whitespace
let collapsed: String = result.split_whitespace().collect::<Vec<&str>>().join(" ");
// Decode common HTML entities
collapsed
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_html_basic() {
assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
}
#[test]
fn test_strip_html_nested() {
assert_eq!(
strip_html("<p>Hello <b>bold</b> world</p>"),
"Hello bold world"
);
}
#[test]
fn test_strip_html_entities() {
assert_eq!(strip_html("&amp; &lt; &gt; &quot;"), "& < > \"");
}
#[test]
fn test_strip_html_multiline() {
let input = "<p>Line one</p>\n<p>Line two</p>";
assert_eq!(strip_html(input), "Line one Line two");
}
#[test]
fn test_strip_html_list() {
let input = "<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>";
assert_eq!(strip_html(input), "Item 1 Item 2");
}
#[test]
fn test_strip_html_plain_text() {
assert_eq!(strip_html("No HTML here"), "No HTML here");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,7 @@ pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
let stack_for_complete = stack_ref.clone(); let stack_for_complete = stack_ref.clone();
let dialog_for_complete = dialog_ref.clone(); let dialog_for_complete = dialog_ref.clone();
let item_count = items_ref.borrow().len();
let review = build_review_step( let review = build_review_step(
&items_for_review, &items_for_review,
move |selected_items| { move |selected_items| {
@@ -115,6 +116,10 @@ pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
} }
stack_ref.add_named(&review, Some("review")); stack_ref.add_named(&review, Some("review"));
stack_ref.set_visible_child_name("review"); stack_ref.set_visible_child_name("review");
widgets::announce(
stack_ref.upcast_ref::<gtk::Widget>(),
&format!("Analysis complete. Found {} reclaimable items.", item_count),
);
} }
Err(_) => { Err(_) => {
let error_page = adw::StatusPage::builder() let error_page = adw::StatusPage::builder()
@@ -146,6 +151,7 @@ fn build_analysis_step() -> gtk::Box {
.height_request(48) .height_request(48)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.build(); .build();
spinner.update_property(&[gtk::accessible::Property::Label("Analyzing disk usage")]);
page.append(&spinner); page.append(&spinner);
let label = gtk::Label::builder() let label = gtk::Label::builder()
@@ -251,6 +257,7 @@ fn build_review_step(
.build(); .build();
let cat_icon = gtk::Image::from_icon_name(cat.icon_name()); let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
cat_icon.set_pixel_size(16); cat_icon.set_pixel_size(16);
cat_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
cat_header.append(&cat_icon); cat_header.append(&cat_icon);
let cat_label_text = cat.label(); let cat_label_text = cat.label();
let cat_label = gtk::Label::builder() let cat_label = gtk::Label::builder()
@@ -273,6 +280,7 @@ fn build_review_step(
.active(item.selected) .active(item.selected)
.valign(gtk::Align::Center) .valign(gtk::Align::Center)
.build(); .build();
check.update_property(&[gtk::accessible::Property::Label(&item.label)]);
check_buttons.borrow_mut().push((*idx, check.clone())); check_buttons.borrow_mut().push((*idx, check.clone()));
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
@@ -403,6 +411,12 @@ fn execute_cleanup(
} }
stack.add_named(&complete, Some("complete")); stack.add_named(&complete, Some("complete"));
stack.set_visible_child_name("complete"); stack.set_visible_child_name("complete");
if total_count > 0 {
widgets::announce(
stack.upcast_ref::<gtk::Widget>(),
&format!("Cleanup complete. Removed {} items, freed {}.", total_count, widgets::format_size(total_size as i64)),
);
}
} }
fn build_complete_step(count: usize, size: u64, dialog: &adw::Dialog) -> gtk::Box { fn build_complete_step(count: usize, size: u64, dialog: &adw::Dialog) -> gtk::Box {

View File

@@ -7,7 +7,7 @@ use crate::core::database::Database;
use crate::core::duplicates; use crate::core::duplicates;
use crate::core::fuse; use crate::core::fuse;
use crate::core::wayland; use crate::core::wayland;
use crate::i18n::ni18n; use crate::i18n::{i18n, ni18n};
use super::widgets; use super::widgets;
/// Build the dashboard page showing system health and statistics. /// Build the dashboard page showing system health and statistics.
@@ -30,14 +30,61 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
let fuse_info = fuse::detect_system_fuse(); let fuse_info = fuse::detect_system_fuse();
if !fuse_info.status.is_functional() { if !fuse_info.status.is_functional() {
let banner = adw::Banner::builder() let banner = adw::Banner::builder()
.title("FUSE is not working - some AppImages may not launch") .title(&i18n("FUSE is not working - some AppImages may not launch"))
.button_label("Fix Now") .button_label(&i18n("Fix Now"))
.revealed(true) .revealed(true)
.accessible_role(gtk::AccessibleRole::Alert)
.build(); .build();
banner.set_action_name(Some("win.fix-fuse")); banner.set_action_name(Some("win.fix-fuse"));
content.append(&banner); content.append(&banner);
} }
// Getting Started checklist (shown for new users)
let records = db.get_all_appimages().unwrap_or_default();
let total_count = records.len();
let integrated_count = records.iter().filter(|r| r.integrated).count();
if total_count < 3 {
let started_group = adw::PreferencesGroup::builder()
.title("Getting Started")
.description("New to Driftwood? Here are three steps to get you up and running.")
.build();
let scan_row = adw::ActionRow::builder()
.title("Scan your system for apps")
.subtitle("Look for AppImage files in your configured folders")
.activatable(true)
.build();
scan_row.set_action_name(Some("win.scan"));
if total_count > 0 {
let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success");
check.set_valign(gtk::Align::Center);
scan_row.add_suffix(&check);
}
started_group.add(&scan_row);
let catalog_row = adw::ActionRow::builder()
.title("Browse the app catalog")
.subtitle("Discover and install apps from the AppImage ecosystem")
.activatable(true)
.build();
catalog_row.set_action_name(Some("win.catalog"));
catalog_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Open catalog")));
started_group.add(&catalog_row);
let menu_row = adw::ActionRow::builder()
.title("Add an app to your launcher")
.subtitle("Make an app findable in your application menu")
.build();
if integrated_count > 0 {
let check = widgets::status_badge_with_icon("emblem-ok-symbolic", "Done", "success");
check.set_valign(gtk::Align::Center);
menu_row.add_suffix(&check);
}
started_group.add(&menu_row);
content.append(&started_group);
}
// Section 1: System Status // Section 1: System Status
content.append(&build_system_status_group()); content.append(&build_system_status_group());
@@ -69,6 +116,7 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled)); toolbar.set_content(Some(&scrolled));
widgets::apply_pointer_cursors(&toolbar);
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title("Dashboard") .title("Dashboard")
@@ -88,6 +136,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let session_row = adw::ActionRow::builder() let session_row = adw::ActionRow::builder()
.title("Display server") .title("Display server")
.subtitle(session.label()) .subtitle(session.label())
.tooltip_text("How your system draws windows on screen")
.build(); .build();
let session_badge = widgets::status_badge( let session_badge = widgets::status_badge(
session.label(), session.label(),
@@ -106,15 +155,16 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let de_row = adw::ActionRow::builder() let de_row = adw::ActionRow::builder()
.title("Desktop environment") .title("Desktop environment")
.subtitle(&de) .subtitle(&de)
.tooltip_text("Your desktop interface")
.build(); .build();
group.add(&de_row); group.add(&de_row);
// FUSE status // FUSE status
let fuse_info = fuse::detect_system_fuse(); let fuse_info = fuse::detect_system_fuse();
let fuse_row = adw::ActionRow::builder() let fuse_row = adw::ActionRow::builder()
.title("FUSE") .title("App compatibility")
.subtitle(&fuse_description(&fuse_info)) .subtitle(&fuse_description(&fuse_info))
.tooltip_text("Filesystem in Userspace - required for mounting AppImages") .tooltip_text("Most AppImages need a system component called FUSE to run. This shows whether it is set up correctly.")
.build(); .build();
let fuse_badge = widgets::status_badge( let fuse_badge = widgets::status_badge(
fuse_info.status.label(), fuse_info.status.label(),
@@ -127,7 +177,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
// Install hint if FUSE not functional // Install hint if FUSE not functional
if let Some(ref hint) = fuse_info.install_hint { if let Some(ref hint) = fuse_info.install_hint {
let hint_row = adw::ActionRow::builder() let hint_row = adw::ActionRow::builder()
.title("Fix FUSE") .title("Fix app compatibility")
.subtitle(hint) .subtitle(hint)
.subtitle_selectable(true) .subtitle_selectable(true)
.build(); .build();
@@ -140,7 +190,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let xwayland_row = adw::ActionRow::builder() let xwayland_row = adw::ActionRow::builder()
.title("XWayland") .title("XWayland")
.subtitle(if has_xwayland { "Running" } else { "Not detected" }) .subtitle(if has_xwayland { "Running" } else { "Not detected" })
.tooltip_text("X11 compatibility layer for Wayland desktops") .tooltip_text("Compatibility layer that lets older apps run on modern displays")
.build(); .build();
let xwayland_badge = widgets::status_badge( let xwayland_badge = widgets::status_badge(
if has_xwayland { "Available" } else { "Unavailable" }, if has_xwayland { "Available" } else { "Unavailable" },
@@ -206,9 +256,7 @@ fn build_library_stats_group(db: &Rc<Database>) -> adw::PreferencesGroup {
.subtitle(&total.to_string()) .subtitle(&total.to_string())
.activatable(true) .activatable(true)
.build(); .build();
let total_arrow = gtk::Image::from_icon_name("go-next-symbolic"); total_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View library")));
total_arrow.set_valign(gtk::Align::Center);
total_row.add_suffix(&total_arrow);
total_row.set_action_name(Some("navigation.pop")); total_row.set_action_name(Some("navigation.pop"));
group.add(&total_row); group.add(&total_row);
@@ -276,9 +324,7 @@ fn build_updates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
updates_row.add_suffix(&badge); updates_row.add_suffix(&badge);
} }
let updates_arrow = gtk::Image::from_icon_name("go-next-symbolic"); updates_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View updates")));
updates_arrow.set_valign(gtk::Align::Center);
updates_row.add_suffix(&updates_arrow);
group.add(&updates_row); group.add(&updates_row);
if with_updates > 0 { if with_updates > 0 {
@@ -291,9 +337,7 @@ fn build_updates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
let update_badge = widgets::status_badge("Go", "suggested"); let update_badge = widgets::status_badge("Go", "suggested");
update_badge.set_valign(gtk::Align::Center); update_badge.set_valign(gtk::Align::Center);
update_all_row.add_suffix(&update_badge); update_all_row.add_suffix(&update_badge);
let arrow = gtk::Image::from_icon_name("go-next-symbolic"); update_all_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Update all apps")));
arrow.set_valign(gtk::Align::Center);
update_all_row.add_suffix(&arrow);
group.add(&update_all_row); group.add(&update_all_row);
} }
@@ -340,9 +384,7 @@ fn build_duplicates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
.activatable(true) .activatable(true)
.build(); .build();
groups_row.set_action_name(Some("win.find-duplicates")); groups_row.set_action_name(Some("win.find-duplicates"));
let dupes_arrow = gtk::Image::from_icon_name("go-next-symbolic"); groups_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View duplicates")));
dupes_arrow.set_valign(gtk::Align::Center);
groups_row.add_suffix(&dupes_arrow);
group.add(&groups_row); group.add(&groups_row);
if summary.total_potential_savings > 0 { if summary.total_potential_savings > 0 {
@@ -376,9 +418,7 @@ fn build_disk_usage_group(db: &Rc<Database>) -> adw::PreferencesGroup {
.activatable(true) .activatable(true)
.build(); .build();
total_row.set_action_name(Some("win.cleanup")); total_row.set_action_name(Some("win.cleanup"));
let disk_arrow = gtk::Image::from_icon_name("go-next-symbolic"); total_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Clean up disk")));
disk_arrow.set_valign(gtk::Align::Center);
total_row.add_suffix(&disk_arrow);
group.add(&total_row); group.add(&total_row);
// Largest AppImages // Largest AppImages
@@ -452,7 +492,7 @@ fn build_quick_actions_group() -> adw::PreferencesGroup {
scan_btn.add_css_class("suggested-action"); scan_btn.add_css_class("suggested-action");
scan_btn.set_action_name(Some("win.scan")); scan_btn.set_action_name(Some("win.scan"));
scan_btn.update_property(&[ scan_btn.update_property(&[
gtk::accessible::Property::Label("Scan for AppImages"), gtk::accessible::Property::Description("Scan for AppImages in configured directories"),
]); ]);
let updates_btn = gtk::Button::builder() let updates_btn = gtk::Button::builder()
@@ -462,7 +502,7 @@ fn build_quick_actions_group() -> adw::PreferencesGroup {
updates_btn.add_css_class("pill"); updates_btn.add_css_class("pill");
updates_btn.set_action_name(Some("win.check-updates")); updates_btn.set_action_name(Some("win.check-updates"));
updates_btn.update_property(&[ updates_btn.update_property(&[
gtk::accessible::Property::Label("Check for updates"), gtk::accessible::Property::Description("Check all AppImages for available updates"),
]); ]);
let clean_btn = gtk::Button::builder() let clean_btn = gtk::Button::builder()
@@ -472,7 +512,7 @@ fn build_quick_actions_group() -> adw::PreferencesGroup {
clean_btn.add_css_class("pill"); clean_btn.add_css_class("pill");
clean_btn.set_action_name(Some("win.clean-orphans")); clean_btn.set_action_name(Some("win.clean-orphans"));
clean_btn.update_property(&[ clean_btn.update_property(&[
gtk::accessible::Property::Label("Clean orphaned desktop entries"), gtk::accessible::Property::Description("Remove orphaned desktop entries and icons"),
]); ]);
button_box.append(&scan_btn); button_box.append(&scan_btn);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ use crate::core::database::Database;
use crate::core::discovery; use crate::core::discovery;
use crate::core::inspector; use crate::core::inspector;
use crate::i18n::{i18n, ni18n_f}; use crate::i18n::{i18n, ni18n_f};
use super::widgets;
/// Registered file info returned by the fast registration phase. /// Registered file info returned by the fast registration phase.
struct RegisteredFile { struct RegisteredFile {
@@ -50,7 +51,13 @@ pub fn show_drop_dialog(
}; };
let body = if count == 1 { let body = if count == 1 {
files[0].to_string_lossy().to_string() let path_str = files[0].to_string_lossy().to_string();
let size = std::fs::metadata(&files[0]).map(|m| m.len()).unwrap_or(0);
if size > 0 {
format!("{}\n({})", path_str, super::widgets::format_size(size as i64))
} else {
path_str
}
} else { } else {
files files
.iter() .iter()
@@ -81,15 +88,16 @@ pub fn show_drop_dialog(
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.margin_top(12) .margin_top(12)
.build(); .build();
image.update_property(&[gtk::accessible::Property::Label("App icon preview")]);
dialog_ref.set_extra_child(Some(&image)); dialog_ref.set_extra_child(Some(&image));
} }
}); });
} }
dialog.add_response("cancel", &i18n("Cancel")); dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("keep-in-place", &i18n("Keep in place")); dialog.add_response("keep-in-place", &i18n("Run Portable"));
dialog.add_response("copy-only", &i18n("Copy to Applications")); dialog.add_response("copy-only", &i18n("Copy to Apps"));
dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu")); dialog.add_response("copy-and-integrate", &i18n("Copy & Add to Launcher"));
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("copy-and-integrate")); dialog.set_default_response(Some("copy-and-integrate"));
@@ -124,7 +132,7 @@ pub fn show_drop_dialog(
// Show toast // Show toast
if added == 1 { if added == 1 {
toast_ref.add_toast(adw::Toast::new(&i18n("Added to your apps"))); toast_ref.add_toast(widgets::info_toast(&i18n("Added to your apps")));
} else if added > 0 { } else if added > 0 {
let msg = ni18n_f( let msg = ni18n_f(
"Added {} app", "Added {} app",
@@ -132,7 +140,7 @@ pub fn show_drop_dialog(
added as u32, added as u32,
&[("{}", &added.to_string())], &[("{}", &added.to_string())],
); );
toast_ref.add_toast(adw::Toast::new(&msg)); toast_ref.add_toast(widgets::info_toast(&msg));
} }
// Phase 2: Background analysis for each file // Phase 2: Background analysis for each file
@@ -157,11 +165,11 @@ pub fn show_drop_dialog(
} }
Ok(Err(e)) => { Ok(Err(e)) => {
log::error!("Drop processing failed: {}", e); log::error!("Drop processing failed: {}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app"))); toast_ref.add_toast(widgets::error_toast(&i18n("Failed to add app")));
} }
Err(e) => { Err(e) => {
log::error!("Drop task failed: {:?}", e); log::error!("Drop task failed: {:?}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app"))); toast_ref.add_toast(widgets::error_toast(&i18n("Failed to add app")));
} }
} }
}); });

View File

@@ -142,7 +142,7 @@ pub fn show_duplicate_dialog(
removed_count += 1; removed_count += 1;
} }
if removed_count > 0 { if removed_count > 0 {
toast_confirm.add_toast(adw::Toast::new(&ni18n_f( toast_confirm.add_toast(widgets::info_toast(&ni18n_f(
"Removed {count} item", "Removed {count} item",
"Removed {count} items", "Removed {count} items",
removed_count as u32, removed_count as u32,
@@ -270,7 +270,7 @@ fn build_group_widget(
db_ref.remove_appimage(record_id).ok(); db_ref.remove_appimage(record_id).ok();
// Update UI // Update UI
btn.set_sensitive(false); btn.set_sensitive(false);
toast_ref.add_toast(adw::Toast::new(&i18n_f("Removed {name}", &[("{name}", &record_name)]))); toast_ref.add_toast(widgets::info_toast(&i18n_f("Removed {name}", &[("{name}", &record_name)])));
}); });
row.add_suffix(&delete_btn); row.add_suffix(&delete_btn);

View File

@@ -3,6 +3,7 @@ use gtk::gio;
use crate::core::fuse; use crate::core::fuse;
use crate::i18n::i18n; use crate::i18n::i18n;
use super::widgets;
/// Show a FUSE installation wizard dialog. /// Show a FUSE installation wizard dialog.
pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) { pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
@@ -52,7 +53,7 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.build(); .build();
let title = gtk::Label::builder() let title = gtk::Label::builder()
.label(&i18n("FUSE is required")) .label(&i18n("Additional setup needed"))
.xalign(0.0) .xalign(0.0)
.build(); .build();
title.add_css_class("title-3"); title.add_css_class("title-3");
@@ -60,8 +61,9 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
let explanation = gtk::Label::builder() let explanation = gtk::Label::builder()
.label(&i18n( .label(&i18n(
"Most AppImages require libfuse2 to mount and run. \ "Most apps need a small system component called FUSE to start quickly. \
Without it, apps will use a slower extract-and-run fallback or may not launch at all.", Without it, apps will still work but will take longer to start each time. \
You can install it now (requires your password) or skip and use the slower method.",
)) ))
.wrap(true) .wrap(true)
.xalign(0.0) .xalign(0.0)
@@ -90,6 +92,7 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.xalign(0.0) .xalign(0.0)
.wrap(true) .wrap(true)
.visible(false) .visible(false)
.accessible_role(gtk::AccessibleRole::Status)
.build(); .build();
content.append(&status_label); content.append(&status_label);
@@ -101,18 +104,34 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.margin_top(12) .margin_top(12)
.build(); .build();
let skip_btn = gtk::Button::builder()
.label(&i18n("Skip and use slower method"))
.tooltip_text(&i18n("Apps will still work, but they will take longer to start because they unpack themselves each time"))
.build();
skip_btn.add_css_class("flat");
skip_btn.add_css_class("pill");
let install_btn = gtk::Button::builder() let install_btn = gtk::Button::builder()
.label(&i18n("Install via pkexec")) .label(&i18n("Install via pkexec"))
.build(); .build();
install_btn.add_css_class("suggested-action"); install_btn.add_css_class("suggested-action");
install_btn.add_css_class("pill"); install_btn.add_css_class("pill");
button_box.append(&skip_btn);
button_box.append(&install_btn); button_box.append(&install_btn);
content.append(&button_box); content.append(&button_box);
toolbar.set_content(Some(&content)); toolbar.set_content(Some(&content));
dialog.set_child(Some(&toolbar)); dialog.set_child(Some(&toolbar));
// Skip button just closes the dialog
let dialog_weak = dialog.downgrade();
skip_btn.connect_clicked(move |_| {
if let Some(dlg) = dialog_weak.upgrade() {
dlg.close();
}
});
let cmd = install_cmd.clone(); let cmd = install_cmd.clone();
let status_ref = status_label.clone(); let status_ref = status_label.clone();
let btn_ref = install_btn.clone(); let btn_ref = install_btn.clone();
@@ -147,27 +166,37 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
// Verify FUSE is now working // Verify FUSE is now working
let fuse_info = fuse::detect_system_fuse(); let fuse_info = fuse::detect_system_fuse();
if fuse_info.status.is_functional() { if fuse_info.status.is_functional() {
status.set_label(&i18n("FUSE installed successfully! AppImages should now mount natively.")); let msg = i18n("FUSE installed successfully! AppImages should now mount natively.");
status.set_label(&msg);
status.add_css_class("success"); status.add_css_class("success");
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
} else { } else {
status.set_label(&i18n("Installation completed but FUSE still not detected. A reboot may be required.")); let msg = i18n("Installation completed but FUSE still not detected. A reboot may be required.");
status.set_label(&msg);
status.add_css_class("warning"); status.add_css_class("warning");
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
} }
} }
Ok(Ok(_)) => { Ok(Ok(_)) => {
status.set_label(&i18n("Installation was cancelled or failed.")); let msg = i18n("Installation was cancelled or failed.");
status.set_label(&msg);
status.add_css_class("error"); status.add_css_class("error");
btn.set_sensitive(true); btn.set_sensitive(true);
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
} }
Ok(Err(e)) => { Ok(Err(e)) => {
status.set_label(&format!("Error: {}", e)); let msg = format!("Error: {}", e);
status.set_label(&msg);
status.add_css_class("error"); status.add_css_class("error");
btn.set_sensitive(true); btn.set_sensitive(true);
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
} }
Err(_) => { Err(_) => {
status.set_label(&i18n("Task failed unexpectedly.")); let msg = i18n("Task failed unexpectedly.");
status.set_label(&msg);
status.add_css_class("error"); status.add_css_class("error");
btn.set_sensitive(true); btn.set_sensitive(true);
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
} }
} }
}); });

View File

@@ -57,6 +57,7 @@ pub fn show_integration_dialog(
.pixel_size(32) .pixel_size(32)
.build(); .build();
image.set_paintable(Some(&texture)); image.set_paintable(Some(&texture));
image.update_property(&[gtk::accessible::Property::Label(name)]);
name_row.add_prefix(&image); name_row.add_prefix(&image);
} }
} }
@@ -88,6 +89,7 @@ pub fn show_integration_dialog(
.build(); .build();
let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic"); let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check1.set_valign(gtk::Align::Center); check1.set_valign(gtk::Align::Center);
check1.set_accessible_role(gtk::AccessibleRole::Presentation);
desktop_row.add_prefix(&check1); desktop_row.add_prefix(&check1);
actions_box.append(&desktop_row); actions_box.append(&desktop_row);
@@ -97,6 +99,7 @@ pub fn show_integration_dialog(
.build(); .build();
let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic"); let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check2.set_valign(gtk::Align::Center); check2.set_valign(gtk::Align::Center);
check2.set_accessible_role(gtk::AccessibleRole::Presentation);
icon_row.add_prefix(&check2); icon_row.add_prefix(&check2);
actions_box.append(&icon_row); actions_box.append(&icon_row);
@@ -156,6 +159,7 @@ pub fn show_integration_dialog(
.build(); .build();
let warning_icon = gtk::Image::from_icon_name("dialog-warning-symbolic"); let warning_icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
warning_icon.set_pixel_size(16); warning_icon.set_pixel_size(16);
warning_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
warning_header.append(&warning_icon); warning_header.append(&warning_icon);
let warning_title = gtk::Label::builder() let warning_title = gtk::Label::builder()
.label(&i18n("Compatibility Notes")) .label(&i18n("Compatibility Notes"))
@@ -168,6 +172,9 @@ pub fn show_integration_dialog(
let compat_list = gtk::ListBox::new(); let compat_list = gtk::ListBox::new();
compat_list.add_css_class("boxed-list"); compat_list.add_css_class("boxed-list");
compat_list.set_selection_mode(gtk::SelectionMode::None); compat_list.set_selection_mode(gtk::SelectionMode::None);
compat_list.update_property(&[
gtk::accessible::Property::Label(&i18n("Compatibility warnings")),
]);
for (title, subtitle, badge_text) in &warnings { for (title, subtitle, badge_text) in &warnings {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()

View File

@@ -46,6 +46,10 @@ pub struct LibraryView {
records: Rc<RefCell<Vec<AppImageRecord>>>, records: Rc<RefCell<Vec<AppImageRecord>>>,
search_empty_page: adw::StatusPage, search_empty_page: adw::StatusPage,
update_banner: adw::Banner, update_banner: adw::Banner,
// Tag filtering
tag_bar: gtk::Box,
tag_scroll: gtk::ScrolledWindow,
active_tag: Rc<RefCell<Option<String>>>,
// Batch selection // Batch selection
selection_mode: Rc<Cell<bool>>, selection_mode: Rc<Cell<bool>>,
selected_ids: Rc<RefCell<HashSet<i64>>>, selected_ids: Rc<RefCell<HashSet<i64>>>,
@@ -89,7 +93,7 @@ impl LibraryView {
let search_button = gtk::ToggleButton::builder() let search_button = gtk::ToggleButton::builder()
.icon_name("system-search-symbolic") .icon_name("system-search-symbolic")
.tooltip_text(&i18n("Search")) .tooltip_text(&i18n("Search (Ctrl+F)"))
.build(); .build();
search_button.add_css_class("flat"); search_button.add_css_class("flat");
search_button.update_property(&[AccessibleProperty::Label("Toggle search")]); search_button.update_property(&[AccessibleProperty::Label("Toggle search")]);
@@ -124,7 +128,7 @@ impl LibraryView {
// Scan button // Scan button
let scan_button = gtk::Button::builder() let scan_button = gtk::Button::builder()
.icon_name("view-refresh-symbolic") .icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Scan for AppImages")) .tooltip_text(&i18n("Scan for AppImages (Ctrl+R)"))
.build(); .build();
scan_button.add_css_class("flat"); scan_button.add_css_class("flat");
scan_button.set_action_name(Some("win.scan")); scan_button.set_action_name(Some("win.scan"));
@@ -146,6 +150,7 @@ impl LibraryView {
// Add button (shows drop overlay) // Add button (shows drop overlay)
let add_button_icon = gtk::Image::from_icon_name("list-add-symbolic"); let add_button_icon = gtk::Image::from_icon_name("list-add-symbolic");
add_button_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let add_button_label = gtk::Label::new(Some(&i18n("Add app"))); let add_button_label = gtk::Label::new(Some(&i18n("Add app")));
let add_button_content = gtk::Box::builder() let add_button_content = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
@@ -185,6 +190,7 @@ impl LibraryView {
.placeholder_text(&i18n("Search AppImages...")) .placeholder_text(&i18n("Search AppImages..."))
.hexpand(true) .hexpand(true)
.build(); .build();
search_entry.update_property(&[gtk::accessible::Property::Label("Search installed AppImages")]);
let search_clamp = adw::Clamp::builder() let search_clamp = adw::Clamp::builder()
.maximum_size(500) .maximum_size(500)
@@ -219,6 +225,7 @@ impl LibraryView {
.height_request(32) .height_request(32)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.build(); .build();
spinner.update_property(&[gtk::accessible::Property::Label("Scanning for AppImages")]);
loading_page.set_child(Some(&spinner)); loading_page.set_child(Some(&spinner));
stack.add_named(&loading_page, Some("loading")); stack.add_named(&loading_page, Some("loading"));
@@ -244,14 +251,44 @@ impl LibraryView {
browse_catalog_btn.set_action_name(Some("win.catalog")); browse_catalog_btn.set_action_name(Some("win.catalog"));
browse_catalog_btn.update_property(&[AccessibleProperty::Label("Browse app catalog")]); browse_catalog_btn.update_property(&[AccessibleProperty::Label("Browse app catalog")]);
let learn_btn = gtk::Button::builder()
.label(&i18n("What is an AppImage?"))
.build();
learn_btn.add_css_class("flat");
learn_btn.add_css_class("pill");
learn_btn.connect_clicked(|btn| {
let dialog = adw::AlertDialog::builder()
.heading(&i18n("What is an AppImage?"))
.body(&i18n(
"AppImages are self-contained app files for Linux, similar to .exe files on Windows or .dmg files on Mac.\n\n\
Key differences from traditional Linux packages:\n\
- No installation needed - just download and run\n\
- One file per app - easy to back up and share\n\
- Works on most Linux distributions\n\
- Does not require admin/root access\n\n\
Driftwood helps you discover, organize, and keep your AppImages up to date."
))
.build();
dialog.add_response("learn-more", &i18n("Learn More Online"));
dialog.add_response("ok", &i18n("Got It"));
dialog.set_default_response(Some("ok"));
dialog.set_close_response("ok");
dialog.connect_response(Some("learn-more"), |_, _| {
gtk::UriLauncher::new("https://appimage.org")
.launch(gtk::Window::NONE, gtk::gio::Cancellable::NONE, |_| {});
});
dialog.present(Some(btn));
});
empty_button_box.append(&scan_now_btn); empty_button_box.append(&scan_now_btn);
empty_button_box.append(&browse_catalog_btn); empty_button_box.append(&browse_catalog_btn);
empty_button_box.append(&learn_btn);
let empty_page = adw::StatusPage::builder() let empty_page = adw::StatusPage::builder()
.icon_name("application-x-executable-symbolic") .icon_name("application-x-executable-symbolic")
.title(&i18n("No AppImages Yet")) .title(&i18n("No AppImages Yet"))
.description(&i18n( .description(&i18n(
"Drag and drop AppImage files here, or scan your system to find them.", "AppImages are portable apps for Linux - like .exe files, but they run without installation. Drag one here, scan your system, or browse the catalog to get started.",
)) ))
.child(&empty_button_box) .child(&empty_button_box)
.build(); .build();
@@ -320,6 +357,7 @@ impl LibraryView {
let selection_label = gtk::Label::builder() let selection_label = gtk::Label::builder()
.label("0 selected") .label("0 selected")
.accessible_role(gtk::AccessibleRole::Status)
.build(); .build();
action_bar.set_center_widget(Some(&selection_label)); action_bar.set_center_widget(Some(&selection_label));
@@ -344,21 +382,48 @@ impl LibraryView {
.title(&i18n("Updates available")) .title(&i18n("Updates available"))
.button_label(&i18n("View Updates")) .button_label(&i18n("View Updates"))
.revealed(false) .revealed(false)
.accessible_role(gtk::AccessibleRole::Alert)
.build(); .build();
update_banner.set_action_name(Some("win.show-updates")); update_banner.set_action_name(Some("win.show-updates"));
// --- Tag filter bar ---
let tag_bar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_start(12)
.margin_end(12)
.margin_top(6)
.margin_bottom(2)
.visible(false)
.accessible_role(gtk::AccessibleRole::Navigation)
.build();
tag_bar.update_property(&[AccessibleProperty::Label("Tag filter")]);
let active_tag: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let tag_scroll = gtk::ScrolledWindow::builder()
.child(&tag_bar)
.hscrollbar_policy(gtk::PolicyType::Automatic)
.vscrollbar_policy(gtk::PolicyType::Never)
.visible(false)
.build();
// --- Assemble toolbar view --- // --- Assemble toolbar view ---
let content_box = gtk::Box::builder() let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.build(); .build();
content_box.append(&update_banner); content_box.append(&update_banner);
content_box.append(&search_bar); content_box.append(&search_bar);
content_box.append(&tag_scroll);
content_box.append(&stack); content_box.append(&stack);
content_box.append(&action_bar); content_box.append(&action_bar);
let toolbar_view = adw::ToolbarView::new(); let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header_bar); toolbar_view.add_top_bar(&header_bar);
toolbar_view.set_content(Some(&content_box)); toolbar_view.set_content(Some(&content_box));
widgets::apply_pointer_cursors(&toolbar_view);
// Enable type-to-search: any keypress in the view opens the search bar
search_bar.set_key_capture_widget(Some(&toolbar_view));
let page = adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title("Driftwood") .title("Driftwood")
@@ -475,6 +540,7 @@ impl LibraryView {
&i18n_f("No AppImages match '{}'. Try a different search term.", &[("{}", &query)]) &i18n_f("No AppImages match '{}'. Try a different search term.", &[("{}", &query)])
)); ));
stack_d.set_visible_child_name("search-empty"); stack_d.set_visible_child_name("search-empty");
widgets::announce(flow_box_d.upcast_ref::<gtk::Widget>(), "No results found");
} else { } else {
let view_name = if view_mode_d.get() == ViewMode::Grid { let view_name = if view_mode_d.get() == ViewMode::Grid {
"grid" "grid"
@@ -482,6 +548,7 @@ impl LibraryView {
"list" "list"
}; };
stack_d.set_visible_child_name(view_name); stack_d.set_visible_child_name(view_name);
widgets::announce(flow_box_d.upcast_ref::<gtk::Widget>(), &format!("{} results", visible_count));
} }
}, },
); );
@@ -495,13 +562,29 @@ impl LibraryView {
let selected_ids_ref = selected_ids.clone(); let selected_ids_ref = selected_ids.clone();
let action_bar_ref = action_bar.clone(); let action_bar_ref = action_bar.clone();
let selection_label_ref = selection_label.clone(); let selection_label_ref = selection_label.clone();
let flow_box_ref = flow_box.clone();
let list_box_ref = list_box.clone();
let stack_announce = stack.clone();
select_button.connect_toggled(move |btn| { select_button.connect_toggled(move |btn| {
let active = btn.is_active(); let active = btn.is_active();
selection_mode_ref.set(active); selection_mode_ref.set(active);
action_bar_ref.set_visible(active); action_bar_ref.set_visible(active);
let msg = if active { "Selection mode enabled" } else { "Selection mode disabled" };
widgets::announce(&stack_announce, msg);
if !active { if !active {
selected_ids_ref.borrow_mut().clear(); selected_ids_ref.borrow_mut().clear();
selection_label_ref.set_label("0 selected"); selection_label_ref.set_label("0 selected");
// Clear all selection visuals
let mut i = 0;
while let Some(child) = flow_box_ref.child_at_index(i) {
child.remove_css_class("selected");
i += 1;
}
let mut i = 0;
while let Some(row) = list_box_ref.row_at_index(i) {
row.remove_css_class("selected");
i += 1;
}
} }
}); });
} }
@@ -525,6 +608,9 @@ impl LibraryView {
records, records,
search_empty_page, search_empty_page,
update_banner, update_banner,
tag_bar,
tag_scroll,
active_tag,
selection_mode, selection_mode,
selected_ids, selected_ids,
_action_bar: action_bar, _action_bar: action_bar,
@@ -537,6 +623,7 @@ impl LibraryView {
match state { match state {
LibraryState::Loading => { LibraryState::Loading => {
self.stack.set_visible_child_name("loading"); self.stack.set_visible_child_name("loading");
widgets::announce(&self.stack, "Scanning for AppImages");
} }
LibraryState::Empty => { LibraryState::Empty => {
self.stack.set_visible_child_name("empty"); self.stack.set_visible_child_name("empty");
@@ -566,36 +653,41 @@ impl LibraryView {
self.list_box.remove(&row); self.list_box.remove(&row);
} }
// Reset active tag filter
*self.active_tag.borrow_mut() = None;
// Sort records based on current sort mode // Sort records based on current sort mode
let mut new_records = new_records; let mut new_records = new_records;
match self.sort_mode.get() { self.sort_records(&mut new_records);
SortMode::NameAsc => {
new_records.sort_by(|a, b| { // Collect all unique tags for the tag filter bar
let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase(); let mut all_tags = std::collections::BTreeSet::new();
let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase(); for record in &new_records {
name_a.cmp(&name_b) if let Some(ref tags) = record.tags {
}); for tag in tags.split(',') {
} let trimmed = tag.trim();
SortMode::RecentlyAdded => { if !trimmed.is_empty() {
new_records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen)); all_tags.insert(trimmed.to_string());
} }
SortMode::Size => { }
new_records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
} }
} }
// Build tag filter chip bar
self.build_tag_chips(&all_tags);
// Build cards and list rows // Build cards and list rows
for record in &new_records { for record in &new_records {
// Grid card // Grid card
let card = app_card::build_app_card(record); let card = app_card::build_app_card(record);
let card_menu = build_context_menu(record); let card_menu = build_context_menu(record);
attach_context_menu(&card, &card_menu); attach_context_menu(&card, &card_menu, record.id);
self.flow_box.append(&card); self.flow_box.append(&card);
// List row // List row
let row = self.build_list_row(record); let row = self.build_list_row(record);
let row_menu = build_context_menu(record); let row_menu = build_context_menu(record);
attach_context_menu(&row, &row_menu); attach_context_menu(&row, &row_menu, record.id);
self.list_box.append(&row); self.list_box.append(&row);
} }
@@ -618,6 +710,91 @@ impl LibraryView {
} }
} }
fn sort_records(&self, records: &mut Vec<AppImageRecord>) {
match self.sort_mode.get() {
SortMode::NameAsc => {
records.sort_by(|a, b| {
let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase();
let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase();
name_a.cmp(&name_b)
});
}
SortMode::RecentlyAdded => {
records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
}
SortMode::Size => {
records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
}
}
}
fn build_tag_chips(&self, all_tags: &std::collections::BTreeSet<String>) {
// Clear existing chips
while let Some(child) = self.tag_bar.first_child() {
self.tag_bar.remove(&child);
}
if all_tags.is_empty() {
self.tag_scroll.set_visible(false);
self.tag_bar.set_visible(false);
return;
}
self.tag_scroll.set_visible(true);
self.tag_bar.set_visible(true);
// "All" chip
let all_chip = gtk::ToggleButton::builder()
.label(&i18n("All"))
.active(true)
.css_classes(["pill"])
.build();
widgets::set_pointer_cursor(&all_chip);
self.tag_bar.append(&all_chip);
// Tag chips
let mut chips: Vec<gtk::ToggleButton> = vec![all_chip.clone()];
for tag in all_tags {
let chip = gtk::ToggleButton::builder()
.label(tag)
.css_classes(["pill"])
.group(&all_chip)
.build();
widgets::set_pointer_cursor(&chip);
self.tag_bar.append(&chip);
chips.push(chip);
}
// Connect "All" chip
{
let active_tag = self.active_tag.clone();
let flow_box = self.flow_box.clone();
let list_box = self.list_box.clone();
let records = self.records.clone();
all_chip.connect_toggled(move |btn| {
if btn.is_active() {
*active_tag.borrow_mut() = None;
apply_tag_filter(&flow_box, &list_box, &records.borrow(), None);
}
});
}
// Connect each tag chip
for chip in &chips[1..] {
let tag_name = chip.label().map(|l| l.to_string()).unwrap_or_default();
let active_tag = self.active_tag.clone();
let flow_box = self.flow_box.clone();
let list_box = self.list_box.clone();
let records = self.records.clone();
chip.connect_toggled(move |btn| {
if btn.is_active() {
*active_tag.borrow_mut() = Some(tag_name.clone());
apply_tag_filter(&flow_box, &list_box, &records.borrow(), Some(&tag_name));
}
});
}
}
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow { fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
let name = record.app_name.as_deref().unwrap_or(&record.filename); let name = record.app_name.as_deref().unwrap_or(&record.filename);
@@ -669,6 +846,18 @@ impl LibraryView {
icon.add_css_class("icon-rounded"); icon.add_css_class("icon-rounded");
row.add_prefix(&icon); row.add_prefix(&icon);
// Quick launch button
let launch_btn = widgets::accessible_icon_button(
"media-playback-start-symbolic",
&format!("Launch {}", name),
&i18n("Launch"),
);
launch_btn.add_css_class("circular");
launch_btn.set_action_name(Some("win.launch-appimage"));
launch_btn.set_action_target_value(Some(&record.id.to_variant()));
widgets::set_pointer_cursor(&launch_btn);
row.add_suffix(&launch_btn);
// Single most important badge as suffix (same priority as cards) // Single most important badge as suffix (same priority as cards)
if let Some(badge) = app_card::build_priority_badge(record) { if let Some(badge) = app_card::build_priority_badge(record) {
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -676,8 +865,7 @@ impl LibraryView {
} }
// Navigate arrow // Navigate arrow
let arrow = gtk::Image::from_icon_name("go-next-symbolic"); row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Open details")));
row.add_suffix(&arrow);
row row
} }
@@ -734,13 +922,35 @@ impl LibraryView {
/// Toggle selection of a record ID (used by card click in selection mode). /// Toggle selection of a record ID (used by card click in selection mode).
pub fn toggle_selection(&self, id: i64) { pub fn toggle_selection(&self, id: i64) {
let mut ids = self.selected_ids.borrow_mut(); let mut ids = self.selected_ids.borrow_mut();
if ids.contains(&id) { let was_selected = ids.contains(&id);
if was_selected {
ids.remove(&id); ids.remove(&id);
} else { } else {
ids.insert(id); ids.insert(id);
} }
let count = ids.len(); let count = ids.len();
self.selection_label.set_label(&format!("{} selected", count)); self.selection_label.set_label(&ni18n_f(
"{} selected", "{} selected", count as u32,
&[("{}", &count.to_string())],
));
// Update visual highlight on the toggled widget
if let Some(idx) = self.records.borrow().iter().position(|r| r.id == id) {
if let Some(child) = self.flow_box.child_at_index(idx as i32) {
if was_selected {
child.remove_css_class("selected");
} else {
child.add_css_class("selected");
}
}
if let Some(row) = self.list_box.row_at_index(idx as i32) {
if was_selected {
row.remove_css_class("selected");
} else {
row.add_css_class("selected");
}
}
}
} }
/// Whether the library is in selection mode. /// Whether the library is in selection mode.
@@ -791,7 +1001,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 1: Launch // Section 1: Launch
let section1 = gtk::gio::Menu::new(); let section1 = gtk::gio::Menu::new();
section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id))); section1.append(Some("Open"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
menu.append_section(None, &section1); menu.append_section(None, &section1);
// Section 2: Actions // Section 2: Actions
@@ -802,7 +1012,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 3: Integration + folder // Section 3: Integration + folder
let section3 = gtk::gio::Menu::new(); let section3 = gtk::gio::Menu::new();
let integrate_label = if record.integrated { "Remove from app menu" } else { "Add to app menu" }; let integrate_label = if record.integrated { "Remove from launcher" } else { "Add to launcher" };
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id))); section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id))); section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3); menu.append_section(None, &section3);
@@ -812,15 +1022,39 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id))); section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id)));
menu.append_section(None, &section4); menu.append_section(None, &section4);
// Section 5: Destructive actions
let section5 = gtk::gio::Menu::new();
let uninstall_item = gtk::gio::MenuItem::new(None, Some(&format!("win.uninstall-appimage(int64 {})", record.id)));
uninstall_item.set_attribute_value("custom", Some(&"uninstall".to_variant()));
section5.append_item(&uninstall_item);
menu.append_section(None, &section5);
menu menu
} }
/// Attach a right-click context menu to a widget. /// Attach a right-click context menu to a widget.
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::gio::Menu) { fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::gio::Menu, record_id: i64) {
let popover = gtk::PopoverMenu::from_model(Some(menu_model)); let popover = gtk::PopoverMenu::from_model_full(menu_model, gtk::PopoverMenuFlags::NESTED);
popover.set_parent(widget.as_ref()); popover.set_parent(widget.as_ref());
popover.set_has_arrow(false); popover.set_has_arrow(false);
// Add custom destructive-styled uninstall button
let uninstall_btn = gtk::Button::builder()
.label("Uninstall")
.build();
uninstall_btn.add_css_class("destructive-context-item");
// Left-align the label to match other menu items
if let Some(label) = uninstall_btn.child().and_then(|c| c.downcast::<gtk::Label>().ok()) {
label.set_halign(gtk::Align::Start);
}
uninstall_btn.set_action_name(Some("win.uninstall-appimage"));
uninstall_btn.set_action_target_value(Some(&record_id.to_variant()));
let popover_ref = popover.clone();
uninstall_btn.connect_clicked(move |_| {
popover_ref.popdown();
});
popover.add_child(&uninstall_btn, "uninstall");
// Unparent the popover when the widget is destroyed to avoid GTK warnings // Unparent the popover when the widget is destroyed to avoid GTK warnings
let popover_cleanup = popover.clone(); let popover_cleanup = popover.clone();
widget.as_ref().connect_destroy(move |_| { widget.as_ref().connect_destroy(move |_| {
@@ -848,3 +1082,48 @@ fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model:
}); });
widget.as_ref().add_controller(long_press); widget.as_ref().add_controller(long_press);
} }
/// Apply tag filtering to both flow_box (grid) and list_box (list).
fn apply_tag_filter(
flow_box: &gtk::FlowBox,
list_box: &gtk::ListBox,
records: &[AppImageRecord],
tag: Option<&str>,
) {
let match_flags: Vec<bool> = records
.iter()
.map(|rec| {
match tag {
None => true, // "All" - show everything
Some(filter_tag) => {
rec.tags.as_ref().map_or(false, |tags| {
tags.split(',')
.any(|t| t.trim().eq_ignore_ascii_case(filter_tag))
})
}
}
})
.collect();
// Filter grid view
let flags_grid = match_flags.clone();
flow_box.set_filter_func(move |child| {
let idx = child.index() as usize;
flags_grid.get(idx).copied().unwrap_or(false)
});
// Filter list view
for (i, visible) in match_flags.iter().enumerate() {
if let Some(row) = list_box.row_at_index(i as i32) {
row.set_visible(*visible);
}
}
// Announce filter result to screen readers
let visible_count = match_flags.iter().filter(|&&m| m).count();
let msg = match tag {
None => format!("{} apps shown", visible_count),
Some(t) => format!("Filtered by tag: {}, {} apps", t, visible_count),
};
widgets::announce(flow_box.upcast_ref::<gtk::Widget>(), &msg);
}

View File

@@ -1,5 +1,7 @@
pub mod app_card; pub mod app_card;
pub mod batch_update_dialog; pub mod batch_update_dialog;
pub mod catalog_detail;
pub mod catalog_tile;
pub mod catalog_view; pub mod catalog_view;
pub mod cleanup_wizard; pub mod cleanup_wizard;
pub mod dashboard; pub mod dashboard;

View File

@@ -37,10 +37,10 @@ pub fn show_permission_dialog(
extra.append(&access_label); extra.append(&access_label);
let items = [ let items = [
"Your home directory and files", "Your files and folders",
"Network and internet access", "Internet and network access",
"Display server (Wayland/X11)", "Your screen and windows",
"System D-Bus and services", "Background system services",
]; ];
for item in &items { for item in &items {
let label = gtk::Label::builder() let label = gtk::Label::builder()
@@ -50,15 +50,29 @@ pub fn show_permission_dialog(
extra.append(&label); extra.append(&label);
} }
// Explanation paragraph
let explain = gtk::Label::builder()
.label(&i18n(
"AppImages run like regular programs on your computer. Unlike phone apps, \
desktop apps typically have full access to your files and system. This is normal.",
))
.wrap(true)
.xalign(0.0)
.margin_top(8)
.build();
explain.add_css_class("caption");
explain.add_css_class("dim-label");
extra.append(&explain);
// Show firejail option if available // Show firejail option if available
if launcher::has_firejail() { if launcher::has_firejail() {
let sandbox_note = gtk::Label::builder() let sandbox_note = gtk::Label::builder()
.label(&i18n( .label(&i18n(
"Firejail is available on your system. You can configure sandboxing in the app's system tab.", "You can restrict this app's access later in Details > Security Restrictions.",
)) ))
.wrap(true) .wrap(true)
.xalign(0.0) .xalign(0.0)
.margin_top(8) .margin_top(4)
.build(); .build();
sandbox_note.add_css_class("dim-label"); sandbox_note.add_css_class("dim-label");
extra.append(&sandbox_note); extra.append(&sandbox_note);

View File

@@ -14,6 +14,7 @@ pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
dialog.add(&build_general_page(&settings, &dialog)); dialog.add(&build_general_page(&settings, &dialog));
dialog.add(&build_updates_page(&settings)); dialog.add(&build_updates_page(&settings));
super::widgets::apply_pointer_cursors(&dialog);
dialog.present(Some(parent)); dialog.present(Some(parent));
} }
@@ -80,7 +81,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Scan Locations group // Scan Locations group
let scan_group = adw::PreferencesGroup::builder() let scan_group = adw::PreferencesGroup::builder()
.title(&i18n("Scan Locations")) .title(&i18n("Scan Locations"))
.description(&i18n("Directories to scan for AppImage files")) .description(&i18n("Folders where Driftwood looks for AppImage files. Add any folder where you save downloaded apps."))
.build(); .build();
let dirs = settings.strv("scan-directories"); let dirs = settings.strv("scan-directories");
@@ -103,7 +104,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
.build(); .build();
add_button.add_css_class("flat"); add_button.add_css_class("flat");
add_button.update_property(&[ add_button.update_property(&[
gtk::accessible::Property::Label(&i18n("Add scan directory")), gtk::accessible::Property::Description(&i18n("Add a new directory to scan for AppImages")),
]); ]);
let settings_add = settings.clone(); let settings_add = settings.clone();
@@ -156,6 +157,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Automation group // Automation group
let automation_group = adw::PreferencesGroup::builder() let automation_group = adw::PreferencesGroup::builder()
.title(&i18n("Automation")) .title(&i18n("Automation"))
.description(&i18n("Control what happens automatically when Driftwood starts or finds new apps."))
.build(); .build();
let auto_scan_row = adw::SwitchRow::builder() let auto_scan_row = adw::SwitchRow::builder()
@@ -259,6 +261,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Checking group // Update Checking group
let checking_group = adw::PreferencesGroup::builder() let checking_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Checking")) .title(&i18n("Update Checking"))
.description(&i18n("Let Driftwood periodically check if newer versions of your apps are available."))
.build(); .build();
let auto_update_row = adw::SwitchRow::builder() let auto_update_row = adw::SwitchRow::builder()
@@ -296,6 +299,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Behavior group // Update Behavior group
let behavior_group = adw::PreferencesGroup::builder() let behavior_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Behavior")) .title(&i18n("Update Behavior"))
.description(&i18n("Control what happens when an app is updated to a newer version."))
.build(); .build();
let cleanup_row = adw::ComboRow::builder() let cleanup_row = adw::ComboRow::builder()
@@ -358,7 +362,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Security Scanning group // Security Scanning group
let security_group = adw::PreferencesGroup::builder() let security_group = adw::PreferencesGroup::builder()
.title(&i18n("Security Scanning")) .title(&i18n("Security Scanning"))
.description(&i18n("Check bundled libraries for known CVEs via OSV.dev")) .description(&i18n("Automatically check the components bundled inside your apps for known security issues via the OSV.dev database."))
.build(); .build();
let auto_security_row = adw::SwitchRow::builder() let auto_security_row = adw::SwitchRow::builder()
@@ -413,6 +417,44 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
page.add(&security_group); page.add(&security_group);
// Catalog Enrichment group
let enrichment_group = adw::PreferencesGroup::builder()
.title(&i18n("Catalog Enrichment"))
.description(&i18n("Fetch additional app information like stars, downloads, and descriptions from GitHub to enrich the catalog."))
.build();
let auto_enrich_row = adw::SwitchRow::builder()
.title(&i18n("Auto-enrich catalog apps"))
.subtitle(&i18n("Fetch metadata from GitHub in the background"))
.active(settings.boolean("catalog-auto-enrich"))
.build();
let settings_enrich = settings.clone();
auto_enrich_row.connect_active_notify(move |row| {
settings_enrich.set_boolean("catalog-auto-enrich", row.is_active()).ok();
});
enrichment_group.add(&auto_enrich_row);
let token_row = adw::PasswordEntryRow::builder()
.title(&i18n("GitHub token"))
.build();
let current_token = settings.string("github-token");
if !current_token.is_empty() {
token_row.set_text(&current_token);
}
let settings_token = settings.clone();
token_row.connect_changed(move |row| {
settings_token.set_string("github-token", &row.text()).ok();
});
enrichment_group.add(&token_row);
let token_hint = adw::ActionRow::builder()
.title(&i18n("Optional. Speeds up catalog data fetching. Get a free token at github.com/settings/tokens (no special permissions needed)."))
.css_classes(["dim-label"])
.build();
enrichment_group.add(&token_hint);
page.add(&enrichment_group);
page page
} }
@@ -421,15 +463,12 @@ fn add_directory_row(list_box: &gtk::ListBox, dir: &str, settings: &gio::Setting
.title(dir) .title(dir)
.build(); .build();
let remove_btn = gtk::Button::builder() let remove_label = format!("{} {}", i18n("Remove directory"), dir);
.icon_name("edit-delete-symbolic") let remove_btn = super::widgets::accessible_icon_button(
.valign(gtk::Align::Center) "edit-delete-symbolic",
.tooltip_text(&i18n("Remove")) &remove_label,
.build(); &i18n("Remove"),
remove_btn.add_css_class("flat"); );
remove_btn.update_property(&[
gtk::accessible::Property::Label(&format!("{} {}", i18n("Remove directory"), dir)),
]);
let list_ref = list_box.clone(); let list_ref = list_box.clone();
let settings_ref = settings.clone(); let settings_ref = settings.clone();

View File

@@ -7,6 +7,7 @@ use crate::core::database::Database;
use crate::core::notification; use crate::core::notification;
use crate::core::report; use crate::core::report;
use crate::core::security; use crate::core::security;
use crate::i18n::{i18n, i18n_f};
use super::widgets; use super::widgets;
/// Build the security scan report as a full navigation page. /// Build the security scan report as a full navigation page.
@@ -46,6 +47,7 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
scan_button.connect_clicked(move |btn| { scan_button.connect_clicked(move |btn| {
btn.set_sensitive(false); btn.set_sensitive(false);
btn.set_label("Scanning..."); btn.set_label("Scanning...");
widgets::announce(btn.upcast_ref::<gtk::Widget>(), "Scanning for vulnerabilities");
let btn_clone = btn.clone(); let btn_clone = btn.clone();
let db_refresh = db_scan.clone(); let db_refresh = db_scan.clone();
let stack_refresh = stack_ref.clone(); let stack_refresh = stack_ref.clone();
@@ -162,6 +164,7 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
btn_clone.set_sensitive(false); btn_clone.set_sensitive(false);
btn_clone.set_label("Exporting..."); btn_clone.set_label("Exporting...");
widgets::announce(btn_clone.upcast_ref::<gtk::Widget>(), "Exporting report");
let btn_done = btn_clone.clone(); let btn_done = btn_clone.clone();
let toast_done = toast_for_save.clone(); let toast_done = toast_for_save.clone();
let db_bg = db_for_save.clone(); let db_bg = db_for_save.clone();
@@ -187,11 +190,11 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("report"); .unwrap_or("report");
toast_done.add_toast( toast_done.add_toast(
adw::Toast::new(&format!("Report saved as {}", filename)), widgets::info_toast(&i18n_f("Report saved as {filename}", &[("{filename}", filename)])),
); );
} }
_ => { _ => {
toast_done.add_toast(adw::Toast::new("Failed to export report")); toast_done.add_toast(widgets::error_toast(&i18n("Failed to export report")));
} }
} }
}); });
@@ -277,7 +280,7 @@ fn build_report_content(db: &Rc<Database>) -> gtk::ScrolledWindow {
fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup { fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Vulnerability Summary") .title("Vulnerability Summary")
.description("Overall security status across all your apps") .description("Driftwood checks the software components bundled inside your apps against a database of known security issues (CVEs). Most issues are in underlying libraries, not the apps themselves.")
.build(); .build();
let total_row = adw::ActionRow::builder() let total_row = adw::ActionRow::builder()
@@ -294,6 +297,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Critical") .title("Critical")
.subtitle(&summary.critical.to_string()) .subtitle(&summary.critical.to_string())
.tooltip_text("Could allow an attacker to take control of affected components")
.build(); .build();
let badge = widgets::status_badge("Critical", "error"); let badge = widgets::status_badge("Critical", "error");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -304,6 +308,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("High") .title("High")
.subtitle(&summary.high.to_string()) .subtitle(&summary.high.to_string())
.tooltip_text("Could allow unauthorized access to data processed by the app")
.build(); .build();
let badge = widgets::status_badge("High", "error"); let badge = widgets::status_badge("High", "error");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -314,6 +319,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Medium") .title("Medium")
.subtitle(&summary.medium.to_string()) .subtitle(&summary.medium.to_string())
.tooltip_text("Could cause the app to behave unexpectedly or crash")
.build(); .build();
let badge = widgets::status_badge("Medium", "warning"); let badge = widgets::status_badge("Medium", "warning");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -324,6 +330,7 @@ fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::Pref
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Low") .title("Low")
.subtitle(&summary.low.to_string()) .subtitle(&summary.low.to_string())
.tooltip_text("Minor issue with limited practical impact")
.build(); .build();
let badge = widgets::status_badge("Low", "neutral"); let badge = widgets::status_badge("Low", "neutral");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -340,7 +347,10 @@ fn build_app_findings_group(
summary: &crate::core::database::CveSummary, summary: &crate::core::database::CveSummary,
cve_matches: &[crate::core::database::CveMatchRecord], cve_matches: &[crate::core::database::CveMatchRecord],
) -> adw::PreferencesGroup { ) -> adw::PreferencesGroup {
let description = format!("{} known security issues found", summary.total()); let description = format!(
"{} known security issues found. Check if a newer version is available in the catalog or from the developer's website. Most security issues are fixed in newer releases.",
summary.total()
);
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title(app_name) .title(app_name)
.description(&description) .description(&description)
@@ -401,7 +411,14 @@ fn build_app_findings_group(
let cve_row = adw::ActionRow::builder() let cve_row = adw::ActionRow::builder()
.title(&format!("{} ({})", cve.cve_id, severity)) .title(&format!("{} ({})", cve.cve_id, severity))
.subtitle(&subtitle) .subtitle(&subtitle)
.subtitle_selectable(true)
.build(); .build();
cve_row.update_property(&[
gtk::accessible::Property::Description(&format!(
"{} severity vulnerability in {}. {}",
severity, lib_name, subtitle,
)),
]);
expander.add_row(&cve_row); expander.add_row(&cve_row);
} }

View File

@@ -255,18 +255,23 @@ fn handle_old_version_cleanup(dialog: &adw::AlertDialog, old_path: PathBuf) {
} }
/// Batch check all AppImages for updates. Returns count of updates found. /// Batch check all AppImages for updates. Returns count of updates found.
pub fn batch_check_updates(db: &Database) -> u32 { /// Check all apps for updates, returns (count, list of app names with updates).
pub fn batch_check_updates_detailed(db: &Database) -> (u32, Vec<String>) {
let records = match db.get_all_appimages() { let records = match db.get_all_appimages() {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
log::error!("Failed to get appimages for update check: {}", e); log::error!("Failed to get appimages for update check: {}", e);
return 0; return (0, vec![]);
} }
}; };
let mut updates_found = 0u32; let mut updates_found = 0u32;
let mut updated_names = Vec::new();
for record in &records { for record in &records {
if record.pinned {
continue;
}
let appimage_path = std::path::Path::new(&record.path); let appimage_path = std::path::Path::new(&record.path);
if !appimage_path.exists() { if !appimage_path.exists() {
continue; continue;
@@ -292,6 +297,8 @@ pub fn batch_check_updates(db: &Database) -> u32 {
if let Some(ref version) = result.latest_version { if let Some(ref version) = result.latest_version {
db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok(); db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok();
updates_found += 1; updates_found += 1;
let name = record.app_name.as_deref().unwrap_or(&record.filename);
updated_names.push(name.to_string());
} }
} else { } else {
db.clear_update_available(record.id).ok(); db.clear_update_available(record.id).ok();
@@ -299,5 +306,9 @@ pub fn batch_check_updates(db: &Database) -> u32 {
} }
} }
updates_found (updates_found, updated_names)
}
pub fn batch_check_updates(db: &Database) -> u32 {
batch_check_updates_detailed(db).0
} }

View File

@@ -7,7 +7,7 @@ use gtk::gio;
use crate::config::APP_ID; use crate::config::APP_ID;
use crate::core::database::Database; use crate::core::database::Database;
use crate::core::updater; use crate::core::updater;
use crate::i18n::i18n; use crate::i18n::{i18n, ni18n_f};
use crate::ui::update_dialog; use crate::ui::update_dialog;
use crate::ui::widgets; use crate::ui::widgets;
@@ -23,10 +23,11 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
header.set_title_widget(Some(&title)); header.set_title_widget(Some(&title));
// Check Now button // Check Now button
let check_btn = gtk::Button::builder() let check_btn = widgets::accessible_icon_button(
.icon_name("view-refresh-symbolic") "view-refresh-symbolic",
.tooltip_text(&i18n("Check for updates")) "Check for updates",
.build(); &i18n("Check for updates (Ctrl+U)"),
);
header.pack_end(&check_btn); header.pack_end(&check_btn);
// Update All button (only visible when updates exist) // Update All button (only visible when updates exist)
@@ -60,6 +61,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
.height_request(32) .height_request(32)
.halign(gtk::Align::Center) .halign(gtk::Align::Center)
.build(); .build();
spinner.update_property(&[gtk::accessible::Property::Label("Checking for updates")]);
checking_page.set_child(Some(&spinner)); checking_page.set_child(Some(&spinner));
stack.add_named(&checking_page, Some("checking")); stack.add_named(&checking_page, Some("checking"));
@@ -86,6 +88,18 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
.build(); .build();
updates_content.append(&last_checked_label); updates_content.append(&last_checked_label);
// "What will happen" explanation
let explanation = gtk::Label::builder()
.label(&i18n("Each app will be downloaded fresh. Your settings and data are kept. You can choose to keep old versions as backup in Preferences."))
.css_classes(["caption", "dim-label"])
.wrap(true)
.xalign(0.0)
.margin_start(18)
.margin_end(18)
.margin_top(6)
.build();
updates_content.append(&explanation);
let clamp = adw::Clamp::builder() let clamp = adw::Clamp::builder()
.maximum_size(800) .maximum_size(800)
.tightening_threshold(600) .tightening_threshold(600)
@@ -145,7 +159,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
// Re-read records from the shared db so UI picks up changes from the bg thread // Re-read records from the shared db so UI picks up changes from the bg thread
drop(fresh_db); drop(fresh_db);
populate_update_list(&state_c); populate_update_list(&state_c);
state_c.toast_overlay.add_toast(adw::Toast::new(&i18n("Update check complete"))); state_c.toast_overlay.add_toast(widgets::info_toast(&i18n("Update check complete")));
}); });
}); });
} }
@@ -186,7 +200,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
let count = result.unwrap_or(0); let count = result.unwrap_or(0);
if count > 0 { if count > 0 {
state_c.toast_overlay.add_toast( state_c.toast_overlay.add_toast(
adw::Toast::new(&format!("{} apps updated", count)), widgets::info_toast(&ni18n_f("{} app updated", "{} apps updated", count, &[("{}", &count.to_string())])),
); );
} }
populate_update_list(&state_c); populate_update_list(&state_c);
@@ -197,6 +211,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
let toolbar_view = adw::ToolbarView::new(); let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header); toolbar_view.add_top_bar(&header);
toolbar_view.set_content(Some(&toast_overlay)); toolbar_view.set_content(Some(&toast_overlay));
widgets::apply_pointer_cursors(&toolbar_view);
toolbar_view toolbar_view
} }
@@ -244,25 +259,62 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
state.stack.set_visible_child_name("updates"); state.stack.set_visible_child_name("updates");
state.update_all_btn.set_visible(true); state.update_all_btn.set_visible(true);
state.title.set_subtitle(&format!("{} updates available", updatable.len())); let count = updatable.len();
state.title.set_subtitle(&format!("{} updates available", count));
widgets::announce(state.list_box.upcast_ref::<gtk::Widget>(), &format!("{} updates available", count));
for record in &updatable { for record in &updatable {
let name = record.app_name.as_deref().unwrap_or(&record.filename); let name = record.app_name.as_deref().unwrap_or(&record.filename);
let row = adw::ActionRow::builder() // Show version info: current -> latest (with size if available)
.title(name)
.activatable(false)
.build();
// Show version info: current -> latest
let current = record.app_version.as_deref().unwrap_or("unknown"); let current = record.app_version.as_deref().unwrap_or("unknown");
let latest = record.latest_version.as_deref().unwrap_or("unknown"); let latest = record.latest_version.as_deref().unwrap_or("unknown");
row.set_subtitle(&format!("{} -> {}", current, latest)); let subtitle = if record.size_bytes > 0 {
format!("{} -> {} ({})", current, latest, widgets::format_size(record.size_bytes))
} else {
format!("{} -> {}", current, latest)
};
// App icon // Try to find changelog from the app's own release_history or from catalog
let changelog = find_changelog_for_version(
&state.db, record, latest,
);
// Use ExpanderRow if we have changelog, otherwise plain ActionRow
let row: adw::ExpanderRow = adw::ExpanderRow::builder()
.title(name)
.subtitle(&subtitle)
.show_enable_switch(false)
.expanded(false)
.build();
// App icon (decorative - row title already names the app)
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32);
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
row.add_prefix(&icon); row.add_prefix(&icon);
// "What's new" content inside the expander
let changelog_text = match &changelog {
Some(text) => text.clone(),
None => i18n("Release notes not available"),
};
let changelog_label = gtk::Label::builder()
.label(&changelog_text)
.wrap(true)
.xalign(0.0)
.css_classes(if changelog.is_some() { vec!["body"] } else { vec!["dim-label", "caption"] })
.margin_start(12)
.margin_end(12)
.margin_top(8)
.margin_bottom(8)
.selectable(true)
.build();
let changelog_row = adw::ActionRow::builder()
.activatable(false)
.child(&changelog_label)
.build();
row.add_row(&changelog_row);
// Individual update button // Individual update button
let update_btn = gtk::Button::builder() let update_btn = gtk::Button::builder()
.icon_name("software-update-available-symbolic") .icon_name("software-update-available-symbolic")
@@ -270,6 +322,7 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
.tooltip_text(&i18n("Update this app")) .tooltip_text(&i18n("Update this app"))
.css_classes(["flat"]) .css_classes(["flat"])
.build(); .build();
update_btn.update_property(&[gtk::accessible::Property::Label(&format!("Update {}", name))]);
let app_id = record.id; let app_id = record.id;
let update_url = record.update_url.clone(); let update_url = record.update_url.clone();
@@ -308,11 +361,11 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
btn_c.set_sensitive(true); btn_c.set_sensitive(true);
if result.unwrap_or(false) { if result.unwrap_or(false) {
state_c.toast_overlay.add_toast( state_c.toast_overlay.add_toast(
adw::Toast::new(&i18n("Update complete")), widgets::info_toast(&i18n("Update complete")),
); );
} else { } else {
state_c.toast_overlay.add_toast( state_c.toast_overlay.add_toast(
adw::Toast::new(&i18n("Update failed")), widgets::error_toast(&i18n("Update failed")),
); );
} }
populate_update_list(&state_c); populate_update_list(&state_c);
@@ -323,3 +376,81 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
state.list_box.append(&row); state.list_box.append(&row);
} }
} }
/// Try to find changelog/release notes for a specific version.
/// Checks the installed app's release_history first, then falls back
/// to the catalog app's release_history (populated by GitHub enrichment).
fn find_changelog_for_version(
db: &Database,
record: &crate::core::database::AppImageRecord,
target_version: &str,
) -> Option<String> {
// First check the installed app's own release_history
if let Some(ref history_json) = record.release_history {
if let Some(text) = extract_version_notes(history_json, target_version) {
return Some(text);
}
}
// Fall back to catalog app's release_history
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
if let Ok(Some(ref history_json)) = db.get_catalog_release_history_by_name(app_name) {
if let Some(text) = extract_version_notes(history_json, target_version) {
return Some(text);
}
}
None
}
/// Parse release_history JSON and extract the description for a given version.
/// Format: [{"version": "1.0.0", "date": "2026-01-01", "description": "..."}]
fn extract_version_notes(history_json: &str, target_version: &str) -> Option<String> {
let releases: Vec<serde_json::Value> = serde_json::from_str(history_json).ok()?;
// Try exact match first
for release in &releases {
if let Some(ver) = release.get("version").and_then(|v| v.as_str()) {
if ver == target_version {
return release.get("description")
.and_then(|d| d.as_str())
.map(|s| truncate_changelog(s));
}
}
}
// If no exact match, try matching without "v" prefix on both sides
let clean_target = target_version.strip_prefix('v').unwrap_or(target_version);
for release in &releases {
if let Some(ver) = release.get("version").and_then(|v| v.as_str()) {
let clean_ver = ver.strip_prefix('v').unwrap_or(ver);
if clean_ver == clean_target {
return release.get("description")
.and_then(|d| d.as_str())
.map(|s| truncate_changelog(s));
}
}
}
// No matching version found - show the latest entry's notes as a fallback
// (the first entry is typically the newest release)
if let Some(first) = releases.first() {
if let Some(desc) = first.get("description").and_then(|d| d.as_str()) {
let ver = first.get("version").and_then(|v| v.as_str()).unwrap_or("?");
return Some(format!("(v{}) {}", ver, truncate_changelog(desc)));
}
}
None
}
/// Truncate long changelog text to keep the UI compact.
fn truncate_changelog(text: &str) -> String {
let max_len = 500;
let trimmed = text.trim();
if trimmed.len() <= max_len {
trimmed.to_string()
} else {
format!("{}...", &trimmed[..max_len])
}
}

View File

@@ -1,6 +1,8 @@
use adw::prelude::*; use adw::prelude::*;
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::i18n::i18n;
/// Ensures the shared letter-icon CSS provider is registered on the default /// Ensures the shared letter-icon CSS provider is registered on the default
/// display exactly once. The provider defines `.letter-icon-a` through /// display exactly once. The provider defines `.letter-icon-a` through
/// `.letter-icon-z` (and `.letter-icon-other`) with distinct hue-based /// `.letter-icon-z` (and `.letter-icon-other`) with distinct hue-based
@@ -24,19 +26,20 @@ fn ensure_letter_icon_css() {
/// Generate CSS rules for `.letter-icon-a` through `.letter-icon-z` and a /// Generate CSS rules for `.letter-icon-a` through `.letter-icon-z` and a
/// `.letter-icon-other` fallback. Each letter gets a unique hue evenly /// `.letter-icon-other` fallback. Each letter gets a unique hue evenly
/// distributed around the color wheel (saturation 55%, lightness 45% for /// distributed around the color wheel. Lightness is adjusted per hue range
/// the background, lightness 97% for the foreground text) so that the 26 /// to guarantee WCAG AAA 7:1 contrast ratio with the white foreground text.
/// letter icons are visually distinct while remaining legible. /// Yellow/green hues (50-170) are inherently lighter, so they use darker
/// lightness values.
fn generate_letter_icon_css() -> String { fn generate_letter_icon_css() -> String {
let mut css = String::with_capacity(4096); let mut css = String::with_capacity(4096);
for i in 0u32..26 { for i in 0u32..26 {
let letter = (b'a' + i as u8) as char; let letter = (b'a' + i as u8) as char;
let hue = (i * 360) / 26; let hue = (i * 360) / 26;
// HSL background: moderate saturation, medium lightness // Yellow/green hues have high luminance; darken them more for contrast
// HSL foreground: same hue, very light for contrast let lightness = if (50..170).contains(&hue) { 30 } else { 38 };
css.push_str(&format!( css.push_str(&format!(
"label.letter-icon-{letter} {{ \ "label.letter-icon-{letter} {{ \
background: hsl({hue}, 55%, 45%); \ background: hsl({hue}, 65%, {lightness}%); \
color: hsl({hue}, 100%, 97%); \ color: hsl({hue}, 100%, 97%); \
border-radius: 50%; \ border-radius: 50%; \
font-weight: 700; \ font-weight: 700; \
@@ -46,7 +49,7 @@ fn generate_letter_icon_css() -> String {
// Fallback for non-alphabetic first characters // Fallback for non-alphabetic first characters
css.push_str( css.push_str(
"label.letter-icon-other { \ "label.letter-icon-other { \
background: hsl(0, 0%, 50%); \ background: hsl(0, 0%, 35%); \
color: hsl(0, 0%, 97%); \ color: hsl(0, 0%, 97%); \
border-radius: 50%; \ border-radius: 50%; \
font-weight: 700; \ font-weight: 700; \
@@ -55,6 +58,37 @@ fn generate_letter_icon_css() -> String {
css css
} }
/// Set the pointer (hand) cursor on a widget, so it looks clickable on hover.
pub fn set_pointer_cursor(widget: &impl IsA<gtk::Widget>) {
widget.as_ref().set_cursor_from_name(Some("pointer"));
}
/// Recursively walk a widget tree and set pointer cursor on all interactive elements.
/// Call this on a view's root container after building it to cover buttons, switches,
/// toggle buttons, activatable rows, and other clickable widgets.
pub fn apply_pointer_cursors(widget: &impl IsA<gtk::Widget>) {
let w = widget.as_ref();
let is_interactive = w.is::<gtk::Button>()
|| w.is::<gtk::ToggleButton>()
|| w.is::<adw::SplitButton>()
|| w.is::<gtk::Switch>()
|| w.is::<gtk::CheckButton>()
|| w.is::<gtk::DropDown>()
|| w.is::<gtk::Scale>()
|| w.has_css_class("activatable");
if is_interactive {
w.set_cursor_from_name(Some("pointer"));
}
let mut child = w.first_child();
while let Some(c) = child {
apply_pointer_cursors(&c);
child = c.next_sibling();
}
}
/// Create a status badge pill label with the given text and style class. /// Create a status badge pill label with the given text and style class.
/// Style classes: "success", "warning", "error", "info", "neutral" /// Style classes: "success", "warning", "error", "info", "neutral"
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label { pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
@@ -79,6 +113,7 @@ pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) ->
let icon = gtk::Image::from_icon_name(icon_name); let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(12); icon.set_pixel_size(12);
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
hbox.append(&icon); hbox.append(&icon);
let label = gtk::Label::new(Some(text)); let label = gtk::Label::new(Some(text));
@@ -96,6 +131,76 @@ pub fn integration_badge(integrated: bool) -> gtk::Label {
} }
} }
/// Create an icon-only button with proper accessible label and minimum target size.
/// Every icon-only button in the app should use this factory to ensure AAA compliance.
pub fn accessible_icon_button(icon_name: &str, accessible_label: &str, tooltip: &str) -> gtk::Button {
let btn = gtk::Button::builder()
.icon_name(icon_name)
.tooltip_text(tooltip)
.valign(gtk::Align::Center)
.build();
btn.add_css_class("flat");
btn.add_css_class("accessible-icon-btn");
btn.update_property(&[gtk::accessible::Property::Label(accessible_label)]);
btn
}
/// Create a decorative suffix icon (e.g. arrows, checkmarks) with an accessible label.
/// Use this for "go-next-symbolic" and similar icons appended to rows.
pub fn accessible_suffix_icon(icon_name: &str, accessible_label: &str) -> gtk::Image {
let img = gtk::Image::from_icon_name(icon_name);
img.set_pixel_size(16);
img.set_accessible_role(gtk::AccessibleRole::Img);
img.update_property(&[gtk::accessible::Property::Label(accessible_label)]);
img
}
/// Announce the result of an async operation to screen readers.
/// Uses Alert role for errors and Status role for success.
pub fn announce_result(container: &impl gtk::prelude::IsA<gtk::Widget>, success: bool, message: &str) {
let role = if success {
gtk::AccessibleRole::Status
} else {
gtk::AccessibleRole::Alert
};
let label = gtk::Label::builder()
.label(message)
.visible(false)
.accessible_role(role)
.build();
label.update_property(&[gtk::accessible::Property::Label(message)]);
let target_box = find_ancestor_box(container.upcast_ref::<gtk::Widget>());
if let Some(box_widget) = target_box {
box_widget.append(&label);
label.set_visible(true);
let label_clone = label.clone();
let box_clone = box_widget.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
box_clone.remove(&label_clone);
});
}
}
/// Create a toast for informational messages (short-lived, normal priority).
pub fn info_toast(message: &str) -> adw::Toast {
adw::Toast::builder()
.title(message)
.timeout(4)
.build()
}
/// Create a toast for error/failure messages (longer display, high priority
/// so it jumps ahead of queued info toasts).
pub fn error_toast(message: &str) -> adw::Toast {
adw::Toast::builder()
.title(message)
.timeout(7)
.priority(adw::ToastPriority::High)
.build()
}
/// Format bytes into a human-readable string. /// Format bytes into a human-readable string.
pub fn format_size(bytes: i64) -> String { pub fn format_size(bytes: i64) -> String {
humansize::format_size(bytes as u64, humansize::BINARY) humansize::format_size(bytes as u64, humansize::BINARY)
@@ -104,8 +209,9 @@ pub fn format_size(bytes: i64) -> String {
/// Build an app icon widget with letter-circle fallback. /// Build an app icon widget with letter-circle fallback.
/// If the icon_path exists and is loadable, show the real icon. /// If the icon_path exists and is loadable, show the real icon.
/// Otherwise, generate a colored circle with the first letter of the app name. /// Otherwise, generate a colored circle with the first letter of the app name.
/// All returned widgets have an accessible label set to app_name for screen readers.
pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget { pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget {
// Try to load from path // Try to load from explicit path
if let Some(icon_path) = icon_path { if let Some(icon_path) = icon_path {
let path = std::path::Path::new(icon_path); let path = std::path::Path::new(icon_path);
if path.exists() { if path.exists() {
@@ -114,13 +220,31 @@ pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk
.pixel_size(pixel_size) .pixel_size(pixel_size)
.build(); .build();
image.set_paintable(Some(&texture)); image.set_paintable(Some(&texture));
image.update_property(&[gtk::accessible::Property::Label(app_name)]);
return image.upcast(); return image.upcast();
} }
} }
} }
// Letter-circle fallback // Try cached catalog icon
build_letter_icon(app_name, pixel_size) let cache_dir = crate::core::catalog::icon_cache_dir();
let sanitized = crate::core::catalog::sanitize_filename(app_name);
let cached_path = cache_dir.join(format!("{}.png", sanitized));
if cached_path.exists() {
if let Ok(texture) = gtk::gdk::Texture::from_filename(&cached_path) {
let image = gtk::Image::builder()
.pixel_size(pixel_size)
.build();
image.set_paintable(Some(&texture));
image.update_property(&[gtk::accessible::Property::Label(app_name)]);
return image.upcast();
}
}
// Letter-circle fallback (label text already visible, add accessible label)
let icon = build_letter_icon(app_name, pixel_size);
icon.update_property(&[gtk::accessible::Property::Label(app_name)]);
icon
} }
/// Build a colored circle with the first letter of the name as a fallback icon. /// Build a colored circle with the first letter of the name as a fallback icon.
@@ -185,7 +309,7 @@ pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>
let clipboard = button.display().clipboard(); let clipboard = button.display().clipboard();
clipboard.set_text(&text); clipboard.set_text(&text);
if let Some(ref overlay) = toast { if let Some(ref overlay) = toast {
overlay.add_toast(adw::Toast::new("Copied to clipboard")); overlay.add_toast(info_toast(&i18n("Copied to clipboard")));
} }
}); });
btn btn
@@ -240,6 +364,7 @@ pub fn show_crash_dialog(
.label(&format!("{}\n\nExit code: {}", explanation, exit_str)) .label(&format!("{}\n\nExit code: {}", explanation, exit_str))
.wrap(true) .wrap(true)
.xalign(0.0) .xalign(0.0)
.accessible_role(gtk::AccessibleRole::Alert)
.build(); .build();
content.append(&explanation_label); content.append(&explanation_label);
@@ -248,8 +373,10 @@ pub fn show_crash_dialog(
let heading = gtk::Label::builder() let heading = gtk::Label::builder()
.label("Error output:") .label("Error output:")
.xalign(0.0) .xalign(0.0)
.accessible_role(gtk::AccessibleRole::Heading)
.build(); .build();
heading.add_css_class("heading"); heading.add_css_class("heading");
heading.update_property(&[gtk::accessible::Property::Level(2)]);
content.append(&heading); content.append(&heading);
let text_view = gtk::TextView::builder() let text_view = gtk::TextView::builder()
@@ -264,6 +391,7 @@ pub fn show_crash_dialog(
.build(); .build();
text_view.buffer().set_text(stderr.trim()); text_view.buffer().set_text(stderr.trim());
text_view.add_css_class("card"); text_view.add_css_class("card");
text_view.update_property(&[gtk::accessible::Property::Label("Error output")]);
let scrolled = gtk::ScrolledWindow::builder() let scrolled = gtk::ScrolledWindow::builder()
.child(&text_view) .child(&text_view)
@@ -277,11 +405,13 @@ pub fn show_crash_dialog(
.build(); .build();
copy_btn.add_css_class("pill"); copy_btn.add_css_class("pill");
let full_error_copy = full_error.clone(); let full_error_copy = full_error.clone();
let content_for_copy = content.clone();
copy_btn.connect_clicked(move |btn| { copy_btn.connect_clicked(move |btn| {
let clipboard = btn.display().clipboard(); let clipboard = btn.display().clipboard();
clipboard.set_text(&full_error_copy); clipboard.set_text(&full_error_copy);
btn.set_label("Copied!"); btn.set_label("Copied!");
btn.set_sensitive(false); btn.set_sensitive(false);
announce(&content_for_copy, "Copied to clipboard");
}); });
content.append(&copy_btn); content.append(&copy_btn);
} }
@@ -294,9 +424,8 @@ pub fn show_crash_dialog(
/// Generate a plain-text explanation of why an app crashed based on stderr patterns. /// Generate a plain-text explanation of why an app crashed based on stderr patterns.
fn crash_explanation(stderr: &str) -> String { fn crash_explanation(stderr: &str) -> String {
if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") { if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") {
return "The app couldn't find a required display plugin. This usually means \ return "This app needs a display system plugin that is not installed. \
it needs a Qt library that isn't bundled inside the AppImage or \ Try installing the Qt platform packages for your system.".to_string();
available on your system.".to_string();
} }
if stderr.contains("cannot open shared object file") { if stderr.contains("cannot open shared object file") {
if let Some(pos) = stderr.find("cannot open shared object file") { if let Some(pos) = stderr.find("cannot open shared object file") {
@@ -305,29 +434,31 @@ fn crash_explanation(stderr: &str) -> String {
let lib = before[start + 2..].trim(); let lib = before[start + 2..].trim();
if !lib.is_empty() { if !lib.is_empty() {
return format!( return format!(
"The app needs a system library ({}) that isn't installed. \ "This app is missing a component it needs to run \
You may be able to fix this by installing the missing package.", (similar to a missing DLL on Windows). \
The missing component is: {}",
lib, lib,
); );
} }
} }
} }
return "The app needs a system library that isn't installed on your system.".to_string(); return "This app is missing a component it needs to run \
(similar to a missing DLL on Windows).".to_string();
} }
if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") { if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") {
return "The app crashed due to a memory error. This is usually a bug \ return "This app crashed immediately. This is usually a bug in the \
in the app itself, not something you can fix.".to_string(); app itself, not something you can fix.".to_string();
} }
if stderr.contains("Permission denied") { if stderr.contains("Permission denied") {
return "The app was blocked from accessing something it needs. \ return "This app does not have permission to run. Driftwood usually \
Check that the AppImage file has the right permissions.".to_string(); fixes this automatically - try removing and re-adding the app.".to_string();
} }
if stderr.contains("fatal IO error") || stderr.contains("display connection") { if stderr.contains("fatal IO error") || stderr.contains("display connection") {
return "The app lost its connection to the display server. This can happen \ return "This app could not connect to your display. If you are using \
with apps that don't fully support your display system.".to_string(); a remote session or container, this may not work.".to_string();
} }
if stderr.contains("FATAL:") || stderr.contains("Aborted") { if stderr.contains("FATAL:") || stderr.contains("Aborted") {
return "The app hit a fatal error and had to stop. The error details \ return "This app encountered an error during startup. The error details \
below may help identify the cause.".to_string(); below may help identify the cause.".to_string();
} }
if stderr.contains("Failed to initialize") { if stderr.contains("Failed to initialize") {
@@ -383,6 +514,42 @@ pub fn relative_time(timestamp: &str) -> String {
} }
} }
/// Format a count with K/M suffixes for readability.
pub fn format_count(n: i64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
/// Walk up the widget tree from the given widget to find the nearest Box
/// (directly or as a visible child of a Stack). This fixes announce/announce_result
/// silently failing when called on non-Box containers like FlowBox.
fn find_ancestor_box(widget: &gtk::Widget) -> Option<gtk::Box> {
let mut current: Option<gtk::Widget> = Some(widget.clone());
loop {
match current {
Some(ref w) => {
if let Some(b) = w.dynamic_cast_ref::<gtk::Box>() {
return Some(b.clone());
}
if let Some(s) = w.dynamic_cast_ref::<gtk::Stack>() {
if let Some(child) = s.visible_child() {
if let Ok(b) = child.downcast::<gtk::Box>() {
return Some(b);
}
}
}
current = w.parent();
}
None => return None,
}
}
}
/// Create a screen-reader live region announcement. /// Create a screen-reader live region announcement.
/// Inserts a hidden label with AccessibleRole::Alert into the given container, /// Inserts a hidden label with AccessibleRole::Alert into the given container,
/// which causes AT-SPI to announce the text to screen readers. /// which causes AT-SPI to announce the text to screen readers.
@@ -395,14 +562,7 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
.build(); .build();
label.update_property(&[gtk::accessible::Property::Label(text)]); label.update_property(&[gtk::accessible::Property::Label(text)]);
// Try to find a suitable Box container to attach the label to let target_box = find_ancestor_box(container.upcast_ref::<gtk::Widget>());
let target_box = container.dynamic_cast_ref::<gtk::Box>().cloned()
.or_else(|| {
// For Stack widgets, use the visible child if it's a Box
container.dynamic_cast_ref::<gtk::Stack>()
.and_then(|s| s.visible_child())
.and_then(|c| c.downcast::<gtk::Box>().ok())
});
if let Some(box_widget) = target_box { if let Some(box_widget) = target_box {
box_widget.append(&label); box_widget.append(&label);
@@ -414,3 +574,168 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
}); });
} }
} }
/// Build a GTK widget tree from markdown text using pulldown-cmark.
/// Returns a vertical Box containing formatted labels for each block element.
pub fn build_markdown_view(markdown: &str) -> gtk::Box {
use pulldown_cmark::{Event, Tag, TagEnd, Options, Parser};
let container = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
let parser = Parser::new_ext(markdown, options);
// Accumulate inline Pango markup, flush as labels on block boundaries
let mut markup = String::new();
let mut in_heading: Option<u8> = None;
let mut in_code_block = false;
let mut code_block_text = String::new();
let mut list_depth: u32 = 0;
let mut list_item_open = false;
let flush_label = |container: &gtk::Box, markup: &mut String, heading: Option<u8>| {
let text = markup.trim().to_string();
if text.is_empty() {
markup.clear();
return;
}
let label = gtk::Label::builder()
.use_markup(true)
.wrap(true)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.xalign(0.0)
.halign(gtk::Align::Fill)
.selectable(true)
.build();
label.set_markup(&text);
match heading {
Some(1) => { label.add_css_class("title-1"); label.set_margin_top(12); }
Some(2) => { label.add_css_class("title-2"); label.set_margin_top(10); }
Some(3) => { label.add_css_class("title-3"); label.set_margin_top(8); }
Some(4..=6) => { label.add_css_class("title-4"); label.set_margin_top(6); }
_ => {}
}
if let Some(level) = heading {
label.set_accessible_role(gtk::AccessibleRole::Heading);
label.update_property(&[gtk::accessible::Property::Level(level as i32)]);
}
container.append(&label);
markup.clear();
};
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
flush_label(&container, &mut markup, None);
in_heading = Some(level as u8);
}
Event::End(TagEnd::Heading(_)) => {
let level = in_heading.take();
flush_label(&container, &mut markup, level);
}
Event::Start(Tag::Paragraph) => {}
Event::End(TagEnd::Paragraph) => {
if !in_code_block {
flush_label(&container, &mut markup, None);
}
}
Event::Start(Tag::CodeBlock(_)) => {
flush_label(&container, &mut markup, None);
in_code_block = true;
code_block_text.clear();
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
let code_label = gtk::Label::builder()
.use_markup(true)
.wrap(true)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.xalign(0.0)
.halign(gtk::Align::Fill)
.selectable(true)
.css_classes(["monospace", "card"])
.margin_start(8)
.margin_end(8)
.build();
// Escape for Pango markup inside the <tt> tag
let escaped = glib::markup_escape_text(&code_block_text);
code_label.set_markup(&format!("<tt>{}</tt>", escaped));
code_label.update_property(&[gtk::accessible::Property::Description("Code block")]);
container.append(&code_label);
code_block_text.clear();
}
Event::Start(Tag::Strong) => markup.push_str("<b>"),
Event::End(TagEnd::Strong) => markup.push_str("</b>"),
Event::Start(Tag::Emphasis) => markup.push_str("<i>"),
Event::End(TagEnd::Emphasis) => markup.push_str("</i>"),
Event::Start(Tag::Strikethrough) => markup.push_str("<s>"),
Event::End(TagEnd::Strikethrough) => markup.push_str("</s>"),
Event::Start(Tag::Link { dest_url, .. }) => {
markup.push_str(&format!("<a href=\"{}\">", glib::markup_escape_text(&dest_url)));
}
Event::End(TagEnd::Link) => markup.push_str("</a>"),
Event::Start(Tag::List(_)) => {
flush_label(&container, &mut markup, None);
list_depth += 1;
}
Event::End(TagEnd::List(_)) => {
flush_label(&container, &mut markup, None);
list_depth = list_depth.saturating_sub(1);
}
Event::Start(Tag::Item) => {
list_item_open = true;
let indent = " ".repeat(list_depth.saturating_sub(1) as usize);
markup.push_str(&format!("{} \u{2022} ", indent));
}
Event::End(TagEnd::Item) => {
list_item_open = false;
flush_label(&container, &mut markup, None);
}
Event::Code(code) => {
markup.push_str(&format!("<tt>{}</tt>", glib::markup_escape_text(&code)));
}
Event::Text(text) => {
if in_code_block {
code_block_text.push_str(&text);
} else {
markup.push_str(&glib::markup_escape_text(&text));
}
}
Event::SoftBreak => {
if in_code_block {
code_block_text.push('\n');
} else if list_item_open {
markup.push(' ');
} else {
markup.push(' ');
}
}
Event::HardBreak => {
if in_code_block {
code_block_text.push('\n');
} else {
markup.push('\n');
}
}
Event::Rule => {
flush_label(&container, &mut markup, None);
let sep = gtk::Separator::new(gtk::Orientation::Horizontal);
sep.set_margin_top(8);
sep.set_margin_bottom(8);
container.append(&sep);
}
// Skip images, HTML, footnotes, etc.
_ => {}
}
}
// Flush any remaining text
flush_label(&container, &mut markup, None);
container
}

File diff suppressed because it is too large Load Diff