11 Commits

Author SHA1 Message Date
Keith Smith
3c158269bf Fix SSH askpass helper text-file-busy race 2026-03-01 09:42:32 -07:00
Keith Smith
e2a8b874d7 Document Milestone 3 deliverables and SSH prerequisites 2026-03-01 09:37:43 -07:00
Keith Smith
71a2f2e868 Expand profile editor for SSH auth and host policy fields 2026-03-01 09:37:37 -07:00
Keith Smith
6a4bcb75eb Add threaded session backend architecture with real SSH backend 2026-03-01 09:37:34 -07:00
Keith Smith
582c57bc5f Auto-fill standard port when protocol changes 2026-03-01 09:24:31 -07:00
Keith Smith
01762422e9 Document Milestone 2 scope and completion 2026-03-01 09:21:57 -07:00
Keith Smith
f8a81ebe36 Implement Milestone 2 profile schema, dialog, and connect lifecycle 2026-03-01 09:21:53 -07:00
Keith Smith
87b0f60569 Update progress for Milestone 1 completion 2026-03-01 09:06:36 -07:00
Keith Smith
09c54313af Implement SQLite-backed profile CRUD in Profiles window 2026-03-01 09:06:34 -07:00
Keith Smith
fba7336f69 Add SQLite profile repository and Qt SQL dependency 2026-03-01 09:06:31 -07:00
Keith Smith
6d147894c5 Add cross-platform development environment setup guide 2026-03-01 09:02:53 -07:00
22 changed files with 2035 additions and 79 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build/

View File

@@ -10,18 +10,31 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
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)
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
install(TARGETS orbithub RUNTIME DESTINATION bin)

59
docs/BUILDING.md Normal file
View File

