From ef508a30fa578f349f57be336aa429a7ec9ebcea Mon Sep 17 00:00:00 2001 From: Genadi Samokovarov Date: Mon, 18 Aug 2014 01:42:43 +0300 Subject: [PATCH] Bring back 1.0 features without automount We removed the code, so its easier for Ryan to develop, with no legacy code to think or worry about. Let's bring it back for the final 2.0 release. It carries a twist though, I'm disabling the default `/console` mount. If the users still want the old functionally, they have to explicitly enable it. --- .../javascripts/web_console/application.js | 1 + .../web_console/console_sessions.js | 172 + .../stylesheets/web_console/application.css | 13 + .../web_console/console_sessions.css.erb | 6 + .../web_console/application_controller.rb | 13 + .../console_sessions_controller.rb | 43 + app/helpers/web_console/application_helper.rb | 4 + .../web_console/console_session_helper.rb | 4 + app/models/web_console/console_session.rb | 96 + .../layouts/web_console/application.html.erb | 14 + .../console_sessions/index.html.erb | 15 + config/routes.rb | 11 + lib/assets/javascripts/web-console.js | 1 + lib/assets/javascripts/web_console.js | 41 + lib/web_console.rb | 19 +- lib/web_console/colors.rb | 87 + lib/web_console/colors/light.rb | 24 + lib/web_console/colors/monokai.rb | 24 + lib/web_console/colors/solarized.rb | 47 + lib/web_console/colors/tango.rb | 24 + lib/web_console/colors/xterm.rb | 24 + lib/web_console/engine.rb | 87 + lib/web_console/railtie.rb | 15 - lib/web_console/slave.rb | 139 + .../console_sessions_controller_test.rb | 95 + .../controllers/exception_test_controller.rb | 4 + .../app/views/exception_test/xhr.html.erb | 1 + test/dummy/config/application.rb | 38 +- test/dummy/config/routes.rb | 1 + test/models/console_session_test.rb | 58 + test/web_console/colors_test.rb | 58 + test/web_console/engine_test.rb | 136 + test/web_console/slave_test.rb | 71 + vendor/assets/javascripts/term.js | 5726 +++++++++++++++++ 34 files changed, 7092 insertions(+), 20 deletions(-) create mode 100644 app/assets/javascripts/web_console/application.js create mode 100644 app/assets/javascripts/web_console/console_sessions.js create mode 100644 app/assets/stylesheets/web_console/application.css create mode 100644 app/assets/stylesheets/web_console/console_sessions.css.erb create mode 100644 app/controllers/web_console/application_controller.rb create mode 100644 app/controllers/web_console/console_sessions_controller.rb create mode 100644 app/helpers/web_console/application_helper.rb create mode 100644 app/helpers/web_console/console_session_helper.rb create mode 100644 app/models/web_console/console_session.rb create mode 100644 app/views/layouts/web_console/application.html.erb create mode 100644 app/views/web_console/console_sessions/index.html.erb create mode 100644 config/routes.rb create mode 100644 lib/assets/javascripts/web-console.js create mode 100644 lib/assets/javascripts/web_console.js create mode 100644 lib/web_console/colors.rb create mode 100644 lib/web_console/colors/light.rb create mode 100644 lib/web_console/colors/monokai.rb create mode 100644 lib/web_console/colors/solarized.rb create mode 100644 lib/web_console/colors/tango.rb create mode 100644 lib/web_console/colors/xterm.rb create mode 100644 lib/web_console/engine.rb delete mode 100644 lib/web_console/railtie.rb create mode 100644 lib/web_console/slave.rb create mode 100644 test/controllers/web_console/console_sessions_controller_test.rb create mode 100644 test/dummy/app/views/exception_test/xhr.html.erb create mode 100644 test/models/console_session_test.rb create mode 100644 test/web_console/colors_test.rb create mode 100644 test/web_console/engine_test.rb create mode 100644 test/web_console/slave_test.rb create mode 100644 vendor/assets/javascripts/term.js diff --git a/app/assets/javascripts/web_console/application.js b/app/assets/javascripts/web_console/application.js new file mode 100644 index 00000000..2c03f2d7 --- /dev/null +++ b/app/assets/javascripts/web_console/application.js @@ -0,0 +1 @@ +//= require_tree . diff --git a/app/assets/javascripts/web_console/console_sessions.js b/app/assets/javascripts/web_console/console_sessions.js new file mode 100644 index 00000000..ac1679d2 --- /dev/null +++ b/app/assets/javascripts/web_console/console_sessions.js @@ -0,0 +1,172 @@ +//= require web-console + +var AJAXTransport = (function(WebConsole) { + + var inherits = WebConsole.inherits; + var EventEmitter = WebConsole.EventEmitter; + + var FORM_MIME_TYPE = 'application/x-www-form-urlencoded; charset=utf-8'; + + var AJAXTransport = function(options) { + EventEmitter.call(this); + options || (options = {}); + + this.url = (typeof options.url === 'string') ? { + input: options.url, + pendingOutput: options.url, + configuration: options.url + } : options.url; + + this.pendingInput = ''; + + this.initializeEventHandlers(); + }; + + inherits(AJAXTransport, EventEmitter); + + // Initializes the default event handlers. + AJAXTransport.prototype.initializeEventHandlers = function() { + this.on('input', this.sendInput); + this.on('configuration', this.sendConfiguration); + this.once('initialization', function(cols, rows) { + this.emit('configuration', cols, rows); + this.pollForPendingOutput(); + }); + }; + + // Shorthand for creating XHR requests. + AJAXTransport.prototype.createRequest = function(method, url, options) { + options || (options = {}); + + var request = new XMLHttpRequest; + request.open(method, url); + + if (typeof options.form === 'object') { + var content = [], form = options.form; + + for (var key in form) { + var value = form[key]; + content.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); + } + + request.setRequestHeader('Content-Type', FORM_MIME_TYPE); + request.data = content.join('&'); + } + + return request; + }; + + AJAXTransport.prototype.pollForPendingOutput = function() { + var request = this.createRequest('GET', this.url.pendingOutput); + + var self = this; + request.onreadystatechange = function() { + if (request.readyState === XMLHttpRequest.DONE) { + if (request.status === 200) { + self.emit('pendingOutput', request.responseText); + self.pollForPendingOutput(); + } else { + self.emit('disconnect', request); + } + } + }; + + request.send(null); + }; + + // Send the input to the server. + // + // Each key press is encoded to an intermediate format, before it is sent to + // the server. + // + // WebConsole#keysPressed is an alias for WebConsole#sendInput. + AJAXTransport.prototype.sendInput = function(input) { + input || (input = ''); + + if (this.disconnected) return; + if (this.sendingInput) return this.pendingInput += input; + + // Indicate that we are starting to send input. + this.sendingInput = true; + + var request = this.createRequest('PUT', this.url.input, { + form: { input: this.pendingInput + input } + }); + + // Clear the pending input. + this.pendingInput = ''; + + var self = this; + request.onreadystatechange = function() { + if (request.readyState === XMLHttpRequest.DONE) { + self.sendingInput = false; + if (self.pendingInput) self.sendInput(); + } + }; + + request.send(request.data); + }; + + // Send the terminal configuration to the server. + // + // Right now by configuration, we understand the terminal widht and terminal + // height. + // + // WebConsole#resized is an alias for WebConsole#sendconfiguration. + AJAXTransport.prototype.sendConfiguration = function(cols, rows) { + if (this.disconnected) return; + + var request = this.createRequest('PUT', this.url.configuration, { + form: { width: cols, height: rows } + }); + + // Just send the configuration and don't care about any output. + request.send(request.data); + }; + + return AJAXTransport; + +}).call(this, WebConsole); + +window.addEventListener('load', function() { + var geometry = calculateFitScreenGeometry(); + config.terminal.cols = geometry[0]; + config.terminal.rows = geometry[1]; + + var terminal = window.terminal = new WebConsole.Terminal(config.terminal); + + terminal.on('data', function(data) { + transport.emit('input', data); + }); + + var transport = new AJAXTransport(config.transport); + + transport.on('pendingOutput', function(response) { + var json = JSON.parse(response); + if (json.output) terminal.write(json.output); + }); + + transport.on('disconnect', function() { + terminal.destroy(); + }); + + transport.emit('initialization', terminal.cols, terminal.rows); + + // Utilities + // --------- + + function calculateFitScreenGeometry() { + // Currently, resizing term.js is broken. E.g. opening vi causes it to go + // back to 80x24 and fail with off-by-one error. Other stuff, like chip8 + // are rendered incorrectly and so on. + // + // To work around it, create a temporary terminal, just so we can get the + // best dimensions for the screen. + var temporary = new WebConsole.Terminal; + try { + return temporary.fitScreen(); + } finally { + temporary.destroy(); + } + } +}); diff --git a/app/assets/stylesheets/web_console/application.css b/app/assets/stylesheets/web_console/application.css new file mode 100644 index 00000000..3192ec89 --- /dev/null +++ b/app/assets/stylesheets/web_console/application.css @@ -0,0 +1,13 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the top of the + * compiled file, but it's generally better to create a new file per style scope. + * + *= require_self + *= require_tree . + */ diff --git a/app/assets/stylesheets/web_console/console_sessions.css.erb b/app/assets/stylesheets/web_console/console_sessions.css.erb new file mode 100644 index 00000000..2b960e33 --- /dev/null +++ b/app/assets/stylesheets/web_console/console_sessions.css.erb @@ -0,0 +1,6 @@ +<% WebConsole.config.style.instance_eval do %> + body { color: <%= colors.foreground %>; background: <%= colors.background %>; margin: 0; padding: 0; } + + .terminal { float: left; overflow: hidden; font: <%= font %>; } + .terminal-cursor { color: <%= colors.background %>; background: <%= colors.foreground %>; } +<% end %> diff --git a/app/controllers/web_console/application_controller.rb b/app/controllers/web_console/application_controller.rb new file mode 100644 index 00000000..256853d5 --- /dev/null +++ b/app/controllers/web_console/application_controller.rb @@ -0,0 +1,13 @@ +module WebConsole + class ApplicationController < ActionController::Base + before_action :prevent_unauthorized_requests! + + private + + def prevent_unauthorized_requests! + unless request.remote_ip.in?(WebConsole.config.whitelisted_ips) + render nothing: true, status: :unauthorized + end + end + end +end diff --git a/app/controllers/web_console/console_sessions_controller.rb b/app/controllers/web_console/console_sessions_controller.rb new file mode 100644 index 00000000..99c5ca7f --- /dev/null +++ b/app/controllers/web_console/console_sessions_controller.rb @@ -0,0 +1,43 @@ +require_dependency 'web_console/application_controller' + +module WebConsole + class ConsoleSessionsController < ApplicationController + rescue_from ConsoleSession::Unavailable do |exception| + render json: exception, status: :gone + end + + rescue_from ConsoleSession::Invalid do |exception| + render json: exception, status: :unprocessable_entity + end + + def index + @console_session = ConsoleSession.create + end + + def input + @console_session = ConsoleSession.find(params[:id]) + @console_session.send_input(console_session_params[:input]) + + render nothing: true + end + + def configuration + @console_session = ConsoleSession.find(params[:id]) + @console_session.configure(console_session_params) + + render nothing: true + end + + def pending_output + @console_session = ConsoleSession.find(params[:id]) + + render json: { output: @console_session.pending_output } + end + + private + + def console_session_params + params.permit(:id, :input, :width, :height) + end + end +end diff --git a/app/helpers/web_console/application_helper.rb b/app/helpers/web_console/application_helper.rb new file mode 100644 index 00000000..1defa34e --- /dev/null +++ b/app/helpers/web_console/application_helper.rb @@ -0,0 +1,4 @@ +module WebConsole + module ApplicationHelper + end +end diff --git a/app/helpers/web_console/console_session_helper.rb b/app/helpers/web_console/console_session_helper.rb new file mode 100644 index 00000000..54f24b4b --- /dev/null +++ b/app/helpers/web_console/console_session_helper.rb @@ -0,0 +1,4 @@ +module WebConsole + module ConsoleSessionHelper + end +end diff --git a/app/models/web_console/console_session.rb b/app/models/web_console/console_session.rb new file mode 100644 index 00000000..51f719eb --- /dev/null +++ b/app/models/web_console/console_session.rb @@ -0,0 +1,96 @@ +module WebConsole + # Manage and persist (in memory) WebConsole::Slave instances. + class ConsoleSession + include ActiveModel::Model + + # In-memory storage for the console sessions. Session preservation is + # troubled on servers with multiple workers and threads. + INMEMORY_STORAGE = {} + + # Base error class for ConsoleSession specific exceptions. + # + # Provides #to_json implementation, so all subclasses are JSON + # serializable. + class Error < StandardError + def to_json(*) + { error: to_s }.to_json + end + end + + # Raised when trying to find a session that is no longer in the in-memory + # session storage or when the slave process exited. + Unavailable = Class.new(Error) + + # Raised when an operation transition to an invalid state. + Invalid = Class.new(Error) + + class << self + # Finds a session by its pid. + # + # Raises WebConsole::ConsoleSession::Expired if there is no such session. + def find(pid) + INMEMORY_STORAGE[pid.to_i] or raise Unavailable, 'Session unavailable' + end + + # Creates an already persisted consolse session. + # + # Use this method if you need to persist a session, without providing it + # any input. + def create + new.persist + end + end + + def initialize + @slave = WebConsole::Slave.new + end + + # Explicitly persist the model in the in-memory storage. + def persist + INMEMORY_STORAGE[pid] = self + end + + # Returns true if the current session is persisted in the in-memory storage. + def persisted? + self == INMEMORY_STORAGE[pid] + end + + # Returns an Enumerable of all key attributes if any is set, regardless if + # the object is persisted or not. + def to_key + [pid] if persisted? + end + + private + + def delegate_and_call_slave_method(name, *args, &block) + # Cache the delegated method, so we don't have to hit #method_missing + # on every call. + define_singleton_method(name) do |*inner_args, &inner_block| + begin + @slave.public_send(name, *inner_args, &inner_block) + rescue ArgumentError => exc + raise Invalid, exc + rescue Slave::Closed => exc + raise Unavailable, exc + end + end + + # Now call the method, since that's our most common use case. Delegate + # the method and than call it. + public_send(name, *args, &block) + end + + def method_missing(name, *args, &block) + if @slave.respond_to?(name) + delegate_and_call_slave_method(name, *args, &block) + else + super + end + end + + def respond_to_missing?(name, include_all = false) + @slave.respond_to?(name) or super + end + end +end diff --git a/app/views/layouts/web_console/application.html.erb b/app/views/layouts/web_console/application.html.erb new file mode 100644 index 00000000..da159f7c --- /dev/null +++ b/app/views/layouts/web_console/application.html.erb @@ -0,0 +1,14 @@ + + + + WebConsole + <%= stylesheet_link_tag "web_console/application", media: "all" %> + <%= javascript_include_tag "web_console/application" %> + <%= csrf_meta_tags %> + + + +<%= yield %> + + + diff --git a/app/views/web_console/console_sessions/index.html.erb b/app/views/web_console/console_sessions/index.html.erb new file mode 100644 index 00000000..f0fae447 --- /dev/null +++ b/app/views/web_console/console_sessions/index.html.erb @@ -0,0 +1,15 @@ + diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 00000000..1e65cf51 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,11 @@ +WebConsole::Engine.routes.draw do + root to: 'console_sessions#index' + + resources :console_sessions do + member do + put :input + get :pending_output + put :configuration + end + end +end diff --git a/lib/assets/javascripts/web-console.js b/lib/assets/javascripts/web-console.js new file mode 100644 index 00000000..da13a698 --- /dev/null +++ b/lib/assets/javascripts/web-console.js @@ -0,0 +1 @@ +//= require ./web_console diff --git a/lib/assets/javascripts/web_console.js b/lib/assets/javascripts/web_console.js new file mode 100644 index 00000000..b5fd97d4 --- /dev/null +++ b/lib/assets/javascripts/web_console.js @@ -0,0 +1,41 @@ +//= require term + +;(function(BaseTerminal) { + + // Expose the main WebConsole namespace. + var WebConsole = this.WebConsole = {}; + + // Follow term.js example and expose inherits and EventEmitter. + var inherits = WebConsole.inherits = BaseTerminal.inherits; + var EventEmitter = WebConsole.EventEmitter = BaseTerminal.EventEmitter; + + var Terminal = WebConsole.Terminal = function(options) { + if (typeof options === 'number') { + return BaseTerminal.apply(this, arguments); + } + + BaseTerminal.call(this, options || (options = {})); + + this.open(); + + if (!(options.rows || options.cols) || !options.geometry) { + this.fitScreen(); + } + }; + + // Make WebConsole.Terminal inherit from BaseTerminal (term.js). + inherits(Terminal, BaseTerminal); + + Terminal.prototype.fitScreen = function() { + var width = Math.floor(this.element.clientWidth / this.cols); + var height = Math.floor(this.element.clientHeight / this.rows); + + var rows = Math.floor(window.innerHeight / height); + var cols = Math.floor(this.parent.clientWidth / width); + + this.resize(cols, rows); + + return [cols, rows]; + }; + +}).call(this, Terminal); diff --git a/lib/web_console.rb b/lib/web_console.rb index 85af5023..ea9d570a 100644 --- a/lib/web_console.rb +++ b/lib/web_console.rb @@ -1,23 +1,34 @@ require 'active_support/lazy_load_hooks' -require 'web_console/repl' -require 'web_console/repl_session' require 'action_dispatch/exception_wrapper' require 'action_dispatch/debug_exceptions' +require "web_console/view_helpers" +require 'web_console/colors' +require 'web_console/engine' +require 'web_console/repl' +require 'web_console/repl_session' +require 'web_console/slave' + module WebConsole class << self attr_accessor :binding_of_caller_available alias_method :binding_of_caller_available?, :binding_of_caller_available + + # Shortcut the +WebConsole::Engine.config.web_console+. + def config + Engine.config.web_console + end end + + ActiveSupport.run_load_hooks(:web_console, self) end begin require 'binding_of_caller' WebConsole.binding_of_caller_available = true -rescue LoadError => e +rescue LoadError WebConsole.binding_of_caller_available = false end require 'web_console/exception_extension' -require 'web_console/railtie' diff --git a/lib/web_console/colors.rb b/lib/web_console/colors.rb new file mode 100644 index 00000000..e3764cb9 --- /dev/null +++ b/lib/web_console/colors.rb @@ -0,0 +1,87 @@ +require 'active_support/core_ext/hash/indifferent_access' + +module WebConsole + # = Colors + # + # Manages the creation and serialization of terminal color themes. + # + # Colors is a subclass of +Array+ and it stores a collection of CSS color + # values, to be used from the client-side terminal. + # + # You can specify 8 or 16 colors and additional +background+ and +foreground+ + # colors. If not explicitly specified, +background+ and +foreground+ are + # considered to be the first and the last of the given colors. + class Colors < Array + class << self + # Registry of color themes mapped to a name. + # + # Don't manually alter the registry. Use WebConsole::Colors.register_theme + # for adding entries. + def themes + @@themes ||= {}.with_indifferent_access + end + + # Register a color theme into the color themes registry. + # + # Registration maps a name and Colors instance. + # + # If a block is given, it would be yielded with a new Colors instance to + # populate the theme colors in. + # + # If a Colors instance is already instantiated it can be passed directly + # as the second (_colors_) argument. In this case, if a block is given, + # it won't be executed. + def register_theme(name, colors = nil) + themes[name] = colors || new.tap { |c| yield c } + end + + # The default colors theme. + def default + self[:light] + end + + # Shortcut for WebConsole::Colors.themes#[]. + def [](name) + themes[name] + end + end + + alias :add :<< + + # Background color getter and setter. + # + # If called without arguments it acts like a getter. Otherwise it acts like + # a setter. + # + # The default background color will be the first entry in the colors theme. + def background(value = nil) + @background = value unless value.nil? + @background ||= self.first + end + + alias :background= :background + + # Foreground color getter and setter. + # + # If called without arguments it acts like a getter. Otherwise it acts like + # a setter. + # + # The default foreground color will be the last entry in the colors theme. + def foreground(value = nil) + @foreground = value unless value.nil? + @foreground ||= self.last + end + + alias :foreground= :foreground + + def to_json + (dup << background << foreground).to_a.to_json + end + end +end + +require 'web_console/colors/light' +require 'web_console/colors/monokai' +require 'web_console/colors/solarized' +require 'web_console/colors/tango' +require 'web_console/colors/xterm' diff --git a/lib/web_console/colors/light.rb b/lib/web_console/colors/light.rb new file mode 100644 index 00000000..327e1a96 --- /dev/null +++ b/lib/web_console/colors/light.rb @@ -0,0 +1,24 @@ +module WebConsole + Colors.register_theme(:light) do |c| + c.add '#000000' + c.add '#cd0000' + c.add '#00cd00' + c.add '#cdcd00' + c.add '#0000ee' + c.add '#cd00cd' + c.add '#00cdcd' + c.add '#e5e5e5' + + c.add '#7f7f7f' + c.add '#ff0000' + c.add '#00ff00' + c.add '#ffff00' + c.add '#5c5cff' + c.add '#ff00ff' + c.add '#00ffff' + c.add '#ffffff' + + c.background '#ffffff' + c.foreground '#000000' + end +end diff --git a/lib/web_console/colors/monokai.rb b/lib/web_console/colors/monokai.rb new file mode 100644 index 00000000..4a3f94e5 --- /dev/null +++ b/lib/web_console/colors/monokai.rb @@ -0,0 +1,24 @@ +module WebConsole + Colors.register_theme(:monokai) do |c| + c.add '#1c1d19' + c.add '#d01b24' + c.add '#a7d32C' + c.add '#d8cf67' + c.add '#61b8d0' + c.add '#695abb' + c.add '#d53864' + c.add '#fefffe' + + c.add '#1c1d19' + c.add '#d12a24' + c.add '#a7d32c' + c.add '#d8cf67' + c.add '#61b8d0' + c.add '#695abb' + c.add '#d53864' + c.add '#fefffe' + + c.background '#1c1d19' + c.foreground '#fefffe' + end +end diff --git a/lib/web_console/colors/solarized.rb b/lib/web_console/colors/solarized.rb new file mode 100644 index 00000000..c8810ea0 --- /dev/null +++ b/lib/web_console/colors/solarized.rb @@ -0,0 +1,47 @@ +module WebConsole + Colors.register_theme(:solarized_dark) do |c| + c.add '#073642' + c.add '#dc322f' + c.add '#859900' + c.add '#b58900' + c.add '#268bd2' + c.add '#d33682' + c.add '#2aa198' + c.add '#eee8d5' + + c.add '#002b36' + c.add '#cb4b16' + c.add '#586e75' + c.add '#657b83' + c.add '#839496' + c.add '#6c71c4' + c.add '#93a1a1' + c.add '#fdf6e3' + + c.background '#002b36' + c.foreground '#657b83' + end + + Colors.register_theme(:solarized_light) do |c| + c.add '#073642' + c.add '#dc322f' + c.add '#859900' + c.add '#b58900' + c.add '#268bd2' + c.add '#d33682' + c.add '#2aa198' + c.add '#eee8d5' + + c.add '#002b36' + c.add '#cb4b16' + c.add '#586e75' + c.add '#657b83' + c.add '#839496' + c.add '#6c71c4' + c.add '#93a1a1' + c.add '#fdf6e3' + + c.background '#fdf6e3' + c.foreground '#657b83' + end +end diff --git a/lib/web_console/colors/tango.rb b/lib/web_console/colors/tango.rb new file mode 100644 index 00000000..b181381f --- /dev/null +++ b/lib/web_console/colors/tango.rb @@ -0,0 +1,24 @@ +module WebConsole + Colors.register_theme(:tango) do |c| + c.add '#2e3436' + c.add '#cc0000' + c.add '#4e9a06' + c.add '#c4a000' + c.add '#3465a4' + c.add '#75507b' + c.add '#06989a' + c.add '#d3d7cf' + + c.add '#555753' + c.add '#ef2929' + c.add '#8ae234' + c.add '#fce94f' + c.add '#729fcf' + c.add '#ad7fa8' + c.add '#34e2e2' + c.add '#eeeeec' + + c.background '#2e3436' + c.foreground '#eeeeec' + end +end diff --git a/lib/web_console/colors/xterm.rb b/lib/web_console/colors/xterm.rb new file mode 100644 index 00000000..e531e374 --- /dev/null +++ b/lib/web_console/colors/xterm.rb @@ -0,0 +1,24 @@ +module WebConsole + Colors.register_theme(:xterm) do |c| + c.add '#000000' + c.add '#cd0000' + c.add '#00cd00' + c.add '#cdcd00' + c.add '#0000ee' + c.add '#cd00cd' + c.add '#00cdcd' + c.add '#e5e5e5' + + c.add '#7f7f7f' + c.add '#ff0000' + c.add '#00ff00' + c.add '#ffff00' + c.add '#5c5cff' + c.add '#ff00ff' + c.add '#00ffff' + c.add '#ffffff' + + c.background '#000000' + c.foreground '#ffffff' + end +end diff --git a/lib/web_console/engine.rb b/lib/web_console/engine.rb new file mode 100644 index 00000000..34fbac92 --- /dev/null +++ b/lib/web_console/engine.rb @@ -0,0 +1,87 @@ +require 'ipaddr' +require 'active_support/core_ext/numeric/time' +require 'rails/engine' + +require 'active_model' +require 'sprockets/rails' + +module WebConsole + class Engine < ::Rails::Engine + isolate_namespace WebConsole + + config.web_console = ActiveSupport::OrderedOptions.new.tap do |c| + c.automount = false + c.command = nil + c.default_mount_path = '/console' + c.timeout = 0.seconds + c.term = 'xterm-color' + c.whitelisted_ips = '127.0.0.1' + + c.style = ActiveSupport::OrderedOptions.new.tap do |s| + s.colors = 'light' + s.font = 'large DejaVu Sans Mono, Liberation Mono, monospace' + end + end + + initializer "web_console.view_helpers" do + ActiveSupport.on_load :action_view do + include WebConsole::ViewHelpers + end + + ActiveSupport.on_load :action_controller do + prepend_view_path File.dirname(__FILE__) + '/../action_dispatch/templates' + end + end + + initializer 'web_console.add_default_route' do |app| + # While we don't need the route in the test environment, we define it + # there as well, so we can easily test it. + if config.web_console.automount && (Rails.env.development? || Rails.env.test?) + app.routes.append do + mount WebConsole::Engine => app.config.web_console.default_mount_path + end + end + end + + initializer 'web_console.process_whitelisted_ips' do + config.web_console.tap do |c| + # Ensure that it is an array of IPAddr instances and it is defaulted to + # 127.0.0.1 if not precent. Only unique entries are left in the end. + c.whitelisted_ips = Array(c.whitelisted_ips).map do |ip| + ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.presence || '127.0.0.1') + end.uniq + + # IPAddr instances can cover whole networks, so simplify the #include? + # check for the most common case. + def (c.whitelisted_ips).include?(ip) + ip.is_a?(IPAddr) ? super : any? { |net| net.include?(ip.to_s) } + end + end + end + + initializer 'web_console.process_command' do + config.web_console.tap do |c| + # +Rails.root+ is not available while we set the default values of the + # other options. Default it during initialization. + + # Not all people created their Rails 4 applications with the Rails 4 + # generator, so bin/rails may not be available. + if c.command.blank? + local_rails = Rails.root.join('bin/rails') + c.command = "#{local_rails.executable? ? local_rails : 'rails'} console" + end + end + end + + initializer 'web_console.process_colors' do + config.web_console.style.tap do |c| + case colors = c.colors + when Symbol, String + c.colors = Colors[colors] || Colors.default + else + c.colors = Colors.new(colors) + end + end + end + end +end diff --git a/lib/web_console/railtie.rb b/lib/web_console/railtie.rb deleted file mode 100644 index fc7302fd..00000000 --- a/lib/web_console/railtie.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "web_console/view_helpers" - -module WebConsole - class Railtie < Rails::Railtie - initializer "web_console.view_helpers" do - ActiveSupport.on_load :action_view do - include WebConsole::ViewHelpers - end - - ActiveSupport.on_load :action_controller do - prepend_view_path File.dirname(__FILE__) + '/../action_dispatch/templates' - end - end - end -end diff --git a/lib/web_console/slave.rb b/lib/web_console/slave.rb new file mode 100644 index 00000000..158c46a9 --- /dev/null +++ b/lib/web_console/slave.rb @@ -0,0 +1,139 @@ +require 'pty' +require 'io/console' + +module WebConsole + # = Slave\ Process\ Wrapper + # + # Creates and communicates with slave processes. + # + # The communication happens through an input with attached psuedo-terminal. + # All of the communication is done in asynchronous way, meaning that when you + # send input to the process, you have get the output by polling for it. + class Slave + # Different OS' and platforms raises different errors when trying to read + # on output end of a closed process. + READING_ON_CLOSED_END_ERRORS = [ Errno::EIO, EOFError ] + + # Raised when trying to read from a closed (exited) process. + Closed = Class.new(IOError) + + # The slave process id. + attr_reader :pid + + def initialize(command = WebConsole.config.command, options = {}) + using_term(options[:term] || WebConsole.config.term) do + @output, @input, @pid = PTY.spawn(command.to_s) + end + configure(options) + end + + # Configure the psuedo terminal properties. + # + # Options: + # :width The width of the terminal in number of columns. + # :height The height of the terminal in number of rows. + # + # If any of the width or height is missing (or zero), the terminal size + # won't be set. + def configure(options = {}) + dimentions = options.values_at(:height, :width).collect(&:to_i) + @input.winsize = dimentions unless dimentions.any?(&:zero?) + end + + # Sends input to the slave process STDIN. + # + # Returns immediately. + def send_input(input) + raise ArgumentError if input.nil? or input.try(:empty?) + input.each_char { |char| @input.putc(char) } + end + + # Returns whether the slave process has any pending output in +wait+ + # seconds. + # + # By default, the +timeout+ follows +config.web_console.timeout+. Usually, + # it is zero, making the response immediate. + def pending_output?(timeout = WebConsole.config.timeout) + # JRuby's select won't automatically coerce ActiveSupport::Duration. + !!IO.select([@output], [], [], timeout.to_i) + end + + # Gets the pending output of the process. + # + # The pending output is read in an non blocking way by chunks, in the size + # of +chunk_len+. By default, +chunk_len+ is 49152 bytes. + # + # Returns +nil+, if there is no pending output at the moment. Otherwise, + # returns the output that hasn't been read since the last invocation. + # + # Raises Errno:EIO on closed output stream. This can happen if the + # underlying process exits. + def pending_output(chunk_len = 49152) + # Returns nil if there is no pending output. + return unless pending_output? + + pending = String.new + while chunk = @output.read_nonblock(chunk_len) + pending << chunk + end + pending.force_encoding('UTF-8') + rescue IO::WaitReadable + pending.force_encoding('UTF-8') + rescue + raise Closed if READING_ON_CLOSED_END_ERRORS.any? { |exc| $!.is_a?(exc) } + end + + # Dispose the underlying process, sending +SIGTERM+. + # + # After the process is disposed, it is detached from the parent to prevent + # zombies. + # + # If the process is already disposed an Errno::ESRCH will be raised and + # handled internally. If you want to handle Errno::ESRCH yourself, pass + # +{raise: true}+ as options. + # + # Returns a thread, which can be used to wait for the process termination. + def dispose(options = {}) + dispose_with(:SIGTERM, options) + end + + # Dispose the underlying process, sending +SIGKILL+. + # + # After the process is disposed, it is detached from the parent to prevent + # zombies. + # + # If the process is already disposed an Errno::ESRCH will be raised and + # handled internally. If you want to handle Errno::ESRCH yourself, pass + # +{raise: true}+ as options. + # + # Returns a thread, which can be used to wait for the process termination. + def dispose!(options = {}) + dispose_with(:SIGKILL, options) + end + + private + + LOCK = Mutex.new + + def using_term(term) + if term.nil? + yield + else + LOCK.synchronize do + begin + (previous_term, ENV['TERM'] = ENV['TERM'], term) and yield + ensure + ENV['TERM'] = previous_term + end + end + end + end + + def dispose_with(signal, options = {}) + Process.kill(signal, @pid) + Process.detach(@pid) + rescue Errno::ESRCH + raise if options[:raise] + end + end +end diff --git a/test/controllers/web_console/console_sessions_controller_test.rb b/test/controllers/web_console/console_sessions_controller_test.rb new file mode 100644 index 00000000..ac445995 --- /dev/null +++ b/test/controllers/web_console/console_sessions_controller_test.rb @@ -0,0 +1,95 @@ +require 'test_helper' + +module WebConsole + class ConsoleSessionsControllerTest < ActionController::TestCase + setup do + PTY.stubs(:spawn).returns([StringIO.new, StringIO.new, Random.rand(20000)]) + @request.stubs(:remote_ip).returns('127.0.0.1') + end + + test 'index is successful' do + get :index, use_route: 'web_console' + assert_response :success + end + + test 'GET index creates new console session' do + assert_difference 'ConsoleSession::INMEMORY_STORAGE.size' do + get :index, use_route: 'web_console' + end + end + + test 'PUT input validates for missing input' do + get :index, use_route: 'web_console' + + assert_not_nil console_session = assigns(:console_session) + + console_session.instance_variable_get(:@slave).stubs(:send_input).raises(ArgumentError) + put :input, id: console_session.pid, use_route: 'web_console' + + assert_response :unprocessable_entity + end + + test 'PUT input sends input to the slave' do + get :index, use_route: 'web_console' + + assert_not_nil console_session = assigns(:console_session) + + console_session.expects(:send_input) + put :input, input: ' ', id: console_session.pid, use_route: 'web_console' + end + + test 'GET pending_output gives the slave pending output' do + get :index, use_route: 'web_console' + + assert_not_nil console_session = assigns(:console_session) + console_session.expects(:pending_output) + + get :pending_output, id: console_session.pid, use_route: 'web_console' + end + + test 'GET pending_output raises 410 on exitted slave processes' do + get :index, use_route: 'web_console' + + assert_not_nil console_session = assigns(:console_session) + console_session.stubs(:pending_output).raises(ConsoleSession::Unavailable) + + get :pending_output, id: console_session.pid, use_route: 'web_console' + assert_response :gone + end + + test 'PUT configuration adjust the terminal size' do + get :index, use_route: 'web_console' + + assert_not_nil console_session = assigns(:console_session) + console_session.expects(:configure).with('id' => console_session.pid.to_s, 'width' => '80', 'height' => '24') + + put :configuration, id: console_session.pid, width: 80, height: 24, use_route: 'web_console' + assert_response :success + end + + test 'blocks requests from non-whitelisted ips' do + @request.stubs(:remote_ip).returns('128.0.0.1') + get :index, use_route: 'web_console' + assert_response :unauthorized + end + + test 'allows requests from whitelisted ips' do + @request.stubs(:remote_ip).returns('127.0.0.1') + get :index, use_route: 'web_console' + assert_response :success + end + + test 'index generated path' do + assert_generates mount_path, { + use_route: 'web_console', + controller: 'console_sessions' + }, {}, {controller: 'console_sessions'} + end + + private + + def mount_path + WebConsole::Engine.config.web_console.default_mount_path + end + end +end diff --git a/test/dummy/app/controllers/exception_test_controller.rb b/test/dummy/app/controllers/exception_test_controller.rb index 6dc15a8b..ca9d9175 100644 --- a/test/dummy/app/controllers/exception_test_controller.rb +++ b/test/dummy/app/controllers/exception_test_controller.rb @@ -4,6 +4,10 @@ def index test_method end + def xhr + raise "asda" if request.xhr? + end + def test_method test2 = "Test2" raise diff --git a/test/dummy/app/views/exception_test/xhr.html.erb b/test/dummy/app/views/exception_test/xhr.html.erb new file mode 100644 index 00000000..f2494362 --- /dev/null +++ b/test/dummy/app/views/exception_test/xhr.html.erb @@ -0,0 +1 @@ +

