fix(dictation): macOS auto-paste — don't steal focus, write clipboard natively (#287) (#299)

Dictation via the global shortcut transcribed fine but the text never reached
the target app on macOS, due to two stacked bugs (diagnosed, patched, and
verified by @geektf in #287):

1. The ShortcutState::Pressed handler called win.set_focus(), making the
   widget frontmost — the simulated ⌘V from simulate_paste() landed in the
   widget instead of the app being dictated into. Skip set_focus() on macOS
   (same #[cfg(not(target_os = "macos"))] guard the other widget call sites
   already use).

2. With the widget unfocused, the WebView clipboard APIs
   (navigator.clipboard.writeText / execCommand('copy')) fail silently in
   WKWebView, so ⌘V pasted whatever was previously on the clipboard.
   simulate_paste now takes Option<String> and writes the transcript to the
   clipboard natively (arboard) before sending the keystroke — no window
   focus required. CaptureWidget passes the transcript; copyText() stays as
   best-effort for browser (non-Tauri) mode, and the optional param keeps
   any text-less call sites working.

cargo check clean (the unreachable_code warning in setup.rs is pre-existing
from #286); frontend node:test suite passes. End-to-end behavior verified by
the reporter on macOS 26 / M4 Pro with both patches applied.

Fixes #287

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-08 00:36:47 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 71cdc1553e
commit f3e403193e
5 changed files with 129 additions and 2 deletions
+102
View File
@@ -84,6 +84,26 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"x11rb",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -614,6 +634,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
@@ -748,6 +777,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1161,6 +1196,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -1188,6 +1229,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1747,6 +1794,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -2071,6 +2129,7 @@ dependencies = [
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
]
[[package]]
@@ -2637,6 +2696,7 @@ dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
]
@@ -2890,6 +2950,7 @@ dependencies = [
name = "omnivoice-studio"
version = "0.3.5"
dependencies = [
"arboard",
"dirs-next",
"enigo",
"fs4",
@@ -3340,6 +3401,12 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.2"
@@ -4932,6 +4999,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -5732,6 +5813,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6757,6 +6844,21 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.10.1"
+4
View File
@@ -51,6 +51,10 @@ walkdir = "2"
fs4 = "0.13"
# Cross-platform home/config directories for pill autostart registration
dirs-next = "2"
# Native (OS-side) clipboard write for dictation auto-paste: the widget window
# is unfocused on macOS so the simulated ⌘V reaches the target app, which makes
# the WebView clipboard APIs fail silently there (#287)
arboard = "3"
[target.'cfg(windows)'.dependencies]
zip = { version = "2", default-features = false, features = ["deflate"] }
+13 -1
View File
@@ -258,7 +258,19 @@ fn hf_hub_cache_dir() -> PathBuf {
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
#[tauri::command]
pub fn simulate_paste() -> Result<(), String> {
pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
// Write the transcript to the clipboard natively first: the widget window
// is intentionally unfocused on macOS (so the simulated ⌘V reaches the
// target app), which makes the WebView clipboard APIs (navigator.clipboard
// / execCommand('copy')) fail silently there (#287). `text` is optional so
// call sites that already populated the clipboard keep working.
if let Some(t) = text {
let mut cb = arboard::Clipboard::new()
.map_err(|e| format!("clipboard init failed: {e}"))?;
cb.set_text(t)
.map_err(|e| format!("clipboard write failed: {e}"))?;
}
std::thread::sleep(Duration::from_millis(80));
let mut enigo = Enigo::new(&EnigoSettings::default())
+5
View File
@@ -219,6 +219,11 @@ pub fn run() {
let _ = win.center();
}
let _ = win.show();
// Don't steal focus on macOS: the simulated ⌘V from
// simulate_paste() must land in the app the user is
// dictating into — focusing the widget would swallow
// it (#287).
#[cfg(not(target_os = "macos"))]
let _ = win.set_focus();
}
let _ = app_handle.emit("tray-dictate", ());
+5 -1
View File
@@ -120,10 +120,14 @@ export default function CaptureWidget({ onDismiss }) {
if (data.text) {
try {
// Best-effort WebView copy (works in browser mode). In Tauri the
// widget window is unfocused on macOS, where WebView clipboard APIs
// fail silently — so pass the transcript to simulate_paste, which
// writes the clipboard natively (OS-side) before sending ⌘V (#287).
await copyText(data.text);
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste');
await invoke('simulate_paste', { text: data.text });
} catch { /* not in Tauri */ }
} catch { /* clipboard API may fail */ }