Skip to content

fix(input): held modifiers on mouse events + Ctrl+click→Cmd+click remap - #183

Merged
clintcan merged 3 commits into
clintcan:mainfrom
antonmos:claude/mouse-modifier-flags
Sep 30, 2026
Merged

clintcan merged 3 commits into
clintcan:mainfrom
antonmos:claude/mouse-modifier-flags

Conversation

@antonmos

@antonmos antonmos commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Synthesized mouse events never carried the held-modifier state. button() and post_move() created their CGEvents but never called set_flags, so a click/drag 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,
  • Ctrl+click wasn't a secondary click.

The keyboard path (key()) has always set_flags for this reason; the mouse path just never did.

Change

  • button() / post_move() now stamp the current modifier state onto every mouse event via a new mouse_event_flags() helper.
  • Under --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 shared ctrl_to_cmd_flags() bit-swap (refactored out of post_ctrl_as_cmd), gated identically (Ctrl held, no Cmd/Alt, non-excluded app). The frontmost_is_excluded() check sits behind the cheap Ctrl-held guards, so an ordinary unmodified drag (hundreds of post_move calls/s) never pays for it.

Testing

  • cargo build + cargo test input:: (9 passed).
  • Live-verified on real Windows App for macOS over RDP: Ctrl+click opens a link in a new tab.
  • Note on that client: it forwards the Command/Windows key as an instantaneous tap, not a held modifier, so a genuine Cmd+click can't be made to work server-side there — the Ctrl+click remap is the reliable gesture. (Documented in the quirk note.)

🤖 Generated with Claude Code

@clintcan

Copy link
Copy Markdown
Owner

Thanks @antonmos — the underlying diagnosis is right and I hadn't realised this
was broken. macOS genuinely does not fold session modifier state into a
synthesized mouse event, so a Cmd+click or Shift+click over RDP has been
arriving bare this whole time. ax_press_spotlight already clears flags
explicitly for exactly this reason, so the codebase half-knew. The
ctrl_to_cmd_flags extraction is clean and I like that the expensive frontmost
check sits behind the cheap Ctrl-held guards.

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 it

Before this PR button() ignored self.mods entirely, so a modifier key-up
that never arrived — the classic case being the client losing focus while Ctrl
is held — was a keyboard-only annoyance. With set_flags on the click
(src/input.rs:1404), l_ctrl stuck at true means macOS turns every left
click into a secondary click.

Three things make the blast radius bigger than it first looks:

  • synchronize() (src/input.rs:779) reconciles caps_lock and nothing else,
    and the MS-RDPBCGR Synchronize PDU only carries lock keys (Scroll/Num/Caps/
    Kana) anyway — so there is no protocol-level path to reconcile a held Ctrl.
  • Nothing resets .mods anywhere in the file.
  • inner is a plain field of MacInputHandler, constructed once at
    src/main.rs:2518. Modifier state is process-lifetime, so this survives
    disconnect and reconnect and clears only when macrdp itself restarts.

To be clear, this PR doesn't create the stuck-modifier bug — it removes the
buffer that was hiding it. But "every click is a right-click until you restart
the server" is a bad failure mode for the one channel the user has, and it's
the same shape as #179/#180, where a change made something latent reachable.

The awkward part is that RdpServerInputHandler only has keyboard/mouse,
so there's no natural per-connection hook to reset on. Options I see, in
increasing order of effort: clear mods the first time a connection delivers
input after an idle gap; add a reset seam driven from the server's
connect/disconnect edge; or treat a mouse event with no recent key traffic as
a resync opportunity. Happy with any of them — mostly I want the decision made
deliberately rather than inherited.

Should fix

1. The remap verdict can flip between down and up (src/input.rs:1404).
mouse_event_flags() is evaluated independently for the down and the up, and
the down itself fires update_focus_from_click (src/input.rs:1413), which
refreshes LAST_FOCUS_BUNDLE off-thread. Ctrl+clicking into an excluded app
can therefore post a Cmd-flagged down and a Ctrl-flagged up — a mismatched pair,
and the "excluded apps get a real Ctrl+click" guarantee breaks on the event that
actually drives the context menu. Latching the decision at button-down (a
remapped_buttons set mirroring remapped_keys) and reusing it for the up
fixes this, and also kills the per-move cost below.

