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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,9 @@ pub fn run() {
|
||||
commands::windows::open_viewer_window,
|
||||
// Clipboard
|
||||
commands::clipboard::copy_to_clipboard,
|
||||
// Paste (auto-insert at cursor)
|
||||
commands::paste::paste_text,
|
||||
commands::paste::detect_paste_backends,
|
||||
// Hardware
|
||||
commands::hardware::probe_system,
|
||||
commands::hardware::rank_models,
|
||||
|
||||
@@ -479,7 +479,22 @@
|
||||
replaceSegmentsWithCleanedText(cleanedTranscript);
|
||||
}
|
||||
|
||||
if (settings.autoCopy) {
|
||||
if (settings.autoPaste) {
|
||||
try {
|
||||
const outcome = await invoke("paste_text", { text: transcript });
|
||||
if (!outcome?.pasted && outcome?.message) {
|
||||
toasts.warn(
|
||||
"Auto-paste unavailable",
|
||||
`${outcome.message}${outcome.copied ? ". Transcript is on your clipboard." : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await navigator.clipboard
|
||||
.writeText(transcript)
|
||||
.catch(() => invoke("copy_to_clipboard", { text: transcript }).catch(() => {}));
|
||||
toasts.warn("Auto-paste failed", String(err));
|
||||
}
|
||||
} else if (settings.autoCopy) {
|
||||
navigator.clipboard.writeText(transcript).catch(() => {
|
||||
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
let engineOk = $state(false);
|
||||
let downloadedModels = $state([]);
|
||||
let runtimeCapabilities = $state(null);
|
||||
let pasteBackends = $state([]);
|
||||
let pasteBackendsDescription = $derived.by(() => {
|
||||
if (pasteBackends.length === 0) {
|
||||
return "Install wtype (Wayland) or xdotool (X11) to enable auto-paste. Focus must already be on the target window.";
|
||||
}
|
||||
return `Uses ${pasteBackends.join(", ")}. Focus must already be on the target window.`;
|
||||
});
|
||||
let downloadingModel = $state("");
|
||||
let downloadProgress = $state(0);
|
||||
let unlisten = null;
|
||||
@@ -541,6 +548,12 @@
|
||||
downloadedModels = await invoke("list_models");
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
pasteBackends = (await invoke("detect_paste_backends")) || [];
|
||||
} catch {
|
||||
pasteBackends = [];
|
||||
}
|
||||
|
||||
// Parakeet status
|
||||
try {
|
||||
parakeetOk = await invoke("check_parakeet_engine");
|
||||
@@ -1498,6 +1511,11 @@
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<div class="space-y-0.5">
|
||||
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
|
||||
<Toggle
|
||||
bind:checked={settings.autoPaste}
|
||||
label="Auto-paste into focused window"
|
||||
description={pasteBackendsDescription}
|
||||
/>
|
||||
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
|
||||
<Toggle
|
||||
bind:checked={settings.saveAudio}
|
||||
|
||||
@@ -46,6 +46,7 @@ const defaults: SettingsState = {
|
||||
antiHallucination: true,
|
||||
britishEnglish: true,
|
||||
autoCopy: true,
|
||||
autoPaste: false,
|
||||
includeTimestamps: true,
|
||||
theme: "Dark",
|
||||
fontSize: 14,
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface SettingsState {
|
||||
antiHallucination: boolean;
|
||||
britishEnglish: boolean;
|
||||
autoCopy: boolean;
|
||||
autoPaste: boolean;
|
||||
includeTimestamps: boolean;
|
||||
theme: "Dark" | "Light" | "System";
|
||||
fontSize: number;
|
||||
|
||||
Reference in New Issue
Block a user