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