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(); });