Skip to main content

win_text_inject/
lib.rs

1//! Correct text injection into the focused Windows application.
2//!
3//! Every open-source dictation tool surveyed in July 2026 delivers text the same way: save the
4//! clipboard, overwrite it, synthesize Ctrl+V, `sleep()`, restore. That approach has three defects
5//! that this crate exists to fix.
6//!
7//! 1. **Transcripts leak into clipboard history and the Microsoft cloud clipboard.** Writing
8//!    `CF_UNICODETEXT` alone opts into both. See [`clipboard::set_text_private`].
9//! 2. **Held modifiers corrupt the synthesized chord.** In push-to-talk a modifier is held by
10//!    construction when injection fires. See [`modifiers::sanitize`].
11//! 3. **Injection into elevated windows fails silently.** UIPI blocks it and reports nothing
12//!    through `GetLastError` or the return value, so text vanishes. See [`Target::accepts_injection`].
13//! 4. **The clipboard restore races the target's read.** A target reads the clipboard whenever its
14//!    message pump gets to the paste, so a timer-based restore can win and the target then reads
15//!    the *previous* clipboard. Any fixed delay is a guess. See [`delayed`].
16//!
17//! # Example
18//!
19//! ```no_run
20//! # fn main() -> Result<(), win_text_inject::Error> {
21//! // Capture at hotkey press, so focus changes during dictation cannot misdirect the text.
22//! let target = win_text_inject::Target::foreground()?;
23//!
24//! // ... record and transcribe ...
25//!
26//! let outcome = win_text_inject::inject(&target, "hello world", Default::default())?;
27//! if outcome.needs_manual_paste() {
28//!     // Text is on the clipboard; tell the user to press Ctrl+V.
29//! }
30//! # Ok(())
31//! # }
32//! ```
33
34#![cfg(windows)]
35#![warn(missing_docs)]
36#![warn(clippy::doc_markdown)]
37
38pub mod clipboard;
39pub mod delayed;
40pub mod modifiers;
41pub mod sendinput;
42mod target;
43
44pub use delayed::Offer;
45pub use sendinput::{type_text, INJECT_TAG};
46pub use target::{Integrity, Target};
47
48use std::time::Duration;
49
50use windows::Win32::UI::Input::KeyboardAndMouse::{
51    VIRTUAL_KEY, VK_CONTROL, VK_INSERT, VK_SHIFT, VK_V,
52};
53
54/// Failures that are worth distinguishing at the call site.
55#[derive(Debug)]
56pub enum Error {
57    /// No foreground window, or it belongs to no process.
58    NoForegroundWindow,
59    /// Another process held the clipboard lock across every retry.
60    ClipboardLocked(windows::core::Error),
61    /// A clipboard call failed after the clipboard was successfully opened.
62    Clipboard(windows::core::Error),
63    /// `GlobalAlloc` or `GlobalLock` failed while preparing clipboard data.
64    Alloc(windows::core::Error),
65    /// `SendInput` accepted fewer events than submitted. Almost always UIPI.
66    SendInputBlocked,
67    /// Focus moved between capture and injection.
68    FocusChanged,
69    /// The hidden clipboard-owner window could not be created.
70    OwnerWindowFailed,
71}
72
73impl std::fmt::Display for Error {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Error::NoForegroundWindow => write!(f, "no foreground window"),
77            Error::ClipboardLocked(e) => write!(f, "clipboard held by another process: {e}"),
78            Error::Clipboard(e) => write!(f, "clipboard operation failed: {e}"),
79            Error::Alloc(e) => write!(f, "global allocation failed: {e}"),
80            Error::SendInputBlocked => write!(f, "SendInput was blocked, most likely by UIPI"),
81            Error::FocusChanged => write!(f, "focus moved away from the captured target"),
82            Error::OwnerWindowFailed => write!(f, "clipboard owner window could not be created"),
83        }
84    }
85}
86
87impl std::error::Error for Error {}
88
89/// Key combination used to trigger a paste in the target application.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum Chord {
92    /// The near-universal paste binding.
93    CtrlV,
94    /// Terminals, where Ctrl+V is a control character or unbound.
95    CtrlShiftV,
96    /// Legacy paste binding, still honored by some older Win32 software.
97    ShiftInsert,
98}
99
100impl Chord {
101    /// The chord most likely to paste in the given application.
102    ///
103    /// Every entry here is verified against the real application (see `examples/real_app_test.rs`),
104    /// not taken from documentation. VS Code was previously listed as needing Shift+Insert on the
105    /// strength of a vendor support page; testing showed Shift+Insert does nothing in the editor
106    /// and Ctrl+V works. That claim appears to describe the integrated terminal, not the editor.
107    pub fn for_exe(exe: &str) -> Self {
108        match exe {
109            // Terminals bind Ctrl+V to a control character or to nothing.
110            "windowsterminal.exe"
111            | "conhost.exe"
112            | "mintty.exe"
113            | "putty.exe"
114            | "alacritty.exe"
115            | "wezterm-gui.exe" => Chord::CtrlShiftV,
116            _ => Chord::CtrlV,
117        }
118    }
119
120    fn keys(self) -> (&'static [VIRTUAL_KEY], VIRTUAL_KEY) {
121        match self {
122            Chord::CtrlV => (&[VK_CONTROL], VK_V),
123            Chord::CtrlShiftV => (&[VK_CONTROL, VK_SHIFT], VK_V),
124            Chord::ShiftInsert => (&[VK_SHIFT], VK_INSERT),
125        }
126    }
127}
128
129/// How text should be delivered.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum Strategy {
132    /// Clipboard plus a synthesized paste chord. Fast and correct for long text.
133    #[default]
134    ClipboardPaste,
135    /// Type the text as Unicode input. Slower, but works where paste is blocked.
136    UnicodeType,
137    /// Write the clipboard and stop, leaving the paste to the user.
138    ClipboardOnly,
139}
140
141/// Tunables. Defaults are deliberately conservative.
142#[derive(Debug, Clone, Copy)]
143pub struct Options {
144    /// How the text should be delivered.
145    pub strategy: Strategy,
146    /// Chord override. `None` selects per target executable.
147    pub chord: Option<Chord>,
148    /// Settle time between writing the clipboard and sending the paste chord.
149    pub pre_paste: Duration,
150    /// Time allowed for the target to read the clipboard before restoring it.
151    ///
152    /// Only consulted when [`Options::delayed_render`] is off. With delayed rendering the restore
153    /// is triggered by the target's actual read, so there is no delay to tune.
154    pub post_paste: Duration,
155    /// Restore the previous clipboard contents after pasting.
156    pub restore_clipboard: bool,
157    /// Abort if focus left the captured target.
158    pub require_same_target: bool,
159    /// Publish the text as a delayed-render promise and restore once the target actually reads it.
160    ///
161    /// On by default. Turning this off falls back to the timer-based restore that every other tool
162    /// ships, which loses the transcript when the target is slower than `post_paste`.
163    pub delayed_render: bool,
164    /// How long to wait for the target to read the clipboard before giving up.
165    pub read_timeout: Duration,
166    /// After the first read, how long reads must stay quiet before the clipboard is restored.
167    ///
168    /// Consumers may read more than once per paste. This is not a race-the-target delay -- it
169    /// starts from an observed read, so it does not need to be tuned per machine.
170    pub read_quiet: Duration,
171}
172
173impl Default for Options {
174    fn default() -> Self {
175        Self {
176            strategy: Strategy::default(),
177            chord: None,
178            pre_paste: Duration::from_millis(30),
179            post_paste: Duration::from_millis(120),
180            restore_clipboard: true,
181            require_same_target: true,
182            delayed_render: true,
183            read_timeout: Duration::from_secs(3),
184            read_quiet: Duration::from_millis(400),
185        }
186    }
187}
188
189/// What actually happened, so the caller can tell the user the truth.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum Outcome {
192    /// A paste chord was sent.
193    ///
194    /// `read_confirmed` is true only when the target was observed reading the clipboard, which
195    /// delayed rendering makes knowable. When it is false the paste may not have landed — worth
196    /// surfacing rather than assuming success, which is what every other tool does.
197    Pasted {
198        /// Whether Windows reported the target actually reading the clipboard.
199        read_confirmed: bool,
200    },
201    /// Text was typed directly into the target.
202    Typed,
203    /// Text is on the clipboard but was not delivered; the user must paste it.
204    ClipboardOnly(ClipboardOnlyReason),
205}
206
207/// Why the text was left on the clipboard instead of being delivered.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum ClipboardOnlyReason {
210    /// Caller asked for it.
211    Requested,
212    /// Target runs at a higher integrity level, so UIPI would discard the input.
213    ElevatedTarget,
214}
215
216impl Outcome {
217    /// True when the user has to paste manually for the text to arrive.
218    pub fn needs_manual_paste(self) -> bool {
219        matches!(self, Outcome::ClipboardOnly(_))
220    }
221}
222
223/// Deliver `text` to `target`.
224///
225/// Order matters: the elevation check happens before anything is written, and modifier
226/// sanitization happens before any synthesized chord.
227pub fn inject(target: &Target, text: &str, options: Options) -> Result<Outcome, Error> {
228    if options.require_same_target && !target.still_foreground() {
229        return Err(Error::FocusChanged);
230    }
231
232    // Refusing early is the whole point: injecting here would succeed silently and deliver nothing.
233    if !target.accepts_injection() {
234        clipboard::set_text_private(text)?;
235        return Ok(Outcome::ClipboardOnly(ClipboardOnlyReason::ElevatedTarget));
236    }
237
238    match options.strategy {
239        Strategy::ClipboardOnly => {
240            clipboard::set_text_private(text)?;
241            Ok(Outcome::ClipboardOnly(ClipboardOnlyReason::Requested))
242        }
243        Strategy::UnicodeType => {
244            modifiers::sanitize()?;
245            sendinput::type_text(text)?;
246            Ok(Outcome::Typed)
247        }
248        Strategy::ClipboardPaste => {
249            let snapshot = if options.restore_clipboard {
250                clipboard::Snapshot::capture().ok()
251            } else {
252                None
253            };
254
255            let chord = options.chord.unwrap_or_else(|| Chord::for_exe(&target.exe));
256
257            if options.delayed_render {
258                // Publish a promise rather than the text. Windows reports the target's actual read,
259                // so the restore is sequenced after it instead of racing a timer.
260                let offer = delayed::Offer::publish(text)?;
261                std::thread::sleep(options.pre_paste);
262                modifiers::sanitize()?;
263
264                // Anything that reads the clipboard satisfies the render, so a read observed before
265                // the paste says nothing about the target. A clipboard manager that archives every
266                // change will otherwise confirm a paste that never happened.
267                offer.mark_paste_sent();
268                send_chord(chord)?;
269
270                // Not a single render: Chromium probes the clipboard before the read that actually
271                // populates the field, so restoring after the first one reintroduces the very bug
272                // this path exists to fix.
273                let reads = offer.wait_for_target_read(options.read_timeout, options.read_quiet);
274                let read_confirmed = reads.is_some();
275
276                // Three cases, and they need different handling:
277                //
278                // 1. A read after the paste. Confident; restore now, sequenced after the read.
279                // 2. No such read, but the promise was consumed before the paste (a clipboard
280                //    manager archived it). The target's read is unobservable, so fall back to the
281                //    timer. Worse than case 1, but not restoring at all would permanently destroy
282                //    the user's clipboard on every dictation while a manager is running.
283                // 3. No read at all. The paste most likely never landed, so leave the transcript on
284                //    the clipboard for the user to paste manually rather than discarding it.
285                let should_restore = if read_confirmed {
286                    true
287                } else if offer.consumed_before_paste() {
288                    std::thread::sleep(options.post_paste);
289                    true
290                } else {
291                    false
292                };
293
294                if should_restore {
295                    if let Some(snapshot) = snapshot {
296                        let _ = snapshot.restore();
297                    }
298                }
299                return Ok(Outcome::Pasted { read_confirmed });
300            }
301
302            clipboard::set_text_private(text)?;
303            let ours = clipboard::sequence_number();
304
305            std::thread::sleep(options.pre_paste);
306            modifiers::sanitize()?;
307            send_chord(chord)?;
308            std::thread::sleep(options.post_paste);
309
310            // Only restore when the clipboard still holds our write, so a third party that took the
311            // clipboard mid-paste is not clobbered. Note this does NOT prevent the target from
312            // reading the restored value -- only delayed rendering does.
313            if let Some(snapshot) = snapshot {
314                let _ = snapshot.restore_if_ours(ours);
315            }
316
317            Ok(Outcome::Pasted {
318                read_confirmed: false,
319            })
320        }
321    }
322}
323
324fn send_chord(chord: Chord) -> Result<(), Error> {
325    use windows::Win32::UI::Input::KeyboardAndMouse::KEYBD_EVENT_FLAGS;
326
327    let (mods, key) = chord.keys();
328    let mut inputs = Vec::with_capacity(mods.len() * 2 + 2);
329
330    for m in mods {
331        inputs.push(sendinput::tagged_keyboard_input(*m, KEYBD_EVENT_FLAGS(0)));
332    }
333    inputs.push(sendinput::tagged_keyboard_input(key, KEYBD_EVENT_FLAGS(0)));
334    inputs.push(modifiers::key_up(key));
335    for m in mods.iter().rev() {
336        inputs.push(modifiers::key_up(*m));
337    }
338
339    sendinput::send(&inputs)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn terminals_get_ctrl_shift_v() {
348        assert_eq!(Chord::for_exe("windowsterminal.exe"), Chord::CtrlShiftV);
349        assert_eq!(Chord::for_exe("alacritty.exe"), Chord::CtrlShiftV);
350    }
351
352    #[test]
353    fn vs_code_gets_ctrl_v_not_shift_insert() {
354        // Verified against real VS Code: Shift+Insert does nothing in the editor and the paste
355        // never fires. The Shift+Insert advice in vendor docs describes the integrated terminal.
356        assert_eq!(Chord::for_exe("code.exe"), Chord::CtrlV);
357        assert_eq!(Chord::for_exe("cursor.exe"), Chord::CtrlV);
358    }
359
360    #[test]
361    fn unknown_apps_fall_back_to_ctrl_v() {
362        assert_eq!(Chord::for_exe("notepad.exe"), Chord::CtrlV);
363        assert_eq!(Chord::for_exe(""), Chord::CtrlV);
364    }
365
366    #[test]
367    fn chord_lookup_assumes_lowercased_input() {
368        // Target::foreground lowercases the exe name, so the table only needs lowercase keys.
369        assert_eq!(Chord::for_exe("Code.exe"), Chord::CtrlV);
370    }
371
372    #[test]
373    fn only_clipboard_only_requires_manual_paste() {
374        assert!(!Outcome::Pasted {
375            read_confirmed: true
376        }
377        .needs_manual_paste());
378        assert!(!Outcome::Typed.needs_manual_paste());
379        assert!(Outcome::ClipboardOnly(ClipboardOnlyReason::ElevatedTarget).needs_manual_paste());
380    }
381
382    #[test]
383    fn defaults_restore_the_clipboard_and_pin_the_target() {
384        let o = Options::default();
385        assert!(o.restore_clipboard);
386        assert!(o.require_same_target);
387        assert_eq!(o.strategy, Strategy::ClipboardPaste);
388    }
389
390    #[test]
391    fn delayed_render_is_the_default() {
392        // The timer path is the known-broken one; it must be opt-in, not the default.
393        assert!(Options::default().delayed_render);
394    }
395
396    #[test]
397    fn timer_path_cannot_confirm_a_read() {
398        // Only the delayed-render path can know the target actually read the clipboard.
399        assert!(!Outcome::Pasted {
400            read_confirmed: false
401        }
402        .needs_manual_paste());
403    }
404
405    #[test]
406    fn chord_key_sequences_are_well_formed() {
407        assert_eq!(Chord::CtrlV.keys().0.len(), 1);
408        assert_eq!(Chord::CtrlShiftV.keys().0.len(), 2);
409        assert_eq!(Chord::ShiftInsert.keys().1, VK_INSERT);
410    }
411}