From a730672a4ebfaf6fbdafb83e4e3fecdf340ff184 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 6 Aug 2026 17:39:33 +0200 Subject: [PATCH] Stamp modifiers on injected macOS key events so shifted input works --- client/vnc/server/input_darwin.go | 134 +++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 29 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index ec2569e62..6bc30effd 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -47,12 +47,26 @@ const ( // letter is treated as a menu accelerator. kCGEventFlagMaskSecondaryFn uint64 = 0x00800000 + // The modifier bits macOS reads off an event to decide what a keystroke + // means. A synthesised key event carries its own modifier state: pressing + // the Shift keycode first does not shift the key that follows, which is why + // every posted event has to spell its modifiers out. + kCGEventFlagMaskAlphaShift uint64 = 0x00010000 + kCGEventFlagMaskShift uint64 = 0x00020000 + kCGEventFlagMaskControl uint64 = 0x00040000 + kCGEventFlagMaskAlternate uint64 = 0x00080000 + kCGEventFlagMaskCommand uint64 = 0x00100000 + // kCGMouseEventClickState (event field 1) tells macOS how many // consecutive clicks of this button have happened. Without it, a // double click looks like two independent single clicks and apps // never see the dblclick (window-bar maximize, text word-select, ...). kCGMouseEventClickState int32 = 1 + // kCGKeyboardEventKeycode (event field 9) names the physical key on a + // flagsChanged event, which is how apps tell left Shift from right. + kCGKeyboardEventKeycode int32 = 9 + // doubleClickWindow is the upper bound on the gap between two // down events that still counts as a multi-click. macOS reads the // user's setting from CGEventSourceGetDoubleClickInterval; 500ms is @@ -324,6 +338,9 @@ type MacInputInjector struct { // rather than by event count. axNextTry atomic.Int64 axAsks atomic.Int32 + // modifiers is the CGEventFlags state the remote client has built up with + // its modifier key events, stamped onto everything posted afterwards. + modifiers atomic.Uint64 } // NewMacInputInjector creates a macOS input injector. @@ -527,27 +544,78 @@ func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) // active" for the next key (e.g. the next letter activates a menu // accelerator). func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { + if bit, ok := modifierFlagFor(keycode); ok { + m.postModifier(src, keycode, down, bit) + return + } + fnShifted := isFnShiftedKeycode(keycode) if fnShifted && down { postFnFlagsChanged(src, true) } - event := cgEventCreateKeyboardEvent(src, keycode, down) - if event == 0 { - if fnShifted && !down { - postFnFlagsChanged(src, false) - } - return + flags := m.modifiers.Load() + if fnShifted { + flags |= kCGEventFlagMaskSecondaryFn } - if fnShifted && cgEventSetFlags != nil { - cgEventSetFlags(event, kCGEventFlagMaskSecondaryFn) - } - cgEventPost(kCGHIDEventTap, event) - cfRelease(event) + m.postKeyEvent(src, keycode, down, flags) if fnShifted && !down { postFnFlagsChanged(src, false) } } +// postModifier records the new modifier state and reports it the way hardware +// does, as a flagsChanged event rather than a keystroke. Apps read the modifier +// bits off each event they receive, so the state has to be attached to +// everything posted afterwards, which is what m.modifiers is for. +func (m *MacInputInjector) postModifier(src uintptr, keycode uint16, down bool, bit uint64) { + var flags uint64 + for { + old := m.modifiers.Load() + flags = old | bit + if !down { + flags = old &^ bit + } + if m.modifiers.CompareAndSwap(old, flags) { + break + } + } + + if cgEventCreateForInput == nil || cgEventSetType == nil || cgEventSetFlags == nil { + // No way to synthesise the transition, so fall back to the keystroke. + m.postKeyEvent(src, keycode, down, flags) + return + } + event := cgEventCreateForInput(src) + if event == 0 { + return + } + cgEventSetType(event, kCGEventFlagsChanged) + // The keycode tells apps which physical key moved; several read it to tell + // left from right. + if cgEventSetIntegerValueField != nil { + cgEventSetIntegerValueField(event, kCGKeyboardEventKeycode, int64(keycode)) + } + cgEventSetFlags(event, flags) + cgEventPost(kCGHIDEventTap, event) + cfRelease(event) +} + +// postKeyEvent posts one key event carrying exactly the given modifiers. The +// flags are always set, including when empty: the event source is the combined +// session state, so an event left alone inherits whatever the local user happens +// to be holding down. +func (m *MacInputInjector) postKeyEvent(src uintptr, keycode uint16, down bool, flags uint64) { + event := cgEventCreateKeyboardEvent(src, keycode, down) + if event == 0 { + return + } + if cgEventSetFlags != nil { + cgEventSetFlags(event, flags) + } + cgEventPost(kCGHIDEventTap, event) + cfRelease(event) +} + // postFnFlagsChanged emits a synthetic Fn modifier transition so the // system updates its global modifier state to match the key events we // post for the navigation cluster. Without this, posting a Fn-flagged @@ -774,14 +842,13 @@ func (m *MacInputInjector) TypeText(text string) { break } count++ - typeRune(src, r) + m.typeRune(src, r) } } // typeRune emits the press/release events for a single ASCII rune, framing // the keystroke with Shift-down/up when required by the keysym. -func typeRune(src uintptr, r rune) { - const shiftKey = uint16(0x38) // kVK_Shift +func (m *MacInputInjector) typeRune(src uintptr, r rune) { keysym, shift, ok := keysymForASCIIRune(r) if !ok { return @@ -790,23 +857,15 @@ func typeRune(src uintptr, r rune) { if keycode == 0xFFFF { return } + // The shift travels on the key event itself. Framing the keystroke with + // Shift key events instead leaves the letter unshifted, which is how pasted + // text used to arrive in lower case. + var flags uint64 if shift { - postKey(src, shiftKey, true) + flags = kCGEventFlagMaskShift } - postKey(src, keycode, true) - postKey(src, keycode, false) - if shift { - postKey(src, shiftKey, false) - } -} - -func postKey(src uintptr, keycode uint16, down bool) { - e := cgEventCreateKeyboardEvent(src, keycode, down) - if e == 0 { - return - } - cgEventPost(kCGHIDEventTap, e) - cfRelease(e) + m.postKeyEvent(src, keycode, true, flags) + m.postKeyEvent(src, keycode, false, flags) } // GetClipboard reads the macOS clipboard using pbpaste. @@ -827,6 +886,23 @@ func (m *MacInputInjector) Close() { releasePreventIdleSleep() } +// modifierFlagFor maps a modifier keycode to the CGEventFlags bit it controls. +func modifierFlagFor(keycode uint16) (uint64, bool) { + switch keycode { + case 0x38, 0x3C: // Shift, Right Shift + return kCGEventFlagMaskShift, true + case 0x3B, 0x3E: // Control, Right Control + return kCGEventFlagMaskControl, true + case 0x3A, 0x3D: // Option, Right Option + return kCGEventFlagMaskAlternate, true + case 0x37, 0x36: // Command, Right Command + return kCGEventFlagMaskCommand, true + case 0x39: // Caps Lock + return kCGEventFlagMaskAlphaShift, true + } + return 0, false +} + func keysymToMacKeycode(keysym uint32) uint16 { if keysym >= 0x61 && keysym <= 0x7a { return asciiToMacKey[keysym-0x61]