From b50147404a607f272ff1bc983594b7f73d1c22a3 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 15:37:25 +0200 Subject: [PATCH] Add file manager integration install/uninstall logic Implements actual extension file creation for Nautilus (Python extension), Nemo (.nemo_action files), Thunar (custom actions XML), and Dolphin (KDE service menu .desktop). Each extension creates a "Process with Pixstrip" submenu with all presets listed. Toggle switches in welcome wizard and settings now call install/uninstall. --- pixstrip-core/src/fm_integration.rs | 430 ++++++++++++++++++++++++++++ pixstrip-core/src/lib.rs | 1 + pixstrip-gtk/src/settings.rs | 28 +- pixstrip-gtk/src/welcome.rs | 29 +- 4 files changed, 471 insertions(+), 17 deletions(-) create mode 100644 pixstrip-core/src/fm_integration.rs diff --git a/pixstrip-core/src/fm_integration.rs b/pixstrip-core/src/fm_integration.rs new file mode 100644 index 0000000..b53fcb4 --- /dev/null +++ b/pixstrip-core/src/fm_integration.rs @@ -0,0 +1,430 @@ +use std::path::{Path, PathBuf}; + +use crate::error::Result; +use crate::preset::Preset; +use crate::storage::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(()) +} + +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(|_| "~".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, + name, + )); + } + + 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 = bin, + ); + + std::fs::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(|_| "~".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, + ); + std::fs::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 \"{name}\" --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, + bin = bin, + ); + std::fs::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(|_| "~".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: {name}\n\ + \x20 {bin} --preset \"{name}\" --files %F\n\ + \x20 Process with {name} preset\n\ + \x20 *.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp\n\ + \x20 \n\ + \x20 \n\ + \n", + name = name, + bin = bin, + )); + } + + actions.push_str("\n"); + std::fs::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(|_| "~".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 \"{name}\" --files %F\n\n", + i = i, + name = name, + bin = bin, + )); + } + + std::fs::write(dolphin_service_path(), desktop)?; + Ok(()) +} + +fn uninstall_dolphin() -> Result<()> { + let path = dolphin_service_path(); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} diff --git a/pixstrip-core/src/lib.rs b/pixstrip-core/src/lib.rs index a73727e..39f2a21 100644 --- a/pixstrip-core/src/lib.rs +++ b/pixstrip-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod discovery; pub mod encoder; pub mod error; pub mod executor; +pub mod fm_integration; pub mod loader; pub mod operations; pub mod pipeline; diff --git a/pixstrip-gtk/src/settings.rs b/pixstrip-gtk/src/settings.rs index 0e9df98..e7c6329 100644 --- a/pixstrip-gtk/src/settings.rs +++ b/pixstrip-gtk/src/settings.rs @@ -85,15 +85,16 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { .description("Add 'Process with Pixstrip' to your file manager's right-click menu") .build(); + use pixstrip_core::fm_integration::FileManager; let file_managers = [ - ("Nautilus", "org.gnome.Nautilus"), - ("Nemo", "org.nemo.Nemo"), - ("Thunar", "thunar"), - ("Dolphin", "org.kde.dolphin"), + (FileManager::Nautilus, "org.gnome.Nautilus"), + (FileManager::Nemo, "org.nemo.Nemo"), + (FileManager::Thunar, "thunar"), + (FileManager::Dolphin, "org.kde.dolphin"), ]; let mut found_fm = false; - for (name, desktop_id) in &file_managers { + for (fm, desktop_id) in &file_managers { let is_installed = gtk::gio::AppInfo::all() .iter() .any(|info| { @@ -104,11 +105,22 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog { if is_installed { found_fm = true; + let already_installed = fm.is_installed(); let row = adw::SwitchRow::builder() - .title(*name) - .subtitle(format!("Add right-click menu to {}", name)) - .active(false) + .title(fm.name()) + .subtitle(format!("Add right-click menu to {}", fm.name())) + .active(already_installed) .build(); + + let fm_copy = *fm; + row.connect_active_notify(move |row| { + if row.is_active() { + let _ = fm_copy.install(); + } else { + let _ = fm_copy.uninstall(); + } + }); + fm_group.add(&row); } } diff --git a/pixstrip-gtk/src/welcome.rs b/pixstrip-gtk/src/welcome.rs index 09c0d60..fdff6db 100644 --- a/pixstrip-gtk/src/welcome.rs +++ b/pixstrip-gtk/src/welcome.rs @@ -197,16 +197,16 @@ fn build_file_manager_page(nav_view: &adw::NavigationView) -> adw::NavigationPag .build(); // Detect installed file managers + use pixstrip_core::fm_integration::FileManager; let file_managers = [ - ("Nautilus", "org.gnome.Nautilus", "system-file-manager-symbolic"), - ("Nemo", "org.nemo.Nemo", "system-file-manager-symbolic"), - ("Thunar", "thunar", "system-file-manager-symbolic"), - ("Dolphin", "org.kde.dolphin", "system-file-manager-symbolic"), + (FileManager::Nautilus, "org.gnome.Nautilus", "system-file-manager-symbolic"), + (FileManager::Nemo, "org.nemo.Nemo", "system-file-manager-symbolic"), + (FileManager::Thunar, "thunar", "system-file-manager-symbolic"), + (FileManager::Dolphin, "org.kde.dolphin", "system-file-manager-symbolic"), ]; let mut found_any = false; - for (name, desktop_id, icon) in &file_managers { - // Check if the file manager is installed by looking for its .desktop file + for (fm, desktop_id, icon) in &file_managers { let is_installed = gtk::gio::AppInfo::all() .iter() .any(|info| { @@ -217,12 +217,23 @@ fn build_file_manager_page(nav_view: &adw::NavigationView) -> adw::NavigationPag if is_installed { found_any = true; + let already_installed = fm.is_installed(); let row = adw::SwitchRow::builder() - .title(*name) - .subtitle(format!("Add right-click menu to {}", name)) - .active(false) + .title(fm.name()) + .subtitle(format!("Add right-click menu to {}", fm.name())) + .active(already_installed) .build(); row.add_prefix(>k::Image::from_icon_name(icon)); + + let fm_copy = *fm; + row.connect_active_notify(move |row| { + if row.is_active() { + let _ = fm_copy.install(); + } else { + let _ = fm_copy.uninstall(); + } + }); + group.add(&row); } }