From b90077b2288b6350c2f9999c8d6f70a3ea05fad4 Mon Sep 17 00:00:00 2001 From: Pierre Carru Date: Thu, 19 Feb 2026 10:07:16 +0100 Subject: [PATCH] Add "external script" paste method (#638) * feat(linux): add "external script" paste method * feat(i18n): external script translation for fr, de, it --- src-tauri/src/clipboard.rs | 36 ++++++++++++++++++++-- src-tauri/src/lib.rs | 1 + src-tauri/src/settings.rs | 3 ++ src-tauri/src/shortcut/mod.rs | 13 ++++++++ src/bindings.ts | 16 +++++++--- src/components/settings/PasteMethod.tsx | 41 ++++++++++++++++++++----- src/i18n/locales/de/translation.json | 6 ++-- src/i18n/locales/en/translation.json | 6 ++-- src/i18n/locales/fr/translation.json | 6 ++-- src/i18n/locales/it/translation.json | 6 ++-- src/stores/settingsStore.ts | 2 ++ 11 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 48d17b5..57972cb 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -4,14 +4,13 @@ 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}; -#[cfg(target_os = "linux")] -use std::process::Command; /// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard. fn paste_via_clipboard( @@ -500,6 +499,31 @@ fn send_key_combo_via_xdotool(paste_method: &PasteMethod) -> Result<(), String> 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, @@ -612,6 +636,14 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { 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) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5fedea4..58faaf2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -272,6 +272,7 @@ pub fn run(cli_args: CliArgs) { shortcut::change_paste_method_setting, shortcut::get_available_typing_tools, shortcut::change_typing_tool_setting, + shortcut::change_external_script_path_setting, shortcut::change_clipboard_handling_setting, shortcut::change_auto_submit_setting, shortcut::change_auto_submit_key_setting, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 494f17b..8843378 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -134,6 +134,7 @@ pub enum PasteMethod { None, ShiftInsert, CtrlShiftV, + ExternalScript, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] @@ -358,6 +359,7 @@ pub struct AppSettings { pub paste_delay_ms: u64, #[serde(default = "default_typing_tool")] pub typing_tool: TypingTool, + pub external_script_path: Option, } fn default_model() -> String { @@ -721,6 +723,7 @@ pub fn get_default_settings() -> AppSettings { show_tray_icon: default_show_tray_icon(), paste_delay_ms: default_paste_delay_ms(), typing_tool: default_typing_tool(), + external_script_path: None, } } diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 757c1f0..7cd9bf5 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -668,6 +668,7 @@ pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), "none" => PasteMethod::None, "shift_insert" => PasteMethod::ShiftInsert, "ctrl_shift_v" => PasteMethod::CtrlShiftV, + "external_script" => PasteMethod::ExternalScript, other => { warn!("Invalid paste method '{}', defaulting to ctrl_v", other); PasteMethod::CtrlV @@ -712,6 +713,18 @@ pub fn change_typing_tool_setting(app: AppHandle, tool: String) -> Result<(), St Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_external_script_path_setting( + app: AppHandle, + path: Option, +) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.external_script_path = path; + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Result<(), String> { diff --git a/src/bindings.ts b/src/bindings.ts index 82c56cc..04bc976 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -128,6 +128,14 @@ async changeTypingToolSetting(tool: string) : Promise> { else return { status: "error", error: e as any }; } }, +async changeExternalScriptPathSetting(path: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_external_script_path_setting", { path }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeClipboardHandlingSetting(handling: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_clipboard_handling_setting", { handling }) }; @@ -706,7 +714,7 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } @@ -741,7 +749,7 @@ export type HistoryEntry = { id: number; file_name: string; timestamp: number; s /** * Result of changing keyboard implementation */ -export type ImplementationChangeResult = { success: boolean; +export type ImplementationChangeResult = { success: boolean; /** * List of binding IDs that were reset to defaults due to incompatibility */ @@ -753,7 +761,7 @@ export type ModelInfo = { id: string; name: string; description: string; filenam export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null } export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5" export type OverlayPosition = "none" | "top" | "bottom" -export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" +export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } diff --git a/src/components/settings/PasteMethod.tsx b/src/components/settings/PasteMethod.tsx index 8621567..52df551 100644 --- a/src/components/settings/PasteMethod.tsx +++ b/src/components/settings/PasteMethod.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; +import { Input } from "../ui/Input"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import type { PasteMethod } from "@/bindings"; @@ -55,11 +56,20 @@ export const PasteMethodSetting: React.FC = React.memo( ); } + // External script is only available on Linux + if (osType === "linux") { + options.push({ + value: "external_script", + label: t("settings.advanced.pasteMethod.options.externalScript"), + }); + } + return options; }; const selectedMethod = (getSetting("paste_method") || "ctrl_v") as PasteMethod; + const externalScriptPath = getSetting("external_script_path") || ""; const pasteMethodOptions = getPasteMethodOptions(osType); @@ -71,14 +81,29 @@ export const PasteMethodSetting: React.FC = React.memo( grouped={grouped} tooltipPosition="bottom" > - - updateSetting("paste_method", value as PasteMethod) - } - disabled={isUpdating("paste_method")} - /> +
+ + updateSetting("paste_method", value as PasteMethod) + } + disabled={isUpdating("paste_method")} + /> + {selectedMethod === "external_script" && ( + + updateSetting("external_script_path", e.target.value) + } + placeholder={t( + "settings.advanced.pasteMethod.externalScriptPlaceholder", + )} + disabled={isUpdating("external_script_path")} + /> + )} +
); }, diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 6a6e1f8..60d45f5 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -261,8 +261,10 @@ "clipboardCtrlShiftV": "Zwischenablage (Strg+Umschalt+V)", "clipboardShiftInsert": "Zwischenablage (Umschalt+Einfg)", "direct": "Direkt", - "none": "Keine" - } + "none": "Keine", + "externalScript": "Externes Skript" + }, + "externalScriptPlaceholder": "/pfad/zu/ihrem/skript.sh" }, "typingTool": { "title": "Eingabetool", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index d07b537..69fd796 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -261,8 +261,10 @@ "clipboardCtrlShiftV": "Clipboard (Ctrl+Shift+V)", "clipboardShiftInsert": "Clipboard (Shift+Insert)", "direct": "Direct", - "none": "None" - } + "none": "None", + "externalScript": "External Script" + }, + "externalScriptPlaceholder": "/path/to/your/script.sh" }, "typingTool": { "title": "Typing Tool", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 5aa9bd7..cc01fda 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -261,8 +261,10 @@ "clipboardCtrlShiftV": "Presse-papiers (Ctrl+Shift+V)", "clipboardShiftInsert": "Presse-papiers (Shift+Insert)", "direct": "Direct", - "none": "Aucun" - } + "none": "Aucun", + "externalScript": "Script externe" + }, + "externalScriptPlaceholder": "/chemin/vers/votre/script.sh" }, "typingTool": { "title": "Outil de frappe", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index f7ddcf7..c167910 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -261,8 +261,10 @@ "clipboardCtrlShiftV": "Appunti (Ctrl+Shift+V)", "clipboardShiftInsert": "Appunti (Shift+Insert)", "direct": "Diretto", - "none": "Nessuno" - } + "none": "Nessuno", + "externalScript": "Script esterno" + }, + "externalScriptPlaceholder": "/percorso/del/tuo/script.sh" }, "typingTool": { "title": "Strumento di digitazione", diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 46cab18..462dbf9 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -113,6 +113,8 @@ const settingUpdaters: { commands.changeWordCorrectionThresholdSetting(value as number), paste_method: (value) => commands.changePasteMethodSetting(value as string), typing_tool: (value) => commands.changeTypingToolSetting(value as string), + external_script_path: (value) => + commands.changeExternalScriptPathSetting(value as string | null), clipboard_handling: (value) => commands.changeClipboardHandlingSetting(value as string), auto_submit: (value) => commands.changeAutoSubmitSetting(value as boolean),