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
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.
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.
SendInputwithKEYEVENTF_UNICODEis 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. EachWM_RENDERFORMATthe 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.
ExcludeClipboardContentFromMonitorProcessingandClipboard Viewer Ignoreare presence-only.CanIncludeInClipboardHistoryandCanUploadToCloudClipboardare 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_RENDERFORMATreintroduces 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.
SetClipboardDatareturns 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 checkedGetClipboardOwnerandIsClipboardFormatAvailableinstead.- 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