Internal
Public Access
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1506d87719 | ||
|
|
b3cedcfa48 | ||
|
|
0cdf930303 | ||
|
|
7559488ceb | ||
|
|
ab4a34fc01 | ||
|
|
7aa8849f8e | ||
|
|
776db5ec04 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.21)
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
project(OrbitHub VERSION 2026.9.16 LANGUAGES CXX)
|
project(OrbitHub VERSION 2026.9.16.6 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|||||||
@@ -157,6 +157,75 @@ Delivered:
|
|||||||
gets its own prompt, with an empty password allowed through (unlike
|
gets its own prompt, with an empty password allowed through (unlike
|
||||||
RDP's hard requirement) since some VNC servers are no-auth and there's
|
RDP's hard requirement) since some VNC servers are no-auth and there's
|
||||||
no way to know that before the server's security-type negotiation
|
no way to know that before the server's security-type negotiation
|
||||||
|
- Profiles can now leave the username blank for every protocol (issue
|
||||||
|
#21): SSH/RDP previously hard-required one at profile-save time; that
|
||||||
|
validation is gone, and `SessionTab` now asks for it at connect time
|
||||||
|
instead (reusing the existing password-prompt bar in unmasked mode),
|
||||||
|
same as it already does for a blank password. VNC's username is only
|
||||||
|
ever asked for if the server's negotiated auth method actually needs
|
||||||
|
one -- plain VNC Authentication and no-auth never do, only the two
|
||||||
|
Apple schemes (30/33) do -- which happens *mid-connection*, after the
|
||||||
|
backend has already picked a security type, not before connecting like
|
||||||
|
SSH/RDP. This needed a new async request/response pair on
|
||||||
|
`SessionBackend` (`usernameRequested()` / `provideUsername()`,
|
||||||
|
mirroring the existing SSH host-key-confirmation pattern):
|
||||||
|
`VncSessionBackend` pauses its state machine mid-parse (without
|
||||||
|
consuming the already-buffered response bytes) and emits the request,
|
||||||
|
resuming once `SessionTab` answers; cancelling fails the connection
|
||||||
|
cleanly rather than sending Apple auth a blank username. The value is
|
||||||
|
kept on the tab's in-memory profile copy for its lifetime, not written
|
||||||
|
back to the saved profile
|
||||||
|
- The blank-username relaxation above initially only covered
|
||||||
|
`ProfileDialog`'s own save-time validation; `ProfileRepository::
|
||||||
|
isProfileValid()` had the identical "username required for SSH/RDP"
|
||||||
|
check independently, called directly by `insertProfile()`/
|
||||||
|
`updateProfile()` -- which is exactly the path mRemoteNG import uses
|
||||||
|
(it builds `Profile` objects and inserts them directly, never going
|
||||||
|
through the dialog), so importing any SSH/RDP entry without a
|
||||||
|
username still failed outright until this second check was found and
|
||||||
|
removed too
|
||||||
|
- A third, independent username check was still live even after the two
|
||||||
|
above were removed: `SessionTab::validateProfileForConnect()` (run at
|
||||||
|
the very top of `connectSession()`/`reconnectSession()`, before
|
||||||
|
`requestConnectOptions()` ever gets a chance to run its async prompt)
|
||||||
|
had its own hard-fail "SSH/RDP username is required" `QMessageBox`,
|
||||||
|
so a blank-username profile still couldn't connect at all -- it just
|
||||||
|
told the user to go edit the profile instead of ever prompting inline.
|
||||||
|
Removed; connect-time prompting is now the only username gate for
|
||||||
|
SSH/RDP
|
||||||
|
- Even with the three checks above gone, a username entered at the
|
||||||
|
connect-time prompt still never actually reached SSH or RDP
|
||||||
|
authentication: `SshSessionBackend`/`RdpSessionBackend` are constructed
|
||||||
|
with their own `Profile` copy up front (moved to a worker thread) and
|
||||||
|
read `profile().username` directly, which never sees `SessionTab`'s
|
||||||
|
later edit to its own in-memory profile once the user answers the
|
||||||
|
prompt. `SessionConnectOptions` (which already carries `password` the
|
||||||
|
same way) gained a `username` field, populated by `SessionTab` from
|
||||||
|
its profile copy on every connect attempt; both backends now prefer
|
||||||
|
`options.username` over `profile().username` when building the actual
|
||||||
|
connect target/auth call. Covered by a new SSH regression test
|
||||||
|
(`tests/fixtures/fake_ssh.sh`'s `requireuser` host only accepts an
|
||||||
|
exact `prompted-user@requireuser` target, so the test fails unless the
|
||||||
|
option, not the stale profile copy, is actually used) -- RDP has no
|
||||||
|
equivalent fake-server test harness, so that side relies on mirroring
|
||||||
|
the already-tested `m_activeOptions.password` pattern exactly
|
||||||
|
- Issue #22: the inline username/password prompt bar used to just be a
|
||||||
|
plain `QWidget` with `setAutoFillBackground(true)` and no explicit
|
||||||
|
color, which meant it rendered in the same color as everything else
|
||||||
|
around it and was easy to miss -- especially on a tab that wasn't the
|
||||||
|
active one, where there was previously no indication anything needed
|
||||||
|
attention at all. Now uses a solid `QPalette::Highlight` fill with
|
||||||
|
`QPalette::HighlightedText` for the label (the OS theme's own
|
||||||
|
guaranteed-contrasting pair, so it stays correct under both light and
|
||||||
|
dark themes without a hardcoded color) plus a hand-drawn "?" badge
|
||||||
|
(not a themed `QStyle` icon, whose own colors are outside our control
|
||||||
|
and could land close in hue to the bar's background); a background
|
||||||
|
tab showing the prompt gets its title suffixed "(Needs input)" and its
|
||||||
|
tab-bar text colored distinctly from the four connection-state colors.
|
||||||
|
A first pass at the tab color (`#6a1b9a`) was reported unreadable in
|
||||||
|
dark mode -- its perceived luminance was well below the four existing
|
||||||
|
state colors -- and was replaced with `#ab47bc`, tuned to roughly
|
||||||
|
match their visibility
|
||||||
- Robustness fix: an unrecognized `FramebufferUpdate` rectangle encoding
|
- Robustness fix: an unrecognized `FramebufferUpdate` rectangle encoding
|
||||||
used to abort the connection generically; `kAnnouncedEncodings` is now
|
used to abort the connection generically; `kAnnouncedEncodings` is now
|
||||||
the single source of truth for what `SetEncodings` announces and what
|
the single source of truth for what `SetEncodings` announces and what
|
||||||
|
|||||||
+6
-11
@@ -257,13 +257,6 @@ void ProfileDialog::accept()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString protocol = m_protocolInput->currentText();
|
const QString protocol = m_protocolInput->currentText();
|
||||||
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
|
|
||||||
&& m_usernameInput->text().trimmed().isEmpty()) {
|
|
||||||
QMessageBox::warning(this,
|
|
||||||
QStringLiteral("Validation Error"),
|
|
||||||
QStringLiteral("Username is required for %1 profiles.").arg(protocol));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (protocol == QStringLiteral("SSH")
|
if (protocol == QStringLiteral("SSH")
|
||||||
&& m_authModeInput->currentText() == QStringLiteral("Private Key")) {
|
&& m_authModeInput->currentText() == QStringLiteral("Private Key")) {
|
||||||
@@ -310,12 +303,14 @@ void ProfileDialog::refreshAuthFields()
|
|||||||
|
|
||||||
if (isSsh) {
|
if (isSsh) {
|
||||||
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
||||||
m_protocolHint->setText(
|
m_protocolHint->setText(QStringLiteral(
|
||||||
QStringLiteral("SSH: username is required. Choose Password or Private Key auth."));
|
"SSH: you'll be asked for a username at connect time if left blank here. "
|
||||||
|
"Choose Password or Private Key auth."));
|
||||||
} else if (isRdp) {
|
} else if (isRdp) {
|
||||||
m_usernameInput->setPlaceholderText(QStringLiteral("Administrator"));
|
m_usernameInput->setPlaceholderText(QStringLiteral("Administrator"));
|
||||||
m_protocolHint->setText(
|
m_protocolHint->setText(QStringLiteral(
|
||||||
QStringLiteral("RDP: username and password are required. Domain is optional."));
|
"RDP: you'll be asked for a username at connect time if left blank here. "
|
||||||
|
"Domain is optional."));
|
||||||
} else if (isVnc) {
|
} else if (isVnc) {
|
||||||
m_usernameInput->setPlaceholderText(QStringLiteral("optional"));
|
m_usernameInput->setPlaceholderText(QStringLiteral("optional"));
|
||||||
m_protocolHint->setText(QStringLiteral(
|
m_protocolHint->setText(QStringLiteral(
|
||||||
|
|||||||
@@ -235,13 +235,6 @@ bool isProfileValid(const Profile& profile, QString* error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString protocol = normalizedProtocol(profile.protocol);
|
const QString protocol = normalizedProtocol(profile.protocol);
|
||||||
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
|
|
||||||
&& profile.username.trimmed().isEmpty()) {
|
|
||||||
if (error != nullptr) {
|
|
||||||
*error = QStringLiteral("Username is required for %1 profiles.").arg(protocol);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
|
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
|
||||||
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
|
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
|
||||||
|
|||||||
@@ -1640,7 +1640,13 @@ void RdpSessionBackend::workerMain()
|
|||||||
const Profile& p = profile();
|
const Profile& p = profile();
|
||||||
|
|
||||||
const QString host = p.host.trimmed();
|
const QString host = p.host.trimmed();
|
||||||
QString username = p.username.trimmed();
|
// m_activeOptions.username carries a value prompted for at connect time
|
||||||
|
// (see SessionTab::requestConnectOptions()) when the saved profile's
|
||||||
|
// own username was blank; profile().username never sees that edit
|
||||||
|
// since this backend's Profile copy was captured at construction time.
|
||||||
|
QString username = m_activeOptions.username.trimmed().isEmpty()
|
||||||
|
? p.username.trimmed()
|
||||||
|
: m_activeOptions.username.trimmed();
|
||||||
QString domain = p.domain.trimmed();
|
QString domain = p.domain.trimmed();
|
||||||
if (domain.isEmpty()) {
|
if (domain.isEmpty()) {
|
||||||
const int domainSeparator = username.indexOf(QLatin1Char('\\'));
|
const int domainSeparator = username.indexOf(QLatin1Char('\\'));
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
class SessionConnectOptions
|
class SessionConnectOptions
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
// Only set when the profile's own username was blank and SessionTab
|
||||||
|
// prompted for one inline at connect time (see issue #21); empty means
|
||||||
|
// "use the backend's own profile().username" as before. SSH/RDP need
|
||||||
|
// this up front, unlike VNC's Apple auth which discovers the need for
|
||||||
|
// one mid-connection via usernameRequested()/provideUsername() instead.
|
||||||
|
QString username;
|
||||||
QString password;
|
QString password;
|
||||||
QString privateKeyPath;
|
QString privateKeyPath;
|
||||||
QString knownHostsPolicy;
|
QString knownHostsPolicy;
|
||||||
@@ -55,6 +61,16 @@ public slots:
|
|||||||
{
|
{
|
||||||
Q_UNUSED(text);
|
Q_UNUSED(text);
|
||||||
}
|
}
|
||||||
|
// Response to usernameRequested(), for backends that discover mid-
|
||||||
|
// connection (not before connectSession() is even called) that they
|
||||||
|
// need one -- currently only VNC's Apple authentication schemes,
|
||||||
|
// which only require a username for security types 30/33, not for
|
||||||
|
// plain VNC Authentication or no-auth. An empty username is treated
|
||||||
|
// the same as the prompt being cancelled.
|
||||||
|
virtual void provideUsername(const QString& username)
|
||||||
|
{
|
||||||
|
Q_UNUSED(username);
|
||||||
|
}
|
||||||
virtual void sendKeyEvent(int key,
|
virtual void sendKeyEvent(int key,
|
||||||
quint32 nativeScanCode,
|
quint32 nativeScanCode,
|
||||||
const QString& text,
|
const QString& text,
|
||||||
@@ -93,6 +109,10 @@ signals:
|
|||||||
void connectionError(const QString& displayMessage, const QString& rawMessage);
|
void connectionError(const QString& displayMessage, const QString& rawMessage);
|
||||||
void outputReceived(const QString& text);
|
void outputReceived(const QString& text);
|
||||||
void hostKeyConfirmationRequested(const QString& prompt);
|
void hostKeyConfirmationRequested(const QString& prompt);
|
||||||
|
// Mirrors hostKeyConfirmationRequested()'s request/response shape, for
|
||||||
|
// a backend that discovers mid-connection it needs a username it
|
||||||
|
// wasn't given -- see provideUsername().
|
||||||
|
void usernameRequested(const QString& prompt);
|
||||||
void frameUpdated(const QImage& frame);
|
void frameUpdated(const QImage& frame);
|
||||||
void remoteDesktopSizeChanged(int width, int height);
|
void remoteDesktopSizeChanged(int width, int height);
|
||||||
void remoteClipboardTextChanged(const QString& text);
|
void remoteClipboardTextChanged(const QString& text);
|
||||||
|
|||||||
+126
-9
@@ -17,10 +17,14 @@
|
|||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPixmap>
|
||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QMimeData>
|
#include <QMimeData>
|
||||||
|
#include <QColor>
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QProcessEnvironment>
|
#include <QProcessEnvironment>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
@@ -59,6 +63,34 @@ TerminalTheme themeForName(const QString& themeName)
|
|||||||
return TerminalTheme::loadKonsoleTheme(
|
return TerminalTheme::loadKonsoleTheme(
|
||||||
QStringLiteral(":/KodoTermThemes/konsole/Breeze.colorscheme"));
|
QStringLiteral(":/KodoTermThemes/konsole/Breeze.colorscheme"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A filled circle with a bold "?", used on the username/password prompt
|
||||||
|
// bar (issue #22). Drawn by hand rather than pulled from a QStyle standard
|
||||||
|
// icon because a themed icon's own internal colors are outside our
|
||||||
|
// control and could end up close in hue to the bar's own background,
|
||||||
|
// undermining the contrast the bar is trying to achieve; painting it
|
||||||
|
// ourselves guarantees fillColor/textColor are exactly the same
|
||||||
|
// guaranteed-contrasting pair used for the rest of the bar.
|
||||||
|
QPixmap questionMarkBadgePixmap(const QColor& fillColor, const QColor& textColor, int diameter)
|
||||||
|
{
|
||||||
|
QPixmap pixmap(diameter, diameter);
|
||||||
|
pixmap.fill(Qt::transparent);
|
||||||
|
|
||||||
|
QPainter painter(&pixmap);
|
||||||
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
painter.setPen(Qt::NoPen);
|
||||||
|
painter.setBrush(fillColor);
|
||||||
|
painter.drawEllipse(0, 0, diameter, diameter);
|
||||||
|
|
||||||
|
QFont font = painter.font();
|
||||||
|
font.setBold(true);
|
||||||
|
font.setPixelSize(static_cast<int>(diameter * 0.65));
|
||||||
|
painter.setFont(font);
|
||||||
|
painter.setPen(textColor);
|
||||||
|
painter.drawText(QRect(0, 0, diameter, diameter), Qt::AlignCenter, QStringLiteral("?"));
|
||||||
|
|
||||||
|
return pixmap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionTab::SessionTab(const Profile& profile,
|
SessionTab::SessionTab(const Profile& profile,
|
||||||
@@ -93,10 +125,12 @@ SessionTab::SessionTab(const Profile& profile,
|
|||||||
m_exportEventsButton(nullptr),
|
m_exportEventsButton(nullptr),
|
||||||
m_eventsPanel(nullptr),
|
m_eventsPanel(nullptr),
|
||||||
m_passwordPromptBar(nullptr),
|
m_passwordPromptBar(nullptr),
|
||||||
|
m_passwordPromptIcon(nullptr),
|
||||||
m_passwordPromptLabel(nullptr),
|
m_passwordPromptLabel(nullptr),
|
||||||
m_passwordPromptInput(nullptr),
|
m_passwordPromptInput(nullptr),
|
||||||
m_passwordPromptConnectButton(nullptr),
|
m_passwordPromptConnectButton(nullptr),
|
||||||
m_passwordPromptCancelButton(nullptr),
|
m_passwordPromptCancelButton(nullptr),
|
||||||
|
m_awaitingUserInput(false),
|
||||||
m_eventSeverityFilter(EventSeverity::Info),
|
m_eventSeverityFilter(EventSeverity::Info),
|
||||||
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
|
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
|
||||||
{
|
{
|
||||||
@@ -215,6 +249,11 @@ SessionTab::SessionTab(const Profile& profile,
|
|||||||
m_backend,
|
m_backend,
|
||||||
&SessionBackend::setClipboardText,
|
&SessionBackend::setClipboardText,
|
||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
|
connect(this,
|
||||||
|
&SessionTab::requestProvideUsername,
|
||||||
|
m_backend,
|
||||||
|
&SessionBackend::provideUsername,
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
|
||||||
connect(m_backend,
|
connect(m_backend,
|
||||||
&SessionBackend::stateChanged,
|
&SessionBackend::stateChanged,
|
||||||
@@ -241,6 +280,11 @@ SessionTab::SessionTab(const Profile& profile,
|
|||||||
this,
|
this,
|
||||||
&SessionTab::onBackendHostKeyConfirmationRequested,
|
&SessionTab::onBackendHostKeyConfirmationRequested,
|
||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
|
connect(m_backend,
|
||||||
|
&SessionBackend::usernameRequested,
|
||||||
|
this,
|
||||||
|
&SessionTab::onBackendUsernameRequested,
|
||||||
|
Qt::QueuedConnection);
|
||||||
connect(m_backend,
|
connect(m_backend,
|
||||||
&SessionBackend::frameUpdated,
|
&SessionBackend::frameUpdated,
|
||||||
this,
|
this,
|
||||||
@@ -331,9 +375,17 @@ SessionTab::~SessionTab()
|
|||||||
|
|
||||||
QString SessionTab::tabTitle() const
|
QString SessionTab::tabTitle() const
|
||||||
{
|
{
|
||||||
|
if (m_awaitingUserInput) {
|
||||||
|
return QStringLiteral("%1 (Needs input)").arg(m_profile.name);
|
||||||
|
}
|
||||||
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
|
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SessionTab::awaitingUserInput() const
|
||||||
|
{
|
||||||
|
return m_awaitingUserInput;
|
||||||
|
}
|
||||||
|
|
||||||
void SessionTab::connectSession()
|
void SessionTab::connectSession()
|
||||||
{
|
{
|
||||||
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
|
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
|
||||||
@@ -714,6 +766,16 @@ void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
|
|||||||
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
|
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SessionTab::onBackendUsernameRequested(const QString& prompt)
|
||||||
|
{
|
||||||
|
showPasswordPrompt(
|
||||||
|
prompt.isEmpty() ? QStringLiteral("Username for %1:").arg(m_profile.host) : prompt,
|
||||||
|
[this](std::optional<QString> username) {
|
||||||
|
emit requestProvideUsername(username.value_or(QString()).trimmed());
|
||||||
|
},
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
|
void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
|
||||||
{
|
{
|
||||||
if (text == m_lastSyncedClipboardText) {
|
if (text == m_lastSyncedClipboardText) {
|
||||||
@@ -786,21 +848,42 @@ void SessionTab::setupUi()
|
|||||||
|
|
||||||
applyTerminalTheme(m_terminalThemeName);
|
applyTerminalTheme(m_terminalThemeName);
|
||||||
|
|
||||||
|
// Styled distinctly (issue #22: this bar used to blend straight into
|
||||||
|
// the plain window background and was easy to miss, especially on a
|
||||||
|
// tab you weren't actively looking at). A solid QPalette::Highlight
|
||||||
|
// fill with QPalette::HighlightedText for the label is used rather
|
||||||
|
// than a subtle tint -- a faint tint proved to still be easy to miss,
|
||||||
|
// and Highlight/HighlightedText are the OS theme's own guaranteed-
|
||||||
|
// contrasting pair, so this stays readable under both light and dark
|
||||||
|
// themes without hardcoding a color.
|
||||||
auto* passwordPromptLayout = new QHBoxLayout();
|
auto* passwordPromptLayout = new QHBoxLayout();
|
||||||
|
m_passwordPromptIcon = new QLabel(this);
|
||||||
|
const QColor highlight = palette().color(QPalette::Highlight);
|
||||||
|
const QColor highlightedText = palette().color(QPalette::HighlightedText);
|
||||||
|
m_passwordPromptIcon->setPixmap(questionMarkBadgePixmap(highlightedText, highlight, 22));
|
||||||
m_passwordPromptLabel = new QLabel(this);
|
m_passwordPromptLabel = new QLabel(this);
|
||||||
|
m_passwordPromptLabel->setObjectName(QStringLiteral("passwordPromptLabel"));
|
||||||
m_passwordPromptInput = new QLineEdit(this);
|
m_passwordPromptInput = new QLineEdit(this);
|
||||||
m_passwordPromptInput->setEchoMode(QLineEdit::Password);
|
m_passwordPromptInput->setEchoMode(QLineEdit::Password);
|
||||||
m_passwordPromptConnectButton = new QPushButton(QStringLiteral("Connect"), this);
|
m_passwordPromptConnectButton = new QPushButton(QStringLiteral("Connect"), this);
|
||||||
m_passwordPromptCancelButton = new QPushButton(QStringLiteral("Cancel"), this);
|
m_passwordPromptCancelButton = new QPushButton(QStringLiteral("Cancel"), this);
|
||||||
|
passwordPromptLayout->addWidget(m_passwordPromptIcon);
|
||||||
passwordPromptLayout->addWidget(m_passwordPromptLabel);
|
passwordPromptLayout->addWidget(m_passwordPromptLabel);
|
||||||
passwordPromptLayout->addWidget(m_passwordPromptInput, 1);
|
passwordPromptLayout->addWidget(m_passwordPromptInput, 1);
|
||||||
passwordPromptLayout->addWidget(m_passwordPromptConnectButton);
|
passwordPromptLayout->addWidget(m_passwordPromptConnectButton);
|
||||||
passwordPromptLayout->addWidget(m_passwordPromptCancelButton);
|
passwordPromptLayout->addWidget(m_passwordPromptCancelButton);
|
||||||
|
passwordPromptLayout->setContentsMargins(10, 8, 10, 8);
|
||||||
|
|
||||||
m_passwordPromptBar = new QWidget(this);
|
m_passwordPromptBar = new QWidget(this);
|
||||||
|
m_passwordPromptBar->setObjectName(QStringLiteral("passwordPromptBar"));
|
||||||
m_passwordPromptBar->setLayout(passwordPromptLayout);
|
m_passwordPromptBar->setLayout(passwordPromptLayout);
|
||||||
m_passwordPromptBar->setAutoFillBackground(true);
|
m_passwordPromptBar->setAutoFillBackground(true);
|
||||||
m_passwordPromptBar->setVisible(false);
|
m_passwordPromptBar->setVisible(false);
|
||||||
|
m_passwordPromptBar->setStyleSheet(
|
||||||
|
QStringLiteral("QWidget#passwordPromptBar { background-color: %1; }"
|
||||||
|
"QWidget#passwordPromptBar QLabel#passwordPromptLabel "
|
||||||
|
"{ color: %2; font-weight: bold; font-size: 11pt; }")
|
||||||
|
.arg(highlight.name(), highlightedText.name()));
|
||||||
rootLayout->addWidget(m_passwordPromptBar);
|
rootLayout->addWidget(m_passwordPromptBar);
|
||||||
|
|
||||||
connect(m_passwordPromptConnectButton, &QPushButton::clicked, this, [this]() {
|
connect(m_passwordPromptConnectButton, &QPushButton::clicked, this, [this]() {
|
||||||
@@ -978,11 +1061,35 @@ void SessionTab::requestConnectOptions(
|
|||||||
{
|
{
|
||||||
SessionConnectOptions baseOptions;
|
SessionConnectOptions baseOptions;
|
||||||
baseOptions.knownHostsPolicy = m_profile.knownHostsPolicy;
|
baseOptions.knownHostsPolicy = m_profile.knownHostsPolicy;
|
||||||
|
// The backend's own Profile copy was captured when it was constructed
|
||||||
|
// and never sees later edits to m_profile (e.g. the username prompt
|
||||||
|
// below) -- it has to travel through here instead.
|
||||||
|
baseOptions.username = m_profile.username.trimmed();
|
||||||
|
|
||||||
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
|
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
|
||||||
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
|
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
|
||||||
const bool isVnc = m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0;
|
const bool isVnc = m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0;
|
||||||
|
|
||||||
|
// SSH and RDP always need a username; a profile is now allowed to
|
||||||
|
// leave it blank (see profile_dialog.cpp) and get asked here instead,
|
||||||
|
// the same way a blank password is already handled below. The value
|
||||||
|
// is kept on this in-memory m_profile copy for the rest of the tab's
|
||||||
|
// lifetime, not written back to the saved profile.
|
||||||
|
if ((isSsh || isRdp) && m_profile.username.trimmed().isEmpty()) {
|
||||||
|
showPasswordPrompt(
|
||||||
|
QStringLiteral("%1 username for %2:").arg(m_profile.protocol, m_profile.host),
|
||||||
|
[this, callback](std::optional<QString> username) {
|
||||||
|
if (!username.has_value() || username->trimmed().isEmpty()) {
|
||||||
|
callback(std::nullopt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_profile.username = username->trimmed();
|
||||||
|
requestConnectOptions(callback);
|
||||||
|
},
|
||||||
|
false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isVnc) {
|
if (isVnc) {
|
||||||
// Unlike RDP, an empty password is allowed through: some VNC
|
// Unlike RDP, an empty password is allowed through: some VNC
|
||||||
// servers (no-auth) don't need one at all, and there's no
|
// servers (no-auth) don't need one at all, and there's no
|
||||||
@@ -1102,7 +1209,8 @@ void SessionTab::requestConnectOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::showPasswordPrompt(const QString& labelText,
|
void SessionTab::showPasswordPrompt(const QString& labelText,
|
||||||
std::function<void(std::optional<QString>)> callback)
|
std::function<void(std::optional<QString>)> callback,
|
||||||
|
bool maskInput)
|
||||||
{
|
{
|
||||||
if (m_passwordPromptCallback) {
|
if (m_passwordPromptCallback) {
|
||||||
const auto previousCallback = m_passwordPromptCallback;
|
const auto previousCallback = m_passwordPromptCallback;
|
||||||
@@ -1113,14 +1221,27 @@ void SessionTab::showPasswordPrompt(const QString& labelText,
|
|||||||
m_passwordPromptCallback = std::move(callback);
|
m_passwordPromptCallback = std::move(callback);
|
||||||
m_passwordPromptLabel->setText(labelText);
|
m_passwordPromptLabel->setText(labelText);
|
||||||
m_passwordPromptInput->clear();
|
m_passwordPromptInput->clear();
|
||||||
|
m_passwordPromptInput->setEchoMode(maskInput ? QLineEdit::Password : QLineEdit::Normal);
|
||||||
m_passwordPromptBar->setVisible(true);
|
m_passwordPromptBar->setVisible(true);
|
||||||
m_passwordPromptInput->setFocus();
|
m_passwordPromptInput->setFocus();
|
||||||
|
|
||||||
|
if (!m_awaitingUserInput) {
|
||||||
|
m_awaitingUserInput = true;
|
||||||
|
emit awaitingUserInputChanged(true);
|
||||||
|
emit tabTitleChanged(tabTitle());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::hidePasswordPrompt()
|
void SessionTab::hidePasswordPrompt()
|
||||||
{
|
{
|
||||||
m_passwordPromptBar->setVisible(false);
|
m_passwordPromptBar->setVisible(false);
|
||||||
m_passwordPromptCallback = nullptr;
|
m_passwordPromptCallback = nullptr;
|
||||||
|
|
||||||
|
if (m_awaitingUserInput) {
|
||||||
|
m_awaitingUserInput = false;
|
||||||
|
emit awaitingUserInputChanged(false);
|
||||||
|
emit tabTitleChanged(tabTitle());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SessionTab::validateProfileForConnect()
|
bool SessionTab::validateProfileForConnect()
|
||||||
@@ -1139,14 +1260,10 @@ bool SessionTab::validateProfileForConnect()
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0
|
// SSH/RDP no longer hard-require a username here -- a blank one is
|
||||||
|| m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0)
|
// handled by requestConnectOptions() prompting for it inline at connect
|
||||||
&& m_profile.username.trimmed().isEmpty()) {
|
// time (see issue #21). Do not re-add a check here without also
|
||||||
QMessageBox::warning(this,
|
// updating that flow.
|
||||||
QStringLiteral("Connect"),
|
|
||||||
QStringLiteral("%1 username is required.").arg(m_profile.protocol));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -67,10 +67,16 @@ public:
|
|||||||
bool supportsVncScaleToggle() const;
|
bool supportsVncScaleToggle() const;
|
||||||
void setVncScaleToFit(bool scaleToFit);
|
void setVncScaleToFit(bool scaleToFit);
|
||||||
bool vncScaleToFit() const;
|
bool vncScaleToFit() const;
|
||||||
|
bool awaitingUserInput() const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void tabTitleChanged(const QString& title);
|
void tabTitleChanged(const QString& title);
|
||||||
void tabStateChanged(SessionState state);
|
void tabStateChanged(SessionState state);
|
||||||
|
// Fires whenever the inline username/password prompt bar is shown or
|
||||||
|
// hidden -- independent of tabStateChanged(), since the backend is
|
||||||
|
// still just "Connecting" while it's up. Lets SessionWindow mark a
|
||||||
|
// background tab that needs the user's attention (issue #22).
|
||||||
|
void awaitingUserInputChanged(bool waiting);
|
||||||
void terminalThemeChanged(const QString& themeName);
|
void terminalThemeChanged(const QString& themeName);
|
||||||
void terminalFontSizeChanged(int pointSize);
|
void terminalFontSizeChanged(int pointSize);
|
||||||
void eventsPanelVisibilityChanged(bool expanded);
|
void eventsPanelVisibilityChanged(bool expanded);
|
||||||
@@ -91,6 +97,7 @@ signals:
|
|||||||
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
|
void requestMouseButtonEvent(int x, int y, int button, bool pressed);
|
||||||
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
|
void requestMouseWheelEvent(int x, int y, int deltaX, int deltaY);
|
||||||
void requestSetClipboardText(const QString& text);
|
void requestSetClipboardText(const QString& text);
|
||||||
|
void requestProvideUsername(const QString& username);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onBackendStateChanged(SessionState state, const QString& message);
|
void onBackendStateChanged(SessionState state, const QString& message);
|
||||||
@@ -98,6 +105,7 @@ private slots:
|
|||||||
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
||||||
void onBackendOutputReceived(const QString& text);
|
void onBackendOutputReceived(const QString& text);
|
||||||
void onBackendHostKeyConfirmationRequested(const QString& prompt);
|
void onBackendHostKeyConfirmationRequested(const QString& prompt);
|
||||||
|
void onBackendUsernameRequested(const QString& prompt);
|
||||||
void onBackendRemoteClipboardTextChanged(const QString& text);
|
void onBackendRemoteClipboardTextChanged(const QString& text);
|
||||||
void onSystemClipboardChanged();
|
void onSystemClipboardChanged();
|
||||||
|
|
||||||
@@ -127,11 +135,13 @@ private:
|
|||||||
QToolButton* m_exportEventsButton;
|
QToolButton* m_exportEventsButton;
|
||||||
QWidget* m_eventsPanel;
|
QWidget* m_eventsPanel;
|
||||||
QWidget* m_passwordPromptBar;
|
QWidget* m_passwordPromptBar;
|
||||||
|
QLabel* m_passwordPromptIcon;
|
||||||
QLabel* m_passwordPromptLabel;
|
QLabel* m_passwordPromptLabel;
|
||||||
QLineEdit* m_passwordPromptInput;
|
QLineEdit* m_passwordPromptInput;
|
||||||
QPushButton* m_passwordPromptConnectButton;
|
QPushButton* m_passwordPromptConnectButton;
|
||||||
QPushButton* m_passwordPromptCancelButton;
|
QPushButton* m_passwordPromptCancelButton;
|
||||||
std::function<void(std::optional<QString>)> m_passwordPromptCallback;
|
std::function<void(std::optional<QString>)> m_passwordPromptCallback;
|
||||||
|
bool m_awaitingUserInput;
|
||||||
enum class EventSeverity {
|
enum class EventSeverity {
|
||||||
Info,
|
Info,
|
||||||
Warning,
|
Warning,
|
||||||
@@ -149,7 +159,8 @@ private:
|
|||||||
void setupUi();
|
void setupUi();
|
||||||
void requestConnectOptions(std::function<void(std::optional<SessionConnectOptions>)> callback);
|
void requestConnectOptions(std::function<void(std::optional<SessionConnectOptions>)> callback);
|
||||||
void showPasswordPrompt(const QString& labelText,
|
void showPasswordPrompt(const QString& labelText,
|
||||||
std::function<void(std::optional<QString>)> callback);
|
std::function<void(std::optional<QString>)> callback,
|
||||||
|
bool maskInput = true);
|
||||||
void hidePasswordPrompt();
|
void hidePasswordPrompt();
|
||||||
bool validateProfileForConnect();
|
bool validateProfileForConnect();
|
||||||
void appendEvent(const QString& message);
|
void appendEvent(const QString& message);
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ QColor tabColorForState(SessionState state, const QPalette& palette)
|
|||||||
return palette.color(QPalette::WindowText);
|
return palette.color(QPalette::WindowText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distinct from all four tabColorForState() colors -- a tab awaiting a
|
||||||
|
// username/password prompt response needs to stand out even from a
|
||||||
|
// tab that's merely "Connecting" (issue #22), including when it isn't
|
||||||
|
// the one currently in view. #6a1b9a (a much darker violet) was tried
|
||||||
|
// first and reported unreadable against a dark-theme tab bar -- its
|
||||||
|
// perceived luminance is well below the other three colors above, which
|
||||||
|
// this one is tuned to roughly match so it reads about as well as they
|
||||||
|
// do in both light and dark themes.
|
||||||
|
QColor awaitingInputTabColor()
|
||||||
|
{
|
||||||
|
return QColor(QStringLiteral("#ab47bc"));
|
||||||
|
}
|
||||||
|
|
||||||
QStringList terminalThemeNames()
|
QStringList terminalThemeNames()
|
||||||
{
|
{
|
||||||
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
|
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
|
||||||
@@ -57,6 +70,7 @@ SessionWindow::SessionWindow(QWidget* parent)
|
|||||||
QWidget* tab = m_tabs->widget(index);
|
QWidget* tab = m_tabs->widget(index);
|
||||||
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
|
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
|
||||||
sessionTab->disconnectSession();
|
sessionTab->disconnectSession();
|
||||||
|
m_tabStates.remove(sessionTab);
|
||||||
}
|
}
|
||||||
m_tabs->removeTab(index);
|
m_tabs->removeTab(index);
|
||||||
delete tab;
|
delete tab;
|
||||||
@@ -261,6 +275,7 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
|||||||
} else {
|
} else {
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
||||||
}
|
}
|
||||||
|
m_tabStates.insert(tab, SessionState::Disconnected);
|
||||||
m_tabs->tabBar()->setTabTextColor(
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
||||||
|
|
||||||
@@ -272,6 +287,13 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
|||||||
&SessionTab::tabStateChanged,
|
&SessionTab::tabStateChanged,
|
||||||
this,
|
this,
|
||||||
[this, tab](SessionState state) {
|
[this, tab](SessionState state) {
|
||||||
|
m_tabStates.insert(tab, state);
|
||||||
|
if (tab->awaitingUserInput()) {
|
||||||
|
// Keep the "needs input" color on top -- it'll be
|
||||||
|
// restored to reflect this state once the prompt
|
||||||
|
// resolves (see awaitingUserInputChanged below).
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (int i = 0; i < m_tabs->count(); ++i) {
|
for (int i = 0; i < m_tabs->count(); ++i) {
|
||||||
if (m_tabs->widget(i) == tab) {
|
if (m_tabs->widget(i) == tab) {
|
||||||
m_tabs->tabBar()->setTabTextColor(
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
@@ -280,6 +302,22 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
connect(tab,
|
||||||
|
&SessionTab::awaitingUserInputChanged,
|
||||||
|
this,
|
||||||
|
[this, tab](bool waiting) {
|
||||||
|
for (int i = 0; i < m_tabs->count(); ++i) {
|
||||||
|
if (m_tabs->widget(i) != tab) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QColor color = waiting
|
||||||
|
? awaitingInputTabColor()
|
||||||
|
: tabColorForState(m_tabStates.value(tab, SessionState::Disconnected),
|
||||||
|
m_tabs->palette());
|
||||||
|
m_tabs->tabBar()->setTabTextColor(i, color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
connect(tab,
|
connect(tab,
|
||||||
&SessionTab::terminalThemeChanged,
|
&SessionTab::terminalThemeChanged,
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
#include "session_tab.h"
|
#include "session_tab.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
|
||||||
class QTabWidget;
|
class QTabWidget;
|
||||||
@@ -21,6 +22,10 @@ private:
|
|||||||
QTabWidget* m_tabs;
|
QTabWidget* m_tabs;
|
||||||
ProfilesWindow* m_profilesWidget;
|
ProfilesWindow* m_profilesWidget;
|
||||||
SessionUiPreferences m_preferences;
|
SessionUiPreferences m_preferences;
|
||||||
|
// Last known connection state per tab, so the tab color can be
|
||||||
|
// restored correctly once an awaitingUserInputChanged(false) fires
|
||||||
|
// (that signal is orthogonal to SessionState -- see session_tab.h).
|
||||||
|
QHash<SessionTab*, SessionState> m_tabStates;
|
||||||
|
|
||||||
void addSessionTab(const Profile& profile);
|
void addSessionTab(const Profile& profile);
|
||||||
void updateTabTitle(SessionTab* tab, const QString& title);
|
void updateTabTitle(SessionTab* tab, const QString& title);
|
||||||
|
|||||||
@@ -388,9 +388,16 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
|
|||||||
<< QStringLiteral("PasswordAuthentication=no");
|
<< QStringLiteral("PasswordAuthentication=no");
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString target = p.username.trimmed().isEmpty()
|
// options.username carries a value prompted for at connect time (see
|
||||||
|
// SessionTab::requestConnectOptions()) when the saved profile's own
|
||||||
|
// username was blank; profile().username never sees that edit since
|
||||||
|
// the backend's Profile copy was captured at construction time.
|
||||||
|
const QString username = options.username.trimmed().isEmpty()
|
||||||
|
? p.username.trimmed()
|
||||||
|
: options.username.trimmed();
|
||||||
|
const QString target = username.isEmpty()
|
||||||
? p.host.trimmed()
|
? p.host.trimmed()
|
||||||
: QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed());
|
: QStringLiteral("%1@%2").arg(username, p.host.trimmed());
|
||||||
args << target;
|
args << target;
|
||||||
|
|
||||||
m_process->setProcessEnvironment(environment);
|
m_process->setProcessEnvironment(environment);
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ VncSessionBackend::VncSessionBackend(const Profile& profile, QObject* parent)
|
|||||||
m_tightCompressionMode(0),
|
m_tightCompressionMode(0),
|
||||||
m_tightFilterId(0),
|
m_tightFilterId(0),
|
||||||
m_tightLengthByteIndex(0),
|
m_tightLengthByteIndex(0),
|
||||||
m_appleAuthKeyLength(0)
|
m_appleAuthKeyLength(0),
|
||||||
|
m_waitingForUsername(false)
|
||||||
{
|
{
|
||||||
std::memset(m_zrleInflateStream, 0, sizeof(z_stream_s));
|
std::memset(m_zrleInflateStream, 0, sizeof(z_stream_s));
|
||||||
for (z_stream_s* stream : m_tightInflateStreams) {
|
for (z_stream_s* stream : m_tightInflateStreams) {
|
||||||
@@ -531,6 +532,8 @@ void VncSessionBackend::resetProtocolState()
|
|||||||
}
|
}
|
||||||
m_appleAuthGenerator.clear();
|
m_appleAuthGenerator.clear();
|
||||||
m_appleAuthKeyLength = 0;
|
m_appleAuthKeyLength = 0;
|
||||||
|
m_waitingForUsername = false;
|
||||||
|
m_promptedUsername.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool VncSessionBackend::haveBytes(int count) const
|
bool VncSessionBackend::haveBytes(int count) const
|
||||||
@@ -671,6 +674,47 @@ void VncSessionBackend::sendAppleRsaHostKeyRequest()
|
|||||||
m_socket->write(msg);
|
m_socket->write(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString VncSessionBackend::effectiveUsername() const
|
||||||
|
{
|
||||||
|
const QString profileUsername = profile().username.trimmed();
|
||||||
|
return profileUsername.isEmpty() ? m_promptedUsername : profileUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VncSessionBackend::ensureUsernameAvailable()
|
||||||
|
{
|
||||||
|
if (!effectiveUsername().isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!m_waitingForUsername) {
|
||||||
|
m_waitingForUsername = true;
|
||||||
|
emit usernameRequested(
|
||||||
|
QStringLiteral("A username is required for %1's authentication method:")
|
||||||
|
.arg(profile().host));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VncSessionBackend::provideUsername(const QString& username)
|
||||||
|
{
|
||||||
|
if (!m_waitingForUsername) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_waitingForUsername = false;
|
||||||
|
if (m_state != SessionState::Connecting) {
|
||||||
|
// The connection already failed or was torn down while the
|
||||||
|
// prompt was pending -- nothing left to resume.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_promptedUsername = username.trimmed();
|
||||||
|
if (m_promptedUsername.isEmpty()) {
|
||||||
|
failConnection(
|
||||||
|
QStringLiteral("A username is required for this VNC server's authentication method."),
|
||||||
|
QStringLiteral("Username prompt was cancelled or left empty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processReceiveBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
void VncSessionBackend::finishHandshakeIntoRunningState()
|
void VncSessionBackend::finishHandshakeIntoRunningState()
|
||||||
{
|
{
|
||||||
emit remoteDesktopSizeChanged(m_framebuffer.width(), m_framebuffer.height());
|
emit remoteDesktopSizeChanged(m_framebuffer.width(), m_framebuffer.height());
|
||||||
@@ -963,13 +1007,20 @@ void VncSessionBackend::processReceiveBuffer()
|
|||||||
if (!haveBytes(static_cast<int>(m_pendingLength))) {
|
if (!haveBytes(static_cast<int>(m_pendingLength))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Checked before consuming any bytes: if a username is needed
|
||||||
|
// and not yet available, pause here (leaving m_recvBuffer
|
||||||
|
// untouched) until provideUsername() resumes us and this same
|
||||||
|
// case re-parses identically.
|
||||||
|
if (!ensureUsernameAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const int keyLength = static_cast<int>(m_appleAuthKeyLength);
|
const int keyLength = static_cast<int>(m_appleAuthKeyLength);
|
||||||
const QByteArray prime = m_recvBuffer.left(keyLength);
|
const QByteArray prime = m_recvBuffer.left(keyLength);
|
||||||
const QByteArray serverPublicKey = m_recvBuffer.mid(keyLength, keyLength);
|
const QByteArray serverPublicKey = m_recvBuffer.mid(keyLength, keyLength);
|
||||||
m_recvBuffer.remove(0, static_cast<int>(m_pendingLength));
|
m_recvBuffer.remove(0, static_cast<int>(m_pendingLength));
|
||||||
|
|
||||||
const VncAppleDhAuth::Response response = VncAppleDhAuth::computeResponse(
|
const VncAppleDhAuth::Response response = VncAppleDhAuth::computeResponse(
|
||||||
m_appleAuthGenerator, prime, serverPublicKey, profile().username,
|
m_appleAuthGenerator, prime, serverPublicKey, effectiveUsername(),
|
||||||
m_activeOptions.password);
|
m_activeOptions.password);
|
||||||
if (response.clientPublicKey.isEmpty()) {
|
if (response.clientPublicKey.isEmpty()) {
|
||||||
failConnection(
|
failConnection(
|
||||||
@@ -1020,11 +1071,15 @@ void VncSessionBackend::processReceiveBuffer()
|
|||||||
if (!haveBytes(totalBytes)) {
|
if (!haveBytes(totalBytes)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// See the matching comment in WaitingAppleAuthPrimeAndServerKey.
|
||||||
|
if (!ensureUsernameAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const QByteArray hostKeyDer = m_recvBuffer.left(static_cast<int>(m_pendingLength));
|
const QByteArray hostKeyDer = m_recvBuffer.left(static_cast<int>(m_pendingLength));
|
||||||
m_recvBuffer.remove(0, totalBytes);
|
m_recvBuffer.remove(0, totalBytes);
|
||||||
|
|
||||||
const VncAppleRsaAuth::Response response = VncAppleRsaAuth::computeResponse(
|
const VncAppleRsaAuth::Response response = VncAppleRsaAuth::computeResponse(
|
||||||
hostKeyDer, profile().username, m_activeOptions.password);
|
hostKeyDer, effectiveUsername(), m_activeOptions.password);
|
||||||
if (response.encryptedCredentials.isEmpty() || response.encryptedAesKey.isEmpty()) {
|
if (response.encryptedCredentials.isEmpty() || response.encryptedAesKey.isEmpty()) {
|
||||||
failConnection(
|
failConnection(
|
||||||
QStringLiteral(
|
QStringLiteral(
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ public slots:
|
|||||||
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;
|
void setClipboardText(const QString& text) override;
|
||||||
|
void provideUsername(const QString& username) override;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onSocketConnected();
|
void onSocketConnected();
|
||||||
@@ -209,6 +210,16 @@ private:
|
|||||||
QByteArray m_appleAuthGenerator;
|
QByteArray m_appleAuthGenerator;
|
||||||
quint32 m_appleAuthKeyLength;
|
quint32 m_appleAuthKeyLength;
|
||||||
|
|
||||||
|
// Apple auth (types 30 and 33) is the only case where VNC ever needs a
|
||||||
|
// username -- plain VNC Authentication and no-auth never do, so this
|
||||||
|
// isn't asked for upfront. If profile().username is empty when an
|
||||||
|
// Apple-auth response is about to be computed, processReceiveBuffer()
|
||||||
|
// pauses (leaving the already-buffered bytes untouched so re-entry
|
||||||
|
// re-parses them identically) and emits usernameRequested(); resumed
|
||||||
|
// by provideUsername() once SessionTab answers.
|
||||||
|
bool m_waitingForUsername;
|
||||||
|
QString m_promptedUsername;
|
||||||
|
|
||||||
void setState(SessionState state, const QString& message);
|
void setState(SessionState state, const QString& message);
|
||||||
void resetProtocolState();
|
void resetProtocolState();
|
||||||
void processReceiveBuffer();
|
void processReceiveBuffer();
|
||||||
@@ -227,6 +238,12 @@ private:
|
|||||||
QRect currentHextileTileRect() const;
|
QRect currentHextileTileRect() const;
|
||||||
void advanceHextileTile();
|
void advanceHextileTile();
|
||||||
bool inflateTightStream(int streamIndex, const QByteArray& compressed, QByteArray* decompressed);
|
bool inflateTightStream(int streamIndex, const QByteArray& compressed, QByteArray* decompressed);
|
||||||
|
QString effectiveUsername() const;
|
||||||
|
// Returns false (and pauses, emitting usernameRequested() at most once
|
||||||
|
// until provideUsername() resumes processing) if a username is needed
|
||||||
|
// but not yet available. Callers must return from processReceiveBuffer()
|
||||||
|
// immediately when this returns false, without consuming any bytes.
|
||||||
|
bool ensureUsernameAvailable();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Vendored
+16
@@ -6,6 +6,22 @@
|
|||||||
# host, optionally as user@host, as the final argument).
|
# host, optionally as user@host, as the final argument).
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
|
prompted-user@requireuser)
|
||||||
|
# Only the exact user@host below is accepted -- used to prove a
|
||||||
|
# username supplied via SessionConnectOptions (prompted for at
|
||||||
|
# connect time because the saved profile's own username was
|
||||||
|
# blank) actually reaches the ssh command line, not just that
|
||||||
|
# *some* connection to this host succeeds.
|
||||||
|
echo "Welcome to the fake host."
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "$line"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*@requireuser|requireuser)
|
||||||
|
echo "Permission denied (publickey,password)." >&2
|
||||||
|
exit 255
|
||||||
|
;;
|
||||||
*@succeed|succeed)
|
*@succeed|succeed)
|
||||||
echo "Welcome to the fake host."
|
echo "Welcome to the fake host."
|
||||||
# Stay alive echoing stdin back (simulates an interactive
|
# Stay alive echoing stdin back (simulates an interactive
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ private slots:
|
|||||||
void createProfileRejectsMissingName();
|
void createProfileRejectsMissingName();
|
||||||
void createProfileRejectsMissingHost();
|
void createProfileRejectsMissingHost();
|
||||||
void createProfileRejectsInvalidPort();
|
void createProfileRejectsInvalidPort();
|
||||||
void createProfileRejectsMissingUsernameForSsh();
|
void createProfileAllowsMissingUsernameForSsh();
|
||||||
void createProfileRejectsMissingPrivateKeyForKeyAuth();
|
void createProfileRejectsMissingPrivateKeyForKeyAuth();
|
||||||
void createProfileRejectsDuplicateName();
|
void createProfileRejectsDuplicateName();
|
||||||
void updateProfilePersistsChanges();
|
void updateProfilePersistsChanges();
|
||||||
@@ -156,11 +156,17 @@ void TestProfileRepository::createProfileRejectsInvalidPort()
|
|||||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
void TestProfileRepository::createProfileRejectsMissingUsernameForSsh()
|
void TestProfileRepository::createProfileAllowsMissingUsernameForSsh()
|
||||||
{
|
{
|
||||||
|
// SSH/RDP no longer require a username at save time (issue #21) --
|
||||||
|
// among other things, this unblocks importing mRemoteNG entries that
|
||||||
|
// don't have one recorded, which used to fail outright. The user is
|
||||||
|
// asked for it at connect time instead (see SessionTab).
|
||||||
Profile profile = makeSshProfile();
|
Profile profile = makeSshProfile();
|
||||||
profile.username.clear();
|
profile.username.clear();
|
||||||
QVERIFY(!m_repo->createProfile(profile).has_value());
|
const auto created = m_repo->createProfile(profile);
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
QVERIFY(created->username.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
|
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ Profile makeProfile(const QString& fixtureHost)
|
|||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Profile makeBlankUsernameProfile(const QString& fixtureHost)
|
||||||
|
{
|
||||||
|
Profile profile = makeProfile(fixtureHost);
|
||||||
|
profile.username.clear();
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
SessionConnectOptions makeOptions()
|
SessionConnectOptions makeOptions()
|
||||||
{
|
{
|
||||||
SessionConnectOptions options;
|
SessionConnectOptions options;
|
||||||
@@ -50,10 +57,12 @@ private slots:
|
|||||||
void connectionRefusedReachesFailedState();
|
void connectionRefusedReachesFailedState();
|
||||||
void sendInputEchoesThroughOutputReceived();
|
void sendInputEchoesThroughOutputReceived();
|
||||||
void reconnectRestartsAndReachesConnectedAgain();
|
void reconnectRestartsAndReachesConnectedAgain();
|
||||||
|
void connectOptionsUsernameReachesProcessWhenProfileUsernameIsBlank();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QString fixturePath() const;
|
QString fixturePath() const;
|
||||||
void createBackend(const QString& fixtureHost);
|
void createBackend(const QString& fixtureHost);
|
||||||
|
void createBackend(const Profile& profile);
|
||||||
|
|
||||||
std::unique_ptr<SshSessionBackend> m_backend;
|
std::unique_ptr<SshSessionBackend> m_backend;
|
||||||
SessionState m_lastState = SessionState::Disconnected;
|
SessionState m_lastState = SessionState::Disconnected;
|
||||||
@@ -69,8 +78,12 @@ QString TestSshSessionBackend::fixturePath() const
|
|||||||
|
|
||||||
void TestSshSessionBackend::createBackend(const QString& fixtureHost)
|
void TestSshSessionBackend::createBackend(const QString& fixtureHost)
|
||||||
{
|
{
|
||||||
m_backend =
|
createBackend(makeProfile(fixtureHost));
|
||||||
std::make_unique<SshSessionBackend>(makeProfile(fixtureHost), fixturePath(), nullptr);
|
}
|
||||||
|
|
||||||
|
void TestSshSessionBackend::createBackend(const Profile& profile)
|
||||||
|
{
|
||||||
|
m_backend = std::make_unique<SshSessionBackend>(profile, fixturePath(), nullptr);
|
||||||
connect(m_backend.get(),
|
connect(m_backend.get(),
|
||||||
&SessionBackend::stateChanged,
|
&SessionBackend::stateChanged,
|
||||||
this,
|
this,
|
||||||
@@ -232,5 +245,24 @@ void TestSshSessionBackend::reconnectRestartsAndReachesConnectedAgain()
|
|||||||
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestSshSessionBackend::connectOptionsUsernameReachesProcessWhenProfileUsernameIsBlank()
|
||||||
|
{
|
||||||
|
// Regression test for a bug where a username entered at the
|
||||||
|
// connect-time prompt (SessionTab::requestConnectOptions(), for a
|
||||||
|
// profile with no saved username -- issue #21) never actually reached
|
||||||
|
// the ssh process: SshSessionBackend built its target purely from
|
||||||
|
// profile().username, which is a separate copy captured when the
|
||||||
|
// backend was constructed and never sees SessionTab's later edit.
|
||||||
|
// fixtures/fake_ssh.sh's "requireuser" host only accepts the exact
|
||||||
|
// target "prompted-user@requireuser", so this fails unless
|
||||||
|
// SessionConnectOptions::username is actually used.
|
||||||
|
createBackend(makeBlankUsernameProfile(QStringLiteral("requireuser")));
|
||||||
|
|
||||||
|
SessionConnectOptions options = makeOptions();
|
||||||
|
options.username = QStringLiteral("prompted-user");
|
||||||
|
m_backend->connectSession(options);
|
||||||
|
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||||
|
}
|
||||||
|
|
||||||
QTEST_GUILESS_MAIN(TestSshSessionBackend)
|
QTEST_GUILESS_MAIN(TestSshSessionBackend)
|
||||||
#include "test_ssh_session_backend.moc"
|
#include "test_ssh_session_backend.moc"
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ private slots:
|
|||||||
void connectsWithAppleDhAuthenticationRfb38();
|
void connectsWithAppleDhAuthenticationRfb38();
|
||||||
void appleDhAuthIsPreferredOverVncAuthWhenBothOffered();
|
void appleDhAuthIsPreferredOverVncAuthWhenBothOffered();
|
||||||
void appleDhAuthAcceptsRealCapturedMacOsServerParameters();
|
void appleDhAuthAcceptsRealCapturedMacOsServerParameters();
|
||||||
|
void cancellingUsernamePromptFailsConnectionCleanly();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unique_ptr<FakeVncServer> m_server;
|
std::unique_ptr<FakeVncServer> m_server;
|
||||||
@@ -2036,8 +2037,20 @@ void TestVncSessionBackend::connectsWithAppleDhAuthenticationRfb38()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// makeVncProfile() sets no username, and Apple DH is the one VNC auth
|
||||||
|
// method that needs one -- expect the backend to pause and ask,
|
||||||
|
// exactly like a real profile with a blank username would hit at
|
||||||
|
// connect time (see issue #21 / SessionBackend::usernameRequested).
|
||||||
|
QString requestedPrompt;
|
||||||
|
connect(m_backend.get(), &SessionBackend::usernameRequested, this,
|
||||||
|
[this, &requestedPrompt](const QString& prompt) {
|
||||||
|
requestedPrompt = prompt;
|
||||||
|
m_backend->provideUsername(QStringLiteral("tester"));
|
||||||
|
});
|
||||||
|
|
||||||
m_backend->connectSession(makeOptions(password));
|
m_backend->connectSession(makeOptions(password));
|
||||||
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
||||||
|
QVERIFY(!requestedPrompt.isEmpty());
|
||||||
|
|
||||||
// Verify wire order: encrypted credentials (128 bytes) MUST come
|
// Verify wire order: encrypted credentials (128 bytes) MUST come
|
||||||
// before the client's public key, per neatvnc's authoritative
|
// before the client's public key, per neatvnc's authoritative
|
||||||
@@ -2085,7 +2098,9 @@ void TestVncSessionBackend::connectsWithAppleDhAuthenticationRfb38()
|
|||||||
EVP_CIPHER_CTX_free(decCtx);
|
EVP_CIPHER_CTX_free(decCtx);
|
||||||
|
|
||||||
QByteArray expected(128, char(0));
|
QByteArray expected(128, char(0));
|
||||||
|
const QByteArray userBytes = QByteArrayLiteral("tester").left(64);
|
||||||
const QByteArray passBytes = password.toLatin1().left(64);
|
const QByteArray passBytes = password.toLatin1().left(64);
|
||||||
|
std::memcpy(expected.data(), userBytes.constData(), static_cast<size_t>(userBytes.size()));
|
||||||
std::memcpy(expected.data() + 64, passBytes.constData(), static_cast<size_t>(passBytes.size()));
|
std::memcpy(expected.data() + 64, passBytes.constData(), static_cast<size_t>(passBytes.size()));
|
||||||
QCOMPARE(plain, expected);
|
QCOMPARE(plain, expected);
|
||||||
|
|
||||||
@@ -2172,5 +2187,51 @@ void TestVncSessionBackend::appleDhAuthAcceptsRealCapturedMacOsServerParameters(
|
|||||||
QCOMPARE(response.encryptedCredentials.size(), 128);
|
QCOMPARE(response.encryptedCredentials.size(), 128);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestVncSessionBackend::cancellingUsernamePromptFailsConnectionCleanly()
|
||||||
|
{
|
||||||
|
ToyDhKeypair serverKeypair = generateToyDhKeypair();
|
||||||
|
const QByteArray authMessage = appleDhAuthServerMessage(
|
||||||
|
serverKeypair.generatorBytes, serverKeypair.primeBytes, serverKeypair.publicKeyBytes);
|
||||||
|
|
||||||
|
bool gotUsernameRequest = false;
|
||||||
|
connect(m_backend.get(), &SessionBackend::usernameRequested, this,
|
||||||
|
[this, &gotUsernameRequest](const QString&) {
|
||||||
|
gotUsernameRequest = true;
|
||||||
|
// An empty response mirrors the prompt being cancelled
|
||||||
|
// (see SessionTab::onBackendUsernameRequested).
|
||||||
|
m_backend->provideUsername(QString());
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_server.get(), &FakeVncServer::clientConnected, this, [this]() {
|
||||||
|
m_server->sendWhenConnected(QByteArray("RFB 003.008\n"));
|
||||||
|
});
|
||||||
|
connect(m_server.get(), &FakeVncServer::dataReceived, this, [this, authMessage]() {
|
||||||
|
switch (m_server->nextStep()) {
|
||||||
|
case 0: {
|
||||||
|
QByteArray securityTypes;
|
||||||
|
securityTypes.append(char(1));
|
||||||
|
securityTypes.append(char(30)); // Apple's scheme -- no other option offered
|
||||||
|
m_server->sendWhenConnected(securityTypes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
m_server->sendWhenConnected(authMessage);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// No username in the profile (makeVncProfile() sets none) and no
|
||||||
|
// other security type to fall back to.
|
||||||
|
m_backend->connectSession(makeOptions(QStringLiteral("s3cret-pass")));
|
||||||
|
QTRY_COMPARE(m_lastState, SessionState::Failed);
|
||||||
|
QVERIFY(gotUsernameRequest);
|
||||||
|
QVERIFY(m_lastErrorDisplay.contains(QStringLiteral("username"), Qt::CaseInsensitive));
|
||||||
|
|
||||||
|
BN_free(serverKeypair.privateExponent);
|
||||||
|
BN_free(serverKeypair.prime);
|
||||||
|
}
|
||||||
|
|
||||||
QTEST_GUILESS_MAIN(TestVncSessionBackend)
|
QTEST_GUILESS_MAIN(TestVncSessionBackend)
|
||||||
#include "test_vnc_session_backend.moc"
|
#include "test_vnc_session_backend.moc"
|
||||||
|
|||||||
Reference in New Issue
Block a user