Skip to content

feat: remap keys on their way to other devices - #483

Open
khac88 wants to merge 5 commits into
feschber:mainfrom
khac88:feat/key-remap
Open

feat: remap keys on their way to other devices#483
khac88 wants to merge 5 commits into
feschber:mainfrom
khac88:feat/key-remap

Conversation

@khac88

@khac88 khac88 commented Aug 10, 2026

Copy link
Copy Markdown

Related to #361.

Adds [input_pre_processing.remap_keys], rewriting individual keys just before
they go on the wire:

[input_pre_processing.remap_keys]
KeyLeftMeta = "KeyLeftCtrl"
KeyLeftCtrl = "KeyLeftMeta"

The motivating case is the one from #361 — modifier layouts differing across
operating systems. Sending Command from a Mac as Control (and Control as Super)
keeps Cmd+C copying on a Windows or Linux peer instead of firing whatever
Super+C happens to be bound to.

Approach

Remapping is deliberately send-side only, applied right before events go on
the wire. Everything local — the release bind, enter binds, the host's own
shortcuts — keeps seeing the physical keys, so the remap cannot lock the user
out of their own machine.

The section name follows the direction set in #347: input_post_processing for
transformations on events received from other devices, input_pre_processing
for events sent to them. If #471 lands first I am happy to rebase onto
whatever shape it settles on.

Two things a naive remap gets wrong

Both are covered by tests:

  • A modifier arrives twice — once as a key event and once as a mask update.
    Rewriting only the key leaves the peer holding one modifier while its
    KeyboardEvent::Modifiers mask claims another. A swap also needs every source
    bit cleared before any target bit is set, or A→B together with B→A drops
    one of the two.
  • release_capture synthesizes key-up events from the physical keys it
    recorded, so those have to go through the same remap — otherwise the peer is
    released from a key it was never pressed with and keeps holding the one it
    actually got.

Testing

  • cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings
    and cargo test --workspace --all-features clean on macOS; cargo check /
    cargo clippy clean for x86_64-pc-windows-msvc.
  • 9 unit tests on the remap itself, including the mask/key consistency above.
  • Verified end-to-end macOS → Windows over Tailscale: with the swap configured,
    Cmd+C and Cmd+X arrive as Ctrl+C / Ctrl+X on the Windows peer.

Not in this PR

Per-client remaps. #361 asks for it per client/server pair; this is global
because that is the smaller change and matches the section being about the
sending side as a whole. Happy to extend it if you would rather have it keyed
per client.

khac88 added 5 commits August 10, 2026 11:30
Adds `[input_pre_processing.remap_keys]`, rewriting individual keys just
before they go on the wire. The motivating case is modifier layouts
differing across operating systems: sending Command from a Mac as
Control (and Control as Super) keeps Cmd+C copying on a Windows peer.

Remapping is deliberately send-side only, so everything local — the
release bind, the host's own shortcuts — keeps seeing the physical keys.

Two things a naive remap gets wrong, both covered by tests:

- a modifier arrives twice, once as a key and once as a mask update, so
  `KeyboardEvent::Modifiers` has to be rewritten alongside the key or
  the peer holds one modifier while its mask claims another. A swap
  needs every source bit cleared before any target bit is set.
- `release_capture` synthesizes key-up events from the physical keys it
  recorded, so those have to go through the same remap or the peer is
  released from a key it was never pressed with.

Related to feschber#361
Adds `[input_pre_processing.invert_scroll_vertical]` and
`invert_scroll_horizontal`, flipping the sign of outgoing scroll axis
events. The motivating case is macOS' "natural scrolling" reaching a
Windows or Linux peer that scrolls the traditional way, making the
wheel feel backwards on the remote machine.

Like `remap_keys`, this happens on the sending side only, so scrolling
on the local machine itself is unaffected.
Crossing an edge now carries the cursor's position along that edge
over to the peer — leaving near the top of the right edge enters the
peer near the top of its left edge, scaled to the peer's own display
size if the two differ. Previously the peer always entered at the
edge's midpoint, unrelated to where the cursor actually crossed.

`ProtoEvent::Enter` gains a normalized (0.0..=1.0) cross-axis position,
computed on the capturing side from the raw crossing point and applied
on the receiving side as an absolute cursor warp before relative
motion resumes. The same position flows the other way through
`ProtoEvent::Leave` for the hand-back case (moving the cursor further
past the edge it just entered at, without a matching `[[clients]]` on
the other side): the side being left reports the spot its own local
crossing landed on, so the cursor reappears there on the peer instead
of snapping back to the original entry point.

macOS implements both directions (reporting on capture, applying on
emulation). Windows implements the emulation (receiving) side only —
its capture side still needs the same per-display-RECT math macOS
already has via a single union bounding box. X11/Wayland backends are
unimplemented captures/emulations already and are unaffected either
way. All of these fall back to the edge's midpoint, matching the
previous behavior exactly, so nothing regresses.
Adds `[[input_pre_processing.remap_chords]]`, layered on top of
`remap_keys`. A plain per-key remap can't tell "Command alone" from
"Command as part of a chord" — it always sends a given key as the same
thing. `remap_chords` covers that: a modifier is sent as something
else specifically when a configured trigger key is pressed while it's
held, without touching what it sends as the rest of the time. The
motivating case is `Cmd+Tab` reaching a Windows peer as `Alt+Tab` (its
app switcher) instead of `Ctrl+Tab`, while `Cmd+C` still becomes
`Ctrl+C` via the existing Command→Control mapping.

A chord-eligible modifier's down event is buffered instead of sent
immediately, since its outcome isn't known until the next event:

- the configured trigger key follows → the buffered down (and the
  paired `Modifiers` mask update, also held back) resolve to the
  override, and stay resolved for the rest of that hold
- anything else follows → resolve as the plain `remap_keys` mapping
  instead, same as if no chord rule existed
- another modifier follows (e.g. Shift, for `Cmd+Shift+Tab` cycling
  backwards) → left alone, so it doesn't cancel a chord in progress
- plain cursor motion never resolves anything, so an incidental mouse
  twitch while holding the modifier can't break a chord either

`KeyRemap::apply` now returns zero, one, or two events per physical
event instead of exactly one, so `release_capture`'s synthesized
key-up-on-disconnect path needed its own chord awareness too: a
still-`pending` modifier never had a down event sent for it, and
`release_key` reports that (`None`) rather than releasing a key the
peer was never told was pressed.
An input method engine composing text (e.g. a Vietnamese IME like
OpenKey doing Telex/VNI diacritics) deletes the real keystroke from
the event stream and re-injects a synthetic correction: a Backspace
plus a "letter" event carrying a placeholder keycode, with the actual
composed character riding along in a Unicode-string field this
capture never reads. Both apps install the same kind of system-wide
CGEventTap (session-level, head-insert), and macOS doesn't give either
one control over which sees a keystroke first. When the IME's tap
processes it first, our tap only ever sees its placeholder-keycode
correction — and forwards that to the peer as if it were what was
typed, producing wrong characters on the remote machine.

Genuine hardware input reports an event source state of
`HIDSystemState`; synthetic events from another process's own
`CGEventSourceCreate` typically don't. Drop keyboard events that don't
carry it instead of translating and forwarding them — consumed
locally same as a real key, just never sent to the peer. This isn't
Vietnamese-specific: it's a general defense against anything that
injects synthetic keystrokes system-wide while capture is active.
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.

1 participant