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}