Add DesktopSize, DesktopName, LastRect pseudo-encodings with resize detection

This commit is contained in:
Viktor Liu
2026-05-17 08:34:22 +02:00
parent e75948753a
commit db5b6cfbb7
3 changed files with 235 additions and 8 deletions

View File

@@ -0,0 +1,57 @@
package server
import "testing"
func TestEncodeDesktopSizeBody(t *testing.T) {
got := encodeDesktopSizeBody(1920, 1080)
if len(got) != 12 {
t.Fatalf("DesktopSize body length: want 12, got %d", len(got))
}
if got[0] != 0 || got[1] != 0 || got[2] != 0 || got[3] != 0 {
t.Fatalf("DesktopSize: x and y must be zero; got % x", got[0:4])
}
if got[4] != 0x07 || got[5] != 0x80 {
t.Fatalf("DesktopSize: width should be 1920 (0x0780); got % x", got[4:6])
}
if got[6] != 0x04 || got[7] != 0x38 {
t.Fatalf("DesktopSize: height should be 1080 (0x0438); got % x", got[6:8])
}
// Encoding = -223 → 0xFFFFFF21 in two's complement big-endian.
if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFF || got[11] != 0x21 {
t.Fatalf("DesktopSize: encoding bytes wrong: % x", got[8:12])
}
}
func TestEncodeDesktopNameBody(t *testing.T) {
name := "vma@debian3"
got := encodeDesktopNameBody(name)
if len(got) != 12+4+len(name) {
t.Fatalf("DesktopName body length: want %d, got %d", 12+4+len(name), len(got))
}
// Encoding = -307 → 0xFFFFFECD.
if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFE || got[11] != 0xCD {
t.Fatalf("DesktopName: encoding bytes wrong: % x", got[8:12])
}
if got[12] != 0 || got[13] != 0 || got[14] != 0 || got[15] != byte(len(name)) {
t.Fatalf("DesktopName: name length prefix wrong: % x", got[12:16])
}
if string(got[16:]) != name {
t.Fatalf("DesktopName: name body wrong: %q", got[16:])
}
}
func TestEncodeLastRectBody(t *testing.T) {
got := encodeLastRectBody()
if len(got) != 12 {
t.Fatalf("LastRect body length: want 12, got %d", len(got))
}
for i := 0; i < 8; i++ {
if got[i] != 0 {
t.Fatalf("LastRect: header bytes 0..7 must be zero; got byte %d = 0x%02x", i, got[i])
}
}
// Encoding = -224 → 0xFFFFFF20.
if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFF || got[11] != 0x20 {
t.Fatalf("LastRect: encoding bytes wrong: % x", got[8:12])
}
}

View File

