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
+6
View File
@@ -274,6 +274,8 @@ SessionTab::SessionTab(const Profile& profile,
[this](const QImage& image, const QPoint& hotspot) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorImage(image, hotspot);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorImage(image, hotspot);
}
},
Qt::QueuedConnection);
@@ -283,6 +285,8 @@ SessionTab::SessionTab(const Profile& profile,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorHidden();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorHidden();
}
},
Qt::QueuedConnection);
@@ -292,6 +296,8 @@ SessionTab::SessionTab(const Profile& profile,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorDefault();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorDefault();
}
},
Qt::QueuedConnection);
+67 -1
View File
@@ -1,9 +1,11 @@
#include "vnc_display_widget.h"
#include <QCursor>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPixmap>
#include <QResizeEvent>
#include <QTimer>
#include <QWheelEvent>
@@ -30,7 +32,8 @@ VncDisplayWidget::VncDisplayWidget(QWidget* parent)
: QWidget(parent),
m_remoteSize(1280, 720),
m_resizeDebounceTimer(new QTimer(this)),
m_scaleToFit(true)
m_scaleToFit(true),
m_cursorMode(CursorMode::Default)
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
@@ -83,6 +86,7 @@ void VncDisplayWidget::setScaleToFit(bool scaleToFit)
}
m_scaleToFit = scaleToFit;
applySizeConstraint();
applyCursor();
update();
}
@@ -131,6 +135,7 @@ void VncDisplayWidget::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
scheduleViewportGeometryEmit();
applyCursor();
}
bool VncDisplayWidget::event(QEvent* event)
@@ -282,3 +287,64 @@ QSize VncDisplayWidget::effectiveRemoteSize() const
}
return QSize(1280, 720);
}
void VncDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
{
m_cursorImage = image;
m_cursorHotspot = hotspot;
m_cursorMode = CursorMode::Custom;
applyCursor();
}
void VncDisplayWidget::setCursorHidden()
{
m_cursorMode = CursorMode::Hidden;
applyCursor();
}
void VncDisplayWidget::setCursorDefault()
{
m_cursorMode = CursorMode::Default;
applyCursor();
}
void VncDisplayWidget::applyCursor()
{
if (m_cursorMode == CursorMode::Hidden) {
setCursor(Qt::BlankCursor);
return;
}
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
unsetCursor();
return;
}
// renderRect()/effectiveRemoteSize() already account for both display
// modes: in actual-size mode the scale factor naturally comes out to
// 1.0 (see applySizeConstraint()'s comment), so no special-casing is
// needed here beyond reusing the same geometry helpers RDP's version
// uses for its single (always scale-to-fit) mode.
const QSize remote = effectiveRemoteSize();
const QRectF target = renderRect();
if (remote.isEmpty() || target.isEmpty()) {
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
m_cursorHotspot.x(),
m_cursorHotspot.y()));
return;
}
const qreal scale = target.width() / remote.width();
QImage scaledImage = m_cursorImage;
if (!qFuzzyCompare(scale, 1.0)) {
scaledImage = m_cursorImage.scaled(
qMax(1, qRound(m_cursorImage.width() * scale)),
qMax(1, qRound(m_cursorImage.height() * scale)),
Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
}
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
}
+21 -2
View File
@@ -2,6 +2,7 @@
#define ORBITHUB_VNC_DISPLAY_WIDGET_H
#include <QImage>
#include <QPoint>
#include <QWidget>
class QKeyEvent;
@@ -12,8 +13,7 @@ class QTimer;
class QWheelEvent;
// Renders a VNC framebuffer and forwards local input, scaled-to-fit --
// same shape as RdpDisplayWidget, minus cursor-shape sync (out of scope for
// the initial VNC implementation; the system cursor is left alone).
// same shape as RdpDisplayWidget, including remote cursor-shape sync.
class VncDisplayWidget : public QWidget
{
Q_OBJECT
@@ -38,6 +38,15 @@ public:
return m_scaleToFit;
}
// Mirrors RdpDisplayWidget's cursor handling. VNC's Cursor pseudo-
// encoding never signals "reset to default" the way RDP's SetDefault
// callback does -- it only ever supplies a shape or hides the cursor --
// so setCursorDefault() exists for symmetry/future use but VNC sessions
// never call it today.
void setCursorImage(const QImage& image, const QPoint& hotspot);
void setCursorHidden();
void setCursorDefault();
signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
void mouseMoveInput(int x, int y);
@@ -59,10 +68,19 @@ protected:
bool focusNextPrevChild(bool next) override;
private:
enum class CursorMode {
Default,
Hidden,
Custom,
};
QImage m_frame;
QSize m_remoteSize;
QTimer* m_resizeDebounceTimer;
bool m_scaleToFit;
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
@@ -70,6 +88,7 @@ private:
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
void applySizeConstraint();
void applyCursor();
};
#endif
+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;
+4 -2
View File
@@ -21,8 +21,9 @@ class QTcpSocket;
// Scope (see plan / issue #3 for the full rationale): standard VNC
// Authentication (security type 2) and no-auth (type 1) only -- not
// Apple's Screen Sharing scheme (type 30). Raw + CopyRect encodings only.
// No dynamic resize, no remote cursor shape sync. Clipboard sync (Latin-1
// only, per RFB's ServerCutText/ClientCutText) is supported.
// No dynamic resize. Clipboard sync (Latin-1 only, per RFB's
// ServerCutText/ClientCutText) and remote cursor shape sync (the Cursor
// pseudo-encoding) are supported.
class VncSessionBackend : public SessionBackend
{
Q_OBJECT
@@ -81,6 +82,7 @@ private:
WaitingRectangleHeader,
WaitingRawPixelData,
WaitingCopyRectSource,
WaitingCursorPixelData,
WaitingSetColourMapHeader,
WaitingSetColourMapData,
WaitingServerCutTextHeader,
+124
View File
@@ -112,6 +112,8 @@ private slots:
void unannouncedEncodingFailsConnectionWithClearMessage();
void serverCutTextEmitsRemoteClipboardTextChanged();
void setClipboardTextSendsClientCutText();
void cursorPseudoEncodingProducesExpectedImageAndHotspot();
void zeroSizeCursorPseudoEncodingHidesCursor();
private:
std::unique_ptr<FakeVncServer> m_server;
@@ -719,5 +721,127 @@ void TestVncSessionBackend::setClipboardTextSendsClientCutText()
QTRY_VERIFY(m_server->received.endsWith(expected));
}
void TestVncSessionBackend::cursorPseudoEncodingProducesExpectedImageAndHotspot()
{
QImage lastCursorImage;
QPoint lastHotspot;
bool gotCursor = false;
connect(m_backend.get(), &SessionBackend::cursorImageChanged, this,
[&](const QImage& image, const QPoint& hotspot) {
lastCursorImage = image;
lastHotspot = hotspot;
gotCursor = true;
});
connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() {
m_server->sendWhenConnected(QByteArray("RFB 003.008\n"));
});
connect(m_server.get(), &FakeVncServer::dataReceived, this, [this]() {
switch (m_server->nextStep()) {
case 0: {
QByteArray securityTypes;
securityTypes.append(char(1));
securityTypes.append(char(1));
m_server->sendWhenConnected(securityTypes);
break;
}
case 1:
m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK
break;
case 2: { // ClientInit
QByteArray serverInit;
serverInit.append(char(0)); serverInit.append(char(4)); // width = 4
serverInit.append(char(0)); serverInit.append(char(4)); // height = 4
serverInit.append(QByteArray(16, char(0)));
serverInit.append(QByteArray(4, char(0)));
m_server->sendWhenConnected(serverInit);
break;
}
case 3: { // first FramebufferUpdateRequest -> a Cursor pseudo-encoding rectangle
QByteArray update;
update.append(char(0)); update.append(char(0));
update.append(char(0)); update.append(char(1)); // 1 rectangle
update.append(char(0)); update.append(char(1)); // x = hotspot x = 1
update.append(char(0)); update.append(char(1)); // y = hotspot y = 1
update.append(char(0)); update.append(char(2)); // width = 2
update.append(char(0)); update.append(char(1)); // height = 1
update.append(char(0xff)); update.append(char(0xff));
update.append(char(0xff)); update.append(char(0x11)); // encoding = -239 (Cursor)
// pixel 0: red, opaque (byte order B,G,R,pad, per our negotiated format)
update.append(char(0)); update.append(char(0)); update.append(char(0xff));
update.append(char(0));
// pixel 1: blue, will be masked transparent
update.append(char(0xff)); update.append(char(0)); update.append(char(0));
update.append(char(0));
// mask: ceil(2/8)=1 byte/row * height 1 -- bit7=pixel0 opaque, bit6=pixel1 transparent
update.append(char(0x80));
m_server->sendWhenConnected(update);
break;
}
default:
break;
}
});
m_backend->connectSession(makeOptions());
QTRY_VERIFY(gotCursor);
QCOMPARE(lastCursorImage.size(), QSize(2, 1));
QCOMPARE(lastHotspot, QPoint(1, 1));
QCOMPARE(lastCursorImage.pixelColor(0, 0), QColor(255, 0, 0, 255));
QCOMPARE(lastCursorImage.pixelColor(1, 0).alpha(), 0);
}
void TestVncSessionBackend::zeroSizeCursorPseudoEncodingHidesCursor()
{
bool gotHidden = false;
connect(m_backend.get(), &SessionBackend::cursorHidden, this,
[&gotHidden]() { gotHidden = true; });
connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() {
m_server->sendWhenConnected(QByteArray("RFB 003.008\n"));
});
connect(m_server.get(), &FakeVncServer::dataReceived, this, [this]() {
switch (m_server->nextStep()) {
case 0: {
QByteArray securityTypes;
securityTypes.append(char(1));
securityTypes.append(char(1));
m_server->sendWhenConnected(securityTypes);
break;
}
case 1:
m_server->sendWhenConnected(QByteArray(4, char(0))); // SecurityResult: OK
break;
case 2: { // ClientInit
QByteArray serverInit;
serverInit.append(char(0)); serverInit.append(char(1));
serverInit.append(char(0)); serverInit.append(char(1));
serverInit.append(QByteArray(16, char(0)));
serverInit.append(QByteArray(4, char(0)));
m_server->sendWhenConnected(serverInit);
break;
}
case 3: { // a 0x0 Cursor rectangle means "hide the cursor"
QByteArray update;
update.append(char(0)); update.append(char(0));
update.append(char(0)); update.append(char(1)); // 1 rectangle
update.append(char(0)); update.append(char(0)); // x (hotspot, unused here)
update.append(char(0)); update.append(char(0)); // y
update.append(char(0)); update.append(char(0)); // width = 0
update.append(char(0)); update.append(char(0)); // height = 0
update.append(char(0xff)); update.append(char(0xff));
update.append(char(0xff)); update.append(char(0x11)); // encoding = -239 (Cursor)
m_server->sendWhenConnected(update);
break;
}
default:
break;
}
});
m_backend->connectSession(makeOptions());
QTRY_VERIFY(gotHidden);
}
QTEST_GUILESS_MAIN(TestVncSessionBackend)
#include "test_vnc_session_backend.moc"