From 1829e4d9a6572ca7a7f70909e870780834309ddf Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Fri, 20 Mar 2026 13:01:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Electron=20headles?= =?UTF-8?q?s=20=E7=BB=88=E7=AB=AF=E5=BA=94=E7=94=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.js | 397 ++++++++++++++++++++++++++++++++++++++++++++ electron/preload.js | 10 ++ package.json | 47 +++++- vite.config.ts | 5 + 4 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 electron/main.js create mode 100644 electron/preload.js diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..635c078 --- /dev/null +++ b/electron/main.js @@ -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(); +}); diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..57397c4 --- /dev/null +++ b/electron/preload.js @@ -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, + }, +}); diff --git a/package.json b/package.json index f9b9b7d..b93c2ce 100644 --- a/package.json +++ b/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" + } } } diff --git a/vite.config.ts b/vite.config.ts index 58580ab..92fbe78 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,11 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + base: './', + build: { + outDir: 'dist', + emptyOutDir: true, + }, define: { 'process.env': {}, global: 'globalThis',