From 896530fd8221d7c41f4642b6db93ad955eb62fc4 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 20 May 2026 11:51:17 +0200 Subject: [PATCH] Add ExtendedMouseButtons for back/forward mouse buttons --- client/vnc/server/input_darwin.go | 21 +++++++----- client/vnc/server/input_uinput_unix.go | 10 ++++-- client/vnc/server/input_windows.go | 37 +++++++++++++++++++--- client/vnc/server/input_x11.go | 20 +++++++----- client/vnc/server/rfb.go | 1 + client/vnc/server/server.go | 5 ++- client/vnc/server/session.go | 44 ++++++++++++++++++++++++-- client/vnc/server/session_encode.go | 22 +++++++++++++ client/vnc/server/stubs.go | 2 +- 9 files changed, 133 insertions(+), 29 deletions(-) diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index c88fdebad..eefce934c 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -272,15 +272,15 @@ func ensureEventSource() uintptr { // MacInputInjector injects keyboard and mouse events via Core Graphics. type MacInputInjector struct { - lastButtons uint8 + lastButtons uint16 pbcopyPath string pbpastePath string // clickCount[i] / clickAt[i] track the multi-click sequence for // button i (0=left, 1=right, 2=middle). macOS apps reconstruct // double/triple click semantics from the kCGMouseEventClickState // field on each posted event, not from event timing. - clickCount [3]int64 - clickAt [3]time.Time + clickCount [5]int64 + clickAt [5]time.Time } // NewMacInputInjector creates a macOS input injector. @@ -406,7 +406,7 @@ func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) { } // InjectPointer simulates mouse movement and button events. -func (m *MacInputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) { +func (m *MacInputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) { wakeDisplay() if serverW == 0 || serverH == 0 { return @@ -438,7 +438,7 @@ func scalePxToLogical(px, py, serverW, serverH int) (float64, float64) { float64(py) * float64(logicalH) / float64(serverH) } -func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint8, x, y float64) { +func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint16, x, y float64) { leftDown := buttonMask&0x01 != 0 rightDown := buttonMask&0x04 != 0 middleDown := buttonMask&0x02 != 0 @@ -462,8 +462,8 @@ func (m *MacInputInjector) postMoveOrDrag(src uintptr, leftDown, rightDown bool, // postButtonTransitions emits the up/down events for each button whose // state changed against m.lastButtons, computing the click count so // macOS recognises double / triple clicks. -func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, x, y float64) { - emit := func(curBit, prevBit uint8, down, up int32, button int32, idx int) { +func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint16, x, y float64) { + emit := func(curBit, prevBit uint16, down, up int32, button int32, idx int) { cur := buttonMask&curBit != 0 prev := m.lastButtons&prevBit != 0 if cur && !prev { @@ -486,9 +486,14 @@ func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft, 0) emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight, 1) emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter, 2) + // CG mouse-button numbers 3 (back) and 4 (forward) are emitted as + // "other" events; macOS apps that swallow Browser nav (Finder, web + // views) react to these directly. + emit(1<<7, 1<<7, kCGEventOtherMouseDown, kCGEventOtherMouseUp, 3, 3) + emit(1<<8, 1<<8, kCGEventOtherMouseDown, kCGEventOtherMouseUp, 4, 4) } -func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) { +func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint16) { if buttonMask&0x08 != 0 { m.postScroll(src, scrollPixelsPerWheelTick) } diff --git a/client/vnc/server/input_uinput_unix.go b/client/vnc/server/input_uinput_unix.go index 4b70594f2..098104678 100644 --- a/client/vnc/server/input_uinput_unix.go +++ b/client/vnc/server/input_uinput_unix.go @@ -42,6 +42,8 @@ const ( btnLeft = 0x110 btnRight = 0x111 btnMiddle = 0x112 + btnSide = 0x113 // mouse-back (X1) + btnExtra = 0x114 // mouse-forward (X2) ) // inputEvent matches struct input_event for x86_64 (timeval is 16 bytes). @@ -63,7 +65,7 @@ type UInputInjector struct { fd int closeOnce sync.Once keysymToKey map[uint32]uint16 - prevButtons uint8 + prevButtons uint16 screenW int screenH int } @@ -233,7 +235,7 @@ func (u *UInputInjector) emitKeyCode(code uint16, down bool) { // InjectPointer moves the absolute pointer and presses/releases buttons // based on the RFB button mask delta against the previous mask. -func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) { +func (u *UInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) { u.mu.Lock() defer u.mu.Unlock() if serverW <= 1 || serverH <= 1 { @@ -245,13 +247,15 @@ func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH _ = u.emit(evAbs, absY, absYVal) type btnMap struct { - bit uint8 + bit uint16 key uint16 } for _, b := range []btnMap{ {0x01, btnLeft}, {0x02, btnMiddle}, {0x04, btnRight}, + {1 << 7, btnSide}, + {1 << 8, btnExtra}, } { pressed := buttonMask&b.bit != 0 was := u.prevButtons&b.bit != 0 diff --git a/client/vnc/server/input_windows.go b/client/vnc/server/input_windows.go index aeeb35be6..b89ade86c 100644 --- a/client/vnc/server/input_windows.go +++ b/client/vnc/server/input_windows.go @@ -30,9 +30,16 @@ const ( mouseeventfRightUp = 0x0010 mouseeventfMiddleDown = 0x0020 mouseeventfMiddleUp = 0x0040 + mouseeventfXDown = 0x0080 + mouseeventfXUp = 0x0100 mouseeventfWheel = 0x0800 mouseeventfAbsolute = 0x8000 + // X-button identifiers carried in the dwData field of MOUSEEVENTF_X* + // events. XBUTTON1 is mouse-back, XBUTTON2 is mouse-forward. + xButton1 = 0x0001 + xButton2 = 0x0002 + wheelDelta = 120 keyeventfExtendedKey = 0x0001 @@ -112,7 +119,7 @@ type inputCmd struct { keysym uint32 scancode uint32 down bool - buttonMask uint8 + buttonMask uint16 x, y int serverW int serverH int @@ -127,7 +134,7 @@ type WindowsInputInjector struct { ch chan inputCmd closed chan struct{} closeOnce sync.Once - prevButtonMask uint8 + prevButtonMask uint16 ctrlDown bool altDown bool } @@ -220,7 +227,7 @@ func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, // thread. Pointer events coalesce: when the channel is full (slow desktop // switch, hung SendInput), drop the new sample so the read loop never // blocks. The next mouse event carries fresher position anyway. -func (w *WindowsInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) { +func (w *WindowsInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) { w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}) } @@ -303,7 +310,7 @@ func signalSAS() { } } -func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, serverH int) { +func (w *WindowsInputInjector) doInjectPointer(buttonMask uint16, x, y, serverW, serverH int) { if serverW == 0 || serverH == 0 { return } @@ -317,7 +324,7 @@ func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, w.prevButtonMask = buttonMask type btnMap struct { - bit uint8 + bit uint16 down uint32 up uint32 } @@ -346,6 +353,26 @@ func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, if changed&0x10 != 0 && buttonMask&0x10 != 0 { sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, negWheelDelta) } + + // XBUTTON1/back at bit 7, XBUTTON2/forward at bit 8. SendInput + // MOUSEEVENTF_X{DOWN,UP} carries the X button number in dwData. + xbuttons := [...]struct { + bit uint16 + data uint32 + }{ + {1 << 7, xButton1}, + {1 << 8, xButton2}, + } + for _, b := range xbuttons { + if changed&b.bit == 0 { + continue + } + var flags uint32 = mouseeventfXUp + if buttonMask&b.bit != 0 { + flags = mouseeventfXDown + } + sendMouseInput(flags|mouseeventfAbsolute, absX, absY, b.data) + } } // keysym2VK converts an X11 keysym to a Windows virtual key code. diff --git a/client/vnc/server/input_x11.go b/client/vnc/server/input_x11.go index 60a325806..ca0c75631 100644 --- a/client/vnc/server/input_x11.go +++ b/client/vnc/server/input_x11.go @@ -22,7 +22,7 @@ type X11InputInjector struct { screen *xproto.ScreenInfo display string keysymMap map[uint32]byte - lastButtons uint8 + lastButtons uint16 clipboardTool string clipboardToolName string } @@ -110,7 +110,7 @@ func (x *X11InputInjector) fakeKeyEvent(keycode byte, down bool) { } // InjectPointer simulates mouse movement and button events. -func (x *X11InputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) { +func (x *X11InputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) { if serverW == 0 || serverH == 0 { return } @@ -128,15 +128,19 @@ func (x *X11InputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serv // bit3=scrollUp, bit4=scrollDown. X11 buttons: 1=left, 2=middle, 3=right, // 4=scrollUp, 5=scrollDown. type btnMap struct { - rfbBit uint8 + rfbBit uint16 x11Btn byte } + // X11 button numbers: 1=left, 2=middle, 3=right, 4/5=scroll up/down, + // 6/7=scroll left/right (skipped), 8=back, 9=forward. buttons := [...]btnMap{ - {0x01, 1}, // left - {0x02, 2}, // middle - {0x04, 3}, // right - {0x08, 4}, // scroll up - {0x10, 5}, // scroll down + {0x01, 1}, + {0x02, 2}, + {0x04, 3}, + {0x08, 4}, + {0x10, 5}, + {1 << 7, 8}, + {1 << 8, 9}, } for _, b := range buttons { diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index 291d3529a..97f7908b4 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -77,6 +77,7 @@ const ( pseudoEncQEMUExtendedKeyEvent = -258 pseudoEncDesktopName = -307 pseudoEncExtendedDesktopSize = -308 + pseudoEncExtendedMouseButtons = -316 // Quality/Compression level pseudo-encodings. The client picks one // value from each range to tune JPEG quality and zlib effort. 0 is diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 29498c64a..29bc49a3c 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -105,7 +105,10 @@ type InputInjector interface { // 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) + // buttonMask is the RFB ExtendedMouseButtons mask: bits 0-6 follow + // the standard PointerEvent layout (left/middle/right/wheel), + // bit 7 is mouse-back (X1), bit 8 is mouse-forward (X2). + InjectPointer(buttonMask uint16, x, y, serverW, serverH int) // SetClipboard sets the system clipboard to the given text. SetClipboard(text string) // GetClipboard returns the current system clipboard text. diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 18eb4461d..9bb063592 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -79,7 +79,18 @@ type session struct { clientSupportsQEMUKey bool clientSupportsExtClipboard bool clientSupportsCursor bool - extClipCapsSent bool + // clientSupportsExtMouseButtons is set when the client advertises the + // ExtendedMouseButtons pseudo-encoding (-316). Once the server emits + // the ack rect, the client switches its pointer events to the 6-byte + // extended format that carries back/forward buttons in a second mask + // byte. Without this gate the byte after the type field would still + // be a standard 7-bit mask and our parser must not look further. + clientSupportsExtMouseButtons bool + // extMouseAckSent is set once we've emitted the pseudo-rect ack that + // flips the client into extended-pointer mode. Sticky for the + // session because the client only needs to see it once. + extMouseAckSent bool + extClipCapsSent bool // lastCursorSerial is the serial of the cursor sprite last emitted. // The encoder re-queries the source each cycle and only emits when // the serial changes. @@ -359,6 +370,10 @@ func (s *session) handleSetEncodings() error { if sendExtClipCaps { s.extClipCapsSent = true } + sendExtMouseAck := s.clientSupportsExtMouseButtons && !s.extMouseAckSent + if sendExtMouseAck { + s.extMouseAckSent = true + } s.encMu.Unlock() if len(encs) > 0 { s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) @@ -368,6 +383,11 @@ func (s *session) handleSetEncodings() error { return fmt.Errorf("send ext clipboard caps: %w", err) } } + if sendExtMouseAck { + if err := s.sendExtMouseAck(); err != nil { + return fmt.Errorf("send ext mouse ack: %w", err) + } + } return nil } @@ -387,6 +407,7 @@ func (s *session) resetEncodingCaps() { s.clientSupportsQEMUKey = false s.clientSupportsExtClipboard = false s.clientSupportsCursor = false + s.clientSupportsExtMouseButtons = false s.cursorSourceFailed = false s.clientJPEGQuality = -1 s.clientZlibLevel = -1 @@ -427,6 +448,9 @@ func (s *session) applyEncoding(enc int32) string { } s.clientSupportsCursor = true return "cursor" + case pseudoEncExtendedMouseButtons: + s.clientSupportsExtMouseButtons = true + return "ext-mouse-buttons" case encTight: s.useTight = true return "tight" @@ -541,14 +565,28 @@ func (s *session) handlePointerEvent() error { if _, err := io.ReadFull(s.conn, data[:]); err != nil { return fmt.Errorf("read PointerEvent: %w", err) } - buttonMask := data[0] + mask := uint16(data[0]) x := int(binary.BigEndian.Uint16(data[1:3])) y := int(binary.BigEndian.Uint16(data[3:5])) + + s.encMu.RLock() + extended := s.clientSupportsExtMouseButtons && s.extMouseAckSent + s.encMu.RUnlock() + if extended && mask&0x80 != 0 { + var hi [1]byte + if _, err := io.ReadFull(s.conn, hi[:]); err != nil { + return fmt.Errorf("read ExtendedPointerEvent tail: %w", err) + } + // Strip the marker bit; bits 0..6 are the low part of the mask, + // hi byte holds bits 7..14 (back at bit 7, forward at bit 8). + mask = (mask & 0x7f) | uint16(hi[0])<<7 + } + s.pointerMu.Lock() s.lastPointerX = x s.lastPointerY = y s.pointerMu.Unlock() - s.injector.InjectPointer(buttonMask, x, y, s.serverW, s.serverH) + s.injector.InjectPointer(mask, x, y, s.serverW, s.serverH) return nil } diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 8068bb1d7..c2346a2f4 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -269,6 +269,28 @@ func (s *session) sendDesktopSize(w, h int) error { return err } +// sendExtMouseAck emits the pseudo-rect that flips the client into +// ExtendedMouseButtons mode, where mouse-back and mouse-forward are +// carried in a second mask byte. The rect has zero geometry and no +// body; the encoding number alone is the signal. +func (s *session) sendExtMouseAck() error { + header := make([]byte, 4) + header[0] = serverFramebufferUpdate + binary.BigEndian.PutUint16(header[2:4], 1) + + rect := make([]byte, 12) + enc := int32(pseudoEncExtendedMouseButtons) + binary.BigEndian.PutUint32(rect[8:12], uint32(enc)) + + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := s.conn.Write(header); err != nil { + return err + } + _, err := s.conn.Write(rect) + return err +} + // refreshCopyRectIndex does a full hash sweep of the just-swapped prevFrame. // Used after full-frame sends, where we don't have a per-tile dirty list to // drive an incremental update. diff --git a/client/vnc/server/stubs.go b/client/vnc/server/stubs.go index 0417252e0..954607ada 100644 --- a/client/vnc/server/stubs.go +++ b/client/vnc/server/stubs.go @@ -35,7 +35,7 @@ func (s *StubInputInjector) InjectKeyScancode(_ uint32, _ uint32, _ bool) { } // InjectPointer is a no-op on unsupported platforms. -func (s *StubInputInjector) InjectPointer(_ uint8, _, _, _, _ int) { +func (s *StubInputInjector) InjectPointer(_ uint16, _, _, _, _ int) { // no-op }