diff --git a/demo/app.js b/demo/app.js index 2b5cfc02..8637dad8 100644 --- a/demo/app.js +++ b/demo/app.js @@ -25,7 +25,6 @@ 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, diff --git a/demo/index.html b/demo/index.html index 84b94830..daa60b6f 100644 --- a/demo/index.html +++ b/demo/index.html @@ -8,25 +8,14 @@ - - -

xterm.js: xterm, in the browser

-
- -
- -
-

Actions

diff --git a/demo/main.js b/demo/main.js index 8c6a71d8..2ed2b33c 100644 --- a/demo/main.js +++ b/demo/main.js @@ -109,75 +109,11 @@ function createTerminal() { socket.onopen = runRealTerminal; socket.onclose = runFakeTerminal; socket.onerror = runFakeTerminal; - - term.zmodemAttach(socket, { - noTerminalWriteOutsideSession: true, - } ); - - term.on("zmodemDetect", (detection) => { - term.detach(); - let zsession = detection.confirm(); - - var promise; - - if (zsession.type === "receive") { - promise = zmodemHandleReceiveSession(zsession); - } - else { - promise = zmodemHandleSendSession(zsession); - } - - promise.catch( console.error.bind(console) ).then( () => { - term.attach(socket); - } ); - }); }); }); }, 0); } -function zmodemHandleReceiveSession(zsession) { - zsession.on("offer", function(xfer) { - xfer.accept().then( - (inputs) => { - Zmodem.Browser.save_to_disk(inputs, xfer.get_details().name); - }, - console.error.bind(console) - ); - } ); - - var promise = new Promise( (res) => { - zsession.on("session_end", res); - } ); - - zsession.start(); - - return promise; -} - -function zmodemHandleSendSession(zsession) { - var chooseForm = document.getElementById("zm_choose"); - var fileEl = document.getElementById("zm_files"); - - chooseForm.style.display = ""; - - return new Promise( (res) => { - fileEl.onchange = function(e) { - chooseForm.style.display = "none"; - - var filesObj = fileEl.files; - - Zmodem.Browser.send_files( - zsession, - filesObj - ).then( - zsession.close.bind(zsession), - console.error.bind(console) - ).then(res); - }; - } ); -} - function runRealTerminal() { term.attach(socket); term._initialized = true; diff --git a/gulpfile.js b/gulpfile.js index 3c75e1b3..31beba63 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -111,6 +111,11 @@ gulp.task('browserify-addons', ['tsc'], function() { './src/addons/zmodem/zmodem.js', ]).pipe( concat('zmodem.js') ).pipe( gulp.dest( `${buildDir}/addons/zmodem` ) ); + let zmodemDemoBundle = gulp.src([ + // Copy JS addons + `${outDir}/addons/zmodem/demo/**`, + ]).pipe(gulp.dest(`${buildDir}/addons/zmodem/demo`)); + // Copy all add-ons from outDir to buildDir let copyAddons = gulp.src([ // Copy JS addons @@ -121,7 +126,7 @@ gulp.task('browserify-addons', ['tsc'], function() { `!${outDir}/addons/zmodem/**`, ]).pipe(gulp.dest(`${buildDir}/addons`)); - return merge(searchBundle, copyAddons); + return merge(searchBundle, zmodemBundle, zmodemDemoBundle, copyAddons); }); gulp.task('instrument-test', function () { diff --git a/package.json b/package.json index 2dae64b4..ecff08cc 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "scripts": { "prestart": "npm run build", "start": "node demo/app", + "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", diff --git a/src/addons/zmodem/demo/app.js b/src/addons/zmodem/demo/app.js new file mode 100644 index 00000000..a9d0bbd0 --- /dev/null +++ b/src/addons/zmodem/demo/app.js @@ -0,0 +1,85 @@ +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.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..c6750618 --- /dev/null +++ b/src/addons/zmodem/demo/index.html @@ -0,0 +1,127 @@ + + + + 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/demo/style.css b/src/addons/zmodem/demo/style.css new file mode 120000 index 00000000..8d425da2 --- /dev/null +++ b/src/addons/zmodem/demo/style.css @@ -0,0 +1 @@ +../../../../demo/style.css \ No newline at end of file