Compare commits
7 Commits
v0-m1-done
...
v0-m3-done
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c158269bf | ||
|
|
e2a8b874d7 | ||
|
|
71a2f2e868 | ||
|
|
6a4bcb75eb | ||
|
|
582c57bc5f | ||
|
|
01762422e9 | ||
|
|
f8a81ebe36 |
@@ -16,12 +16,23 @@ qt_standard_project_setup()
|
|||||||
|
|
||||||
add_executable(orbithub
|
add_executable(orbithub
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
|
src/profile_dialog.cpp
|
||||||
|
src/profile_dialog.h
|
||||||
src/profile_repository.cpp
|
src/profile_repository.cpp
|
||||||
src/profile_repository.h
|
src/profile_repository.h
|
||||||
src/profiles_window.cpp
|
src/profiles_window.cpp
|
||||||
src/profiles_window.h
|
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.cpp
|
||||||
src/session_window.h
|
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)
|
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 update
|
||||||
sudo apt install -y \
|
sudo apt install -y \
|
||||||
build-essential cmake ninja-build git pkg-config \
|
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 -S . -B build -G Ninja
|
||||||
cmake --build build
|
cmake --build build
|
||||||
@@ -20,7 +21,7 @@ cmake --build build
|
|||||||
```bash
|
```bash
|
||||||
xcode-select --install
|
xcode-select --install
|
||||||
brew update
|
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 -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
|
||||||
cmake --build build
|
cmake --build build
|
||||||
@@ -33,6 +34,7 @@ cmake --build build
|
|||||||
winget install -e --id Git.Git
|
winget install -e --id Git.Git
|
||||||
winget install -e --id Kitware.CMake
|
winget install -e --id Kitware.CMake
|
||||||
winget install -e --id Ninja-build.Ninja
|
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 `
|
winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
|
||||||
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
|
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
|
||||||
```
|
```
|
||||||
@@ -53,4 +55,5 @@ cmake --build build
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+.
|
- 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.
|
- 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
|
- Profiles CRUD
|
||||||
- Connect loads profile name into tab
|
- Connect loads profile name into tab
|
||||||
- Tag: v0-m1-done
|
- 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`
|
- Cross-platform build command guide in `docs/BUILDING.md`
|
||||||
|
|
||||||
Git:
|
Git:
|
||||||
- Branch: `milestone-0-restart-cpp`
|
|
||||||
- Tag: `v0-m0-done`
|
- Tag: `v0-m0-done`
|
||||||
|
|
||||||
## Milestone 1 - Storage and CRUD
|
## Milestone 1 - Storage and CRUD
|
||||||
@@ -29,5 +28,35 @@ Delivered:
|
|||||||
- Double-click connect opens `SessionWindow` tab with selected profile name
|
- Double-click connect opens `SessionWindow` tab with selected profile name
|
||||||
|
|
||||||
Git:
|
Git:
|
||||||
- Branch: `milestone-1-storage`
|
|
||||||
- Tag: `v0-m1-done`
|
- 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 "profile_repository.h"
|
||||||
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QSet>
|
||||||
#include <QSqlDatabase>
|
#include <QSqlDatabase>
|
||||||
#include <QSqlError>
|
#include <QSqlError>
|
||||||
#include <QSqlQuery>
|
#include <QSqlQuery>
|
||||||
@@ -20,6 +21,44 @@ QString buildDatabasePath()
|
|||||||
|
|
||||||
return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite"));
|
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"))
|
ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbithub_main"))
|
||||||
@@ -45,6 +84,11 @@ QString ProfileRepository::initError() const
|
|||||||
return m_initError;
|
return m_initError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ProfileRepository::lastError() const
|
||||||
|
{
|
||||||
|
return m_lastError;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const
|
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const
|
||||||
{
|
{
|
||||||
std::vector<Profile> result;
|
std::vector<Profile> result;
|
||||||
@@ -53,68 +97,115 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLastError(QString());
|
||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
if (searchQuery.trimmed().isEmpty()) {
|
if (searchQuery.trimmed().isEmpty()) {
|
||||||
query.prepare(QStringLiteral(
|
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 {
|
} else {
|
||||||
query.prepare(QStringLiteral(
|
query.prepare(QStringLiteral(
|
||||||
"SELECT id, name FROM profiles "
|
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
|
||||||
"WHERE lower(name) LIKE lower(?) "
|
"FROM profiles "
|
||||||
|
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) "
|
||||||
"ORDER BY lower(name) ASC, id ASC"));
|
"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()) {
|
if (!query.exec()) {
|
||||||
|
setLastError(query.lastError().text());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (query.next()) {
|
while (query.next()) {
|
||||||
result.push_back(Profile{query.value(0).toLongLong(), query.value(1).toString()});
|
result.push_back(profileFromQuery(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<Profile> ProfileRepository::createProfile(const QString& name) const
|
std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
|
||||||
{
|
{
|
||||||
if (!QSqlDatabase::contains(m_connectionName)) {
|
if (!QSqlDatabase::contains(m_connectionName)) {
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString trimmedName = name.trimmed();
|
setLastError(QString());
|
||||||
if (trimmedName.isEmpty()) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
query.prepare(QStringLiteral("INSERT INTO profiles(name) VALUES (?)"));
|
query.prepare(QStringLiteral(
|
||||||
query.addBindValue(trimmedName);
|
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
|
||||||
|
"FROM profiles WHERE id = ?"));
|
||||||
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.addBindValue(id);
|
query.addBindValue(id);
|
||||||
|
|
||||||
if (!query.exec()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +218,14 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLastError(QString());
|
||||||
|
|
||||||
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
QSqlQuery query(QSqlDatabase::database(m_connectionName));
|
||||||
query.prepare(QStringLiteral("DELETE FROM profiles WHERE id = ?"));
|
query.prepare(QStringLiteral("DELETE FROM profiles WHERE id = ?"));
|
||||||
query.addBindValue(id);
|
query.addBindValue(id);
|
||||||
|
|
||||||
if (!query.exec()) {
|
if (!query.exec()) {
|
||||||
|
setLastError(query.lastError().text());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +246,78 @@ bool ProfileRepository::initializeDatabase()
|
|||||||
const bool created = query.exec(QStringLiteral(
|
const bool created = query.exec(QStringLiteral(
|
||||||
"CREATE TABLE IF NOT EXISTS profiles ("
|
"CREATE TABLE IF NOT EXISTS profiles ("
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
"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) {
|
if (!created) {
|
||||||
m_initError = query.lastError().text();
|
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
|
#define ORBITHUB_PROFILE_REPOSITORY_H
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct Profile
|
struct Profile
|
||||||
{
|
{
|
||||||
qint64 id;
|
qint64 id = -1;
|
||||||
QString name;
|
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
|
class ProfileRepository
|
||||||
@@ -19,17 +27,22 @@ public:
|
|||||||
~ProfileRepository();
|
~ProfileRepository();
|
||||||
|
|
||||||
QString initError() const;
|
QString initError() const;
|
||||||
|
QString lastError() const;
|
||||||
|
|
||||||
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const;
|
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const;
|
||||||
std::optional<Profile> createProfile(const QString& name) const;
|
std::optional<Profile> getProfile(qint64 id) const;
|
||||||
bool updateProfile(qint64 id, const QString& name) const;
|
std::optional<Profile> createProfile(const Profile& profile) const;
|
||||||
|
bool updateProfile(const Profile& profile) const;
|
||||||
bool deleteProfile(qint64 id) const;
|
bool deleteProfile(qint64 id) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QString m_connectionName;
|
QString m_connectionName;
|
||||||
QString m_initError;
|
QString m_initError;
|
||||||
|
mutable QString m_lastError;
|
||||||
|
|
||||||
bool initializeDatabase();
|
bool initializeDatabase();
|
||||||
|
bool ensureProfileSchema() const;
|
||||||
|
void setLastError(const QString& error) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "profiles_window.h"
|
#include "profiles_window.h"
|
||||||
|
|
||||||
|
#include "profile_dialog.h"
|
||||||
#include "profile_repository.h"
|
#include "profile_repository.h"
|
||||||
#include "session_window.h"
|
#include "session_window.h"
|
||||||
|
|
||||||
@@ -7,7 +8,6 @@
|
|||||||
|
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QInputDialog>
|
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QListWidget>
|
#include <QListWidget>
|
||||||
@@ -18,6 +18,14 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#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)
|
ProfilesWindow::ProfilesWindow(QWidget* parent)
|
||||||
: QMainWindow(parent),
|
: QMainWindow(parent),
|
||||||
m_searchBox(nullptr),
|
m_searchBox(nullptr),
|
||||||
@@ -28,7 +36,7 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
|
|||||||
m_repository(std::make_unique<ProfileRepository>())
|
m_repository(std::make_unique<ProfileRepository>())
|
||||||
{
|
{
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
|
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
|
||||||
resize(520, 620);
|
resize(640, 620);
|
||||||
|
|
||||||
setupUi();
|
setupUi();
|
||||||
|
|
||||||
@@ -57,7 +65,7 @@ void ProfilesWindow::setupUi()
|
|||||||
|
|
||||||
auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
|
auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
|
||||||
m_searchBox = new QLineEdit(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 = new QListWidget(central);
|
||||||
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection);
|
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
@@ -97,15 +105,32 @@ void ProfilesWindow::setupUi()
|
|||||||
void ProfilesWindow::loadProfiles(const QString& query)
|
void ProfilesWindow::loadProfiles(const QString& query)
|
||||||
{
|
{
|
||||||
m_profilesList->clear();
|
m_profilesList->clear();
|
||||||
|
m_profileCache.clear();
|
||||||
|
|
||||||
const std::vector<Profile> profiles = m_repository->listProfiles(query);
|
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) {
|
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->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();
|
QListWidgetItem* item = m_profilesList->currentItem();
|
||||||
if (item == nullptr) {
|
if (item == nullptr) {
|
||||||
@@ -117,27 +142,31 @@ std::optional<qint64> ProfilesWindow::selectedProfileId() const
|
|||||||
return std::nullopt;
|
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()
|
void ProfilesWindow::createProfile()
|
||||||
{
|
{
|
||||||
bool accepted = false;
|
ProfileDialog dialog(this);
|
||||||
const QString name = QInputDialog::getText(this,
|
dialog.setDialogTitle(QStringLiteral("New Profile"));
|
||||||
QStringLiteral("New Profile"),
|
|
||||||
QStringLiteral("Profile name:"),
|
|
||||||
QLineEdit::Normal,
|
|
||||||
QString(),
|
|
||||||
&accepted);
|
|
||||||
|
|
||||||
if (!accepted || name.trimmed().isEmpty()) {
|
if (dialog.exec() != QDialog::Accepted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m_repository->createProfile(name).has_value()) {
|
if (!m_repository->createProfile(dialog.profile()).has_value()) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Create Profile"),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,31 +175,32 @@ void ProfilesWindow::createProfile()
|
|||||||
|
|
||||||
void ProfilesWindow::editSelectedProfile()
|
void ProfilesWindow::editSelectedProfile()
|
||||||
{
|
{
|
||||||
const std::optional<qint64> profileId = selectedProfileId();
|
const std::optional<Profile> selected = selectedProfile();
|
||||||
QListWidgetItem* currentItem = m_profilesList->currentItem();
|
if (!selected.has_value()) {
|
||||||
if (!profileId.has_value() || currentItem == nullptr) {
|
|
||||||
QMessageBox::information(this,
|
QMessageBox::information(this,
|
||||||
QStringLiteral("Edit Profile"),
|
QStringLiteral("Edit Profile"),
|
||||||
QStringLiteral("Select a profile first."));
|
QStringLiteral("Select a profile first."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool accepted = false;
|
ProfileDialog dialog(this);
|
||||||
const QString name = QInputDialog::getText(this,
|
dialog.setDialogTitle(QStringLiteral("Edit Profile"));
|
||||||
QStringLiteral("Edit Profile"),
|
dialog.setProfile(selected.value());
|
||||||
QStringLiteral("Profile name:"),
|
|
||||||
QLineEdit::Normal,
|
|
||||||
currentItem->text(),
|
|
||||||
&accepted);
|
|
||||||
|
|
||||||
if (!accepted || name.trimmed().isEmpty()) {
|
if (dialog.exec() != QDialog::Accepted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m_repository->updateProfile(profileId.value(), name)) {
|
Profile updated = dialog.profile();
|
||||||
|
updated.id = selected->id;
|
||||||
|
|
||||||
|
if (!m_repository->updateProfile(updated)) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Edit Profile"),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +209,8 @@ void ProfilesWindow::editSelectedProfile()
|
|||||||
|
|
||||||
void ProfilesWindow::deleteSelectedProfile()
|
void ProfilesWindow::deleteSelectedProfile()
|
||||||
{
|
{
|
||||||
const std::optional<qint64> profileId = selectedProfileId();
|
const std::optional<Profile> selected = selectedProfile();
|
||||||
QListWidgetItem* currentItem = m_profilesList->currentItem();
|
if (!selected.has_value()) {
|
||||||
if (!profileId.has_value() || currentItem == nullptr) {
|
|
||||||
QMessageBox::information(this,
|
QMessageBox::information(this,
|
||||||
QStringLiteral("Delete Profile"),
|
QStringLiteral("Delete Profile"),
|
||||||
QStringLiteral("Select a profile first."));
|
QStringLiteral("Select a profile first."));
|
||||||
@@ -191,7 +220,7 @@ void ProfilesWindow::deleteSelectedProfile()
|
|||||||
const QMessageBox::StandardButton confirm = QMessageBox::question(
|
const QMessageBox::StandardButton confirm = QMessageBox::question(
|
||||||
this,
|
this,
|
||||||
QStringLiteral("Delete Profile"),
|
QStringLiteral("Delete Profile"),
|
||||||
QStringLiteral("Delete profile '%1'?").arg(currentItem->text()),
|
QStringLiteral("Delete profile '%1'?").arg(selected->name),
|
||||||
QMessageBox::Yes | QMessageBox::No,
|
QMessageBox::Yes | QMessageBox::No,
|
||||||
QMessageBox::No);
|
QMessageBox::No);
|
||||||
|
|
||||||
@@ -199,10 +228,13 @@ void ProfilesWindow::deleteSelectedProfile()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m_repository->deleteProfile(profileId.value())) {
|
if (!m_repository->deleteProfile(selected->id)) {
|
||||||
QMessageBox::warning(this,
|
QMessageBox::warning(this,
|
||||||
QStringLiteral("Delete Profile"),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +247,24 @@ void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
|
|||||||
return;
|
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);
|
session->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
|
||||||
m_sessionWindows.emplace_back(session);
|
m_sessionWindows.emplace_back(session);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#ifndef ORBITHUB_PROFILES_WINDOW_H
|
#ifndef ORBITHUB_PROFILES_WINDOW_H
|
||||||
#define ORBITHUB_PROFILES_WINDOW_H
|
#define ORBITHUB_PROFILES_WINDOW_H
|
||||||
|
|
||||||
|
#include "profile_repository.h"
|
||||||
|
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
@@ -8,6 +10,7 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
class QListWidget;
|
class QListWidget;
|
||||||
@@ -15,7 +18,6 @@ class QListWidgetItem;
|
|||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class SessionWindow;
|
class SessionWindow;
|
||||||
class ProfileRepository;
|
|
||||||
|
|
||||||
class ProfilesWindow : public QMainWindow
|
class ProfilesWindow : public QMainWindow
|
||||||
{
|
{
|
||||||
@@ -33,10 +35,11 @@ private:
|
|||||||
QPushButton* m_deleteButton;
|
QPushButton* m_deleteButton;
|
||||||
std::vector<QPointer<SessionWindow>> m_sessionWindows;
|
std::vector<QPointer<SessionWindow>> m_sessionWindows;
|
||||||
std::unique_ptr<ProfileRepository> m_repository;
|
std::unique_ptr<ProfileRepository> m_repository;
|
||||||
|
std::unordered_map<qint64, Profile> m_profileCache;
|
||||||
|
|
||||||
void setupUi();
|
void setupUi();
|
||||||
void loadProfiles(const QString& query = QString());
|
void loadProfiles(const QString& query = QString());
|
||||||
std::optional<qint64> selectedProfileId() const;
|
std::optional<Profile> selectedProfile() const;
|
||||||
void createProfile();
|
void createProfile();
|
||||||
void editSelectedProfile();
|
void editSelectedProfile();
|
||||||
void deleteSelectedProfile();
|
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 "session_window.h"
|
||||||
|
|
||||||
#include <QFont>
|
#include "session_tab.h"
|
||||||
#include <QLabel>
|
|
||||||
#include <QTabWidget>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
SessionWindow::SessionWindow(const QString& profileName, QWidget* parent)
|
#include <QTabWidget>
|
||||||
|
|
||||||
|
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
||||||
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
||||||
{
|
{
|
||||||
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profileName));
|
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
|
||||||
resize(900, 600);
|
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);
|
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* tab = new SessionTab(profile, this);
|
||||||
auto* layout = new QVBoxLayout(container);
|
const int index = m_tabs->addTab(tab, tab->tabTitle());
|
||||||
|
m_tabs->setCurrentIndex(index);
|
||||||
|
|
||||||
auto* titleLabel = new QLabel(QStringLiteral("Profile: %1").arg(profileName), container);
|
connect(tab,
|
||||||
auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), container);
|
&SessionTab::tabTitleChanged,
|
||||||
|
this,
|
||||||
QFont titleFont = titleLabel->font();
|
[this, tab](const QString& title) { updateTabTitle(tab, title); });
|
||||||
titleFont.setBold(true);
|
}
|
||||||
titleLabel->setFont(titleFont);
|
|
||||||
|
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
|
||||||
QFont surfaceFont = surfaceLabel->font();
|
{
|
||||||
surfaceFont.setPointSize(surfaceFont.pointSize() + 6);
|
for (int i = 0; i < m_tabs->count(); ++i) {
|
||||||
surfaceFont.setBold(true);
|
if (m_tabs->widget(i) == tab) {
|
||||||
surfaceLabel->setFont(surfaceFont);
|
m_tabs->setTabText(i, title);
|
||||||
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
#ifndef ORBITHUB_SESSION_WINDOW_H
|
#ifndef ORBITHUB_SESSION_WINDOW_H
|
||||||
#define ORBITHUB_SESSION_WINDOW_H
|
#define ORBITHUB_SESSION_WINDOW_H
|
||||||
|
|
||||||
|
#include "profile_repository.h"
|
||||||
|
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QString>
|
|
||||||
|
|
||||||
class QTabWidget;
|
class QTabWidget;
|
||||||
|
class SessionTab;
|
||||||
|
|
||||||
class SessionWindow : public QMainWindow
|
class SessionWindow : public QMainWindow
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit SessionWindow(const QString& profileName, QWidget* parent = nullptr);
|
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QTabWidget* m_tabs;
|
QTabWidget* m_tabs;
|
||||||
|
|
||||||
void addPlaceholderTab(const QString& profileName);
|
void addSessionTab(const Profile& profile);
|
||||||
|
void updateTabTitle(SessionTab* tab, const QString& title);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#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