Files
netbird/client/vnc/server/cursor_x11.go
2026-06-10 10:57:50 +02:00

128 lines
4.3 KiB
Go

//go:build (linux && !android) || freebsd
package server
import (
"fmt"
"image"
"sync"
"github.com/jezek/xgb"
"github.com/jezek/xgb/xfixes"
)
// xfixesCursor reports the current X cursor sprite via the XFixes extension.
// CursorSerial changes whenever the server picks a different cursor, so
// callers can cache by serial without comparing pixels.
type xfixesCursor struct {
mu sync.Mutex
conn *xgb.Conn
// lastPosX/lastPosY hold the cursor screen position observed on the
// most recent successful GetCursorImage. cursorPositionSource readers
// share this value so we do not pay a second X round-trip per frame.
lastPosX, lastPosY int
hasPos bool
// lastImg, lastHotX, lastHotY, lastSerial cache the most recent good
// GetCursorImage result so transient failures (cursor hidden, server
// briefly unresponsive) reuse the previous sprite instead of going
// dark. Without this the encoder's compositing path drops to no-op as
// soon as the cursor becomes momentarily unavailable.
lastImg *image.RGBA
lastHotX int
lastHotY int
lastSerial uint64
}
// newXFixesCursor initialises the XFixes extension on conn. Returns an
// error if the extension is unavailable; callers can fall back to no
// cursor emission instead of asking on every frame.
func newXFixesCursor(conn *xgb.Conn) (*xfixesCursor, error) {
if err := xfixes.Init(conn); err != nil {
return nil, fmt.Errorf("xfixes init: %w", err)
}
if _, err := xfixes.QueryVersion(conn, 4, 0).Reply(); err != nil {
return nil, fmt.Errorf("xfixes query version: %w", err)
}
return &xfixesCursor{conn: conn}, nil
}
// Cursor returns the current cursor sprite as RGBA along with its hotspot
// and serial. Callers should treat an unchanged serial as "no update". On
// a transient GetCursorImage failure the last cached sprite is returned
// so compositing keeps painting the cursor instead of disappearing.
func (c *xfixesCursor) Cursor() (*image.RGBA, int, int, uint64, error) {
c.mu.Lock()
defer c.mu.Unlock()
reply, err := xfixes.GetCursorImage(c.conn).Reply()
if err != nil {
if c.lastImg != nil {
return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil
}
return nil, 0, 0, 0, fmt.Errorf("xfixes GetCursorImage: %w", err)
}
c.lastPosX, c.lastPosY, c.hasPos = int(reply.X), int(reply.Y), true
w, h := int(reply.Width), int(reply.Height)
if w <= 0 || h <= 0 {
if c.lastImg != nil {
return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil
}
return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent")
}
if len(reply.CursorImage) < w*h {
if c.lastImg != nil {
return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil
}
return nil, 0, 0, 0, fmt.Errorf("cursor pixel buffer truncated: %d < %d", len(reply.CursorImage), w*h)
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
// XFixes packs each pixel as a uint32 in ARGB order with premultiplied
// alpha. Unpack into the standard RGBA byte layout.
for i, p := range reply.CursorImage[:w*h] {
o := i * 4
img.Pix[o+0] = byte(p >> 16)
img.Pix[o+1] = byte(p >> 8)
img.Pix[o+2] = byte(p)
img.Pix[o+3] = byte(p >> 24)
}
c.lastImg = img
c.lastHotX = int(reply.Xhot)
c.lastHotY = int(reply.Yhot)
c.lastSerial = uint64(reply.CursorSerial)
return img, c.lastHotX, c.lastHotY, c.lastSerial, nil
}
// Cursor on X11Capturer satisfies cursorSource. The XFixes binding is
// created lazily on the same X connection used for screen capture; the
// first init failure is latched so we stop asking on every frame.
func (x *X11Capturer) Cursor() (*image.RGBA, int, int, uint64, error) {
x.mu.Lock()
if x.cursor == nil && x.cursorInitErr == nil {
x.cursor, x.cursorInitErr = newXFixesCursor(x.conn)
}
cur := x.cursor
initErr := x.cursorInitErr
x.mu.Unlock()
if initErr != nil {
return nil, 0, 0, 0, initErr
}
return cur.Cursor()
}
// CursorPos on X11Capturer returns the screen position from the most
// recent successful Cursor() call. Sessions call Cursor() once per encode
// cycle, so this stays current without a second X round-trip.
func (x *X11Capturer) CursorPos() (int, int, error) {
x.mu.Lock()
cur := x.cursor
x.mu.Unlock()
if cur == nil {
return 0, 0, fmt.Errorf("cursor source not initialised")
}
cur.mu.Lock()
defer cur.mu.Unlock()
if !cur.hasPos {
return 0, 0, fmt.Errorf("cursor position not sampled yet")
}
return cur.lastPosX, cur.lastPosY, nil
}