From 8dec6dff877904641095a0890ecbf768a29cfa42 Mon Sep 17 00:00:00 2001 From: "John \"Elwin\" Edwards" Date: Sun, 6 May 2012 08:45:40 -0700 Subject: [PATCH] Put this project under version control, finally. --- bell.svg | 72 ++++ index-rlg.html | 103 +++++ index-sh.html | 91 ++++ ptyhelper.c | 149 +++++++ quickrypt.c | 28 ++ rlgterm.js | 485 +++++++++++++++++++++ shterm.js | 478 +++++++++++++++++++++ termemu.js | 1105 ++++++++++++++++++++++++++++++++++++++++++++++++ tty.css | 90 ++++ webtty.js | 329 ++++++++++++++ webttyd.js | 531 +++++++++++++++++++++++ 11 files changed, 3461 insertions(+) create mode 100644 bell.svg create mode 100644 index-rlg.html create mode 100644 index-sh.html create mode 100644 ptyhelper.c create mode 100644 quickrypt.c create mode 100644 rlgterm.js create mode 100644 shterm.js create mode 100644 termemu.js create mode 100644 tty.css create mode 100755 webtty.js create mode 100755 webttyd.js diff --git a/bell.svg b/bell.svg new file mode 100644 index 0000000..967fa8e --- /dev/null +++ b/bell.svg @@ -0,0 +1,72 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/index-rlg.html b/index-rlg.html new file mode 100644 index 0000000..f5567c0 --- /dev/null +++ b/index-rlg.html @@ -0,0 +1,103 @@ + + + +WebTTY + + + + + +

WebTTY

+
+ + bell +
+
TERM
+
+
`
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
0
+
-
+
=
+
Bksp
+
+
+
Tab
+
Q
+
W
+
E
+
R
+
T
+
Y
+
U
+
I
+
O
+
P
+
[
+
]
+
\
+
+
+
Ctrl
+
A
+
S
+
D
+
F
+
G
+
H
+
J
+
K
+
L
+
;
+
'
+
Ret
+
+
+
Shift
+
Z
+
X
+
C
+
V
+
B
+
N
+
M
+
,
+
.
+
/
+
+
+
+
+
Stop
+
Font: +Smaller +Larger +
+
+
+
+Name: +Password: +Choose game: + +
+
+
+
+

Debugging Output

+
+ + diff --git a/index-sh.html b/index-sh.html new file mode 100644 index 0000000..39f4707 --- /dev/null +++ b/index-sh.html @@ -0,0 +1,91 @@ + + + +WebTTY + + + + + +

WebTTY

+
+ + bell +
+
+Browsing with Javascript turned off? I sympathize. I didn't want Javascript to be necessary for WebTerm. Unfortunately, the only other way to make it work was Java applets. +
+
+
`
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
0
+
-
+
=
+
Bksp
+
+
+
Tab
+
Q
+
W
+
E
+
R
+
T
+
Y
+
U
+
I
+
O
+
P
+
[
+
]
+
\
+
+
+
Ctrl
+
A
+
S
+
D
+
F
+
G
+
H
+
J
+
K
+
L
+
;
+
'
+
Ret
+
+
+
Shift
+
Z
+
X
+
C
+
V
+
B
+
N
+
M
+
,
+
.
+
/
+
+
+
+
+
Log in
+
Stop
+
Font: +Smaller +Larger +
+
+

Debugging Output

