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 @@ + + +
+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