Skip to content

Windows: sequence the clipboard restore after the target has read it#1770

Open
emerson-d-lopes wants to merge 2 commits into
cjpais:mainfrom
emerson-d-lopes:win-text-inject-injection
Open

Windows: sequence the clipboard restore after the target has read it#1770
emerson-d-lopes wants to merge 2 commits into
cjpais:mainfrom
emerson-d-lopes:win-text-inject-injection

Conversation

@emerson-d-lopes

@emerson-d-lopes emerson-d-lopes commented Jul 22, 2026

Copy link
Copy Markdown

Windows: sequence the clipboard restore after the target has read it

Part of #502, Windows path only. This does not close the issue: macOS and Linux use different paste paths and are untouched here, and #502 has reports on both.

The bug

paste_via_clipboard writes the transcript, sleeps paste_delay_ms, sends the chord, sleeps paste_delay_after_ms, then restores. The target application reads the clipboard when its message pump reaches the paste, which is not when the keystroke was sent. If the app is busy, the restore lands first and the user gets their previous clipboard.

Any fixed delay has a lag that beats it. That is why #502 has been open since December with 52 comments, and why raising the slider to 400ms does not close it.

I have a Windows machine and could reproduce it deterministically, which I know has been the blocker here.

Reproduction

A real Win32 window handling a real WM_PASTE, with a controllable delay standing in for message-pump backlog. At a 120ms restore delay:

app lag target received
10 ms transcript
60 ms transcript
150 ms previous clipboard
400 ms previous clipboard

Runnable: cargo run --example repro_502 in win-text-inject.

The fix

Publish the text as a delayed-render promise rather than as data: SetClipboardData(CF_UNICODETEXT, NULL) with a hidden owner window. Windows then sends WM_RENDERFORMAT when a consumer actually asks for the data. That message is the signal that the target read the clipboard, so the restore is sequenced after the read instead of racing it. No delay constant is involved.

Two things I got wrong first, in case they save anyone time:

A sequence-number gate does not fix this. Restoring only when GetClipboardSequenceNumber still matches our own write sounds right and changes nothing: the sequence number is still ours, because nobody else wrote. We clobber ourselves.

One render is not proof the paste finished. Chromium touches the clipboard more than once per paste, an early probe and then the real read. Restoring after the first WM_RENDERFORMAT lands between them and reproduces the original bug. The fix waits for renders to go quiet, with the clock starting from an observed read rather than from a guess.

Three other defects fixed on the same path

Transcripts leak into clipboard history and the Microsoft cloud clipboard. Writing CF_UNICODETEXT alone opts into both. Chrome's Incognito mode avoids this by registering four opt-out formats; almost nothing else does, including Handy and Wispr Flow (whose docs state that dictated text "may appear in Windows clipboard managers"). Now attached on every write.

Held modifiers corrupt the chord. In push-to-talk a modifier is held by construction when injection fires. Per MSDN, SendInput "does not reset the keyboard's current state." A user holding Right-Alt turns the synthesized Ctrl+V into AltGr+V, a different character on many layouts. Held modifiers are now released first, and deliberately not restored, since re-pressing one the user has since released leaves it stuck.

Injection into elevated windows fails silently. Per MSDN, when SendInput is blocked by UIPI, "neither GetLastError nor the return value will indicate the failure." The text just vanishes. An integrity-level comparison costs microseconds and lets the app say "elevated window, press Ctrl+V" instead. This is also the mechanism behind #434.

Verification

Verified against real applications by making each app copy its own field back, which is the only readback that works across Win32, Chromium and Electron. WM_GETTEXT cannot see Chromium or WinUI text, and UI Automation cannot insert at all.

application chord delivered clipboard kept read confirmed
Chrome Ctrl+V yes yes yes
VS Code Ctrl+V yes yes yes
Notepad Ctrl+V yes yes yes
Windows Terminal Ctrl+Shift+V yes yes yes
Notepad, clipboard manager running Ctrl+V yes yes correctly reported false

Clipboard managers

Worth stating explicitly, since it changes behaviour.

A manager that honours the opt-out formats does not read the promise at all. Windows clipboard history behaves this way, verified with history enabled.

A manager that ignores them consumes the promise on publish. Once anything renders it, the clipboard holds real data and Windows sends no further WM_RENDERFORMAT, so the target's read becomes unobservable. The crate detects this, falls back to the timer restore, and reports read_confirmed: false honestly instead of pretending. Without that handling the clipboard was never restored at all while a manager was running, which is worse than the original bug.

Shape of the change

Two files, 86 lines. A #[cfg(windows)] early return in paste_via_clipboard delegating to the crate, and the dependency. Existing PasteMethod values map straight onto the crate's chords, so no settings or UI changes are needed.

If it fails for any reason it logs and falls through to the current implementation, so this cannot lose a transcript to an integration problem.

win-text-inject is MIT OR Apache-2.0, Windows-only, and its sole dependency is the windows crate, which Handy already has. CI runs on stable and 1.82 across fmt, clippy, tests, doc tests and rustdoc, and exercises the clipboard against a real headless runner.