2. The swap hits right- and middle-click too (src/input.rs:1404).
set_flags(self.mouse_event_flags()) is unconditional, regardless of which
CGMouseButton is in play, so under --map-ctrl-to-cmd a Ctrl+right-click
becomes Cmd+right-click. The docs in this PR only promise Ctrl+click → Cmd+click
for the primary button. Gating the swap on CGMouseButton::Left would match the
stated intent.

3. scroll() wasn't updated (src/input.rs:1417), but the docs say it was.
docs/features.md now reads "held modifiers now also ride every synthesized
mouse event", and new_scroll_event is posted with no set_flags — so
Shift+scroll (horizontal in most apps) and Cmd+scroll (zoom) still arrive bare.
Either extend the same call to the scroll path or narrow the wording.

Minor

Holding Ctrl while moving makes every motion PDU pay for
frontmost_is_excluded() — two mutex locks, a format!("{entry}.") allocation
per candidate bundle, a debug!, and a synchronous AXUIElementCopy...
round-trip on the input thread whenever LAST_FOCUS_BUNDLE is still None
(i.e. before the first click of a session). The comment at src/input.rs:1049 is
accurate that an unmodified drag never pays, but a Ctrl-held drag does.
Latching per button-down (Should-fix 1) removes this too.

Also: no tests. I realise mouse_event_flags() leans on AX and process globals
so it isn't trivially pure, but if the gating predicate were split out from the
flag construction, the decision table (remap on/off × ctrl × cmd × alt ×
excluded) would unit-test the way map_client_to_display does.

CI is green on all three jobs and the refactor itself is good — this is
really about the modifier-reset question.

antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 13, 2026
… 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>
@antonmos

Copy link
Copy Markdown
Contributor Author

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 resync

I went with two of your three options rather than one, because they cover different failure shapes:

  • Connection edge (deterministic). capture.rs's per-connection ScreenCaptureUpdates::start now calls input::request_modifier_reset() — the same seam that already resets display_suppressed per connection. So a reconnect is a clean slate; the "survives disconnect and reconnect" property is gone.
  • Idle gap (the case that has no disconnect at all). The classic failure — client loses focus mid-session, Ctrl is released where we can't see it — never involves a reconnect, so the edge alone doesn't fix it. resync_modifiers_if_stale (top of keyboard()/mouse()) clears held non-lock modifiers after MACRDP_MODS_RESYNC_IDLE_MS (default 10 s, 0 disables the idle trigger).

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 FlagsChanged with the emptied set (macOS derives release from the flags diff) and drops any outstanding remapped_keys/click latch. Caps Lock is deliberately preserved — it's a toggle synchronize() owns, and clearing it here would fight that path; there's a test pinning that.

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

  1. Latched at button-down. New left_click_remapped (the mouse analogue of remapped_keys). The verdict is decided once on left-down and reused for the drag and the up, so the pair can never disagree — and frontmost_is_excluded() is off the drag path entirely, which also resolves the minor per-motion-cost point.
  2. Primary button only. Right/middle carry the real held modifiers; a Ctrl+right-click no longer becomes Cmd+right-click. Docs narrowed to match.
  3. scroll() now carries modifiers (Shift+scroll, Cmd+scroll), so the "every synthesized mouse event" wording is now true rather than narrowed. Deliberately no Ctrl→Cmd swap on scroll — Ctrl+scroll is macOS's own screen-zoom accessibility gesture and silently rewriting it would hijack a system binding.

Tests

