Files
orbithub/src/session_tab.cpp
T
ksmithandClaude Sonnet 5 1506d87719 Make username/password connect prompts hard to miss (issue #22)
The inline prompt bar previously had no explicit styling and rendered
in the same color as the rest of the tab; a tab showing the prompt in
the background had no indication anything needed attention. The bar
now uses a solid QPalette::Highlight fill with HighlightedText for the
label and a hand-drawn contrasting badge, and a background tab gets a
"(Needs input)" title suffix plus a distinct tab-bar color.

A first pass at the tab color (#6a1b9a) was reported unreadable in
dark mode; replaced with #ab47bc, tuned to match the visibility of the
existing connection-state colors.

Bump version to v2026.9.16.6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:18:43 -06:00

1501 lines
52 KiB
C++

#include "session_tab.h"
#include "rdp_display_widget.h"
#include "session_backend_factory.h"
#include "terminal_view.h"
#include "vnc_display_widget.h"
#include <KodoTerm/KodoTerm.hpp>
#include <QDateTime>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QFont>
#include <QFontDatabase>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPlainTextEdit>
#include <QApplication>
#include <QClipboard>
#include <QMimeData>
#include <QColor>
#include <QComboBox>
#include <QProcessEnvironment>
#include <QPushButton>
#include <QScrollArea>
#include <QThread>
#include <QTimer>
#include <QToolButton>
#include <QTextStream>
#include <QVBoxLayout>
#include <memory>
namespace {
QFont defaultTerminalFont()
{
QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
font.setStyleHint(QFont::Monospace);
font.setFixedPitch(true);
font.setKerning(false);
font.setLetterSpacing(QFont::AbsoluteSpacing, 0.0);
return font;
}
TerminalTheme themeForName(const QString& themeName)
{
if (themeName.compare(QStringLiteral("Light"), Qt::CaseInsensitive) == 0) {
return TerminalTheme::loadKonsoleTheme(
QStringLiteral(":/KodoTermThemes/konsole/BlackOnWhite.colorscheme"));
}
if (themeName.compare(QStringLiteral("Solarized Dark"), Qt::CaseInsensitive) == 0) {
return TerminalTheme::loadKonsoleTheme(
QStringLiteral(":/KodoTermThemes/konsole/Solarized.colorscheme"));
}
return TerminalTheme::loadKonsoleTheme(
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,
const SessionUiPreferences& preferences,
QWidget* parent)
: QWidget(parent),
m_profile(profile),
m_backendThread(nullptr),
m_backend(nullptr),
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
== 0),
m_state(SessionState::Disconnected),
m_terminalThemeName(preferences.terminalThemeName.trimmed().isEmpty()
? QStringLiteral("Dark")
: preferences.terminalThemeName.trimmed()),
m_terminalFontPointSize(preferences.terminalFontPointSize > 0
? preferences.terminalFontPointSize
: 0),
m_clipboardSyncSupported(
profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
|| profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0),
m_sshTerminal(nullptr),
m_rdpDisplay(nullptr),
m_vncDisplay(nullptr),
m_vncScrollArea(nullptr),
m_terminalOutput(nullptr),
m_eventLog(nullptr),
m_toggleEventsButton(nullptr),
m_eventFilterInput(nullptr),
m_eventSeverityFilterInput(nullptr),
m_clearEventsButton(nullptr),
m_exportEventsButton(nullptr),
m_eventsPanel(nullptr),
m_passwordPromptBar(nullptr),
m_passwordPromptIcon(nullptr),
m_passwordPromptLabel(nullptr),
m_passwordPromptInput(nullptr),
m_passwordPromptConnectButton(nullptr),
m_passwordPromptCancelButton(nullptr),
m_awaitingUserInput(false),
m_eventSeverityFilter(EventSeverity::Info),
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
{
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState");
setupUi();
if (m_vncDisplay != nullptr) {
m_vncDisplay->setScaleToFit(preferences.vncScaleToFit);
}
if (m_useKodoTermForSsh) {
connect(m_sshTerminal,
&KodoTerm::finished,
this,
[this](int exitCode, int) {
if (m_state == SessionState::Disconnected) {
return;
}
if (m_state == SessionState::Connected) {
if (exitCode != 0) {
appendEvent(QStringLiteral("SSH session closed with exit code %1.")
.arg(exitCode));
}
setState(SessionState::Disconnected,
QStringLiteral("SSH session closed."));
return;
}
if (exitCode == 0) {
setState(SessionState::Disconnected,
QStringLiteral("SSH session ended."));
return;
}
m_lastError = QStringLiteral("ssh exited with code %1").arg(exitCode);
appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
setState(SessionState::Failed,
QStringLiteral("SSH session exited unexpectedly."));
});
connect(m_sshTerminal,
&KodoTerm::cwdChanged,
this,
[this](const QString& cwd) {
if (!cwd.trimmed().isEmpty()) {
appendEvent(QStringLiteral("Remote cwd: %1").arg(cwd));
}
});
} else {
m_backendThread = new QThread(this);
std::unique_ptr<SessionBackend> backend = createSessionBackend(m_profile);
m_backend = backend.release();
m_backend->moveToThread(m_backendThread);
connect(m_backendThread, &QThread::finished, m_backend, &QObject::deleteLater);
connect(this,
&SessionTab::requestConnect,
m_backend,
&SessionBackend::connectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestDisconnect,
m_backend,
&SessionBackend::disconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestReconnect,
m_backend,
&SessionBackend::reconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestInput,
m_backend,
&SessionBackend::sendInput,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestHostKeyConfirmation,
m_backend,
&SessionBackend::confirmHostKey,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestTerminalSize,
m_backend,
&SessionBackend::updateTerminalSize,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestDisplayScale,
m_backend,
&SessionBackend::updateDisplayScale,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestKeyEvent,
m_backend,
&SessionBackend::sendKeyEvent,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestMouseMoveEvent,
m_backend,
&SessionBackend::sendMouseMoveEvent,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestMouseButtonEvent,
m_backend,
&SessionBackend::sendMouseButtonEvent,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestMouseWheelEvent,
m_backend,
&SessionBackend::sendMouseWheelEvent,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestSetClipboardText,
m_backend,
&SessionBackend::setClipboardText,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestProvideUsername,
m_backend,
&SessionBackend::provideUsername,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::stateChanged,
this,
&SessionTab::onBackendStateChanged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::eventLogged,
this,
&SessionTab::onBackendEventLogged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::connectionError,
this,
&SessionTab::onBackendConnectionError,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::outputReceived,
this,
&SessionTab::onBackendOutputReceived,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::hostKeyConfirmationRequested,
this,
&SessionTab::onBackendHostKeyConfirmationRequested,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::usernameRequested,
this,
&SessionTab::onBackendUsernameRequested,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::frameUpdated,
this,
[this](const QImage& frame) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setFrame(frame);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setFrame(frame);
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::remoteDesktopSizeChanged,
this,
[this](int width, int height) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setRemoteDesktopSize(width, height);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setRemoteDesktopSize(width, height);
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::remoteClipboardTextChanged,
this,
&SessionTab::onBackendRemoteClipboardTextChanged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::cursorImageChanged,
this,
[this](const QImage& image, const QPoint& hotspot) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorImage(image, hotspot);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorImage(image, hotspot);
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::cursorHidden,
this,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorHidden();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorHidden();
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::cursorReset,
this,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorDefault();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorDefault();
}
},
Qt::QueuedConnection);
m_backendThread->start();
}
if (m_clipboardSyncSupported) {
connect(QApplication::clipboard(),
&QClipboard::dataChanged,
this,
&SessionTab::onSystemClipboardChanged);
}
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
QTimer::singleShot(0, this, &SessionTab::connectSession);
}
SessionTab::~SessionTab()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr && m_state != SessionState::Disconnected) {
m_sshTerminal->kill();
}
if (m_backend != nullptr && m_backendThread != nullptr && m_backendThread->isRunning()) {
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
m_backendThread->quit();
m_backendThread->wait(2000);
}
}
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());
}
bool SessionTab::awaitingUserInput() const
{
return m_awaitingUserInput;
}
void SessionTab::connectSession()
{
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
return;
}
if (!validateProfileForConnect()) {
return;
}
requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
if (!options.has_value()) {
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
startSshTerminal(options.value());
return;
}
emit requestConnect(options.value());
});
}
void SessionTab::disconnectSession()
{
if (m_state == SessionState::Disconnected) {
return;
}
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->kill();
}
setState(SessionState::Disconnected, QStringLiteral("Session disconnected."));
return;
}
emit requestDisconnect();
}
void SessionTab::reconnectSession()
{
if (!validateProfileForConnect()) {
return;
}
requestConnectOptions([this](std::optional<SessionConnectOptions> options) {
if (!options.has_value()) {
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->kill();
}
QTimer::singleShot(50,
this,
[this, options]() { startSshTerminal(options.value()); });
return;
}
emit requestReconnect(options.value());
});
}
void SessionTab::clearTerminal()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->clearScrollback();
m_sshTerminal->setFocus();
return;
}
if (m_terminalOutput != nullptr) {
m_terminalOutput->clear();
if (m_state == SessionState::Connected) {
emit requestInput(QStringLiteral("\x0c"));
}
m_terminalOutput->setFocus();
return;
}
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->clearFrame();
m_rdpDisplay->setFocus();
return;
}
if (m_vncDisplay != nullptr) {
m_vncDisplay->clearFrame();
m_vncDisplay->setFocus();
}
}
void SessionTab::setTerminalThemeName(const QString& themeName)
{
const QString normalized = themeName.trimmed();
if (normalized.isEmpty()) {
return;
}
if (m_terminalThemeName.compare(normalized, Qt::CaseInsensitive) == 0) {
return;
}
m_terminalThemeName = normalized;
applyTerminalTheme(m_terminalThemeName);
appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName));
emit terminalThemeChanged(m_terminalThemeName);
}
QString SessionTab::terminalThemeName() const
{
return m_terminalThemeName;
}
bool SessionTab::supportsThemeSelection() const
{
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
}
bool SessionTab::supportsClearAction() const
{
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
}
bool SessionTab::supportsZoom() const
{
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
}
bool SessionTab::supportsVncScaleToggle() const
{
return m_vncDisplay != nullptr;
}
void SessionTab::setVncScaleToFit(bool scaleToFit)
{
if (m_vncDisplay == nullptr || m_vncDisplay->scaleToFit() == scaleToFit) {
return;
}
m_vncDisplay->setScaleToFit(scaleToFit);
appendEvent(scaleToFit ? QStringLiteral("Display mode set to scale to fit.")
: QStringLiteral("Display mode set to actual size."));
emit vncScaleModeChanged(scaleToFit);
}
bool SessionTab::vncScaleToFit() const
{
return m_vncDisplay != nullptr ? m_vncDisplay->scaleToFit() : true;
}
void SessionTab::zoomIn()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->zoomIn();
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
} else if (m_terminalOutput != nullptr) {
m_terminalFontPointSize = m_terminalOutput->font().pointSize() + 1;
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
void SessionTab::zoomOut()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->zoomOut();
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
} else if (m_terminalOutput != nullptr) {
const int newSize = m_terminalOutput->font().pointSize() - 1;
if (newSize < 6) {
return;
}
m_terminalFontPointSize = newSize;
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
void SessionTab::resetZoom()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->resetZoom();
m_terminalFontPointSize = m_sshTerminal->getConfig().font.pointSize();
} else if (m_terminalOutput != nullptr) {
m_terminalFontPointSize = defaultTerminalFont().pointSize();
m_terminalOutput->setFontPointSize(m_terminalFontPointSize);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
void SessionTab::setTerminalFontPointSize(int pointSize)
{
const int clamped = qBound(6, pointSize, 72);
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
KodoTermConfig config = m_sshTerminal->getConfig();
config.font.setPointSize(clamped);
m_sshTerminal->setConfig(config);
m_terminalFontPointSize = clamped;
} else if (m_terminalOutput != nullptr) {
m_terminalFontPointSize = clamped;
m_terminalOutput->setFontPointSize(clamped);
} else {
return;
}
emit terminalFontSizeChanged(m_terminalFontPointSize);
}
int SessionTab::terminalFontPointSize() const
{
return m_terminalFontPointSize;
}
bool SessionTab::isEventsPanelExpanded() const
{
return m_eventsPanelExpanded;
}
void SessionTab::setEventsPanelExpanded(bool expanded)
{
if (m_eventsPanel == nullptr || m_toggleEventsButton == nullptr) {
return;
}
if (m_eventsPanelExpanded == expanded && m_eventsPanel->isVisible() == expanded) {
return;
}
m_eventsPanelExpanded = expanded;
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
emit eventsPanelVisibilityChanged(m_eventsPanelExpanded);
}
void SessionTab::clearEvents()
{
m_eventEntries.clear();
if (m_eventLog != nullptr) {
m_eventLog->clear();
}
}
void SessionTab::copyEvents() const
{
QStringList visibleLines;
for (const EventEntry& entry : m_eventEntries) {
const bool matchesText = m_eventFilter.isEmpty()
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
bool matchesSeverity = true;
if (m_eventSeverityFilter == EventSeverity::Warning) {
matchesSeverity = entry.severity == EventSeverity::Warning;
} else if (m_eventSeverityFilter == EventSeverity::Error) {
matchesSeverity = entry.severity == EventSeverity::Error;
} else if (m_eventSeverityFilter == EventSeverity::Info) {
matchesSeverity = true;
}
if (matchesText && matchesSeverity) {
visibleLines.push_back(entry.line);
}
}
if (!visibleLines.isEmpty()) {
QApplication::clipboard()->setText(visibleLines.join(QChar::fromLatin1('\n')));
}
}
void SessionTab::exportEventsToFile()
{
QStringList visibleLines;
for (const EventEntry& entry : m_eventEntries) {
const bool matchesText = m_eventFilter.isEmpty()
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
bool matchesSeverity = true;
if (m_eventSeverityFilter == EventSeverity::Warning) {
matchesSeverity = entry.severity == EventSeverity::Warning;
} else if (m_eventSeverityFilter == EventSeverity::Error) {
matchesSeverity = entry.severity == EventSeverity::Error;
} else if (m_eventSeverityFilter == EventSeverity::Info) {
matchesSeverity = true;
}
if (matchesText && matchesSeverity) {
visibleLines.push_back(entry.line);
}
}
if (visibleLines.isEmpty()) {
QMessageBox::information(this,
QStringLiteral("Export Events"),
QStringLiteral("No events match the current filters."));
return;
}
const QString defaultName =
QStringLiteral("orbithub-events-%1.log")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")));
const QString targetPath = QFileDialog::getSaveFileName(this,
QStringLiteral("Export Session Events"),
defaultName,
QStringLiteral("Log Files (*.log);;Text Files (*.txt);;All Files (*)"));
if (targetPath.isEmpty()) {
return;
}
QFile file(targetPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this,
QStringLiteral("Export Events"),
QStringLiteral("Failed to write file: %1").arg(targetPath));
return;
}
QTextStream stream(&file);
for (const QString& line : visibleLines) {
stream << line << '\n';
}
file.close();
}
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
{
setState(state, message);
}
void SessionTab::onBackendEventLogged(const QString& message)
{
appendEvent(message);
}
void SessionTab::onBackendConnectionError(const QString& displayMessage, const QString& rawMessage)
{
m_lastError = rawMessage.isEmpty() ? displayMessage : rawMessage;
appendEvent(QStringLiteral("Error: %1").arg(displayMessage));
if (!rawMessage.trimmed().isEmpty() && rawMessage.trimmed() != displayMessage.trimmed()) {
appendEvent(QStringLiteral("Raw Error: %1").arg(rawMessage.trimmed()));
}
}
void SessionTab::onBackendOutputReceived(const QString& text)
{
if (text.isEmpty() || m_terminalOutput == nullptr) {
return;
}
m_terminalOutput->appendTerminalData(text);
}
void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
{
const QString question = prompt.isEmpty()
? QStringLiteral("Unknown SSH host key. Do you trust this host?")
: prompt;
const QMessageBox::StandardButton reply = QMessageBox::question(
this,
QStringLiteral("SSH Host Key Confirmation"),
QStringLiteral("%1\n\nTrust and continue?").arg(question),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
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)
{
if (text == m_lastSyncedClipboardText) {
return;
}
m_lastSyncedClipboardText = text;
QApplication::clipboard()->setText(text);
}
void SessionTab::onSystemClipboardChanged()
{
if (!m_clipboardSyncSupported || m_state != SessionState::Connected) {
return;
}
const QClipboard* clipboard = QApplication::clipboard();
if (!clipboard->mimeData()->hasText()) {
return;
}
const QString text = clipboard->text();
if (text == m_lastSyncedClipboardText) {
return;
}
m_lastSyncedClipboardText = text;
emit requestSetClipboardText(text);
}
void SessionTab::setupUi()
{
auto* rootLayout = new QVBoxLayout(this);
if (m_useKodoTermForSsh) {
m_sshTerminal = new KodoTerm(this);
QFont terminalFont = defaultTerminalFont();
if (m_terminalFontPointSize > 0) {
terminalFont.setPointSize(m_terminalFontPointSize);
}
KodoTermConfig config = m_sshTerminal->getConfig();
config.font = terminalFont;
config.textAntialiasing = true;
config.maxScrollback = 12000;
m_sshTerminal->setConfig(config);
rootLayout->addWidget(m_sshTerminal, 1);
} else if (m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
m_rdpDisplay = new RdpDisplayWidget(this);
rootLayout->addWidget(m_rdpDisplay, 1);
} else if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
m_vncDisplay = new VncDisplayWidget(this);
m_vncScrollArea = new QScrollArea(this);
m_vncScrollArea->setWidget(m_vncDisplay);
m_vncScrollArea->setWidgetResizable(true);
m_vncScrollArea->setFrameShape(QFrame::NoFrame);
rootLayout->addWidget(m_vncScrollArea, 1);
} else {
m_terminalOutput = new TerminalView(this);
QFont fallbackFont = defaultTerminalFont();
if (m_terminalFontPointSize > 0) {
fallbackFont.setPointSize(m_terminalFontPointSize);
}
m_terminalOutput->setFont(fallbackFont);
m_terminalOutput->setMinimumHeight(260);
m_terminalOutput->setReadOnly(true);
m_terminalOutput->setPlaceholderText(QStringLiteral("Session output appears here."));
rootLayout->addWidget(m_terminalOutput, 1);
}
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();
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->setObjectName(QStringLiteral("passwordPromptLabel"));
m_passwordPromptInput = new QLineEdit(this);
m_passwordPromptInput->setEchoMode(QLineEdit::Password);
m_passwordPromptConnectButton = new QPushButton(QStringLiteral("Connect"), this);
m_passwordPromptCancelButton = new QPushButton(QStringLiteral("Cancel"), this);
passwordPromptLayout->addWidget(m_passwordPromptIcon);
passwordPromptLayout->addWidget(m_passwordPromptLabel);
passwordPromptLayout->addWidget(m_passwordPromptInput, 1);
passwordPromptLayout->addWidget(m_passwordPromptConnectButton);
passwordPromptLayout->addWidget(m_passwordPromptCancelButton);
passwordPromptLayout->setContentsMargins(10, 8, 10, 8);
m_passwordPromptBar = new QWidget(this);
m_passwordPromptBar->setObjectName(QStringLiteral("passwordPromptBar"));
m_passwordPromptBar->setLayout(passwordPromptLayout);
m_passwordPromptBar->setAutoFillBackground(true);
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);
connect(m_passwordPromptConnectButton, &QPushButton::clicked, this, [this]() {
if (!m_passwordPromptCallback) {
return;
}
const QString password = m_passwordPromptInput->text();
const auto callback = m_passwordPromptCallback;
hidePasswordPrompt();
callback(password);
});
connect(m_passwordPromptCancelButton, &QPushButton::clicked, this, [this]() {
if (!m_passwordPromptCallback) {
return;
}
const auto callback = m_passwordPromptCallback;
hidePasswordPrompt();
callback(std::nullopt);
});
connect(m_passwordPromptInput,
&QLineEdit::returnPressed,
m_passwordPromptConnectButton,
&QPushButton::click);
auto* eventsHeader = new QHBoxLayout();
m_toggleEventsButton = new QToolButton(this);
m_toggleEventsButton->setCheckable(true);
m_eventFilterInput = new QLineEdit(this);
m_eventFilterInput->setPlaceholderText(QStringLiteral("Filter events..."));
m_eventSeverityFilterInput = new QComboBox(this);
m_eventSeverityFilterInput->addItem(QStringLiteral("All"));
m_eventSeverityFilterInput->addItem(QStringLiteral("Warnings"));
m_eventSeverityFilterInput->addItem(QStringLiteral("Errors"));
m_clearEventsButton = new QToolButton(this);
m_clearEventsButton->setText(QStringLiteral("Clear Events"));
m_exportEventsButton = new QToolButton(this);
m_exportEventsButton->setText(QStringLiteral("Export Events"));
eventsHeader->addWidget(m_toggleEventsButton);
eventsHeader->addWidget(m_eventFilterInput, 1);
eventsHeader->addWidget(m_eventSeverityFilterInput);
eventsHeader->addWidget(m_exportEventsButton);
eventsHeader->addWidget(m_clearEventsButton);
eventsHeader->addStretch();
m_eventsPanel = new QWidget(this);
auto* eventsLayout = new QVBoxLayout(m_eventsPanel);
eventsLayout->setContentsMargins(0, 0, 0, 0);
auto* eventTitle = new QLabel(QStringLiteral("Session Events"), m_eventsPanel);
m_eventLog = new QPlainTextEdit(m_eventsPanel);
m_eventLog->setReadOnly(true);
m_eventLog->setPlaceholderText(QStringLiteral("Session event log..."));
m_eventLog->setMinimumHeight(140);
eventsLayout->addWidget(eventTitle);
eventsLayout->addWidget(m_eventLog);
rootLayout->addLayout(eventsHeader);
rootLayout->addWidget(m_eventsPanel);
setPanelExpanded(
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), m_eventsPanelExpanded);
connect(m_toggleEventsButton,
&QToolButton::toggled,
this,
[this](bool expanded) {
setEventsPanelExpanded(expanded);
});
connect(m_eventFilterInput,
&QLineEdit::textChanged,
this,
[this](const QString& text) {
m_eventFilter = text.trimmed();
refreshEventLogView();
});
connect(m_eventSeverityFilterInput,
&QComboBox::currentTextChanged,
this,
[this](const QString& selected) {
if (selected.compare(QStringLiteral("Errors"), Qt::CaseInsensitive) == 0) {
m_eventSeverityFilter = EventSeverity::Error;
} else if (selected.compare(QStringLiteral("Warnings"), Qt::CaseInsensitive) == 0) {
m_eventSeverityFilter = EventSeverity::Warning;
} else {
m_eventSeverityFilter = EventSeverity::Info;
}
refreshEventLogView();
});
connect(m_exportEventsButton,
&QToolButton::clicked,
this,
[this]() { exportEventsToFile(); });
connect(m_clearEventsButton,
&QToolButton::clicked,
this,
[this]() { clearEvents(); });
if (m_terminalOutput != nullptr) {
connect(m_terminalOutput,
&TerminalView::inputGenerated,
this,
[this](const QString& input) { emit requestInput(input); });
connect(m_terminalOutput,
&TerminalView::terminalSizeChanged,
this,
[this](int columns, int rows) { emit requestTerminalSize(columns, rows); });
} else if (m_rdpDisplay != nullptr) {
connect(m_rdpDisplay,
&RdpDisplayWidget::viewportSizeChanged,
this,
[this](int width, int height) { emit requestTerminalSize(width, height); });
connect(m_rdpDisplay,
&RdpDisplayWidget::displayScaleChanged,
this,
[this](qreal ratio) { emit requestDisplayScale(ratio); });
connect(m_rdpDisplay,
&RdpDisplayWidget::keyInput,
this,
[this](int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers) {
emit requestKeyEvent(key, nativeScanCode, text, pressed, modifiers);
});
connect(m_rdpDisplay,
&RdpDisplayWidget::mouseMoveInput,
this,
[this](int x, int y) { emit requestMouseMoveEvent(x, y); });
connect(m_rdpDisplay,
&RdpDisplayWidget::mouseButtonInput,
this,
[this](int x, int y, int button, bool pressed) {
emit requestMouseButtonEvent(x, y, button, pressed);
});
connect(m_rdpDisplay,
&RdpDisplayWidget::mouseWheelInput,
this,
[this](int x, int y, int deltaX, int deltaY) {
emit requestMouseWheelEvent(x, y, deltaX, deltaY);
});
} else if (m_vncDisplay != nullptr) {
connect(m_vncDisplay,
&VncDisplayWidget::viewportSizeChanged,
this,
[this](int width, int height) { emit requestTerminalSize(width, height); });
connect(m_vncDisplay,
&VncDisplayWidget::displayScaleChanged,
this,
[this](qreal ratio) { emit requestDisplayScale(ratio); });
connect(m_vncDisplay,
&VncDisplayWidget::keyInput,
this,
[this](int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers) {
emit requestKeyEvent(key, nativeScanCode, text, pressed, modifiers);
});
connect(m_vncDisplay,
&VncDisplayWidget::mouseMoveInput,
this,
[this](int x, int y) { emit requestMouseMoveEvent(x, y); });
connect(m_vncDisplay,
&VncDisplayWidget::mouseButtonInput,
this,
[this](int x, int y, int button, bool pressed) {
emit requestMouseButtonEvent(x, y, button, pressed);
});
connect(m_vncDisplay,
&VncDisplayWidget::mouseWheelInput,
this,
[this](int x, int y, int deltaX, int deltaY) {
emit requestMouseWheelEvent(x, y, deltaX, deltaY);
});
}
}
void SessionTab::requestConnectOptions(
std::function<void(std::optional<SessionConnectOptions>)> callback)
{
SessionConnectOptions baseOptions;
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 isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), 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) {
// Unlike RDP, an empty password is allowed through: some VNC
// servers (no-auth) don't need one at all, and there's no
// client-side way to know that before the server's security-type
// negotiation happens.
showPasswordPrompt(
QStringLiteral("VNC password for %1 (leave blank if the server doesn't require one):")
.arg(m_profile.host),
[baseOptions, callback](std::optional<QString> password) {
if (!password.has_value()) {
callback(std::nullopt);
return;
}
SessionConnectOptions options = baseOptions;
options.password = password.value();
callback(options);
});
return;
}
if (!isSsh && !isRdp) {
callback(baseOptions);
return;
}
if (isRdp) {
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) {
callback(baseOptions);
return;
}
const QString label = QStringLiteral("RDP password for %1:")
.arg(m_profile.username.trimmed().isEmpty()
? m_profile.host
: QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host));
showPasswordPrompt(
label,
[this, baseOptions, callback](std::optional<QString> password) {
if (!password.has_value()) {
callback(std::nullopt);
return;
}
if (password->isEmpty()) {
QMessageBox::warning(
this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
callback(std::nullopt);
return;
}
SessionConnectOptions options = baseOptions;
options.password = password.value();
callback(options);
});
return;
}
if (m_useKodoTermForSsh
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
// Password is entered directly in terminal prompt.
callback(baseOptions);
return;
}
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
showPasswordPrompt(
QStringLiteral("SSH password for %1@%2:").arg(m_profile.username, m_profile.host),
[this, baseOptions, callback](std::optional<QString> password) {
if (!password.has_value()) {
callback(std::nullopt);
return;
}
if (password->isEmpty()) {
QMessageBox::warning(
this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
callback(std::nullopt);
return;
}
SessionConnectOptions options = baseOptions;
options.password = password.value();
callback(options);
});
return;
}
QString keyPath = m_profile.privateKeyPath.trimmed();
if (keyPath.isEmpty()) {
keyPath = QFileDialog::getOpenFileName(this,
QStringLiteral("Select Private Key"),
QString(),
QStringLiteral("All Files (*)"));
if (keyPath.isEmpty()) {
callback(std::nullopt);
return;
}
}
if (!QFileInfo::exists(keyPath)) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Private key file not found: %1").arg(keyPath));
callback(std::nullopt);
return;
}
SessionConnectOptions options = baseOptions;
options.privateKeyPath = keyPath;
callback(options);
}
void SessionTab::showPasswordPrompt(const QString& labelText,
std::function<void(std::optional<QString>)> callback,
bool maskInput)
{
if (m_passwordPromptCallback) {
const auto previousCallback = m_passwordPromptCallback;
m_passwordPromptCallback = nullptr;
previousCallback(std::nullopt);
}
m_passwordPromptCallback = std::move(callback);
m_passwordPromptLabel->setText(labelText);
m_passwordPromptInput->clear();
m_passwordPromptInput->setEchoMode(maskInput ? QLineEdit::Password : QLineEdit::Normal);
m_passwordPromptBar->setVisible(true);
m_passwordPromptInput->setFocus();
if (!m_awaitingUserInput) {
m_awaitingUserInput = true;
emit awaitingUserInputChanged(true);
emit tabTitleChanged(tabTitle());
}
}
void SessionTab::hidePasswordPrompt()
{
m_passwordPromptBar->setVisible(false);
m_passwordPromptCallback = nullptr;
if (m_awaitingUserInput) {
m_awaitingUserInput = false;
emit awaitingUserInputChanged(false);
emit tabTitleChanged(tabTitle());
}
}
bool SessionTab::validateProfileForConnect()
{
if (m_profile.host.trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("%1 host is required.").arg(m_profile.protocol));
return false;
}
if (m_profile.port < 1 || m_profile.port > 65535) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Port must be between 1 and 65535."));
return false;
}
// SSH/RDP no longer hard-require a username here -- a blank one is
// handled by requestConnectOptions() prompting for it inline at connect
// time (see issue #21). Do not re-add a check here without also
// updating that flow.
return true;
}
void SessionTab::appendEvent(const QString& message)
{
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
m_eventEntries.push_back(
EventEntry{QStringLiteral("[%1] %2").arg(timestamp, message),
classifyEventSeverity(message)});
constexpr int kMaxEventLines = 5000;
while (m_eventEntries.size() > static_cast<size_t>(kMaxEventLines)) {
m_eventEntries.erase(m_eventEntries.begin());
}
refreshEventLogView();
}
void SessionTab::setState(SessionState state, const QString& message)
{
m_state = state;
appendEvent(QStringLiteral("Connection state: %1").arg(message));
refreshActionButtons();
emit tabTitleChanged(tabTitle());
emit tabStateChanged(state);
}
QString SessionTab::stateSuffix() const
{
switch (m_state) {
case SessionState::Disconnected:
return QStringLiteral("Disconnected");
case SessionState::Connecting:
return QStringLiteral("Connecting");
case SessionState::Connected:
return QStringLiteral("Connected");
case SessionState::Failed:
return QStringLiteral("Failed");
}
return QStringLiteral("Unknown");
}
void SessionTab::refreshActionButtons()
{
const bool isConnected = m_state == SessionState::Connected;
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->setEnabled(true);
m_sshTerminal->setFocus();
return;
}
if (m_terminalOutput != nullptr) {
m_terminalOutput->setEnabled(isConnected);
m_terminalOutput->setFocus();
return;
}
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setEnabled(isConnected);
if (isConnected) {
m_rdpDisplay->setFocus();
}
return;
}
if (m_vncDisplay != nullptr) {
m_vncDisplay->setEnabled(isConnected);
if (isConnected) {
m_vncDisplay->setFocus();
}
}
}
void SessionTab::setPanelExpanded(QToolButton* button,
QWidget* panel,
const QString& name,
bool expanded)
{
if (button == nullptr || panel == nullptr) {
return;
}
button->blockSignals(true);
button->setChecked(expanded);
button->blockSignals(false);
panel->setVisible(expanded);
button->setText(expanded ? QStringLiteral("Hide %1").arg(name)
: QStringLiteral("Show %1").arg(name));
}
bool SessionTab::startSshTerminal(const SessionConnectOptions& options)
{
if (m_sshTerminal == nullptr) {
return false;
}
QStringList args;
args << QStringLiteral("-tt") << QStringLiteral("-p") << QString::number(m_profile.port)
<< QStringLiteral("-o") << QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveCountMax=2");
const QString policy = options.knownHostsPolicy.trimmed().isEmpty()
? m_profile.knownHostsPolicy.trimmed()
: options.knownHostsPolicy.trimmed();
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
#ifdef Q_OS_WIN
const QString knownHostsNullDevice = QStringLiteral("NUL");
#else
const QString knownHostsNullDevice = QStringLiteral("/dev/null");
#endif
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=no")
<< QStringLiteral("-o")
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsNullDevice);
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
} else if (policy.compare(QStringLiteral("Ask"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=ask");
} else {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
}
if (m_profile.authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
QString keyPath = options.privateKeyPath.trimmed();
if (keyPath.isEmpty()) {
keyPath = m_profile.privateKeyPath.trimmed();
}
if (keyPath.isEmpty()) {
m_lastError = QStringLiteral("Private key path is required.");
appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
setState(SessionState::Failed, m_lastError);
return false;
}
args << QStringLiteral("-i") << keyPath;
}
const QString target = m_profile.username.trimmed().isEmpty()
? m_profile.host.trimmed()
: QStringLiteral("%1@%2").arg(m_profile.username.trimmed(), m_profile.host.trimmed());
args << target;
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
if (!env.contains(QStringLiteral("TERM"))) {
env.insert(QStringLiteral("TERM"), QStringLiteral("xterm-256color"));
}
if (!env.contains(QStringLiteral("COLORTERM"))) {
env.insert(QStringLiteral("COLORTERM"), QStringLiteral("truecolor"));
}
m_sshTerminal->setProgram(QStringLiteral("ssh"));
m_sshTerminal->setArguments(args);
m_sshTerminal->setProcessEnvironment(env);
appendEvent(QStringLiteral("Launching SSH terminal session."));
setState(SessionState::Connecting, QStringLiteral("Starting SSH terminal..."));
if (!m_sshTerminal->start()) {
m_lastError = QStringLiteral("Failed to start embedded SSH terminal process.");
appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
setState(SessionState::Failed, QStringLiteral("Failed to start SSH terminal."));
return false;
}
setState(SessionState::Connected, QStringLiteral("SSH session established."));
return true;
}
void SessionTab::applyTerminalTheme(const QString& themeName)
{
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->setTheme(themeForName(themeName));
}
return;
}
if (m_terminalOutput != nullptr) {
m_terminalOutput->setThemeName(themeName);
}
}
void SessionTab::refreshEventLogView()
{
if (m_eventLog == nullptr) {
return;
}
QStringList visibleLines;
visibleLines.reserve(static_cast<int>(m_eventEntries.size()));
for (const EventEntry& entry : m_eventEntries) {
if (!m_eventFilter.isEmpty() && !entry.line.contains(m_eventFilter, Qt::CaseInsensitive)) {
continue;
}
if (m_eventSeverityFilter == EventSeverity::Warning
&& entry.severity != EventSeverity::Warning) {
continue;
}
if (m_eventSeverityFilter == EventSeverity::Error
&& entry.severity != EventSeverity::Error) {
continue;
}
visibleLines.push_back(entry.line);
}
m_eventLog->setPlainText(visibleLines.join(QChar::fromLatin1('\n')));
m_eventLog->moveCursor(QTextCursor::End);
}
SessionTab::EventSeverity SessionTab::classifyEventSeverity(const QString& message)
{
const QString normalized = message.trimmed().toLower();
if (normalized.startsWith(QStringLiteral("error:"))
|| normalized.contains(QStringLiteral("failed"))
|| normalized.contains(QStringLiteral("permission denied"))) {
return EventSeverity::Error;
}
if (normalized.startsWith(QStringLiteral("warning:"))
|| normalized.contains(QStringLiteral("warning"))) {
return EventSeverity::Warning;
}
return EventSeverity::Info;
}