Files
orbithub/src/session_tab.cpp

806 lines
27 KiB
C++

#include "session_tab.h"
#include "rdp_display_widget.h"
#include "session_backend_factory.h"
#include "terminal_view.h"
#include <KodoTerm/KodoTerm.hpp>
#include <QDateTime>
#include <QFileDialog>
#include <QFileInfo>
#include <QFont>
#include <QFontDatabase>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QProcessEnvironment>
#include <QThread>
#include <QTimer>
#include <QToolButton>
#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"));
}
}
SessionTab::SessionTab(const Profile& profile, 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(QStringLiteral("Dark")),
m_sshTerminal(nullptr),
m_rdpDisplay(nullptr),
m_terminalOutput(nullptr),
m_eventLog(nullptr),
m_toggleEventsButton(nullptr),
m_eventsPanel(nullptr)
{
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState");
setupUi();
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::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(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::frameUpdated,
this,
[this](const QImage& frame) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setFrame(frame);
}
},
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::remoteDesktopSizeChanged,
this,
[this](int width, int height) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setRemoteDesktopSize(width, height);
}
},
Qt::QueuedConnection);
m_backendThread->start();
}
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
{
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
}
void SessionTab::connectSession()
{
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
return;
}
if (!validateProfileForConnect()) {
return;
}
const std::optional<SessionConnectOptions> options = buildConnectOptions();
if (!options.has_value()) {
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (!startSshTerminal(options.value())) {
return;
}
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;
}
const std::optional<SessionConnectOptions> options = buildConnectOptions();
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();
}
}
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));
}
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;
}
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::setupUi()
{
auto* rootLayout = new QVBoxLayout(this);
if (m_useKodoTermForSsh) {
m_sshTerminal = new KodoTerm(this);
const QFont terminalFont = defaultTerminalFont();
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 {
m_terminalOutput = new TerminalView(this);
m_terminalOutput->setFont(defaultTerminalFont());
m_terminalOutput->setMinimumHeight(260);
m_terminalOutput->setReadOnly(true);
if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
m_terminalOutput->setPlaceholderText(
QStringLiteral("Embedded VNC session output appears here when the backend is available."));
} else {
m_terminalOutput->setPlaceholderText(
QStringLiteral("Session output appears here."));
}
rootLayout->addWidget(m_terminalOutput, 1);
}
applyTerminalTheme(m_terminalThemeName);
auto* eventsHeader = new QHBoxLayout();
m_toggleEventsButton = new QToolButton(this);
m_toggleEventsButton->setCheckable(true);
eventsHeader->addWidget(m_toggleEventsButton);
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"), false);
connect(m_toggleEventsButton,
&QToolButton::toggled,
this,
[this](bool expanded) {
setPanelExpanded(
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
});
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::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);
});
}
}
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
{
SessionConnectOptions options;
options.knownHostsPolicy = m_profile.knownHostsPolicy;
const bool isSsh = m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0;
const bool isRdp = m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0;
if (!isSsh && !isRdp) {
return options;
}
if (isRdp) {
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) != 0) {
return options;
}
bool accepted = false;
const QString password = QInputDialog::getText(
this,
QStringLiteral("RDP Password"),
QStringLiteral("Password for %1:")
.arg(m_profile.username.trimmed().isEmpty()
? m_profile.host
: QStringLiteral("%1@%2").arg(m_profile.username, m_profile.host)),
QLineEdit::Password,
QString(),
&accepted);
if (!accepted) {
return std::nullopt;
}
if (password.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
return std::nullopt;
}
options.password = password;
return options;
}
if (m_useKodoTermForSsh
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
// Password is entered directly in terminal prompt.
return options;
}
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
bool accepted = false;
const QString password = QInputDialog::getText(this,
QStringLiteral("SSH Password"),
QStringLiteral("Password for %1@%2:")
.arg(m_profile.username, m_profile.host),
QLineEdit::Password,
QString(),
&accepted);
if (!accepted) {
return std::nullopt;
}
if (password.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
return std::nullopt;
}
options.password = password;
return options;
}
QString keyPath = m_profile.privateKeyPath.trimmed();
if (keyPath.isEmpty()) {
keyPath = QFileDialog::getOpenFileName(this,
QStringLiteral("Select Private Key"),
QString(),
QStringLiteral("All Files (*)"));
if (keyPath.isEmpty()) {
return std::nullopt;
}
}
if (!QFileInfo::exists(keyPath)) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Private key file not found: %1").arg(keyPath));
return std::nullopt;
}
options.privateKeyPath = keyPath;
return options;
}
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;
}
if ((m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0
|| m_profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0)
&& m_profile.username.trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("%1 username is required.").arg(m_profile.protocol));
return false;
}
return true;
}
void SessionTab::appendEvent(const QString& message)
{
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
m_eventLog->appendPlainText(QStringLiteral("[%1] %2").arg(timestamp, message));
}
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();
}
}
}
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);
}
}