//! Auto-insert-at-cursor. //! //! `copy_to_clipboard` puts the transcript on the user's clipboard. That is //! fine when the user wants to choose where it lands. It is friction when //! they were about to paste into the already-focused window — which is //! almost always, for a dictation tool. //! //! This module adds the follow-on step: send the platform's Ctrl+V / Cmd+V //! keystroke to the focused window so the transcript lands where the cursor //! already is. Each platform uses a different primitive (wtype / xdotool / //! ydotool / osascript / SendKeys), so we probe and fall back. //! //! NOTE: focus must already be on the target window when the keystroke //! fires. The global hotkey flow preserves this naturally; clicking Kon's //! window does not. The frontend surfaces this caveat next to the toggle. use std::process::Command; use std::time::Duration; use arboard::Clipboard; use serde::Serialize; use tauri::Manager; /// Window after the paste keystroke at which we restore the user's /// prior clipboard content. 300 ms is enough for even a slow Wayland /// compositor to have fully delivered the synthesised Ctrl+V to the /// focused app; longer risks the user manually pasting again and /// stomping themselves. See Handy #921. const CLIPBOARD_RESTORE_MS: u64 = 300; /// Compositor settle time after hiding the preview overlay before firing /// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter /// Wayland for focus to return to the previously-focused app; shorter /// risks the keystroke still landing on the (now-invisible) overlay. const PREVIEW_HIDE_SETTLE_MS: u64 = 80; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PasteOutcome { /// The backend that executed the paste, if any (`"wtype"`, `"xdotool"`, /// `"ydotool"`, `"osascript"`, `"sendkeys"`). pub backend: Option, pub pasted: bool, pub copied: bool, /// Diagnostic message when either step failed. Present even on success /// if the paste fell back to copy-only so the frontend can toast it. pub message: Option, } /// Copy `text` to the clipboard, then trigger a paste keystroke in the /// focused window. Returns a structured result so the frontend can surface /// partial success (clipboard set, paste failed). /// /// Wayland compositor quirk: if Kon's always-on-top preview overlay is /// visible when the keystroke fires, KWin / Mutter may resolve the keystroke /// against the overlay (even with `focused: false` set at build time) and /// the paste lands inside Kon instead of the previously-focused app. We hide /// the preview window and give the compositor a beat to re-focus the real /// target before dispatching. Matches OpenWhispr's PR #246 fix on GNOME. #[tauri::command] pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result { let mut outcome = PasteOutcome { backend: None, pasted: false, copied: false, message: None, }; // Snapshot the user's existing clipboard text BEFORE we stomp it // with the transcript, so a background task can restore it after // the paste keystroke has landed. `arboard::Clipboard::get_text` // returns Err when the clipboard holds non-text content (images, // files, empty) — in those cases we skip the restore step // entirely rather than trying to coerce. This is the "never // silently clobber the user's clipboard" contract from Handy // #921 (Workstream B brief item #3). let prior_clipboard: Option = match Clipboard::new() { Ok(mut cb) => cb.get_text().ok(), Err(_) => None, }; match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) { Ok(()) => outcome.copied = true, Err(err) => { outcome.message = Some(format!("clipboard: {err}")); return Ok(outcome); } } // Brief item #10: if the focused window is a known terminal // emulator, skip the synthesised Ctrl+V — it duplicates the // keystroke through the PTY, which Kitty / Alacritty / Windows // Terminal all demonstrably mishandle (Handy #692). Clipboard-only // paste with a "Terminal detected" message lets the user finish // with a manual right-click paste or Ctrl+Shift+V. if let Some(term) = detect_focused_terminal() { outcome.message = Some(format!( "Terminal detected ({term}) — transcript is on your clipboard. \ Use Ctrl+Shift+V (or right-click → paste) to insert." )); // Prior clipboard snapshot is intentionally NOT restored here: // the user's path to recover the transcript is their own paste, // which needs the transcript on the clipboard. The restore // task below only runs when `outcome.pasted` is true. return Ok(outcome); } hide_preview_overlay_for_paste(&app).await; match trigger_paste_keystroke() { Ok(backend) => { outcome.backend = Some(backend); outcome.pasted = true; } Err(err) => outcome.message = Some(err), } // Fire-and-forget: restore the prior clipboard in the background // after CLIPBOARD_RESTORE_MS, regardless of whether the paste // succeeded. If paste failed, the transcript is still on the // clipboard and Kon's own "Copy" button remains the user's // recovery path; overwriting it with the restore would undo that. // So only restore when the keystroke actually fired. if outcome.pasted { if let Some(prior) = prior_clipboard.clone() { let transcript_clone = text.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await; restore_prior_clipboard(&prior, &transcript_clone); }); } } Ok(outcome) } /// Write `prior` back to the clipboard only if the current clipboard /// content is still the transcript we just set — i.e. the user hasn't /// already copied something new themselves in the last 300 ms. This /// avoids stomping a deliberate Cmd+C that happened right after the /// paste. fn restore_prior_clipboard(prior: &str, transcript: &str) { let mut cb = match Clipboard::new() { Ok(cb) => cb, Err(_) => return, }; if should_restore(cb.get_text().ok().as_deref(), transcript) { let _ = cb.set_text(prior.to_string()); } } /// Pure decision function: should we restore the prior clipboard? /// /// - Some(current) == transcript → yes, the clipboard still holds /// what we wrote, so restore is safe. /// - Some(current) != transcript → no, user has already copied /// something else in the 300 ms window; respect it. /// - None → couldn't read the clipboard (image, no access, etc.); /// conservative no-op. fn should_restore(current: Option<&str>, transcript: &str) -> bool { matches!(current, Some(text) if text == transcript) } /// Hide the transcription-preview window if it's currently visible, then /// sleep a short beat so the compositor can recompute focus. No-ops when /// the window isn't registered yet (user never enabled the overlay) or /// isn't currently shown. async fn hide_preview_overlay_for_paste(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("transcription-preview") else { return; }; let visible = window.is_visible().unwrap_or(false); if !visible { return; } if window.hide().is_err() { return; } tokio::time::sleep(Duration::from_millis(PREVIEW_HIDE_SETTLE_MS)).await; } /// Report which paste backends the OS has available right now. Pure probe — /// does not paste. Used by Settings to tell the user "install wtype" when /// nothing is available on their session. #[tauri::command] pub fn detect_paste_backends() -> Vec { let mut available = Vec::new(); #[cfg(target_os = "linux")] { for tool in ["wtype", "xdotool", "ydotool"] { if which_on_path(tool) { available.push(tool.to_string()); } } } #[cfg(target_os = "macos")] { available.push("osascript".to_string()); } #[cfg(target_os = "windows")] { available.push("sendkeys".to_string()); } available } /// Well-known terminal-emulator window classes / process names. Each /// entry is matched case-insensitively against whatever the /// focused-window probe returns on each platform. The list is /// deliberately conservative — users can always copy + paste manually, /// so false negatives are cheap; false positives silently break /// auto-paste on a legitimate GUI, which is expensive. /// Longest / most specific matches come first so that `classify_terminal` /// returns the specific name when two needles would both hit /// (e.g. "windowsterminal" contains "terminal"). Substring match is /// otherwise blunt enough that a `foot`/`pwsh` match on a non-terminal /// window would be vanishingly unlikely. const KNOWN_TERMINAL_CLASSES: &[&str] = &[ // Windows — specific names first so they win over plain "terminal". "windowsterminal", "powershell", "conhost", "console", "pwsh", "cmd", // macOS "iterm2", "iterm", "terminal", // Linux "gnome-terminal-server", "alacritty", "konsole", "wezterm", "kitty", "tilix", "xterm", "urxvt", "st-256color", "hyper", "foot", ]; /// Return `Some(class)` when the focused window matches a known /// terminal emulator, `None` otherwise. Pure by design: the actual /// platform probe is isolated in `detect_focused_window_class` so the /// classification rule is unit-testable. fn detect_focused_terminal() -> Option { let raw = detect_focused_window_class()?; classify_terminal(&raw) } /// Case-insensitive substring match of the focused window class / /// process name against `KNOWN_TERMINAL_CLASSES`. Returns the matched /// needle (for user-facing copy: "Terminal detected (kitty)") when /// positive. fn classify_terminal(raw: &str) -> Option { let haystack = raw.to_ascii_lowercase(); for needle in KNOWN_TERMINAL_CLASSES { if haystack.contains(needle) { return Some((*needle).to_string()); } } None } /// Probe the focused window's class name / process name. Returns /// `None` when the probe tool is missing or fails — we fall back to /// the default auto-paste path rather than guessing wrong and /// breaking a GUI user's workflow. fn detect_focused_window_class() -> Option { #[cfg(target_os = "linux")] { return detect_focused_window_class_linux(); } #[cfg(target_os = "macos")] { return detect_focused_window_class_macos(); } #[cfg(target_os = "windows")] { return detect_focused_window_class_windows(); } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { return None; } } #[cfg(target_os = "linux")] fn detect_focused_window_class_linux() -> Option { // X11: xdotool getactivewindow getwindowclassname if let Ok(output) = Command::new("xdotool") .args(["getactivewindow", "getwindowclassname"]) .output() { if output.status.success() { let class = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !class.is_empty() { return Some(class); } } } // Wayland: no reliable focused-window probe from an unprivileged // client (by design). Return None and let the normal paste path // run; users on Kitty-under-Wayland already lean on Ctrl+Shift+V // themselves. None } #[cfg(target_os = "macos")] fn detect_focused_window_class_macos() -> Option { // `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'` let output = Command::new("osascript") .args([ "-e", "tell application \"System Events\" to get name of first application process whose frontmost is true", ]) .output() .ok()?; if !output.status.success() { return None; } let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if name.is_empty() { None } else { Some(name) } } #[cfg(target_os = "windows")] fn detect_focused_window_class_windows() -> Option { // PowerShell one-liner: (Get-Process -Id (Get-Process | Where-Object ... // Keeping it simple here — GetForegroundWindow + GetWindowThreadProcessId // via PowerShell works without a `windows` crate dep. let script = r#" $w = Add-Type -MemberDefinition ' [DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int pid); ' -Name W -PassThru $pid = 0 [void]$w::GetWindowThreadProcessId($w::GetForegroundWindow(), [ref]$pid) (Get-Process -Id $pid).ProcessName "#; let output = Command::new("powershell") .args(["-NoProfile", "-Command", script]) .output() .ok()?; if !output.status.success() { return None; } let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); if name.is_empty() { None } else { Some(name) } } fn trigger_paste_keystroke() -> Result { #[cfg(target_os = "linux")] { linux_paste( std::env::var("XDG_SESSION_TYPE").ok().as_deref(), std::env::var_os("WAYLAND_DISPLAY").is_some(), ) } #[cfg(target_os = "macos")] { macos_paste() } #[cfg(target_os = "windows")] { windows_paste() } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { Err("auto-paste not implemented on this platform".into()) } } #[cfg(target_os = "linux")] fn linux_paste(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result { for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) { match run_linux_tool(tool) { Ok(()) => return Ok(tool.to_string()), Err(_) => continue, } } Err( "No paste backend available. Install wtype (Wayland) or xdotool (X11) to enable \ auto-insert-at-cursor." .into(), ) } #[cfg(target_os = "linux")] fn pick_linux_backend_order( xdg_session_type: Option<&str>, wayland_display_set: bool, ) -> &'static [&'static str] { let is_wayland = xdg_session_type .map(|value| value.eq_ignore_ascii_case("wayland")) .unwrap_or(false) || wayland_display_set; if is_wayland { &["wtype", "ydotool", "xdotool"] } else { &["xdotool", "ydotool", "wtype"] } } #[cfg(target_os = "linux")] fn run_linux_tool(tool: &str) -> Result<(), String> { let output = match tool { // wtype -M ctrl v -m ctrl (press ctrl, tap v, release ctrl) "wtype" => Command::new("wtype") .args(["-M", "ctrl", "v", "-m", "ctrl"]) .output(), "xdotool" => Command::new("xdotool").args(["key", "ctrl+v"]).output(), // ydotool linux input keycodes: 29=LEFTCTRL, 47=V. Format is // `code:state` pairs. Requires ydotoold running with access to // /dev/uinput. "ydotool" => Command::new("ydotool") .args(["key", "29:1", "47:1", "47:0", "29:0"]) .output(), other => return Err(format!("unknown backend: {other}")), } .map_err(|e| format!("{tool} unavailable: {e}"))?; if output.status.success() { Ok(()) } else { Err(format!( "{tool} exit {}: {}", output.status, String::from_utf8_lossy(&output.stderr).trim() )) } } #[cfg(target_os = "linux")] fn which_on_path(tool: &str) -> bool { Command::new("sh") .args(["-c", &format!("command -v {tool}")]) .output() .map(|output| output.status.success()) .unwrap_or(false) } #[cfg(target_os = "macos")] fn macos_paste() -> Result { let output = Command::new("osascript") .args([ "-e", "tell application \"System Events\" to keystroke \"v\" using command down", ]) .output() .map_err(|e| format!("osascript: {e}"))?; if output.status.success() { Ok("osascript".into()) } else { Err(format!( "osascript exit {}: {}", output.status, String::from_utf8_lossy(&output.stderr).trim() )) } } #[cfg(target_os = "windows")] fn windows_paste() -> Result { // SendKeys("^v") simulates Ctrl+V in the foreground window. Requires // no extra permissions on Windows. A native SendInput call would skip // the PowerShell spawn but pulls in the windows crate; cost not worth // the complexity until this hot path actually shows up. let output = Command::new("powershell") .args([ "-NoProfile", "-Command", "(New-Object -ComObject WScript.Shell).SendKeys('^v')", ]) .output() .map_err(|e| format!("powershell: {e}"))?; if output.status.success() { Ok("sendkeys".into()) } else { Err(format!( "powershell exit {}: {}", output.status, String::from_utf8_lossy(&output.stderr).trim() )) } } #[cfg(all(test, target_os = "linux"))] mod tests_linux { use super::pick_linux_backend_order; #[test] fn wayland_session_prefers_wtype_then_ydotool() { assert_eq!( pick_linux_backend_order(Some("wayland"), true), &["wtype", "ydotool", "xdotool"] ); } #[test] fn x11_session_prefers_xdotool() { assert_eq!( pick_linux_backend_order(Some("x11"), false), &["xdotool", "ydotool", "wtype"] ); } #[test] fn wayland_display_env_var_alone_is_enough() { assert_eq!( pick_linux_backend_order(None, true), &["wtype", "ydotool", "xdotool"] ); } #[test] fn uppercase_wayland_token_still_detects_wayland() { assert_eq!( pick_linux_backend_order(Some("WAYLAND"), false), &["wtype", "ydotool", "xdotool"] ); } } #[cfg(test)] mod tests_clipboard_restore { use super::should_restore; #[test] fn restores_when_clipboard_still_holds_transcript() { assert!(should_restore(Some("dictated text"), "dictated text")); } #[test] fn does_not_restore_when_user_copied_something_else() { assert!(!should_restore( Some("user copied this just now"), "dictated text" )); } #[test] fn does_not_restore_when_clipboard_unreadable() { assert!(!should_restore(None, "dictated text")); } #[test] fn does_not_restore_on_empty_mismatch() { assert!(!should_restore(Some(""), "dictated text")); } } #[cfg(test)] mod tests_terminal_classification { use super::classify_terminal; #[test] fn kitty_class_is_a_terminal() { assert_eq!(classify_terminal("kitty"), Some("kitty".into())); } #[test] fn alacritty_matches_case_insensitively() { assert_eq!(classify_terminal("Alacritty"), Some("alacritty".into())); } #[test] fn windows_terminal_matches_collapsed_name() { // Windows reports the process name sans space: "WindowsTerminal" assert_eq!( classify_terminal("WindowsTerminal"), Some("windowsterminal".into()), ); } #[test] fn iterm_matches_iterm2_as_well() { assert_eq!(classify_terminal("iTerm2"), Some("iterm2".into())); } #[test] fn firefox_is_not_a_terminal() { assert_eq!(classify_terminal("firefox"), None); } #[test] fn empty_class_is_not_a_terminal() { assert_eq!(classify_terminal(""), None); } #[test] fn substring_match_survives_xdotool_decorations() { // Some X11 window managers report "Alacritty.Alacritty" assert_eq!( classify_terminal("Alacritty.Alacritty"), Some("alacritty".into()), ); } }