diff --git a/CMakeLists.txt b/CMakeLists.txt index a55f3ff..a364eac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.21) -project(OrbitHub VERSION 2026.9.16.7 LANGUAGES CXX) +project(OrbitHub VERSION 2026.9.26 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3bce18d..cf9ae20 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -226,6 +226,22 @@ Delivered: dark mode -- its perceived luminance was well below the four existing state colors -- and was replaced with `#ab47bc`, tuned to roughly match their visibility +- Clipboard sync (RDP/VNC) randomly stopping, reported on Windows and + Linux: (1) `SessionTab` de-duplicated local copies against the last text + it had synced, with no expiry, so once one send was lost (channel not + ready, remote clipboard changed to something it never saw) re-copying + the same text did nothing indefinitely -- it now suppresses only the + echo of its own clipboard write, for one second; (2) every open tab + listened to the system clipboard, so a background session re-announcing + its stale clipboard overwrote what was just copied and one tab's update + was forwarded to every other connected session -- only the visible tab + syncs now; (3) RDP answered Monitor Ready with capabilities but never + the initial Format List MS-RDPECLIP requires, and the server can send + Monitor Ready again mid-session (e.g. rdpclip.exe restarting), leaving + sync dead until reconnect -- it now always sends one; announce failures + and not-yet-ready-channel copies are logged to the session event log + (with a line per copy sent/received) instead of silently dropped. Not + reproduced locally; needs confirmation from real use - Robustness fix: an unrecognized `FramebufferUpdate` rectangle encoding used to abort the connection generically; `kAnnouncedEncodings` is now the single source of truth for what `SetEncodings` announces and what diff --git a/src/rdp_session_backend.cpp b/src/rdp_session_backend.cpp index 781e50e..2fb9210 100644 --- a/src/rdp_session_backend.cpp +++ b/src/rdp_session_backend.cpp @@ -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 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 } diff --git a/src/session_tab.cpp b/src/session_tab.cpp index 7cf13c9..a00a4a3 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -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() diff --git a/src/session_tab.h b/src/session_tab.h index b4401a7..7321a2a 100644 --- a/src/session_tab.h +++ b/src/session_tab.h @@ -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;