Internal
Public Access
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>
1501 lines
52 KiB
C++
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;
|
|
}
|