#include "session_tab.h" #include "rdp_display_widget.h" #include "session_backend_factory.h" #include "terminal_view.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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"); qRegisterMetaType("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 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 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 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 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); } }