diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e69de29b diff --git a/gulpfile.js b/gulpfile.js index 7e852cf2..13a3ddac 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -139,7 +139,7 @@ gulp.task('browserify-addons', ['tsc'], function() { `${outDir}/addons/**/*`, // Exclude TS addons from copy as they are being built via browserify `!${outDir}/addons/search`, - `!${outDir}/addons/search/**` + `!${outDir}/addons/search/**`, ]).pipe(gulp.dest(`${buildDir}/addons`)); return merge(searchBundle, winptyCompatBundle, copyAddons); diff --git a/package.json b/package.json index 8d337fc8..6cdebd90 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "glob": "^7.0.5", "gulp": "3.9.1", "gulp-cli": "^1.2.2", + "gulp-concat": "^2.6.1", "gulp-coveralls": "^0.1.4", "gulp-istanbul": "^1.1.1", "gulp-mocha": "^3.0.1", @@ -66,11 +67,14 @@ "tslint": "^4.0.2", "typescript": "~2.4.0", "vinyl-buffer": "^1.0.0", - "vinyl-source-stream": "^1.1.0" + "vinyl-source-stream": "^1.1.0", + "zmodem.js": "^0.1.5" }, "scripts": { "prestart": "npm run build", "start": "node demo/app", + "prestart-zmodem": "npm run build", + "start-zmodem": "node build/addons/zmodem/demo/app", "dev": "nodemon -e js,ts,css --watch src --watch demo --exec npm start", "lint": "tslint src/*.ts src/**/*.ts", "test": "gulp test", @@ -78,5 +82,6 @@ "build": "gulp build", "prepublish": "npm run build", "coveralls": "gulp coveralls" - } + }, + "dependencies": {} } diff --git a/src/addons/attach/attach.js b/src/addons/attach/attach.js index 997d1097..712d911d 100644 --- a/src/addons/attach/attach.js +++ b/src/addons/attach/attach.js @@ -56,11 +56,27 @@ } }; + var myTextDecoder; + term._getMessage = function (ev) { + var str; + if (typeof ev.data === "object") { + if (ev.data instanceof ArrayBuffer) { + if (!myTextDecoder) { + myTextDecoder = new TextDecoder(); + } + + str = myTextDecoder.decode( ev.data ); + } + else { + throw "TODO: handle Blob?"; + } + } + if (buffered) { - term._pushToBuffer(ev.data); + term._pushToBuffer(str || ev.data); } else { - term.write(ev.data); + term.write(str || ev.data); } }; diff --git a/src/addons/zmodem/demo/app.js b/src/addons/zmodem/demo/app.js new file mode 100644 index 00000000..31ecd7d2 --- /dev/null +++ b/src/addons/zmodem/demo/app.js @@ -0,0 +1,87 @@ +var express = require('express'); +var app = express(); +var expressWs = require('express-ws')(app); +var os = require('os'); +var pty = require('node-pty'); + +var terminals = {}, + logs = {}; + +app.use('/build', express.static(__dirname + '/../../../../build')); +app.use('/demo', express.static(__dirname + '/../../../../demo')); +app.use('/zmodemjs', express.static(__dirname + '/../../../../node_modules/zmodem.js/dist')); + +app.get('/', function(req, res){ + res.sendFile(__dirname + '/index.html'); +}); + +app.get('/style.css', function(req, res){ + res.sendFile(__dirname + '/style.css'); +}); + +app.get('/main.js', function(req, res){ + res.sendFile(__dirname + '/main.js'); +}); + +app.post('/terminals', function (req, res) { + var cols = parseInt(req.query.cols), + rows = parseInt(req.query.rows), + term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : 'bash', [], { + encoding: null, + name: 'xterm-color', + cols: cols || 80, + rows: rows || 24, + cwd: process.env.PWD, + env: process.env + }); + + console.log('Created terminal with PID: ' + term.pid); + terminals[term.pid] = term; + logs[term.pid] = ''; + term.on('data', function(data) { + logs[term.pid] += data; + }); + res.send(term.pid.toString()); + res.end(); +}); + +app.post('/terminals/:pid/size', function (req, res) { + var pid = parseInt(req.params.pid), + cols = parseInt(req.query.cols), + rows = parseInt(req.query.rows), + term = terminals[pid]; + + term.resize(cols, rows); + console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); + res.end(); +}); + +app.ws('/terminals/:pid', function (ws, req) { + var term = terminals[parseInt(req.params.pid)]; + console.log('Connected to terminal ' + term.pid); + ws.send(logs[term.pid]); + + term.on('data', function(data) { + try { + ws.send(data); + } catch (ex) { + // The WebSocket is not open, ignore + } + }); + ws.on('message', function(msg) { + term.write(msg); + }); + ws.on('close', function () { + term.kill(); + console.log('Closed terminal ' + term.pid); + // Clean things up + delete terminals[term.pid]; + delete logs[term.pid]; + }); +}); + +var port = process.env.PORT || 3000, + host = os.platform() === 'win32' ? '127.0.0.1' : '0.0.0.0'; + +console.log('App listening to http://' + host + ':' + port); +app.listen(port, host); diff --git a/src/addons/zmodem/demo/index.html b/src/addons/zmodem/demo/index.html new file mode 100644 index 00000000..aee7742a --- /dev/null +++ b/src/addons/zmodem/demo/index.html @@ -0,0 +1,128 @@ + + + + xterm.js demo + + + + + + + + + + + + + + + + +

xterm.js: xterm, in the browser

+ +
+ +
+ + + + + + + + + +
+ +
+

Actions

+

+ + +

+
+
+

Options

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+
+

Size

+
+
+ + +
+
+ + +
+
+
+
+

Attention: The demo is a barebones implementation and is designed for xterm.js evaluation purposes only. Exposing the demo to the public as is would introduce security risks for the host.

+

* ZMODEM file transfers are supported via an addon. To try it out, install lrzsz onto the remote peer, then run rz to send from your browser or sz <file> to send from the remote peer.

+ + + diff --git a/src/addons/zmodem/demo/main.js b/src/addons/zmodem/demo/main.js new file mode 100644 index 00000000..2a153fb5 --- /dev/null +++ b/src/addons/zmodem/demo/main.js @@ -0,0 +1,383 @@ +"use strict"; + +var term, + protocol, + socketURL, + socket, + pid; + +var terminalContainer = document.getElementById('terminal-container'), + actionElements = { + findNext: document.querySelector('#find-next'), + findPrevious: document.querySelector('#find-previous') + }, + optionElements = { + cursorBlink: document.querySelector('#option-cursor-blink'), + cursorStyle: document.querySelector('#option-cursor-style'), + scrollback: document.querySelector('#option-scrollback'), + tabstopwidth: document.querySelector('#option-tabstopwidth'), + bellStyle: document.querySelector('#option-bell-style') + }, + colsElement = document.getElementById('cols'), + rowsElement = document.getElementById('rows'); + +function setTerminalSize() { + var cols = parseInt(colsElement.value, 10); + var rows = parseInt(rowsElement.value, 10); + var viewportElement = document.querySelector('.xterm-viewport'); + var scrollBarWidth = viewportElement.offsetWidth - viewportElement.clientWidth; + var width = (cols * term.charMeasure.width + 20 /*room for scrollbar*/).toString() + 'px'; + var height = (rows * term.charMeasure.height).toString() + 'px'; + + terminalContainer.style.width = width; + terminalContainer.style.height = height; + term.resize(cols, rows); +} + +colsElement.addEventListener('change', setTerminalSize); +rowsElement.addEventListener('change', setTerminalSize); + +actionElements.findNext.addEventListener('keypress', function (e) { + if (e.key === "Enter") { + e.preventDefault(); + term.findNext(actionElements.findNext.value); + } +}); +actionElements.findPrevious.addEventListener('keypress', function (e) { + if (e.key === "Enter") { + e.preventDefault(); + term.findPrevious(actionElements.findPrevious.value); + } +}); + +optionElements.cursorBlink.addEventListener('change', function () { + term.setOption('cursorBlink', optionElements.cursorBlink.checked); +}); +optionElements.cursorStyle.addEventListener('change', function () { + term.setOption('cursorStyle', optionElements.cursorStyle.value); +}); +optionElements.bellStyle.addEventListener('change', function () { + term.setOption('bellStyle', optionElements.bellStyle.value); +}); +optionElements.scrollback.addEventListener('change', function () { + term.setOption('scrollback', parseInt(optionElements.scrollback.value, 10)); +}); +optionElements.tabstopwidth.addEventListener('change', function () { + term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10)); +}); + +createTerminal(); + +function createTerminal() { + // Clean terminal + while (terminalContainer.children.length) { + terminalContainer.removeChild(terminalContainer.children[0]); + } + term = new Terminal({ + cursorBlink: optionElements.cursorBlink.checked, + scrollback: parseInt(optionElements.scrollback.value, 10), + tabStopWidth: parseInt(optionElements.tabstopwidth.value, 10) + }); + term.on('resize', function (size) { + if (!pid) { + return; + } + var cols = size.cols, + rows = size.rows, + url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows; + + fetch(url, {method: 'POST'}); + }); + protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'; + socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/terminals/'; + + term.open(terminalContainer); + term.fit(); + + // fit is called within a setTimeout, cols and rows need this. + setTimeout(function () { + colsElement.value = term.cols; + rowsElement.value = term.rows; + + // Set terminal size again to set the specific dimensions on the demo + setTerminalSize(); + + fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, {method: 'POST'}).then(function (res) { + + res.text().then(function (pid) { + window.pid = pid; + socketURL += pid; + socket = new WebSocket(socketURL); + socket.onopen = runRealTerminal; + socket.onclose = runFakeTerminal; + socket.onerror = runFakeTerminal; + + term.zmodemAttach(socket, { + noTerminalWriteOutsideSession: true, + } ); + + term.on("zmodemRetract", () => { + start_form.style.display = "none"; + start_form.onsubmit = null; + }); + + term.on("zmodemDetect", (detection) => { + function do_zmodem() { + term.detach(); + let zsession = detection.confirm(); + + var promise; + + if (zsession.type === "receive") { + promise = _handle_receive_session(zsession); + } + else { + promise = _handle_send_session(zsession); + } + + promise.catch( console.error.bind(console) ).then( () => { + term.attach(socket); + } ); + } + + if (_auto_zmodem()) { + do_zmodem(); + } + else { + start_form.style.display = ""; + start_form.onsubmit = function(e) { + start_form.style.display = "none"; + + if (document.getElementById("zmstart_yes").checked) { + do_zmodem(); + } + else { + detection.deny(); + } + }; + } + }); + }); + }); + }, 0); +} + +//---------------------------------------------------------------------- +// UI STUFF + +function _show_file_info(xfer) { + var file_info = xfer.get_details(); + + document.getElementById("name").textContent = file_info.name; + document.getElementById("size").textContent = file_info.size; + document.getElementById("mtime").textContent = file_info.mtime; + document.getElementById("files_remaining").textContent = file_info.files_remaining; + document.getElementById("bytes_remaining").textContent = file_info.bytes_remaining; + + document.getElementById("mode").textContent = "0" + file_info.mode.toString(8); + + var xfer_opts = xfer.get_options(); + ["conversion", "management", "transport", "sparse"].forEach( (lbl) => { + document.getElementById(`zfile_${lbl}`).textContent = xfer_opts[lbl]; + } ); + + document.getElementById("zm_file").style.display = ""; +} +function _hide_file_info() { + document.getElementById("zm_file").style.display = "none"; +} + +function _save_to_disk(xfer, buffer) { + return Zmodem.Browser.save_to_disk(buffer, xfer.get_details().name); +} + +var skipper_button = document.getElementById("zm_progress_skipper"); +var skipper_button_orig_text = skipper_button.textContent; + +function _show_progress() { + skipper_button.disabled = false; + skipper_button.textContent = skipper_button_orig_text; + + document.getElementById("bytes_received").textContent = 0; + document.getElementById("percent_received").textContent = 0; + + document.getElementById("zm_progress").style.display = ""; +} + +function _update_progress(xfer) { + var total_in = xfer.get_offset(); + + document.getElementById("bytes_received").textContent = total_in; + + var percent_received = 100 * total_in / xfer.get_details().size; + document.getElementById("percent_received").textContent = percent_received.toFixed(2); +} + +function _hide_progress() { + document.getElementById("zm_progress").style.display = "none"; +} + +var start_form = document.getElementById("zm_start"); + +function _auto_zmodem() { + return document.getElementById("zmodem-auto").checked; +} + +// END UI STUFF +//---------------------------------------------------------------------- + +function _handle_receive_session(zsession) { + zsession.on("offer", function(xfer) { + current_receive_xfer = xfer; + + _show_file_info(xfer); + + var offer_form = document.getElementById("zm_offer"); + + function on_form_submit() { + offer_form.style.display = "none"; + + //START + //if (offer_form.zmaccept.value) { + if (_auto_zmodem() || document.getElementById("zmaccept_yes").checked) { + _show_progress(); + + var FILE_BUFFER = []; + xfer.on("input", (payload) => { + _update_progress(xfer); + FILE_BUFFER.push( new Uint8Array(payload) ); + }); + xfer.accept().then( + () => { + _save_to_disk(xfer, FILE_BUFFER); + }, + console.error.bind(console) + ); + } + else { + xfer.skip(); + } + //END + } + + if (_auto_zmodem()) { + on_form_submit(); + } + else { + offer_form.onsubmit = on_form_submit; + offer_form.style.display = ""; + } + } ); + + var promise = new Promise( (res) => { + zsession.on("session_end", () => { + _hide_file_info(); + _hide_progress(); + res(); + } ); + } ); + + zsession.start(); + + return promise; +} + +function _handle_send_session(zsession) { + var choose_form = document.getElementById("zm_choose"); + choose_form.style.display = ""; + + var file_el = document.getElementById("zm_files"); + + var promise = new Promise( (res) => { + file_el.onchange = function(e) { + choose_form.style.display = "none"; + + var files_obj = file_el.files; + + Zmodem.Browser.send_files( + zsession, + files_obj, + { + on_offer_response(obj, xfer) { + if (xfer) _show_progress(); + //console.log("offer", xfer ? "accepted" : "skipped"); + }, + on_progress(obj, xfer) { + _update_progress(xfer); + }, + on_file_complete(obj) { + //console.log("COMPLETE", obj); + _hide_progress(); + }, + } + ).then(_hide_progress).then( + zsession.close.bind(zsession), + console.error.bind(console) + ).then( () => { + _hide_file_info(); + _hide_progress(); + res(); + } ); + }; + } ); + + return promise; +} + +//This is here to allow canceling of an in-progress ZMODEM transfer. +var current_receive_xfer; + +//Called from HTML directly. +function skip_current_file() { + current_receive_xfer.skip(); + + skipper_button.disabled = true; + skipper_button.textContent = "Waiting for server to acknowledge skip …"; +} + +function runRealTerminal() { + term.attach(socket); + + term._initialized = true; +} + +function runFakeTerminal() { + if (term._initialized) { + return; + } + + term._initialized = true; + + var shellprompt = '$ '; + + term.prompt = function () { + term.write('\r\n' + shellprompt); + }; + + term.writeln('Welcome to xterm.js'); + term.writeln('This is a local terminal emulation, without a real terminal in the back-end.'); + term.writeln('Type some keys and commands to play around.'); + term.writeln(''); + term.prompt(); + + term.on('key', function (key, ev) { + var printable = ( + !ev.altKey && !ev.altGraphKey && !ev.ctrlKey && !ev.metaKey + ); + + if (ev.keyCode == 13) { + term.prompt(); + } else if (ev.keyCode == 8) { + // Do not delete the prompt + if (term.x > 2) { + term.write('\b \b'); + } + } else if (printable) { + term.write(key); + } + }); + + term.on('paste', function (data, ev) { + term.write(data); + }); +} diff --git a/src/addons/zmodem/zmodem.js b/src/addons/zmodem/zmodem.js new file mode 100644 index 00000000..1bab2976 --- /dev/null +++ b/src/addons/zmodem/zmodem.js @@ -0,0 +1,108 @@ +/** + * + * Allow xterm.js to handle ZMODEM uploads and downloads. + * + * This addon is a wrapper around zmodem.js. It adds the following to the + * Terminal class: + * + * - function `zmodemAttach(, )` - creates a Zmodem.Sentry + * on the passed WebSocket object. The Object passed is optional and + * can contain: + * - noTerminalWriteOutsideSession: Suppress writes from the Sentry + * object to the Terminal while there is no active Session. This + * is necessary for compatibility with, for example, the + * `attach.js` addon. + * + * - event `zmodemDetect` - fired on Zmodem.Sentry’s `on_detect` callback. + * Passes the zmodem.js Detection object. + * + * - event `zmodemRetract` - fired on Zmodem.Sentry’s `on_retract` callback. + * + * You’ll need to provide logic to handle uploads and downloads. + * See zmodem.js’s documentation for more details. + * + * **IMPORTANT:** After you confirm() a zmodem.js Detection, if you have + * used the `attach` or `terminado` addons, you’ll need to suspend their + * operation for the duration of the ZMODEM session. (The demo does this + * via `detach()` and a re-`attach()`.) + */ +(function (addon) { + if (typeof exports === 'object' && typeof module === 'object') { + /* + * CommonJS environment + */ + module.exports = addon(require('../../Terminal').Terminal); + } else if (typeof define == 'function') { + /* + * Require.js is available + */ + define(['../../xterm'], addon); + } else { + /* + * Plain browser environment + */ + addon(window.Terminal); + } +})(function _zmodemAddon(Terminal) { + Object.assign( + Terminal.prototype, + { + zmodemAttach: function zmodemAttach(ws, opts) { + var term = this; + + if (!opts) opts = {}; + + var senderFunc = function _ws_sender_func(octets) { + ws.send( new Uint8Array(octets) ); + }; + + var zsentry; + + function _shouldWrite() { + return !!zsentry.get_confirmed_session() || !opts.noTerminalWriteOutsideSession; + } + + zsentry = new Zmodem.Sentry( { + to_terminal: function _to_terminal(octets) { + if (_shouldWrite()) { + term.write( + String.fromCharCode.apply(String, octets) + ); + } + }, + + sender: senderFunc, + + on_retract: function _on_retract() { + term.emit("zmodemRetract"); + }, + + on_detect: function _on_detect(detection) { + term.emit("zmodemDetect", detection); + }, + } ); + + function handleWSMessage(evt) { + + //In testing with xterm.js’s demo the first message was + //always text even if the rest were binary. While that + //may be specific to xterm.js’s demo, ultimately we + //should reject anything that isn’t binary. + if (typeof evt.data === "string") { + if (_shouldWrite()) { + term.write(evt.data); + } + } + else { + zsentry.consume(evt.data); + } + } + + ws.binaryType = "arraybuffer"; + ws.addEventListener("message", handleWSMessage); + }, + + zmodemBrowser: Zmodem.Browser, + } + ); +});