diff options
Diffstat (limited to 'util/interfaces')
-rw-r--r-- | util/interfaces/CommandLineInterface.js | 92 | ||||
-rw-r--r-- | util/interfaces/Flushable.js | 126 | ||||
-rw-r--r-- | util/interfaces/TelnetInterface.js | 139 | ||||
-rw-r--r-- | util/interfaces/index.js | 4 |
4 files changed, 361 insertions, 0 deletions
diff --git a/util/interfaces/CommandLineInterface.js b/util/interfaces/CommandLineInterface.js new file mode 100644 index 0000000..66c8c43 --- /dev/null +++ b/util/interfaces/CommandLineInterface.js @@ -0,0 +1,92 @@ +import EventEmitter from 'node:events' + +import * as ansi from '../ansi.js' +import waitForData from '../waitForData.js' + +export default class CommandLineInterface extends EventEmitter { + constructor(inStream = process.stdin, outStream = process.stdout, proc = process) { + super() + + this.inStream = inStream + this.outStream = outStream + this.process = proc + + inStream.on('data', buffer => { + this.emit('inputData', buffer) + }) + + inStream.setRawMode(true) + + proc.on('SIGWINCH', async buffer => { + this.emit('resize', await this.getScreenSize()) + }) + } + + 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 (const 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/interfaces/Flushable.js b/util/interfaces/Flushable.js new file mode 100644 index 0000000..d8b72d3 --- /dev/null +++ b/util/interfaces/Flushable.js @@ -0,0 +1,126 @@ +import * as ansi from '../ansi.js' +import unic from '../unichars.js' + +export default 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 + + // Whether or not to show compression statistics (original written size + // and ANSI-interpreted compressed size) in the output of flush. + this.shouldShowCompressionStatistics = false + + // Use resizeScreen if you plan on using the ANSI compressor! + this.screenLines = 24 + this.screenCols = 80 + this.lastFrame = undefined + + this.ended = false + this.paused = false + this.requestedFlush = false + + this.chunks = [] + } + + resizeScreen({lines, cols}) { + this.screenLines = lines + this.screenCols = cols + this.clearLastFrame() + } + + clearLastFrame() { + this.lastFrame = undefined + } + + 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 output = ansi.interpret( + toWrite, this.screenLines, this.screenCols, this.lastFrame + ) + + let { screen } = output + + this.lastFrame = output + + if (this.shouldShowCompressionStatistics) { + let msg = this.lastInterpretMessage + if (screen.length > 0 || !this.lastInterpretMessage) { + const pcSaved = Math.round(1000 - (1000 / toWrite.length * screen.length)) / 10 + const kbSaved = Math.round((toWrite.length - screen.length) / 100) / 10 + msg = this.lastInterpretMessage = ( + '(ANSI-interpret: ' + + `${toWrite.length} -> ${screen.length} ${pcSaved}% / ${kbSaved} KB saved)` + ) + } + screen += '\x1b[H\x1b[0m' + screen += msg + unic.BOX_H_DOUBLE.repeat(this.screenCols - msg.length) + this.lastFrame.oldLastChar.attributes = [] + } + + return screen + } +} diff --git a/util/interfaces/TelnetInterface.js b/util/interfaces/TelnetInterface.js new file mode 100644 index 0000000..8777680 --- /dev/null +++ b/util/interfaces/TelnetInterface.js @@ -0,0 +1,139 @@ +import EventEmitter from 'node:events' + +import * as ansi from '../ansi.js' +import waitForData from '../waitForData.js' + +export default class TelnetInterface 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 (const 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 (const 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)) + this.emit('resize', 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/interfaces/index.js b/util/interfaces/index.js new file mode 100644 index 0000000..83aeb2c --- /dev/null +++ b/util/interfaces/index.js @@ -0,0 +1,4 @@ +export {default as Flushable} from './Flushable.js' + +export {default as CommandLineInterface} from './CommandLineInterface.js' +export {default as TelnetInterface} from './TelnetInterface.js' |