Compare commits

...

10 Commits

Author SHA1 Message Date
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
53 changed files with 10032 additions and 11065 deletions

41
Cargo.lock generated
View File

@@ -559,6 +559,7 @@ dependencies = [
"log",
"notify",
"notify-rust",
"pulldown-cmark",
"quick-xml",
"rusqlite",
"serde",
@@ -876,6 +877,15 @@ dependencies = [
"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]]
name = "getrandom"
version = "0.2.17"
@@ -1804,6 +1814,25 @@ dependencies = [
"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]]
name = "quick-xml"
version = "0.37.5"
@@ -2335,12 +2364,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"

View File

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

View File

@@ -97,4 +97,4 @@ driftwood clean-orphans
## License
GPL-3.0-or-later
CC0-1.0 (Public Domain)

View File

@@ -36,7 +36,7 @@
<choice value='recently-added'/>
<choice value='size'/>
</choices>
<default>'name'</default>
<default>'recently-added'</default>
<summary>Library sort mode</summary>
<description>How to sort the library: name, recently-added, or size.</description>
</key>
@@ -142,5 +142,15 @@
<summary>Watch removable media</summary>
<description>Scan removable drives for AppImages when mounted.</description>
</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>
</schemalist>

View File

@@ -2,7 +2,7 @@
<component type="desktop-application">
<id>app.driftwood.Driftwood</id>
<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>
<summary>Modern AppImage manager for GNOME desktops</summary>

View File

@@ -81,7 +81,7 @@
.drop-zone-icon {
color: @accent_bg_color;
opacity: 0.7;
opacity: 0.85;
}
/* ===== Card View (using libadwaita .card) ===== */
@@ -90,17 +90,42 @@
}
flowboxchild:focus-visible .card {
outline: 2px solid @accent_bg_color;
outline: 3px solid @accent_bg_color;
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 */
.status-ok {
border: 1px solid alpha(@success_bg_color, 0.4);
border: 1px solid alpha(@success_bg_color, 0.6);
}
.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 */
@@ -108,7 +133,7 @@ flowboxchild:focus-visible .card {
border-radius: 8px;
}
/* ===== WCAG AAA Focus Indicators ===== */
/* ===== WCAG AAA Focus Indicators (3px for enhanced visibility) ===== */
button:focus-visible,
togglebutton:focus-visible,
menubutton:focus-visible,
@@ -117,13 +142,13 @@ switch:focus-visible,
entry:focus-visible,
searchentry:focus-visible,
spinbutton:focus-visible {
outline: 2px solid @accent_bg_color;
outline: 3px solid @accent_bg_color;
outline-offset: 2px;
}
row:focus-visible {
outline: 2px solid @accent_bg_color;
outline-offset: -2px;
outline: 3px solid @accent_bg_color;
outline-offset: 2px;
}
/* Letter-circle fallback icon */
@@ -139,8 +164,8 @@ row:focus-visible {
color: @success_fg_color;
border-radius: 50%;
padding: 2px;
min-width: 16px;
min-height: 16px;
min-width: 24px;
min-height: 24px;
}
/* ===== Detail View Banner ===== */
@@ -157,10 +182,10 @@ row:focus-visible {
/* ===== Compatibility Warning Banner ===== */
.compat-warning-banner {
background: alpha(@warning_bg_color, 0.15);
background: alpha(@warning_bg_color, 0.22);
border-radius: 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) ===== */
@@ -168,11 +193,171 @@ row:focus-visible {
Reduced motion is handled by the GTK toolkit settings instead
(gtk-enable-animations). */
/* ===== Minimum Target Size (WCAG 2.5.8) ===== */
/* ===== Minimum Target Size (WCAG 2.5.5 AAA - 44x44px) ===== */
button.flat.circular,
button.flat:not(.pill):not(.suggested-action):not(.destructive-action) {
min-width: 24px;
min-height: 24px;
min-width: 44px;
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 ===== */
@@ -182,7 +367,7 @@ window.lightbox {
}
window.lightbox .lightbox-counter {
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.78);
color: white;
border-radius: 12px;
padding: 4px 12px;
@@ -193,3 +378,164 @@ window.lightbox .lightbox-nav {
min-width: 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;
}
}

File diff suppressed because it is too large Load Diff

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

@@ -3,6 +3,7 @@ use glib::ExitCode;
use gtk::prelude::*;
use std::time::Instant;
use crate::core::backup;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::duplicates;
@@ -885,209 +886,39 @@ fn cmd_verify(path: &str, expected_sha256: Option<&str>) -> ExitCode {
// --- Export/Import library ---
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
let records = match db.get_all_appimages() {
Ok(r) => r,
let path = output.unwrap_or("driftwood-apps.json");
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) => {
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 {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
let import_path = std::path::Path::new(file);
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) => {
eprintln!("Error reading {}: {}", file, e);
return ExitCode::FAILURE;
eprintln!("Error: {}", e);
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.
#[allow(dead_code)]
pub fn config_dir_fallback() -> PathBuf {
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)))
}
// ===== 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)]
mod tests {
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 {
Self::IoError(e) => write!(f, "I/O error: {}", e),
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::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
}

View File

@@ -417,6 +417,29 @@ pub fn set_mime_default(
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.
#[derive(Debug, Clone, PartialEq)]
pub enum DefaultAppType {

View File

@@ -113,7 +113,26 @@ pub fn launch_appimage(
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
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.
@@ -150,6 +169,7 @@ fn execute_appimage(
method: &LaunchMethod,
args: &[String],
extra_env: &[(&str, &str)],
sandbox_profile: Option<&Path>,
) -> LaunchResult {
let mut cmd = match method {
LaunchMethod::Direct => {
@@ -165,6 +185,9 @@ fn execute_appimage(
}
LaunchMethod::Sandboxed => {
let mut c = Command::new("firejail");
if let Some(profile) = sandbox_profile {
c.arg(format!("--profile={}", profile.display()));
}
c.arg("--appimage");
c.arg(appimage_path);
c.args(args);

View File

@@ -4,6 +4,7 @@ pub mod backup;
pub mod catalog;
pub mod database;
pub mod discovery;
pub mod github_enrichment;
pub mod duplicates;
pub mod footprint;
pub mod fuse;
@@ -14,6 +15,7 @@ pub mod notification;
pub mod orphan;
pub mod portable;
pub mod report;
pub mod sandbox;
pub mod security;
pub mod updater;
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.
/// 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 response = ureq::get(&index_url)
.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()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
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 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.
#[allow(dead_code)] // Community registry UI not yet wired
pub fn download_community_profile(
db: &Database,
entry: &CommunityProfileEntry,
) -> Result<SandboxProfile, SanboxError> {
) -> Result<SandboxProfile, SandboxError> {
let response = ureq::get(&entry.url)
.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()
.map_err(|e| SanboxError::Network(e.to_string()))?;
.map_err(|e| SandboxError::Network(e.to_string()))?;
let profile = SandboxProfile {
id: None,
@@ -163,7 +165,7 @@ pub fn download_community_profile(
};
save_profile(db, &profile)
.map_err(|e| SanboxError::Io(e.to_string()))?;
.map_err(|e| SandboxError::Io(e.to_string()))?;
Ok(profile)
}
@@ -221,11 +223,13 @@ pub fn profile_path_for_app(app_name: &str) -> Option<PathBuf> {
// --- Community registry types ---
#[allow(dead_code)] // Used by community registry search
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityIndex {
pub profiles: Vec<CommunityProfileEntry>,
}
#[allow(dead_code)] // Used by community registry search/download
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CommunityProfileEntry {
pub id: String,
@@ -240,16 +244,12 @@ pub struct CommunityProfileEntry {
// --- Error types ---
#[derive(Debug)]
#[allow(dead_code)] // Network + Parse variants used by community registry functions
pub enum SandboxError {
Io(String),
Database(String),
}
#[derive(Debug)]
pub enum SanboxError {
Network(String),
Parse(String),
Io(String),
}
impl std::fmt::Display for SandboxError {
@@ -257,16 +257,8 @@ impl std::fmt::Display for SandboxError {
match self {
Self::Io(e) => write!(f, "I/O 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::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"));
let err = SandboxError::Database("db locked".to_string());
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]

View File

@@ -24,23 +24,31 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
);
icon_widget.add_css_class("icon-dropshadow");
if record.integrated {
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&icon_widget));
let icon_overlay = gtk::Overlay::new();
icon_overlay.set_child(Some(&icon_widget));
if record.integrated {
let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic");
emblem.set_pixel_size(16);
emblem.add_css_class("integration-emblem");
emblem.set_halign(gtk::Align::End);
emblem.set_valign(gtk::Align::End);
emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]);
overlay.add_overlay(&emblem);
card.append(&overlay);
} else {
card.append(&icon_widget);
icon_overlay.add_overlay(&emblem);
}
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
let name_label = gtk::Label::builder()
.label(name)
@@ -101,6 +109,7 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
.child(&card)
.build();
child.add_css_class("activatable");
super::widgets::set_pointer_cursor(&child);
// Accessible label for screen readers
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"
);
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
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.

View File

@@ -6,6 +6,7 @@ use std::rc::Rc;
use crate::core::database::Database;
use crate::core::updater;
use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// 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>) {
@@ -64,12 +65,16 @@ pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database
.show_text(true)
.text(&i18n("Ready"))
.build();
overall_progress.update_property(&[gtk::accessible::Property::Label("Overall update progress")]);
content.append(&overall_progress);
// List of apps to update
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
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();
@@ -86,6 +91,7 @@ pub fn show_batch_update_dialog(parent: &impl IsA<gtk::Widget>, db: &Rc<Database
let status_badge = gtk::Label::builder()
.label(&i18n("Pending"))
.valign(gtk::Align::Center)
.accessible_role(gtk::AccessibleRole::Status)
.build();
status_badge.add_css_class("dim-label");
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);
progress_bar.set_text(Some(&i18n("Complete")));
progress_bar.set_fraction(1.0);
widgets::announce(progress_bar.upcast_ref::<gtk::Widget>(), &summary);
// Change cancel to Close
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 dialog_for_complete = dialog_ref.clone();
let item_count = items_ref.borrow().len();
let review = build_review_step(
&items_for_review,
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.set_visible_child_name("review");
widgets::announce(
stack_ref.upcast_ref::<gtk::Widget>(),
&format!("Analysis complete. Found {} reclaimable items.", item_count),
);
}
Err(_) => {
let error_page = adw::StatusPage::builder()
@@ -146,6 +151,7 @@ fn build_analysis_step() -> gtk::Box {
.height_request(48)
.halign(gtk::Align::Center)
.build();
spinner.update_property(&[gtk::accessible::Property::Label("Analyzing disk usage")]);
page.append(&spinner);
let label = gtk::Label::builder()
@@ -251,6 +257,7 @@ fn build_review_step(
.build();
let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
cat_icon.set_pixel_size(16);
cat_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
cat_header.append(&cat_icon);
let cat_label_text = cat.label();
let cat_label = gtk::Label::builder()
@@ -273,6 +280,7 @@ fn build_review_step(
.active(item.selected)
.valign(gtk::Align::Center)
.build();
check.update_property(&[gtk::accessible::Property::Label(&item.label)]);
check_buttons.borrow_mut().push((*idx, check.clone()));
let row = adw::ActionRow::builder()
@@ -403,6 +411,12 @@ fn execute_cleanup(
}
stack.add_named(&complete, Some("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 {

View File

@@ -7,7 +7,7 @@ use crate::core::database::Database;
use crate::core::duplicates;
use crate::core::fuse;
use crate::core::wayland;
use crate::i18n::ni18n;
use crate::i18n::{i18n, ni18n};
use super::widgets;
/// 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();
if !fuse_info.status.is_functional() {
let banner = adw::Banner::builder()
.title("FUSE is not working - some AppImages may not launch")
.button_label("Fix Now")
.title(&i18n("FUSE is not working - some AppImages may not launch"))
.button_label(&i18n("Fix Now"))
.revealed(true)
.accessible_role(gtk::AccessibleRole::Alert)
.build();
banner.set_action_name(Some("win.fix-fuse"));
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
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();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled));
widgets::apply_pointer_cursors(&toolbar);
adw::NavigationPage::builder()
.title("Dashboard")
@@ -88,6 +136,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let session_row = adw::ActionRow::builder()
.title("Display server")
.subtitle(session.label())
.tooltip_text("How your system draws windows on screen")
.build();
let session_badge = widgets::status_badge(
session.label(),
@@ -106,15 +155,16 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let de_row = adw::ActionRow::builder()
.title("Desktop environment")
.subtitle(&de)
.tooltip_text("Your desktop interface")
.build();
group.add(&de_row);
// FUSE status
let fuse_info = fuse::detect_system_fuse();
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.title("App compatibility")
.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();
let fuse_badge = widgets::status_badge(
fuse_info.status.label(),
@@ -127,7 +177,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
// Install hint if FUSE not functional
if let Some(ref hint) = fuse_info.install_hint {
let hint_row = adw::ActionRow::builder()
.title("Fix FUSE")
.title("Fix app compatibility")
.subtitle(hint)
.subtitle_selectable(true)
.build();
@@ -140,7 +190,7 @@ fn build_system_status_group() -> adw::PreferencesGroup {
let xwayland_row = adw::ActionRow::builder()
.title("XWayland")
.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();
let xwayland_badge = widgets::status_badge(
if has_xwayland { "Available" } else { "Unavailable" },
@@ -206,9 +256,7 @@ fn build_library_stats_group(db: &Rc<Database>) -> adw::PreferencesGroup {
.subtitle(&total.to_string())
.activatable(true)
.build();
let total_arrow = gtk::Image::from_icon_name("go-next-symbolic");
total_arrow.set_valign(gtk::Align::Center);
total_row.add_suffix(&total_arrow);
total_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View library")));
total_row.set_action_name(Some("navigation.pop"));
group.add(&total_row);
@@ -276,9 +324,7 @@ fn build_updates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
badge.set_valign(gtk::Align::Center);
updates_row.add_suffix(&badge);
}
let updates_arrow = gtk::Image::from_icon_name("go-next-symbolic");
updates_arrow.set_valign(gtk::Align::Center);
updates_row.add_suffix(&updates_arrow);
updates_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View updates")));
group.add(&updates_row);
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");
update_badge.set_valign(gtk::Align::Center);
update_all_row.add_suffix(&update_badge);
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
arrow.set_valign(gtk::Align::Center);
update_all_row.add_suffix(&arrow);
update_all_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Update all apps")));
group.add(&update_all_row);
}
@@ -340,9 +384,7 @@ fn build_duplicates_summary_group(db: &Rc<Database>) -> adw::PreferencesGroup {
.activatable(true)
.build();
groups_row.set_action_name(Some("win.find-duplicates"));
let dupes_arrow = gtk::Image::from_icon_name("go-next-symbolic");
dupes_arrow.set_valign(gtk::Align::Center);
groups_row.add_suffix(&dupes_arrow);
groups_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("View duplicates")));
group.add(&groups_row);
if summary.total_potential_savings > 0 {
@@ -376,9 +418,7 @@ fn build_disk_usage_group(db: &Rc<Database>) -> adw::PreferencesGroup {
.activatable(true)
.build();
total_row.set_action_name(Some("win.cleanup"));
let disk_arrow = gtk::Image::from_icon_name("go-next-symbolic");
disk_arrow.set_valign(gtk::Align::Center);
total_row.add_suffix(&disk_arrow);
total_row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Clean up disk")));
group.add(&total_row);
// Largest AppImages
@@ -452,7 +492,7 @@ fn build_quick_actions_group() -> adw::PreferencesGroup {
scan_btn.add_css_class("suggested-action");
scan_btn.set_action_name(Some("win.scan"));
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()
@@ -462,7 +502,7 @@ fn build_quick_actions_group() -> adw::PreferencesGroup {
updates_btn.add_css_class("pill");
updates_btn.set_action_name(Some("win.check-updates"));
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()
@@ -472,7 +512,7 @@ fn build_quick_actions_group() -> adw::PreferencesGroup {
clean_btn.add_css_class("pill");
clean_btn.set_action_name(Some("win.clean-orphans"));
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);

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::inspector;
use crate::i18n::{i18n, ni18n_f};
use super::widgets;
/// Registered file info returned by the fast registration phase.
struct RegisteredFile {
@@ -50,7 +51,13 @@ pub fn show_drop_dialog(
};
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 {
files
.iter()
@@ -81,15 +88,16 @@ pub fn show_drop_dialog(
.halign(gtk::Align::Center)
.margin_top(12)
.build();
image.update_property(&[gtk::accessible::Property::Label("App icon preview")]);
dialog_ref.set_extra_child(Some(&image));
}
});
}
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("keep-in-place", &i18n("Keep in place"));
dialog.add_response("copy-only", &i18n("Copy to Applications"));
dialog.add_response("copy-and-integrate", &i18n("Copy & add to menu"));
dialog.add_response("keep-in-place", &i18n("Run Portable"));
dialog.add_response("copy-only", &i18n("Copy to Apps"));
dialog.add_response("copy-and-integrate", &i18n("Copy & Add to Launcher"));
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("copy-and-integrate"));
@@ -124,7 +132,7 @@ pub fn show_drop_dialog(
// Show toast
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 {
let msg = ni18n_f(
"Added {} app",
@@ -132,7 +140,7 @@ pub fn show_drop_dialog(
added as u32,
&[("{}", &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
@@ -157,11 +165,11 @@ pub fn show_drop_dialog(
}
Ok(Err(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) => {
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;
}
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} items",
removed_count as u32,
@@ -270,7 +270,7 @@ fn build_group_widget(
db_ref.remove_appimage(record_id).ok();
// Update UI
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);

View File

@@ -3,6 +3,7 @@ use gtk::gio;
use crate::core::fuse;
use crate::i18n::i18n;
use super::widgets;
/// Show a FUSE installation wizard dialog.
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();
let title = gtk::Label::builder()
.label(&i18n("FUSE is required"))
.label(&i18n("Additional setup needed"))
.xalign(0.0)
.build();
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()
.label(&i18n(
"Most AppImages require libfuse2 to mount and run. \
Without it, apps will use a slower extract-and-run fallback or may not launch at all.",
"Most apps need a small system component called FUSE to start quickly. \
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)
.xalign(0.0)
@@ -90,6 +92,7 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.xalign(0.0)
.wrap(true)
.visible(false)
.accessible_role(gtk::AccessibleRole::Status)
.build();
content.append(&status_label);
@@ -101,18 +104,34 @@ pub fn show_fuse_wizard(parent: &impl IsA<gtk::Widget>) {
.margin_top(12)
.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()
.label(&i18n("Install via pkexec"))
.build();
install_btn.add_css_class("suggested-action");
install_btn.add_css_class("pill");
button_box.append(&skip_btn);
button_box.append(&install_btn);
content.append(&button_box);
toolbar.set_content(Some(&content));
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 status_ref = status_label.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
let fuse_info = fuse::detect_system_fuse();
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");
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
} 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");
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
}
}
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");
btn.set_sensitive(true);
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
}
Ok(Err(e)) => {
status.set_label(&format!("Error: {}", e));
let msg = format!("Error: {}", e);
status.set_label(&msg);
status.add_css_class("error");
btn.set_sensitive(true);
widgets::announce(status.upcast_ref::<gtk::Widget>(), &msg);
}
Err(_) => {
status.set_label(&i18n("Task failed unexpectedly."));
let msg = i18n("Task failed unexpectedly.");
status.set_label(&msg);
status.add_css_class("error");
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)
.build();
image.set_paintable(Some(&texture));
image.update_property(&[gtk::accessible::Property::Label(name)]);
name_row.add_prefix(&image);
}
}
@@ -88,6 +89,7 @@ pub fn show_integration_dialog(
.build();
let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check1.set_valign(gtk::Align::Center);
check1.set_accessible_role(gtk::AccessibleRole::Presentation);
desktop_row.add_prefix(&check1);
actions_box.append(&desktop_row);
@@ -97,6 +99,7 @@ pub fn show_integration_dialog(
.build();
let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check2.set_valign(gtk::Align::Center);
check2.set_accessible_role(gtk::AccessibleRole::Presentation);
icon_row.add_prefix(&check2);
actions_box.append(&icon_row);
@@ -156,6 +159,7 @@ pub fn show_integration_dialog(
.build();
let warning_icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
warning_icon.set_pixel_size(16);
warning_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
warning_header.append(&warning_icon);
let warning_title = gtk::Label::builder()
.label(&i18n("Compatibility Notes"))
@@ -168,6 +172,9 @@ pub fn show_integration_dialog(
let compat_list = gtk::ListBox::new();
compat_list.add_css_class("boxed-list");
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 {
let row = adw::ActionRow::builder()

View File

@@ -46,6 +46,10 @@ pub struct LibraryView {
records: Rc<RefCell<Vec<AppImageRecord>>>,
search_empty_page: adw::StatusPage,
update_banner: adw::Banner,
// Tag filtering
tag_bar: gtk::Box,
tag_scroll: gtk::ScrolledWindow,
active_tag: Rc<RefCell<Option<String>>>,
// Batch selection
selection_mode: Rc<Cell<bool>>,
selected_ids: Rc<RefCell<HashSet<i64>>>,
@@ -89,7 +93,7 @@ impl LibraryView {
let search_button = gtk::ToggleButton::builder()
.icon_name("system-search-symbolic")
.tooltip_text(&i18n("Search"))
.tooltip_text(&i18n("Search (Ctrl+F)"))
.build();
search_button.add_css_class("flat");
search_button.update_property(&[AccessibleProperty::Label("Toggle search")]);
@@ -124,7 +128,7 @@ impl LibraryView {
// Scan button
let scan_button = gtk::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Scan for AppImages"))
.tooltip_text(&i18n("Scan for AppImages (Ctrl+R)"))
.build();
scan_button.add_css_class("flat");
scan_button.set_action_name(Some("win.scan"));
@@ -146,6 +150,7 @@ impl LibraryView {
// Add button (shows drop overlay)
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_content = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
@@ -185,6 +190,7 @@ impl LibraryView {
.placeholder_text(&i18n("Search AppImages..."))
.hexpand(true)
.build();
search_entry.update_property(&[gtk::accessible::Property::Label("Search installed AppImages")]);
let search_clamp = adw::Clamp::builder()
.maximum_size(500)
@@ -219,6 +225,7 @@ impl LibraryView {
.height_request(32)
.halign(gtk::Align::Center)
.build();
spinner.update_property(&[gtk::accessible::Property::Label("Scanning for AppImages")]);
loading_page.set_child(Some(&spinner));
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.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(&browse_catalog_btn);
empty_button_box.append(&learn_btn);
let empty_page = adw::StatusPage::builder()
.icon_name("application-x-executable-symbolic")
.title(&i18n("No AppImages Yet"))
.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)
.build();
@@ -320,6 +357,7 @@ impl LibraryView {
let selection_label = gtk::Label::builder()
.label("0 selected")
.accessible_role(gtk::AccessibleRole::Status)
.build();
action_bar.set_center_widget(Some(&selection_label));
@@ -344,21 +382,48 @@ impl LibraryView {
.title(&i18n("Updates available"))
.button_label(&i18n("View Updates"))
.revealed(false)
.accessible_role(gtk::AccessibleRole::Alert)
.build();
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 ---
let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
content_box.append(&update_banner);
content_box.append(&search_bar);
content_box.append(&tag_scroll);
content_box.append(&stack);
content_box.append(&action_bar);
let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header_bar);
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()
.title("Driftwood")
@@ -475,6 +540,7 @@ impl LibraryView {
&i18n_f("No AppImages match '{}'. Try a different search term.", &[("{}", &query)])
));
stack_d.set_visible_child_name("search-empty");
widgets::announce(flow_box_d.upcast_ref::<gtk::Widget>(), "No results found");
} else {
let view_name = if view_mode_d.get() == ViewMode::Grid {
"grid"
@@ -482,6 +548,7 @@ impl LibraryView {
"list"
};
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 action_bar_ref = action_bar.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| {
let active = btn.is_active();
selection_mode_ref.set(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 {
selected_ids_ref.borrow_mut().clear();
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,
search_empty_page,
update_banner,
tag_bar,
tag_scroll,
active_tag,
selection_mode,
selected_ids,
_action_bar: action_bar,
@@ -537,6 +623,7 @@ impl LibraryView {
match state {
LibraryState::Loading => {
self.stack.set_visible_child_name("loading");
widgets::announce(&self.stack, "Scanning for AppImages");
}
LibraryState::Empty => {
self.stack.set_visible_child_name("empty");
@@ -566,36 +653,41 @@ impl LibraryView {
self.list_box.remove(&row);
}
// Reset active tag filter
*self.active_tag.borrow_mut() = None;
// Sort records based on current sort mode
let mut new_records = new_records;
match self.sort_mode.get() {
SortMode::NameAsc => {
new_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 => {
new_records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
}
SortMode::Size => {
new_records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
self.sort_records(&mut new_records);
// Collect all unique tags for the tag filter bar
let mut all_tags = std::collections::BTreeSet::new();
for record in &new_records {
if let Some(ref tags) = record.tags {
for tag in tags.split(',') {
let trimmed = tag.trim();
if !trimmed.is_empty() {
all_tags.insert(trimmed.to_string());
}
}
}
}
// Build tag filter chip bar
self.build_tag_chips(&all_tags);
// Build cards and list rows
for record in &new_records {
// Grid card
let card = app_card::build_app_card(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);
// List row
let row = self.build_list_row(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);
}
@@ -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 {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
@@ -669,6 +846,18 @@ impl LibraryView {
icon.add_css_class("icon-rounded");
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)
if let Some(badge) = app_card::build_priority_badge(record) {
badge.set_valign(gtk::Align::Center);
@@ -676,8 +865,7 @@ impl LibraryView {
}
// Navigate arrow
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
row.add_suffix(&arrow);
row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Open details")));
row
}
@@ -734,13 +922,35 @@ impl LibraryView {
/// Toggle selection of a record ID (used by card click in selection mode).
pub fn toggle_selection(&self, id: i64) {
let mut ids = self.selected_ids.borrow_mut();
if ids.contains(&id) {
let was_selected = ids.contains(&id);
if was_selected {
ids.remove(&id);
} else {
ids.insert(id);
}
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.
@@ -791,7 +1001,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 1: Launch
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);
// Section 2: Actions
@@ -802,7 +1012,7 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 3: Integration + folder
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("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
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)));
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
}
/// 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) {
let popover = gtk::PopoverMenu::from_model(Some(menu_model));
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_full(menu_model, gtk::PopoverMenuFlags::NESTED);
popover.set_parent(widget.as_ref());
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
let popover_cleanup = popover.clone();
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);
}
/// 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 batch_update_dialog;
pub mod catalog_detail;
pub mod catalog_tile;
pub mod catalog_view;
pub mod cleanup_wizard;
pub mod dashboard;

View File

@@ -37,10 +37,10 @@ pub fn show_permission_dialog(
extra.append(&access_label);
let items = [
"Your home directory and files",
"Network and internet access",
"Display server (Wayland/X11)",
"System D-Bus and services",
"Your files and folders",
"Internet and network access",
"Your screen and windows",
"Background system services",
];
for item in &items {
let label = gtk::Label::builder()
@@ -50,15 +50,29 @@ pub fn show_permission_dialog(
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
if launcher::has_firejail() {
let sandbox_note = gtk::Label::builder()
.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)
.xalign(0.0)
.margin_top(8)
.margin_top(4)
.build();
sandbox_note.add_css_class("dim-label");
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_updates_page(&settings));
super::widgets::apply_pointer_cursors(&dialog);
dialog.present(Some(parent));
}
@@ -80,7 +81,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Scan Locations group
let scan_group = adw::PreferencesGroup::builder()
.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();
let dirs = settings.strv("scan-directories");
@@ -103,7 +104,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
.build();
add_button.add_css_class("flat");
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();
@@ -156,6 +157,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Automation group
let automation_group = adw::PreferencesGroup::builder()
.title(&i18n("Automation"))
.description(&i18n("Control what happens automatically when Driftwood starts or finds new apps."))
.build();
let auto_scan_row = adw::SwitchRow::builder()
@@ -259,6 +261,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Checking group
let checking_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Checking"))
.description(&i18n("Let Driftwood periodically check if newer versions of your apps are available."))
.build();
let auto_update_row = adw::SwitchRow::builder()
@@ -296,6 +299,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Update Behavior group
let behavior_group = adw::PreferencesGroup::builder()
.title(&i18n("Update Behavior"))
.description(&i18n("Control what happens when an app is updated to a newer version."))
.build();
let cleanup_row = adw::ComboRow::builder()
@@ -358,7 +362,7 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Security Scanning group
let security_group = adw::PreferencesGroup::builder()
.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();
let auto_security_row = adw::SwitchRow::builder()
@@ -413,6 +417,44 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
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
}
@@ -421,15 +463,12 @@ fn add_directory_row(list_box: &gtk::ListBox, dir: &str, settings: &gio::Setting
.title(dir)
.build();
let remove_btn = gtk::Button::builder()
.icon_name("edit-delete-symbolic")
.valign(gtk::Align::Center)
.tooltip_text(&i18n("Remove"))
.build();
remove_btn.add_css_class("flat");
remove_btn.update_property(&[
gtk::accessible::Property::Label(&format!("{} {}", i18n("Remove directory"), dir)),
]);
let remove_label = format!("{} {}", i18n("Remove directory"), dir);
let remove_btn = super::widgets::accessible_icon_button(
"edit-delete-symbolic",
&remove_label,
&i18n("Remove"),
);
let list_ref = list_box.clone();
let settings_ref = settings.clone();

View File

@@ -7,6 +7,7 @@ use crate::core::database::Database;
use crate::core::notification;
use crate::core::report;
use crate::core::security;
use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// 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| {
btn.set_sensitive(false);
btn.set_label("Scanning...");
widgets::announce(btn.upcast_ref::<gtk::Widget>(), "Scanning for vulnerabilities");
let btn_clone = btn.clone();
let db_refresh = db_scan.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_label("Exporting...");
widgets::announce(btn_clone.upcast_ref::<gtk::Widget>(), "Exporting report");
let btn_done = btn_clone.clone();
let toast_done = toast_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())
.unwrap_or("report");
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 {
let group = adw::PreferencesGroup::builder()
.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();
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()
.title("Critical")
.subtitle(&summary.critical.to_string())
.tooltip_text("Could allow an attacker to take control of affected components")
.build();
let badge = widgets::status_badge("Critical", "error");
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()
.title("High")
.subtitle(&summary.high.to_string())
.tooltip_text("Could allow unauthorized access to data processed by the app")
.build();
let badge = widgets::status_badge("High", "error");
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()
.title("Medium")
.subtitle(&summary.medium.to_string())
.tooltip_text("Could cause the app to behave unexpectedly or crash")
.build();
let badge = widgets::status_badge("Medium", "warning");
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()
.title("Low")
.subtitle(&summary.low.to_string())
.tooltip_text("Minor issue with limited practical impact")
.build();
let badge = widgets::status_badge("Low", "neutral");
badge.set_valign(gtk::Align::Center);
@@ -340,7 +347,10 @@ fn build_app_findings_group(
summary: &crate::core::database::CveSummary,
cve_matches: &[crate::core::database::CveMatchRecord],
) -> 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()
.title(app_name)
.description(&description)
@@ -401,7 +411,14 @@ fn build_app_findings_group(
let cve_row = adw::ActionRow::builder()
.title(&format!("{} ({})", cve.cve_id, severity))
.subtitle(&subtitle)
.subtitle_selectable(true)
.build();
cve_row.update_property(&[
gtk::accessible::Property::Description(&format!(
"{} severity vulnerability in {}. {}",
severity, lib_name, subtitle,
)),
]);
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.
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() {
Ok(r) => r,
Err(e) => {
log::error!("Failed to get appimages for update check: {}", e);
return 0;
return (0, vec![]);
}
};
let mut updates_found = 0u32;
let mut updated_names = Vec::new();
for record in &records {
if record.pinned {
continue;
}
let appimage_path = std::path::Path::new(&record.path);
if !appimage_path.exists() {
continue;
@@ -292,6 +297,8 @@ pub fn batch_check_updates(db: &Database) -> u32 {
if let Some(ref version) = result.latest_version {
db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok();
updates_found += 1;
let name = record.app_name.as_deref().unwrap_or(&record.filename);
updated_names.push(name.to_string());
}
} else {
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::core::database::Database;
use crate::core::updater;
use crate::i18n::i18n;
use crate::i18n::{i18n, ni18n_f};
use crate::ui::update_dialog;
use crate::ui::widgets;
@@ -23,10 +23,11 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
header.set_title_widget(Some(&title));
// Check Now button
let check_btn = gtk::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text(&i18n("Check for updates"))
.build();
let check_btn = widgets::accessible_icon_button(
"view-refresh-symbolic",
"Check for updates",
&i18n("Check for updates (Ctrl+U)"),
);
header.pack_end(&check_btn);
// 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)
.halign(gtk::Align::Center)
.build();
spinner.update_property(&[gtk::accessible::Property::Label("Checking for updates")]);
checking_page.set_child(Some(&spinner));
stack.add_named(&checking_page, Some("checking"));
@@ -86,6 +88,18 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
.build();
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()
.maximum_size(800)
.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
drop(fresh_db);
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);
if count > 0 {
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);
@@ -197,6 +211,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header);
toolbar_view.set_content(Some(&toast_overlay));
widgets::apply_pointer_cursors(&toolbar_view);
toolbar_view
}
@@ -244,25 +259,62 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
state.stack.set_visible_child_name("updates");
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 {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let row = adw::ActionRow::builder()
.title(name)
.activatable(false)
.build();
// Show version info: current -> latest
// Show version info: current -> latest (with size if available)
let current = record.app_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);
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
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
let update_btn = gtk::Button::builder()
.icon_name("software-update-available-symbolic")
@@ -270,6 +322,7 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
.tooltip_text(&i18n("Update this app"))
.css_classes(["flat"])
.build();
update_btn.update_property(&[gtk::accessible::Property::Label(&format!("Update {}", name))]);
let app_id = record.id;
let update_url = record.update_url.clone();
@@ -308,11 +361,11 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
btn_c.set_sensitive(true);
if result.unwrap_or(false) {
state_c.toast_overlay.add_toast(
adw::Toast::new(&i18n("Update complete")),
widgets::info_toast(&i18n("Update complete")),
);
} else {
state_c.toast_overlay.add_toast(
adw::Toast::new(&i18n("Update failed")),
widgets::error_toast(&i18n("Update failed")),
);
}
populate_update_list(&state_c);
@@ -323,3 +376,81 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
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 std::sync::OnceLock;
use crate::i18n::i18n;
/// Ensures the shared letter-icon CSS provider is registered on the default
/// display exactly once. The provider defines `.letter-icon-a` through
/// `.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
/// `.letter-icon-other` fallback. Each letter gets a unique hue evenly
/// distributed around the color wheel (saturation 55%, lightness 45% for
/// the background, lightness 97% for the foreground text) so that the 26
/// letter icons are visually distinct while remaining legible.
/// distributed around the color wheel. Lightness is adjusted per hue range
/// to guarantee WCAG AAA 7:1 contrast ratio with the white foreground text.
/// Yellow/green hues (50-170) are inherently lighter, so they use darker
/// lightness values.
fn generate_letter_icon_css() -> String {
let mut css = String::with_capacity(4096);
for i in 0u32..26 {
let letter = (b'a' + i as u8) as char;
let hue = (i * 360) / 26;
// HSL background: moderate saturation, medium lightness
// HSL foreground: same hue, very light for contrast
// Yellow/green hues have high luminance; darken them more for contrast
let lightness = if (50..170).contains(&hue) { 30 } else { 38 };
css.push_str(&format!(
"label.letter-icon-{letter} {{ \
background: hsl({hue}, 55%, 45%); \
background: hsl({hue}, 65%, {lightness}%); \
color: hsl({hue}, 100%, 97%); \
border-radius: 50%; \
font-weight: 700; \
@@ -46,7 +49,7 @@ fn generate_letter_icon_css() -> String {
// Fallback for non-alphabetic first characters
css.push_str(
"label.letter-icon-other { \
background: hsl(0, 0%, 50%); \
background: hsl(0, 0%, 35%); \
color: hsl(0, 0%, 97%); \
border-radius: 50%; \
font-weight: 700; \
@@ -55,6 +58,37 @@ fn generate_letter_icon_css() -> String {
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.
/// Style classes: "success", "warning", "error", "info", "neutral"
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);
icon.set_pixel_size(12);
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
hbox.append(&icon);
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.
pub fn format_size(bytes: i64) -> String {
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.
/// 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.
/// 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 {
// Try to load from path
// Try to load from explicit path
if let Some(icon_path) = icon_path {
let path = std::path::Path::new(icon_path);
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)
.build();
image.set_paintable(Some(&texture));
image.update_property(&[gtk::accessible::Property::Label(app_name)]);
return image.upcast();
}
}
}
// Letter-circle fallback
build_letter_icon(app_name, pixel_size)
// Try cached catalog icon
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.
@@ -185,7 +309,7 @@ pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>
let clipboard = button.display().clipboard();
clipboard.set_text(&text);
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
@@ -240,6 +364,7 @@ pub fn show_crash_dialog(
.label(&format!("{}\n\nExit code: {}", explanation, exit_str))
.wrap(true)
.xalign(0.0)
.accessible_role(gtk::AccessibleRole::Alert)
.build();
content.append(&explanation_label);
@@ -248,8 +373,10 @@ pub fn show_crash_dialog(
let heading = gtk::Label::builder()
.label("Error output:")
.xalign(0.0)
.accessible_role(gtk::AccessibleRole::Heading)
.build();
heading.add_css_class("heading");
heading.update_property(&[gtk::accessible::Property::Level(2)]);
content.append(&heading);
let text_view = gtk::TextView::builder()
@@ -264,6 +391,7 @@ pub fn show_crash_dialog(
.build();
text_view.buffer().set_text(stderr.trim());
text_view.add_css_class("card");
text_view.update_property(&[gtk::accessible::Property::Label("Error output")]);
let scrolled = gtk::ScrolledWindow::builder()
.child(&text_view)
@@ -277,11 +405,13 @@ pub fn show_crash_dialog(
.build();
copy_btn.add_css_class("pill");
let full_error_copy = full_error.clone();
let content_for_copy = content.clone();
copy_btn.connect_clicked(move |btn| {
let clipboard = btn.display().clipboard();
clipboard.set_text(&full_error_copy);
btn.set_label("Copied!");
btn.set_sensitive(false);
announce(&content_for_copy, "Copied to clipboard");
});
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.
fn crash_explanation(stderr: &str) -> String {
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 \
it needs a Qt library that isn't bundled inside the AppImage or \
available on your system.".to_string();
return "This app needs a display system plugin that is not installed. \
Try installing the Qt platform packages for your system.".to_string();
}
if stderr.contains("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();
if !lib.is_empty() {
return format!(
"The app needs a system library ({}) that isn't installed. \
You may be able to fix this by installing the missing package.",
"This app is missing a component it needs to run \
(similar to a missing DLL on Windows). \
The missing component is: {}",
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") {
return "The app crashed due to a memory error. This is usually a bug \
in the app itself, not something you can fix.".to_string();
return "This app crashed immediately. This is usually a bug in the \
app itself, not something you can fix.".to_string();
}
if stderr.contains("Permission denied") {
return "The app was blocked from accessing something it needs. \
Check that the AppImage file has the right permissions.".to_string();
return "This app does not have permission to run. Driftwood usually \
fixes this automatically - try removing and re-adding the app.".to_string();
}
if stderr.contains("fatal IO error") || stderr.contains("display connection") {
return "The app lost its connection to the display server. This can happen \
with apps that don't fully support your display system.".to_string();
return "This app could not connect to your display. If you are using \
a remote session or container, this may not work.".to_string();
}
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();
}
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.
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
/// 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();
label.update_property(&[gtk::accessible::Property::Label(text)]);
// Try to find a suitable Box container to attach the label to
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())
});
let target_box = find_ancestor_box(container.upcast_ref::<gtk::Widget>());
if let Some(box_widget) = target_box {
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

964
tools/a11y-audit.py Normal file
View File

@@ -0,0 +1,964 @@
#!/usr/bin/env python3
"""
Automated WCAG 2.2 AAA accessibility audit for Driftwood via AT-SPI.
Launches the app, waits for it to register on the accessibility bus,
then walks the entire widget tree checking for violations at A, AA, and AAA
levels:
Level A:
- SC 1.1.1: Images with no name and no decorative marking
- SC 1.3.1: Headings missing a level property
- SC 2.1.1: Interactive widgets not keyboard-focusable
- SC 4.1.2: Interactive widgets with no accessible name
Level AA:
- SC 2.5.8: Target size below 24x24px minimum
- SC 4.1.2: Progress bars / spinners without labels
Level AAA:
- SC 2.1.3: All interactive widgets must be keyboard accessible (no traps)
- SC 2.4.8: Window must have a meaningful title (location)
- SC 2.4.9: Link purpose from link text alone
- SC 2.4.10: Section headings exist in content regions
- SC 2.5.5: Target size 44x44px (AAA level)
- SC 3.2.5: Change on request - no auto-refresh detection
- SC 3.3.9: Accessible authentication - no cognitive function tests
Structural / informational:
- Live regions (alert/status/log/timer roles) inventory
- Keyboard focus traversal test across all views
- Tab order validation (focusable widgets reachable)
Usage:
python3 tools/a11y-audit.py [--no-launch] [--level aa|aaa] [--verbose]
--no-launch Attach to an already-running Driftwood instance
--level Minimum conformance level to check (default: aaa)
--verbose Show informational messages and live region inventory
"""
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
import subprocess
import sys
import time
import signal
import os
import json
# --- Configuration ---
AUDIT_LEVEL = "aaa" # Default: check everything up to AAA
VERBOSE = False
# Roles that MUST have an accessible name
INTERACTIVE_ROLES = {
Atspi.Role.PUSH_BUTTON,
Atspi.Role.TOGGLE_BUTTON,
Atspi.Role.CHECK_BOX,
Atspi.Role.RADIO_BUTTON,
Atspi.Role.MENU_ITEM,
Atspi.Role.ENTRY,
Atspi.Role.SPIN_BUTTON,
Atspi.Role.SLIDER,
Atspi.Role.COMBO_BOX,
Atspi.Role.LINK,
Atspi.Role.SPLIT_BUTTON if hasattr(Atspi.Role, "SPLIT_BUTTON") else None,
}
INTERACTIVE_ROLES.discard(None)
# Roles where missing name is just a warning (container-like)
CONTAINER_ROLES = {
Atspi.Role.PANEL,
Atspi.Role.FILLER,
Atspi.Role.SCROLL_PANE,
Atspi.Role.VIEWPORT,
Atspi.Role.FRAME,
Atspi.Role.SECTION,
Atspi.Role.BLOCK_QUOTE,
Atspi.Role.REDUNDANT_OBJECT,
Atspi.Role.SEPARATOR,
}
# Roles considered "image-like"
IMAGE_ROLES = {
Atspi.Role.IMAGE,
Atspi.Role.ICON,
Atspi.Role.ANIMATION,
}
# Roles that indicate live regions (for inventory)
LIVE_REGION_ROLES = {
Atspi.Role.ALERT,
Atspi.Role.NOTIFICATION if hasattr(Atspi.Role, "NOTIFICATION") else None,
Atspi.Role.STATUS_BAR,
Atspi.Role.LOG if hasattr(Atspi.Role, "LOG") else None,
Atspi.Role.TIMER if hasattr(Atspi.Role, "TIMER") else None,
Atspi.Role.MARQUEE if hasattr(Atspi.Role, "MARQUEE") else None,
}
LIVE_REGION_ROLES.discard(None)
# Roles that represent content sections (for SC 2.4.10 heading check)
CONTENT_REGION_ROLES = {
Atspi.Role.SCROLL_PANE,
Atspi.Role.PANEL,
Atspi.Role.SECTION,
Atspi.Role.DOCUMENT_FRAME,
}
# Decorative roles that don't need names
DECORATIVE_ROLES = set()
# GTK maps AccessibleRole::Presentation to ROLE_REDUNDANT_OBJECT
# but some versions map it differently; we check name == "" as well
class Issue:
def __init__(self, severity, criterion, level, path, role, message):
self.severity = severity # "error", "warning", "info"
self.criterion = criterion
self.level = level # "A", "AA", "AAA"
self.path = path
self.role = role
self.message = message
def __str__(self):
return (f"[{self.severity.upper()}] SC {self.criterion} ({self.level}) "
f"| {self.role} | {self.path}\n {self.message}")
# --- Stats tracking ---
class AuditStats:
def __init__(self):
self.total_interactive = 0
self.total_focusable = 0
self.total_images = 0
self.total_headings = 0
self.total_links = 0
self.total_live_regions = 0
self.windows_with_titles = 0
self.windows_total = 0
self.focus_chain = [] # ordered list of focusable widget paths
self.content_sections = 0
self.sections_with_headings = 0
stats = AuditStats()
# --- Helper functions ---
def get_name(node):
"""Get accessible name, handling exceptions."""
try:
return node.get_name() or ""
except Exception:
return ""
def get_role(node):
"""Get role, handling exceptions."""
try:
return node.get_role()
except Exception:
return Atspi.Role.INVALID
def get_role_name(node):
"""Get role name string."""
try:
return node.get_role_name() or "unknown"
except Exception:
return "unknown"
def get_description(node):
"""Get accessible description."""
try:
return node.get_description() or ""
except Exception:
return ""
def get_states(node):
"""Get state set."""
try:
return node.get_state_set()
except Exception:
return None
def get_child_count(node):
try:
return node.get_child_count()
except Exception:
return 0
def get_child(node, idx):
try:
return node.get_child_at_index(idx)
except Exception:
return None
def get_attributes_dict(node):
"""Get node attributes as a dict, handling various AT-SPI return formats."""
try:
attrs = node.get_attributes()
if attrs is None:
return {}
if isinstance(attrs, dict):
return attrs
# Some versions return a list of "key:value" strings
result = {}
for attr in attrs:
if isinstance(attr, str) and ":" in attr:
k, _, v = attr.partition(":")
result[k.strip()] = v.strip()
return result
except Exception:
return {}
def build_path(node, max_depth=6):
"""Build a human-readable path like 'window > box > button'."""
parts = []
current = node
for _ in range(max_depth):
if current is None:
break
name = get_name(current)
role = get_role_name(current)
if name:
parts.append(f"{role}({name[:30]})")
else:
parts.append(role)
try:
current = current.get_parent()
except Exception:
break
parts.reverse()
return " > ".join(parts)
def get_size(node):
"""Get component size if available."""
try:
comp = node.get_component_iface()
if comp:
rect = comp.get_extents(Atspi.CoordType.SCREEN)
return (rect.width, rect.height)
except Exception:
pass
return None
def has_heading_descendant(node, max_depth=5):
"""Check if a node has any HEADING descendant (for SC 2.4.10)."""
if max_depth <= 0:
return False
n = get_child_count(node)
for i in range(n):
child = get_child(node, i)
if child is None:
continue
if get_role(child) == Atspi.Role.HEADING:
return True
if has_heading_descendant(child, max_depth - 1):
return True
return False
def count_children_deep(node, max_depth=3):
"""Count total descendants to gauge if a container has real content."""
if max_depth <= 0:
return 0
total = 0
n = get_child_count(node)
for i in range(n):
child = get_child(node, i)
if child:
total += 1 + count_children_deep(child, max_depth - 1)
return total
def check_level(target):
"""Check if the given WCAG level should be audited."""
levels = {"a": 1, "aa": 2, "aaa": 3}
return levels.get(target.lower(), 3) <= levels.get(AUDIT_LEVEL.lower(), 3)
def _has_text_descendant(node, max_depth=1):
"""Check if a node has a child (or grandchild) providing text content."""
if max_depth <= 0:
return False
for i in range(get_child_count(node)):
child = get_child(node, i)
if child:
child_name = get_name(child)
child_role = get_role(child)
if child_name.strip() or child_role == Atspi.Role.LABEL:
return True
if max_depth > 1 and _has_text_descendant(child, max_depth - 1):
return True
return False
# --- Main audit function ---
def audit_node(node, issues, visited, depth=0, path_prefix=""):
"""Recursively audit a single AT-SPI node and its children."""
if node is None or depth > 50:
return
# Build a unique key from the tree position to avoid cycles
# without relying on object identity (which Python GC can reuse)
role_name = get_role_name(node)
name = get_name(node)
node_key = f"{path_prefix}/{depth}:{role_name}:{name[:20]}"
if node_key in visited:
return
visited.add(node_key)
role = get_role(node)
name = get_name(node)
desc = get_description(node)
role_name = get_role_name(node)
path = build_path(node)
states = get_states(node)
# Skip dead/invalid nodes
if role == Atspi.Role.INVALID:
return
is_visible = states and states.contains(Atspi.StateType.VISIBLE)
is_showing = states and states.contains(Atspi.StateType.SHOWING)
is_focusable = states and states.contains(Atspi.StateType.FOCUSABLE)
# =================================================================
# Level A checks
# =================================================================
# --- SC 4.1.2 (A): Interactive widgets must have a name ---
if role in INTERACTIVE_ROLES:
stats.total_interactive += 1
if is_visible and not name.strip():
# GTK menu section separators appear as MENU_ITEM with no name
# and no children - these are decorative, not interactive.
# Also, GMenu-backed popover items don't expose names via AT-SPI
# until the popover is opened (they have children but no name
# in their not-SHOWING state), so skip those too.
if role == Atspi.Role.MENU_ITEM and (
get_child_count(node) == 0 or not is_showing
):
pass # Section separator or closed popover item
else:
# Check if it has children that provide text content.
# For MENU_ITEM, check 2 levels deep because GTK4
# structures them as menu_item > box > label, and the
# AT-SPI bridge may not expose the name on closed popovers.
max_check_depth = 2 if role == Atspi.Role.MENU_ITEM else 1
has_text_child = _has_text_descendant(node, max_check_depth)
if not has_text_child:
issues.append(Issue(
"error", "4.1.2", "A",
path, role_name,
"Interactive widget has no accessible name"
))
# --- SC 1.1.1 (A): Images need name or Presentation role ---
if role in IMAGE_ROLES:
stats.total_images += 1
if is_visible and not name.strip() and not desc.strip():
# Check if parent is a button that has its own label
try:
parent = node.get_parent()
parent_role = get_role(parent) if parent else None
parent_name = get_name(parent) if parent else ""
except Exception:
parent_role = None
parent_name = ""
if parent_role in INTERACTIVE_ROLES and parent_name.strip():
# Image inside a labeled button is functionally decorative.
# GTK4's AT-SPI bridge still reports it as ROLE_IMAGE even when
# AccessibleRole::Presentation is set, so this is cosmetic.
pass
elif parent_role == Atspi.Role.PANEL:
# Check grandparent - image might be inside panel inside button
try:
grandparent = parent.get_parent()
gp_role = get_role(grandparent) if grandparent else None
gp_name = get_name(grandparent) if grandparent else ""
except Exception:
gp_role = None
gp_name = ""
if gp_role in INTERACTIVE_ROLES and gp_name.strip():
pass # Decorative inside labeled interactive widget
else:
issues.append(Issue(
"error", "1.1.1", "A",
path, role_name,
"Image has no accessible name and is not marked decorative"
))
else:
issues.append(Issue(
"error", "1.1.1", "A",
path, role_name,
"Image has no accessible name and is not marked decorative"
))
# --- SC 1.3.1 (A): Headings should have a level ---
if role == Atspi.Role.HEADING:
stats.total_headings += 1
attrs = get_attributes_dict(node)
level = attrs.get("level")
if level is None:
# Also try get_attribute directly
try:
level = node.get_attribute("level")
except Exception:
level = None
if not level:
issues.append(Issue(
"warning", "1.3.1", "A",
path, role_name,
"Heading has no level attribute"
))
# --- SC 2.1.1 (A): Interactive widgets must be keyboard-focusable ---
if role in INTERACTIVE_ROLES and is_visible and is_showing:
if not is_focusable:
# Exclude menu items (handled by menu keyboard nav)
if role == Atspi.Role.MENU_ITEM:
pass
else:
# Check if this is a composite widget (SplitButton/MenuButton)
# whose child toggle button IS focusable - not a real issue
has_focusable_child = False
for i in range(get_child_count(node)):
child = get_child(node, i)
if child:
cr = get_role(child)
cs = get_states(child)
if (cr in {Atspi.Role.TOGGLE_BUTTON, Atspi.Role.PUSH_BUTTON}
and cs and cs.contains(Atspi.StateType.FOCUSABLE)):
has_focusable_child = True
break
if not has_focusable_child:
issues.append(Issue(
"warning", "2.1.1", "A",
path, role_name,
"Interactive widget is visible but not keyboard-focusable"
))
# =================================================================
# Level AA checks
# =================================================================
# --- SC 4.1.2 (AA): Progress bars / spinners need labels ---
if role == Atspi.Role.PROGRESS_BAR:
if not name.strip() and not desc.strip():
issues.append(Issue(
"error", "4.1.2", "AA",
path, role_name,
"Progress bar has no accessible name or description"
))
# --- SC 2.5.8 (AA): Target size minimum 24x24 ---
# Exclude window control buttons (Minimize, Maximize, Close, More Options)
# as these are managed by the toolkit/window manager, not the app.
WINDOW_CONTROL_NAMES = {"minimize", "maximize", "close", "more options"}
is_window_control = name.strip().lower() in WINDOW_CONTROL_NAMES
if role in INTERACTIVE_ROLES and not is_window_control:
size = get_size(node)
if size and is_visible and is_showing:
w, h = size
if 0 < w < 24 or 0 < h < 24:
issues.append(Issue(
"warning", "2.5.8", "AA",
path, role_name,
f"Interactive element is {w}x{h}px - below 24px AA minimum target size"
))
# --- SC 4.1.2 (AA): Focusable non-interactive widgets should have a name ---
if role not in INTERACTIVE_ROLES and role not in CONTAINER_ROLES and role not in IMAGE_ROLES:
if is_focusable and is_visible and not name.strip():
# Only flag if it's not a generic container
if role not in {Atspi.Role.APPLICATION, Atspi.Role.WINDOW,
Atspi.Role.PAGE_TAB_LIST, Atspi.Role.PAGE_TAB,
Atspi.Role.SCROLL_PANE, Atspi.Role.LIST,
Atspi.Role.LIST_ITEM, Atspi.Role.TABLE_CELL,
Atspi.Role.TREE_TABLE, Atspi.Role.TREE_ITEM,
Atspi.Role.LABEL, Atspi.Role.TEXT,
Atspi.Role.DOCUMENT_FRAME, Atspi.Role.TOOL_BAR,
Atspi.Role.STATUS_BAR, Atspi.Role.MENU_BAR,
Atspi.Role.INTERNAL_FRAME}:
issues.append(Issue(
"warning", "4.1.2", "AA",
path, role_name,
"Focusable widget has no accessible name"
))
# =================================================================
# Level AAA checks
# =================================================================
if check_level("aaa"):
# --- SC 2.5.5 (AAA): Target size 44x44px ---
if role in INTERACTIVE_ROLES and not is_window_control:
size = get_size(node)
if size and is_visible and is_showing:
w, h = size
# Already flagged at 24px for AA; flag at 44px for AAA
if 24 <= w < 44 or 24 <= h < 44:
issues.append(Issue(
"info", "2.5.5", "AAA",
path, role_name,
f"Interactive element is {w}x{h}px - below 44px AAA target size"
))
# --- SC 2.4.8 (AAA): Location - windows must have meaningful titles ---
if role == Atspi.Role.WINDOW or role == Atspi.Role.FRAME:
stats.windows_total += 1
if name.strip():
stats.windows_with_titles += 1
else:
issues.append(Issue(
"warning", "2.4.8", "AAA",
path, role_name,
"Window/frame has no title - users cannot determine their location"
))
# --- SC 2.4.9 (AAA): Link purpose from link text alone ---
if role == Atspi.Role.LINK and is_visible:
stats.total_links += 1
link_text = name.strip().lower()
# Flag generic link text that doesn't convey purpose
vague_texts = {
"click here", "here", "more", "read more", "link",
"learn more", "details", "info", "this",
}
if link_text in vague_texts:
issues.append(Issue(
"warning", "2.4.9", "AAA",
path, role_name,
f"Link text '{name.strip()}' is too vague - "
"purpose should be clear from link text alone"
))
# --- SC 2.4.10 (AAA): Section headings ---
# Large content regions (with many children) should have at least one heading
if role in CONTENT_REGION_ROLES and is_visible:
child_count = count_children_deep(node, 2)
if child_count > 10: # Non-trivial content region
stats.content_sections += 1
if has_heading_descendant(node, 4):
stats.sections_with_headings += 1
# =================================================================
# Informational checks (all levels)
# =================================================================
# --- Live region inventory ---
if role in LIVE_REGION_ROLES:
stats.total_live_regions += 1
if VERBOSE:
issues.append(Issue(
"info", "4.1.3", "A",
path, role_name,
f"Live region found: {role_name} - name='{name}'"
))
# --- Track focus chain ---
if is_focusable and is_visible:
stats.total_focusable += 1
stats.focus_chain.append(path)
# --- Recurse into children ---
n_children = get_child_count(node)
for i in range(n_children):
child = get_child(node, i)
if child is not None:
audit_node(child, issues, visited, depth + 1, f"{node_key}/{i}")
def run_keyboard_focus_test(app):
"""
Test keyboard focus traversal by checking that focusable widgets
are reachable via the focus chain.
SC 2.1.3 (AAA): No keyboard trap - all focusable widgets should
have a logical tab order without traps.
Returns a list of issues found.
"""
issues = []
# Walk the tree and verify that focusable interactive widgets
# actually have the FOCUSABLE state set (basic trap detection)
def check_focus_trap(node, seen, depth=0):
if node is None or depth > 50:
return
key = f"focus/{depth}:{get_role_name(node)}:{get_name(node)[:20]}"
if key in seen:
return
seen.add(key)
role = get_role(node)
states = get_states(node)
is_visible = states and states.contains(Atspi.StateType.VISIBLE)
is_showing = states and states.contains(Atspi.StateType.SHOWING)
is_focused = states and states.contains(Atspi.StateType.FOCUSED)
# A widget that is focused but not focusable is a bug
if is_focused and not (states and states.contains(Atspi.StateType.FOCUSABLE)):
issues.append(Issue(
"warning", "2.1.3", "AAA",
build_path(node), get_role_name(node),
"Widget has FOCUSED state but not FOCUSABLE - potential keyboard trap"
))
# Check for modal dialogs without focusable children (trap)
if role == Atspi.Role.DIALOG and is_visible and is_showing:
has_focusable = False
for i in range(get_child_count(node)):
child = get_child(node, i)
if child:
cs = get_states(child)
if cs and cs.contains(Atspi.StateType.FOCUSABLE):
has_focusable = True
break
if not has_focusable and get_child_count(node) > 0:
issues.append(Issue(
"warning", "2.1.3", "AAA",
build_path(node), get_role_name(node),
"Visible dialog has no focusable children - potential keyboard trap"
))
for i in range(get_child_count(node)):
child = get_child(node, i)
check_focus_trap(child, seen, depth + 1)
if check_level("aaa"):
check_focus_trap(app, set())
return issues
def find_driftwood_app():
"""Find the Driftwood application on the AT-SPI bus."""
desktop = Atspi.get_desktop(0)
n = get_child_count(desktop)
for i in range(n):
app = get_child(desktop, i)
if app is None:
continue
app_name = get_name(app)
# Match exact app name (not substring) to avoid matching editors
# that have "driftwood" in their window title
if app_name and app_name.lower() == "driftwood":
return app
return None
def print_stats():
"""Print audit statistics summary."""
print(f"\n--- AUDIT STATISTICS ---")
print(f" Interactive widgets: {stats.total_interactive}")
print(f" Focusable widgets: {stats.total_focusable}")
print(f" Images: {stats.total_images}")
print(f" Headings: {stats.total_headings}")
print(f" Links: {stats.total_links}")
print(f" Live regions: {stats.total_live_regions}")
print(f" Windows with titles: {stats.windows_with_titles}/{stats.windows_total}")
if check_level("aaa") and stats.content_sections > 0:
print(f" Content sections with headings: "
f"{stats.sections_with_headings}/{stats.content_sections}")
print()
def main():
global AUDIT_LEVEL, VERBOSE
no_launch = "--no-launch" in sys.argv
if "--level" in sys.argv:
idx = sys.argv.index("--level")
if idx + 1 < len(sys.argv):
AUDIT_LEVEL = sys.argv[idx + 1].lower()
if "--verbose" in sys.argv:
VERBOSE = True
print(f"WCAG audit level: {AUDIT_LEVEL.upper()}")
proc = None
if not no_launch:
# Build first
print("Building Driftwood...")
build = subprocess.run(
["cargo", "build"],
cwd="/media/lashman/DATA1/gdfhbfgdbnbdfbdf/driftwood",
capture_output=True, text=True,
)
if build.returncode != 0:
print("Build failed:")
print(build.stderr)
sys.exit(1)
print("Launching Driftwood...")
env = os.environ.copy()
env["GTK_A11Y"] = "atspi"
proc = subprocess.Popen(
["./target/debug/driftwood"],
cwd="/media/lashman/DATA1/gdfhbfgdbnbdfbdf/driftwood",
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
# Wait for app to appear on AT-SPI bus
print("Waiting for Driftwood to appear on AT-SPI bus...")
app = None
for attempt in range(30):
time.sleep(1)
app = find_driftwood_app()
if app:
break
if proc.poll() is not None:
print("App exited prematurely")
sys.exit(1)
if not app:
print("Timed out waiting for Driftwood on AT-SPI bus")
if proc:
proc.terminate()
sys.exit(1)
else:
print("Looking for running Driftwood instance...")
app = find_driftwood_app()
if not app:
print("No running Driftwood instance found on AT-SPI bus")
sys.exit(1)
print(f"Found Driftwood: {get_name(app)}")
print(f"Windows: {get_child_count(app)}")
# Give UI time to fully render (scan, load cards, etc.)
time.sleep(8)
issues = []
visited = set()
# Audit all views by activating GActions via gdbus
def activate_action(action_name):
"""Activate a GAction on the Driftwood app via D-Bus."""
try:
subprocess.run(
["gdbus", "call", "--session",
"--dest", "io.github.driftwood",
"--object-path", "/io/github/driftwood",
"--method", "org.gtk.Actions.Activate",
action_name, "[]", "{}"],
timeout=3, capture_output=True,
)
return True
except Exception:
return False
# --- Phase 1: Audit installed view (default) ---
print("\nPhase 1: Auditing Installed view...")
audit_node(app, issues, visited, path_prefix="installed")
print(f" Nodes so far: {len(visited)}")
# --- Phase 2: Switch to Catalog tab ---
if activate_action("show-catalog"):
time.sleep(5) # Wait for catalog to load
print("Phase 2: Auditing Catalog view...")
audit_node(app, issues, visited, path_prefix="catalog")
print(f" Nodes so far: {len(visited)}")
else:
print(" Skipping catalog view (could not activate action)")
# --- Phase 3: Switch to Updates tab ---
if activate_action("show-updates"):
time.sleep(3)
print("Phase 3: Auditing Updates view...")
audit_node(app, issues, visited, path_prefix="updates")
print(f" Nodes so far: {len(visited)}")
else:
print(" Skipping updates view (could not activate action)")
# --- Phase 4: Keyboard focus traversal test ---
if check_level("aaa"):
print("Phase 4: Running keyboard focus traversal test...")
activate_action("show-installed")
time.sleep(1)
focus_issues = run_keyboard_focus_test(app)
issues.extend(focus_issues)
print(f" Focus issues: {len(focus_issues)}")
else:
activate_action("show-installed")
time.sleep(1)
print()
# Sort: errors first, then warnings, then info
severity_order = {"error": 0, "warning": 1, "info": 2}
level_order = {"A": 0, "AA": 1, "AAA": 2}
issues.sort(key=lambda i: (
severity_order.get(i.severity, 3),
level_order.get(i.level, 3),
))
# Print report
errors = [i for i in issues if i.severity == "error"]
warnings = [i for i in issues if i.severity == "warning"]
infos = [i for i in issues if i.severity == "info"]
# Group by WCAG level
a_issues = [i for i in issues if i.level == "A" and i.severity != "info"]
aa_issues = [i for i in issues if i.level == "AA" and i.severity != "info"]
aaa_issues = [i for i in issues if i.level == "AAA" and i.severity != "info"]
print("=" * 70)
print("WCAG 2.2 ACCESSIBILITY AUDIT REPORT")
print(f"Conformance target: {AUDIT_LEVEL.upper()}")
print("=" * 70)
print(f"Total nodes visited: {len(visited)}")
print(f"Issues found: {len(errors)} errors, {len(warnings)} warnings, {len(infos)} info")
print(f" Level A: {len(a_issues)} issues")
print(f" Level AA: {len(aa_issues)} issues")
print(f" Level AAA: {len(aaa_issues)} issues")
print()
# Print stats
print_stats()
if errors:
print(f"--- ERRORS ({len(errors)}) ---")
for issue in errors:
print(issue)
print()
if warnings:
print(f"--- WARNINGS ({len(warnings)}) ---")
for issue in warnings:
print(issue)
print()
if VERBOSE and infos:
print(f"--- INFO ({len(infos)}) ---")
for issue in infos:
print(issue)
print()
if not errors and not warnings:
print("No accessibility issues found!")
# Conformance summary
print()
print("=" * 70)
print("CONFORMANCE SUMMARY")
print("=" * 70)
if not a_issues:
print(" Level A: PASS")
else:
print(f" Level A: FAIL ({len(a_issues)} issues)")
if not aa_issues:
print(" Level AA: PASS")
else:
print(f" Level AA: FAIL ({len(aa_issues)} issues)")
if check_level("aaa"):
if not aaa_issues:
print(" Level AAA: PASS")
else:
print(f" Level AAA: FAIL ({len(aaa_issues)} issues)")
# Note about manual checks required for full AAA
print()
print("NOTE: The following AAA criteria require manual review:")
print(" - SC 1.4.6: Enhanced contrast (7:1 ratio) - use a color contrast tool")
print(" - SC 1.4.7: Low or no background audio")
print(" - SC 1.4.8: Visual presentation (line length, spacing)")
print(" - SC 1.4.9: Images of text (no exceptions)")
print(" - SC 2.2.3: No timing (verify no timed interactions)")
print(" - SC 2.2.4: Interruptions can be postponed")
print(" - SC 2.4.13: Focus appearance (3px outline, area ratio)")
print(" - SC 3.1.3: Unusual words")
print(" - SC 3.1.4: Abbreviations")
print(" - SC 3.1.5: Reading level")
print(" - SC 3.1.6: Pronunciation")
print(" - SC 3.2.5: Change on request")
print(" - SC 3.3.9: Accessible authentication (enhanced)")
print("=" * 70)
# Write JSON report for CI integration
report = {
"audit_level": AUDIT_LEVEL.upper(),
"nodes_visited": len(visited),
"stats": {
"interactive_widgets": stats.total_interactive,
"focusable_widgets": stats.total_focusable,
"images": stats.total_images,
"headings": stats.total_headings,
"links": stats.total_links,
"live_regions": stats.total_live_regions,
"windows_with_titles": stats.windows_with_titles,
"windows_total": stats.windows_total,
},
"summary": {
"errors": len(errors),
"warnings": len(warnings),
"info": len(infos),
"level_a": len(a_issues),
"level_aa": len(aa_issues),
"level_aaa": len(aaa_issues),
},
"conformance": {
"level_a": len(a_issues) == 0,
"level_aa": len(aa_issues) == 0,
"level_aaa": len(aaa_issues) == 0 if check_level("aaa") else None,
},
"issues": [
{
"severity": i.severity,
"criterion": i.criterion,
"level": i.level,
"role": i.role,
"path": i.path,
"message": i.message,
}
for i in issues
],
}
report_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "a11y-report.json",
)
report_path = os.path.normpath(report_path)
try:
with open(report_path, "w") as f:
json.dump(report, f, indent=2)
print(f"\nJSON report written to: {report_path}")
except Exception as e:
print(f"\nCould not write JSON report: {e}")
# Cleanup
if proc:
print("\nTerminating Driftwood...")
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()