diff --git a/Makefile.am b/Makefile.am index a7b92a7..0e18464 100644 --- a/Makefile.am +++ b/Makefile.am @@ -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 diff --git a/desktop/x3-ssh.desktop b/desktop/x3-ssh.desktop new file mode 100644 index 0000000..2f2af7a --- /dev/null +++ b/desktop/x3-ssh.desktop @@ -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 diff --git a/src/server_store.cpp b/src/server_store.cpp index aaee501..0532caa 100644 --- a/src/server_store.cpp +++ b/src/server_store.cpp @@ -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"); diff --git a/src/ssh_client.cpp b/src/ssh_client.cpp index 165c943..32b2f03 100644 --- a/src/ssh_client.cpp +++ b/src/ssh_client.cpp @@ -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(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), "Connect to %s@%s", user.c_str(), host.c_str()); + snprintf(markup, sizeof(markup), "Connect to %s@%s:%d", 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(d); - self->show_password_dialog("localhost", "root", "", "Local"); + self->show_password_dialog("localhost", 22, "root", "", "Local"); } void SshClientWindow::on_disconnect(GtkWidget*, gpointer d) { diff --git a/src/ssh_client.h b/src/ssh_client.h index ea34020..7dbf720 100644 --- a/src/ssh_client.h +++ b/src/ssh_client.h @@ -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_; diff --git a/src/ssh_terminal.cpp b/src/ssh_terminal.cpp index 3548514..cf0d7f2 100644 --- a/src/ssh_terminal.cpp +++ b/src/ssh_terminal.cpp @@ -4,19 +4,19 @@ #include #include #include +#include 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 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(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(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(); } diff --git a/src/ssh_terminal.h b/src/ssh_terminal.h index af71819..199a3a0 100644 --- a/src/ssh_terminal.h +++ b/src/ssh_terminal.h @@ -4,6 +4,7 @@ #include #include #include +#include #include 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 running_; std::atomic connected_; + std::atomic cleanup_done_; GThread* thread_; + std::mutex mutex_; std::string server_name_; };