@@ -0,0 +1,59 @@
# Building OrbitHub (C++ / Qt6 Widgets)
Run all commands from the repository root unless noted.
## Linux (Ubuntu / Mint)
```bash
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 \
openssh-client
cmake -S . -B build -G Ninja
cmake --build build
./build/orbithub
```
## macOS (Homebrew)
```bash
xcode-select --install
brew update
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
./build/orbithub
```
## Windows 11 (PowerShell + MSVC + vcpkg)
```powershell
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"
```
Open a new terminal after installs, then:
```powershell
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
C:\dev\vcpkg\bootstrap-vcpkg.bat
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows
cmake -S . -B build -G Ninja `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build
.\build\orbithub.exe
```
## 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.

View File

@@ -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

View File

@@ -11,7 +11,52 @@ Delivered:
- `SessionWindow` (`QMainWindow`) with `QTabWidget`
- Placeholder tab content showing `OrbitHub Native Surface`
- `main.cpp` wiring for application startup
- Cross-platform build command guide in `docs/BUILDING.md`
Git:
- Branch: `milestone-0-restart-cpp`
- Tag: `v0-m0-done`
## Milestone 1 - Storage and CRUD
Status: Completed
Delivered:
- SQLite integration via Qt SQL (`QSQLITE`)
- Persistent profile database bootstrap (`profiles` table)
- Profiles CRUD (New / Edit / Delete) in `ProfilesWindow`
- Search-backed profile listing from storage
- Double-click connect opens `SessionWindow` tab with selected profile name
Git:
- 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
View 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
View 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

323
src/profile_repository.cpp Normal file
View File

@@ -0,0 +1,323 @@
#include "profile_repository.h"
#include <QDir>
#include <QSet>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QStandardPaths>
#include <QVariant>
namespace {
QString buildDatabasePath()
{
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if (appDataPath.isEmpty()) {
appDataPath = QDir::currentPath();
}
QDir dataDir(appDataPath);
dataDir.mkpath(QStringLiteral("."));
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"))
{
if (!initializeDatabase()) {
QSqlDatabase::removeDatabase(m_connectionName);
}
}
ProfileRepository::~ProfileRepository()
{
if (QSqlDatabase::contains(m_connectionName)) {
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
if (db.isOpen()) {
db.close();
}
}
QSqlDatabase::removeDatabase(m_connectionName);
}
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;
if (!QSqlDatabase::contains(m_connectionName)) {
return result;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
"FROM profiles "
"ORDER BY lower(name) ASC, id ASC"));
} else {
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode, 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"));
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(profileFromQuery(query));
}
return result;
}
std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return std::nullopt;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
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;
}
return query.numRowsAffected() > 0;
}
bool ProfileRepository::deleteProfile(qint64 id) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
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;
}
return query.numRowsAffected() > 0;
}
bool ProfileRepository::initializeDatabase()
{
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
database.setDatabaseName(buildDatabasePath());
if (!database.open()) {
m_initError = database.lastError().text();
return false;
}
QSqlQuery query(database);
const bool created = query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profiles ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"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;
}
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;
}

48
src/profile_repository.h Normal file
View File

@@ -0,0 +1,48 @@
#ifndef ORBITHUB_PROFILE_REPOSITORY_H
#define ORBITHUB_PROFILE_REPOSITORY_H
#include <QString>
#include <QtGlobal>
#include <optional>
#include <vector>
struct Profile
{
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
{
public:
ProfileRepository();
~ProfileRepository();
QString initError() const;
QString lastError() const;
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) 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

View File

@@ -1,5 +1,7 @@
#include "profiles_window.h"
#include "profile_dialog.h"
#include "profile_repository.h"
#include "session_window.h"
#include <algorithm>
@@ -12,25 +14,50 @@
#include <QListWidgetItem>
#include <QMessageBox>
#include <QPushButton>
#include <QStringList>
#include <QVariant>
#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),
m_profilesList(nullptr),
m_newButton(nullptr),
m_editButton(nullptr),
m_deleteButton(nullptr)
m_deleteButton(nullptr),
m_repository(std::make_unique<ProfileRepository>())
{
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
resize(520, 620);
resize(640, 620);
setupUi();
populateSampleProfiles();
if (!m_repository->initError().isEmpty()) {
QMessageBox::critical(this,
QStringLiteral("Database Error"),
QStringLiteral("Failed to initialize SQLite database: %1")
.arg(m_repository->initError()));
m_newButton->setEnabled(false);
m_editButton->setEnabled(false);
m_deleteButton->setEnabled(false);
m_searchBox->setEnabled(false);
m_profilesList->setEnabled(false);
return;
}
loadProfiles();
}
ProfilesWindow::~ProfilesWindow() = default;
void ProfilesWindow::setupUi()
{
auto* central = new QWidget(this);
@@ -38,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);
@@ -63,49 +90,155 @@ void ProfilesWindow::setupUi()
connect(m_searchBox,
&QLineEdit::textChanged,
this,
[this](const QString& text) { filterProfiles(text); });
[this](const QString& text) { loadProfiles(text); });
connect(m_profilesList,
&QListWidget::itemDoubleClicked,
this,
[this](QListWidgetItem* item) { openSessionForItem(item); });
// Milestone 0 keeps profile management as placeholders.
auto showTodo = [this](const QString& action) {
connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); });
connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); });
connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); });
}
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(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<Profile> ProfilesWindow::selectedProfile() const
{
QListWidgetItem* item = m_profilesList->currentItem();
if (item == nullptr) {
return std::nullopt;
}
const QVariant value = item->data(Qt::UserRole);
if (!value.isValid()) {
return std::nullopt;
}
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()
{
ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("New Profile"));
if (dialog.exec() != QDialog::Accepted) {
return;
}
if (!m_repository->createProfile(dialog.profile()).has_value()) {
QMessageBox::warning(this,
QStringLiteral("Create Profile"),
QStringLiteral("Failed to create profile: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::editSelectedProfile()
{
const std::optional<Profile> selected = selectedProfile();
if (!selected.has_value()) {
QMessageBox::information(this,
QStringLiteral("Milestone 0"),
QStringLiteral("%1 is planned for Milestone 1.").arg(action));
};
QStringLiteral("Edit Profile"),
QStringLiteral("Select a profile first."));
return;
}
connect(m_newButton, &QPushButton::clicked, this, [showTodo]() { showTodo(QStringLiteral("New Profile")); });
connect(m_editButton, &QPushButton::clicked, this, [showTodo]() { showTodo(QStringLiteral("Edit Profile")); });
connect(m_deleteButton,
&QPushButton::clicked,
ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("Edit Profile"));
dialog.setProfile(selected.value());
if (dialog.exec() != QDialog::Accepted) {
return;
}
Profile updated = dialog.profile();
updated.id = selected->id;
if (!m_repository->updateProfile(updated)) {
QMessageBox::warning(this,
QStringLiteral("Edit Profile"),
QStringLiteral("Failed to update profile: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::deleteSelectedProfile()
{
const std::optional<Profile> selected = selectedProfile();
if (!selected.has_value()) {
QMessageBox::information(this,
QStringLiteral("Delete Profile"),
QStringLiteral("Select a profile first."));
return;
}
const QMessageBox::StandardButton confirm = QMessageBox::question(
this,
[showTodo]() { showTodo(QStringLiteral("Delete Profile")); });
QStringLiteral("Delete Profile"),
QStringLiteral("Delete profile '%1'?").arg(selected->name),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
if (confirm != QMessageBox::Yes) {
return;
}
void ProfilesWindow::populateSampleProfiles()
{
const QStringList sampleProfiles = {
QStringLiteral("Production Bastion"),
QStringLiteral("Staging API Host"),
QStringLiteral("Local Lab VM"),
QStringLiteral("CI Build Agent")};
m_profilesList->addItems(sampleProfiles);
if (!m_repository->deleteProfile(selected->id)) {
QMessageBox::warning(this,
QStringLiteral("Delete Profile"),
QStringLiteral("Failed to delete profile: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
void ProfilesWindow::filterProfiles(const QString& query)
{
const QString trimmed = query.trimmed();
for (int i = 0; i < m_profilesList->count(); ++i) {
QListWidgetItem* item = m_profilesList->item(i);
const bool matches = trimmed.isEmpty()
|| item->text().contains(trimmed, Qt::CaseInsensitive);
item->setHidden(!matches);
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
@@ -114,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);

View File

@@ -1,10 +1,16 @@
#ifndef ORBITHUB_PROFILES_WINDOW_H
#define ORBITHUB_PROFILES_WINDOW_H
#include "profile_repository.h"
#include <QMainWindow>
#include <QString>
#include <QtGlobal>
#include <memory>
#include <optional>
#include <QPointer>
#include <unordered_map>
#include <vector>
class QListWidget;
@@ -19,6 +25,7 @@ class ProfilesWindow : public QMainWindow
public:
explicit ProfilesWindow(QWidget* parent = nullptr);
~ProfilesWindow() override;
private:
QLineEdit* m_searchBox;
@@ -27,10 +34,15 @@ private:
QPushButton* m_editButton;
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 populateSampleProfiles();
void filterProfiles(const QString& query);
void loadProfiles(const QString& query = QString());
std::optional<Profile> selectedProfile() const;
void createProfile();
void editSelectedProfile();
void deleteSelectedProfile();
void openSessionForItem(QListWidgetItem* item);
};

57
src/session_backend.h Normal file
View 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

View 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);
}

View 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
View 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
View 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

View File

@@ -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;
}
}
}

View File

@@ -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
View 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
View 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

View 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);
}

View 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