From 3dd894407aa456ba72c978bd7eec7179dc64943e Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Tue, 15 Sep 2026 20:31:03 -0600 Subject: [PATCH] Add VNC clipboard sync RFB's ServerCutText/ClientCutText messages are much simpler than RDP's CLIPRDR channel (no format-list/format-request negotiation -- text is just sent directly in both directions), so ServerCutText now emits remoteClipboardTextChanged instead of being discarded, and VncSessionBackend overrides setClipboardText to send ClientCutText immediately. Extends session_tab.cpp's clipboard-sync gate to cover VNC alongside RDP; the QClipboard wiring itself was already protocol-agnostic. Latin-1 only, per RFB's wire format -- no Unicode clipboard extension is in scope. Co-Authored-By: Claude Sonnet 5 --- src/session_tab.cpp | 5 +- src/vnc_session_backend.cpp | 30 ++++++++- src/vnc_session_backend.h | 5 +- tests/test_vnc_session_backend.cpp | 99 ++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/session_tab.cpp b/src/session_tab.cpp index b30bfee..75f0b83 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -77,8 +77,9 @@ SessionTab::SessionTab(const Profile& profile, m_terminalFontPointSize(preferences.terminalFontPointSize > 0 ? preferences.terminalFontPointSize : 0), - m_clipboardSyncSupported(profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) - == 0), + m_clipboardSyncSupported( + profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0 + || profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0), m_sshTerminal(nullptr), m_rdpDisplay(nullptr), m_vncDisplay(nullptr), diff --git a/src/vnc_session_backend.cpp b/src/vnc_session_backend.cpp index 035068d..6b78353 100644 --- a/src/vnc_session_backend.cpp +++ b/src/vnc_session_backend.cpp @@ -263,6 +263,16 @@ void VncSessionBackend::sendMouseWheelEvent(int x, int y, int deltaX, int deltaY } } +void VncSessionBackend::setClipboardText(const QString& text) +{ + if (m_socket->state() != QAbstractSocket::ConnectedState) { + return; + } + // Unlike RDP's CLIPRDR (list -> server request -> response), RFB's + // ClientCutText has no negotiation: send it immediately. + sendClientCutText(text); +} + void VncSessionBackend::onSocketConnected() { emit eventLogged(QStringLiteral("TCP connection established; waiting for VNC handshake.")); @@ -465,6 +475,19 @@ void VncSessionBackend::sendWheelClick(quint8 wheelBit) m_socket->write(release); } +void VncSessionBackend::sendClientCutText(const QString& text) +{ + // Latin-1 only, matching ServerCutText's decode above -- RFB has no + // Unicode clipboard extension in scope here. + const QByteArray latin1 = text.toLatin1(); + QByteArray msg; + msg.append(kMsgClientCutText); + msg.append(3, char(0)); // padding + appendU32BE(msg, static_cast(latin1.size())); + msg.append(latin1); + m_socket->write(msg); +} + void VncSessionBackend::finishHandshakeIntoRunningState() { emit remoteDesktopSizeChanged(m_framebuffer.width(), m_framebuffer.height()); @@ -895,10 +918,13 @@ void VncSessionBackend::processReceiveBuffer() if (!haveBytes(static_cast(m_pendingLength))) { return; } - // Clipboard sync is out of scope for this pass; consume and - // discard so the byte stream stays in sync. + // RFB's ServerCutText is Latin-1 only (RFC 6143 SS7.5.4) -- no + // Unicode extension is implemented, unlike RDP's CLIPRDR. + const QString text = + QString::fromLatin1(m_recvBuffer.left(static_cast(m_pendingLength))); m_recvBuffer.remove(0, static_cast(m_pendingLength)); m_rfbState = RfbState::WaitingServerMessageType; + emit remoteClipboardTextChanged(text); break; } } diff --git a/src/vnc_session_backend.h b/src/vnc_session_backend.h index 976eda7..a56a5e2 100644 --- a/src/vnc_session_backend.h +++ b/src/vnc_session_backend.h @@ -21,7 +21,8 @@ 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, no clipboard sync. +// No dynamic resize, no remote cursor shape sync. Clipboard sync (Latin-1 +// only, per RFB's ServerCutText/ClientCutText) is supported. class VncSessionBackend : public SessionBackend { Q_OBJECT @@ -52,6 +53,7 @@ public slots: void sendMouseMoveEvent(int x, int y) override; void sendMouseButtonEvent(int x, int y, int button, bool pressed) override; void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override; + void setClipboardText(const QString& text) override; private slots: void onSocketConnected(); @@ -129,6 +131,7 @@ private: void onRectangleFinished(); void sendPointerEvent(); void sendWheelClick(quint8 wheelBit); + void sendClientCutText(const QString& text); }; #endif diff --git a/tests/test_vnc_session_backend.cpp b/tests/test_vnc_session_backend.cpp index 58a2536..f11ed99 100644 --- a/tests/test_vnc_session_backend.cpp +++ b/tests/test_vnc_session_backend.cpp @@ -110,6 +110,8 @@ private slots: void rawFramebufferUpdateProducesExpectedPixels(); void copyRectEncodingDoesNotFailConnection(); void unannouncedEncodingFailsConnectionWithClearMessage(); + void serverCutTextEmitsRemoteClipboardTextChanged(); + void setClipboardTextSendsClientCutText(); private: std::unique_ptr m_server; @@ -620,5 +622,102 @@ void TestVncSessionBackend::unannouncedEncodingFailsConnectionWithClearMessage() QVERIFY(m_lastErrorDisplay.contains(QStringLiteral("non-compliant"))); } +void TestVncSessionBackend::serverCutTextEmitsRemoteClipboardTextChanged() +{ + QString lastClipboardText; + connect(m_backend.get(), &SessionBackend::remoteClipboardTextChanged, this, + [&lastClipboardText](const QString& text) { lastClipboardText = text; }); + + 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 -- reply with ServerInit, then a ServerCutText message + 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))); + + QByteArray cutText; + cutText.append(char(3)); // message-type: ServerCutText + cutText.append(3, char(0)); // padding + const QByteArray text = QByteArray("hello clipboard"); + cutText.append(char(0)); cutText.append(char(0)); cutText.append(char(0)); + cutText.append(static_cast(text.size())); // length (BE, fits one byte here) + + m_server->sendWhenConnected(serverInit + cutText + text); + break; + } + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(lastClipboardText, QStringLiteral("hello clipboard")); +} + +void TestVncSessionBackend::setClipboardTextSendsClientCutText() +{ + 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; + } + default: + break; + } + }); + + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Connected); + + m_backend->setClipboardText(QStringLiteral("hi")); + + // The exact ClientCutText bytes must eventually appear at the tail of + // whatever the fake server received -- checked with endsWith() rather + // than an exact match on the whole buffer, since exactly how the + // preceding handshake/SetEncodings/FramebufferUpdateRequest bytes get + // chunked into TCP reads isn't guaranteed and doesn't matter here. + QByteArray expected; + expected.append(char(6)); // message-type: ClientCutText + expected.append(3, char(0)); // padding + expected.append(char(0)); expected.append(char(0)); expected.append(char(0)); + expected.append(char(2)); // length = 2 + expected.append("hi"); + QTRY_VERIFY(m_server->received.endsWith(expected)); +} + QTEST_GUILESS_MAIN(TestVncSessionBackend) #include "test_vnc_session_backend.moc"