Add system modification tracking for reversible installs

This commit is contained in:
lashman
2026-02-27 23:35:27 +02:00
parent 6e2e7e8e36
commit 8dd0dc71ed
9 changed files with 207 additions and 10 deletions

View File

@@ -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)) {

View File

@@ -68,6 +68,24 @@ pub struct AppImageRecord {
pub desktop_actions: Option<String>,
pub has_signature: bool,
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)]
@@ -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<AppImageRecord> {
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<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)]
@@ -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 {

View File

@@ -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!(

View File

@@ -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<PathBuf>,
}
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<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() {
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,