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
+
+
+
+
+
+
+
+
+
+
Name:
+
Size:
+
Last modified:
+
Mode:
+
+
Conversion:
+
Management:
+
Transport:
+
Sparse?
+
+
Files remaining in batch:
+
Bytes remaining in batch:
+
+
+
+
+
+
+
+
+
+
Options
+
+ cursorBlink
+
+
+ Accept all ZMODEM prompts*
+
+
+
+ cursorStyle
+
+ block
+ underline
+ bar
+
+
+
+
+
+ bellStyle
+
+ none
+ sound
+ visual
+ both
+
+
+
+
+ scrollback
+
+
+ tabStopWidth
+
+
+
+ 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,
+ }
+ );
+});