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>
300 lines
9.4 KiB
Rust
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"]
|
|
);
|
|
}
|
|
}
|