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

1490 lines
42 KiB
Markdown

# 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<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:
```rust
#[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**
```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<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:
```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<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:
```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<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:
```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<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**
```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<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**
```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: &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:
```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
<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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 <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**
```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
<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**
```rust
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**
```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<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**
```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
<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:
```rust
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**
```rust
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**
```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