use std::path::{Path, PathBuf}; use crate::error::Result; use crate::preset::Preset; use crate::storage::{atomic_write, PresetStore}; /// Supported file managers for right-click integration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileManager { Nautilus, Nemo, Thunar, Dolphin, } impl FileManager { /// Human-readable name. pub fn name(&self) -> &'static str { match self { Self::Nautilus => "Nautilus", Self::Nemo => "Nemo", Self::Thunar => "Thunar", Self::Dolphin => "Dolphin", } } /// Desktop file ID used to detect if the FM is installed. pub fn desktop_id(&self) -> &'static str { match self { Self::Nautilus => "org.gnome.Nautilus", Self::Nemo => "org.nemo.Nemo", Self::Thunar => "thunar", Self::Dolphin => "org.kde.dolphin", } } /// Install the integration extension for this file manager. pub fn install(&self) -> Result<()> { match self { Self::Nautilus => install_nautilus(), Self::Nemo => install_nemo(), Self::Thunar => install_thunar(), Self::Dolphin => install_dolphin(), } } /// Remove the integration extension for this file manager. pub fn uninstall(&self) -> Result<()> { match self { Self::Nautilus => uninstall_nautilus(), Self::Nemo => uninstall_nemo(), Self::Thunar => uninstall_thunar(), Self::Dolphin => uninstall_dolphin(), } } /// Check if the integration is currently installed. pub fn is_installed(&self) -> bool { match self { Self::Nautilus => nautilus_extension_path().exists(), Self::Nemo => nemo_action_path().exists(), Self::Thunar => thunar_action_path().exists(), Self::Dolphin => dolphin_service_path().exists(), } } } /// Regenerate the extension files for all installed file managers. /// Call this after presets change so the submenu stays up to date. pub fn regenerate_all() -> Result<()> { for fm in &[FileManager::Nautilus, FileManager::Nemo, FileManager::Thunar, FileManager::Dolphin] { if fm.is_installed() { fm.install()?; } } Ok(()) } /// Sanitize a string for safe use in shell Exec= lines and XML command elements. /// Removes or replaces characters that could cause shell injection. fn shell_safe(s: &str) -> String { s.chars() .filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '(' | ')' | ',')) .collect() } fn pixstrip_bin() -> String { // Try to find the pixstrip binary path if let Ok(exe) = std::env::current_exe() { // If running from the GTK app, find the CLI sibling let dir = exe.parent().unwrap_or(Path::new("/usr/bin")); let cli_path = dir.join("pixstrip"); if cli_path.exists() { return cli_path.display().to_string(); } let gtk_path = dir.join("pixstrip-gtk"); if gtk_path.exists() { return gtk_path.display().to_string(); } return exe.display().to_string(); } "pixstrip-gtk".into() } fn get_preset_names() -> Vec { let mut names: Vec = Preset::all_builtins() .into_iter() .map(|p| p.name) .collect(); let store = PresetStore::new(); if let Ok(user_presets) = store.list() { for p in user_presets { if p.is_custom && !names.contains(&p.name) { names.push(p.name); } } } names } // --- Nautilus (Python extension) --- fn nautilus_extension_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.local/share", home) }); PathBuf::from(data).join("nautilus-python").join("extensions") } fn nautilus_extension_path() -> PathBuf { nautilus_extension_dir().join("pixstrip-integration.py") } fn install_nautilus() -> Result<()> { let dir = nautilus_extension_dir(); std::fs::create_dir_all(&dir)?; let bin = pixstrip_bin(); let presets = get_preset_names(); let mut preset_items = String::new(); for name in &presets { preset_items.push_str(&format!( " item = Nautilus.MenuItem(\n\ \x20 name='Pixstrip::Preset::{}',\n\ \x20 label='{}',\n\ \x20 )\n\ \x20 item.connect('activate', self._on_preset, '{}', files)\n\ \x20 submenu.append_item(item)\n\n", name.replace(' ', "_"), name.replace('\\', "\\\\").replace('\'', "\\'"), name.replace('\\', "\\\\").replace('\'', "\\'"), )); } let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'"); let script = format!( r#"import subprocess from gi.repository import Nautilus, GObject class PixstripExtension(GObject.GObject, Nautilus.MenuProvider): def get_file_items(self, files): if not files: return [] # Only show for image files image_mimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/tiff', 'image/avif', 'image/bmp'] valid = [f for f in files if f.get_mime_type() in image_mimes or f.is_directory()] if not valid: return [] top = Nautilus.MenuItem( name='Pixstrip::Menu', label='Process with Pixstrip', icon='applications-graphics-symbolic', ) submenu = Nautilus.Menu() top.set_submenu(submenu) # Open in Pixstrip (wizard) open_item = Nautilus.MenuItem( name='Pixstrip::Open', label='Open in Pixstrip...', ) open_item.connect('activate', self._on_open, files) submenu.append_item(open_item) # Separator via disabled item sep = Nautilus.MenuItem( name='Pixstrip::Sep', label='---', sensitive=False, ) submenu.append_item(sep) # Preset items {preset_items} return [top] def _on_open(self, menu, files): paths = [f.get_location().get_path() for f in files if f.get_location()] subprocess.Popen(['{bin}', '--files'] + paths) def _on_preset(self, menu, preset_name, files): paths = [f.get_location().get_path() for f in files if f.get_location()] subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths) "#, preset_items = preset_items, bin = escaped_bin, ); atomic_write(&nautilus_extension_path(), &script)?; Ok(()) } fn uninstall_nautilus() -> Result<()> { let path = nautilus_extension_path(); if path.exists() { std::fs::remove_file(path)?; } Ok(()) } // --- Nemo (.nemo_action files) --- fn nemo_action_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.local/share", home) }); PathBuf::from(data).join("nemo").join("actions") } fn nemo_action_path() -> PathBuf { nemo_action_dir().join("pixstrip-open.nemo_action") } fn install_nemo() -> Result<()> { let dir = nemo_action_dir(); std::fs::create_dir_all(&dir)?; let bin = pixstrip_bin(); // Main "Open in Pixstrip" action let open_action = format!( "[Nemo Action]\n\ Name=Open in Pixstrip...\n\ Comment=Process images with Pixstrip\n\ Exec={bin} --files %F\n\ Icon-Name=applications-graphics-symbolic\n\ Selection=Any\n\ Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\ Mimetypes=image/*;\n", bin = bin, ); atomic_write(&nemo_action_path(), &open_action)?; // Per-preset actions let presets = get_preset_names(); for name in &presets { let safe_name = name.replace(' ', "-").to_lowercase(); let action_path = dir.join(format!("pixstrip-preset-{}.nemo_action", safe_name)); let action = format!( "[Nemo Action]\n\ Name=Pixstrip: {name}\n\ Comment=Process with {name} preset\n\ Exec={bin} --preset \"{safe_label}\" --files %F\n\ Icon-Name=applications-graphics-symbolic\n\ Selection=Any\n\ Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\ Mimetypes=image/*;\n", name = name, safe_label = shell_safe(name), bin = bin, ); atomic_write(&action_path, &action)?; } Ok(()) } fn uninstall_nemo() -> Result<()> { let dir = nemo_action_dir(); // Remove main action let main_path = nemo_action_path(); if main_path.exists() { std::fs::remove_file(main_path)?; } // Remove preset actions if dir.exists() { for entry in std::fs::read_dir(&dir)?.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("pixstrip-preset-") && name_str.ends_with(".nemo_action") { let _ = std::fs::remove_file(entry.path()); } } } Ok(()) } // --- Thunar (Custom Actions via uca.xml) --- fn thunar_action_dir() -> PathBuf { let config = std::env::var("XDG_CONFIG_HOME") .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.config", home) }); PathBuf::from(config).join("Thunar") } fn thunar_action_path() -> PathBuf { thunar_action_dir().join("pixstrip-actions.xml") } fn install_thunar() -> Result<()> { let dir = thunar_action_dir(); std::fs::create_dir_all(&dir)?; let bin = pixstrip_bin(); let presets = get_preset_names(); let mut actions = String::from("\n\n"); // Open in Pixstrip actions.push_str(&format!( " \n\ \x20 applications-graphics-symbolic\n\ \x20 Open in Pixstrip...\n\ \x20 {bin} --files %F\n\ \x20 Process images with Pixstrip\n\ \x20 *.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp\n\ \x20 \n\ \x20 \n\ \n", bin = bin, )); for name in &presets { actions.push_str(&format!( " \n\ \x20 applications-graphics-symbolic\n\ \x20 Pixstrip: {xml_name}\n\ \x20 {bin} --preset \"{safe_label}\" --files %F\n\ \x20 Process with {xml_name} preset\n\ \x20 *.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp\n\ \x20 \n\ \x20 \n\ \n", xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), safe_label = shell_safe(name), bin = bin, )); } actions.push_str("\n"); atomic_write(&thunar_action_path(), &actions)?; Ok(()) } fn uninstall_thunar() -> Result<()> { let path = thunar_action_path(); if path.exists() { std::fs::remove_file(path)?; } Ok(()) } // --- Dolphin (KDE Service Menu) --- fn dolphin_service_dir() -> PathBuf { let data = std::env::var("XDG_DATA_HOME") .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_else(|| "/tmp".into())); format!("{}/.local/share", home) }); PathBuf::from(data).join("kio").join("servicemenus") } fn dolphin_service_path() -> PathBuf { dolphin_service_dir().join("pixstrip.desktop") } fn install_dolphin() -> Result<()> { let dir = dolphin_service_dir(); std::fs::create_dir_all(&dir)?; let bin = pixstrip_bin(); let presets = get_preset_names(); let mut desktop = format!( "[Desktop Entry]\n\ Type=Service\n\ X-KDE-ServiceTypes=KonqPopupMenu/Plugin\n\ MimeType=image/jpeg;image/png;image/webp;image/gif;image/tiff;image/avif;image/bmp;inode/directory;\n\ Actions=Open;{preset_actions}\n\ X-KDE-Submenu=Process with Pixstrip\n\ Icon=applications-graphics-symbolic\n\n\ [Desktop Action Open]\n\ Name=Open in Pixstrip...\n\ Icon=applications-graphics-symbolic\n\ Exec={bin} --files %F\n\n", bin = bin, preset_actions = presets .iter() .enumerate() .map(|(i, _)| format!("Preset{}", i)) .collect::>() .join(";"), ); for (i, name) in presets.iter().enumerate() { desktop.push_str(&format!( "[Desktop Action Preset{i}]\n\ Name={name}\n\ Icon=applications-graphics-symbolic\n\ Exec={bin} --preset \"{safe_label}\" --files %F\n\n", i = i, name = name, safe_label = shell_safe(name), bin = bin, )); } atomic_write(&dolphin_service_path(), &desktop)?; Ok(()) } fn uninstall_dolphin() -> Result<()> { let path = dolphin_service_path(); if path.exists() { std::fs::remove_file(path)?; } Ok(()) }