mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 02:20:10 +08:00
Add comprehensive documentation analyzing VS Code terminal implementation and providing two approaches: 1. Pure JavaScript solution (recommended) - no compilation required 2. node-pty solution - more complete but requires compilation The guide covers architecture, implementation details, performance considerations, and security best practices for both approaches.
1094 lines
27 KiB
Markdown
1094 lines
27 KiB
Markdown
# Terminal Implementation Guide for Electron Applications
|
||
|
||
<!-- This doc is generated by claude code. -->
|
||
|
||
基于 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
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Simple Terminal</title>
|
||
<link rel="stylesheet" href="node_modules/xterm/css/xterm.css">
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background-color: #1e1e1e;
|
||
color: #cccccc;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.header {
|
||
padding: 8px 16px;
|
||
background-color: #2d2d30;
|
||
border-bottom: 1px solid #3e3e42;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 16px;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.header .controls {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 4px 12px;
|
||
background-color: #0e639c;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.btn:hover {
|
||
background-color: #1177bb;
|
||
}
|
||
|
||
.btn.secondary {
|
||
background-color: #5a5a5a;
|
||
}
|
||
|
||
.btn.secondary:hover {
|
||
background-color: #6a6a6a;
|
||
}
|
||
|
||
#terminal-container {
|
||
flex: 1;
|
||
padding: 8px;
|
||
}
|
||
|
||
.terminal-wrapper {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 1px solid #3e3e42;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>🖥️ Simple Terminal</h1>
|
||
<div class="controls">
|
||
<button class="btn secondary" onclick="window.terminal?.clear()">Clear</button>
|
||
<button class="btn secondary" onclick="window.terminal?.focus()">Focus</button>
|
||
<button class="btn" onclick="createNewTerminal()">New Terminal</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="terminal-container">
|
||
<div class="terminal-wrapper" id="terminal-wrapper"></div>
|
||
</div>
|
||
|
||
<script type="module" src="renderer.js"></script>
|
||
<script>
|
||
function createNewTerminal() {
|
||
// 重新创建终端实例
|
||
if (window.terminal) {
|
||
window.terminal.dispose();
|
||
}
|
||
|
||
// 清空容器
|
||
const wrapper = document.getElementById('terminal-wrapper');
|
||
wrapper.innerHTML = '';
|
||
wrapper.id = 'terminal-container';
|
||
|
||
// 创建新终端
|
||
setTimeout(() => {
|
||
window.terminal = new SimpleTerminal('terminal-container');
|
||
}, 100);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
### 6. 构建配置 (package.json)
|
||
|
||
```json
|
||
{
|
||
"name": "simple-terminal",
|
||
"version": "1.0.0",
|
||
"description": "Simple terminal implementation with xterm.js and node-pty",
|
||
"main": "main.js",
|
||
"scripts": {
|
||
"start": "electron .",
|
||
"build": "electron-builder",
|
||
"rebuild": "electron-rebuild"
|
||
},
|
||
"dependencies": {
|
||
"electron": "^22.0.0",
|
||
"xterm": "^5.1.0",
|
||
"@xterm/addon-fit": "^0.7.0",
|
||
"@xterm/addon-web-links": "^0.8.0",
|
||
"@xterm/addon-search": "^0.12.0",
|
||
"node-pty": "^0.10.1"
|
||
},
|
||
"devDependencies": {
|
||
"electron-builder": "^24.0.0",
|
||
"electron-rebuild": "^3.2.9"
|
||
},
|
||
"build": {
|
||
"appId": "com.example.simple-terminal",
|
||
"productName": "Simple Terminal",
|
||
"directories": {
|
||
"output": "dist"
|
||
},
|
||
"files": [
|
||
"**/*",
|
||
"!node_modules/node-pty/build/Release/obj/**/*"
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
## 增强功能
|
||
|
||
### 主题支持
|
||
|
||
```javascript
|
||
const themes = {
|
||
dark: {
|
||
background: '#1e1e1e',
|
||
foreground: '#cccccc',
|
||
// ... 其他颜色
|
||
},
|
||
light: {
|
||
background: '#ffffff',
|
||
foreground: '#000000',
|
||
// ... 其他颜色
|
||
},
|
||
solarized: {
|
||
background: '#002b36',
|
||
foreground: '#839496',
|
||
// ... 其他颜色
|
||
}
|
||
};
|
||
|
||
// 切换主题
|
||
terminal.options.theme = themes.light;
|
||
```
|
||
|
||
### 多标签页支持
|
||
|
||
```javascript
|
||
class TerminalManager {
|
||
constructor() {
|
||
this.terminals = new Map();
|
||
this.activeTab = null;
|
||
}
|
||
|
||
createTab(id) {
|
||
const terminal = new SimpleTerminal(`terminal-${id}`);
|
||
this.terminals.set(id, terminal);
|
||
return terminal;
|
||
}
|
||
|
||
switchTab(id) {
|
||
if (this.activeTab) {
|
||
this.activeTab.container.style.display = 'none';
|
||
}
|
||
|
||
const terminal = this.terminals.get(id);
|
||
if (terminal) {
|
||
terminal.container.style.display = 'block';
|
||
terminal.focus();
|
||
this.activeTab = terminal;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 历史命令支持
|
||
|
||
```javascript
|
||
class CommandHistory {
|
||
constructor() {
|
||
this.history = [];
|
||
this.currentIndex = -1;
|
||
}
|
||
|
||
add(command) {
|
||
this.history.push(command);
|
||
this.currentIndex = this.history.length;
|
||
}
|
||
|
||
previous() {
|
||
if (this.currentIndex > 0) {
|
||
this.currentIndex--;
|
||
return this.history[this.currentIndex];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
next() {
|
||
if (this.currentIndex < this.history.length - 1) {
|
||
this.currentIndex++;
|
||
return this.history[this.currentIndex];
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 部署注意事项
|
||
|
||
### Windows 环境
|
||
|
||
1. 安装 Visual Studio Build Tools 或完整版本
|
||
2. 或使用 `npm install --global windows-build-tools`
|
||
3. node-pty 需要本地编译
|
||
|
||
### macOS/Linux 环境
|
||
|
||
1. 确保安装了 Python 和基本开发工具
|
||
2. macOS 需要 Xcode Command Line Tools
|
||
3. Linux 需要 build-essential 包
|
||
|
||
### Electron 打包
|
||
|
||
```bash
|
||
# 重建原生模块
|
||
npm run rebuild
|
||
|
||
# 构建应用
|
||
npm run build
|
||
```
|
||
|
||
## 性能优化
|
||
|
||
1. **限制滚动缓冲区**: 设置合理的 `scrollback` 值
|
||
2. **按需渲染**: 使用 `renderer.renderRows()` 控制渲染范围
|
||
3. **内存管理**: 及时清理不使用的终端实例
|
||
4. **数据节流**: 对高频输出进行节流处理
|
||
|
||
## 安全考虑
|
||
|
||
1. **命令验证**: 对用户输入进行基本验证
|
||
2. **环境隔离**: 限制终端进程的环境变量
|
||
3. **权限控制**: 以最小权限运行终端进程
|
||
4. **输出过滤**: 过滤潜在的恶意输出序列
|
||
|
||
## 两种方案对比
|
||
|
||
| 特性 | 纯 JavaScript 方案 | node-pty 方案 |
|
||
| -------------- | ------------------ | ----------------- |
|
||
| **环境要求** | ✅ 无需编译环境 | ❌ 需要 Python/C++ |
|
||
| **安装简易度** | ✅ 简单快速 | ❌ 复杂,可能出错 |
|
||
| **打包体积** | ✅ 更小 | ❌ 更大 |
|
||
| **跨平台兼容** | ✅ 良好 | ✅ 很好 |
|
||
| **功能完整度** | ⚠️ 基本功能 | ✅ 完整功能 |
|
||
| **终端特性** | ⚠️ 部分支持 | ✅ 完全支持 |
|
||
| **性能** | ✅ 良好 | ✅ 很好 |
|
||
|
||
## 功能限制说明
|
||
|
||
### 纯 JavaScript 方案限制
|
||
|
||
1. **无法处理原生终端特性**:
|
||
- 不支持真正的 PTY(伪终端)
|
||
- 无法正确处理一些交互式程序(如 `vim`、`nano`)
|
||
- 终端大小调整可能不完整
|
||
|
||
2. **输入输出处理较简单**:
|
||
- 可能无法完美处理所有 ANSI 转义序列
|
||
- 某些特殊按键组合可能不工作
|
||
- 命令历史记录需要自己实现
|
||
|
||
3. **适用场景**:
|
||
- 简单命令执行
|
||
- 日志输出显示
|
||
- 开发工具集成
|
||
- 快速原型开发
|
||
|
||
### node-pty 方案优势
|
||
|
||
1. **完整终端体验**:
|
||
- 真正的 PTY 支持
|
||
- 完整的 ANSI 转义序列处理
|
||
- 支持所有交互式程序
|
||
|
||
2. **更好的兼容性**:
|
||
- 与系统终端行为一致
|
||
- 支持复杂的终端应用
|
||
- 更好的字符编码处理
|
||
|
||
## 推荐使用场景
|
||
|
||
### 选择纯 JavaScript 方案当:
|
||
- ✅ 只需要基本的命令执行功能
|
||
- ✅ 希望简化部署和分发
|
||
- ✅ 避免编译环境依赖
|
||
- ✅ 应用体积敏感
|
||
- ✅ 快速开发原型
|
||
|
||
### 选择 node-pty 方案当:
|
||
- ✅ 需要完整的终端体验
|
||
- ✅ 要支持复杂的交互式程序
|
||
- ✅ 对终端功能要求很高
|
||
- ✅ 不在乎部署复杂度
|
||
- ✅ 有稳定的构建环境
|
||
|
||
## 总结
|
||
|
||
**纯 JavaScript 方案**提供了:
|
||
- ✅ 零依赖编译环境
|
||
- ✅ 简单快速的部署
|
||
- ✅ 基本但实用的终端功能
|
||
- ✅ 良好的跨平台兼容性
|
||
- ✅ 更小的应用体积
|
||
|
||
**node-pty 方案**提供了:
|
||
- ✅ 完整的终端体验
|
||
- ✅ 更好的程序兼容性
|
||
- ✅ 专业级的功能支持
|
||
- ❌ 复杂的环境要求
|
||
- ❌ 更大的应用体积
|
||
|
||
对于大多数应用场景,**推荐使用纯 JavaScript 方案**,它能够满足 80% 的终端使用需求,同时避免了环境依赖的复杂性。
|