feat(paste): auto-insert transcript at cursor via wtype/xdotool/ydotool/osascript/SendKeys
Adds an opt-in "auto-paste into focused window" toggle. When enabled, the dictation pipeline sets the clipboard and then sends a Ctrl+V / Cmd+V keystroke to whatever window currently has focus — the common case after a global-hotkey dictation, since Kon's own window never stole focus. Backend (src-tauri/src/commands/paste.rs) probes for a platform paste tool and falls back cleanly: - Linux Wayland: wtype > ydotool > xdotool - Linux X11: xdotool > ydotool > wtype - macOS: osascript System Events keystroke - Windows: PowerShell WScript.Shell SendKeys detect_paste_backends is a pure probe used by Settings to describe the available backend next to the toggle (or nudge the user to install one). paste_text always copies first, so auto-paste failure degrades to the existing clipboard-only behaviour and surfaces a warn toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ pub mod hotkey;
|
||||
pub mod live;
|
||||
pub mod llm;
|
||||
pub mod models;
|
||||
pub mod paste;
|
||||
pub mod profiles;
|
||||
pub mod tasks;
|
||||
pub mod transcription;
|
||||
|
||||
264
src-tauri/src/commands/paste.rs
Normal file
264
src-tauri/src/commands/paste.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
//! 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 arboard::Clipboard;
|
||||
use serde::Serialize;
|
||||
|
||||
#[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).
|
||||
#[tauri::command]
|
||||
pub async fn paste_text(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);
|
||||
}
|
||||
}
|
||||
|
||||
match trigger_paste_keystroke() {
|
||||
Ok(backend) => {
|
||||
outcome.backend = Some(backend);
|
||||
outcome.pasted = true;
|
||||
}
|
||||
Err(err) => outcome.message = Some(err),
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user