Files
pixstrip/pixstrip-core/src/fm_integration.rs
lashman 7e5d19ab03 Fix 12 medium-severity bugs across all crates
- Escape backslashes in Nautilus preset names preventing Python injection
- Fix tiled watermarks starting at (spacing,spacing) instead of (0,0)
- Fix text watermark width overestimation (1.0x to 0.6x multiplier)
- Fix output_dpi forcing re-encoding for metadata-only presets
- Fix AVIF/WebP compression detection comparing against wrong preset values
- Add shared batch_updating guard for Ctrl+A/Ctrl+Shift+A select actions
- Fix overwrite conflict check ignoring preserve_directory_structure
- Add changes_filename()/changes_extension() for smarter overwrite checks
- Fix watch folder hardcoding "Blog Photos" preset
- Fix undo dropping history for partially-trashed batches
- Fix skipped files inflating size statistics
- Make CLI watch config writes atomic
2026-03-07 23:35:32 +02:00

443 lines
14 KiB
Rust

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<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(|_| 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("<?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: {xml_name}</name>\n\
\x20 <command>{bin} --preset \"{safe_label}\" --files %F</command>\n\
\x20 <description>Process with {xml_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",
xml_name = name.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;"),
safe_label = shell_safe(name),
bin = bin,
));
}
actions.push_str("</actions>\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::<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 \"{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(())
}