
上图这是SSH登录到一台Linux系统(Ubuntu)后的页面显示。在窗口的右上角有“Open”和“Close”两个链接按钮,点击“Open”将发起到Linux远端主机的连接和登录,点击“Close”关闭远端主机的连接。
Shell终端窗口的页面,在index.html文件的html和javascript描述中,index.html文件由三部分组成:
- 第7行~134行,由<style>…</style>标签包含的CSS样式
- 第137行~156行,由<div>…</div>标签包含的页面布局
- 第158行~649行,由<script>…</script>标签包含的javascript
css样式和div布局,我没有什么可以深入讲解的,虽然作为一名老程序员,但基本没有编写html页面的经验,最终页面效果看起来还行,主要是靠了deepseek的帮助。在css这部分,可以稍微关注一下这几个样式:
- * — 定义字体和字号
- .terminal-line — 普通行的文字样式
- .terminal-line-cursor — 光标行的文字样式
- .cursor — 光标样式
- @keyframes cursorBlink — 光标闪动
我们还是聚焦在Javascript部分,对于shell终端这样的程序,怎样和远端Linux主机进行交互,是重中之重。
全局常量和变量
在<script>标签的起始部分,定义了几个全局性的常量和变量:
let addr = "192.168.152.152:22";
let user = "dyf";
let passwd = "dyf";
let rows = 24;
let cols = 120;
let session_id = "";
let seq = 0;
let cx = 0; //Cursor position x (Range: 0 ~ cols)
let cy = 0; //Cursor position y (Range: 0 ~ rows)
let cs = false;
这几行定义了远端Linux主机的地址、端口、登录用户名和密码,出于简单性目的,这些参数硬编码在这里,你可以根据业务需求对这些常量进行扩展。
- rows 和 cols — 表示当前终端窗口最多可显示的文字行数和列数(需要根据窗口大小,以及字体、字号计算出合适的值)
- session_id — SSH登录成功后的会话ID
- seq — 终端输出的序列(前面章节解释过seq的作用)
- cx 和 cy — 光标在终端窗口中的位置,cx表示所在列,cy表示所在行
- cs — 光标是否显示
Ajax接口
Javascript和后台交互,被封装在这几个Ajax接口函数中:
1)function open_session() — 创建session
在SSH协议中,创建一个shell终端需要创建socket连接,协商密钥、创建通道、登录验证等多个步骤,这里统一封装到open_session()函数中。
| 示例 | 说明 | |
| 发送数据 | { “addr”: “192.168.152.152:22”, “user”: “dyf”, “passwd”: “dyf”, “rows”: 24, “cols”: 120 } | addr — 远端主机的地址和端口 user — ssh登录用户名 passwd — ssh登录密码 rows — 窗口字符行数 cols — 窗口字符宽度 |
| 返回数据 | { status: true, session_id: “shell-87c07b1f-b45a-479e-b7d7-65289b87f73c”, err_msg: “”, } | status — true表示创建session成功,false表示创建失败 session_id — status为true时返回 err_msg — status为false时返回失败原因 |
| 后台Url | /onthessh/OpenSession |
2)function close_session() — 关闭session
| 示例 | 说明 | |
| 发送数据 | { “session_id”: “shell-81768f43-9620-42eb-8ae5-24769e223e21” } | |
| 返回数据 | 无 | |
| 后台Url | /onthessh/CloseSession |
3)function stdout() — 获得shell的输出
在前面的章节中,讲述过发送数据中的参数seq的作用。
返回数据是类似于html的页面描述,由<head>和<body>标签组成,通过解析这些标签描述来绘制整个窗口。
| 示例 | 说明 | |
| 发送数据 | { “session_id”: “shell-81768f43-9620-42eb-8ae5-24769e223e21”, “seq”: 0 } | seq — 请求序列 |
| 返回数据 | { “status”: true, “page”: “<head>…</head>\r\n<body>…</body>” } | status — true表示成功,false表示失败 page — 类似html的页面描述 |
| 后台Url | /onthessh/Stdout |
stdout()与其他接口不一样的地方,是需要不断轮询。
const POLL_INTERVAL = 200; //200 milliseconds
let isProcessing = false;
let pollInterval = setInterval(stdout, POLL_INTERVAL);
function stdout()
{
if (session_id == "") return;
if (isProcessing) return;
isProcessing = true;
// console.log('poll...');
POLL_INTERVAL定义了轮询间隔是200毫秒,isProcessing变量保障同一时间只能有一个stdout后台请求在运行,setInterval()驱动轮询的运转。
4)function stdin(chars) — 终端输入
输入都是来自键盘的敲击,当我们在窗口光标处敲击‘pwd’命令并回车执行时,实际上是向远端Linux依次发送了4个字符,分别是:’p’, ‘w’, ‘d’, ‘\r’。当远端主机收到’p’字符时,将’p’字符添加到终端输出的末尾,并控制光标向右移动1个字符,Stout接口获得到新的页面描述反应了这一切,就好像是我们直接将’p’字符输入到了窗口中,输入’w’, ‘d’过程也是一样的。当键入回车时,Stdin接口向后台发送’\r’字符,远端Linux主机收到后,将前面连续输入的‘pwd’解析为命令并执行,执行结果放在终端窗口的下一行输出,并在最后添加一个新行,内容 ‘dyf@ubuntu: $ ‘,将光标位置移动到 ‘$’ 之后。
| 示例 | 说明 | |
| 发送数据 | { “session_id”: “shell-81768f43-9620-42eb-8ae5-24769e223e21”, “chars”: “ls” } | chars — 输入字符(如 ‘ls’ 是文件列表命令) |
| 返回数据 | 无 | |
| 后台Url | /onthessh/Stdin |
5)function scroll(lines) — 滚动窗口
滚动窗口不属于SSH协议的内容,它是对核心模块中的输出页面缓存做视图滚动,控制Stdout接口获得到的页面正好在符合shell窗口的行数和列数范围内。
| 示例 | 说明 | |
| 发送数据 | { “session_id”: “shell-81768f43-9620-42eb-8ae5-24769e223e21”, “lines”: 3 } | lines — 滚动行数(正数向下滚动,负数向上滚动) |
| 返回数据 | 无 | |
| 后台Url | /onthessh/Scroll |
6)function window_size_change(rows, cols) — 窗口尺寸变动
当shell窗口大小改变,窗口可容纳字符的行数和列数也会变化,这一变化需要即时通知远端Linux主机,Linux主机会根据新的行数和列数,重新构建窗口输出。
当前index.html中只提供了window_size_change()接口,并未实现shell窗口尺寸改变事件。
字体、字号的改变,也会造成窗口行数和列数的变化,这点要注意。
| 示例 | 说明 | |
| 发送数据 | { “session_id”: “shell-81768f43-9620-42eb-8ae5-24769e223e21”, “rows”: 30, “cols”: 150 } | rows — 新的窗口行数 cols — 新的窗口列数 |
| 返回数据 | 无 | |
| 后台Url | /onthessh/WindowSizeChange |
绘制终端窗口
对终端输出的解析,在函数parse_page(page)中:
function parse_page(page)
{
//head----
let a = page.indexOf("<head>") + "<head>".length;
let b = page.indexOf("</head>");
let head = page.substring(a, b);
//error
if (head.indexOf("<error>1</error>") != -1)
{
close_session();
return;
}
//seq
a = head.indexOf("<seq>") + "<seq>".length;
b = head.indexOf("</seq>");
let head_seq = parseInt(head.substring(a, b));
if (seq == head_seq) return; //If the seq values are the same, ignore this page data
seq = head_seq; //update seq
// console.log("RSP SEQ:", seq);
//cursor position
a = head.indexOf("<cxy>") + "<cxy>".length;
b = head.indexOf("</cxy>");
let cxy = head.substring(a, b);
let ss = cxy.split(";");
cx = parseInt(ss[0]);
cy = parseInt(ss[1]);
//is the cursor displayed
cs = head.indexOf("<cs>1</cs>") == -1 ? false : true;
......
首选解析<head>部分,如果<head>中存在<error>1</error>,表示当前session出现异常(多是网络或远端Linux的错误)。接下来解析seq,如果<head>中的seq值和请求seq(Stdout接口发送的seq)一样,表示终端窗口输出没有变化,也就不需要后续解析,如果seq值不同,表示窗口输出有变化。
<cxy>标签描述光标位置,<cs>标签描述光标是否显示,解析完成后需要更新全局变量cx,cy,cs。
......
//body----
a = page.indexOf("<body>") + "<body>".length;
b = page.indexOf("</body>");
let body = page.substring(a, b);
//Ignore these tags (of course, they can also be used)
body = body.replaceAll("<b>", ""); //<b> & </b> - bold
body = body.replaceAll("</b>", "");
body = body.replaceAll("<u>", ""); //<u> & </u> - underline
body = body.replaceAll("</u>", "");
body = body.replaceAll("<f>", ""); //<f> & </f> - text flickers
body = body.replaceAll("</f>", "");
output.innerHTML = ''; //clear
//lines
let lines = body.split("<br/>");
for (i = 0; i < lines.length; i++)
{
line = lines[i];
// console.log('i:', i, " - ", line);
displayLine(line, i);
}
//cursor
if (cs == true)
{
displayCursor();
}
}
<body>部分的解析,出于简单性目的,略过了粗体(<b>)、下划线(<u>)、字符闪烁(<f>)的解析,在正式应用中可以完善这部分,从而让shell窗口展示的更精细。<br/>标签将文字内容分割为行,通过行遍历,调用displayLine()函数一行行绘制文字内容。如果<head>中的<cs>描述要显示光标,调用displayCursor()函数进行光标绘制。
function displayLine(line, lineNo)
{
line = line.replaceAll("</fc>", "</span>");
line = line.replaceAll("</bc>", "</span>");
//fore-color tag
while (true)
{
let a = line.indexOf("<fc#");
if ( a == -1) break;
let b = line.indexOf(">", a);
if ( b != -1)
{
let tag = line.substring(a, b+1);
line = line.replace(tag, colorStyle(tag));
}
}
......
在行解析中,需要解析<fc#n>…</fc>标签描述的文字前景色,和<bc#n>…</bc>标签描述的文字背景色。其中‘#n’表示颜色编码,通过colorStyle()函数将颜色编码转义为html的16位颜色编码,具体的转换可根据事先定义的颜色样式,出于简单性目的,这里只提供了一种颜色样式:
| 编码 | html颜色 |
| #0 | #B2B2B2 |
| #1 | #000000 |
| #2 | #000000 |
| #3 | #B21818 |
| #4 | #18B218 |
| #5 | #B26818 |
| #6 | #1818B2 |
| #7 | #B218B2 |
| #8 | #18B2B2 |
| #9 | #B2B2B2 |
| #10 | #B2B2B2 |
| #11 | #000000 |
| #12 | #686868 |
| #13 | #FF5454 |
| #14 | #54FF54 |
| #15 | #FFFF54 |
| #16 | #5454FF |
| #17 | #FF54FF |
| #18 | #54FFFF |
| #19 | #FFFFFF |
综上所述,对于<body>中的一行数据,转换结果相当于将:
这是一行文字<fc#0>前景色</fc><bc#1>背景色</bc><br/>
<!-- 转换为 -->
<div class="terminal-line">这是一行文字<span style="color: #B2B2B2;">前景色</span><span style="color: #000000;">背景色</span></div>
绘制光标
前面介绍的displayLine函数,有第二个参数LineNo,它表示光标所在的行下标。行内容和光标都是使用<div>来绘制的,因此光标行要允许两个<div>绘制在同一行。光标所在行的<div>样式多了“position: relative;”的属性,允许光标<div>可以浮动在它上面。
光标绘制的代码对应的函数是displayCursor():
//display cursor
function displayCursor()
{
const divs = document.querySelectorAll('#terminal-output div'); //all div lines
if (divs.length < cy) return;
let line = divs[cy]; //cursor line div
const cursor = document.createElement('div');
cursor.className = 'cursor';
let char_width = charWidth();
// console.log("char_width:", char_width);
cursor.style.left = char_width * cx + 'px';
line.appendChild(cursor);
}
function charWidth()
{
const span = document.createElement('span');
span.style.fontFamily = 'Consolas, monospace';
span.style.fontSize = '13px';
span.style.whiteSpace = 'nowrap';
span.style.visibility = 'hidden';
span.style.position = 'absolute';
const testChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
span.textContent = testChars;
document.body.appendChild(span);
const totalWidth = span.offsetWidth;
document.body.removeChild(span);
return totalWidth / testChars.length;
}
charWidth()函数是计算光标<div>距离终端窗口左边像素距离的,首选创建一个隐藏的<span>,注意这个<span>中的字体和字号要和终端窗口一致,然后在<span>中输入一段文字,计算这段文字的平均字符像素宽度,有了这个字符宽度,乘以光标所在列(cx),即可计算出光标<div>的偏移像素值。
键盘输入
键盘事件、鼠标滚轮事件,在addEventListener()函数中:
// input for Keyboard and mouse wheels
document.addEventListener('DOMContentLoaded', function() {
// init terminal
function initTerminal()
{
// Listen for keyboard input
document.addEventListener('keydown', handleKeyDown);
// Listen for mouse wheel input
document.addEventListener('wheel', handleWheel);
}
......
initTerminal()函数中,注册了键盘和滚轮两个事件侦听器,处理函数分别对应handleKeyDown和handleWheel。在handleKeyDown函数中,最重要的是针对特殊键的编码,特殊键分两类,Ctrl+特殊键 和 特殊键,如下:
if (event.ctrlKey) //Ctrl
{
switch (key)
{
case "Space": inputChars = "\x00"; break;
case "ArrowLeft": inputChars = "\x1b[1;5D"; break;
case "ArrowRight": inputChars = "\x1b[1;5C"; break;
case "ArrowUp": inputChars = "\x1b[1;5A"; break;
case "ArrowDown": inputChars = "\x1b[1;5B"; break;
default: inputChars = ""; break;
}
}
else
{
switch (key)
{
case "Enter": inputChars = "\r"; event.preventDefault(); break;
case "Tab": inputChars = "\t"; event.preventDefault(); break; // Prevent the default focus switching behavior
case "Space": inputChars = " "; break;
case "ArrowLeft": inputChars = "\x1b[D"; break;
case "ArrowRight": inputChars = "\x1b[C"; break;
case "ArrowUp": inputChars = "\x1b[A"; break;
case "ArrowDown": inputChars = "\x1b[B"; break;
case "Home": inputChars = "\x1b[H"; break;
case "End": inputChars = "\x1b[F"; break;
case "Insert": inputChars = "\x1b[2~"; break;
case "Delete": inputChars = "\x1b[3~"; break;
case "PageUp": inputChars = "\x1b[5~"; break;
case "PageDown": inputChars = "\x1b[6~"; break;
case "Pause": inputChars = "\x1a"; break;
case "Escape": inputChars = "\x1b"; break;
case "Backspace": inputChars = "\x7f"; break;
case "F1": inputChars = "\x1bOP"; break;
case "F2": inputChars = "\x1bOQ"; break;
case "F3": inputChars = "\x1bOR"; break;
case "F4": inputChars = "\x1bOS"; break;
case "F5": inputChars = "\x1b[15~"; break;
case "F6": inputChars = "\x1b[17~"; break;
case "F7": inputChars = "\x1b[18~"; break;
case "F8": inputChars = "\x1b[19~"; break;
case "F9": inputChars = "\x1b[20~"; break;
case "F10": inputChars = "\x1b[21~"; break;
case "F11": inputChars = "\x1b[23~"; break;
case "F12": inputChars = "\x1b[24~"; break;
default: console.log("====key:", key); break;
}
经过特殊键编码转换后,调用Stdin()接口函数,将键值发送给后台。
鼠标滚动事件
滚动的处理函数是handleWheel()
// Handle wheel input
function handleWheel(event)
{
const deltaY = event.deltaY;
const deltaMode = event.deltaMode;
console.log("deltaY:", deltaY, "deltaMode:", deltaMode);
if (deltaMode == 0) //0: px, 1: line, 2: page
{
let lines = deltaY > 0 ? Math.floor(deltaY / 100 * 3) : Math.ceil(deltaY / 100 * 3);
console.log("scroll lines:", lines);
scroll(lines);
}
}
event.deltaY是鼠标滚轮每次滚动(嘎达一声)的像素值,大约100px,因此滚动行数要除以100再乘以3。每次滚动3行是大部分shell终端的默认值,当然你也可以调整这个值。
最后说明
index.html总共不到700行代码,如果要实现原始的 xterm 控制序列,这点代码量是远远不够的。OnTheSSH内部做了大量的转换工作,让传统的复杂的shell终端,可用简单的html + CSS + Javascript技术来实现。
出于简单性和教学性,index.html的Web shell终端窗口只实现了最基本的功能,相对于OnTheSSH App,缺少了复制、粘贴,颜色样式切换,输入法输入,命令帮助等扩展功能,实际应用时,可参考OnTheSSH的源代码(Qt/C++部分)来实现这些扩展功能。
如有问题,可以发邮件给我,邮箱:gzmaike@onthessh.com
