diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 57972cb02..a639bb524 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -3,7 +3,7 @@ use crate::input::{self, EnigoState}; use crate::settings::TypingTool; use crate::settings::{get_settings, AutoSubmitKey, ClipboardHandling, PasteMethod}; use enigo::{Direction, Enigo, Key, Keyboard}; -use log::info; +use log::{info, warn}; use std::process::Command; use std::time::Duration; use tauri::{AppHandle, Manager}; @@ -12,6 +12,13 @@ use tauri_plugin_clipboard_manager::ClipboardExt; #[cfg(target_os = "linux")] use crate::utils::{is_kde_wayland, is_wayland}; +/// Configuration for Linux direct-typing tools (dotool, wtype, etc). +#[cfg(target_os = "linux")] +struct LinuxTypingConfig { + tool: TypingTool, + delay_ms: u64, +} + /// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard. fn paste_via_clipboard( enigo: &mut Enigo, @@ -120,10 +127,10 @@ fn try_send_key_combo_linux(paste_method: &PasteMethod) -> Result /// 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 { +fn try_direct_typing_linux(text: &str, config: &LinuxTypingConfig) -> Result { // If user specified a tool, try only that one - if preferred_tool != TypingTool::Auto { - return match preferred_tool { + if config.tool != TypingTool::Auto { + return match config.tool { TypingTool::Wtype if is_wtype_available() => { info!("Using user-specified wtype"); type_text_via_wtype(text)?; @@ -136,7 +143,7 @@ fn try_direct_typing_linux(text: &str, preferred_tool: TypingTool) -> Result { info!("Using user-specified dotool"); - type_text_via_dotool(text)?; + type_text_via_dotool(text, config.delay_ms)?; Ok(true) } TypingTool::Ydotool if is_ydotool_available() => { @@ -151,7 +158,7 @@ fn try_direct_typing_linux(text: &str, preferred_tool: TypingTool) -> Result Err(format!( "Typing tool {:?} is not available on this system", - preferred_tool + config.tool )), }; } @@ -173,7 +180,7 @@ fn try_direct_typing_linux(text: &str, preferred_tool: TypingTool) -> Result bool { .unwrap_or(false) } -/// Check if dotool is available (another Wayland text input tool) +/// Check if dotool is available (uinput-based, works on both Wayland and X11) #[cfg(target_os = "linux")] fn is_dotool_available() -> bool { Command::new("which") @@ -241,6 +248,16 @@ fn is_dotool_available() -> bool { .unwrap_or(false) } +/// Check if dotoolc is available (daemon client for dotoold) +#[cfg(target_os = "linux")] +fn is_dotoolc_available() -> bool { + Command::new("which") + .arg("dotoolc") + .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 { @@ -317,34 +334,160 @@ fn type_text_via_xdotool(text: &str) -> Result<(), String> { } /// Type text directly via dotool (works on both Wayland and X11 via uinput). +/// Prefers dotoolc (daemon client, faster) when dotoold is running, +/// falls back to standalone dotool if dotoolc fails. +/// +/// `delay_ms` controls inter-keystroke timing: split evenly between +/// typedelay (gap between keys) and typehold (key press duration). +#[cfg(target_os = "linux")] +fn type_text_via_dotool(text: &str, delay_ms: u64) -> Result<(), String> { + // Try dotoolc first (reuses daemon's virtual device), fall back to dotool + if is_dotoolc_available() { + match run_dotool_cmd("dotoolc", text, delay_ms) { + Ok(()) => return Ok(()), + Err(e) => { + warn!("dotoolc failed (dotoold may not be running): {}", e); + info!("Falling back to standalone dotool"); + } + } + } + run_dotool_cmd("dotool", text, delay_ms) +} + +/// Sanitize text for dotool stdin to prevent command injection. +/// dotool interprets each line as a separate command, so newlines in +/// transcript text could inject arbitrary commands (e.g. `keydelay 9999`). +/// Control characters (tabs, escapes, etc.) are replaced with spaces, then +/// all whitespace is collapsed to single spaces. This is intentional: +/// dotool commands are whitespace-delimited, so extra spaces could cause +/// parse issues. Speech-to-text output rarely contains meaningful +/// multi-space formatting. #[cfg(target_os = "linux")] -fn type_text_via_dotool(text: &str) -> Result<(), String> { +fn sanitize_dotool_text(text: &str) -> String { + text.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Run a dotool/dotoolc command with the given text and delay settings. +/// Times out after 5 seconds to prevent hangs if the daemon socket is unhealthy. +#[cfg(target_os = "linux")] +fn run_dotool_cmd(cmd_name: &str, text: &str, delay_ms: u64) -> Result<(), String> { use std::io::Write; use std::process::Stdio; - let mut child = Command::new("dotool") + let sanitized = sanitize_dotool_text(text); + + let mut child = Command::new(cmd_name) .stdin(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() - .map_err(|e| format!("Failed to spawn dotool: {}", e))?; + .map_err(|e| format!("Failed to spawn {}: {}", cmd_name, e))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| format!("Failed to open stdin for {}", cmd_name))?; + let half_delay = delay_ms / 2; + writeln!(stdin, "typedelay {}", half_delay) + .map_err(|e| format!("Failed to write to {} stdin: {}", cmd_name, e))?; + writeln!(stdin, "typehold {}", delay_ms - half_delay) + .map_err(|e| format!("Failed to write to {} stdin: {}", cmd_name, e))?; + writeln!(stdin, "type {}", sanitized) + .map_err(|e| format!("Failed to write to {} stdin: {}", cmd_name, e))?; + drop(stdin); // Child sees EOF and finishes + + // Wait with timeout to prevent indefinite hang if daemon socket is unhealthy. + // Poll try_wait() to avoid spawning a thread that can leak if the child hangs. + // + // Budget = grace period + (chars * max(delay_ms, 1) * 2 for safety margin). + // The `max(delay_ms, 1)` floor ensures long transcriptions at delay=0 still + // get per-char headroom beyond the fixed grace period. Unhealthy daemons exit + // in milliseconds, so this only extends the window for legitimate typing. + let per_char_ms = delay_ms.max(1); + let typing_budget_ms = (sanitized.chars().count() as u64) + .saturating_mul(per_char_ms) + .saturating_mul(2); + let timeout = Duration::from_millis(DOTOOL_TIMEOUT_GRACE_MS.saturating_add(typing_budget_ms)); + let deadline = std::time::Instant::now() + timeout; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if std::time::Instant::now() >= deadline { + // Before declaring timeout, check one more time — the process may + // have just exited between the last try_wait() and the deadline check. + match child.try_wait() { + Ok(Some(status)) => break status, + _ => { + if let Err(e) = child.kill() { + info!("{} kill failed (may have already exited): {}", cmd_name, e); + } + let _ = child.wait(); // Reap zombie regardless + let stderr = read_child_stderr(&mut child); + return Err(format_dotool_error( + cmd_name, + "timed out", + &timeout, + &stderr, + )); + } + } + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => { + return Err(format!("Failed to wait for {}: {}", cmd_name, e)); + } + } + }; - if let Some(mut stdin) = child.stdin.take() { - // dotool uses "type " command - writeln!(stdin, "type {}", text) - .map_err(|e| format!("Failed to write to dotool stdin: {}", e))?; + if !exit_status.success() { + let stderr = read_child_stderr(&mut child); + let detail = format!("exited with status {}", exit_status); + return Err(format_dotool_error(cmd_name, &detail, &timeout, &stderr)); } - let output = child - .wait_with_output() - .map_err(|e| format!("Failed to wait for dotool: {}", e))?; + Ok(()) +} - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("dotool failed: {}", stderr)); - } +/// Drain stderr from a dotool child process. Returns an empty string if stderr +/// wasn't piped or the read failed — callers surface whatever we got. +#[cfg(target_os = "linux")] +fn read_child_stderr(child: &mut std::process::Child) -> String { + use std::io::Read; + let Some(mut stderr) = child.stderr.take() else { + return String::new(); + }; + let mut buf = String::new(); + let _ = stderr.read_to_string(&mut buf); + buf.trim().to_string() +} - Ok(()) +/// Format a dotool error with stderr context when available. +#[cfg(target_os = "linux")] +fn format_dotool_error(cmd_name: &str, detail: &str, timeout: &Duration, stderr: &str) -> String { + if stderr.is_empty() { + if detail == "timed out" { + format!("{} timed out after {:?}", cmd_name, timeout) + } else { + format!("{} {}", cmd_name, detail) + } + } else if detail == "timed out" { + format!("{} timed out after {:?}: {}", cmd_name, timeout, stderr) + } else { + format!("{} {}: {}", cmd_name, detail, stderr) + } } +/// Grace period added to the dotool typing deadline. Covers socket setup, +/// process spawn, and completion latency beyond raw keystroke time. +#[cfg(target_os = "linux")] +const DOTOOL_TIMEOUT_GRACE_MS: u64 = 5_000; + /// Type text directly via ydotool (uinput-based, requires ydotoold daemon). #[cfg(target_os = "linux")] fn type_text_via_ydotool(text: &str) -> Result<(), String> { @@ -528,14 +671,17 @@ fn paste_via_external_script(text: &str, script_path: &str) -> Result<(), String fn paste_direct( enigo: &mut Enigo, text: &str, - #[cfg(target_os = "linux")] typing_tool: TypingTool, + #[cfg(target_os = "linux")] config: &LinuxTypingConfig, ) -> Result<(), String> { #[cfg(target_os = "linux")] { - if try_direct_typing_linux(text, typing_tool)? { + if try_direct_typing_linux(text, config)? { return Ok(()); } - info!("Falling back to enigo for direct text input"); + warn!( + "No native typing tool available. Falling back to enigo (may be unreliable on Wayland). \ + Install dotool (recommended) or ydotool for reliable direct typing." + ); } input::paste_text_direct(enigo, text) @@ -624,7 +770,10 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { &mut enigo, &text, #[cfg(target_os = "linux")] - settings.typing_tool, + &LinuxTypingConfig { + tool: settings.typing_tool, + delay_ms: settings.typing_delay_ms, + }, )?; } PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => { @@ -684,4 +833,84 @@ mod tests { assert!(should_send_auto_submit(true, PasteMethod::CtrlShiftV)); assert!(should_send_auto_submit(true, PasteMethod::ShiftInsert)); } + + #[cfg(target_os = "linux")] + #[test] + fn dotool_sanitization_prevents_command_injection() { + let malicious = "hello\nkeydelay 9999\ntype pwned"; + let sanitized = sanitize_dotool_text(malicious); + assert_eq!(sanitized, "hello keydelay 9999 type pwned"); + assert!(!sanitized.contains('\n')); + } + + #[cfg(target_os = "linux")] + #[test] + fn dotool_sanitization_handles_crlf() { + let sanitized = sanitize_dotool_text("line one\r\nline two\r\n"); + assert_eq!(sanitized, "line one line two"); + } + + #[cfg(target_os = "linux")] + #[test] + fn dotool_sanitization_strips_control_chars() { + let with_controls = "hello\x07world\x1b[31mred"; + let sanitized = sanitize_dotool_text(with_controls); + assert!(!sanitized.chars().any(|c| c.is_control())); + assert!(sanitized.contains("hello")); + assert!(sanitized.contains("world")); + } + + #[cfg(target_os = "linux")] + #[test] + fn dotool_sanitization_preserves_unicode() { + let unicode = "café résumé naïve"; + let sanitized = sanitize_dotool_text(unicode); + assert_eq!(sanitized, "café résumé naïve"); + } + + #[test] + fn dotool_delay_halving_is_correct() { + // Even delay splits evenly + let delay: u64 = 4; + let half = delay / 2; + let remainder = delay - half; + assert_eq!(half, 2); + assert_eq!(remainder, 2); + + // Odd delay: no millisecond lost + let delay: u64 = 5; + let half = delay / 2; + let remainder = delay - half; + assert_eq!(half + remainder, 5); + + // Zero delay + let delay: u64 = 0; + let half = delay / 2; + let remainder = delay - half; + assert_eq!(half, 0); + assert_eq!(remainder, 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn dotool_timeout_scales_with_text_and_delay() { + // Compute the effective timeout the way run_dotool_cmd does so long + // transcriptions aren't killed mid-typing. + fn timeout_ms(chars: u64, delay_ms: u64) -> u64 { + DOTOOL_TIMEOUT_GRACE_MS.saturating_add(chars.saturating_mul(delay_ms).saturating_mul(2)) + } + + // Empty text still gets the grace period + assert_eq!(timeout_ms(0, 2), DOTOOL_TIMEOUT_GRACE_MS); + + // 1000 chars at 50ms/key ≈ 50s of typing → needs >50s timeout + let t = timeout_ms(1_000, 50); + assert!( + t >= 100_000, + "expected ≥100s for 1000 chars @ 50ms, got {t}ms" + ); + + // Overflow safety + assert_eq!(timeout_ms(u64::MAX, u64::MAX), u64::MAX); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dfbc27dd9..19fb3243b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -339,6 +339,7 @@ pub fn run(cli_args: CliArgs) { shortcut::change_word_correction_threshold_setting, shortcut::change_extra_recording_buffer_setting, shortcut::change_paste_delay_ms_setting, + shortcut::change_typing_delay_ms_setting, shortcut::change_paste_method_setting, shortcut::get_available_typing_tools, shortcut::change_typing_tool_setting, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 878d5a98e..20848c2ad 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -417,6 +417,13 @@ pub struct AppSettings { pub show_tray_icon: bool, #[serde(default = "default_paste_delay_ms")] pub paste_delay_ms: u64, + /// Inter-keystroke delay for direct typing tools (dotool, etc). + /// Clamped to 0-50ms on deserialization to prevent hang-like behavior. + #[serde( + default = "default_typing_delay_ms", + deserialize_with = "deserialize_typing_delay_ms" + )] + pub typing_delay_ms: u64, #[serde(default = "default_typing_tool")] pub typing_tool: TypingTool, pub external_script_path: Option, @@ -483,6 +490,26 @@ fn default_paste_delay_ms() -> u64 { 60 } +fn default_typing_delay_ms() -> u64 { + 2 +} + +pub const MAX_TYPING_DELAY_MS: u64 = 50; + +fn deserialize_typing_delay_ms<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = u64::deserialize(deserializer).unwrap_or_else(|_| { + warn!( + "Invalid typing_delay_ms value, using default ({}ms)", + default_typing_delay_ms() + ); + default_typing_delay_ms() + }); + Ok(value.min(MAX_TYPING_DELAY_MS)) +} + fn default_auto_submit() -> bool { false } @@ -807,6 +834,7 @@ pub fn get_default_settings() -> AppSettings { keyboard_implementation: KeyboardImplementation::default(), show_tray_icon: default_show_tray_icon(), paste_delay_ms: default_paste_delay_ms(), + typing_delay_ms: default_typing_delay_ms(), typing_tool: default_typing_tool(), external_script_path: None, custom_filler_words: None, @@ -986,4 +1014,44 @@ mod tests { assert!(!out.contains("secret")); assert!(out.contains("[REDACTED]")); } + + #[test] + fn default_typing_delay_is_2ms() { + let settings = get_default_settings(); + assert_eq!(settings.typing_delay_ms, 2); + } + + /// Serialize default settings, apply a JSON override, and deserialize back. + /// This mirrors real-world settings persistence where missing fields get defaults. + fn settings_with_override(override_json: &str) -> AppSettings { + let defaults = get_default_settings(); + let mut value: serde_json::Value = serde_json::to_value(&defaults).unwrap(); + let overrides: serde_json::Value = serde_json::from_str(override_json).unwrap(); + if let (serde_json::Value::Object(ref mut map), serde_json::Value::Object(ovr)) = + (&mut value, overrides) + { + for (k, v) in ovr { + map.insert(k, v); + } + } + serde_json::from_value(value).unwrap() + } + + #[test] + fn typing_delay_clamped_to_max() { + let settings = settings_with_override(r#"{"typing_delay_ms": 9999}"#); + assert_eq!(settings.typing_delay_ms, MAX_TYPING_DELAY_MS); + } + + #[test] + fn typing_delay_zero_is_valid() { + let settings = settings_with_override(r#"{"typing_delay_ms": 0}"#); + assert_eq!(settings.typing_delay_ms, 0); + } + + #[test] + fn typing_delay_missing_uses_default() { + let settings = get_default_settings(); + assert_eq!(settings.typing_delay_ms, 2); + } } diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 79e0766e3..ae42723f4 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -678,6 +678,15 @@ pub fn change_paste_delay_ms_setting(app: AppHandle, ms: u64) -> Result<(), Stri Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_typing_delay_ms_setting(app: AppHandle, ms: u64) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.typing_delay_ms = ms.min(settings::MAX_TYPING_DELAY_MS); + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> { diff --git a/src/bindings.ts b/src/bindings.ts index 476c41180..36ceed5f6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -125,6 +125,14 @@ async changePasteDelayMsSetting(ms: number) : Promise> { else return { status: "error", error: e as any }; } }, +async changeTypingDelayMsSetting(ms: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_typing_delay_ms_setting", { ms }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changePasteMethodSetting(method: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_paste_method_setting", { method }) }; @@ -832,7 +840,7 @@ historyUpdatePayload: "history-update-payload" /** 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?: SecretMap; 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; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number } +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?: SecretMap; 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; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type AvailableAccelerators = { whisper: string[]; ort: string[]; gpu_devices: GpuDeviceOption[] } diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index 733f97db6..0c8c1fdb2 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -20,6 +20,7 @@ import { useSettings } from "../../../hooks/useSettings"; import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationSelector"; import { AccelerationSelector } from "../AccelerationSelector"; import { LazyStreamClose } from "../LazyStreamClose"; +import { TypingDelay } from "../debug/TypingDelay"; export const AdvancedSettings: React.FC = () => { const { t } = useTranslation(); @@ -65,6 +66,7 @@ export const AdvancedSettings: React.FC = () => { grouped={true} /> + )} diff --git a/src/components/settings/debug/TypingDelay.tsx b/src/components/settings/debug/TypingDelay.tsx new file mode 100644 index 000000000..e64f35741 --- /dev/null +++ b/src/components/settings/debug/TypingDelay.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Slider } from "../../ui/Slider"; +import { useSettings } from "../../../hooks/useSettings"; + +interface TypingDelayProps { + descriptionMode?: "tooltip" | "inline"; + grouped?: boolean; +} + +export const TypingDelay: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { t } = useTranslation(); + const { settings, updateSetting } = useSettings(); + + const handleDelayChange = (value: number) => { + updateSetting("typing_delay_ms", value); + }; + + return ( + `${v}ms`} + /> + ); +}; diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 81f7a14ec..7c9970b2e 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -485,6 +485,10 @@ "recordingBuffer": { "title": "مخزن التسجيل الإضافي", "description": "وقت إضافي (بالمللي ثانية) للاستمرار في التسجيل بعد تحرير المفتاح، لالتقاط الصوت المتبقي. 0 = لا مخزن إضافي." + }, + "typingDelay": { + "title": "تأخير الكتابة", + "description": "تأخير بين ضغطات المفاتيح لأدوات الكتابة المباشرة مثل dotool (بالمللي ثانية). أقل = كتابة أسرع، أعلى = أكثر موثوقية مع مديري النوافذ البطيئين. الافتراضي: 2 مللي ثانية." } }, "about": { diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 2cb5d79dc..251ceaa41 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Допълнителен буфер на записа", "description": "Допълнително време (в милисекунди) за запис след пускане на клавиша, за да се улови краят на аудиото. 0 = без допълнителен буфер." + }, + "typingDelay": { + "title": "Забавяне при писане", + "description": "Забавяне между натискания на клавиши за директни инструменти за писане като dotool (в милисекунди). По-ниско = по-бързо писане, по-високо = по-надеждно при бавни композитори. По подразбиране: 2 мс." } }, "about": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index aa93251c5..f39d66d76 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Extra vyrovnávací paměť nahrávání", "description": "Extra čas (v milisekundách) pro pokračování nahrávání po uvolnění klávesy, pro zachycení zbývajícího zvuku. 0 = žádná extra vyrovnávací paměť." + }, + "typingDelay": { + "title": "Zpoždění psaní", + "description": "Zpoždění mezi stisky kláves pro přímé nástroje pro psaní jako dotool (v milisekundách). Nižší = rychlejší psaní, vyšší = spolehlivější na pomalých kompozitorech. Výchozí: 2 ms." } }, "about": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index c031bea6d..f8315852f 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Zusätzlicher Aufnahmepuffer", "description": "Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiter aufzunehmen, um nachlaufendes Audio zu erfassen. 0 = kein zusätzlicher Puffer." + }, + "typingDelay": { + "title": "Tippverzögerung", + "description": "Verzögerung pro Tastendruck für direkte Eingabetools wie dotool (in Millisekunden). Niedriger = schnelleres Tippen, höher = zuverlässiger auf langsamen Compositoren. Standard: 2 ms." } }, "about": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index eef96e5be..9fb25c2cd 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -306,7 +306,7 @@ }, "typingTool": { "title": "Typing Tool", - "description": "Choose which Linux typing tool to use for Direct paste method. Auto will automatically detect and use the best available tool for your system.", + "description": "Choose which Linux typing tool to use for Direct paste method. Auto will automatically detect and use the best available tool for your system. Note: multiline transcriptions are flattened to single-line for safety with direct typing tools like dotool.", "options": { "auto": "Auto (Recommended)" } @@ -504,6 +504,10 @@ "title": "Paste Delay", "description": "Delay before sending paste keystroke (in milliseconds). Increase if wrong text is being pasted." }, + "typingDelay": { + "title": "Typing Delay", + "description": "Per-keystroke delay for direct typing tools like dotool (in milliseconds). Lower = faster typing, higher = more reliable on slow compositors. Default: 2ms." + }, "recordingBuffer": { "title": "Extra Recording Buffer", "description": "Extra time (in milliseconds) to keep recording after you release the key, to capture trailing audio. 0 = no extra buffer." diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index e33d5cb41..19face205 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Búfer de grabación adicional", "description": "Tiempo adicional (en milisegundos) para seguir grabando después de soltar la tecla, para capturar el audio restante. 0 = sin búfer adicional." + }, + "typingDelay": { + "title": "Retraso de escritura", + "description": "Retraso por pulsación para herramientas de escritura directa como dotool (en milisegundos). Más bajo = escritura más rápida, más alto = más fiable en compositores lentos. Predeterminado: 2 ms." } }, "about": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 479d00c49..3196d0b0c 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Tampon d'enregistrement supplémentaire", "description": "Temps supplémentaire (en millisecondes) pour continuer l'enregistrement après avoir relâché la touche, pour capturer l'audio restant. 0 = pas de tampon supplémentaire." + }, + "typingDelay": { + "title": "Délai de frappe", + "description": "Délai par touche pour les outils de saisie directe comme dotool (en millisecondes). Plus bas = frappe plus rapide, plus haut = plus fiable sur les compositeurs lents. Par défaut : 2 ms." } }, "about": { diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 9b0518d39..c0ba1bc61 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "באפר הקלטה נוסף", "description": "זמן נוסף (במילישניות) להמשך הקלטה אחרי שחרור המקש, כדי ללכוד סוף דיבור. 0 = בלי באפר נוסף." + }, + "typingDelay": { + "title": "השהיית הקלדה", + "description": "השהיה בין הקשות מקש עבור כלי הקלדה ישירים כמו dotool (במילישניות). נמוך יותר = הקלדה מהירה יותר, גבוה יותר = אמין יותר על compositors איטיים. ברירת מחדל: 2 מילישניות." } }, "about": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 6f7cd035e..6e003b016 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Buffer di registrazione extra", "description": "Tempo extra (in millisecondi) per continuare a registrare dopo aver rilasciato il tasto, per catturare l'audio finale. 0 = nessun buffer extra." + }, + "typingDelay": { + "title": "Ritardo di digitazione", + "description": "Ritardo per pressione di tasto per strumenti di digitazione diretta come dotool (in millisecondi). Più basso = digitazione più veloce, più alto = più affidabile su compositor lenti. Predefinito: 2 ms." } }, "about": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index a9aad4ed4..ce57e0ae2 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "追加録音バッファ", "description": "キーを離した後に録音を続ける追加時間(ミリ秒)。末尾の音声を捕捉するため。0 = 追加バッファなし。" + }, + "typingDelay": { + "title": "入力遅延", + "description": "dotool など直接入力ツールのキー入力ごとの遅延(ミリ秒)。低い = 入力が速い、高い = 遅いコンポジターでより確実。デフォルト: 2ms。" } }, "about": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 4e168d2b0..70244ab75 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -507,6 +507,10 @@ "appData": "앱 데이터:", "models": "모델:", "settings": "설정:" + }, + "typingDelay": { + "title": "입력 지연", + "description": "dotool 같은 직접 입력 도구의 키 입력당 지연 시간(밀리초). 낮음 = 빠른 입력, 높음 = 느린 컴포지터에서 더 안정적. 기본값: 2ms." } }, "about": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index ed064221e..24ef3e3e7 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Dodatkowy bufor nagrywania", "description": "Dodatkowy czas (w milisekundach) na kontynuowanie nagrywania po zwolnieniu klawisza, aby przechwycić końcowy dźwięk. 0 = brak dodatkowego bufora." + }, + "typingDelay": { + "title": "Opóźnienie pisania", + "description": "Opóźnienie na naciśnięcie klawisza dla bezpośrednich narzędzi do pisania jak dotool (w milisekundach). Niższe = szybsze pisanie, wyższe = bardziej niezawodne na wolnych kompozytorach. Domyślnie: 2 ms." } }, "about": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index a85436f47..1dfb60469 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Buffer de gravação extra", "description": "Tempo extra (em milissegundos) para continuar gravando após soltar a tecla, para capturar áudio restante. 0 = sem buffer extra." + }, + "typingDelay": { + "title": "Atraso de digitação", + "description": "Atraso por tecla pressionada para ferramentas de digitação direta como dotool (em milissegundos). Menor = digitação mais rápida, maior = mais confiável em compositores lentos. Padrão: 2 ms." } }, "about": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index b196a329a..03034ef85 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Дополнительный буфер записи", "description": "Дополнительное время (в миллисекундах) для продолжения записи после отпускания клавиши, чтобы захватить завершающий звук. 0 = без дополнительного буфера." + }, + "typingDelay": { + "title": "Задержка ввода", + "description": "Задержка между нажатиями клавиш для инструментов прямого ввода, таких как dotool (в миллисекундах). Меньше = быстрее ввод, больше = надёжнее на медленных композиторах. По умолчанию: 2 мс." } }, "about": { diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index de731efc5..0138a8e37 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Extra inspelningsbuffert", "description": "Extra tid (i millisekunder) för att fortsätta spela in efter att du släppt tangenten, för att fånga upp avslutande ljud. 0 = ingen extra buffert." + }, + "typingDelay": { + "title": "Skrivfördröjning", + "description": "Fördröjning per tangenttryckning för direkta skrivverktyg som dotool (i millisekunder). Lägre = snabbare skrivning, högre = mer tillförlitligt på långsamma kompositörer. Standard: 2 ms." } }, "about": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index e4cc4e2e7..ed9fc413d 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Ekstra kayıt tamponu", "description": "Tuşu bıraktıktan sonra arka plandaki sesi yakalamak için kaydı sürdürme süresi (milisaniye). 0 = ekstra tampon yok." + }, + "typingDelay": { + "title": "Yazma gecikmesi", + "description": "dotool gibi doğrudan yazma araçları için tuş vuruşu başına gecikme (milisaniye cinsinden). Daha düşük = daha hızlı yazma, daha yüksek = yavaş birleştiricilerde daha güvenilir. Varsayılan: 2 ms." } }, "about": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 825c1ee15..2f5d6d001 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Додатковий буфер запису", "description": "Додатковий час (у мілісекундах) для продовження запису після відпускання клавіші, щоб захопити завершальний звук. 0 = без додаткового буфера." + }, + "typingDelay": { + "title": "Затримка введення", + "description": "Затримка між натисканнями клавіш для інструментів прямого введення, таких як dotool (у мілісекундах). Менше = швидше введення, більше = надійніше на повільних композиторах. За замовчуванням: 2 мс." } }, "about": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index b6bfe7c96..665eeac1c 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "Bộ đệm ghi âm thêm", "description": "Thời gian thêm (tính bằng mili giây) để tiếp tục ghi âm sau khi nhả phím, để thu âm thanh cuối. 0 = không có bộ đệm thêm." + }, + "typingDelay": { + "title": "Độ trễ gõ phím", + "description": "Độ trễ mỗi lần gõ phím cho các công cụ gõ trực tiếp như dotool (tính bằng mili giây). Thấp hơn = gõ nhanh hơn, cao hơn = đáng tin cậy hơn trên các compositor chậm. Mặc định: 2ms." } }, "about": { diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 6eee9637a..28e4c5f43 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "額外錄音緩衝", "description": "放開按鍵後繼續錄音的額外時間(毫秒),以捕捉尾音。0 = 無額外緩衝。" + }, + "typingDelay": { + "title": "鍵入延遲", + "description": "像 dotool 這樣的直接鍵入工具的每鍵延遲(毫秒)。越低 = 鍵入越快,越高 = 在較慢的合成器上更可靠。預設:2 毫秒。" } }, "about": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 7dd690c54..ac406f3d3 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -507,6 +507,10 @@ "recordingBuffer": { "title": "额外录音缓冲", "description": "放开按键后继续录音的额外时间(毫秒),以捕捉尾音。0 = 无额外缓冲。" + }, + "typingDelay": { + "title": "键入延迟", + "description": "像 dotool 这样的直接键入工具的每键延迟(毫秒)。越低 = 键入越快,越高 = 在较慢的合成器上更可靠。默认值:2 毫秒。" } }, "about": { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index ef35ebfc2..cd539084f 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -119,6 +119,8 @@ const settingUpdaters: { commands.changeWordCorrectionThresholdSetting(value as number), paste_delay_ms: (value) => commands.changePasteDelayMsSetting(value as number), + typing_delay_ms: (value) => + commands.changeTypingDelayMsSetting(value as number), paste_method: (value) => commands.changePasteMethodSetting(value as string), typing_tool: (value) => commands.changeTypingToolSetting(value as string), external_script_path: (value) =>