Cross-Platform TTY Shell Implementation in C++ (Part 2) – Server Implementation and Advanced Features

Disclaimer:The user is responsible for any direct or indirect consequences and losses caused by the dissemination and use of the information provided by this public account. The public account and the author do not bear any responsibility for this, and any consequences must be borne by the user!

01

Core Architecture of the Server

In the previous article, the author introduced the basic architecture and client implementation of the TTY Shell. This article will focus on the design and implementation of the server, as well as some technical details of advanced features. Unlike the client, the server needs to handle more complex tasks: creating and managing pseudo-terminals (PTY), starting and monitoring shell processes, handling multiple concurrent client connections, and ensuring the security and stability of the entire system.

1.1 Server Component Hierarchy

The RunShell server consists of the following main components:

1. **Server Class**: The top-level component responsible for network listening and client session management.

2. **ClientSession Class**: The session container for each client connection.

3. **PseudoTerminal Class**: The pseudo-terminal abstraction that provides the ability to interact with actual terminal devices.

4. **Process Class**: The process management abstraction used to create and control shell processes.

The relationships between these components are illustrated in the following diagram:

“`

Server

└── Manages multiple ClientSessions

├── Each session has a PseudoTerminal

└── Each session has a Process (Shell process)

“`

1.2 Server Class Design

The Server class is the core class of the server, responsible for listening on network ports, accepting client connections, and managing client sessions:

