Add ExtendedMouseButtons for back/forward mouse buttons

This commit is contained in:
Viktor Liu
2026-05-20 11:51:17 +02:00
parent 354fd004c7
commit 896530fd82
9 changed files with 133 additions and 29 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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
}