Add "external script" paste method (#638)

* feat(linux): add "external script" paste method

* feat(i18n): external script translation for fr, de, it
This commit is contained in:
Pierre Carru 2026-02-19 10:07:16 +01:00 committed by GitHub
parent f8ee7fce7c
commit b90077b228
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 114 additions and 22 deletions

View file

@ -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) {

View file

@ -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,

View file

@ -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<String>,
}
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,
}
}

View file

@ -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<String>,
) -> 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> {

View file

@ -128,6 +128,14 @@ async changeTypingToolSetting(tool: string) : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async changeExternalScriptPathSetting(path: string | null) : Promise<Result<null, string>> {
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<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_clipboard_handling_setting", { handling }) };
@ -706,7 +714,7 @@ async updateRecordingRetentionPeriod(period: string) : Promise<Result<null, stri
},
/**
* Checks if the Mac is a laptop by detecting battery presence
*
*
* This uses pmset to check for battery information.
* Returns true if a battery is detected (laptop), false otherwise (desktop)
*/
@ -730,7 +738,7 @@ async isLaptop() : Promise<Result<boolean, string>> {
/** 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 }

View file

@ -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<PasteMethodProps> = 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<PasteMethodProps> = React.memo(
grouped={grouped}
tooltipPosition="bottom"
>
<Dropdown
options={pasteMethodOptions}
selectedValue={selectedMethod}
onSelect={(value) =>
updateSetting("paste_method", value as PasteMethod)
}
disabled={isUpdating("paste_method")}
/>
<div className="flex flex-col gap-2">
<Dropdown
options={pasteMethodOptions}
selectedValue={selectedMethod}
onSelect={(value) =>
updateSetting("paste_method", value as PasteMethod)
}
disabled={isUpdating("paste_method")}
/>
{selectedMethod === "external_script" && (
<Input
type="text"
value={externalScriptPath}
onChange={(e) =>
updateSetting("external_script_path", e.target.value)
}
placeholder={t(
"settings.advanced.pasteMethod.externalScriptPlaceholder",
)}
disabled={isUpdating("external_script_path")}
/>
)}
</div>
</SettingContainer>
);
},

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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),