From 6a4bcb75eb4c0131c09f710c6130b24a7980f314 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sun, 1 Mar 2026 09:37:34 -0700 Subject: [PATCH] Add threaded session backend architecture with real SSH backend --- CMakeLists.txt | 9 + src/profile_repository.cpp | 29 +- src/profile_repository.h | 2 + src/session_backend.h | 57 ++++ src/session_backend_factory.cpp | 14 + src/session_backend_factory.h | 12 + src/session_tab.cpp | 363 ++++++++++++++++++++++++ src/session_tab.h | 67 +++++ src/session_window.cpp | 85 ++---- src/session_window.h | 2 + src/ssh_session_backend.cpp | 419 ++++++++++++++++++++++++++++ src/ssh_session_backend.h | 52 ++++ src/unsupported_session_backend.cpp | 26 ++ src/unsupported_session_backend.h | 19 ++ 14 files changed, 1083 insertions(+), 73 deletions(-) create mode 100644 src/session_backend.h create mode 100644 src/session_backend_factory.cpp create mode 100644 src/session_backend_factory.h create mode 100644 src/session_tab.cpp create mode 100644 src/session_tab.h create mode 100644 src/ssh_session_backend.cpp create mode 100644 src/ssh_session_backend.h create mode 100644 src/unsupported_session_backend.cpp create mode 100644 src/unsupported_session_backend.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e052dd9..8f932d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,8 +22,17 @@ add_executable(orbithub src/profile_repository.h src/profiles_window.cpp src/profiles_window.h + src/session_backend.h + src/session_backend_factory.cpp + src/session_backend_factory.h + src/session_tab.cpp + src/session_tab.h src/session_window.cpp src/session_window.h + src/ssh_session_backend.cpp + src/ssh_session_backend.h + src/unsupported_session_backend.cpp + src/unsupported_session_backend.h ) target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql) diff --git a/src/profile_repository.cpp b/src/profile_repository.cpp index ae761c9..bfd4555 100644 --- a/src/profile_repository.cpp +++ b/src/profile_repository.cpp @@ -30,6 +30,10 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile) query.addBindValue(profile.username.trimmed()); query.addBindValue(profile.protocol.trimmed()); query.addBindValue(profile.authMode.trimmed()); + query.addBindValue(profile.privateKeyPath.trimmed()); + query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty() + ? QStringLiteral("Strict") + : profile.knownHostsPolicy.trimmed()); } Profile profileFromQuery(const QSqlQuery& query) @@ -42,6 +46,11 @@ Profile profileFromQuery(const QSqlQuery& query) profile.username = query.value(4).toString(); profile.protocol = query.value(5).toString(); profile.authMode = query.value(6).toString(); + profile.privateKeyPath = query.value(7).toString(); + profile.knownHostsPolicy = query.value(8).toString(); + if (profile.knownHostsPolicy.isEmpty()) { + profile.knownHostsPolicy = QStringLiteral("Strict"); + } return profile; } @@ -93,12 +102,12 @@ std::vector ProfileRepository::listProfiles(const QString& searchQuery) QSqlQuery query(QSqlDatabase::database(m_connectionName)); if (searchQuery.trimmed().isEmpty()) { query.prepare(QStringLiteral( - "SELECT id, name, host, port, username, protocol, auth_mode " + "SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy " "FROM profiles " "ORDER BY lower(name) ASC, id ASC")); } else { query.prepare(QStringLiteral( - "SELECT id, name, host, port, username, protocol, auth_mode " + "SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy " "FROM profiles " "WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) " "ORDER BY lower(name) ASC, id ASC")); @@ -129,7 +138,7 @@ std::optional ProfileRepository::getProfile(qint64 id) const QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(QStringLiteral( - "SELECT id, name, host, port, username, protocol, auth_mode " + "SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy " "FROM profiles WHERE id = ?")); query.addBindValue(id); @@ -160,8 +169,8 @@ std::optional ProfileRepository::createProfile(const Profile& profile) QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(QStringLiteral( - "INSERT INTO profiles(name, host, port, username, protocol, auth_mode) " - "VALUES (?, ?, ?, ?, ?, ?)")); + "INSERT INTO profiles(name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")); bindProfileFields(query, profile); if (!query.exec()) { @@ -190,7 +199,7 @@ bool ProfileRepository::updateProfile(const Profile& profile) const QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(QStringLiteral( "UPDATE profiles " - "SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ? " + "SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ? " "WHERE id = ?")); bindProfileFields(query, profile); query.addBindValue(profile.id); @@ -242,7 +251,9 @@ bool ProfileRepository::initializeDatabase() "port INTEGER NOT NULL DEFAULT 22," "username TEXT NOT NULL DEFAULT ''," "protocol TEXT NOT NULL DEFAULT 'SSH'," - "auth_mode TEXT NOT NULL DEFAULT 'Password'" + "auth_mode TEXT NOT NULL DEFAULT 'Password'," + "private_key_path TEXT NOT NULL DEFAULT ''," + "known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'" ")")); if (!created) { @@ -286,7 +297,9 @@ bool ProfileRepository::ensureProfileSchema() const {QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")}, {QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")}, - {QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")}}; + {QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")}, + {QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")}, + {QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'")}}; for (const ColumnDef& column : required) { if (columns.contains(column.name)) { diff --git a/src/profile_repository.h b/src/profile_repository.h index fef063d..51155b9 100644 --- a/src/profile_repository.h +++ b/src/profile_repository.h @@ -16,6 +16,8 @@ struct Profile QString username; QString protocol = QStringLiteral("SSH"); QString authMode = QStringLiteral("Password"); + QString privateKeyPath; + QString knownHostsPolicy = QStringLiteral("Strict"); }; class ProfileRepository diff --git a/src/session_backend.h b/src/session_backend.h new file mode 100644 index 0000000..b189cf4 --- /dev/null +++ b/src/session_backend.h @@ -0,0 +1,57 @@ +#ifndef ORBITHUB_SESSION_BACKEND_H +#define ORBITHUB_SESSION_BACKEND_H + +#include "profile_repository.h" + +#include +#include + +class SessionConnectOptions +{ +public: + QString password; + QString privateKeyPath; + QString knownHostsPolicy; +}; + +enum class SessionState { + Disconnected, + Connecting, + Connected, + Failed, +}; + +class SessionBackend : public QObject +{ + Q_OBJECT + +public: + explicit SessionBackend(const Profile& profile, QObject* parent = nullptr) + : QObject(parent), m_profile(profile) + { + } + ~SessionBackend() override = default; + + const Profile& profile() const + { + return m_profile; + } + +public slots: + virtual void connectSession(const SessionConnectOptions& options) = 0; + virtual void disconnectSession() = 0; + virtual void reconnectSession(const SessionConnectOptions& options) = 0; + +signals: + void stateChanged(SessionState state, const QString& message); + void eventLogged(const QString& message); + void connectionError(const QString& displayMessage, const QString& rawMessage); + +private: + Profile m_profile; +}; + +Q_DECLARE_METATYPE(SessionConnectOptions) +Q_DECLARE_METATYPE(SessionState) + +#endif diff --git a/src/session_backend_factory.cpp b/src/session_backend_factory.cpp new file mode 100644 index 0000000..f3deb2b --- /dev/null +++ b/src/session_backend_factory.cpp @@ -0,0 +1,14 @@ +#include "session_backend_factory.h" + +#include "session_backend.h" +#include "ssh_session_backend.h" +#include "unsupported_session_backend.h" + +std::unique_ptr createSessionBackend(const Profile& profile) +{ + if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) { + return std::make_unique(profile); + } + + return std::make_unique(profile); +} diff --git a/src/session_backend_factory.h b/src/session_backend_factory.h new file mode 100644 index 0000000..a286ab4 --- /dev/null +++ b/src/session_backend_factory.h @@ -0,0 +1,12 @@ +#ifndef ORBITHUB_SESSION_BACKEND_FACTORY_H +#define ORBITHUB_SESSION_BACKEND_FACTORY_H + +#include "profile_repository.h" + +#include + +class SessionBackend; + +std::unique_ptr createSessionBackend(const Profile& profile); + +#endif diff --git a/src/session_tab.cpp b/src/session_tab.cpp new file mode 100644 index 0000000..febf605 --- /dev/null +++ b/src/session_tab.cpp @@ -0,0 +1,363 @@ +#include "session_tab.h" + +#include "session_backend_factory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +SessionTab::SessionTab(const Profile& profile, QWidget* parent) + : QWidget(parent), + m_profile(profile), + m_backendThread(new QThread(this)), + m_backend(nullptr), + m_state(SessionState::Disconnected), + m_statusLabel(nullptr), + m_errorLabel(nullptr), + m_eventLog(nullptr), + m_connectButton(nullptr), + m_disconnectButton(nullptr), + m_reconnectButton(nullptr), + m_copyErrorButton(nullptr) +{ + qRegisterMetaType("SessionConnectOptions"); + qRegisterMetaType("SessionState"); + + setupUi(); + + 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(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); + + m_backendThread->start(); + + setState(SessionState::Disconnected, QStringLiteral("Ready to connect.")); +} + +SessionTab::~SessionTab() +{ + if (m_backend != 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::onConnectClicked() +{ + if (!validateProfileForConnect()) { + return; + } + + const std::optional options = buildConnectOptions(); + if (!options.has_value()) { + return; + } + + emit requestConnect(options.value()); +} + +void SessionTab::onDisconnectClicked() +{ + emit requestDisconnect(); +} + +void SessionTab::onReconnectClicked() +{ + if (!validateProfileForConnect()) { + return; + } + + const std::optional options = buildConnectOptions(); + if (!options.has_value()) { + return; + } + + emit requestReconnect(options.value()); +} + +void SessionTab::onCopyErrorClicked() +{ + if (m_lastError.isEmpty()) { + return; + } + + QGuiApplication::clipboard()->setText(m_lastError); + appendEvent(QStringLiteral("Copied last error to clipboard.")); +} + +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; + m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(displayMessage)); + m_copyErrorButton->setEnabled(true); +} + +void SessionTab::setupUi() +{ + auto* rootLayout = new QVBoxLayout(this); + + auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(m_profile.name), this); + auto* endpointLabel = new QLabel( + QStringLiteral("Endpoint: %1://%2@%3:%4") + .arg(m_profile.protocol, + m_profile.username.isEmpty() ? QStringLiteral("") : m_profile.username, + m_profile.host, + QString::number(m_profile.port)), + this); + auto* authLabel = new QLabel(QStringLiteral("Auth: %1").arg(m_profile.authMode), this); + + m_statusLabel = new QLabel(this); + m_errorLabel = new QLabel(QStringLiteral("Last Error: None"), this); + m_errorLabel->setWordWrap(true); + + auto* actionRow = new QHBoxLayout(); + m_connectButton = new QPushButton(QStringLiteral("Connect"), this); + m_disconnectButton = new QPushButton(QStringLiteral("Disconnect"), this); + m_reconnectButton = new QPushButton(QStringLiteral("Reconnect"), this); + m_copyErrorButton = new QPushButton(QStringLiteral("Copy Error"), this); + + actionRow->addWidget(m_connectButton); + actionRow->addWidget(m_disconnectButton); + actionRow->addWidget(m_reconnectButton); + actionRow->addWidget(m_copyErrorButton); + actionRow->addStretch(); + + auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), this); + QFont surfaceFont = surfaceLabel->font(); + surfaceFont.setPointSize(surfaceFont.pointSize() + 6); + surfaceFont.setBold(true); + surfaceLabel->setFont(surfaceFont); + surfaceLabel->setAlignment(Qt::AlignCenter); + surfaceLabel->setMinimumHeight(180); + surfaceLabel->setStyleSheet( + QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;")); + + m_eventLog = new QPlainTextEdit(this); + m_eventLog->setReadOnly(true); + m_eventLog->setPlaceholderText(QStringLiteral("Session event log...")); + m_eventLog->setMinimumHeight(180); + + rootLayout->addWidget(profileLabel); + rootLayout->addWidget(endpointLabel); + rootLayout->addWidget(authLabel); + rootLayout->addWidget(m_statusLabel); + rootLayout->addWidget(m_errorLabel); + rootLayout->addLayout(actionRow); + rootLayout->addWidget(surfaceLabel); + rootLayout->addWidget(m_eventLog, 1); + + connect(m_connectButton, &QPushButton::clicked, this, &SessionTab::onConnectClicked); + connect(m_disconnectButton, &QPushButton::clicked, this, &SessionTab::onDisconnectClicked); + connect(m_reconnectButton, &QPushButton::clicked, this, &SessionTab::onReconnectClicked); + connect(m_copyErrorButton, &QPushButton::clicked, this, &SessionTab::onCopyErrorClicked); +} + +std::optional SessionTab::buildConnectOptions() +{ + SessionConnectOptions options; + options.knownHostsPolicy = m_profile.knownHostsPolicy; + + if (m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) != 0) { + 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.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) != 0) { + return true; + } + + if (m_profile.host.trimmed().isEmpty()) { + QMessageBox::warning(this, + QStringLiteral("Connect"), + QStringLiteral("SSH host is required.")); + return false; + } + + if (m_profile.username.trimmed().isEmpty()) { + QMessageBox::warning(this, + QStringLiteral("Connect"), + QStringLiteral("SSH username is required.")); + return false; + } + + if (m_profile.port < 1 || m_profile.port > 65535) { + QMessageBox::warning(this, + QStringLiteral("Connect"), + QStringLiteral("SSH port must be between 1 and 65535.")); + 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; + + QString style; + switch (state) { + case SessionState::Disconnected: + style = QStringLiteral("border: 1px solid #8a8a8a; background-color: #efefef; padding: 6px;"); + break; + case SessionState::Connecting: + style = QStringLiteral("border: 1px solid #a5a5a5; background-color: #fff3cd; padding: 6px;"); + break; + case SessionState::Connected: + style = QStringLiteral("border: 1px solid #3c763d; background-color: #dff0d8; padding: 6px;"); + break; + case SessionState::Failed: + style = QStringLiteral("border: 1px solid #a94442; background-color: #f2dede; padding: 6px;"); + break; + } + + m_statusLabel->setStyleSheet(style); + m_statusLabel->setText(QStringLiteral("Connection State: %1").arg(message)); + + refreshActionButtons(); + emit tabTitleChanged(tabTitle()); +} + +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() +{ + m_connectButton->setEnabled(m_state == SessionState::Disconnected + || m_state == SessionState::Failed); + m_disconnectButton->setEnabled(m_state == SessionState::Connected + || m_state == SessionState::Connecting); + m_reconnectButton->setEnabled(m_state == SessionState::Connected + || m_state == SessionState::Failed + || m_state == SessionState::Disconnected); + m_copyErrorButton->setEnabled(!m_lastError.isEmpty()); +} diff --git a/src/session_tab.h b/src/session_tab.h new file mode 100644 index 0000000..8b7a824 --- /dev/null +++ b/src/session_tab.h @@ -0,0 +1,67 @@ +#ifndef ORBITHUB_SESSION_TAB_H +#define ORBITHUB_SESSION_TAB_H + +#include "profile_repository.h" +#include "session_backend.h" + +#include + +#include + +class QLabel; +class QPlainTextEdit; +class QPushButton; +class QThread; +class SessionBackend; + +class SessionTab : public QWidget +{ + Q_OBJECT + +public: + explicit SessionTab(const Profile& profile, QWidget* parent = nullptr); + ~SessionTab() override; + + QString tabTitle() const; + +signals: + void tabTitleChanged(const QString& title); + void requestConnect(const SessionConnectOptions& options); + void requestDisconnect(); + void requestReconnect(const SessionConnectOptions& options); + +private slots: + void onConnectClicked(); + void onDisconnectClicked(); + void onReconnectClicked(); + void onCopyErrorClicked(); + + void onBackendStateChanged(SessionState state, const QString& message); + void onBackendEventLogged(const QString& message); + void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage); + +private: + Profile m_profile; + QThread* m_backendThread; + SessionBackend* m_backend; + SessionState m_state; + QString m_lastError; + + QLabel* m_statusLabel; + QLabel* m_errorLabel; + QPlainTextEdit* m_eventLog; + QPushButton* m_connectButton; + QPushButton* m_disconnectButton; + QPushButton* m_reconnectButton; + QPushButton* m_copyErrorButton; + + void setupUi(); + std::optional buildConnectOptions(); + bool validateProfileForConnect(); + void appendEvent(const QString& message); + void setState(SessionState state, const QString& message); + QString stateSuffix() const; + void refreshActionButtons(); +}; + +#endif diff --git a/src/session_window.cpp b/src/session_window.cpp index cc758a2..93c81d6 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -1,17 +1,14 @@ #include "session_window.h" -#include -#include +#include "session_tab.h" + #include -#include -#include -#include SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) : QMainWindow(parent), m_tabs(new QTabWidget(this)) { setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name)); - resize(980, 680); + resize(1080, 760); m_tabs->setTabsClosable(true); connect(m_tabs, @@ -32,64 +29,22 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) void SessionWindow::addSessionTab(const Profile& profile) { - auto* container = new QWidget(this); - auto* layout = new QVBoxLayout(container); + auto* tab = new SessionTab(profile, this); + const int index = m_tabs->addTab(tab, tab->tabTitle()); + m_tabs->setCurrentIndex(index); - auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(profile.name), container); - auto* endpointLabel = new QLabel( - QStringLiteral("Endpoint: %1://%2@%3:%4") - .arg(profile.protocol, - profile.username.isEmpty() ? QStringLiteral("") : profile.username, - profile.host, - QString::number(profile.port)), - container); - auto* authModeLabel = new QLabel(QStringLiteral("Auth Mode: %1").arg(profile.authMode), container); - auto* statusLabel = new QLabel(QStringLiteral("Connection State: Connecting"), container); - auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), container); - - QFont profileFont = profileLabel->font(); - profileFont.setBold(true); - profileLabel->setFont(profileFont); - - QFont surfaceFont = surfaceLabel->font(); - surfaceFont.setPointSize(surfaceFont.pointSize() + 6); - surfaceFont.setBold(true); - surfaceLabel->setFont(surfaceFont); - - statusLabel->setStyleSheet( - QStringLiteral("border: 1px solid #a5a5a5; background-color: #fff3cd; padding: 6px;")); - - surfaceLabel->setAlignment(Qt::AlignCenter); - surfaceLabel->setMinimumHeight(220); - surfaceLabel->setStyleSheet( - QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;")); - - layout->addWidget(profileLabel); - layout->addWidget(endpointLabel); - layout->addWidget(authModeLabel); - layout->addWidget(statusLabel); - layout->addWidget(surfaceLabel, 1); - - const int tabIndex = m_tabs->addTab(container, QStringLiteral("%1 (Connecting)").arg(profile.name)); - - QTimer::singleShot(900, - this, - [this, tabIndex, statusLabel, profile]() { - const bool shouldFail = profile.host.contains(QStringLiteral("fail"), - Qt::CaseInsensitive); - if (shouldFail) { - statusLabel->setText(QStringLiteral("Connection State: Failed")); - statusLabel->setStyleSheet(QStringLiteral( - "border: 1px solid #a94442; background-color: #f2dede; padding: 6px;")); - m_tabs->setTabText(tabIndex, - QStringLiteral("%1 (Failed)").arg(profile.name)); - return; - } - - statusLabel->setText(QStringLiteral("Connection State: Connected")); - statusLabel->setStyleSheet(QStringLiteral( - "border: 1px solid #3c763d; background-color: #dff0d8; padding: 6px;")); - m_tabs->setTabText(tabIndex, - QStringLiteral("%1 (Connected)").arg(profile.name)); - }); + connect(tab, + &SessionTab::tabTitleChanged, + this, + [this, tab](const QString& title) { updateTabTitle(tab, title); }); +} + +void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title) +{ + for (int i = 0; i < m_tabs->count(); ++i) { + if (m_tabs->widget(i) == tab) { + m_tabs->setTabText(i, title); + return; + } + } } diff --git a/src/session_window.h b/src/session_window.h index 1136ca6..abfbcac 100644 --- a/src/session_window.h +++ b/src/session_window.h @@ -6,6 +6,7 @@ #include class QTabWidget; +class SessionTab; class SessionWindow : public QMainWindow { @@ -18,6 +19,7 @@ private: QTabWidget* m_tabs; void addSessionTab(const Profile& profile); + void updateTabTitle(SessionTab* tab, const QString& title); }; #endif diff --git a/src/ssh_session_backend.cpp b/src/ssh_session_backend.cpp new file mode 100644 index 0000000..0795663 --- /dev/null +++ b/src/ssh_session_backend.cpp @@ -0,0 +1,419 @@ +#include "ssh_session_backend.h" + +#include +#include +#include +#include +#include +#include + +namespace { +QString escapeForShellSingleQuotes(const QString& value) +{ + QString escaped = value; + escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'")); + return escaped; +} + +QString escapedForWindowsEcho(const QString& value) +{ + QString escaped = value; + escaped.replace(QStringLiteral("^"), QStringLiteral("^^")); + escaped.replace(QStringLiteral("&"), QStringLiteral("^&")); + escaped.replace(QStringLiteral("|"), QStringLiteral("^|")); + escaped.replace(QStringLiteral("<"), QStringLiteral("^<")); + escaped.replace(QStringLiteral(">"), QStringLiteral("^>")); + return escaped; +} +} + +SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent) + : SessionBackend(profile, parent), + m_process(new QProcess(this)), + m_connectedProbeTimer(new QTimer(this)), + m_state(SessionState::Disconnected), + m_userInitiatedDisconnect(false), + m_reconnectPending(false), + m_askPassScript(nullptr) +{ + m_connectedProbeTimer->setSingleShot(true); + + connect(m_process, &QProcess::started, this, &SshSessionBackend::onProcessStarted); + connect(m_process, + &QProcess::errorOccurred, + this, + &SshSessionBackend::onProcessErrorOccurred); + connect(m_process, + qOverload(&QProcess::finished), + this, + &SshSessionBackend::onProcessFinished); + connect(m_process, + &QProcess::readyReadStandardError, + this, + &SshSessionBackend::onReadyReadStandardError); + connect(m_connectedProbeTimer, + &QTimer::timeout, + this, + &SshSessionBackend::onConnectedProbeTimeout); +} + +SshSessionBackend::~SshSessionBackend() +{ + if (m_process->state() != QProcess::NotRunning) { + m_process->kill(); + m_process->waitForFinished(500); + } + cleanupAskPassScript(); +} + +void SshSessionBackend::connectSession(const SessionConnectOptions& options) +{ + if (m_state == SessionState::Connected || m_state == SessionState::Connecting) { + emit eventLogged(QStringLiteral("Connect skipped: session is already active.")); + return; + } + + m_userInitiatedDisconnect = false; + m_reconnectPending = false; + m_lastRawError.clear(); + + if (!startSshProcess(options)) { + return; + } + + setState(SessionState::Connecting, QStringLiteral("Connecting to SSH endpoint...")); + emit eventLogged(QStringLiteral("Launching ssh client.")); +} + +void SshSessionBackend::disconnectSession() +{ + if (m_process->state() == QProcess::NotRunning) { + if (m_state != SessionState::Disconnected) { + setState(SessionState::Disconnected, QStringLiteral("Session is disconnected.")); + } + return; + } + + m_userInitiatedDisconnect = true; + emit eventLogged(QStringLiteral("Disconnect requested.")); + m_connectedProbeTimer->stop(); + + m_process->terminate(); + QTimer::singleShot(1500, + this, + [this]() { + if (m_process->state() != QProcess::NotRunning) { + emit eventLogged(QStringLiteral("Force-stopping ssh process.")); + m_process->kill(); + } + }); +} + +void SshSessionBackend::reconnectSession(const SessionConnectOptions& options) +{ + emit eventLogged(QStringLiteral("Reconnect requested.")); + + if (m_process->state() == QProcess::NotRunning) { + connectSession(options); + return; + } + + m_reconnectPending = true; + m_reconnectOptions = options; + m_userInitiatedDisconnect = true; + m_process->terminate(); +} + +void SshSessionBackend::onProcessStarted() +{ + emit eventLogged(QStringLiteral("ssh process started.")); + m_connectedProbeTimer->start(1200); +} + +void SshSessionBackend::onProcessErrorOccurred(QProcess::ProcessError) +{ + const QString rawError = m_process->errorString(); + if (!rawError.isEmpty()) { + m_lastRawError += rawError + QLatin1Char('\n'); + } + + if (m_state == SessionState::Connecting) { + const QString display = mapSshError(m_lastRawError); + setState(SessionState::Failed, display); + emit connectionError(display, m_lastRawError.trimmed()); + } +} + +void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus) +{ + m_connectedProbeTimer->stop(); + cleanupAskPassScript(); + + if (m_reconnectPending) { + m_reconnectPending = false; + SessionConnectOptions options = m_reconnectOptions; + setState(SessionState::Disconnected, QStringLiteral("Reconnecting...")); + QTimer::singleShot(0, this, [this, options]() { connectSession(options); }); + return; + } + + if (m_userInitiatedDisconnect) { + m_userInitiatedDisconnect = false; + setState(SessionState::Disconnected, QStringLiteral("Session disconnected.")); + emit eventLogged(QStringLiteral("ssh process exited after disconnect request.")); + return; + } + + if (m_state == SessionState::Connecting || exitCode != 0) { + QString rawError = m_lastRawError.trimmed(); + if (rawError.isEmpty()) { + rawError = QStringLiteral("ssh exited with code %1").arg(exitCode); + } + + const QString display = mapSshError(rawError); + setState(SessionState::Failed, display); + emit connectionError(display, rawError); + return; + } + + setState(SessionState::Disconnected, QStringLiteral("SSH session ended.")); +} + +void SshSessionBackend::onReadyReadStandardError() +{ + const QString chunk = QString::fromUtf8(m_process->readAllStandardError()); + if (chunk.isEmpty()) { + return; + } + + m_lastRawError += chunk; + + const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + for (const QString& line : lines) { + emit eventLogged(line.trimmed()); + } +} + +void SshSessionBackend::onConnectedProbeTimeout() +{ + if (m_state != SessionState::Connecting) { + return; + } + + if (m_process->state() == QProcess::Running) { + setState(SessionState::Connected, QStringLiteral("SSH session established.")); + } +} + +void SshSessionBackend::setState(SessionState state, const QString& message) +{ + m_state = state; + emit stateChanged(state, message); + emit eventLogged(message); +} + +bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options) +{ + const Profile& p = profile(); + + if (p.host.trimmed().isEmpty()) { + const QString message = QStringLiteral("Host is required for SSH connections."); + setState(SessionState::Failed, message); + emit connectionError(message, message); + return false; + } + + if (p.port < 1 || p.port > 65535) { + const QString message = QStringLiteral("Port must be between 1 and 65535."); + setState(SessionState::Failed, message); + emit connectionError(message, message); + return false; + } + + cleanupAskPassScript(); + + QStringList args; + args << QStringLiteral("-N") << QStringLiteral("-T") << QStringLiteral("-p") + << QString::number(p.port) << QStringLiteral("-o") + << QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o") + << QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o") + << QStringLiteral("ServerAliveCountMax=2"); + + const QString policy = options.knownHostsPolicy.trimmed().isEmpty() + ? p.knownHostsPolicy.trimmed() + : options.knownHostsPolicy.trimmed(); + + if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) { + args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=no") + << QStringLiteral("-o") + << QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice()); + } else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) { + args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new"); + } else { + args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes"); + } + + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + + if (p.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) { + if (options.password.isEmpty()) { + const QString message = QStringLiteral("Password is required for password authentication."); + setState(SessionState::Failed, message); + emit connectionError(message, message); + return false; + } + + args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password") + << QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no") + << QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1"); + + QString askPassError; + if (!configureAskPass(options, environment, askPassError)) { + setState(SessionState::Failed, askPassError); + emit connectionError(askPassError, askPassError); + return false; + } + } else if (p.authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) { + QString keyPath = options.privateKeyPath.trimmed(); + if (keyPath.isEmpty()) { + keyPath = p.privateKeyPath.trimmed(); + } + + if (keyPath.isEmpty()) { + const QString message = QStringLiteral("Private key path is required."); + setState(SessionState::Failed, message); + emit connectionError(message, message); + return false; + } + + if (!QFileInfo::exists(keyPath)) { + const QString message = QStringLiteral("Private key file does not exist: %1") + .arg(keyPath); + setState(SessionState::Failed, message); + emit connectionError(message, message); + return false; + } + + args << QStringLiteral("-i") << keyPath << QStringLiteral("-o") + << QStringLiteral("PreferredAuthentications=publickey") << QStringLiteral("-o") + << QStringLiteral("PasswordAuthentication=no"); + } + + const QString target = p.username.trimmed().isEmpty() + ? p.host.trimmed() + : QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed()); + args << target; + + m_process->setProcessEnvironment(environment); + m_process->setProgram(QStringLiteral("ssh")); + m_process->setArguments(args); + m_process->setProcessChannelMode(QProcess::SeparateChannels); + + m_process->start(); + if (!m_process->waitForStarted(3000)) { + const QString rawError = m_process->errorString(); + const QString display = mapSshError(rawError); + setState(SessionState::Failed, display); + emit connectionError(display, rawError); + return false; + } + + return true; +} + +bool SshSessionBackend::configureAskPass(const SessionConnectOptions& options, + QProcessEnvironment& environment, + QString& error) +{ +#ifdef Q_OS_WIN + m_askPassScript = new QTemporaryFile(QDir::tempPath() + QStringLiteral("/orbithub_askpass_XXXXXX.cmd"), + this); +#else + m_askPassScript = new QTemporaryFile(QDir::tempPath() + QStringLiteral("/orbithub_askpass_XXXXXX.sh"), + this); +#endif + + if (!m_askPassScript->open()) { + error = QStringLiteral("Failed to create temporary askpass helper script."); + cleanupAskPassScript(); + return false; + } + + QTextStream out(m_askPassScript); +#ifdef Q_OS_WIN + out << "@echo off\r\n"; + out << "echo " << escapedForWindowsEcho(options.password) << "\r\n"; +#else + out << "#!/bin/sh\n"; + out << "printf '%s\\n' '" << escapeForShellSingleQuotes(options.password) << "'\n"; +#endif + out.flush(); + m_askPassScript->flush(); + m_askPassScript->close(); + +#ifndef Q_OS_WIN + QFile::setPermissions(m_askPassScript->fileName(), + QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); +#endif + + environment.insert(QStringLiteral("SSH_ASKPASS"), m_askPassScript->fileName()); + environment.insert(QStringLiteral("SSH_ASKPASS_REQUIRE"), QStringLiteral("force")); + if (!environment.contains(QStringLiteral("DISPLAY"))) { + environment.insert(QStringLiteral("DISPLAY"), QStringLiteral(":0")); + } + + return true; +} + +void SshSessionBackend::cleanupAskPassScript() +{ + if (m_askPassScript != nullptr) { + delete m_askPassScript; + m_askPassScript = nullptr; + } +} + +QString SshSessionBackend::mapSshError(const QString& rawError) const +{ + const QString raw = rawError.trimmed(); + if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) { + return QStringLiteral("Authentication failed. Check username and credentials."); + } + if (raw.contains(QStringLiteral("Host key verification failed"), Qt::CaseInsensitive)) { + return QStringLiteral("Host key verification failed."); + } + if (raw.contains(QStringLiteral("Could not resolve hostname"), Qt::CaseInsensitive)) { + return QStringLiteral("Host could not be resolved."); + } + if (raw.contains(QStringLiteral("Connection timed out"), Qt::CaseInsensitive) + || raw.contains(QStringLiteral("Operation timed out"), Qt::CaseInsensitive)) { + return QStringLiteral("Connection timed out."); + } + if (raw.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive)) { + return QStringLiteral("Connection refused by remote host."); + } + if (raw.contains(QStringLiteral("No route to host"), Qt::CaseInsensitive)) { + return QStringLiteral("No route to host."); + } + if (raw.contains(QStringLiteral("Identity file"), Qt::CaseInsensitive) + && raw.contains(QStringLiteral("not accessible"), Qt::CaseInsensitive)) { + return QStringLiteral("Private key file is not accessible."); + } + if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) { + return QStringLiteral("Required file was not found."); + } + if (raw.isEmpty()) { + return QStringLiteral("SSH connection failed for an unknown reason."); + } + + return QStringLiteral("SSH connection failed."); +} + +QString SshSessionBackend::knownHostsFileForNullDevice() const +{ +#ifdef Q_OS_WIN + return QStringLiteral("NUL"); +#else + return QStringLiteral("/dev/null"); +#endif +} diff --git a/src/ssh_session_backend.h b/src/ssh_session_backend.h new file mode 100644 index 0000000..60fd312 --- /dev/null +++ b/src/ssh_session_backend.h @@ -0,0 +1,52 @@ +#ifndef ORBITHUB_SSH_SESSION_BACKEND_H +#define ORBITHUB_SSH_SESSION_BACKEND_H + +#include "session_backend.h" + +#include +#include +#include + +class QTemporaryFile; + +class SshSessionBackend : public SessionBackend +{ + Q_OBJECT + +public: + explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr); + ~SshSessionBackend() override; + +public slots: + void connectSession(const SessionConnectOptions& options) override; + void disconnectSession() override; + void reconnectSession(const SessionConnectOptions& options) override; + +private slots: + void onProcessStarted(); + void onProcessErrorOccurred(QProcess::ProcessError error); + void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); + void onReadyReadStandardError(); + void onConnectedProbeTimeout(); + +private: + QProcess* m_process; + QTimer* m_connectedProbeTimer; + SessionState m_state; + bool m_userInitiatedDisconnect; + bool m_reconnectPending; + SessionConnectOptions m_reconnectOptions; + QString m_lastRawError; + QTemporaryFile* m_askPassScript; + + void setState(SessionState state, const QString& message); + bool startSshProcess(const SessionConnectOptions& options); + bool configureAskPass(const SessionConnectOptions& options, + QProcessEnvironment& environment, + QString& error); + void cleanupAskPassScript(); + QString mapSshError(const QString& rawError) const; + QString knownHostsFileForNullDevice() const; +}; + +#endif diff --git a/src/unsupported_session_backend.cpp b/src/unsupported_session_backend.cpp new file mode 100644 index 0000000..3b35757 --- /dev/null +++ b/src/unsupported_session_backend.cpp @@ -0,0 +1,26 @@ +#include "unsupported_session_backend.h" + +UnsupportedSessionBackend::UnsupportedSessionBackend(const Profile& profile, QObject* parent) + : SessionBackend(profile, parent) +{ +} + +void UnsupportedSessionBackend::connectSession(const SessionConnectOptions&) +{ + const QString message = QStringLiteral("Protocol '%1' is not implemented yet.") + .arg(profile().protocol); + emit eventLogged(message); + emit stateChanged(SessionState::Failed, message); + emit connectionError(message, message); +} + +void UnsupportedSessionBackend::disconnectSession() +{ + emit stateChanged(SessionState::Disconnected, + QStringLiteral("No active connection for this protocol.")); +} + +void UnsupportedSessionBackend::reconnectSession(const SessionConnectOptions& options) +{ + connectSession(options); +} diff --git a/src/unsupported_session_backend.h b/src/unsupported_session_backend.h new file mode 100644 index 0000000..743f5cc --- /dev/null +++ b/src/unsupported_session_backend.h @@ -0,0 +1,19 @@ +#ifndef ORBITHUB_UNSUPPORTED_SESSION_BACKEND_H +#define ORBITHUB_UNSUPPORTED_SESSION_BACKEND_H + +#include "session_backend.h" + +class UnsupportedSessionBackend : public SessionBackend +{ + Q_OBJECT + +public: + explicit UnsupportedSessionBackend(const Profile& profile, QObject* parent = nullptr); + +public slots: + void connectSession(const SessionConnectOptions& options) override; + void disconnectSession() override; + void reconnectSession(const SessionConnectOptions& options) override; +}; + +#endif