Compare commits
5 Commits
01762422e9
...
v0-m3-done
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c158269bf | ||
|
|
e2a8b874d7 | ||
|
|
71a2f2e868 | ||
|
|
6a4bcb75eb | ||
|
|
582c57bc5f |
@@ -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)
|
||||
|
||||
@@ -8,7 +8,8 @@ Run all commands from the repository root unless noted.
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
build-essential cmake ninja-build git pkg-config \
|
||||
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools
|
||||
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
|
||||
openssh-client
|
||||
|
||||
cmake -S . -B build -G Ninja
|
||||
cmake --build build
|
||||
@@ -20,7 +21,7 @@ cmake --build build
|
||||
```bash
|
||||
xcode-select --install
|
||||
brew update
|
||||
brew install cmake ninja pkg-config qt@6
|
||||
brew install cmake ninja pkg-config qt@6 openssh
|
||||
|
||||
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
|
||||
cmake --build build
|
||||
@@ -33,6 +34,7 @@ cmake --build build
|
||||
winget install -e --id Git.Git
|
||||
winget install -e --id Kitware.CMake
|
||||
winget install -e --id Ninja-build.Ninja
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
|
||||
winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
|
||||
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
|
||||
```
|
||||
@@ -53,4 +55,5 @@ cmake --build build
|
||||
## Notes
|
||||
|
||||
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+.
|
||||
- Milestone 3 SSH sessions require an `ssh` client available on `PATH`.
|
||||
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.
|
||||
|
||||
@@ -64,3 +64,16 @@ OrbitHub uses a two-window model:
|
||||
- Connect loads full profile details into session tab
|
||||
- Session lifecycle states in UI (`Connecting`, `Connected`, `Failed`) with non-blocking updates
|
||||
- Tag: v0-m2-done
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3
|
||||
|
||||
- Real SSH backend using native `ssh` process (connect, disconnect, reconnect)
|
||||
- Protocol backend abstraction with worker-thread execution
|
||||
- RDP/VNC explicitly marked as not implemented in session UX
|
||||
- Connect-time credential prompts (password/private key path) with no secret storage in DB
|
||||
- Session tab controls: `Connect`, `Disconnect`, `Reconnect`, `Copy Error`
|
||||
- Per-session timestamped event log and user-friendly error mapping
|
||||
- Profile schema extended with `private_key_path` and `known_hosts_policy`
|
||||
- Tag: v0-m3-done
|
||||
|
||||
@@ -43,3 +43,20 @@ Delivered:
|
||||
|
||||
Git:
|
||||
- Tag: `v0-m2-done`
|
||||
|
||||
## Milestone 3 - Real SSH Backend and Session Controls
|
||||
|
||||
Status: Completed
|
||||
|
||||
Delivered:
|
||||
- Backend architecture introduced (`SessionBackend` + protocol-specific implementations)
|
||||
- Worker-thread backend execution for connection lifecycle operations
|
||||
- Real SSH process backend (`ssh`) with connect/disconnect/reconnect
|
||||
- Unsupported protocol backend with explicit not-implemented messaging (RDP/VNC)
|
||||
- Session tab controls: `Connect`, `Disconnect`, `Reconnect`, `Copy Error`
|
||||
- Connect-time credential flow (password prompt / private-key path selection)
|
||||
- Session event log pane with timestamps and user-friendly error mapping
|
||||
- SQLite profile schema migration for `private_key_path` and `known_hosts_policy`
|
||||
|
||||
Git:
|
||||
- Tag: `v0-m3-done`
|
||||
|
||||
@@ -2,13 +2,30 @@
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSignalBlocker>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace {
|
||||
int standardPortForProtocol(const QString& protocol)
|
||||
{
|
||||
if (protocol == QStringLiteral("RDP")) {
|
||||
return 3389;
|
||||
}
|
||||
if (protocol == QStringLiteral("VNC")) {
|
||||
return 5900;
|
||||
}
|
||||
return 22; // SSH default
|
||||
}
|
||||
}
|
||||
|
||||
ProfileDialog::ProfileDialog(QWidget* parent)
|
||||
: QDialog(parent),
|
||||
m_nameInput(new QLineEdit(this)),
|
||||
@@ -16,9 +33,12 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
||||
m_portInput(new QSpinBox(this)),
|
||||
m_usernameInput(new QLineEdit(this)),
|
||||
m_protocolInput(new QComboBox(this)),
|
||||
m_authModeInput(new QComboBox(this))
|
||||
m_authModeInput(new QComboBox(this)),
|
||||
m_privateKeyPathInput(new QLineEdit(this)),
|
||||
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
|
||||
m_knownHostsPolicyInput(new QComboBox(this))
|
||||
{
|
||||
resize(420, 260);
|
||||
resize(520, 340);
|
||||
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
auto* form = new QFormLayout();
|
||||
@@ -31,6 +51,42 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
||||
|
||||
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
|
||||
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
|
||||
m_knownHostsPolicyInput->addItems(
|
||||
{QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")});
|
||||
|
||||
m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519"));
|
||||
|
||||
auto* privateKeyRow = new QWidget(this);
|
||||
auto* privateKeyLayout = new QHBoxLayout(privateKeyRow);
|
||||
privateKeyLayout->setContentsMargins(0, 0, 0, 0);
|
||||
privateKeyLayout->addWidget(m_privateKeyPathInput, 1);
|
||||
privateKeyLayout->addWidget(m_browsePrivateKeyButton);
|
||||
|
||||
connect(m_browsePrivateKeyButton,
|
||||
&QPushButton::clicked,
|
||||
this,
|
||||
[this]() {
|
||||
const QString selected = QFileDialog::getOpenFileName(this,
|
||||
QStringLiteral("Select Private Key"),
|
||||
QString(),
|
||||
QStringLiteral("All Files (*)"));
|
||||
if (!selected.isEmpty()) {
|
||||
m_privateKeyPathInput->setText(selected);
|
||||
}
|
||||
});
|
||||
|
||||
connect(m_protocolInput,
|
||||
&QComboBox::currentTextChanged,
|
||||
this,
|
||||
[this](const QString& protocol) {
|
||||
m_portInput->setValue(standardPortForProtocol(protocol));
|
||||
refreshAuthFields();
|
||||
});
|
||||
|
||||
connect(m_authModeInput,
|
||||
&QComboBox::currentTextChanged,
|
||||
this,
|
||||
[this](const QString&) { refreshAuthFields(); });
|
||||
|
||||
form->addRow(QStringLiteral("Name"), m_nameInput);
|
||||
form->addRow(QStringLiteral("Host"), m_hostInput);
|
||||
@@ -38,13 +94,23 @@ ProfileDialog::ProfileDialog(QWidget* parent)
|
||||
form->addRow(QStringLiteral("Username"), m_usernameInput);
|
||||
form->addRow(QStringLiteral("Protocol"), m_protocolInput);
|
||||
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
|
||||
form->addRow(QStringLiteral("Private Key"), privateKeyRow);
|
||||
form->addRow(QStringLiteral("Known Hosts"), m_knownHostsPolicyInput);
|
||||
|
||||
auto* note = new QLabel(
|
||||
QStringLiteral("Passwords are requested at connect time and are not stored."),
|
||||
this);
|
||||
note->setWordWrap(true);
|
||||
|
||||
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
layout->addLayout(form);
|
||||
layout->addWidget(note);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
refreshAuthFields();
|
||||
}
|
||||
|
||||
void ProfileDialog::setDialogTitle(const QString& title)
|
||||
@@ -58,12 +124,22 @@ void ProfileDialog::setProfile(const Profile& profile)
|
||||
m_hostInput->setText(profile.host);
|
||||
m_portInput->setValue(profile.port > 0 ? profile.port : 22);
|
||||
m_usernameInput->setText(profile.username);
|
||||
m_privateKeyPathInput->setText(profile.privateKeyPath);
|
||||
|
||||
const int protocolIndex = m_protocolInput->findText(profile.protocol);
|
||||
m_protocolInput->setCurrentIndex(protocolIndex >= 0 ? protocolIndex : 0);
|
||||
{
|
||||
// Keep stored custom port when loading an existing profile.
|
||||
const QSignalBlocker blocker(m_protocolInput);
|
||||
m_protocolInput->setCurrentIndex(protocolIndex >= 0 ? protocolIndex : 0);
|
||||
}
|
||||
|
||||
const int authModeIndex = m_authModeInput->findText(profile.authMode);
|
||||
m_authModeInput->setCurrentIndex(authModeIndex >= 0 ? authModeIndex : 0);
|
||||
|
||||
const int knownHostsIndex = m_knownHostsPolicyInput->findText(profile.knownHostsPolicy);
|
||||
m_knownHostsPolicyInput->setCurrentIndex(knownHostsIndex >= 0 ? knownHostsIndex : 0);
|
||||
|
||||
refreshAuthFields();
|
||||
}
|
||||
|
||||
Profile ProfileDialog::profile() const
|
||||
@@ -76,6 +152,8 @@ Profile ProfileDialog::profile() const
|
||||
profile.username = m_usernameInput->text().trimmed();
|
||||
profile.protocol = m_protocolInput->currentText();
|
||||
profile.authMode = m_authModeInput->currentText();
|
||||
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed();
|
||||
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText();
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -95,5 +173,24 @@ void ProfileDialog::accept()
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_protocolInput->currentText() == QStringLiteral("SSH")
|
||||
&& m_usernameInput->text().trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Validation Error"),
|
||||
QStringLiteral("Username is required for SSH profiles."));
|
||||
return;
|
||||
}
|
||||
|
||||
QDialog::accept();
|
||||
}
|
||||
|
||||
void ProfileDialog::refreshAuthFields()
|
||||
{
|
||||
const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH");
|
||||
const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key");
|
||||
|
||||
m_authModeInput->setEnabled(isSsh);
|
||||
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
|
||||
m_browsePrivateKeyButton->setEnabled(isSsh && isPrivateKey);
|
||||
m_knownHostsPolicyInput->setEnabled(isSsh);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
class QComboBox;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
|
||||
class ProfileDialog : public QDialog
|
||||
@@ -30,6 +31,11 @@ private:
|
||||
QLineEdit* m_usernameInput;
|
||||
QComboBox* m_protocolInput;
|
||||
QComboBox* m_authModeInput;
|
||||
QLineEdit* m_privateKeyPathInput;
|
||||
QPushButton* m_browsePrivateKeyButton;
|
||||
QComboBox* m_knownHostsPolicyInput;
|
||||
|
||||
void refreshAuthFields();
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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<Profile> 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<Profile> 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<Profile> 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)) {
|
||||
|
||||
@@ -16,6 +16,8 @@ struct Profile
|
||||
QString username;
|
||||
QString protocol = QStringLiteral("SSH");
|
||||
QString authMode = QStringLiteral("Password");
|
||||
QString privateKeyPath;
|
||||
QString knownHostsPolicy = QStringLiteral("Strict");
|
||||
};
|
||||
|
||||
class ProfileRepository
|
||||
|
||||
@@ -119,12 +119,13 @@ void ProfilesWindow::loadProfiles(const QString& query)
|
||||
for (const Profile& profile : profiles) {
|
||||
auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList);
|
||||
item->setData(Qt::UserRole, QVariant::fromValue(profile.id));
|
||||
item->setToolTip(QStringLiteral("%1://%2@%3:%4\nAuth: %5")
|
||||
item->setToolTip(QStringLiteral("%1://%2@%3:%4\nAuth: %5\nKnown Hosts: %6")
|
||||
.arg(profile.protocol,
|
||||
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
|
||||
profile.host,
|
||||
QString::number(profile.port),
|
||||
profile.authMode));
|
||||
profile.authMode,
|
||||
profile.knownHostsPolicy));
|
||||
m_profileCache.insert_or_assign(profile.id, profile);
|
||||
}
|
||||
}
|
||||
|
||||
57
src/session_backend.h
Normal file
57
src/session_backend.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#ifndef ORBITHUB_SESSION_BACKEND_H
|
||||
#define ORBITHUB_SESSION_BACKEND_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
14
src/session_backend_factory.cpp
Normal file
14
src/session_backend_factory.cpp
Normal file
@@ -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<SessionBackend> createSessionBackend(const Profile& profile)
|
||||
{
|
||||
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
|
||||
return std::make_unique<SshSessionBackend>(profile);
|
||||
}
|
||||
|
||||
return std::make_unique<UnsupportedSessionBackend>(profile);
|
||||
}
|
||||
12
src/session_backend_factory.h
Normal file
12
src/session_backend_factory.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#ifndef ORBITHUB_SESSION_BACKEND_FACTORY_H
|
||||
#define ORBITHUB_SESSION_BACKEND_FACTORY_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
class SessionBackend;
|
||||
|
||||
std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile);
|
||||
|
||||
#endif
|
||||
363
src/session_tab.cpp
Normal file
363
src/session_tab.cpp
Normal file
@@ -0,0 +1,363 @@
|
||||
#include "session_tab.h"
|
||||
|
||||
#include "session_backend_factory.h"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QDateTime>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QThread>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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>("SessionConnectOptions");
|
||||
qRegisterMetaType<SessionState>("SessionState");
|
||||
|
||||
setupUi();
|
||||
|
||||
std::unique_ptr<SessionBackend> backend = createSessionBackend(m_profile);
|
||||
m_backend = backend.release();
|
||||
m_backend->moveToThread(m_backendThread);
|
||||
|
||||
connect(m_backendThread, &QThread::finished, m_backend, &QObject::deleteLater);
|
||||
connect(this,
|
||||
&SessionTab::requestConnect,
|
||||
m_backend,
|
||||
&SessionBackend::connectSession,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestDisconnect,
|
||||
m_backend,
|
||||
&SessionBackend::disconnectSession,
|
||||
Qt::QueuedConnection);
|
||||
connect(this,
|
||||
&SessionTab::requestReconnect,
|
||||
m_backend,
|
||||
&SessionBackend::reconnectSession,
|
||||
Qt::QueuedConnection);
|
||||
|
||||
connect(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<SessionConnectOptions> 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<SessionConnectOptions> 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("<none>") : 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<SessionConnectOptions> 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());
|
||||
}
|
||||
67
src/session_tab.h
Normal file
67
src/session_tab.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#ifndef ORBITHUB_SESSION_TAB_H
|
||||
#define ORBITHUB_SESSION_TAB_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
#include "session_backend.h"
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include <optional>
|
||||
|
||||
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<SessionConnectOptions> buildConnectOptions();
|
||||
bool validateProfileForConnect();
|
||||
void appendEvent(const QString& message);
|
||||
void setState(SessionState state, const QString& message);
|
||||
QString stateSuffix() const;
|
||||
void refreshActionButtons();
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,17 +1,14 @@
|
||||
#include "session_window.h"
|
||||
|
||||
#include <QFont>
|
||||
#include <QLabel>
|
||||
#include "session_tab.h"
|
||||
|
||||
#include <QTabWidget>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
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("<none>") : 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <QMainWindow>
|
||||
|
||||
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
|
||||
|
||||
429
src/ssh_session_backend.cpp
Normal file
429
src/ssh_session_backend.cpp
Normal file
@@ -0,0 +1,429 @@
|
||||
#include "ssh_session_backend.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTextStream>
|
||||
#include <QUuid>
|
||||
|
||||
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_connectedProbeTimer->setSingleShot(true);
|
||||
|
||||
connect(m_process, &QProcess::started, this, &SshSessionBackend::onProcessStarted);
|
||||
connect(m_process,
|
||||
&QProcess::errorOccurred,
|
||||
this,
|
||||
&SshSessionBackend::onProcessErrorOccurred);
|
||||
connect(m_process,
|
||||
qOverload<int, QProcess::ExitStatus>(&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)
|
||||
{
|
||||
cleanupAskPassScript();
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
m_askPassScriptPath = QDir::temp().filePath(
|
||||
QStringLiteral("orbithub_askpass_%1.cmd")
|
||||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces)));
|
||||
#else
|
||||
m_askPassScriptPath = QDir::temp().filePath(
|
||||
QStringLiteral("orbithub_askpass_%1.sh")
|
||||
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces)));
|
||||
#endif
|
||||
|
||||
QFile script(m_askPassScriptPath);
|
||||
if (!script.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
error = QStringLiteral("Failed to create temporary askpass helper script.");
|
||||
cleanupAskPassScript();
|
||||
return false;
|
||||
}
|
||||
|
||||
QTextStream out(&script);
|
||||
#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();
|
||||
script.close();
|
||||
|
||||
#ifndef Q_OS_WIN
|
||||
if (!QFile::setPermissions(m_askPassScriptPath,
|
||||
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)) {
|
||||
error = QStringLiteral("Failed to set permissions on askpass helper script.");
|
||||
cleanupAskPassScript();
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
environment.insert(QStringLiteral("SSH_ASKPASS"), m_askPassScriptPath);
|
||||
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_askPassScriptPath.isEmpty()) {
|
||||
QFile::remove(m_askPassScriptPath);
|
||||
m_askPassScriptPath.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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.contains(QStringLiteral("Text file busy"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("Credential helper could not start (text file busy). Retry the connection.");
|
||||
}
|
||||
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
|
||||
}
|
||||
50
src/ssh_session_backend.h
Normal file
50
src/ssh_session_backend.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#ifndef ORBITHUB_SSH_SESSION_BACKEND_H
|
||||
#define ORBITHUB_SSH_SESSION_BACKEND_H
|
||||
|
||||
#include "session_backend.h"
|
||||
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
|
||||
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;
|
||||
QString m_askPassScriptPath;
|
||||
|
||||
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
|
||||
26
src/unsupported_session_backend.cpp
Normal file
26
src/unsupported_session_backend.cpp
Normal file
@@ -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);
|
||||
}
|
||||
19
src/unsupported_session_backend.h
Normal file
19
src/unsupported_session_backend.h
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user