diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 1b830035d..595219745 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -352,6 +352,30 @@ func (m *MacInputInjector) InjectKey(keysym uint32, down bool) { if keycode == 0xFFFF { return } + m.postMacKey(src, keycode, down) +} + +// InjectKeyScancode injects using the QEMU scancode, mapped via the +// qemuToMacVK table to Apple's virtual-keycode space. Apple uses an +// entirely different scheme from PC AT scancodes, so the table is the +// authoritative bridge. On miss we fall back to the keysym path. +func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + wakeDisplay() + src := ensureEventSource() + if src == 0 { + return + } + vk, ok := qemuToMacVK[scancode] + if !ok { + // Fall back to the keysym path so unmapped keys still work. + m.InjectKey(keysym, down) + return + } + m.postMacKey(src, vk, down) +} + +// postMacKey emits a single key down/up event via Core Graphics. +func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { event := cgEventCreateKeyboardEvent(src, keycode, down) if event == 0 { return diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go index 03c1b8997..e9a89f3dc 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_unix.go @@ -199,6 +199,27 @@ func (u *UInputInjector) InjectKey(keysym uint32, down bool) { if !ok { return } + u.emitKeyCode(code, down) +} + +// InjectKeyScancode injects a press or release using the QEMU scancode. +// uinput speaks Linux KEY_* codes natively, so we map QEMU scancode → +// KEY_* via qemuToLinuxKey. On miss (scancode we don't have a mapping +// for) we fall back to the keysym path, which is exactly the legacy +// behaviour. +func (u *UInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + code := qemuScancodeToLinuxKey(scancode) + if code == 0 { + u.InjectKey(keysym, down) + return + } + u.mu.Lock() + defer u.mu.Unlock() + u.emitKeyCode(uint16(code), down) +} + +// emitKeyCode emits one key down/up event plus a sync. Caller holds u.mu. +func (u *UInputInjector) emitKeyCode(code uint16, down bool) { value := int32(0) if down { value = 1 @@ -297,45 +318,8 @@ func (u *UInputInjector) Close() { }) } -// Linux KEY_* codes for the small set we care about. -const ( - keyEsc = 1 - keyMinus = 12 - keyEqual = 13 - keyBackspace = 14 - keyTab = 15 - keyEnter = 28 - keyLeftCtrl = 29 - keySemicolon = 39 - keyApostrophe = 40 - keyGrave = 41 - keyLeftShift = 42 - keyBackslash = 43 - keyComma = 51 - keyDot = 52 - keySlash = 53 - keyRightShift = 54 - keyLeftAlt = 56 - keySpace = 57 - keyCapsLock = 58 - keyF1 = 59 - keyLeftBracket = 26 - keyRightBracket = 27 - keyHome = 102 - keyUp = 103 - keyPageUp = 104 - keyLeft = 105 - keyRight = 106 - keyEnd = 107 - keyDown = 108 - keyPageDown = 109 - keyInsert = 110 - keyDelete = 111 - keyRightCtrl = 97 - keyRightAlt = 100 - keyLeftMeta = 125 - keyRightMeta = 126 -) +// Linux KEY_* codes live in scancodes.go (shared with the QEMU scancode +// path). Don't duplicate them here. // buildUInputKeymap returns every linux KEY_ code we want the virtual // device to advertise during UI_SET_KEYBIT. Order doesn't matter. diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index 0dea1f343..9f6af4089 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -106,9 +106,11 @@ const sasEventName = `Global\NetBirdVNC_SAS` type inputCmd struct { isKey bool + isScancode bool isClipboard bool isType bool keysym uint32 + scancode uint32 down bool buttonMask uint8 x, y int @@ -187,6 +189,8 @@ func (w *WindowsInputInjector) dispatch(cmd inputCmd) { w.doSetClipboard(cmd.clipText) case cmd.isType: w.typeUnicodeText(cmd.clipText) + case cmd.isScancode: + w.doInjectKeyScancode(cmd.scancode, cmd.keysym, cmd.down) case cmd.isKey: w.doInjectKey(cmd.keysym, cmd.down) default: @@ -199,6 +203,19 @@ func (w *WindowsInputInjector) InjectKey(keysym uint32, down bool) { w.tryEnqueue(inputCmd{isKey: true, keysym: keysym, down: down}) } +// InjectKeyScancode queues a raw-scancode key event. PC AT Set 1 maps +// directly onto what SendInput's KEYEVENTF_SCANCODE flag wants, so the +// only translation is splitting the optional 0xE0 prefix off into the +// KEYEVENTF_EXTENDEDKEY flag. keysym is the noVNC-provided fallback we +// reach for if the scancode is zero. +func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, down bool) { + if scancode == 0 { + w.InjectKey(keysym, down) + return + } + w.tryEnqueue(inputCmd{isScancode: true, scancode: scancode, keysym: keysym, down: down}) +} + // InjectPointer queues a pointer event for injection on the input desktop // thread. Pointer events coalesce: when the channel is full (slow desktop // switch, hung SendInput), drop the new sample so the read loop never @@ -207,6 +224,32 @@ func (w *WindowsInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, se w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}) } +// doInjectKeyScancode injects a key event using the QEMU scancode directly, +// bypassing the keysym→VK lookup. Windows accepts PC AT Set 1 scancodes +// natively via KEYEVENTF_SCANCODE, so the only work is splitting the +// optional 0xE0 prefix off into the EXTENDEDKEY flag and tracking +// modifier state for the SAS Ctrl+Alt+Del shortcut. +func (w *WindowsInputInjector) doInjectKeyScancode(scancode, keysym uint32, down bool) { + switch keysym { + case 0xffe3, 0xffe4: + w.ctrlDown = down + case 0xffe9, 0xffea: + w.altDown = down + } + if (keysym == 0xff9f || keysym == 0xffff) && w.ctrlDown && w.altDown && down { + signalSAS() + return + } + flags := uint32(keyeventfScanCode) + if !down { + flags |= keyeventfKeyUp + } + if qemuScancodeIsExtended(scancode) { + flags |= keyeventfExtendedKey + } + sendKeyInput(0, qemuScancodeLowByte(scancode), flags) +} + func (w *WindowsInputInjector) doInjectKey(keysym uint32, down bool) { switch keysym { case 0xffe3, 0xffe4: diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index 6696552ee..ada791ce8 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -74,14 +74,38 @@ func (x *X11InputInjector) InjectKey(keysym uint32, down bool) { if keycode == 0 { return } + x.fakeKeyEvent(keycode, down) +} +// InjectKeyScancode injects using the QEMU scancode by translating to a +// Linux KEY_ code and then to an X11 keycode (KEY_* + xkbKeycodeOffset). +// On a server running a standard XKB keymap this is layout-independent: +// the scancode names the physical key, the server's layout determines the +// resulting character. Falls back to the keysym path when the scancode +// has no Linux mapping. +func (x *X11InputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + linuxKey := qemuScancodeToLinuxKey(scancode) + if linuxKey == 0 { + x.InjectKey(keysym, down) + return + } + x.fakeKeyEvent(byte(linuxKey+xkbKeycodeOffset), down) +} + +// xkbKeycodeOffset is the per-server constant offset between Linux KEY_* +// event codes and the X server's keycode space under XKB. The X protocol +// reserves keycodes 0..7 for internal use, so any normal XKB keymap +// starts at 8 (KEY_ESC=1 → X keycode 9, KEY_A=30 → X keycode 38, etc.). +const xkbKeycodeOffset = 8 + +// fakeKeyEvent sends an XTest FakeInput for a press or release. +func (x *X11InputInjector) fakeKeyEvent(keycode byte, down bool) { var eventType byte if down { eventType = xproto.KeyPress } else { eventType = xproto.KeyRelease } - xtest.FakeInput(x.conn, eventType, keycode, 0, x.root, 0, 0, 0) } diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index e5eafada2..136b5599b 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -29,6 +29,14 @@ const ( clientKeyEvent = 4 clientPointerEvent = 5 clientCutText = 6 + // clientQEMUMessage is the QEMU vendor message wrapper. The subtype + // byte that follows selects the actual operation; we only handle the + // Extended Key Event (subtype 0) which carries a hardware scancode in + // addition to the X11 keysym. Layout-independent key entry. + clientQEMUMessage = 255 + + // QEMU Extended Key Event subtype carried inside clientQEMUMessage. + qemuSubtypeExtendedKeyEvent = 0 // clientNetbirdTypeText is a NetBird-specific message that asks the // server to synthesize the given text as keystrokes regardless of the @@ -53,10 +61,11 @@ const ( // Pseudo-encodings carried over wire as rects with a negative // encoding value. The client advertises supported optional protocol // extensions by listing these in SetEncodings. - pseudoEncDesktopSize = -223 - pseudoEncLastRect = -224 - pseudoEncDesktopName = -307 - pseudoEncExtendedDesktopSize = -308 + pseudoEncDesktopSize = -223 + pseudoEncLastRect = -224 + pseudoEncQEMUExtendedKeyEvent = -258 + pseudoEncDesktopName = -307 + pseudoEncExtendedDesktopSize = -308 // Tight compression-control byte top nibble. Stream-reset bits 0-3 // (one per zlib stream) are unused while we run a single stream. diff --git a/client/vnc/server/scancodes.go b/client/vnc/server/scancodes.go new file mode 100644 index 000000000..2016bbc45 --- /dev/null +++ b/client/vnc/server/scancodes.go @@ -0,0 +1,272 @@ +package server + +// QEMU Extended Key Event carries hardware scancodes encoded as PC AT Set 1. +// Single-byte codes cover the standard keys; the "extended" prefix 0xE0 is +// merged into the high byte (so 0xE048 is the extended-Up arrow). This file +// translates those scancodes into the per-platform identifiers each input +// backend wants: +// +// - Linux uinput wants Linux KEY_* codes (defined in +// linux/input-event-codes.h). uinput is what we use for virtual Xvfb +// sessions on Linux. +// - X11 XTest wants XKB keycodes, which on a standard layout equal +// Linux KEY_* + 8 (the per-server offset between the Linux event code +// and the X server's keycode space). +// - Windows SendInput accepts the PC AT scancode directly via +// KEYEVENTF_SCANCODE, so no mapping table is needed there; the +// extended-key bit is set when the QEMU scancode high byte is 0xE0. +// - macOS CGEventCreateKeyboardEvent takes a "virtual keycode" from +// Apple's HID set, which is unrelated to PC AT and needs its own +// table (see qemuToMacVK in input_darwin.go). +// +// Linux KEY_* codes. Only the ones we reference, since the full +// linux/input-event-codes.h list isn't useful here. Naming mirrors the +// existing constants in input_uinput_unix.go (mixed case, no underscores). +const ( + keyEsc = 1 + key1 = 2 + key2 = 3 + key3 = 4 + key4 = 5 + key5 = 6 + key6 = 7 + key7 = 8 + key8 = 9 + key9 = 10 + key0 = 11 + keyMinus = 12 + keyEqual = 13 + keyBackspace = 14 + keyTab = 15 + keyQ = 16 + keyW = 17 + keyE = 18 + keyR = 19 + keyT = 20 + keyY = 21 + keyU = 22 + keyI = 23 + keyO = 24 + keyP = 25 + keyLeftBracket = 26 + keyRightBracket = 27 + keyEnter = 28 + keyLeftCtrl = 29 + keyA = 30 + keyS = 31 + keyD = 32 + keyF = 33 + keyG = 34 + keyH = 35 + keyJ = 36 + keyK = 37 + keyL = 38 + keySemicolon = 39 + keyApostrophe = 40 + keyGrave = 41 + keyLeftShift = 42 + keyBackslash = 43 + keyZ = 44 + keyX = 45 + keyC = 46 + keyV = 47 + keyB = 48 + keyN = 49 + keyM = 50 + keyComma = 51 + keyDot = 52 + keySlash = 53 + keyRightShift = 54 + keyKPAsterisk = 55 + keyLeftAlt = 56 + keySpace = 57 + keyCapsLock = 58 + keyF1 = 59 + keyF2 = 60 + keyF3 = 61 + keyF4 = 62 + keyF5 = 63 + keyF6 = 64 + keyF7 = 65 + keyF8 = 66 + keyF9 = 67 + keyF10 = 68 + keyNumLock = 69 + keyScrollLock = 70 + keyKP7 = 71 + keyKP8 = 72 + keyKP9 = 73 + keyKPMinus = 74 + keyKP4 = 75 + keyKP5 = 76 + keyKP6 = 77 + keyKPPlus = 78 + keyKP1 = 79 + keyKP2 = 80 + keyKP3 = 81 + keyKP0 = 82 + keyKPDot = 83 + key102nd = 86 + keyF11 = 87 + keyF12 = 88 + keyKPEnter = 96 + keyRightCtrl = 97 + keyKPSlash = 98 + keySysRq = 99 + keyRightAlt = 100 + keyHome = 102 + keyUp = 103 + keyPageUp = 104 + keyLeft = 105 + keyRight = 106 + keyEnd = 107 + keyDown = 108 + keyPageDown = 109 + keyInsert = 110 + keyDelete = 111 + keyMute = 113 + keyVolumeDown = 114 + keyVolumeUp = 115 + keyLeftMeta = 125 + keyRightMeta = 126 + keyCompose = 127 +) + +// qemuToLinuxKey maps the PC AT Set 1 scancode QEMU sends to a Linux KEY_* +// code. The high byte 0xE0 marks "extended" scancodes (arrows, the right- +// side modifier keys, keypad enter/divide, browser keys, etc.). +// +// Keep this table dense so a reviewer sees the whole keyboard at a glance, +// and so adding a new key is a single line. +var qemuToLinuxKey = map[uint32]int{ + // Single-byte (non-extended) scancodes. + 0x01: keyEsc, + 0x02: key1, + 0x03: key2, + 0x04: key3, + 0x05: key4, + 0x06: key5, + 0x07: key6, + 0x08: key7, + 0x09: key8, + 0x0A: key9, + 0x0B: key0, + 0x0C: keyMinus, + 0x0D: keyEqual, + 0x0E: keyBackspace, + 0x0F: keyTab, + 0x10: keyQ, + 0x11: keyW, + 0x12: keyE, + 0x13: keyR, + 0x14: keyT, + 0x15: keyY, + 0x16: keyU, + 0x17: keyI, + 0x18: keyO, + 0x19: keyP, + 0x1A: keyLeftBracket, + 0x1B: keyRightBracket, + 0x1C: keyEnter, + 0x1D: keyLeftCtrl, + 0x1E: keyA, + 0x1F: keyS, + 0x20: keyD, + 0x21: keyF, + 0x22: keyG, + 0x23: keyH, + 0x24: keyJ, + 0x25: keyK, + 0x26: keyL, + 0x27: keySemicolon, + 0x28: keyApostrophe, + 0x29: keyGrave, + 0x2A: keyLeftShift, + 0x2B: keyBackslash, + 0x2C: keyZ, + 0x2D: keyX, + 0x2E: keyC, + 0x2F: keyV, + 0x30: keyB, + 0x31: keyN, + 0x32: keyM, + 0x33: keyComma, + 0x34: keyDot, + 0x35: keySlash, + 0x36: keyRightShift, + 0x37: keyKPAsterisk, + 0x38: keyLeftAlt, + 0x39: keySpace, + 0x3A: keyCapsLock, + 0x3B: keyF1, + 0x3C: keyF2, + 0x3D: keyF3, + 0x3E: keyF4, + 0x3F: keyF5, + 0x40: keyF6, + 0x41: keyF7, + 0x42: keyF8, + 0x43: keyF9, + 0x44: keyF10, + 0x45: keyNumLock, + 0x46: keyScrollLock, + 0x47: keyKP7, + 0x48: keyKP8, + 0x49: keyKP9, + 0x4A: keyKPMinus, + 0x4B: keyKP4, + 0x4C: keyKP5, + 0x4D: keyKP6, + 0x4E: keyKPPlus, + 0x4F: keyKP1, + 0x50: keyKP2, + 0x51: keyKP3, + 0x52: keyKP0, + 0x53: keyKPDot, + 0x56: key102nd, + 0x57: keyF11, + 0x58: keyF12, + + // Extended (0xE0-prefixed) scancodes. + 0xE01C: keyKPEnter, + 0xE01D: keyRightCtrl, + 0xE020: keyMute, + 0xE02E: keyVolumeDown, + 0xE030: keyVolumeUp, + 0xE035: keyKPSlash, + 0xE037: keySysRq, // PrintScreen + 0xE038: keyRightAlt, + 0xE047: keyHome, + 0xE048: keyUp, + 0xE049: keyPageUp, + 0xE04B: keyLeft, + 0xE04D: keyRight, + 0xE04F: keyEnd, + 0xE050: keyDown, + 0xE051: keyPageDown, + 0xE052: keyInsert, + 0xE053: keyDelete, + 0xE05B: keyLeftMeta, + 0xE05C: keyRightMeta, + 0xE05D: keyCompose, +} + +// qemuScancodeToLinuxKey is the lookup the uinput and X11 paths use. +// Returns 0 (which Linux treats as KEY_RESERVED) when the scancode has no +// mapping, signalling "fall back to the keysym path". +func qemuScancodeToLinuxKey(scancode uint32) int { + return qemuToLinuxKey[scancode] +} + +// qemuScancodeIsExtended reports whether a QEMU scancode is in the +// 0xE0-prefixed extended range. Used by Windows SendInput to set the +// KEYEVENTF_EXTENDEDKEY flag. +func qemuScancodeIsExtended(scancode uint32) bool { + return scancode&0xFF00 == 0xE000 +} + +// qemuScancodeLowByte returns the byte SendInput's wScan field actually +// stores: the low byte of the scancode regardless of any extended prefix. +func qemuScancodeLowByte(scancode uint32) uint16 { + return uint16(scancode & 0xFF) +} diff --git a/client/vnc/server/scancodes_darwin.go b/client/vnc/server/scancodes_darwin.go new file mode 100644 index 000000000..7d9fd9de8 --- /dev/null +++ b/client/vnc/server/scancodes_darwin.go @@ -0,0 +1,238 @@ +//go:build darwin && !ios + +package server + +// Apple keyboard virtual-key codes used with CGEventCreateKeyboardEvent. +// These are the kVK_ANSI_* / kVK_* values from Apple's +// HIToolbox/Events.h; reproduced here so we don't need to drag in the +// HIToolbox framework just for the constants. +const ( + macKeyA uint16 = 0x00 + macKeyS uint16 = 0x01 + macKeyD uint16 = 0x02 + macKeyF uint16 = 0x03 + macKeyH uint16 = 0x04 + macKeyG uint16 = 0x05 + macKeyZ uint16 = 0x06 + macKeyX uint16 = 0x07 + macKeyC uint16 = 0x08 + macKeyV uint16 = 0x09 + macKeyNonUSBackslash uint16 = 0x0A // ISO_Section / 102nd + macKeyB uint16 = 0x0B + macKeyQ uint16 = 0x0C + macKeyW uint16 = 0x0D + macKeyE uint16 = 0x0E + macKeyR uint16 = 0x0F + macKeyY uint16 = 0x10 + macKeyT uint16 = 0x11 + macKey1 uint16 = 0x12 + macKey2 uint16 = 0x13 + macKey3 uint16 = 0x14 + macKey4 uint16 = 0x15 + macKey6 uint16 = 0x16 + macKey5 uint16 = 0x17 + macKeyEqual uint16 = 0x18 + macKey9 uint16 = 0x19 + macKey7 uint16 = 0x1A + macKeyMinus uint16 = 0x1B + macKey8 uint16 = 0x1C + macKey0 uint16 = 0x1D + macKeyRightBracket uint16 = 0x1E + macKeyO uint16 = 0x1F + macKeyU uint16 = 0x20 + macKeyLeftBracket uint16 = 0x21 + macKeyI uint16 = 0x22 + macKeyP uint16 = 0x23 + macKeyReturn uint16 = 0x24 + macKeyL uint16 = 0x25 + macKeyJ uint16 = 0x26 + macKeyApostrophe uint16 = 0x27 + macKeyK uint16 = 0x28 + macKeySemicolon uint16 = 0x29 + macKeyBackslash uint16 = 0x2A + macKeyComma uint16 = 0x2B + macKeySlash uint16 = 0x2C + macKeyN uint16 = 0x2D + macKeyM uint16 = 0x2E + macKeyPeriod uint16 = 0x2F + macKeyTab uint16 = 0x30 + macKeySpace uint16 = 0x31 + macKeyGrave uint16 = 0x32 + macKeyDelete uint16 = 0x33 // Backspace + macKeyEscape uint16 = 0x35 + macKeyCommand uint16 = 0x37 + macKeyShift uint16 = 0x38 + macKeyCapsLock uint16 = 0x39 + macKeyOption uint16 = 0x3A // Alt + macKeyControl uint16 = 0x3B + macKeyRightShift uint16 = 0x3C + macKeyRightOption uint16 = 0x3D + macKeyRightControl uint16 = 0x3E + macKeyFunction uint16 = 0x3F + macKeyF17 uint16 = 0x40 + macKeyKPDecimal uint16 = 0x41 + macKeyKPMultiply uint16 = 0x43 + macKeyKPPlus uint16 = 0x45 + macKeyKPClear uint16 = 0x47 // numlock + macKeyVolumeUp uint16 = 0x48 + macKeyVolumeDown uint16 = 0x49 + macKeyMute uint16 = 0x4A + macKeyKPDivide uint16 = 0x4B + macKeyKPEnter uint16 = 0x4C + macKeyKPMinus uint16 = 0x4E + macKeyF18 uint16 = 0x4F + macKeyF19 uint16 = 0x50 + macKeyKPEqual uint16 = 0x51 + macKeyKP0 uint16 = 0x52 + macKeyKP1 uint16 = 0x53 + macKeyKP2 uint16 = 0x54 + macKeyKP3 uint16 = 0x55 + macKeyKP4 uint16 = 0x56 + macKeyKP5 uint16 = 0x57 + macKeyKP6 uint16 = 0x58 + macKeyKP7 uint16 = 0x59 + macKeyF20 uint16 = 0x5A + macKeyKP8 uint16 = 0x5B + macKeyKP9 uint16 = 0x5C + macKeyF5 uint16 = 0x60 + macKeyF6 uint16 = 0x61 + macKeyF7 uint16 = 0x62 + macKeyF3 uint16 = 0x63 + macKeyF8 uint16 = 0x64 + macKeyF9 uint16 = 0x65 + macKeyF11 uint16 = 0x67 + macKeyF13 uint16 = 0x69 // PrintScreen on most layouts + macKeyF16 uint16 = 0x6A + macKeyF14 uint16 = 0x6B + macKeyF10 uint16 = 0x6D + macKeyF12 uint16 = 0x6F + macKeyF15 uint16 = 0x71 + macKeyHelp uint16 = 0x72 // Insert on PC keyboards + macKeyHome uint16 = 0x73 + macKeyPageUp uint16 = 0x74 + macKeyForwardDelete uint16 = 0x75 + macKeyF4 uint16 = 0x76 + macKeyEnd uint16 = 0x77 + macKeyF2 uint16 = 0x78 + macKeyPageDown uint16 = 0x79 + macKeyF1 uint16 = 0x7A + macKeyLeft uint16 = 0x7B + macKeyRight uint16 = 0x7C + macKeyDown uint16 = 0x7D + macKeyUp uint16 = 0x7E +) + +// qemuToMacVK maps PC AT Set 1 scancodes (as QEMU emits them, with the +// 0xE0 prefix merged into the high byte) onto Apple virtual-key codes. +// Layout-independent: the scancode names the physical key, the user's +// active keyboard layout on the Mac decides what the key produces. +var qemuToMacVK = map[uint32]uint16{ + // Single-byte (non-extended). + 0x01: macKeyEscape, + 0x02: macKey1, + 0x03: macKey2, + 0x04: macKey3, + 0x05: macKey4, + 0x06: macKey5, + 0x07: macKey6, + 0x08: macKey7, + 0x09: macKey8, + 0x0A: macKey9, + 0x0B: macKey0, + 0x0C: macKeyMinus, + 0x0D: macKeyEqual, + 0x0E: macKeyDelete, // PC Backspace -> mac "Delete" + 0x0F: macKeyTab, + 0x10: macKeyQ, + 0x11: macKeyW, + 0x12: macKeyE, + 0x13: macKeyR, + 0x14: macKeyT, + 0x15: macKeyY, + 0x16: macKeyU, + 0x17: macKeyI, + 0x18: macKeyO, + 0x19: macKeyP, + 0x1A: macKeyLeftBracket, + 0x1B: macKeyRightBracket, + 0x1C: macKeyReturn, + 0x1D: macKeyControl, + 0x1E: macKeyA, + 0x1F: macKeyS, + 0x20: macKeyD, + 0x21: macKeyF, + 0x22: macKeyG, + 0x23: macKeyH, + 0x24: macKeyJ, + 0x25: macKeyK, + 0x26: macKeyL, + 0x27: macKeySemicolon, + 0x28: macKeyApostrophe, + 0x29: macKeyGrave, + 0x2A: macKeyShift, + 0x2B: macKeyBackslash, + 0x2C: macKeyZ, + 0x2D: macKeyX, + 0x2E: macKeyC, + 0x2F: macKeyV, + 0x30: macKeyB, + 0x31: macKeyN, + 0x32: macKeyM, + 0x33: macKeyComma, + 0x34: macKeyPeriod, + 0x35: macKeySlash, + 0x36: macKeyRightShift, + 0x37: macKeyKPMultiply, + 0x38: macKeyOption, // Left Alt -> Option + 0x39: macKeySpace, + 0x3A: macKeyCapsLock, + 0x3B: macKeyF1, + 0x3C: macKeyF2, + 0x3D: macKeyF3, + 0x3E: macKeyF4, + 0x3F: macKeyF5, + 0x40: macKeyF6, + 0x41: macKeyF7, + 0x42: macKeyF8, + 0x43: macKeyF9, + 0x44: macKeyF10, + 0x45: macKeyKPClear, // PC NumLock -> mac Clear + 0x47: macKeyKP7, + 0x48: macKeyKP8, + 0x49: macKeyKP9, + 0x4A: macKeyKPMinus, + 0x4B: macKeyKP4, + 0x4C: macKeyKP5, + 0x4D: macKeyKP6, + 0x4E: macKeyKPPlus, + 0x4F: macKeyKP1, + 0x50: macKeyKP2, + 0x51: macKeyKP3, + 0x52: macKeyKP0, + 0x53: macKeyKPDecimal, + 0x56: macKeyNonUSBackslash, + 0x57: macKeyF11, + 0x58: macKeyF12, + + // Extended (0xE0 prefix). + 0xE01C: macKeyKPEnter, + 0xE01D: macKeyRightControl, + 0xE020: macKeyMute, + 0xE02E: macKeyVolumeDown, + 0xE030: macKeyVolumeUp, + 0xE035: macKeyKPDivide, + 0xE037: macKeyF13, // PrintScreen + 0xE038: macKeyRightOption, + 0xE047: macKeyHome, + 0xE048: macKeyUp, + 0xE049: macKeyPageUp, + 0xE04B: macKeyLeft, + 0xE04D: macKeyRight, + 0xE04F: macKeyEnd, + 0xE050: macKeyDown, + 0xE051: macKeyPageDown, + 0xE052: macKeyHelp, // PC Insert -> mac Help + 0xE053: macKeyForwardDelete, + 0xE05B: macKeyCommand, // Left Windows -> Command + 0xE05C: macKeyCommand, // Right Windows -> Command (no separate code) +} diff --git a/client/vnc/server/scancodes_test.go b/client/vnc/server/scancodes_test.go new file mode 100644 index 000000000..1c6beafa6 --- /dev/null +++ b/client/vnc/server/scancodes_test.go @@ -0,0 +1,98 @@ +package server + +import "testing" + +func TestQemuScancodeToLinuxKey_KnownLetters(t *testing.T) { + // Spot-check a few familiar letter keys against the Linux KEY_* + // values they're supposed to land on. + tests := []struct { + name string + scancode uint32 + want int + }{ + {"A", 0x1E, keyA}, + {"S", 0x1F, keyS}, + {"D", 0x20, keyD}, + {"Q", 0x10, keyQ}, + {"Z", 0x2C, keyZ}, + {"1", 0x02, key1}, + {"Esc", 0x01, keyEsc}, + {"Tab", 0x0F, keyTab}, + {"Space", 0x39, keySpace}, + {"LeftShift", 0x2A, keyLeftShift}, + } + for _, tc := range tests { + got := qemuScancodeToLinuxKey(tc.scancode) + if got != tc.want { + t.Errorf("%s: scancode 0x%X => %d, want %d", tc.name, tc.scancode, got, tc.want) + } + } +} + +func TestQemuScancodeToLinuxKey_Extended(t *testing.T) { + // Extended (0xE0-prefixed) scancodes for arrow + navigation cluster. + tests := []struct { + name string + scancode uint32 + want int + }{ + {"Up", 0xE048, keyUp}, + {"Down", 0xE050, keyDown}, + {"Left", 0xE04B, keyLeft}, + {"Right", 0xE04D, keyRight}, + {"Home", 0xE047, keyHome}, + {"End", 0xE04F, keyEnd}, + {"PageUp", 0xE049, keyPageUp}, + {"PageDown", 0xE051, keyPageDown}, + {"Insert", 0xE052, keyInsert}, + {"Delete", 0xE053, keyDelete}, + {"RightCtrl", 0xE01D, keyRightCtrl}, + {"RightAlt", 0xE038, keyRightAlt}, + {"KPEnter", 0xE01C, keyKPEnter}, + {"KPSlash", 0xE035, keyKPSlash}, + } + for _, tc := range tests { + got := qemuScancodeToLinuxKey(tc.scancode) + if got != tc.want { + t.Errorf("%s: scancode 0x%X => %d, want %d", tc.name, tc.scancode, got, tc.want) + } + } +} + +func TestQemuScancodeToLinuxKey_Miss(t *testing.T) { + // 0xE0FF is in the extended range but not a real key. Must return 0 + // so the caller can fall back to the keysym path. + if got := qemuScancodeToLinuxKey(0xE0FF); got != 0 { + t.Errorf("unknown scancode should miss: got %d, want 0", got) + } + if got := qemuScancodeToLinuxKey(0xFF); got != 0 { + t.Errorf("unknown non-extended scancode should miss: got %d, want 0", got) + } +} + +func TestQemuScancodeIsExtended(t *testing.T) { + cases := []struct { + scancode uint32 + want bool + }{ + {0x1E, false}, + {0xE048, true}, + {0xE000, true}, + {0xFF, false}, + {0xE0FF, true}, + } + for _, tc := range cases { + if got := qemuScancodeIsExtended(tc.scancode); got != tc.want { + t.Errorf("isExtended(0x%X) = %v, want %v", tc.scancode, got, tc.want) + } + } +} + +func TestQemuScancodeLowByte(t *testing.T) { + if got := qemuScancodeLowByte(0xE048); got != 0x48 { + t.Errorf("lowByte(0xE048) = 0x%X, want 0x48", got) + } + if got := qemuScancodeLowByte(0x1E); got != 0x1E { + t.Errorf("lowByte(0x1E) = 0x%X, want 0x1E", got) + } +} diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 237a45aa6..64d8e6e7e 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -79,6 +79,13 @@ var errFrameUnchanged = errors.New("frame unchanged") type InputInjector interface { // InjectKey simulates a key press or release. keysym is an X11 KeySym. InjectKey(keysym uint32, down bool) + // InjectKeyScancode simulates a key press or release using the QEMU + // scancode (PC AT set 1, high byte 0xE0 for extended keys). Layout- + // independent: the server's local keyboard layout decides what + // character the key produces. Implementations should fall back to + // InjectKey(keysym, down) when they don't have a scancode mapping + // for the given code; that's strictly no worse than the legacy path. + InjectKeyScancode(scancode uint32, keysym uint32, down bool) // InjectPointer simulates mouse movement and button state. InjectPointer(buttonMask uint8, x, y, serverW, serverH int) // SetClipboard sets the system clipboard to the given text. diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index c5733dc0e..cd4e89cd2 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -57,6 +57,7 @@ type session struct { clientSupportsExtendedDesktopSize bool clientSupportsDesktopName bool clientSupportsLastRect bool + clientSupportsQEMUKey bool // prevFrame, curFrame and idleFrames live on the encoder goroutine and // must not be touched elsewhere. curFrame holds a session-owned copy of // the capturer's latest frame so the encoder works on a stable buffer @@ -241,6 +242,8 @@ func (s *session) messageLoop() error { err = s.handlePointerEvent() case clientCutText: err = s.handleCutText() + case clientQEMUMessage: + err = s.handleQEMUMessage() case clientNetbirdTypeText: err = s.handleTypeText() default: @@ -311,6 +314,9 @@ func (s *session) handleSetEncodings() error { case pseudoEncLastRect: s.clientSupportsLastRect = true encs = append(encs, "last-rect") + case pseudoEncQEMUExtendedKeyEvent: + s.clientSupportsQEMUKey = true + encs = append(encs, "qemu-key") case encTight: s.useTight = true if s.tight == nil { @@ -749,6 +755,27 @@ func (s *session) handleKeyEvent() error { return nil } +// handleQEMUMessage parses one QEMU vendor message. Today we only handle +// subtype 0 (Extended Key Event); the message itself is 12 bytes total so +// reading 11 more after the type byte covers the layout regardless of +// subtype, and unknown subtypes are dropped without aborting the session. +func (s *session) handleQEMUMessage() error { + var data [11]byte // subtype(1) + down(2) + keysym(4) + keycode(4) + if _, err := io.ReadFull(s.conn, data[:]); err != nil { + return fmt.Errorf("read QEMU message: %w", err) + } + subtype := data[0] + if subtype != qemuSubtypeExtendedKeyEvent { + s.log.Tracef("ignoring QEMU subtype %d", subtype) + return nil + } + down := binary.BigEndian.Uint16(data[1:3]) != 0 + keysym := binary.BigEndian.Uint32(data[3:7]) + scancode := binary.BigEndian.Uint32(data[7:11]) + s.injector.InjectKeyScancode(scancode, keysym, down) + return nil +} + func (s *session) handlePointerEvent() error { var data [5]byte if _, err := io.ReadFull(s.conn, data[:]); err != nil { diff --git a/client/vnc/server/stubs.go b/client/vnc/server/stubs.go index 0ac44b506..d8441751a 100644 --- a/client/vnc/server/stubs.go +++ b/client/vnc/server/stubs.go @@ -27,6 +27,11 @@ func (s *StubInputInjector) InjectKey(_ uint32, _ bool) { // no-op } +// InjectKeyScancode is a no-op on unsupported platforms. +func (s *StubInputInjector) InjectKeyScancode(_ uint32, _ uint32, _ bool) { + // no-op +} + // InjectPointer is a no-op on unsupported platforms. func (s *StubInputInjector) InjectPointer(_ uint8, _, _, _, _ int) { // no-op