+
+ + diff --git a/ptyhelper.c b/ptyhelper.c new file mode 100644 index 0000000..0b8a8aa --- /dev/null +++ b/ptyhelper.c @@ -0,0 +1,149 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int got_sighup = 0; + +void handle_HUP(int signum) { + if (signum == SIGHUP) + got_sighup = 1; + return; +} + +int main(int argc, char *argv[]) { + + int ptymaster, ptyslave; /* File descriptors */ + int child; + int status, selstatus; + int w = 80, h = 24, t; + struct sigaction sighup_act; + fd_set readset; + struct timeval select_time; + char buf[4096]; + int nread; + char *penv, *ptmp; +#if 0 + struct termios ptysettings; +#endif + struct winsize ptysize; + + if (argc == 1) { + fprintf(stderr, "No command given.\n"); + exit(1); + } + + /* Set up the signal handler. */ + sighup_act.sa_handler = &handle_HUP; + sighup_act.sa_flags = SA_RESTART; + sigaction(SIGHUP, &sighup_act, NULL); + + /* Check the environment for configuration options. */ + penv = getenv("PTYHELPER"); + if (penv != NULL) { + t = strtol(penv, &ptmp, 10); + if (t > 0 && t < 256) + h = t; + if (*ptmp != '\0') { + penv = ptmp + 1; + t = strtol(penv, &ptmp, 10); + if (t > 0 && t < 256) + w = t; + } + } + /* Set up the size. */ + ptysize.ws_row = h; + ptysize.ws_col = w; + + /* Open a pty */ + if (openpty(&ptymaster, &ptyslave, NULL, NULL, &ptysize)) { + return 1; + } +#if 0 + /* Put it into raw mode. */ + tcgetattr(ptyslave, &ptysettings); + cfmakeraw(&ptysettings); + tcsetattr(ptyslave, TCSANOW, &ptysettings); +#endif + + /* Start the child */ + /* forkpty() might be more convenient. */ + if (!(child = fork())) { + /* Child process */ + login_tty(ptyslave); + close(ptymaster); + execvp(argv[1], argv + 1); + perror("execvp() failed"); + return 1; + } + close(ptyslave); + + while (1) { + /* Now do a select() over stdin and ptymaster, and write anything that + * appears to ptymaster and stdout respectively. */ + FD_ZERO(&readset); + FD_SET(0, &readset); + FD_SET(ptymaster, &readset); + select_time.tv_sec = 1; + select_time.tv_usec = 0; + selstatus = select(ptymaster + 1, &readset, NULL, NULL, &select_time); + if (selstatus > 0) { + /* TODO make sure it all gets written if a signal interrupts write(). */ + if (FD_ISSET(0, &readset)) { + nread = read(0, buf, 4096); + if (nread > 0) { + write(ptymaster, buf, nread); + } + } + if (FD_ISSET(ptymaster, &readset)) { + nread = read(ptymaster, buf, 4096); + if (nread > 0) { + write(1, buf, nread); + } + } + } + + /* Periodically check to see if we're done. */ + /* TODO: catch SIGCHLD and only wait() if it is delivered. */ + if (waitpid(child, &status, WNOHANG)) { + break; + } + + /* If node sighup's us, pass it along. */ + if (got_sighup) { + kill(child, SIGHUP); + } + } + + /* Get any leftover output and clean up. */ + /* FIXME looping over select() is pointless if there's only one fd that + * nothing's writing to. Just loop over read() until it's empty. */ + while (1) { + FD_ZERO(&readset); + FD_SET(ptymaster, &readset); + select_time.tv_sec = 0; + select_time.tv_usec = 0; + if (select(ptymaster + 1, &readset, NULL, NULL, &select_time) > 0) { + nread = read(ptymaster, buf, 4096); + if (nread > 0) { + write(1, buf, nread); + } + else + break; + } + else + break; + } + close(ptymaster); + + /* Return the child's exit status. */ + if (WIFEXITED(status)) + return WEXITSTATUS(status); + return 0; +} diff --git a/quickrypt.c b/quickrypt.c new file mode 100644 index 0000000..4a88f2f --- /dev/null +++ b/quickrypt.c @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +int main(int argc, char *argv[]) { + char clear[32], enc[120], *ptr; + fgets(&clear, 32, stdin); + if (!(ptr = strchr(&clear, '\n'))) + return 1; + else + *ptr = '\0'; + fgets(&enc, 120, stdin); + if (!(ptr = strchr(&enc, '\n'))) + return 1; + else + *ptr = '\0'; + ptr = crypt(clear, enc); + if (!strcmp(argv[argc - 1], "-s")) { + /* Option -s for "show": output the encrypted version. */ + printf("%s\n", ptr); + return 0; + } + /* Otherwise this is a check. */ + else if (!strcmp(ptr, enc)) + return 0; + return 1; +} diff --git a/rlgterm.js b/rlgterm.js new file mode 100644 index 0000000..21e5569 --- /dev/null +++ b/rlgterm.js @@ -0,0 +1,485 @@ +/* rlgterm.js: Roguelike Gallery's driver for termemu.js */ + +// A state machine that keeps track of polling the server. +var ajaxstate = { + state: 0, + timerID: null, + clear: function () { + if (this.timerID != null) { + window.clearTimeout(this.timerID); + this.timerID = null; + } + }, + set: function (ms) { + this.clear(); + this.timerID = window.setTimeout(getData, ms); + }, + gotdata: function () { + this.set(100); + this.state = 0; + }, + gotnothing: function () { + if (this.state == 0) { + this.set(100); + this.state = 1; + } + else if (this.state == 1) { + this.set(300); + this.state = 2; + } + else if (this.state == 2) { + this.set(1000); + this.state = 3; + } + else { + this.set(5000); + this.state = 3; + } + }, + posted: function () { + this.set(100); + this.state = 0; + } +}; + +function writeData(hexstr) { + var codenum; + var codes = []; + var nc; + var u8wait = 0; /* Stores bits from previous bytes of multibyte sequences. */ + var expect = 0; /* The number of 10------ bytes expected. */ + /* UTF-8 translation. */ + for (var i = 0; i < hexstr.length; i += 2) { + nc = Number("0x" + hexstr.substr(i, 2)); + if (nc < 0x7F) { + /* 0------- */ + codes.push(nc); + /* Any incomplete sequence will be discarded. */ + u8wait = 0; + expect = 0; + } + else if (nc < 0xC0) { + /* 10------ : part of a multibyte sequence */ + if (expect > 0) { + u8wait <<= 6; + u8wait += (nc & 0x3F); + expect--; + if (expect == 0) { + codes.push(u8wait); + u8wait = 0; + } + } + else { + /* Assume an initial byte was missed. */ + u8wait = 0; + } + } + /* These will all discard any incomplete sequence. */ + else if (nc < 0xE0) { + /* 110----- : introduces 2-byte sequence */ + u8wait = (nc & 0x1F); + expect = 1; + } + else if (nc < 0xF0) { + /* 1110---- : introduces 3-byte sequence */ + u8wait = (nc & 0x0F); + expect = 2; + } + else if (nc < 0xF8) { + /* 11110--- : introduces 4-byte sequence */ + u8wait = (nc & 0x07); + expect = 3; + } + else if (nc < 0xFC) { + /* 111110-- : introduces 5-byte sequence */ + u8wait = (nc & 0x03); + expect = 4; + } + else if (nc < 0xFE) { + /* 1111110- : introduces 6-byte sequence */ + u8wait = (nc & 0x01); + expect = 5; + } + else { + /* 1111111- : should never appear */ + u8wait = 0; + expect = 0; + } + /* Supporting all 31 bits is probably overkill... */ + } + termemu.write(codes); + return; +} + +/* Processes a message from the server, returning true or false if it was a + * data message with or without data, null if not data. */ +function processMsg(msg) { + var msglines = msg.split("\n"); + var havedata = null; + if (!msglines[0]) + return null; + if (msglines[0].charAt(0) == 'd') { + if (msglines[1]){ + writeData(msglines[1]); + havedata = true; + } + else { + havedata = false; + } + } + else if (msglines[0] == "E1") { + logout(); + } + else if (msglines[0].charAt(0) == "T") { + setTitle(msglines[1]); + } + else if (msglines[0] == "q1") { + logout(); + } + else { + debug(1, "Unrecognized server message " + msglines[0]); + } + return havedata; +} + +function getData() { + if (termemu.sessid == null) + return; + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = function () { + if (datareq.readyState == 4 && datareq.status == 200) { + var wasdata = processMsg(datareq.responseText); + if (wasdata != null) { + if (wasdata) + ajaxstate.gotdata(); + else + ajaxstate.gotnothing(); + } + return; + } + }; + datareq.open('POST', '/feed', true); + datareq.send("id=" + termemu.sessid); + return; +} + +function postResponseHandler() { + if (this.readyState == 4 && this.status == 200) { + // We might want to do something with wasdata someday. + var wasdata = processMsg(this.responseText); + ajaxstate.posted(); + return; + } +} + +function sendback(str) { + /* For responding to terminal queries. */ + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = postResponseHandler; + datareq.open('POST', '/feed', true); + datareq.send("id=" + termemu.sessid + "&keys=" + str); + return; +} + +/* ASCII values of keys 0-9. */ +var numShifts = [41, 33, 64, 35, 36, 37, 94, 38, 42, 40]; + +var keyHexCodes = { + init: function () { + this[KeyboardEvent.DOM_VK_RETURN] = ["0d", "0d"]; + this[KeyboardEvent.DOM_VK_SPACE] = ["20", "20"]; + this[KeyboardEvent.DOM_VK_TAB] = ["09", "09"]; + this[KeyboardEvent.DOM_VK_BACK_QUOTE] = ["60", "7e"]; + this[KeyboardEvent.DOM_VK_OPEN_BRACKET] = ["5b", "7b"]; + this[KeyboardEvent.DOM_VK_CLOSE_BRACKET] = ["5d", "7d"]; + this[KeyboardEvent.DOM_VK_BACK_SLASH] = ["5c", "7c"]; + this[KeyboardEvent.DOM_VK_SEMICOLON] = ["3b", "3a"]; + this[KeyboardEvent.DOM_VK_QUOTE] = ["27", "22"]; + this[KeyboardEvent.DOM_VK_COMMA] = ["2c", "3c"]; + this[KeyboardEvent.DOM_VK_PERIOD] = ["2e", "3e"]; + this[KeyboardEvent.DOM_VK_SLASH] = ["2f", "3f"]; + this[KeyboardEvent.DOM_VK_EQUALS] = ["3d", "2b"]; + this[KeyboardEvent.DOM_VK_SUBTRACT] = ["2d", "5f"]; + this[KeyboardEvent.DOM_VK_BACK_SPACE] = ["08", "08"]; + this[KeyboardEvent.DOM_VK_ESCAPE] = ["1b", "1b"]; + /* Multi-char control sequences! Neat! */ + this[KeyboardEvent.DOM_VK_PAGE_UP] = ["1b5b357e", "1b5b357e"]; + this[KeyboardEvent.DOM_VK_PAGE_DOWN] = ["1b5b367e", "1b5b367e"]; + this.appCursor(false); + this.appKeypad(false); + }, + appCursor: function (on) { + if (on) { + this[KeyboardEvent.DOM_VK_LEFT] = ["1b4f44", "1b4f44"]; + this[KeyboardEvent.DOM_VK_RIGHT] = ["1b4f43", "1b4f43"]; + this[KeyboardEvent.DOM_VK_UP] = ["1b4f41", "1b4f41"]; + this[KeyboardEvent.DOM_VK_DOWN] = ["1b4f42", "1b4f42"]; + this[KeyboardEvent.DOM_VK_END] = ["1b4f46", "1b4f46"]; + this[KeyboardEvent.DOM_VK_HOME] = ["1b4f48", "1b4f48"]; + } + else { + this[KeyboardEvent.DOM_VK_LEFT] = ["1b5b44", "1b5b44"]; + this[KeyboardEvent.DOM_VK_RIGHT] = ["1b5b43", "1b5b43"]; + this[KeyboardEvent.DOM_VK_UP] = ["1b5b41", "1b5b41"]; + this[KeyboardEvent.DOM_VK_DOWN] = ["1b5b42", "1b5b42"]; + this[KeyboardEvent.DOM_VK_END] = ["1b5b46", "1b5b46"]; + this[KeyboardEvent.DOM_VK_HOME] = ["1b5b48", "1b5b48"]; + } + }, + appKeypad: function (on) { + /* In theory, these should produce either numerals or the k[a-c][1-3] + * sequences. Since we can't count on the terminfo description actually + * containing those sequences, pretend they're just arrow keys etc. + */ + this[KeyboardEvent.DOM_VK_NUMPAD1] = ["1b4f46", "1b4f46"]; + this[KeyboardEvent.DOM_VK_NUMPAD2] = ["1b4f42", "1b4f42"]; + this[KeyboardEvent.DOM_VK_NUMPAD3] = ["1b5b367e", "1b5b367e"]; + this[KeyboardEvent.DOM_VK_NUMPAD4] = ["1b4f44", "1b4f44"]; + this[KeyboardEvent.DOM_VK_NUMPAD5] = ["1b5b45", "1b5b45"]; + this[KeyboardEvent.DOM_VK_NUMPAD6] = ["1b4f43", "1b4f43"]; + this[KeyboardEvent.DOM_VK_NUMPAD7] = ["1b4f48", "1b4f48"]; + this[KeyboardEvent.DOM_VK_NUMPAD8] = ["1b4f41", "1b4f41"]; + this[KeyboardEvent.DOM_VK_NUMPAD9] = ["1b5b357e", "1b5b357e"]; + return; + } +}; + +function sendkey(ev) { + if (termemu.sessid == null) + return; + var keynum = ev.keyCode; + var code; + if (keynum >= ev.DOM_VK_A && keynum <= ev.DOM_VK_Z) { + /* Letters. This assumes the codes are 65-90. */ + if (ev.ctrlKey) + keynum -= 64; + else if (!ev.shiftKey) + keynum += 32; + code = keynum.toString(16); + if (code.length < 2) + code = "0" + code; + } + else if (keynum >= ev.DOM_VK_0 && keynum <= ev.DOM_VK_9) { + /* The number row, NOT the numpad. */ + if (ev.shiftKey) { + code = numShifts[keynum - 48].toString(16); + } + else { + code = keynum.toString(16); + } + } + else if (keynum in keyHexCodes) { + if (ev.shiftKey) + code = keyHexCodes[keynum][1]; + else + code = keyHexCodes[keynum][0]; + } + else if (keynum == ev.DOM_VK_SHIFT || keynum == ev.DOM_VK_CONTROL || + keynum == ev.DOM_VK_ALT || keynum == ev.DOM_VK_CAPS_LOCK) { + return; + } + else { + debug(1, "Ignoring keycode " + keynum); + return; + } + if (termemu.sessid != null) + ev.preventDefault(); + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = postResponseHandler; + datareq.open('POST', '/feed', true); + datareq.send("id=" + termemu.sessid + "&keys=" + code); + return; +} + +var charshifts = { '-': "5f", '=': "2b", '[': "7b", ']': "7d", '\\': "7c", + ';': "3a", '\'': "22", ',': "3c", '.': "3e", '/': "3f", '`': "7e" +} + +function vkey(c) { + if (termemu.sessid == null) + return; + var keystr; + if (c.match(/^[a-z]$/)) { + if (termemu.ctrlp()) { + var n = c.charCodeAt(0) - 96; + keystr = n.toString(16); + if (keystr.length < 2) + keystr = "0" + keystr; + } + else if (termemu.shiftp()) + keystr = c.toUpperCase().charCodeAt(0).toString(16); + else + keystr = c.charCodeAt(0).toString(16); + } + else if (c.match(/^[0-9]$/)) { + if (termemu.shiftp()) + keystr = numShifts[c.charCodeAt(0) - 48].toString(16); + else + keystr = c.charCodeAt(0).toString(16); + } + else if (c == '\n') + keystr = "0a"; + else if (c == '\t') + keystr = "09"; + else if (c == '\b') + keystr = "08"; + else if (c == ' ') + keystr = "20"; + else if (c in charshifts) { + if (termemu.shiftp()) + keystr = charshifts[c]; + else + keystr = c.charCodeAt(0).toString(16); + } + else + return; + //writeData("Sending " + keystr); + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = postResponseHandler; + datareq.open('POST', '/feed', true); + datareq.send("id=" + termemu.sessid + "&keys=" + keystr); + return; +} + +function setup() { + keyHexCodes.init(); + termemu.init("termwrap"); + setTitle("Not connected."); + return; +} + +function toggleshift() { + termemu.toggleshift(); + keydiv = document.getElementById("shiftkey"); + if (termemu.shiftp()) + keydiv.className = "keysel"; + else + keydiv.className = "key"; + return; +} + +function togglectrl() { + termemu.togglectrl(); + keydiv = document.getElementById("ctrlkey"); + if (termemu.ctrlp()) + keydiv.className = "keysel"; + else + keydiv.className = "key"; + return; +} + +function formlogin(ev) { + ev.preventDefault(); + if (termemu.sessid != null) + return; + var formname = document.getElementById("input_name").value; + var formpass = document.getElementById("input_pw").value; + var formgame = document.getElementById("input_game").value; + var formdata = "game=" + encodeURIComponent(formgame) + "&name=" + encodeURIComponent(formname) + "&pw=" + encodeURIComponent(formpass); + var req = new XMLHttpRequest(); + req.onreadystatechange = function () { + if (req.readyState == 4 && req.status == 200) { + var datalines = req.responseText.split("\n"); + if (datalines[0] == 'l1') { + /* Success */ + termemu.sessid = datalines[1]; + setTitle("Playing as " + formname); + debug(1, "Logged in with id " + termemu.sessid); + document.getElementById("loginform").style.display = "none"; + getData(); + } + else { + debug(1, "Could not start game: " + datalines[1]); + document.getElementById("input_name").value = ""; + document.getElementById("input_pw").value = ""; + } + } + }; + req.open('POST', '/login', true); + req.send(formdata); + return; +} + +function logout() { + if (termemu.sessid == null) + return; + termemu.sessid = null; + setTitle("Game over."); + document.getElementById("loginform").style.display = "block"; + return; +} + +function stop() { + var req = new XMLHttpRequest(); + req.onreadystatechange = function () { + if (req.readyState == 4 && req.status == 200) { + processMsg(req.responseText); + return; + } + }; + req.open('POST', '/feed', true); + req.send("id=" + termemu.sessid + "&quit=quit"); + return; +} + +function debug(level, msg) { + if (level < debugSuppress) + return; + var msgdiv = document.createElement("div"); + var msgtext = document.createTextNode(msg); + msgdiv.appendChild(msgtext); + document.getElementById("debug").appendChild(msgdiv); + return; +} + +function textsize(larger) { + var cssSize = termemu.view.style.fontSize; + if (!cssSize) { + return; + } + var match = cssSize.match(/\d*/); + if (!match) { + return; + } + var csize = Number(match[0]); + var nsize; + if (larger) { + if (csize >= 48) + nsize = 48; + else if (csize >= 20) + nsize = csize + 4; + else if (csize >= 12) + nsize = csize + 2; + else if (csize >= 8) + nsize = csize + 1; + else + nsize = 8; + } + else { + if (csize <= 8) + nsize = 8; + else if (csize <= 12) + nsize = csize - 1; + else if (csize <= 20) + nsize = csize - 2; + else if (csize <= 48) + nsize = csize - 4; + else + nsize = 48; + } + document.getElementById("term").style.fontSize = nsize.toString() + "px"; + termemu.resize(); + debug(1, "Changing font size to " + nsize.toString()); + return; +} + +function bell(on) { + var imgnode = document.getElementById("bell"); + if (on) { + imgnode.style.visibility = "visible"; + window.setTimeout(bell, 1500, false); + } + else + imgnode.style.visibility = "hidden"; + return; +} diff --git a/shterm.js b/shterm.js new file mode 100644 index 0000000..9b99e26 --- /dev/null +++ b/shterm.js @@ -0,0 +1,478 @@ +// A state machine that keeps track of polling the server. +var ajaxstate = { + state: 0, + timerID: null, + clear: function () { + if (this.timerID != null) { + window.clearTimeout(this.timerID); + this.timerID = null; + } + }, + set: function (ms) { + this.clear(); + this.timerID = window.setTimeout(getData, ms); + }, + gotdata: function () { + this.set(100); + this.state = 0; + }, + gotnothing: function () { + if (this.state == 0) { + this.set(100); + this.state = 1; + } + else if (this.state == 1) { + this.set(300); + this.state = 2; + } + else if (this.state == 2) { + this.set(1000); + this.state = 3; + } + else { + this.set(5000); + this.state = 3; + } + }, + posted: function () { + this.set(100); + this.state = 0; + } +}; + +function writeData(hexstr) { + var codenum; + var codes = []; + var nc; + var u8wait = 0; /* Stores bits from previous bytes of multibyte sequences. */ + var expect = 0; /* The number of 10------ bytes expected. */ + /* UTF-8 translation. */ + for (var i = 0; i < hexstr.length; i += 2) { + nc = Number("0x" + hexstr.substr(i, 2)); + if (nc < 0x7F) { + /* 0------- */ + codes.push(nc); + /* Any incomplete sequence will be discarded. */ + u8wait = 0; + expect = 0; + } + else if (nc < 0xC0) { + /* 10------ : part of a multibyte sequence */ + if (expect > 0) { + u8wait <<= 6; + u8wait += (nc & 0x3F); + expect--; + if (expect == 0) { + codes.push(u8wait); + u8wait = 0; + } + } + else { + /* Assume an initial byte was missed. */ + u8wait = 0; + } + } + /* These will all discard any incomplete sequence. */ + else if (nc < 0xE0) { + /* 110----- : introduces 2-byte sequence */ + u8wait = (nc & 0x1F); + expect = 1; + } + else if (nc < 0xF0) { + /* 1110---- : introduces 3-byte sequence */ + u8wait = (nc & 0x0F); + expect = 2; + } + else if (nc < 0xF8) { + /* 11110--- : introduces 4-byte sequence */ + u8wait = (nc & 0x07); + expect = 3; + } + else if (nc < 0xFC) { + /* 111110-- : introduces 5-byte sequence */ + u8wait = (nc & 0x03); + expect = 4; + } + else if (nc < 0xFE) { + /* 1111110- : introduces 6-byte sequence */ + u8wait = (nc & 0x01); + expect = 5; + } + else { + /* 1111111- : should never appear */ + u8wait = 0; + expect = 0; + } + /* Supporting all 31 bits is probably overkill... */ + } + termemu.write(codes); + return; +} + +function getData() { + if (!termemu.alive) + return; + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = function () { + if (datareq.readyState == 4 && datareq.status == 200) { + var datalines = datareq.responseText.split("\n"); + if (!datalines[0]) { + return; + } + else if (datalines[0] == "E1") { + termemu.alive = false; + return; + } + else if (datalines[0].charAt(0) != 'd') { + return; + } + if (datalines[1]) { + writeData(datalines[1]); + ajaxstate.gotdata(); + } + else { + ajaxstate.gotnothing(); + } + return; + } + }; + datareq.open('GET', '/feed', true); + datareq.send(null); + return; +} + +function postResponseHandler() { + if (this.readyState == 4 && this.status == 200) { + var datalines = this.responseText.split("\n"); + if (!datalines[0]) + return; + else if (datalines[0] == "E1") { + termemu.alive = false; + return; + } + else if (datalines[0].charAt(0) != "d") + return; + /* It is a data message */ + if (datalines[1]) { + writeData(datalines[1]); + } + ajaxstate.posted(); + return; + } +} + +function sendback(str) { + /* For responding to terminal queries. */ + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = postResponseHandler; + datareq.open('POST', '/feed', true); + datareq.send("keys=" + str); + return; +} + +/* ASCII values of keys 0-9. */ +var numShifts = [41, 33, 64, 35, 36, 37, 94, 38, 42, 40]; + +var keyHexCodes = { + init: function () { + this[KeyboardEvent.DOM_VK_RETURN] = ["0d", "0d"]; + this[KeyboardEvent.DOM_VK_SPACE] = ["20", "20"]; + this[KeyboardEvent.DOM_VK_TAB] = ["09", "09"]; + this[KeyboardEvent.DOM_VK_BACK_QUOTE] = ["60", "7e"]; + this[KeyboardEvent.DOM_VK_OPEN_BRACKET] = ["5b", "7b"]; + this[KeyboardEvent.DOM_VK_CLOSE_BRACKET] = ["5d", "7d"]; + this[KeyboardEvent.DOM_VK_BACK_SLASH] = ["5c", "7c"]; + this[KeyboardEvent.DOM_VK_SEMICOLON] = ["3b", "3a"]; + this[KeyboardEvent.DOM_VK_QUOTE] = ["27", "22"]; + this[KeyboardEvent.DOM_VK_COMMA] = ["2c", "3c"]; + this[KeyboardEvent.DOM_VK_PERIOD] = ["2e", "3e"]; + this[KeyboardEvent.DOM_VK_SLASH] = ["2f", "3f"]; + this[KeyboardEvent.DOM_VK_EQUALS] = ["3d", "2b"]; + this[KeyboardEvent.DOM_VK_SUBTRACT] = ["2d", "5f"]; + this[KeyboardEvent.DOM_VK_BACK_SPACE] = ["08", "08"]; + this[KeyboardEvent.DOM_VK_ESCAPE] = ["1b", "1b"]; + this[KeyboardEvent.DOM_VK_PAGE_UP] = ["1b5b357e", "1b5b357e"]; + this[KeyboardEvent.DOM_VK_PAGE_DOWN] = ["1b5b367e", "1b5b367e"]; + this.appCursor(false); + this.appKeypad(false); + }, + /* Multi-char control sequences! Neat! */ + appCursor: function (on) { + /* Aren't special keys vile? */ + if (on) { + this[KeyboardEvent.DOM_VK_LEFT] = ["1b4f44", "1b4f44"]; + this[KeyboardEvent.DOM_VK_RIGHT] = ["1b4f43", "1b4f43"]; + this[KeyboardEvent.DOM_VK_UP] = ["1b4f41", "1b4f41"]; + this[KeyboardEvent.DOM_VK_DOWN] = ["1b4f42", "1b4f42"]; + this[KeyboardEvent.DOM_VK_END] = ["1b4f46", "1b4f46"]; + this[KeyboardEvent.DOM_VK_HOME] = ["1b4f48", "1b4f48"]; + } + else { + this[KeyboardEvent.DOM_VK_LEFT] = ["1b5b44", "1b5b44"]; + this[KeyboardEvent.DOM_VK_RIGHT] = ["1b5b43", "1b5b43"]; + this[KeyboardEvent.DOM_VK_UP] = ["1b5b41", "1b5b41"]; + this[KeyboardEvent.DOM_VK_DOWN] = ["1b5b42", "1b5b42"]; + this[KeyboardEvent.DOM_VK_END] = ["1b5b46", "1b5b46"]; + this[KeyboardEvent.DOM_VK_HOME] = ["1b5b48", "1b5b48"]; + } + }, + appKeypad: function (on) { + /* In theory, these should produce either numerals or the k[a-c][1-3] + * sequences. Since we can't count on the terminfo description actually + * containing those sequences, pretend they're just arrow keys etc. + */ + this[KeyboardEvent.DOM_VK_NUMPAD1] = ["1b4f46", "1b4f46"]; + this[KeyboardEvent.DOM_VK_NUMPAD2] = ["1b4f42", "1b4f42"]; + this[KeyboardEvent.DOM_VK_NUMPAD3] = ["1b5b367e", "1b5b367e"]; + this[KeyboardEvent.DOM_VK_NUMPAD4] = ["1b4f44", "1b4f44"]; + this[KeyboardEvent.DOM_VK_NUMPAD5] = ["1b5b45", "1b5b45"]; + this[KeyboardEvent.DOM_VK_NUMPAD6] = ["1b4f43", "1b4f43"]; + this[KeyboardEvent.DOM_VK_NUMPAD7] = ["1b4f48", "1b4f48"]; + this[KeyboardEvent.DOM_VK_NUMPAD8] = ["1b4f41", "1b4f41"]; + this[KeyboardEvent.DOM_VK_NUMPAD9] = ["1b5b357e", "1b5b357e"]; + return; + } +}; + +function sendkey(ev) { + var keynum = ev.keyCode; + var code; + if (keynum >= ev.DOM_VK_A && keynum <= ev.DOM_VK_Z) { + /* Letters. This assumes the codes are 65-90. */ + if (ev.ctrlKey) + keynum -= 64; + else if (!ev.shiftKey) + keynum += 32; + code = keynum.toString(16); + if (code.length < 2) + code = "0" + code; + } + else if (keynum >= ev.DOM_VK_0 && keynum <= ev.DOM_VK_9) { + /* The number row. */ + if (ev.shiftKey) { + code = numShifts[keynum - 48].toString(16); + } + else { + code = keynum.toString(16); + } + } + else if (keynum in keyHexCodes) { + if (ev.shiftKey) + code = keyHexCodes[keynum][1]; + else + code = keyHexCodes[keynum][0]; + } + else if (keynum == ev.DOM_VK_SHIFT || keynum == ev.DOM_VK_CONTROL || + keynum == ev.DOM_VK_ALT || keynum == ev.DOM_VK_CAPS_LOCK) { + return; + } + else { + debug(1, "Ignoring keycode " + keynum); + return; + } + if (termemu.alive) + ev.preventDefault(); + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = postResponseHandler; + datareq.open('POST', '/feed', true); + datareq.send("keys=" + code); + //dkey(code); + return; +} + +var charshifts = { '-': "5f", '=': "2b", '[': "7b", ']': "7d", '\\': "7c", + ';': "3a", '\'': "22", ',': "3c", '.': "3e", '/': "3f", '`': "7e" +} + +function vkey(c) { + var keystr; + if (c.match(/^[a-z]$/)) { + if (termemu.ctrlp()) { + var n = c.charCodeAt(0) - 96; + keystr = n.toString(16); + if (keystr.length < 2) + keystr = "0" + keystr; + } + else if (termemu.shiftp()) + keystr = c.toUpperCase().charCodeAt(0).toString(16); + else + keystr = c.charCodeAt(0).toString(16); + } + else if (c.match(/^[0-9]$/)) { + if (termemu.shiftp()) + keystr = numShifts[c.charCodeAt(0) - 48].toString(16); + else + keystr = c.charCodeAt(0).toString(16); + } + else if (c == '\n') + keystr = "0a"; + else if (c == '\t') + keystr = "09"; + else if (c == '\b') + keystr = "08"; + else if (c == ' ') + keystr = "20"; + else if (c in charshifts) { + if (termemu.shiftp()) + keystr = charshifts[c]; + else + keystr = c.charCodeAt(0).toString(16); + } + else + return; + //writeData("Sending " + keystr); + var datareq = new XMLHttpRequest(); + datareq.onreadystatechange = postResponseHandler; + datareq.open('POST', '/feed', true); + datareq.send("keys=" + keystr); + return; +} + +function setup() { + keyHexCodes.init(); + termemu.init("termwrap"); + setTitle("Not connected."); + return; +} + +function toggleshift() { + termemu.toggleshift(); + keydiv = document.getElementById("shiftkey"); + if (termemu.shiftp()) + keydiv.className = "keysel"; + else + keydiv.className = "key"; + return; +} + +function togglectrl() { + termemu.togglectrl(); + keydiv = document.getElementById("ctrlkey"); + if (termemu.ctrlp()) + keydiv.className = "keysel"; + else + keydiv.className = "key"; + return; +} + +function login() { + if (termemu.alive) + return; + var req = new XMLHttpRequest(); + req.onreadystatechange = function () { + if (req.readyState == 4 && req.status == 200) { + var datalines = req.responseText.split("\n"); + if (datalines[0] == 'l1') { + /* Success */ + termemu.alive = true; + setTitle("Logged in"); + debug(1, "Logged in with id " + datalines[1]); + getData(); + return; + } + return; + } + }; + req.open('POST', '/login', true); + req.send("login=login"); + return; +} + +function stop() { + var req = new XMLHttpRequest(); + req.onreadystatechange = function () { + if (req.readyState == 4 && req.status == 200) { + var datalines = req.responseText.split("\n"); + /* Figure out whether or not it worked. */ + termemu.alive = false; + return; + } + }; + req.open('POST', '/feed', true); + req.send("quit=quit"); + return; +} + +function setTitle(tstr) { + var titlespan = document.getElementById("ttitle"); + var tnode = document.createTextNode(tstr); + if (titlespan.childNodes.length == 0) + titlespan.appendChild(tnode); + else + titlespan.replaceChild(tnode, titlespan.childNodes[0]); + return; +} + +function debug(level, msg) { + if (level < debugSuppress) + return; + var msgdiv = document.createElement("div"); + var msgtext = document.createTextNode(msg); + msgdiv.appendChild(msgtext); + document.getElementById("debug").appendChild(msgdiv); + return; +} + +/* This should be a termemu method. */ +function textsize(larger) { + var cssSize = document.getElementById("term").style.fontSize; + if (!cssSize) + return; + var match = cssSize.match(/\d*/); + if (!match) + return; + var csize = Number(match[0]); + var nsize; + if (larger) { + if (csize >= 48) + nsize = 48; + else if (csize >= 20) + nsize = csize + 4; + else if (csize >= 12) + nsize = csize + 2; + else if (csize >= 8) + nsize = csize + 1; + else + nsize = 8; + } + else { + if (csize <= 8) + nsize = 8; + else if (csize <= 12) + nsize = csize - 1; + else if (csize <= 20) + nsize = csize - 2; + else if (csize <= 48) + nsize = csize - 4; + else + nsize = 48; + } + document.getElementById("term").style.fontSize = nsize.toString() + "px"; + termemu.resize(); + debug(1, "Changing font size to " + nsize.toString()); + return; +} + +function bell(on) { + var imgnode = document.getElementById("bell"); + if (on) { + imgnode.style.visibility = "visible"; + window.setTimeout(bell, 1500, false); + } + else + imgnode.style.visibility = "hidden"; + return; +} + +function dkey(codestr) { + var dstr = "Keystring: "; + for (var i = 0; i < codestr.length; i += 2) { + code = Number("0x" + codestr.substr(i, 2)); + if (code < 32 || (code >= 127 && code < 160)) + dstr += "\\x" + code.toString(16); + else + dstr += String.fromCharCode(code); + } + debug(1, dstr); + return; +} diff --git a/termemu.js b/termemu.js new file mode 100644 index 0000000..6600508 --- /dev/null +++ b/termemu.js @@ -0,0 +1,1105 @@ +/* termemu.js: a mostly xterm-compatible terminal emulator for a webpage */ +/* SELF-HOSTING 2011-09-23 */ + +// How detailed the debugging should be. +var debugSuppress = 1; +// Some char values. +var csiPre = [63, 62, 33]; +var csiPost = [36, 34, 39, 32]; +function csiFinal(code) { + /* @A-Z */ + if (code >= 64 && code <= 90) + return true; + /* `a-z{| */ + if (code >= 96 && code <= 124) + return true; + return false; +} +var esc7ctl = [68, 69, 72, 77, 78, 79, 80, 86, 87, 88, 90, 91, 92, 93, 94, 95]; +var escSingle = [55, 56, 61, 62, 70, 99, 108, 109, 110, 111, 124, 125, 126]; +var escDouble = [32, 35, 37, 40, 41, 42, 43, 45, 46, 47]; + +var decChars = {96: 0x2666, 97: 0x2592, 102: 0xB0, 103: 0xB1, + 106: 0x2518, 107: 0x2510, 108: 0x250C, 109: 0x2514, + 110: 0x253C, 111: 0x23BA, 112: 0x23BB, 113: 0x2500, + 114: 0x23BC, 115: 0x23BD, 116: 0x251C, 117: 0x2524, + 118: 0x2534, 119: 0x252C, 120: 0x2502, 121: 0x2264, + 122: 0x2265, 123: 0x03C0, 124: 0x2260, 125: 0xA3, 126: 0xB7}; + +/* Not everything that should be saved by DECSC has been implemented yet. */ +function Cursor(src) { + if (src) { + this.x = src.x; + this.y = src.y; + this.bold = src.bold; + this.inverse = src.inverse; + this.uline = src.uline; + this.fg = src.fg; + this.bg = src.bg; + this.cset = src.cset; + } + else { + this.x = 0; + this.y = 0; + this.bold = false; + this.inverse = false; + this.uline = false; + this.fg = null; + this.bg = null; + this.cset = "B"; + } + return; +} + +// An object representing the terminal emulator. +var termemu = { + sessid: null, // Session key assigned by the server + /* Some elements of the page. */ + inwrap: null, // A non-table div wrapping the screen + view: null, // The div holding the terminal screen + screen: null, // The div representing the active screen area + normbuf: null, // The normal screen buffer + altbuf: null, // The alternate screen buffer + histbuf: null, // The screen history buffer + fgColor: "#b2b2b2", // Default color for text + bgColor: "black", // Default background color + c: null, // Contains cursor position and text attributes + offedge: false, // Going off the edge doesn't mean adding a new line + clearAttrs: function () { + /* Make sure to reset ALL attribute properties and NOTHING else. */ + this.c.bold = false; + this.c.inverse = false; + this.c.uline = false; + this.c.fg = null; + this.c.bg = null; + }, + saved: null, // saved cursor + normc: null, // Stores the normal screen buffer cursor when using altbuf + ansicolors: ["#000000", "#b21818", "#18b218", "#b26818", "#1818b2", + "#b218b2", "#18b2b2", "#b2b2b2"], + brightcolors: ["#686868", "#ff5454", "#54ff54", "#ffff54", "#5454ff", + "#ff54ff", "#54ffff", "#ffffff"], + cssColor: function (fg) { + /* returns a CSS color specification for the text or background */ + var n; + var fallback; + var cube6 = ["00", "5f", "87", "af", "d7", "ff"]; + if (this.c.inverse) + fg = !fg; + if (fg) { + n = this.c.fg; + fallback = this.fgColor; + if (n == null) + return fallback; + } + else { + n = this.c.bg; + fallback = this.bgColor; + if (n == null) + return fallback; + } + if (n < 0) + return fallback; + else if (n < 8) { + if (this.c.bold && fg) + return this.brightcolors[n]; + else + return this.ansicolors[n]; + } + else if (n < 16) + return this.brightcolors[n - 8]; + else if (n < 232) { + var r = cube6[Math.floor((n - 16) / 36)]; + var g = cube6[Math.floor((n - 16) / 6) % 6]; + var b = cube6[(n - 16) % 6]; + return "#" + r + g + b; + } + else if (n < 256) { + var colstr = ((n - 232) * 10 + 8).toString(16); + if (colstr.length < 2) + colstr = "0" + colstr; + return "#" + colstr + colstr + colstr; + } + else + return fallback; + }, + scrT: 0, // top and bottom of scrolling region + scrB: 23, + // These keyboard-related things don't really belong here. + shift: false, + shiftp: function () { + return this.shift; + }, + toggleshift: function () { + this.shift = !this.shift; + }, + ctrl: false, + ctrlp: function () { + return this.ctrl; + }, + togglectrl: function () { + this.ctrl = !this.ctrl; + }, + init: function (divID) { + /* Makes a div full of character cells. */ + if (this.screen != null) + return; + var owrap = document.getElementById(divID); + if (!owrap) + return; + while (owrap.firstChild != null) + owrap.removeChild(owrap.firstChild); + this.c = new Cursor(null); + /* Create the contents of the terminal div */ + this.inwrap = document.createElement("div"); + this.inwrap.id = "inwrap"; + owrap.appendChild(this.inwrap); + var termdiv = document.createElement("div"); + termdiv.id = "term"; + termdiv.style.fontSize = "12px"; + this.inwrap.appendChild(termdiv); + /* Set up the screen buffers */ + this.histbuf = document.createElement("div"); + this.histbuf.id = "histbuf"; + termdiv.appendChild(this.histbuf); + this.normbuf = document.createElement("div"); + this.normbuf.id = "normbuf"; + termdiv.appendChild(this.normbuf); + for (var row = 0; row < 24; row++) { + this.normbuf.appendChild(this.makeRow()); + } + this.altbuf = document.createElement("div"); + this.altbuf.id = "altbuf"; + termdiv.appendChild(this.altbuf); + this.altbuf.style.display = "none"; + /* altbuf will be filled when it is used. */ + /* Attach them. */ + this.view = termdiv; + this.screen = this.normbuf; + this.resize(); + this.cmove(0, 0); + }, + valign: function () { + if (this.screen == this.normbuf) + this.inwrap.scrollTop = this.histbuf.clientHeight; + }, + resize: function () { + var owrap = document.getElementById("termwrap"); + /* Set the height up properly. */ + this.inwrap.style.height = this.screen.scrollHeight.toString() + "px"; + this.valign(); + // Figure out how wide the vertical scrollbar is. + var dwid = this.inwrap.offsetWidth - this.inwrap.clientWidth; + // And resize accordingly. + this.inwrap.style.width = (this.view.scrollWidth + dwid).toString() + "px"; + owrap.style.width = this.inwrap.offsetWidth.toString() + "px"; + return; + }, + comseq: [], // Part of an impending control sequence + flipCursor: function () { + /* Swaps the text and background colors of the active location. */ + /* This will change when other cursor styles are supported. */ + if (this.c.x != null && this.c.y != null) { + var oldcell = this.screen.childNodes[this.c.y].childNodes[this.c.x]; + var tempswap = oldcell.style.color; + oldcell.style.color = oldcell.style.backgroundColor; + oldcell.style.backgroundColor = tempswap; + } + return; + }, + saveCursor: function () { + this.saved = new Cursor(this.c); + return; + }, + restoreCursor: function () { + if (!this.saved) { + this.cmove(0, 0); + this.c = new Cursor(null); + } + else { + this.cmove(this.saved.y, this.saved.x); + this.c = new Cursor(this.saved); + } + return; + }, + toAltBuf: function () { + if (this.screen == this.altbuf) + return; + while (this.altbuf.firstChild != null) + this.altbuf.removeChild(this.altbuf.firstChild); + for (var i = 0; i < 24; i++) { + this.altbuf.appendChild(this.makeRow()); + } + this.normc = new Cursor(this.c); + this.altbuf.style.display = "table-row-group"; + this.normbuf.style.display = "none"; + this.histbuf.style.display = "none"; + this.screen = this.altbuf; + debug(0, "Altbuf with charset " + this.c.cset); + return; + }, + toNormBuf: function () { + if (this.screen == this.normbuf) + return; + this.altbuf.style.display = "none"; + this.normbuf.style.display = "table-row-group"; + this.histbuf.style.display = "table-row-group"; + this.screen = this.normbuf; + this.valign(); + /* The cursor isn't actually at this position in normbuf, but cmove will + * flip it anyway. Flip it again to compensate. */ + this.flipCursor(); + this.cmove(this.normc.y, this.normc.x); + this.c = new Cursor(this.normc); + }, + cmove: function (y, x) { + /* Move the cursor. NOTE coords are [row, col] as in curses. */ + /* If x or y is null, that coordinate is not altered. */ + /* Sanity checks and initializations. */ + if (x == null) { + if (this.c.x != null) + x = this.c.x; + else + return; + } + else { + this.offedge = false; + if (x < 0) + x = 0; + else if (x > 79) + x = 79; + } + if (y == null) { + if (this.c.y != null) + y = this.c.y; + else + return; + } + else if (y < 0) + y = 0; + else if (y > 23) + y = 23; + /* Un-reverse video the current location. */ + this.flipCursor(); + this.c.x = x; + this.c.y = y; + /* Reverse-video the new location. */ + this.flipCursor(); + return; + }, + historize: function (n) { + if (n < 0 || n >= this.screen.childNodes.length) + return; + var oldrow = this.screen.childNodes[n]; + if (this.screen != this.altbuf && this.scrT == 0) { + this.histbuf.appendChild(oldrow); + } + else { + this.screen.removeChild(oldrow); + } + /* These may not be the correct heights... */ + this.inwrap.style.height = this.screen.clientHeight.toString() + "px"; + this.valign(); + }, + scroll: function (lines) { + if (lines == 0) + return; + var count; + if (lines > 0) + count = lines; + else + count = -lines; + this.flipCursor(); + while (count > 0) { + var blankrow = this.makeRow(); + /* Careful with the order */ + if (lines > 0) { + if (this.scrB == 23) + this.screen.appendChild(blankrow); + else + this.screen.insertBefore(blankrow, this.screen.childNodes[this.scrB + + 1]); + this.historize(this.scrT); + } + else { + /* Historize here? */ + this.screen.removeChild(this.screen.childNodes[this.scrB]); + this.screen.insertBefore(blankrow, this.screen.childNodes[this.scrT]); + } + count--; + } + this.valign(); // needed? + this.flipCursor(); + return; + }, + newline: function (doReturn) { + if (this.c.y == this.scrB) + this.scroll(1) + else if (this.c.y < 23) + this.cmove(this.c.y + 1, null); + /* If the cursor is at the bottom but outside the scrolling region, + * nothing can be done. */ + if (doReturn) { + this.cmove(null, 0); + } + }, + antinewline: function () { + if (this.c.y == this.scrT) + this.scroll(-1); + else if (this.c.y > 0) + this.cmove(this.c.y - 1, null); + }, + advance: function () { + if (this.c.x < 79) + this.cmove(null, this.c.x + 1); + else { + this.offedge = true; + } + }, + placechar: function (str) { + if (this.offedge) { + this.newline(true); + } + var nextch = str.charAt(0); + var newcell = this.makeCell(nextch); + var rowdiv = this.screen.childNodes[this.c.y]; + rowdiv.replaceChild(newcell, rowdiv.childNodes[this.c.x]); + this.flipCursor(); // The replace removed the cursor. + /* Update the cursor. */ + this.advance(); + }, + reset: function () { + /* Reset ALL state, hopefully in the right order. */ + /* TODO test this and compare it with xterm */ + this.toNormBuf(); + this.clearAttrs(); + this.c.cset = 'B'; + this.cmove(0, 0); + this.saved = null; + this.normc = null; + this.scrT = 0; + this.scrB = 23; + while (this.histbuf.firstChild != null) { + this.histbuf.removeChild(this.histbuf.firstChild); + } + for (var i = 0; i < 24; i++) { + this.screen.replaceChild(this.makeRow(), this.screen.childNodes[i]); + } + this.flipCursor(); // make it appear in the new row + return; + }, + write: function (codes) { + //dchunk(codes); + for (var i = 0; i < codes.length; i++) { + /* First see if there's an incomplete command sequence waiting. */ + if (this.comseq.length > 0) { + if (this.comseq.length == 1 && this.comseq[0] == 27) { + /* Just ESC */ + if (codes[i] == 55) { + /* ESC 7 : save cursor */ + this.saveCursor(); + this.comseq = []; + } + else if (codes[i] == 56) { + /* ESC 8 : restore cursor */ + this.restoreCursor(); + this.comseq = []; + } + else if (codes[i] == 61) { + /* ESC = : application keypad */ + keyHexCodes.appKeypad(true); + this.comseq = []; + } + else if (codes[i] == 62) { + /* ESC > : normal keypad */ + keyHexCodes.appKeypad(false); + this.comseq = []; + } + else if (codes[i] == 68) { + /* ESC D = IND */ + this.newline(false); + this.comseq = []; + } + else if (codes[i] == 69) { + /* ESC E = NEL */ + this.newline(true); + this.comseq = []; + } + else if (codes[i] == 77) { + /* ESC M = RI */ + this.antinewline(); + this.comseq = []; + } + else if (codes[i] == 91) { + /* ESC [ = CSI */ + this.comseq[0] = 155; + } + else if (codes[i] == 93) { + /* ESC [ = OSC */ + this.comseq[0] = 157; + } + else if (codes[i] == 99) { + /* ESC c = reset */ + this.reset(); + this.comseq = []; + } + else if (escSingle.indexOf(codes[i]) >= 0) { + /* An unimplemented two-char sequence. */ + debug(1, "Unimplemented sequence ESC " + codes[i].toString(16)); + this.comseq = []; + } + else if (escDouble.indexOf(codes[i]) >= 0) { + /* A three-char sequence. */ + this.comseq.push(codes[i]); + } + else { + /* Nothing else is implemented yet. */ + debug(1, "Unrecognized sequence ESC " + codes[i].toString(16)); + this.comseq = []; + } + } + else if (this.comseq.length == 2 && this.comseq[0] == 27) { + /* An ESC C N sequence. Not implemented. Doesn't check validity + * of N. */ + if (this.comseq[1] == 40) { + if (codes[i] == 48) { + this.c.cset = "0"; + debug(0, "Switching to DEC graphics."); + } + else if (codes[i] == 66) { + this.c.cset = "B"; + debug(0, "Switching to ASCII."); + } + else { + debug(1, "Unimplemented character set: " + + String.fromCharCode(codes[i])); + } + debug(0, "cset is now: " + this.c.cset); + } + else + debug(1, "Unknown sequence ESC " + + String.fromCharCode(this.comseq[1]) + " 0x" + + codes[i].toString(16)); + this.comseq = []; + } + else if (this.comseq[0] == 157) { + /* Commands beginning with OSC */ + /* Check for string terminator */ + if (codes[i] == 7 || codes[i] == 156 || (codes[i] == 92 && + this.comseq[this.comseq.length - 1] == 27)) { + if (codes[i] == 92 && this.comseq[this.comseq.length - 1] == 27) + this.comseq.pop(); + debug(0, "Got " + (this.comseq.length - 1) + "-byte OSC sequence"); + this.oscProcess(); + this.comseq = []; + } + else + this.comseq.push(codes[i]); + } + else if (this.comseq[0] == 155) { + /* Commands starting with CSI */ + // End at first char that's not numeric ; ? > ! $ " space ' + // i.e. letter @ ` lbrace | + // ?>! must come directly after CSI + // $"'space must come directly before terminator + // FIXME put this checking code into csiProcess + if (csiPre.indexOf(codes[i]) >= 0) { + if (this.comseq.length > 1) { + /* Chars in csiPre can only occur right after the CSI */ + debug(1, "Invalid CSI sequence: misplaced prefix"); + this.comseq = []; + } + else + this.comseq.push(codes[i]); + } + else if (csiPost.indexOf(this.comseq[this.comseq.length - 1]) >= 0 && + !csiFinal(codes[i])) { + /* Chars is csiPost must come right before the final char */ + debug(1, "Invalid CSI sequence: misplaced postfix"); + this.comseq = []; + } + else if ((codes[i] >= 48 && codes[i] <= 57) || codes[i] == 59 || + csiPost.indexOf(codes[i]) >= 0) { + /* Numbers and ; can go anywhere */ + this.comseq.push(codes[i]); + } + else if (csiFinal(codes[i])) { + this.comseq.push(codes[i]); + this.csiProcess(); + this.comseq = []; + } + else { + debug(1, "Invalid CSI sequence: unknown code " + codes[i].toString(16)); + this.comseq = []; + } + } + else { + debug(1, "Unknown sequence with " + this.comseq[0].toString(16)); + this.comseq = []; + } + continue; + } + /* Treat it as a single character. */ + if (codes[i] == 5) { + sendback("06"); + } + else if (codes[i] == 7) { + /* bell */ + bell(true); + } + else if (codes[i] == 8) { + /* backspace */ + if (this.offedge) + this.offedge = false; + else if (this.c.x > 0) + this.cmove(null, this.c.x - 1); + } + else if (codes[i] == 9) { + /* tab */ + var xnew; + if (this.c.x < 79) { + xnew = 8 * (Math.floor(this.c.x / 8) + 1); + if (xnew > 79) + xnew = 79; + this.cmove(null, xnew); + } + else { + this.offedge = true; + } + } + else if (codes[i] >= 10 && codes[i] <= 12) { + /* newline, vertical tab, form feed */ + if (this.offedge) + this.newline(true); + else + this.newline(false); + } + else if (codes[i] == 13) { + /* carriage return \r */ + this.cmove(null, 0); + } + else if (codes[i] == 14) { + /* shift out */ + // Currently assuming that G1 is DEC Special & Line Drawing + this.c.cset = "0"; + debug(0, "Using DEC graphics charset."); + } + else if (codes[i] == 15) { + /* shift in */ + // Currently assuming that G0 is ASCII + this.c.cset = "B"; + debug(0, "Using ASCII charset."); + } + else if (codes[i] == 27) { + /* escape */ + this.comseq.push(codes[i]); + } + else if (codes[i] < 32 || (codes[i] >= 127 && codes[i] < 160)) { + /* Some kind of control character. */ + debug(1, "Unprintable character 0x" + codes[i].toString(16)); + } + else { + /* If it's ASCII, it's printable; take a risk on anything higher */ + if ((this.c.cset == "0") && (codes[i] in decChars)) { + // DEC special character set + this.placechar(String.fromCharCode(decChars[codes[i]])); + } + else { + this.placechar(String.fromCharCode(codes[i])); + } + } + } + return; + }, + csiProcess: function () { + /* Processes the CSI sequence in this.comseq */ + var c = this.comseq[this.comseq.length - 1]; + if (this.comseq[0] != 155 || !csiFinal(c)) + return; + var comstr = ""; + for (var i = 1; i < this.comseq.length; i++) + comstr += String.fromCharCode(this.comseq[i]); + debug(0, "CSI sequence: " + comstr); + var reCSI = /^([>?!])?([0-9;]*)([ "$'])?([A-Za-z@`{|])$/; + var matchCSI = comstr.match(reCSI); + if (!matchCSI) { + debug(1, "Unrecognized CSI sequence: " + comstr); + return; + } + var prefix = null; + if (matchCSI[1]) + prefix = matchCSI[1]; + var postfix = null; + if (matchCSI[3]) + postfix = matchCSI[3]; + var params = []; + if (matchCSI[2]) { + var numstrs = matchCSI[2].split(";"); + for (var i = 0; i < numstrs.length; i++) { + if (numstrs[i]) + params.push(Number(numstrs[i])); + else + params.push(null); + } + } + /* Useful if expecting a single parameter which is a count. */ + var count = 1; + if (params[0]) + count = params[0]; + /* The final character determines the action. */ + if (c == 64) { + /* @ - insert spaces at cursor */ + if (prefix || postfix) { + debug(1, "Invalid CSI @ sequence: " + comstr); + return; + } + /* The cursor stays still, but characters move out from under it. */ + this.flipCursor(); + var rowdiv = this.screen.childNodes[this.c.y]; + var newspace; + while (count > 0) { + newspace = this.makeCell(' '); + rowdiv.insertBefore(newspace, rowdiv.childNodes[this.c.x]); + rowdiv.removeChild(rowdiv.lastChild); + count--; + } + /* Finally, put the cursor back. */ + this.flipCursor(); + } + else if (c >= 65 && c <= 71) { + /* A - up, B - down, C - forward, D - backward */ + /* E - next line, F - previous line, G - to column */ + if (prefix || postfix) { + debug(1, "Invalid CSI sequence: " + comstr); + return; + } + /* These may be out of range, but cmove will take care of that. */ + if (c == 65) + this.cmove(this.c.y - count, null); + else if (c == 66) + this.cmove(this.c.y + count, null); + else if (c == 67) + this.cmove(null, this.c.x + count); + else if (c == 68) + this.cmove(null, this.c.x - count); + else if (c == 69) + this.cmove(this.c.y + count, 0); + else if (c == 70) + this.cmove(this.c.y - count, 0); + else if (c == 71) + this.cmove(null, count - 1); + } + else if (c == 72) { + /* H - move */ + var x = 0; + var y = 0; + if (prefix || postfix) { + debug(1, "Invalid CSI H sequence: " + comstr); + return; + } + if (params[0]) + y = params[0] - 1; + if (params[1]) + x = params[1] - 1; + if (y > 23) + y = 23; + if (x > 79) + x = 79; + debug(0, "Moving to row " + y + ", col " + x); + this.cmove(y, x); + } + else if (c == 73) { + /* I - move forward by tabs */ + var x = this.c.x; + if (prefix || postfix) { + debug(1, "Invalid CSI I sequence: " + comstr); + return; + } + while (count > 0) { + x = 8 * (Math.floor(x / 8) + 1); + if (x > 79) { + x = 79; + break; + } + count--; + } + this.cmove(null, x); + } + else if (c == 74) { + /* J - erase display */ + var start; + var end; + var cols; + if (prefix == '?') + debug(1, "Warning: CSI ?J not implemented"); + else if (prefix || postfix) { + debug(1, "Invalid CSI J sequence: " + comstr); + return; + } + if (!params[0]) { + /* Either 0 or not given */ + start = this.c.y + 1; + end = 23; + cols = 1; + } + else if (params[0] == 1) { + start = 0; + end = this.c.y - 1; + cols = -1; + } + else if (params[0] == 2) { + start = 0; + end = 23; + cols = 0; + } + else { + debug(1, "Unimplemented parameter in CSI J sequence: " + comstr); + return; + } + for (var nrow = start; nrow <= end; nrow++) { + this.screen.replaceChild(this.makeRow(), this.screen.childNodes[nrow]); + } + if (cols != 0) { + /* Otherwise, the whole screen was erased and the active row doesn't + * need special treatment. */ + var cursrow = this.screen.childNodes[this.c.y]; + for (var ncol = this.c.x; ncol >= 0 && ncol < 80; ncol += cols) { + cursrow.replaceChild(this.makeCell(' '), cursrow.childNodes[ncol]); + } + } + this.offedge = false; + /* Always flip after replacing the active position. */ + this.flipCursor(); + } + else if (c == 75) { + /* K - erase line */ + /* The ? is for an erase method that respects an "uneraseable" attribute, + * which isn't implemented, so the methods are equivalent for now. */ + var start; + var end; + if (prefix == '?') + debug(1, "Warning: CSI ?K not implemented"); + else if (prefix || postfix) { + debug(1, "Invalid CSI K sequence: " + comstr); + return; + } + /* 0 (default): right, 1: left, 2: all. Include cursor position. */ + if (params[0] == 1) { + start = 0; + end = this.c.x; + } + else if (params[0] == 2) { + start = 0; + end = 79; + } + else { + start = this.c.x; + end = 79; + } + var rowdiv = this.screen.childNodes[this.c.y]; + for (var i = start; i <= end; i++) { + rowdiv.replaceChild(this.makeCell(' '), rowdiv.childNodes[i]); + } + /* Deleting stuff tends to clear this */ + this.offedge = false; + /* The active position is always cleared, so the cursor must be made + * visible again. */ + this.flipCursor(); + } + else if (c == 76 || c == 77) { + /* L - insert lines at the current position. + * M - delete current lines */ + if (prefix || postfix) { + debug(1, "Invalid CSI sequence: " + comstr); + return; + } + /* CSI LM have no effect outside of the scrolling region */ + if (this.c.y < this.scrT || this.c.y > this.scrB) + return; + this.flipCursor(); + while (count > 0) { + var blankrow = this.makeRow(); + if (c == 76) { + this.historize(this.scrB); + this.screen.insertBefore(blankrow, this.screen.childNodes[this.c.y]); + } + else { + if (this.scrB == 23) + this.screen.appendChild(blankrow); + else + this.screen.insertBefore(blankrow, this.screen.childNodes[this.scrB + + 1]); + this.historize(this.c.y); + } + count--; + } + /* It doesn't seem necessary to reset this, but xterm does it. */ + this.offedge = false; + this.flipCursor(); + } + else if (c == 80) { + /* P - delete at active position, causing cells on the right to shift. */ + if (prefix || postfix) { + debug(1, "Invalid CSI P sequence: " + comstr); + return; + } + var cursrow = this.screen.childNodes[this.c.y]; + while (count > 0) { + cursrow.removeChild(cursrow.childNodes[this.c.x]); + cursrow.appendChild(this.makeCell(' ')); + count--; + } + this.offedge = false; + this.flipCursor(); + } + else if (c == 83 || c == 84) { + /* S - scroll up, T - scroll down */ + if (prefix || postfix) { + debug(1, "Invalid CSI sequence: " + comstr); + return; + } + if (c == 83) + this.scroll(count); + else + this.scroll(-count); + } + else if (c == 88) { + /* X - erase characters */ + if (prefix || postfix) { + debug(1, "Invalid CSI sequence: " + comstr); + return; + } + var row = this.screen.childNodes[this.c.y]; + for (var i = 0; i < count && this.c.x + i < 80; i++) { + row.replaceChild(this.makeCell(' '), row.childNodes[this.c.x + i]); + } + this.flipCursor(); + } + else if (c == 90) { + /* Z - tab backwards */ + var x = this.c.x; + if (prefix || postfix) { + debug(1, "Invalid CSI Z sequence: " + comstr); + return; + } + while (count > 0) { + x = 8 * (Math.ceil(x / 8) - 1); + if (x < 0) { + x = 0; + break; + } + count--; + } + this.cmove(null, x); + } + else if (c == 96) { + /* ` - go to col */ + if (prefix || postfix) { + debug(1, "Invalid CSI ` sequence: " + comstr); + return; + } + this.cmove(null, count - 1); + } + else if (c == 100) { + /* d - go to row */ + if (prefix || postfix) { + debug(1, "Invalid CSI d sequence: " + comstr); + return; + } + this.cmove(count - 1, null); + } + else if (c == 102) { + /* f - move */ + var x = 0; + var y = 0; + if (prefix || postfix) { + debug(1, "Invalid CSI f sequence: " + comstr); + return; + } + if (params[0]) + y = params[0] - 1; + if (params[1]) + x = params[1] - 1; + this.cmove(y, x); + } + else if (c == 104) { + /* h - set modes */ + if (prefix != '?') { + debug(1, "Unimplemented CSI sequence: " + comstr); + return; + } + for (var i = 0; i < params.length; i++) { + if (params[i] == 1) { + keyHexCodes.appCursor(true); + } + else if (params[i] == 1048) { + this.saveCursor(); + } + else if (params[i] == 1049) { + this.toAltBuf(); + } + else { + debug(1, "Unimplemented CSI ?h parameter: " + params[i]); + } + } + } + else if (c == 108) { + /* l - reset modes */ + if (prefix != '?') { + debug(1, "Unimplemented CSI sequence: " + comstr); + return; + } + for (var i = 0; i < params.length; i++) { + if (params[i] == 1) { + keyHexCodes.appCursor(false); + } + else if (params[i] == 1048) { + this.restoreCursor(); + } + else if (params[i] == 1049) { + this.toNormBuf(); + } + else { + debug(1, "Unimplemented CSI ?l parameter: " + params[i]); + } + } + } + else if (c == 109) { + /* m - character attributes */ + if (params.length == 0) + this.clearAttrs(); + for (var i = 0; i < params.length; i++) { + if (params[i] == null || params[i] == 0) + this.clearAttrs(); + else if (params[i] == 1) + this.c.bold = true; + else if (params[i] == 4) + this.c.uline = true; + else if (params[i] == 7) + this.c.inverse = true; + else if (params[i] == 22) + this.c.bold = false; + else if (params[i] == 24) + this.c.uline = false; + else if (params[i] == 27) + this.c.inverse = false; + else if (params[i] >= 30 && params[i] <= 37) + this.c.fg = params[i] - 30; + else if (params[i] == 39) + this.c.fg = null; + else if (params[i] >= 40 && params[i] <= 47) + this.c.bg = params[i] - 40; + else if (params[i] == 49) + this.c.bg = null; + else if (params[i] >= 90 && params[i] <= 97) + this.c.fg = params[i] - 82; + else if (params[i] >= 100 && params[i] <= 107) + this.c.bg = params[i] - 92; + else if (params[i] == 38 && params[i + 1] == 5) { + if (i + 2 < params.length && params[i+2] != null && params[i+2] < 256) + this.c.fg = params[i+2]; + i += 2; + } + else if (params[i] == 48 && params[i + 1] == 5) { + if (i + 2 < params.length && params[i+2] != null && params[i+2] < 256) + this.c.bg = params[i+2]; + i += 2; + } + else + debug(1, "Unimplemented CSI m parameter: " + params[i]); + } + } + else if (c == 114) { + /* r - set scrolling region */ + var t = 0; + var b = 23; + if (params[0] && params[0] <= 23) + t = params[0] - 1; + if (params[1] && params[1] <= 24) + b = params[1] - 1; + if (b <= t) + return; + this.scrT = t; + this.scrB = b; + this.cmove(0, 0); + } + else { + debug(1, "Unimplemented CSI sequence: " + comstr); + } + return; + }, + oscProcess: function () { + var numstr = ""; + var i; + for (i = 1; i < this.comseq.length; i++) { + if (this.comseq[i] >= 48 && this.comseq[i] <= 57) + numstr += String.fromCharCode(this.comseq[i]); + else + break; + } + if (this.comseq[i] != 59) { + debug(1, "Invalid OSC sequence"); + return; + } + var codenum = Number(numstr); + var msgstr = ""; + i++; + while (i < this.comseq.length) { + msgstr += String.fromCharCode(this.comseq[i]); + i++; + } + if (codenum == 0 || codenum == 2) { + setTitle(msgstr); + } + else + debug(1, "Unimplemented OSC command " + codenum + " " + msgstr); + return; + }, + makeCell: function(c) { + var tnode; + if (c && c.charAt && c.charAt(0)) + tnode = document.createTextNode(c.charAt(0)); + else + tnode = document.createTextNode(' '); + var cell = document.createElement("span"); + cell.className = "termcell"; + /* cssColor will handle reverse-video and bold->bright */ + cell.style.color = this.cssColor(true); + cell.style.backgroundColor = this.cssColor(false); + if (this.c.bold) + cell.style.fontWeight = "bold"; + if (this.c.uline) + cell.style.textDecoration = "underline"; + cell.appendChild(tnode); + return cell; + }, + makeRow: function() { + var blankrow = document.createElement("div"); + blankrow.className = "termrow"; + for (var i = 0; i < 80; i++) + blankrow.appendChild(this.makeCell(' ')); + return blankrow; + } +}; + +function setTitle(tstr) { + var titlespan = document.getElementById("ttitle"); + var tnode = document.createTextNode(tstr); + if (titlespan.childNodes.length == 0) + titlespan.appendChild(tnode); + else + titlespan.replaceChild(tnode, titlespan.childNodes[0]); + return; +} + +function dchunk(codes) { + var dstr = "Chunk: "; + for (var i = 0; i < codes.length; i++) { + if (codes[i] < 32 || (codes[i] >= 127 && codes[i] < 160)) + dstr += "\\x" + codes[i].toString(16); + else + dstr += String.fromCharCode(codes[i]); + } + debug(1, dstr); + return; +} diff --git a/tty.css b/tty.css new file mode 100644 index 0000000..6af45f3 --- /dev/null +++ b/tty.css @@ -0,0 +1,90 @@ +div#top { + font-size: 1.2em; + text-align: center; + margin: 0.2em; +} +img#bell { + visibility: hidden; + margin-left: 2em; +} +div.keyrow { + font-size: 1.2em; +} +div.keyrow > div:first-child { + clear: left; +} +div.key { + width: 2em; + height: 2em; + float: left; + border: 2px solid black; + margin: 0.2em; + text-align: center; +} +div.keysel { + width: 2em; + height: 2em; + float: left; + border: 2px solid black; + margin: 0.2em; + text-align: center; + background-color: #C0FFC0; +} +div#shiftkey { + width: 4em; +} +div#ctrlkey { + width: 3em; +} +div#spacebar { + width: 8em; + margin-left: 12em; +} +div#termwrap { + margin: 0.5em auto; + padding: 1em; + background-color: #808080; + border: 0.25em solid #202020; + border-radius: 1em; +} +div#inwrap { + overflow-y: scroll; +} +div#term { + display: table; + font-size: 12px; + font-family: monospace; + white-space: pre; +} +div#term > div { + display: table-row-group; +} +div.termrow { + display: table-row; +} +span.termcell { + display: table-cell; +} +div.rbutton { + float: right; + clear: right; + border: 2px solid black; + text-align: center; + margin: 0.2em; + font-size: 1.2em; + padding: 0.1em; +} +div.rbutton > span { + border: 1px solid black; + padding: 0 0.1em; +} +div#debug { + width: 100%; + height: 10em; + overflow: scroll; + white-space: pre; + clear: both; +} +div#debug > div { + font-family: monospace; +} diff --git a/webtty.js b/webtty.js new file mode 100755 index 0000000..cb31c1a --- /dev/null +++ b/webtty.js @@ -0,0 +1,329 @@ +#!/usr/bin/env node +var http = require('http'); +var url = require('url'); +var path = require('path'); +var fs = require('fs'); +//var tty = require("tty"); +var child_process = require("child_process"); + +var serveStaticRoot = "/home/elwin/hk/nodejs/rlg/s/"; +var sessions = {}; + +/* Constructor for TermSessions. Note that it opens the terminal and + * adds itself to the sessions dict. + */ +function TermSession(sessid) { + //var pterm = tty.open("/bin/bash"); + this.child = child_process.spawn("./ptywreck/ptyhelperC", ["bash"]); + var ss = this; + /* Eventually we'll need to make sure the sessid isn't in use yet. */ + this.sessid = sessid; + //this.ptmx = pterm[0]; + //this.child = pterm[1]; + this.alive = true; + this.data = []; + this.child.stdout.on("data", function (buf) { + ss.data.push(buf); + }); + this.child.stderr.on("data", function (buf) { + ss.data.push(buf); + }); + this.child.on("exit", function (code, signal) { + ss.exitcode = (code != null ? code : 255); + ss.alive = false; + /* Wait for all the data to get collected */ + setTimeout(ss.cleanup, 1000); + }); + this.write = function (data) { + if (this.alive) + this.child.stdin.write(data); + /* Otherwise, throw some kind of exception? */ + }; + this.read = function () { + if (this.data.length == 0) + return null; + var pos = 0; + var i = 0; + for (i = 0; i < this.data.length; i++) + pos += this.data[i].length; + var nbuf = new Buffer(pos); + var tptr; + pos = 0; + while (this.data.length > 0) { + tptr = this.data.shift(); + tptr.copy(nbuf, pos); + pos += tptr.length; + } + return nbuf; + }; + this.close = function () { + if (this.alive) + this.child.kill('SIGHUP'); + }; + this.cleanup = function () { + /* Call this when the child is dead. */ + if (this.alive) + return; + //ss.ptmx.destroy(); + /* Give the client a chance to read any leftover data. */ + if (ss.data.length > 0) + setTimeout(ss.remove, 8000); + else + ss.remove(); + }; + this.remove = function () { + delete sessions[ss.sessid]; + console.log("Session " + this.sessid + " removed."); + }; + sessions[sessid] = this; +} + +function randkey() { + rnum = Math.floor(Math.random() * 65536 * 65536); + hexstr = rnum.toString(16); + while (hexstr.length < 8) + hexstr = "0" + hexstr; + return hexstr; +} + +/* Returns a list of the cookies in the request, obviously. */ +function getCookies(req) { + cookies = []; + if ("cookie" in req.headers) { + cookstrs = req.headers["cookie"].split("; "); + for (var i = 0; i < cookstrs.length; i++) { + eqsign = cookstrs[i].indexOf("="); + if (eqsign > 0) { + name = cookstrs[i].slice(0, eqsign).toLowerCase(); + val = cookstrs[i].slice(eqsign + 1); + cookies[name] = val; + } + else if (eqsign < 0) + cookies[cookstrs[i]] = null; + } + } + return cookies; +} + +function urlDec(encstr) { + var decstr = ""; + var tnum; + for (var i = 0; i < encstr.length; i++) + { + if (encstr.charAt(i) == "+") + decstr += " "; + else if (encstr.charAt(i) == "%") + { + tnum = Number("0x" + encstr.slice(i + 1, 2)); + if (!isNaN(tnum) && tnum >= 0) + decstr += String.fromCharCode(tnum); + i += 2; + } + else + decstr += encstr.charAt(i); + } + return decstr; +} + +/* Returns the contents of a form */ +function getFormValues(formtext) { + var pairstrs = formtext.split("&"); + var data = {}; + for (var i = 0; i < pairstrs.length; i++) + { + var eqsign = pairstrs[i].indexOf("="); + if (eqsign > 0) { + rawname = pairstrs[i].slice(0, eqsign); + rawval = pairstrs[i].slice(eqsign + 1); + name = urlDec(rawname); + val = urlDec(rawval); + if (!(name in data)) + data[name] = []; + data[name].push(val); + } + } + return data; +} + +function login(req, res, formdata) { + var resheaders = {'Content-Type': 'text/plain'}; + var sessid = randkey(); + var nsession = new TermSession(sessid); + resheaders["Set-Cookie"] = "ID=" + sessid; + res.writeHead(200, resheaders); + res.write("l1\n" + sessid + "\n"); + res.end(); + console.log("Started new session with key " + sessid + ", pid " + nsession.child.pid); + return; +} + +function findTermSession(req) { + var cookies = getCookies(req); + if ("id" in cookies) { + var sessid = cookies["id"]; + if (sessid in sessions) { + return sessions[sessid]; + } + } + return null; +} + +function serveStatic(req, res, fname) { + var nname = path.normalize(fname); + if (nname == "" || nname == "/") + nname = "index.html"; + if (nname.match(/\/$/)) + path.join(nname, "index.html"); /* it was a directory */ + var realname = path.join(serveStaticRoot, nname); + var extension = path.extname(realname); + path.exists(realname, function (exists) { + var resheaders = {}; + if (!exists || !extension || extension == ".html") + resheaders["Content-Type"] = "text/html"; + else if (extension == ".png") + resheaders["Content-Type"] = "image/png"; + else if (extension == ".css") + resheaders["Content-Type"] = "text/css"; + else if (extension == ".js") + resheaders["Content-Type"] = "text/javascript"; + else if (extension == ".svg") + resheaders["Content-Type"] = "image/svg+xml"; + else + resheaders["Content-Type"] = "application/octet-stream"; + if (exists) { + /* Not nice, not sensible. First see if it's readable, then respond + * 200 or 500. Don't throw nasty errors. */ + res.writeHead(200, resheaders); + fs.readFile(realname, function (error, data) { + if (error) throw error; + res.write(data); + res.end(); + }); + } + else { + res.writeHead(404, resheaders); + res.write("" + nname + "\n

" + nname + " Not Found

\n"); + res.end(); + } + }); + return; +} + +function readFeed(res, term) { + res.writeHead(200, { "Content-Type": "text/plain" }); + if (term) { + var result = term.read(); + if (result == null) + resultstr = ""; + else + resultstr = result.toString("hex"); + res.write("d" + resultstr.length.toString() + "\n" + resultstr + "\n"); + } + else { + //console.log("Where's the term?"); + res.write("d0\n\n"); + } +} + +var errorcodes = [ "Generic Error", "Not logged in", "Invalid data" ]; + +function sendError(res, ecode) { + res.writeHead(200, { "Content-Type": "text/plain" }); + if (ecode < errorcodes.length && ecode > 0) + res.write("E" + ecode + '\n' + errorcodes[ecode] + '\n'); + else + res.write("E0\nGeneric Error\n"); + res.end(); +} + +function handler(req, res) { + /* default headers for the response */ + var resheaders = {'Content-Type': 'text/html'}; + /* The request body will be added to this as it arrives. */ + var reqbody = ""; + var formdata; + + /* Register a listener to get the body. */ + function moredata(chunk) { + reqbody += chunk; + } + req.on('data', moredata); + + /* This will send the response once the whole request is here. */ + function respond() { + var target = url.parse(req.url).pathname; + var cterm = findTermSession(req); + /* First figure out if the client is POSTing to a command interface. */ + if (req.method == 'POST') { + formdata = getFormValues(reqbody); + if (target == '/feed') { + if (!cterm) { + sendError(res, 1); + return; + } + if (formdata["quit"] == "quit") { + /* The client wants to quit. */ + // FIXME need to send a message back to the client + cterm.close(); + } + else if (formdata["keys"]) { + /* process the keys */ + hexstr = formdata["keys"][0].replace(/[^0-9a-f]/gi, ""); + if (hexstr.length % 2 != 0) { + sendError(res, 2); + return; + } + keybuf = new Buffer(hexstr, "hex"); + cterm.write(keybuf); + } + readFeed(res, cterm); + res.end(); + } + else if (target == "/login") { + login(req, res, formdata); + } + else { + res.writeHead(405, resheaders); + res.end(); + } + } + else if (req.method == 'GET' || req.method == 'HEAD') { + if (target == '/feed') { + if (!cterm) { + sendError(res, 1); + return; + } + readFeed(res, cterm); + res.end(); + } + /* Default page, create a new term */ + /* FIXME New term not created anymore, is a special case still needed? */ + else if (target == '/') { + serveStatic(req, res, "/"); + } + else /* Go look for it in the filesystem */ + serveStatic(req, res, target); + } + else { /* Some other method */ + res.writeHead(501, resheaders); + res.write("501\n

501 Not Implemented

\n"); + res.end(); + } + return; + } + req.on('end', respond); + +} + +process.on("exit", function () { + for (var sessid in sessions) { + if (sessions[sessid].alive) + sessions[sessid].child.kill('SIGHUP'); + } + console.log("Quitting..."); + return; +}); + +process.env["TERM"] = "xterm-256color"; +http.createServer(handler).listen(8080, "127.0.0.1"); +console.log('Server running at http://127.0.0.1:8080/'); diff --git a/webttyd.js b/webttyd.js new file mode 100755 index 0000000..51ea4a4 --- /dev/null +++ b/webttyd.js @@ -0,0 +1,531 @@ +#!/usr/bin/env node + +// If you can't quite trust node to find it on its own +var localModules = '/usr/local/lib/node_modules/'; +var http = require('http'); +var url = require('url'); +var path = require('path'); +var fs = require('fs'); +//var tty = require("tty"); +var child_process = require('child_process'); +var daemon = require(path.join(localModules, "daemon")); + +var chrootDir = "/var/dgl/"; +var dropToUID = 501; +var dropToGID = 501; +var serveStaticRoot = "/var/www/"; // inside the chroot +var passwdfile = "/dgldir/dgl-login"; +var sessions = {}; + +var games = { + "rogue3": { + "name": "Rogue V3", + "uname": "rogue3", + "path": "/bin/rogue3" + }, + "rogue4": { + "name": "Rogue V4", + "uname": "rogue4", + "path": "/bin/rogue4" + }, + "rogue5": { + "name": "Rogue V5", + "uname": "rogue5", + "path": "/bin/rogue5" + }, + "srogue": { + "name": "Super-Rogue", + "uname": "srogue", + "path": "/bin/srogue" + } +}; + +/* Constructor for TermSessions. Note that it opens the terminal and + * adds itself to the sessions dict. It currently assumes the user has + * been authenticated. + */ +function TermSession(game, user, files) { + /* First make sure starting the game will work. */ + if (!(game in games)) { + // TODO: throw an exception instead + return null; + } + /* This order seems to best avoid race conditions... */ + this.alive = false; + this.sessid = randkey(); + while (this.sessid in sessions) { + this.sessid = randkey(); + } + /* Grab a spot in the sessions table. */ + sessions[this.sessid] = this; + /* TODO handle tty-opening errors */ + //var pterm = tty.open(games[game].path, ["-n", user.toString()]); + /* TODO make argument-finding into a method */ + args = [games[game].path, "-n", user.toString()]; + this.child = child_process.spawn("/bin/ptyhelper", args); + var ss = this; + //this.ptmx = pterm[0]; + //this.child = pterm[1]; + this.alive = true; + this.data = []; + this.lock = files[0]; + fs.writeFile(this.lock, this.child.pid.toString() + '\n80\n24\n', "utf8"); + this.record = fs.createWriteStream(files[1], { mode: 0664 }); + /* END setup */ + function ttyrec_chunk(buf) { + var ts = new Date(); + var chunk = new Buffer(buf.length + 12); + /* TTYREC headers */ + chunk.writeUInt32LE(Math.floor(ts.getTime() / 1000), 0); + chunk.writeUInt32LE(1000 * (ts.getTime() % 1000), 4); + chunk.writeUInt32LE(buf.length, 8); + buf.copy(chunk, 12); + ss.data.push(chunk); + ss.record.write(chunk); + } + this.child.stdout.on("data", ttyrec_chunk); + this.child.stderr.on("data", ttyrec_chunk); + this.child.on("exit", function (code, signal) { + ss.exitcode = (code != null ? code : 255); + ss.alive = false; + fs.unlink(ss.lock); + /* Wait for all the data to get collected */ + setTimeout(ss.cleanup, 1000); + }); + this.write = function (data) { + if (this.alive) + this.child.stdin.write(data); + /* Otherwise, throw some kind of exception? */ + }; + this.read = function () { + if (this.data.length == 0) + return null; + var pos = 0; + var i = 0; + for (i = 0; i < this.data.length; i++) + pos += this.data[i].length - 12; + var nbuf = new Buffer(pos); + var tptr; + pos = 0; + while (this.data.length > 0) { + tptr = this.data.shift(); + tptr.copy(nbuf, pos, 12); + pos += tptr.length - 12; + } + return nbuf; + }; + this.close = function () { + if (this.alive) + this.child.kill('SIGHUP'); + }; + this.cleanup = function () { + /* Call this when the child is dead. */ + if (this.alive) + return; + //ss.ptmx.destroy(); + ss.record.end(); + /* Give the client a chance to read any leftover data. */ + if (ss.data.length > 0) + setTimeout(ss.remove, 8000); + else + ss.remove(); + }; + this.remove = function () { + delete sessions[ss.sessid]; + console.log("Session " + this.sessid + " removed."); + }; +} + +/* A few utility functions */ +function timestamp() { + dd = new Date(); + sd = dd.toISOString(); + sd = sd.slice(0, sd.indexOf(".")); + return sd.replace("T", "."); +} + +function randkey() { + rnum = Math.floor(Math.random() * 65536 * 65536); + hexstr = rnum.toString(16); + while (hexstr.length < 8) + hexstr = "0" + hexstr; + return hexstr; +} + +/* Returns a list of the cookies in the request, obviously. */ +function getCookies(req) { + cookies = []; + if ("cookie" in req.headers) { + cookstrs = req.headers["cookie"].split("; "); + for (var i = 0; i < cookstrs.length; i++) { + eqsign = cookstrs[i].indexOf("="); + if (eqsign > 0) { + name = cookstrs[i].slice(0, eqsign).toLowerCase(); + val = cookstrs[i].slice(eqsign + 1); + cookies[name] = val; + } + else if (eqsign < 0) + cookies[cookstrs[i]] = null; + } + } + return cookies; +} + +function urlDec(encstr) { + var decstr = ""; + var tnum; + for (var i = 0; i < encstr.length; i++) + { + if (encstr.charAt(i) == "+") + decstr += " "; + else if (encstr.charAt(i) == "%") + { + tnum = Number("0x" + encstr.slice(i + 1, 2)); + if (!isNaN(tnum) && tnum >= 0) + decstr += String.fromCharCode(tnum); + i += 2; + } + else + decstr += encstr.charAt(i); + } + return decstr; +} + +/* Returns the contents of a form */ +function getFormValues(formtext) { + var pairstrs = formtext.split("&"); + var data = {}; + for (var i = 0; i < pairstrs.length; i++) + { + var eqsign = pairstrs[i].indexOf("="); + if (eqsign > 0) { + rawname = pairstrs[i].slice(0, eqsign); + rawval = pairstrs[i].slice(eqsign + 1); + name = urlDec(rawname); + val = urlDec(rawval); + if (!(name in data)) + data[name] = []; + data[name].push(val); + } + } + return data; +} + +function auth(username, password) { + // Real authentication not implemented + return true; +} + +function login(req, res, formdata) { + if (!("game" in formdata)) { + sendError(res, 2, "No game specified."); + return; + } + else if (!("name" in formdata)) { + sendError(res, 2, "Username not given."); + return; + } + else if (!("pw" in formdata)) { + sendError(res, 2, "Password not given."); + return; + } + var username = formdata["name"][0]; + var password = formdata["pw"][0]; + var gname = formdata["game"][0]; + if (!(gname in games)) { + sendError(res, 2, "No such game: " + gname); + console.log("Request for nonexistant game \"" + gname + "\""); + return; + } + var progressdir = "/dgldir/inprogress-" + games[gname].uname; + + // This sets up the game once starting is approved. + function startgame() { + var ts = timestamp(); + var lockfile = path.join(progressdir, username + ":node:" + ts + ".ttyrec"); + var ttyrec = path.join("/dgldir/ttyrec", username, gname, ts + ".ttyrec"); + var nsession = new TermSession(gname, username, [lockfile, ttyrec]); + if (nsession) { + /* Technically there's a race condition for the "lock"file, but since + * it requires the user deliberately starting two games at similar times, + * it's not too serious. We can't get O_EXCL in Node anyway. */ + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.write("l1\n" + nsession.sessid + "\n"); + res.end(); + console.log("%s playing %s (key %s, pid %d)", username, gname, + nsession.sessid, nsession.child.pid); + } + else { + sendError(res, 5, "Failed to open TTY"); + console.log("Unable to allocate TTY for " + gname); + } + } + function checkit(code, signal) { + // check the password + if (code != 0) { + sendError(res, 3); + console.log("Password check failed for user " + username); + return; + } + // check for an existing game + fs.readdir(progressdir, function(err, files) { + if (!err) { + var fre = RegExp("^" + username + ":"); + for (var i = 0; i < files.length; i++) { + if (files[i].match(fre)) { + sendError(res, 4, null); + return; + } + } + } + // If progressdir isn't readable, start a new game anyway. + startgame(); + }); + } + /* Look for the user in the password file */ + fs.readFile(passwdfile, "utf8", function(err, data) { + if (err) { + sendError(res, 3); + console.log("Can't authenticate: " + err.toString()); + return; + } + var dlines = data.split('\n'); + for (var n = 0; n < dlines.length; n++) { + var fields = dlines[n].split(':'); + if (fields[0] == username) { + // check the password with the quickrypt utility + checker = require('child_process').spawn("/bin/quickrypt") + checker.on("exit", checkit); + checker.stdin.end(password + '\n' + fields[2] + '\n', "utf8"); + return; + } + } + sendError(res, 3); + console.log("Attempted login by nonexistent user " + username); + }); + return; +} + +function logout(term, res) { + if (!term.alive) { + sendError(res, 1, null); + return; + } + cterm.close(); + var resheaders = {'Content-Type': 'text/plain'}; + res.writeHead(200, resheaders); + res.write("q1\n\n"); + res.end(); + return; +} + +function findTermSession(formdata) { + if ("id" in formdata) { + var sessid = formdata["id"][0]; + if (sessid in sessions) { + return sessions[sessid]; + } + } + return null; +} + +function serveStatic(req, res, fname) { + var nname = path.normalize(fname); + if (nname == "" || nname == "/") + nname = "index.html"; + if (nname.match(/\/$/)) + path.join(nname, "index.html"); /* it was a directory */ + var realname = path.join(serveStaticRoot, nname); + var extension = path.extname(realname); + path.exists(realname, function (exists) { + var resheaders = {}; + if (!exists || !extension || extension == ".html") + resheaders["Content-Type"] = "text/html"; + else if (extension == ".png") + resheaders["Content-Type"] = "image/png"; + else if (extension == ".css") + resheaders["Content-Type"] = "text/css"; + else if (extension == ".js") + resheaders["Content-Type"] = "text/javascript"; + else if (extension == ".svg") + resheaders["Content-Type"] = "image/svg+xml"; + else + resheaders["Content-Type"] = "application/octet-stream"; + if (exists) { + fs.readFile(realname, function (error, data) { + if (error) { + res.writeHead(500, {}); + res.end(); + } + else { + res.writeHead(200, resheaders); + res.write(data); + res.end(); + } + }); + } + else { + res.writeHead(404, resheaders); + res.write("" + nname + "\n

