From 76add0b9b2becf39fd99b910c9dc377e7c4784e5 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 16:47:53 +0200 Subject: [PATCH] Fix ExtendedClipboard auto-request by advertising all actions in Caps --- client/internal/engine_vnc_stub.go | 1 + client/vnc/server/extclipboard.go | 13 +++++++---- client/vnc/server/extclipboard_test.go | 8 ++++++- client/vnc/server/session.go | 32 ++++++++++++++++---------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/client/internal/engine_vnc_stub.go b/client/internal/engine_vnc_stub.go index b1d0ac262..688a588f7 100644 --- a/client/internal/engine_vnc_stub.go +++ b/client/internal/engine_vnc_stub.go @@ -10,6 +10,7 @@ type vncServer interface{} func (e *Engine) updateVNC(_ *mgmProto.SSHConfig) error { return nil } +// updateVNCServerAuth is a no-op on platforms without a VNC server. func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) {} func (e *Engine) stopVNCServer() error { return nil } diff --git a/client/vnc/server/extclipboard.go b/client/vnc/server/extclipboard.go index 495f0dc63..171430e77 100644 --- a/client/vnc/server/extclipboard.go +++ b/client/vnc/server/extclipboard.go @@ -52,11 +52,16 @@ const ( extClipMaxPayload = extClipMaxText + 1024 ) -// buildExtClipCaps emits the Caps payload advertising the formats we accept -// and our maximum size per format. One uint32 size follows the flags word -// for each format bit set, in ascending bit order. +// buildExtClipCaps emits the Caps payload. The flags word advertises every +// action we support in the high byte (Caps + Request + Peek + Notify + +// Provide) and every format we accept in the low 16 bits. noVNC uses these +// action bits to decide whether to auto-Request on Notify; without +// Request in our Caps it silently drops our Notify messages. After the +// flags word we emit one uint32 max size per format bit set, in ascending +// bit order. func buildExtClipCaps() []byte { - flags := extClipActionCaps | extClipFormatText + flags := extClipActionCaps | extClipActionRequest | extClipActionPeek | + extClipActionNotify | extClipActionProvide | extClipFormatText payload := make([]byte, 4+4) binary.BigEndian.PutUint32(payload[0:4], flags) binary.BigEndian.PutUint32(payload[4:8], uint32(extClipMaxText)) diff --git a/client/vnc/server/extclipboard_test.go b/client/vnc/server/extclipboard_test.go index fd9601a79..43c278bc3 100644 --- a/client/vnc/server/extclipboard_test.go +++ b/client/vnc/server/extclipboard_test.go @@ -16,7 +16,13 @@ func TestBuildExtClipCaps(t *testing.T) { require.Len(t, payload, 8, "Caps with one format should be 4 bytes flags + 4 bytes size") flags := binary.BigEndian.Uint32(payload[0:4]) - assert.Equal(t, extClipActionCaps, flags&extClipActionMask, "action should be Caps") + // noVNC checks individual action bits in our Caps to decide whether to + // auto-Request on Notify, so all supported actions must be advertised. + assert.NotZero(t, flags&extClipActionCaps, "Caps action bit must be set") + assert.NotZero(t, flags&extClipActionRequest, "Request action bit must be set") + assert.NotZero(t, flags&extClipActionPeek, "Peek action bit must be set") + assert.NotZero(t, flags&extClipActionNotify, "Notify action bit must be set") + assert.NotZero(t, flags&extClipActionProvide, "Provide action bit must be set") assert.Equal(t, extClipFormatText, flags&extClipFormatMask, "should advertise text format") maxSize := binary.BigEndian.Uint32(payload[4:8]) diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index aa656c28c..e59fb2edd 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -877,24 +877,32 @@ func (s *session) handleExtCutText(payloadLen uint32) error { } return nil case extClipActionProvide: - if len(rest) == 0 { - return nil - } - text, err := parseExtClipProvideText(flags, rest) - if err != nil { - s.log.Debugf("parse ext clipboard provide: %v", err) - return nil - } - if text != "" { - s.injector.SetClipboard(text) - } - return nil + return s.handleExtClipProvide(flags, rest) default: s.log.Debugf("unknown ext clipboard action 0x%x", action) return nil } } +// handleExtClipProvide decodes a Provide payload and pushes the recovered +// text into the host clipboard. Errors and other unsupported formats (RTF, +// HTML, etc.) are swallowed so a malformed message doesn't tear down the +// session. +func (s *session) handleExtClipProvide(flags uint32, payload []byte) error { + if len(payload) == 0 { + return nil + } + text, err := parseExtClipProvideText(flags, payload) + if err != nil { + s.log.Debugf("parse ext clipboard provide: %v", err) + return nil + } + if text != "" { + s.injector.SetClipboard(text) + } + return nil +} + // sendExtClipProvideText answers an inbound Request(text) with the current // host clipboard contents, capped to extClipMaxText. func (s *session) sendExtClipProvideText() error {