Split the gate into the pure should_remap_click(remap_on, ctrl, cmd, alt, excluded); click_remap_active gathers the impure inputs and runs the cheap flags through the table before paying for the frontmost lookup (kept as a two-step call so the expensive argument isn't evaluated eagerly). Added:

  • mouse_remap_decision_table — the full 32-row table, asserting exactly one row remaps;
  • mouse_remap_guards_each_hold_independently — one named assertion per guard;
  • clear_non_lock_drops_held_keys_but_preserves_caps_lock.

Also fixed a doc comment my earlier refactor had glued onto the wrong fn. cargo clippy --all-targets -- -D warnings, cargo fmt --check, and the full suite (208 passed) are clean.

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.

@clintcan

Copy link
Copy Markdown
Owner

Thanks @antonmos — this is a really good response. Two triggers is the right
call, the cost-asymmetry argument for the generous idle threshold is exactly
right, and splitting out should_remap_click with the full decision table is
what I was after — the guard-independence test is a nice addition. Appreciated
the honest caveat about the live repro, too.

The blocker is resolved. I went through c8712e9 and found two small things in
the new code, plus one pre-existing hazard that your new reset turns out to be
the natural fix for. The fixes for #2 and #3 both go in
resync_modifiers_if_stale and #1 is about where the reset is requested from,
so they fit in one follow-up.

1. The "connection edge" reset also fires on every reactivation

request_modifier_reset() is called from ScreenCaptureUpdates::start
(src/capture.rs:1221), and the comment at src/capture.rs:629 says
RdpServerDisplay::updates "runs at connect AND after every
deactivation-reactivation". The chain is updates() → build_updates() (806) →
ScreenCaptureUpdates::start (813) → the reset. So a held modifier is also
cleared on every live resize, and on blank recovery, which fires on its own
with no user action.

The cost is one re-press, so it's small today — but it means "the only
false-positive shape is a genuine 10-second hold" isn't quite true, and it
matters more once #3 lands, since a reactivation in the middle of a drag would
then break the drag.

One thing to avoid: on_accept looks like the obvious per-connection hook, but
it runs on preemption candidates too, while the live connection is still
being served (vendor/ironrdp-server/src/server.rs:516) — so moving the reset
there would let a mere connection attempt clear the live session's modifiers.
The seam you want is once per served connection: the top of run_connection
(server.rs:1567) and serve_negotiated (1547). Each calls accept_finalize
once and reactivations loop inside it, so a signal there never fires for a
reactivation or a losing candidate — and it'd follow the existing set_*_handle
pattern on the server.

2. On reconnect, the previous connection's latch survives

resync_modifiers_if_stale returns early at src/input.rs:955 when
clear_non_lock() reports nothing was held — before remapped_keys.clear()
and left_click_remapped = false at 970–971. So if a connection ends with the
modifier already released but a remapped key-up or the left-up still
outstanding, that state carries straight into the next connection.

3. Pre-existing: a disconnect mid-drag leaves a phantom drag

Not caused by this PR — main has the same button tracking — but your reset is
the right place to fix it. left_down and right_down are only ever written by
a real button event (src/input.rs:1550–1551), they live in the same
process-lifetime handler as mods, and post_move chooses LeftMouseDragged
from left_down (1508).

So if a connection drops between a left-down and its up, every mouse move on the
next connection is posted as LeftMouseDragged until the user next clicks —
plain pointer movement arrives at macOS as a drag. Blank recovery's fallback drop followed by the ARC
auto-reconnect is a plausible way to hit it, and with #2 that phantom drag would
also carry Cmd.

Putting #2 and #3 together

I'd split the reset by trigger:

  • Connection edge: clear everything per-connection, unconditionally —
    modifiers, the latch, remapped_keys, and button-down state. Nothing from the
    previous connection is live, so none of it is worth preserving.
  • Idle gap: leave it as it is. A click or drag in progress inside a live
    connection is legitimate state. A drag held still sends no events at all, so
    clearing button state here would drop it; and clearing the latch here would
    undo your own down/up fix — Ctrl+down, release Ctrl, hold still past the
    threshold, release, and the up posts plain after a Cmd-flagged down.

Either way, only post the FlagsChanged when a modifier was actually held.

Once these are in I think this is good to go.

clintcan added a commit that referenced this pull request Sep 15, 2026
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.
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 16, 2026
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>
@antonmos

Copy link
Copy Markdown
Contributor Author

All three in 01e86a8 — and thanks for the pointer to run_connection/serve_negotiated; the reactivation loop living inside accept_finalize was the fact I'd missed.

1. Reset moved to a once-per-served-connection seam

New vendored divergence (24): RdpServer::set_input_reset_handle(Arc<AtomicBool>), raised to true at the top of both run_connection and serve_negotiated, placed right next to divergence 13's auto_reconnect_sent = false — same lifecycle, so it fires exactly once per connection that's actually served and never for a reactivation. capture.rs no longer touches it; main.rs installs the handle via input::modifier_reset_handle(). Deliberately not on_accept, per your warning — it runs for preemption candidates while the live session is still served. Both rejected seams are written up in the divergence entry and the quirk note so nobody re-tries them. Off by default (None), so the upstream-shaped path is byte-identical unless macrdp installs the handle.

2. Latch surviving a reconnect

Right — the early return on clear_non_lock() == false skipped the latch/remapped_keys clears. Restructured: the connection-edge clears are unconditional now, and the FlagsChanged is the only thing gated on "a modifier was actually held".

3. Phantom drag

Took this one step further than clearing the tracking: on the connection edge, any button still down is released — a synthetic Up posted through button() — before the modifiers are cleared. Reasoning: we posted the Down and the connection died before the Up, so macOS itself still believes the button is held; merely zeroing left_down would stop our phantom LeftMouseDragged but leave the OS in a drag, and the user's next real click would post a second Down with no Up in between. Going through button() means the Up carries the same latched flags the Down did (it consumes the latch via mem::take) and the click-count bookkeeping stays paired. Ordered before the modifier clear so the pair can't disagree.

Trigger split

Exactly as you laid out: connection edge clears everything (modifiers, latch, remapped_keys, and releases held buttons); 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 fix. Both cases are spelled out in the fn doc.

clippy --all-targets -- -D warnings, fmt --check, full suite (208 passed) clean. Same live caveat as before: the connection-edge path is verified by construction, not yet by a mid-drag disconnect against a real client.

@clintcan

Copy link
Copy Markdown
Owner

Thanks @antonmos — I went through 01e86a8 against the current head and all three hold up. Raising the flag right after auto_reconnect_sent = false and before accept_finalize in both run_connection and serve_negotiated is exactly the right seam, and the unconditional connection-edge clears with only the FlagsChanged gated read well.

Two things, neither a blocker:

1. Where the synthetic button-up lands. resync_modifiers_if_stale runs at the top of both mouse() and keyboard(), before the incoming event updates the cursor, and button() posts at self.last_x/self.last_y. So the release goes out at the pre-disconnect cursor position, on the new connection's first input — a keystroke counts. If the link dropped mid-way through dragging a file in Finder, that release completes the drag as a drop at the old position: into whatever folder was under the cursor, or onto the Trash in the Dock.

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. 01e86a8 doesn't add any, so the connection-edge path is covered only by construction. The decision part — which buttons get released, the unconditional latch and remapped_keys clear on reconnect, and posting FlagsChanged only when a modifier was held — could be split out pure the way you did should_remap_click, and table-tested.

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.

clintcan added a commit that referenced this pull request Sep 17, 2026
…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.
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 17, 2026
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>
clintcan added a commit that referenced this pull request Sep 18, 2026
…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.
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
… 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>
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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>
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
… 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>
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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>
@antonmos
antonmos force-pushed the claude/mouse-modifier-flags branch from 334cc3c to 8eff453 Compare September 30, 2026 02:46
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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>
antonmos pushed a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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.
antonmos pushed a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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.
antonmos pushed a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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.
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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>
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
… 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>
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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>
antonmos and others added 3 commits September 29, 2026 23:56
…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>
@antonmos
antonmos force-pushed the claude/mouse-modifier-flags branch from 012ab86 to 923b123 Compare September 30, 2026 04:56
antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
…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 clintcan left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@clintcan
clintcan merged commit 58db18c into clintcan:main Sep 30, 2026
3 checks passed
clintcan added a commit that referenced this pull request Sep 30, 2026
)

