42 KiB
Driftwood Feature Roadmap Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement 26 features that make Driftwood the definitive AppImage manager for Linux newcomers, with full system modification tracking for clean uninstalls.
Architecture: All features layer on the existing GTK4/libadwaita/Rust stack. A new system_modifications table tracks every system-level change. Features are ordered easiest-first. Each task is a self-contained unit that compiles and can be committed independently.
Tech Stack: Rust, gtk4-rs (0.9.x), libadwaita-rs (0.7.x), rusqlite, gio, notify crate, XDG specs, AppImage feed.json, pkexec/polkit.
Task 1: System Modifications Tracking (Foundation)
Files:
- Modify:
src/core/database.rs - Modify:
src/core/integrator.rs
This is the foundation all other features build on. Every system change (desktop files, icons, autostart, MIME defaults) gets tracked and can be reversed.
Step 1: Add migration to v11 with system_modifications table
In src/core/database.rs, add migrate_to_v11() and call it from the migration chain. The table stores every file we create or system setting we change.
fn migrate_to_v11(&self) -> SqlResult<()> {
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS system_modifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
mod_type TEXT NOT NULL,
file_path TEXT NOT NULL,
previous_value TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_system_mods_appimage
ON system_modifications(appimage_id);"
)?;
// Add new columns for features F3-F23
let new_columns = [
"previous_version_path TEXT",
"source_url TEXT",
"autostart INTEGER NOT NULL DEFAULT 0",
"startup_wm_class TEXT",
"verification_status TEXT",
"first_run_prompted INTEGER NOT NULL DEFAULT 0",
"system_wide INTEGER NOT NULL DEFAULT 0",
"is_portable INTEGER NOT NULL DEFAULT 0",
"mount_point TEXT",
];
for col in &new_columns {
let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col);
self.conn.execute(&sql, []).ok();
}
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![11],
)?;
Ok(())
}
Step 2: Add DB helper methods for system_modifications
pub fn register_modification(
&self,
appimage_id: i64,
mod_type: &str,
file_path: &str,
previous_value: Option<&str>,
) -> SqlResult<i64> {
self.conn.query_row(
"INSERT INTO system_modifications (appimage_id, mod_type, file_path, previous_value)
VALUES (?1, ?2, ?3, ?4)
RETURNING id",
params![appimage_id, mod_type, file_path, previous_value],
|row| row.get(0),
)
}
pub fn get_modifications(&self, appimage_id: i64) -> SqlResult<Vec<SystemModification>> {
let mut stmt = self.conn.prepare(
"SELECT id, mod_type, file_path, previous_value
FROM system_modifications
WHERE appimage_id = ?1
ORDER BY id DESC"
)?;
let rows = stmt.query_map(params![appimage_id], |row| {
Ok(SystemModification {
id: row.get(0)?,
mod_type: row.get(1)?,
file_path: row.get(2)?,
previous_value: row.get(3)?,
})
})?;
rows.collect()
}
pub fn get_all_modifications(&self) -> SqlResult<Vec<(i64, SystemModification)>> {
let mut stmt = self.conn.prepare(
"SELECT appimage_id, id, mod_type, file_path, previous_value
FROM system_modifications
ORDER BY id DESC"
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get(0)?, SystemModification {
id: row.get(1)?,
mod_type: row.get(2)?,
file_path: row.get(3)?,
previous_value: row.get(4)?,
}))
})?;
rows.collect()
}
pub fn remove_modification(&self, id: i64) -> SqlResult<()> {
self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?;
Ok(())
}
Add the struct:
#[derive(Debug, Clone)]
pub struct SystemModification {
pub id: i64,
pub mod_type: String,
pub file_path: String,
pub previous_value: Option<String>,
}
Step 3: Add undo_all_modifications to integrator.rs
pub fn undo_all_modifications(db: &Database, appimage_id: i64) -> Result<(), String> {
let mods = db.get_modifications(appimage_id)
.map_err(|e| format!("Failed to get modifications: {}", e))?;
for m in &mods {
match m.mod_type.as_str() {
"desktop_file" | "autostart" | "icon" => {
let path = std::path::Path::new(&m.file_path);
if path.exists() {
if let Err(e) = std::fs::remove_file(path) {
log::warn!("Failed to remove {}: {}", m.file_path, e);
}
}
}
"mime_default" => {
if let Some(ref prev) = m.previous_value {
// file_path stores the mime type, previous_value stores the old default
let _ = std::process::Command::new("xdg-mime")
.args(["default", prev, &m.file_path])
.status();
}
}
"system_desktop" | "system_icon" | "system_binary" => {
let _ = std::process::Command::new("pkexec")
.args(["rm", "-f", &m.file_path])
.status();
}
_ => {
log::warn!("Unknown modification type: {}", m.mod_type);
}
}
db.remove_modification(m.id).ok();
}
// Refresh desktop database and icon cache
let _ = std::process::Command::new("update-desktop-database")
.arg(crate::core::integrator::applications_dir().to_string_lossy().as_ref())
.status();
Ok(())
}
Step 4: Wire existing integrate/remove_integration to use tracking
Modify integrate() to call db.register_modification() after creating each file. Modify remove_integration() to use undo_all_modifications().
Step 5: Build and verify
Run: cargo build
Expected: Compiles with zero errors
Step 6: Commit
git add src/core/database.rs src/core/integrator.rs
git commit -m "Add system modification tracking for reversible installs"
Task 2: Fix Executable Permissions on Existing Files (F1)
Files:
- Modify:
src/ui/drop_dialog.rs:195 - Modify:
src/core/discovery.rs
Step 1: Fix drop_dialog.rs - check permissions for files already in scan dir
In register_dropped_files(), the if in_scan_dir branch at line 195 just clones the path. Add permission fix:
let final_path = if in_scan_dir {
// Ensure executable even if already in scan dir
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(file) {
if meta.permissions().mode() & 0o111 == 0 {
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(file, perms) {
log::warn!("Failed to set executable on {}: {}", file.display(), e);
}
}
}
}
file.clone()
} else {
// ... existing copy logic
Step 2: Fix discovery.rs - auto-fix permissions during scan
In scan_directories() or wherever DiscoveredAppImage structs are built, if is_executable is false, fix it:
if !is_executable {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
if std::fs::set_permissions(&path, perms).is_ok() {
is_executable = true;
log::info!("Auto-fixed executable permission: {}", path.display());
}
}
}
Step 3: Build and verify
Run: cargo build
Step 4: Commit
git add src/ui/drop_dialog.rs src/core/discovery.rs
git commit -m "Auto-fix executable permissions on discovered AppImages"
Task 3: Drag-and-Drop Keep-in-Place Option (F2)
Files:
- Modify:
src/ui/drop_dialog.rs
Step 1: Add "Keep in place" response to drop dialog
Replace the dialog button setup (lines 66-71):
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("keep-in-place", &i18n("Keep in place"));
dialog.add_response("copy-only", &i18n("Copy to Applications"));
dialog.add_response("copy-and-integrate", &i18n("Copy && add to menu"));
dialog.set_response_appearance("copy-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("copy-and-integrate"));
dialog.set_close_response("cancel");
Step 2: Update response handler
In connect_response, determine copy mode from response:
dialog.connect_response(None, move |_dialog, response| {
if response == "cancel" {
return;
}
let copy = response != "keep-in-place";
let integrate = response == "copy-and-integrate";
let files = files.clone();
let toast_ref = toast_ref.clone();
let on_complete_ref = on_complete.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
register_dropped_files(&files, copy)
}).await;
// ... rest unchanged
Step 3: Add copy parameter to register_dropped_files
Change signature to fn register_dropped_files(files: &[PathBuf], copy_to_target: bool).
When copy_to_target is false: skip the copy, just use the original path as-is, but still set executable permissions and register in DB.
When copy_to_target is true: existing behavior (copy to target_dir).
Step 4: Build and verify
Run: cargo build
Step 5: Commit
git add src/ui/drop_dialog.rs
git commit -m "Add keep-in-place option for drag-and-drop imports"
Task 4: Version Rollback (F3)
Files:
- Modify:
src/core/updater.rs - Modify:
src/ui/detail_view.rs - Modify:
src/core/database.rs
Step 1: Add DB methods for previous_version_path
pub fn set_previous_version(&self, id: i64, path: Option<&str>) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET previous_version_path = ?2 WHERE id = ?1",
params![id, path],
)?;
Ok(())
}
pub fn get_previous_version(&self, id: i64) -> SqlResult<Option<String>> {
self.conn.query_row(
"SELECT previous_version_path FROM appimages WHERE id = ?1",
params![id],
|row| row.get(0),
)
}
Step 2: Save old version before update in updater.rs
In the update download flow, before replacing the AppImage:
let prev_path = format!("{}.prev", appimage_path.display());
if let Err(e) = std::fs::rename(appimage_path, &prev_path) {
log::warn!("Failed to save previous version: {}", e);
} else {
db.set_previous_version(record_id, Some(&prev_path)).ok();
}
// Then write new version to appimage_path
Step 3: Add rollback button to detail view
In the system tab, when record.previous_version_path is Some(path):
let rollback_row = adw::ActionRow::builder()
.title("Previous version available")
.subtitle("Rollback to the version before the last update")
.build();
let rollback_btn = gtk::Button::builder()
.label("Rollback")
.valign(gtk::Align::Center)
.css_classes(["destructive-action"])
.build();
rollback_row.add_suffix(&rollback_btn);
Rollback handler: swap current and .prev files, update DB, re-run analysis.
Step 4: Build and verify
Run: cargo build
Step 5: Commit
git add src/core/updater.rs src/ui/detail_view.rs src/core/database.rs
git commit -m "Add version rollback support for AppImage updates"
Task 5: Source Tracking (F4)
Files:
- Modify:
src/core/database.rs - Modify:
src/ui/detail_view.rs
Step 1: Add DB methods for source_url
pub fn set_source_url(&self, id: i64, url: Option<&str>) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET source_url = ?2 WHERE id = ?1",
params![id, url],
)?;
Ok(())
}
Step 2: Auto-detect source from update_info
In the analysis pipeline or a new helper, parse update_info for GitHub/GitLab URLs:
pub fn detect_source_url(update_info: Option<&str>) -> Option<String> {
let info = update_info?;
if info.starts_with("gh-releases-zsync|") {
let parts: Vec<&str> = info.split('|').collect();
if parts.len() >= 3 {
return Some(format!("https://github.com/{}/{}", parts[1], parts[2]));
}
}
if info.starts_with("zsync|") {
// Extract domain from URL
if let Some(url_part) = info.split('|').nth(1) {
if let Ok(url) = url::Url::parse(url_part) {
return url.host_str().map(|h| format!("https://{}", h));
}
}
}
None
}
Step 3: Display in detail view overview tab
Add a row showing source when available:
if let Some(ref source) = record.source_url {
let source_row = adw::ActionRow::builder()
.title("Source")
.subtitle(source)
.build();
// Add copy-to-clipboard button as suffix
overview_group.append(&source_row);
}
Step 4: Build, verify, commit
git add src/core/database.rs src/ui/detail_view.rs
git commit -m "Add source URL tracking and display for AppImages"
Task 6: Launch Statistics Dashboard (F5)
Files:
- Modify:
src/core/database.rs - Modify:
src/ui/dashboard.rs
Step 1: Add DB query methods
pub fn get_top_launched(&self, limit: i32) -> SqlResult<Vec<(String, u64)>> {
let mut stmt = self.conn.prepare(
"SELECT a.app_name, COUNT(l.id) as cnt
FROM launch_events l
JOIN appimages a ON a.id = l.appimage_id
GROUP BY l.appimage_id
ORDER BY cnt DESC
LIMIT ?1"
)?;
let rows = stmt.query_map(params![limit], |row| {
Ok((
row.get::<_, Option<String>>(0)?.unwrap_or_else(|| "Unknown".to_string()),
row.get::<_, u64>(1)?,
))
})?;
rows.collect()
}
pub fn get_launch_count_since(&self, since: &str) -> SqlResult<u64> {
self.conn.query_row(
"SELECT COUNT(*) FROM launch_events WHERE launched_at >= ?1",
params![since],
|row| row.get(0),
)
}
pub fn get_last_launch(&self) -> SqlResult<Option<(String, String)>> {
self.conn.query_row(
"SELECT a.app_name, l.launched_at
FROM launch_events l
JOIN appimages a ON a.id = l.appimage_id
ORDER BY l.launched_at DESC
LIMIT 1",
[],
|row| Ok(Some((
row.get::<_, Option<String>>(0)?.unwrap_or_else(|| "Unknown".to_string()),
row.get(1)?,
))),
).unwrap_or(Ok(None))
}
Step 2: Add Activity section to dashboard
After the existing dashboard sections, add:
// Activity section
let activity_group = adw::PreferencesGroup::builder()
.title("Activity")
.build();
if let Ok(top) = db.get_top_launched(5) {
for (name, count) in &top {
let row = adw::ActionRow::builder()
.title(name)
.subtitle(&format!("{} launches", count))
.build();
activity_group.append(&row);
}
}
Step 3: Build, verify, commit
git add src/core/database.rs src/ui/dashboard.rs
git commit -m "Add launch statistics to dashboard"
Task 7: Automatic Desktop Integration on Scan (F7)
Files:
- Modify:
src/window.rs - Modify:
src/ui/preferences.rs
Step 1: Wire auto-integrate after scan
In window.rs, after the scan completes and new apps are registered, check the setting:
let auto_integrate = settings.boolean("auto-integrate");
if auto_integrate {
for record in newly_discovered {
if !record.integrated {
match integrator::integrate(&record) {
Ok(result) => {
db.set_integrated(record.id, true, Some(&result.desktop_file_path.to_string_lossy())).ok();
db.register_modification(record.id, "desktop_file", &result.desktop_file_path.to_string_lossy(), None).ok();
if let Some(ref icon) = result.icon_path {
db.register_modification(record.id, "icon", &icon.to_string_lossy(), None).ok();
}
}
Err(e) => log::warn!("Auto-integrate failed for {}: {}", record.filename, e),
}
}
}
}
Step 2: Ensure toggle exists in preferences
Check that preferences.rs has a switch for auto-integrate. If not, add one in the Behavior page.
Step 3: Build, verify, commit
git add src/window.rs src/ui/preferences.rs
git commit -m "Wire auto-integrate setting to scan pipeline"
Task 8: Batch Operations (F6)
Files:
- Modify:
src/ui/library_view.rs - Modify:
src/ui/app_card.rs - Modify:
src/window.rs
Step 1: Add selection state to LibraryView
Add a selection_mode: RefCell<bool> and selected_ids: RefCell<HashSet<i64>> to the LibraryView struct.
Step 2: Add "Select" toggle button to library header
When toggled on: show checkboxes on each app card, show bottom action bar.
Step 3: Add check button to app_card
When library is in selection mode, show a gtk::CheckButton on each card. On toggle, add/remove from selected_ids.
Step 4: Add bottom action bar
let action_bar = gtk::ActionBar::new();
let integrate_btn = gtk::Button::with_label("Integrate");
let delete_btn = gtk::Button::builder()
.label("Delete")
.css_classes(["destructive-action"])
.build();
action_bar.pack_start(&integrate_btn);
action_bar.pack_end(&delete_btn);
Step 5: Wire batch actions
Each action iterates selected_ids, performs the operation, refreshes UI.
Delete uses undo_all_modifications() from Task 1 for each app.
Step 6: Build, verify, commit
git add src/ui/library_view.rs src/ui/app_card.rs src/window.rs
git commit -m "Add batch selection and bulk operations to library view"
Task 9: Autostart Manager (F8)
Files:
- Modify:
src/core/integrator.rs - Modify:
src/core/database.rs - Modify:
src/ui/detail_view.rs
Step 1: Add autostart functions to integrator.rs
pub fn autostart_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| crate::config::home_dir().join(".config"))
.join("autostart")
}
pub fn enable_autostart(db: &Database, record: &AppImageRecord) -> Result<PathBuf, String> {
let dir = autostart_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create autostart dir: {}", e))?;
let desktop_filename = format!("driftwood-{}.desktop", record.id);
let desktop_path = dir.join(&desktop_filename);
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
let icon = record.icon_path.as_deref().unwrap_or("application-x-executable");
let content = format!(
"[Desktop Entry]\nType=Application\nName={}\nExec={}\nIcon={}\nX-GNOME-Autostart-enabled=true\nX-Driftwood-AppImage-ID={}\n",
app_name, record.path, icon, record.id
);
std::fs::write(&desktop_path, &content)
.map_err(|e| format!("Failed to write autostart file: {}", e))?;
db.register_modification(record.id, "autostart", &desktop_path.to_string_lossy(), None)
.map_err(|e| format!("Failed to register modification: {}", e))?;
db.set_autostart(record.id, true).ok();
Ok(desktop_path)
}
pub fn disable_autostart(db: &Database, record_id: i64) -> Result<(), String> {
let mods = db.get_modifications(record_id).unwrap_or_default();
for m in &mods {
if m.mod_type == "autostart" {
let path = std::path::Path::new(&m.file_path);
if path.exists() {
std::fs::remove_file(path).ok();
}
db.remove_modification(m.id).ok();
}
}
db.set_autostart(record_id, false).ok();
Ok(())
}
Step 2: Add DB helpers
pub fn set_autostart(&self, id: i64, enabled: bool) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET autostart = ?2 WHERE id = ?1",
params![id, enabled as i32],
)?;
Ok(())
}
Step 3: Add toggle to detail view system tab
let autostart_row = adw::SwitchRow::builder()
.title("Start at login")
.subtitle("Launch this app automatically when you log in")
.active(record.autostart)
.build();
Connect the switch to enable_autostart/disable_autostart.
Step 4: Build, verify, commit
git add src/core/integrator.rs src/core/database.rs src/ui/detail_view.rs
git commit -m "Add autostart manager with XDG autostart support"
Task 10: System Notification Integration (F9)
Files:
- Modify:
src/core/notification.rs - Modify:
src/window.rs
Step 1: Add gio::Notification helpers
pub fn send_system_notification(app: >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:
notification::send_system_notification(
&app, "crash", &format!("{} crashed", app_name),
&stderr, gio::NotificationPriority::Urgent,
);
Similarly for update checks and security findings.
Step 3: Build, verify, commit
git add src/core/notification.rs src/window.rs
git commit -m "Add system notification support for crashes and updates"
Task 11: Storage Dashboard per App (F10)
Files:
- Modify:
src/core/footprint.rs - Modify:
src/ui/detail_view.rs - Modify:
src/core/database.rs
Step 1: Add FootprintSummary struct and query
#[derive(Debug, Clone, Default)]
pub struct FootprintSummary {
pub binary_size: u64,
pub config_size: u64,
pub cache_size: u64,
pub data_size: u64,
pub state_size: u64,
pub total_size: u64,
}
pub fn get_footprint_summary(db: &Database, record_id: i64, binary_size: u64) -> FootprintSummary {
let paths = db.get_data_paths(record_id).unwrap_or_default();
let mut summary = FootprintSummary { binary_size, ..Default::default() };
for p in &paths {
let size = p.size_bytes.unwrap_or(0) as u64;
match p.path_type.as_str() {
"config" => summary.config_size += size,
"cache" => summary.cache_size += size,
"data" => summary.data_size += size,
"state" => summary.state_size += size,
_ => {}
}
}
summary.total_size = summary.binary_size + summary.config_size + summary.cache_size + summary.data_size + summary.state_size;
summary
}
Step 2: Display in detail view storage tab
Show each category with human-readable size and path. Add "Clean cache" button.
Step 3: Build, verify, commit
git add src/core/footprint.rs src/ui/detail_view.rs src/core/database.rs
git commit -m "Add per-app storage breakdown to detail view"
Task 12: Background Update Checks (F11)
Files:
- Modify:
data/app.driftwood.Driftwood.gschema.xml - Modify:
src/window.rs - Modify:
src/ui/dashboard.rs - Modify:
src/ui/preferences.rs
Step 1: Add GSettings keys
<key name="update-check-interval-hours" type="i">
<range min="1" max="168"/>
<default>24</default>
<summary>Update check interval</summary>
<description>Hours between automatic update checks.</description>
</key>
<key name="last-update-check" type="s">
<default>''</default>
<summary>Last update check timestamp</summary>
<description>ISO timestamp of the last automatic update check.</description>
</key>
Step 2: Check on startup in window.rs
After initial scan, if auto-check-updates is true and enough time has elapsed since last-update-check, spawn background update check for all apps.
Step 3: Show results on dashboard
"X updates available - Last checked: 2h ago"
Step 4: Add interval setting to preferences
ComboRow or SpinRow for the interval.
Step 5: Build, verify, commit
git add data/app.driftwood.Driftwood.gschema.xml src/window.rs src/ui/dashboard.rs src/ui/preferences.rs
git commit -m "Add background update checking with configurable interval"
Task 13: One-Click Update All (F12)
Files:
- Create:
src/ui/batch_update_dialog.rs - Modify:
src/ui/mod.rs - Modify:
src/ui/dashboard.rs - Modify:
src/core/updater.rs
Step 1: Create batch_update_dialog.rs
Dialog showing list of apps with updates, progress bars, cancel button. Uses existing updater::download_update() per app. Saves old version as .prev (integrates with F3 rollback).
Step 2: Add "Update All" button to dashboard
Only visible when updates are available. Opens the batch update dialog.
Step 3: Build, verify, commit
git add src/ui/batch_update_dialog.rs src/ui/mod.rs src/ui/dashboard.rs
git commit -m "Add one-click Update All with batch progress dialog"
Task 14: Full Uninstall with Data Cleanup (F13)
Files:
- Modify:
src/ui/detail_view.rs - Modify:
src/core/footprint.rs
Step 1: Replace simple delete with full uninstall dialog
When user clicks delete on an AppImage:
- Get
FootprintSummary(from Task 11) - Show dialog with checkboxes:
- AppImage file (size)
- Desktop integration (if integrated)
- Configuration (path - size)
- Cache (path - size)
- Data (path - size)
- On confirm:
undo_all_modifications(db, record_id)- removes .desktop, icons, autostart, MIME- Delete checked data paths
- Delete AppImage file
db.delete_appimage(record_id)
Step 2: Build, verify, commit
git add src/ui/detail_view.rs src/core/footprint.rs
git commit -m "Add full uninstall with data cleanup options"
Task 15: Icon Preview in Drop Dialog (F14)
Files:
- Modify:
src/core/inspector.rs - Modify:
src/ui/drop_dialog.rs
Step 1: Add fast icon extraction
pub fn extract_icon_fast(path: &Path) -> Option<PathBuf> {
// Use unsquashfs to list files, find icon, extract just that one
let output = Command::new("unsquashfs")
.args(["-l", &path.to_string_lossy()])
.output().ok()?;
// Parse output for .png or .svg icon, extract to temp dir
// Return path to extracted icon
}
Step 2: Show preview in drop dialog after registration
After Phase 1 registration completes, update the toast to include a small preview if icon was found.
Step 3: Build, verify, commit
git add src/core/inspector.rs src/ui/drop_dialog.rs
git commit -m "Add icon preview to drag-and-drop dialog"
Task 16: FUSE Fix Wizard (F15)
Files:
- Create:
src/ui/fuse_wizard.rs - Modify:
src/ui/mod.rs - Modify:
src/core/fuse.rs - Modify:
src/ui/dashboard.rs - Create:
data/app.driftwood.Driftwood.policy
Step 1: Add distro detection to fuse.rs
pub struct DistroInfo {
pub id: String,
pub id_like: Vec<String>,
pub version_id: String,
}
pub fn detect_distro() -> Option<DistroInfo> {
let content = std::fs::read_to_string("/etc/os-release").ok()?;
// Parse ID=, ID_LIKE=, VERSION_ID=
}
pub fn get_fuse_install_command(distro: &DistroInfo) -> Option<String> {
let family = if distro.id == "ubuntu" || distro.id_like.contains(&"ubuntu".to_string()) || distro.id_like.contains(&"debian".to_string()) {
"debian"
} else if distro.id == "fedora" || distro.id_like.contains(&"fedora".to_string()) || distro.id_like.contains(&"rhel".to_string()) {
"fedora"
} else if distro.id == "arch" || distro.id_like.contains(&"arch".to_string()) {
"arch"
} else if distro.id == "opensuse" || distro.id.starts_with("opensuse") {
"suse"
} else {
return None;
};
Some(match family {
"debian" => "apt install -y libfuse2t64 || apt install -y libfuse2".to_string(),
"fedora" => "dnf install -y fuse-libs".to_string(),
"arch" => "pacman -S --noconfirm fuse2".to_string(),
"suse" => "zypper install -y libfuse2".to_string(),
_ => return None,
})
}
Step 2: Create polkit policy file
data/app.driftwood.Driftwood.policy - standard polkit XML for privileged install.
Step 3: Create fuse_wizard.rs
Multi-step dialog: explain -> show command -> run via pkexec -> verify.
Step 4: Add FUSE banner to dashboard
When FUSE is missing, show a yellow info banner with "Fix now" button.
Step 5: Build, verify, commit
git add src/ui/fuse_wizard.rs src/ui/mod.rs src/core/fuse.rs src/ui/dashboard.rs data/app.driftwood.Driftwood.policy
git commit -m "Add FUSE fix wizard with distro detection and pkexec install"
Task 17: File Type Association Manager (F16)
Files:
- Modify:
src/core/integrator.rs - Modify:
src/ui/detail_view.rs
Step 1: Parse MimeType from desktop entry
pub fn parse_mime_types(desktop_entry: &str) -> Vec<String> {
for line in desktop_entry.lines() {
if line.starts_with("MimeType=") {
return line[9..].split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
}
}
Vec::new()
}
Step 2: Include MimeType in generated .desktop files
When integrating, if the original desktop entry has MimeType, include it.
Step 3: Add file type section to detail view
Show list of MIME types with "Set as default" toggle per type. Before setting, query current default with xdg-mime query default {type}, store in system_modifications.
Step 4: Build, verify, commit
git add src/core/integrator.rs src/ui/detail_view.rs
git commit -m "Add file type association manager with MIME type support"
Task 18: Taskbar Icon Fix - StartupWMClass (F17)
Files:
- Modify:
src/core/integrator.rs - Modify:
src/core/inspector.rs - Modify:
src/ui/detail_view.rs
Step 1: Extract StartupWMClass during inspection
Parse StartupWMClass= from embedded .desktop entry. Store in the new startup_wm_class DB column.
Step 2: Include in generated .desktop files
Add StartupWMClass={value} to generated desktop entries when available.
Step 3: Show in detail view with manual override option
Step 4: Build, verify, commit
git add src/core/integrator.rs src/core/inspector.rs src/ui/detail_view.rs
git commit -m "Extract and apply StartupWMClass for proper taskbar icons"
Task 19: Download Verification Helper (F18)
Files:
- Create:
src/core/verification.rs - Modify:
src/core/mod.rs - Modify:
src/ui/detail_view.rs
Step 1: Create verification module
pub enum VerificationStatus {
SignedValid { signer: String },
SignedInvalid { reason: String },
Unsigned,
ChecksumMatch,
ChecksumMismatch,
NotChecked,
}
pub fn check_embedded_signature(path: &Path) -> VerificationStatus {
// Try --appimage-signature flag
let output = Command::new(path)
.arg("--appimage-signature")
.output();
// Parse result
}
pub fn compute_sha256(path: &Path) -> Result<String, String> {
use sha2::{Sha256, Digest};
// Read file and compute hash
}
pub fn verify_sha256(path: &Path, expected: &str) -> VerificationStatus {
match compute_sha256(path) {
Ok(hash) if hash == expected.to_lowercase() => VerificationStatus::ChecksumMatch,
Ok(_) => VerificationStatus::ChecksumMismatch,
Err(_) => VerificationStatus::NotChecked,
}
}
Step 2: Display in detail view
Show verification badge with status. Add manual SHA256 input field.
Step 3: Build, verify, commit
git add src/core/verification.rs src/core/mod.rs src/ui/detail_view.rs
git commit -m "Add download verification with signature and SHA256 support"
Task 20: First-Run Permission Summary (F19)
Files:
- Create:
src/ui/permission_dialog.rs - Modify:
src/ui/mod.rs - Modify:
src/ui/detail_view.rs
Step 1: Create permission dialog
Shows before first launch: "This app will run with full system access" with sandbox option if firejail is available.
Step 2: Wire into launch handler
Before launching, check first_run_prompted. If false, show dialog first.
Step 3: Build, verify, commit
git add src/ui/permission_dialog.rs src/ui/mod.rs src/ui/detail_view.rs
git commit -m "Add first-run permission summary dialog"
Task 21: Default App Selector (F20)
Files:
- Modify:
src/core/integrator.rs - Modify:
src/ui/detail_view.rs
Step 1: Detect app capabilities from categories
pub fn detect_default_capabilities(categories: &str) -> Vec<DefaultAppType> {
let mut caps = Vec::new();
if categories.contains("WebBrowser") { caps.push(DefaultAppType::WebBrowser); }
if categories.contains("Email") { caps.push(DefaultAppType::EmailClient); }
if categories.contains("FileManager") { caps.push(DefaultAppType::FileManager); }
caps
}
Step 2: Add "Set as default" buttons
For each capability, show a button. On click: query current default, store in system_modifications, set new default via xdg-settings/xdg-mime.
Step 3: Build, verify, commit
git add src/core/integrator.rs src/ui/detail_view.rs
git commit -m "Add default application selector for browsers and mail clients"
Task 22: Multi-User System-Wide Install (F21)
Files:
- Modify:
src/core/integrator.rs - Modify:
src/ui/detail_view.rs - Modify:
data/app.driftwood.Driftwood.policy
Step 1: Add system-wide install function
Uses pkexec to copy to /opt/driftwood-apps/ and .desktop to /usr/share/applications/.
Step 2: Add polkit action for system install
Add app.driftwood.Driftwood.system-install action to policy file.
Step 3: Add UI toggle in detail view
"Install system-wide" button, requires integration first.
Step 4: Track all system paths in system_modifications
Step 5: Build, verify, commit
git add src/core/integrator.rs src/ui/detail_view.rs data/app.driftwood.Driftwood.policy
git commit -m "Add system-wide installation via pkexec"
Task 23: CLI Enhancements (F22)
Files:
- Modify:
src/cli.rs
Step 1: Add new commands
driftwood install <url>- download, validate, register, integratedriftwood update --all- update all apps with available updatesdriftwood autostart <path> --enable/--disabledriftwood purge- remove all system modificationsdriftwood verify <path>- check signature/hash
Step 2: Implement each command
purge calls db.get_all_modifications() and undoes each one.
Step 3: Build, verify, commit
git add src/cli.rs
git commit -m "Add install, update-all, autostart, purge, and verify CLI commands"
Task 24: Portable Mode / USB Drive Support (F23)
Files:
- Create:
src/core/portable.rs - Modify:
src/core/mod.rs - Modify:
src/ui/library_view.rs - Modify:
src/window.rs - Modify:
data/app.driftwood.Driftwood.gschema.xml
Step 1: Add GSettings key
<key name="watch-removable-media" type="b">
<default>false</default>
<summary>Watch removable media</summary>
<description>Scan removable drives for AppImages when mounted.</description>
</key>
Step 2: Create portable.rs
pub struct MountInfo {
pub device: String,
pub mount_point: PathBuf,
pub fs_type: String,
}
pub fn detect_removable_mounts() -> Vec<MountInfo> {
// Parse /proc/mounts for media/run/media paths with vfat/exfat/ntfs
}
pub fn is_path_on_removable(path: &Path) -> bool {
let mounts = detect_removable_mounts();
mounts.iter().any(|m| path.starts_with(&m.mount_point))
}
Step 3: Add "Portable" filter to library view
Step 4: Watch for mount/unmount events
Use gio::VolumeMonitor or poll /proc/mounts.
Step 5: Build, verify, commit
git add src/core/portable.rs src/core/mod.rs src/ui/library_view.rs src/window.rs data/app.driftwood.Driftwood.gschema.xml
git commit -m "Add portable mode with removable media detection"
Task 25: Similar App Recommendations (F24)
Files:
- Modify:
src/ui/detail_view.rs - Modify:
src/core/database.rs
Step 1: Add catalog query for similar apps
pub fn find_similar_catalog_apps(&self, categories: &str, exclude_name: &str, limit: i32) -> SqlResult<Vec<CatalogApp>> {
// Match on shared categories, exclude current app
}
Step 2: Show "You might also like" section in detail view
Only shown when catalog has data (after F26).
Step 3: Build, verify, commit
git add src/ui/detail_view.rs src/core/database.rs
git commit -m "Add similar app recommendations from catalog"
Task 26: AppImageHub In-App Catalog Browser (F25)
Files:
- Create:
src/ui/catalog_view.rs - Create:
src/ui/catalog_detail.rs - Modify:
src/ui/mod.rs - Modify:
src/core/database.rs - Modify:
src/window.rs - Modify:
data/app.driftwood.Driftwood.gschema.xml
This is the largest feature. Break into sub-steps:
Step 1: Add GSettings key
<key name="catalog-last-refreshed" type="s">
<default>''</default>
<summary>Catalog last refreshed</summary>
<description>ISO timestamp of the last catalog refresh.</description>
</key>
Step 2: Add catalog DB methods
upsert_catalog_source(name, url, source_type) -> i64upsert_catalog_app(source_id, name, description, categories, download_url, icon_url, homepage, license) -> i64search_catalog(query: &str, category: Option<&str>, limit: i32) -> Vec<CatalogApp>get_catalog_app(id: i64) -> Option<CatalogApp>get_catalog_categories() -> Vec<(String, u32)>- categories with counts
Add CatalogApp struct:
pub struct CatalogApp {
pub id: i64,
pub name: String,
pub description: Option<String>,
pub categories: Option<String>,
pub download_url: String,
pub icon_url: Option<String>,
pub homepage: Option<String>,
pub license: Option<String>,
pub author: Option<String>,
}
Step 3: Add feed.json fetcher
pub fn refresh_catalog(db: &Database) -> Result<usize, String> {
let body = reqwest::blocking::get("https://appimage.github.io/feed.json")
.map_err(|e| format!("Failed to fetch catalog: {}", e))?
.text()
.map_err(|e| format!("Failed to read response: {}", e))?;
let feed: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse feed: {}", e))?;
let source_id = db.upsert_catalog_source("AppImageHub", "https://appimage.github.io/feed.json", "feed_json")
.map_err(|e| format!("DB error: {}", e))?;
let items = feed["items"].as_array().ok_or("No items in feed")?;
let mut count = 0;
for item in items {
let name = item["name"].as_str().unwrap_or_default();
if name.is_empty() { continue; }
let description = item["description"].as_str().map(|s| s.to_string());
let categories = item["categories"].as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(";"));
let download_url = item["links"].as_array()
.and_then(|links| links.iter().find(|l| l["type"].as_str() == Some("Download")))
.and_then(|l| l["url"].as_str())
.unwrap_or_default();
let icon_url = item["icons"].as_array()
.and_then(|icons| icons.first())
.and_then(|i| i.as_str())
.map(|s| format!("https://appimage.github.io/database/{}", s));
let homepage = item["links"].as_array()
.and_then(|links| links.iter().find(|l| l["type"].as_str() == Some("GitHub")))
.and_then(|l| l["url"].as_str())
.map(|s| if s.contains("://") { s.to_string() } else { format!("https://github.com/{}", s) });
let license = item["license"].as_str().map(|s| s.to_string());
let author = item["authors"].as_array()
.and_then(|a| a.first())
.and_then(|a| a["name"].as_str())
.map(|s| s.to_string());
if download_url.is_empty() { continue; }
db.upsert_catalog_app(
source_id, name,
description.as_deref(), categories.as_deref(),
download_url, icon_url.as_deref(), homepage.as_deref(), license.as_deref(),
).ok();
count += 1;
}
Ok(count)
}
Note: Check if reqwest is already a dependency. If not, use gio or soup for HTTP. Or use std::process::Command to call curl.
Step 4: Create catalog_view.rs
NavigationPage with:
- Search entry at top
- Category filter chips (horizontal scrollable)
- Grid of catalog app cards
- Each card: icon, name, short description
- Click pushes catalog_detail page
Step 5: Create catalog_detail.rs
NavigationPage with:
- Large icon
- App name, author, license
- Full description (rendered from HTML to plain text)
- Screenshots if available
- "Install" button (suggested style)
- Source link button
Step 6: Wire catalog install flow
Install button:
- Resolve download URL (for GitHub, fetch latest release API)
- Download AppImage with progress
- Validate magic bytes
- Move to ~/Applications, set executable
- Register in DB with
source_url - Run analysis pipeline
- Optionally integrate
- Navigate to library detail view
Step 7: Add "Catalog" to main navigation
In window.rs, add Catalog entry to the navigation. Can be a tab or sidebar item.
Step 8: Build, verify, commit
git add src/ui/catalog_view.rs src/ui/catalog_detail.rs src/ui/mod.rs src/core/database.rs src/window.rs data/app.driftwood.Driftwood.gschema.xml
git commit -m "Add AppImageHub in-app catalog browser with search and install"
Verification Checklist
After all tasks are complete:
cargo build- zero errors, zero warningscargo run- app launches, all features accessible- 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
driftwood purge- removes all system modifications cleanly