From 8dd0dc71ed944630bca48aea0788cb2163448ddf Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 23:35:27 +0200 Subject: [PATCH] Add system modification tracking for reversible installs --- src/cli.rs | 5 +- src/core/analysis.rs | 2 +- src/core/database.rs | 114 ++++++++++++++++++++++++++++++++++- src/core/duplicates.rs | 9 +++ src/core/integrator.rs | 78 +++++++++++++++++++++++- src/ui/detail_view.rs | 1 + src/ui/duplicate_dialog.rs | 3 +- src/ui/integration_dialog.rs | 2 +- src/window.rs | 3 +- 9 files changed, 207 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 444a010..1eb55dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -312,7 +312,7 @@ fn cmd_integrate(db: &Database, path: &str) -> ExitCode { return ExitCode::SUCCESS; } - match integrator::integrate(&record) { + match integrator::integrate_tracked(&record, &db) { Ok(result) => { db.set_integrated( record.id, @@ -351,6 +351,7 @@ fn cmd_remove(db: &Database, path: &str) -> ExitCode { return ExitCode::SUCCESS; } + integrator::undo_all_modifications(&db, record.id).ok(); match integrator::remove_integration(&record) { Ok(()) => { db.set_integrated(record.id, false, None).ok(); @@ -858,7 +859,7 @@ fn cmd_import(db: &Database, file: &str) -> ExitCode { // Need the full record to integrate if let Ok(Some(record)) = db.get_appimage_by_id(id) { if !record.integrated { - match integrator::integrate(&record) { + match integrator::integrate_tracked(&record, &db) { Ok(result) => { db.set_integrated( id, diff --git a/src/core/analysis.rs b/src/core/analysis.rs index d55876d..7ea8168 100644 --- a/src/core/analysis.rs +++ b/src/core/analysis.rs @@ -183,7 +183,7 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy // Integrate if requested if integrate { - match integrator::integrate(&rec) { + match integrator::integrate_tracked(&rec, &db) { Ok(result) => { let desktop_path = result.desktop_file_path.to_string_lossy().to_string(); if let Err(e) = db.set_integrated(id, true, Some(&desktop_path)) { diff --git a/src/core/database.rs b/src/core/database.rs index e0e646d..b17b235 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -68,6 +68,24 @@ pub struct AppImageRecord { pub desktop_actions: Option, pub has_signature: bool, pub screenshot_urls: Option, + // Phase 11 fields - system modification tracking + pub previous_version_path: Option, + pub source_url: Option, + pub autostart: bool, + pub startup_wm_class: Option, + pub verification_status: Option, + pub first_run_prompted: bool, + pub system_wide: bool, + pub is_portable: bool, + pub mount_point: Option, +} + +#[derive(Debug, Clone)] +pub struct SystemModification { + pub id: i64, + pub mod_type: String, + pub file_path: String, + pub previous_value: Option, } #[derive(Debug, Clone)] @@ -342,6 +360,10 @@ impl Database { self.migrate_to_v10()?; } + if current_version < 11 { + self.migrate_to_v11()?; + } + // Ensure all expected columns exist (repairs DBs where a migration // was updated after it had already run on this database) self.ensure_columns()?; @@ -743,6 +765,41 @@ impl Database { Ok(()) } + 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);" + )?; + 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(()) + } + pub fn upsert_appimage( &self, path: &str, @@ -904,7 +961,9 @@ impl Database { appstream_id, appstream_description, generic_name, license, homepage_url, bugtracker_url, donation_url, help_url, vcs_url, keywords, mime_types, content_rating, project_group, - release_history, desktop_actions, has_signature, screenshot_urls"; + release_history, desktop_actions, has_signature, screenshot_urls, + previous_version_path, source_url, autostart, startup_wm_class, + verification_status, first_run_prompted, system_wide, is_portable, mount_point"; fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result { Ok(AppImageRecord { @@ -962,6 +1021,15 @@ impl Database { desktop_actions: row.get(51).unwrap_or(None), has_signature: row.get::<_, bool>(52).unwrap_or(false), screenshot_urls: row.get(53).unwrap_or(None), + previous_version_path: row.get(54).unwrap_or(None), + source_url: row.get(55).unwrap_or(None), + autostart: row.get::<_, bool>(56).unwrap_or(false), + startup_wm_class: row.get(57).unwrap_or(None), + verification_status: row.get(58).unwrap_or(None), + first_run_prompted: row.get::<_, bool>(59).unwrap_or(false), + system_wide: row.get::<_, bool>(60).unwrap_or(false), + is_portable: row.get::<_, bool>(61).unwrap_or(false), + mount_point: row.get(62).unwrap_or(None), }) } @@ -1675,6 +1743,47 @@ impl Database { )?; Ok(self.conn.last_insert_rowid()) } + + // --- System modification tracking --- + + 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 remove_modification(&self, id: i64) -> SqlResult<()> { + self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?; + Ok(()) + } } #[cfg(test)] @@ -1845,7 +1954,7 @@ mod tests { [], |row| row.get(0), ).unwrap(); - assert_eq!(version, 10); + assert_eq!(version, 11); // All tables that should exist after the full v1-v7 migration chain let expected_tables = [ @@ -1868,6 +1977,7 @@ mod tests { "sandbox_profiles", "sandbox_profile_history", "runtime_updates", + "system_modifications", ]; for table in &expected_tables { diff --git a/src/core/duplicates.rs b/src/core/duplicates.rs index fed8c76..6d97127 100644 --- a/src/core/duplicates.rs +++ b/src/core/duplicates.rs @@ -438,6 +438,15 @@ mod tests { desktop_actions: None, has_signature: false, screenshot_urls: None, + previous_version_path: None, + source_url: None, + autostart: false, + startup_wm_class: None, + verification_status: None, + first_run_prompted: false, + system_wide: false, + is_portable: false, + mount_point: None, }; assert_eq!( diff --git a/src/core/integrator.rs b/src/core/integrator.rs index 433b205..44d7d1a 100644 --- a/src/core/integrator.rs +++ b/src/core/integrator.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -use super::database::AppImageRecord; +use super::database::{AppImageRecord, Database}; #[derive(Debug)] pub enum IntegrationError { @@ -46,7 +46,7 @@ pub struct IntegrationResult { pub icon_install_path: Option, } -fn applications_dir() -> PathBuf { +pub fn applications_dir() -> PathBuf { crate::config::data_dir_fallback() .join("applications") } @@ -220,6 +220,71 @@ fn remove_icon_files(icon_id: &str) { } } +/// Integrate and track all created files in the system_modifications table. +pub fn integrate_tracked(record: &AppImageRecord, db: &Database) -> Result { + let result = integrate(record)?; + + // Register desktop file + db.register_modification( + record.id, + "desktop_file", + &result.desktop_file_path.to_string_lossy(), + None, + ).ok(); + + // Register icon file + if let Some(ref icon_path) = result.icon_install_path { + db.register_modification( + record.id, + "icon", + &icon_path.to_string_lossy(), + None, + ).ok(); + } + + Ok(result) +} + +/// Undo all tracked system modifications for an AppImage. +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 = Path::new(&m.file_path); + if path.exists() { + if let Err(e) = fs::remove_file(path) { + log::warn!("Failed to remove {}: {}", m.file_path, e); + } + } + } + "mime_default" => { + if let Some(ref prev) = m.previous_value { + let _ = Command::new("xdg-mime") + .args(["default", prev, &m.file_path]) + .status(); + } + } + "system_desktop" | "system_icon" | "system_binary" => { + let _ = 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 + update_desktop_database(); + + Ok(()) +} + fn update_desktop_database() { let apps_dir = applications_dir(); Command::new("update-desktop-database") @@ -301,6 +366,15 @@ mod tests { desktop_actions: None, has_signature: false, screenshot_urls: None, + previous_version_path: None, + source_url: None, + autostart: false, + startup_wm_class: None, + verification_status: None, + first_run_prompted: false, + system_wide: false, + is_portable: false, + mount_point: None, }; // We can't easily test the full integrate() without mocking dirs, diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 4105e2c..ac06f7e 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -969,6 +969,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & }, ); } else { + integrator::undo_all_modifications(&db_ref, record_id).ok(); integrator::remove_integration(&record_clone).ok(); db_ref.set_integrated(record_id, false, None).ok(); } diff --git a/src/ui/duplicate_dialog.rs b/src/ui/duplicate_dialog.rs index d4f5657..4cab7eb 100644 --- a/src/ui/duplicate_dialog.rs +++ b/src/ui/duplicate_dialog.rs @@ -131,7 +131,7 @@ pub fn show_duplicate_dialog( let mut removed_count = 0; for (record_id, record_path, _record_name, integrated) in records.iter() { if *integrated { - // Fetch the full record to properly remove integration + integrator::undo_all_modifications(&db_confirm, *record_id).ok(); if let Ok(Some(full_record)) = db_confirm.get_appimage_by_id(*record_id) { integrator::remove_integration(&full_record).ok(); } @@ -260,6 +260,7 @@ fn build_group_widget( delete_btn.connect_clicked(move |btn| { // Remove integration if any if record_clone.integrated { + integrator::undo_all_modifications(&db_ref, record_id).ok(); integrator::remove_integration(&record_clone).ok(); db_ref.set_integrated(record_id, false, None).ok(); } diff --git a/src/ui/integration_dialog.rs b/src/ui/integration_dialog.rs index 276e67f..c0ddb21 100644 --- a/src/ui/integration_dialog.rs +++ b/src/ui/integration_dialog.rs @@ -192,7 +192,7 @@ pub fn show_integration_dialog( dialog.connect_response(None, move |_dialog, response| { if response == "integrate" { - match integrator::integrate(&record_clone) { + match integrator::integrate_tracked(&record_clone, &db_ref) { Ok(result) => { if let Some(ref icon_path) = result.icon_install_path { log::info!("Icon installed to: {}", icon_path.display()); diff --git a/src/window.rs b/src/window.rs index 7fb9d42..192b888 100644 --- a/src/window.rs +++ b/src/window.rs @@ -742,11 +742,12 @@ impl DriftwoodWindow { let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { if record.integrated { + integrator::undo_all_modifications(&db, record_id).ok(); integrator::remove_integration(&record).ok(); db.set_integrated(record_id, false, None).ok(); toast_overlay.add_toast(adw::Toast::new("Integration removed")); } else { - match integrator::integrate(&record) { + match integrator::integrate_tracked(&record, &db) { Ok(result) => { let desktop_path = result.desktop_file_path.to_string_lossy().to_string(); db.set_integrated(record_id, true, Some(&desktop_path)).ok();