Fix RDP/VNC clipboard sync randomly stopping

Local copies were de-duplicated against the last synced text with no
expiry, so one lost send made re-copying the same text a no-op; every
open tab listened to the system clipboard, letting background sessions
overwrite it and forwarding one tab's update to all others; and RDP
never sent the initial Format List MS-RDPECLIP requires after Monitor
Ready, which the server can re-send mid-session (e.g. rdpclip.exe
restart), leaving sync dead until reconnect. Announce failures are now
logged to the event log instead of silently dropped.

Bump version to v2026.9.26.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ksmithandClaude Sonnet 5 committed 2026-09-26 14:57:48 -06:00
1 parent 34c16cbfa8
commit 0f821feeed
5 files changed
+84 -9

No files matched your search

+32 -1
View File
@@ -2000,6 +2000,10 @@ void RdpSessionBackend::sendClipboardTextToRemote(rdp_freerdp* instance, const Q
}
if (cliprdrContext == nullptr || cliprdrContext->ClientFormatList == nullptr) {
// Kept in m_pendingLocalClipboardText and announced once the channel
// reports MonitorReady (see onCliprdrMonitorReady()).
emit eventLogged(QStringLiteral(
"RDP clipboard: channel not ready; copied text will be sent once it is."));
return;
}
@@ -2012,7 +2016,11 @@ void RdpSessionBackend::sendClipboardTextToRemote(rdp_freerdp* instance, const Q
formatList.formats = &format;
const UINT rc = cliprdrContext->ClientFormatList(cliprdrContext, &formatList);
Q_UNUSED(rc);
if (rc != CHANNEL_RC_OK) {
emit eventLogged(
QStringLiteral("RDP clipboard: failed to announce copied text to the server (0x%1).")
.arg(rc, 0, 16));
}
#endif
}
@@ -2324,6 +2332,29 @@ void RdpSessionBackend::onCliprdrMonitorReady()
}
emit eventLogged(QStringLiteral("RDP clipboard channel ready."));
QString pendingText;
{
std::lock_guard<std::mutex> guard(m_cliprdrMutex);
pendingText = m_pendingLocalClipboardText;
}
if (!pendingText.isEmpty()) {
sendClipboardTextToRemote(nullptr, pendingText);
} else if (cliprdrContext->ClientFormatList != nullptr) {
// MS-RDPECLIP requires the client to answer Monitor Ready with its
// capabilities *and* an initial Format List (empty is fine); the
// server doesn't treat the channel as initialized until it has
// both. The server can send Monitor Ready again mid-session -- e.g.
// when rdpclip.exe restarts -- and without this the clipboard
// stayed dead until the session was reconnected.
CLIPRDR_FORMAT_LIST emptyList = {};
const UINT rc = cliprdrContext->ClientFormatList(cliprdrContext, &emptyList);
if (rc != CHANNEL_RC_OK) {
emit eventLogged(
QStringLiteral("RDP clipboard: initial format list failed (0x%1).")
.arg(rc, 0, 16));
}
}
#endif
}
+30 -6
View File
@@ -778,17 +778,29 @@ void SessionTab::onBackendUsernameRequested(const QString& prompt)
void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
{
if (text == m_lastSyncedClipboardText) {
// Only the tab in front may touch the system clipboard. Every open
// RDP/VNC tab listens to it, and a background session re-announcing its
// own (stale) clipboard used to silently overwrite what was just copied
// locally, and an update applied by one tab was forwarded on to every
// other connected tab's remote as well.
if (!isVisible() || text.isEmpty()) {
return;
}
m_lastSyncedClipboardText = text;
QApplication::clipboard()->setText(text);
QClipboard* clipboard = QApplication::clipboard();
if (clipboard->text() == text) {
return;
}
m_pendingClipboardEcho = text;
m_pendingClipboardEchoMs = QDateTime::currentMSecsSinceEpoch();
clipboard->setText(text);
appendEvent(QStringLiteral("Clipboard: received %1 characters from remote.").arg(text.size()));
}
void SessionTab::onSystemClipboardChanged()
{
if (!m_clipboardSyncSupported || m_state != SessionState::Connected) {
if (!m_clipboardSyncSupported || m_state != SessionState::Connected || !isVisible()) {
return;
}
@@ -798,12 +810,24 @@ void SessionTab::onSystemClipboardChanged()
}
const QString text = clipboard->text();
if (text == m_lastSyncedClipboardText) {
if (text.isEmpty()) {
return;
}
// Suppress only the change we caused ourselves, and only briefly. The
// old "same as last synced text" check had no expiry: once a send was
// lost (channel not ready, remote clipboard changed to something we
// never saw) copying that same text again did nothing, indefinitely.
constexpr qint64 kEchoWindowMs = 1000;
const bool isEcho = !m_pendingClipboardEcho.isEmpty() && text == m_pendingClipboardEcho
&& QDateTime::currentMSecsSinceEpoch() - m_pendingClipboardEchoMs < kEchoWindowMs;
m_pendingClipboardEcho.clear();
if (isEcho) {
return;
}
m_lastSyncedClipboardText = text;
emit requestSetClipboardText(text);
appendEvent(QStringLiteral("Clipboard: sending %1 characters to remote.").arg(text.size()));
}
void SessionTab::setupUi()
+5 -1
View File
@@ -119,7 +119,11 @@ private:
SessionConnectOptions m_lastConnectOptions;
QString m_terminalThemeName;
int m_terminalFontPointSize;
QString m_lastSyncedClipboardText;
// Text we just wrote to the system clipboard from a remote update, so
// the dataChanged() it triggers isn't echoed straight back out. Only
// honored briefly -- see onSystemClipboardChanged().
QString m_pendingClipboardEcho;
qint64 m_pendingClipboardEchoMs = 0;
bool m_clipboardSyncSupported;
KodoTerm* m_sshTerminal;