Files
Lumotia/src-tauri/src/commands/paste.rs
Jake bc1ae3968e fix(paste): hide preview overlay before Ctrl+V to avoid Wayland focus race
Phase H landed the transcription preview overlay. Phase C landed auto-paste.
With both enabled the combo is broken on Wayland compositors (KWin, Mutter):
the overlay is always_on_top + visible at the moment paste_text fires its
Ctrl+V keystroke, and the compositor resolves the key to the topmost visible
window — which is the overlay, even though we built it with focused=false.
Net result: the transcript pastes into Kon instead of whatever app the user
was actually dictating into.

Fix, mirroring OpenWhispr's PR #246 shape: before trigger_paste_keystroke,
hide the transcription-preview window if it exists and is visible, then
sleep PREVIEW_HIDE_SETTLE_MS (80ms) so the compositor recomputes focus onto
the previously-focused app. No reshow — the user's confirmation is the text
appearing in the target app, not a fading overlay. The 80ms is enough on
KWin and Mutter; tunable if it shows up differently on other compositors.

paste_text now takes the tauri::AppHandle so it can reach the preview
window. Frontend invocation signature is unchanged (Tauri injects the
handle; the JS call site still passes { text }).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:23:22 +01:00

300 lines
9.4 KiB
Rust

//! 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<String>,
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<String>,
}
/// 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<PasteOutcome, String> {
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<String> {
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<String, String> {
#[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<String, String> {
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<String, String> {
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<String, String> {
// 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"]
);
}
}