@@ -52,6 +52,14 @@ const (
encZlib = 6
encTight = 7
// 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
// Tight compression-control byte top nibble. Stream-reset bits 0-3
// (one per zlib stream) are unused while we run a single stream.
tightFillSubenc = 0x80
@@ -157,6 +165,52 @@ func encodeCopyRectBody(srcX, srcY, dstX, dstY, w, h int) []byte {
return buf
}
// encodeDesktopSizeBody emits a DesktopSize pseudo-encoded rectangle. The
// "rect" carries no pixel data: x and y are zero, w and h are the new
// framebuffer dimensions, and encoding=-223 signals to the client that the
// framebuffer was resized. Clients reallocate their backing buffer and
// expect a full update at the new size to follow.
func encodeDesktopSizeBody(w, h int) []byte {
buf := make([]byte, 12)
binary.BigEndian.PutUint16(buf[0:2], 0)
binary.BigEndian.PutUint16(buf[2:4], 0)
binary.BigEndian.PutUint16(buf[4:6], uint16(w))
binary.BigEndian.PutUint16(buf[6:8], uint16(h))
enc := int32(pseudoEncDesktopSize)
binary.BigEndian.PutUint32(buf[8:12], uint32(enc))
return buf
}
// encodeDesktopNameBody emits a DesktopName pseudo-encoded rectangle. The
// rect header is all zeros and encoding=-307; the body is a 4-byte
// big-endian length followed by the UTF-8 name. Clients update their
// window title or label without reconnecting.
func encodeDesktopNameBody(name string) []byte {
nameBytes := []byte(name)
buf := make([]byte, 12+4+len(nameBytes))
binary.BigEndian.PutUint16(buf[0:2], 0)
binary.BigEndian.PutUint16(buf[2:4], 0)
binary.BigEndian.PutUint16(buf[4:6], 0)
binary.BigEndian.PutUint16(buf[6:8], 0)
enc := int32(pseudoEncDesktopName)
binary.BigEndian.PutUint32(buf[8:12], uint32(enc))
binary.BigEndian.PutUint32(buf[12:16], uint32(len(nameBytes)))
copy(buf[16:], nameBytes)
return buf
}
// encodeLastRectBody emits a LastRect sentinel. When the server sets
// numRects=0xFFFF in the FramebufferUpdate header, the client reads rects
// until it sees one with this encoding. Lets us stream rects from a
// goroutine without committing to a count up front.
func encodeLastRectBody() []byte {
buf := make([]byte, 12)
// x, y, w, h all zero; encoding = -224.
enc := int32(pseudoEncLastRect)
binary.BigEndian.PutUint32(buf[8:12], uint32(enc))
return buf
}
// encodeRawRect encodes a framebuffer region as a raw RFB rectangle.
// The returned buffer includes the FramebufferUpdate header (1 rectangle).
func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte {

View File

@@ -34,13 +34,14 @@ const (
)
type session struct {
conn net.Conn
capturer ScreenCapturer
injector InputInjector
serverW int
serverH int
password string
log *log.Entry
conn net.Conn
capturer ScreenCapturer
injector InputInjector
serverW int
serverH int
desktopName string
password string
log *log.Entry
writeMu sync.Mutex
// encMu guards the negotiated pixel format and encoding state below.
@@ -56,6 +57,12 @@ type session struct {
zlib *zlibState
tight *tightState
copyRectDet *copyRectDetector
// Pseudo-encodings the client advertised support for. Updated under
// encMu by handleSetEncodings and read by the encoder goroutine.
clientSupportsDesktopSize bool
clientSupportsExtendedDesktopSize bool
clientSupportsDesktopName bool
clientSupportsLastRect 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
@@ -233,7 +240,11 @@ func (s *session) doVNCAuth() error {
}
func (s *session) sendServerInit() error {
name := []byte("NetBird VNC")
desktop := s.desktopName
if desktop == "" {
desktop = "NetBird VNC"
}
name := []byte(desktop)
buf := make([]byte, 0, 4+16+4+len(name))
// Framebuffer width and height.
@@ -333,6 +344,18 @@ func (s *session) handleSetEncodings() error {
s.copyRectDet = newCopyRectDetector(tileSize)
}
encs = append(encs, "copyrect")
case pseudoEncDesktopSize:
s.clientSupportsDesktopSize = true
encs = append(encs, "desktop-size")
case pseudoEncExtendedDesktopSize:
s.clientSupportsExtendedDesktopSize = true
encs = append(encs, "ext-desktop-size")
case pseudoEncDesktopName:
s.clientSupportsDesktopName = true
encs = append(encs, "desktop-name")
case pseudoEncLastRect:
s.clientSupportsLastRect = true
encs = append(encs, "last-rect")
case encZlib:
s.useZlib = true
if s.zlib == nil {
@@ -401,6 +424,15 @@ func (s *session) encoderLoop(done chan<- struct{}) {
}
func (s *session) processFBRequest(req fbRequest) error {
// Watch for resolution changes between cycles. When the capturer
// reports a new size, tell the client via DesktopSize so it can
// reallocate its backing buffer; the next full update will then fill
// the new dimensions. Clients that didn't advertise support are stuck
// with the original handshake size and just see clipping on resize.
if err := s.handleResize(); err != nil {
return err
}
img, err := s.captureFrame()
if errors.Is(err, errFrameUnchanged) {
// macOS hashes the raw capture bytes and short-circuits when the
@@ -503,6 +535,90 @@ func (s *session) captureRecovered() {
}
}
// handleResize detects framebuffer-size changes between encode cycles and
// notifies the client via the DesktopSize pseudo-encoding. Returns an
// error only on write failure; capturers that don't expose Width/Height
// yet (zero values during early startup) are silently ignored.
func (s *session) handleResize() error {
w, h := s.capturer.Width(), s.capturer.Height()
if w <= 0 || h <= 0 {
return nil
}
if w == s.serverW && h == s.serverH {
return nil
}
s.log.Debugf("framebuffer resized: %dx%d -> %dx%d", s.serverW, s.serverH, w, h)
s.serverW = w
s.serverH = h
// Drop the prev frame so the next encode produces a full update at
// the new dimensions rather than diffing against a stale-sized buffer.
s.prevFrame = nil
s.curFrame = nil
if s.copyRectDet != nil {
// Tile geometry changed; let updateDirty rebuild from scratch on
// the next pass instead of reusing stale hashes keyed on old
// (cols, rows).
s.copyRectDet.prevTiles = nil
s.copyRectDet.tileHash = nil
}
if err := s.sendDesktopSize(w, h); err != nil {
return fmt.Errorf("send desktop size: %w", err)
}
return nil
}
// sendDesktopSize emits a single-rect FramebufferUpdate carrying the
// DesktopSize pseudo-encoding. No-op if the client did not negotiate it,
// in which case the client just sees the new dimensions on the next full
// update and will likely clip or scale.
func (s *session) sendDesktopSize(w, h int) error {
s.encMu.RLock()
supported := s.clientSupportsDesktopSize || s.clientSupportsExtendedDesktopSize
s.encMu.RUnlock()
if !supported {
return nil
}
header := make([]byte, 4)
header[0] = serverFramebufferUpdate
binary.BigEndian.PutUint16(header[2:4], 1)
body := encodeDesktopSizeBody(w, h)
s.writeMu.Lock()
defer s.writeMu.Unlock()
if _, err := s.conn.Write(header); err != nil {
return err
}
_, err := s.conn.Write(body)
return err
}
// SendDesktopName pushes a DesktopName pseudo-encoded update to the
// client if it advertised support. Used by the server to keep the
// dashboard title in sync with the active session (e.g. username
// changes after login on a virtual session).
func (s *session) SendDesktopName(name string) error {
s.encMu.RLock()
supported := s.clientSupportsDesktopName
s.encMu.RUnlock()
if !supported {
s.desktopName = name
return nil
}
s.desktopName = name
header := make([]byte, 4)
header[0] = serverFramebufferUpdate
binary.BigEndian.PutUint16(header[2:4], 1)
body := encodeDesktopNameBody(name)
s.writeMu.Lock()
defer s.writeMu.Unlock()
if _, err := s.conn.Write(header); err != nil {
return err
}
_, err := s.conn.Write(body)
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.