From ea7a4e9f6d41a56c86a76d27b69b0730598a6fb4 Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 23 Apr 2026 18:46:19 -0700 Subject: [PATCH] fix(capture): conditional clipboard restore + always-attempt on paste failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in paste_final_text' clipboard handling: 1. Restore was unconditional. If the user ⌘C'd in the target app during the 400 ms paste-consume window — or a clipboard history tool (Paste, Pastebot, Maccy) or Universal Clipboard sync snapshotted our staged text — the blind restore overwrote their newer content with the pre-paste snapshot, silently losing user data. 2. send_paste' errors were propagated with ? before the restore, so a CGEventPost / SendInput failure left the user's clipboard stuck on the transcript. Fix folds both into one pattern: capture the post-write change count, re-read it after paste-consume, restore only when they match (plus treat a change-count read failure as "unknown, don't overwrite"). Isolate send_paste's error so the restore runs regardless of paste success, then propagate the paste error after. Co-Authored-By: Claude Opus 4.7 (1M context) --- tauri/src-tauri/src/main.rs | 38 ++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 50855ab0..1de56a30 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -1036,7 +1036,22 @@ fn open_input_monitoring_settings(app: tauri::AppHandle) -> Result<(), String> { /// /// Pipeline: activate the captured PID → settle → save the user's /// clipboard → write `text` → fire ⌘V → wait for the target to consume it -/// → restore the original clipboard. +/// → conditionally restore the original clipboard. +/// +/// The restore is conditional on `NSPasteboard.changeCount` (or the +/// Windows sequence number) matching the value captured right after +/// `write_text`: if something else wrote to the clipboard during the +/// paste-consume window — the user's own ⌘C in the target app, a +/// clipboard history tool (Paste, Pastebot, Maccy), Universal Clipboard +/// sync, 1Password inserting a secret — their newer content takes +/// priority over our snapshot and is preserved. A +/// [`clipboard::current_change_count`] read failure is treated the same +/// way: unknown state is safer than an unconditional overwrite. +/// +/// `send_paste` failure is isolated from the restore decision: we always +/// attempt the conditional restore before propagating the paste error, +/// so a failed `CGEventPost` / `SendInput` never leaves the user's +/// clipboard stuck on the transcript. /// /// Skips (returns `false`) without touching anything when: /// - `focus.bundle_id` is Voicebox itself — step 6 will inject directly @@ -1066,11 +1081,24 @@ async fn paste_final_text( tokio::time::sleep(std::time::Duration::from_millis(POST_ACTIVATE_SETTLE_MS)).await; let snapshot = clipboard::save_clipboard()?; - clipboard::write_text(&text)?; - synthetic_keys::send_paste()?; - tokio::time::sleep(std::time::Duration::from_millis(PASTE_CONSUME_MS)).await; - clipboard::restore_clipboard(&snapshot)?; + let after_write = clipboard::write_text(&text)?; + let paste_result = synthetic_keys::send_paste(); + tokio::time::sleep(std::time::Duration::from_millis(PASTE_CONSUME_MS)).await; + + let safe_to_restore = matches!( + clipboard::current_change_count(), + Ok(current) if current == after_write + ); + if safe_to_restore { + clipboard::restore_clipboard(&snapshot)?; + } else { + eprintln!( + "[voicebox] clipboard mutated during paste window — skipping restore to preserve newer content" + ); + } + + paste_result?; Ok(true) }