diff --git a/docs/plans/2026-02-27-feature-roadmap-implementation.md b/docs/plans/2026-02-27-feature-roadmap-implementation.md new file mode 100644 index 0000000..514e3ed --- /dev/null +++ b/docs/plans/2026-02-27-feature-roadmap-implementation.md @@ -0,0 +1,1489 @@ +# 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