From da9568df61e962e93f3c1c6108c2bc38a80991a9 Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 1 Mar 2026 12:44:35 +0200 Subject: [PATCH] Remove archived plan documents from tracking Moved to .trash/ for reference - no longer needed in source tree. --- docs/PHASE-5-PLAN.md | 1135 ---------- docs/plans/2026-02-27-20-improvements.md | 31 - .../2026-02-27-appimage-metadata-design.md | 183 -- ...-02-27-appimage-metadata-implementation.md | 1723 --------------- docs/plans/2026-02-27-audit-fixes-design.md | 107 - .../2026-02-27-audit-fixes-implementation.md | 1155 ---------- ...026-02-27-beginner-friendly-copy-design.md | 127 -- .../2026-02-27-feature-roadmap-design.md | 679 ------ ...26-02-27-feature-roadmap-implementation.md | 1489 ------------- .../plans/2026-02-27-ui-ux-overhaul-design.md | 289 --- ...026-02-27-ui-ux-overhaul-implementation.md | 1885 ----------------- .../2026-02-27-wcag-aaa-compliance-design.md | 246 --- .../2026-02-27-wcag-aaa-implementation.md | 1123 ---------- ...2026-03-01-final-ux-improvements-design.md | 166 -- 14 files changed, 10338 deletions(-) delete mode 100644 docs/PHASE-5-PLAN.md delete mode 100644 docs/plans/2026-02-27-20-improvements.md delete mode 100644 docs/plans/2026-02-27-appimage-metadata-design.md delete mode 100644 docs/plans/2026-02-27-appimage-metadata-implementation.md delete mode 100644 docs/plans/2026-02-27-audit-fixes-design.md delete mode 100644 docs/plans/2026-02-27-audit-fixes-implementation.md delete mode 100644 docs/plans/2026-02-27-beginner-friendly-copy-design.md delete mode 100644 docs/plans/2026-02-27-feature-roadmap-design.md delete mode 100644 docs/plans/2026-02-27-feature-roadmap-implementation.md delete mode 100644 docs/plans/2026-02-27-ui-ux-overhaul-design.md delete mode 100644 docs/plans/2026-02-27-ui-ux-overhaul-implementation.md delete mode 100644 docs/plans/2026-02-27-wcag-aaa-compliance-design.md delete mode 100644 docs/plans/2026-02-27-wcag-aaa-implementation.md delete mode 100644 docs/plans/2026-03-01-final-ux-improvements-design.md diff --git a/docs/PHASE-5-PLAN.md b/docs/PHASE-5-PLAN.md deleted file mode 100644 index a2c6121..0000000 --- a/docs/PHASE-5-PLAN.md +++ /dev/null @@ -1,1135 +0,0 @@ -# Phase 5 - Ecosystem - -**Goal: Become the standard AppImage management tool.** - -Phase 5 is the post-release expansion phase. Each feature is designed to be independently shippable - no feature blocks another. They're ordered by impact and dependency, not strict sequence. - ---- - -## Overview - -| # | Feature | Scope | New Modules | DB Tables | Priority | -|---|---------|-------|-------------|-----------|----------| -| 1 | Config backup/restore across versions | Medium | backup.rs | 2 | High | -| 2 | Exportable security reports | Medium | report.rs | 1 | High | -| 3 | CVE push notifications | Medium | notification.rs | 1 | High | -| 4 | AppImage developer tools (validate) | Small | - (extends cli.rs) | 0 | High | -| 5 | ARM64/aarch64 support | Small | - (extends existing) | 0 | Medium | -| 6 | Wayland runtime detection (post-launch) | Medium | - (extends wayland.rs) | 1 | Medium | -| 7 | Community sandbox profile sharing | Large | sandbox/profiles.rs | 2 | Medium | -| 8 | AppImage catalog integration | Large | catalog.rs | 2 | Medium | -| 9 | Batch re-packaging (FUSE runtime updates) | Large | repackager.rs | 1 | Low | -| 10 | Multi-user / system-wide mode | Medium | - (extends existing) | 0 | Low | -| 11 | GNOME Software / KDE Discover plugin | Large | appstream plugin | 0 | Low | -| 12 | Qt6 frontend | Very Large | ui_qt6/ + D-Bus daemon | 0 | Low | - ---- - -## Feature 1: Config Backup/Restore Across AppImage Versions - -### Why it matters - -When an AppImage is updated, its config/data files (in ~/.config/appname, ~/.local/share/appname, etc.) may be incompatible with the new version. Users currently have no way to rollback their settings. Driftwood already discovers these paths via footprint.rs - we just need to snapshot them before updating. - -### Design - -**Trigger points:** -- Automatic: before every update (if preference enabled) -- Manual: "Backup Config" button in detail view -- CLI: `driftwood backup ` and `driftwood restore ` - -**Backup format:** -- tar.xz archive in `~/.local/share/driftwood/backups/` -- Naming: `{app_id}-{version}-{timestamp}.tar.xz` -- Manifest JSON embedded as first file in archive -- Includes: all paths from footprint discovery with confidence >= Medium - -**Restore flow:** -1. User picks a backup from the list (sorted by date, grouped by app) -2. Preview dialog shows what will be restored (config vs data vs cache) -3. User can selectively exclude cache files -4. Extract to original paths, preserving permissions -5. Record restore event in database - -### New module: src/core/backup.rs (~350 lines) - -```rust -pub struct BackupManifest { - pub app_name: String, - pub app_version: String, - pub created_at: String, - pub paths: Vec, - pub total_size: u64, -} - -pub struct BackupEntry { - pub original_path: String, - pub path_type: String, // config, data, cache - pub relative_path: String, // path inside archive - pub size_bytes: u64, -} - -pub fn create_backup(db: &Database, appimage_id: i64) -> Result -pub fn restore_backup(backup_path: &Path, selective: &[String]) -> Result<()> -pub fn list_backups(app_id: &str) -> Result> -pub fn delete_backup(backup_path: &Path) -> Result<()> -pub fn auto_cleanup_old_backups(retention_days: u32) -> Result -``` - -### Database changes (migration v5) - -```sql -CREATE TABLE config_backups ( - id INTEGER PRIMARY KEY, - appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, - app_version TEXT, - archive_path TEXT NOT NULL, - archive_size INTEGER, - checksum TEXT, - created_at TEXT NOT NULL, - path_count INTEGER, - restored_count INTEGER DEFAULT 0, - last_restored_at TEXT -); - -CREATE TABLE backup_entries ( - id INTEGER PRIMARY KEY, - backup_id INTEGER REFERENCES config_backups(id) ON DELETE CASCADE, - original_path TEXT NOT NULL, - path_type TEXT NOT NULL, - size_bytes INTEGER -); -``` - -### GSettings - -```xml - - true - - - 30 - -``` - -### UI changes - -- Detail view: new "Config Backup" section between Usage and Security - - "Create Backup" button - - List of existing backups with date/size - - "Restore" and "Delete" buttons per backup -- Preferences: Behavior page gets backup retention setting -- Update dialog: after successful update, show "Config backed up" confirmation - -### CLI - -``` -driftwood backup ~/Apps/MyApp.AppImage -driftwood restore ~/Apps/MyApp.AppImage --list -driftwood restore ~/Apps/MyApp.AppImage --backup-id 3 -driftwood backup --cleanup # remove backups older than retention period -``` - -### Integration with updater - -In `update_dialog.rs` and `updater.rs`, before `perform_update()`: -1. Check `auto-backup-before-update` setting -2. If enabled, call `backup::create_backup()` -3. Store backup_id in `update_history` row -4. On update failure, offer automatic restore - -### Dependencies - -- `tar` crate (or shell out to `tar`) for archive creation -- `xz2` crate for compression (or shell out to `xz`) -- Existing `footprint.rs` for path discovery (no changes needed there) - ---- - -## Feature 2: Exportable Security Reports - -### Why it matters - -Organizations deploying AppImages need audit trails. A sysadmin managing 50 desktops with AppImages needs to generate a report showing which apps have known CVEs, export it as JSON/HTML, and attach it to a compliance ticket. - -### Design - -**Export formats:** -- **JSON** - machine-readable, for integration with SIEM/ticketing systems -- **HTML** - human-readable, styled single-file report for email/printing -- **CSV** - spreadsheet-friendly flat format - -**Report types:** -- Single app: all CVEs for one AppImage -- Full audit: all AppImages with their CVE status -- Summary: counts only, no CVE details - -### New module: src/core/report.rs (~250 lines) - -```rust -pub enum ReportFormat { Json, Html, Csv } -pub enum ReportScope { SingleApp(i64), AllApps, Summary } - -pub struct SecurityReport { - pub generated_at: String, - pub driftwood_version: String, - pub scope: String, - pub apps: Vec, - pub totals: CveSummary, -} - -pub struct AppSecurityEntry { - pub name: String, - pub version: Option, - pub path: String, - pub libraries_scanned: usize, - pub cve_summary: CveSummary, - pub findings: Vec, -} - -pub struct CveFinding { - pub cve_id: String, - pub severity: String, - pub cvss_score: Option, - pub summary: String, - pub library_name: String, - pub library_version: String, - pub fixed_version: Option, -} - -pub fn generate_report(db: &Database, scope: ReportScope, format: ReportFormat) -> Result> -pub fn generate_json_report(report: &SecurityReport) -> Result -pub fn generate_html_report(report: &SecurityReport) -> Result -pub fn generate_csv_report(report: &SecurityReport) -> Result -``` - -### HTML report template - -Embedded in the binary as a const string. Clean, printable layout: -- Header with generation timestamp and Driftwood version -- Summary table: total apps, total CVEs by severity -- Per-app sections with severity badges -- Library version comparison (bundled vs system) -- Footer with disclaimer about heuristic detection - -### Database changes - -```sql -CREATE TABLE exported_reports ( - id INTEGER PRIMARY KEY, - scope TEXT NOT NULL, - format TEXT NOT NULL, - file_path TEXT, - generated_at TEXT NOT NULL, - app_count INTEGER, - cve_count INTEGER -); -``` - -### UI changes - -- Dashboard: "Export Security Report" button in the security summary card -- Security report view: "Export" button in header bar with format picker -- Detail view security section: "Export" link - -### CLI - -``` -driftwood security --export json --output report.json -driftwood security --export html --output report.html -driftwood security ~/Apps/MyApp.AppImage --export csv -``` - -### Dependencies - -- No new crates needed. HTML templating via format!() strings. JSON via serde_json (already used). CSV via manual formatting. - ---- - -## Feature 3: CVE Push Notifications - -### Why it matters - -Users shouldn't have to manually check for new CVEs. When a critical vulnerability is published in OpenSSL or libcurl and affects a bundled library in their AppImages, they should get a desktop notification immediately. - -### Design - -**Notification flow:** -1. Background scan runs on schedule (daily by default) -2. Scan compares current CVE matches against previously known ones -3. New CRITICAL or HIGH findings trigger a desktop notification -4. Notification includes: app name, severity, affected library -5. Clicking notification opens Driftwood's security report view - -**Notification deduplication:** -- Track which CVEs have been notified per app -- Don't re-notify for the same CVE unless severity changes -- Batch multiple findings into one notification per app - -### New module: src/core/notification.rs (~200 lines) - -```rust -pub struct CveNotification { - pub app_name: String, - pub severity: String, - pub cve_count: usize, - pub affected_libraries: Vec, -} - -pub fn send_desktop_notification(notif: &CveNotification) -> Result<()> -pub fn check_and_notify(db: &Database) -> Result> -pub fn has_been_notified(db: &Database, appimage_id: i64, cve_id: &str) -> Result -pub fn mark_notified(db: &Database, appimage_id: i64, cve_id: &str) -> Result<()> -``` - -**Desktop notification mechanism:** -- Use `notify-rust` crate (wraps org.freedesktop.Notifications D-Bus) -- Works on both GNOME and KDE without changes -- Supports actions ("View Report", "Dismiss") -- Supports urgency levels (critical = persistent notification) - -### Database changes - -```sql -CREATE TABLE cve_notifications ( - id INTEGER PRIMARY KEY, - appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, - cve_id TEXT NOT NULL, - severity TEXT NOT NULL, - notified_at TEXT NOT NULL, - user_action TEXT, -- 'viewed', 'dismissed', NULL - acted_at TEXT, - UNIQUE(appimage_id, cve_id) -); -``` - -### GSettings - -```xml - - true - - - 'high' - Minimum severity for notifications - critical, high, medium, or low - - - 'daily' - Background security scan frequency - daily, weekly, or manual - -``` - -### UI changes - -- Preferences: Security page gets notification toggle, threshold picker, schedule picker -- Dashboard: notification history section showing recent alerts - -### CLI - -``` -driftwood security --notify # run scan and send notifications for new findings -``` - -### Dependencies - -- `notify-rust` crate (~lightweight, wraps D-Bus notifications) - -### Integration with existing scan - -In `window.rs` auto-scan-on-startup flow, after scanning completes: -1. If `security-notifications` enabled, run `check_and_notify()` -2. New CVEs found -> send notifications -3. Store notification records for deduplication - ---- - -## Feature 4: AppImage Developer Tools (Validate) - -### Why it matters - -AppImage developers need a way to validate their builds before distribution. Currently they use `appimaged` or manual inspection. Driftwood can leverage its existing analysis engine to provide a comprehensive validation report. - -### Design - -New `dev` subcommand group in the CLI: - -``` -driftwood dev validate # comprehensive validation -driftwood dev check-libs # library health check -driftwood dev wayland-check # Wayland compatibility analysis -driftwood dev export-metadata --format json # dump extracted metadata -``` - -### Implementation (extends cli.rs, ~200 lines) - -**`driftwood dev validate `** runs all checks: - -1. **ELF validation** - valid header, correct magic bytes, architecture -2. **AppImage type detection** - Type 1 or Type 2, magic bytes present -3. **SquashFS integrity** - can we extract/mount the payload? -4. **Desktop entry** - present, valid, has required fields (Name, Exec, Icon) -5. **Icon** - present, correct size (at least 256x256), valid format -6. **Update info** - present in .upd_info section, valid format, reachable URL -7. **Executable bit** - set correctly -8. **Library audit** - bundled libs with known CVEs, deprecated libraries -9. **Wayland readiness** - toolkit detection, missing platform plugins -10. **FUSE compatibility** - runtime type, extract-and-run support -11. **Architecture** - x86_64 vs aarch64 vs armv7, matches system - -**Output format:** -``` -Validation Report - MyApp-1.0-x86_64.AppImage -============================================== -[PASS] Valid ELF header (Type 2 AppImage) -[PASS] SquashFS payload intact (47 MB) -[PASS] Desktop entry: myapp.desktop -[WARN] Icon is 128x128 - recommend 256x256 or SVG -[PASS] Update info: gh-releases-zsync|user|repo|latest|*.zsync -[FAIL] libssl.so.1.1 has 3 CRITICAL CVEs - upgrade to OpenSSL 3.x -[PASS] Wayland: GTK4 detected - native Wayland support -[PASS] Executable bit set -[INFO] Architecture: x86_64 - -Score: 7/9 checks passed, 1 warning, 1 failure -``` - -**`driftwood dev check-libs `** output: -``` -Bundled Library Health Check -============================ -Library Version System CVEs Status -libssl.so.1.1 1.1.1k 3.0.13 3 C OUTDATED - 3 major versions behind -libcurl.so.4 8.1.0 8.5.0 0 Minor update available -zlib.so.1 1.3.1 1.3.1 0 Current -libpng16.so.16 1.6.43 1.6.43 0 Current -``` - -**`driftwood dev wayland-check `** output: -``` -Wayland Compatibility Analysis -============================== -Detected toolkit: GTK4 (via libgtk-4.so.1) -Wayland status: Native -Wayland libraries: libwayland-client.so.0 present -Platform plugins: N/A (GTK4 uses native backend) -Recommended env vars: none needed -``` - -### No new modules needed - -All functionality exists in inspector.rs, security.rs, wayland.rs, fuse.rs, and discovery.rs. The `dev` commands are thin wrappers that call existing functions and format the output. - -### No database changes - -Developer tools are stateless - they analyze a file and print results. - ---- - -## Feature 5: ARM64/aarch64 Support - -### Why it matters - -ARM64 Linux desktops are growing (Raspberry Pi 5, Apple Silicon via Asahi Linux, Qualcomm Snapdragon laptops). AppImages for aarch64 exist and Driftwood should handle them correctly. - -### Design - -**What needs to change:** - -1. **Architecture detection** - already exists in inspector.rs (`detect_architecture()`). Just needs to handle `aarch64` and `armv7l` in addition to `x86_64`. - -2. **Architecture mismatch warning** - when an AppImage's architecture doesn't match the host system, show a warning badge and explain that it can't run natively. - -3. **QEMU/binfmt_misc detection** - check if the system can run foreign-arch binaries via binfmt_misc: - ```rust - pub fn can_run_foreign_arch(arch: &str) -> bool { - // Check /proc/sys/fs/binfmt_misc/ for registered interpreters - // e.g., /proc/sys/fs/binfmt_misc/qemu-aarch64 - Path::new(&format!("/proc/sys/fs/binfmt_misc/qemu-{}", arch)).exists() - } - ``` - -4. **Library view badge** - show architecture badge on cards when the AppImage is for a different arch than the host. - -5. **Cross-compilation of Driftwood itself** - ensure Cargo.toml doesn't use x86_64-specific dependencies. Current deps (gtk4-rs, rusqlite bundled, sha2, ureq) all support aarch64. - -### Implementation (~50 lines of changes across existing files) - -- `inspector.rs`: extend `detect_architecture()` to return `aarch64`, `armv7l`, `i686` in addition to `x86_64` -- `launcher.rs`: before launching, check arch match. If mismatch, check binfmt_misc. If neither, return `LaunchResult::Failed` with helpful message. -- `app_card.rs`: show architecture badge when arch != host -- `cli.rs status`: show host architecture - -### No new modules, no database changes - -The `architecture` column already exists in the `appimages` table. - ---- - -## Feature 6: Wayland Runtime Detection (Post-Launch) - -### Why it matters - -Static analysis (inspecting bundled .so files) catches most cases but can't tell you what actually happens when the app runs. Some apps bundle both X11 and Wayland backends and pick at runtime based on environment. Post-launch analysis confirms the actual behavior. - -### Design - -**How it works:** -1. Launch the AppImage (via launcher.rs) -2. Wait 2-3 seconds for the app to initialize -3. Inspect the running process: - - Check /proc/PID/fd for Wayland socket connections - - Check /proc/PID/environ for GDK_BACKEND, QT_QPA_PLATFORM - - Query GNOME Shell Introspect D-Bus interface for window info - - Check if process has X11 connections via /proc/PID/fd -> .X11-unix -4. Store the result as `runtime_wayland_status` alongside the existing static analysis - -**Implementation - extends wayland.rs (~150 lines)** - -```rust -pub struct RuntimeWaylandAnalysis { - pub pid: u32, - pub has_wayland_socket: bool, - pub has_x11_connection: bool, - pub detected_backend: Option, // "wayland", "x11", "both" - pub env_vars: HashMap, // relevant env vars from /proc -} - -pub fn analyze_running_process(pid: u32) -> Result -pub fn check_wayland_socket(pid: u32) -> bool -pub fn check_x11_connection(pid: u32) -> bool -pub fn get_process_env_var(pid: u32, var: &str) -> Option -``` - -**Integration with launcher.rs:** -- After `cmd.spawn()` succeeds, optionally spawn a delayed analysis task -- Store result in database: `runtime_wayland_status` column (new) -- Only run on first launch or when user requests re-analysis - -### Database changes - -```sql -ALTER TABLE appimages ADD COLUMN runtime_wayland_status TEXT; -ALTER TABLE appimages ADD COLUMN runtime_wayland_checked TEXT; -``` - -### GSettings - -```xml - - false - Check actual Wayland usage after launching - Inspects the running process to confirm whether it uses native Wayland or XWayland - -``` - -### UI changes - -- Detail view runtime section: show both static analysis AND runtime analysis results when available -- Badge could show "Confirmed native Wayland" vs "Confirmed XWayland" after runtime check - ---- - -## Feature 7: Community Sandbox Profile Sharing - -### Why it matters - -Writing Firejail profiles is tedious. Most users won't do it. But security-conscious users who DO write profiles could share them, creating a crowdsourced library of per-app sandbox configs. Think of it like Firefox add-ons but for Firejail profiles. - -### Design - -**Architecture:** -- Local profile storage in `~/.config/driftwood/sandbox/` (one .profile file per app) -- Remote registry at a simple HTTPS API (could be a static GitHub repo initially) -- Profiles are plain-text Firejail .profile files with Driftwood metadata header - -**Profile format:** -```ini -# Driftwood Sandbox Profile -# App: Firefox -# Version: 1.0 -# Author: username -# Created: 2026-03-01 -# Description: Restricts Firefox to Documents and Downloads - -include disable-common.inc -include disable-devel.inc -whitelist ${HOME}/Documents -whitelist ${HOME}/Downloads -caps.drop all -netfilter -nonewprivs -noroot -seccomp -``` - -### New module: src/core/sandbox.rs (~400 lines) - -```rust -pub struct SandboxProfile { - pub id: Option, - pub app_name: String, - pub profile_version: String, - pub author: String, - pub description: String, - pub content: String, - pub created_at: String, - pub downloads: u32, - pub source: ProfileSource, // Local, Community, Firejail-Default -} - -pub enum ProfileSource { - Local, - Community { registry_id: String }, - FirejailDefault, -} - -// Local management -pub fn save_profile(profile: &SandboxProfile) -> Result -pub fn load_profile(app_name: &str) -> Result> -pub fn delete_profile(app_name: &str) -> Result<()> -pub fn list_local_profiles() -> Result> - -// Community registry -pub fn search_community_profiles(app_name: &str) -> Result> -pub fn download_community_profile(registry_id: &str) -> Result -pub fn submit_profile(profile: &SandboxProfile) -> Result - -// Default profile generation -pub fn generate_default_profile(app_name: &str, permissions: &[Permission]) -> String -``` - -### Community registry - simple approach - -Start with a GitHub repository as the "registry": -- `profiles/` directory with one JSON file per app -- CI validates profile syntax -- Users submit profiles via PR -- Driftwood fetches the raw JSON index file - -This avoids building a web service initially. The index file: -```json -{ - "profiles": [ - { - "id": "firefox-strict", - "app_name": "Firefox", - "author": "contributor1", - "description": "Strict sandbox for Firefox", - "url": "https://raw.githubusercontent.com/.../firefox-strict.profile", - "downloads": 42 - } - ] -} -``` - -### Database changes - -```sql -CREATE TABLE sandbox_profiles ( - id INTEGER PRIMARY KEY, - app_name TEXT NOT NULL, - profile_version TEXT, - author TEXT, - description TEXT, - content TEXT NOT NULL, - source TEXT NOT NULL, - registry_id TEXT, - created_at TEXT, - applied_to_appimage_id INTEGER REFERENCES appimages(id) -); - -CREATE TABLE sandbox_profile_history ( - id INTEGER PRIMARY KEY, - profile_id INTEGER REFERENCES sandbox_profiles(id) ON DELETE CASCADE, - action TEXT NOT NULL, -- 'applied', 'removed', 'updated' - timestamp TEXT NOT NULL -); -``` - -### UI changes - -- Detail view sandbox section: "Browse Profiles" button next to the Firejail toggle -- Profile browser dialog: search, preview, apply -- Profile editor: text area with syntax hints for advanced users - -### CLI - -``` -driftwood sandbox list # list local profiles -driftwood sandbox search firefox # search community profiles -driftwood sandbox apply firefox-strict # apply a community profile -driftwood sandbox generate ~/Apps/MyApp.AppImage # generate default profile -``` - ---- - -## Feature 8: AppImage Catalog Integration - -### Why it matters - -Currently users must find AppImages manually (GitHub releases, AppImageHub website, developer sites). A built-in catalog lets users browse, search, and install AppImages from curated sources directly within Driftwood. - -### Design - -**Catalog sources:** -1. **AppImageHub** (appimage.github.io) - the existing community catalog - - JSON API available - - ~1500 listed applications -2. **GitHub Releases** - search GitHub for repos with AppImage releases - - Uses GitHub API with pagination - - Filter by `*.AppImage` in release assets -3. **Custom sources** - user-defined URLs pointing to a JSON index - -**Catalog flow:** -1. User opens "Browse Catalog" from hamburger menu -2. Driftwood fetches/caches the catalog index -3. User searches/browses by category -4. User clicks "Install" on an app -5. Driftwood downloads to ~/Applications, verifies integrity, adds to library -6. Optional: auto-integrate into desktop menu - -### New module: src/core/catalog.rs (~400 lines) - -```rust -pub struct CatalogSource { - pub id: Option, - pub name: String, - pub url: String, - pub source_type: CatalogType, - pub enabled: bool, - pub last_synced: Option, -} - -pub enum CatalogType { - AppImageHub, - GitHubSearch, - Custom, -} - -pub struct CatalogApp { - pub name: String, - pub description: Option, - pub categories: Vec, - pub latest_version: Option, - pub download_url: String, - pub icon_url: Option, - pub homepage: Option, - pub file_size: Option, - pub architecture: Option, -} - -pub fn sync_catalog(source: &CatalogSource) -> Result> -pub fn search_catalog(db: &Database, query: &str) -> Result> -pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result -pub fn fetch_appimage_hub_index() -> Result> -``` - -### Database changes - -```sql -CREATE TABLE catalog_sources ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - url TEXT NOT NULL UNIQUE, - source_type TEXT NOT NULL, - enabled INTEGER DEFAULT 1, - last_synced TEXT, - app_count INTEGER DEFAULT 0 -); - -CREATE TABLE catalog_apps ( - id INTEGER PRIMARY KEY, - source_id INTEGER REFERENCES catalog_sources(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - categories TEXT, - latest_version TEXT, - download_url TEXT NOT NULL, - icon_url TEXT, - homepage TEXT, - file_size INTEGER, - architecture TEXT, - cached_at TEXT -); -``` - -### UI changes - -- New "Browse Catalog" page accessible from hamburger menu or empty state -- Catalog browser: AdwNavigationPage with search, category filter, app cards -- Each catalog app card shows: name, description, size, "Install" button -- Download progress via AdwToast or inline progress bar -- Preferences: "Catalog Sources" section to enable/disable sources - -### CLI - -``` -driftwood catalog search firefox -driftwood catalog install firefox --source appimage-hub -driftwood catalog sync # refresh catalog index -driftwood catalog list # list available apps -``` - -### Privacy consideration - -Catalog fetches send HTTP requests to external servers. Clearly disclose this: -- Default: AppImageHub enabled, GitHub search disabled (requires API token) -- Custom sources added manually by user -- No telemetry or tracking sent from Driftwood - ---- - -## Feature 9: Batch Re-Packaging (FUSE Runtime Updates) - -### Why it matters - -Many AppImages ship with the old fuse2-only runtime. The new type2-runtime (updated Jan 2026) supports fuse2, fuse3, and a static runtime that eliminates FUSE dependency entirely. Batch re-packaging replaces the old runtime binary with the new one, fixing FUSE issues across the user's entire library in one operation. - -### Design - -**How AppImage runtime replacement works:** -1. An AppImage is: `[ELF runtime binary] + [SquashFS payload]` -2. The runtime binary is the first N bytes (typically 180-240 KB) -3. The SquashFS payload starts at a known offset stored in the ELF -4. To replace the runtime: extract the offset, concatenate new runtime + old payload -5. Copy permissions, verify the result launches correctly - -**Safety:** -- Always create a backup of the original file before modifying -- Verify the new file is a valid AppImage before replacing -- Rollback if verification fails -- Show detailed preview of what will change - -### New module: src/core/repackager.rs (~350 lines) - -```rust -pub struct RuntimeInfo { - pub runtime_size: u64, - pub payload_offset: u64, - pub runtime_type: String, // "old-fuse2", "new-multi", "static" - pub runtime_version: Option, -} - -pub struct RepackageResult { - pub original_path: PathBuf, - pub backup_path: PathBuf, - pub new_runtime: String, - pub old_size: u64, - pub new_size: u64, - pub fuse_status_before: String, - pub fuse_status_after: String, -} - -pub fn detect_runtime(appimage_path: &Path) -> Result -pub fn extract_payload_offset(appimage_path: &Path) -> Result -pub fn replace_runtime(appimage_path: &Path, new_runtime: &Path, keep_backup: bool) -> Result -pub fn batch_replace_runtimes(db: &Database, new_runtime: &Path) -> Result> -pub fn download_latest_runtime() -> Result -pub fn verify_appimage_integrity(appimage_path: &Path) -> Result -``` - -### UI changes - -- Dashboard: "Update Runtimes" card showing how many AppImages use the old runtime -- Batch dialog: list of AppImages to update with checkboxes, preview of changes -- Per-app detail view: runtime info in the FUSE section, "Update Runtime" button - -### CLI - -``` -driftwood dev repackage ~/Apps/MyApp.AppImage --runtime latest -driftwood dev repackage --all --dry-run # show what would change -driftwood dev repackage --all # batch update all -``` - -### Database changes - -```sql -CREATE TABLE runtime_updates ( - id INTEGER PRIMARY KEY, - appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE, - old_runtime TEXT, - new_runtime TEXT, - backup_path TEXT, - updated_at TEXT, - success INTEGER -); -``` - -### Risk mitigation - -- Large red warning: "This modifies your AppImage files. Backups are created automatically." -- Dry-run mode shows what would change without modifying anything -- Each modified AppImage is verified before the backup is deleted -- If verification fails, automatic rollback - ---- - -## Feature 10: Multi-User / System-Wide Mode - -### Why it matters - -In shared workstations, labs, or family computers, AppImages in /opt or /usr/local/share should be manageable by an admin and usable by all users. Currently Driftwood is single-user only. - -### Design - -**Two modes:** -1. **User mode** (default) - current behavior, per-user database and config -2. **System mode** - system-wide AppImage directory, shared database, per-user launch tracking - -**System mode details:** -- System AppImage directory: `/opt/appimages/` or configurable -- System database: `/var/lib/driftwood/driftwood.db` (readable by all, writable by admin) -- Per-user overlay: launch history and preferences remain per-user -- Integration: system .desktop files go to `/usr/local/share/applications/` -- CLI: `driftwood --system scan` / `driftwood --system list` - -### Implementation (~200 lines across existing modules) - -**Changes to database.rs:** -```rust -pub fn open_system() -> Result { - let path = PathBuf::from("/var/lib/driftwood/driftwood.db"); - Self::open_at(&path) -} - -pub fn open_at(path: &Path) -> Result { - // Same as open() but with explicit path -} -``` - -**Changes to launcher.rs:** -- Add `launched_by` field to launch events -- Track which user launched each app - -**Changes to integrator.rs:** -- System mode: install to `/usr/local/share/applications/` instead of `~/.local/share/applications/` -- Requires elevated permissions (polkit or sudo) - -**Changes to cli.rs:** -- Add `--system` global flag -- Route to system database when flag is present - -**Changes to config.rs:** -```rust -pub fn data_dir(system_mode: bool) -> PathBuf { - if system_mode { - PathBuf::from("/var/lib/driftwood") - } else { - dirs::data_dir().unwrap().join("driftwood") - } -} -``` - -### No new database tables - -Same schema, different file location. - -### Polkit integration - -For system-mode operations that need root: -- Create a polkit policy file: `data/app.driftwood.Driftwood.policy` -- Actions: `app.driftwood.manage-system-appimages` -- Use `pkexec` or D-Bus activation for elevated operations - ---- - -## Feature 11: GNOME Software / KDE Discover Plugin - -### Why it matters - -Users expect to find and manage applications through their desktop's software center. A GNOME Software plugin would let users browse AppImages alongside Flatpaks and native packages. - -### Design - -**GNOME Software plugin approach:** -- GNOME Software supports plugins via `gs_plugin_*` C API -- Plugin provides: search results, app details, install/remove actions -- Source data: Driftwood's catalog + locally discovered AppImages - -**Alternative approach (simpler):** -- Generate AppStream catalog XML from Driftwood's database -- GNOME Software can read local AppStream catalogs -- Place catalog at `~/.local/share/swcatalog/xml/driftwood.xml` -- This requires no C plugin - just generating the right XML - -### Implementation - -**Simpler path - AppStream catalog generation:** - -```rust -// In src/core/appstream.rs (~150 lines) -pub fn generate_appstream_catalog(db: &Database) -> Result { - let apps = db.get_all_appimages()?; - let mut xml = String::from("\n\n"); - for app in &apps { - xml.push_str(&format!( - "\n\ - appimage.{}\n\ - {}\n\ - {}\n\ - {}\n\ - {}\n\ - \n", - make_app_id(app.app_name.as_deref().unwrap_or(&app.filename)), - app.app_name.as_deref().unwrap_or(&app.filename), - app.description.as_deref().unwrap_or(""), - app.filename, - app.desktop_file.as_deref().unwrap_or(""), - )); - } - xml.push_str("\n"); - Ok(xml) -} - -pub fn install_appstream_catalog(db: &Database) -> Result -``` - -**Full plugin path (future):** -- Write a GNOME Software plugin in C that calls Driftwood's D-Bus interface -- This requires the D-Bus daemon from Feature 12 -- Not practical until the daemon exists - -### No database changes - ---- - -## Feature 12: Qt6 Frontend - -### Why it matters - -KDE Plasma users prefer Qt-native applications. While GTK4 apps work on KDE, a native Qt6 frontend would provide better visual integration with Breeze theme, KDE file dialogs, and Plasma notifications. - -### Design - -**Architecture: shared core, separate UI** - -The key insight: Driftwood's `src/core/` has zero GTK dependencies. It's pure Rust business logic. A Qt6 frontend can use the same core directly as a Rust library. - -**Approach options:** - -1. **Rust + Qt6 via cxx-qt** - Write Qt6 UI in Rust using cxx-qt bindings. Same language, same build system. - -2. **D-Bus daemon + Qt6 C++ frontend** - Driftwood runs as a D-Bus service (the daemon from the design doc). Qt6 frontend communicates via D-Bus. Two separate binaries. - -3. **Shared Rust library + Qt6 QML frontend** - Compile core/ as a cdylib. Qt6 frontend calls it via FFI. Mixed Rust+C++. - -**Recommended: Option 1 (cxx-qt)** - -``` -driftwood-qt/ - Cargo.toml # depends on driftwood-core (workspace member) - build.rs # cxx-qt build integration - src/ - main.rs # Qt application entry - bridge.rs # cxx-qt bridge definitions - qml/ - Main.qml # main window - AppList.qml # library view - AppDetail.qml # detail view - Settings.qml # preferences -``` - -**Prerequisites:** -- Extract `src/core/` into a separate crate (`driftwood-core`) within a Cargo workspace -- This is a significant refactoring but benefits both frontends -- The GTK4 frontend becomes `driftwood-gtk` depending on `driftwood-core` - -### Workspace structure - -```toml -# Root Cargo.toml -[workspace] -members = ["driftwood-core", "driftwood-gtk", "driftwood-qt"] - -# driftwood-core/Cargo.toml -[package] -name = "driftwood-core" -# No GTK dependencies - pure Rust + rusqlite + ureq + sha2 + etc. - -# driftwood-gtk/Cargo.toml -[package] -name = "driftwood-gtk" -[dependencies] -driftwood-core = { path = "../driftwood-core" } -gtk = { version = "0.11", package = "gtk4" } -adw = { version = "0.9", package = "libadwaita" } - -# driftwood-qt/Cargo.toml -[package] -name = "driftwood-qt" -[dependencies] -driftwood-core = { path = "../driftwood-core" } -cxx-qt = "0.7" -``` - -### Scope - -This is a very large feature. The workspace refactoring alone is significant. The Qt6 UI needs to replicate: -- Library view (grid + list) -- Detail view (9 sections) -- Dashboard -- Preferences -- All dialogs (update, security, duplicate, cleanup, integration) - -**Recommended phasing:** -1. Extract driftwood-core crate (refactoring, no new features) -2. Build minimal Qt6 frontend with library view + detail view -3. Add remaining views incrementally - ---- - -## Implementation Order - -Based on dependencies, impact, and effort: - -**Wave 1 - Quick wins (can start immediately, independent):** -- Feature 4: Developer tools (extends CLI, uses existing analysis) -- Feature 5: ARM64 support (small changes across existing modules) -- Feature 2: Exportable security reports (new module, straightforward) - -**Wave 2 - High impact (foundation for later features):** -- Feature 1: Config backup/restore (enables safe updates) -- Feature 3: CVE push notifications (requires notify-rust dep) -- Feature 6: Wayland runtime detection (extends existing module) - -**Wave 3 - Community and ecosystem:** -- Feature 7: Community sandbox profiles (needs remote registry design) -- Feature 8: AppImage catalog integration (needs API integration) - -**Wave 4 - Advanced (larger scope, lower priority):** -- Feature 9: Batch re-packaging (modifies user files - needs extra care) -- Feature 10: Multi-user mode (polkit integration, permission model) -- Feature 11: GNOME Software plugin (AppStream catalog generation first) -- Feature 12: Qt6 frontend (workspace refactoring prerequisite) - ---- - -## New Dependencies - -| Feature | New Crate | Purpose | -|---------|-----------|---------| -| 1 (Backup) | `tar` + `xz2` or shell to tar/xz | Archive creation | -| 3 (Notifications) | `notify-rust` | Desktop notifications via D-Bus | -| 12 (Qt6) | `cxx-qt` | Qt6 bindings for Rust | - -All other features use existing dependencies (ureq for HTTP, serde_json for JSON, rusqlite for database). - -## Database Migration Plan - -Phase 5 features add up to 8 new tables. These should be grouped into 2-3 migrations: - -**Migration v5 (Wave 1-2 features):** -- config_backups + backup_entries -- exported_reports -- cve_notifications -- runtime_wayland columns on appimages - -**Migration v6 (Wave 3-4 features):** -- catalog_sources + catalog_apps -- sandbox_profiles + sandbox_profile_history -- runtime_updates - -This keeps each migration focused and testable. diff --git a/docs/plans/2026-02-27-20-improvements.md b/docs/plans/2026-02-27-20-improvements.md deleted file mode 100644 index 6c6a283..0000000 --- a/docs/plans/2026-02-27-20-improvements.md +++ /dev/null @@ -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 diff --git a/docs/plans/2026-02-27-appimage-metadata-design.md b/docs/plans/2026-02-27-appimage-metadata-design.md deleted file mode 100644 index 012a39e..0000000 --- a/docs/plans/2026-02-27-appimage-metadata-design.md +++ /dev/null @@ -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 ]` 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`: -- `` - reverse-DNS identifier -- `` - localized app name -- `` - one-line description -- `` - full rich-text description -- `` - developer/organization -- `` - SPDX license -- `` - umbrella project (GNOME, KDE, etc.) -- `` - homepage, bugtracker, donation, help, vcs-browser, contribute -- `` - search terms -- `` - menu categories -- `` - screenshot URLs with captions -- `` - version history with dates and changelogs -- `` - age rating -- `` - MIME types, binaries, D-Bus interfaces -- `` - 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 - name: Option - summary: Option - description: Option - developer: Option - project_license: Option - project_group: Option - urls: HashMap - keywords: Vec - categories: Vec - content_rating_summary: Option - releases: Vec - mime_types: Vec - -ReleaseInfo - version: String - date: Option - description: Option -``` - -Parser function: `parse_appstream_file(path: &Path) -> Option` -- Walks XML events, extracts all fields -- Strips HTML from `` (joins `

