Post

Building a Remote Command Execution GUI with Electron

Building a Remote Command Execution GUI with Electron

Remote hardware control and embedded system testing often require sending commands to devices over a network. While command-line tools suffice for individual commands, complex test sequences benefit from a graphical interface that supports payload management, command sequencing, and persistent terminal output. This post documents the architecture of a desktop application built with Electron, Socket.IO, and Xterm.js for remote command execution in controlled test environments.

Problem Statement

Testing embedded systems and remote hardware presents several interface challenges:

Command complexity: Hardware test sequences often involve multiple parameterized commands executed in specific orders. Managing these through raw terminal sessions introduces transcription errors and slows iteration.

Session persistence: Terminal sessions disconnect. Commands typed into SSH sessions disappear when connections drop. Test engineers benefit from persistent command histories and saved payloads.

Output capture: Test results displayed in terminals are transient unless explicitly logged. Scrollback buffers overflow. Evidence of test execution must be preserved for documentation and debugging.

Repeatability: The same command sequences execute repeatedly across devices. Manual re-entry is error-prone and time-consuming.

A purpose-built GUI addresses these concerns by providing:

  • Persistent command payloads saved as JSON
  • Sequential command execution with configurable delays
  • Terminal-style output with full logging capability
  • Connection management with visual feedback

Technical Stack Overview

The application combines three technologies:

ComponentPurpose
ElectronDesktop application framework; provides native windowing, file system access, and Node.js integration
Socket.IOBidirectional real-time communication; handles TCP connections to remote servers
Xterm.jsTerminal emulator for the browser; renders command output with full ANSI support

Why Electron

Electron bundles Chromium and Node.js into a single executable. The renderer process runs standard web technologies (HTML, CSS, JavaScript) while the main process has full access to Node.js APIs including the file system, child processes, and native modules.

For a command execution tool, this architecture provides:

  • File system access: Save and load payload files without server-side infrastructure
  • Native menus: Standard application menus for file operations
  • Cross-platform deployment: Single codebase runs on Windows, macOS, and Linux
  • No external dependencies: The application is self-contained

Why Socket.IO

Socket.IO provides an abstraction over WebSockets with automatic reconnection, room-based broadcasting, and fallback transports. For command execution:

  • Bidirectional communication: Send commands, receive responses and status updates
  • Event-based API: Cleanly separate command types (execute, status, log)
  • Reconnection handling: Automatic reconnection with configurable backoff

Why Xterm.js

Xterm.js is a terminal emulator that runs in the browser. It handles:

  • ANSI escape codes: Colors, cursor movement, text formatting
  • Scrollback buffer: Configurable history length
  • Copy/paste: Standard terminal selection behavior
  • Performance: WebGL rendering for high-throughput output

Application Architecture

The application follows Electron’s process model:

graph TD
    subgraph MainProcess ["Main Process"]
        FS["File System Operations"]
        IPC["IPC Handler"]
        Menus["Menus"]
    end

    subgraph RendererProcess ["Renderer Process"]
        SocketClient["Socket.IO Client"]
        Terminal["Xterm.js Terminal"]
        PayloadEditor["Payload Editor"]
    end

    subgraph RemoteServer ["Remote Server"]
        SocketServer["Socket.IO Server"]
        CmdExec["Command Execution"]
        RespStream["Response Streaming"]
    end

    MainProcess -->|IPC contextBridge| RendererProcess
    RendererProcess -->|Socket.IO| RemoteServer
    SocketServer --> CmdExec
    SocketServer --> RespStream

Main Process Responsibilities

The main process (main.js) handles operations requiring Node.js APIs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
const fs = require('fs');
const path = require('path');

let mainWindow;

function createWindow() {
    mainWindow = new BrowserWindow({
        width: 1200,
        height: 800,
        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
            contextIsolation: true,
            nodeIntegration: false
        }
    });

    mainWindow.loadFile('index.html');
}

// File operations via IPC
ipcMain.handle('save-payload', async (event, payload) => {
    const { filePath } = await dialog.showSaveDialog({
        filters: [{ name: 'JSON', extensions: ['json'] }]
    });

    if (filePath) {
        fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
        return { success: true, path: filePath };
    }
    return { success: false };
});

