Add VNC remote cursor shape sync

Implements RFB's Cursor pseudo-encoding (RFC 6143 SS7.8.2, type -239):
a FramebufferUpdate rectangle carrying a cursor shape instead of
screen content (x/y are the hotspot, not position; width/height are
the cursor image size), decoded into an ARGB32 QImage using the
rectangle's RGB pixel data plus its opacity bitmask, then never
painted into the framebuffer. A 0x0 rectangle means "hide the
cursor" per spec.

VncDisplayWidget gains RdpDisplayWidget's setCursorImage/Hidden/
Default() + applyCursor() shape, reusing its own renderRect()/
effectiveRemoteSize() so cursor scaling works correctly in both the
scale-to-fit and actual-size display modes with no special-casing.
VNC never emits cursorReset() (RFB's Cursor pseudo-encoding has no
"reset to system default" signal, unlike RDP's SetDefault callback) --
setCursorDefault() exists for symmetry but is unused today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 20:34:20 -06:00
co-authored by Claude Sonnet 5
parent 3dd894407a
commit 9dd1af21d6
6 changed files with 286 additions and 6 deletions
+64 -1
View File
@@ -32,8 +32,13 @@ constexpr quint8 kServerMsgServerCutText = 3;
// of truth for what we tell the server we can decode via SetEncodings.
constexpr qint32 kEncRaw = 0;
constexpr qint32 kEncCopyRect = 1;
// RFC 6143 SS7.8.2 "Cursor pseudo-encoding": not a real screen-content
// encoding -- a rectangle with this type carries a cursor shape update
// instead (hotspot in x/y, image dims in width/height), never painted into
// the framebuffer.
constexpr qint32 kEncCursor = -239;
constexpr std::array<qint32, 2> kAnnouncedEncodings = { kEncRaw, kEncCopyRect };
constexpr std::array<qint32, 3> kAnnouncedEncodings = { kEncRaw, kEncCopyRect, kEncCursor };
quint16 readU16BE(const QByteArray& buf, int offset)
{
@@ -320,6 +325,7 @@ void VncSessionBackend::onSocketDisconnected()
&& m_rfbState != RfbState::WaitingRectangleHeader
&& m_rfbState != RfbState::WaitingRawPixelData
&& m_rfbState != RfbState::WaitingCopyRectSource
&& m_rfbState != RfbState::WaitingCursorPixelData
&& m_rfbState != RfbState::WaitingSetColourMapHeader
&& m_rfbState != RfbState::WaitingSetColourMapData
&& m_rfbState != RfbState::WaitingServerCutTextHeader
@@ -810,6 +816,9 @@ void VncSessionBackend::processReceiveBuffer()
case kEncCopyRect:
m_rfbState = RfbState::WaitingCopyRectSource;
break;
case kEncCursor:
m_rfbState = RfbState::WaitingCursorPixelData;
break;
default: {
// SetEncodings (see kAnnouncedEncodings) is entirely
// client-controlled, so a spec-compliant server will never
@@ -878,6 +887,60 @@ void VncSessionBackend::processReceiveBuffer()
break;
}
case RfbState::WaitingCursorPixelData: {
// Cursor pseudo-encoding (RFC 6143 SS7.8.2): x/y in the already-
// parsed rectangle header are the hotspot, not screen position;
// width/height are the cursor image's own dimensions. Never
// painted into m_framebuffer. Payload is width*height pixels in
// our negotiated 32bpp format, followed by a row-padded,
// MSB-first-per-byte opacity bitmask.
const int width = m_currentRectangle.width;
const int height = m_currentRectangle.height;
const qint64 maskRowBytes = (static_cast<qint64>(width) + 7) / 8;
const qint64 pixelBytes = static_cast<qint64>(width) * height * 4;
const qint64 maskBytes = maskRowBytes * height;
const qint64 totalBytes = pixelBytes + maskBytes;
if (totalBytes < 0 || totalBytes > std::numeric_limits<int>::max()) {
failConnection(QStringLiteral("The VNC server sent an implausibly large cursor image."),
QStringLiteral("Cursor rectangle %1x%2").arg(width).arg(height));
return;
}
if (!haveBytes(static_cast<int>(totalBytes))) {
return;
}
// A 0x0 cursor rectangle is the spec's way of saying "hide the
// cursor"; treat any other degenerate (zero-area) size the same
// way rather than trying to build an empty QImage.
if (width <= 0 || height <= 0) {
emit cursorHidden();
} else {
QImage cursorImage(width, height, QImage::Format_ARGB32);
const auto* pixelData = reinterpret_cast<const uchar*>(m_recvBuffer.constData());
const uchar* maskData = pixelData + pixelBytes;
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const uchar* px = pixelData + ((static_cast<qint64>(y) * width + x) * 4);
// Matches our negotiated SetPixelFormat: little-
// endian 32bpp, R at shift 16 / G at 8 / B at 0 --
// byte order B,G,R,pad.
const uchar b = px[0];
const uchar g = px[1];
const uchar r = px[2];
const uchar maskByte = maskData[y * maskRowBytes + (x / 8)];
const bool opaque = (maskByte & (0x80 >> (x % 8))) != 0;
cursorImage.setPixel(x, y, qRgba(r, g, b, opaque ? 255 : 0));
}
}
emit cursorImageChanged(cursorImage,
QPoint(m_currentRectangle.x, m_currentRectangle.y));
}
m_recvBuffer.remove(0, static_cast<int>(totalBytes));
onRectangleFinished();
break;
}
case RfbState::WaitingSetColourMapHeader: {
if (!haveBytes(5)) {
return;