" + nname + " Not Found

\n"); + res.end(); + } + }); + return; +} + +function readFeed(res, term) { + if (term) { + var result = term.read(); + res.writeHead(200, { "Content-Type": "text/plain" }); + if (result == null) + resultstr = ""; + else + resultstr = result.toString("hex"); + if (result == null && !term.alive) { + /* Child has terminated and data is flushed. */ + res.write("q1\n\n"); + } + else + res.write("d" + resultstr.length.toString() + "\n" + resultstr + "\n"); + res.end(); + } + else { + //console.log("Where's the term?"); + sendError(res, 1, null); + } +} + +var errorcodes = [ "Generic Error", "Not logged in", "Invalid data", + "Login failed", "Already playing", "Game launch failed" ]; + +function sendError(res, ecode, msg) { + res.writeHead(200, { "Content-Type": "text/plain" }); + if (ecode < errorcodes.length && ecode > 0) { + var emsg = errorcodes[ecode]; + if (msg) + emsg += ": " + msg; + res.write("E" + ecode + '\n' + emsg + '\n'); + } + else + res.write("E0\nGeneric Error\n"); + res.end(); +} + +function handler(req, res) { + /* default headers for the response */ + var resheaders = {'Content-Type': 'text/html'}; + /* The request body will be added to this as it arrives. */ + var reqbody = ""; + var formdata; + + /* Register a listener to get the body. */ + function moredata(chunk) { + reqbody += chunk; + } + req.on('data', moredata); + + /* This will send the response once the whole request is here. */ + function respond() { + formdata = getFormValues(reqbody); + var target = url.parse(req.url).pathname; + var cterm = findTermSession(formdata); + /* First figure out if the client is POSTing to a command interface. */ + if (req.method == 'POST') { + if (target == '/feed') { + if (!cterm) { + sendError(res, 1, null); + return; + } + if ("quit" in formdata) { + /* The client wants to terminate the process. */ + logout(cterm, res); + } + else if (formdata["keys"]) { + /* process the keys */ + hexstr = formdata["keys"][0].replace(/[^0-9a-f]/gi, ""); + if (hexstr.length % 2 != 0) { + sendError(res, 2, "incomplete byte"); + return; + } + keybuf = new Buffer(hexstr, "hex"); + cterm.write(keybuf); + } + readFeed(res, cterm); + } + else if (target == "/login") { + login(req, res, formdata); + } + else { + res.writeHead(405, resheaders); + res.end(); + } + } + else if (req.method == 'GET' || req.method == 'HEAD') { + if (target == '/feed') { + if (!cterm) { + sendError(res, 1, null); + return; + } + readFeed(res, cterm); + } + /* Default page, create a new term */ + /* FIXME New term not created anymore, is a special case still needed? */ + else if (target == '/') { + serveStatic(req, res, "/"); + } + else /* Go look for it in the filesystem */ + serveStatic(req, res, target); + } + else { /* Some other method */ + res.writeHead(501, resheaders); + res.write("501\n

501 Not Implemented

\n"); + res.end(); + } + return; + } + req.on('end', respond); + +} + +process.on("exit", function () { + for (var sessid in sessions) { + if (sessions[sessid].alive) + sessions[sessid].child.kill('SIGHUP'); + } + console.log("Quitting..."); + return; +}); + +/* Initialization STARTS HERE */ +process.env["TERM"] = "xterm-256color"; + +if (process.getuid() != 0) { + console.log("Not running as root, cannot chroot."); + process.exit(1); +} +try { + process.chdir(chrootDir); +} +catch (err) { + console.log("Cannot enter " + chrootDir + " : " + err); + process.exit(1); +} +try { + daemon.chroot(chrootDir); +} +catch (err) { + console.log("chroot to " + chrootDir + " failed: " + err); + process.exit(1); +} +try { + // drop gid first, that requires UID=0 + process.setgid(dropToGID); + process.setuid(dropToUID); +} +catch (err) { + console.log("Could not drop permissions: " + err); + process.exit(1); +} + +http.createServer(handler).listen(8080, "127.0.0.1"); +console.log('webttyd running at http://127.0.0.1:8080/');