Compare commits
7 Commits
v0-m1-done
...
3c158269bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c158269bf | ||
|
|
e2a8b874d7 | ||
|
|
71a2f2e868 | ||
|
|
6a4bcb75eb | ||
|
|
582c57bc5f | ||
|
|
01762422e9 | ||
|
|
f8a81ebe36 |
@@ -16,12 +16,23 @@ qt_standard_project_setup()
|
||||
|
||||
add_executable(orbithub
|
||||
src/main.cpp
|
||||
src/profile_dialog.cpp
|
||||
src/profile_dialog.h
|
||||
src/profile_repository.cpp
|
||||
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.
|
||||
|
||||
@@ -54,3 +54,26 @@ OrbitHub uses a two-window model:
|
||||
- Profiles CRUD
|
||||
- Connect loads profile name into tab
|
||||
- Tag: v0-m1-done
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2
|
||||
|
||||
- Extend profiles schema (`host`, `port`, `username`, `protocol`, `auth_mode`)
|
||||
- Replace prompt-based create/edit with a structured profile dialog form
|
||||
- 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
|
||||
|
||||
@@ -14,7 +14,6 @@ Delivered:
|
||||
- Cross-platform build command guide in `docs/BUILDING.md`
|
||||
|
||||
Git:
|
||||
- Branch: `milestone-0-restart-cpp`
|
||||
- Tag: `v0-m0-done`
|
||||
|
||||
## Milestone 1 - Storage and CRUD
|
||||
@@ -29,5 +28,35 @@ Delivered:
|
||||
- Double-click connect opens `SessionWindow` tab with selected profile name
|
||||
|
||||
Git:
|
||||
- Branch: `milestone-1-storage`
|
||||
- Tag: `v0-m1-done`
|
||||
|
||||
## Milestone 2 - Profile Details and Connect Lifecycle
|
||||
|
||||
Status: Completed
|
||||
|
||||
Delivered:
|
||||
- SQLite schema migration for profile details (`host`, `port`, `username`, `protocol`, `auth_mode`)
|
||||
- New `ProfileDialog` form for New/Edit profile workflows
|
||||
- Profiles list now shows endpoint metadata and supports search by name or host
|
||||
- Connect now loads complete profile details into `SessionWindow`
|
||||
- Session tab lifecycle status updates (`Connecting`, `Connected`, `Failed`) via non-blocking timer flow
|
||||
|
||||
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`
|
||||
|
||||
196
src/profile_dialog.cpp
Normal file
196
src/profile_dialog.cpp
Normal file
@@ -0,0 +1,196 @@
|
||||
#include "profile_dialog.h"
|
||||
|
||||
#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)),
|
||||
m_hostInput(new QLineEdit(this)),
|
||||
m_portInput(new QSpinBox(this)),
|
||||
m_usernameInput(new QLineEdit(this)),
|
||||
m_protocolInput(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(520, 340);
|
||||
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
auto* form = new QFormLayout();
|
||||
|
||||
m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
|
||||
m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
|
||||
m_portInput->setRange(1, 65535);
|
||||
m_portInput->setValue(22);
|
||||
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
|
||||
|
||||
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);
|
||||
form->addRow(QStringLiteral("Port"), m_portInput);
|
||||
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)
|
||||
{
|
||||
setWindowTitle(title);
|
||||
}
|
||||
|
||||
void ProfileDialog::setProfile(const Profile& profile)
|
||||
{
|
||||
m_nameInput->setText(profile.name);
|
||||
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);
|
||||
{
|
||||
// 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
|
||||
{
|
||||
Profile profile;
|
||||
profile.id = -1;
|
||||
profile.name = m_nameInput->text().trimmed();
|
||||
profile.host = m_hostInput->text().trimmed();
|
||||
profile.port = m_portInput->value();
|
||||
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;
|
||||
}
|
||||
|
||||
void ProfileDialog::accept()
|
||||
{
|
||||
if (m_nameInput->text().trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Validation Error"),
|
||||
QStringLiteral("Profile name is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_hostInput->text().trimmed().isEmpty()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Validation Error"),
|
||||
QStringLiteral("Host is required."));
|
||||
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);
|
||||
}
|
||||
41
src/profile_dialog.h
Normal file
41
src/profile_dialog.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#ifndef ORBITHUB_PROFILE_DIALOG_H
|
||||
#define ORBITHUB_PROFILE_DIALOG_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
class QComboBox;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class QSpinBox;
|
||||
|
||||
class ProfileDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ProfileDialog(QWidget* parent = nullptr);
|
||||
|
||||
void setDialogTitle(const QString& title);
|
||||
void setProfile(const Profile& profile);
|
||||
Profile profile() const;
|
||||
|
||||
protected:
|
||||
void accept() override;
|
||||
|
||||
private:
|
||||
QLineEdit* m_nameInput;
|
||||
QLineEdit* m_hostInput;
|
||||
QSpinBox* m_portInput;
|
||||
QLineEdit* m_usernameInput;
|
||||
QComboBox* m_protocolInput;
|
||||
QComboBox* m_authModeInput;
|
||||
QLineEdit* m_privateKeyPathInput;
|
||||
QPushButton* m_browsePrivateKeyButton;
|
||||
QComboBox* m_knownHostsPolicyInput;
|
||||
|
||||
void refreshAuthFields();
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QSet>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
@@ -20,6 +21,44 @@ QString buildDatabasePath()
|
||||
|
||||
return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite"));
|
||||
}
|
||||
|
||||
void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
||||
{
|
||||
query.addBindValue(profile.name.trimmed());
|
||||
query.addBindValue(profile.host.trimmed());
|
||||
query.addBindValue(profile.port);
|
||||
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)
|
||||
{
|
||||
Profile profile;
|
||||
profile.id = query.value(0).toLongLong();
|
||||
profile.name = query.value(1).toString();
|
||||
profile.host = query.value(2).toString();
|
||||
profile.port = query.value(3).toInt();
|
||||
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;
|
||||
}
|
||||
|
||||
bool isProfileValid(const Profile& profile)
|
||||
{
|
||||
return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty()
|
||||
&& profile.port >= 1 && profile.port <= 65535;
|
||||
}
|
||||
}
|
||||
|
||||
ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbithub_main"))
|
||||
@@ -45,6 +84,11 @@ QString ProfileRepository::initError() const
|
||||
return m_initError;
|
||||
}
|
||||
|
||||
QString ProfileRepository::lastError() const
|
||||
{
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const
|
||||
{
|
||||
std::vector<Profile> result;
|
||||
@@ -53,68 +97,115 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
|
||||
return result;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
if (searchQuery.trimmed().isEmpty()) {
|
||||
query.prepare(QStringLiteral(
|
||||
"SELECT id, name FROM profiles ORDER BY lower(name) ASC, id ASC"));
|
||||
"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 FROM profiles "
|
||||
"WHERE lower(name) LIKE lower(?) "
|
||||
"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"));
|
||||
query.addBindValue(QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%"));
|
||||
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
|
||||
query.addBindValue(search);
|
||||
query.addBindValue(search);
|
||||
}
|
||||
|
||||
if (!query.exec()) {
|
||||
setLastError(query.lastError().text());
|
||||
return result;
|
||||
}
|
||||
|
||||
while (query.next()) {
|
||||
result.push_back(Profile{query.value(0).toLongLong(), query.value(1).toString()});
|
||||
result.push_back(profileFromQuery(query));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Profile> ProfileRepository::createProfile(const QString& name) const
|
||||
std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
|
||||
{
|
||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const QString trimmedName = name.trimmed();
|
||||
if (trimmedName.isEmpty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
setLastError(QString());
|
||||
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(QStringLiteral("INSERT INTO profiles(name) VALUES (?)"));
|
||||
query.addBindValue(trimmedName);
|
||||
|
||||
if (!query.exec()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return Profile{query.lastInsertId().toLongLong(), trimmedName};
|
||||
}
|
||||
|
||||
bool ProfileRepository::updateProfile(qint64 id, const QString& name) const
|
||||
{
|
||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString trimmedName = name.trimmed();
|
||||
if (trimmedName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(QStringLiteral("UPDATE profiles SET name = ? WHERE id = ?"));
|
||||
query.addBindValue(trimmedName);
|
||||
query.prepare(QStringLiteral(
|
||||
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
|
||||
"FROM profiles WHERE id = ?"));
|
||||
query.addBindValue(id);
|
||||
|
||||
if (!query.exec()) {
|
||||
setLastError(query.lastError().text());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (!query.next()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return profileFromQuery(query);
|
||||
}
|
||||
|
||||
std::optional<Profile> ProfileRepository::createProfile(const Profile& profile) const
|
||||
{
|
||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
|
||||
if (!isProfileValid(profile)) {
|
||||
setLastError(QStringLiteral("Name, host, and a valid port are required."));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(QStringLiteral(
|
||||
"INSERT INTO profiles(name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)"));
|
||||
bindProfileFields(query, profile);
|
||||
|
||||
if (!query.exec()) {
|
||||
setLastError(query.lastError().text());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Profile created = profile;
|
||||
created.id = query.lastInsertId().toLongLong();
|
||||
return created;
|
||||
}
|
||||
|
||||
bool ProfileRepository::updateProfile(const Profile& profile) const
|
||||
{
|
||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
|
||||
if (profile.id < 0 || !isProfileValid(profile)) {
|
||||
setLastError(QStringLiteral("Invalid profile data."));
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(QStringLiteral(
|
||||
"UPDATE profiles "
|
||||
"SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ? "
|
||||
"WHERE id = ?"));
|
||||
bindProfileFields(query, profile);
|
||||
query.addBindValue(profile.id);
|
||||
|
||||
if (!query.exec()) {
|
||||
setLastError(query.lastError().text());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -127,11 +218,14 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
||||
return false;
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
|
||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||
query.prepare(QStringLiteral("DELETE FROM profiles WHERE id = ?"));
|
||||
query.addBindValue(id);
|
||||
|
||||
if (!query.exec()) {
|
||||
setLastError(query.lastError().text());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -152,12 +246,78 @@ bool ProfileRepository::initializeDatabase()
|
||||
const bool created = query.exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS profiles ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"name TEXT NOT NULL UNIQUE"
|
||||
"name TEXT NOT NULL UNIQUE,"
|
||||
"host TEXT NOT NULL DEFAULT '',"
|
||||
"port INTEGER NOT NULL DEFAULT 22,"
|
||||
"username TEXT NOT NULL DEFAULT '',"
|
||||
"protocol TEXT NOT NULL DEFAULT 'SSH',"
|
||||
"auth_mode TEXT NOT NULL DEFAULT 'Password',"
|
||||
"private_key_path TEXT NOT NULL DEFAULT '',"
|
||||
"known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'"
|
||||
")"));
|
||||
|
||||
if (!created) {
|
||||
m_initError = query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
|
||||
return created;
|
||||
if (!ensureProfileSchema()) {
|
||||
m_initError = m_lastError;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ProfileRepository::ensureProfileSchema() const
|
||||
{
|
||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||
setLastError(QStringLiteral("Database connection missing."));
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery tableInfo(QSqlDatabase::database(m_connectionName));
|
||||
if (!tableInfo.exec(QStringLiteral("PRAGMA table_info(profiles)"))) {
|
||||
setLastError(tableInfo.lastError().text());
|
||||
return false;
|
||||
}
|
||||
|
||||
QSet<QString> columns;
|
||||
while (tableInfo.next()) {
|
||||
columns.insert(tableInfo.value(1).toString());
|
||||
}
|
||||
|
||||
struct ColumnDef {
|
||||
QString name;
|
||||
QString ddl;
|
||||
};
|
||||
|
||||
const std::vector<ColumnDef> required = {
|
||||
{QStringLiteral("host"), QStringLiteral("ALTER TABLE profiles ADD COLUMN host TEXT NOT NULL DEFAULT ''")},
|
||||
{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("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)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QSqlQuery alter(QSqlDatabase::database(m_connectionName));
|
||||
if (!alter.exec(column.ddl)) {
|
||||
setLastError(alter.lastError().text());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setLastError(QString());
|
||||
return true;
|
||||
}
|
||||
|
||||
void ProfileRepository::setLastError(const QString& error) const
|
||||
{
|
||||
m_lastError = error;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
#define ORBITHUB_PROFILE_REPOSITORY_H
|
||||
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
struct Profile
|
||||
{
|
||||
qint64 id;
|
||||
qint64 id = -1;
|
||||
QString name;
|
||||
QString host;
|
||||
int port = 22;
|
||||
QString username;
|
||||
QString protocol = QStringLiteral("SSH");
|
||||
QString authMode = QStringLiteral("Password");
|
||||
QString privateKeyPath;
|
||||
QString knownHostsPolicy = QStringLiteral("Strict");
|
||||
};
|
||||
|
||||
class ProfileRepository
|
||||
@@ -19,17 +27,22 @@ public:
|
||||
~ProfileRepository();
|
||||
|
||||
QString initError() const;
|
||||
QString lastError() const;
|
||||
|
||||
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const;
|
||||
std::optional<Profile> createProfile(const QString& name) const;
|
||||
bool updateProfile(qint64 id, const QString& name) const;
|
||||
std::optional<Profile> getProfile(qint64 id) const;
|
||||
std::optional<Profile> createProfile(const Profile& profile) const;
|
||||
bool updateProfile(const Profile& profile) const;
|
||||
bool deleteProfile(qint64 id) const;
|
||||
|
||||
private:
|
||||
QString m_connectionName;
|
||||
QString m_initError;
|
||||
mutable QString m_lastError;
|
||||
|
||||
bool initializeDatabase();
|
||||
bool ensureProfileSchema() const;
|
||||
void setLastError(const QString& error) const;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "profiles_window.h"
|
||||
|
||||
#include "profile_dialog.h"
|
||||
#include "profile_repository.h"
|
||||
#include "session_window.h"
|
||||
|
||||
@@ -7,7 +8,6 @@
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
@@ -18,6 +18,14 @@
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
namespace {
|
||||
QString formatProfileListItem(const Profile& profile)
|
||||
{
|
||||
return QStringLiteral("%1 [%2 %3:%4]")
|
||||
.arg(profile.name, profile.protocol, profile.host, QString::number(profile.port));
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||
: QMainWindow(parent),
|
||||
m_searchBox(nullptr),
|
||||
@@ -28,7 +36,7 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||
m_repository(std::make_unique<ProfileRepository>())
|
||||
{
|
||||
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
|
||||
resize(520, 620);
|
||||
resize(640, 620);
|
||||
|
||||
setupUi();
|
||||
|
||||
@@ -57,7 +65,7 @@ void ProfilesWindow::setupUi()
|
||||
|
||||
auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
|
||||
m_searchBox = new QLineEdit(central);
|
||||
m_searchBox->setPlaceholderText(QStringLiteral("Filter profiles..."));
|
||||
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name or host..."));
|
||||
|
||||
m_profilesList = new QListWidget(central);
|
||||
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
@@ -97,15 +105,32 @@ void ProfilesWindow::setupUi()
|
||||
void ProfilesWindow::loadProfiles(const QString& query)
|
||||
{
|
||||
m_profilesList->clear();
|
||||
m_profileCache.clear();
|
||||
|
||||
const std::vector<Profile> profiles = m_repository->listProfiles(query);
|
||||
if (!m_repository->lastError().isEmpty()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Load Profiles"),
|
||||
QStringLiteral("Failed to load profiles: %1")
|
||||
.arg(m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const Profile& profile : profiles) {
|
||||
auto* item = new QListWidgetItem(profile.name, m_profilesList);
|
||||
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\nKnown Hosts: %6")
|
||||
.arg(profile.protocol,
|
||||
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
|
||||
profile.host,
|
||||
QString::number(profile.port),
|
||||
profile.authMode,
|
||||
profile.knownHostsPolicy));
|
||||
m_profileCache.insert_or_assign(profile.id, profile);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<qint64> ProfilesWindow::selectedProfileId() const
|
||||
std::optional<Profile> ProfilesWindow::selectedProfile() const
|
||||
{
|
||||
QListWidgetItem* item = m_profilesList->currentItem();
|
||||
if (item == nullptr) {
|
||||
@@ -117,27 +142,31 @@ std::optional<qint64> ProfilesWindow::selectedProfileId() const
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return value.toLongLong();
|
||||
const qint64 id = value.toLongLong();
|
||||
const auto cacheIt = m_profileCache.find(id);
|
||||
if (cacheIt != m_profileCache.end()) {
|
||||
return cacheIt->second;
|
||||
}
|
||||
|
||||
return m_repository->getProfile(id);
|
||||
}
|
||||
|
||||
void ProfilesWindow::createProfile()
|
||||
{
|
||||
bool accepted = false;
|
||||
const QString name = QInputDialog::getText(this,
|
||||
QStringLiteral("New Profile"),
|
||||
QStringLiteral("Profile name:"),
|
||||
QLineEdit::Normal,
|
||||
QString(),
|
||||
&accepted);
|
||||
ProfileDialog dialog(this);
|
||||
dialog.setDialogTitle(QStringLiteral("New Profile"));
|
||||
|
||||
if (!accepted || name.trimmed().isEmpty()) {
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_repository->createProfile(name).has_value()) {
|
||||
if (!m_repository->createProfile(dialog.profile()).has_value()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Create Profile"),
|
||||
QStringLiteral("Failed to create profile. Names must be unique."));
|
||||
QStringLiteral("Failed to create profile: %1")
|
||||
.arg(m_repository->lastError().isEmpty()
|
||||
? QStringLiteral("unknown error")
|
||||
: m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,31 +175,32 @@ void ProfilesWindow::createProfile()
|
||||
|
||||
void ProfilesWindow::editSelectedProfile()
|
||||
{
|
||||
const std::optional<qint64> profileId = selectedProfileId();
|
||||
QListWidgetItem* currentItem = m_profilesList->currentItem();
|
||||
if (!profileId.has_value() || currentItem == nullptr) {
|
||||
const std::optional<Profile> selected = selectedProfile();
|
||||
if (!selected.has_value()) {
|
||||
QMessageBox::information(this,
|
||||
QStringLiteral("Edit Profile"),
|
||||
QStringLiteral("Select a profile first."));
|
||||
return;
|
||||
}
|
||||
|
||||
bool accepted = false;
|
||||
const QString name = QInputDialog::getText(this,
|
||||
QStringLiteral("Edit Profile"),
|
||||
QStringLiteral("Profile name:"),
|
||||
QLineEdit::Normal,
|
||||
currentItem->text(),
|
||||
&accepted);
|
||||
ProfileDialog dialog(this);
|
||||
dialog.setDialogTitle(QStringLiteral("Edit Profile"));
|
||||
dialog.setProfile(selected.value());
|
||||
|
||||
if (!accepted || name.trimmed().isEmpty()) {
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_repository->updateProfile(profileId.value(), name)) {
|
||||
Profile updated = dialog.profile();
|
||||
updated.id = selected->id;
|
||||
|
||||
if (!m_repository->updateProfile(updated)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Edit Profile"),
|
||||
QStringLiteral("Failed to update profile. Names must be unique."));
|
||||
QStringLiteral("Failed to update profile: %1")
|
||||
.arg(m_repository->lastError().isEmpty()
|
||||
? QStringLiteral("unknown error")
|
||||
: m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,9 +209,8 @@ void ProfilesWindow::editSelectedProfile()
|
||||
|
||||
void ProfilesWindow::deleteSelectedProfile()
|
||||
{
|
||||
const std::optional<qint64> profileId = selectedProfileId();
|
||||
QListWidgetItem* currentItem = m_profilesList->currentItem();
|
||||
if (!profileId.has_value() || currentItem == nullptr) {
|
||||
const std::optional<Profile> selected = selectedProfile();
|
||||
if (!selected.has_value()) {
|
||||
QMessageBox::information(this,
|
||||
QStringLiteral("Delete Profile"),
|
||||
QStringLiteral("Select a profile first."));
|
||||
@@ -191,7 +220,7 @@ void ProfilesWindow::deleteSelectedProfile()
|
||||
const QMessageBox::StandardButton confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Delete Profile"),
|
||||
QStringLiteral("Delete profile '%1'?").arg(currentItem->text()),
|
||||
QStringLiteral("Delete profile '%1'?").arg(selected->name),
|
||||
QMessageBox::Yes | QMessageBox::No,
|
||||
QMessageBox::No);
|
||||
|
||||
@@ -199,10 +228,13 @@ void ProfilesWindow::deleteSelectedProfile()
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_repository->deleteProfile(profileId.value())) {
|
||||
if (!m_repository->deleteProfile(selected->id)) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Delete Profile"),
|
||||
QStringLiteral("Failed to delete profile."));
|
||||
QStringLiteral("Failed to delete profile: %1")
|
||||
.arg(m_repository->lastError().isEmpty()
|
||||
? QStringLiteral("unknown error")
|
||||
: m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +247,24 @@ void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
|
||||
return;
|
||||
}
|
||||
|
||||
auto* session = new SessionWindow(item->text());
|
||||
const QVariant value = item->data(Qt::UserRole);
|
||||
if (!value.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const qint64 id = value.toLongLong();
|
||||
const std::optional<Profile> profile = m_repository->getProfile(id);
|
||||
if (!profile.has_value()) {
|
||||
QMessageBox::warning(this,
|
||||
QStringLiteral("Connect"),
|
||||
QStringLiteral("Failed to load profile for session: %1")
|
||||
.arg(m_repository->lastError().isEmpty()
|
||||
? QStringLiteral("profile not found")
|
||||
: m_repository->lastError()));
|
||||
return;
|
||||
}
|
||||
|
||||
auto* session = new SessionWindow(profile.value());
|
||||
session->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
m_sessionWindows.emplace_back(session);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#ifndef ORBITHUB_PROFILES_WINDOW_H
|
||||
#define ORBITHUB_PROFILES_WINDOW_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
@@ -8,6 +10,7 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <QPointer>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
class QListWidget;
|
||||
@@ -15,7 +18,6 @@ class QListWidgetItem;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class SessionWindow;
|
||||
class ProfileRepository;
|
||||
|
||||
class ProfilesWindow : public QMainWindow
|
||||
{
|
||||
@@ -33,10 +35,11 @@ private:
|
||||
QPushButton* m_deleteButton;
|
||||
std::vector<QPointer<SessionWindow>> m_sessionWindows;
|
||||
std::unique_ptr<ProfileRepository> m_repository;
|
||||
std::unordered_map<qint64, Profile> m_profileCache;
|
||||
|
||||
void setupUi();
|
||||
void loadProfiles(const QString& query = QString());
|
||||
std::optional<qint64> selectedProfileId() const;
|
||||
std::optional<Profile> selectedProfile() const;
|
||||
void createProfile();
|
||||
void editSelectedProfile();
|
||||
void deleteSelectedProfile();
|
||||
|
||||
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,45 +1,50 @@
|
||||
#include "session_window.h"
|
||||
|
||||
#include <QFont>
|
||||
#include <QLabel>
|
||||
#include <QTabWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include "session_tab.h"
|
||||
|
||||
SessionWindow::SessionWindow(const QString& profileName, QWidget* parent)
|
||||
#include <QTabWidget>
|
||||
|
||||
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
||||
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
||||
{
|
||||
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profileName));
|
||||
resize(900, 600);
|
||||
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
||||
resize(1080, 760);
|
||||
|
||||
m_tabs->setTabsClosable(true);
|
||||
connect(m_tabs,
|
||||
&QTabWidget::tabCloseRequested,
|
||||
this,
|
||||
[this](int index) {
|
||||
QWidget* tab = m_tabs->widget(index);
|
||||
m_tabs->removeTab(index);
|
||||
delete tab;
|
||||
if (m_tabs->count() == 0) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
setCentralWidget(m_tabs);
|
||||
addPlaceholderTab(profileName);
|
||||
addSessionTab(profile);
|
||||
}
|
||||
|
||||
void SessionWindow::addPlaceholderTab(const QString& profileName)
|
||||
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* titleLabel = new QLabel(QStringLiteral("Profile: %1").arg(profileName), container);
|
||||
auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), container);
|
||||
|
||||
QFont titleFont = titleLabel->font();
|
||||
titleFont.setBold(true);
|
||||
titleLabel->setFont(titleFont);
|
||||
|
||||
QFont surfaceFont = surfaceLabel->font();
|
||||
surfaceFont.setPointSize(surfaceFont.pointSize() + 6);
|
||||
surfaceFont.setBold(true);
|
||||
surfaceLabel->setFont(surfaceFont);
|
||||
|
||||
surfaceLabel->setAlignment(Qt::AlignCenter);
|
||||
surfaceLabel->setMinimumHeight(200);
|
||||
surfaceLabel->setStyleSheet(
|
||||
QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;"));
|
||||
|
||||
layout->addWidget(titleLabel);
|
||||
layout->addWidget(surfaceLabel, 1);
|
||||
|
||||
m_tabs->addTab(container, profileName);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
#ifndef ORBITHUB_SESSION_WINDOW_H
|
||||
#define ORBITHUB_SESSION_WINDOW_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QString>
|
||||
|
||||
class QTabWidget;
|
||||
class SessionTab;
|
||||
|
||||
class SessionWindow : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SessionWindow(const QString& profileName, QWidget* parent = nullptr);
|
||||
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr);
|
||||
|
||||
private:
|
||||
QTabWidget* m_tabs;
|
||||
|
||||
void addPlaceholderTab(const QString& profileName);
|
||||
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