diff options
Diffstat (limited to 'util')
-rw-r--r-- | util/CommandLineInterfacer.js | 86 | ||||
-rw-r--r-- | util/Flushable.js | 97 | ||||
-rw-r--r-- | util/TelnetInterfacer.js | 137 | ||||
-rw-r--r-- | util/ansi.js | 272 | ||||
-rw-r--r-- | util/exception.js | 7 | ||||
-rw-r--r-- | util/telchars.js | 30 | ||||
-rw-r--r-- | util/unichars.js | 17 | ||||
-rw-r--r-- | util/waitForData.js | 9 |
8 files changed, 655 insertions, 0 deletions
diff --git a/util/CommandLineInterfacer.js b/util/CommandLineInterfacer.js new file mode 100644 index 0000000..3f9d208 --- /dev/null +++ b/util/CommandLineInterfacer.js @@ -0,0 +1,86 @@ +const EventEmitter = require('events') +const waitForData = require('./waitForData') +const ansi = require('./ansi') + +module.exports = class CommandLineInterfacer extends EventEmitter { + constructor(inStream = process.stdin, outStream = process.stdout) { + super() + + this.inStream = inStream + this.outStream = outStream + + inStream.on('data', buffer => { + this.emit('inputData', buffer) + }) + + inStream.setRawMode(true) + } + + async getScreenSize() { + const waitUntil = cond => waitForData(this.inStream, cond) + + // Get old cursor position.. + this.outStream.write(ansi.requestCursorPosition()) + const { options: oldCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Move far to the bottom right of the screen, then get cursor position.. + // (We could use moveCursor here, but the 0-index offset isn't really + // relevant.) + this.outStream.write(ansi.moveCursorRaw(9999, 9999)) + this.outStream.write(ansi.requestCursorPosition()) + const { options: sizeCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Restore to old cursor position.. (Using moveCursorRaw is actaully + // necessary here, since we'll be passing the coordinates returned from + // another ANSI command.) + this.outStream.write(ansi.moveCursorRaw(oldCoords[0], oldCoords[1])) + + // And return dimensions. + const [ sizeLine, sizeCol ] = sizeCoords + return { + lines: sizeLine, cols: sizeCol, + width: sizeCol, height: sizeLine + } + } + + parseANSICommand(buffer) { + // Typically ANSI commands are written ESC[1;2;3;4C + // ..where ESC is the ANSI escape code, equal to hexadecimal 1B and + // decimal 33 + // ..where [ and ; are the literal strings "[" and ";" + // ..where 1, 2, 3, and 4 are decimal integer arguments written in ASCII + // that may last more than one byte (e.g. "15") + // ..where C is some number representing the code of the command + + if (buffer[0] !== 0x1b || buffer[1] !== 0x5b) { + throw new Error('Not an ANSI command') + } + + const options = [] + let curOption = '' + let commandCode = null + for (let val of buffer.slice(2)) { + if (48 <= val && val <= 57) { // 0124356789 + curOption = curOption.concat(val - 48) + } else { + options.push(parseInt(curOption)) + curOption = '' + + if (val !== 59) { // ; + commandCode = val + break + } + } + } + + return {code: commandCode, options: options} + } + + write(data) { + this.outStream.write(data) + } +} diff --git a/util/Flushable.js b/util/Flushable.js new file mode 100644 index 0000000..b031677 --- /dev/null +++ b/util/Flushable.js @@ -0,0 +1,97 @@ +const ansi = require('./ansi') + +module.exports = class Flushable { + // A writable that can be used to collect chunks of data before writing + // them. + + constructor(writable, shouldCompress = false) { + this.target = writable + + // Use the magical ANSI self-made compression method that probably + // doesn't *quite* work but should drastically decrease write size? + this.shouldCompress = shouldCompress + + // Update these if you plan on using the ANSI compressor! + this.screenLines = 24 + this.screenCols = 80 + + this.ended = false + this.paused = false + this.requestedFlush = false + + this.chunks = [] + } + + write(what) { + this.chunks.push(what) + } + + flush() { + // If we're paused, we don't want to write, but we will keep a note that a + // flush was requested for when we unpause. + if (this.paused) { + this.requestedFlush = true + return + } + + // Don't write if we've ended. + if (this.ended) { + return + } + + // End if the target is destroyed. + // Yes, this relies on the target having a destroyed property + // Don't worry, it'll still work if there is no destroyed property though + // (I think) + if (this.target.destroyed) { + this.end() + return + } + + let toWrite = this.chunks.join('') + + if (this.shouldCompress) { + toWrite = this.compress(toWrite) + } + + try { + this.target.write(toWrite) + } catch(err) { + console.error('Flushable write error (ending):', err.message) + this.end() + } + + this.chunks = [] + } + + pause() { + this.paused = true + } + + resume() { + this.paused = false + + if (this.requestedFlush) { + this.flush() + } + } + + end() { + this.ended = true + } + + compress(toWrite) { + // TODO: customize screen size + const screen = ansi.interpret(toWrite, this.screenLines, this.screenCols) + + /* + const pcSaved = Math.round(100 - (100 / toWrite.length * screen.length)) + console.log( + '\x1b[1A' + + `${toWrite.length} - ${screen.length} ${pcSaved}% saved ` + ) + */ + + return screen + } +} diff --git a/util/TelnetInterfacer.js b/util/TelnetInterfacer.js new file mode 100644 index 0000000..f9b1c23 --- /dev/null +++ b/util/TelnetInterfacer.js @@ -0,0 +1,137 @@ +const ansi = require('./ansi') +const waitForData = require('./waitForData') +const EventEmitter = require('events') + +module.exports = class TelnetInterfacer extends EventEmitter { + constructor(socket) { + super() + + this.socket = socket + + socket.on('data', buffer => { + if (buffer[0] === 255) { + this.handleTelnetData(buffer) + } else { + this.emit('inputData', buffer) + } + }) + + this.initTelnetOptions() + } + + initTelnetOptions() { + // Initializes various socket options, using telnet magic. + + // Disables linemode. + this.socket.write(Buffer.from([ + 255, 253, 34, // IAC DO LINEMODE + 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE + 255, 251, 1 // IAC WILL ECHO + ])) + + // Will SGA. Helps with putty apparently. + this.socket.write(Buffer.from([ + 255, 251, 3 // IAC WILL SGA + ])) + + this.socket.write(ansi.hideCursor()) + } + + cleanTelnetOptions() { + // Resets the telnet options and magic set in initTelnetOptions. + + this.socket.write(ansi.resetAttributes()) + this.socket.write(ansi.showCursor()) + } + + async getScreenSize() { + this.socket.write(Buffer.from([255, 253, 31])) // IAC DO NAWS + + let didWillNAWS = false + let didSBNAWS = false + let sb + + inputLoop: while (true) { + const data = await waitForData(this.socket) + + for (let command of this.parseTelnetCommands(data)) { + // WILL NAWS + if (command[1] === 251 && command[2] === 31) { + didWillNAWS = true + continue + } + + // SB NAWS + if (didWillNAWS && command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sb = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + break inputLoop + } + } + } + + return this.parseSBNAWS(sb) + } + + parseTelnetCommands(buffer) { + if (buffer[0] === 255) { + // Telnet IAC (Is A Command) - ignore + + // Split the data into multiple IAC commands if more than one IAC was + // sent. + const values = Array.from(buffer.values()) + const commands = [] + const curCmd = [255] + for (let value of values) { + if (value === 255) { // IAC + commands.push(Array.from(curCmd)) + curCmd.splice(1, curCmd.length) + continue + } + curCmd.push(value) + } + commands.push(curCmd) + + return commands + } else { + return [] + } + } + + write(data) { + this.socket.write(data) + } + + handleTelnetData(buffer) { + let didSBNAWS = false + let sbNAWS + + for (let command of this.parseTelnetCommands(buffer)) { + // SB NAWS + if (command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sbNAWS = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + didSBNAWS = false + this.emit('screenSizeUpdated', this.parseSBNAWS(sbNAWS)) + continue + } + } + } + + parseSBNAWS(sb) { + const cols = (sb[0] << 8) + sb[1] + const lines = (sb[2] << 8) + sb[3] + + return { cols, lines, width: cols, height: lines } + } +} diff --git a/util/ansi.js b/util/ansi.js new file mode 100644 index 0000000..0e6e3fe --- /dev/null +++ b/util/ansi.js @@ -0,0 +1,272 @@ +const ESC = '\x1b' + +const isDigit = char => '0123456789'.indexOf(char) >= 0 + +const ansi = { + ESC, + + // Attributes + A_RESET: 0, + A_BRIGHT: 1, + A_DIM: 2, + A_INVERT: 7, + C_BLACK: 30, + C_RED: 31, + C_GREEN: 32, + C_YELLOW: 33, + C_BLUE: 34, + C_MAGENTA: 35, + C_CYAN: 36, + C_WHITE: 37, + C_RESET: 39, + + clearScreen() { + // Clears the screen, removing any characters displayed, and resets the + // cursor position. + + return `${ESC}[2J` + }, + + moveCursorRaw(line, col) { + // Moves the cursor to the given line and column on the screen. + // Returns the pure ANSI code, with no modification to line or col. + + return `${ESC}[${line};${col}H` + }, + + moveCursor(line, col) { + // Moves the cursor to the given line and column on the screen. + // Note that since in JavaScript indexes start at 0, but in ANSI codes + // the top left of the screen is (1, 1), this function adjusts the + // arguments to act as if the top left of the screen is (0, 0). + + return `${ESC}[${line + 1};${col + 1}H` + }, + + hideCursor() { + // Makes the cursor invisible. + + return `${ESC}[?25l` + }, + + showCursor() { + // Makes the cursor visible. + + return `${ESC}[?25h` + }, + + resetAttributes() { + // Resets all attributes, including text decorations, foreground and + // background color. + + return `${ESC}[0m` + }, + + setAttributes(attrs) { + // Set some raw attributes. See the attributes section of the ansi.js + // source code for attributes that can be used with this; A_RESET resets + // all attributes. + + return `${ESC}[${attrs.join(';')}m` + }, + + setForeground(color) { + // Sets the foreground color to print text with. See C_(COLOR) for colors + // that can be used with this; C_RESET resets the foreground. + // + // If null or undefined is passed, this function will return a blank + // string (no ANSI escape codes). + + if (typeof color === 'undefined' || color === null) { + return '' + } + + return ansi.setAttributes([color]) + }, + + invert() { + // Inverts the foreground and background colors. + + return `${ESC}[7m` + }, + + requestCursorPosition() { + // Requests the position of the cursor. + // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based), + // c is the column number (also 1-based), and R is the literal character + // 'R' (decimal code 82). + + return `${ESC}[6n` + }, + + isANSICommand(buffer, code = null) { + return ( + buffer[0] === 0x1b && buffer[1] === 0x5b && + (code ? buffer[buffer.length - 1] === code : true) + ) + }, + + + interpret(text, scrRows, scrCols) { + // Interprets the given ansi code, more or less. + + const blank = { + attributes: [], + char: ' ' + } + + const chars = new Array(scrRows * scrCols).fill(blank) + + let cursorRow = 1 + let cursorCol = 1 + const attributes = [] + const getCursorIndex = () => (cursorRow - 1) * scrCols + (cursorCol - 1) + + for (let charI = 0; charI < text.length; charI++) { + if (text[charI] === ESC) { + charI++ + + if (text[charI] !== '[') { + throw new Error('ESC not followed by [') + } + + charI++ + + const args = [] + let val = '' + while (isDigit(text[charI])) { + val += text[charI] + charI++ + + if (text[charI] === ';') { + charI++ + args.push(val) + val = '' + continue + } + } + args.push(val) + + // CUP - Cursor Position (moveCursor) + if (text[charI] === 'H') { + cursorRow = args[0] + cursorCol = args[1] + } + + // ED - Erase Display (clearScreen) + if (text[charI] === 'J') { + // ESC[2J - erase whole display + if (args[0] === '2') { + chars.fill(blank) + charI += 3 + cursorCol = 1 + cursorRow = 1 + } + + // ESC[1J - erase to beginning + else if (args[0] === '1') { + for (let i = 0; i < getCursorIndex(); i++) { + chars[i] = blank + } + } + + // ESC[0J - erase to end + else if (args.length === 0 || args[0] === '0') { + for (let i = getCursorIndex(); i < chars.length; i++) { + chars[i] = blank + } + } + } + + // SGR - Select Graphic Rendition + if (text[charI] === 'm') { + for (let arg of args) { + if (arg === '0') { + attributes.splice(0, attributes.length) + } else { + attributes.push(arg) + } + } + } + + continue + } + + // debug + /* + if (text[charI] === '.') { + console.log( + `#1-char "${text[charI]}" at ` + + `(${cursorRow},${cursorCol}):${getCursorIndex()} ` + + ` attr:[${attributes.join(';')}]` + ) + } + */ + + chars[getCursorIndex()] = { + char: text[charI], + attributes: attributes.slice() + } + + cursorCol++ + + if (cursorCol > scrCols) { + cursorCol = 1 + cursorRow++ + } + } + + // Character concatenation ----------- + + // Move to the top left of the screen initially. + const result = [ ansi.moveCursorRaw(1, 1) ] + + let lastChar = { + char: '', + attributes: [] + } + + //let n = 1 // debug + + for (let char of chars) { + const newAttributes = ( + char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) + ) + + const removedAttributes = ( + lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) + ) + + // The only way to practically remove any character attribute is to + // reset all of its attributes and then re-add its existing attributes. + // If we do that, there's no need to add new attributes. + if (removedAttributes.length) { + // console.log( + // `removed some attributes "${char.char}"`, removedAttributes + // ) + result.push(ansi.resetAttributes()) + result.push(`${ESC}[${char.attributes.join(';')}m`) + } else if (newAttributes.length) { + result.push(`${ESC}[${newAttributes.join(';')}m`) + } + + // debug + /* + if (char.char !== ' ') { + console.log( + `#2-char ${char.char}; ${chars.indexOf(char) - n} inbetween` + ) + n = chars.indexOf(char) + } + */ + + result.push(char.char) + + lastChar = char + } + + return result.join('') + } +} + +module.exports = ansi diff --git a/util/exception.js b/util/exception.js new file mode 100644 index 0000000..e88ff99 --- /dev/null +++ b/util/exception.js @@ -0,0 +1,7 @@ +module.exports = function exception(code, message) { + // Makes a custom error with the given code and message. + + const err = new Error(`${code}: ${message}`) + err.code = code + return err +} diff --git a/util/telchars.js b/util/telchars.js new file mode 100644 index 0000000..8cf414c --- /dev/null +++ b/util/telchars.js @@ -0,0 +1,30 @@ +// Useful telnet key detection. + +const telchars = { + isSpace: buf => buf[0] === 0x20, + isEnter: buf => buf[0] === 0x0d && buf[1] === 0x00, + isTab: buf => buf[0] === 0x09, + isBackTab: buf => buf[0] === 0x1b && buf[2] === 0x5A, + + // isEscape is hard because it's just send as ESC (the ANSI escape code), + // so we need to make sure that the escape code is all on its own + // (i.e. the length is 1) + isEscape: buf => buf[0] === 0x1b && buf.length === 1, + + // Use this for when you'd like to detect the user confirming or issuing a + // command, like the X button on your PlayStation controller, or the mouse + // when you click on a button. + isSelect: buf => telchars.isSpace(buf) || telchars.isEnter(buf), + + // Use this for when you'd like to detect the user cancelling an action, + // like the O button on your PlayStation controller, or the Escape key on + // your keyboard. + isCancel: buf => telchars.isEscape(buf), + + isUp: buf => buf[0] === 0x1b && buf[2] === 0x41, + isDown: buf => buf[0] === 0x1b && buf[2] === 0x42, + isRight: buf => buf[0] === 0x1b && buf[2] === 0x43, + isLeft: buf => buf[0] === 0x1b && buf[2] === 0x44, +} + +module.exports = telchars diff --git a/util/unichars.js b/util/unichars.js new file mode 100644 index 0000000..d685890 --- /dev/null +++ b/util/unichars.js @@ -0,0 +1,17 @@ +// Useful Unicode characters. + +module.exports = { + /* … */ ELLIPSIS: '\u2026', + + /* ─ */ BOX_H: '\u2500', + /* ━ */ BOX_H_THICK: '\u2501', + /* ═ */ BOX_H_DOUBLE: '\u2550', + /* │ */ BOX_V: '\u2502', + /* ┃ */ BOX_V_THICK: '\u2503', + /* ║ */ BOX_V_DOUBLE: '\u2551', + + /* ┐ */ BOX_CORNER_TR: '\u2510', + /* └ */ BOX_CORNER_BL: '\u2514', + /* ┘ */ BOX_CORNER_BR: '\u2518', + /* ┌ */ BOX_CORNER_TL: '\u250C' +} diff --git a/util/waitForData.js b/util/waitForData.js new file mode 100644 index 0000000..bf40c52 --- /dev/null +++ b/util/waitForData.js @@ -0,0 +1,9 @@ +module.exports = function waitForData(stream, cond = null) { + return new Promise(resolve => { + stream.on('data', data => { + if (cond ? cond(data) : true) { + resolve(data) + } + }) + }) +} |