` with newlines, `

  • ` 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 ]` 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 diff --git a/docs/plans/2026-02-27-appimage-metadata-implementation.md b/docs/plans/2026-02-27-appimage-metadata-implementation.md deleted file mode 100644 index 3a82771..0000000 --- a/docs/plans/2026-02-27-appimage-metadata-implementation.md +++ /dev/null @@ -1,1723 +0,0 @@ -# AppImage Comprehensive Metadata Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Extract all available metadata from AppImage files (AppStream XML, extended desktop entry fields, ELF signature detection) and display it comprehensively in the overview tab. - -**Architecture:** Parse AppStream XML and extended desktop entry fields during background analysis, store everything in the database (migration v9 with 16 new columns), and render a redesigned overview tab with 8 groups that gracefully degrade when data is missing. - -**Tech Stack:** Rust, quick-xml 0.37, GTK4/libadwaita, SQLite (rusqlite) - ---- - -### Task 1: Add quick-xml dependency - -**Files:** -- Modify: `Cargo.toml` - -**Step 1: Add quick-xml to dependencies** - -In `Cargo.toml`, add after the existing `tempfile` dependency: - -```toml -quick-xml = "0.37" -``` - -**Step 2: Verify it compiles** - -Run: `cargo check` -Expected: Success with existing warnings only - -**Step 3: Commit** - -```bash -git add Cargo.toml -git commit -m "Add quick-xml dependency for AppStream XML parsing" -``` - ---- - -### Task 2: Create AppStream XML parser module - -**Files:** -- Create: `src/core/appstream.rs` -- Modify: `src/core/mod.rs` (line 1, add `pub mod appstream;`) - -**Step 1: Create the appstream module** - -Create `src/core/appstream.rs` with the full parser: - -```rust -use std::collections::HashMap; -use std::path::Path; - -use quick_xml::events::Event; -use quick_xml::Reader; - -#[derive(Debug, Clone, Default)] -pub struct AppStreamMetadata { - pub id: Option, - pub name: Option, - pub summary: Option, - pub description: Option, - pub developer: Option, - pub project_license: Option, - pub project_group: Option, - pub urls: HashMap, - pub keywords: Vec, - pub categories: Vec, - pub content_rating_summary: Option, - pub releases: Vec, - pub mime_types: Vec, -} - -#[derive(Debug, Clone)] -pub struct ReleaseInfo { - pub version: String, - pub date: Option, - pub description: Option, -} - -/// Parse an AppStream metainfo XML file and extract all metadata. -/// -/// Returns None if the file cannot be read or parsed. -/// Handles both `*.appdata.xml` and `*.metainfo.xml` files. -pub fn parse_appstream_file(path: &Path) -> Option { - let content = std::fs::read_to_string(path).ok()?; - parse_appstream_xml(&content) -} - -/// Parse AppStream XML from a string. -pub fn parse_appstream_xml(xml: &str) -> Option { - let mut reader = Reader::from_str(xml); - let mut meta = AppStreamMetadata::default(); - let mut buf = Vec::new(); - - // State tracking - let mut current_tag = String::new(); - let mut in_component = false; - let mut in_description = false; - let mut in_release = false; - let mut in_release_description = false; - let mut in_provides = false; - let mut in_keywords = false; - let mut in_categories = false; - let mut description_parts: Vec = Vec::new(); - let mut release_desc_parts: Vec = Vec::new(); - let mut current_url_type = String::new(); - let mut current_release_version = String::new(); - let mut current_release_date = String::new(); - let mut content_rating_attrs: Vec<(String, String)> = Vec::new(); - let mut in_content_rating = false; - let mut current_content_attr_id = String::new(); - let mut in_developer = false; - let mut depth = 0u32; - let mut description_depth = 0u32; - let mut release_desc_depth = 0u32; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - depth += 1; - - match tag_name.as_str() { - "component" => { - in_component = true; - } - "description" if in_component && !in_release => { - in_description = true; - description_depth = depth; - description_parts.clear(); - } - "description" if in_release => { - in_release_description = true; - release_desc_depth = depth; - release_desc_parts.clear(); - } - "p" if in_description && !in_release_description => { - current_tag = "description_p".to_string(); - } - "li" if in_description && !in_release_description => { - current_tag = "description_li".to_string(); - } - "p" if in_release_description => { - current_tag = "release_desc_p".to_string(); - } - "li" if in_release_description => { - current_tag = "release_desc_li".to_string(); - } - "url" if in_component => { - current_url_type = String::new(); - for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"type" { - current_url_type = - String::from_utf8_lossy(&attr.value).to_string(); - } - } - current_tag = "url".to_string(); - } - "release" if in_component => { - in_release = true; - current_release_version.clear(); - current_release_date.clear(); - release_desc_parts.clear(); - for attr in e.attributes().flatten() { - match attr.key.as_ref() { - b"version" => { - current_release_version = - String::from_utf8_lossy(&attr.value).to_string(); - } - b"date" => { - current_release_date = - String::from_utf8_lossy(&attr.value).to_string(); - } - _ => {} - } - } - } - "content_rating" if in_component => { - in_content_rating = true; - content_rating_attrs.clear(); - } - "content_attribute" if in_content_rating => { - current_content_attr_id.clear(); - for attr in e.attributes().flatten() { - if attr.key.as_ref() == b"id" { - current_content_attr_id = - String::from_utf8_lossy(&attr.value).to_string(); - } - } - current_tag = "content_attribute".to_string(); - } - "provides" if in_component => { - in_provides = true; - } - "mediatype" if in_provides => { - current_tag = "mediatype".to_string(); - } - "keywords" if in_component => { - in_keywords = true; - } - "keyword" if in_keywords => { - current_tag = "keyword".to_string(); - } - "categories" if in_component => { - in_categories = true; - } - "category" if in_categories => { - current_tag = "category".to_string(); - } - "developer" if in_component => { - in_developer = true; - } - "developer_name" if in_component && !in_developer => { - // Legacy tag (deprecated but common) - current_tag = "developer_name".to_string(); - } - "name" if in_developer => { - current_tag = "developer_child_name".to_string(); - } - "id" if in_component && depth == 2 => { - current_tag = "id".to_string(); - } - "name" if in_component && !in_developer && depth == 2 => { - // Only capture unlocalized (no xml:lang attribute) - let has_lang = e.attributes().flatten().any(|a| { - a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang" - }); - if !has_lang { - current_tag = "name".to_string(); - } - } - "summary" if in_component && depth == 2 => { - let has_lang = e.attributes().flatten().any(|a| { - a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang" - }); - if !has_lang { - current_tag = "summary".to_string(); - } - } - "project_license" if in_component => { - current_tag = "project_license".to_string(); - } - "project_group" if in_component => { - current_tag = "project_group".to_string(); - } - _ => {} - } - } - Ok(Event::End(ref e)) => { - let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - - match tag_name.as_str() { - "component" => { - in_component = false; - } - "description" if in_release_description && depth == release_desc_depth => { - in_release_description = false; - } - "description" if in_description && depth == description_depth => { - in_description = false; - if !description_parts.is_empty() { - meta.description = Some(description_parts.join("\n\n")); - } - } - "release" => { - if !current_release_version.is_empty() && meta.releases.len() < 10 { - let desc = if release_desc_parts.is_empty() { - None - } else { - Some(release_desc_parts.join("\n")) - }; - meta.releases.push(ReleaseInfo { - version: current_release_version.clone(), - date: if current_release_date.is_empty() { - None - } else { - Some(current_release_date.clone()) - }, - description: desc, - }); - } - in_release = false; - in_release_description = false; - } - "content_rating" => { - in_content_rating = false; - meta.content_rating_summary = - Some(summarize_content_rating(&content_rating_attrs)); - } - "provides" => { - in_provides = false; - } - "keywords" => { - in_keywords = false; - } - "categories" => { - in_categories = false; - } - "developer" => { - in_developer = false; - } - _ => {} - } - - current_tag.clear(); - depth = depth.saturating_sub(1); - } - Ok(Event::Text(ref e)) => { - let text = e.unescape().unwrap_or_default().trim().to_string(); - if text.is_empty() { - continue; - } - - match current_tag.as_str() { - "id" => meta.id = Some(text), - "name" => meta.name = Some(text), - "summary" => meta.summary = Some(text), - "project_license" => meta.project_license = Some(text), - "project_group" => meta.project_group = Some(text), - "url" if !current_url_type.is_empty() => { - meta.urls.insert(current_url_type.clone(), text); - } - "description_p" => { - description_parts.push(text); - } - "description_li" => { - description_parts.push(format!(" - {}", text)); - } - "release_desc_p" => { - release_desc_parts.push(text); - } - "release_desc_li" => { - release_desc_parts.push(format!(" - {}", text)); - } - "content_attribute" if !current_content_attr_id.is_empty() => { - content_rating_attrs - .push((current_content_attr_id.clone(), text)); - } - "mediatype" => { - meta.mime_types.push(text); - } - "keyword" => { - meta.keywords.push(text); - } - "category" => { - meta.categories.push(text); - } - "developer_name" => { - if meta.developer.is_none() { - meta.developer = Some(text); - } - } - "developer_child_name" => { - // Modern ... - meta.developer = Some(text); - } - _ => {} - } - } - Ok(Event::Empty(ref e)) => { - let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - // Handle self-closing tags like - if tag_name == "release" && in_component { - let mut ver = String::new(); - let mut date = String::new(); - for attr in e.attributes().flatten() { - match attr.key.as_ref() { - b"version" => { - ver = String::from_utf8_lossy(&attr.value).to_string(); - } - b"date" => { - date = String::from_utf8_lossy(&attr.value).to_string(); - } - _ => {} - } - } - if !ver.is_empty() && meta.releases.len() < 10 { - meta.releases.push(ReleaseInfo { - version: ver, - date: if date.is_empty() { None } else { Some(date) }, - description: None, - }); - } - } - } - Ok(Event::Eof) => break, - Err(e) => { - log::warn!("AppStream XML parse error: {}", e); - break; - } - _ => {} - } - buf.clear(); - } - - if meta.id.is_some() || meta.name.is_some() || meta.summary.is_some() { - Some(meta) - } else { - None - } -} - -/// Summarize OARS content rating attributes into a human-readable level. -fn summarize_content_rating(attrs: &[(String, String)]) -> String { - let max_level = attrs - .iter() - .map(|(_, v)| match v.as_str() { - "intense" => 3, - "moderate" => 2, - "mild" => 1, - _ => 0, - }) - .max() - .unwrap_or(0); - - match max_level { - 0 => "All ages".to_string(), - 1 => "Mild content".to_string(), - 2 => "Moderate content".to_string(), - 3 => "Mature content".to_string(), - _ => "Unknown".to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_minimal_appstream() { - let xml = r#" - - org.example.TestApp - Test App - A test application - CC0-1.0 - MIT -"#; - let meta = parse_appstream_xml(xml).unwrap(); - assert_eq!(meta.id.as_deref(), Some("org.example.TestApp")); - assert_eq!(meta.name.as_deref(), Some("Test App")); - assert_eq!(meta.summary.as_deref(), Some("A test application")); - assert_eq!(meta.project_license.as_deref(), Some("MIT")); - } - - #[test] - fn test_parse_full_appstream() { - let xml = r#" - - org.kde.krita - Krita - Krita - Digital Painting, Creative Freedom - - KDE Community - - GPL-3.0-or-later - KDE - -

    Krita is a creative painting application.

    -

    Features include:

    -
      -
    • Layer support
    • -
    • Brush engines
    • -
    -
    - https://krita.org - https://bugs.kde.org - https://krita.org/support-us/donations - - paint - draw - - - none - mild - - - - -

    Major update with new features.

    -
    -
    - -
    - - image/png - image/jpeg - -
    "#; - let meta = parse_appstream_xml(xml).unwrap(); - assert_eq!(meta.id.as_deref(), Some("org.kde.krita")); - assert_eq!(meta.name.as_deref(), Some("Krita")); - assert_eq!(meta.developer.as_deref(), Some("KDE Community")); - assert_eq!(meta.project_license.as_deref(), Some("GPL-3.0-or-later")); - assert_eq!(meta.project_group.as_deref(), Some("KDE")); - assert_eq!(meta.urls.get("homepage").map(|s| s.as_str()), Some("https://krita.org")); - assert_eq!(meta.urls.get("bugtracker").map(|s| s.as_str()), Some("https://bugs.kde.org")); - assert_eq!(meta.urls.get("donation").map(|s| s.as_str()), Some("https://krita.org/support-us/donations")); - assert_eq!(meta.keywords, vec!["paint", "draw"]); - assert_eq!(meta.content_rating_summary.as_deref(), Some("Mild content")); - assert_eq!(meta.releases.len(), 2); - assert_eq!(meta.releases[0].version, "5.2.0"); - assert_eq!(meta.releases[0].date.as_deref(), Some("2024-01-15")); - assert!(meta.releases[0].description.is_some()); - assert_eq!(meta.releases[1].version, "5.1.0"); - assert_eq!(meta.mime_types, vec!["image/png", "image/jpeg"]); - assert!(meta.description.unwrap().contains("Krita is a creative painting application")); - } - - #[test] - fn test_parse_legacy_developer_name() { - let xml = r#" - - org.test.App - Test - Test app - Old Developer Tag -"#; - let meta = parse_appstream_xml(xml).unwrap(); - assert_eq!(meta.developer.as_deref(), Some("Old Developer Tag")); - } - - #[test] - fn test_parse_empty_returns_none() { - let xml = r#""#; - assert!(parse_appstream_xml(xml).is_none()); - } - - #[test] - fn test_content_rating_summary() { - assert_eq!(summarize_content_rating(&[]), "All ages"); - assert_eq!( - summarize_content_rating(&[ - ("violence-cartoon".to_string(), "none".to_string()), - ("language-humor".to_string(), "mild".to_string()), - ]), - "Mild content" - ); - assert_eq!( - summarize_content_rating(&[ - ("violence-realistic".to_string(), "intense".to_string()), - ]), - "Mature content" - ); - } -} -``` - -**Step 2: Wire the module in mod.rs** - -In `src/core/mod.rs`, add at line 1 (alphabetical order): - -```rust -pub mod appstream; -``` - -**Step 3: Verify compilation and tests** - -Run: `cargo test core::appstream` -Expected: All 5 tests pass - -**Step 4: Commit** - -```bash -git add src/core/appstream.rs src/core/mod.rs -git commit -m "Add AppStream XML parser module with comprehensive metadata extraction" -``` - ---- - -### Task 3: Database migration v9 - new metadata columns - -**Files:** -- Modify: `src/core/database.rs` - -**Step 1: Add the migration function** - -After the existing `migrate_to_v8()` function (around line 680), add: - -```rust -fn migrate_to_v9(conn: &Connection) -> SqlResult<()> { - let new_columns = [ - "appstream_id TEXT", - "appstream_description TEXT", - "generic_name TEXT", - "license TEXT", - "homepage_url TEXT", - "bugtracker_url TEXT", - "donation_url TEXT", - "help_url TEXT", - "vcs_url TEXT", - "keywords TEXT", - "mime_types TEXT", - "content_rating TEXT", - "project_group TEXT", - "release_history TEXT", - "desktop_actions TEXT", - "has_signature INTEGER NOT NULL DEFAULT 0", - ]; - for col in &new_columns { - let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col); - match conn.execute(&sql, []) { - Ok(_) => {} - Err(e) => { - let msg = e.to_string(); - if !msg.contains("duplicate column") { - return Err(e); - } - } - } - } - conn.execute("UPDATE schema_version SET version = 9", [])?; - Ok(()) -} -``` - -**Step 2: Call migrate_to_v9 in the migration chain** - -In the `ensure_schema()` method, after the `migrate_to_v8` call, add: - -```rust -if version < 9 { - migrate_to_v9(&self.conn)?; -} -``` - -**Step 3: Update APPIMAGE_COLUMNS constant** - -Replace the existing constant (line ~776) with: - -```rust -const APPIMAGE_COLUMNS: &str = - "id, path, filename, app_name, app_version, appimage_type, \ - size_bytes, sha256, icon_path, desktop_file, integrated, \ - integrated_at, is_executable, desktop_entry_content, \ - categories, description, developer, architecture, \ - first_seen, last_scanned, file_modified, \ - fuse_status, wayland_status, update_info, update_type, \ - latest_version, update_checked, update_url, notes, sandbox_mode, \ - runtime_wayland_status, runtime_wayland_checked, analysis_status, \ - launch_args, tags, pinned, avg_startup_ms, \ - appstream_id, appstream_description, generic_name, license, \ - homepage_url, bugtracker_url, donation_url, help_url, vcs_url, \ - keywords, mime_types, content_rating, project_group, \ - release_history, desktop_actions, has_signature"; -``` - -**Step 4: Add fields to AppImageRecord struct** - -After the existing `avg_startup_ms` field (line ~52), add: - -```rust - // Phase 9 fields - comprehensive metadata - pub appstream_id: Option, - pub appstream_description: Option, - pub generic_name: Option, - pub license: Option, - pub homepage_url: Option, - pub bugtracker_url: Option, - pub donation_url: Option, - pub help_url: Option, - pub vcs_url: Option, - pub keywords: Option, - pub mime_types: Option, - pub content_rating: Option, - pub project_group: Option, - pub release_history: Option, - pub desktop_actions: Option, - pub has_signature: bool, -``` - -**Step 5: Update row_to_record() to read new columns** - -After the existing `avg_startup_ms` mapping (index 36), add mappings for indices 37-52: - -```rust - appstream_id: row.get(37).unwrap_or(None), - appstream_description: row.get(38).unwrap_or(None), - generic_name: row.get(39).unwrap_or(None), - license: row.get(40).unwrap_or(None), - homepage_url: row.get(41).unwrap_or(None), - bugtracker_url: row.get(42).unwrap_or(None), - donation_url: row.get(43).unwrap_or(None), - help_url: row.get(44).unwrap_or(None), - vcs_url: row.get(45).unwrap_or(None), - keywords: row.get(46).unwrap_or(None), - mime_types: row.get(47).unwrap_or(None), - content_rating: row.get(48).unwrap_or(None), - project_group: row.get(49).unwrap_or(None), - release_history: row.get(50).unwrap_or(None), - desktop_actions: row.get(51).unwrap_or(None), - has_signature: row.get::<_, bool>(52).unwrap_or(false), -``` - -**Step 6: Add update_appstream_metadata() method** - -Add a new method to the `impl Database` block: - -```rust - pub fn update_appstream_metadata( - &self, - id: i64, - appstream_id: Option<&str>, - appstream_description: Option<&str>, - generic_name: Option<&str>, - license: Option<&str>, - homepage_url: Option<&str>, - bugtracker_url: Option<&str>, - donation_url: Option<&str>, - help_url: Option<&str>, - vcs_url: Option<&str>, - keywords: Option<&str>, - mime_types: Option<&str>, - content_rating: Option<&str>, - project_group: Option<&str>, - release_history: Option<&str>, - desktop_actions: Option<&str>, - has_signature: bool, - ) -> SqlResult<()> { - self.conn.execute( - "UPDATE appimages SET - appstream_id = ?2, - appstream_description = ?3, - generic_name = ?4, - license = ?5, - homepage_url = ?6, - bugtracker_url = ?7, - donation_url = ?8, - help_url = ?9, - vcs_url = ?10, - keywords = ?11, - mime_types = ?12, - content_rating = ?13, - project_group = ?14, - release_history = ?15, - desktop_actions = ?16, - has_signature = ?17 - WHERE id = ?1", - params![ - id, - appstream_id, - appstream_description, - generic_name, - license, - homepage_url, - bugtracker_url, - donation_url, - help_url, - vcs_url, - keywords, - mime_types, - content_rating, - project_group, - release_history, - desktop_actions, - has_signature, - ], - )?; - Ok(()) - } -``` - -**Step 7: Verify compilation** - -Run: `cargo check` -Expected: Success (warnings about unused fields are OK for now) - -**Step 8: Commit** - -```bash -git add src/core/database.rs -git commit -m "Add database migration v9 with 16 new metadata columns" -``` - ---- - -### Task 4: Extend desktop entry parser and inspector metadata struct - -**Files:** -- Modify: `src/core/inspector.rs` - -**Step 1: Extend DesktopEntryFields struct** - -Replace the existing `DesktopEntryFields` struct (line ~48) with: - -```rust -#[derive(Debug, Default)] -struct DesktopEntryFields { - name: Option, - icon: Option, - comment: Option, - categories: Vec, - exec: Option, - version: Option, - generic_name: Option, - keywords: Vec, - mime_types: Vec, - terminal: bool, - x_appimage_name: Option, - actions: Vec, -} -``` - -**Step 2: Extend parse_desktop_entry() to capture new fields** - -In `parse_desktop_entry()`, add new cases in the match block (after the existing `"X-AppImage-Version"` case around line 276): - -```rust - "GenericName" => fields.generic_name = Some(value.to_string()), - "Keywords" => { - fields.keywords = value - .split(';') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - } - "MimeType" => { - fields.mime_types = value - .split(';') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - } - "Terminal" => fields.terminal = value == "true", - "X-AppImage-Name" => fields.x_appimage_name = Some(value.to_string()), - "Actions" => { - fields.actions = value - .split(';') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - } -``` - -**Step 3: Extend AppImageMetadata struct** - -Replace the existing struct (line ~36) with: - -```rust -#[derive(Debug, Clone, Default)] -pub struct AppImageMetadata { - pub app_name: Option, - pub app_version: Option, - pub description: Option, - pub developer: Option, - pub icon_name: Option, - pub categories: Vec, - pub desktop_entry_content: String, - pub architecture: Option, - pub cached_icon_path: Option, - // Extended metadata from AppStream XML and desktop entry - pub appstream_id: Option, - pub appstream_description: Option, - pub generic_name: Option, - pub license: Option, - pub homepage_url: Option, - pub bugtracker_url: Option, - pub donation_url: Option, - pub help_url: Option, - pub vcs_url: Option, - pub keywords: Vec, - pub mime_types: Vec, - pub content_rating: Option, - pub project_group: Option, - pub releases: Vec, - pub desktop_actions: Vec, - pub has_signature: bool, -} -``` - -**Step 4: Add signature detection function** - -Add this function after `detect_architecture()`: - -```rust -/// Check if an AppImage has a GPG signature in its ELF sections. -/// Reads the ELF section headers to find .sha256_sig section. -fn detect_signature(path: &Path) -> bool { - let data = match std::fs::read(path) { - Ok(d) => d, - Err(_) => return false, - }; - // Simple check: look for the section name ".sha256_sig" in the binary - // and verify there's non-zero content nearby - let needle = b".sha256_sig"; - for window in data.windows(needle.len()) { - if window == needle { - return true; - } - } - false -} -``` - -**Step 5: Add AppStream file finder** - -Add this function after `find_icon_recursive()`: - -```rust -/// Find an AppStream metainfo XML file in the extract directory. -fn find_appstream_file(extract_dir: &Path) -> Option { - // Check modern path first - let metainfo_dir = extract_dir.join("usr/share/metainfo"); - if let Ok(entries) = std::fs::read_dir(&metainfo_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext == "xml" { - return Some(path); - } - } - } - } - // Check legacy path - let appdata_dir = extract_dir.join("usr/share/appdata"); - if let Ok(entries) = std::fs::read_dir(&appdata_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext == "xml" { - return Some(path); - } - } - } - } - None -} -``` - -**Step 6: Update inspect_appimage() to use AppStream and extended fields** - -In `inspect_appimage()`, after the desktop entry parsing section (around line 500), before the final `Ok(AppImageMetadata { ... })`, add AppStream parsing and merge logic. Replace the final return block with: - -```rust - // Parse AppStream metainfo XML if available - let appstream = find_appstream_file(&extract_dir) - .and_then(|p| crate::core::appstream::parse_appstream_file(&p)); - - // Merge: AppStream takes priority for overlapping fields - let final_name = appstream.as_ref() - .and_then(|a| a.name.clone()) - .or(fields.name); - let final_description = appstream.as_ref() - .and_then(|a| a.description.clone()) - .or(appstream.as_ref().and_then(|a| a.summary.clone())) - .or(fields.comment); - let final_developer = appstream.as_ref() - .and_then(|a| a.developer.clone()); - let final_categories = if let Some(ref a) = appstream { - if !a.categories.is_empty() { a.categories.clone() } else { fields.categories } - } else { - fields.categories - }; - - // Determine version (desktop entry > filename heuristic) - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - let version = fields - .version - .or_else(|| extract_version_from_filename(filename)); - - // Find and cache icon - let icon = find_icon(&extract_dir, fields.icon.as_deref()); - let app_id = make_app_id( - final_name.as_deref().unwrap_or( - filename - .strip_suffix(".AppImage") - .unwrap_or(filename), - ), - ); - let cached_icon = icon.and_then(|icon_path| cache_icon(&icon_path, &app_id)); - - // Collect keywords: merge desktop entry and AppStream - let mut all_keywords = fields.keywords; - if let Some(ref a) = appstream { - for kw in &a.keywords { - if !all_keywords.contains(kw) { - all_keywords.push(kw.clone()); - } - } - } - - // Collect MIME types: merge desktop entry and AppStream - let mut all_mime_types = fields.mime_types; - if let Some(ref a) = appstream { - for mt in &a.mime_types { - if !all_mime_types.contains(mt) { - all_mime_types.push(mt.clone()); - } - } - } - - // Check for signature - let has_sig = detect_signature(path); - - Ok(AppImageMetadata { - app_name: final_name, - app_version: version, - description: final_description, - developer: final_developer, - icon_name: fields.icon, - categories: final_categories, - desktop_entry_content: desktop_content, - architecture: detect_architecture(path), - cached_icon_path: cached_icon, - appstream_id: appstream.as_ref().and_then(|a| a.id.clone()), - appstream_description: appstream.as_ref().and_then(|a| a.description.clone()), - generic_name: fields.generic_name.or_else(|| appstream.as_ref().and_then(|a| a.summary.clone())), - license: appstream.as_ref().and_then(|a| a.project_license.clone()), - homepage_url: appstream.as_ref().and_then(|a| a.urls.get("homepage").cloned()), - bugtracker_url: appstream.as_ref().and_then(|a| a.urls.get("bugtracker").cloned()), - donation_url: appstream.as_ref().and_then(|a| a.urls.get("donation").cloned()), - help_url: appstream.as_ref().and_then(|a| a.urls.get("help").cloned()), - vcs_url: appstream.as_ref().and_then(|a| a.urls.get("vcs-browser").cloned()), - keywords: all_keywords, - mime_types: all_mime_types, - content_rating: appstream.as_ref().and_then(|a| a.content_rating_summary.clone()), - project_group: appstream.as_ref().and_then(|a| a.project_group.clone()), - releases: appstream.as_ref().map(|a| a.releases.clone()).unwrap_or_default(), - desktop_actions: fields.actions, - has_signature: has_sig, - }) -``` - -Note: This replaces the existing final section of `inspect_appimage()` starting from the "Determine version" comment through the final `Ok(...)`. - -**Step 7: Update tests** - -Update the existing `test_parse_desktop_entry` test to verify new fields: - -```rust - #[test] - fn test_parse_desktop_entry_extended() { - let content = "[Desktop Entry] -Type=Application -Name=Test App -GenericName=Testing Tool -Icon=test-icon -Comment=A test application -Categories=Utility;Development; -Exec=test %U -X-AppImage-Version=1.2.3 -Keywords=test;debug; -MimeType=text/plain;application/json; -Terminal=false -Actions=NewWindow;Quit; - -[Desktop Action NewWindow] -Name=New Window -Exec=test --new-window - -[Desktop Action Quit] -Name=Quit -Exec=test --quit -"; - let fields = parse_desktop_entry(content); - assert_eq!(fields.generic_name.as_deref(), Some("Testing Tool")); - assert_eq!(fields.keywords, vec!["test", "debug"]); - assert_eq!(fields.mime_types, vec!["text/plain", "application/json"]); - assert!(!fields.terminal); - assert_eq!(fields.actions, vec!["NewWindow", "Quit"]); - } -``` - -**Step 8: Verify compilation and tests** - -Run: `cargo test inspector` -Expected: All tests pass - -**Step 9: Commit** - -```bash -git add src/core/inspector.rs -git commit -m "Extend inspector with AppStream XML parsing and comprehensive metadata extraction" -``` - ---- - -### Task 5: Update analysis pipeline to store new metadata - -**Files:** -- Modify: `src/core/analysis.rs` - -**Step 1: Update run_background_analysis() to store extended metadata** - -In `run_background_analysis()`, after the existing metadata update block (around line 86), add the AppStream metadata storage. Replace the existing `if let Ok(meta) = inspector::inspect_appimage(...)` block with: - -```rust - // Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.) - if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) { - let categories = if meta.categories.is_empty() { - None - } else { - Some(meta.categories.join(";")) - }; - if let Err(e) = db.update_metadata( - id, - meta.app_name.as_deref(), - meta.app_version.as_deref(), - meta.description.as_deref(), - meta.developer.as_deref(), - categories.as_deref(), - meta.architecture.as_deref(), - meta.cached_icon_path - .as_ref() - .map(|p| p.to_string_lossy()) - .as_deref(), - Some(&meta.desktop_entry_content), - ) { - log::warn!("Failed to update metadata for id {}: {}", id, e); - } - - // Store extended metadata from AppStream XML and desktop entry - let keywords = if meta.keywords.is_empty() { - None - } else { - Some(meta.keywords.join(",")) - }; - let mime_types = if meta.mime_types.is_empty() { - None - } else { - Some(meta.mime_types.join(";")) - }; - let release_json = if meta.releases.is_empty() { - None - } else { - let releases: Vec = meta.releases.iter().map(|r| { - serde_json::json!({ - "version": r.version, - "date": r.date, - "description": r.description, - }) - }).collect(); - Some(serde_json::to_string(&releases).unwrap_or_default()) - }; - let actions_json = if meta.desktop_actions.is_empty() { - None - } else { - Some(serde_json::to_string(&meta.desktop_actions).unwrap_or_default()) - }; - - if let Err(e) = db.update_appstream_metadata( - id, - meta.appstream_id.as_deref(), - meta.appstream_description.as_deref(), - meta.generic_name.as_deref(), - meta.license.as_deref(), - meta.homepage_url.as_deref(), - meta.bugtracker_url.as_deref(), - meta.donation_url.as_deref(), - meta.help_url.as_deref(), - meta.vcs_url.as_deref(), - keywords.as_deref(), - mime_types.as_deref(), - meta.content_rating.as_deref(), - meta.project_group.as_deref(), - release_json.as_deref(), - actions_json.as_deref(), - meta.has_signature, - ) { - log::warn!("Failed to update appstream metadata for id {}: {}", id, e); - } - } -``` - -**Step 2: Add serde_json import** - -At the top of `analysis.rs`, add: - -```rust -use serde_json; -``` - -**Step 3: Verify compilation** - -Run: `cargo check` -Expected: Success - -**Step 4: Commit** - -```bash -git add src/core/analysis.rs -git commit -m "Store comprehensive AppStream and desktop entry metadata during analysis" -``` - ---- - -### Task 6: Redesign overview tab with all metadata groups - -**Files:** -- Modify: `src/ui/detail_view.rs` - -**Step 1: Replace build_overview_tab() function** - -Replace the entire `build_overview_tab()` function with the new version that includes all 8 groups. The function starts at line 263 and ends at line 447. - -```rust -fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { - let tab = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .margin_top(18) - .margin_bottom(24) - .margin_start(18) - .margin_end(18) - .build(); - - let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) - .build(); - - let inner = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .build(); - - // ----------------------------------------------------------------------- - // About section (new - shows identity and provenance) - // ----------------------------------------------------------------------- - let has_about_data = record.appstream_id.is_some() - || record.generic_name.is_some() - || record.developer.is_some() - || record.license.is_some() - || record.project_group.is_some(); - - if has_about_data { - let about_group = adw::PreferencesGroup::builder() - .title("About") - .build(); - - if let Some(ref id) = record.appstream_id { - let row = adw::ActionRow::builder() - .title("App ID") - .subtitle(id) - .subtitle_selectable(true) - .build(); - about_group.add(&row); - } - - if let Some(ref gn) = record.generic_name { - let row = adw::ActionRow::builder() - .title("Type") - .subtitle(gn) - .build(); - about_group.add(&row); - } - - if let Some(ref dev) = record.developer { - let row = adw::ActionRow::builder() - .title("Developer") - .subtitle(dev) - .build(); - about_group.add(&row); - } - - if let Some(ref lic) = record.license { - let row = adw::ActionRow::builder() - .title("License") - .subtitle(lic) - .tooltip_text("SPDX license identifier for this application") - .build(); - about_group.add(&row); - } - - if let Some(ref pg) = record.project_group { - let row = adw::ActionRow::builder() - .title("Project group") - .subtitle(pg) - .build(); - about_group.add(&row); - } - - inner.append(&about_group); - } - - // ----------------------------------------------------------------------- - // Description section (new - full AppStream description) - // ----------------------------------------------------------------------- - if let Some(ref desc) = record.appstream_description { - if !desc.is_empty() { - let desc_group = adw::PreferencesGroup::builder() - .title("Description") - .build(); - - let label = gtk::Label::builder() - .label(desc) - .wrap(true) - .xalign(0.0) - .css_classes(["body"]) - .selectable(true) - .margin_top(8) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build(); - desc_group.add(&label); - - inner.append(&desc_group); - } - } - - // ----------------------------------------------------------------------- - // Links section (new - clickable URLs) - // ----------------------------------------------------------------------- - let has_links = record.homepage_url.is_some() - || record.bugtracker_url.is_some() - || record.donation_url.is_some() - || record.help_url.is_some() - || record.vcs_url.is_some(); - - if has_links { - let links_group = adw::PreferencesGroup::builder() - .title("Links") - .build(); - - let link_entries: &[(&str, &str, &Option)] = &[ - ("Homepage", "web-browser-symbolic", &record.homepage_url), - ("Bug tracker", "bug-symbolic", &record.bugtracker_url), - ("Source code", "code-symbolic", &record.vcs_url), - ("Documentation", "help-browser-symbolic", &record.help_url), - ("Donate", "heart-filled-symbolic", &record.donation_url), - ]; - - for (title, icon_name, url_opt) in link_entries { - if let Some(ref url) = url_opt { - let row = adw::ActionRow::builder() - .title(*title) - .subtitle(url) - .activatable(true) - .build(); - - let icon = gtk::Image::from_icon_name("external-link-symbolic"); - icon.set_valign(gtk::Align::Center); - row.add_suffix(&icon); - - let prefix_icon = gtk::Image::from_icon_name(*icon_name); - prefix_icon.set_valign(gtk::Align::Center); - row.add_prefix(&prefix_icon); - - let url_clone = url.clone(); - row.connect_activated(move |row| { - let launcher = gtk::UriLauncher::new(&url_clone); - let window = row.root() - .and_then(|r| r.downcast::().ok()); - launcher.launch( - window.as_ref(), - None::<>k::gio::Cancellable>, - |_| {}, - ); - }); - links_group.add(&row); - } - } - - inner.append(&links_group); - } - - // ----------------------------------------------------------------------- - // Updates section (existing - unchanged) - // ----------------------------------------------------------------------- - let updates_group = adw::PreferencesGroup::builder() - .title("Updates") - .description("Keep this app up to date by checking for new versions.") - .build(); - - if let Some(ref update_type) = record.update_type { - let display_label = updater::parse_update_info(update_type) - .map(|ut| ut.type_label_display()) - .unwrap_or("Unknown format"); - let row = adw::ActionRow::builder() - .title("Update method") - .subtitle(&format!( - "This app checks for updates using: {}", - display_label - )) - .tooltip_text( - "AppImages can include built-in update information that tells Driftwood \ - where to check for newer versions. Common methods include GitHub releases, \ - zsync (efficient delta updates), and direct download URLs." - ) - .build(); - updates_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title("Update method") - .subtitle( - "This app does not include update information. \ - You will need to check for new versions manually." - ) - .tooltip_text( - "AppImages can include built-in update information that tells Driftwood \ - where to check for newer versions. This one doesn't have any, so you'll \ - need to download updates yourself from wherever you got the app." - ) - .build(); - let badge = widgets::status_badge("Manual only", "neutral"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - updates_group.add(&row); - } - - if let Some(ref latest) = record.latest_version { - let is_newer = record - .app_version - .as_deref() - .map(|current| crate::core::updater::version_is_newer(latest, current)) - .unwrap_or(true); - - if is_newer { - let subtitle = format!( - "A newer version is available: {} (you have {})", - latest, - record.app_version.as_deref().unwrap_or("unknown"), - ); - let row = adw::ActionRow::builder() - .title("Update available") - .subtitle(&subtitle) - .build(); - let badge = widgets::status_badge("Update", "info"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - updates_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title("Version status") - .subtitle("You are running the latest version.") - .build(); - let badge = widgets::status_badge("Latest", "success"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - updates_group.add(&row); - } - } - - if let Some(ref checked) = record.update_checked { - let row = adw::ActionRow::builder() - .title("Last checked") - .subtitle(checked) - .build(); - updates_group.add(&row); - } - inner.append(&updates_group); - - // ----------------------------------------------------------------------- - // Release History section (new) - // ----------------------------------------------------------------------- - if let Some(ref release_json) = record.release_history { - if let Ok(releases) = serde_json::from_str::>(release_json) { - if !releases.is_empty() { - let release_group = adw::PreferencesGroup::builder() - .title("Release History") - .description("Recent versions of this application.") - .build(); - - for release in releases.iter().take(10) { - let version = release.get("version") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let date = release.get("date") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let desc = release.get("description") - .and_then(|v| v.as_str()); - - let title = if date.is_empty() { - format!("v{}", version) - } else { - format!("v{} - {}", version, date) - }; - - if let Some(desc_text) = desc { - let row = adw::ExpanderRow::builder() - .title(&title) - .subtitle("Click to see changes") - .build(); - - let label = gtk::Label::builder() - .label(desc_text) - .wrap(true) - .xalign(0.0) - .css_classes(["body"]) - .margin_top(8) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build(); - let label_row = adw::ActionRow::new(); - label_row.set_child(Some(&label)); - row.add_row(&label_row); - - release_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title(&title) - .build(); - release_group.add(&row); - } - } - - inner.append(&release_group); - } - } - } - - // ----------------------------------------------------------------------- - // Usage section (existing - unchanged) - // ----------------------------------------------------------------------- - let usage_group = adw::PreferencesGroup::builder() - .title("Usage") - .build(); - - let stats = launcher::get_launch_stats(db, record.id); - - let launches_row = adw::ActionRow::builder() - .title("Total launches") - .subtitle(&stats.total_launches.to_string()) - .build(); - usage_group.add(&launches_row); - - if let Some(ref last) = stats.last_launched { - let row = adw::ActionRow::builder() - .title("Last launched") - .subtitle(last) - .build(); - usage_group.add(&row); - } - inner.append(&usage_group); - - // ----------------------------------------------------------------------- - // Capabilities section (new - keywords, MIME types, content rating, actions) - // ----------------------------------------------------------------------- - let has_capabilities = record.keywords.is_some() - || record.mime_types.is_some() - || record.content_rating.is_some() - || record.desktop_actions.is_some(); - - if has_capabilities { - let cap_group = adw::PreferencesGroup::builder() - .title("Capabilities") - .build(); - - if let Some(ref kw) = record.keywords { - if !kw.is_empty() { - let row = adw::ActionRow::builder() - .title("Keywords") - .subtitle(kw) - .build(); - cap_group.add(&row); - } - } - - if let Some(ref mt) = record.mime_types { - if !mt.is_empty() { - let row = adw::ActionRow::builder() - .title("Supported file types") - .subtitle(mt) - .build(); - cap_group.add(&row); - } - } - - if let Some(ref cr) = record.content_rating { - let row = adw::ActionRow::builder() - .title("Content rating") - .subtitle(cr) - .tooltip_text("Content rating based on the OARS (Open Age Ratings Service) system") - .build(); - cap_group.add(&row); - } - - if let Some(ref actions_json) = record.desktop_actions { - if let Ok(actions) = serde_json::from_str::>(actions_json) { - if !actions.is_empty() { - let row = adw::ActionRow::builder() - .title("Desktop actions") - .subtitle(&actions.join(", ")) - .tooltip_text("Additional actions available from the right-click menu when this app is integrated into the desktop") - .build(); - cap_group.add(&row); - } - } - } - - inner.append(&cap_group); - } - - // ----------------------------------------------------------------------- - // File info section (existing - extended with signature) - // ----------------------------------------------------------------------- - let info_group = adw::PreferencesGroup::builder() - .title("File Information") - .build(); - - let type_str = match record.appimage_type { - Some(1) => "Type 1 (ISO 9660) - older format, still widely supported", - Some(2) => "Type 2 (SquashFS) - modern format, most common today", - _ => "Unknown type", - }; - let type_row = adw::ActionRow::builder() - .title("AppImage format") - .subtitle(type_str) - .tooltip_text( - "AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \ - (older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \ - files). Type 2 is the standard today and is what most AppImage tools \ - produce." - ) - .build(); - info_group.add(&type_row); - - let exec_row = adw::ActionRow::builder() - .title("Executable") - .subtitle(if record.is_executable { - "Yes - this file has execute permission" - } else { - "No - execute permission is missing. It will be set automatically when launched." - }) - .build(); - info_group.add(&exec_row); - - // Signature status - let sig_row = adw::ActionRow::builder() - .title("Digital signature") - .subtitle(if record.has_signature { - "This AppImage contains a GPG signature" - } else { - "Not signed" - }) - .tooltip_text( - "AppImages can be digitally signed by their author using GPG. \ - A signature helps verify that the file hasn't been tampered with." - ) - .build(); - let sig_badge = if record.has_signature { - widgets::status_badge("Signed", "success") - } else { - widgets::status_badge("Unsigned", "neutral") - }; - sig_badge.set_valign(gtk::Align::Center); - sig_row.add_suffix(&sig_badge); - info_group.add(&sig_row); - - let seen_row = adw::ActionRow::builder() - .title("First seen") - .subtitle(&record.first_seen) - .build(); - info_group.add(&seen_row); - - let scanned_row = adw::ActionRow::builder() - .title("Last scanned") - .subtitle(&record.last_scanned) - .build(); - info_group.add(&scanned_row); - - if let Some(ref notes) = record.notes { - if !notes.is_empty() { - let row = adw::ActionRow::builder() - .title("Notes") - .subtitle(notes) - .build(); - info_group.add(&row); - } - } - inner.append(&info_group); - - clamp.set_child(Some(&inner)); - tab.append(&clamp); - tab -} -``` - -**Step 2: Add serde_json import at top of file** - -Add at the top of `detail_view.rs`: - -```rust -use serde_json; -``` - -**Step 3: Verify compilation** - -Run: `cargo check` -Expected: Success - -**Step 4: Commit** - -```bash -git add src/ui/detail_view.rs -git commit -m "Redesign overview tab with About, Description, Links, Release History, and Capabilities sections" -``` - ---- - -### Task 7: Build, test, and verify - -**Step 1: Full build** - -Run: `cargo build 2>&1` -Expected: Compiles with zero errors (existing warnings OK) - -**Step 2: Run tests** - -Run: `cargo test` -Expected: All tests pass including new AppStream parser tests - -**Step 3: Manual verification** - -Run: `cargo run` -Expected: -- App launches without crashes -- Click on an AppImage - overview tab shows new sections if data is available -- AppImages without AppStream XML gracefully show only the existing sections -- URL links are clickable and open in browser -- Release history entries expand when clicked - -**Step 4: Final commit** - -```bash -git add -A -git commit -m "Comprehensive AppImage metadata extraction and display" -``` diff --git a/docs/plans/2026-02-27-audit-fixes-design.md b/docs/plans/2026-02-27-audit-fixes-design.md deleted file mode 100644 index 4eac524..0000000 --- a/docs/plans/2026-02-27-audit-fixes-design.md +++ /dev/null @@ -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`, 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 `` 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 `` to enumerated string keys, `` 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. diff --git a/docs/plans/2026-02-27-audit-fixes-implementation.md b/docs/plans/2026-02-27-audit-fixes-implementation.md deleted file mode 100644 index ff01240..0000000 --- a/docs/plans/2026-02-27-audit-fixes-implementation.md +++ /dev/null @@ -1,1155 +0,0 @@ -# Audit Fixes Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Fix all 29 findings from the full codebase correctness audit, organized by severity tier. - -**Architecture:** Fix by severity tier (Critical -> High -> Medium -> Low) with a `cargo build` gate after each tier. Each task is a single focused fix. Fixes #10 and #11 (launcher async + stderr) are combined because they touch the same function. - -**Tech Stack:** Rust, gtk4-rs, libadwaita-rs, rusqlite, serde_json, std::io - ---- - -## Tier 1: Critical Fixes - -### Task 1: Fix unsquashfs argument order in security.rs - -**Files:** -- Modify: `src/core/security.rs:253-261` - -**Step 1: Fix the argument order** - -The `unsquashfs` command expects: `unsquashfs [options] [patterns]`. Currently the AppImage path comes after the pattern. Fix: - -```rust - let extract_output = Command::new("unsquashfs") - .args(["-o", &offset, "-f", "-d"]) - .arg(temp_dir.path()) - .arg("-no-progress") - .arg(appimage_path) - .arg(lib_file_path.trim_start_matches("squashfs-root/")) - .output() - .ok()?; -``` - -Remove the `-e` flag (unsquashfs takes extract patterns as positional args after the archive path, not with `-e`). - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 2: Quote Exec path in desktop entries - -**Files:** -- Modify: `src/core/integrator.rs:97` - -**Step 1: Add quotes around the exec path** - -Change line 97 from: -``` - Exec={exec} %U\n\ -``` -to: -``` - Exec=\"{exec}\" %U\n\ -``` - -This ensures paths with spaces (e.g., `/home/user/My Apps/Firefox.AppImage`) work correctly per the Desktop Entry Specification. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 3: Fix compare_versions total order violation - -**Files:** -- Modify: `src/core/duplicates.rs:332-341` - -**Step 1: Use clean_version for equality check** - -Replace the function body: - -```rust -fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { - use super::updater::version_is_newer; - use super::updater::clean_version; - - let ca = clean_version(a); - let cb = clean_version(b); - - if ca == cb { - std::cmp::Ordering::Equal - } else if version_is_newer(a, b) { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Less - } -} -``` - -Note: `clean_version` is currently `fn` (private). It must be made `pub(crate)` in `src/core/updater.rs:704`. - -**Step 2: Make clean_version pub(crate)** - -In `src/core/updater.rs:704`, change `fn clean_version` to `pub(crate) fn clean_version`. - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 4: Chunk-based signature detection in inspector.rs - -**Files:** -- Modify: `src/core/inspector.rs:530-537` - -**Step 1: Replace fs::read with chunked BufReader** - -Replace the `detect_signature` function: - -```rust -fn detect_signature(path: &Path) -> bool { - use std::io::{BufReader, Read}; - let file = match fs::File::open(path) { - Ok(f) => f, - Err(_) => return false, - }; - let needle = b".sha256_sig"; - let mut reader = BufReader::new(file); - let mut buf = vec![0u8; 64 * 1024]; - let mut carry = Vec::new(); - - loop { - let n = match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => n, - Err(_) => break, - }; - // Prepend carry bytes from previous chunk to handle needle spanning chunks - let search_buf = if carry.is_empty() { - &buf[..n] - } else { - carry.extend_from_slice(&buf[..n]); - carry.as_slice() - }; - if search_buf.windows(needle.len()).any(|w| w == needle) { - return true; - } - // Keep the last (needle.len - 1) bytes as carry for the next iteration - let keep = needle.len() - 1; - carry.clear(); - if n >= keep { - carry.extend_from_slice(&buf[n - keep..n]); - } else { - carry.extend_from_slice(&buf[..n]); - } - } - false -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 5: Read only first 12 bytes in verify_appimage - -**Files:** -- Modify: `src/core/updater.rs:961-980` - -**Step 1: Replace fs::read with targeted read** - -```rust -fn verify_appimage(path: &Path) -> bool { - use std::io::Read; - let mut file = match fs::File::open(path) { - Ok(f) => f, - Err(_) => return false, - }; - let mut header = [0u8; 12]; - if file.read_exact(&mut header).is_err() { - return false; - } - // Check ELF magic - if &header[0..4] != b"\x7FELF" { - return false; - } - // Check AppImage Type 2 magic at offset 8: AI\x02 - if header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x02 { - return true; - } - // Check AppImage Type 1 magic at offset 8: AI\x01 - header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x01 -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 6: Tier 1 build gate - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Zero output (clean build with no warnings or errors) - ---- - -## Tier 2: High Fixes - -### Task 7: Handle NULL severity in CVE summaries - -**Files:** -- Modify: `src/core/database.rs:1348-1349` and `1369-1370` - -**Step 1: Change get_cve_summary to handle Option** - -At line 1348-1349, change: -```rust - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) -``` -to: -```rust - let severity: String = row.get::<_, Option>(0)? - .unwrap_or_else(|| "MEDIUM".to_string()); - Ok((severity, row.get::<_, i64>(1)?)) -``` - -**Step 2: Same fix for get_all_cve_summary** - -At line 1369-1370, apply the same change: -```rust - let severity: String = row.get::<_, Option>(0)? - .unwrap_or_else(|| "MEDIUM".to_string()); - Ok((severity, row.get::<_, i64>(1)?)) -``` - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 8: Fix potential deadlock in extract_metadata_files - -**Files:** -- Modify: `src/core/inspector.rs:248` - -**Step 1: Change piped stderr to null** - -At line 248, change: -```rust - .stderr(std::process::Stdio::piped()) -``` -to: -```rust - .stderr(std::process::Stdio::null()) -``` - -We don't use the stderr output (we only check the exit status), so piping it creates a deadlock risk for no benefit. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 9: Fix glob_match edge case - -**Files:** -- Modify: `src/core/updater.rs:680-698` - -**Step 1: Reduce search text after matching the last part** - -Replace the function body from the `// Last part must match` section (line 680) through the return (line 700): - -```rust - // Last part must match at the end (unless pattern ends with *) - let last = parts[parts.len() - 1]; - let end_limit = if !last.is_empty() { - if !text.ends_with(last) { - return false; - } - text.len() - last.len() - } else { - text.len() - }; - - // Middle parts must appear in order within the allowed range - for part in &parts[1..parts.len() - 1] { - if part.is_empty() { - continue; - } - if pos >= end_limit { - return false; - } - if let Some(found) = text[pos..end_limit].find(part) { - pos += found + part.len(); - } else { - return false; - } - } - - true -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 10: Fix backup archive filename collisions - -**Files:** -- Modify: `src/core/backup.rs:122-132` and `195-198` - -**Step 1: Use home-relative paths in archive creation** - -Replace the tar archive entry loop (lines 122-132): - -```rust - let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); - - for entry in &entries { - let source = Path::new(&entry.original_path); - if source.exists() { - if let Ok(rel) = source.strip_prefix(&home_dir) { - tar_args.push("-C".to_string()); - tar_args.push(home_dir.to_string_lossy().to_string()); - tar_args.push(rel.to_string_lossy().to_string()); - } else { - // Path outside home dir - use parent/filename as fallback - tar_args.push("-C".to_string()); - tar_args.push( - source.parent().unwrap_or(Path::new("/")).to_string_lossy().to_string(), - ); - tar_args.push( - source.file_name().unwrap_or_default().to_string_lossy().to_string(), - ); - } - } - } -``` - -Note: Need `use std::path::PathBuf;` if not already imported. - -**Step 2: Fix restore to match the new archive layout** - -Replace the restore path lookup (lines 195-198): - -```rust - for entry in &manifest.paths { - let source = Path::new(&entry.original_path); - let extracted = if let Ok(rel) = source.strip_prefix(&home_dir) { - temp_dir.path().join(rel) - } else { - let source_name = source.file_name().unwrap_or_default(); - temp_dir.path().join(source_name) - }; - let target = Path::new(&entry.original_path); -``` - -Add `let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));` before the restore loop. - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 11: Async crash detection + drop stderr pipe - -**Files:** -- Modify: `src/core/launcher.rs:147-224` - -**Step 1: Remove blocking sleep, return immediately, drop stderr on success** - -Replace the entire `execute_appimage` function (lines 147-224): - -```rust -/// Execute the AppImage process with the given method. -fn execute_appimage( - appimage_path: &Path, - method: &LaunchMethod, - args: &[String], - extra_env: &[(&str, &str)], -) -> LaunchResult { - let mut cmd = match method { - LaunchMethod::Direct => { - let mut c = Command::new(appimage_path); - c.args(args); - c - } - LaunchMethod::ExtractAndRun => { - let mut c = Command::new(appimage_path); - c.env("APPIMAGE_EXTRACT_AND_RUN", "1"); - c.args(args); - c - } - LaunchMethod::Sandboxed => { - let mut c = Command::new("firejail"); - c.arg("--appimage"); - c.arg(appimage_path); - c.args(args); - c - } - }; - - // Apply extra environment variables - for (key, value) in extra_env { - cmd.env(key, value); - } - - // Detach stdin, let stderr go to /dev/null to prevent pipe buffer deadlock - cmd.stdin(Stdio::null()); - cmd.stderr(Stdio::null()); - - match cmd.spawn() { - Ok(child) => { - LaunchResult::Started { - child, - method: method.clone(), - } - } - Err(e) => LaunchResult::Failed(e.to_string()), - } -} -``` - -The caller side (detail_view.rs and window.rs) already has async crash detection via `glib::timeout_future` + `child.try_wait()`. The 1.5s sleep was redundant with that pattern. Since we now use `Stdio::null()` for stderr, we need to update the `Crashed` variant handling in callers to not expect stderr content from the launcher - callers can capture stderr separately if needed. - -Actually, let me check if callers rely on the `Crashed` variant. Looking at the current flow: `execute_appimage` returns `Started` or `Failed`. The `Crashed` variant was produced by the sleep+try_wait logic we're removing. The callers in detail_view.rs (line 131) and window.rs pattern-match on `Crashed`. We need to remove those match arms since `execute_appimage` will never produce `Crashed` anymore. - -**Step 2: Update detail_view.rs caller** - -At line 131 in detail_view.rs, the match arm `Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method })` should be removed. The caller already detects crashes async via the wayland analysis timeout (lines 119-129). However, crash detection UX needs to be preserved. Let's keep the `Crashed` variant in the enum but not produce it from `execute_appimage`. Instead, add a comment that callers should poll `child.try_wait()` after a delay to detect crashes. This is already the pattern used in window.rs. - -Actually, looking more carefully at the callers: the detail_view.rs code at line 112-146 handles `Started`, `Crashed`, and `Failed`. If we remove `Crashed` production from `execute_appimage`, immediate crashes (process exits within milliseconds of spawning) would show as `Started` and the user would see "Launched" with no feedback that it crashed. - -Better approach: keep crash detection but make it non-blocking. Change `execute_appimage` to not sleep. Instead, do a single non-blocking `try_wait()` with no sleep. This catches processes that fail immediately (e.g., missing executable, permission denied) without blocking for 1.5 seconds: - -```rust - match cmd.spawn() { - Ok(mut child) => { - // Non-blocking check: catch immediate spawn failures - // (e.g., missing libs, exec format error) - match child.try_wait() { - Ok(Some(status)) => { - // Already exited - immediate crash - LaunchResult::Crashed { - exit_code: status.code(), - stderr: String::new(), - method: method.clone(), - } - } - _ => { - // Still running or can't check - assume success - LaunchResult::Started { - child, - method: method.clone(), - } - } - } - } - Err(e) => LaunchResult::Failed(e.to_string()), - } -``` - -This gives us: no blocking sleep, no stderr pipe deadlock, still catches instant crashes. The stderr field is empty (since we use Stdio::null) but that's fine - the crash dialog still shows the exit code. - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 12: Tier 2 build gate - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Zero output - ---- - -## Tier 3: Medium Fixes - -### Task 13: Gate scan on auto-scan-on-startup setting - -**Files:** -- Modify: `src/window.rs:834-835` - -**Step 1: Wrap trigger_scan in settings check** - -Replace lines 834-835: -```rust - // Always scan on startup to discover new AppImages and complete pending analyses - self.trigger_scan(); -``` -with: -```rust - // Scan on startup if enabled in preferences - if self.settings().boolean("auto-scan-on-startup") { - self.trigger_scan(); - } -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 14: Fix window size persistence - -**Files:** -- Modify: `src/window.rs:1252` - -**Step 1: Use actual dimensions instead of default_size** - -Change line 1252 from: -```rust - let (width, height) = self.default_size(); -``` -to: -```rust - let (width, height) = (self.width(), self.height()); -``` - -`self.width()` and `self.height()` return the current allocated size, which is what we want to persist. `default_size()` returns the value set by `set_default_size()`, which doesn't change on manual resize. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 15: Fix announce() to work with any container - -**Files:** -- Modify: `src/ui/widgets.rs:347-363` - -**Step 1: Replace Box-specific logic with generic parent approach** - -Replace the `announce` function: - -```rust -pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { - let label = gtk::Label::builder() - .label(text) - .visible(false) - .accessible_role(gtk::AccessibleRole::Alert) - .build(); - label.update_property(&[gtk::accessible::Property::Label(text)]); - - // Try common container types - if let Some(box_widget) = container.dynamic_cast_ref::() { - 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); - }); - } else if let Some(stack) = container.dynamic_cast_ref::() { - // For stacks, add to the visible child if it's a box, else use overlay - if let Some(child) = stack.visible_child() { - if let Some(child_box) = child.dynamic_cast_ref::() { - child_box.append(&label); - label.set_visible(true); - let label_clone = label.clone(); - let box_clone = child_box.clone(); - glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { - box_clone.remove(&label_clone); - }); - } - } - } else if let Some(overlay) = container.dynamic_cast_ref::() { - if let Some(child) = overlay.child() { - if let Some(child_box) = child.dynamic_cast_ref::() { - child_box.append(&label); - label.set_visible(true); - let label_clone = label.clone(); - let box_clone = child_box.clone(); - glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { - box_clone.remove(&label_clone); - }); - } - } - } -} -``` - -Note: Needs `use adw::prelude::*;` if not already imported in widgets.rs. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 16: Claim gesture in lightbox - -**Files:** -- Modify: `src/ui/detail_view.rs:1948` - -**Step 1: Claim the gesture sequence** - -Change line 1948 from: -```rust - pic_gesture.connect_released(|_, _, _, _| {}); -``` -to: -```rust - pic_gesture.connect_released(|gesture, _, _, _| { - gesture.set_state(gtk::EventSequenceState::Claimed); - }); -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 17: Use serde_json for CLI JSON output - -**Files:** -- Modify: `src/cli.rs:114-131` - -**Step 1: Replace hand-crafted JSON with serde_json** - -Replace lines 114-131: - -```rust - if format == "json" { - let items: Vec = records - .iter() - .map(|r| { - serde_json::json!({ - "name": r.app_name.as_deref().unwrap_or(&r.filename), - "version": r.app_version.as_deref().unwrap_or(""), - "path": r.path, - "size": r.size_bytes, - "integrated": r.integrated, - }) - }) - .collect(); - println!("{}", serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".into())); - return ExitCode::SUCCESS; - } -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 18: Remove dead @media blocks from CSS - -**Files:** -- Modify: `data/resources/style.css:174-211` - -**Step 1: Delete both @media blocks** - -Remove lines 174-211 (the `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)` blocks). GTK4 CSS does not support these media queries - they are silently ignored. The libadwaita named colors (`@warning_bg_color`, etc.) already adapt to dark mode automatically. - -Keep the comment at line 213-216 about `prefers-reduced-motion` since it explains *why* GTK handles it differently. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build (CSS is compiled at build time via gresource) - ---- - -### Task 19: Wire detail-tab persistence - -**Files:** -- Modify: `src/ui/detail_view.rs:30-32` (read setting) and add save on tab switch - -**Step 1: Restore saved tab on detail view open** - -After the view_stack creation (line 32), after all tabs are added (around line 55), add: - -```rust - // Restore last selected tab - let settings = gio::Settings::new(crate::config::APP_ID); - let saved_tab = settings.string("detail-tab"); - if view_stack.child_by_name(&saved_tab).is_some() { - view_stack.set_visible_child_name(&saved_tab); - } -``` - -**Step 2: Save tab on switch** - -After the tab restore code, add: - -```rust - // Save tab selection - let settings_tab = settings.clone(); - view_stack.connect_visible_child_name_notify(move |stack| { - if let Some(name) = stack.visible_child_name() { - settings_tab.set_string("detail-tab", &name).ok(); - } - }); -``` - -Ensure `use gtk::gio;` and `use crate::config::APP_ID;` are imported at the top of detail_view.rs (check if they already are). - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 20: Remove invalid categories from metainfo - -**Files:** -- Modify: `data/app.driftwood.Driftwood.metainfo.xml:54-58` - -**Step 1: Delete the categories block** - -Remove lines 54-58: -```xml - - System - PackageManager - GTK - -``` - -Per AppStream spec, categories belong in the `.desktop` file only (where they already are). - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 21: Byte-level search in fuse.rs - -**Files:** -- Modify: `src/core/fuse.rs:189-192` - -**Step 1: Replace lossy UTF-8 conversion with byte-level search** - -Replace lines 188-192: -```rust - let data = &buf[..n]; - let haystack = String::from_utf8_lossy(data).to_lowercase(); - haystack.contains("type2-runtime") - || haystack.contains("libfuse3") -``` -with: -```rust - let data = &buf[..n]; - fn bytes_contains_ci(haystack: &[u8], needle: &[u8]) -> bool { - haystack.windows(needle.len()).any(|w| { - w.iter().zip(needle).all(|(a, b)| a.to_ascii_lowercase() == *b) - }) - } - bytes_contains_ci(data, b"type2-runtime") - || bytes_contains_ci(data, b"libfuse3") -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 22: Tier 3 build gate - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Zero output - ---- - -## Tier 4: Low Fixes - -### Task 23: Tighten Wayland env var detection - -**Files:** -- Modify: `src/core/wayland.rs:389-393` - -**Step 1: Remove WAYLAND_DISPLAY from fallback** - -Change lines 389-393 from: -```rust - has_wayland_socket = env_vars.iter().any(|(k, v)| { - (k == "GDK_BACKEND" && v.contains("wayland")) - || (k == "QT_QPA_PLATFORM" && v.contains("wayland")) - || (k == "WAYLAND_DISPLAY" && !v.is_empty()) - }); -``` -to: -```rust - has_wayland_socket = env_vars.iter().any(|(k, v)| { - (k == "GDK_BACKEND" && v.contains("wayland")) - || (k == "QT_QPA_PLATFORM" && v.contains("wayland")) - }); -``` - -`WAYLAND_DISPLAY` is typically inherited from the parent environment and doesn't indicate the app is actually using Wayland. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 24: Add ELF magic validation to detect_architecture - -**Files:** -- Modify: `src/core/inspector.rs:427-440` - -**Step 1: Add magic check and endianness-aware parsing** - -Replace the function: - -```rust -fn detect_architecture(path: &Path) -> Option { - let mut file = fs::File::open(path).ok()?; - let mut header = [0u8; 20]; - file.read_exact(&mut header).ok()?; - - // Validate ELF magic - if &header[0..4] != b"\x7FELF" { - return None; - } - - // ELF e_machine at offset 18, endianness from byte 5 - let machine = if header[5] == 2 { - // Big-endian - u16::from_be_bytes([header[18], header[19]]) - } else { - // Little-endian (default) - u16::from_le_bytes([header[18], header[19]]) - }; - - match machine { - 0x03 => Some("i386".to_string()), - 0x3E => Some("x86_64".to_string()), - 0xB7 => Some("aarch64".to_string()), - 0x28 => Some("armhf".to_string()), - _ => Some(format!("unknown(0x{:02X})", machine)), - } -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 25: Add timeout to extract_update_info_runtime - -**Files:** -- Modify: `src/core/updater.rs:372-388` - -**Step 1: Add 5-second timeout** - -Replace the function: - -```rust -fn extract_update_info_runtime(path: &Path) -> Option { - let mut child = std::process::Command::new(path) - .arg("--appimage-updateinformation") - .env("APPIMAGE_EXTRACT_AND_RUN", "1") - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; - - let timeout = std::time::Duration::from_secs(5); - let start = std::time::Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - if status.success() { - let mut output = String::new(); - if let Some(mut stdout) = child.stdout.take() { - use std::io::Read; - stdout.read_to_string(&mut output).ok()?; - } - let info = output.trim().to_string(); - if !info.is_empty() && info.contains('|') { - return Some(info); - } - } - return None; - } - Ok(None) => { - if start.elapsed() >= timeout { - let _ = child.kill(); - let _ = child.wait(); - log::warn!("Timed out reading update info from {}", path.display()); - return None; - } - std::thread::sleep(std::time::Duration::from_millis(50)); - } - Err(_) => return None, - } - } -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 26: Handle quoted args in parse_launch_args - -**Files:** -- Modify: `src/core/launcher.rs:227-232` - -**Step 1: Implement simple shell-like tokenizer** - -Replace the function: - -```rust -pub fn parse_launch_args(args: Option<&str>) -> Vec { - let Some(s) = args else { - return Vec::new(); - }; - let s = s.trim(); - if s.is_empty() { - return Vec::new(); - } - - let mut result = Vec::new(); - let mut current = String::new(); - let mut in_quotes = false; - let mut chars = s.chars().peekable(); - - while let Some(c) = chars.next() { - match c { - '"' => in_quotes = !in_quotes, - ' ' | '\t' if !in_quotes => { - if !current.is_empty() { - result.push(std::mem::take(&mut current)); - } - } - _ => current.push(c), - } - } - if !current.is_empty() { - result.push(current); - } - - result -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 27: Stop watcher timer on window destroy - -**Files:** -- Modify: `src/window.rs:1114-1121` - -**Step 1: Return Break when window is gone** - -Replace lines 1114-1121: -```rust - glib::timeout_add_local(std::time::Duration::from_secs(1), move || { - if changed.swap(false, std::sync::atomic::Ordering::Relaxed) { - if let Some(window) = window_weak.upgrade() { - window.trigger_scan(); - } - } - glib::ControlFlow::Continue - }); -``` -with: -```rust - glib::timeout_add_local(std::time::Duration::from_secs(1), move || { - let Some(window) = window_weak.upgrade() else { - return glib::ControlFlow::Break; - }; - if changed.swap(false, std::sync::atomic::Ordering::Relaxed) { - window.trigger_scan(); - } - glib::ControlFlow::Continue - }); -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 28: Add GSettings choices/range constraints - -**Files:** -- Modify: `data/app.driftwood.Driftwood.gschema.xml` - -**Step 1: Add choices to enumerated string keys** - -For `view-mode` key, add choices: -```xml - - - - - - 'grid' -``` - -For `color-scheme` key: -```xml - - - - - -``` - -For `detail-tab` key: -```xml - - - - - - -``` - -For `update-cleanup` key: -```xml - - - - - -``` - -For `security-notification-threshold` key: -```xml - - - - - - -``` - -For `backup-retention-days`, add range: -```xml - - - 30 -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 29: Remove unused CSS classes - -**Files:** -- Modify: `data/resources/style.css` - -**Step 1: Remove unused class definitions** - -Remove these blocks: -- `.badge-row` (lines 120-123) - defined but never added to any widget -- `.detail-view-switcher` (lines 154-158) - defined but never added -- `.quick-action-pill` (lines 160-164) - defined but never added - -Keep `.letter-icon` (lines 125-130) since the per-letter variants in widgets.rs inherit these base properties through CSS specificity. - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 30: Define CSS for status-ok/status-attention or remove from code - -**Files:** -- Modify: `data/resources/style.css` (add CSS rules) - -**Step 1: Add CSS rules for the card status classes** - -Add after the `.card` styles: - -```css -/* App card status indicators */ -.status-ok { - border: 1px solid alpha(@success_bg_color, 0.4); -} - -.status-attention { - border: 1px solid alpha(@warning_bg_color, 0.4); -} -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Clean build - ---- - -### Task 31: Tier 4 build gate + final verification - -Run: `cargo build 2>&1 | grep "warning\|error"` -Expected: Zero output - -Run: `cargo run` and verify app launches. - ---- - -## Verification Checklist - -After all tasks: -1. `cargo build` - zero errors, zero warnings -2. `cargo run` - app launches and shows library view -3. Navigate to preferences, toggle settings, verify they save -4. Open a detail view, switch tabs, reopen - tab should be restored diff --git a/docs/plans/2026-02-27-beginner-friendly-copy-design.md b/docs/plans/2026-02-27-beginner-friendly-copy-design.md deleted file mode 100644 index 7166642..0000000 --- a/docs/plans/2026-02-27-beginner-friendly-copy-design.md +++ /dev/null @@ -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) diff --git a/docs/plans/2026-02-27-feature-roadmap-design.md b/docs/plans/2026-02-27-feature-roadmap-design.md deleted file mode 100644 index 0534a7e..0000000 --- a/docs/plans/2026-02-27-feature-roadmap-design.md +++ /dev/null @@ -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` 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 - 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` - - 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, - pub version_id: String, - } - pub fn detect_distro() -> Option // parses /etc/os-release - pub fn get_fuse_install_command(distro: &DistroInfo) -> Option - ``` -- 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 - - - - - Install FUSE library for AppImage support - Authentication is required to install the FUSE library - - auth_admin - auth_admin - auth_admin - - - - ``` -- 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` - 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 ` - 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 --enable/--disable` - Toggle autostart for an AppImage -- `driftwood purge` - Remove ALL system modifications for all managed apps (for Driftwood removal) -- `driftwood verify ` - 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 - pub fn scan_mount_for_appimages(mount: &MountInfo) -> Vec - 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` -- 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 diff --git a/docs/plans/2026-02-27-feature-roadmap-implementation.md b/docs/plans/2026-02-27-feature-roadmap-implementation.md deleted file mode 100644 index 514e3ed..0000000 --- a/docs/plans/2026-02-27-feature-roadmap-implementation.md +++ /dev/null @@ -1,1489 +0,0 @@ -# Driftwood Feature Roadmap Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement 26 features that make Driftwood the definitive AppImage manager for Linux newcomers, with full system modification tracking for clean uninstalls. - -**Architecture:** All features layer on the existing GTK4/libadwaita/Rust stack. A new `system_modifications` table tracks every system-level change. Features are ordered easiest-first. Each task is a self-contained unit that compiles and can be committed independently. - -**Tech Stack:** Rust, gtk4-rs (0.9.x), libadwaita-rs (0.7.x), rusqlite, gio, notify crate, XDG specs, AppImage feed.json, pkexec/polkit. - ---- - -## Task 1: System Modifications Tracking (Foundation) - -**Files:** -- Modify: `src/core/database.rs` -- Modify: `src/core/integrator.rs` - -This is the foundation all other features build on. Every system change (desktop files, icons, autostart, MIME defaults) gets tracked and can be reversed. - -**Step 1: Add migration to v11 with system_modifications table** - -In `src/core/database.rs`, add `migrate_to_v11()` and call it from the migration chain. The table stores every file we create or system setting we change. - -```rust -fn migrate_to_v11(&self) -> SqlResult<()> { - self.conn.execute_batch( - "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);" - )?; - // Add new columns for features F3-F23 - let new_columns = [ - "previous_version_path TEXT", - "source_url TEXT", - "autostart INTEGER NOT NULL DEFAULT 0", - "startup_wm_class TEXT", - "verification_status TEXT", - "first_run_prompted INTEGER NOT NULL DEFAULT 0", - "system_wide INTEGER NOT NULL DEFAULT 0", - "is_portable INTEGER NOT NULL DEFAULT 0", - "mount_point TEXT", - ]; - for col in &new_columns { - let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col); - self.conn.execute(&sql, []).ok(); - } - self.conn.execute( - "UPDATE schema_version SET version = ?1", - params![11], - )?; - Ok(()) -} -``` - -**Step 2: Add DB helper methods for system_modifications** - -```rust -pub fn register_modification( - &self, - appimage_id: i64, - mod_type: &str, - file_path: &str, - previous_value: Option<&str>, -) -> SqlResult { - self.conn.query_row( - "INSERT INTO system_modifications (appimage_id, mod_type, file_path, previous_value) - VALUES (?1, ?2, ?3, ?4) - RETURNING id", - params![appimage_id, mod_type, file_path, previous_value], - |row| row.get(0), - ) -} - -pub fn get_modifications(&self, appimage_id: i64) -> SqlResult> { - let mut stmt = self.conn.prepare( - "SELECT id, mod_type, file_path, previous_value - FROM system_modifications - WHERE appimage_id = ?1 - ORDER BY id DESC" - )?; - let rows = stmt.query_map(params![appimage_id], |row| { - Ok(SystemModification { - id: row.get(0)?, - mod_type: row.get(1)?, - file_path: row.get(2)?, - previous_value: row.get(3)?, - }) - })?; - rows.collect() -} - -pub fn get_all_modifications(&self) -> SqlResult> { - let mut stmt = self.conn.prepare( - "SELECT appimage_id, id, mod_type, file_path, previous_value - FROM system_modifications - ORDER BY id DESC" - )?; - let rows = stmt.query_map([], |row| { - Ok((row.get(0)?, SystemModification { - id: row.get(1)?, - mod_type: row.get(2)?, - file_path: row.get(3)?, - previous_value: row.get(4)?, - })) - })?; - rows.collect() -} - -pub fn remove_modification(&self, id: i64) -> SqlResult<()> { - self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?; - Ok(()) -} -``` - -Add the struct: -```rust -#[derive(Debug, Clone)] -pub struct SystemModification { - pub id: i64, - pub mod_type: String, - pub file_path: String, - pub previous_value: Option, -} -``` - -**Step 3: Add undo_all_modifications to integrator.rs** - -```rust -pub fn undo_all_modifications(db: &Database, appimage_id: i64) -> Result<(), String> { - let mods = db.get_modifications(appimage_id) - .map_err(|e| format!("Failed to get modifications: {}", e))?; - - for m in &mods { - match m.mod_type.as_str() { - "desktop_file" | "autostart" | "icon" => { - let path = std::path::Path::new(&m.file_path); - if path.exists() { - if let Err(e) = std::fs::remove_file(path) { - log::warn!("Failed to remove {}: {}", m.file_path, e); - } - } - } - "mime_default" => { - if let Some(ref prev) = m.previous_value { - // file_path stores the mime type, previous_value stores the old default - let _ = std::process::Command::new("xdg-mime") - .args(["default", prev, &m.file_path]) - .status(); - } - } - "system_desktop" | "system_icon" | "system_binary" => { - let _ = std::process::Command::new("pkexec") - .args(["rm", "-f", &m.file_path]) - .status(); - } - _ => { - log::warn!("Unknown modification type: {}", m.mod_type); - } - } - db.remove_modification(m.id).ok(); - } - - // Refresh desktop database and icon cache - let _ = std::process::Command::new("update-desktop-database") - .arg(crate::core::integrator::applications_dir().to_string_lossy().as_ref()) - .status(); - - Ok(()) -} -``` - -**Step 4: Wire existing integrate/remove_integration to use tracking** - -Modify `integrate()` to call `db.register_modification()` after creating each file. Modify `remove_integration()` to use `undo_all_modifications()`. - -**Step 5: Build and verify** - -Run: `cargo build` -Expected: Compiles with zero errors - -**Step 6: Commit** - -```bash -git add src/core/database.rs src/core/integrator.rs -git commit -m "Add system modification tracking for reversible installs" -``` - ---- - -## Task 2: Fix Executable Permissions on Existing Files (F1) - -**Files:** -- Modify: `src/ui/drop_dialog.rs:195` -- Modify: `src/core/discovery.rs` - -**Step 1: Fix drop_dialog.rs - check permissions for files already in scan dir** - -In `register_dropped_files()`, the `if in_scan_dir` branch at line 195 just clones the path. Add permission fix: - -```rust -let final_path = if in_scan_dir { - // Ensure executable even if already in scan dir - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = std::fs::metadata(file) { - if meta.permissions().mode() & 0o111 == 0 { - let perms = std::fs::Permissions::from_mode(0o755); - if let Err(e) = std::fs::set_permissions(file, perms) { - log::warn!("Failed to set executable on {}: {}", file.display(), e); - } - } - } - } - file.clone() -} else { - // ... existing copy logic -``` - -**Step 2: Fix discovery.rs - auto-fix permissions during scan** - -In `scan_directories()` or wherever `DiscoveredAppImage` structs are built, if `is_executable` is false, fix it: - -```rust -if !is_executable { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o755); - if std::fs::set_permissions(&path, perms).is_ok() { - is_executable = true; - log::info!("Auto-fixed executable permission: {}", path.display()); - } - } -} -``` - -**Step 3: Build and verify** - -Run: `cargo build` - -**Step 4: Commit** - -```bash -git add src/ui/drop_dialog.rs src/core/discovery.rs -git commit -m "Auto-fix executable permissions on discovered AppImages" -``` - ---- - -## Task 3: Drag-and-Drop Keep-in-Place Option (F2) - -**Files:** -- Modify: `src/ui/drop_dialog.rs` - -**Step 1: Add "Keep in place" response to drop dialog** - -Replace the dialog button setup (lines 66-71): - -```rust -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.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested); -dialog.set_default_response(Some("copy-and-integrate")); -dialog.set_close_response("cancel"); -``` - -**Step 2: Update response handler** - -In `connect_response`, determine copy mode from response: - -```rust -dialog.connect_response(None, move |_dialog, response| { - if response == "cancel" { - return; - } - - let copy = response != "keep-in-place"; - let integrate = response == "copy-and-integrate"; - let files = files.clone(); - let toast_ref = toast_ref.clone(); - let on_complete_ref = on_complete.clone(); - - glib::spawn_future_local(async move { - let result = gio::spawn_blocking(move || { - register_dropped_files(&files, copy) - }).await; - // ... rest unchanged -``` - -**Step 3: Add `copy` parameter to register_dropped_files** - -Change signature to `fn register_dropped_files(files: &[PathBuf], copy_to_target: bool)`. - -When `copy_to_target` is false: skip the copy, just use the original path as-is, but still set executable permissions and register in DB. - -When `copy_to_target` is true: existing behavior (copy to target_dir). - -**Step 4: Build and verify** - -Run: `cargo build` - -**Step 5: Commit** - -```bash -git add src/ui/drop_dialog.rs -git commit -m "Add keep-in-place option for drag-and-drop imports" -``` - ---- - -## Task 4: Version Rollback (F3) - -**Files:** -- Modify: `src/core/updater.rs` -- Modify: `src/ui/detail_view.rs` -- Modify: `src/core/database.rs` - -**Step 1: Add DB methods for previous_version_path** - -```rust -pub fn set_previous_version(&self, id: i64, path: Option<&str>) -> SqlResult<()> { - self.conn.execute( - "UPDATE appimages SET previous_version_path = ?2 WHERE id = ?1", - params![id, path], - )?; - Ok(()) -} - -pub fn get_previous_version(&self, id: i64) -> SqlResult> { - self.conn.query_row( - "SELECT previous_version_path FROM appimages WHERE id = ?1", - params![id], - |row| row.get(0), - ) -} -``` - -**Step 2: Save old version before update in updater.rs** - -In the update download flow, before replacing the AppImage: - -```rust -let prev_path = format!("{}.prev", appimage_path.display()); -if let Err(e) = std::fs::rename(appimage_path, &prev_path) { - log::warn!("Failed to save previous version: {}", e); -} else { - db.set_previous_version(record_id, Some(&prev_path)).ok(); -} -// Then write new version to appimage_path -``` - -**Step 3: Add rollback button to detail view** - -In the system tab, when `record.previous_version_path` is `Some(path)`: - -```rust -let rollback_row = adw::ActionRow::builder() - .title("Previous version available") - .subtitle("Rollback to the version before the last update") - .build(); -let rollback_btn = gtk::Button::builder() - .label("Rollback") - .valign(gtk::Align::Center) - .css_classes(["destructive-action"]) - .build(); -rollback_row.add_suffix(&rollback_btn); -``` - -Rollback handler: swap current and .prev files, update DB, re-run analysis. - -**Step 4: Build and verify** - -Run: `cargo build` - -**Step 5: Commit** - -```bash -git add src/core/updater.rs src/ui/detail_view.rs src/core/database.rs -git commit -m "Add version rollback support for AppImage updates" -``` - ---- - -## Task 5: Source Tracking (F4) - -**Files:** -- Modify: `src/core/database.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Add DB methods for source_url** - -```rust -pub fn set_source_url(&self, id: i64, url: Option<&str>) -> SqlResult<()> { - self.conn.execute( - "UPDATE appimages SET source_url = ?2 WHERE id = ?1", - params![id, url], - )?; - Ok(()) -} -``` - -**Step 2: Auto-detect source from update_info** - -In the analysis pipeline or a new helper, parse update_info for GitHub/GitLab URLs: - -```rust -pub fn detect_source_url(update_info: Option<&str>) -> Option { - let info = update_info?; - if info.starts_with("gh-releases-zsync|") { - let parts: Vec<&str> = info.split('|').collect(); - if parts.len() >= 3 { - return Some(format!("https://github.com/{}/{}", parts[1], parts[2])); - } - } - if info.starts_with("zsync|") { - // Extract domain from URL - if let Some(url_part) = info.split('|').nth(1) { - if let Ok(url) = url::Url::parse(url_part) { - return url.host_str().map(|h| format!("https://{}", h)); - } - } - } - None -} -``` - -**Step 3: Display in detail view overview tab** - -Add a row showing source when available: -```rust -if let Some(ref source) = record.source_url { - let source_row = adw::ActionRow::builder() - .title("Source") - .subtitle(source) - .build(); - // Add copy-to-clipboard button as suffix - overview_group.append(&source_row); -} -``` - -**Step 4: Build, verify, commit** - -```bash -git add src/core/database.rs src/ui/detail_view.rs -git commit -m "Add source URL tracking and display for AppImages" -``` - ---- - -## Task 6: Launch Statistics Dashboard (F5) - -**Files:** -- Modify: `src/core/database.rs` -- Modify: `src/ui/dashboard.rs` - -**Step 1: Add DB query methods** - -```rust -pub fn get_top_launched(&self, limit: i32) -> SqlResult> { - let mut stmt = self.conn.prepare( - "SELECT a.app_name, COUNT(l.id) as cnt - FROM launch_events l - JOIN appimages a ON a.id = l.appimage_id - GROUP BY l.appimage_id - ORDER BY cnt DESC - LIMIT ?1" - )?; - let rows = stmt.query_map(params![limit], |row| { - Ok(( - row.get::<_, Option>(0)?.unwrap_or_else(|| "Unknown".to_string()), - row.get::<_, u64>(1)?, - )) - })?; - rows.collect() -} - -pub fn get_launch_count_since(&self, since: &str) -> SqlResult { - self.conn.query_row( - "SELECT COUNT(*) FROM launch_events WHERE launched_at >= ?1", - params![since], - |row| row.get(0), - ) -} - -pub fn get_last_launch(&self) -> SqlResult> { - self.conn.query_row( - "SELECT a.app_name, l.launched_at - FROM launch_events l - JOIN appimages a ON a.id = l.appimage_id - ORDER BY l.launched_at DESC - LIMIT 1", - [], - |row| Ok(Some(( - row.get::<_, Option>(0)?.unwrap_or_else(|| "Unknown".to_string()), - row.get(1)?, - ))), - ).unwrap_or(Ok(None)) -} -``` - -**Step 2: Add Activity section to dashboard** - -After the existing dashboard sections, add: -```rust -// Activity section -let activity_group = adw::PreferencesGroup::builder() - .title("Activity") - .build(); - -if let Ok(top) = db.get_top_launched(5) { - for (name, count) in &top { - let row = adw::ActionRow::builder() - .title(name) - .subtitle(&format!("{} launches", count)) - .build(); - activity_group.append(&row); - } -} -``` - -**Step 3: Build, verify, commit** - -```bash -git add src/core/database.rs src/ui/dashboard.rs -git commit -m "Add launch statistics to dashboard" -``` - ---- - -## Task 7: Automatic Desktop Integration on Scan (F7) - -**Files:** -- Modify: `src/window.rs` -- Modify: `src/ui/preferences.rs` - -**Step 1: Wire auto-integrate after scan** - -In `window.rs`, after the scan completes and new apps are registered, check the setting: - -```rust -let auto_integrate = settings.boolean("auto-integrate"); -if auto_integrate { - for record in newly_discovered { - if !record.integrated { - match integrator::integrate(&record) { - Ok(result) => { - db.set_integrated(record.id, true, Some(&result.desktop_file_path.to_string_lossy())).ok(); - db.register_modification(record.id, "desktop_file", &result.desktop_file_path.to_string_lossy(), None).ok(); - if let Some(ref icon) = result.icon_path { - db.register_modification(record.id, "icon", &icon.to_string_lossy(), None).ok(); - } - } - Err(e) => log::warn!("Auto-integrate failed for {}: {}", record.filename, e), - } - } - } -} -``` - -**Step 2: Ensure toggle exists in preferences** - -Check that preferences.rs has a switch for `auto-integrate`. If not, add one in the Behavior page. - -**Step 3: Build, verify, commit** - -```bash -git add src/window.rs src/ui/preferences.rs -git commit -m "Wire auto-integrate setting to scan pipeline" -``` - ---- - -## Task 8: Batch Operations (F6) - -**Files:** -- Modify: `src/ui/library_view.rs` -- Modify: `src/ui/app_card.rs` -- Modify: `src/window.rs` - -**Step 1: Add selection state to LibraryView** - -Add a `selection_mode: RefCell` and `selected_ids: RefCell>` to the LibraryView struct. - -**Step 2: Add "Select" toggle button to library header** - -When toggled on: show checkboxes on each app card, show bottom action bar. - -**Step 3: Add check button to app_card** - -When library is in selection mode, show a `gtk::CheckButton` on each card. On toggle, add/remove from `selected_ids`. - -**Step 4: Add bottom action bar** - -```rust -let action_bar = gtk::ActionBar::new(); -let integrate_btn = gtk::Button::with_label("Integrate"); -let delete_btn = gtk::Button::builder() - .label("Delete") - .css_classes(["destructive-action"]) - .build(); -action_bar.pack_start(&integrate_btn); -action_bar.pack_end(&delete_btn); -``` - -**Step 5: Wire batch actions** - -Each action iterates `selected_ids`, performs the operation, refreshes UI. - -Delete uses `undo_all_modifications()` from Task 1 for each app. - -**Step 6: Build, verify, commit** - -```bash -git add src/ui/library_view.rs src/ui/app_card.rs src/window.rs -git commit -m "Add batch selection and bulk operations to library view" -``` - ---- - -## Task 9: Autostart Manager (F8) - -**Files:** -- Modify: `src/core/integrator.rs` -- Modify: `src/core/database.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Add autostart functions to integrator.rs** - -```rust -pub fn autostart_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| crate::config::home_dir().join(".config")) - .join("autostart") -} - -pub fn enable_autostart(db: &Database, record: &AppImageRecord) -> Result { - let dir = autostart_dir(); - std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create autostart dir: {}", e))?; - - let desktop_filename = format!("driftwood-{}.desktop", record.id); - let desktop_path = dir.join(&desktop_filename); - - let app_name = record.app_name.as_deref().unwrap_or(&record.filename); - let icon = record.icon_path.as_deref().unwrap_or("application-x-executable"); - - let content = format!( - "[Desktop Entry]\nType=Application\nName={}\nExec={}\nIcon={}\nX-GNOME-Autostart-enabled=true\nX-Driftwood-AppImage-ID={}\n", - app_name, record.path, icon, record.id - ); - - std::fs::write(&desktop_path, &content) - .map_err(|e| format!("Failed to write autostart file: {}", e))?; - - db.register_modification(record.id, "autostart", &desktop_path.to_string_lossy(), None) - .map_err(|e| format!("Failed to register modification: {}", e))?; - - db.set_autostart(record.id, true).ok(); - - Ok(desktop_path) -} - -pub fn disable_autostart(db: &Database, record_id: i64) -> Result<(), String> { - let mods = db.get_modifications(record_id).unwrap_or_default(); - for m in &mods { - if m.mod_type == "autostart" { - let path = std::path::Path::new(&m.file_path); - if path.exists() { - std::fs::remove_file(path).ok(); - } - db.remove_modification(m.id).ok(); - } - } - db.set_autostart(record_id, false).ok(); - Ok(()) -} -``` - -**Step 2: Add DB helpers** - -```rust -pub fn set_autostart(&self, id: i64, enabled: bool) -> SqlResult<()> { - self.conn.execute( - "UPDATE appimages SET autostart = ?2 WHERE id = ?1", - params![id, enabled as i32], - )?; - Ok(()) -} -``` - -**Step 3: Add toggle to detail view system tab** - -```rust -let autostart_row = adw::SwitchRow::builder() - .title("Start at login") - .subtitle("Launch this app automatically when you log in") - .active(record.autostart) - .build(); -``` - -Connect the switch to `enable_autostart`/`disable_autostart`. - -**Step 4: Build, verify, commit** - -```bash -git add src/core/integrator.rs src/core/database.rs src/ui/detail_view.rs -git commit -m "Add autostart manager with XDG autostart support" -``` - ---- - -## Task 10: System Notification Integration (F9) - -**Files:** -- Modify: `src/core/notification.rs` -- Modify: `src/window.rs` - -**Step 1: Add gio::Notification helpers** - -```rust -pub fn send_system_notification(app: >k::gio::Application, id: &str, title: &str, body: &str, priority: gio::NotificationPriority) { - let notification = gio::Notification::new(title); - notification.set_body(Some(body)); - notification.set_priority(priority); - app.send_notification(Some(id), ¬ification); -} -``` - -**Step 2: Use for crash, update, and security events** - -In window.rs launch handler, on crash: -```rust -notification::send_system_notification( - &app, "crash", &format!("{} crashed", app_name), - &stderr, gio::NotificationPriority::Urgent, -); -``` - -Similarly for update checks and security findings. - -**Step 3: Build, verify, commit** - -```bash -git add src/core/notification.rs src/window.rs -git commit -m "Add system notification support for crashes and updates" -``` - ---- - -## Task 11: Storage Dashboard per App (F10) - -**Files:** -- Modify: `src/core/footprint.rs` -- Modify: `src/ui/detail_view.rs` -- Modify: `src/core/database.rs` - -**Step 1: Add FootprintSummary struct and query** - -```rust -#[derive(Debug, Clone, Default)] -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, -} - -pub fn get_footprint_summary(db: &Database, record_id: i64, binary_size: u64) -> FootprintSummary { - let paths = db.get_data_paths(record_id).unwrap_or_default(); - let mut summary = FootprintSummary { binary_size, ..Default::default() }; - for p in &paths { - let size = p.size_bytes.unwrap_or(0) as u64; - match p.path_type.as_str() { - "config" => summary.config_size += size, - "cache" => summary.cache_size += size, - "data" => summary.data_size += size, - "state" => summary.state_size += size, - _ => {} - } - } - summary.total_size = summary.binary_size + summary.config_size + summary.cache_size + summary.data_size + summary.state_size; - summary -} -``` - -**Step 2: Display in detail view storage tab** - -Show each category with human-readable size and path. Add "Clean cache" button. - -**Step 3: Build, verify, commit** - -```bash -git add src/core/footprint.rs src/ui/detail_view.rs src/core/database.rs -git commit -m "Add per-app storage breakdown to detail view" -``` - ---- - -## Task 12: Background Update Checks (F11) - -**Files:** -- Modify: `data/app.driftwood.Driftwood.gschema.xml` -- Modify: `src/window.rs` -- Modify: `src/ui/dashboard.rs` -- Modify: `src/ui/preferences.rs` - -**Step 1: Add GSettings keys** - -```xml - - - 24 - Update check interval - Hours between automatic update checks. - - - '' - Last update check timestamp - ISO timestamp of the last automatic update check. - -``` - -**Step 2: Check on startup in window.rs** - -After initial scan, if `auto-check-updates` is true and enough time has elapsed since `last-update-check`, spawn background update check for all apps. - -**Step 3: Show results on dashboard** - -"X updates available - Last checked: 2h ago" - -**Step 4: Add interval setting to preferences** - -ComboRow or SpinRow for the interval. - -**Step 5: Build, verify, commit** - -```bash -git add data/app.driftwood.Driftwood.gschema.xml src/window.rs src/ui/dashboard.rs src/ui/preferences.rs -git commit -m "Add background update checking with configurable interval" -``` - ---- - -## Task 13: One-Click Update All (F12) - -**Files:** -- Create: `src/ui/batch_update_dialog.rs` -- Modify: `src/ui/mod.rs` -- Modify: `src/ui/dashboard.rs` -- Modify: `src/core/updater.rs` - -**Step 1: Create batch_update_dialog.rs** - -Dialog showing list of apps with updates, progress bars, cancel button. Uses existing `updater::download_update()` per app. Saves old version as .prev (integrates with F3 rollback). - -**Step 2: Add "Update All" button to dashboard** - -Only visible when updates are available. Opens the batch update dialog. - -**Step 3: Build, verify, commit** - -```bash -git add src/ui/batch_update_dialog.rs src/ui/mod.rs src/ui/dashboard.rs -git commit -m "Add one-click Update All with batch progress dialog" -``` - ---- - -## Task 14: Full Uninstall with Data Cleanup (F13) - -**Files:** -- Modify: `src/ui/detail_view.rs` -- Modify: `src/core/footprint.rs` - -**Step 1: Replace simple delete with full uninstall dialog** - -When user clicks delete on an AppImage: - -1. Get `FootprintSummary` (from Task 11) -2. Show dialog with checkboxes: - - [x] AppImage file (size) - - [x] Desktop integration (if integrated) - - [x] Configuration (path - size) - - [x] Cache (path - size) - - [x] Data (path - size) -3. On confirm: - - `undo_all_modifications(db, record_id)` - removes .desktop, icons, autostart, MIME - - Delete checked data paths - - Delete AppImage file - - `db.delete_appimage(record_id)` - -**Step 2: Build, verify, commit** - -```bash -git add src/ui/detail_view.rs src/core/footprint.rs -git commit -m "Add full uninstall with data cleanup options" -``` - ---- - -## Task 15: Icon Preview in Drop Dialog (F14) - -**Files:** -- Modify: `src/core/inspector.rs` -- Modify: `src/ui/drop_dialog.rs` - -**Step 1: Add fast icon extraction** - -```rust -pub fn extract_icon_fast(path: &Path) -> Option { - // Use unsquashfs to list files, find icon, extract just that one - let output = Command::new("unsquashfs") - .args(["-l", &path.to_string_lossy()]) - .output().ok()?; - // Parse output for .png or .svg icon, extract to temp dir - // Return path to extracted icon -} -``` - -**Step 2: Show preview in drop dialog after registration** - -After Phase 1 registration completes, update the toast to include a small preview if icon was found. - -**Step 3: Build, verify, commit** - -```bash -git add src/core/inspector.rs src/ui/drop_dialog.rs -git commit -m "Add icon preview to drag-and-drop dialog" -``` - ---- - -## Task 16: FUSE Fix Wizard (F15) - -**Files:** -- Create: `src/ui/fuse_wizard.rs` -- Modify: `src/ui/mod.rs` -- Modify: `src/core/fuse.rs` -- Modify: `src/ui/dashboard.rs` -- Create: `data/app.driftwood.Driftwood.policy` - -**Step 1: Add distro detection to fuse.rs** - -```rust -pub struct DistroInfo { - pub id: String, - pub id_like: Vec, - pub version_id: String, -} - -pub fn detect_distro() -> Option { - let content = std::fs::read_to_string("/etc/os-release").ok()?; - // Parse ID=, ID_LIKE=, VERSION_ID= -} - -pub fn get_fuse_install_command(distro: &DistroInfo) -> Option { - let family = if distro.id == "ubuntu" || distro.id_like.contains(&"ubuntu".to_string()) || distro.id_like.contains(&"debian".to_string()) { - "debian" - } else if distro.id == "fedora" || distro.id_like.contains(&"fedora".to_string()) || distro.id_like.contains(&"rhel".to_string()) { - "fedora" - } else if distro.id == "arch" || distro.id_like.contains(&"arch".to_string()) { - "arch" - } else if distro.id == "opensuse" || distro.id.starts_with("opensuse") { - "suse" - } else { - return None; - }; - - Some(match family { - "debian" => "apt install -y libfuse2t64 || apt install -y libfuse2".to_string(), - "fedora" => "dnf install -y fuse-libs".to_string(), - "arch" => "pacman -S --noconfirm fuse2".to_string(), - "suse" => "zypper install -y libfuse2".to_string(), - _ => return None, - }) -} -``` - -**Step 2: Create polkit policy file** - -`data/app.driftwood.Driftwood.policy` - standard polkit XML for privileged install. - -**Step 3: Create fuse_wizard.rs** - -Multi-step dialog: explain -> show command -> run via pkexec -> verify. - -**Step 4: Add FUSE banner to dashboard** - -When FUSE is missing, show a yellow info banner with "Fix now" button. - -**Step 5: Build, verify, commit** - -```bash -git add src/ui/fuse_wizard.rs src/ui/mod.rs src/core/fuse.rs src/ui/dashboard.rs data/app.driftwood.Driftwood.policy -git commit -m "Add FUSE fix wizard with distro detection and pkexec install" -``` - ---- - -## Task 17: File Type Association Manager (F16) - -**Files:** -- Modify: `src/core/integrator.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Parse MimeType from desktop entry** - -```rust -pub fn parse_mime_types(desktop_entry: &str) -> Vec { - for line in desktop_entry.lines() { - if line.starts_with("MimeType=") { - return line[9..].split(';') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - } - } - Vec::new() -} -``` - -**Step 2: Include MimeType in generated .desktop files** - -When integrating, if the original desktop entry has MimeType, include it. - -**Step 3: Add file type section to detail view** - -Show list of MIME types with "Set as default" toggle per type. Before setting, query current default with `xdg-mime query default {type}`, store in system_modifications. - -**Step 4: Build, verify, commit** - -```bash -git add src/core/integrator.rs src/ui/detail_view.rs -git commit -m "Add file type association manager with MIME type support" -``` - ---- - -## Task 18: Taskbar Icon Fix - StartupWMClass (F17) - -**Files:** -- Modify: `src/core/integrator.rs` -- Modify: `src/core/inspector.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Extract StartupWMClass during inspection** - -Parse `StartupWMClass=` from embedded .desktop entry. Store in the new `startup_wm_class` DB column. - -**Step 2: Include in generated .desktop files** - -Add `StartupWMClass={value}` to generated desktop entries when available. - -**Step 3: Show in detail view with manual override option** - -**Step 4: Build, verify, commit** - -```bash -git add src/core/integrator.rs src/core/inspector.rs src/ui/detail_view.rs -git commit -m "Extract and apply StartupWMClass for proper taskbar icons" -``` - ---- - -## Task 19: Download Verification Helper (F18) - -**Files:** -- Create: `src/core/verification.rs` -- Modify: `src/core/mod.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Create verification module** - -```rust -pub enum VerificationStatus { - SignedValid { signer: String }, - SignedInvalid { reason: String }, - Unsigned, - ChecksumMatch, - ChecksumMismatch, - NotChecked, -} - -pub fn check_embedded_signature(path: &Path) -> VerificationStatus { - // Try --appimage-signature flag - let output = Command::new(path) - .arg("--appimage-signature") - .output(); - // Parse result -} - -pub fn compute_sha256(path: &Path) -> Result { - use sha2::{Sha256, Digest}; - // Read file and compute hash -} - -pub fn verify_sha256(path: &Path, expected: &str) -> VerificationStatus { - match compute_sha256(path) { - Ok(hash) if hash == expected.to_lowercase() => VerificationStatus::ChecksumMatch, - Ok(_) => VerificationStatus::ChecksumMismatch, - Err(_) => VerificationStatus::NotChecked, - } -} -``` - -**Step 2: Display in detail view** - -Show verification badge with status. Add manual SHA256 input field. - -**Step 3: Build, verify, commit** - -```bash -git add src/core/verification.rs src/core/mod.rs src/ui/detail_view.rs -git commit -m "Add download verification with signature and SHA256 support" -``` - ---- - -## Task 20: First-Run Permission Summary (F19) - -**Files:** -- Create: `src/ui/permission_dialog.rs` -- Modify: `src/ui/mod.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Create permission dialog** - -Shows before first launch: "This app will run with full system access" with sandbox option if firejail is available. - -**Step 2: Wire into launch handler** - -Before launching, check `first_run_prompted`. If false, show dialog first. - -**Step 3: Build, verify, commit** - -```bash -git add src/ui/permission_dialog.rs src/ui/mod.rs src/ui/detail_view.rs -git commit -m "Add first-run permission summary dialog" -``` - ---- - -## Task 21: Default App Selector (F20) - -**Files:** -- Modify: `src/core/integrator.rs` -- Modify: `src/ui/detail_view.rs` - -**Step 1: Detect app capabilities from categories** - -```rust -pub fn detect_default_capabilities(categories: &str) -> Vec { - let mut caps = Vec::new(); - if categories.contains("WebBrowser") { caps.push(DefaultAppType::WebBrowser); } - if categories.contains("Email") { caps.push(DefaultAppType::EmailClient); } - if categories.contains("FileManager") { caps.push(DefaultAppType::FileManager); } - caps -} -``` - -**Step 2: Add "Set as default" buttons** - -For each capability, show a button. On click: query current default, store in system_modifications, set new default via xdg-settings/xdg-mime. - -**Step 3: Build, verify, commit** - -```bash -git add src/core/integrator.rs src/ui/detail_view.rs -git commit -m "Add default application selector for browsers and mail clients" -``` - ---- - -## Task 22: Multi-User System-Wide Install (F21) - -**Files:** -- Modify: `src/core/integrator.rs` -- Modify: `src/ui/detail_view.rs` -- Modify: `data/app.driftwood.Driftwood.policy` - -**Step 1: Add system-wide install function** - -Uses pkexec to copy to `/opt/driftwood-apps/` and `.desktop` to `/usr/share/applications/`. - -**Step 2: Add polkit action for system install** - -Add `app.driftwood.Driftwood.system-install` action to policy file. - -**Step 3: Add UI toggle in detail view** - -"Install system-wide" button, requires integration first. - -**Step 4: Track all system paths in system_modifications** - -**Step 5: Build, verify, commit** - -```bash -git add src/core/integrator.rs src/ui/detail_view.rs data/app.driftwood.Driftwood.policy -git commit -m "Add system-wide installation via pkexec" -``` - ---- - -## Task 23: CLI Enhancements (F22) - -**Files:** -- Modify: `src/cli.rs` - -**Step 1: Add new commands** - -- `driftwood install ` - download, validate, register, integrate -- `driftwood update --all` - update all apps with available updates -- `driftwood autostart --enable/--disable` -- `driftwood purge` - remove all system modifications -- `driftwood verify ` - check signature/hash - -**Step 2: Implement each command** - -`purge` calls `db.get_all_modifications()` and undoes each one. - -**Step 3: Build, verify, commit** - -```bash -git add src/cli.rs -git commit -m "Add install, update-all, autostart, purge, and verify CLI commands" -``` - ---- - -## Task 24: Portable Mode / USB Drive Support (F23) - -**Files:** -- Create: `src/core/portable.rs` -- Modify: `src/core/mod.rs` -- Modify: `src/ui/library_view.rs` -- Modify: `src/window.rs` -- Modify: `data/app.driftwood.Driftwood.gschema.xml` - -**Step 1: Add GSettings key** - -```xml - - false - Watch removable media - Scan removable drives for AppImages when mounted. - -``` - -**Step 2: Create portable.rs** - -```rust -pub struct MountInfo { - pub device: String, - pub mount_point: PathBuf, - pub fs_type: String, -} - -pub fn detect_removable_mounts() -> Vec { - // Parse /proc/mounts for media/run/media paths with vfat/exfat/ntfs -} - -pub fn is_path_on_removable(path: &Path) -> bool { - let mounts = detect_removable_mounts(); - mounts.iter().any(|m| path.starts_with(&m.mount_point)) -} -``` - -**Step 3: Add "Portable" filter to library view** - -**Step 4: Watch for mount/unmount events** - -Use `gio::VolumeMonitor` or poll `/proc/mounts`. - -**Step 5: Build, verify, commit** - -```bash -git add src/core/portable.rs src/core/mod.rs src/ui/library_view.rs src/window.rs data/app.driftwood.Driftwood.gschema.xml -git commit -m "Add portable mode with removable media detection" -``` - ---- - -## Task 25: Similar App Recommendations (F24) - -**Files:** -- Modify: `src/ui/detail_view.rs` -- Modify: `src/core/database.rs` - -**Step 1: Add catalog query for similar apps** - -```rust -pub fn find_similar_catalog_apps(&self, categories: &str, exclude_name: &str, limit: i32) -> SqlResult> { - // Match on shared categories, exclude current app -} -``` - -**Step 2: Show "You might also like" section in detail view** - -Only shown when catalog has data (after F26). - -**Step 3: Build, verify, commit** - -```bash -git add src/ui/detail_view.rs src/core/database.rs -git commit -m "Add similar app recommendations from catalog" -``` - ---- - -## Task 26: AppImageHub In-App Catalog Browser (F25) - -**Files:** -- Create: `src/ui/catalog_view.rs` -- Create: `src/ui/catalog_detail.rs` -- Modify: `src/ui/mod.rs` -- Modify: `src/core/database.rs` -- Modify: `src/window.rs` -- Modify: `data/app.driftwood.Driftwood.gschema.xml` - -This is the largest feature. Break into sub-steps: - -**Step 1: Add GSettings key** - -```xml - - '' - Catalog last refreshed - ISO timestamp of the last catalog refresh. - -``` - -**Step 2: Add catalog DB methods** - -- `upsert_catalog_source(name, url, source_type) -> i64` -- `upsert_catalog_app(source_id, name, description, categories, download_url, icon_url, homepage, license) -> i64` -- `search_catalog(query: &str, category: Option<&str>, limit: i32) -> Vec` -- `get_catalog_app(id: i64) -> Option` -- `get_catalog_categories() -> Vec<(String, u32)>` - categories with counts - -Add `CatalogApp` struct: -```rust -pub struct CatalogApp { - pub id: i64, - pub name: String, - pub description: Option, - pub categories: Option, - pub download_url: String, - pub icon_url: Option, - pub homepage: Option, - pub license: Option, - pub author: Option, -} -``` - -**Step 3: Add feed.json fetcher** - -```rust -pub fn refresh_catalog(db: &Database) -> Result { - let body = reqwest::blocking::get("https://appimage.github.io/feed.json") - .map_err(|e| format!("Failed to fetch catalog: {}", e))? - .text() - .map_err(|e| format!("Failed to read response: {}", e))?; - - let feed: serde_json::Value = serde_json::from_str(&body) - .map_err(|e| format!("Failed to parse feed: {}", e))?; - - let source_id = db.upsert_catalog_source("AppImageHub", "https://appimage.github.io/feed.json", "feed_json") - .map_err(|e| format!("DB error: {}", e))?; - - let items = feed["items"].as_array().ok_or("No items in feed")?; - let mut count = 0; - - for item in items { - let name = item["name"].as_str().unwrap_or_default(); - if name.is_empty() { continue; } - - let description = item["description"].as_str().map(|s| s.to_string()); - let categories = item["categories"].as_array() - .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>().join(";")); - - let download_url = item["links"].as_array() - .and_then(|links| links.iter().find(|l| l["type"].as_str() == Some("Download"))) - .and_then(|l| l["url"].as_str()) - .unwrap_or_default(); - - let icon_url = item["icons"].as_array() - .and_then(|icons| icons.first()) - .and_then(|i| i.as_str()) - .map(|s| format!("https://appimage.github.io/database/{}", s)); - - let homepage = item["links"].as_array() - .and_then(|links| links.iter().find(|l| l["type"].as_str() == Some("GitHub"))) - .and_then(|l| l["url"].as_str()) - .map(|s| if s.contains("://") { s.to_string() } else { format!("https://github.com/{}", s) }); - - let license = item["license"].as_str().map(|s| s.to_string()); - let author = item["authors"].as_array() - .and_then(|a| a.first()) - .and_then(|a| a["name"].as_str()) - .map(|s| s.to_string()); - - if download_url.is_empty() { continue; } - - db.upsert_catalog_app( - source_id, name, - description.as_deref(), categories.as_deref(), - download_url, icon_url.as_deref(), homepage.as_deref(), license.as_deref(), - ).ok(); - - count += 1; - } - - Ok(count) -} -``` - -Note: Check if `reqwest` is already a dependency. If not, use `gio` or `soup` for HTTP. Or use `std::process::Command` to call `curl`. - -**Step 4: Create catalog_view.rs** - -NavigationPage with: -- Search entry at top -- Category filter chips (horizontal scrollable) -- Grid of catalog app cards -- Each card: icon, name, short description -- Click pushes catalog_detail page - -**Step 5: Create catalog_detail.rs** - -NavigationPage with: -- Large icon -- App name, author, license -- Full description (rendered from HTML to plain text) -- Screenshots if available -- "Install" button (suggested style) -- Source link button - -**Step 6: Wire catalog install flow** - -Install button: -1. Resolve download URL (for GitHub, fetch latest release API) -2. Download AppImage with progress -3. Validate magic bytes -4. Move to ~/Applications, set executable -5. Register in DB with `source_url` -6. Run analysis pipeline -7. Optionally integrate -8. Navigate to library detail view - -**Step 7: Add "Catalog" to main navigation** - -In `window.rs`, add Catalog entry to the navigation. Can be a tab or sidebar item. - -**Step 8: Build, verify, commit** - -```bash -git add src/ui/catalog_view.rs src/ui/catalog_detail.rs src/ui/mod.rs src/core/database.rs src/window.rs data/app.driftwood.Driftwood.gschema.xml -git commit -m "Add AppImageHub in-app catalog browser with search and install" -``` - ---- - -## Verification Checklist - -After all tasks are complete: - -1. `cargo build` - zero errors, zero warnings -2. `cargo run` - app launches, all features accessible -3. Test each feature: - - Drag-drop with all 3 options - - Integrate then fully uninstall - verify no leftover files - - Autostart toggle on/off - verify .desktop created/removed - - FUSE wizard (if FUSE missing) - - Catalog search and install - - Version rollback after update -4. `driftwood purge` - removes all system modifications cleanly diff --git a/docs/plans/2026-02-27-ui-ux-overhaul-design.md b/docs/plans/2026-02-27-ui-ux-overhaul-design.md deleted file mode 100644 index a015c48..0000000 --- a/docs/plans/2026-02-27-ui-ux-overhaul-design.md +++ /dev/null @@ -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 diff --git a/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md b/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md deleted file mode 100644 index edf3dd6..0000000 --- a/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md +++ /dev/null @@ -1,1885 +0,0 @@ -# UI/UX Overhaul Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Make Driftwood look and feel like a first-class GNOME app by overhauling cards, list rows, detail view, CSS, and adding right-click context menus. - -**Architecture:** Replace custom `.app-card` CSS with libadwaita `.card` + `.activatable`. Use `adw::ViewStack`/`adw::ViewSwitcher` to split the detail view into 4 tabs. Add `gio::Menu`-driven context menus attached via `GtkGestureClick` on cards and rows. All changes are in 5 files: `style.css`, `app_card.rs`, `library_view.rs`, `detail_view.rs`, `window.rs`. - -**Tech Stack:** Rust, gtk4-rs 0.11 (v4_18), libadwaita-rs 0.9 (v1_8), GTK CSS - ---- - -### Task 1: CSS Foundation - Remove .app-card, Add New Classes - -**Files:** -- Modify: `data/resources/style.css` - -**Step 1: Update style.css** - -Replace the entire `.app-card` block (lines 69-91), the dark mode `.app-card` block (lines 152-167), and the high contrast `.app-card` block (lines 171-177) with new minimal classes. Also update the `.detail-banner` block (lines 133-136). - -Remove these CSS blocks entirely: -- `.app-card` (lines 69-85) -- `flowboxchild:focus-visible .app-card` (lines 87-91) -- Dark mode `.app-card` overrides (lines 153-166) -- High contrast `.app-card` and `flowboxchild:focus-visible .app-card` overrides (lines 171-177) - -Add these new CSS rules in their place: - -```css -/* ===== Card View (using libadwaita .card) ===== */ -flowboxchild:focus-visible .card { - outline: 2px solid @accent_bg_color; - outline-offset: 3px; -} - -/* 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; -} - -/* Inline ViewSwitcher positioning */ -.detail-view-switcher { - margin-top: 6px; - margin-bottom: 6px; -} -``` - -Update the high contrast section to reference `.card` instead of `.app-card`: - -```css -/* In high contrast section: */ -flowboxchild:focus-visible .card { - outline-width: 3px; -} -``` - -**Step 2: Build to verify CSS compiles** - -Run: `cargo build 2>&1 | head -5` -Expected: Compiles (CSS is loaded at runtime, not compile-checked, but Rust code referencing removed classes will break in later tasks) - -**Step 3: Commit** - -```bash -git add data/resources/style.css -git commit -m "style: replace .app-card CSS with libadwaita .card classes - -Remove custom .app-card hover/active/dark/contrast rules. Add -.icon-rounded for list view icons, gradient .detail-banner, and -.detail-view-switcher for tabbed detail layout." -``` - ---- - -### Task 2: Card View Overhaul - -**Files:** -- Modify: `src/ui/app_card.rs` - -**Step 1: Rewrite build_app_card function** - -Replace the entire `build_app_card` function body (lines 10-124) with: - -```rust -pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { - let card = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(6) - .margin_top(14) - .margin_bottom(14) - .margin_start(14) - .margin_end(14) - .halign(gtk::Align::Center) - .build(); - card.add_css_class("card"); - card.set_size_request(200, -1); - - // Icon (72x72) with integration emblem overlay - let name = record.app_name.as_deref().unwrap_or(&record.filename); - let icon_widget = widgets::app_icon( - record.icon_path.as_deref(), - name, - 72, - ); - icon_widget.add_css_class("icon-dropshadow"); - - // If integrated, overlay a small checkmark emblem - if record.integrated { - let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&icon_widget)); - - 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); - } - - // App name - use .title-3 for more visual weight - let name_label = gtk::Label::builder() - .label(name) - .css_classes(["title-3"]) - .ellipsize(gtk::pango::EllipsizeMode::End) - .max_width_chars(20) - .build(); - - // Version + size combined on one line - let version_text = record.app_version.as_deref().unwrap_or(""); - let size_text = widgets::format_size(record.size_bytes); - let meta_text = if version_text.is_empty() { - size_text - } else { - format!("{} - {}", version_text, size_text) - }; - let meta_label = gtk::Label::builder() - .label(&meta_text) - .css_classes(["caption", "dimmed", "numeric"]) - .ellipsize(gtk::pango::EllipsizeMode::End) - .build(); - - card.append(&name_label); - card.append(&meta_label); - - // Single most important badge (priority: Update > FUSE issue > Wayland issue) - if let Some(badge) = build_priority_badge(record) { - let badge_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .halign(gtk::Align::Center) - .margin_top(4) - .build(); - badge_box.append(&badge); - card.append(&badge_box); - } - - let child = gtk::FlowBoxChild::builder() - .child(&card) - .build(); - child.add_css_class("activatable"); - - // Accessible label for screen readers - let accessible_name = build_accessible_label(record); - child.update_property(&[AccessibleProperty::Label(&accessible_name)]); - - child -} -``` - -**Step 2: Add the priority badge helper function** - -Add this new function after `build_app_card`, before `build_accessible_label`: - -```rust -/// Return the single most important badge for a card. -/// Priority: Update available > FUSE issue > Wayland issue. -fn build_priority_badge(record: &AppImageRecord) -> Option { - // 1. Update available (highest priority) - if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { - if crate::core::updater::version_is_newer(latest, current) { - return Some(widgets::status_badge("Update", "info")); - } - } - - // 2. FUSE issue - if let Some(ref fs) = record.fuse_status { - let status = FuseStatus::from_str(fs); - if !status.is_functional() { - return Some(widgets::status_badge(status.label(), status.badge_class())); - } - } - - // 3. Wayland issue (not Native or Unknown) - if let Some(ref ws) = record.wayland_status { - let status = WaylandStatus::from_str(ws); - if status != WaylandStatus::Unknown && status != WaylandStatus::Native { - return Some(widgets::status_badge(status.label(), status.badge_class())); - } - } - - None -} -``` - -**Step 3: Build and test** - -Run: `cargo build 2>&1 | tail -3` -Expected: Compiles successfully - -Run: `cargo test 2>&1 | tail -5` -Expected: All tests pass - -**Step 4: Commit** - -```bash -git add src/ui/app_card.rs -git commit -m "ui: overhaul card view with larger icons and single badge - -72px icons with .icon-dropshadow, .title-3 for app names, version+size -on one combined line, single priority badge (Update > FUSE > Wayland), -libadwaita .card class replacing custom .app-card, 200px card width." -``` - ---- - -### Task 3: Library View - FlowBox and List Row Changes - -**Files:** -- Modify: `src/ui/library_view.rs` - -**Step 1: Update FlowBox settings** - -In the `new()` method, change the FlowBox builder (around line 196) from: - -```rust - let flow_box = gtk::FlowBox::builder() - .valign(gtk::Align::Start) - .selection_mode(gtk::SelectionMode::None) - .homogeneous(true) - .min_children_per_line(2) - .max_children_per_line(6) - .row_spacing(12) - .column_spacing(12) - .margin_top(12) - .margin_bottom(12) - .margin_start(12) - .margin_end(12) - .build(); -``` - -to: - -```rust - let flow_box = gtk::FlowBox::builder() - .valign(gtk::Align::Start) - .selection_mode(gtk::SelectionMode::None) - .homogeneous(true) - .min_children_per_line(2) - .max_children_per_line(4) - .row_spacing(14) - .column_spacing(14) - .margin_top(14) - .margin_bottom(14) - .margin_start(14) - .margin_end(14) - .build(); -``` - -Changes: max 4 columns (from 6), 14px spacing (from 12) to match GNOME HIG. - -**Step 2: Add .rich-list to ListBox** - -In the `new()` method, after the ListBox builder (around line 221), change: - -```rust - list_box.add_css_class("boxed-list"); -``` - -to: - -```rust - list_box.add_css_class("boxed-list"); - list_box.add_css_class("rich-list"); -``` - -**Step 3: Rewrite build_list_row for structured subtitle and single badge** - -Replace the entire `build_list_row` method (lines 459-543) with: - -```rust - fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow { - let name = record.app_name.as_deref().unwrap_or(&record.filename); - - // Structured two-line subtitle: - // Line 1: Description snippet or file path (dimmed) - // Line 2: Version + size (caption, dimmed, numeric) - let line1 = if let Some(ref desc) = record.description { - if !desc.is_empty() { - let snippet: String = desc.chars().take(60).collect(); - if snippet.len() < desc.len() { - format!("{}...", snippet.trim_end()) - } else { - snippet - } - } else { - record.path.clone() - } - } else { - record.path.clone() - }; - - let mut meta_parts = Vec::new(); - if let Some(ref ver) = record.app_version { - meta_parts.push(ver.clone()); - } - meta_parts.push(widgets::format_size(record.size_bytes)); - let line2 = meta_parts.join(" - "); - - let subtitle = format!("{}\n{}", line1, line2); - - let row = adw::ActionRow::builder() - .title(name) - .subtitle(&subtitle) - .subtitle_lines(2) - .activatable(true) - .build(); - - // Icon prefix (48x48 with rounded clipping and letter fallback) - let icon = widgets::app_icon( - record.icon_path.as_deref(), - name, - 48, - ); - icon.add_css_class("icon-rounded"); - row.add_prefix(&icon); - - // 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); - row.add_suffix(&badge); - } - - // Navigate arrow - let arrow = gtk::Image::from_icon_name("go-next-symbolic"); - row.add_suffix(&arrow); - - row - } -``` - -**Step 4: Make build_priority_badge public in app_card.rs** - -In `src/ui/app_card.rs`, change: - -```rust -fn build_priority_badge(record: &AppImageRecord) -> Option { -``` - -to: - -```rust -pub fn build_priority_badge(record: &AppImageRecord) -> Option { -``` - -**Step 5: Build and test** - -Run: `cargo build 2>&1 | tail -3` -Expected: Compiles successfully - -Run: `cargo test 2>&1 | tail -5` -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add src/ui/library_view.rs src/ui/app_card.rs -git commit -m "ui: overhaul list view and FlowBox grid settings - -FlowBox max 4 columns with 14px spacing. ListBox gets .rich-list class. -List rows use 48px rounded icons, structured two-line subtitle (desc + -version/size), single priority badge instead of all badges." -``` - ---- - -### Task 4: Detail View - Tabbed Layout with ViewStack - -**Files:** -- Modify: `src/ui/detail_view.rs` - -This is the largest task. The detail view currently has one scrolling page with 3 PreferencesGroups. We restructure it into a hero banner + 4-tab ViewStack (Overview, System, Security, Storage). - -**Step 1: Rewrite build_detail_page to use ViewStack** - -Replace the `build_detail_page` function (lines 19-156) with: - -```rust -pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::NavigationPage { - let name = record.app_name.as_deref().unwrap_or(&record.filename); - - // Toast overlay for copy actions - let toast_overlay = adw::ToastOverlay::new(); - - // Main content container - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - - // Hero banner (not scrolled, always visible at top) - let banner = build_banner(record); - content.append(&banner); - - // ViewSwitcher (tab bar) - inline style, between banner and tab content - let view_stack = adw::ViewStack::new(); - - let switcher = adw::ViewSwitcher::builder() - .stack(&view_stack) - .policy(adw::ViewSwitcherPolicy::Wide) - .build(); - switcher.add_css_class("inline"); - switcher.add_css_class("detail-view-switcher"); - content.append(&switcher); - - // Build tab pages - let overview_page = build_overview_tab(record, db); - view_stack.add_titled(&overview_page, Some("overview"), "Overview"); - if let Some(page) = view_stack.page(&overview_page) { - page.set_icon_name(Some("info-symbolic")); - } - - let system_page = build_system_tab(record, db); - view_stack.add_titled(&system_page, Some("system"), "System"); - if let Some(page) = view_stack.page(&system_page) { - page.set_icon_name(Some("system-run-symbolic")); - } - - let security_page = build_security_tab(record, db); - view_stack.add_titled(&security_page, Some("security"), "Security"); - if let Some(page) = view_stack.page(&security_page) { - page.set_icon_name(Some("security-medium-symbolic")); - } - - let storage_page = build_storage_tab(record, db, &toast_overlay); - view_stack.add_titled(&storage_page, Some("storage"), "Storage"); - if let Some(page) = view_stack.page(&storage_page) { - page.set_icon_name(Some("drive-harddisk-symbolic")); - } - - // Scrollable area for tab content - let scrolled = gtk::ScrolledWindow::builder() - .child(&view_stack) - .vexpand(true) - .build(); - content.append(&scrolled); - - toast_overlay.set_child(Some(&content)); - - // Header bar with per-app actions - let header = adw::HeaderBar::new(); - - let launch_button = gtk::Button::builder() - .label("Launch") - .tooltip_text("Launch this AppImage") - .build(); - launch_button.add_css_class("suggested-action"); - launch_button.update_property(&[ - gtk::accessible::Property::Label("Launch application"), - ]); - let record_id = record.id; - let path = record.path.clone(); - let db_launch = db.clone(); - launch_button.connect_clicked(move |_| { - let appimage_path = std::path::Path::new(&path); - let result = launcher::launch_appimage( - &db_launch, - record_id, - appimage_path, - "gui_detail", - &[], - &[], - ); - match result { - launcher::LaunchResult::Started { child, method } => { - let pid = child.id(); - log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str()); - - let db_wayland = db_launch.clone(); - let path_clone = path.clone(); - glib::spawn_future_local(async move { - glib::timeout_future(std::time::Duration::from_secs(3)).await; - - let analysis_result = gio::spawn_blocking(move || { - wayland::analyze_running_process(pid) - }).await; - - match analysis_result { - Ok(Ok(analysis)) => { - let status_label = analysis.status_label(); - let status_str = analysis.as_status_str(); - log::info!( - "Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})", - path_clone, analysis.pid, status_label, - analysis.has_wayland_socket, - analysis.has_x11_connection, - analysis.env_vars.len(), - ); - db_wayland.update_runtime_wayland_status( - record_id, status_str, - ).ok(); - } - Ok(Err(e)) => { - log::debug!("Runtime analysis failed for PID {}: {}", pid, e); - } - Err(_) => { - log::debug!("Runtime analysis task failed for PID {}", pid); - } - } - }); - } - launcher::LaunchResult::Failed(msg) => { - log::error!("Failed to launch: {}", msg); - } - } - }); - header.pack_end(&launch_button); - - let update_button = gtk::Button::builder() - .icon_name("software-update-available-symbolic") - .tooltip_text("Check for updates") - .build(); - update_button.update_property(&[ - gtk::accessible::Property::Label("Check for updates"), - ]); - let record_for_update = record.clone(); - let db_update = db.clone(); - update_button.connect_clicked(move |btn| { - update_dialog::show_update_dialog(btn, &record_for_update, &db_update); - }); - header.pack_end(&update_button); - - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&toast_overlay)); - - adw::NavigationPage::builder() - .title(name) - .tag("detail") - .child(&toolbar) - .build() -} -``` - -**Step 2: Update the banner to use 96px icon** - -Replace the `build_banner` function (lines 158-247) with: - -```rust -fn build_banner(record: &AppImageRecord) -> gtk::Box { - let banner = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(16) - .margin_start(18) - .margin_end(18) - .build(); - banner.add_css_class("detail-banner"); - banner.set_accessible_role(gtk::AccessibleRole::Banner); - - let name = record.app_name.as_deref().unwrap_or(&record.filename); - - // Large icon (96x96) with drop shadow - let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96); - icon.set_valign(gtk::Align::Start); - icon.add_css_class("icon-dropshadow"); - banner.append(&icon); - - // Text column - let text_col = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(4) - .valign(gtk::Align::Center) - .build(); - - let name_label = gtk::Label::builder() - .label(name) - .css_classes(["title-1"]) - .halign(gtk::Align::Start) - .build(); - text_col.append(&name_label); - - // Version + architecture inline - let meta_parts: Vec = [ - record.app_version.as_deref().map(|v| v.to_string()), - record.architecture.as_deref().map(|a| a.to_string()), - ] - .iter() - .filter_map(|p| p.clone()) - .collect(); - - if !meta_parts.is_empty() { - let meta_label = gtk::Label::builder() - .label(&meta_parts.join(" - ")) - .css_classes(["dimmed"]) - .halign(gtk::Align::Start) - .build(); - text_col.append(&meta_label); - } - - // Description - if let Some(ref desc) = record.description { - if !desc.is_empty() { - let desc_label = gtk::Label::builder() - .label(desc) - .css_classes(["body"]) - .halign(gtk::Align::Start) - .wrap(true) - .xalign(0.0) - .build(); - text_col.append(&desc_label); - } - } - - // Key status badges inline - let badge_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .margin_top(4) - .build(); - - if record.integrated { - badge_box.append(&widgets::status_badge("Integrated", "success")); - } - - if let Some(ref ws) = record.wayland_status { - let status = WaylandStatus::from_str(ws); - if status != WaylandStatus::Unknown { - badge_box.append(&widgets::status_badge(status.label(), status.badge_class())); - } - } - - if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { - if crate::core::updater::version_is_newer(latest, current) { - badge_box.append(&widgets::status_badge("Update available", "info")); - } - } - - text_col.append(&badge_box); - banner.append(&text_col); - banner -} -``` - -**Step 3: Create the 4 tab builder functions** - -Replace `build_system_integration_group` (lines 249-471), `build_updates_usage_group` (lines 493-580), and `build_security_storage_group` (lines 582-878) with these 4 new functions. Keep the `wayland_description` and `fuse_description` helper functions. - -**Tab 1 - Overview:** - -```rust -/// Tab 1: Overview - most commonly needed info at a glance -fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { - let tab = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .margin_top(18) - .margin_bottom(24) - .margin_start(18) - .margin_end(18) - .build(); - - let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) - .build(); - - let inner = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .build(); - - // Updates section - let updates_group = adw::PreferencesGroup::builder() - .title("Updates") - .build(); - - if let Some(ref update_type) = record.update_type { - let display_label = updater::parse_update_info(update_type) - .map(|ut| ut.type_label_display()) - .unwrap_or("Unknown format"); - let row = adw::ActionRow::builder() - .title("Update method") - .subtitle(display_label) - .build(); - updates_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title("Update method") - .subtitle("This app cannot check for updates automatically") - .build(); - let badge = widgets::status_badge("None", "neutral"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - updates_group.add(&row); - } - - if let Some(ref latest) = record.latest_version { - let is_newer = record - .app_version - .as_deref() - .map(|current| crate::core::updater::version_is_newer(latest, current)) - .unwrap_or(true); - - if is_newer { - let subtitle = format!( - "{} -> {}", - record.app_version.as_deref().unwrap_or("unknown"), - latest - ); - let row = adw::ActionRow::builder() - .title("Update available") - .subtitle(&subtitle) - .build(); - let badge = widgets::status_badge("Update", "info"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - updates_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title("Status") - .subtitle("Up to date") - .build(); - let badge = widgets::status_badge("Latest", "success"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - updates_group.add(&row); - } - } - - if let Some(ref checked) = record.update_checked { - let row = adw::ActionRow::builder() - .title("Last checked") - .subtitle(checked) - .build(); - updates_group.add(&row); - } - inner.append(&updates_group); - - // Usage section - let usage_group = adw::PreferencesGroup::builder() - .title("Usage") - .build(); - - let stats = launcher::get_launch_stats(db, record.id); - - let launches_row = adw::ActionRow::builder() - .title("Total launches") - .subtitle(&stats.total_launches.to_string()) - .build(); - usage_group.add(&launches_row); - - if let Some(ref last) = stats.last_launched { - let row = adw::ActionRow::builder() - .title("Last launched") - .subtitle(last) - .build(); - usage_group.add(&row); - } - inner.append(&usage_group); - - // File info section - let info_group = adw::PreferencesGroup::builder() - .title("File Information") - .build(); - - let type_str = match record.appimage_type { - Some(1) => "Type 1", - Some(2) => "Type 2", - _ => "Unknown", - }; - let type_row = adw::ActionRow::builder() - .title("AppImage type") - .subtitle(type_str) - .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") - .build(); - info_group.add(&type_row); - - let exec_row = adw::ActionRow::builder() - .title("Executable") - .subtitle(if record.is_executable { "Yes" } else { "No" }) - .build(); - info_group.add(&exec_row); - - let seen_row = adw::ActionRow::builder() - .title("First seen") - .subtitle(&record.first_seen) - .build(); - info_group.add(&seen_row); - - let scanned_row = adw::ActionRow::builder() - .title("Last scanned") - .subtitle(&record.last_scanned) - .build(); - info_group.add(&scanned_row); - - if let Some(ref notes) = record.notes { - if !notes.is_empty() { - let row = adw::ActionRow::builder() - .title("Notes") - .subtitle(notes) - .build(); - info_group.add(&row); - } - } - inner.append(&info_group); - - clamp.set_child(Some(&inner)); - tab.append(&clamp); - tab -} -``` - -**Tab 2 - System:** - -```rust -/// Tab 2: System - integration, compatibility, sandboxing -fn build_system_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { - let tab = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .margin_top(18) - .margin_bottom(24) - .margin_start(18) - .margin_end(18) - .build(); - - let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) - .build(); - - let inner = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .build(); - - // Desktop Integration group - let integration_group = adw::PreferencesGroup::builder() - .title("Desktop Integration") - .description("Add this app to your application menu") - .build(); - - let switch_row = adw::SwitchRow::builder() - .title("Add to application menu") - .subtitle("Creates a .desktop file and installs the icon") - .active(record.integrated) - .build(); - - let record_id = record.id; - let record_clone = record.clone(); - let db_ref = db.clone(); - let db_dialog = db.clone(); - let record_dialog = record.clone(); - let suppress = Rc::new(Cell::new(false)); - let suppress_ref = suppress.clone(); - switch_row.connect_active_notify(move |row| { - if suppress_ref.get() { - return; - } - if row.is_active() { - let row_clone = row.clone(); - let suppress_inner = suppress_ref.clone(); - integration_dialog::show_integration_dialog( - row, - &record_dialog, - &db_dialog, - move |success| { - if !success { - suppress_inner.set(true); - row_clone.set_active(false); - suppress_inner.set(false); - } - }, - ); - } else { - integrator::remove_integration(&record_clone).ok(); - db_ref.set_integrated(record_id, false, None).ok(); - } - }); - integration_group.add(&switch_row); - - if record.integrated { - if let Some(ref desktop_file) = record.desktop_file { - let row = adw::ActionRow::builder() - .title("Desktop file") - .subtitle(desktop_file) - .subtitle_selectable(true) - .build(); - row.add_css_class("property"); - integration_group.add(&row); - } - } - inner.append(&integration_group); - - // Runtime Compatibility group - let compat_group = adw::PreferencesGroup::builder() - .title("Runtime Compatibility") - .description("Wayland support and FUSE status") - .build(); - - let wayland_status = record - .wayland_status - .as_deref() - .map(WaylandStatus::from_str) - .unwrap_or(WaylandStatus::Unknown); - - let wayland_row = adw::ActionRow::builder() - .title("Wayland") - .subtitle(wayland_description(&wayland_status)) - .tooltip_text("Display protocol for Linux desktops") - .build(); - let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class()); - wayland_badge.set_valign(gtk::Align::Center); - wayland_row.add_suffix(&wayland_badge); - compat_group.add(&wayland_row); - - let analyze_row = adw::ActionRow::builder() - .title("Analyze toolkit") - .subtitle("Inspect bundled libraries to detect UI toolkit") - .activatable(true) - .build(); - let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic"); - analyze_icon.set_valign(gtk::Align::Center); - analyze_row.add_suffix(&analyze_icon); - - let record_path_wayland = record.path.clone(); - analyze_row.connect_activated(move |row| { - row.set_sensitive(false); - row.update_state(&[gtk::accessible::State::Busy(true)]); - row.set_subtitle("Analyzing..."); - let row_clone = row.clone(); - let path = record_path_wayland.clone(); - glib::spawn_future_local(async move { - let result = gio::spawn_blocking(move || { - let appimage_path = std::path::Path::new(&path); - wayland::analyze_appimage(appimage_path) - }) - .await; - - row_clone.set_sensitive(true); - row_clone.update_state(&[gtk::accessible::State::Busy(false)]); - match result { - Ok(analysis) => { - let toolkit_label = analysis.toolkit.label(); - let lib_count = analysis.libraries_found.len(); - row_clone.set_subtitle(&format!( - "Toolkit: {} ({} libraries scanned)", - toolkit_label, lib_count, - )); - } - Err(_) => { - row_clone.set_subtitle("Analysis failed"); - } - } - }); - }); - compat_group.add(&analyze_row); - - if let Some(ref runtime_status) = record.runtime_wayland_status { - let runtime_row = adw::ActionRow::builder() - .title("Runtime display protocol") - .subtitle(runtime_status) - .build(); - if let Some(ref checked) = record.runtime_wayland_checked { - let info = gtk::Label::builder() - .label(checked) - .css_classes(["dimmed", "caption"]) - .valign(gtk::Align::Center) - .build(); - runtime_row.add_suffix(&info); - } - compat_group.add(&runtime_row); - } - - let fuse_system = fuse::detect_system_fuse(); - let fuse_status = record - .fuse_status - .as_deref() - .map(FuseStatus::from_str) - .unwrap_or(fuse_system.status.clone()); - - let fuse_row = adw::ActionRow::builder() - .title("FUSE") - .subtitle(fuse_description(&fuse_status)) - .tooltip_text("Filesystem in Userspace - required for mounting AppImages") - .build(); - let fuse_badge = widgets::status_badge_with_icon( - if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" }, - fuse_status.label(), - fuse_status.badge_class(), - ); - fuse_badge.set_valign(gtk::Align::Center); - fuse_row.add_suffix(&fuse_badge); - compat_group.add(&fuse_row); - - let appimage_path = std::path::Path::new(&record.path); - let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path); - let launch_method_row = adw::ActionRow::builder() - .title("Launch method") - .subtitle(app_fuse_status.label()) - .build(); - let launch_badge = widgets::status_badge( - fuse_system.status.as_str(), - app_fuse_status.badge_class(), - ); - launch_badge.set_valign(gtk::Align::Center); - launch_method_row.add_suffix(&launch_badge); - compat_group.add(&launch_method_row); - inner.append(&compat_group); - - // Sandboxing group - let sandbox_group = adw::PreferencesGroup::builder() - .title("Sandboxing") - .description("Isolate this app with Firejail") - .build(); - - let current_mode = record - .sandbox_mode - .as_deref() - .map(SandboxMode::from_str) - .unwrap_or(SandboxMode::None); - - let firejail_available = launcher::has_firejail(); - - let sandbox_subtitle = if firejail_available { - format!("Current mode: {}", current_mode.label()) - } else { - "Firejail is not installed".to_string() - }; - - let firejail_row = adw::SwitchRow::builder() - .title("Firejail sandbox") - .subtitle(&sandbox_subtitle) - .tooltip_text("Linux application sandboxing tool") - .active(current_mode == SandboxMode::Firejail) - .sensitive(firejail_available) - .build(); - - let record_id = record.id; - let db_ref = db.clone(); - firejail_row.connect_active_notify(move |row| { - let mode = if row.is_active() { - SandboxMode::Firejail - } else { - SandboxMode::None - }; - if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) { - log::warn!("Failed to update sandbox mode: {}", e); - } - }); - sandbox_group.add(&firejail_row); - - if !firejail_available { - let info_row = adw::ActionRow::builder() - .title("Install Firejail") - .subtitle("sudo apt install firejail") - .build(); - let badge = widgets::status_badge("Missing", "warning"); - badge.set_valign(gtk::Align::Center); - info_row.add_suffix(&badge); - sandbox_group.add(&info_row); - } - inner.append(&sandbox_group); - - clamp.set_child(Some(&inner)); - tab.append(&clamp); - tab -} -``` - -**Tab 3 - Security:** - -```rust -/// Tab 3: Security - vulnerability scanning and integrity -fn build_security_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { - let tab = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .margin_top(18) - .margin_bottom(24) - .margin_start(18) - .margin_end(18) - .build(); - - let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) - .build(); - - let inner = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .build(); - - let group = adw::PreferencesGroup::builder() - .title("Vulnerability Scanning") - .description("Check bundled libraries for known CVEs") - .build(); - - let libs = db.get_bundled_libraries(record.id).unwrap_or_default(); - let summary = db.get_cve_summary(record.id).unwrap_or_default(); - - if libs.is_empty() { - let row = adw::ActionRow::builder() - .title("Security scan") - .subtitle("Not yet scanned for vulnerabilities") - .build(); - let badge = widgets::status_badge("Not scanned", "neutral"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - group.add(&row); - } else { - let lib_row = adw::ActionRow::builder() - .title("Bundled libraries") - .subtitle(&libs.len().to_string()) - .build(); - group.add(&lib_row); - - if summary.total() == 0 { - let row = adw::ActionRow::builder() - .title("Vulnerabilities") - .subtitle("No known vulnerabilities") - .build(); - let badge = widgets::status_badge("Clean", "success"); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title("Vulnerabilities") - .subtitle(&format!("{} found", summary.total())) - .build(); - let badge = widgets::status_badge(summary.max_severity(), summary.badge_class()); - badge.set_valign(gtk::Align::Center); - row.add_suffix(&badge); - group.add(&row); - } - } - - // Scan button - let scan_row = adw::ActionRow::builder() - .title("Scan this AppImage") - .subtitle("Check bundled libraries for known CVEs") - .activatable(true) - .build(); - let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic"); - scan_icon.set_valign(gtk::Align::Center); - scan_row.add_suffix(&scan_icon); - - let record_id = record.id; - let record_path = record.path.clone(); - scan_row.connect_activated(move |row| { - row.set_sensitive(false); - row.update_state(&[gtk::accessible::State::Busy(true)]); - row.set_subtitle("Scanning..."); - let row_clone = row.clone(); - let path = record_path.clone(); - glib::spawn_future_local(async move { - let result = gio::spawn_blocking(move || { - let bg_db = Database::open().expect("Failed to open database"); - let appimage_path = std::path::Path::new(&path); - security::scan_and_store(&bg_db, record_id, appimage_path) - }) - .await; - - row_clone.set_sensitive(true); - row_clone.update_state(&[gtk::accessible::State::Busy(false)]); - match result { - Ok(scan_result) => { - let total = scan_result.total_cves(); - if total == 0 { - row_clone.set_subtitle("No vulnerabilities found"); - } else { - row_clone.set_subtitle(&format!( - "Found {} CVE{}", total, if total == 1 { "" } else { "s" } - )); - } - } - Err(_) => { - row_clone.set_subtitle("Scan failed"); - } - } - }); - }); - group.add(&scan_row); - inner.append(&group); - - // Integrity group - let integrity_group = adw::PreferencesGroup::builder() - .title("Integrity") - .build(); - - if let Some(ref hash) = record.sha256 { - let hash_row = adw::ActionRow::builder() - .title("SHA256 checksum") - .subtitle(hash) - .subtitle_selectable(true) - .tooltip_text("Cryptographic hash for verifying file integrity") - .build(); - hash_row.add_css_class("property"); - integrity_group.add(&hash_row); - } - inner.append(&integrity_group); - - clamp.set_child(Some(&inner)); - tab.append(&clamp); - tab -} -``` - -**Tab 4 - Storage:** - -```rust -/// Tab 4: Storage - disk usage and data discovery -fn build_storage_tab( - record: &AppImageRecord, - db: &Rc, - toast_overlay: &adw::ToastOverlay, -) -> gtk::Box { - let tab = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .margin_top(18) - .margin_bottom(24) - .margin_start(18) - .margin_end(18) - .build(); - - let clamp = adw::Clamp::builder() - .maximum_size(800) - .tightening_threshold(600) - .build(); - - let inner = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(24) - .build(); - - // Disk usage group - let size_group = adw::PreferencesGroup::builder() - .title("Disk Usage") - .build(); - - let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64); - - let appimage_row = adw::ActionRow::builder() - .title("AppImage file size") - .subtitle(&widgets::format_size(record.size_bytes)) - .build(); - size_group.add(&appimage_row); - - if !fp.paths.is_empty() { - let data_total = fp.data_total(); - if data_total > 0 { - let total_row = adw::ActionRow::builder() - .title("Total disk footprint") - .subtitle(&format!( - "{} (AppImage) + {} (data) = {}", - widgets::format_size(record.size_bytes), - widgets::format_size(data_total as i64), - widgets::format_size(fp.total_size() as i64), - )) - .build(); - size_group.add(&total_row); - } - } - inner.append(&size_group); - - // Data paths group - let paths_group = adw::PreferencesGroup::builder() - .title("Data Paths") - .description("Config, data, and cache directories for this app") - .build(); - - // Discover button - let discover_row = adw::ActionRow::builder() - .title("Discover data paths") - .subtitle("Search for config, data, and cache directories") - .activatable(true) - .build(); - let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic"); - discover_icon.set_valign(gtk::Align::Center); - discover_row.add_suffix(&discover_icon); - - let record_clone = record.clone(); - let record_id = record.id; - discover_row.connect_activated(move |row| { - row.set_sensitive(false); - row.set_subtitle("Discovering..."); - let row_clone = row.clone(); - let rec = record_clone.clone(); - glib::spawn_future_local(async move { - let result = gio::spawn_blocking(move || { - let bg_db = Database::open().expect("Failed to open database"); - footprint::discover_and_store(&bg_db, record_id, &rec); - footprint::get_footprint(&bg_db, record_id, rec.size_bytes as u64) - }) - .await; - - row_clone.set_sensitive(true); - match result { - Ok(fp) => { - let count = fp.paths.len(); - if count == 0 { - row_clone.set_subtitle("No associated paths found"); - } else { - row_clone.set_subtitle(&format!( - "Found {} path{} ({})", - count, - if count == 1 { "" } else { "s" }, - widgets::format_size(fp.data_total() as i64), - )); - } - } - Err(_) => { - row_clone.set_subtitle("Discovery failed"); - } - } - }); - }); - paths_group.add(&discover_row); - - // Individual discovered paths - for dp in &fp.paths { - if dp.exists { - let row = adw::ActionRow::builder() - .title(dp.path_type.label()) - .subtitle(&*dp.path.to_string_lossy()) - .subtitle_selectable(true) - .build(); - let icon = gtk::Image::from_icon_name(dp.path_type.icon_name()); - icon.set_pixel_size(16); - row.add_prefix(&icon); - let conf_badge = widgets::status_badge( - dp.confidence.as_str(), - dp.confidence.badge_class(), - ); - conf_badge.set_valign(gtk::Align::Center); - row.add_suffix(&conf_badge); - let size_label = gtk::Label::builder() - .label(&widgets::format_size(dp.size_bytes as i64)) - .css_classes(["dimmed", "caption"]) - .valign(gtk::Align::Center) - .build(); - row.add_suffix(&size_label); - paths_group.add(&row); - } - } - inner.append(&paths_group); - - // File location group - let location_group = adw::PreferencesGroup::builder() - .title("File Location") - .build(); - - let path_row = adw::ActionRow::builder() - .title("Path") - .subtitle(&record.path) - .subtitle_selectable(true) - .build(); - path_row.add_css_class("property"); - let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay)); - copy_path_btn.set_valign(gtk::Align::Center); - path_row.add_suffix(©_path_btn); - - let folder_path = std::path::Path::new(&record.path) - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - if !folder_path.is_empty() { - let open_folder_btn = gtk::Button::builder() - .icon_name("folder-open-symbolic") - .tooltip_text("Open containing folder") - .valign(gtk::Align::Center) - .build(); - open_folder_btn.add_css_class("flat"); - open_folder_btn.update_property(&[ - gtk::accessible::Property::Label("Open containing folder"), - ]); - let folder = folder_path.clone(); - open_folder_btn.connect_clicked(move |_| { - let file = gio::File::for_path(&folder); - let launcher = gtk::FileLauncher::new(Some(&file)); - launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); - }); - path_row.add_suffix(&open_folder_btn); - } - location_group.add(&path_row); - inner.append(&location_group); - - clamp.set_child(Some(&inner)); - tab.append(&clamp); - tab -} -``` - -**Step 4: Build and test** - -Run: `cargo build 2>&1 | tail -5` -Expected: Compiles successfully - -Run: `cargo test 2>&1 | tail -5` -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add src/ui/detail_view.rs -git commit -m "ui: restructure detail view into tabbed layout - -Replace single scrolling page with ViewStack/ViewSwitcher tabs: -Overview (updates, usage, file info), System (integration, compatibility, -sandboxing), Security (CVE scanning, integrity), Storage (disk usage, -data paths, file location). 96px hero banner with gradient background." -``` - ---- - -### Task 5: Right-Click Context Menu - Window Actions - -**Files:** -- Modify: `src/window.rs` - -**Step 1: Add parameterized context menu actions** - -In `setup_window_actions()`, after the existing action entries and before the `self.add_action_entries(...)` call (around line 361), add new parameterized actions. Since `ActionEntry` doesn't support parameterized actions, we create them separately using `gio::SimpleAction`. - -Add the following code right before the `self.add_action_entries([...])` call: - -```rust - // --- Context menu actions (parameterized with record ID) --- - let param_type = Some(glib::VariantTy::INT64); - - // Launch action - let launch_action = gio::SimpleAction::new("launch-appimage", param_type); - { - let window_weak = self.downgrade(); - launch_action.connect_activate(move |_, param| { - let Some(window) = window_weak.upgrade() else { return }; - let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - let appimage_path = std::path::Path::new(&record.path); - match launcher::launch_appimage(&db, record_id, appimage_path, "gui_context", &[], &[]) { - launcher::LaunchResult::Started { child, method } => { - log::info!("Context menu launched: {} (PID: {}, method: {})", record.path, child.id(), method.as_str()); - } - launcher::LaunchResult::Failed(msg) => { - log::error!("Failed to launch: {}", msg); - } - } - } - }); - } - self.add_action(&launch_action); - - // Check for updates action (per-app) - let check_update_action = gio::SimpleAction::new("check-update", param_type); - { - let window_weak = self.downgrade(); - check_update_action.connect_activate(move |_, param| { - let Some(window) = window_weak.upgrade() else { return }; - let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - glib::spawn_future_local(async move { - let path = record.path.clone(); - let result = gio::spawn_blocking(move || { - let bg_db = Database::open().expect("DB open failed"); - update_dialog::check_single_update(&bg_db, &record) - }).await; - match result { - Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")), - Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")), - Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")), - } - }); - } - }); - } - self.add_action(&check_update_action); - - // Scan for vulnerabilities (per-app) - let scan_security_action = gio::SimpleAction::new("scan-security", param_type); - { - let window_weak = self.downgrade(); - scan_security_action.connect_activate(move |_, param| { - let Some(window) = window_weak.upgrade() else { return }; - let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - glib::spawn_future_local(async move { - let path = record.path.clone(); - let result = gio::spawn_blocking(move || { - let bg_db = Database::open().expect("DB open failed"); - let appimage_path = std::path::Path::new(&path); - security::scan_and_store(&bg_db, record_id, appimage_path) - }).await; - match result { - Ok(scan_result) => { - let total = scan_result.total_cves(); - if total == 0 { - toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found")); - } else { - let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" }); - toast_overlay.add_toast(adw::Toast::new(&msg)); - } - } - Err(_) => toast_overlay.add_toast(adw::Toast::new("Security scan failed")), - } - }); - } - }); - } - self.add_action(&scan_security_action); - - // Toggle integration - let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type); - { - let window_weak = self.downgrade(); - toggle_integration_action.connect_activate(move |_, param| { - let Some(window) = window_weak.upgrade() else { return }; - let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - if record.integrated { - integrator::remove_integration(&record).ok(); - db.set_integrated(record_id, false, None).ok(); - toast_overlay.add_toast(adw::Toast::new("Integration removed")); - } else { - // For context menu, do a quick integrate without dialog - match integrator::integrate_appimage(&record) { - Ok(desktop_path) => { - db.set_integrated(record_id, true, Some(&desktop_path)).ok(); - toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu")); - } - Err(e) => { - log::error!("Integration failed: {}", e); - toast_overlay.add_toast(adw::Toast::new("Integration failed")); - } - } - } - // Refresh library view - let lib_view = window.imp().library_view.get().unwrap(); - match db.get_all_appimages() { - Ok(records) => lib_view.populate(records), - Err(_) => {} - } - } - }); - } - self.add_action(&toggle_integration_action); - - // Open containing folder - let open_folder_action = gio::SimpleAction::new("open-folder", param_type); - { - let window_weak = self.downgrade(); - open_folder_action.connect_activate(move |_, param| { - let Some(window) = window_weak.upgrade() else { return }; - let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - let file = gio::File::for_path(&record.path); - let launcher = gtk::FileLauncher::new(Some(&file)); - launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); - } - }); - } - self.add_action(&open_folder_action); - - // Copy path to clipboard - let copy_path_action = gio::SimpleAction::new("copy-path", param_type); - { - let window_weak = self.downgrade(); - copy_path_action.connect_activate(move |_, param| { - let Some(window) = window_weak.upgrade() else { return }; - let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - if let Some(display) = window.display().into() { - let clipboard = gtk::gdk::Display::clipboard(&display); - clipboard.set_text(&record.path); - toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard")); - } - } - }); - } - self.add_action(©_path_action); -``` - -**Step 2: Add missing imports at the top of window.rs** - -Add these imports to the use block at the top (after line 14): - -```rust -use crate::core::integrator; -use crate::core::launcher; -use crate::core::security; -``` - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: Compiles. (Note: if `update_dialog::check_single_update` doesn't exist, we need to add it or use a toast-based approach instead.) - -**Step 4: Commit** - -```bash -git add src/window.rs -git commit -m "ui: add parameterized context menu actions to window - -Define launch-appimage, check-update, scan-security, toggle-integration, -open-folder, and copy-path actions with i64 record ID parameter for -the right-click context menu on cards and list rows." -``` - ---- - -### Task 6: Right-Click Context Menu - Attach to Cards and List Rows - -**Files:** -- Modify: `src/ui/library_view.rs` - -**Step 1: Add context menu builder function** - -Add a helper function at the end of `library_view.rs` (module-level, outside the `impl LibraryView` block): - -```rust -/// Build the right-click context menu model for an AppImage. -fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu { - let menu = gtk::gio::Menu::new(); - - // Section 1: Launch - let section1 = gtk::gio::Menu::new(); - section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id))); - menu.append_section(None, §ion1); - - // Section 2: Actions - let section2 = gtk::gio::Menu::new(); - section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id))); - section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id))); - menu.append_section(None, §ion2); - - // Section 3: Integration + folder - let section3 = gtk::gio::Menu::new(); - let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" }; - section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id))); - section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id))); - menu.append_section(None, §ion3); - - // Section 4: Clipboard - let section4 = gtk::gio::Menu::new(); - section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id))); - menu.append_section(None, §ion4); - - menu -} - -/// Attach a right-click context menu to a widget. -fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::gio::Menu) { - let popover = gtk::PopoverMenu::from_model(Some(menu_model)); - popover.set_parent(widget.as_ref()); - popover.set_has_arrow(false); - - let click = gtk::GestureClick::new(); - click.set_button(3); // Right click - let popover_ref = popover.clone(); - click.connect_pressed(move |gesture, _, x, y| { - gesture.set_state(gtk::EventSequenceState::Claimed); - popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - popover_ref.popup(); - }); - widget.as_ref().add_controller(click); - - // Long press for touch - let long_press = gtk::GestureLongPress::new(); - let popover_ref = popover; - long_press.connect_pressed(move |gesture, x, y| { - gesture.set_state(gtk::EventSequenceState::Claimed); - popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - popover_ref.popup(); - }); - widget.as_ref().add_controller(long_press); -} -``` - -**Step 2: Wire context menu to grid cards in populate()** - -In the `populate()` method, after building the card (around line 432-433), add context menu attachment: - -Change: - -```rust - for record in &new_records { - // Grid card - let card = app_card::build_app_card(record); - self.flow_box.append(&card); - - // List row - let row = self.build_list_row(record); - self.list_box.append(&row); - } -``` - -to: - -```rust - 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); - 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); - self.list_box.append(&row); - } -``` - -**Step 3: Build and test** - -Run: `cargo build 2>&1 | tail -5` -Expected: Compiles successfully - -Run: `cargo test 2>&1 | tail -5` -Expected: All tests pass - -**Step 4: Commit** - -```bash -git add src/ui/library_view.rs -git commit -m "ui: attach right-click context menu to cards and list rows - -GtkPopoverMenu with gio::Menu model, triggered by GestureClick (button 3) -and GestureLongPress for touch. Menu sections: Launch, Check Updates / -Scan Vulnerabilities, Integrate / Open Folder, Copy Path." -``` - ---- - -### Task 7: Handle check_single_update Helper - -**Files:** -- Modify: `src/ui/update_dialog.rs` - -The context menu's check-update action calls `update_dialog::check_single_update`. We need to verify this function exists or add it. - -**Step 1: Check if function exists** - -Read `src/ui/update_dialog.rs` and look for `check_single_update` or `batch_check_updates`. - -If `check_single_update` does not exist, add it: - -```rust -/// Check for updates for a single AppImage. Returns true if update available. -pub fn check_single_update(db: &Database, record: &AppImageRecord) -> bool { - if record.update_type.is_none() { - return false; - } - match crate::core::updater::check_for_update(record) { - Ok(Some(latest)) => { - let is_newer = record - .app_version - .as_deref() - .map(|current| crate::core::updater::version_is_newer(&latest, current)) - .unwrap_or(true); - if is_newer { - db.update_latest_version(record.id, &latest).ok(); - return true; - } - false - } - _ => false, - } -} -``` - -**Step 2: Build and test** - -Run: `cargo build 2>&1 | tail -5` -Expected: Compiles - -Run: `cargo test 2>&1 | tail -5` -Expected: All tests pass - -**Step 3: Commit** - -```bash -git add src/ui/update_dialog.rs -git commit -m "ui: add check_single_update helper for context menu" -``` - ---- - -### Task 8: Final Build Verification and Polish - -**Files:** -- All modified files - -**Step 1: Full build** - -Run: `cargo build 2>&1` -Expected: Zero errors. Fix any warnings. - -**Step 2: Run tests** - -Run: `cargo test 2>&1` -Expected: All 128+ tests pass. - -**Step 3: Visual verification** - -Run: `cargo run` - -Verify: -1. Card view: 200px cards, 72px icons, .title-3 names, version+size combined, single badge -2. List view: 48px rounded icons, .rich-list spacing, structured subtitle, single badge -3. Detail view: 96px icon banner with gradient, ViewSwitcher with 4 tabs -4. Right-click on card or list row opens context menu -5. All context menu actions work (Launch, Check Updates, Scan, Integrate, Open Folder, Copy Path) -6. Light and dark mode look correct -7. Keyboard navigation works (Tab, Enter, Escape) - -**Step 4: Commit any final fixes** - -```bash -git add -u -git commit -m "ui: final polish and fixes for UI/UX overhaul" -``` - ---- - -## Files Modified (Summary) - -| File | Task | Changes | -|------|------|---------| -| `data/resources/style.css` | 1 | Remove .app-card CSS, add .icon-rounded, .detail-banner gradient, .detail-view-switcher | -| `src/ui/app_card.rs` | 2 | 72px icon, .title-3, version+size combined, single priority badge, .card class, 200px width | -| `src/ui/library_view.rs` | 3, 6 | FlowBox max 4 cols, .rich-list, list row restructure, context menu build + attach | -| `src/ui/detail_view.rs` | 4 | 96px banner, ViewStack/ViewSwitcher, 4 tab pages (Overview, System, Security, Storage) | -| `src/window.rs` | 5 | 6 parameterized actions for context menu | -| `src/ui/update_dialog.rs` | 7 | check_single_update helper | - -## Verification - -After all tasks: -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 preserved -7. All WCAG AAA compliance preserved diff --git a/docs/plans/2026-02-27-wcag-aaa-compliance-design.md b/docs/plans/2026-02-27-wcag-aaa-compliance-design.md deleted file mode 100644 index 0265437..0000000 --- a/docs/plans/2026-02-27-wcag-aaa-compliance-design.md +++ /dev/null @@ -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 diff --git a/docs/plans/2026-02-27-wcag-aaa-implementation.md b/docs/plans/2026-02-27-wcag-aaa-implementation.md deleted file mode 100644 index c2a637e..0000000 --- a/docs/plans/2026-02-27-wcag-aaa-implementation.md +++ /dev/null @@ -1,1123 +0,0 @@ -# WCAG 2.2 AAA Compliance Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Make Driftwood fully WCAG 2.2 AAA compliant across all four principles (Perceivable, Operable, Understandable, Robust). - -**Architecture:** Hybrid approach - centralized accessibility helpers in `widgets.rs` for repeated patterns (labeled buttons, live announcements, described badges), plus direct `update_property`/`update_state`/`update_relation` calls in each UI file for unique cases. CSS additions in `style.css` for focus indicators, high-contrast mode, reduced-motion expansion, and target sizes. - -**Tech Stack:** Rust, gtk4-rs (0.11), libadwaita-rs (0.9), GTK4 accessible API (`gtk::accessible::Property`, `gtk::accessible::State`, `gtk::accessible::Relation`, `gtk::AccessibleRole`) - ---- - -### Task 1: CSS Foundation - Focus Indicators, High Contrast, Reduced Motion, Target Sizes - -**Files:** -- Modify: `data/resources/style.css` - -**Step 1: Add universal focus-visible indicators** - -Append after the existing `flowboxchild:focus-visible .app-card` block (line 91). These ensure every focusable widget has a visible 2px accent-color outline meeting WCAG 2.4.7 and 2.4.13: - -```css -/* ===== WCAG AAA Focus Indicators ===== */ -button:focus-visible, -togglebutton:focus-visible, -menubutton:focus-visible, -checkbutton:focus-visible, -switch:focus-visible, -entry:focus-visible, -searchentry:focus-visible, -spinbutton:focus-visible { - outline: 2px solid @accent_bg_color; - outline-offset: 2px; -} - -row:focus-visible { - outline: 2px solid @accent_bg_color; - outline-offset: -2px; -} -``` - -**Step 2: Add high-contrast media query** - -Append a `prefers-contrast: more` section (WCAG 1.4.6 Enhanced Contrast, 1.4.11 Non-text Contrast): - -```css -/* ===== High Contrast Mode (WCAG AAA 1.4.6) ===== */ -@media (prefers-contrast: more) { - .app-card { - border: 2px solid @window_fg_color; - } - - flowboxchild:focus-visible .app-card { - outline-width: 3px; - } - - button:focus-visible, - togglebutton:focus-visible, - menubutton:focus-visible, - checkbutton:focus-visible, - switch:focus-visible, - entry:focus-visible, - searchentry:focus-visible, - spinbutton:focus-visible { - outline-width: 3px; - } - - row:focus-visible { - outline-width: 3px; - } - - .status-badge, - .status-badge-with-icon { - border: 1px solid currentColor; - } - - .compat-warning-banner { - border: 2px solid @warning_bg_color; - } -} -``` - -**Step 3: Expand reduced-motion to cover ALL transitions (WCAG 2.3.3)** - -Replace the existing `@media (prefers-reduced-motion: reduce)` block (lines 152-160) with: - -```css -/* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */ -@media (prefers-reduced-motion: reduce) { - * { - transition-duration: 0 !important; - transition-delay: 0 !important; - animation-duration: 0 !important; - animation-delay: 0 !important; - } -} -``` - -**Step 4: Add minimum target size (WCAG 2.5.8)** - -```css -/* ===== Minimum Target Size (WCAG 2.5.8) ===== */ -button.flat.circular, -button.flat:not(.pill):not(.suggested-action):not(.destructive-action) { - min-width: 24px; - min-height: 24px; -} -``` - -**Step 5: Build to verify CSS loads** - -Run: `cargo build 2>&1 | tail -5` -Expected: success (CSS is loaded at runtime, not compiled) - -**Step 6: Commit** - -``` -git add data/resources/style.css -git commit -m "Add WCAG AAA focus indicators, high-contrast mode, and reduced-motion coverage" -``` - ---- - -### Task 2: Accessibility Helpers in widgets.rs - -**Files:** -- Modify: `src/ui/widgets.rs` - -**Step 1: Add accessible label to copy_button** - -In the `copy_button` function (line 129-149), add an accessible label after creating the button. Change: - -```rust -pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>) -> gtk::Button { - let btn = gtk::Button::builder() - .icon_name("edit-copy-symbolic") - .tooltip_text("Copy to clipboard") - .valign(gtk::Align::Center) - .build(); - btn.add_css_class("flat"); -``` - -To: - -```rust -pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>) -> gtk::Button { - let btn = gtk::Button::builder() - .icon_name("edit-copy-symbolic") - .tooltip_text("Copy to clipboard") - .valign(gtk::Align::Center) - .build(); - btn.add_css_class("flat"); - btn.update_property(&[gtk::accessible::Property::Label("Copy to clipboard")]); -``` - -**Step 2: Add accessible description to status_badge** - -Update `status_badge` (lines 5-10) to include a `RoleDescription`: - -```rust -pub fn status_badge(text: &str, style_class: &str) -> gtk::Label { - let label = gtk::Label::new(Some(text)); - label.add_css_class("status-badge"); - label.add_css_class(style_class); - label.set_accessible_role(gtk::AccessibleRole::Status); - label -} -``` - -**Step 3: Add accessible role to status_badge_with_icon** - -Update `status_badge_with_icon` (lines 14-30) similarly: - -```rust -pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> gtk::Box { - let hbox = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .accessible_role(gtk::AccessibleRole::Status) - .build(); - hbox.add_css_class("status-badge-with-icon"); - hbox.add_css_class(style_class); - hbox.update_property(&[gtk::accessible::Property::Label(text)]); - - let icon = gtk::Image::from_icon_name(icon_name); - icon.set_pixel_size(12); - hbox.append(&icon); - - let label = gtk::Label::new(Some(text)); - hbox.append(&label); - - hbox -} -``` - -**Step 4: Add `announce()` live region helper function** - -Add at the end of `widgets.rs`: - -```rust -/// 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. -/// The label auto-removes after a short delay. -pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { - let label = gtk::Label::builder() - .label(text) - .visible(false) - .accessible_role(gtk::AccessibleRole::Alert) - .build(); - label.update_property(&[gtk::accessible::Property::Label(text)]); - - // We need to add it to a container to make it part of the accessible tree. - // Use the widget's first ancestor that is a Box, or fall back to toast overlay. - // Since we cannot generically append to any widget, the caller should pass - // a gtk::Box or adw::ToastOverlay. - if let Some(box_widget) = container.dynamic_cast_ref::() { - box_widget.append(&label); - // Make visible briefly so AT-SPI picks it up, then remove - 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); - }); - } -} -``` - -**Step 5: Add `use gtk::prelude::*;` import check** - -The file already has `use gtk::prelude::*;` at line 1. No change needed. - -**Step 6: Build to verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success with zero errors - -**Step 7: Commit** - -``` -git add src/ui/widgets.rs -git commit -m "Add WCAG accessibility helpers: labeled badges, live announcements, copy button label" -``` - ---- - -### Task 3: Library View Accessible Labels and Roles - -**Files:** -- Modify: `src/ui/library_view.rs` - -**Step 1: Add accessible labels to header bar buttons** - -After each icon-only button is built, add an accessible label. After line 64 (menu_button): - -```rust -menu_button.update_property(&[AccessibleProperty::Label("Main menu")]); -``` - -After line 70 (search_button): - -```rust -search_button.update_property(&[AccessibleProperty::Label("Toggle search")]); -``` - -After line 77 (grid_button): - -```rust -grid_button.update_property(&[AccessibleProperty::Label("Switch to grid view")]); -``` - -After line 84 (list_button): - -```rust -list_button.update_property(&[AccessibleProperty::Label("Switch to list view")]); -``` - -**Step 2: Add accessible labels to empty state buttons** - -After line 156 (scan_now_btn): - -```rust -scan_now_btn.update_property(&[AccessibleProperty::Label("Scan for AppImages")]); -``` - -After line 162 (prefs_btn): - -```rust -prefs_btn.update_property(&[AccessibleProperty::Label("Open preferences")]); -``` - -**Step 3: Add accessible label to list_box** - -After line 214 (`list_box.add_css_class("boxed-list");`): - -```rust -list_box.update_property(&[AccessibleProperty::Label("AppImage library list")]); -``` - -**Step 4: Add AccessibleRole::Search to search_bar** - -After line 118 (`search_bar.connect_entry(&search_entry);`): - -```rust -search_bar.set_accessible_role(gtk::AccessibleRole::Search); -``` - -**Step 5: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success with zero errors - -**Step 6: Commit** - -``` -git add src/ui/library_view.rs -git commit -m "Add WCAG accessible labels to library view buttons, list box, and search bar" -``` - ---- - -### Task 4: App Card Accessible Emblem Description - -**Files:** -- Modify: `src/ui/app_card.rs` - -**Step 1: Add accessible description to integration emblem** - -In `build_app_card` (line 32-43), after creating the emblem overlay, add a description. Change: - -```rust - if record.integrated { - let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&icon_widget)); - - 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); - overlay.add_overlay(&emblem); - - card.append(&overlay); -``` - -To: - -```rust - if record.integrated { - let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&icon_widget)); - - 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); -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 3: Commit** - -``` -git add src/ui/app_card.rs -git commit -m "Add accessible label to integration emblem overlay in app cards" -``` - ---- - -### Task 5: Detail View - Tooltips, Plain Language, Busy States - -**Files:** -- Modify: `src/ui/detail_view.rs` - -**Step 1: Add accessible role to banner** - -In `build_banner` (line 160), after `banner.add_css_class("detail-banner");`, add: - -```rust -banner.set_accessible_role(gtk::AccessibleRole::Banner); -``` - -**Step 2: Add tooltips for technical terms** - -In `build_system_integration_group`: - -For the Wayland row (around line 315), change: -```rust - let wayland_row = adw::ActionRow::builder() - .title("Wayland") - .subtitle(wayland_description(&wayland_status)) - .build(); -``` -To: -```rust - let wayland_row = adw::ActionRow::builder() - .title("Wayland") - .subtitle(wayland_description(&wayland_status)) - .tooltip_text("Display protocol for Linux desktops") - .build(); -``` - -For the FUSE row (around line 389), change: -```rust - let fuse_row = adw::ActionRow::builder() - .title("FUSE") - .subtitle(fuse_description(&fuse_status)) - .build(); -``` -To: -```rust - let fuse_row = adw::ActionRow::builder() - .title("FUSE") - .subtitle(fuse_description(&fuse_status)) - .tooltip_text("Filesystem in Userspace - required for mounting AppImages") - .build(); -``` - -For the Firejail row (around line 432), change: -```rust - let firejail_row = adw::SwitchRow::builder() - .title("Firejail sandbox") -``` -To: -```rust - let firejail_row = adw::SwitchRow::builder() - .title("Firejail sandbox") - .tooltip_text("Linux application sandboxing tool") -``` - -**Step 3: Plain language rewrites in build_updates_usage_group** - -Change line ~507 from: -```rust - .subtitle("No update information embedded") -``` -To: -```rust - .subtitle("This app cannot check for updates automatically") -``` - -**Step 4: Add tooltip to SHA256 row** - -In `build_security_storage_group`, for the SHA256 row (around line 830), change: -```rust - let hash_row = adw::ActionRow::builder() - .title("SHA256") -``` -To: -```rust - let hash_row = adw::ActionRow::builder() - .title("SHA256 checksum") - .tooltip_text("Cryptographic hash for verifying file integrity") -``` - -**Step 5: Add tooltip to AppImage type row** - -Change (around line 815): -```rust - let type_row = adw::ActionRow::builder() - .title("AppImage type") - .subtitle(type_str) - .build(); -``` -To: -```rust - let type_row = adw::ActionRow::builder() - .title("AppImage type") - .subtitle(type_str) - .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") - .build(); -``` - -**Step 6: Add busy state to security scan row** - -In the security scan `connect_activated` closure (around line 640-670), add busy state when scan starts and clear when done. - -After `row.set_sensitive(false);` add: -```rust - row.update_state(&[gtk::accessible::State::Busy(true)]); -``` - -After `row_clone.set_sensitive(true);` add: -```rust - row_clone.update_state(&[gtk::accessible::State::Busy(false)]); -``` - -**Step 7: Add busy state to analyze toolkit row** - -Same pattern in the analyze toolkit `connect_activated` closure (around line 335-361). - -After `row.set_sensitive(false);` add: -```rust - row.update_state(&[gtk::accessible::State::Busy(true)]); -``` - -After `row_clone.set_sensitive(true);` add: -```rust - row_clone.update_state(&[gtk::accessible::State::Busy(false)]); -``` - -**Step 8: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success with zero errors - -**Step 9: Commit** - -``` -git add src/ui/detail_view.rs -git commit -m "Add WCAG tooltips, plain language, busy states, and banner role to detail view" -``` - ---- - -### Task 6: Dashboard Tooltips for Technical Terms - -**Files:** -- Modify: `src/ui/dashboard.rs` - -**Step 1: Add tooltips to system status rows** - -For the FUSE row (around line 97), change: -```rust - let fuse_row = adw::ActionRow::builder() - .title("FUSE") - .subtitle(&fuse_description(&fuse_info)) - .build(); -``` -To: -```rust - let fuse_row = adw::ActionRow::builder() - .title("FUSE") - .subtitle(&fuse_description(&fuse_info)) - .tooltip_text("Filesystem in Userspace - required for mounting AppImages") - .build(); -``` - -For the XWayland row (around line 122), change: -```rust - let xwayland_row = adw::ActionRow::builder() - .title("XWayland") - .subtitle(if has_xwayland { "Running" } else { "Not detected" }) - .build(); -``` -To: -```rust - let xwayland_row = adw::ActionRow::builder() - .title("XWayland") - .subtitle(if has_xwayland { "Running" } else { "Not detected" }) - .tooltip_text("X11 compatibility layer for Wayland desktops") - .build(); -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 3: Commit** - -``` -git add src/ui/dashboard.rs -git commit -m "Add WCAG tooltips for technical terms on dashboard" -``` - ---- - -### Task 7: Duplicate Dialog - Accessible Labels and Confirmation - -**Files:** -- Modify: `src/ui/duplicate_dialog.rs` - -**Step 1: Add accessible label to bulk remove button** - -After the bulk_btn is created (around line 41-46), add: - -```rust - bulk_btn.update_property(&[ - gtk::accessible::Property::Label("Remove all suggested duplicates"), - ]); -``` - -**Step 2: Add accessible label to per-row delete buttons** - -In `build_group_widget`, after the delete_btn is created (around line 195-201), add: - -```rust - delete_btn.update_property(&[ - gtk::accessible::Property::Label(&format!("Delete {}", record_name)), - ]); -``` - -(This line must go after the `record_name` variable is created at line 205.) - -Actually, re-checking the code structure - `record_name` is defined at line 205 and `delete_btn` at line 195. We need to move the accessible label after `record_name` is defined. Add after line 205: - -```rust - delete_btn.update_property(&[ - gtk::accessible::Property::Label(&format!("Delete {}", record_name)), - ]); -``` - -**Step 3: Add confirmation to bulk remove** - -Wrap the bulk_btn `connect_clicked` handler (lines 95-115) to show a confirmation AlertDialog first. Replace the entire `bulk_btn.connect_clicked` block: - -```rust - let parent_for_confirm = dialog.clone(); - bulk_btn.connect_clicked(move |btn| { - let records = removable.borrow(); - if records.is_empty() { - return; - } - let count = records.len(); - let confirm = adw::AlertDialog::builder() - .heading("Confirm Removal") - .body(&format!("Remove {} suggested duplicate{}?", count, if count == 1 { "" } else { "s" })) - .close_response("cancel") - .default_response("remove") - .build(); - confirm.add_response("cancel", "Cancel"); - confirm.add_response("remove", "Remove"); - confirm.set_response_appearance("remove", adw::ResponseAppearance::Destructive); - - let db_bulk = db_bulk.clone(); - let toast_bulk = toast_bulk.clone(); - let removable_inner = removable.clone(); - let btn_clone = btn.clone(); - confirm.connect_response(None, move |_dlg, response| { - if response != "remove" { - return; - } - let records = removable_inner.borrow(); - let mut removed_count = 0; - for (record_id, record_path, _record_name, integrated) in records.iter() { - if *integrated { - if let Ok(Some(full_record)) = db_bulk.get_appimage_by_id(*record_id) { - integrator::remove_integration(&full_record).ok(); - } - db_bulk.set_integrated(*record_id, false, None).ok(); - } - std::fs::remove_file(record_path).ok(); - db_bulk.remove_appimage(*record_id).ok(); - removed_count += 1; - } - if removed_count > 0 { - toast_bulk.add_toast(adw::Toast::new(&format!("Removed {} items", removed_count))); - btn_clone.set_sensitive(false); - btn_clone.set_label("Done"); - } - }); - confirm.present(Some(&parent_for_confirm)); - }); -``` - -**Step 4: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 5: Commit** - -``` -git add src/ui/duplicate_dialog.rs -git commit -m "Add WCAG accessible labels and confirmation dialog to duplicate removal" -``` - ---- - -### Task 8: Cleanup Wizard - Labels, Confirmation, Busy Announcement - -**Files:** -- Modify: `src/ui/cleanup_wizard.rs` - -**Step 1: Add accessible label to clean button** - -In `build_review_step`, after the clean_button is created (around line 302-305), add: - -```rust - clean_button.update_property(&[ - gtk::accessible::Property::Label("Clean selected items"), - ]); -``` - -**Step 2: Add accessible label to close button** - -In `build_complete_step`, after the close_button is created (around line 400-404), add: - -```rust - close_button.update_property(&[ - gtk::accessible::Property::Label("Close cleanup dialog"), - ]); -``` - -**Step 3: Add accessible labels to category list boxes** - -In `build_review_step`, after each `list_box` is created (around line 260-262), add: - -```rust - list_box.update_property(&[ - gtk::accessible::Property::Label(cat.label()), - ]); -``` - -**Step 4: Add confirmation before cleanup** - -Wrap the `clean_button.connect_clicked` handler (lines 309-324) to add a confirmation dialog. Replace it with: - -```rust - let dialog_for_confirm = Rc::new(RefCell::new(None::)); - // The dialog reference will be set by the caller - for now use the page as parent - let page_ref = page.clone(); - clean_button.connect_clicked(move |_| { - let checks = checks.borrow(); - let mut items_mut = items_clone.borrow_mut(); - for (idx, check) in checks.iter() { - if *idx < items_mut.len() { - items_mut[*idx].selected = check.is_active(); - } - } - let selected: Vec = items_mut - .iter() - .filter(|i| i.selected) - .cloned() - .collect(); - drop(items_mut); - - if selected.is_empty() { - on_confirm(selected); - return; - } - - let count = selected.len(); - let total_size: u64 = selected.iter().map(|i| i.size_bytes).sum(); - let confirm = adw::AlertDialog::builder() - .heading("Confirm Cleanup") - .body(&format!( - "Remove {} item{} ({})?", - count, - if count == 1 { "" } else { "s" }, - super::widgets::format_size(total_size as i64), - )) - .close_response("cancel") - .default_response("clean") - .build(); - confirm.add_response("cancel", "Cancel"); - confirm.add_response("clean", "Clean"); - confirm.set_response_appearance("clean", adw::ResponseAppearance::Destructive); - - let on_confirm_inner = { - // We need to move on_confirm into the closure, but it's already moved. - // This requires restructuring - use Rc>> - selected.clone() - }; - confirm.connect_response(None, move |_dlg, response| { - if response == "clean" { - on_confirm(on_confirm_inner.clone()); - } - }); - confirm.present(Some(&page_ref)); - }); -``` - -Note: This task requires careful restructuring because `on_confirm` is `impl Fn` not `Clone`. The simplest approach is to wrap the confirmation at a higher level. Actually, since `on_confirm` takes a `Vec` and is `Fn + 'static`, we can use `Rc` wrapping. Let me simplify - just add the confirmation inside the existing closure pattern. - -Actually, re-reading the code more carefully: `on_confirm` is `impl Fn(Vec) + 'static` - it can be called multiple times. We should wrap it in an `Rc` to share between the confirmation dialog closure and the outer closure. But since `impl Fn` doesn't implement `Clone`, we need a different approach. - -The simplest fix: wrap `on_confirm` in an `Rc` at the function level. Change the signature: - -In `build_review_step` function signature, no change needed since we just call `on_confirm` inside the confirmation callback. But we need `on_confirm` to be callable from inside the nested closure. - -Simplest approach: Store selected items in an `Rc>>` and have the confirmation dialog closure read from it. - -This task is complex enough to warrant its own careful implementation. For now, the key requirement is: -- The "Clean Selected" button shows a confirmation AlertDialog before actually cleaning. -- Keep the existing flow but interpose a dialog. - -**Step 5: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 6: Commit** - -``` -git add src/ui/cleanup_wizard.rs -git commit -m "Add WCAG accessible labels and confirmation dialog to cleanup wizard" -``` - ---- - -### Task 9: Preferences - Accessible Labels - -**Files:** -- Modify: `src/ui/preferences.rs` - -**Step 1: Add accessible label to Add Location button** - -After line 98 (add_button creation), add: - -```rust - add_button.update_property(&[ - gtk::accessible::Property::Label("Add scan directory"), - ]); -``` - -**Step 2: Add accessible label to remove directory buttons** - -In `add_directory_row` function, after the remove_btn is created (around line 392-397), add: - -```rust - remove_btn.update_property(&[ - gtk::accessible::Property::Label(&format!("Remove directory {}", dir)), - ]); -``` - -**Step 3: Add accessible label to directory list box** - -After line 87 (`dir_list_box.set_selection_mode(gtk::SelectionMode::None);`), add: - -```rust - dir_list_box.update_property(&[ - gtk::accessible::Property::Label("Scan directories"), - ]); -``` - -Note: This requires importing `gtk::accessible::Property` or using the full path. Since preferences.rs doesn't import it yet, add at the top: - -```rust -use gtk::prelude::*; -``` - -The file already uses `adw::prelude::*` and `gtk::gio`. We need to also import `gtk::prelude::*` for `update_property`. Check if `adw::prelude::*` re-exports it... it does (adw re-exports gtk::prelude). So we just need the accessible path. Use full path: `gtk::accessible::Property::Label(...)`. - -**Step 4: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 5: Commit** - -``` -git add src/ui/preferences.rs -git commit -m "Add WCAG accessible labels to preferences buttons and directory list" -``` - ---- - -### Task 10: Security Report - Labels and Tooltips - -**Files:** -- Modify: `src/ui/security_report.rs` - -**Step 1: Add tooltips for CVE terms** - -In `build_summary_group`, change the total_row (around line 150): -```rust - let total_row = adw::ActionRow::builder() - .title("Total vulnerabilities") - .subtitle(&summary.total().to_string()) - .build(); -``` -To: -```rust - let total_row = adw::ActionRow::builder() - .title("Total vulnerabilities") - .subtitle(&summary.total().to_string()) - .tooltip_text("Common Vulnerabilities and Exposures found in bundled libraries") - .build(); -``` - -**Step 2: Expand "CVE" abbreviation in app findings** - -In `build_app_findings_group`, change the description (around line 209): -```rust - let description = format!("{} vulnerabilities found", summary.total()); -``` -To: -```rust - let description = format!("{} CVE (vulnerability) records found", summary.total()); -``` - -**Step 3: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 4: Commit** - -``` -git add src/ui/security_report.rs -git commit -m "Add WCAG tooltips and expanded abbreviations to security report" -``` - ---- - -### Task 11: Integration Dialog - List Box Labels - -**Files:** -- Modify: `src/ui/integration_dialog.rs` - -**Step 1: Add accessible labels to list boxes** - -After line 41 (`identity_box.set_selection_mode(gtk::SelectionMode::None);`), add: - -```rust - identity_box.update_property(&[ - gtk::accessible::Property::Label("Application details"), - ]); -``` - -After line 76 (`actions_box.set_selection_mode(gtk::SelectionMode::None);`), add: - -```rust - actions_box.update_property(&[ - gtk::accessible::Property::Label("Integration actions"), - ]); -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 3: Commit** - -``` -git add src/ui/integration_dialog.rs -git commit -m "Add WCAG accessible labels to integration dialog list boxes" -``` - ---- - -### Task 12: Update Dialog - Plain Language - -**Files:** -- Modify: `src/ui/update_dialog.rs` - -**Step 1: Plain language rewrite** - -Change line ~121 from: -```rust - dialog_ref.set_body( - "This AppImage does not contain update information. \ - Updates must be downloaded manually.", - ); -``` -To: -```rust - dialog_ref.set_body( - "This app does not support automatic updates. \ - Check the developer's website for newer versions.", - ); -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 3: Commit** - -``` -git add src/ui/update_dialog.rs -git commit -m "Rewrite update dialog text to plain language for WCAG readability" -``` - ---- - -### Task 13: Window - Dynamic Title and Live Announcements - -**Files:** -- Modify: `src/window.rs` - -**Step 1: Update window title on navigation** - -In `setup_ui`, after the `navigation_view.connect_popped` block (around line 199), add a `connect_pushed` handler to update the window title: - -```rust - // Update window title for accessibility (WCAG 2.4.8 Location) - { - let window_weak = self.downgrade(); - navigation_view.connect_pushed(move |_nav, page| { - if let Some(window) = window_weak.upgrade() { - let page_title = page.title(); - if !page_title.is_empty() { - window.set_title(Some(&format!("Driftwood - {}", page_title))); - } - } - }); - } - { - let window_weak = self.downgrade(); - let nav_ref = navigation_view.clone(); - navigation_view.connect_popped(move |_nav, _page| { - if let Some(window) = window_weak.upgrade() { - // After pop, get the now-visible page title - if let Some(visible) = nav_ref.visible_page() { - let title = visible.title(); - if title == "Driftwood" { - window.set_title(Some("Driftwood")); - } else { - window.set_title(Some(&format!("Driftwood - {}", title))); - } - } - } - }); - } -``` - -Wait - there's already a `connect_popped` handler at line 188. We need to add the title update logic inside the existing handler, not create a duplicate. Modify the existing handler to also update the title. - -Change the existing `connect_popped` block (lines 186-199): - -```rust - { - let db = self.database().clone(); - let window_weak = self.downgrade(); - navigation_view.connect_popped(move |_nav, page| { - if let Some(window) = window_weak.upgrade() { - // Update window title for accessibility (WCAG 2.4.8) - window.set_title(Some("Driftwood")); - - if page.tag().as_deref() == Some("detail") { - let lib_view = window.imp().library_view.get().unwrap(); - match db.get_all_appimages() { - Ok(records) => lib_view.populate(records), - Err(_) => lib_view.set_state(LibraryState::Empty), - } - } - } - }); - } -``` - -And add a new `connect_pushed` handler after it: - -```rust - // Update window title when navigating to sub-pages (WCAG 2.4.8 Location) - { - let window_weak = self.downgrade(); - navigation_view.connect_pushed(move |_nav, page| { - if let Some(window) = window_weak.upgrade() { - let page_title = page.title(); - if !page_title.is_empty() { - window.set_title(Some(&format!("Driftwood - {}", page_title))); - } - } - }); - } -``` - -**Step 2: Build and verify** - -Run: `cargo build 2>&1 | tail -5` -Expected: success - -**Step 3: Commit** - -``` -git add src/window.rs -git commit -m "Update window title dynamically for WCAG 2.4.8 Location compliance" -``` - ---- - -### Task 14: Final Build Verification - -**Files:** None (verification only) - -**Step 1: Full build** - -Run: `cargo build 2>&1` -Expected: zero errors, zero warnings - -**Step 2: Run tests** - -Run: `cargo test 2>&1` -Expected: all tests pass - -**Step 3: Commit any remaining changes** - -If there are any uncommitted fixes from build errors: - -``` -git add -u -git commit -m "Fix build issues from WCAG AAA compliance changes" -``` - ---- - -## Summary of WCAG Criteria Addressed - -| Criterion | Level | Status | Task | -|-----------|-------|--------|------| -| 1.1.1 Non-text Content | A | Tasks 2-11 | Accessible labels on all icon-only elements | -| 1.3.1 Info and Relationships | A | Tasks 2-3, 7-11 | Roles and labels on containers | -| 1.3.6 Identify Purpose | AAA | Tasks 2, 3, 5 | Landmark roles (Banner, Search, Status) | -| 1.4.6 Enhanced Contrast | AAA | Task 1 | High-contrast media query | -| 1.4.11 Non-text Contrast | AA | Task 1 | Focus ring and badge border contrast | -| 2.1.3 Keyboard No Exception | AAA | Already met | All functionality keyboard accessible | -| 2.3.3 Animation from Interactions | AAA | Task 1 | Universal reduced-motion | -| 2.4.7 Focus Visible | AA | Task 1 | Focus indicators on all widgets | -| 2.4.8 Location | AAA | Task 13 | Dynamic window title per page | -| 2.4.13 Focus Appearance | AAA | Task 1 | 2-3px focus rings with contrast | -| 2.5.8 Target Size | AA | Task 1 | 24px minimum target sizes | -| 3.1.3 Unusual Words | AAA | Tasks 5, 6, 10 | Tooltips for technical terms | -| 3.1.4 Abbreviations | AAA | Tasks 5, 10 | Expanded abbreviations | -| 3.1.5 Reading Level | AAA | Tasks 5, 12 | Plain language rewrites | -| 3.3.5 Help | AAA | Tasks 5, 6 | Contextual descriptions | -| 3.3.6 Error Prevention All | AAA | Tasks 7, 8 | Confirmation on destructive actions | -| 4.1.2 Name, Role, Value | A | Tasks 2-13 | Complete accessible names/roles | -| 4.1.3 Status Messages | AA | Task 2 | Live region announcements | diff --git a/docs/plans/2026-03-01-final-ux-improvements-design.md b/docs/plans/2026-03-01-final-ux-improvements-design.md deleted file mode 100644 index e6b985c..0000000 --- a/docs/plans/2026-03-01-final-ux-improvements-design.md +++ /dev/null @@ -1,166 +0,0 @@ -# Final UX Improvements Design: Tags, Export/Import, Changelog - -## Overview - -Three remaining UX improvements from the 30-item enhancement list: - -- **#12** App grouping with tags + grouped library sections -- **#13** Unified export/import (shared core for CLI + GUI) with extended fields -- **#14** Bridge changelog gap (GitHub release notes -> release_history) + updates view preview - -## #12: Tags + Grouped Library Sections - -### Current State - -- `tags: Option` field exists in `AppImageRecord` (database.rs:50) -- `update_tags(id, tags)` DB method exists (database.rs:2069) -- No UI for viewing, editing, or filtering by tags -- Library view is flat (grid or list mode, no sections) - -### Design - -**Detail view - tag editor:** -- Add a "Tags" row in the detail view info section -- Display existing tags as pill-shaped chips with "x" remove buttons -- A "+" button at the end opens an inline text entry to add a new tag -- Tags stored as comma-separated string in the existing `tags` column -- On add/remove, call `db.update_tags()` immediately (no save button) - -**Library view - tag filter chips:** -- Add a horizontal scrollable chip bar at the top of the library view (same pattern as catalog category chips) -- "All" chip selected by default, plus one chip per unique tag found across all apps -- Selecting a tag filters the view to only apps with that tag -- Chip bar is only visible when at least one app has tags - -**Library view - grouped sections:** -- When no search is active and no tag filter is selected, group apps by their first tag under collapsible section headers -- Apps with no tags go under an "Uncategorized" section at the bottom -- Sort dropdown still works within each group -- In search mode or tag-filter mode, grouping is disabled (flat list) - -**Data model:** No schema changes. Comma-separated tags in existing column. - -### Files Modified - -| File | Changes | -|------|---------| -| `src/ui/detail_view.rs` | Add tag editor chips row in info section | -| `src/ui/library_view.rs` | Add tag filter chip bar, grouped section headers | -| `src/core/database.rs` | Add `get_all_tags()` query to collect distinct tags | - -## #13: Unified Export/Import (CLI + GUI) - -### Current State - -- CLI-only export/import in `cli.rs` (lines 887-1000+) -- JSON format v1 with limited fields: path, app_name, app_version, integrated, notes, categories -- No GUI file picker dialogs for export/import -- No shared module - logic is inline in CLI handlers - -### Design - -**Shared core module: `src/core/backup.rs`** - -New module with two public functions: - -``` -pub fn export_app_list(db: &Database, path: &Path) -> Result -pub fn import_app_list(db: &Database, path: &Path) -> Result -``` - -Where `ImportResult` contains: -- `matched: usize` - apps found and metadata merged -- `missing: Vec` - app names/paths not found on disk - -**JSON format v2:** - -```json -{ - "version": 2, - "exported_at": "2026-03-01T10:00:00Z", - "appimages": [ - { - "path": "/home/user/Applications/MyApp.AppImage", - "app_name": "MyApp", - "app_version": "1.0.0", - "integrated": true, - "notes": "user notes", - "categories": "Graphics;Utility;", - "tags": "work,design", - "pinned": false, - "launch_args": "--no-sandbox", - "sandbox_mode": "permissive", - "autostart": false - } - ] -} -``` - -Graceful v1 handling: missing fields default to None (not overwritten on import). - -**Import merge semantics:** -- Match apps by path (exact match) -- For matched apps, merge each field: only overwrite if the import value is non-empty/non-null AND the existing value is empty/null (preserve user's current data) -- Tags: merge (union of existing + imported tags, no duplicates) -- For unmatched paths: collect into `missing` list - -**CLI (cli.rs):** -- `cmd_export()` and `cmd_import()` become thin wrappers calling `backup::export_app_list()` / `backup::import_app_list()` -- Same CLI flags and output messages - -**GUI (window.rs):** -- Add "Export app list" and "Import app list" items to the hamburger menu -- Export: `gtk::FileDialog` save picker, default filename `driftwood-apps.json`, calls `backup::export_app_list()`, toast on success -- Import: `gtk::FileDialog` open picker with `.json` filter, calls `backup::import_app_list()`, toast showing "Imported N apps" + dialog listing missing apps if any -- Refresh library view after import - -### Files Modified - -| File | Changes | -|------|---------| -| `src/core/backup.rs` | New module: export/import logic, JSON v2 format | -| `src/core/mod.rs` | Add `pub mod backup;` | -| `src/cli.rs` | Refactor cmd_export/cmd_import to call backup module | -| `src/window.rs` | Add menu items + file picker dialogs for export/import | - -## #14: Bridge Changelog Gap + Updates View Preview - -### Current State - -- `release_history: Option` field exists in AppImageRecord (JSON array) -- Detail view already renders release history when data exists (detail_view.rs:760-820) -- AppStream parsing populates release_history for local metainfo (rare) -- GitHub enrichment fetches release info but does NOT write to release_history -- Updates view shows update-available cards but no changelog preview - -### Design - -**Enrichment bridge (github_enrichment.rs):** -- After `enrich_app_release_info()` fetches the latest release, also fetch up to 10 recent releases from the GitHub releases API -- Convert each release to `ReleaseInfo { version, date, description }` format -- Serialize as JSON array and write to `release_history` column -- Only update if the field is currently empty (don't overwrite AppStream data which may be more authoritative) -- The existing detail view Release History section will then automatically display this data for all GitHub-enriched apps - -**New DB method:** -- `update_release_history(id: i64, history_json: &str)` - simple UPDATE on the release_history column - -**Updates view changelog preview (updates_view.rs):** -- Each update card gets an `adw::ExpanderRow` "What's new" section below the existing content -- If `release_history` is populated, find the entry matching `latest_version` and show its description -- If no matching entry or no release_history data: show "Release notes not available" in dim text -- Collapsed by default to keep the view compact - -### Files Modified - -| File | Changes | -|------|---------| -| `src/core/github_enrichment.rs` | Fetch recent releases, write to release_history | -| `src/core/database.rs` | Add `update_release_history()` method | -| `src/ui/updates_view.rs` | Add expandable "What's new" section to update cards | - -## Implementation Order - -1. **#14 first** - Smallest scope, self-contained enrichment + UI change -2. **#12 second** - Tags are self-contained, no dependency on other features -3. **#13 last** - Export should include tags (depends on #12 being functional)