diff --git a/src/session_tab.cpp b/src/session_tab.cpp index 75f0b83..17d5b1b 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -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); diff --git a/src/vnc_display_widget.cpp b/src/vnc_display_widget.cpp index 589e45a..d1f6919 100644 --- a/src/vnc_display_widget.cpp +++ b/src/vnc_display_widget.cpp @@ -1,9 +1,11 @@ #include "vnc_display_widget.h" +#include #include #include #include #include +#include #include #include #include @@ -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)); +} diff --git a/src/vnc_display_widget.h b/src/vnc_display_widget.h index af3f4bd..cf41836 100644 --- a/src/vnc_display_widget.h +++ b/src/vnc_display_widget.h @@ -2,6 +2,7 @@ #define ORBITHUB_VNC_DISPLAY_WIDGET_H #include +#include #include 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 diff --git a/src/vnc_session_backend.cpp b/src/vnc_session_backend.cpp index 6b78353..2eb3a5b 100644 --- a/src/vnc_session_backend.cpp +++ b/src/vnc_session_backend.cpp @@ -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 kAnnouncedEncodings = { kEncRaw, kEncCopyRect }; +constexpr std::array 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(width) + 7) / 8; + const qint64 pixelBytes = static_cast(width) * height * 4; + const qint64 maskBytes = maskRowBytes * height; + const qint64 totalBytes = pixelBytes + maskBytes; + if (totalBytes < 0 || totalBytes > std::numeric_limits::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(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(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(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(totalBytes)); + onRectangleFinished(); + break; + } + case RfbState::WaitingSetColourMapHeader: { if (!haveBytes(5)) { return; diff --git a/src/vnc_session_backend.h b/src/vnc_session_backend.h index a56a5e2..c7dcd01 100644 --- a/src/vnc_session_backend.h +++ b/src/vnc_session_backend.h @@ -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, diff --git a/tests/test_vnc_session_backend.cpp b/tests/test_vnc_session_backend.cpp index f11ed99..61343fa 100644 --- a/tests/test_vnc_session_backend.cpp +++ b/tests/test_vnc_session_backend.cpp @@ -112,6 +112,8 @@ private slots: void unannouncedEncodingFailsConnectionWithClearMessage(); void serverCutTextEmitsRemoteClipboardTextChanged(); void setClipboardTextSendsClientCutText(); + void cursorPseudoEncodingProducesExpectedImageAndHotspot(); + void zeroSizeCursorPseudoEncodingHidesCursor(); private: std::unique_ptr 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"