Notes

  • The chord table in the crate is verified against running applications rather than documentation. One correction that may interest you: VS Code does not paste on Shift+Insert. It does nothing in the editor and the paste never fires; Ctrl+V works. The Shift+Insert advice in vendor docs appears to describe the integrated terminal. Handy's PasteMethod is user-selected so this PR does not change any default, but it is worth knowing.
  • Happy to split this into separate commits per defect if that reviews better, or to put the whole Windows path behind a setting for a release before it becomes the default.
  • Strategy::UnicodeType in the crate is not yet exercised against a real window and is not used by this PR.

… a timer

Closes cjpais#502. Windows only; the macOS and Linux paths are untouched.

paste_via_clipboard writes the transcript, sleeps, sends the chord, sleeps
again, then restores. The target reads the clipboard when its message pump
reaches the paste, which is not when the keystroke was sent. If the app is
busy the restore lands first and the user gets their previous clipboard.

Any fixed delay has a lag that beats it, which is why raising the slider to
400ms does not close cjpais#502.

Delayed rendering removes the guess. The text is published as a promise,
SetClipboardData(CF_UNICODETEXT, NULL) with a hidden owner window, and
Windows sends WM_RENDERFORMAT when a consumer actually asks for the data.
That message is the signal that the target read the clipboard, so the
restore is sequenced after the read rather than racing it.

Three further defects on the same path:

Transcripts no longer enter Windows clipboard history or sync to the
Microsoft cloud clipboard. Writing CF_UNICODETEXT alone opts into both.

Modifiers held at injection time are released first. In push-to-talk a
modifier is held by construction and SendInput does not reset keyboard
state, so a held Right-Alt turns Ctrl+V into AltGr+V.

Injection into an elevated window is detected up front instead of failing
silently, which MSDN confirms it does: neither the return value nor
GetLastError reports a UIPI block.

Verified against Chrome, VS Code, Notepad and Windows Terminal by making
each application copy its own field back, and separately with a clipboard
manager running.

If the new path fails for any reason it logs and falls through to the
existing implementation, so this cannot lose a transcript to an integration
problem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@emerson-d-lopes
emerson-d-lopes marked this pull request as ready for review July 22, 2026 17:50
0.1.1 fixes an intermittent abort when publishing the delayed-render promise.
SetClipboardData returns NULL on success for that call, which windows-rs
surfaces as Err, and Win32 does not reset the thread last-error on success, so
a stale code read as a failure that never happened.

Seen while driving this branch through real dictations: one paste in four
aborted with ERROR_NOT_FOUND and fell back to the timer path. With 0.1.1 the
fallback no longer triggers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@emerson-d-lopes

Copy link
Copy Markdown
Author

Update: this has now been run end to end through Handy itself, not just against the crate in isolation. Doing that found a bug, which is fixed in win-text-inject 0.1.1 (pushed to this branch).

What the real run found. Driving this branch through actual dictations, one paste in four aborted and fell back to the existing timer path:

[WARN] win-text-inject failed, falling back to timer paste:
       clipboard operation failed: Element not found. (0x80070490)

The cause is a Win32 trap I walked straight into. SetClipboardData returns NULL on success when publishing a delayed-render promise, so windows-rs reports it as Err. I had guarded that with "only a non-zero last-error is a real failure" — but Win32 does not reset the thread's last-error on success, so a stale code from an unrelated earlier call read as a failure that never happened. The clipboard write had actually succeeded.

The return value cannot detect failure there at all. 0.1.1 ignores it and establishes success from observable clipboard state instead: we own the clipboard, and CF_UNICODETEXT is advertised.

Before and after, same machine, same flow, real speech into Notepad:

version pastes fell back to timer
0.1.0 5 1
0.1.1 4 0

Small sample, and I would not present it as more than that. But the failure mode is understood rather than guessed at, and the previous clipboard survived every single dictation in both sessions, which is the behaviour #502 is about.

Worth noting the fallback did its job here: the transcript still landed via the existing path, and the user would not have noticed anything beyond it being a bit slower. That is the intended failure posture for this change.

Full build now verified too. cargo build on src-tauri links a working handy.exe (Vulkan backends detected, model loads, transcription runs). My earlier note said cargo check; it is a real build now.

Still unverified, and I would rather say so than let a green check imply otherwise: Windows on ARM, RDP/Citrix/VDI, games and other raw-input consumers, and Windows 10.

@MatteoGauthier

Copy link
Copy Markdown

Hi, it looks like this PR addresses the Windows platform bug, so I guess we can remove the "Close #XXX" as the issue is also on other platforms (like macOS). What do you think?

@cjpais

cjpais commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Yeah this does not close #502

@emerson-d-lopes emerson-d-lopes changed the title Fix #502: restore the clipboard when the target has read it, not on a timer Windows: sequence the clipboard restore after the target has read it Jul 26, 2026
@emerson-d-lopes

Copy link
Copy Markdown
Author

@MatteoGauthier @cjpais Agreed, and thanks for catching it. Updated the title and the first line: this is now "Part of #502, Windows path only" with no closing keyword, so merging it will not auto-close the issue.

You are right about the scope. The mechanism here is Win32-specific (delayed-render promise plus WM_RENDERFORMAT as the signal that the target actually read the clipboard), so it can only fix the Windows reports. The macOS and Linux paste paths are untouched, and #502 has reports on both, including Wayland with wl-copy.

Worth noting for whoever picks up the macOS side: the underlying race is the same shape (the restore is scheduled on a timer rather than sequenced after the consumer's read), so the diagnosis in this PR should transfer even though the API does not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants