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