//! 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; /// 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, }; 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); } } 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), } Ok(outcome) } /// 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 } 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 { 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"] ); } }