前言
在每次想登录服务器的时候,就必须打开终端软件,比如 mac 的 iterm2,然后在用 ssh 登录。作为一个爱折腾的前端开发者,打算用 web 技术实现终端。这样就只需要打开一个 url,就可以登录服务器,对服务器进行操作了,而不需要每次打开终端(太麻烦)。
效果图
功能
- 可以创建多个 tab 窗口
- 每一个窗口可以创建最多四个 pane,每个 pane 会继承上一个 pane 的 cwd 目录环境
- 可以切换主题,共有 156 个主题可以选择
技术栈
如何使用 web 技术实现终端功能呢?经过调研,最常用的技术如下:
- node-pty 伪终端是 node 和系统 shell 之间的通讯中间库;
- xterm.js 负责绘制浏览器端的终端模拟器
- socket.io 负责连接 xterm.js 和 node-pty 的连接器,比如:在浏览器端 xterm 中输入命令行,会触发 socket 通信,调用 node-pty 写入
-
express 提供 web 服务
- vue 前端页面框架
大致代码
前端 xterm 封装
首先是对 xterm 添加一些中间件,实现搜索、全屏、改变窗口大小等功能
import { Terminal } from 'xterm';
import 'xterm/dist/xterm.css';
import 'xterm/lib/addons/fullscreen/fullscreen.css';
import '@/assets/css/index.css';
import * as fit from 'xterm/lib/addons/fit/fit';
import * as attach from 'xterm/lib/addons/attach/attach';
import * as fullscreen from 'xterm/lib/addons/fullscreen/fullscreen.js';
import * as search from 'xterm/lib/addons/search/search.js';
Terminal.applyAddon(fit);
Terminal.applyAddon(attach);
Terminal.applyAddon(fullscreen);
Terminal.applyAddon(search);
export default Terminal;
创建 xterm 实例
实现如下功能:
- 当在前端输入命令时,触发 socket 的 intput 事件,并把输入的信息通过 socket 传到 node 层;
- 把 node 层的 shell 日志通过 socket 传到前端,使用 xterm 展示出来
import Terminal from './Xterm';
import io from 'socket.io-client';
const socket = io(window.location.origin + '/terminal', { reconnection: true });
const term = new Terminal();
term.on('resize', size => {
socket.emit('resize', [size.cols, size.rows]);
});
// 当在前端输入命令时,触发socket的intput事件,并把输入的信息通过socket传到node层
term.on('data', data => {
socket.emit('input', data);
});
// 把node层的shell信息通过socket传到前端,使用xterm展示出来
socket.on('output', arrayBuffer => {
term.write(arrayBuffer);
});
socket.on('pid', pid => {
pane.pid = pid;
});
window.addEventListener('resize', () => {
term.fit();
});
node 层
- 根据不同环境使用不同的 shell 类型
- 创建一个 terminal 的 socket 命名空间,获取 socket 实例
- 创建 pty 进程实例
- 绑定 data 事件,通过触发 socket 的 output 事件,把 shell 日志传到前端
- socket 绑定 input 事件,用于接收前端发送的命令,并传到 ptyProcess 中
const app = require('express')();
const pty = require('node-pty');
const os = require('os');
const userhome = require('user-home');
const server = require('http').createServer(app);
const io = require('socket.io')(server);
// 根据不同环境使用不同的shell类型
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
// 创建一个terminal的socket命名空间
io.use('terminal').on('connection', socket => {
let ptyProcess = pty.spawn(shell, ['--login'], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: userhome, // 首次进入系统根目录
env: process.env
});
// 绑定data事件,通过socket的output事件,把shell日志传到前端
ptyProcess.on('data', data => socket.emit('output', data));
// socket绑定input事件,用于接收前端发送的命令,并传到ptyProcess中
socket.on('input', data => ptyProcess.write(data));
socket.on('resize', size => {
ptyProcess.resize(size[0], size[1]);
});
socket.on('exit', size => {
ptyProcess.destroy();
});
socket.emit('pid', ptyProcess.pid);
});
至此,一个简单的 web terminal 就创建完成了。但是开发中遇到了一些问题,在此记录下
问题
创建分屏时多个 xterm 的 dom 会和 vue 的 Virtual DOM 冲突
在创建分屏时,会出现某个 xterm 实例的 dom 会丢失。这种现象是由 vue 的 Virtual DOM 和 xterm 同时操作 dom 有关。解决方法是在每次创建 xterm 时,检测已有的 xterm 的 dom 是否还存在,没有存在就重新添加到 dom 中。
// 创建xterm实例之后
this.$nextTick(() => {
term.open(document.getElementById(terminalname)); // terminalname是当前xterm实例的id名
// container中包含所有的xterm实例
container.children.forEach(item => {
let termEle = document.getElementById(item.name);
// 判断当前dom中元素和xterm实例创建的dom不一致时,重新把xterm的dom添加的dom中
if (item.term.element != termEle.children[0]) {
termEle.innerHTML = '';
termEle.append(item.term.element);
}
item.term.fit();
});
});
如何在分屏时保持和上一个面板同一个根目录?
经过调研,xterm 和 node-pty 都没有提供这样的功能。但是 node-pty 能够获取上一个实例子进程的进程 id,然后根据 liunx 命令,可以由进程 id 获取当前进程的所在的根目录。大致代码如下:
const express = require('express');
var router = express.Router();
const { exec } = require('child_process');
const { promisify } = require('util');
const promiseExec = promisify(exec);
router.get('/cwd', function(req, res) {
const { pid } = req.query;
promiseExec(`lsof -a -p ${pid} -d cwd -Fn | tail -1 | sed 's/.//'`).then(newCwd => {
const cwd = typeof newCwd === 'string' ? newCwd.trim() : newCwd.stdout.trim();
res.success(cwd);
});
});
module.exports = router;
如何像 iterm2 那样更换主题?
xterm 也是支持主题的,但是在 npm 官网上居然没有找到 xterm 相关主题包,所以自己做了一个:xterm-theme。它已经支持了 156 个主题,大家可以随意选择。
import { Terminal } from 'xterm';
import { AdventureTime } from 'xterm-theme';
const terminal = new Terminal({
theme: AdventureTime // xtermTheme.AdventureTime
});
// 或
import xtermTheme from 'xterm-theme';
const terminal = new Terminal({
theme: xtermTheme.AdventureTime // xtermTheme.AdventureTime
});
总结
上述代码已经放到我的 github 上了:https://github.com/ysk2014/webshell,大家可以 clone 下来玩一下(觉得好的话,跪求 start)。这个可以放在自己的开发机上,提供大家对开发机的使用效率。如果觉得不安全,可以自己在此基础上添加一个登录功能(代码很方便扩展)。