The connection-edge button release from #183 is posted at the
pre-disconnect cursor position on the next connection's first input, so a
drag cut off by a disconnect finishes as a drop there. Documented as
agreed in the #183 review; not yet live-verified with a real drag.
@clintcan

Copy link
Copy Markdown
Owner

Merged — thanks @antonmos. I added the interrupted-drag caveat to the quirk note on main (b43a047).

One follow-up when you have time: a small test for the reconnect reset — which buttons get released, the latch and remapped_keys cleared on reconnect, and FlagsChanged posted only when a modifier was held — split out pure the way should_remap_click is. And if you get to the live mid-drag disconnect with a real file over a folder, I'd like to note what macOS actually does.

antonmos added a commit to antonmos/macrdp that referenced this pull request Sep 30, 2026
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>
clintcan pushed a commit that referenced this pull request Oct 1, 2026
#184)

Adds the comma key (0x2B) to the --map-ctrl-to-cmd remap set so Ctrl+, opens an app's Preferences (Cmd+,), with the remap test and doc lines updated. Squash-merged: the branch also carried copies of #183's commits, already on main.
antonmos added a commit to antonmos/macrdp that referenced this pull request Oct 1, 2026
…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 added a commit that referenced this pull request Oct 1, 2026
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.
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.

2 participants