Compare commits

...
Author SHA1 Message Date
ksmithandClaude Sonnet 5 988637eb39 Add Quick Connect: open a session without saving a profile first
File > Quick Connect... and a matching Profiles-tab button open
ProfileDialog in a new quick-connect mode (Name/Tags/Folder hidden,
Name validation skipped) and hand the resulting in-memory profile
straight to a session tab -- nothing is written to the profile
database. The tab title is derived from host/username/protocol for
display only. A Quick Connect tab's context menu gets a "Save as
Profile..." action, shown only on tabs that were never saved, that
opens the normal profile dialog pre-filled to persist it afterward.

Bump version to v2026.9.28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 16:48:00 -06:00
ksmithandClaude Sonnet 5 67ea7ea6b3 Sync local clipboard to a tab when it becomes visible
Only the visible tab syncs the clipboard, but a tab in the background
missed local clipboard changes and kept a stale remote clipboard when
switched to, so with two RDP/VNC sessions only the one last copied in
worked. A tab now pushes the current local clipboard to its remote on
show.

Bump version to v2026.9.26.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:38:58 -06:00
ksmithandClaude Sonnet 5 0f821feeed 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>
2026-09-26 14:57:48 -06:00
11 changed files with 260 additions and 13 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 2026.9.16.7 LANGUAGES CXX)
project(OrbitHub VERSION 2026.9.28 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+38
View File
@@ -226,6 +226,27 @@ 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
- Follow-up to the clipboard fix above: restricting sync to the visible
tab left a tab that had been in the background with a stale remote
clipboard when switched to (with two RDP sessions only the one last
copied in worked). A tab now pushes the current local clipboard to its
remote whenever it becomes visible
- 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
@@ -403,6 +424,23 @@ Git:
- Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes)
- Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu)
- Quick Connect (issue #23): a `File > Quick Connect...` action and a
matching button on the Profiles tab open a connection without saving a
profile first. Reuses `ProfileDialog` via a new `setQuickConnectMode()`
toggle (Name/Tags/Folder hidden, Name validation skipped) rather than a
second dialog class, so all protocol-specific field logic
(`refreshAuthFields()`) stays in one place. The tab title is derived
from host/username/protocol (e.g. `user@host`) purely for display --
never written to the database. A Quick Connect tab's right-click menu
gets a "Save as Profile..." action (absent on normal profile-backed
tabs) that opens the same dialog pre-filled to persist it afterward.
Detecting a Quick Connect tab needed no new state: `ProfileDialog::
profile()` already always returns `id = -1` for an unsaved profile,
and Quick Connect is the only path today that hands such a profile to
`SessionWindow::addSessionTab()`. GUI-only change with no automated
test coverage (consistent with `ProfileDialog` never having had any);
verified manually by running the built binary
## Milestone 10 - v1.0 Stabilization
Status: Planned
+40 -2
View File
@@ -65,7 +65,10 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_rdpSecurityModeInput(new QComboBox(this)),
m_rdpPerformanceProfileInput(new QComboBox(this)),
m_protocolHint(new QLabel(this)),
m_folderHint(new QLabel(this))
m_folderHint(new QLabel(this)),
m_nameLabel(nullptr),
m_tagsLabel(nullptr),
m_quickConnectMode(false)
{
resize(560, 360);
@@ -145,6 +148,9 @@ ProfileDialog::ProfileDialog(QWidget* parent)
form->addRow(QStringLiteral("RDP Security"), m_rdpSecurityModeInput);
form->addRow(QStringLiteral("RDP Performance"), m_rdpPerformanceProfileInput);
m_nameLabel = form->labelForField(m_nameInput);
m_tagsLabel = form->labelForField(m_tagsInput);
auto* note = new QLabel(
QStringLiteral("Passwords are requested at connect time and are not stored."),
this);
@@ -176,6 +182,24 @@ void ProfileDialog::setDefaultFolderPath(const QString& folderPath)
refreshAuthFields();
}
void ProfileDialog::setQuickConnectMode(bool enabled)
{
m_quickConnectMode = enabled;
const bool showOrganizationalFields = !enabled;
if (m_nameLabel != nullptr) {
m_nameLabel->setVisible(showOrganizationalFields);
}
m_nameInput->setVisible(showOrganizationalFields);
if (m_tagsLabel != nullptr) {
m_tagsLabel->setVisible(showOrganizationalFields);
}
m_tagsInput->setVisible(showOrganizationalFields);
m_folderHint->setVisible(showOrganizationalFields);
refreshAuthFields();
}
void ProfileDialog::setProfile(const Profile& profile)
{
m_nameInput->setText(profile.name);
@@ -237,12 +261,26 @@ Profile ProfileDialog::profile() const
profile.rdpPerformanceProfile = protocol == QStringLiteral("RDP")
? m_rdpPerformanceProfileInput->currentText()
: QStringLiteral("Balanced");
if (m_quickConnectMode) {
// Name is never entered in Quick Connect mode -- derive something
// usable for the session tab's title. Only ever a starting point:
// never written to the database directly, and fully editable if
// the user later saves this connection as a real profile.
profile.name = profile.username.isEmpty()
? profile.host
: QStringLiteral("%1@%2").arg(profile.username, profile.host);
if (protocol != QStringLiteral("SSH")) {
profile.name = QStringLiteral("%1 (%2)").arg(profile.name, protocol);
}
}
return profile;
}
void ProfileDialog::accept()
{
if (m_nameInput->text().trimmed().isEmpty()) {
if (!m_quickConnectMode && m_nameInput->text().trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Profile name is required."));
+9
View File
@@ -21,6 +21,12 @@ public:
void setDialogTitle(const QString& title);
void setDefaultFolderPath(const QString& folderPath);
void setProfile(const Profile& profile);
// Hides Name/Tags/Folder and skips the Name-required validation, for
// Quick Connect (issue #23) -- a connection opened without ever being
// saved as a profile. profile() derives a usable name from
// host/username/protocol instead. Everything else (protocol-specific
// fields, defaults) behaves identically to a normal New Profile.
void setQuickConnectMode(bool enabled);
Profile profile() const;
protected:
@@ -42,7 +48,10 @@ private:
QComboBox* m_rdpPerformanceProfileInput;
QLabel* m_protocolHint;
QLabel* m_folderHint;
QWidget* m_nameLabel;
QWidget* m_tagsLabel;
QString m_defaultFolderPath;
bool m_quickConnectMode;
void refreshAuthFields();
};
+24 -2
View File
@@ -151,6 +151,7 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
m_newButton(nullptr),
m_editButton(nullptr),
m_deleteButton(nullptr),
m_quickConnectButton(nullptr),
m_repository(std::make_unique<ProfileRepository>())
{
setupUi();
@@ -226,10 +227,12 @@ void ProfilesWindow::setupUi()
m_newButton = new QPushButton(QStringLiteral("New"), this);
m_editButton = new QPushButton(QStringLiteral("Edit"), this);
m_deleteButton = new QPushButton(QStringLiteral("Delete"), this);
m_quickConnectButton = new QPushButton(QStringLiteral("Quick Connect"), this);
buttonRow->addWidget(m_newButton);
buttonRow->addWidget(m_editButton);
buttonRow->addWidget(m_deleteButton);
buttonRow->addWidget(m_quickConnectButton);
buttonRow->addStretch();
auto* filterRow = new QHBoxLayout();
@@ -300,6 +303,7 @@ void ProfilesWindow::setupUi()
connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); });
connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); });
connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); });
connect(m_quickConnectButton, &QPushButton::clicked, this, [this]() { quickConnect(); });
}
void ProfilesWindow::loadProfiles()
@@ -932,7 +936,11 @@ void ProfilesWindow::createProfile(const QString& defaultFolderPath)
return;
}
const Profile newProfile = dialog.profile();
createProfileFromExisting(dialog.profile());
}
bool ProfilesWindow::createProfileFromExisting(const Profile& newProfile)
{
if (!newProfile.folderPath.trimmed().isEmpty()) {
m_repository->createFolder(newProfile.folderPath);
}
@@ -944,10 +952,24 @@ void ProfilesWindow::createProfile(const QString& defaultFolderPath)
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
return false;
}
loadProfiles();
return true;
}
void ProfilesWindow::quickConnect()
{
ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("Quick Connect"));
dialog.setQuickConnectMode(true);
if (dialog.exec() != QDialog::Accepted) {
return;
}
emit connectRequested(dialog.profile());
}
void ProfilesWindow::editSelectedProfile()
+13
View File
@@ -35,6 +35,18 @@ public:
void exportProfiles();
void importProfiles();
void importFromMRemoteNG();
// Opens the New/Edit Profile dialog with Name/Tags/Folder hidden (see
// ProfileDialog::setQuickConnectMode()) and, on accept, emits
// connectRequested() directly -- never writes to the repository
// (issue #23).
void quickConnect();
// Persists an already-built Profile (e.g. from ProfileDialog::profile())
// the same way createProfile() does after its own dialog accepts:
// optional folder creation, repository insert with a failure warning,
// then refresh. Used by SessionWindow's "Save as Profile..." action so
// repository access stays inside ProfilesWindow. Returns true on
// success.
bool createProfileFromExisting(const Profile& newProfile);
signals:
void connectRequested(const Profile& profile);
@@ -49,6 +61,7 @@ private:
QPushButton* m_newButton;
QPushButton* m_editButton;
QPushButton* m_deleteButton;
QPushButton* m_quickConnectButton;
std::unique_ptr<ProfileRepository> m_repository;
std::unordered_map<qint64, Profile> m_profileCache;
QString m_pendingTagFilterPreference;
+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
}
+55 -6
View File
@@ -28,6 +28,7 @@
#include <QComboBox>
#include <QProcessEnvironment>
#include <QPushButton>
#include <QShowEvent>
#include <QScrollArea>
#include <QThread>
#include <QTimer>
@@ -386,6 +387,16 @@ bool SessionTab::awaitingUserInput() const
return m_awaitingUserInput;
}
bool SessionTab::isQuickConnect() const
{
return m_profile.id <= 0;
}
Profile SessionTab::profileForSaving() const
{
return m_profile;
}
void SessionTab::connectSession()
{
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
@@ -778,17 +789,49 @@ 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) {
const QClipboard* clipboard = QApplication::clipboard();
const QString text = clipboard->mimeData()->hasText() ? clipboard->text() : QString();
// 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;
}
pushLocalClipboardToRemote();
}
void SessionTab::pushLocalClipboardToRemote()
{
if (!m_clipboardSyncSupported || m_state != SessionState::Connected || !isVisible()) {
return;
}
@@ -798,12 +841,18 @@ void SessionTab::onSystemClipboardChanged()
}
const QString text = clipboard->text();
if (text == m_lastSyncedClipboardText) {
if (text.isEmpty()) {
return;
}
m_lastSyncedClipboardText = text;
emit requestSetClipboardText(text);
appendEvent(QStringLiteral("Clipboard: sending %1 characters to remote.").arg(text.size()));
}
void SessionTab::showEvent(QShowEvent* event)
{
QWidget::showEvent(event);
pushLocalClipboardToRemote();
}
void SessionTab::setupUi()
+20 -1
View File
@@ -24,6 +24,7 @@ class QComboBox;
class QLabel;
class QPushButton;
class QScrollArea;
class QShowEvent;
class KodoTerm;
struct SessionUiPreferences
@@ -68,6 +69,14 @@ public:
void setVncScaleToFit(bool scaleToFit);
bool vncScaleToFit() const;
bool awaitingUserInput() const;
// True if this tab's profile was never saved to the repository (i.e.
// it came from Quick Connect, issue #23, rather than a saved profile).
// ProfileDialog::profile() always returns id <= 0 for an unsaved
// profile, and Quick Connect is the only path that hands such a
// profile to SessionWindow::addSessionTab() -- every other path looks
// the profile up from the repository first, which returns id > 0.
bool isQuickConnect() const;
Profile profileForSaving() const;
signals:
void tabTitleChanged(const QString& title);
@@ -99,6 +108,11 @@ signals:
void requestSetClipboardText(const QString& text);
void requestProvideUsername(const QString& username);
protected:
// A tab that wasn't in front while the local clipboard changed missed
// that change (only the visible tab syncs), so catch it up on show.
void showEvent(QShowEvent* event) override;
private slots:
void onBackendStateChanged(SessionState state, const QString& message);
void onBackendEventLogged(const QString& message);
@@ -119,7 +133,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;
@@ -162,6 +180,7 @@ private:
std::function<void(std::optional<QString>)> callback,
bool maskInput = true);
void hidePasswordPrompt();
void pushLocalClipboardToRemote();
bool validateProfileForConnect();
void appendEvent(const QString& message);
void setState(SessionState state, const QString& message);
+27
View File
@@ -1,6 +1,7 @@
#include "session_window.h"
#include "about_dialog.h"
#include "profile_dialog.h"
#include "profiles_window.h"
#include "user_guide_dialog.h"
#include <QApplication>
@@ -148,6 +149,12 @@ SessionWindow::SessionWindow(QWidget* parent)
actualSizeAction->setChecked(!tab->vncScaleToFit());
}
QAction* saveAsProfileAction = nullptr;
if (tab->isQuickConnect()) {
menu.addSeparator();
saveAsProfileAction = menu.addAction(QStringLiteral("Save as Profile..."));
}
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
if (chosen == disconnectAction) {
tab->disconnectSession();
@@ -188,6 +195,8 @@ SessionWindow::SessionWindow(QWidget* parent)
tab->setVncScaleToFit(true);
} else if (actualSizeAction != nullptr && chosen == actualSizeAction) {
tab->setVncScaleToFit(false);
} else if (saveAsProfileAction != nullptr && chosen == saveAsProfileAction) {
saveTabAsProfile(tab);
} else {
for (QAction* themeAction : themeActions) {
if (chosen == themeAction) {
@@ -201,6 +210,7 @@ SessionWindow::SessionWindow(QWidget* parent)
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
QAction* quickConnectAction = fileMenu->addAction(QStringLiteral("Quick Connect..."));
fileMenu->addSeparator();
QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles..."));
QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles..."));
@@ -216,6 +226,10 @@ SessionWindow::SessionWindow(QWidget* parent)
&QAction::triggered,
this,
[this]() { m_profilesWidget->createFolderInCurrentContext(); });
connect(quickConnectAction,
&QAction::triggered,
this,
[this]() { m_profilesWidget->quickConnect(); });
connect(importProfilesAction,
&QAction::triggered,
this,
@@ -368,6 +382,19 @@ int SessionWindow::sessionTabCount() const
return m_tabs->count() - 1;
}
void SessionWindow::saveTabAsProfile(SessionTab* tab)
{
ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("Save as Profile"));
dialog.setProfile(tab->profileForSaving());
if (dialog.exec() != QDialog::Accepted) {
return;
}
m_profilesWidget->createProfileFromExisting(dialog.profile());
}
void SessionWindow::loadUiPreferences()
{
QSettings settings;
+1
View File
@@ -32,6 +32,7 @@ private:
void loadUiPreferences();
void saveUiPreferences() const;
int sessionTabCount() const;
void saveTabAsProfile(SessionTab* tab);
};
#endif