ipcMain.handle('load-payload', async () => {
    const { filePaths } = await dialog.showOpenDialog({
        filters: [{ name: 'JSON', extensions: ['json'] }]
    });

    if (filePaths.length > 0) {
        const content = fs.readFileSync(filePaths[0], 'utf-8');
        return { success: true, payload: JSON.parse(content) };
    }
    return { success: false };
});

Preload Script and Context Isolation

Electron’s security model requires context isolation. The preload script exposes specific APIs to the renderer:

1
2
3
4
5
6
7
8
9
// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
    savePayload: (payload) => ipcRenderer.invoke('save-payload', payload),
    loadPayload: () => ipcRenderer.invoke('load-payload'),
    saveLog: (content) => ipcRenderer.invoke('save-log', content),
    onMenuAction: (callback) => ipcRenderer.on('menu-action', callback)
});

The renderer accesses these through window.electronAPI:

1
2
// In renderer
const result = await window.electronAPI.savePayload(currentPayload);

Renderer Process: UI and Communication

The renderer manages the user interface, Socket.IO connection, and terminal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// renderer.js
import { io } from 'socket.io-client';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';

let socket = null;
let terminal = null;

function initTerminal() {
    terminal = new Terminal({
        cursorBlink: true,
        scrollback: 10000,
        fontSize: 14,
        fontFamily: 'Consolas, monospace'
    });

    const fitAddon = new FitAddon();
    terminal.loadAddon(fitAddon);
    terminal.open(document.getElementById('terminal-container'));
    fitAddon.fit();

    window.addEventListener('resize', () => fitAddon.fit());
}

function connect(host, port) {
    const url = `http://${host}:${port}`;

    socket = io(url, {
        reconnection: true,
        reconnectionDelay: 1000,
        reconnectionAttempts: 5
    });

    socket.on('connect', () => {
        terminal.writeln(`\x1b[32mConnected to ${url}\x1b[0m`);
        updateConnectionStatus('connected');
    });

    socket.on('disconnect', () => {
        terminal.writeln('\x1b[31mDisconnected\x1b[0m');
        updateConnectionStatus('disconnected');
    });

    socket.on('output', (data) => {
        terminal.write(data);
    });

    socket.on('error', (err) => {
        terminal.writeln(`\x1b[31mError: ${err}\x1b[0m`);
    });
}

Payload System

Commands are organized into payloads—JSON documents containing command sequences:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
    "name": "Hardware Initialization Sequence",
    "description": "Power-on test sequence for device under test",
    "version": "1.0",
    "commands": [
        {
            "id": "reset",
            "command": "RESET",
            "parameters": {},
            "delay_after_ms": 2000,
            "description": "Hardware reset"
        },
        {
            "id": "init",
            "command": "INIT",
            "parameters": {
                "mode": "test",
                "verbose": true
            },
            "delay_after_ms": 500,
            "description": "Initialize in test mode"
        },
        {
            "id": "status",
            "command": "STATUS",
            "parameters": {},
            "delay_after_ms": 0,
            "description": "Query device status"
        }
    ]
}

Payload Execution Engine

