[Add] Remote SSH sessions and window management.

This is the beginning of the remote session functionality. It allows
players to connect to different NPC systems via an 'ssh' command.

*Server*
- Can now manage a world of multiple NPC file systems that are
  identified by IP addresses.
- Implemented SSH command to allow connection to remote NPC systems.
  Each remote session is managed via a 'CommandProcessor'.
- 'exit' command causes the remote server to terminate the client
  connection

*Client*
- Terminal can enter a 'remote' state via the SSH command which sends
  subsequent commands to the server.
- the local 'exit' command will close the terminal window.
- Refactored UI object ownership to prevent memory leaks and ensure
  proper cleanup when a window is closed or the terminal application.
This commit is contained in:
Ritchie Cunningham 2025-09-21 15:22:35 +01:00
parent 0c4a98a03d
commit 208314f54a
16 changed files with 172 additions and 94 deletions

View File

@ -60,6 +60,13 @@ void ClientNetwork::send_command(const char* command) {
NET_WriteToStreamSocket(_socket, command, strlen(command)+1); NET_WriteToStreamSocket(_socket, command, strlen(command)+1);
} }
void ClientNetwork::disconnect(void) {
if(_socket) {
NET_DestroyStreamSocket(_socket);
_socket = nullptr;
}
}
std::string ClientNetwork::receive_response(void) { std::string ClientNetwork::receive_response(void) {
if(!_socket) return ""; if(!_socket) return "";
char buffer[2048]; char buffer[2048];

View File

@ -10,6 +10,7 @@ public:
bool connect(const char* host, int port); bool connect(const char* host, int port);
void send_command(const char* command); void send_command(const char* command);
void disconnect(void);
std::string receive_response(void); std::string receive_response(void);
private: private:

View File

@ -88,6 +88,8 @@ int main(int argc, char** argv) {
desktop->handle_event(&event); desktop->handle_event(&event);
} }
desktop->update();
Uint32 current_time = SDL_GetTicks(); Uint32 current_time = SDL_GetTicks();
if(current_time - last_blink_time > 500) { /* Every 500ms. */ if(current_time - last_blink_time > 500) { /* Every 500ms. */
show_cursor = !show_cursor; show_cursor = !show_cursor;
@ -106,8 +108,6 @@ int main(int argc, char** argv) {
/* Cleanup. */ /* Cleanup. */
delete desktop; delete desktop;
delete term;
delete test_window;
delete shape_renderer_instance; delete shape_renderer_instance;
delete txt_render_instance; delete txt_render_instance;
SDL_GL_DestroyContext(context); SDL_GL_DestroyContext(context);

View File

@ -14,15 +14,22 @@
Terminal::Terminal(void) { Terminal::Terminal(void) {
/* Placeholder welcome message to history. */ /* Placeholder welcome message to history. */
_history.push_back("Welcome to Bettola (local mode)"); _history.push_back("Welcome to Bettola (local mode)");
_network = nullptr; /* No network connection for now. */
_network = new ClientNetwork();
_is_remote = false;
_should_close = false;
/* Create local file system for the client. */ /* Create local file system for the client. */
_local_vfs = vfs_manager::create_root_system(); _local_vfs = vfs_manager::create_root_system("local");
_local_cmd_processor = new CommandProcessor(_local_vfs); _local_cmd_processor = new CommandProcessor(_local_vfs);
_current_path = get_full_path(_local_cmd_processor->get_current_dir()); _current_path = get_full_path(_local_cmd_processor->get_current_dir());
_scroll_offset = 0; _scroll_offset = 0;
} }
bool Terminal::close(void) {
return _should_close;
}
Terminal::~Terminal(void) { Terminal::~Terminal(void) {
if(_network) { if(_network) {
delete _network; delete _network;
@ -31,35 +38,57 @@ Terminal::~Terminal(void) {
void Terminal::_on_ret_press(void) { void Terminal::_on_ret_press(void) {
std::string command = _input_buffer; std::string command = _input_buffer;
_input_buffer.clear(); /* Clear the input buffer immediately. */
/* Add the command to history. */ /* Add the command to history. */
_history.push_back(_current_path + "> " + command); _history.push_back(_current_path + "> " + command);
std::string response; if(_is_remote) {
/* /* REMOTE STATE. */
* All commands run locally for now. _network->send_command(command.c_str());
* TODO: Remote commands.
*/
if(command == "clear") {
_history.clear();
} else {
response = _local_cmd_processor->process_command(command);
}
/* Check if the response is a new path for the prompt. */ if(command == "exit") {
if(response.rfind("/", 0) == 0) { /* Don't wait for a response, the server booted us. */
_current_path = response; _is_remote = false;
} else if(!response.empty()) { _network->disconnect();
/* Otherwise, it's command out. Split it by newline and add to history. */ _current_path = get_full_path(_local_cmd_processor->get_current_dir());
std::stringstream ss(response); _history.push_back("Connection closed.");
std::string line; } else {
while(std::getline(ss, line, '\n')) { /* For all other commands, wait for a response. */
if(!line.empty()) { std::string response = _network->receive_response();
_history.push_back(line); if(!response.empty()) { _history.push_back(response); }
}
} else {
/* LOCAL STATE! */
if(command.rfind("ssh ", 0) == 0) {
if(_network->connect("localhost", 8080)) {
_network->send_command(command.c_str());
std::string response = _network->receive_response();
if(response.rfind("CONNECTED ", 0) == 0) {
_is_remote = true;
std::string ip = response.substr(10);
_current_path = "root@" + ip;
} else {
_history.push_back(response);
_network->disconnect(); /* Disconnect on fialed ssh login. */
}
} else {
_history.push_back("Connection failed.");
}
} else if(command == "exit") {
_should_close = true;
} else if(command == "clear") {
_history.clear();
} else {
std::string response = _local_cmd_processor->process_command(command);
if(response.rfind("/", 0) == 0) {
_current_path = response;
} else if(!response.empty()) {
_history.push_back(response);
} }
} }
} }
_input_buffer.clear();
/* TODO: Ugly hack. Refactor to pass window height /* TODO: Ugly hack. Refactor to pass window height
* We need the window height to know if we should * We need the window height to know if we should
* auto-scroll, but we don't have it here. * auto-scroll, but we don't have it here.

View File

@ -16,14 +16,17 @@ public:
void handle_input(SDL_Event* event); void handle_input(SDL_Event* event);
void render(TextRenderer* renderer, int x, int y, int width, int height, bool show_cursor); void render(TextRenderer* renderer, int x, int y, int width, int height, bool show_cursor);
void scroll(int amount, int content_height); void scroll(int amount, int content_height);
bool close(void);
private: private:
void _on_ret_press(void); void _on_ret_press(void);
std::string _input_buffer; std::string _input_buffer;
bool _should_close;
std::vector<std::string> _history; std::vector<std::string> _history;
int _scroll_offset; int _scroll_offset;
std::string _current_path; std::string _current_path;
bool _is_remote;
vfs_node* _local_vfs; vfs_node* _local_vfs;
CommandProcessor* _local_cmd_processor; CommandProcessor* _local_cmd_processor;
ClientNetwork* _network; ClientNetwork* _network;

View File

@ -4,14 +4,19 @@
#include <SDL3/SDL_events.h> #include <SDL3/SDL_events.h>
#include "gfx/shape_renderer.h" #include "gfx/shape_renderer.h"
#include "gfx/txt_renderer.h" #include "gfx/txt_renderer.h"
#include "terminal.h"
#include "ui/ui_window.h" #include "ui/ui_window.h"
Desktop::Desktop(void) { Desktop::Desktop(void) {
_focused_window = nullptr; _focused_window = nullptr;
} }
/* Desktop won't own the windows, so doesn't delete them. */ /* Desktop owns UIWindow, make sure we delete them. */
Desktop::~Desktop(void) {} Desktop::~Desktop(void) {
for(auto win : _windows) {
delete win;
}
}
void Desktop::add_window(UIWindow* window) { void Desktop::add_window(UIWindow* window) {
_windows.push_back(window); _windows.push_back(window);
@ -51,6 +56,20 @@ void Desktop::handle_event(SDL_Event* event) {
} }
} }
void Desktop::update(void) {
_windows.erase(std::remove_if(_windows.begin(), _windows.end(),
[](UIWindow* window) {
Terminal* term = window->get_content();
if(term && term->close()) {
/* Window owns the terminal content, delete window also deletes terminal. */
delete window;
return true; /* Remove from vector. */
}
return false;
}),
_windows.end());
}
void Desktop::render(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, void Desktop::render(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer,
int screen_height, bool show_cursor) { int screen_height, bool show_cursor) {
for(auto win: _windows) { for(auto win: _windows) {

View File

@ -14,6 +14,7 @@ public:
void add_window(UIWindow* window); void add_window(UIWindow* window);
void handle_event(SDL_Event* event); void handle_event(SDL_Event* event);
void update(void);
void render(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, int screen_height, void render(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, int screen_height,
bool show_cursor); bool show_cursor);

View File

@ -15,6 +15,12 @@ UIWindow::UIWindow(const char* title, int x, int y, int width, int height) {
_is_focused = false; /* Not focused by default? */ _is_focused = false; /* Not focused by default? */
} }
UIWindow::~UIWindow(void) {
if(_content) {
delete _content;
}
}
void UIWindow::set_content(Terminal* term) { void UIWindow::set_content(Terminal* term) {
_content = term; _content = term;
} }

View File

@ -9,6 +9,7 @@
class UIWindow { class UIWindow {
public: public:
UIWindow(const char* title, int x, int y, int width, int height); UIWindow(const char* title, int x, int y, int width, int height);
~UIWindow(void);
void render(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, int screen_height, void render(ShapeRenderer* shape_renderer, TextRenderer* txt_renderer, int screen_height,
bool show_cursor); bool show_cursor);

View File

@ -10,7 +10,7 @@ vfs_node* new_node(std::string name, vfs_node_type type, vfs_node* parent) {
return node; return node;
} }
vfs_node* vfs_manager::create_root_system(void) { vfs_node* vfs_manager::create_root_system(const std::string& system_type) {
/* Rood directory. */ /* Rood directory. */
vfs_node* root = new_node("/", DIR_NODE, nullptr); vfs_node* root = new_node("/", DIR_NODE, nullptr);
@ -33,5 +33,12 @@ vfs_node* vfs_manager::create_root_system(void) {
ls_exe->content = "[executable]"; ls_exe->content = "[executable]";
bin->children["ls"] = ls_exe; bin->children["ls"] = ls_exe;
if(system_type == "npc") {
vfs_node* npc_file = new_node("npc_system.txt", FILE_NODE, root);
npc_file->content = "This guy sucks nuts!";
root->children["npc_system.txt"] = npc_file;
}
return root; return root;
} }

View File

@ -3,5 +3,5 @@
#include "vfs.h" #include "vfs.h"
namespace vfs_manager { namespace vfs_manager {
vfs_node* create_root_system(void); vfs_node* create_root_system(const std::string& system_type);
} }

View File

@ -1,16 +1,22 @@
#include <cstdio> #include <cstdio>
#include <map>
#include <string>
#include "vfs.h"
#include "vfs_manager.h" #include "vfs_manager.h"
#include "network_manager.h" #include "network_manager.h"
int main(int argc, char** argv) { int main(int argc, char** argv) {
printf("=== Server starting ===\n"); printf("=== Server starting ===\n");
vfs_node* root_vfs = vfs_manager::create_root_system(); /* Create the world map of networks. */
printf("Virtual file system created. Root contains '%s' directory.\n", std::map<std::string, vfs_node*> world_vfs;
root_vfs->children["home"]->name.c_str()); world_vfs["8.8.8.8"] = vfs_manager::create_root_system("npc"); /* Basic npc system. */
world_vfs["10.0.2.15"] = vfs_manager::create_root_system("npc"); /* Another one. */
printf("Created world with %zu networks.\n", world_vfs.size());
NetworkManager* net_manager = new NetworkManager(root_vfs); /* Network manager now managed the whole world. */
NetworkManager* net_manager = new NetworkManager(world_vfs);
net_manager->start(); /* Our loop. */ net_manager->start(); /* Our loop. */
delete net_manager; /* Shouldn't get here. */ delete net_manager; /* Shouldn't get here. */

View File

@ -1,13 +1,15 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <algorithm>
#include <SDL_net.h>
#include "SDL_net.h"
#include "vfs.h" #include "vfs.h"
#include "command_processor.h"
#include "network_manager.h" #include "network_manager.h"
NetworkManager::NetworkManager(vfs_node* vfs_root) { NetworkManager::NetworkManager(std::map<std::string, vfs_node*> world_vfs) {
_vfs_root = vfs_root; _world_vfs = world_vfs;
if(!NET_Init()) { if(!NET_Init()) {
printf("Error: SDLNet_Init: %s\n", SDL_GetError()); printf("Error: SDLNet_Init: %s\n", SDL_GetError());
@ -45,7 +47,7 @@ void NetworkManager::_handle_new_connections(void) {
if(NET_AcceptClient(_server_socket, &client_socket)) { if(NET_AcceptClient(_server_socket, &client_socket)) {
if(client_socket) { if(client_socket) {
Player* new_player = new Player(client_socket); Player* new_player = new Player(client_socket);
new_player->current_dir = _vfs_root; new_player->cmd_processor = nullptr; /* Not connected to remote host yet. */
_players.push_back(new_player); _players.push_back(new_player);
printf("Client connected. Total players: %zu\n", _players.size()); printf("Client connected. Total players: %zu\n", _players.size());
} }
@ -63,72 +65,65 @@ void NetworkManager::_handle_client_activity(void) {
* 0 if no data, and < 0 on error (disconnect). * 0 if no data, and < 0 on error (disconnect).
*/ */
int bytes_received = NET_ReadFromStreamSocket(player->socket, buffer, 512); int bytes_received = NET_ReadFromStreamSocket(player->socket, buffer, 512);
if(bytes_received > 0) {
printf("bytes_received: %d\n", bytes_received);
}
if(bytes_received > 0) { if(bytes_received > 0) {
_process_command(player, buffer); /* _process_command returns true, the client wants to exit, boot it! */
} else if(bytes_received < 0 ) { if(_process_command(player, buffer)) {
_disconnect_client(player, i);
}
} else if(bytes_received < 0) {
/* Disconnect on error or if the client closes the connection. */
_disconnect_client(player, i); _disconnect_client(player, i);
} }
} }
} }
void NetworkManager::_process_command(Player* player, char* command) { bool NetworkManager::_process_command(Player* player, char* command) {
/* Remove trailing newline for command comparison. */ /* Create a clean std::string from the buffer. */
std::string cmd_str = command; std::string cmd_str = command;
if(!cmd_str.empty() && cmd_str.back() == '\n') {
cmd_str.pop_back();
}
if(strncmp(command, "cd ", 3) == 0) {
/* Handle 'cd' command. */
std::string target_dir_name = command + 3;
/* Remove trailing newline if it exists. */
if(!target_dir_name.empty() && target_dir_name.back() == '\n') {
target_dir_name.pop_back();
}
if(target_dir_name == "..") { if(cmd_str == "exit") {
if(player->current_dir->parent) { /* Client is disconnecting, no response needed. */
player->current_dir = player->current_dir->parent; return true; /* Disconnect signal. */
} } else if(strncmp(cmd_str.c_str(), "ssh ", 4) == 0) {
} else if(player->current_dir->children.count(target_dir_name)) { std::string target = cmd_str.substr(4);
vfs_node* target_node = player->current_dir->children[target_dir_name]; std::string target_ip = target;
if(target_node->type == DIR_NODE) { size_t at_pos = target.find('@');
player->current_dir = target_node; if(at_pos != std::string::npos) {
} else { target_ip = target.substr(at_pos + 1);
/* It's a file, not a directory. */
NET_WriteToStreamSocket(player->socket, "cd: not a directory\n", 22);
return;
}
} }
/* On successful cd, send back the new path for the prompt. */ if(_world_vfs.count(target_ip)) {
std::string new_path = get_full_path(player->current_dir); /* Create new command processor for the player's session on the target VFS. */
NET_WriteToStreamSocket(player->socket, new_path.c_str(), new_path.length()+1); if(player->cmd_processor) { delete player->cmd_processor; }
} else if(cmd_str == "ls") { player->cmd_processor = new CommandProcessor(_world_vfs[target_ip]);
std::string response = ""; std::string response = "CONNECTED " + target_ip;
if(player->current_dir && player->current_dir->type == DIR_NODE) { NET_WriteToStreamSocket(player->socket, response.c_str(), response.length()+1);
for(auto const& [name, node] : player->current_dir->children) { } else {
response += name; NET_WriteToStreamSocket(player->socket, "ssh: Could not resolve hostname\n", 32);
if(node->type == DIR_NODE) {
response += "/";
}
response += " ";
}
} }
/* NET_WriteToStreamSocket is essentially the new send function in SDL3. */
NET_WriteToStreamSocket(player->socket, response.c_str(), response.length()+1);
} else if(cmd_str == "clear") {
/* Send an empty reply to unblock. */
NET_WriteToStreamSocket(player->socket, "", 1);
} else { } else {
/* Unknown command. */ /* If not ssh command, and we have a command processor, process it. */
std::string response = "Unknown command: " + cmd_str + "\n"; if(player->cmd_processor) {
NET_WriteToStreamSocket(player->socket, response.c_str(), response.length()+1); std::string response = player->cmd_processor->process_command(cmd_str);
printf("OK: cmd_processor exits. Processing command '%s'\n", cmd_str.c_str());
NET_WriteToStreamSocket(player->socket, response.c_str(), response.length()+1);
} else {
printf("ERROR: cmd_processor is null. Cannot process command '%s'\n", cmd_str.c_str());
NET_WriteToStreamSocket(player->socket, "Error: Not in a remote session.\n", 32);
}
} }
return false; /* Don't disconnect. */
} }
void NetworkManager::_disconnect_client(Player* player, int index) { void NetworkManager::_disconnect_client(Player* player, int index) {
printf("Client disconnected.\n");
NET_DestroyStreamSocket(player->socket); NET_DestroyStreamSocket(player->socket);
_players.erase(_players.begin() + index); /* If we have a valid index, remove the player. Otherwise, find them. */
if(index != -1) {
_players.erase(_players.begin() + index);
}
if(player->cmd_processor) { delete player->cmd_processor; }
delete player; delete player;
} }

View File

@ -1,14 +1,15 @@
#pragma once #pragma once
#include <vector> #include <vector>
#include <SDL3/SDL.h> #include <map>
#include <string>
#include "SDL_net.h" #include "SDL_net.h"
#include "player.h" #include "player.h"
class NetworkManager { class NetworkManager {
public: public:
NetworkManager(vfs_node* vfs_root); NetworkManager(std::map<std::string, vfs_node*> world_vfs);
~NetworkManager(void); ~NetworkManager(void);
void start(void); void start(void);
@ -16,10 +17,10 @@ public:
private: private:
void _handle_new_connections(void); void _handle_new_connections(void);
void _handle_client_activity(void); void _handle_client_activity(void);
void _process_command(Player* player, char* command); bool _process_command(Player* player, char* command);
void _disconnect_client(Player* player, int index); void _disconnect_client(Player* player, int index);
NET_Server* _server_socket; NET_Server* _server_socket;
std::vector<Player*> _players; std::vector<Player*> _players;
vfs_node* _vfs_root; std::map<std::string, vfs_node*> _world_vfs;
}; };

View File

@ -3,5 +3,5 @@
Player::Player(NET_StreamSocket* new_socket) { Player::Player(NET_StreamSocket* new_socket) {
socket = new_socket; socket = new_socket;
current_dir = nullptr; /* Will set to VFS root on connection. */ cmd_processor = nullptr;
} }

View File

@ -1,13 +1,15 @@
#pragma once #pragma once
#include <SDL_net.h> #include <SDL_net.h>
#include "vfs.h" #include "vfs.h"
#include "command_processor.h"
class Player { class Player {
public: public:
Player(NET_StreamSocket* socket); Player(NET_StreamSocket* socket);
NET_StreamSocket* socket; NET_StreamSocket* socket;
vfs_node* current_dir; CommandProcessor* cmd_processor; /* Manages the VFS state for the remote session. */
private: private:
}; };