442 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|