Files
driftwood/src/core/integrator.rs

442 lines
13 KiB
Rust

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::database::{AppImageRecord, Database};
#[derive(Debug)]
pub enum IntegrationError {
IoError(std::io::Error),
NoAppName,
}
impl std::fmt::Display for IntegrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IoError(e) => write!(f, "I/O error: {}", e),
Self::NoAppName => write!(f, "Cannot integrate: no application name"),
}
}
}
impl From<std::io::Error> for IntegrationError {
fn from(e: std::io::Error) -> Self {
Self::IoError(e)
}
}
/// Escape a string for use inside a double-quoted Exec argument in a .desktop file.
/// Per the Desktop Entry spec, `\`, `"`, `` ` ``, and `$` must be escaped with `\`.
fn escape_exec_arg(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '"' | '`' | '$' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
pub struct IntegrationResult {
pub desktop_file_path: PathBuf,
pub icon_install_path: Option<PathBuf>,
}
pub fn applications_dir() -> PathBuf {
crate::config::data_dir_fallback()
.join("applications")
}
fn icons_dir() -> PathBuf {
crate::config::data_dir_fallback()
.join("icons/hicolor")
}
/// Generate a sanitized app ID.
pub fn make_app_id(app_name: &str) -> String {
let id: String = app_name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
id.trim_matches('-').to_string()
}
/// Integrate an AppImage: create .desktop file and install icon.
pub fn integrate(record: &AppImageRecord) -> Result<IntegrationResult, IntegrationError> {
let app_name = record
.app_name
.as_deref()
.or(Some(&record.filename))
.ok_or(IntegrationError::NoAppName)?;
let app_id = make_app_id(app_name);
let desktop_filename = format!("driftwood-{}.desktop", app_id);
let apps_dir = applications_dir();
fs::create_dir_all(&apps_dir)?;
let desktop_path = apps_dir.join(&desktop_filename);
// Build the .desktop file content
let categories = record
.categories
.as_deref()
.unwrap_or("");
let comment = record
.description
.as_deref()
.unwrap_or("");
let version = record
.app_version
.as_deref()
.unwrap_or("");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let icon_id = format!("driftwood-{}", app_id);
let desktop_content = format!("\
[Desktop Entry]
Type=Application
Name={name}
Exec=\"{exec}\" %U
Icon={icon}
Categories={categories}
Comment={comment}
Terminal=false
X-AppImage-Path={path}
X-AppImage-Version={version}
X-AppImage-Managed-By=Driftwood
X-AppImage-Integrated-Date={date}
",
name = app_name,
exec = escape_exec_arg(&record.path),
icon = icon_id,
categories = categories,
comment = comment,
path = record.path,
version = version,
date = now,
);
fs::write(&desktop_path, &desktop_content)?;
// Install icon if we have a cached one
let icon_install_path = if let Some(ref cached_icon) = record.icon_path {
let cached = Path::new(cached_icon);
if cached.exists() {
install_icon(cached, &icon_id)?
} else {
None
}
} else {
None
};
// Update desktop database (best effort)
update_desktop_database();
Ok(IntegrationResult {
desktop_file_path: desktop_path,
icon_install_path,
})
}
/// Install an icon to the hicolor icon theme directory.
fn install_icon(source: &Path, icon_id: &str) -> Result<Option<PathBuf>, IntegrationError> {
let ext = source
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png");
let (subdir, filename) = if ext == "svg" {
("scalable/apps", format!("{}.svg", icon_id))
} else {
("256x256/apps", format!("{}.png", icon_id))
};
let dest_dir = icons_dir().join(subdir);
fs::create_dir_all(&dest_dir)?;
let dest = dest_dir.join(&filename);
fs::copy(source, &dest)?;
Ok(Some(dest))
}
/// Remove integration for an AppImage.
pub fn remove_integration(record: &AppImageRecord) -> Result<(), IntegrationError> {
let app_name = record
.app_name
.as_deref()
.or(Some(&record.filename))
.ok_or(IntegrationError::NoAppName)?;
let app_id = make_app_id(app_name);
// Remove .desktop file
if let Some(ref desktop_file) = record.desktop_file {
let path = Path::new(desktop_file);
if path.exists() {
fs::remove_file(path)?;
}
} else {
// Try the conventional path
let desktop_path = applications_dir().join(format!("driftwood-{}.desktop", app_id));
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
}
}
// Remove icon files
let icon_id = format!("driftwood-{}", app_id);
remove_icon_files(&icon_id);
update_desktop_database();
Ok(())
}
fn remove_icon_files(icon_id: &str) {
let base = icons_dir();
let candidates = [
base.join(format!("256x256/apps/{}.png", icon_id)),
base.join(format!("scalable/apps/{}.svg", icon_id)),
base.join(format!("128x128/apps/{}.png", icon_id)),
base.join(format!("48x48/apps/{}.png", icon_id)),
];
for path in &candidates {
if path.exists() {
fs::remove_file(path).ok();
}
}
}
/// 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)
}
pub fn autostart_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("autostart")
}
pub fn enable_autostart(db: &Database, record: &AppImageRecord) -> Result<PathBuf, String> {
let dir = autostart_dir();
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create autostart dir: {}", e))?;
let app_id = record.app_name.as_deref()
.map(|n| make_app_id(n))
.unwrap_or_else(|| format!("appimage-{}", record.id));
let desktop_filename = format!("driftwood-{}.desktop", app_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]
Type=Application
Name={}
Exec=\"{}\" %U
Icon={}
X-GNOME-Autostart-enabled=true
X-Driftwood-AppImage-ID={}
", app_name, escape_exec_arg(&record.path), icon, record.id);
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 = Path::new(&m.file_path);
if path.exists() {
fs::remove_file(path).ok();
}
db.remove_modification(m.id).ok();
}
}
db.set_autostart(record_id, false).ok();
Ok(())
}
/// 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")
.arg(&apps_dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_make_app_id() {
assert_eq!(make_app_id("Firefox"), "firefox");
assert_eq!(make_app_id("My Cool App"), "my-cool-app");
assert_eq!(make_app_id(" Spaces "), "spaces");
}
#[test]
fn test_integrate_creates_desktop_file() {
let _dir = tempfile::tempdir().unwrap();
// Override the applications dir for testing by creating the record
// with a specific path and testing the desktop content generation
let record = AppImageRecord {
id: 1,
path: "/home/user/Apps/Firefox.AppImage".to_string(),
filename: "Firefox.AppImage".to_string(),
app_name: Some("Firefox".to_string()),
app_version: Some("124.0".to_string()),
appimage_type: Some(2),
size_bytes: 100_000_000,
sha256: None,
icon_path: None,
desktop_file: None,
integrated: false,
integrated_at: None,
is_executable: true,
desktop_entry_content: None,
categories: Some("Network;WebBrowser".to_string()),
description: Some("Web Browser".to_string()),
developer: None,
architecture: Some("x86_64".to_string()),
first_seen: "2026-01-01".to_string(),
last_scanned: "2026-01-01".to_string(),
file_modified: None,
fuse_status: None,
wayland_status: None,
update_info: None,
update_type: None,
latest_version: None,
update_checked: None,
update_url: None,
notes: None,
sandbox_mode: None,
runtime_wayland_status: None,
runtime_wayland_checked: None,
analysis_status: None,
launch_args: None,
tags: None,
pinned: false,
avg_startup_ms: None,
appstream_id: None,
appstream_description: None,
generic_name: None,
license: None,
homepage_url: None,
bugtracker_url: None,
donation_url: None,
help_url: None,
vcs_url: None,
keywords: None,
mime_types: None,
content_rating: None,
project_group: None,
release_history: None,
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,
// but we can verify make_app_id and the desktop content format
let app_id = make_app_id(record.app_name.as_deref().unwrap());
assert_eq!(app_id, "firefox");
assert_eq!(format!("driftwood-{}.desktop", app_id), "driftwood-firefox.desktop");
}
}