From db5b6cfbb71e860cc0cf9e9f9b7b960084928308 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:34:22 +0200 Subject: [PATCH] Add DesktopSize, DesktopName, LastRect pseudo-encodings with resize detection --- client/vnc/server/pseudo_encodings_test.go | 57 +++++++++ client/vnc/server/rfb.go | 54 +++++++++ client/vnc/server/session.go | 132 +++++++++++++++++++-- 3 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 client/vnc/server/pseudo_encodings_test.go diff --git a/client/vnc/server/pseudo_encodings_test.go b/client/vnc/server/pseudo_encodings_test.go new file mode 100644 index 000000000..a319227c5 --- /dev/null +++ b/client/vnc/server/pseudo_encodings_test.go @@ -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]) + } +} diff --git a/client/vnc/server/rfb.go b/client/vnc/server/rfb.go index affc8c0a8..f9a3af485 100644 --- a/client/vnc/server/rfb.go +++ b/client/vnc/server/rfb.go @@ -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 { diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 1185360f3..f80fae600 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -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.