win-text-inject

deliver text into the focused windows app without destroying the clipboard

a rust crate for the last step of every dictation tool: putting text where the caret is. it restores the clipboard only after the target has actually read the new text, keeps transcripts out of clipboard history, releases held modifiers, and refuses elevated targets honestly.

rust, win32 through the windows crate, no other dependencies

Every open-source dictation tool I surveyed in July 2026 did the same five steps: save the clipboard, overwrite it, synthesize Ctrl+V, sleep, restore. That has four defects. Transcripts leak into Windows clipboard history and the cloud clipboard. A physically held modifier corrupts the synthesized chord. Injection into an elevated window fails silently under UIPI. The timed restore races the target’s asynchronous read, so a slow app pastes the previous clipboard. That last one is Handy issue #502, open since December 2025. The crate fixes all four and ships a deterministic reproduction of the race as an example. Dictate is the first consumer, and the fix went upstream to Handy as a pull request.

architecture

when is it safe to restore the clipboard after a paste?

every dictation tool surveyed restores on a timer. the crate publishes a delayed-render promise instead, so the target's own read is the signal.

risk or limit
capture target focus + integrity level snapshot clipboard text + sequence number publish promise CF_UNICODETEXT = NULL send Ctrl+V after releasing held keys wait for reads to settle (condvar) restore clipboard only after a read receives the paste GetClipboardData sometimes more than once SendInput WM_RENDERFORMAT per read quiet for 400 ms win-text-inject focused application a fixed delay guesses when the read happened.guess short and the target pastes the previousclipboard. guess long and the user waits.

the promise also carries four privacy formats so the transcript never enters clipboard history or the cloud clipboard. the integrity check runs before any write, so an elevated target gets an honest ClipboardOnly result instead of a silent loss.

Six modules. Target capture reads the foreground window and its integrity level. The clipboard module does private writes plus snapshot and restore. The delayed-render module owns a hidden window on its own message-pump thread that publishes the promise. The modifiers module releases physically held modifiers. The sendinput module tags every synthesized event and provides Unicode typing as a fallback. The library root sequences them in inject.

The target is meant to be captured at hotkey press. inject first aborts with FocusChanged if that window no longer holds foreground, then compares the target’s mandatory-label RID against its own before any write. If the target is higher, the text is written privately and the caller gets ClipboardOnly(ElevatedTarget).

technical decisions

  • Paste over typing. SendInput with KEYEVENTF_UNICODE is the fallback, chunked at 256 code units. It is slow and pump-bound. Paste is right for long text.
  • Delayed render over a timer. SetClipboardData(CF_UNICODETEXT, NULL) publishes a promise. Each WM_RENDERFORMAT the owner window receives is a read by the target and bumps a counter. A condvar waits for the count to move past the baseline, then for reads to go quiet for 400 ms, because Chromium probes and then reads. Only then is the previous clipboard restored. A sequence-number gate does not fix the race, because the sequence number is still ours.
  • Four opt-out formats. ExcludeClipboardContentFromMonitorProcessing and Clipboard Viewer Ignore are presence-only. CanIncludeInClipboardHistory and CanUploadToCloudClipboard are DWORD zero. They are cooperative, and the docs say so.
  • Modifiers released, never restored. Re-pressing a key the user has since released leaves it stuck down forever.
  • Every synthesized event carries a tag in dwExtraInfo, so a caller’s low-level keyboard hook can skip its own paste.
  • Not UI Automation and not a text service. UIA is read-only and forces Chromium’s accessibility tree on. A TSF text service is the correct answer and far too large for a dictation tool.

hard problems

  • Handy #502, reproduced deterministically in examples/repro_502.rs.
  • One render is not enough. Restoring after the first WM_RENDERFORMAT reintroduces the bug in Chromium apps, hence the settle wait.
  • Clipboard managers consume the promise before the paste, which kills the signal. A baseline is recorded when the paste is sent, so a pre-paste render is classified as consumed and that one injection falls back to the timer path.
  • SetClipboardData returns NULL on success for a promise, and Win32 does not clear last-error on success. A stale error code failed about one paste in four until 0.1.1 checked GetClipboardOwner and IsClipboardFormatAvailable instead.
  • VS Code: Shift+Insert does nothing in the editor, contrary to vendor docs. Terminals get Ctrl+Shift+V.

the test run

cargo test --release on my machine, abridged to the tests that name a decision above:

running 27 tests
test clipboard::tests::only_the_dword_formats_carry_a_payload ... ok
test delayed::tests::non_text_formats_are_not_rendered ... ok
test delayed::tests::owner_window_starts_and_is_reused ... ok
test modifiers::tests::modifier_set_covers_both_sides_of_every_modifier ... ok
test sendinput::tests::every_event_is_tagged_for_hook_filtering ... ok
test sendinput::tests::non_bmp_characters_produce_four_events ... ok
test target::tests::a_normal_test_process_runs_at_medium_or_high ... ok
test tests::delayed_render_is_the_default ... ok
test tests::terminals_get_ctrl_shift_v ... ok
test tests::timer_path_cannot_confirm_a_read ... ok
test tests::vs_code_gets_ctrl_v_not_shift_insert ... ok

test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured