From 18e12d61a0399298856591dd7553d5fedc048fe0 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 16 Jan 2014 18:01:36 +0100 Subject: [PATCH] And... done --- app/routes.py | 28 +- app/static/coffees/main.coffee | 61 +- app/static/javascripts/main.js | 64 +- app/static/javascripts/term.js | 5756 +++++++++++++++++++++++++++++++ app/static/sass/main.sass | 16 +- app/static/stylesheets/main.css | 25 +- app/templates/index.html | 5 +- 7 files changed, 5877 insertions(+), 78 deletions(-) create mode 100644 app/static/javascripts/term.js diff --git a/app/routes.py b/app/routes.py index 5d90e52..ae04666 100644 --- a/app/routes.py +++ b/app/routes.py @@ -3,7 +3,6 @@ import os import io import sys import struct -import signal import fcntl import termios import tornado.websocket @@ -24,9 +23,7 @@ class Index(Route): @url(r'/ws') class TermWebSocket(Route, tornado.websocket.WebSocketHandler): - def open(self): - self.log.info('Websocket opened') - + def pty(self): pid, fd = pty.fork() if pid == 0: # Child @@ -79,10 +76,22 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): ) ioloop.add_handler(fd, self.shell, ioloop.READ) + def open(self): + self.log.info('Websocket opened') + self.pty() + def on_message(self, message): - self.log.info('WRIT<%s' % message) - self.writer.write(message) - self.writer.flush() + if message.startswith('RS|'): + message = message[3:] + cols, rows = map(int, message.split(',')) + s = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(self.fd, termios.TIOCSWINSZ, s) + self.log.info('SIZE (%d, %d)' % (cols, rows)) + elif message.startswith('SH|'): + message = message[3:] + self.log.info('WRIT<%r' % message) + self.writer.write(message) + self.writer.flush() def shell(self, fd, events): if events & ioloop.READ: @@ -90,15 +99,16 @@ class TermWebSocket(Route, tornado.websocket.WebSocketHandler): try: read = self.reader.read() except IOError: - self.log.info('READ>%s' % read) + self.log.info('READ>%r' % read) self.write_message('Exited') return - self.log.info('READ>%s' % read) + self.log.info('READ>%r' % read) self.write_message(read) def on_close(self): self.writer.write('') + self.writer.flush() os.close(self.fd) os.waitpid(self.pid, 0) self.log.info('Websocket closed') diff --git a/app/static/coffees/main.coffee b/app/static/coffees/main.coffee index b3303d4..1d6d4a0 100644 --- a/app/static/coffees/main.coffee +++ b/app/static/coffees/main.coffee @@ -1,27 +1,48 @@ -ws = null +term = ws = null +cols = rows = null $ -> + ws = new WebSocket 'ws://' + document.location.host + '/ws' - ws.onopen = -> console.log "WebSocket open", arguments - ws.onclose = -> console.log "WebSocket closed", arguments + ws.onopen = -> + console.log "WebSocket open", arguments + term = new Terminal( + visualBell: true + screenKeys: true + ) + term.on "data", (data) -> + ws.send 'SH|' + data + + term.on "title", (title) -> + document.title = title + + term.open $('main').get(0) + $('.terminal').attr('style', '') + $(window).trigger 'resize' + + + ws.onclose = -> + if term + term.destroy() + console.log "WebSocket closed", arguments + ws.onerror = -> console.log "WebSocket error", arguments ws.onmessage = (event) -> - $('.term code').html($('.term code').html() + event.data) + term.write event.data - $('html,body').on('keypress', (event) -> - code = event.keyCode - ws.send(String.fromCharCode(code)) - event.preventDefault() - event.stopPropagation() - return false - ).on('keydown', (event) -> - code = event.keyCode - return if code == 17 - if event.ctrlKey - code -= 64 - ws.send(String.fromCharCode(code)) + $(window).resize -> + $main = $('main') + $termtest = $('
').addClass('terminal') + $test = $('
').css(display: 'inline-block').text('0123456789') + $termtest.append($test) - event.preventDefault() - event.stopPropagation() - return false - ) + $main.append($termtest) + ew = $test.outerWidth() / 10 + eh = $test.outerHeight() + $termtest.remove() + w = $main.outerWidth() + h = $main.outerHeight() + cols = Math.floor(w / ew) + rows = Math.floor(h / eh) + term.resize cols, rows + ws.send "RS|#{cols},#{rows}" diff --git a/app/static/javascripts/main.js b/app/static/javascripts/main.js index f1d30e8..5ed8d45 100644 --- a/app/static/javascripts/main.js +++ b/app/static/javascripts/main.js @@ -1,41 +1,57 @@ // Generated by CoffeeScript 1.6.3 -var ws; +var cols, rows, term, ws; -ws = null; +term = ws = null; + +cols = rows = null; $(function() { ws = new WebSocket('ws://' + document.location.host + '/ws'); ws.onopen = function() { - return console.log("WebSocket open", arguments); + console.log("WebSocket open", arguments); + term = new Terminal({ + visualBell: true, + screenKeys: true + }); + term.on("data", function(data) { + return ws.send('SH|' + data); + }); + term.on("title", function(title) { + return document.title = title; + }); + term.open($('main').get(0)); + $('.terminal').attr('style', ''); + return $(window).trigger('resize'); }; ws.onclose = function() { + if (term) { + term.destroy(); + } return console.log("WebSocket closed", arguments); }; ws.onerror = function() { return console.log("WebSocket error", arguments); }; ws.onmessage = function(event) { - return $('.term code').html($('.term code').html() + event.data); + return term.write(event.data); }; - return $('html,body').on('keypress', function(event) { - var code; - code = event.keyCode; - ws.send(String.fromCharCode(code)); - event.preventDefault(); - event.stopPropagation(); - return false; - }).on('keydown', function(event) { - var code; - code = event.keyCode; - if (code === 17) { - return; - } - if (event.ctrlKey) { - code -= 64; - ws.send(String.fromCharCode(code)); - event.preventDefault(); - event.stopPropagation(); - return false; - } + return $(window).resize(function() { + var $main, $termtest, $test, eh, ew, h, w; + $main = $('main'); + $termtest = $('
').addClass('terminal'); + $test = $('
').css({ + display: 'inline-block' + }).text('0123456789'); + $termtest.append($test); + $main.append($termtest); + ew = $test.outerWidth() / 10; + eh = $test.outerHeight(); + $termtest.remove(); + w = $main.outerWidth(); + h = $main.outerHeight(); + cols = Math.floor(w / ew); + rows = Math.floor(h / eh); + term.resize(cols, rows); + return ws.send("RS|" + cols + "," + rows); }); }); diff --git a/app/static/javascripts/term.js b/app/static/javascripts/term.js new file mode 100644 index 0000000..92fe7cb --- /dev/null +++ b/app/static/javascripts/term.js @@ -0,0 +1,5756 @@ +/** + * term.js - an xterm emulator + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +;(function() { + +/** + * Terminal Emulation References: + * http://vt100.net/ + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt + * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * http://invisible-island.net/vttest/ + * http://www.inwap.com/pdp10/ansicode.txt + * http://linux.die.net/man/4/console_codes + * http://linux.die.net/man/7/urxvt + */ + +'use strict'; + +/** + * Shared + */ + +var window = this + , document = this.document; + +/** + * EventEmitter + */ + +function EventEmitter() { + this._events = this._events || {}; +} + +EventEmitter.prototype.addListener = function(type, listener) { + this._events[type] = this._events[type] || []; + this._events[type].push(listener); +}; + +EventEmitter.prototype.on = EventEmitter.prototype.addListener; + +EventEmitter.prototype.removeListener = function(type, listener) { + if (!this._events[type]) return; + + var obj = this._events[type] + , i = obj.length; + + while (i--) { + if (obj[i] === listener || obj[i].listener === listener) { + obj.splice(i, 1); + return; + } + } +}; + +EventEmitter.prototype.off = EventEmitter.prototype.removeListener; + +EventEmitter.prototype.removeAllListeners = function(type) { + if (this._events[type]) delete this._events[type]; +}; + +EventEmitter.prototype.once = function(type, listener) { + function on() { + var args = Array.prototype.slice.call(arguments); + this.removeListener(type, on); + return listener.apply(this, args); + } + on.listener = listener; + return this.on(type, on); +}; + +EventEmitter.prototype.emit = function(type) { + if (!this._events[type]) return; + + var args = Array.prototype.slice.call(arguments, 1) + , obj = this._events[type] + , l = obj.length + , i = 0; + + for (; i < l; i++) { + obj[i].apply(this, args); + } +}; + +EventEmitter.prototype.listeners = function(type) { + return this._events[type] = this._events[type] || []; +}; + +/** + * States + */ + +var normal = 0 + , escaped = 1 + , csi = 2 + , osc = 3 + , charset = 4 + , dcs = 5 + , ignore = 6; + +/** + * Terminal + */ + +function Terminal(options) { + var self = this; + + if (!(this instanceof Terminal)) { + return new Terminal(arguments[0], arguments[1], arguments[2]); + } + + EventEmitter.call(this); + + if (typeof options === 'number') { + options = { + cols: arguments[0], + rows: arguments[1], + handler: arguments[2] + }; + } + + options = options || {}; + + each(keys(Terminal.defaults), function(key) { + if (options[key] == null) { + options[key] = Terminal.options[key]; + // Legacy: + if (Terminal[key] !== Terminal.defaults[key]) { + options[key] = Terminal[key]; + } + } + self[key] = options[key]; + }); + + if (options.colors.length === 8) { + options.colors = options.colors.concat(Terminal._colors.slice(8)); + } else if (options.colors.length === 16) { + options.colors = options.colors.concat(Terminal._colors.slice(16)); + } else if (options.colors.length === 10) { + options.colors = options.colors.slice(0, -2).concat( + Terminal._colors.slice(8, -2), options.colors.slice(-2)); + } else if (options.colors.length === 18) { + options.colors = options.colors.concat( + Terminal._colors.slice(16, -2), options.colors.slice(-2)); + } + this.colors = options.colors; + + this.options = options; + + // this.context = options.context || window; + // this.document = options.document || document; + this.parent = options.body || options.parent + || (document ? document.getElementsByTagName('body')[0] : null); + + this.cols = options.cols || options.geometry[0]; + this.rows = options.rows || options.geometry[1]; + + if (options.handler) { + this.on('data', options.handler); + } + + this.ybase = 0; + this.ydisp = 0; + this.x = 0; + this.y = 0; + this.cursorState = 0; + this.cursorHidden = false; + this.convertEol; + this.state = 0; + this.queue = ''; + this.scrollTop = 0; + this.scrollBottom = this.rows - 1; + + // modes + this.applicationKeypad = false; + this.applicationCursor = false; + this.originMode = false; + this.insertMode = false; + this.wraparoundMode = false; + this.normal = null; + + // select modes + this.prefixMode = false; + this.selectMode = false; + this.visualMode = false; + this.searchMode = false; + this.searchDown; + this.entry = ''; + this.entryPrefix = 'Search: '; + this._real; + this._selected; + this._textarea; + + // charset + this.charset = null; + this.gcharset = null; + this.glevel = 0; + this.charsets = [null]; + + // mouse properties + this.decLocator; + this.x10Mouse; + this.vt200Mouse; + this.vt300Mouse; + this.normalMouse; + this.mouseEvents; + this.sendFocus; + this.utfMouse; + this.sgrMouse; + this.urxvtMouse; + + // misc + this.element; + this.children; + this.refreshStart; + this.refreshEnd; + this.savedX; + this.savedY; + this.savedCols; + + // stream + this.readable = true; + this.writable = true; + + this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); + this.curAttr = this.defAttr; + + this.params = []; + this.currentParam = 0; + this.prefix = ''; + this.postfix = ''; + + this.lines = []; + var i = this.rows; + while (i--) { + this.lines.push(this.blankLine()); + } + + this.tabs; + this.setupStops(); +} + +inherits(Terminal, EventEmitter); + +// back_color_erase feature for xterm. +Terminal.prototype.eraseAttr = function() { + // if (this.is('screen')) return this.defAttr; + return (this.defAttr & ~0x1ff) | (this.curAttr & 0x1ff); +}; + +/** + * Colors + */ + +// Colors 0-15 +Terminal.tangoColors = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +]; + +Terminal.xtermColors = [ + // dark: + '#000000', // black + '#cd0000', // red3 + '#00cd00', // green3 + '#cdcd00', // yellow3 + '#0000ee', // blue2 + '#cd00cd', // magenta3 + '#00cdcd', // cyan3 + '#e5e5e5', // gray90 + // bright: + '#7f7f7f', // gray50 + '#ff0000', // red + '#00ff00', // green + '#ffff00', // yellow + '#5c5cff', // rgb:5c/5c/ff + '#ff00ff', // magenta + '#00ffff', // cyan + '#ffffff' // white +]; + +// Colors 0-15 + 16-255 +// Much thanks to TooTallNate for writing this. +Terminal.colors = (function() { + var colors = Terminal.tangoColors.slice() + , r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + , i; + + // 16-231 + i = 0; + for (; i < 216; i++) { + out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); + } + + // 232-255 (grey) + i = 0; + for (; i < 24; i++) { + r = 8 + i * 10; + out(r, r, r); + } + + function out(r, g, b) { + colors.push('#' + hex(r) + hex(g) + hex(b)); + } + + function hex(c) { + c = c.toString(16); + return c.length < 2 ? '0' + c : c; + } + + return colors; +})(); + +// Default BG/FG +Terminal.colors[256] = '#000000'; +Terminal.colors[257] = '#f0f0f0'; + +Terminal._colors = Terminal.colors.slice(); + +Terminal.vcolors = (function() { + var out = [] + , colors = Terminal.colors + , i = 0 + , color; + + for (; i < 256; i++) { + color = parseInt(colors[i].substring(1), 16); + out.push([ + (color >> 16) & 0xff, + (color >> 8) & 0xff, + color & 0xff + ]); + } + + return out; +})(); + +/** + * Options + */ + +Terminal.defaults = { + colors: Terminal.colors, + convertEol: false, + termName: 'xterm', + geometry: [80, 24], + cursorBlink: true, + visualBell: false, + popOnBell: false, + scrollback: 1000, + screenKeys: false, + debug: false, + useStyle: false + // programFeatures: false, + // focusKeys: false, +}; + +Terminal.options = {}; + +each(keys(Terminal.defaults), function(key) { + Terminal[key] = Terminal.defaults[key]; + Terminal.options[key] = Terminal.defaults[key]; +}); + +/** + * Focused Terminal + */ + +Terminal.focus = null; + +Terminal.prototype.focus = function() { + if (Terminal.focus === this) return; + + if (Terminal.focus) { + Terminal.focus.blur(); + } + + if (this.sendFocus) this.send('\x1b[I'); + this.showCursor(); + + // try { + // this.element.focus(); + // } catch (e) { + // ; + // } + + // this.emit('focus'); + + Terminal.focus = this; +}; + +Terminal.prototype.blur = function() { + if (Terminal.focus !== this) return; + + this.cursorState = 0; + this.refresh(this.y, this.y); + if (this.sendFocus) this.send('\x1b[O'); + + // try { + // this.element.blur(); + // } catch (e) { + // ; + // } + + // this.emit('blur'); + + Terminal.focus = null; +}; + +/** + * Initialize global behavior + */ + +Terminal.prototype.initGlobal = function() { + var document = this.document; + + Terminal._boundDocs = Terminal._boundDocs || []; + if (~indexOf(Terminal._boundDocs, document)) { + return; + } + Terminal._boundDocs.push(document); + + Terminal.bindPaste(document); + + Terminal.bindKeys(document); + + Terminal.bindCopy(document); + + if (this.isIpad || this.isIphone) { + Terminal.fixIpad(document); + } + + if (this.useStyle) { + Terminal.insertStyle(document, this.colors[256], this.colors[257]); + } +}; + +/** + * Bind to paste event + */ + +Terminal.bindPaste = function(document) { + // This seems to work well for ctrl-V and middle-click, + // even without the contentEditable workaround. + var window = document.defaultView; + on(window, 'paste', function(ev) { + var term = Terminal.focus; + if (!term) return; + if (ev.clipboardData) { + term.send(ev.clipboardData.getData('text/plain')); + } else if (term.context.clipboardData) { + term.send(term.context.clipboardData.getData('Text')); + } + // Not necessary. Do it anyway for good measure. + term.element.contentEditable = 'inherit'; + return cancel(ev); + }); +}; + +/** + * Global Events for key handling + */ + +Terminal.bindKeys = function(document) { + // We should only need to check `target === body` below, + // but we can check everything for good measure. + on(document, 'keydown', function(ev) { + if (!Terminal.focus) return; + var target = ev.target || ev.srcElement; + if (!target) return; + if (target === Terminal.focus.element + || target === Terminal.focus.context + || target === Terminal.focus.document + || target === Terminal.focus.body + || target === Terminal._textarea + || target === Terminal.focus.parent) { + return Terminal.focus.keyDown(ev); + } + }, true); + + on(document, 'keypress', function(ev) { + if (!Terminal.focus) return; + var target = ev.target || ev.srcElement; + if (!target) return; + if (target === Terminal.focus.element + || target === Terminal.focus.context + || target === Terminal.focus.document + || target === Terminal.focus.body + || target === Terminal._textarea + || target === Terminal.focus.parent) { + return Terminal.focus.keyPress(ev); + } + }, true); + + // If we click somewhere other than a + // terminal, unfocus the terminal. + on(document, 'mousedown', function(ev) { + if (!Terminal.focus) return; + + var el = ev.target || ev.srcElement; + if (!el) return; + + do { + if (el === Terminal.focus.element) return; + } while (el = el.parentNode); + + Terminal.focus.blur(); + }); +}; + +/** + * Copy Selection w/ Ctrl-C (Select Mode) + */ + +Terminal.bindCopy = function(document) { + var window = document.defaultView; + + // if (!('onbeforecopy' in document)) { + // // Copies to *only* the clipboard. + // on(window, 'copy', function fn(ev) { + // var term = Terminal.focus; + // if (!term) return; + // if (!term._selected) return; + // var text = term.grabText( + // term._selected.x1, term._selected.x2, + // term._selected.y1, term._selected.y2); + // term.emit('copy', text); + // ev.clipboardData.setData('text/plain', text); + // }); + // return; + // } + + // Copies to primary selection *and* clipboard. + // NOTE: This may work better on capture phase, + // or using the `beforecopy` event. + on(window, 'copy', function(ev) { + var term = Terminal.focus; + if (!term) return; + if (!term._selected) return; + var textarea = term.getCopyTextarea(); + var text = term.grabText( + term._selected.x1, term._selected.x2, + term._selected.y1, term._selected.y2); + term.emit('copy', text); + textarea.focus(); + textarea.textContent = text; + textarea.value = text; + textarea.setSelectionRange(0, text.length); + setTimeout(function() { + term.element.focus(); + term.focus(); + }, 1); + }); +}; + +/** + * Fix iPad - no idea if this works + */ + +Terminal.fixIpad = function(document) { + var textarea = document.createElement('textarea'); + textarea.style.position = 'absolute'; + textarea.style.left = '-32000px'; + textarea.style.top = '-32000px'; + textarea.style.width = '0px'; + textarea.style.height = '0px'; + textarea.style.opacity = '0'; + textarea.style.backgroundColor = 'transparent'; + textarea.style.borderStyle = 'none'; + textarea.style.outlineStyle = 'none'; + textarea.autocapitalize='none'; + textarea.autocorrect='off'; + + document.getElementsByTagName('body')[0].appendChild(textarea); + + Terminal._textarea = textarea; + + setTimeout(function() { + textarea.focus(); + }, 1000); +}; + +/** + * Insert a default style + */ + +Terminal.insertStyle = function(document, bg, fg) { + var style = document.getElementById('term-style'); + if (style) return; + + var head = document.getElementsByTagName('head')[0]; + if (!head) return; + + var style = document.createElement('style'); + style.id = 'term-style'; + + // textContent doesn't work well with IE for