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.
This commit is contained in:
2026-03-06 15:37:25 +02:00
parent 5bdeb8a2e3
commit b50147404a
4 changed files with 471 additions and 17 deletions

View File

@@ -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<String> {
let mut names: Vec<String> = 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n");
// Open in Pixstrip
actions.push_str(&format!(
" <action>\n\
\x20 <icon>applications-graphics-symbolic</icon>\n\
\x20 <name>Open in Pixstrip...</name>\n\
\x20 <command>{bin} --files %F</command>\n\
\x20 <description>Process images with Pixstrip</description>\n\
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
\x20 <image-files/>\n\
\x20 <directories/>\n\
</action>\n",
bin = bin,
));
for name in &presets {
actions.push_str(&format!(
" <action>\n\
\x20 <icon>applications-graphics-symbolic</icon>\n\
\x20 <name>Pixstrip: {name}</name>\n\
\x20 <command>{bin} --preset \"{name}\" --files %F</command>\n\
\x20 <description>Process with {name} preset</description>\n\
\x20 <patterns>*.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp</patterns>\n\
\x20 <image-files/>\n\
\x20 <directories/>\n\
</action>\n",
name = name,
bin = bin,
));
}
actions.push_str("</actions>\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::<Vec<_>>()
.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(())
}

View File

@@ -3,6 +3,7 @@ pub mod discovery;
pub mod encoder; pub mod encoder;
pub mod error; pub mod error;
pub mod executor; pub mod executor;
pub mod fm_integration;
pub mod loader; pub mod loader;
pub mod operations; pub mod operations;
pub mod pipeline; pub mod pipeline;

View File

@@ -85,15 +85,16 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
.description("Add 'Process with Pixstrip' to your file manager's right-click menu") .description("Add 'Process with Pixstrip' to your file manager's right-click menu")
.build(); .build();
use pixstrip_core::fm_integration::FileManager;
let file_managers = [ let file_managers = [
("Nautilus", "org.gnome.Nautilus"), (FileManager::Nautilus, "org.gnome.Nautilus"),
("Nemo", "org.nemo.Nemo"), (FileManager::Nemo, "org.nemo.Nemo"),
("Thunar", "thunar"), (FileManager::Thunar, "thunar"),
("Dolphin", "org.kde.dolphin"), (FileManager::Dolphin, "org.kde.dolphin"),
]; ];
let mut found_fm = false; 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() let is_installed = gtk::gio::AppInfo::all()
.iter() .iter()
.any(|info| { .any(|info| {
@@ -104,11 +105,22 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
if is_installed { if is_installed {
found_fm = true; found_fm = true;
let already_installed = fm.is_installed();
let row = adw::SwitchRow::builder() let row = adw::SwitchRow::builder()
.title(*name) .title(fm.name())
.subtitle(format!("Add right-click menu to {}", name)) .subtitle(format!("Add right-click menu to {}", fm.name()))
.active(false) .active(already_installed)
.build(); .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); fm_group.add(&row);
} }
} }

View File

@@ -197,16 +197,16 @@ fn build_file_manager_page(nav_view: &adw::NavigationView) -> adw::NavigationPag
.build(); .build();
// Detect installed file managers // Detect installed file managers
use pixstrip_core::fm_integration::FileManager;
let file_managers = [ let file_managers = [
("Nautilus", "org.gnome.Nautilus", "system-file-manager-symbolic"), (FileManager::Nautilus, "org.gnome.Nautilus", "system-file-manager-symbolic"),
("Nemo", "org.nemo.Nemo", "system-file-manager-symbolic"), (FileManager::Nemo, "org.nemo.Nemo", "system-file-manager-symbolic"),
("Thunar", "thunar", "system-file-manager-symbolic"), (FileManager::Thunar, "thunar", "system-file-manager-symbolic"),
("Dolphin", "org.kde.dolphin", "system-file-manager-symbolic"), (FileManager::Dolphin, "org.kde.dolphin", "system-file-manager-symbolic"),
]; ];
let mut found_any = false; let mut found_any = false;
for (name, desktop_id, icon) in &file_managers { for (fm, desktop_id, icon) in &file_managers {
// Check if the file manager is installed by looking for its .desktop file
let is_installed = gtk::gio::AppInfo::all() let is_installed = gtk::gio::AppInfo::all()
.iter() .iter()
.any(|info| { .any(|info| {
@@ -217,12 +217,23 @@ fn build_file_manager_page(nav_view: &adw::NavigationView) -> adw::NavigationPag
if is_installed { if is_installed {
found_any = true; found_any = true;
let already_installed = fm.is_installed();
let row = adw::SwitchRow::builder() let row = adw::SwitchRow::builder()
.title(*name) .title(fm.name())
.subtitle(format!("Add right-click menu to {}", name)) .subtitle(format!("Add right-click menu to {}", fm.name()))
.active(false) .active(already_installed)
.build(); .build();
row.add_prefix(&gtk::Image::from_icon_name(icon)); row.add_prefix(&gtk::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); group.add(&row);
} }
} }