# 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