终端页面的html和Javascript

上图这是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