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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 20:31:03 -06:00
co-authored by Claude Sonnet 5
parent 35d4daec7f
commit 3dd894407a
4 changed files with 134 additions and 5 deletions
+3 -2
View File
@@ -77,8 +77,9 @@ SessionTab::SessionTab(const Profile& profile,
m_terminalFontPointSize(preferences.terminalFontPointSize > 0 m_terminalFontPointSize(preferences.terminalFontPointSize > 0
? preferences.terminalFontPointSize ? preferences.terminalFontPointSize
: 0), : 0),
m_clipboardSyncSupported(profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) m_clipboardSyncSupported(
== 0), profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
|| profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0),
m_sshTerminal(nullptr), m_sshTerminal(nullptr),
m_rdpDisplay(nullptr), m_rdpDisplay(nullptr),
m_vncDisplay(nullptr), m_vncDisplay(nullptr),
+28 -2
View File
@@ -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() void VncSessionBackend::onSocketConnected()
{ {
emit eventLogged(QStringLiteral("TCP connection established; waiting for VNC handshake.")); emit eventLogged(QStringLiteral("TCP connection established; waiting for VNC handshake."));
@@ -465,6 +475,19 @@ void VncSessionBackend::sendWheelClick(quint8 wheelBit)
m_socket->write(release); 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<quint32>(latin1.size()));
msg.append(latin1);
m_socket->write(msg);
}
void VncSessionBackend::finishHandshakeIntoRunningState() void VncSessionBackend::finishHandshakeIntoRunningState()
{ {
emit remoteDesktopSizeChanged(m_framebuffer.width(), m_framebuffer.height()); emit remoteDesktopSizeChanged(m_framebuffer.width(), m_framebuffer.height());
@@ -895,10 +918,13 @@ void VncSessionBackend::processReceiveBuffer()
if (!haveBytes(static_cast<int>(m_pendingLength))) { if (!haveBytes(static_cast<int>(m_pendingLength))) {
return; return;
} }
// Clipboard sync is out of scope for this pass; consume and // RFB's ServerCutText is Latin-1 only (RFC 6143 SS7.5.4) -- no
// discard so the byte stream stays in sync. // Unicode extension is implemented, unlike RDP's CLIPRDR.
const QString text =
QString::fromLatin1(m_recvBuffer.left(static_cast<int>(m_pendingLength)));
m_recvBuffer.remove(0, static_cast<int>(m_pendingLength)); m_recvBuffer.remove(0, static_cast<int>(m_pendingLength));
m_rfbState = RfbState::WaitingServerMessageType; m_rfbState = RfbState::WaitingServerMessageType;
emit remoteClipboardTextChanged(text);
break; break;
} }
} }
+4 -1
View File
@@ -21,7 +21,8 @@ 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, 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 class VncSessionBackend : public SessionBackend
{ {
Q_OBJECT Q_OBJECT
@@ -52,6 +53,7 @@ public slots:
void sendMouseMoveEvent(int x, int y) override; void sendMouseMoveEvent(int x, int y) override;
void sendMouseButtonEvent(int x, int y, int button, bool pressed) override; void sendMouseButtonEvent(int x, int y, int button, bool pressed) override;
void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override; void sendMouseWheelEvent(int x, int y, int deltaX, int deltaY) override;
void setClipboardText(const QString& text) override;
private slots: private slots:
void onSocketConnected(); void onSocketConnected();
@@ -129,6 +131,7 @@ private:
void onRectangleFinished(); void onRectangleFinished();
void sendPointerEvent(); void sendPointerEvent();
void sendWheelClick(quint8 wheelBit); void sendWheelClick(quint8 wheelBit);
void sendClientCutText(const QString& text);
}; };
#endif #endif
+99
View File
@@ -110,6 +110,8 @@ private slots:
void rawFramebufferUpdateProducesExpectedPixels(); void rawFramebufferUpdateProducesExpectedPixels();
void copyRectEncodingDoesNotFailConnection(); void copyRectEncodingDoesNotFailConnection();
void unannouncedEncodingFailsConnectionWithClearMessage(); void unannouncedEncodingFailsConnectionWithClearMessage();
void serverCutTextEmitsRemoteClipboardTextChanged();
void setClipboardTextSendsClientCutText();
private: private:
std::unique_ptr<FakeVncServer> m_server; std::unique_ptr<FakeVncServer> m_server;
@@ -620,5 +622,102 @@ void TestVncSessionBackend::unannouncedEncodingFailsConnectionWithClearMessage()
QVERIFY(m_lastErrorDisplay.contains(QStringLiteral("non-compliant"))); 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<char>(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) QTEST_GUILESS_MAIN(TestVncSessionBackend)
#include "test_vnc_session_backend.moc" #include "test_vnc_session_backend.moc"