The execution engine processes commands sequentially with configurable delays:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async function executePayload(payload) {
    terminal.writeln(`\x1b[36m=== Executing: ${payload.name} ===\x1b[0m`);

    for (const cmd of payload.commands) {
        terminal.writeln(`\x1b[33m> ${cmd.command}\x1b[0m`);

        socket.emit('execute', {
            command: cmd.command,
            parameters: cmd.parameters
        });

        // Wait for response or timeout
        await waitForResponse(cmd.id, 5000);

        if (cmd.delay_after_ms > 0) {
            terminal.writeln(`  (delay ${cmd.delay_after_ms}ms)`);
            await sleep(cmd.delay_after_ms);
        }
    }

    terminal.writeln('\x1b[36m=== Sequence complete ===\x1b[0m');
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

function waitForResponse(commandId, timeout) {
    return new Promise((resolve) => {
        const handler = (data) => {
            if (data.commandId === commandId) {
                socket.off('response', handler);
                resolve(data);
            }
        };

        socket.on('response', handler);

        setTimeout(() => {
            socket.off('response', handler);
            resolve({ timeout: true });
        }, timeout);
    });
}

Payload Editor Interface

The UI provides controls for building payloads:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="payload-editor">
    <div class="toolbar">
        <button id="btn-new">New Payload</button>
        <button id="btn-load">Load</button>
        <button id="btn-save">Save</button>
        <button id="btn-execute">Execute</button>
    </div>

    <div class="payload-info">
        <input type="text" id="payload-name" placeholder="Payload name">
        <textarea id="payload-description" placeholder="Description"></textarea>
    </div>

    <div id="command-list">
        <!-- Commands rendered dynamically -->
    </div>

    <button id="btn-add-command">Add Command</button>
</div>

Terminal Integration

Xterm.js provides the output display with full terminal emulation capabilities.

ANSI Color Support

The terminal renders ANSI escape codes for colored output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const COLORS = {
    reset: '\x1b[0m',
    red: '\x1b[31m',
    green: '\x1b[32m',
    yellow: '\x1b[33m',
    blue: '\x1b[34m',
    cyan: '\x1b[36m'
};

function logInfo(message) {
    terminal.writeln(`${COLORS.cyan}[INFO]${COLORS.reset} ${message}`);
}

function logError(message) {
    terminal.writeln(`${COLORS.red}[ERROR]${COLORS.reset} ${message}`);
}

function logSuccess(message) {
    terminal.writeln(`${COLORS.green}[OK]${COLORS.reset} ${message}`);
}

Log Export

Terminal contents export to text files:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function exportLog() {
    const buffer = terminal.buffer.active;
    const lines = [];

    for (let i = 0; i < buffer.length; i++) {
        const line = buffer.getLine(i);
        if (line) {
            lines.push(line.translateToString(true));
        }
    }

    const content = lines.join('\n');
    const result = await window.electronAPI.saveLog(content);

    if (result.success) {
        logInfo(`Log saved to ${result.path}`);
    }
}

Terminal Configuration

Terminal behavior is configurable:

1
2
3
4
5
6
7
8
9
10
11
12
13
const terminalConfig = {
    cursorBlink: true,
    cursorStyle: 'block',     // 'block', 'underline', 'bar'
    scrollback: 10000,        // Lines retained in buffer
    fontSize: 14,
    fontFamily: 'Consolas, "Courier New", monospace',
    theme: {
        background: '#1e1e1e',
        foreground: '#d4d4d4',
        cursor: '#ffffff',
        selection: '#264f78'
    }
};

Connection Management

The application manages connections to remote servers with status feedback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class ConnectionManager {
    constructor() {
        this.socket = null;
        this.status = 'disconnected';
        this.host = null;
        this.port = null;
    }

    connect(host, port) {
        this.host = host;
        this.port = port;
        this.status = 'connecting';
        this.updateUI();

        this.socket = io(`http://${host}:${port}`, {
            reconnection: true,
            reconnectionDelay: 1000,
            reconnectionDelayMax: 5000,
            reconnectionAttempts: 10,
            timeout: 5000
        });

        this.socket.on('connect', () => {
            this.status = 'connected';
            this.updateUI();
        });

        this.socket.on('disconnect', (reason) => {
            this.status = 'disconnected';
            this.updateUI();
            console.log(`Disconnected: ${reason}`);
        });

        this.socket.on('connect_error', (error) => {
            this.status = 'error';
            this.updateUI();
            console.error(`Connection error: ${error.message}`);
        });
    }

    disconnect() {
        if (this.socket) {
            this.socket.disconnect();
            this.socket = null;
        }
        this.status = 'disconnected';
        this.updateUI();
    }

    updateUI() {
        const indicator = document.getElementById('connection-status');
        indicator.className = `status-${this.status}`;
        indicator.textContent = this.status.toUpperCase();
    }
}

Security Considerations

This application is designed for controlled test environments, not production deployments. Several security aspects require consideration:

Network Exposure

Socket.IO connections are unencrypted HTTP by default. For any deployment beyond localhost testing:

  • Deploy behind a VPN or private network
  • Configure TLS termination at a reverse proxy
  • Restrict firewall rules to known test machines

Command Injection

Commands sent to remote systems execute with the privileges of the server process. Mitigations:

  • Server-side validation: The receiving server should validate and sanitize all commands
  • Command whitelisting: Restrict allowed commands to a predefined set
  • Parameter validation: Validate parameter types and ranges
1
2
3
4
5
6
7
8
9
10
// Server-side example (not part of GUI)
const ALLOWED_COMMANDS = ['RESET', 'INIT', 'STATUS', 'READ', 'WRITE'];

socket.on('execute', (data) => {
    if (!ALLOWED_COMMANDS.includes(data.command)) {
        socket.emit('error', `Unknown command: ${data.command}`);
        return;
    }
    // Execute validated command
});

File System Access

The application reads and writes payload files. Electron’s context isolation prevents the renderer from accessing Node.js APIs directly; all file operations route through IPC handlers in the main process.

No Authentication

The current architecture does not implement authentication. All connecting clients can execute commands. For multi-user environments:

  • Implement token-based authentication
  • Add user identification to logs
  • Consider role-based command restrictions

Project Structure

A typical project layout:

1
2
3
4
5
6
7
8
9
10
11
remote-command-gui/
├── package.json
├── main.js              # Main process
├── preload.js           # Context bridge
├── index.html           # UI structure
├── renderer.js          # Renderer process logic
├── styles.css           # Application styling
├── payloads/            # Default payload directory
│   └── example.json
└── assets/
    └── icon.png

Package Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
    "name": "remote-command-gui",
    "version": "1.0.0",
    "main": "main.js",
    "scripts": {
        "start": "electron .",
        "build": "electron-builder"
    },
    "dependencies": {
        "socket.io-client": "^4.6.0",
        "xterm": "^5.3.0",
        "xterm-addon-fit": "^0.8.0"
    },
    "devDependencies": {
        "electron": "^28.0.0",
        "electron-builder": "^24.9.0"
    }
}

