Skip to content

fix(macos): modifier-key injection for apps & VM guests, scroll, and Caps Lock (#450)#460

Open
jtjones09 wants to merge 4 commits into
feschber:mainfrom
jtjones09:fix/macos-vz-modifier-injection
Open

fix(macos): modifier-key injection for apps & VM guests, scroll, and Caps Lock (#450)#460
jtjones09 wants to merge 4 commits into
feschber:mainfrom
jtjones09:fix/macos-vz-modifier-injection

Conversation

@jtjones09

@jtjones09 jtjones09 commented Jun 16, 2026

Copy link
Copy Markdown

Problem

On macOS, lan-mouse injects keystrokes via CGEvent posted to the HID tap. Plain typing worked, but modifier chords were broken in several ways (#450):

  • holding a modifier injected a phantom "A" (a bare CGEvent defaults to keycode 0 = kVK_ANSI_A), breaking hotkey listeners and shortcut recorders;
  • modifiers were torn down by the single-slot key-repeat task (sticky / dropped modifiers — also [Linux client + MacOS server] Sticky modifier keys #357);
  • AltGr / Mod5 was dropped, so Alt/Option chords silently lost Option;
  • and even with correct flags, modifier chords did not register inside Apple Virtualization.framework guest windows (VZVirtualMachineView) — e.g. Shift+2 produced 2, Cmd/Ctrl combos did nothing.

Scroll direction was also wrong on the receiving Mac — it ignored the Mac's own Natural scrolling setting.

Fix

Modifiers (commit 1)

Layer 1 — the four CGEvent bugs from #450 (diagnosed and originally patched by @bmajewski):

  1. FlagsChanged events now carry the modifier's real keycode (was a bare CGEvent → keycode 0 → phantom "A").
  2. Modifier keys no longer enter the key-repeat machinery, so a second modifier no longer releases the first while it's still held.
  3. The wholesale Modifiers state handler only updates internal state now (no duplicate / phantom-"A" post).
  4. to_cgevent_flags() maps both Mod1 and Mod5 (AltGr) to Option.

Layer 2 — Virtualization.framework guest modifiers:
VZVirtualMachineView derives guest modifier state from AppKit's flagsChanged(with:) and reads the device-dependent low-word flag bits, which macOS does not synthesize for posted (synthetic) events. The synthetic FlagsChanged now also sets the IOKit NX_DEVICE*KEYMASK bits + CGEventFlagNonCoalesced, making it byte-faithful to a real hardware modifier event. Ordinary apps mask incoming events to the device-independent word and ignore these bits, so this is safe for every app and needs no VM/bundle-id detection.

Note: the forums-documented NSEvent / NSWindow.postEvent approach is in-process only and can't work from a separate process like lan-mouse. Setting the device-dependent bits on the CGEvent is the cross-process equivalent. See https://developer.apple.com/forums/thread/766014

Scroll (commit 2 — separable)

macOS applies Natural scrolling to real input devices but not to synthetic events. The receiver now reads its own com.apple.swipescrolldirection and flips the scroll sign accordingly — honouring the receiver's preference (Synergy-style) rather than the sender's convention. Kept as its own commit so it can be dropped independently.

Known limitation: this fixes the common cross-OS case (a non-macOS sender → macOS receiver, where the wire delta is in a fixed convention). When both peers are macOS with natural scrolling enabled, the macOS sender already forwards a natural-oriented delta, so the receiver-side flip double-inverts; fixing that cleanly needs scroll normalization on the macOS capture side, which is out of scope for this PR. The same double-inversion shows up when scrolling into a nested macOS VM guest (e.g. a Parallels macOS guest), since the guest OS applies its own natural-scrolling on top of the receiver-side flip — workaround: disable Natural Scrolling in the guest.

Caps Lock (commit 3)

Rerouting modifiers out of the key-repeat path (above) made Caps Lock momentary, because it's classified as a modifier. Caps Lock is a locking modifier, so update_modifiers() now toggles it on key-down and ignores key-up — it latches correctly. (A synthetic CGEvent can't flip the hardware Caps Lock LED, but the typed output is correct, and number-row symbols stay unshifted like real Caps Lock.)

Hypervisor coverage

Layer 2 targets VZVirtualMachineView specifically, so it covers every Apple Virtualization.framework guest: UTM's Apple-Virtualization backend, Parallels Desktop macOS guests, VirtualBuddy, and Apple's own Virtualization samples. Hypervisors with their own input layers (UTM-QEMU, VMware Fusion, VirtualBox) use different code paths and aren't addressed here — though Layer 1 still improves normal modifier handling everywhere.

Testing

Tested end-to-end on a Windows 11 sender → macOS client:

  • modifier chords (Shift+2@, Cmd+C/Cmd+V, Ctrl and Option combos) in normal macOS apps and inside a Parallels macOS guest
  • scroll direction correct on the Mac, and it tracks the macOS Natural scrolling toggle ✅
  • Caps Lock latches (tap → capitals, tap again → lowercase) ✅ — the hardware LED doesn't light (synthetic-event limitation), but typed output is correct

References


Co-developed with Claude Opus 4.8 · 🤖 Generated with Claude Code

jtjones09 and others added 4 commits June 16, 2026 15:08
…ework guests (feschber#450)

Modifier-key injection on macOS was broken in several ways. This fixes the
CGEvent bugs diagnosed in feschber#450 and additionally makes modifier chords register
inside Apple Virtualization.framework guest windows.

Layer 1 - CGEvent bugs (originally diagnosed and patched by bmajewski, feschber#450):
- modifier_event() built a bare CGEvent, which defaults to keycode 0
  (kVK_ANSI_A), so every modifier change arrived in apps as a phantom "A" key
  (holding Ctrl registered as Ctrl+A; shortcut recorders captured "A"). It now
  posts a FlagsChanged event carrying the modifier's real keycode
  (modifier_key_event).
- Modifier keys went through the single-slot key-repeat task; pressing a second
  modifier cancelled the first's repeat task and released it while still held,
  tearing chords apart (also the likely cause of feschber#357). Modifiers no longer
  enter the repeat machinery.
- The wholesale Modifiers state handler also posted a (bare) FlagsChanged,
  duplicating the per-key event / injecting the phantom "A"; it now only updates
  internal modifier state.
- to_cgevent_flags() silently dropped Mod5 (ISO_Level3_Shift / AltGr), so Alt
  reported as Mod5 (common on xkb keymaps) never set Option. Mod1 and Mod5 now
  both map to Option.

Layer 2 - Virtualization.framework guest modifiers:
VZVirtualMachineView (Apple Virtualization.framework, used by UTM's Apple
backend, Parallels' macOS guests, VirtualBuddy, etc.) derives the guest's
modifier state from AppKit's flagsChanged(with:) and reads the device-dependent
low-word flag bits, which macOS does not synthesize for posted events - so
Cmd/Ctrl/Shift/Opt chords reached the guest unmodified. modifier_flags_changed_flags()
now also sets the IOKit NX_DEVICE*KEYMASK bits and CGEventFlagNonCoalesced so the
synthetic FlagsChanged is byte-faithful to real hardware. Ordinary apps mask
incoming events to the device-independent word and ignore these bits, so this is
safe for every app and needs no VM/bundle-id detection.

See https://developer.apple.com/forums/thread/766014

Co-authored-by: bmajewski <bmajewski@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS applies the "natural scrolling" preference to real input devices but not
to synthetic CGEvents, so injected scrolling ignored the Mac's own setting and
felt inverted. natural_scroll_enabled() reads com.apple.swipescrolldirection and
the Axis / AxisDiscrete120 handlers flip the sign accordingly, so injected
scrolling matches a physical device on this Mac - honouring the receiver's
preference rather than the sender's scroll convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routing modifier keys out of the key-repeat path (feschber#450) also rerouted Caps
Lock, which is classified as a modifier (LockMask). That made it momentary - it
only applied while the key was physically held instead of latching. Caps Lock
is a LOCKING modifier (XKB "locked"), so a press should toggle a persistent
state.

update_modifiers() now toggles LockMask on Caps Lock key-down and ignores
key-up, so the AlphaShift flag stays set on every subsequent keystroke until
Caps Lock is pressed again. A synthetic CGEvent can't flip the hardware Caps
Lock LED, but carrying the flag produces the correct upper-case output (and,
like real Caps Lock, leaves number-row symbols unshifted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups; no behavior change for normal preferences:
- Replace the hand-rolled CoreFoundation extern block in
  natural_scroll_enabled() with the crate-provided
  CFPreferencesGetAppBooleanValue (core-foundation-sys, already a dependency).
  Drops the duplicated unsafe FFI surface, the manual CFRelease, and the
  kCFBooleanTrue pointer comparison, and treats a non-boolean preference value
  as "use the default" (the old pointer compare silently read it as false).
- Extract apply_natural_scroll(), shared by both scroll arms, negating with
  saturating_neg() so a malformed i32::MIN value from the wire cannot overflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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