Handy/src-tauri/src/clipboard.rs
Pierre Carru b90077b228
Add "external script" paste method (#638)
* feat(linux): add "external script" paste method

* feat(i18n): external script translation for fr, de, it
2026-02-19 17:07:16 +08:00

687 lines
22 KiB
Rust

use crate::input::{self, EnigoState};
#[cfg(target_os = "linux")]
use crate::settings::TypingTool;
use crate::settings::{get_settings, AutoSubmitKey, ClipboardHandling, PasteMethod};
use enigo::{Direction, Enigo, Key, Keyboard};
use log::info;
use std::process::Command;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tauri_plugin_clipboard_manager::ClipboardExt;
#[cfg(target_os = "linux")]
use crate::utils::{is_kde_wayland, is_wayland};
/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard.
fn paste_via_clipboard(
enigo: &mut Enigo,
text: &str,
app_handle: &AppHandle,
paste_method: &PasteMethod,
paste_delay_ms: u64,
) -> Result<(), String> {
let clipboard = app_handle.clipboard();
let clipboard_content = clipboard.read_text().unwrap_or_default();
// Write text to clipboard first
// On Wayland, prefer wl-copy for better compatibility (especially with umlauts)
#[cfg(target_os = "linux")]
let write_result = if is_wayland() && is_wl_copy_available() {
info!("Using wl-copy for clipboard write on Wayland");
write_clipboard_via_wl_copy(text)
} else {
clipboard
.write_text(text)
.map_err(|e| format!("Failed to write to clipboard: {}", e))
};
#[cfg(not(target_os = "linux"))]
let write_result = clipboard
.write_text(text)
.map_err(|e| format!("Failed to write to clipboard: {}", e));
write_result?;
std::thread::sleep(Duration::from_millis(paste_delay_ms));
// Send paste key combo
#[cfg(target_os = "linux")]
let key_combo_sent = try_send_key_combo_linux(paste_method)?;
#[cfg(not(target_os = "linux"))]
let key_combo_sent = false;
// Fall back to enigo if no native tool handled it
if !key_combo_sent {
match paste_method {
PasteMethod::CtrlV => input::send_paste_ctrl_v(enigo)?,
PasteMethod::CtrlShiftV => input::send_paste_ctrl_shift_v(enigo)?,
PasteMethod::ShiftInsert => input::send_paste_shift_insert(enigo)?,
_ => return Err("Invalid paste method for clipboard paste".into()),
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
// Restore original clipboard content
// On Wayland, prefer wl-copy for better compatibility
#[cfg(target_os = "linux")]
if is_wayland() && is_wl_copy_available() {
let _ = write_clipboard_via_wl_copy(&clipboard_content);
} else {
let _ = clipboard.write_text(&clipboard_content);
}
#[cfg(not(target_os = "linux"))]
let _ = clipboard.write_text(&clipboard_content);
Ok(())
}
/// Attempts to send a key combination using Linux-native tools.
/// Returns `Ok(true)` if a native tool handled it, `Ok(false)` to fall back to enigo.
#[cfg(target_os = "linux")]
fn try_send_key_combo_linux(paste_method: &PasteMethod) -> Result<bool, String> {
if is_wayland() {
// Wayland: prefer wtype (but not on KDE), then dotool, then ydotool
// Note: wtype doesn't work on KDE (no zwp_virtual_keyboard_manager_v1 support)
if !is_kde_wayland() && is_wtype_available() {
info!("Using wtype for key combo");
send_key_combo_via_wtype(paste_method)?;
return Ok(true);
}
if is_dotool_available() {
info!("Using dotool for key combo");
send_key_combo_via_dotool(paste_method)?;
return Ok(true);
}
if is_ydotool_available() {
info!("Using ydotool for key combo");
send_key_combo_via_ydotool(paste_method)?;
return Ok(true);
}
} else {
// X11: prefer xdotool, then ydotool
if is_xdotool_available() {
info!("Using xdotool for key combo");
send_key_combo_via_xdotool(paste_method)?;
return Ok(true);
}
if is_ydotool_available() {
info!("Using ydotool for key combo");
send_key_combo_via_ydotool(paste_method)?;
return Ok(true);
}
}
Ok(false)
}
/// Attempts to type text directly using Linux-native tools.
/// Returns `Ok(true)` if a native tool handled it, `Ok(false)` to fall back to enigo.
#[cfg(target_os = "linux")]
fn try_direct_typing_linux(text: &str, preferred_tool: TypingTool) -> Result<bool, String> {
// If user specified a tool, try only that one
if preferred_tool != TypingTool::Auto {
return match preferred_tool {
TypingTool::Wtype if is_wtype_available() => {
info!("Using user-specified wtype");
type_text_via_wtype(text)?;
Ok(true)
}
TypingTool::Kwtype if is_kwtype_available() => {
info!("Using user-specified kwtype");
type_text_via_kwtype(text)?;
Ok(true)
}
TypingTool::Dotool if is_dotool_available() => {
info!("Using user-specified dotool");
type_text_via_dotool(text)?;
Ok(true)
}
TypingTool::Ydotool if is_ydotool_available() => {
info!("Using user-specified ydotool");
type_text_via_ydotool(text)?;
Ok(true)
}
TypingTool::Xdotool if is_xdotool_available() => {
info!("Using user-specified xdotool");
type_text_via_xdotool(text)?;
Ok(true)
}
_ => Err(format!(
"Typing tool {:?} is not available on this system",
preferred_tool
)),
};
}
// Auto mode - existing fallback chain
if is_wayland() {
// KDE Wayland: prefer kwtype (uses KDE Fake Input protocol, supports umlauts)
if is_kde_wayland() && is_kwtype_available() {
info!("Using kwtype for direct text input on KDE Wayland");
type_text_via_kwtype(text)?;
return Ok(true);
}
// Wayland: prefer wtype, then dotool, then ydotool
// Note: wtype doesn't work on KDE (no zwp_virtual_keyboard_manager_v1 support)
if !is_kde_wayland() && is_wtype_available() {
info!("Using wtype for direct text input");
type_text_via_wtype(text)?;
return Ok(true);
}
if is_dotool_available() {
info!("Using dotool for direct text input");
type_text_via_dotool(text)?;
return Ok(true);
}
if is_ydotool_available() {
info!("Using ydotool for direct text input");
type_text_via_ydotool(text)?;
return Ok(true);
}
} else {
// X11: prefer xdotool, then ydotool
if is_xdotool_available() {
info!("Using xdotool for direct text input");
type_text_via_xdotool(text)?;
return Ok(true);
}
if is_ydotool_available() {
info!("Using ydotool for direct text input");
type_text_via_ydotool(text)?;
return Ok(true);
}
}
Ok(false)
}
/// Returns the list of available typing tools on this system.
/// Always includes "auto" as the first entry.
#[cfg(target_os = "linux")]
pub fn get_available_typing_tools() -> Vec<String> {
let mut tools = vec!["auto".to_string()];
if is_wtype_available() {
tools.push("wtype".to_string());
}
if is_kwtype_available() {
tools.push("kwtype".to_string());
}
if is_dotool_available() {
tools.push("dotool".to_string());
}
if is_ydotool_available() {
tools.push("ydotool".to_string());
}
if is_xdotool_available() {
tools.push("xdotool".to_string());
}
tools
}
/// Check if wtype is available (Wayland text input tool)
#[cfg(target_os = "linux")]
fn is_wtype_available() -> bool {
Command::new("which")
.arg("wtype")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
/// Check if dotool is available (another Wayland text input tool)
#[cfg(target_os = "linux")]
fn is_dotool_available() -> bool {
Command::new("which")
.arg("dotool")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
/// Check if ydotool is available (uinput-based, works on both Wayland and X11)
#[cfg(target_os = "linux")]
fn is_ydotool_available() -> bool {
Command::new("which")
.arg("ydotool")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn is_xdotool_available() -> bool {
Command::new("which")
.arg("xdotool")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
/// Check if kwtype is available (KDE Wayland virtual keyboard input tool)
#[cfg(target_os = "linux")]
fn is_kwtype_available() -> bool {
Command::new("which")
.arg("kwtype")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
/// Check if wl-copy is available (Wayland clipboard tool)
#[cfg(target_os = "linux")]
fn is_wl_copy_available() -> bool {
Command::new("which")
.arg("wl-copy")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
/// Type text directly via wtype on Wayland.
#[cfg(target_os = "linux")]
fn type_text_via_wtype(text: &str) -> Result<(), String> {
let output = Command::new("wtype")
.arg("--") // Protect against text starting with -
.arg(text)
.output()
.map_err(|e| format!("Failed to execute wtype: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("wtype failed: {}", stderr));
}
Ok(())
}
/// Type text directly via xdotool on X11.
#[cfg(target_os = "linux")]
fn type_text_via_xdotool(text: &str) -> Result<(), String> {
let output = Command::new("xdotool")
.arg("type")
.arg("--clearmodifiers")
.arg("--")
.arg(text)
.output()
.map_err(|e| format!("Failed to execute xdotool: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xdotool failed: {}", stderr));
}
Ok(())
}
/// Type text directly via dotool (works on both Wayland and X11 via uinput).
#[cfg(target_os = "linux")]
fn type_text_via_dotool(text: &str) -> Result<(), String> {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new("dotool")
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn dotool: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
// dotool uses "type <text>" command
writeln!(stdin, "type {}", text)
.map_err(|e| format!("Failed to write to dotool stdin: {}", e))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for dotool: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("dotool failed: {}", stderr));
}
Ok(())
}
/// Type text directly via ydotool (uinput-based, requires ydotoold daemon).
#[cfg(target_os = "linux")]
fn type_text_via_ydotool(text: &str) -> Result<(), String> {
let output = Command::new("ydotool")
.arg("type")
.arg("--")
.arg(text)
.output()
.map_err(|e| format!("Failed to execute ydotool: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ydotool failed: {}", stderr));
}
Ok(())
}
/// Type text directly via kwtype (KDE Wayland virtual keyboard, uses KDE Fake Input protocol).
#[cfg(target_os = "linux")]
fn type_text_via_kwtype(text: &str) -> Result<(), String> {
let output = Command::new("kwtype")
.arg("--")
.arg(text)
.output()
.map_err(|e| format!("Failed to execute kwtype: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("kwtype failed: {}", stderr));
}
Ok(())
}
/// Write text to clipboard via wl-copy (Wayland clipboard tool).
/// Uses Stdio::null() to avoid blocking on repeated calls — wl-copy forks a
/// daemon that inherits piped fds, causing read_to_end to hang indefinitely.
#[cfg(target_os = "linux")]
fn write_clipboard_via_wl_copy(text: &str) -> Result<(), String> {
use std::process::Stdio;
let status = Command::new("wl-copy")
.arg("--")
.arg(text)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|e| format!("Failed to execute wl-copy: {}", e))?;
if !status.success() {
return Err("wl-copy failed".into());
}
Ok(())
}
/// Send a key combination (e.g., Ctrl+V) via wtype on Wayland.
#[cfg(target_os = "linux")]
fn send_key_combo_via_wtype(paste_method: &PasteMethod) -> Result<(), String> {
let args: Vec<&str> = match paste_method {
PasteMethod::CtrlV => vec!["-M", "ctrl", "-k", "v"],
PasteMethod::ShiftInsert => vec!["-M", "shift", "-k", "Insert"],
PasteMethod::CtrlShiftV => vec!["-M", "ctrl", "-M", "shift", "-k", "v"],
_ => return Err("Unsupported paste method".into()),
};
let output = Command::new("wtype")
.args(&args)
.output()
.map_err(|e| format!("Failed to execute wtype: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("wtype failed: {}", stderr));
}
Ok(())
}
/// Send a key combination (e.g., Ctrl+V) via dotool.
#[cfg(target_os = "linux")]
fn send_key_combo_via_dotool(paste_method: &PasteMethod) -> Result<(), String> {
let command;
match paste_method {
PasteMethod::CtrlV => command = "echo key ctrl+v | dotool",
PasteMethod::ShiftInsert => command = "echo key shift+insert | dotool",
PasteMethod::CtrlShiftV => command = "echo key ctrl+shift+v | dotool",
_ => return Err("Unsupported paste method".into()),
}
use std::process::Stdio;
let status = Command::new("sh")
.arg("-c")
.arg(command)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|e| format!("Failed to execute dotool: {}", e))?;
if !status.success() {
return Err("dotool failed".into());
}
Ok(())
}
/// Send a key combination (e.g., Ctrl+V) via ydotool (requires ydotoold daemon).
#[cfg(target_os = "linux")]
fn send_key_combo_via_ydotool(paste_method: &PasteMethod) -> Result<(), String> {
// ydotool uses Linux input event keycodes with format <keycode>:<pressed>
// where pressed is 1 for down, 0 for up. Keycodes: ctrl=29, shift=42, v=47, insert=110
let args: Vec<&str> = match paste_method {
PasteMethod::CtrlV => vec!["key", "29:1", "47:1", "47:0", "29:0"],
PasteMethod::ShiftInsert => vec!["key", "42:1", "110:1", "110:0", "42:0"],
PasteMethod::CtrlShiftV => vec!["key", "29:1", "42:1", "47:1", "47:0", "42:0", "29:0"],
_ => return Err("Unsupported paste method".into()),
};
let output = Command::new("ydotool")
.args(&args)
.output()
.map_err(|e| format!("Failed to execute ydotool: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ydotool failed: {}", stderr));
}
Ok(())
}
/// Send a key combination (e.g., Ctrl+V) via xdotool on X11.
#[cfg(target_os = "linux")]
fn send_key_combo_via_xdotool(paste_method: &PasteMethod) -> Result<(), String> {
let key_combo = match paste_method {
PasteMethod::CtrlV => "ctrl+v",
PasteMethod::CtrlShiftV => "ctrl+shift+v",
PasteMethod::ShiftInsert => "shift+Insert",
_ => return Err("Unsupported paste method".into()),
};
let output = Command::new("xdotool")
.arg("key")
.arg("--clearmodifiers")
.arg(key_combo)
.output()
.map_err(|e| format!("Failed to execute xdotool: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("xdotool failed: {}", stderr));
}
Ok(())
}
/// Pastes text by invoking an external script.
/// The script receives the text to paste as a single argument.
fn paste_via_external_script(text: &str, script_path: &str) -> Result<(), String> {
info!("Pasting via external script: {}", script_path);
let output = Command::new(script_path)
.arg(text)
.output()
.map_err(|e| format!("Failed to execute external script '{}': {}", script_path, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!(
"External script '{}' failed with exit code {:?}. stderr: {}, stdout: {}",
script_path,
output.status.code(),
stderr.trim(),
stdout.trim()
));
}
Ok(())
}
/// Types text directly by simulating individual key presses.
fn paste_direct(
enigo: &mut Enigo,
text: &str,
#[cfg(target_os = "linux")] typing_tool: TypingTool,
) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
if try_direct_typing_linux(text, typing_tool)? {
return Ok(());
}
info!("Falling back to enigo for direct text input");
}
input::paste_text_direct(enigo, text)
}
fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> {
match key_type {
AutoSubmitKey::Enter => {
enigo
.key(Key::Return, Direction::Press)
.map_err(|e| format!("Failed to press Return key: {}", e))?;
enigo
.key(Key::Return, Direction::Release)
.map_err(|e| format!("Failed to release Return key: {}", e))?;
}
AutoSubmitKey::CtrlEnter => {
enigo
.key(Key::Control, Direction::Press)
.map_err(|e| format!("Failed to press Control key: {}", e))?;
enigo
.key(Key::Return, Direction::Press)
.map_err(|e| format!("Failed to press Return key: {}", e))?;
enigo
.key(Key::Return, Direction::Release)
.map_err(|e| format!("Failed to release Return key: {}", e))?;
enigo
.key(Key::Control, Direction::Release)
.map_err(|e| format!("Failed to release Control key: {}", e))?;
}
AutoSubmitKey::CmdEnter => {
enigo
.key(Key::Meta, Direction::Press)
.map_err(|e| format!("Failed to press Meta/Cmd key: {}", e))?;
enigo
.key(Key::Return, Direction::Press)
.map_err(|e| format!("Failed to press Return key: {}", e))?;
enigo
.key(Key::Return, Direction::Release)
.map_err(|e| format!("Failed to release Return key: {}", e))?;
enigo
.key(Key::Meta, Direction::Release)
.map_err(|e| format!("Failed to release Meta/Cmd key: {}", e))?;
}
}
Ok(())
}
fn should_send_auto_submit(auto_submit: bool, paste_method: PasteMethod) -> bool {
auto_submit && paste_method != PasteMethod::None
}
pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> {
let settings = get_settings(&app_handle);
let paste_method = settings.paste_method;
let paste_delay_ms = settings.paste_delay_ms;
// Append trailing space if setting is enabled
let text = if settings.append_trailing_space {
format!("{} ", text)
} else {
text
};
info!(
"Using paste method: {:?}, delay: {}ms",
paste_method, paste_delay_ms
);
// Get the managed Enigo instance
let enigo_state = app_handle
.try_state::<EnigoState>()
.ok_or("Enigo state not initialized")?;
let mut enigo = enigo_state
.0
.lock()
.map_err(|e| format!("Failed to lock Enigo: {}", e))?;
// Perform the paste operation
match paste_method {
PasteMethod::None => {
info!("PasteMethod::None selected - skipping paste action");
}
PasteMethod::Direct => {
paste_direct(
&mut enigo,
&text,
#[cfg(target_os = "linux")]
settings.typing_tool,
)?;
}
PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => {
paste_via_clipboard(
&mut enigo,
&text,
&app_handle,
&paste_method,
paste_delay_ms,
)?
}
PasteMethod::ExternalScript => {
let script_path = settings
.external_script_path
.as_ref()
.filter(|p| !p.is_empty())
.ok_or("External script path is not configured")?;
paste_via_external_script(&text, script_path)?;
}
}
if should_send_auto_submit(settings.auto_submit, paste_method) {
std::thread::sleep(Duration::from_millis(50));
send_return_key(&mut enigo, settings.auto_submit_key)?;
}
// After pasting, optionally copy to clipboard based on settings
if settings.clipboard_handling == ClipboardHandling::CopyToClipboard {
let clipboard = app_handle.clipboard();
clipboard
.write_text(&text)
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_submit_requires_setting_enabled() {
assert!(!should_send_auto_submit(false, PasteMethod::CtrlV));
assert!(!should_send_auto_submit(false, PasteMethod::Direct));
}
#[test]
fn auto_submit_skips_none_paste_method() {
assert!(!should_send_auto_submit(true, PasteMethod::None));
}
#[test]
fn auto_submit_runs_for_active_paste_methods() {
assert!(should_send_auto_submit(true, PasteMethod::CtrlV));
assert!(should_send_auto_submit(true, PasteMethod::Direct));
assert!(should_send_auto_submit(true, PasteMethod::CtrlShiftV));
assert!(should_send_auto_submit(true, PasteMethod::ShiftInsert));
}
}