fix(macos): modifier-key injection for apps & VM guests, scroll, and Caps Lock (#450)#460
Open
jtjones09 wants to merge 4 commits into
Open
fix(macos): modifier-key injection for apps & VM guests, scroll, and Caps Lock (#450)#460jtjones09 wants to merge 4 commits into
jtjones09 wants to merge 4 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On macOS, lan-mouse injects keystrokes via
CGEventposted to the HID tap. Plain typing worked, but modifier chords were broken in several ways (#450):CGEventdefaults to keycode 0 =kVK_ANSI_A), breaking hotkey listeners and shortcut recorders;Mod5was dropped, so Alt/Option chords silently lost Option;VZVirtualMachineView) — e.g.Shift+2produced2,Cmd/Ctrlcombos 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
CGEventbugs from #450 (diagnosed and originally patched by @bmajewski):FlagsChangedevents now carry the modifier's real keycode (was a bareCGEvent→ keycode 0 → phantom "A").Modifiersstate handler only updates internal state now (no duplicate / phantom-"A" post).to_cgevent_flags()maps bothMod1andMod5(AltGr) to Option.Layer 2 — Virtualization.framework guest modifiers:
VZVirtualMachineViewderives guest modifier state from AppKit'sflagsChanged(with:)and reads the device-dependent low-word flag bits, which macOS does not synthesize for posted (synthetic) events. The syntheticFlagsChangednow also sets the IOKitNX_DEVICE*KEYMASKbits +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.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.swipescrolldirectionand 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.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 syntheticCGEventcan'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
VZVirtualMachineViewspecifically, 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:
Shift+2→@,Cmd+C/Cmd+V,CtrlandOptioncombos) in normal macOS apps and inside a Parallels macOS guest ✅References
Co-developed with Claude Opus 4.8 · 🤖 Generated with Claude Code