fix: critical bugs + .desktop file + system install

Bug fixes:
- Fixed PTY bridge: IO thread now uses pty_slave_ instead of pty_master_
  (remote output was never reaching the terminal)
- Fixed double-delete crash on tab close by removing page-removed callback
- Fixed port being ignored in password dialog (always used port 22)
- Added EINTR handling in select() for IO thread
- Added mutex for thread-safe cleanup
- Added try/catch for std::stoi in server config parsing
- VtePty lifecycle now managed properly (no double-close)
- Redundant ssh_channel_poll_timeout call removed

New:
- Added .desktop file for system-wide application menu entry
- Makefile.am installs desktop file to $(datadir)/applications
- Makefile.am installs icon to $(datadir)/icons/hicolor/256x256/apps
This commit is contained in:
x3
2026-09-07 15:00:08 +00:00
parent 77f9568cb1
commit 4a2b2eac33
7 changed files with 109 additions and 72 deletions
+9 -1
View File
@@ -1,2 +1,10 @@
SUBDIRS = src
EXTRA_DIST = README.md
EXTRA_DIST = README.md desktop/x3-ssh.desktop icons/x3-ssh.png
# Install desktop file to system applications directory
applicationsdir = $(datadir)/applications
dist_applications_DATA = desktop/x3-ssh.desktop
# Install icon to system hicolor theme
iconsdir = $(datadir)/icons/hicolor/256x256/apps
dist_icons_DATA = icons/x3-ssh.png
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=x3-ssh
GenericName=SSH Client
Comment=A GTK3 SSH client with tabbed terminal, server management and key storage
Exec=x3-ssh
Icon=x3-ssh
Terminal=false
Categories=Network;RemoteAccess;SSH;
Keywords=ssh;remote;terminal;shell;secure;
StartupNotify=true
+1 -1
View File
@@ -52,7 +52,7 @@ void ServerStore::load() {
std::string key = trim(line.substr(0, eq));
std::string val = trim(line.substr(eq + 1));
if (key == "host") current.host = val;
else if (key == "port") current.port = std::stoi(val);
else if (key == "port") { try { current.port = std::stoi(val); } catch (...) { current.port = 22; } }
else if (key == "user") current.user = val;
else if (key == "keyfile") current.keyfile = val;
else if (key == "use_key") current.use_key = (val == "true");
+32 -34
View File
@@ -52,7 +52,10 @@ SshClientWindow::SshClientWindow()
}
SshClientWindow::~SshClientWindow() {
for (auto* t : terminals_) delete t;
for (auto* t : terminals_) {
t->disconnect();
delete t;
}
terminals_.clear();
if (window_) gtk_widget_destroy(window_);
}
@@ -67,7 +70,7 @@ void SshClientWindow::build_ui() {
const char* icon_paths[] = {
"icons/x3-ssh.png",
"/usr/share/icons/x3-ssh.png",
"/usr/share/icons/hicolor/256x256/apps/x3-ssh.png",
"/usr/local/share/icons/x3-ssh.png",
nullptr
};
@@ -133,7 +136,6 @@ void SshClientWindow::build_toolbar() {
void SshClientWindow::build_notebook() {
notebook_ = gtk_notebook_new();
gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook_), TRUE);
g_signal_connect(notebook_, "page-removed", G_CALLBACK(on_tab_close), this);
}
void SshClientWindow::build_statusbar() {
@@ -183,31 +185,40 @@ void SshClientWindow::close_current_tab() {
void SshClientWindow::close_tab_at(int idx) {
GtkWidget* page = gtk_notebook_get_nth_page(GTK_NOTEBOOK(notebook_), idx);
if (!page) return;
/* Find and disconnect the terminal first, before removing from notebook */
SshTerminal* found = nullptr;
for (auto it = terminals_.begin(); it != terminals_.end(); ++it) {
if ((*it)->widget() == page) {
(*it)->disconnect();
delete *it;
found = *it;
terminals_.erase(it);
break;
}
}
if (found) {
found->disconnect();
delete found;
}
/* Now safe to remove page — no page-removed callback will cause issues */
gtk_notebook_remove_page(GTK_NOTEBOOK(notebook_), idx);
}
void SshClientWindow::on_tab_close(GtkNotebook* nb, GtkWidget* page, guint idx, gpointer d) {
(void)nb; (void)idx;
auto* self = static_cast<SshClientWindow*>(d);
for (auto it = self->terminals_.begin(); it != self->terminals_.end(); ++it) {
if ((*it)->widget() == page) {
(*it)->disconnect();
delete *it;
self->terminals_.erase(it);
void SshClientWindow::disconnect() {
int idx = gtk_notebook_get_current_page(GTK_NOTEBOOK(notebook_));
if (idx < 0) return;
GtkWidget* page = gtk_notebook_get_nth_page(GTK_NOTEBOOK(notebook_), idx);
for (auto* t : terminals_) {
if (t->widget() == page) {
t->disconnect();
update_status("Disconnected");
return;
}
}
}
/* ─── connect / disconnect ─────────────────────────────────── */
/* ─── connect ─────────────────────────────────────────────── */
void SshClientWindow::connect_to_server(const std::string& host, int port,
const std::string& user, const std::string& keyfile,
@@ -239,19 +250,6 @@ void SshClientWindow::connect_to_server(const std::string& host, int port,
}
}
void SshClientWindow::disconnect() {
int idx = gtk_notebook_get_current_page(GTK_NOTEBOOK(notebook_));
if (idx < 0) return;
GtkWidget* page = gtk_notebook_get_nth_page(GTK_NOTEBOOK(notebook_), idx);
for (auto* t : terminals_) {
if (t->widget() == page) {
t->disconnect();
update_status("Disconnected");
return;
}
}
}
/* ─── quick connect ───────────────────────────────────────── */
void SshClientWindow::on_quick_connect(GtkWidget*, gpointer d) {
@@ -273,17 +271,17 @@ void SshClientWindow::on_quick_connect(GtkWidget*, gpointer d) {
self->connect_to_server(s.host, s.port, s.user, s.keyfile, "", s.name);
return;
}
self->show_password_dialog(s.host, s.user, s.keyfile, s.name);
self->show_password_dialog(s.host, s.port, s.user, s.keyfile, s.name);
return;
}
self->show_password_dialog(host, u, "", host);
self->show_password_dialog(host, port, u, "", host);
}
/* ─── password dialog ─────────────────────────────────────── */
void SshClientWindow::show_password_dialog(const std::string& host, const std::string& user,
const std::string& keyfile, const std::string& name) {
void SshClientWindow::show_password_dialog(const std::string& host, int port,
const std::string& user, const std::string& keyfile, const std::string& name) {
GtkWidget* dialog = gtk_dialog_new_with_buttons(
"SSH Authentication", GTK_WINDOW(window_),
@@ -303,7 +301,7 @@ void SshClientWindow::show_password_dialog(const std::string& host, const std::s
GtkWidget* info_label = gtk_label_new(nullptr);
char markup[512];
snprintf(markup, sizeof(markup), "<b>Connect to %s@%s</b>", user.c_str(), host.c_str());
snprintf(markup, sizeof(markup), "<b>Connect to %s@%s:%d</b>", user.c_str(), host.c_str(), port);
gtk_label_set_markup(GTK_LABEL(info_label), markup);
gtk_grid_attach(GTK_GRID(grid), info_label, 0, 0, 2, 1);
@@ -322,7 +320,7 @@ void SshClientWindow::show_password_dialog(const std::string& host, const std::s
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
const char* password = gtk_entry_get_text(GTK_ENTRY(pass_entry));
connect_to_server(host, 22, user, keyfile, password ? password : "", name);
connect_to_server(host, port, user, keyfile, password ? password : "", name);
}
gtk_widget_destroy(dialog);
}
@@ -662,7 +660,7 @@ void SshClientWindow::on_close_tab(GtkWidget*, gpointer d) {
void SshClientWindow::on_connect(GtkWidget*, gpointer d) {
auto* self = static_cast<SshClientWindow*>(d);
self->show_password_dialog("localhost", "root", "", "Local");
self->show_password_dialog("localhost", 22, "root", "", "Local");
}
void SshClientWindow::on_disconnect(GtkWidget*, gpointer d) {
+1 -2
View File
@@ -30,7 +30,7 @@ private:
void show_server_dialog(int edit_index = -1);
void show_key_dialog();
void show_password_dialog(const std::string& host, const std::string& user,
void show_password_dialog(const std::string& host, int port, const std::string& user,
const std::string& keyfile, const std::string& name);
void update_server_combo();
void update_status(const std::string& msg);
@@ -42,7 +42,6 @@ private:
static void on_manage_servers(GtkWidget* w, gpointer d);
static void on_manage_keys(GtkWidget* w, gpointer d);
static void on_quick_connect(GtkWidget* w, gpointer d);
static void on_tab_close(GtkNotebook* nb, GtkWidget* page, guint idx, gpointer d);
GtkWidget* window_;
GtkWidget* notebook_;
+51 -34
View File
@@ -4,19 +4,19 @@
#include <sys/select.h>
#include <signal.h>
#include <cstring>
#include <cerrno>
SshTerminal::SshTerminal()
: terminal_(nullptr), vte_box_(nullptr), session_(nullptr), channel_(nullptr),
pty_master_(-1), pty_slave_(-1), running_(false), connected_(false), thread_(nullptr) {
: terminal_(nullptr), vte_box_(nullptr), vte_pty_(nullptr),
session_(nullptr), channel_(nullptr),
pty_master_(-1), pty_slave_(-1),
running_(false), connected_(false), cleanup_done_(true), thread_(nullptr) {
vte_box_ = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
terminal_ = VTE_TERMINAL(vte_terminal_new());
gtk_box_pack_start(GTK_BOX(vte_box_), GTK_WIDGET(terminal_), TRUE, TRUE, 0);
gtk_widget_show_all(vte_box_);
VtePty *pty = vte_terminal_pty_new_sync(terminal_, VTE_PTY_DEFAULT, nullptr, nullptr);
(void)pty;
g_signal_connect(terminal_, "size-changed", G_CALLBACK(on_size_changed), this);
}
@@ -34,6 +34,10 @@ void SshTerminal::on_size_changed(VteTerminal* term, guint cols, guint rows, gpo
}
void SshTerminal::cleanup() {
std::lock_guard<std::mutex> lock(mutex_);
if (cleanup_done_) return;
cleanup_done_ = true;
if (channel_) {
ssh_channel_close(channel_);
ssh_channel_free(channel_);
@@ -44,19 +48,23 @@ void SshTerminal::cleanup() {
ssh_free(session_);
session_ = nullptr;
}
if (pty_master_ >= 0) { close(pty_master_); pty_master_ = -1; }
if (pty_slave_ >= 0) { close(pty_slave_); pty_slave_ = -1; }
if (pty_master_ >= 0) { close(pty_master_); pty_master_ = -1; }
connected_ = false;
}
gpointer SshTerminal::io_thread_func(gpointer data) {
auto* self = static_cast<SshTerminal*>(data);
int ssh_fd = ssh_get_fd(self->session_);
if (ssh_fd < 0) {
self->cleanup();
ssh_session sess = self->session_;
ssh_channel chan = self->channel_;
int ssh_fd = ssh_get_fd(sess);
int slave_fd = self->pty_slave_;
if (ssh_fd < 0 || slave_fd < 0) {
g_idle_add([](gpointer d) -> gboolean {
auto* s = static_cast<SshTerminal*>(d);
if (s->on_status_change) s->on_status_change(false, "SSH get_fd failed");
if (s->on_status_change) s->on_status_change(false, "SSH fd unavailable");
return G_SOURCE_REMOVE;
}, self);
return nullptr;
@@ -68,40 +76,44 @@ gpointer SshTerminal::io_thread_func(gpointer data) {
while (self->running_) {
FD_ZERO(&rfds);
FD_SET(self->pty_master_, &rfds);
FD_SET(slave_fd, &rfds);
FD_SET(ssh_fd, &rfds);
int maxfd = (self->pty_master_ > ssh_fd ? self->pty_master_ : ssh_fd) + 1;
int maxfd = (slave_fd > ssh_fd ? slave_fd : ssh_fd) + 1;
tv.tv_sec = 0;
tv.tv_usec = 50000;
int ret = select(maxfd, &rfds, nullptr, nullptr, &tv);
if (ret < 0) break;
if (ret < 0) {
if (errno == EINTR) continue;
break;
}
if (ret == 0) continue;
if (FD_ISSET(self->pty_master_, &rfds)) {
ssize_t n = read(self->pty_master_, buf, sizeof(buf));
if (FD_ISSET(slave_fd, &rfds)) {
ssize_t n = read(slave_fd, buf, sizeof(buf));
if (n > 0) {
ssh_channel_write(self->channel_, buf, n);
if (ssh_channel_write(chan, buf, n) < 0) break;
} else if (n <= 0) {
break;
}
}
if (FD_ISSET(ssh_fd, &rfds)) {
if (ssh_channel_poll_timeout(self->channel_, 0, 0) > 0) {
ssize_t n = ssh_channel_read_timeout(self->channel_, buf, sizeof(buf), 0, 0);
int poll_result = ssh_channel_poll_timeout(chan, 50, 0);
if (poll_result > 0) {
ssize_t n = ssh_channel_read_timeout(chan, buf, sizeof(buf), 0, 0);
if (n > 0) {
write(self->pty_master_, buf, n);
if (write(slave_fd, buf, n) < 0 && errno != EINTR) break;
} else if (n == SSH_EOF || n == SSH_ERROR) {
break;
}
} else if (ssh_channel_poll_timeout(self->channel_, 0, 0) < 0) {
} else if (poll_result < 0) {
break;
}
}
if (ssh_channel_is_eof(self->channel_)) break;
if (ssh_channel_is_eof(chan)) break;
}
self->connected_ = false;
@@ -193,18 +205,19 @@ auth_done:
return false;
}
VtePty *pty = vte_terminal_get_pty(terminal_);
if (pty) {
int vte_fd = vte_pty_get_fd(pty);
if (vte_fd >= 0) close(vte_fd);
}
vte_terminal_set_pty(terminal_, nullptr);
VtePty *new_pty = vte_pty_new_foreign_sync(pty_master_, nullptr, nullptr);
if (new_pty) {
vte_terminal_set_pty(terminal_, new_pty);
/* VTE gets the master side of the pty. It reads terminal output from
master and writes user input to master. Our IO thread uses the slave
side to bridge between the pty and the SSH channel. */
vte_pty_ = vte_pty_new_foreign_sync(pty_master_, nullptr, nullptr);
if (vte_pty_) {
vte_terminal_set_pty(terminal_, vte_pty_);
} else {
cleanup();
if (on_status_change) on_status_change(false, "VTE pty setup failed");
return false;
}
cleanup_done_ = false;
connected_ = true;
running_ = true;
thread_ = g_thread_new("ssh_io", io_thread_func, this);
@@ -216,13 +229,17 @@ auth_done:
void SshTerminal::disconnect() {
running_ = false;
if (pty_master_ >= 0) { close(pty_master_); pty_master_ = -1; }
if (pty_slave_ >= 0) { close(pty_slave_); pty_slave_ = -1; }
if (thread_) {
g_thread_join(thread_);
thread_ = nullptr;
}
/* Clean up VTE pty first (releases master fd ownership) */
if (vte_pty_) {
g_object_unref(vte_pty_);
vte_pty_ = nullptr;
vte_terminal_set_pty(terminal_, nullptr);
}
cleanup();
}
+4
View File
@@ -4,6 +4,7 @@
#include <libssh/libssh.h>
#include <atomic>
#include <string>
#include <mutex>
#include <functional>
class SshTerminal {
@@ -28,12 +29,15 @@ private:
GtkWidget* vte_box_;
VteTerminal* terminal_;
VtePty* vte_pty_;
ssh_session session_;
ssh_channel channel_;
int pty_master_;
int pty_slave_;
std::atomic<bool> running_;
std::atomic<bool> connected_;
std::atomic<bool> cleanup_done_;
GThread* thread_;
std::mutex mutex_;
std::string server_name_;
};