Files
XCTerminal/electron/main.js

398 lines
10 KiB
JavaScript

import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import { createServer } from 'node:http';
import os from 'os';
import pty from 'node-pty';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT_FILE = '.terminal-server-port';
const isDev = process.env.NODE_ENV !== 'production' && !app.isPackaged;
let server;
let wss;
const terminalSessions = new Map();
let sessionIdCounter = 1;
function getShell() {
if (process.platform === 'win32') {
return 'powershell.exe';
}
return process.env.SHELL || '/bin/bash';
}
function generateSessionId() {
return `term-${Date.now()}-${sessionIdCounter++}`;
}
function createTerminalSession(cwd, cols = 80, rows = 24) {
const shell = getShell();
let workingDir = cwd || os.homedir();
try {
const fs = import('fs');
if (!fs.existsSync(workingDir)) {
workingDir = os.homedir();
}
} catch {
workingDir = os.homedir();
}
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols,
rows,
cwd: workingDir,
env: process.env,
});
return ptyProcess;
}
async function findAvailablePort(startPort) {
const net = await import('net');
return new Promise((resolve) => {
const testServer = net.createServer();
testServer.listen(startPort, () => {
const port = testServer.address().port;
testServer.close(() => resolve(port));
});
testServer.on('error', () => {
resolve(findAvailablePort(startPort + 1));
});
});
}
function setupTerminalAPI(app) {
app.post('/api/terminal/create', async (req, res) => {
try {
const { cwd, cols, rows } = req.body;
const sessionId = generateSessionId();
const ptyProcess = createTerminalSession(cwd, cols || 80, rows || 24);
const session = {
id: sessionId,
pty: ptyProcess,
cwd: ptyProcess.cwd,
cols: cols || 80,
rows: rows || 24,
createdAt: Date.now(),
};
terminalSessions.set(sessionId, session);
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal exited: session=${sessionId} code=${exitCode} signal=${signal}`);
terminalSessions.delete(sessionId);
});
res.json({
sessionId,
cols: session.cols,
rows: session.rows,
capabilities: {
input: {
preferred: 'http',
transports: ['ws', 'http'],
ws: {
path: '/api/terminal/input-ws',
},
},
},
});
} catch (error) {
console.error('Failed to create terminal:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/terminal/:sessionId/stream', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const dataHandler = (data) => {
res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
};
const exitHandler = ({ exitCode, signal }) => {
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
res.end();
};
session.pty.onData(dataHandler);
session.pty.onExit(exitHandler);
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
req.on('close', () => {
session.pty.removeListener('data', dataHandler);
session.pty.removeListener('exit', exitHandler);
});
});
app.post('/api/terminal/:sessionId/input', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const data = req.body;
session.pty.write(data);
res.status(204).send();
} catch (error) {
console.error('Failed to write to terminal:', error);
res.status(500).json({ error: 'Failed to write to terminal' });
}
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const { cols, rows } = req.body;
session.pty.resize(cols, rows);
session.cols = cols;
session.rows = rows;
res.status(204).send();
} catch (error) {
console.error('Failed to resize terminal:', error);
res.status(500).json({ error: 'Failed to resize terminal' });
}
});
app.delete('/api/terminal/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
session.pty.kill();
terminalSessions.delete(sessionId);
res.status(204).send();
} catch (error) {
console.error('Failed to close terminal:', error);
res.status(500).json({ error: 'Failed to close terminal' });
}
});
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
const { cwd, cols, rows } = req.body;
session.pty.kill();
terminalSessions.delete(sessionId);
const newSessionId = generateSessionId();
const ptyProcess = createTerminalSession(cwd, cols || 80, rows || 24);
const newSession = {
id: newSessionId,
pty: ptyProcess,
cwd: ptyProcess.cwd,
cols: cols || 80,
rows: rows || 24,
createdAt: Date.now(),
};
terminalSessions.set(newSessionId, newSession);
ptyProcess.onExit(() => {
terminalSessions.delete(newSessionId);
});
res.json({
sessionId: newSessionId,
cols: newSession.cols,
rows: newSession.rows,
capabilities: {
input: {
preferred: 'http',
transports: ['ws', 'http'],
ws: {
path: '/api/terminal/input-ws',
},
},
},
});
} catch (error) {
console.error('Failed to restart terminal:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/terminal/force-kill', (req, res) => {
const { sessionId, cwd } = req.body;
let killedCount = 0;
if (sessionId) {
const session = terminalSessions.get(sessionId);
if (session) {
session.pty.kill();
terminalSessions.delete(sessionId);
killedCount = 1;
}
} else if (cwd) {
for (const [id, session] of terminalSessions) {
if (session.cwd === cwd) {
session.pty.kill();
terminalSessions.delete(id);
killedCount++;
}
}
}
res.json({ killed: killedCount });
});
}
function setupWebSocket() {
wss = new WebSocketServer({ server, path: '/api/terminal/input-ws' });
wss.on('connection', (ws) => {
console.log('WebSocket connected for terminal input');
let boundSessionId = null;
ws.on('message', (message) => {
try {
const data = new Uint8Array(message);
if (data[0] === 0x01) {
const jsonStr = new TextDecoder().decode(data.slice(1));
const payload = JSON.parse(jsonStr);
if (payload.t === 'b' && payload.s) {
boundSessionId = payload.s;
console.log(`WebSocket bound to session: ${boundSessionId}`);
}
if (payload.t === 'p') {
ws.send(Buffer.from([0x01, ...new TextEncoder().encode(JSON.stringify({ t: 'po' }))]));
}
return;
}
const text = new TextDecoder().decode(message);
if (boundSessionId) {
const session = terminalSessions.get(boundSessionId);
if (session) {
session.pty.write(text);
}
}
} catch (error) {
console.error('WebSocket message error:', error);
}
});
ws.on('close', () => {
console.log('WebSocket disconnected');
});
});
}
async function startServer() {
const app = express();
const fs = await import('fs');
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
app.use(express.json({ limit: '50mb' }));
app.use(express.text({ type: '*/*' }));
setupTerminalAPI(app);
if (isDev) {
app.use(express.static(path.join(__dirname, '../dist')));
} else {
app.use(express.static(path.join(__dirname, '../build')));
}
app.use((req, res) => {
if (isDev) {
res.sendFile(path.join(__dirname, '../dist/index.html'));
} else {
res.sendFile(path.join(__dirname, '../build/index.html'));
}
});
server = createServer(app);
setupWebSocket();
const startPort = parseInt(process.env.PORT || '3002', 10);
const availablePort = await findAvailablePort(startPort);
server.listen(availablePort, () => {
console.log(`Terminal server running on http://localhost:${availablePort}`);
fs.writeFileSync(PORT_FILE, String(availablePort));
});
process.on('exit', () => {
try {
fs.unlinkSync(PORT_FILE);
} catch {}
});
}
app.whenReady().then(() => {
startServer();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
startServer();
}
});
app.on('before-quit', () => {
for (const [id, session] of terminalSessions) {
try {
session.pty.kill();
} catch {}
}
terminalSessions.clear();
});