From cf7e206097af08e924a336a636dde216466114f3 Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Fri, 11 Sep 2026 06:16:49 -0500 Subject: [PATCH] Put the modifiers back, so unspooling repeats Releasing the held modifiers before synthesizing Ctrl+V fixed the paste and broke the gesture the feature exists for. Windows was left believing Win and Alt were up while the user was still physically holding them, so the next U arrived as a bare u and was typed into their document: unspool worked once, then printed uuuu. Waiting several seconds appeared to help only because the user let go and pressed again, which re-registered the modifiers. Every modifier that was down is now pressed again after the paste. Hold Win+Alt, tap U as often as you like, and each tap serves and pastes the next clip. Restoring Win threatens a Start menu, since Windows opens it on a Win release with no other key in between and a restored Win looks exactly like that. An unassigned virtual key sent straight afterwards marks the chord as used. Measured against a real window with the modifiers held throughout: three taps, three distinct clips, no stray characters, and the foreground unchanged after release. Co-Authored-By: Claude Opus 5 --- PLAN.md | 16 ++++-- native/clipboard/src/clipboard_win.cc | 77 +++++++++++++++------------ 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/PLAN.md b/PLAN.md index cb765b5..c8e60ad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -676,9 +676,19 @@ at the instant the handler runs the user is still holding `Win+Alt`. Synthesizin state delivers `Win+Alt+Ctrl+V`, which is a paste in no application on earth, and nothing happens. It presented as "unspooling advances the spool but never pastes", and it looked intermittent because a handler that happened to run after the keys came up worked perfectly — which is how it survived a -round of testing. **So the addon lifts every modifier that is currently down before pressing Ctrl+V**, -and does not restore them: the user's own keys are still physically held, their release is harmless, -and re-pressing `Win` would open the Start menu. +round of testing. **So the addon lifts every modifier that is currently down before pressing Ctrl+V** — and +then puts them back. + +Lifting them was only half the answer, and the half alone was worse than the disease. Releasing keys +the user is still physically holding leaves Windows believing they are up, so the *next* `U` arrives +as a bare `u` and is typed into their document: unspooling worked once and then printed `uuuu`. The +gesture this whole design rests on — hold `Win+Alt`, tap `U` as often as you like — was broken by the +fix for the bug above it. Restoring every modifier that was down is what makes the repeat survive. + +Re-pressing `Win` then threatens a Start menu, because Windows opens it on a `Win` release with no +other key in between, and a restored `Win` looks exactly like that. An unassigned virtual key sent +straight after marks the chord as used. Measured: `Win+Alt` held, `U` tapped three times, three +distinct clips pasted, no stray characters, no Start menu on release. Two consequences follow from taking the user's account seriously rather than the code's. Pasting the whole spool pastes too — one key that pastes and one that silently changes the clipboard is an diff --git a/native/clipboard/src/clipboard_win.cc b/native/clipboard/src/clipboard_win.cc index 305ff8a..d871a11 100644 --- a/native/clipboard/src/clipboard_win.cc +++ b/native/clipboard/src/clipboard_win.cc @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -325,50 +326,56 @@ Napi::Value SendPaste(const Napi::CallbackInfo& info) { GetWindowThreadProcessId(foreground, &foreground_pid); if (foreground_pid == GetCurrentProcessId()) return Napi::Boolean::New(env, false); - // **Release whatever the user is still holding first.** + // **Lift whatever the user is holding, paste, then put it back.** // - // The hotkey that asked for this paste fires on the key *down*, so at this instant Win and Alt - // are almost certainly still held — the user has not let go of `Win+Alt+U` yet. Synthesizing - // Ctrl+V into that state delivers `Win+Alt+Ctrl+V`, which is not a paste in any application, and - // nothing happens. It cost a user their trust in the feature before it was understood, and it - // looked intermittent because a handler that happened to run after the keys came up worked fine. + // The hotkey fires on the key *down*, so at this instant Win and Alt are still held — the user + // has not let go of `Win+Alt+U`. Synthesizing Ctrl+V into that state delivers `Win+Alt+Ctrl+V`, + // which is a paste in no application, and nothing happens. // - // So: lift every modifier that is currently down, then press Ctrl+V cleanly. They are not - // restored afterwards. The user's own keys are still physically held and their next release is - // harmless, whereas re-pressing Win here would open the Start menu. + // Lifting them is only half the answer, and the half on its own is worse than the disease. + // Releasing keys the user is still physically holding leaves Windows believing they are up, so + // the *next* `U` arrives as a bare `u` and gets typed into their document. Unspooling worked + // once and then printed `uuuu`. **So every modifier that was down is pressed again afterwards**, + // and the repeat gesture survives: hold Win+Alt, tap U as often as you like. const WORD kModifiers[] = {VK_LWIN, VK_RWIN, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_LCONTROL, VK_RCONTROL}; - std::vector inputs; + std::vector held; for (WORD vk : kModifiers) { - if ((GetAsyncKeyState(vk) & 0x8000) == 0) continue; - INPUT up = {}; - up.type = INPUT_KEYBOARD; - up.ki.wVk = vk; - up.ki.dwFlags = KEYEVENTF_KEYUP; - inputs.push_back(up); + if ((GetAsyncKeyState(vk) & 0x8000) != 0) held.push_back(vk); } - const size_t released = inputs.size(); - - INPUT press = {}; - press.type = INPUT_KEYBOARD; - press.ki.wVk = VK_CONTROL; - inputs.push_back(press); - - press.ki.wVk = 'V'; - inputs.push_back(press); - - INPUT release = {}; - release.type = INPUT_KEYBOARD; - release.ki.dwFlags = KEYEVENTF_KEYUP; - release.ki.wVk = 'V'; - inputs.push_back(release); - - release.ki.wVk = VK_CONTROL; - inputs.push_back(release); + std::vector inputs; + const auto key = [&inputs](WORD vk, bool down) { + INPUT input = {}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = vk; + input.ki.dwFlags = down ? 0 : KEYEVENTF_KEYUP; + inputs.push_back(input); + }; + + for (WORD vk : held) key(vk, false); + + key(VK_CONTROL, true); + key('V', true); + key('V', false); + key(VK_CONTROL, false); + + // Put them back in the order they were found, so the user's own eventual release is the one that + // ends them. + for (WORD vk : held) key(vk, true); + + // Re-pressing Win would otherwise open the Start menu the moment the user lets go: Windows opens + // it on a Win release that had no other key in between, and ours would look exactly like that. + // An unassigned virtual key marks the chord as used without doing anything else. + const bool restoredWin = std::find(held.begin(), held.end(), static_cast(VK_LWIN)) != held.end() || + std::find(held.begin(), held.end(), static_cast(VK_RWIN)) != held.end(); + if (restoredWin) { + key(0xE8, true); + key(0xE8, false); + } - const UINT expected = static_cast(released + 4); + const UINT expected = static_cast(inputs.size()); const UINT sent = SendInput(expected, inputs.data(), sizeof(INPUT)); return Napi::Boolean::New(env, sent == expected); }