Add system modification tracking for reversible installs
This commit is contained in:
@@ -312,7 +312,7 @@ fn cmd_integrate(db: &Database, path: &str) -> ExitCode {
|
|||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
match integrator::integrate(&record) {
|
match integrator::integrate_tracked(&record, &db) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
db.set_integrated(
|
db.set_integrated(
|
||||||
record.id,
|
record.id,
|
||||||
@@ -351,6 +351,7 @@ fn cmd_remove(db: &Database, path: &str) -> ExitCode {
|
|||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
integrator::undo_all_modifications(&db, record.id).ok();
|
||||||
match integrator::remove_integration(&record) {
|
match integrator::remove_integration(&record) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
db.set_integrated(record.id, false, None).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
|
// Need the full record to integrate
|
||||||
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
|
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
|
||||||
if !record.integrated {
|
if !record.integrated {
|
||||||
match integrator::integrate(&record) {
|
match integrator::integrate_tracked(&record, &db) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
db.set_integrated(
|
db.set_integrated(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy
|
|||||||
|
|
||||||
// Integrate if requested
|
// Integrate if requested
|
||||||
if integrate {
|
if integrate {
|
||||||
match integrator::integrate(&rec) {
|
match integrator::integrate_tracked(&rec, &db) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
||||||
if let Err(e) = db.set_integrated(id, true, Some(&desktop_path)) {
|
if let Err(e) = db.set_integrated(id, true, Some(&desktop_path)) {
|
||||||
|
|||||||
@@ -68,6 +68,24 @@ pub struct AppImageRecord {
|
|||||||
pub desktop_actions: Option<String>,
|
pub desktop_actions: Option<String>,
|
||||||
pub has_signature: bool,
|
pub has_signature: bool,
|
||||||
pub screenshot_urls: Option<String>,
|
pub screenshot_urls: Option<String>,
|
||||||
|
// Phase 11 fields - system modification tracking
|
||||||
|
pub previous_version_path: Option<String>,
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
pub autostart: bool,
|
||||||
|
pub startup_wm_class: Option<String>,
|
||||||
|
pub verification_status: Option<String>,
|
||||||
|
pub first_run_prompted: bool,
|
||||||
|
pub system_wide: bool,
|
||||||
|
pub is_portable: bool,
|
||||||
|
pub mount_point: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SystemModification {
|
||||||
|
pub id: i64,
|
||||||
|
pub mod_type: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub previous_value: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -342,6 +360,10 @@ impl Database {
|
|||||||
self.migrate_to_v10()?;
|
self.migrate_to_v10()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if current_version < 11 {
|
||||||
|
self.migrate_to_v11()?;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure all expected columns exist (repairs DBs where a migration
|
// Ensure all expected columns exist (repairs DBs where a migration
|
||||||
// was updated after it had already run on this database)
|
// was updated after it had already run on this database)
|
||||||
self.ensure_columns()?;
|
self.ensure_columns()?;
|
||||||
@@ -743,6 +765,41 @@ impl Database {
|
|||||||
Ok(())
|
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(
|
pub fn upsert_appimage(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -904,7 +961,9 @@ impl Database {
|
|||||||
appstream_id, appstream_description, generic_name, license,
|
appstream_id, appstream_description, generic_name, license,
|
||||||
homepage_url, bugtracker_url, donation_url, help_url, vcs_url,
|
homepage_url, bugtracker_url, donation_url, help_url, vcs_url,
|
||||||
keywords, mime_types, content_rating, project_group,
|
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<AppImageRecord> {
|
fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<AppImageRecord> {
|
||||||
Ok(AppImageRecord {
|
Ok(AppImageRecord {
|
||||||
@@ -962,6 +1021,15 @@ impl Database {
|
|||||||
desktop_actions: row.get(51).unwrap_or(None),
|
desktop_actions: row.get(51).unwrap_or(None),
|
||||||
has_signature: row.get::<_, bool>(52).unwrap_or(false),
|
has_signature: row.get::<_, bool>(52).unwrap_or(false),
|
||||||
screenshot_urls: row.get(53).unwrap_or(None),
|
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())
|
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<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 remove_modification(&self, id: i64) -> SqlResult<()> {
|
||||||
|
self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1845,7 +1954,7 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
).unwrap();
|
).unwrap();
|
||||||
assert_eq!(version, 10);
|
assert_eq!(version, 11);
|
||||||
|
|
||||||
// All tables that should exist after the full v1-v7 migration chain
|
// All tables that should exist after the full v1-v7 migration chain
|
||||||
let expected_tables = [
|
let expected_tables = [
|
||||||
@@ -1868,6 +1977,7 @@ mod tests {
|
|||||||
"sandbox_profiles",
|
"sandbox_profiles",
|
||||||
"sandbox_profile_history",
|
"sandbox_profile_history",
|
||||||
"runtime_updates",
|
"runtime_updates",
|
||||||
|
"system_modifications",
|
||||||
];
|
];
|
||||||
|
|
||||||
for table in &expected_tables {
|
for table in &expected_tables {
|
||||||
|
|||||||
@@ -438,6 +438,15 @@ mod tests {
|
|||||||
desktop_actions: None,
|
desktop_actions: None,
|
||||||
has_signature: false,
|
has_signature: false,
|
||||||
screenshot_urls: None,
|
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!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use super::database::AppImageRecord;
|
use super::database::{AppImageRecord, Database};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum IntegrationError {
|
pub enum IntegrationError {
|
||||||
@@ -46,7 +46,7 @@ pub struct IntegrationResult {
|
|||||||
pub icon_install_path: Option<PathBuf>,
|
pub icon_install_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn applications_dir() -> PathBuf {
|
pub fn applications_dir() -> PathBuf {
|
||||||
crate::config::data_dir_fallback()
|
crate::config::data_dir_fallback()
|
||||||
.join("applications")
|
.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<IntegrationResult, IntegrationError> {
|
||||||
|
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() {
|
fn update_desktop_database() {
|
||||||
let apps_dir = applications_dir();
|
let apps_dir = applications_dir();
|
||||||
Command::new("update-desktop-database")
|
Command::new("update-desktop-database")
|
||||||
@@ -301,6 +366,15 @@ mod tests {
|
|||||||
desktop_actions: None,
|
desktop_actions: None,
|
||||||
has_signature: false,
|
has_signature: false,
|
||||||
screenshot_urls: None,
|
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,
|
// We can't easily test the full integrate() without mocking dirs,
|
||||||
|
|||||||
@@ -969,6 +969,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
integrator::undo_all_modifications(&db_ref, record_id).ok();
|
||||||
integrator::remove_integration(&record_clone).ok();
|
integrator::remove_integration(&record_clone).ok();
|
||||||
db_ref.set_integrated(record_id, false, None).ok();
|
db_ref.set_integrated(record_id, false, None).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ pub fn show_duplicate_dialog(
|
|||||||
let mut removed_count = 0;
|
let mut removed_count = 0;
|
||||||
for (record_id, record_path, _record_name, integrated) in records.iter() {
|
for (record_id, record_path, _record_name, integrated) in records.iter() {
|
||||||
if *integrated {
|
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) {
|
if let Ok(Some(full_record)) = db_confirm.get_appimage_by_id(*record_id) {
|
||||||
integrator::remove_integration(&full_record).ok();
|
integrator::remove_integration(&full_record).ok();
|
||||||
}
|
}
|
||||||
@@ -260,6 +260,7 @@ fn build_group_widget(
|
|||||||
delete_btn.connect_clicked(move |btn| {
|
delete_btn.connect_clicked(move |btn| {
|
||||||
// Remove integration if any
|
// Remove integration if any
|
||||||
if record_clone.integrated {
|
if record_clone.integrated {
|
||||||
|
integrator::undo_all_modifications(&db_ref, record_id).ok();
|
||||||
integrator::remove_integration(&record_clone).ok();
|
integrator::remove_integration(&record_clone).ok();
|
||||||
db_ref.set_integrated(record_id, false, None).ok();
|
db_ref.set_integrated(record_id, false, None).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ pub fn show_integration_dialog(
|
|||||||
|
|
||||||
dialog.connect_response(None, move |_dialog, response| {
|
dialog.connect_response(None, move |_dialog, response| {
|
||||||
if response == "integrate" {
|
if response == "integrate" {
|
||||||
match integrator::integrate(&record_clone) {
|
match integrator::integrate_tracked(&record_clone, &db_ref) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if let Some(ref icon_path) = result.icon_install_path {
|
if let Some(ref icon_path) = result.icon_install_path {
|
||||||
log::info!("Icon installed to: {}", icon_path.display());
|
log::info!("Icon installed to: {}", icon_path.display());
|
||||||
|
|||||||
@@ -742,11 +742,12 @@ impl DriftwoodWindow {
|
|||||||
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
||||||
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||||
if record.integrated {
|
if record.integrated {
|
||||||
|
integrator::undo_all_modifications(&db, record_id).ok();
|
||||||
integrator::remove_integration(&record).ok();
|
integrator::remove_integration(&record).ok();
|
||||||
db.set_integrated(record_id, false, None).ok();
|
db.set_integrated(record_id, false, None).ok();
|
||||||
toast_overlay.add_toast(adw::Toast::new("Integration removed"));
|
toast_overlay.add_toast(adw::Toast::new("Integration removed"));
|
||||||
} else {
|
} else {
|
||||||
match integrator::integrate(&record) {
|
match integrator::integrate_tracked(&record, &db) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
||||||
db.set_integrated(record_id, true, Some(&desktop_path)).ok();
|
db.set_integrated(record_id, true, Some(&desktop_path)).ok();
|
||||||
|
|||||||
Reference in New Issue
Block a user