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) { [this](const QImage& image, const QPoint& hotspot) {
if (m_rdpDisplay != nullptr) { if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorImage(image, hotspot); m_rdpDisplay->setCursorImage(image, hotspot);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorImage(image, hotspot);
} }
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
@@ -283,6 +285,8 @@ SessionTab::SessionTab(const Profile& profile,
[this]() { [this]() {
if (m_rdpDisplay != nullptr) { if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorHidden(); m_rdpDisplay->setCursorHidden();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorHidden();
} }
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
@@ -292,6 +296,8 @@ SessionTab::SessionTab(const Profile& profile,
[this]() { [this]() {
if (m_rdpDisplay != nullptr) { if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorDefault(); m_rdpDisplay->setCursorDefault();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorDefault();
} }
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
+67 -1
View File
@@ -1,9 +1,11 @@
#include "vnc_display_widget.h" #include "vnc_display_widget.h"
#include <QCursor>
#include <QEvent> #include <QEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
#include <QPixmap>
#include <QResizeEvent> #include <QResizeEvent>
#include <QTimer> #include <QTimer>
#include <QWheelEvent> #include <QWheelEvent>
@@ -30,7 +32,8 @@ VncDisplayWidget::VncDisplayWidget(QWidget* parent)
: QWidget(parent), : QWidget(parent),
m_remoteSize(1280, 720), m_remoteSize(1280, 720),
m_resizeDebounceTimer(new QTimer(this)), m_resizeDebounceTimer(new QTimer(this)),
m_scaleToFit(true) m_scaleToFit(true),
m_cursorMode(CursorMode::Default)
{ {
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); setMouseTracking(true);
@@ -83,6 +86,7 @@ void VncDisplayWidget::setScaleToFit(bool scaleToFit)
} }
m_scaleToFit = scaleToFit; m_scaleToFit = scaleToFit;
applySizeConstraint(); applySizeConstraint();
applyCursor();
update(); update();
} }
@@ -131,6 +135,7 @@ void VncDisplayWidget::resizeEvent(QResizeEvent* event)
{ {
QWidget::resizeEvent(event); QWidget::resizeEvent(event);
scheduleViewportGeometryEmit(); scheduleViewportGeometryEmit();
applyCursor();
} }
bool VncDisplayWidget::event(QEvent* event) bool VncDisplayWidget::event(QEvent* event)
@@ -282,3 +287,64 @@ QSize VncDisplayWidget::effectiveRemoteSize() const
} }
return QSize(1280, 720); 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 #define ORBITHUB_VNC_DISPLAY_WIDGET_H
#include <QImage> #include <QImage>
#include <QPoint>
#include <QWidget> #include <QWidget>
class QKeyEvent; class QKeyEvent;
@@ -12,8 +13,7 @@ class QTimer;
class QWheelEvent; class QWheelEvent;
// Renders a VNC framebuffer and forwards local input, scaled-to-fit -- // Renders a VNC framebuffer and forwards local input, scaled-to-fit --
// same shape as RdpDisplayWidget, minus cursor-shape sync (out of scope for // same shape as RdpDisplayWidget, including remote cursor-shape sync.
// the initial VNC implementation; the system cursor is left alone).
class VncDisplayWidget : public QWidget class VncDisplayWidget : public QWidget
{ {
Q_OBJECT Q_OBJECT
@@ -38,6 +38,15 @@ public:
return m_scaleToFit; 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: signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers); void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
void mouseMoveInput(int x, int y); void mouseMoveInput(int x, int y);
@@ -59,10 +68,19 @@ protected:
bool focusNextPrevChild(bool next) override; bool focusNextPrevChild(bool next) override;
private: private:
enum class CursorMode {
Default,
Hidden,
Custom,
};
QImage m_frame; QImage m_frame;
QSize m_remoteSize; QSize m_remoteSize;
QTimer* m_resizeDebounceTimer; QTimer* m_resizeDebounceTimer;
bool m_scaleToFit; bool m_scaleToFit;
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QRectF renderRect() const; QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const; QPoint mapToRemote(const QPointF& pos) const;
@@ -70,6 +88,7 @@ private:
void emitViewportGeometry(); void emitViewportGeometry();
void scheduleViewportGeometryEmit(); void scheduleViewportGeometryEmit();
void applySizeConstraint(); void applySizeConstraint();
void applyCursor();
}; };
#endif #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. // of truth for what we tell the server we can decode via SetEncodings.
constexpr qint32 kEncRaw = 0; constexpr qint32 kEncRaw = 0;
constexpr qint32 kEncCopyRect = 1; 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) quint16 readU16BE(const QByteArray& buf, int offset)
{ {
@@ -320,6 +325,7 @@ void VncSessionBackend::onSocketDisconnected()
&& m_rfbState != RfbState::WaitingRectangleHeader && m_rfbState != RfbState::WaitingRectangleHeader
&& m_rfbState != RfbState::WaitingRawPixelData && m_rfbState != RfbState::WaitingRawPixelData
&& m_rfbState != RfbState::WaitingCopyRectSource && m_rfbState != RfbState::WaitingCopyRectSource
&& m_rfbState != RfbState::WaitingCursorPixelData
&& m_rfbState != RfbState::WaitingSetColourMapHeader && m_rfbState != RfbState::WaitingSetColourMapHeader
&& m_rfbState != RfbState::WaitingSetColourMapData && m_rfbState != RfbState::WaitingSetColourMapData
&& m_rfbState != RfbState::WaitingServerCutTextHeader && m_rfbState != RfbState::WaitingServerCutTextHeader
@@ -810,6 +816,9 @@ void VncSessionBackend::processReceiveBuffer()
case kEncCopyRect: case kEncCopyRect:
m_rfbState = RfbState::WaitingCopyRectSource; m_rfbState = RfbState::WaitingCopyRectSource;
break; break;
case kEncCursor:
m_rfbState = RfbState::WaitingCursorPixelData;
break;
default: { default: {
// SetEncodings (see kAnnouncedEncodings) is entirely // SetEncodings (see kAnnouncedEncodings) is entirely
// client-controlled, so a spec-compliant server will never // client-controlled, so a spec-compliant server will never
@@ -878,6 +887,60 @@ void VncSessionBackend::processReceiveBuffer()
break; 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: { case RfbState::WaitingSetColourMapHeader: {
if (!haveBytes(5)) { if (!haveBytes(5)) {
return; return;
+4 -2
View File
@@ -21,8 +21,9 @@ class QTcpSocket;
// Scope (see plan / issue #3 for the full rationale): standard VNC // Scope (see plan / issue #3 for the full rationale): standard VNC
// Authentication (security type 2) and no-auth (type 1) only -- not // Authentication (security type 2) and no-auth (type 1) only -- not
// Apple's Screen Sharing scheme (type 30). Raw + CopyRect encodings only. // Apple's Screen Sharing scheme (type 30). Raw + CopyRect encodings only.
// No dynamic resize, no remote cursor shape sync. Clipboard sync (Latin-1 // No dynamic resize. Clipboard sync (Latin-1 only, per RFB's
// only, per RFB's ServerCutText/ClientCutText) is supported. // ServerCutText/ClientCutText) and remote cursor shape sync (the Cursor
// pseudo-encoding) are supported.
class VncSessionBackend : public SessionBackend class VncSessionBackend : public SessionBackend
{ {
Q_OBJECT Q_OBJECT
@@ -81,6 +82,7 @@ private:
WaitingRectangleHeader, WaitingRectangleHeader,
WaitingRawPixelData, WaitingRawPixelData,
WaitingCopyRectSource, WaitingCopyRectSource,
WaitingCursorPixelData,
WaitingSetColourMapHeader, WaitingSetColourMapHeader,
WaitingSetColourMapData, WaitingSetColourMapData,
WaitingServerCutTextHeader, WaitingServerCutTextHeader,
+124
View File
@@ -112,6 +112,8 @@ private slots:
void unannouncedEncodingFailsConnectionWithClearMessage(); void unannouncedEncodingFailsConnectionWithClearMessage();
void serverCutTextEmitsRemoteClipboardTextChanged(); void serverCutTextEmitsRemoteClipboardTextChanged();
void setClipboardTextSendsClientCutText(); void setClipboardTextSendsClientCutText();
void cursorPseudoEncodingProducesExpectedImageAndHotspot();
void zeroSizeCursorPseudoEncodingHidesCursor();
private: private:
std::unique_ptr<FakeVncServer> m_server; std::unique_ptr<FakeVncServer> m_server;
@@ -719,5 +721,127 @@ void TestVncSessionBackend::setClipboardTextSendsClientCutText()
QTRY_VERIFY(m_server->received.endsWith(expected)); 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) QTEST_GUILESS_MAIN(TestVncSessionBackend)
#include "test_vnc_session_backend.moc" #include "test_vnc_session_backend.moc"