Add ExtendedMouseButtons for back/forward mouse buttons

This commit is contained in:
Viktor Liu
2026-05-20 12:15:00 +02:00
parent 2ce8ba1068
commit cb3269df4c
9 changed files with 133 additions and 29 deletions
+13 -8
View File
@@ -272,15 +272,15 @@ func ensureEventSource() uintptr {
// MacInputInjector injects keyboard and mouse events via Core Graphics. // MacInputInjector injects keyboard and mouse events via Core Graphics.
type MacInputInjector struct { type MacInputInjector struct {
lastButtons uint8 lastButtons uint16
pbcopyPath string pbcopyPath string
pbpastePath string pbpastePath string
// clickCount[i] / clickAt[i] track the multi-click sequence for // clickCount[i] / clickAt[i] track the multi-click sequence for
// button i (0=left, 1=right, 2=middle). macOS apps reconstruct // button i (0=left, 1=right, 2=middle). macOS apps reconstruct
// double/triple click semantics from the kCGMouseEventClickState // double/triple click semantics from the kCGMouseEventClickState
// field on each posted event, not from event timing. // field on each posted event, not from event timing.
clickCount [3]int64 clickCount [5]int64
clickAt [3]time.Time clickAt [5]time.Time
} }
// NewMacInputInjector creates a macOS input injector. // 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. // 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() wakeDisplay()
if serverW == 0 || serverH == 0 { if serverW == 0 || serverH == 0 {
return return
@@ -438,7 +438,7 @@ func scalePxToLogical(px, py, serverW, serverH int) (float64, float64) {
float64(py) * float64(logicalH) / float64(serverH) 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 leftDown := buttonMask&0x01 != 0
rightDown := buttonMask&0x04 != 0 rightDown := buttonMask&0x04 != 0
middleDown := buttonMask&0x02 != 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 // postButtonTransitions emits the up/down events for each button whose
// state changed against m.lastButtons, computing the click count so // state changed against m.lastButtons, computing the click count so
// macOS recognises double / triple clicks. // macOS recognises double / triple clicks.
func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, x, y float64) { func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint16, x, y float64) {
emit := func(curBit, prevBit uint8, down, up int32, button int32, idx int) { emit := func(curBit, prevBit uint16, down, up int32, button int32, idx int) {
cur := buttonMask&curBit != 0 cur := buttonMask&curBit != 0
prev := m.lastButtons&prevBit != 0 prev := m.lastButtons&prevBit != 0
if cur && !prev { if cur && !prev {
@@ -486,9 +486,14 @@ func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8,
emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft, 0) emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft, 0)
emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight, 1) emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight, 1)
emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter, 2) 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 { if buttonMask&0x08 != 0 {
m.postScroll(src, scrollPixelsPerWheelTick) m.postScroll(src, scrollPixelsPerWheelTick)
} }
+7 -3
View File
@@ -42,6 +42,8 @@ const (
btnLeft = 0x110 btnLeft = 0x110
btnRight = 0x111 btnRight = 0x111
btnMiddle = 0x112 btnMiddle = 0x112
btnSide = 0x113 // mouse-back (X1)
btnExtra = 0x114 // mouse-forward (X2)
) )
// inputEvent matches struct input_event for x86_64 (timeval is 16 bytes). // inputEvent matches struct input_event for x86_64 (timeval is 16 bytes).
@@ -63,7 +65,7 @@ type UInputInjector struct {
fd int fd int
closeOnce sync.Once closeOnce sync.Once
keysymToKey map[uint32]uint16 keysymToKey map[uint32]uint16
prevButtons uint8 prevButtons uint16
screenW int screenW int
screenH int screenH int
} }
@@ -233,7 +235,7 @@ func (u *UInputInjector) emitKeyCode(code uint16, down bool) {
// InjectPointer moves the absolute pointer and presses/releases buttons // InjectPointer moves the absolute pointer and presses/releases buttons
// based on the RFB button mask delta against the previous mask. // 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() u.mu.Lock()
defer u.mu.Unlock() defer u.mu.Unlock()
if serverW <= 1 || serverH <= 1 { if serverW <= 1 || serverH <= 1 {
@@ -245,13 +247,15 @@ func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH
_ = u.emit(evAbs, absY, absYVal) _ = u.emit(evAbs, absY, absYVal)
type btnMap struct { type btnMap struct {
bit uint8 bit uint16
key uint16 key uint16
} }
for _, b := range []btnMap{ for _, b := range []btnMap{
{0x01, btnLeft}, {0x01, btnLeft},
{0x02, btnMiddle}, {0x02, btnMiddle},
{0x04, btnRight}, {0x04, btnRight},
{1 << 7, btnSide},
{1 << 8, btnExtra},
} { } {
pressed := buttonMask&b.bit != 0 pressed := buttonMask&b.bit != 0
was := u.prevButtons&b.bit != 0 was := u.prevButtons&b.bit != 0
+32 -5
View File
@@ -30,9 +30,16 @@ const (
mouseeventfRightUp = 0x0010 mouseeventfRightUp = 0x0010
mouseeventfMiddleDown = 0x0020 mouseeventfMiddleDown = 0x0020
mouseeventfMiddleUp = 0x0040 mouseeventfMiddleUp = 0x0040
mouseeventfXDown = 0x0080
mouseeventfXUp = 0x0100
mouseeventfWheel = 0x0800 mouseeventfWheel = 0x0800
mouseeventfAbsolute = 0x8000 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 wheelDelta = 120
keyeventfExtendedKey = 0x0001 keyeventfExtendedKey = 0x0001
@@ -112,7 +119,7 @@ type inputCmd struct {
keysym uint32 keysym uint32
scancode uint32 scancode uint32
down bool down bool
buttonMask uint8 buttonMask uint16
x, y int x, y int
serverW int serverW int
serverH int serverH int
@@ -127,7 +134,7 @@ type WindowsInputInjector struct {
ch chan inputCmd ch chan inputCmd
closed chan struct{} closed chan struct{}
closeOnce sync.Once closeOnce sync.Once
prevButtonMask uint8 prevButtonMask uint16
ctrlDown bool ctrlDown bool
altDown 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 // thread. Pointer events coalesce: when the channel is full (slow desktop
// switch, hung SendInput), drop the new sample so the read loop never // switch, hung SendInput), drop the new sample so the read loop never
// blocks. The next mouse event carries fresher position anyway. // 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}) 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 { if serverW == 0 || serverH == 0 {
return return
} }
@@ -317,7 +324,7 @@ func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW,
w.prevButtonMask = buttonMask w.prevButtonMask = buttonMask
type btnMap struct { type btnMap struct {
bit uint8 bit uint16
down uint32 down uint32
up uint32 up uint32
} }
@@ -346,6 +353,26 @@ func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW,
if changed&0x10 != 0 && buttonMask&0x10 != 0 { if changed&0x10 != 0 && buttonMask&0x10 != 0 {
sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, negWheelDelta) 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. // keysym2VK converts an X11 keysym to a Windows virtual key code.
+12 -8
View File
@@ -22,7 +22,7 @@ type X11InputInjector struct {
screen *xproto.ScreenInfo screen *xproto.ScreenInfo
display string display string
keysymMap map[uint32]byte keysymMap map[uint32]byte
lastButtons uint8 lastButtons uint16
clipboardTool string clipboardTool string
clipboardToolName string clipboardToolName string
} }
@@ -110,7 +110,7 @@ func (x *X11InputInjector) fakeKeyEvent(keycode byte, down bool) {
} }
// InjectPointer simulates mouse movement and button events. // 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 { if serverW == 0 || serverH == 0 {
return 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, // bit3=scrollUp, bit4=scrollDown. X11 buttons: 1=left, 2=middle, 3=right,
// 4=scrollUp, 5=scrollDown. // 4=scrollUp, 5=scrollDown.
type btnMap struct { type btnMap struct {
rfbBit uint8 rfbBit uint16
x11Btn byte 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{ buttons := [...]btnMap{
{0x01, 1}, // left {0x01, 1},
{0x02, 2}, // middle {0x02, 2},
{0x04, 3}, // right {0x04, 3},
{0x08, 4}, // scroll up {0x08, 4},
{0x10, 5}, // scroll down {0x10, 5},
{1 << 7, 8},
{1 << 8, 9},
} }
for _, b := range buttons { for _, b := range buttons {
+1
View File
@@ -77,6 +77,7 @@ const (
pseudoEncQEMUExtendedKeyEvent = -258 pseudoEncQEMUExtendedKeyEvent = -258
pseudoEncDesktopName = -307 pseudoEncDesktopName = -307
pseudoEncExtendedDesktopSize = -308 pseudoEncExtendedDesktopSize = -308
pseudoEncExtendedMouseButtons = -316
// Quality/Compression level pseudo-encodings. The client picks one // Quality/Compression level pseudo-encodings. The client picks one
// value from each range to tune JPEG quality and zlib effort. 0 is // value from each range to tune JPEG quality and zlib effort. 0 is
+4 -1
View File
@@ -105,7 +105,10 @@ type InputInjector interface {
// for the given code; that's strictly no worse than the legacy path. // for the given code; that's strictly no worse than the legacy path.
InjectKeyScancode(scancode uint32, keysym uint32, down bool) InjectKeyScancode(scancode uint32, keysym uint32, down bool)
// InjectPointer simulates mouse movement and button state. // 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 sets the system clipboard to the given text.
SetClipboard(text string) SetClipboard(text string)
// GetClipboard returns the current system clipboard text. // GetClipboard returns the current system clipboard text.
+41 -3
View File
@@ -79,7 +79,18 @@ type session struct {
clientSupportsQEMUKey bool clientSupportsQEMUKey bool
clientSupportsExtClipboard bool clientSupportsExtClipboard bool
clientSupportsCursor 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. // lastCursorSerial is the serial of the cursor sprite last emitted.
// The encoder re-queries the source each cycle and only emits when // The encoder re-queries the source each cycle and only emits when
// the serial changes. // the serial changes.
@@ -359,6 +370,10 @@ func (s *session) handleSetEncodings() error {
if sendExtClipCaps { if sendExtClipCaps {
s.extClipCapsSent = true s.extClipCapsSent = true
} }
sendExtMouseAck := s.clientSupportsExtMouseButtons && !s.extMouseAckSent
if sendExtMouseAck {
s.extMouseAckSent = true
}
s.encMu.Unlock() s.encMu.Unlock()
if len(encs) > 0 { if len(encs) > 0 {
s.log.Debugf("client supports encodings: %s", strings.Join(encs, ", ")) 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) 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 return nil
} }
@@ -387,6 +407,7 @@ func (s *session) resetEncodingCaps() {
s.clientSupportsQEMUKey = false s.clientSupportsQEMUKey = false
s.clientSupportsExtClipboard = false s.clientSupportsExtClipboard = false
s.clientSupportsCursor = false s.clientSupportsCursor = false
s.clientSupportsExtMouseButtons = false
s.cursorSourceFailed = false s.cursorSourceFailed = false
s.clientJPEGQuality = -1 s.clientJPEGQuality = -1
s.clientZlibLevel = -1 s.clientZlibLevel = -1
@@ -427,6 +448,9 @@ func (s *session) applyEncoding(enc int32) string {
} }
s.clientSupportsCursor = true s.clientSupportsCursor = true
return "cursor" return "cursor"
case pseudoEncExtendedMouseButtons:
s.clientSupportsExtMouseButtons = true
return "ext-mouse-buttons"
case encTight: case encTight:
s.useTight = true s.useTight = true
return "tight" return "tight"
@@ -541,14 +565,28 @@ func (s *session) handlePointerEvent() error {
if _, err := io.ReadFull(s.conn, data[:]); err != nil { if _, err := io.ReadFull(s.conn, data[:]); err != nil {
return fmt.Errorf("read PointerEvent: %w", err) return fmt.Errorf("read PointerEvent: %w", err)
} }
buttonMask := data[0] mask := uint16(data[0])
x := int(binary.BigEndian.Uint16(data[1:3])) x := int(binary.BigEndian.Uint16(data[1:3]))
y := int(binary.BigEndian.Uint16(data[3:5])) 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.pointerMu.Lock()
s.lastPointerX = x s.lastPointerX = x
s.lastPointerY = y s.lastPointerY = y
s.pointerMu.Unlock() 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 return nil
} }
+22
View File
@@ -269,6 +269,28 @@ func (s *session) sendDesktopSize(w, h int) error {
return err 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. // 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 // Used after full-frame sends, where we don't have a per-tile dirty list to
// drive an incremental update. // drive an incremental update.
+1 -1
View File
@@ -35,7 +35,7 @@ func (s *StubInputInjector) InjectKeyScancode(_ uint32, _ uint32, _ bool) {
} }
// InjectPointer is a no-op on unsupported platforms. // InjectPointer is a no-op on unsupported platforms.
func (s *StubInputInjector) InjectPointer(_ uint8, _, _, _, _ int) { func (s *StubInputInjector) InjectPointer(_ uint16, _, _, _, _ int) {
// no-op // no-op
} }