class Server {public:    explicit Server(const ServerConfig& config = ServerConfig());    ~Server();        // Start the server    void start();        // Stop the server    void stop();        // Get the number of client connections    size_t getClientCount() const;private:    ServerConfig config_;    std::unique_ptr<network::TcpSocket> server_socket_;    std::map<int, std::shared_ptr<ClientSession>> clients_;    std::thread accept_thread_;    std::atomic<bool> running_;    std::mutex clients_mutex_;        // Client accept thread    void acceptThread();        // Connect back to the host thread    void connectBackThread();        // Clean up disconnected clients    void cleanupClients();        // Handle new connections    void handleNewClient(std::shared_ptr<network::Socket> client_socket);        // Initialize as a daemon    void initDaemon();};

The Server class implements two operating modes:

1. **Standard Listening Mode**: Listens on a specified port, waiting for client connections.

2. **Connect Back Mode**: Actively connects to a specified host, suitable for penetrating firewalls.

1.3 Client Session Management

Each client connection is represented and managed by a ClientSession object:

class ClientSession {public:    ClientSession(std::shared_ptr<network::Socket> socket);    ~ClientSession();        // Handle client session    void handle();        // Terminate session    void terminate();        // Get session status    bool isActive() const;    bool isAuthenticated() const;private:    std::shared_ptr<network::Socket> socket_;    std::unique_ptr<crypto::SecureChannel> secure_channel_;    std::shared_ptr<platform::PseudoTerminal> pty_;    std::shared_ptr<platform::Process> shell_process_;    std::thread shell_thread_;    std::atomic<bool> active_;    std::atomic<bool> authenticated_;    std::string term_type_;    uint16_t rows_;    uint16_t cols_;    std::chrono::steady_clock::time_point last_activity_time_;        // Methods to handle various messages...    bool handleAuthentication();    bool handleShellStart(const protocol::Message& message);    bool handleShellData(const protocol::Message& message);    bool handleShellResize(const protocol::Message& message);    bool handleShellStop(const protocol::Message& message);        // Shell I/O forwarding thread    void shellIoThread();        // Communication methods    bool sendMessage(const protocol::Message& message);    bool receiveMessage(protocol::Message& message);        // Check if connection is alive    bool isConnectionAlive();};

When the server receives a new connection, it creates a new ClientSession object and runs it in a separate thread:

void Server::handleNewClient(std::shared_ptr<network::Socket> client_socket) {    try {        // Create client session        auto session = std::make_shared<ClientSession>(client_socket);                // Generate a unique client ID        int client_id;        {            std::lock_guard<std::mutex> lock(clients_mutex_);            client_id = clients_.empty() ? 1 : (clients_.rbegin()->first + 1);            clients_[client_id] = session;        }                // Handle session in a separate thread        std::thread([this, client_id, session]() {            session->handle();                        // After the session ends, remove from client list            std::lock_guard<std::mutex> lock(clients_mutex_);            clients_.erase(client_id);        }).detach();    } catch (const std::exception& e) {        std::cerr << "Exception handling new client: " << e.what() << std::endl;    }}

02

Pseudo Terminal (PTY) and Process Management

The pseudo terminal (Pseudo Terminal, PTY) is the core of implementing remote shell functionality. It provides an interface for remote clients to interact with shell processes with full functionality, including support for cursor movement, command history, tab completion, and other advanced terminal features.

2.1 Pseudo Terminal Abstraction and Implementation

RunShell uses the abstract class `PseudoTerminal` to define the pseudo-terminal interface and provides specific implementations for different platforms:

class PseudoTerminal {public:    virtual ~PseudoTerminal() = default;        // Create a PTY    static std::unique_ptr<PseudoTerminal> create();        // Read and write operations    virtual size_t read(std::vector<uint8_t>& buffer, size_t maxSize) = 0;    virtual size_t write(const std::vector<uint8_t>& buffer) = 0;        // Set terminal size    virtual void resize(uint16_t rows, uint16_t cols) = 0;        // Get master device file descriptor    virtual int getMasterFd() const = 0;        // Get slave device name    virtual std::string getSlaveName() const = 0;        // Close PTY    virtual void close() = 0;};

On Unix/Linux platforms, the pseudo terminal is implemented through the `UnixPseudoTerminal` class:

class UnixPseudoTerminal : public PseudoTerminal {public:    UnixPseudoTerminal() {        // Open pseudo terminal        int result = openpty(&master_fd_, &slave_fd_, slave_name_, nullptr, nullptr);        if (result != 0) {            throw PlatformError("Failed to create pseudo terminal: " + getLastErrorMessage());        }                // Get the name of the slave PTY        if (ptsname_r(master_fd_, slave_name_, sizeof(slave_name_)) != 0) {            throw PlatformError("Failed to get PTY slave device name: " + getLastErrorMessage());        }                // Set master to non-blocking mode        int flags = fcntl(master_fd_, F_GETFL);        fcntl(master_fd_, F_SETFL, flags | O_NONBLOCK);    }        size_t read(std::vector<uint8_t>& buffer, size_t maxSize) override {        buffer.resize(maxSize);                // Use poll to check if there is data to read        struct pollfd pfd;        pfd.fd = master_fd_;        pfd.events = POLLIN;                int ret = poll(&pfd, 1, 50); // 50 ms timeout        if (ret <= 0) {            // Timeout or error            buffer.clear();            return 0;        }                ssize_t bytesRead = ::read(master_fd_, buffer.data(), maxSize);        if (bytesRead < 0) {            if (errno == EAGAIN || errno == EWOULDBLOCK) {                // No data to read in non-blocking mode                buffer.clear();                return 0;            }            throw PlatformError("Error reading PTY: " + getLastErrorMessage());        }                buffer.resize(bytesRead);        return bytesRead;    }        // Other method implementations...private:    int master_fd_;     // Master device file descriptor    int slave_fd_;      // Slave device file descriptor    char slave_name_[256]; // Slave device name};

2.2 Process Management

Process management is handled by the `Process` abstract class and platform-specific implementations:

class Process {public:    virtual ~Process() = default;        // Create a new process    static std::unique_ptr<Process> create(const std::string& command,                                           const std::vector<std::string>& args = {});        // Create a process using PTY    static std::unique_ptr<Process> createWithPty(std::unique_ptr<PseudoTerminal> pty,                                                const std::string& command,                                                const std::vector<std::string>& args = {},                                                const std::vector<std::string>& env_vars = {});        // Check if the process is running    virtual bool isRunning() const = 0;        // Get process ID    virtual int getPid() const = 0;        // Wait for the process to finish    virtual int wait(bool block) = 0;        // Terminate the process    virtual void terminate() = 0;        // Forcefully kill the process    virtual void kill() = 0;};

To associate the shell process with the pseudo terminal, we implemented the `UnixPtyProcess` class:

class UnixPtyProcess : public UnixProcess {public:    UnixPtyProcess(std::unique_ptr<PseudoTerminal> pty, const std::string& command,                   const std::vector<std::string>& args,                  const std::vector<std::string>& env_vars)        : UnixProcess(-1), pty_(std::move(pty)) {                // Get PTY path        std::string slave_name = pty_->getSlaveName();                // Build command line arguments        std::vector<char*> argv;        argv.push_back(const_cast<char*>(command.c_str()));        for (const auto& arg : args) {            argv.push_back(const_cast<char*>(arg.c_str()));        }        argv.push_back(nullptr); // Terminate argument list                // Create child process        pid_t pid = fork();        if (pid < 0) {            throw PlatformError("Failed to create process: " + getLastErrorMessage());        } else if (pid == 0) {            // Child process                        // Create new session            setsid();                        // Open PTY slave device            int slave_fd = open(slave_name.c_str(), O_RDWR);            if (slave_fd < 0) {                _exit(EXIT_FAILURE);            }                        // Set as control terminal            ioctl(slave_fd, TIOCSCTTY, 0);                        // Redirect standard input/output to PTY            dup2(slave_fd, STDIN_FILENO);            dup2(slave_fd, STDOUT_FILENO);            dup2(slave_fd, STDERR_FILENO);                        // Close unnecessary file descriptors            if (slave_fd > STDERR_FILENO) {                close(slave_fd);            }                        // Set environment variables            for (const auto& env : env_vars) {                putenv(const_cast<char*>(env.c_str()));            }                        // Execute command            execvp(command.c_str(), argv.data());                        // If we reach here, execution failed            _exit(EXIT_FAILURE);        }                // Parent process        setPid(pid);    }private:    std::unique_ptr<PseudoTerminal> pty_;};

2.3 Terminal Settings and Environment Variables

Before starting the shell process, it is necessary to correctly configure terminal parameters and environment variables:

bool ClientSession::handleShellStart(const protocol::Message& message) {    try {        // Parse terminal type, rows, and columns        auto [term_type, rows, cols] = protocol::ShellSession::parseStartRequest(message);        term_type_ = term_type;        rows_ = rows;        cols_ = cols;                // Create PTY        pty_ = platform::PseudoTerminal::create();                // Set PTY terminal parameters        #ifndef _WIN32        struct termios term_settings;        if (tcgetattr(pty_->getMasterFd(), &term_settings) != -1) {            // Configure terminal to raw mode to correctly pass all input/output            // Keep echo enabled to display input locally            term_settings.c_lflag |= ECHO | ECHOE | ECHOK;                        // Ensure correct conversion of CR and NL            term_settings.c_iflag |= ICRNL;            term_settings.c_oflag |= ONLCR;                        // Set control characters            term_settings.c_cc[VEOF] = 4;    // Ctrl-D            term_settings.c_cc[VEOL] = 0;    // NUL            term_settings.c_cc[VERASE] = 127; // DEL            term_settings.c_cc[VINTR] = 3;   // Ctrl-C                        // Apply settings            tcsetattr(pty_->getMasterFd(), TCSANOW, &term_settings);        }        #endif                // Set terminal size        pty_->resize(rows, cols);                // Build environment variables        std::vector<std::string> env_vars;        env_vars.push_back("TERM=" + term_type);        env_vars.push_back("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");        env_vars.push_back("HOME=" + platform::User::getHomeDir());        env_vars.push_back("SHELL=/bin/bash");        env_vars.push_back("LANG=en_US.UTF-8"); // Ensure UTF-8 support                // Create shell process using login shell mode        shell_process_ = platform::Process::createWithPty(            pty_, "/bin/bash", {"--login"}, env_vars);                // Start shell I/O thread        shell_thread_ = std::thread(&ClientSession::shellIoThread, this);                return true;    } catch (const std::exception& e) {        std::cerr << "Exception starting shell: " << e.what() << std::endl;        return false;    }}

03

Implementation of Advanced Features

3.1 Shell I/O Thread and Buffer Management

The Shell I/O thread is responsible for forwarding data between the PTY and the client. To improve performance, RunShell implements intelligent buffer management, sending data only when necessary:

void ClientSession::shellIoThread() {    try {        // Send buffer and timer        std::vector<uint8_t> buffer(4096);        std::vector<uint8_t> accumulator;                const size_t MAX_ACCUMULATOR_SIZE = 1024; // Send when accumulating 1KB of data        const int MAX_DELAY_MS = 10; // Or when not sent for more than 10ms                auto last_send_time = std::chrono::steady_clock::now();                while (active_) {            try {                auto current_time = std::chrono::steady_clock::now();                                // Check if the shell process has terminated                if (shell_process_ && !shell_process_->isRunning()) {                    // Get exit code                    int exit_code = shell_process_->wait(false);                    std::cout << "Shell process has exited, sending stop message to client" << std::endl;                                        // Send stop message to client                    sendMessage(protocol::ShellSession::createStop(exit_code));                                        // Terminate session                    active_ = false;                    break;                }                                // Use poll to check if there is readable data in the PTY                struct pollfd pfd;                pfd.fd = pty_->getMasterFd();                pfd.events = POLLIN;                                int poll_result = poll(&pfd, 1, 1); // 1ms timeout, quick response                                // Determine if conditions for sending data are met                bool timeout = std::chrono::duration_cast<std::chrono::milliseconds>(                    current_time - last_send_time).count() >= MAX_DELAY_MS;                                if (poll_result > 0 && (pfd.revents & POLLIN)) {                    // Data is readable                    size_t bytes_read = pty_->read(buffer, buffer.size());                                        if (bytes_read > 0) {                        // Add the read data to the accumulator                        accumulator.insert(accumulator.end(), buffer.begin(), buffer.begin() + bytes_read);                    }                }                                // If enough data has accumulated or the maximum delay time has been reached, send                if (!accumulator.empty() && (accumulator.size() >= MAX_ACCUMULATOR_SIZE || timeout)) {                    // Create and send shell data message                    protocol::Message data_msg = protocol::ShellSession::createData(accumulator);                                        if (sendMessage(data_msg)) {                        debug_log("Sent shell data to client (" + std::to_string(accumulator.size()) + " bytes)", false);                    }                                        // Clear the accumulator and update the send time                    accumulator.clear();                    last_send_time = current_time;                }            } catch (const std::exception& e) {                std::cerr << "Shell IO thread exception: " << e.what() << std::endl;                std::this_thread::sleep_for(std::chrono::seconds(1));            }        }    } catch (const std::exception& e) {        std::cerr << "Fatal error in Shell IO thread: " << e.what() << std::endl;    }        std::cout << "Shell IO thread has exited" << std::endl;}

3.2 Shell Exit Handling

It is also necessary to ensure that the shell process can correctly notify the client upon exit. RunShell addresses this through the following mechanism:

1. Regularly check the shell process status in the Shell I/O thread.

2. When the shell process exit is detected, send a SHELL_STOP message to the client.

3. The client safely terminates the program upon receiving the SHELL_STOP message.

bool ClientSession::handleShellStop(const protocol::Message& message) {    try {        // Parse exit code        int exit_code = protocol::ShellSession::parseStop(message);                // Terminate shell process        if (shell_process_ && shell_process_->isRunning()) {            shell_process_->terminate();                        // Wait for shell to finish            shell_process_->wait(true);        }                // Close PTY        if (pty_) {            pty_->close();        }                // Wait for Shell I/O thread to finish        if (shell_thread_.joinable()) {            shell_thread_.join();        }                // Send confirmation message        sendMessage(protocol::ShellSession::createStop(exit_code));                return false; // Return false to terminate session    } catch (const std::exception& e) {        std::cerr << "Exception stopping shell: " << e.what() << std::endl;        return false;    }}

3.3 Dynamic Window Size Adjustment

When the client terminal window size changes, the server PTY size needs to be updated to ensure correct display:

bool ClientSession::handleShellResize(const protocol::Message& message) {    try {        // Parse window size information        auto [rows, cols] = protocol::ShellSession::parseResize(message);                // Update saved terminal size        rows_ = rows;        cols_ = cols;                // If PTY exists, adjust its size        if (pty_) {            pty_->resize(rows_, cols_);        }                return true;    } catch (const std::exception& e) {        std::cerr << "Exception resizing shell window: " << e.what() << std::endl;        return false;    }}

On Unix/Linux platforms, PTY resizing is implemented through the `TIOCSWINSZ` ioctl:

void UnixPseudoTerminal::resize(uint16_t rows, uint16_t cols) {    struct winsize ws;    ws.ws_row = rows;    ws.ws_col = cols;    ws.ws_xpixel = 0;    ws.ws_ypixel = 0;        if (ioctl(master_fd_, TIOCSWINSZ, &ws) < 0) {        throw PlatformError("Failed to resize PTY: " + getLastErrorMessage());    }}

3.4 Heartbeat Mechanism and Connection Detection

The heartbeat mechanism implemented by RunShell is as follows:

bool ClientSession::isConnectionAlive() {    auto now = std::chrono::steady_clock::now();    auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(        now - last_activity_time_).count();        // If the connection has been inactive for more than 3 seconds, send a heartbeat check    if (elapsed > 3) {        std::cout << "Connection has been inactive for more than 3 seconds, sending heartbeat check..." << std::endl;        protocol::Message ping(protocol::OpCode::HEARTBEAT, {});        if (!sendMessage(ping)) {            std::cerr << "Heartbeat check failed to send, assuming connection is broken" << std::endl;            return false;        }                // Reset activity time        last_activity_time_ = now;    }        return true;}

3.5 Daemon Mode and Connect Back Functionality

RunShell supports two operating modes:

1. **Daemon Mode**: Runs in the background without terminal interaction.

2. **Connect Back Mode**: Actively connects to a specified server for penetrating firewalls or NAT.

void Server::start() {    if (running_) {        return; // Server is already running    }        // If configured for daemon mode, initialize daemon    if (config_.daemonize) {        initDaemon();    }        // Set process name    if (!config_.procName.empty()) {        platform::Daemon::setProcessTitle(config_.procName);    }        // Check PID file    if (!config_.pidFile.empty()) {        if (platform::Daemon::checkPidFile(config_.pidFile)) {            throw std::runtime_error("Server is already running (PID file exists)");        }                // Write PID file        platform::Daemon::writePidFile(config_.pidFile);    }        // Choose startup method based on mode    if (config_.connectBackMode) {        // Connect back mode        running_ = true;        accept_thread_ = std::thread(&Server::connectBackThread, this);    } else {        // Normal listening mode        // ...    }}

The implementation of connect back mode is as follows:

void Server::connectBackThread() {    while (running_) {        try {            // Create client socket            auto client_socket = std::make_unique<network::TcpSocket>();                        // Attempt to connect to host            if (client_socket->connect(config_.connectBackHost, config_.port)) {                // Handle new connection                handleNewClient(std::move(client_socket));            }                        // Clean up disconnected clients            cleanupClients();                        // Wait for a specified time before retrying            std::this_thread::sleep_for(std::chrono::seconds(config_.connectBackDelay));        } catch (const std::exception& e) {            std::cerr << "Reconnect failed: " << e.what() << std::endl;                        // Wait for a while before retrying            std::this_thread::sleep_for(std::chrono::seconds(config_.connectBackDelay));        }    }}

04

Detailed Cross-Platform Adaptation

4.1 Conditional Compilation and Platform Abstraction

RunShell uses conditional compilation and abstract interfaces to handle differences across platforms:

#ifdef RUNSHELL_PLATFORM_WINDOWS    // Windows specific code#else    // Unix/Linux specific code#endif

For each feature that requires cross-platform support, an abstract base class is defined, and specific implementations are provided for different platforms:

// Abstract base class defining functionality interfaceclass Socket {public:    virtual ~Socket() = default;    virtual bool isConnected() const = 0;    virtual void close() = 0;    // ... other methods};    // Unix/Linux platform implementationclass UnixSocket : public Socket {    // Implement methods...};    // Windows platform implementationclass WindowsSocket : public Socket {    // Implement methods...};

4.2 Special Handling for Windows Platform

The Windows platform has significant differences from Unix/Linux platforms in terminal and network handling. For example:

1. **Terminal Handling**: Windows uses the console API instead of POSIX terminal interfaces.

2. **Network API**: Uses Winsock instead of standard socket API.

3. **Process Creation**: Uses CreateProcess instead of fork/exec.

4. **Pseudo Terminal**: The ConPTY API is only supported in newer versions of Windows 10.

#ifdef _WIN32    #include <winsock2.h>        // Windows platform WSA initialization class    class WSAInitializer {    public:        WSAInitializer() {            WSADATA wsaData;            if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {                throw NetworkError("WSAStartup failed");            }        }                ~WSAInitializer() {            WSACleanup();        }    };        // Ensure WSA library is initialized    static WSAInitializer wsaInitializer;#endif

4.3 Platform-Specific Error Handling

The error handling mechanisms differ significantly across platforms, and RunShell provides a unified error handling interface:

std::string getLastErrorMessage() {#ifdef _WIN32    DWORD error_code = GetLastError();    if (error_code == 0) {        return "No error";    }        LPSTR message_buffer = nullptr;    size_t size = FormatMessageA(        FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,        NULL, error_code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),        (LPSTR)&message_buffer, 0, NULL);        std::string message(message_buffer, size);    LocalFree(message_buffer);        return message;#else    return std::string(strerror(errno));#endif}

05

Performance Optimization and Security Hardening

5.1 I/O Performance Optimization

RunShell employs the following strategies to optimize I/O performance:

1. **Non-blocking I/O Mode**: Avoids thread blocking, improving concurrent processing capability.

2. **Smart Buffering**: Processes small packets in batches to reduce network overhead.

3. **Efficient Polling**: Uses poll instead of busy waiting to reduce CPU usage.

// Set non-blocking modeint flags = fcntl(master_fd_, F_GETFL);fcntl(master_fd_, F_SETFL, flags | O_NONBLOCK);    // Smart buffering implementationif (!accumulator.empty() && (accumulator.size() >= MAX_ACCUMULATOR_SIZE || timeout)) {    // Batch send data...}    // Efficient pollingstruct pollfd pfd;pfd.fd = master_fd_;pfd.events = POLLIN;int poll_result = poll(&pfd, 1, 1); // 1ms timeout

5.2 Encryption Communication Security Hardening

RunShell uses modern security algorithms and key derivation methods to ensure communication security:

1. **Encryption Algorithm**: Uses the ChaCha20-Poly1305 algorithm.

2. **Key Exchange**: Combines pre-shared keys and ephemeral keys to prevent replay attacks.

3. **Session Isolation**: Each client uses an independent encryption channel and key.

4. **Counter Synchronization**: Ensures that the encryption counter is correctly synchronized to prevent decryption failures.

// Key derivationstd::vector<uint8_t> derived_key = crypto::HMAC_SHA256::compute(    crypto::HMAC_SHA256::compute(secret_bytes, server_pub_key),     nonce);    // Reset counterssecure_channel_->resetCounters();std::cout << "Reset encryption/decryption counters: send=0, recv=0" << std::endl;

5.3 Error Detection and Recovery Mechanism

To ensure stability, RunShell implements comprehensive error detection and recovery mechanisms:

1. **Timeout Detection**: Detects and handles network communication timeouts.

2. **Connection Disruption Handling**: Handles connection interruption situations.

3. **Resource Cleanup**: Ensures resources are correctly released under all circumstances.

4. **Exception Safety**: All critical operations are enclosed in try-catch blocks.

try {    // Perform operation...} catch (const network::NetworkError& e) {    if (e.what() && std::string(e.what()).find("timeout") != std::string::npos) {        // Handle timeout error...    } else {        // Handle other network errors...    }} catch (const std::exception& e) {    // Handle general exceptions...} catch (...) {    // Catch all other exceptions...}

5.4 Architectural Level Security Assurance

In addition to code-level security measures, RunShell also implements architectural level security assurances:

1. **Permission Separation**: Shell processes run with target user permissions rather than server permissions.

2. **Session Isolation**: Each client session is isolated from one another to prevent interference.

3. **Custom Process Names**: Hides the real process name to reduce detection risk.

4. **Log Control**: Configurable log levels to prevent sensitive information leakage.

06

Conclusion

This article provides a detailed introduction to the server implementation and advanced features of the cross-platform TTY Shell. The previous and current articles offer a complete, functional, and secure remote terminal solution.

Cross-Platform TTY Shell Implementation in C++ (Part 2) - Server Implementation and Advanced Features

“Original content is not easy, feel free to share and appreciate”

Cross-Platform TTY Shell Implementation in C++ (Part 2) - Server Implementation and Advanced Features

Follow the public account for more learning materials

Cross-Platform TTY Shell Implementation in C++ (Part 2) - Server Implementation and Advanced Features

Leave a Comment