Files
driftwood/docs/plans/2026-02-27-feature-roadmap-implementation.md

42 KiB

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.

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

pub fn register_modification(
    &self,
    appimage_id: i64,
    mod_type: &str,
    file_path: &str,
    previous_value: Option<&str>,
) -> SqlResult<i64> {
    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<Vec<SystemModification>> {
    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<Vec<(i64, SystemModification)>> {
    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:

#[derive(Debug, Clone)]
pub struct SystemModification {
    pub id: i64,
    pub mod_type: String,
    pub file_path: String,
    pub previous_value: Option<String>,
}

Step 3: Add undo_all_modifications to integrator.rs

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

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:

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:

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

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

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:

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

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

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<Option<String>> {
    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:

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

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

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

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:

pub fn detect_source_url(update_info: Option<&str>) -> Option<String> {
    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:

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

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

pub fn get_top_launched(&self, limit: i32) -> SqlResult<Vec<(String, u64)>> {
    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<String>>(0)?.unwrap_or_else(|| "Unknown".to_string()),
            row.get::<_, u64>(1)?,
        ))
    })?;
    rows.collect()
}

pub fn get_launch_count_since(&self, since: &str) -> SqlResult<u64> {
    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<Option<(String, String)>> {
    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<String>>(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:

// 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

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:

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

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<bool> and selected_ids: RefCell<HashSet<i64>> 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

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

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

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<PathBuf, String> {
    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

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

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

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

pub fn send_system_notification(app: &gtk::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), &notification);
}

Step 2: Use for crash, update, and security events

In window.rs launch handler, on crash:

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

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

#[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

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

<key name="update-check-interval-hours" type="i">
  <range min="1" max="168"/>
  <default>24</default>
  <summary>Update check interval</summary>
  <description>Hours between automatic update checks.</description>
</key>
<key name="last-update-check" type="s">
  <default>''</default>
  <summary>Last update check timestamp</summary>
  <description>ISO timestamp of the last automatic update check.</description>
</key>

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

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

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:
    • AppImage file (size)
    • Desktop integration (if integrated)
    • Configuration (path - size)
    • Cache (path - size)
    • 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

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

pub fn extract_icon_fast(path: &Path) -> Option<PathBuf> {
    // 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

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

pub struct DistroInfo {
    pub id: String,
    pub id_like: Vec<String>,
    pub version_id: String,
}

pub fn detect_distro() -> Option<DistroInfo> {
    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<String> {
    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

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

pub fn parse_mime_types(desktop_entry: &str) -> Vec<String> {
    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

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

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

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<String, String> {
    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

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

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

pub fn detect_default_capabilities(categories: &str) -> Vec<DefaultAppType> {
    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

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

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 <url> - download, validate, register, integrate
  • driftwood update --all - update all apps with available updates
  • driftwood autostart <path> --enable/--disable
  • driftwood purge - remove all system modifications
  • driftwood verify <path> - check signature/hash

Step 2: Implement each command

purge calls db.get_all_modifications() and undoes each one.

Step 3: Build, verify, commit

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

<key name="watch-removable-media" type="b">
  <default>false</default>
  <summary>Watch removable media</summary>
  <description>Scan removable drives for AppImages when mounted.</description>
</key>

Step 2: Create portable.rs

pub struct MountInfo {
    pub device: String,
    pub mount_point: PathBuf,
    pub fs_type: String,
}

pub fn detect_removable_mounts() -> Vec<MountInfo> {
    // 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

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

pub fn find_similar_catalog_apps(&self, categories: &str, exclude_name: &str, limit: i32) -> SqlResult<Vec<CatalogApp>> {
    // 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

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

<key name="catalog-last-refreshed" type="s">
  <default>''</default>
  <summary>Catalog last refreshed</summary>
  <description>ISO timestamp of the last catalog refresh.</description>
</key>

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<CatalogApp>
  • get_catalog_app(id: i64) -> Option<CatalogApp>
  • get_catalog_categories() -> Vec<(String, u32)> - categories with counts

Add CatalogApp struct:

pub struct CatalogApp {
    pub id: i64,
    pub name: String,
    pub description: Option<String>,
    pub categories: Option<String>,
    pub download_url: String,
    pub icon_url: Option<String>,
    pub homepage: Option<String>,
    pub license: Option<String>,
    pub author: Option<String>,
}

Step 3: Add feed.json fetcher

pub fn refresh_catalog(db: &Database) -> Result<usize, String> {
    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::<Vec<_>>().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

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