feat: 添加 Electron headless 终端应用支持
This commit is contained in:
397
electron/main.js
Normal file
397
electron/main.js
Normal file
@@ -0,0 +1,397 @@
|
||||
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.offData(dataHandler);
|
||||
session.pty.offExit(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();
|
||||
});
|
||||
10
electron/preload.js
Normal file
10
electron/preload.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
platform: process.platform,
|
||||
versions: {
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome,
|
||||
electron: process.versions.electron,
|
||||
},
|
||||
});
|
||||
47
package.json
47
package.json
@@ -3,11 +3,15 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "node dev-runner.js",
|
||||
"dev:server": "node server/index.js",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "npm run build && electron .",
|
||||
"electron:build": "npm run build && electron-builder",
|
||||
"electron:start": "electron ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
@@ -21,17 +25,54 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^41.0.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.xcopencodweb.terminal",
|
||||
"productName": "XCCMD Terminal",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
global: 'globalThis',
|
||||
|
||||
Reference in New Issue
Block a user