diff --git a/docs/terminal-implementation.md b/docs/terminal-implementation.md new file mode 100644 index 0000000000..ffd8b3ebdd --- /dev/null +++ b/docs/terminal-implementation.md @@ -0,0 +1,1093 @@ +# Terminal Implementation Guide for Electron Applications + + + +基于 VS Code 终端功能分析及简化实现方案 + +## 概述 + +本文档分析了 VS Code 的终端实现原理,并提供了两种实现方案: +1. **纯 JavaScript 方案**(推荐)- 无需本地编译环境,基于 child_process +2. **node-pty 方案** - 功能更完整但需要本地编译环境 + +## VS Code Terminal 技术栈分析 + +### 核心组件 + +1. **xterm.js** - 前端终端模拟器(TypeScript编写) +2. **node-pty** - 后端伪终端处理 +3. **ConPTY/winpty** - Windows系统兼容层 + +### 架构设计 + +``` +┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Renderer │ │ Main │ │ System │ +│ (xterm.js) │◄──►│ (node-pty) │◄──►│ Shell │ +└─────────────────┘ └──────────────┘ └─────────────┘ +``` + +VS Code 的终端功能主要特点: + +- **跨平台兼容性**: Windows 10+ 使用 ConPTY API,旧版本回退到 winpty +- **高性能**: xterm.js 经过高度优化,能够处理大量输出 +- **丰富功能**: 支持主题、字体定制、搜索、链接识别等 + +## 方案一:纯 JavaScript 实现(推荐) + +### 环境要求 + +- Node.js 12+ 或 Electron 8+ +- **无需任何本地编译环境** + +### 1. 依赖安装 + +```bash +npm install xterm @xterm/addon-fit +# 不需要 node-pty! +``` + +### 2. 主进程实现 (main.js) - 纯 JavaScript 版本 + +```javascript +const { app, BrowserWindow, ipcMain } = require('electron'); +const { spawn } = require('child_process'); +const path = require('path'); +const os = require('os'); + +let mainWindow; +let shellProcess; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') + } + }); + + mainWindow.loadFile('index.html'); +} + +// 获取系统默认 shell +function getDefaultShell() { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'cmd.exe'; + } else { + return process.env.SHELL || '/bin/bash'; + } +} + +// 创建 shell 进程 +ipcMain.handle('terminal-create', () => { + try { + const shell = getDefaultShell(); + const args = process.platform === 'win32' ? [] : ['-i']; // interactive mode for unix + + shellProcess = spawn(shell, args, { + cwd: os.homedir(), + env: { ...process.env, TERM: 'xterm-256color' }, + stdio: 'pipe' + }); + + // 监听标准输出 + shellProcess.stdout.on('data', (data) => { + mainWindow.webContents.send('terminal-data', data.toString()); + }); + + // 监听错误输出 + shellProcess.stderr.on('data', (data) => { + mainWindow.webContents.send('terminal-data', data.toString()); + }); + + // 监听进程退出 + shellProcess.on('exit', (code) => { + mainWindow.webContents.send('terminal-exit', code); + shellProcess = null; + }); + + // 监听进程错误 + shellProcess.on('error', (error) => { + mainWindow.webContents.send('terminal-error', error.message); + }); + + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +// 向 shell 写入命令 +ipcMain.handle('terminal-write', (event, data) => { + if (shellProcess && shellProcess.stdin) { + shellProcess.stdin.write(data); + } +}); + +// 终端清屏(发送 clear 命令) +ipcMain.handle('terminal-clear', () => { + if (shellProcess && shellProcess.stdin) { + const clearCommand = process.platform === 'win32' ? 'cls\r\n' : 'clear\r\n'; + shellProcess.stdin.write(clearCommand); + } +}); + +// 终止进程 +ipcMain.handle('terminal-kill', () => { + if (shellProcess) { + if (process.platform === 'win32') { + // Windows 使用 taskkill 强制终止 + spawn('taskkill', ['/pid', shellProcess.pid, '/f', '/t']); + } else { + // Unix-like 系统发送 SIGTERM + shellProcess.kill('SIGTERM'); + } + shellProcess = null; + } +}); + +// 发送 Ctrl+C 信号 +ipcMain.handle('terminal-interrupt', () => { + if (shellProcess) { + if (process.platform === 'win32') { + // Windows 发送 Ctrl+C + shellProcess.stdin.write('\x03'); + } else { + // Unix-like 系统发送 SIGINT + shellProcess.kill('SIGINT'); + } + } +}); + +app.whenReady().then(createWindow); + +app.on('before-quit', () => { + if (shellProcess) { + shellProcess.kill(); + } +}); +``` + +### 3. 预加载脚本 (preload.js) - 纯 JavaScript 版本 + +```javascript +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + terminal: { + create: () => ipcRenderer.invoke('terminal-create'), + write: (data) => ipcRenderer.invoke('terminal-write', data), + clear: () => ipcRenderer.invoke('terminal-clear'), + kill: () => ipcRenderer.invoke('terminal-kill'), + interrupt: () => ipcRenderer.invoke('terminal-interrupt'), + onData: (callback) => ipcRenderer.on('terminal-data', callback), + onExit: (callback) => ipcRenderer.on('terminal-exit', callback), + onError: (callback) => ipcRenderer.on('terminal-error', callback) + } +}); +``` + +### 4. 渲染进程实现 (renderer.js) - 纯 JavaScript 版本 + +```javascript +import { Terminal } from 'xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; + +class PureJSTerminal { + constructor(containerId) { + this.terminal = new Terminal({ + theme: { + background: '#1a1a1a', + foreground: '#ffffff', + cursor: '#ffffff', + cursorAccent: '#000000', + selection: 'rgba(255, 255, 255, 0.3)', + black: '#000000', + red: '#e74c3c', + green: '#2ecc71', + yellow: '#f39c12', + blue: '#3498db', + magenta: '#9b59b6', + cyan: '#1abc9c', + white: '#ecf0f1', + brightBlack: '#34495e', + brightRed: '#c0392b', + brightGreen: '#27ae60', + brightYellow: '#e67e22', + brightBlue: '#2980b9', + brightMagenta: '#8e44ad', + brightCyan: '#16a085', + brightWhite: '#bdc3c7' + }, + fontSize: 14, + fontFamily: '"Cascadia Code", "Fira Code", Monaco, Menlo, "Ubuntu Mono", monospace', + fontWeight: 'normal', + fontWeightBold: 'bold', + cursorBlink: true, + cursorStyle: 'block', + scrollback: 1000, + tabStopWidth: 4 + }); + + this.fitAddon = new FitAddon(); + this.webLinksAddon = new WebLinksAddon(); + + this.terminal.loadAddon(this.fitAddon); + this.terminal.loadAddon(this.webLinksAddon); + + this.container = document.getElementById(containerId); + this.isConnected = false; + this.currentCommand = ''; + + this.init(); + } + + async init() { + try { + this.terminal.open(this.container); + this.fitAddon.fit(); + + // 显示欢迎信息 + this.terminal.writeln('\x1b[33m🖥️ Pure JavaScript Terminal\x1b[0m'); + this.terminal.writeln('\x1b[90mConnecting to shell...\x1b[0m'); + + const result = await window.electronAPI.terminal.create(); + if (result.success) { + this.isConnected = true; + this.setupEventListeners(); + this.terminal.writeln('\x1b[32m✓ Connected!\x1b[0m'); + this.terminal.focus(); + } else { + this.terminal.writeln(`\x1b[31m✗ Failed to connect: ${result.error}\x1b[0m`); + } + } catch (error) { + this.terminal.writeln(`\x1b[31m✗ Error: ${error.message}\x1b[0m`); + } + } + + setupEventListeners() { + // 用户输入处理 + this.terminal.onData(data => { + if (!this.isConnected) return; + + // 处理特殊按键 + if (data === '\r') { // Enter + this.currentCommand = ''; + window.electronAPI.terminal.write('\r\n'); + } else if (data === '\u007F') { // Backspace + window.electronAPI.terminal.write('\b \b'); + } else if (data === '\u0003') { // Ctrl+C + window.electronAPI.terminal.interrupt(); + } else { + this.currentCommand += data; + window.electronAPI.terminal.write(data); + } + }); + + // 接收输出数据 + window.electronAPI.terminal.onData((event, data) => { + // 处理 ANSI 转义序列和特殊字符 + this.terminal.write(data); + }); + + // 处理进程退出 + window.electronAPI.terminal.onExit((event, code) => { + this.isConnected = false; + this.terminal.writeln(`\r\n\x1b[33mProcess exited with code ${code}\x1b[0m`); + this.terminal.writeln('\x1b[90mPress any key to restart...\x1b[0m'); + + // 允许重新启动 + this.terminal.onData(() => { + this.init(); + }); + }); + + // 处理错误 + window.electronAPI.terminal.onError((event, error) => { + this.terminal.writeln(`\r\n\x1b[31mError: ${error}\x1b[0m`); + }); + + // 窗口大小调整 + const resizeObserver = new ResizeObserver(() => { + this.fitAddon.fit(); + }); + resizeObserver.observe(this.container); + + // 键盘快捷键 + this.terminal.attachCustomKeyEventHandler((event) => { + // Ctrl+C - 中断当前命令 + if (event.ctrlKey && event.code === 'KeyC' && event.type === 'keydown') { + if (!this.terminal.hasSelection()) { + window.electronAPI.terminal.interrupt(); + return false; + } + // 如果有选中文本,则复制 + document.execCommand('copy'); + return false; + } + + // Ctrl+V - 粘贴 + if (event.ctrlKey && event.code === 'KeyV' && event.type === 'keydown') { + navigator.clipboard.readText().then(text => { + if (this.isConnected) { + window.electronAPI.terminal.write(text); + } + }); + return false; + } + + // Ctrl+L - 清屏 + if (event.ctrlKey && event.code === 'KeyL' && event.type === 'keydown') { + this.clear(); + return false; + } + + return true; + }); + } + + clear() { + if (this.isConnected) { + window.electronAPI.terminal.clear(); + } + } + + focus() { + this.terminal.focus(); + } + + write(text) { + this.terminal.write(text); + } + + dispose() { + if (this.isConnected) { + window.electronAPI.terminal.kill(); + } + this.terminal.dispose(); + } +} + +// 导出给全局使用 +window.PureJSTerminal = PureJSTerminal; + +document.addEventListener('DOMContentLoaded', () => { + window.terminal = new PureJSTerminal('terminal-container'); +}); +``` + +### 5. 构建配置 (package.json) - 纯 JavaScript 版本 + +```json +{ + "name": "pure-js-terminal", + "version": "1.0.0", + "description": "Pure JavaScript terminal implementation with xterm.js", + "main": "main.js", + "scripts": { + "start": "electron .", + "build": "electron-builder", + "dev": "electron . --development" + }, + "dependencies": { + "electron": "^22.0.0", + "xterm": "^5.1.0", + "@xterm/addon-fit": "^0.7.0", + "@xterm/addon-web-links": "^0.8.0" + }, + "devDependencies": { + "electron-builder": "^24.0.0" + }, + "build": { + "appId": "com.example.pure-js-terminal", + "productName": "Pure JS Terminal", + "directories": { + "output": "dist" + } + } +} +``` + +--- + +## 方案二:node-pty 实现(需要编译环境) + +### 环境要求 + +- Node.js 12+ 或 Electron 8+ +- Python 和 C++ 编译器(用于 node-pty 编译) + +### 1. 依赖安装 + +```bash +npm install xterm @xterm/addon-fit node-pty +``` + +### 2. 主进程实现 (main.js) + +```javascript +const { app, BrowserWindow, ipcMain } = require('electron'); +const pty = require('node-pty'); +const path = require('path'); + +let mainWindow; +let ptyProcess; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') + } + }); + + mainWindow.loadFile('index.html'); +} + +// 创建终端进程 +ipcMain.handle('terminal-create', () => { + const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash'; + + ptyProcess = pty.spawn(shell, [], { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: process.env.HOME, + env: process.env + }); + + // 监听终端数据输出 + ptyProcess.on('data', (data) => { + mainWindow.webContents.send('terminal-data', data); + }); + + // 监听进程退出 + ptyProcess.on('exit', (code) => { + mainWindow.webContents.send('terminal-exit', code); + }); + + return { success: true }; +}); + +// 向终端写入数据 +ipcMain.handle('terminal-write', (event, data) => { + if (ptyProcess) { + ptyProcess.write(data); + } +}); + +// 调整终端大小 +ipcMain.handle('terminal-resize', (event, cols, rows) => { + if (ptyProcess) { + ptyProcess.resize(cols, rows); + } +}); + +// 终端清屏 +ipcMain.handle('terminal-clear', () => { + if (ptyProcess) { + ptyProcess.write('\x1bc'); // 发送清屏命令 + } +}); + +// 关闭终端 +ipcMain.handle('terminal-kill', () => { + if (ptyProcess) { + ptyProcess.kill(); + ptyProcess = null; + } +}); + +app.whenReady().then(createWindow); + +app.on('before-quit', () => { + if (ptyProcess) { + ptyProcess.kill(); + } +}); +``` + +### 3. 预加载脚本 (preload.js) + +```javascript +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + terminal: { + create: () => ipcRenderer.invoke('terminal-create'), + write: (data) => ipcRenderer.invoke('terminal-write', data), + resize: (cols, rows) => ipcRenderer.invoke('terminal-resize', cols, rows), + clear: () => ipcRenderer.invoke('terminal-clear'), + kill: () => ipcRenderer.invoke('terminal-kill'), + onData: (callback) => ipcRenderer.on('terminal-data', callback), + onExit: (callback) => ipcRenderer.on('terminal-exit', callback) + } +}); +``` + +### 4. 渲染进程实现 (renderer.js) + +```javascript +import { Terminal } from 'xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { SearchAddon } from '@xterm/addon-search'; + +class SimpleTerminal { + constructor(containerId) { + this.terminal = new Terminal({ + theme: { + background: '#1e1e1e', + foreground: '#cccccc', + cursor: '#ffffff', + selection: 'rgba(255, 255, 255, 0.3)', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff', + }, + fontSize: 14, + fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, "Courier New", monospace', + fontWeight: 'normal', + fontWeightBold: 'bold', + lineHeight: 1.2, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 1000, + tabStopWidth: 4, + bellStyle: 'none' + }); + + // 加载插件 + this.fitAddon = new FitAddon(); + this.webLinksAddon = new WebLinksAddon(); + this.searchAddon = new SearchAddon(); + + this.terminal.loadAddon(this.fitAddon); + this.terminal.loadAddon(this.webLinksAddon); + this.terminal.loadAddon(this.searchAddon); + + this.container = document.getElementById(containerId); + this.isConnected = false; + + this.init(); + } + + async init() { + try { + // 挂载终端到 DOM + this.terminal.open(this.container); + this.fitAddon.fit(); + + // 创建后端终端进程 + const result = await window.electronAPI.terminal.create(); + if (result.success) { + this.isConnected = true; + this.setupEventListeners(); + this.terminal.focus(); + } + } catch (error) { + console.error('Terminal initialization failed:', error); + this.terminal.write('\r\n\x1b[31mError: Failed to initialize terminal\x1b[0m\r\n'); + } + } + + setupEventListeners() { + // 监听用户输入 + this.terminal.onData(data => { + if (this.isConnected) { + window.electronAPI.terminal.write(data); + } + }); + + // 监听后端数据输出 + window.electronAPI.terminal.onData((event, data) => { + this.terminal.write(data); + }); + + // 监听进程退出 + window.electronAPI.terminal.onExit((event, code) => { + this.isConnected = false; + this.terminal.write(`\r\n\x1b[33mProcess exited with code ${code}\x1b[0m\r\n`); + }); + + // 监听窗口大小变化 + const resizeObserver = new ResizeObserver(() => { + this.fitAddon.fit(); + if (this.isConnected) { + const { cols, rows } = this.terminal; + window.electronAPI.terminal.resize(cols, rows); + } + }); + + resizeObserver.observe(this.container); + + // 监听键盘快捷键 + this.terminal.attachCustomKeyEventHandler((event) => { + // Ctrl+C 复制选中内容 + if (event.ctrlKey && event.code === 'KeyC' && event.type === 'keydown') { + const selection = this.terminal.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection); + return false; + } + } + + // Ctrl+V 粘贴 + if (event.ctrlKey && event.code === 'KeyV' && event.type === 'keydown') { + navigator.clipboard.readText().then(text => { + if (this.isConnected) { + window.electronAPI.terminal.write(text); + } + }); + return false; + } + + // Ctrl+L 清屏 + if (event.ctrlKey && event.code === 'KeyL' && event.type === 'keydown') { + this.clear(); + return false; + } + + return true; + }); + + // 鼠标滚轮缩放 + this.terminal.attachCustomWheelEventHandler((event) => { + if (event.ctrlKey) { + event.preventDefault(); + const delta = event.deltaY < 0 ? 1 : -1; + const newSize = Math.max(8, Math.min(24, this.terminal.options.fontSize + delta)); + this.terminal.options.fontSize = newSize; + this.fitAddon.fit(); + return false; + } + return true; + }); + } + + // 清屏 + clear() { + if (this.isConnected) { + window.electronAPI.terminal.clear(); + } + } + + // 聚焦终端 + focus() { + this.terminal.focus(); + } + + // 搜索文本 + search(text, options = {}) { + return this.searchAddon.findNext(text, options); + } + + // 获取选中文本 + getSelection() { + return this.terminal.getSelection(); + } + + // 写入文本到终端 + write(text) { + this.terminal.write(text); + } + + // 销毁终端 + dispose() { + if (this.isConnected) { + window.electronAPI.terminal.kill(); + } + this.terminal.dispose(); + } +} + +// 使用示例 +document.addEventListener('DOMContentLoaded', () => { + const terminal = new SimpleTerminal('terminal-container'); + + // 全局引用,便于调试 + window.terminal = terminal; +}); +``` + +### 5. HTML 界面 (index.html) + +```html + + +
+ + +