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>
This commit is contained in:
2026-04-21 09:23:22 +01:00
parent 6837700ac9
commit bc1ae3968e

View File

@@ -15,9 +15,17 @@
//! 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")]
@@ -35,8 +43,15 @@ pub struct PasteOutcome {
/// 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(text: String) -> Result<PasteOutcome, String> {
pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutcome, String> {
let mut outcome = PasteOutcome {
backend: None,
pasted: false,
@@ -52,6 +67,8 @@ pub async fn paste_text(text: String) -> Result<PasteOutcome, String> {
}
}
hide_preview_overlay_for_paste(&app).await;
match trigger_paste_keystroke() {
Ok(backend) => {
outcome.backend = Some(backend);
@@ -63,6 +80,24 @@ pub async fn paste_text(text: String) -> Result<PasteOutcome, String> {
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.