Do an XHR request!

diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 89636de7..cd6d4f94 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -5,8 +5,44 @@ Bundler.require(*Rails.groups) require 'web_console' +# Require pry-rails if the pry shell is explicitly requested. +require 'pry-rails' if ENV['PRY'] + module Dummy class Application < Rails::Application + # Automatically mount the console to tests the terminal side as well. + config.web_console.automount = true + + # When the Dummy application is ran in a docker container, the local + # computer address is in the 172.16.0.0/12 range. Have it whitelisted. + config.web_console.whitelisted_ips = %w( 127.0.0.1 172.16.0.0/12 ) + + if ENV['LONG_POLLING'] + # You have to explicitly enable the concurrency, as in development mode, + # the falsy config.cache_classes implies no concurrency support. + # + # The concurrency is enabled by removing the Rack::Lock middleware, which + # wraps each request in a mutex, effectively making the request handling + # synchronous. + config.allow_concurrency = true + + # For long-polling 45 seconds timeout seems reasonable. + config.web_console.timeout = 45.seconds + end + + config.web_console.style.colors = + if ENV['SOLARIZED_LIGHT'] + 'solarized_light' + elsif ENV['SOLARIZED_DARK'] + 'solarized_dark' + elsif ENV['TANGO'] + 'tango' + elsif ENV['XTERM'] + 'xterm' + elsif ENV['MONOKAI'] + 'monokai' + else + 'light' + end end end - diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 661f58b5..b6b3b6e4 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -1,5 +1,6 @@ Rails.application.routes.draw do root to: "exception_test#index" get :exception_test, to: "exception_test#index" + get :xhr_test, to: "exception_test#xhr" get :helper_test, to: "helper_test#index" end diff --git a/test/models/console_session_test.rb b/test/models/console_session_test.rb new file mode 100644 index 00000000..00443d9b --- /dev/null +++ b/test/models/console_session_test.rb @@ -0,0 +1,58 @@ +require 'test_helper' + +module WebConsole + class ConsoleSessionTest < ActionView::TestCase + include ActiveModel::Lint::Tests + + setup do + PTY.stubs(:spawn).returns([StringIO.new, StringIO.new, Random.rand(20000)]) + ConsoleSession::INMEMORY_STORAGE.clear + @model1 = @model = ConsoleSession.new + @model2 = ConsoleSession.new + end + + test 'raises ConsoleSession::Unavailable on not found sessions' do + assert_raises(ConsoleSession::Unavailable) { ConsoleSession.find(-1) } + end + + test 'find coerces ids' do + assert_equal @model.persist, ConsoleSession.find("#{@model.pid}") + end + + test 'not found exceptions are JSON serializable' do + exception = assert_raises(ConsoleSession::Unavailable) { ConsoleSession.find(-1) } + assert_equal '{"error":"Session unavailable"}', exception.to_json + end + + test 'can be used as slave as the methods are delegated' do + slave_methods = Slave.instance_methods - @model.methods + slave_methods.each { |method| assert @model.respond_to?(method) } + end + + test 'slave methods are cached on the singleton' do + assert_not @model.singleton_methods.include?(:pending_output?) + @model.pending_output? rescue nil + assert @model.singleton_methods.include?(:pending_output?) + end + + test 'persisted models knows that they are in memory' do + assert_not @model.persisted? + @model.persist + assert @model.persisted? + end + + test 'persisted models knows about their keys' do + assert_nil @model.to_key + @model.persist + assert_not_nil @model.to_key + end + + test 'create gives already persisted models' do + assert ConsoleSession.create.persisted? + end + + test 'no gives not persisted models' do + assert_not ConsoleSession.new.persisted? + end + end +end diff --git a/test/web_console/colors_test.rb b/test/web_console/colors_test.rb new file mode 100644 index 00000000..3249ac66 --- /dev/null +++ b/test/web_console/colors_test.rb @@ -0,0 +1,58 @@ +require 'test_helper' + +module WebConsole + class ColorsTest < ActiveSupport::TestCase + setup do + @colors = Colors.new %w( #7f7f7f #ff0000 #00ff00 #ffff00 #5c5cff #ff00ff #00ffff #ffffff ) + end + + test '.[] is an alias for .themes#[]' do + @colors.class.themes.expects(:[]).with(:light).once + @colors.class[:light] + end + + test '.register_theme creates Colors instance for the block' do + @colors.class.register_theme(:test) { |c| assert c.is_a?(Colors) } + end + + test '#background is the first color if not specified' do + assert_equal '#7f7f7f', @colors.background + end + + test '#background can be explicitly specified' do + @colors.background '#00ff00' + assert_equal '#00ff00', @colors.background + end + + test '#background= is an alias of #background' do + @colors.background = '#00ff00' + assert_equal '#00ff00', @colors.background + end + + test '#foreground is the last color if not specified' do + assert_equal '#ffffff', @colors.foreground + end + + test '#foreground can be explicitly specified' do + @colors.foreground '#f0f0f0' + assert_equal '#f0f0f0', @colors.foreground + end + + test '#foreground= is an alias of #foreground' do + @colors.foreground = '#f0f0f0' + assert_equal '#f0f0f0', @colors.foreground + end + + test '#to_json includes the background and the foreground' do + @colors.background = '#00ff00' + @colors.foreground = '#f0f0f0' + + expected_json = '["#7f7f7f","#ff0000","#00ff00","#ffff00","#5c5cff","#ff00ff","#00ffff","#ffffff","#00ff00","#f0f0f0"]' + assert_equal expected_json, @colors.to_json + end + + test '#default is :light' do + assert_equal @colors.class.default, @colors.class.themes[:light] + end + end +end diff --git a/test/web_console/engine_test.rb b/test/web_console/engine_test.rb new file mode 100644 index 00000000..61cf624f --- /dev/null +++ b/test/web_console/engine_test.rb @@ -0,0 +1,136 @@ +require 'test_helper' + +module WebConsole + class EngineTest < ActiveSupport::TestCase + test 'custom default_mount_path' do + new_uninitialized_app do |app| + app.config.web_console.default_mount_path = '/shell' + app.initialize! + + assert app.routes.named_routes['web_console'].path.match('/shell') + end + end + + test 'disabling automounting' do + new_uninitialized_app do |app| + app.config.web_console.automount = false + app.initialize! + + assert_not app.routes.named_routes['web_console'] + end + end + + test 'blank commands are expanded to the rails console' do + new_uninitialized_app do |app| + app.config.web_console.command = ' ' + app.initialize! + + expected_path = Rails.root.join('bin/rails console').to_s + assert_equal expected_path, app.config.web_console.command + end + end + + test 'present commands are not processed' do + new_uninitialized_app do |app| + app.config.web_console.command = '/bin/login' + app.initialize! + + assert_equal '/bin/login', app.config.web_console.command + end + end + + test 'whitelisted ips are courced to IPAddr' do + new_uninitialized_app do |app| + app.config.web_console.whitelisted_ips = '127.0.0.1' + app.initialize! + + assert_equal [ IPAddr.new('127.0.0.1') ], app.config.web_console.whitelisted_ips + end + end + + test 'whitelisted ips are normalized and unique IPAddr' do + new_uninitialized_app do |app| + app.config.web_console.whitelisted_ips = [ '127.0.0.1', '127.0.0.1', nil, '', ' ' ] + app.initialize! + + assert_equal [ IPAddr.new('127.0.0.1') ], app.config.web_console.whitelisted_ips + end + end + + test 'whitelisted_ips.include? coerces to IPAddr' do + new_uninitialized_app do |app| + app.config.web_console.whitelisted_ips = '127.0.0.1' + app.initialize! + + assert app.config.web_console.whitelisted_ips.include?('127.0.0.1') + end + end + + test 'whitelisted_ips.include? works with IPAddr' do + new_uninitialized_app do |app| + app.config.web_console.whitelisted_ips = '127.0.0.1' + app.initialize! + + assert app.config.web_console.whitelisted_ips.include?(IPAddr.new('127.0.0.1')) + end + end + + test 'whitelist whole networks' do + new_uninitialized_app do |app| + app.config.web_console.whitelisted_ips = '172.16.0.0/12' + app.initialize! + + 1.upto(255).each do |n| + assert_includes app.config.web_console.whitelisted_ips, "172.16.0.#{n}" + end + end + end + + test 'whitelist multiple networks' do + new_uninitialized_app do |app| + app.config.web_console.whitelisted_ips = %w( 172.16.0.0/12 192.168.0.0/16 ) + app.initialize! + + 1.upto(255).each do |n| + assert_includes app.config.web_console.whitelisted_ips, "172.16.0.#{n}" + assert_includes app.config.web_console.whitelisted_ips, "192.168.0.#{n}" + end + end + end + + private + + def new_uninitialized_app(root = File.expand_path('../../dummy', __FILE__)) + skip if Rails::VERSION::MAJOR == 3 + + old_app = Rails.application + + FileUtils.mkdir_p(root) + Dir.chdir(root) do + Rails.application = nil + + app = Class.new(Rails::Application) + app.config.eager_load = false + app.config.time_zone = 'UTC' + app.config.middleware ||= Rails::Configuration::MiddlewareStackProxy.new + app.config.active_support.deprecation = :notify + + yield app + end + ensure + Rails.application = old_app + end + + def teardown_fixtures(*) + super + rescue + # This is nasty hack to prevent a connection to the database in JRuby's + # activerecord-jdbcsqlite3-adapter. We don't really require a database + # connection, for the tests to run. + # + # The sad thing is that I couldn't figure out why does it only happens + # on activerecord-jdbcsqlite3-adapter and how to actually prevent it, + # rather than work-around it. + end + end +end diff --git a/test/web_console/slave_test.rb b/test/web_console/slave_test.rb new file mode 100644 index 00000000..155cef55 --- /dev/null +++ b/test/web_console/slave_test.rb @@ -0,0 +1,71 @@ +require 'stringio' +require 'test_helper' + +module WebConsole + class SlaveTest < ActiveSupport::TestCase + setup do + PTY.stubs(:spawn).returns([StringIO.new, StringIO.new, Random.rand(20000)]) + @slave = Slave.new + end + + test '#send_input raises ArgumentError on bad input' do + assert_raises(ArgumentError) { @slave.send_input(nil) } + assert_raises(ArgumentError) { @slave.send_input('') } + end + + test '#pending_output returns nil on no pending output' do + @slave.stubs(:pending_output?).returns(false) + assert_nil @slave.pending_output + end + + test '#pending_output returns a string with the current output' do + @slave.stubs(:pending_output?).returns(true) + @slave.instance_variable_get(:@output).stubs(:read_nonblock).returns('foo', nil) + assert_equal 'foo', @slave.pending_output + end + + test '#pending_output always encodes output in UTF-8' do + @slave.stubs(:pending_output?).returns(true) + @slave.instance_variable_get(:@output).stubs(:read_nonblock).returns('foo', nil) + assert_equal Encoding::UTF_8, @slave.pending_output.encoding + end + + Slave::READING_ON_CLOSED_END_ERRORS.each do |exception| + test "#pending_output raises Slave::Closed when the end raises #{exception}" do + @slave.stubs(:pending_output?).returns(true) + @slave.instance_variable_get(:@output).stubs(:read_nonblock).raises(exception) + + assert_raises(Slave::Closed) { @slave.pending_output } + end + end + + test '#configure changes @input dimentions' do + @slave.instance_variable_get(:@input).expects(:winsize=).with([32, 64]) + @slave.configure(height: 32, width: 64) + end + + test '#configure only changes the @input dimentions if width is zero' do + @slave.instance_variable_get(:@input).expects(:winsize=).never + @slave.configure(height: 32, width: 0) + end + + test '#configure only changes the @input dimentions if height is zero' do + @slave.instance_variable_get(:@input).expects(:winsize=).never + @slave.configure(height: 0, width: 64) + end + + { dispose: :SIGTERM, dispose!: :SIGKILL }.each do |method, signal| + test "##{method} sends #{signal} to the process and detaches it" do + Process.expects(:kill).with(signal, @slave.pid) + @slave.send(method) + end + + test "##{method} can reraise Errno::ESRCH if requested" do + Process.expects(:kill).with(signal, @slave.pid) + Process.stubs(:detach).raises(Errno::ESRCH) + + assert_raises(Errno::ESRCH) { @slave.send(method, raise: true) } + end + end + end +end diff --git a/vendor/assets/javascripts/term.js b/vendor/assets/javascripts/term.js new file mode 100644 index 00000000..30edfa3a --- /dev/null +++ b/vendor/assets/javascripts/term.js @@ -0,0 +1,5726 @@ +/** + * 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.slice(0, -2).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 = ''; + 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) { + 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'; + + 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