fix(capture): conditional clipboard restore + always-attempt on paste failure

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) <[email protected]>
This commit is contained in:
James Pine
2026-04-23 19:18:41 -07:00
co-authored by Claude Opus 4.7
parent c65531bed1
commit ea7a4e9f6d
+33 -5
View File
@@ -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)
}