fix(input): held modifiers on mouse events + Ctrl+click→Cmd+click remap - #183
Conversation
|
Thanks @antonmos — the underlying diagnosis is right and I hadn't realised this I'd like one thing resolved before this merges, plus three smaller fixes. Blocking: a stuck modifier now costs the user their mouse, and reconnecting doesn't fix itBefore this PR Three things make the blast radius bigger than it first looks:
To be clear, this PR doesn't create the stuck-modifier bug — it removes the The awkward part is that Should fix1. The remap verdict can flip between down and up (src/input.rs:1404). 2. The swap hits right- and middle-click too (src/input.rs:1404). 3. MinorHolding Ctrl while moving makes every motion PDU pay for Also: no tests. I realise CI is green on all three jobs and the refactor itself is good — this is |
… remap Addresses review on clintcan#183. Blocking — stale held modifiers now cost the mouse, so resync them. Once clicks carry modifier flags, a modifier whose key-up never arrived (the client lost focus while Ctrl was held) turns every left click into a secondary click, and the blast radius was the whole process: `Inner` owns `mods` and is constructed once, nothing reset it, `synchronize()` reconciles Caps Lock only, and the Synchronize PDU carries lock keys only — so it survived disconnect AND reconnect and cleared only on a server restart. `resync_modifiers_if_stale` (top of keyboard()/mouse()) now clears every held non-lock modifier (Caps Lock preserved — a toggle synchronize() owns), posts one FlagsChanged with the emptied set, and drops any outstanding remapped_keys / click latch, on either trigger: * a new connection — capture.rs's per-connection ScreenCaptureUpdates::start calls input::request_modifier_reset(), the same seam that already resets display_suppressed (RdpServerInputHandler has no per-connection hook), so a reconnect is a deterministic clean slate; * an idle gap >= MACRDP_MODS_RESYNC_IDLE_MS (default 10 s; 0 disables), which covers the common case with no disconnect at all. Generous on purpose: a false clear costs one keystroke, a missed one costs every click. Should-fix 1 — latch the verdict at button-down. `mouse_event_flags()` was re-evaluated for the down and the up, and the down itself refreshes LAST_FOCUS_BUNDLE off-thread via update_focus_from_click, so Ctrl+clicking INTO an excluded app could post a Cmd-flagged down and a Ctrl-flagged up. The verdict is now decided once at left-button-down (`left_click_remapped`, the mouse analogue of remapped_keys) and reused for the drag and the up. Should-fix 2 — primary button only. Right/middle clicks carry the real held modifiers; a Ctrl+right-click no longer becomes Cmd+right-click. Should-fix 3 — scroll() now carries the held modifiers too (Shift+scroll, Cmd+scroll), so the "every synthesized mouse event" claim is true. No Ctrl→Cmd swap on scroll: Ctrl+scroll is macOS's own screen-zoom accessibility gesture. Minor — the per-motion cost is gone: post_move() reuses the latch and never calls frontmost_is_excluded(). The gating predicate is split into the pure `should_remap_click(remap_on, ctrl, cmd, alt, excluded)` and unit-tested as a full decision table (only one row remaps), plus a test pinning that the resync preserves Caps Lock. Also fixes a doc comment that had been attached to the wrong fn by the earlier refactor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the thorough review — all of it landed in c8712e9. Taking the blocking item first since you asked for the decision to be made deliberately. Modifier reset — decision: both a connection-edge reset AND an idle-gap resyncI went with two of your three options rather than one, because they cover different failure shapes:
The threshold is generous on purpose and the cost asymmetry is the argument: a false clear costs one keystroke (re-press the modifier), a missed one costs every click until restart. Any event — a mouse move included — refreshes the timer, so the only false-positive shape is a genuine 10-second hold with zero input in between. It also posts one You're right that this PR didn't create the stuck-modifier bug, only removed the buffer hiding it — but I agree "every click is a right-click until you restart the server" is not an acceptable failure mode for the one channel the user has. Should-fix
TestsSplit the gate into the pure
Also fixed a doc comment my earlier refactor had glued onto the wrong fn. One honest caveat: the idle-gap resync is verified by unit test and reasoning, not yet by a live focus-loss repro against a real client. |
|
Thanks @antonmos — this is a really good response. Two triggers is the right The blocker is resolved. I went through c8712e9 and found two small things in 1. The "connection edge" reset also fires on every reactivation
The cost is one re-press, so it's small today — but it means "the only One thing to avoid: 2. On reconnect, the previous connection's latch survives
3. Pre-existing: a disconnect mid-drag leaves a phantom dragNot caused by this PR — So if a connection drops between a left-down and its up, every mouse move on the Putting #2 and #3 togetherI'd split the reset by trigger:
Either way, only post the Once these are in I think this is good to go. |
TODO.md - In flight: review state of @antonmos's open PRs. #183's blocker is resolved in c8712e9 with one follow-up requested (the connection reset also fires on every reactivation; the latch survives a reset when no modifier is held; a pre-existing phantom drag after a mid-drag disconnect, latent on main too). #182 asked for a per-step deadline, with a ship-now fallback, and collides with the mic branch on divergence (24). #181 and #184 not yet reviewed. Links issue #186. - Upstreaming watch: a dated update superseding the stale "zero open PRs" note. Divergence (23) is upstream via #1476 + #1913 (ConnectionPolicy) with its traps; IronRDP#1969 blocks de-vendoring (22)/(23) and upstreaming (18) and ships in 0.14.0 unless fixed; #1483 closed; overlapping upstream work to evaluate at the next bump (#1951/#1953/#1954 multitransport stack, ironrdp-rdpeai #1645 + #1946 for the mic divergence). vendor/ironrdp-server/CLAUDE.md - (18): upstream status — #1484 green-lit by a peer, held patch 392 commits stale, and blocked on #1969 because upstream Preempt takes the handler for the race. Notes the --fork-workers scope boundary no longer applies. - (12): the #1951 -> #1953 -> #1954 stack as a possible partial de-vendor path, and how it differs from this divergence. Docs only.
Follow-up to review on clintcan#183 (three items). 1. The connection-edge reset fired on every reactivation. It was requested from ScreenCaptureUpdates::start, but RdpServerDisplay::updates re-runs after every deactivation-reactivation — so a held modifier was cleared on every live resize and on blank recovery, with no user action. Moved to a once-per-SERVED- connection seam in the vendored server (divergence 24): a new `set_input_reset_handle` flag raised at the top of run_connection AND serve_negotiated, next to divergence 13's identical-lifecycle `auto_reconnect_sent = false`. Each calls accept_finalize exactly once with reactivations looping inside it, so it never fires for a reactivation. Not on_accept — that runs for preemption candidates while the live session is still served, so a mere connection attempt could clear the live session's modifiers. capture.rs no longer touches it; main.rs installs the handle via input::modifier_reset_handle(). 2. The previous connection's latch survived a reconnect: the resync returned early when clear_non_lock() reported nothing held, before clearing remapped_keys / left_click_remapped. Restructured so the connection-edge clears are unconditional. 3. Pre-existing phantom drag: left_down/right_down were only ever written by a real button event, so a drop between a left-down and its up posted every move on the next connection as LeftMouseDragged. The connection-edge reset now RELEASES any button still down — a synthetic Up through button(), so it carries the same latched flags the Down did and the click bookkeeping stays paired — before clearing modifiers, rather than merely forgetting the state (macOS itself still believed the button held). Triggers now clear different amounts, per review: the connection edge clears everything; the idle gap clears modifiers ONLY — a click/drag in progress inside a live connection is legitimate state, and clearing the latch there would undo the down/up consistency fix. FlagsChanged is posted only when a modifier was actually held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All three in 01e86a8 — and thanks for the pointer to 1. Reset moved to a once-per-served-connection seamNew vendored divergence (24): 2. Latch surviving a reconnectRight — the early return on 3. Phantom dragTook this one step further than clearing the tracking: on the connection edge, any button still down is released — a synthetic Up posted through Trigger splitExactly as you laid out: connection edge clears everything (modifiers, latch,
|
|
Thanks @antonmos — I went through Two things, neither a blocker: 1. Where the synthetic button-up lands. I agree the double-press problem you describe is real, and I don't see a clean way out of it: Escape would cancel a drag, but outside one it dismisses dialogs and exits full screen in plenty of apps. So I'd keep your approach, and ask for two things: a line in the quirk note stating that an interrupted drag completes as a drop at the last cursor position, and, when you do the live mid-drag disconnect, drag an actual file over a folder so we see what macOS really does. 2. Tests. Numbering: #182 also claims vendored divergence (24), and so does my mic branch. Since this one looks closest to landing, it keeps (24); I'll ask #182 to take (25), and the mic work will take (26). With the quirk-note line and a test, I think this is good to go. I'll review #184 once this lands, since it's stacked on top. |
…ering - Records the numbering decision: three branches claim vendored divergence (24), so by expected merge order #183 keeps (24), #182 takes (25) and the mic divergence becomes (26); otherwise take the next free number at merge. - #183: round 3 addressed all three follow-ups (verified against 334cc3c, CI green). Remaining asks: document and live-test the synthetic button-up, which lands at the pre-disconnect cursor position and can complete an interrupted drag as a drop; and tests for the connection-edge path. - #182: no reply yet; must renumber to (25). - #184: stacked on #183, review after it merges. - Upstreaming watch: mic divergence (24->26). Docs only.
clintcan#183 has since added its own vendored-server divergence (24) (the per-served-connection input-reset handle) and merges first, and clintcan's MS-RDPEAI mic branch takes (26), so this finalize-timeout divergence moves to (25) to avoid two (24)s in the log — the kind of bump-time number collision that produced clintcan#179. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…to (24)/(25) Decided 2026-09-17 (comment 5722493925): hold #182 rather than reshaping it or landing the vendored form. The identical 30 s bound is already upstream in Devolutions/IronRDP#1890 (merged 2026-09-04 by @antonmos, same const, same inner accept_finalize call), so the pin bump harvests it and vendored divergence (25) never has to exist. Exposure until the bump is mild: macrdp preempts unconditionally and eviction isn't gated on the incumbent having activated, so a finalize-wedged client is displaced by the next authenticated connection. His log data also settled the 30 s margin question. The PR stays open on purpose — it holds the regression test and the wedge diagnosis, and acts as the bump reminder; the TODO notes what to retrieve. With #182 never landing a divergence, numbering is back to two claimants: #183 keeps (24) and the mic divergence takes (25). Docs only.
… remap Addresses review on clintcan#183. Blocking — stale held modifiers now cost the mouse, so resync them. Once clicks carry modifier flags, a modifier whose key-up never arrived (the client lost focus while Ctrl was held) turns every left click into a secondary click, and the blast radius was the whole process: `Inner` owns `mods` and is constructed once, nothing reset it, `synchronize()` reconciles Caps Lock only, and the Synchronize PDU carries lock keys only — so it survived disconnect AND reconnect and cleared only on a server restart. `resync_modifiers_if_stale` (top of keyboard()/mouse()) now clears every held non-lock modifier (Caps Lock preserved — a toggle synchronize() owns), posts one FlagsChanged with the emptied set, and drops any outstanding remapped_keys / click latch, on either trigger: * a new connection — capture.rs's per-connection ScreenCaptureUpdates::start calls input::request_modifier_reset(), the same seam that already resets display_suppressed (RdpServerInputHandler has no per-connection hook), so a reconnect is a deterministic clean slate; * an idle gap >= MACRDP_MODS_RESYNC_IDLE_MS (default 10 s; 0 disables), which covers the common case with no disconnect at all. Generous on purpose: a false clear costs one keystroke, a missed one costs every click. Should-fix 1 — latch the verdict at button-down. `mouse_event_flags()` was re-evaluated for the down and the up, and the down itself refreshes LAST_FOCUS_BUNDLE off-thread via update_focus_from_click, so Ctrl+clicking INTO an excluded app could post a Cmd-flagged down and a Ctrl-flagged up. The verdict is now decided once at left-button-down (`left_click_remapped`, the mouse analogue of remapped_keys) and reused for the drag and the up. Should-fix 2 — primary button only. Right/middle clicks carry the real held modifiers; a Ctrl+right-click no longer becomes Cmd+right-click. Should-fix 3 — scroll() now carries the held modifiers too (Shift+scroll, Cmd+scroll), so the "every synthesized mouse event" claim is true. No Ctrl→Cmd swap on scroll: Ctrl+scroll is macOS's own screen-zoom accessibility gesture. Minor — the per-motion cost is gone: post_move() reuses the latch and never calls frontmost_is_excluded(). The gating predicate is split into the pure `should_remap_click(remap_on, ctrl, cmd, alt, excluded)` and unit-tested as a full decision table (only one row remaps), plus a test pinning that the resync preserves Caps Lock. Also fixes a doc comment that had been attached to the wrong fn by the earlier refactor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to review on clintcan#183 (three items). 1. The connection-edge reset fired on every reactivation. It was requested from ScreenCaptureUpdates::start, but RdpServerDisplay::updates re-runs after every deactivation-reactivation — so a held modifier was cleared on every live resize and on blank recovery, with no user action. Moved to a once-per-SERVED- connection seam in the vendored server (divergence 24): a new `set_input_reset_handle` flag raised at the top of run_connection AND serve_negotiated, next to divergence 13's identical-lifecycle `auto_reconnect_sent = false`. Each calls accept_finalize exactly once with reactivations looping inside it, so it never fires for a reactivation. Not on_accept — that runs for preemption candidates while the live session is still served, so a mere connection attempt could clear the live session's modifiers. capture.rs no longer touches it; main.rs installs the handle via input::modifier_reset_handle(). 2. The previous connection's latch survived a reconnect: the resync returned early when clear_non_lock() reported nothing held, before clearing remapped_keys / left_click_remapped. Restructured so the connection-edge clears are unconditional. 3. Pre-existing phantom drag: left_down/right_down were only ever written by a real button event, so a drop between a left-down and its up posted every move on the next connection as LeftMouseDragged. The connection-edge reset now RELEASES any button still down — a synthetic Up through button(), so it carries the same latched flags the Down did and the click bookkeeping stays paired — before clearing modifiers, rather than merely forgetting the state (macOS itself still believed the button held). Triggers now clear different amounts, per review: the connection edge clears everything; the idle gap clears modifiers ONLY — a click/drag in progress inside a live connection is legitimate state, and clearing the latch there would undo the down/up consistency fix. FlagsChanged is posted only when a modifier was actually held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… remap Addresses review on clintcan#183. Blocking — stale held modifiers now cost the mouse, so resync them. Once clicks carry modifier flags, a modifier whose key-up never arrived (the client lost focus while Ctrl was held) turns every left click into a secondary click, and the blast radius was the whole process: `Inner` owns `mods` and is constructed once, nothing reset it, `synchronize()` reconciles Caps Lock only, and the Synchronize PDU carries lock keys only — so it survived disconnect AND reconnect and cleared only on a server restart. `resync_modifiers_if_stale` (top of keyboard()/mouse()) now clears every held non-lock modifier (Caps Lock preserved — a toggle synchronize() owns), posts one FlagsChanged with the emptied set, and drops any outstanding remapped_keys / click latch, on either trigger: * a new connection — capture.rs's per-connection ScreenCaptureUpdates::start calls input::request_modifier_reset(), the same seam that already resets display_suppressed (RdpServerInputHandler has no per-connection hook), so a reconnect is a deterministic clean slate; * an idle gap >= MACRDP_MODS_RESYNC_IDLE_MS (default 10 s; 0 disables), which covers the common case with no disconnect at all. Generous on purpose: a false clear costs one keystroke, a missed one costs every click. Should-fix 1 — latch the verdict at button-down. `mouse_event_flags()` was re-evaluated for the down and the up, and the down itself refreshes LAST_FOCUS_BUNDLE off-thread via update_focus_from_click, so Ctrl+clicking INTO an excluded app could post a Cmd-flagged down and a Ctrl-flagged up. The verdict is now decided once at left-button-down (`left_click_remapped`, the mouse analogue of remapped_keys) and reused for the drag and the up. Should-fix 2 — primary button only. Right/middle clicks carry the real held modifiers; a Ctrl+right-click no longer becomes Cmd+right-click. Should-fix 3 — scroll() now carries the held modifiers too (Shift+scroll, Cmd+scroll), so the "every synthesized mouse event" claim is true. No Ctrl→Cmd swap on scroll: Ctrl+scroll is macOS's own screen-zoom accessibility gesture. Minor — the per-motion cost is gone: post_move() reuses the latch and never calls frontmost_is_excluded(). The gating predicate is split into the pure `should_remap_click(remap_on, ctrl, cmd, alt, excluded)` and unit-tested as a full decision table (only one row remaps), plus a test pinning that the resync preserves Caps Lock. Also fixes a doc comment that had been attached to the wrong fn by the earlier refactor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to review on clintcan#183 (three items). 1. The connection-edge reset fired on every reactivation. It was requested from ScreenCaptureUpdates::start, but RdpServerDisplay::updates re-runs after every deactivation-reactivation — so a held modifier was cleared on every live resize and on blank recovery, with no user action. Moved to a once-per-SERVED- connection seam in the vendored server (divergence 24): a new `set_input_reset_handle` flag raised at the top of run_connection AND serve_negotiated, next to divergence 13's identical-lifecycle `auto_reconnect_sent = false`. Each calls accept_finalize exactly once with reactivations looping inside it, so it never fires for a reactivation. Not on_accept — that runs for preemption candidates while the live session is still served, so a mere connection attempt could clear the live session's modifiers. capture.rs no longer touches it; main.rs installs the handle via input::modifier_reset_handle(). 2. The previous connection's latch survived a reconnect: the resync returned early when clear_non_lock() reported nothing held, before clearing remapped_keys / left_click_remapped. Restructured so the connection-edge clears are unconditional. 3. Pre-existing phantom drag: left_down/right_down were only ever written by a real button event, so a drop between a left-down and its up posted every move on the next connection as LeftMouseDragged. The connection-edge reset now RELEASES any button still down — a synthetic Up through button(), so it carries the same latched flags the Down did and the click bookkeeping stays paired — before clearing modifiers, rather than merely forgetting the state (macOS itself still believed the button held). Triggers now clear different amounts, per review: the connection edge clears everything; the idle gap clears modifiers ONLY — a click/drag in progress inside a live connection is legitimate state, and clearing the latch there would undo the down/up consistency fix. FlagsChanged is posted only when a modifier was actually held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334cc3c to
8eff453
Compare
clintcan#183 has since added its own vendored-server divergence (24) (the per-served-connection input-reset handle) and merges first, and clintcan's MS-RDPEAI mic branch takes (26), so this finalize-timeout divergence moves to (25) to avoid two (24)s in the log — the kind of bump-time number collision that produced clintcan#179. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A third branch claims vendored divergence (24): PR clintcan#183's per-served-connection input-reset handle, alongside PR clintcan#182's FINALIZE_TIMEOUT and this branch's MS-RDPEAI processor. Decided by expected merge order: clintcan#183 keeps (24), clintcan#182 takes (25), and this divergence becomes (26). Updates the collision marker at the divergence heading and the P3 checklist item, and records the rule if the order changes: take the next free number on main at merge time. Docs only.
clintcan#182 was held for the pin bump on 2026-09-17 rather than landing its vendored divergence — the identical bound is already upstream in IronRDP#1890, so macrdp harvests it. That leaves clintcan#183 keeping (24) and frees (25) for this divergence. Updates the collision marker and the P3 checklist item. Docs only.
The protocol layer advertised 48 kHz too and opened whatever the client picked, so a 48 kHz client played at the wrong pitch. It now advertises and opens only 16-bit PCM at 44.1 kHz (the first acceptable entry in the client's list, by its index), follows a Format Change only to an acceptable format (dropping data otherwise), and serves a newer client at version 1. A client with no acceptable format gets no mic, and that is logged. Causal tests in src/audin/mod.rs (4 of 5 fail on the old code). Divergence (24) is reserved for clintcan#183, so the mic is (25). It ships ahead of the IronRDP pin bump; upstream ironrdp-rdpeai replaces it at the bump. Docs and help text no longer describe the old wrong-pitch behaviour.
Resolves the vendored divergence-log conflict: main added (25) (MS-RDPEAI microphone redirection) at the same append point where this branch adds (24) (the per-served-connection input-reset handle). Kept BOTH, (24) then (25) — main's own (25) entry reserves (24) for PR clintcan#183, so keeping both is what each side intended and no renumbering is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… remap Addresses review on clintcan#183. Blocking — stale held modifiers now cost the mouse, so resync them. Once clicks carry modifier flags, a modifier whose key-up never arrived (the client lost focus while Ctrl was held) turns every left click into a secondary click, and the blast radius was the whole process: `Inner` owns `mods` and is constructed once, nothing reset it, `synchronize()` reconciles Caps Lock only, and the Synchronize PDU carries lock keys only — so it survived disconnect AND reconnect and cleared only on a server restart. `resync_modifiers_if_stale` (top of keyboard()/mouse()) now clears every held non-lock modifier (Caps Lock preserved — a toggle synchronize() owns), posts one FlagsChanged with the emptied set, and drops any outstanding remapped_keys / click latch, on either trigger: * a new connection — capture.rs's per-connection ScreenCaptureUpdates::start calls input::request_modifier_reset(), the same seam that already resets display_suppressed (RdpServerInputHandler has no per-connection hook), so a reconnect is a deterministic clean slate; * an idle gap >= MACRDP_MODS_RESYNC_IDLE_MS (default 10 s; 0 disables), which covers the common case with no disconnect at all. Generous on purpose: a false clear costs one keystroke, a missed one costs every click. Should-fix 1 — latch the verdict at button-down. `mouse_event_flags()` was re-evaluated for the down and the up, and the down itself refreshes LAST_FOCUS_BUNDLE off-thread via update_focus_from_click, so Ctrl+clicking INTO an excluded app could post a Cmd-flagged down and a Ctrl-flagged up. The verdict is now decided once at left-button-down (`left_click_remapped`, the mouse analogue of remapped_keys) and reused for the drag and the up. Should-fix 2 — primary button only. Right/middle clicks carry the real held modifiers; a Ctrl+right-click no longer becomes Cmd+right-click. Should-fix 3 — scroll() now carries the held modifiers too (Shift+scroll, Cmd+scroll), so the "every synthesized mouse event" claim is true. No Ctrl→Cmd swap on scroll: Ctrl+scroll is macOS's own screen-zoom accessibility gesture. Minor — the per-motion cost is gone: post_move() reuses the latch and never calls frontmost_is_excluded(). The gating predicate is split into the pure `should_remap_click(remap_on, ctrl, cmd, alt, excluded)` and unit-tested as a full decision table (only one row remaps), plus a test pinning that the resync preserves Caps Lock. Also fixes a doc comment that had been attached to the wrong fn by the earlier refactor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to review on clintcan#183 (three items). 1. The connection-edge reset fired on every reactivation. It was requested from ScreenCaptureUpdates::start, but RdpServerDisplay::updates re-runs after every deactivation-reactivation — so a held modifier was cleared on every live resize and on blank recovery, with no user action. Moved to a once-per-SERVED- connection seam in the vendored server (divergence 24): a new `set_input_reset_handle` flag raised at the top of run_connection AND serve_negotiated, next to divergence 13's identical-lifecycle `auto_reconnect_sent = false`. Each calls accept_finalize exactly once with reactivations looping inside it, so it never fires for a reactivation. Not on_accept — that runs for preemption candidates while the live session is still served, so a mere connection attempt could clear the live session's modifiers. capture.rs no longer touches it; main.rs installs the handle via input::modifier_reset_handle(). 2. The previous connection's latch survived a reconnect: the resync returned early when clear_non_lock() reported nothing held, before clearing remapped_keys / left_click_remapped. Restructured so the connection-edge clears are unconditional. 3. Pre-existing phantom drag: left_down/right_down were only ever written by a real button event, so a drop between a left-down and its up posted every move on the next connection as LeftMouseDragged. The connection-edge reset now RELEASES any button still down — a synthetic Up through button(), so it carries the same latched flags the Down did and the click bookkeeping stays paired — before clearing modifiers, rather than merely forgetting the state (macOS itself still believed the button held). Triggers now clear different amounts, per review: the connection edge clears everything; the idle gap clears modifiers ONLY — a click/drag in progress inside a live connection is legitimate state, and clearing the latch there would undo the down/up consistency fix. FlagsChanged is posted only when a modifier was actually held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ck remap Synthesized mouse click/drag events never called set_flags, so a click while a modifier was held arrived with empty modifierFlags — a genuine Cmd+click didn't open a link in a new tab, Shift+click didn't range-select, and Ctrl+click wasn't a secondary click. button()/post_move() now stamp the held-modifier state onto every mouse event, mirroring the keyboard path in key(), which has always set_flags for exactly this reason. Additionally, under --map-ctrl-to-cmd a plain Ctrl+click is now delivered as Cmd+click (open link in a new tab) instead of a secondary/context click — the mouse analogue of the existing keyboard Ctrl→Cmd remap, via a shared ctrl_to_cmd_flags() bit-swap. Gated identically (Ctrl held, no Cmd/Alt, non-excluded app); the frontmost-exclusion check sits behind the cheap Ctrl-held guards so an ordinary unmodified drag never pays for it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… remap Addresses review on clintcan#183. Blocking — stale held modifiers now cost the mouse, so resync them. Once clicks carry modifier flags, a modifier whose key-up never arrived (the client lost focus while Ctrl was held) turns every left click into a secondary click, and the blast radius was the whole process: `Inner` owns `mods` and is constructed once, nothing reset it, `synchronize()` reconciles Caps Lock only, and the Synchronize PDU carries lock keys only — so it survived disconnect AND reconnect and cleared only on a server restart. `resync_modifiers_if_stale` (top of keyboard()/mouse()) now clears every held non-lock modifier (Caps Lock preserved — a toggle synchronize() owns), posts one FlagsChanged with the emptied set, and drops any outstanding remapped_keys / click latch, on either trigger: * a new connection — capture.rs's per-connection ScreenCaptureUpdates::start calls input::request_modifier_reset(), the same seam that already resets display_suppressed (RdpServerInputHandler has no per-connection hook), so a reconnect is a deterministic clean slate; * an idle gap >= MACRDP_MODS_RESYNC_IDLE_MS (default 10 s; 0 disables), which covers the common case with no disconnect at all. Generous on purpose: a false clear costs one keystroke, a missed one costs every click. Should-fix 1 — latch the verdict at button-down. `mouse_event_flags()` was re-evaluated for the down and the up, and the down itself refreshes LAST_FOCUS_BUNDLE off-thread via update_focus_from_click, so Ctrl+clicking INTO an excluded app could post a Cmd-flagged down and a Ctrl-flagged up. The verdict is now decided once at left-button-down (`left_click_remapped`, the mouse analogue of remapped_keys) and reused for the drag and the up. Should-fix 2 — primary button only. Right/middle clicks carry the real held modifiers; a Ctrl+right-click no longer becomes Cmd+right-click. Should-fix 3 — scroll() now carries the held modifiers too (Shift+scroll, Cmd+scroll), so the "every synthesized mouse event" claim is true. No Ctrl→Cmd swap on scroll: Ctrl+scroll is macOS's own screen-zoom accessibility gesture. Minor — the per-motion cost is gone: post_move() reuses the latch and never calls frontmost_is_excluded(). The gating predicate is split into the pure `should_remap_click(remap_on, ctrl, cmd, alt, excluded)` and unit-tested as a full decision table (only one row remaps), plus a test pinning that the resync preserves Caps Lock. Also fixes a doc comment that had been attached to the wrong fn by the earlier refactor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to review on clintcan#183 (three items). 1. The connection-edge reset fired on every reactivation. It was requested from ScreenCaptureUpdates::start, but RdpServerDisplay::updates re-runs after every deactivation-reactivation — so a held modifier was cleared on every live resize and on blank recovery, with no user action. Moved to a once-per-SERVED- connection seam in the vendored server (divergence 24): a new `set_input_reset_handle` flag raised at the top of run_connection AND serve_negotiated, next to divergence 13's identical-lifecycle `auto_reconnect_sent = false`. Each calls accept_finalize exactly once with reactivations looping inside it, so it never fires for a reactivation. Not on_accept — that runs for preemption candidates while the live session is still served, so a mere connection attempt could clear the live session's modifiers. capture.rs no longer touches it; main.rs installs the handle via input::modifier_reset_handle(). 2. The previous connection's latch survived a reconnect: the resync returned early when clear_non_lock() reported nothing held, before clearing remapped_keys / left_click_remapped. Restructured so the connection-edge clears are unconditional. 3. Pre-existing phantom drag: left_down/right_down were only ever written by a real button event, so a drop between a left-down and its up posted every move on the next connection as LeftMouseDragged. The connection-edge reset now RELEASES any button still down — a synthetic Up through button(), so it carries the same latched flags the Down did and the click bookkeeping stays paired — before clearing modifiers, rather than merely forgetting the state (macOS itself still believed the button held). Triggers now clear different amounts, per review: the connection edge clears everything; the idle gap clears modifiers ONLY — a click/drag in progress inside a live connection is legitimate state, and clearing the latch there would undo the down/up consistency fix. FlagsChanged is posted only when a modifier was actually held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
012ab86 to
923b123
Compare
…ence (26) (24) is reserved for clintcan#183 and (25) shipped as the mic redirection. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
clintcan
left a comment
There was a problem hiding this comment.
Approving. The design holds up and CI is green on the rebased head. The drag-completes-as-a-drop caveat I'll add to the quirk note on main myself; tests for the reconnect reset can come as a follow-up.
|
Merged — thanks @antonmos. I added the interrupted-drag caveat to the quirk note on One follow-up when you have time: a small test for the reconnect reset — which buttons get released, the latch and |
clintcan#183 is merged (58db18c), so main now carries this branch's copies of its three commits. Merging main in collapses the effective diff to just the Ctrl+, change (4 files, 8+/7-) — the outcome the review asked for — without rewriting history. Conflicts were the two long summary lines in docs/features.md and docs/known-quirks.md, where this branch's rebased copies collided with the merged clintcan#183 text plus the interrupted-drag caveat added in b43a047. Resolved by taking main's text (caveat retained) and re-applying only the comma additions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence (26) (24) is reserved for clintcan#183 and (25) shipped as the mic redirection. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Held modifiers now ride clicks, drags and scrolls; under --map-ctrl-to-cmd a left Ctrl+click becomes Cmd+click and Ctrl+, opens Preferences; each newly served connection releases stuck buttons/modifiers once (vendored server divergence 24), with an idle resync (MACRDP_MODS_RESYNC_IDLE_MS, now in docs/cli.md). Contributed by @antonmos (#183, #184). Pre-tag gates: fmt clean (stable + nightly), clippy -D warnings clean, 262 tests passing. Docs: release-history, README status, CLAUDE.md status.
Problem
Synthesized mouse events never carried the held-modifier state.
button()andpost_move()created theirCGEvents but never calledset_flags, so a click/drag while a modifier was held arrived with emptymodifierFlags:The keyboard path (
key()) has alwaysset_flagsfor this reason; the mouse path just never did.Change
button()/post_move()now stamp the current modifier state onto every mouse event via a newmouse_event_flags()helper.--map-ctrl-to-cmd, a plain Ctrl+click is delivered as Cmd+click (open link in a new tab) instead of a secondary/context click — the mouse analogue of the existing keyboard Ctrl→Cmd remap. It reuses a sharedctrl_to_cmd_flags()bit-swap (refactored out ofpost_ctrl_as_cmd), gated identically (Ctrl held, no Cmd/Alt, non-excluded app). Thefrontmost_is_excluded()check sits behind the cheap Ctrl-held guards, so an ordinary unmodified drag (hundreds ofpost_movecalls/s) never pays for it.Testing
cargo build+cargo test input::(9 passed).🤖 Generated with Claude Code