Server-Side Companion

The GUI requires a Socket.IO server on the remote system. A minimal implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// server.js (runs on remote system)
const { Server } = require('socket.io');
const { exec } = require('child_process');

const io = new Server(3000, {
    cors: { origin: '*' }
});

io.on('connection', (socket) => {
    console.log(`Client connected: ${socket.id}`);

    socket.on('execute', (data) => {
        const { command, parameters } = data;

        // Build command string (with appropriate sanitization)
        const cmdString = buildCommand(command, parameters);

        exec(cmdString, (error, stdout, stderr) => {
            if (error) {
                socket.emit('output', `\x1b[31m${stderr}\x1b[0m\n`);
                socket.emit('response', {
                    commandId: data.id,
                    success: false,
                    error: error.message
                });
            } else {
                socket.emit('output', stdout);
                socket.emit('response', {
                    commandId: data.id,
                    success: true
                });
            }
        });
    });

    socket.on('disconnect', () => {
        console.log(`Client disconnected: ${socket.id}`);
    });
});

console.log('Server listening on port 3000');

Use Cases

Embedded System Development

During firmware development, engineers frequently execute test commands:

  1. Flash new firmware
  2. Reset device
  3. Run diagnostic sequence
  4. Capture output for analysis

Payloads capture these sequences for repeatable execution across development cycles.

Hardware Test Automation

Automated test stations benefit from standardized command sequences:

  • Power-on self-test sequences
  • Calibration procedures
  • Burn-in test loops
  • Production verification

Remote Lab Equipment Control

Equipment in remote labs (oscilloscopes, power supplies, test fixtures) often support command interfaces. A centralized GUI simplifies:

  • Multi-device orchestration
  • Command logging for audit trails
  • Parameter adjustment during testing

Conclusion

The combination of Electron, Socket.IO, and Xterm.js provides a capable foundation for remote command execution interfaces. Electron handles desktop integration and file operations. Socket.IO manages real-time bidirectional communication. Xterm.js renders output with full terminal fidelity.

This architecture suits controlled test environments where simplicity and repeatability take precedence over hardened security. For production deployments, additional authentication, encryption, and access control layers are necessary.

The payload system transforms ad-hoc command sequences into versioned, shareable artifacts—reducing errors and improving test documentation.

This post is licensed under CC BY 4.0 by the author.