}
+     */
+    this.children = {};
+
+    // Set layer position
+    var canvas = layer.getCanvas();
+    canvas.style.position = 'absolute';
+    canvas.style.left = '0px';
+    canvas.style.top = '0px';
+
+    // Create div with given size
+    var div = document.createElement('div');
+    div.appendChild(canvas);
+    div.style.width = width + 'px';
+    div.style.height = height + 'px';
+    div.style.position = 'absolute';
+    div.style.left = '0px';
+    div.style.top = '0px';
+    div.style.overflow = 'hidden';
+
+    /**
+     * Superclass resize() function.
+     * @private
+     */
+    var __super_resize = this.resize;
+
+    this.resize = function (width, height) {
+        // Resize containing div
+        div.style.width = width + 'px';
+        div.style.height = height + 'px';
+
+        __super_resize(width, height);
+    };
+
+    /**
+     * Returns the element containing the canvas and any other elements
+     * associated with this layer.
+     *
+     * @returns {!Element}
+     *     The element containing this layer's canvas.
+     */
+    this.getElement = function () {
+        return div;
+    };
+
+    /**
+     * The translation component of this layer's transform.
+     *
+     * @private
+     * @type {!string}
+     */
+    var translate = 'translate(0px, 0px)'; // (0, 0)
+
+    /**
+     * The arbitrary matrix component of this layer's transform.
+     *
+     * @private
+     * @type {!string}
+     */
+    var matrix = 'matrix(1, 0, 0, 1, 0, 0)'; // Identity
+
+    /**
+     * Moves the upper-left corner of this layer to the given X and Y
+     * coordinate.
+     *
+     * @param {!number} x
+     *     The X coordinate to move to.
+     *
+     * @param {!number} y
+     *     The Y coordinate to move to.
+     */
+    this.translate = function (x, y) {
+        layer.x = x;
+        layer.y = y;
+
+        // Generate translation
+        translate = 'translate(' + x + 'px,' + y + 'px)';
+
+        // Set layer transform
+        div.style.transform = div.style.WebkitTransform = div.style.MozTransform = div.style.OTransform = div.style.msTransform = translate + ' ' + matrix;
+    };
+
+    /**
+     * Moves the upper-left corner of this VisibleLayer to the given X and Y
+     * coordinate, sets the Z stacking order, and reparents this VisibleLayer
+     * to the given VisibleLayer.
+     *
+     * @param {!Guacamole.Display.VisibleLayer} parent
+     *     The parent to set.
+     *
+     * @param {!number} x
+     *     The X coordinate to move to.
+     *
+     * @param {!number} y
+     *     The Y coordinate to move to.
+     *
+     * @param {!number} z
+     *     The Z coordinate to move to.
+     */
+    this.move = function (parent, x, y, z) {
+        // Set parent if necessary
+        if (layer.parent !== parent) {
+            // Maintain relationship
+            if (layer.parent) delete layer.parent.children[layer.__unique_id];
+            layer.parent = parent;
+            parent.children[layer.__unique_id] = layer;
+
+            // Reparent element
+            var parent_element = parent.getElement();
+            parent_element.appendChild(div);
+        }
+
+        // Set location
+        layer.translate(x, y);
+        layer.z = z;
+        div.style.zIndex = z;
+    };
+
+    /**
+     * Sets the opacity of this layer to the given value, where 255 is fully
+     * opaque and 0 is fully transparent.
+     *
+     * @param {!number} a
+     *     The opacity to set.
+     */
+    this.shade = function (a) {
+        layer.alpha = a;
+        div.style.opacity = a / 255.0;
+    };
+
+    /**
+     * Removes this layer container entirely, such that it is no longer
+     * contained within its parent layer, if any.
+     */
+    this.dispose = function () {
+        // Remove from parent container
+        if (layer.parent) {
+            delete layer.parent.children[layer.__unique_id];
+            layer.parent = null;
+        }
+
+        // Remove from parent element
+        if (div.parentNode) div.parentNode.removeChild(div);
+    };
+
+    /**
+     * Applies the given affine transform (defined with six values from the
+     * transform's matrix).
+     *
+     * @param {!number} a
+     *     The first value in the affine transform's matrix.
+     *
+     * @param {!number} b
+     *     The second value in the affine transform's matrix.
+     *
+     * @param {!number} c
+     *     The third value in the affine transform's matrix.
+     *
+     * @param {!number} d
+     *     The fourth value in the affine transform's matrix.
+     *
+     * @param {!number} e
+     *     The fifth value in the affine transform's matrix.
+     *
+     * @param {!number} f
+     *     The sixth value in the affine transform's matrix.
+     */
+    this.distort = function (a, b, c, d, e, f) {
+        // Store matrix
+        layer.matrix = [a, b, c, d, e, f];
+
+        // Generate matrix transformation
+        matrix =
+            /* a c e
+             * b d f
+             * 0 0 1
+             */
+
+            'matrix(' + a + ',' + b + ',' + c + ',' + d + ',' + e + ',' + f + ')';
+
+        // Set layer transform
+        div.style.transform = div.style.WebkitTransform = div.style.MozTransform = div.style.OTransform = div.style.msTransform = translate + ' ' + matrix;
+    };
+};
+
+/**
+ * The next identifier to be assigned to the layer container. This identifier
+ * uniquely identifies each VisibleLayer, but is unrelated to the index of
+ * the layer, which exists at the protocol/client level only.
+ *
+ * @private
+ * @type {!number}
+ */
+Guacamole.Display.VisibleLayer.__next_id = 0;
+
+/**
+ * A set of Guacamole display performance statistics, describing the speed at
+ * which the remote desktop, Guacamole server, and Guacamole client are
+ * rendering frames.
+ *
+ * @constructor
+ * @param {Guacamole.Display.Statistics|Object} [template={}]
+ *     The object whose properties should be copied within the new
+ *     Guacamole.Display.Statistics.
+ */
+Guacamole.Display.Statistics = function Statistics(template) {
+    template = template || {};
+
+    /**
+     * The amount of time that the Guacamole client is taking to render
+     * individual frames, in milliseconds, if known. If this value is unknown,
+     * such as if the there are insufficient frame statistics recorded to
+     * calculate this value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.processingLag = template.processingLag;
+
+    /**
+     * The framerate of the remote desktop currently being viewed within the
+     * relevant Gucamole.Display, independent of Guacamole, in frames per
+     * second. This represents the speed at which the remote desktop is
+     * producing frame data for the Guacamole server to consume. If this
+     * value is unknown, such as if the remote desktop server does not actually
+     * define frame boundaries, this will be null.
+     *
+     * @type {?number}
+     */
+    this.desktopFps = template.desktopFps;
+
+    /**
+     * The rate at which the Guacamole server is generating frames for the
+     * Guacamole client to consume, in frames per second. If the Guacamole
+     * server is correctly adjusting for variance in client/browser processing
+     * power, this rate should closely match the client rate, and should remain
+     * independent of any network latency. If this value is unknown, such as if
+     * the there are insufficient frame statistics recorded to calculate this
+     * value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.serverFps = template.serverFps;
+
+    /**
+     * The rate at which the Guacamole client is consuming frames generated by
+     * the Guacamole server, in frames per second. If the Guacamole server is
+     * correctly adjusting for variance in client/browser processing power,
+     * this rate should closely match the server rate, regardless of any
+     * latency on the network between the server and client. If this value is
+     * unknown, such as if the there are insufficient frame statistics recorded
+     * to calculate this value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.clientFps = template.clientFps;
+
+    /**
+     * The rate at which the Guacamole server is dropping or combining frames
+     * received from the remote desktop server to compensate for variance in
+     * client/browser processing power, in frames per second. This value may
+     * also be non-zero if the server is compensating for variances in its own
+     * processing power, or relative slowness in image compression vs. the rate
+     * that inbound frames are received. If this value is unknown, such as if
+     * the remote desktop server does not actually define frame boundaries,
+     * this will be null.
+     */
+    this.dropRate = template.dropRate;
+};
+
+/**
+ * An arbitrary event, emitted by a {@link Guacamole.Event.Target}. This object
+ * should normally serve as the base class for a different object that is more
+ * specific to the event type.
+ *
+ * @constructor
+ * @param {!string} type
+ *     The unique name of this event type.
+ */
+Guacamole.Event = function Event(type) {
+    /**
+     * The unique name of this event type.
+     *
+     * @type {!string}
+     */
+    this.type = type;
+
+    /**
+     * An arbitrary timestamp in milliseconds, indicating this event's
+     * position in time relative to other events.
+     *
+     * @type {!number}
+     */
+    this.timestamp = new Date().getTime();
+
+    /**
+     * Returns the number of milliseconds elapsed since this event was created.
+     *
+     * @return {!number}
+     *     The number of milliseconds elapsed since this event was created.
+     */
+    this.getAge = function getAge() {
+        return new Date().getTime() - this.timestamp;
+    };
+
+    /**
+     * Requests that the legacy event handler associated with this event be
+     * invoked on the given event target. This function will be invoked
+     * automatically by implementations of {@link Guacamole.Event.Target}
+     * whenever {@link Guacamole.Event.Target#emit emit()} is invoked.
+     * 
+     * Older versions of Guacamole relied on single event handlers with the
+     * prefix "on", such as "onmousedown" or "onkeyup". If a Guacamole.Event
+     * implementation is replacing the event previously represented by one of
+     * these handlers, this function gives the implementation the opportunity
+     * to provide backward compatibility with the old handler.
+     * 
+     * Unless overridden, this function does nothing.
+     *
+     * @param {!Guacamole.Event.Target} eventTarget
+     *     The {@link Guacamole.Event.Target} that emitted this event.
+     */
+    this.invokeLegacyHandler = function invokeLegacyHandler(eventTarget) {
+        // Do nothing
+    };
+};
+
+/**
+ * A {@link Guacamole.Event} that may relate to one or more DOM events.
+ * Continued propagation and default behavior of the related DOM events may be
+ * prevented with {@link Guacamole.Event.DOMEvent#stopPropagation stopPropagation()}
+ * and {@link Guacamole.Event.DOMEvent#preventDefault preventDefault()}
+ * respectively.
+ *
+ * @constructor
+ * @augments Guacamole.Event
+ *
+ * @param {!string} type
+ *     The unique name of this event type.
+ *
+ * @param {Event|Event[]} [events=[]]
+ *     The DOM events that are related to this event, if any. Future calls to
+ *     {@link Guacamole.Event.DOMEvent#preventDefault preventDefault()} and
+ *     {@link Guacamole.Event.DOMEvent#stopPropagation stopPropagation()} will
+ *     affect these events.
+ */
+Guacamole.Event.DOMEvent = function DOMEvent(type, events) {
+    Guacamole.Event.call(this, type);
+
+    // Default to empty array
+    events = events || [];
+
+    // Automatically wrap non-array single Event in an array
+    if (!Array.isArray(events)) events = [events];
+
+    /**
+     * Requests that the default behavior of related DOM events be prevented.
+     * Whether this request will be honored by the browser depends on the
+     * nature of those events and the timing of the request.
+     */
+    this.preventDefault = function preventDefault() {
+        events.forEach(function applyPreventDefault(event) {
+            if (event.preventDefault) event.preventDefault();
+            event.returnValue = false;
+        });
+    };
+
+    /**
+     * Stops further propagation of related events through the DOM. Only events
+     * that are directly related to this event will be stopped.
+     */
+    this.stopPropagation = function stopPropagation() {
+        events.forEach(function applyStopPropagation(event) {
+            event.stopPropagation();
+        });
+    };
+};
+
+/**
+ * Convenience function for cancelling all further processing of a given DOM
+ * event. Invoking this function prevents the default behavior of the event and
+ * stops any further propagation.
+ *
+ * @param {!Event} event
+ *     The DOM event to cancel.
+ */
+Guacamole.Event.DOMEvent.cancelEvent = function cancelEvent(event) {
+    event.stopPropagation();
+    if (event.preventDefault) event.preventDefault();
+    event.returnValue = false;
+};
+
+/**
+ * An object which can dispatch {@link Guacamole.Event} objects. Listeners
+ * registered with {@link Guacamole.Event.Target#on on()} will automatically
+ * be invoked based on the type of {@link Guacamole.Event} passed to
+ * {@link Guacamole.Event.Target#dispatch dispatch()}. It is normally
+ * subclasses of Guacamole.Event.Target that will dispatch events, and usages
+ * of those subclasses that will catch dispatched events with on().
+ *
+ * @constructor
+ */
+Guacamole.Event.Target = function Target() {
+    /**
+     * A callback function which handles an event dispatched by an event
+     * target.
+     *
+     * @callback Guacamole.Event.Target~listener
+     * @param {!Guacamole.Event} event
+     *     The event that was dispatched.
+     *
+     * @param {!Guacamole.Event.Target} target
+     *     The object that dispatched the event.
+     */
+
+    /**
+     * All listeners (callback functions) registered for each event type passed
+     * to {@link Guacamole.Event.Targer#on on()}.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var listeners = {};
+
+    /**
+     * Registers a listener for events having the given type, as dictated by
+     * the {@link Guacamole.Event#type type} property of {@link Guacamole.Event}
+     * provided to {@link Guacamole.Event.Target#dispatch dispatch()}.
+     *
+     * @param {!string} type
+     *     The unique name of this event type.
+     *
+     * @param {!Guacamole.Event.Target~listener} listener
+     *     The function to invoke when an event having the given type is
+     *     dispatched. The {@link Guacamole.Event} object provided to
+     *     {@link Guacamole.Event.Target#dispatch dispatch()} will be passed to
+     *     this function, along with the dispatching Guacamole.Event.Target.
+     */
+    this.on = function on(type, listener) {
+        var relevantListeners = listeners[type];
+        if (!relevantListeners) listeners[type] = relevantListeners = [];
+
+        relevantListeners.push(listener);
+    };
+
+    /**
+     * Registers a listener for events having the given types, as dictated by
+     * the {@link Guacamole.Event#type type} property of {@link Guacamole.Event}
+     * provided to {@link Guacamole.Event.Target#dispatch dispatch()}.
+     * 
+     * Invoking this function is equivalent to manually invoking
+     * {@link Guacamole.Event.Target#on on()} for each of the provided types.
+     *
+     * @param {!string[]} types
+     *     The unique names of the event types to associate with the given
+     *     listener.
+     *
+     * @param {!Guacamole.Event.Target~listener} listener
+     *     The function to invoke when an event having any of the given types
+     *     is dispatched. The {@link Guacamole.Event} object provided to
+     *     {@link Guacamole.Event.Target#dispatch dispatch()} will be passed to
+     *     this function, along with the dispatching Guacamole.Event.Target.
+     */
+    this.onEach = function onEach(types, listener) {
+        types.forEach(function addListener(type) {
+            this.on(type, listener);
+        }, this);
+    };
+
+    /**
+     * Dispatches the given event, invoking all event handlers registered with
+     * this Guacamole.Event.Target for that event's
+     * {@link Guacamole.Event#type type}.
+     *
+     * @param {!Guacamole.Event} event
+     *     The event to dispatch.
+     */
+    this.dispatch = function dispatch(event) {
+        // Invoke any relevant legacy handler for the event
+        event.invokeLegacyHandler(this);
+
+        // Invoke all registered listeners
+        var relevantListeners = listeners[event.type];
+        if (relevantListeners) {
+            for (var i = 0; i < relevantListeners.length; i++) {
+                relevantListeners[i](event, this);
+            }
+        }
+    };
+
+    /**
+     * Unregisters a listener that was previously registered with
+     * {@link Guacamole.Event.Target#on on()} or
+     * {@link Guacamole.Event.Target#onEach onEach()}. If no such listener was
+     * registered, this function has no effect. If multiple copies of the same
+     * listener were registered, the first listener still registered will be
+     * removed.
+     *
+     * @param {!string} type
+     *     The unique name of the event type handled by the listener being
+     *     removed.
+     *
+     * @param {!Guacamole.Event.Target~listener} listener
+     *     The listener function previously provided to
+     *     {@link Guacamole.Event.Target#on on()}or
+     *     {@link Guacamole.Event.Target#onEach onEach()}.
+     *
+     * @returns {!boolean}
+     *     true if the specified listener was removed, false otherwise.
+     */
+    this.off = function off(type, listener) {
+        var relevantListeners = listeners[type];
+        if (!relevantListeners) return false;
+
+        for (var i = 0; i < relevantListeners.length; i++) {
+            if (relevantListeners[i] === listener) {
+                relevantListeners.splice(i, 1);
+                return true;
+            }
+        }
+
+        return false;
+    };
+
+    /**
+     * Unregisters listeners that were previously registered with
+     * {@link Guacamole.Event.Target#on on()} or
+     * {@link Guacamole.Event.Target#onEach onEach()}. If no such listeners
+     * were registered, this function has no effect. If multiple copies of the
+     * same listener were registered for the same event type, the first
+     * listener still registered will be removed.
+     * 
+     * Invoking this function is equivalent to manually invoking
+     * {@link Guacamole.Event.Target#off off()} for each of the provided types.
+     *
+     * @param {!string[]} types
+     *     The unique names of the event types handled by the listeners being
+     *     removed.
+     *
+     * @param {!Guacamole.Event.Target~listener} listener
+     *     The listener function previously provided to
+     *     {@link Guacamole.Event.Target#on on()} or
+     *     {@link Guacamole.Event.Target#onEach onEach()}.
+     *
+     * @returns {!boolean}
+     *     true if any of the specified listeners were removed, false
+     *     otherwise.
+     */
+    this.offEach = function offEach(types, listener) {
+        var changed = false;
+
+        types.forEach(function removeListener(type) {
+            changed |= this.off(type, listener);
+        }, this);
+
+        return changed;
+    };
+};
+
+/**
+ * A hidden input field which attempts to keep itself focused at all times,
+ * except when another input field has been intentionally focused, whether
+ * programatically or by the user. The actual underlying input field, returned
+ * by getElement(), may be used as a reliable source of keyboard-related events,
+ * particularly composition and input events which may require a focused input
+ * field to be dispatched at all.
+ *
+ * @constructor
+ */
+Guacamole.InputSink = function InputSink() {
+    /**
+     * Reference to this instance of Guacamole.InputSink.
+     *
+     * @private
+     * @type {!Guacamole.InputSink}
+     */
+    var sink = this;
+
+    /**
+     * The underlying input field, styled to be invisible.
+     *
+     * @private
+     * @type {!Element}
+     */
+    var field = document.createElement('textarea');
+    field.style.position = 'fixed';
+    field.style.outline = 'none';
+    field.style.border = 'none';
+    field.style.margin = '0';
+    field.style.padding = '0';
+    field.style.height = '0';
+    field.style.width = '0';
+    field.style.left = '0';
+    field.style.bottom = '0';
+    field.style.resize = 'none';
+    field.style.background = 'transparent';
+    field.style.color = 'transparent';
+
+    // Keep field clear when modified via normal keypresses
+    field.addEventListener(
+        'keypress',
+        function clearKeypress(e) {
+            field.value = '';
+        },
+        false
+    );
+
+    // Keep field clear when modofied via composition events
+    field.addEventListener(
+        'compositionend',
+        function clearCompletedComposition(e) {
+            if (e.data) field.value = '';
+        },
+        false
+    );
+
+    // Keep field clear when modofied via input events
+    field.addEventListener(
+        'input',
+        function clearCompletedInput(e) {
+            if (e.data && !e.isComposing) field.value = '';
+        },
+        false
+    );
+
+    // Whenever focus is gained, automatically click to ensure cursor is
+    // actually placed within the field (the field may simply be highlighted or
+    // outlined otherwise)
+    field.addEventListener(
+        'focus',
+        function focusReceived() {
+            window.setTimeout(function deferRefocus() {
+                field.click();
+                field.select();
+            }, 0);
+        },
+        true
+    );
+
+    /**
+     * Attempts to focus the underlying input field. The focus attempt occurs
+     * asynchronously, and may silently fail depending on browser restrictions.
+     */
+    this.focus = function focus() {
+        window.setTimeout(function deferRefocus() {
+            field.focus(); // Focus must be deferred to work reliably across browsers
+        }, 0);
+    };
+
+    /**
+     * Returns the underlying input field. This input field MUST be manually
+     * added to the DOM for the Guacamole.InputSink to have any effect.
+     *
+     * @returns {!Element}
+     *     The underlying input field.
+     */
+    this.getElement = function getElement() {
+        return field;
+    };
+
+    // Automatically refocus input sink if part of DOM
+    document.addEventListener(
+        'keydown',
+        function refocusSink(e) {
+            // Do not refocus if focus is on an input field
+            var focused = document.activeElement;
+            if (focused && focused !== document.body) {
+                // Only consider focused input fields which are actually visible
+                var rect = focused.getBoundingClientRect();
+                if (rect.left + rect.width > 0 && rect.top + rect.height > 0) return;
+            }
+
+            // Refocus input sink instead of handling click
+            sink.focus();
+        },
+        true
+    );
+};
+
+/**
+ * An input stream abstraction used by the Guacamole client to facilitate
+ * transfer of files or other binary data.
+ *
+ * @constructor
+ * @param {!Guacamole.Client} client
+ *     The client owning this stream.
+ *
+ * @param {!number} index
+ *     The index of this stream.
+ */
+Guacamole.InputStream = function (client, index) {
+    /**
+     * Reference to this stream.
+     *
+     * @private
+     * @type {!Guacamole.InputStream}
+     */
+    var guac_stream = this;
+
+    /**
+     * The index of this stream.
+     *
+     * @type {!number}
+     */
+    this.index = index;
+
+    /**
+     * Called when a blob of data is received.
+     *
+     * @event
+     * @param {!string} data
+     *     The received base64 data.
+     */
+    this.onblob = null;
+
+    /**
+     * Called when this stream is closed.
+     *
+     * @event
+     */
+    this.onend = null;
+
+    /**
+     * Acknowledges the receipt of a blob.
+     *
+     * @param {!string} message
+     *     A human-readable message describing the error or status.
+     *
+     * @param {!number} code
+     *     The error code, if any, or 0 for success.
+     */
+    this.sendAck = function (message, code) {
+        client.sendAck(guac_stream.index, message, code);
+    };
+};
+
+/**
+ * Integer pool which returns consistently increasing integers while integers
+ * are in use, and previously-used integers when possible.
+ * @constructor
+ */
+Guacamole.IntegerPool = function () {
+    /**
+     * Reference to this integer pool.
+     *
+     * @private
+     */
+    var guac_pool = this;
+
+    /**
+     * Array of available integers.
+     *
+     * @private
+     * @type {!number[]}
+     */
+    var pool = [];
+
+    /**
+     * The next integer to return if no more integers remain.
+     *
+     * @type {!number}
+     */
+    this.next_int = 0;
+
+    /**
+     * Returns the next available integer in the pool. If possible, a previously
+     * used integer will be returned.
+     *
+     * @return {!number}
+     *     The next available integer.
+     */
+    this.next = function () {
+        // If free'd integers exist, return one of those
+        if (pool.length > 0) return pool.shift();
+
+        // Otherwise, return a new integer
+        return guac_pool.next_int++;
+    };
+
+    /**
+     * Frees the given integer, allowing it to be reused.
+     *
+     * @param {!number} integer
+     *     The integer to free.
+     */
+    this.free = function (integer) {
+        pool.push(integer);
+    };
+};
+
+/**
+ * A reader which automatically handles the given input stream, assembling all
+ * received blobs into a JavaScript object by appending them to each other, in
+ * order, and decoding the result as JSON. Note that this object will overwrite
+ * any installed event handlers on the given Guacamole.InputStream.
+ *
+ * @constructor
+ * @param {Guacamole.InputStream} stream
+ *     The stream that JSON will be read from.
+ */
+Guacamole.JSONReader = function guacamoleJSONReader(stream) {
+    /**
+     * Reference to this Guacamole.JSONReader.
+     *
+     * @private
+     * @type {!Guacamole.JSONReader}
+     */
+    var guacReader = this;
+
+    /**
+     * Wrapped Guacamole.StringReader.
+     *
+     * @private
+     * @type {!Guacamole.StringReader}
+     */
+    var stringReader = new Guacamole.StringReader(stream);
+
+    /**
+     * All JSON read thus far.
+     *
+     * @private
+     * @type {!string}
+     */
+    var json = '';
+
+    /**
+     * Returns the current length of this Guacamole.JSONReader, in characters.
+     *
+     * @return {!number}
+     *     The current length of this Guacamole.JSONReader.
+     */
+    this.getLength = function getLength() {
+        return json.length;
+    };
+
+    /**
+     * Returns the contents of this Guacamole.JSONReader as a JavaScript
+     * object.
+     *
+     * @return {object}
+     *     The contents of this Guacamole.JSONReader, as parsed from the JSON
+     *     contents of the input stream.
+     */
+    this.getJSON = function getJSON() {
+        return JSON.parse(json);
+    };
+
+    // Append all received text
+    stringReader.ontext = function ontext(text) {
+        // Append received text
+        json += text;
+
+        // Call handler, if present
+        if (guacReader.onprogress) guacReader.onprogress(text.length);
+    };
+
+    // Simply call onend when end received
+    stringReader.onend = function onend() {
+        if (guacReader.onend) guacReader.onend();
+    };
+
+    /**
+     * Fired once for every blob of data received.
+     *
+     * @event
+     * @param {!number} length
+     *     The number of characters received.
+     */
+    this.onprogress = null;
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     *
+     * @event
+     */
+    this.onend = null;
+};
+
+/**
+ * Provides cross-browser and cross-keyboard keyboard for a specific element.
+ * Browser and keyboard layout variation is abstracted away, providing events
+ * which represent keys as their corresponding X11 keysym.
+ *
+ * @constructor
+ * @param {Element|Document} [element]
+ *    The Element to use to provide keyboard events. If omitted, at least one
+ *    Element must be manually provided through the listenTo() function for
+ *    the Guacamole.Keyboard instance to have any effect.
+ */
+Guacamole.Keyboard = function Keyboard(element) {
+    /**
+     * Reference to this Guacamole.Keyboard.
+     *
+     * @private
+     * @type {!Guacamole.Keyboard}
+     */
+    var guac_keyboard = this;
+
+    /**
+     * An integer value which uniquely identifies this Guacamole.Keyboard
+     * instance with respect to other Guacamole.Keyboard instances.
+     *
+     * @private
+     * @type {!number}
+     */
+    var guacKeyboardID = Guacamole.Keyboard._nextID++;
+
+    /**
+     * The name of the property which is added to event objects via markEvent()
+     * to note that they have already been handled by this Guacamole.Keyboard.
+     *
+     * @private
+     * @constant
+     * @type {!string}
+     */
+    var EVENT_MARKER = '_GUAC_KEYBOARD_HANDLED_BY_' + guacKeyboardID;
+
+    /**
+     * Fired whenever the user presses a key with the element associated
+     * with this Guacamole.Keyboard in focus.
+     *
+     * @event
+     * @param {!number} keysym
+     *     The keysym of the key being pressed.
+     *
+     * @return {!boolean}
+     *     true if the key event should be allowed through to the browser,
+     *     false otherwise.
+     */
+    this.onkeydown = null;
+
+    /**
+     * Fired whenever the user releases a key with the element associated
+     * with this Guacamole.Keyboard in focus.
+     *
+     * @event
+     * @param {!number} keysym
+     *     The keysym of the key being released.
+     */
+    this.onkeyup = null;
+
+    /**
+     * Set of known platform-specific or browser-specific quirks which must be
+     * accounted for to properly interpret key events, even if the only way to
+     * reliably detect that quirk is to platform/browser-sniff.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var quirks = {
+        /**
+         * Whether keyup events are universally unreliable.
+         *
+         * @type {!boolean}
+         */
+        keyupUnreliable: false,
+
+        /**
+         * Whether the Alt key is actually a modifier for typable keys and is
+         * thus never used for keyboard shortcuts.
+         *
+         * @type {!boolean}
+         */
+        altIsTypableOnly: false,
+
+        /**
+         * Whether we can rely on receiving a keyup event for the Caps Lock
+         * key.
+         *
+         * @type {!boolean}
+         */
+        capsLockKeyupUnreliable: false,
+    };
+
+    // Set quirk flags depending on platform/browser, if such information is
+    // available
+    if (navigator && navigator.platform) {
+        // All keyup events are unreliable on iOS (sadly)
+        if (navigator.platform.match(/ipad|iphone|ipod/i)) quirks.keyupUnreliable = true;
+        // The Alt key on Mac is never used for keyboard shortcuts, and the
+        // Caps Lock key never dispatches keyup events
+        else if (navigator.platform.match(/^mac/i)) {
+            quirks.altIsTypableOnly = true;
+            quirks.capsLockKeyupUnreliable = true;
+        }
+    }
+
+    /**
+     * A key event having a corresponding timestamp. This event is non-specific.
+     * Its subclasses should be used instead when recording specific key
+     * events.
+     *
+     * @private
+     * @constructor
+     * @param {KeyboardEvent} [orig]
+     *     The relevant DOM keyboard event.
+     */
+    var KeyEvent = function KeyEvent(orig) {
+        /**
+         * Reference to this key event.
+         *
+         * @private
+         * @type {!KeyEvent}
+         */
+        var key_event = this;
+
+        /**
+         * The JavaScript key code of the key pressed. For most events (keydown
+         * and keyup), this is a scancode-like value related to the position of
+         * the key on the US English "Qwerty" keyboard. For keypress events,
+         * this is the Unicode codepoint of the character that would be typed
+         * by the key pressed.
+         *
+         * @type {!number}
+         */
+        this.keyCode = orig ? orig.which || orig.keyCode : 0;
+
+        /**
+         * The legacy DOM3 "keyIdentifier" of the key pressed, as defined at:
+         * http://www.w3.org/TR/2009/WD-DOM-Level-3-Events-20090908/#events-Events-KeyboardEvent
+         *
+         * @type {!string}
+         */
+        this.keyIdentifier = orig && orig.keyIdentifier;
+
+        /**
+         * The standard name of the key pressed, as defined at:
+         * http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+         *
+         * @type {!string}
+         */
+        this.key = orig && orig.key;
+
+        /**
+         * The location on the keyboard corresponding to the key pressed, as
+         * defined at:
+         * http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+         *
+         * @type {!number}
+         */
+        this.location = orig ? getEventLocation(orig) : 0;
+
+        /**
+         * The state of all local keyboard modifiers at the time this event was
+         * received.
+         *
+         * @type {!Guacamole.Keyboard.ModifierState}
+         */
+        this.modifiers = orig ? Guacamole.Keyboard.ModifierState.fromKeyboardEvent(orig) : new Guacamole.Keyboard.ModifierState();
+
+        /**
+         * An arbitrary timestamp in milliseconds, indicating this event's
+         * position in time relative to other events.
+         *
+         * @type {!number}
+         */
+        this.timestamp = new Date().getTime();
+
+        /**
+         * Whether the default action of this key event should be prevented.
+         *
+         * @type {!boolean}
+         */
+        this.defaultPrevented = false;
+
+        /**
+         * The keysym of the key associated with this key event, as determined
+         * by a best-effort guess using available event properties and keyboard
+         * state.
+         *
+         * @type {number}
+         */
+        this.keysym = null;
+
+        /**
+         * Whether the keysym value of this key event is known to be reliable.
+         * If false, the keysym may still be valid, but it's only a best guess,
+         * and future key events may be a better source of information.
+         *
+         * @type {!boolean}
+         */
+        this.reliable = false;
+
+        /**
+         * Returns the number of milliseconds elapsed since this event was
+         * received.
+         *
+         * @return {!number}
+         *     The number of milliseconds elapsed since this event was
+         *     received.
+         */
+        this.getAge = function () {
+            return new Date().getTime() - key_event.timestamp;
+        };
+    };
+
+    /**
+     * Information related to the pressing of a key, which need not be a key
+     * associated with a printable character. The presence or absence of any
+     * information within this object is browser-dependent.
+     *
+     * @private
+     * @constructor
+     * @augments Guacamole.Keyboard.KeyEvent
+     * @param {!KeyboardEvent} orig
+     *     The relevant DOM "keydown" event.
+     */
+    var KeydownEvent = function KeydownEvent(orig) {
+        // We extend KeyEvent
+        KeyEvent.call(this, orig);
+
+        // If key is known from keyCode or DOM3 alone, use that
+        this.keysym = keysym_from_key_identifier(this.key, this.location) || keysym_from_keycode(this.keyCode, this.location);
+
+        /**
+         * Whether the keyup following this keydown event is known to be
+         * reliable. If false, we cannot rely on the keyup event to occur.
+         *
+         * @type {!boolean}
+         */
+        this.keyupReliable = !quirks.keyupUnreliable;
+
+        // DOM3 and keyCode are reliable sources if the corresponding key is
+        // not a printable key
+        if (this.keysym && !isPrintable(this.keysym)) this.reliable = true;
+
+        // Use legacy keyIdentifier as a last resort, if it looks sane
+        if (!this.keysym && key_identifier_sane(this.keyCode, this.keyIdentifier))
+            this.keysym = keysym_from_key_identifier(this.keyIdentifier, this.location, this.modifiers.shift);
+
+        // If a key is pressed while meta is held down, the keyup will
+        // never be sent in Chrome (bug #108404)
+        if (this.modifiers.meta && this.keysym !== 0xffe7 && this.keysym !== 0xffe8) this.keyupReliable = false;
+        // We cannot rely on receiving keyup for Caps Lock on certain platforms
+        else if (this.keysym === 0xffe5 && quirks.capsLockKeyupUnreliable) this.keyupReliable = false;
+
+        // Determine whether default action for Alt+combinations must be prevented
+        var prevent_alt = !this.modifiers.ctrl && !quirks.altIsTypableOnly;
+
+        // Determine whether default action for Ctrl+combinations must be prevented
+        var prevent_ctrl = !this.modifiers.alt;
+
+        // We must rely on the (potentially buggy) keyIdentifier if preventing
+        // the default action is important
+        if ((prevent_ctrl && this.modifiers.ctrl) || (prevent_alt && this.modifiers.alt) || this.modifiers.meta || this.modifiers.hyper) this.reliable = true;
+
+        // Record most recently known keysym by associated key code
+        recentKeysym[this.keyCode] = this.keysym;
+    };
+
+    KeydownEvent.prototype = new KeyEvent();
+
+    /**
+     * Information related to the pressing of a key, which MUST be
+     * associated with a printable character. The presence or absence of any
+     * information within this object is browser-dependent.
+     *
+     * @private
+     * @constructor
+     * @augments Guacamole.Keyboard.KeyEvent
+     * @param {!KeyboardEvent} orig
+     *     The relevant DOM "keypress" event.
+     */
+    var KeypressEvent = function KeypressEvent(orig) {
+        // We extend KeyEvent
+        KeyEvent.call(this, orig);
+
+        // Pull keysym from char code
+        this.keysym = keysym_from_charcode(this.keyCode);
+
+        // Keypress is always reliable
+        this.reliable = true;
+    };
+
+    KeypressEvent.prototype = new KeyEvent();
+
+    /**
+     * Information related to the releasing of a key, which need not be a key
+     * associated with a printable character. The presence or absence of any
+     * information within this object is browser-dependent.
+     *
+     * @private
+     * @constructor
+     * @augments Guacamole.Keyboard.KeyEvent
+     * @param {!KeyboardEvent} orig
+     *     The relevant DOM "keyup" event.
+     */
+    var KeyupEvent = function KeyupEvent(orig) {
+        // We extend KeyEvent
+        KeyEvent.call(this, orig);
+
+        // If key is known from keyCode or DOM3 alone, use that (keyCode is
+        // still more reliable for keyup when dead keys are in use)
+        this.keysym = keysym_from_keycode(this.keyCode, this.location) || keysym_from_key_identifier(this.key, this.location);
+
+        // Fall back to the most recently pressed keysym associated with the
+        // keyCode if the inferred key doesn't seem to actually be pressed
+        if (!guac_keyboard.pressed[this.keysym]) this.keysym = recentKeysym[this.keyCode] || this.keysym;
+
+        // Keyup is as reliable as it will ever be
+        this.reliable = true;
+    };
+
+    KeyupEvent.prototype = new KeyEvent();
+
+    /**
+     * An array of recorded events, which can be instances of the private
+     * KeydownEvent, KeypressEvent, and KeyupEvent classes.
+     *
+     * @private
+     * @type {!KeyEvent[]}
+     */
+    var eventLog = [];
+
+    /**
+     * Map of known JavaScript keycodes which do not map to typable characters
+     * to their X11 keysym equivalents.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var keycodeKeysyms = {
+        8: [0xff08], // backspace
+        9: [0xff09], // tab
+        12: [0xff0b, 0xff0b, 0xff0b, 0xffb5], // clear       / KP 5
+        13: [0xff0d], // enter
+        16: [0xffe1, 0xffe1, 0xffe2], // shift
+        17: [0xffe3, 0xffe3, 0xffe4], // ctrl
+        18: [0xffe9, 0xffe9, 0xfe03], // alt
+        19: [0xff13], // pause/break
+        20: [0xffe5], // caps lock
+        27: [0xff1b], // escape
+        32: [0x0020], // space
+        33: [0xff55, 0xff55, 0xff55, 0xffb9], // page up     / KP 9
+        34: [0xff56, 0xff56, 0xff56, 0xffb3], // page down   / KP 3
+        35: [0xff57, 0xff57, 0xff57, 0xffb1], // end         / KP 1
+        36: [0xff50, 0xff50, 0xff50, 0xffb7], // home        / KP 7
+        37: [0xff51, 0xff51, 0xff51, 0xffb4], // left arrow  / KP 4
+        38: [0xff52, 0xff52, 0xff52, 0xffb8], // up arrow    / KP 8
+        39: [0xff53, 0xff53, 0xff53, 0xffb6], // right arrow / KP 6
+        40: [0xff54, 0xff54, 0xff54, 0xffb2], // down arrow  / KP 2
+        45: [0xff63, 0xff63, 0xff63, 0xffb0], // insert      / KP 0
+        46: [0xffff, 0xffff, 0xffff, 0xffae], // delete      / KP decimal
+        91: [0xffe7], // left windows/command key (meta_l)
+        92: [0xffe8], // right window/command key (meta_r)
+        93: [0xff67], // menu key
+        96: [0xffb0], // KP 0
+        97: [0xffb1], // KP 1
+        98: [0xffb2], // KP 2
+        99: [0xffb3], // KP 3
+        100: [0xffb4], // KP 4
+        101: [0xffb5], // KP 5
+        102: [0xffb6], // KP 6
+        103: [0xffb7], // KP 7
+        104: [0xffb8], // KP 8
+        105: [0xffb9], // KP 9
+        106: [0xffaa], // KP multiply
+        107: [0xffab], // KP add
+        109: [0xffad], // KP subtract
+        110: [0xffae], // KP decimal
+        111: [0xffaf], // KP divide
+        112: [0xffbe], // f1
+        113: [0xffbf], // f2
+        114: [0xffc0], // f3
+        115: [0xffc1], // f4
+        116: [0xffc2], // f5
+        117: [0xffc3], // f6
+        118: [0xffc4], // f7
+        119: [0xffc5], // f8
+        120: [0xffc6], // f9
+        121: [0xffc7], // f10
+        122: [0xffc8], // f11
+        123: [0xffc9], // f12
+        144: [0xff7f], // num lock
+        145: [0xff14], // scroll lock
+        225: [0xfe03], // altgraph (iso_level3_shift)
+    };
+
+    /**
+     * Map of known JavaScript keyidentifiers which do not map to typable
+     * characters to their unshifted X11 keysym equivalents.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var keyidentifier_keysym = {
+        Again: [0xff66],
+        AllCandidates: [0xff3d],
+        Alphanumeric: [0xff30],
+        Alt: [0xffe9, 0xffe9, 0xfe03],
+        Attn: [0xfd0e],
+        AltGraph: [0xfe03],
+        ArrowDown: [0xff54],
+        ArrowLeft: [0xff51],
+        ArrowRight: [0xff53],
+        ArrowUp: [0xff52],
+        Backspace: [0xff08],
+        CapsLock: [0xffe5],
+        Cancel: [0xff69],
+        Clear: [0xff0b],
+        Convert: [0xff21],
+        Copy: [0xfd15],
+        Crsel: [0xfd1c],
+        CrSel: [0xfd1c],
+        CodeInput: [0xff37],
+        Compose: [0xff20],
+        Control: [0xffe3, 0xffe3, 0xffe4],
+        ContextMenu: [0xff67],
+        Delete: [0xffff],
+        Down: [0xff54],
+        End: [0xff57],
+        Enter: [0xff0d],
+        EraseEof: [0xfd06],
+        Escape: [0xff1b],
+        Execute: [0xff62],
+        Exsel: [0xfd1d],
+        ExSel: [0xfd1d],
+        F1: [0xffbe],
+        F2: [0xffbf],
+        F3: [0xffc0],
+        F4: [0xffc1],
+        F5: [0xffc2],
+        F6: [0xffc3],
+        F7: [0xffc4],
+        F8: [0xffc5],
+        F9: [0xffc6],
+        F10: [0xffc7],
+        F11: [0xffc8],
+        F12: [0xffc9],
+        F13: [0xffca],
+        F14: [0xffcb],
+        F15: [0xffcc],
+        F16: [0xffcd],
+        F17: [0xffce],
+        F18: [0xffcf],
+        F19: [0xffd0],
+        F20: [0xffd1],
+        F21: [0xffd2],
+        F22: [0xffd3],
+        F23: [0xffd4],
+        F24: [0xffd5],
+        Find: [0xff68],
+        GroupFirst: [0xfe0c],
+        GroupLast: [0xfe0e],
+        GroupNext: [0xfe08],
+        GroupPrevious: [0xfe0a],
+        FullWidth: null,
+        HalfWidth: null,
+        HangulMode: [0xff31],
+        Hankaku: [0xff29],
+        HanjaMode: [0xff34],
+        Help: [0xff6a],
+        Hiragana: [0xff25],
+        HiraganaKatakana: [0xff27],
+        Home: [0xff50],
+        Hyper: [0xffed, 0xffed, 0xffee],
+        Insert: [0xff63],
+        JapaneseHiragana: [0xff25],
+        JapaneseKatakana: [0xff26],
+        JapaneseRomaji: [0xff24],
+        JunjaMode: [0xff38],
+        KanaMode: [0xff2d],
+        KanjiMode: [0xff21],
+        Katakana: [0xff26],
+        Left: [0xff51],
+        Meta: [0xffe7, 0xffe7, 0xffe8],
+        ModeChange: [0xff7e],
+        NumLock: [0xff7f],
+        PageDown: [0xff56],
+        PageUp: [0xff55],
+        Pause: [0xff13],
+        Play: [0xfd16],
+        PreviousCandidate: [0xff3e],
+        PrintScreen: [0xff61],
+        Redo: [0xff66],
+        Right: [0xff53],
+        RomanCharacters: null,
+        Scroll: [0xff14],
+        Select: [0xff60],
+        Separator: [0xffac],
+        Shift: [0xffe1, 0xffe1, 0xffe2],
+        SingleCandidate: [0xff3c],
+        Super: [0xffeb, 0xffeb, 0xffec],
+        Tab: [0xff09],
+        UIKeyInputDownArrow: [0xff54],
+        UIKeyInputEscape: [0xff1b],
+        UIKeyInputLeftArrow: [0xff51],
+        UIKeyInputRightArrow: [0xff53],
+        UIKeyInputUpArrow: [0xff52],
+        Up: [0xff52],
+        Undo: [0xff65],
+        Win: [0xffe7, 0xffe7, 0xffe8],
+        Zenkaku: [0xff28],
+        ZenkakuHankaku: [0xff2a],
+    };
+
+    /**
+     * All keysyms which should not repeat when held down.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var no_repeat = {
+        0xfe03: true, // ISO Level 3 Shift (AltGr)
+        0xffe1: true, // Left shift
+        0xffe2: true, // Right shift
+        0xffe3: true, // Left ctrl
+        0xffe4: true, // Right ctrl
+        0xffe5: true, // Caps Lock
+        0xffe7: true, // Left meta
+        0xffe8: true, // Right meta
+        0xffe9: true, // Left alt
+        0xffea: true, // Right alt
+        0xffeb: true, // Left super/hyper
+        0xffec: true, // Right super/hyper
+    };
+
+    /**
+     * All modifiers and their states.
+     *
+     * @type {!Guacamole.Keyboard.ModifierState}
+     */
+    this.modifiers = new Guacamole.Keyboard.ModifierState();
+
+    /**
+     * The state of every key, indexed by keysym. If a particular key is
+     * pressed, the value of pressed for that keysym will be true. If a key
+     * is not currently pressed, it will not be defined.
+     *
+     * @type {!Object.}
+     */
+    this.pressed = {};
+
+    /**
+     * The state of every key, indexed by keysym, for strictly those keys whose
+     * status has been indirectly determined thorugh observation of other key
+     * events. If a particular key is implicitly pressed, the value of
+     * implicitlyPressed for that keysym will be true. If a key
+     * is not currently implicitly pressed (the key is not pressed OR the state
+     * of the key is explicitly known), it will not be defined.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var implicitlyPressed = {};
+
+    /**
+     * The last result of calling the onkeydown handler for each key, indexed
+     * by keysym. This is used to prevent/allow default actions for key events,
+     * even when the onkeydown handler cannot be called again because the key
+     * is (theoretically) still pressed.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var last_keydown_result = {};
+
+    /**
+     * The keysym most recently associated with a given keycode when keydown
+     * fired. This object maps keycodes to keysyms.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var recentKeysym = {};
+
+    /**
+     * Timeout before key repeat starts.
+     *
+     * @private
+     * @type {number}
+     */
+    var key_repeat_timeout = null;
+
+    /**
+     * Interval which presses and releases the last key pressed while that
+     * key is still being held down.
+     *
+     * @private
+     * @type {number}
+     */
+    var key_repeat_interval = null;
+
+    /**
+     * Given an array of keysyms indexed by location, returns the keysym
+     * for the given location, or the keysym for the standard location if
+     * undefined.
+     *
+     * @private
+     * @param {number[]} keysyms
+     *     An array of keysyms, where the index of the keysym in the array is
+     *     the location value.
+     *
+     * @param {!number} location
+     *     The location on the keyboard corresponding to the key pressed, as
+     *     defined at: http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     */
+    var get_keysym = function get_keysym(keysyms, location) {
+        if (!keysyms) return null;
+
+        return keysyms[location] || keysyms[0];
+    };
+
+    /**
+     * Returns true if the given keysym corresponds to a printable character,
+     * false otherwise.
+     *
+     * @param {!number} keysym
+     *     The keysym to check.
+     *
+     * @returns {!boolean}
+     *     true if the given keysym corresponds to a printable character,
+     *     false otherwise.
+     */
+    var isPrintable = function isPrintable(keysym) {
+        // Keysyms with Unicode equivalents are printable
+        return (keysym >= 0x00 && keysym <= 0xff) || (keysym & 0xffff0000) === 0x01000000;
+    };
+
+    function keysym_from_key_identifier(identifier, location, shifted) {
+        if (!identifier) return null;
+
+        var typedCharacter;
+
+        // If identifier is U+xxxx, decode Unicode character
+        var unicodePrefixLocation = identifier.indexOf('U+');
+        if (unicodePrefixLocation >= 0) {
+            var hex = identifier.substring(unicodePrefixLocation + 2);
+            typedCharacter = String.fromCharCode(parseInt(hex, 16));
+        }
+
+        // If single character and not keypad, use that as typed character
+        else if (identifier.length === 1 && location !== 3) typedCharacter = identifier;
+        // Otherwise, look up corresponding keysym
+        else return get_keysym(keyidentifier_keysym[identifier], location);
+
+        // Alter case if necessary
+        if (shifted === true) typedCharacter = typedCharacter.toUpperCase();
+        else if (shifted === false) typedCharacter = typedCharacter.toLowerCase();
+
+        // Get codepoint
+        var codepoint = typedCharacter.charCodeAt(0);
+        return keysym_from_charcode(codepoint);
+    }
+
+    function isControlCharacter(codepoint) {
+        return codepoint <= 0x1f || (codepoint >= 0x7f && codepoint <= 0x9f);
+    }
+
+    function keysym_from_charcode(codepoint) {
+        // Keysyms for control characters
+        if (isControlCharacter(codepoint)) return 0xff00 | codepoint;
+
+        // Keysyms for ASCII chars
+        if (codepoint >= 0x0000 && codepoint <= 0x00ff) return codepoint;
+
+        // Keysyms for Unicode
+        if (codepoint >= 0x0100 && codepoint <= 0x10ffff) return 0x01000000 | codepoint;
+
+        return null;
+    }
+
+    function keysym_from_keycode(keyCode, location) {
+        return get_keysym(keycodeKeysyms[keyCode], location);
+    }
+
+    /**
+     * Heuristically detects if the legacy keyIdentifier property of
+     * a keydown/keyup event looks incorrectly derived. Chrome, and
+     * presumably others, will produce the keyIdentifier by assuming
+     * the keyCode is the Unicode codepoint for that key. This is not
+     * correct in all cases.
+     *
+     * @private
+     * @param {!number} keyCode
+     *     The keyCode from a browser keydown/keyup event.
+     *
+     * @param {string} keyIdentifier
+     *     The legacy keyIdentifier from a browser keydown/keyup event.
+     *
+     * @returns {!boolean}
+     *     true if the keyIdentifier looks sane, false if the keyIdentifier
+     *     appears incorrectly derived or is missing entirely.
+     */
+    var key_identifier_sane = function key_identifier_sane(keyCode, keyIdentifier) {
+        // Missing identifier is not sane
+        if (!keyIdentifier) return false;
+
+        // Assume non-Unicode keyIdentifier values are sane
+        var unicodePrefixLocation = keyIdentifier.indexOf('U+');
+        if (unicodePrefixLocation === -1) return true;
+
+        // If the Unicode codepoint isn't identical to the keyCode,
+        // then the identifier is likely correct
+        var codepoint = parseInt(keyIdentifier.substring(unicodePrefixLocation + 2), 16);
+        if (keyCode !== codepoint) return true;
+
+        // The keyCodes for A-Z and 0-9 are actually identical to their
+        // Unicode codepoints
+        if ((keyCode >= 65 && keyCode <= 90) || (keyCode >= 48 && keyCode <= 57)) return true;
+
+        // The keyIdentifier does NOT appear sane
+        return false;
+    };
+
+    /**
+     * Marks a key as pressed, firing the keydown event if registered. Key
+     * repeat for the pressed key will start after a delay if that key is
+     * not a modifier. The return value of this function depends on the
+     * return value of the keydown event handler, if any.
+     *
+     * @param {number} keysym
+     *     The keysym of the key to press.
+     *
+     * @return {boolean}
+     *     true if event should NOT be canceled, false otherwise.
+     */
+    this.press = function (keysym) {
+        // Don't bother with pressing the key if the key is unknown
+        if (keysym === null) return;
+
+        // Only press if released
+        if (!guac_keyboard.pressed[keysym]) {
+            // Mark key as pressed
+            guac_keyboard.pressed[keysym] = true;
+
+            // Send key event
+            if (guac_keyboard.onkeydown) {
+                var result = guac_keyboard.onkeydown(keysym);
+                last_keydown_result[keysym] = result;
+
+                // Stop any current repeat
+                window.clearTimeout(key_repeat_timeout);
+                window.clearInterval(key_repeat_interval);
+
+                // Repeat after a delay as long as pressed
+                if (!no_repeat[keysym])
+                    key_repeat_timeout = window.setTimeout(function () {
+                        key_repeat_interval = window.setInterval(function () {
+                            guac_keyboard.onkeyup(keysym);
+                            guac_keyboard.onkeydown(keysym);
+                        }, 50);
+                    }, 500);
+
+                return result;
+            }
+        }
+
+        // Return the last keydown result by default, resort to false if unknown
+        return last_keydown_result[keysym] || false;
+    };
+
+    /**
+     * Marks a key as released, firing the keyup event if registered.
+     *
+     * @param {number} keysym
+     *     The keysym of the key to release.
+     */
+    this.release = function (keysym) {
+        // Only release if pressed
+        if (guac_keyboard.pressed[keysym]) {
+            // Mark key as released
+            delete guac_keyboard.pressed[keysym];
+            delete implicitlyPressed[keysym];
+
+            // Stop repeat
+            window.clearTimeout(key_repeat_timeout);
+            window.clearInterval(key_repeat_interval);
+
+            // Send key event
+            if (keysym !== null && guac_keyboard.onkeyup) guac_keyboard.onkeyup(keysym);
+        }
+    };
+
+    /**
+     * Presses and releases the keys necessary to type the given string of
+     * text.
+     *
+     * @param {!string} str
+     *     The string to type.
+     */
+    this.type = function type(str) {
+        // Press/release the key corresponding to each character in the string
+        for (var i = 0; i < str.length; i++) {
+            // Determine keysym of current character
+            var codepoint = str.codePointAt ? str.codePointAt(i) : str.charCodeAt(i);
+            var keysym = keysym_from_charcode(codepoint);
+
+            // Press and release key for current character
+            guac_keyboard.press(keysym);
+            guac_keyboard.release(keysym);
+        }
+    };
+
+    /**
+     * Resets the state of this keyboard, releasing all keys, and firing keyup
+     * events for each released key.
+     */
+    this.reset = function () {
+        // Release all pressed keys
+        for (var keysym in guac_keyboard.pressed) guac_keyboard.release(parseInt(keysym));
+
+        // Clear event log
+        eventLog = [];
+    };
+
+    /**
+     * Resynchronizes the remote state of the given modifier with its
+     * corresponding local modifier state, as dictated by
+     * {@link KeyEvent#modifiers} within the given key event, by pressing or
+     * releasing keysyms.
+     *
+     * @private
+     * @param {!string} modifier
+     *     The name of the {@link Guacamole.Keyboard.ModifierState} property
+     *     being updated.
+     *
+     * @param {!number[]} keysyms
+     *     The keysyms which represent the modifier being updated.
+     *
+     * @param {!KeyEvent} keyEvent
+     *     Guacamole's current best interpretation of the key event being
+     *     processed.
+     */
+    var updateModifierState = function updateModifierState(modifier, keysyms, keyEvent) {
+        var localState = keyEvent.modifiers[modifier];
+        var remoteState = guac_keyboard.modifiers[modifier];
+
+        var i;
+
+        // Do not trust changes in modifier state for events directly involving
+        // that modifier: (1) the flag may erroneously be cleared despite
+        // another version of the same key still being held and (2) the change
+        // in flag may be due to the current event being processed, thus
+        // updating things here is at best redundant and at worst incorrect
+        if (keysyms.indexOf(keyEvent.keysym) !== -1) return;
+
+        // Release all related keys if modifier is implicitly released
+        if (remoteState && localState === false) {
+            for (i = 0; i < keysyms.length; i++) {
+                guac_keyboard.release(keysyms[i]);
+            }
+        }
+
+        // Press if modifier is implicitly pressed
+        else if (!remoteState && localState) {
+            // Verify that modifier flag isn't already pressed or already set
+            // due to another version of the same key being held down
+            for (i = 0; i < keysyms.length; i++) {
+                if (guac_keyboard.pressed[keysyms[i]]) return;
+            }
+
+            // Mark as implicitly pressed only if there is other information
+            // within the key event relating to a different key. Some
+            // platforms, such as iOS, will send essentially empty key events
+            // for modifier keys, using only the modifier flags to signal the
+            // identity of the key.
+            var keysym = keysyms[0];
+            if (keyEvent.keysym) implicitlyPressed[keysym] = true;
+
+            guac_keyboard.press(keysym);
+        }
+    };
+
+    /**
+     * Given a keyboard event, updates the remote key state to match the local
+     * modifier state and remote based on the modifier flags within the event.
+     * This function pays no attention to keycodes.
+     *
+     * @private
+     * @param {!KeyEvent} keyEvent
+     *     Guacamole's current best interpretation of the key event being
+     *     processed.
+     */
+    var syncModifierStates = function syncModifierStates(keyEvent) {
+        // Resync state of alt
+        updateModifierState(
+            'alt',
+            [
+                0xffe9, // Left alt
+                0xffea, // Right alt
+                0xfe03, // AltGr
+            ],
+            keyEvent
+        );
+
+        // Resync state of shift
+        updateModifierState(
+            'shift',
+            [
+                0xffe1, // Left shift
+                0xffe2, // Right shift
+            ],
+            keyEvent
+        );
+
+        // Resync state of ctrl
+        updateModifierState(
+            'ctrl',
+            [
+                0xffe3, // Left ctrl
+                0xffe4, // Right ctrl
+            ],
+            keyEvent
+        );
+
+        // Resync state of meta
+        updateModifierState(
+            'meta',
+            [
+                0xffe7, // Left meta
+                0xffe8, // Right meta
+            ],
+            keyEvent
+        );
+
+        // Resync state of hyper
+        updateModifierState(
+            'hyper',
+            [
+                0xffeb, // Left super/hyper
+                0xffec, // Right super/hyper
+            ],
+            keyEvent
+        );
+
+        // Update state
+        guac_keyboard.modifiers = keyEvent.modifiers;
+    };
+
+    /**
+     * Returns whether all currently pressed keys were implicitly pressed. A
+     * key is implicitly pressed if its status was inferred indirectly from
+     * inspection of other key events.
+     *
+     * @private
+     * @returns {!boolean}
+     *     true if all currently pressed keys were implicitly pressed, false
+     *     otherwise.
+     */
+    var isStateImplicit = function isStateImplicit() {
+        for (var keysym in guac_keyboard.pressed) {
+            if (!implicitlyPressed[keysym]) return false;
+        }
+
+        return true;
+    };
+
+    /**
+     * Reads through the event log, removing events from the head of the log
+     * when the corresponding true key presses are known (or as known as they
+     * can be).
+     *
+     * @private
+     * @return {boolean}
+     *     Whether the default action of the latest event should be prevented.
+     */
+    function interpret_events() {
+        // Do not prevent default if no event could be interpreted
+        var handled_event = interpret_event();
+        if (!handled_event) return false;
+
+        // Interpret as much as possible
+        var last_event;
+        do {
+            last_event = handled_event;
+            handled_event = interpret_event();
+        } while (handled_event !== null);
+
+        // Reset keyboard state if we cannot expect to receive any further
+        // keyup events
+        if (isStateImplicit()) guac_keyboard.reset();
+
+        return last_event.defaultPrevented;
+    }
+
+    /**
+     * Releases Ctrl+Alt, if both are currently pressed and the given keysym
+     * looks like a key that may require AltGr.
+     *
+     * @private
+     * @param {!number} keysym
+     *     The key that was just pressed.
+     */
+    var release_simulated_altgr = function release_simulated_altgr(keysym) {
+        // Both Ctrl+Alt must be pressed if simulated AltGr is in use
+        if (!guac_keyboard.modifiers.ctrl || !guac_keyboard.modifiers.alt) return;
+
+        // Assume [A-Z] never require AltGr
+        if (keysym >= 0x0041 && keysym <= 0x005a) return;
+
+        // Assume [a-z] never require AltGr
+        if (keysym >= 0x0061 && keysym <= 0x007a) return;
+
+        // Release Ctrl+Alt if the keysym is printable
+        if (keysym <= 0xff || (keysym & 0xff000000) === 0x01000000) {
+            guac_keyboard.release(0xffe3); // Left ctrl
+            guac_keyboard.release(0xffe4); // Right ctrl
+            guac_keyboard.release(0xffe9); // Left alt
+            guac_keyboard.release(0xffea); // Right alt
+        }
+    };
+
+    /**
+     * Reads through the event log, interpreting the first event, if possible,
+     * and returning that event. If no events can be interpreted, due to a
+     * total lack of events or the need for more events, null is returned. Any
+     * interpreted events are automatically removed from the log.
+     *
+     * @private
+     * @return {KeyEvent}
+     *     The first key event in the log, if it can be interpreted, or null
+     *     otherwise.
+     */
+    var interpret_event = function interpret_event() {
+        // Peek at first event in log
+        var first = eventLog[0];
+        if (!first) return null;
+
+        // Keydown event
+        if (first instanceof KeydownEvent) {
+            var keysym = null;
+            var accepted_events = [];
+
+            // Defer handling of Meta until it is known to be functioning as a
+            // modifier (it may otherwise actually be an alternative method for
+            // pressing a single key, such as Meta+Left for Home on ChromeOS)
+            if (first.keysym === 0xffe7 || first.keysym === 0xffe8) {
+                // Defer handling until further events exist to provide context
+                if (eventLog.length === 1) return null;
+
+                // Drop keydown if it turns out Meta does not actually apply
+                if (eventLog[1].keysym !== first.keysym) {
+                    if (!eventLog[1].modifiers.meta) return eventLog.shift();
+                }
+
+                // Drop duplicate keydown events while waiting to determine
+                // whether to acknowledge Meta (browser may repeat keydown
+                // while the key is held)
+                else if (eventLog[1] instanceof KeydownEvent) return eventLog.shift();
+            }
+
+            // If event itself is reliable, no need to wait for other events
+            if (first.reliable) {
+                keysym = first.keysym;
+                accepted_events = eventLog.splice(0, 1);
+            }
+
+            // If keydown is immediately followed by a keypress, use the indicated character
+            else if (eventLog[1] instanceof KeypressEvent) {
+                keysym = eventLog[1].keysym;
+                accepted_events = eventLog.splice(0, 2);
+            }
+
+            // If keydown is immediately followed by anything else, then no
+            // keypress can possibly occur to clarify this event, and we must
+            // handle it now
+            else if (eventLog[1]) {
+                keysym = first.keysym;
+                accepted_events = eventLog.splice(0, 1);
+            }
+
+            // Fire a key press if valid events were found
+            if (accepted_events.length > 0) {
+                syncModifierStates(first);
+
+                if (keysym) {
+                    // Fire event
+                    release_simulated_altgr(keysym);
+                    var defaultPrevented = !guac_keyboard.press(keysym);
+                    recentKeysym[first.keyCode] = keysym;
+
+                    // Release the key now if we cannot rely on the associated
+                    // keyup event
+                    if (!first.keyupReliable) guac_keyboard.release(keysym);
+
+                    // Record whether default was prevented
+                    for (var i = 0; i < accepted_events.length; i++) accepted_events[i].defaultPrevented = defaultPrevented;
+                }
+
+                return first;
+            }
+        } // end if keydown
+
+        // Keyup event
+        else if (first instanceof KeyupEvent && !quirks.keyupUnreliable) {
+            // Release specific key if known
+            let keysym = first.keysym;
+            if (keysym) {
+                guac_keyboard.release(keysym);
+                delete recentKeysym[first.keyCode];
+                first.defaultPrevented = true;
+            }
+
+            // Otherwise, fall back to releasing all keys
+            else {
+                guac_keyboard.reset();
+                return first;
+            }
+
+            syncModifierStates(first);
+            return eventLog.shift();
+        } // end if keyup
+
+        // Ignore any other type of event (keypress by itself is invalid, and
+        // unreliable keyup events should simply be dumped)
+        else return eventLog.shift();
+
+        // No event interpreted
+        return null;
+    };
+
+    /**
+     * Returns the keyboard location of the key associated with the given
+     * keyboard event. The location differentiates key events which otherwise
+     * have the same keycode, such as left shift vs. right shift.
+     *
+     * @private
+     * @param {!KeyboardEvent} e
+     *     A JavaScript keyboard event, as received through the DOM via a
+     *     "keydown", "keyup", or "keypress" handler.
+     *
+     * @returns {!number}
+     *     The location of the key event on the keyboard, as defined at:
+     *     http://www.w3.org/TR/DOM-Level-3-Events/#events-KeyboardEvent
+     */
+    var getEventLocation = function getEventLocation(e) {
+        // Use standard location, if possible
+        if ('location' in e) return e.location;
+
+        // Failing that, attempt to use deprecated keyLocation
+        if ('keyLocation' in e) return e.keyLocation;
+
+        // If no location is available, assume left side
+        return 0;
+    };
+
+    /**
+     * Attempts to mark the given Event as having been handled by this
+     * Guacamole.Keyboard. If the Event has already been marked as handled,
+     * false is returned.
+     *
+     * @param {!Event} e
+     *     The Event to mark.
+     *
+     * @returns {!boolean}
+     *     true if the given Event was successfully marked, false if the given
+     *     Event was already marked.
+     */
+    var markEvent = function markEvent(e) {
+        // Fail if event is already marked
+        if (e[EVENT_MARKER]) return false;
+
+        // Mark event otherwise
+        e[EVENT_MARKER] = true;
+        return true;
+    };
+
+    /**
+     * Attaches event listeners to the given Element, automatically translating
+     * received key, input, and composition events into simple keydown/keyup
+     * events signalled through this Guacamole.Keyboard's onkeydown and
+     * onkeyup handlers.
+     *
+     * @param {!(Element|Document)} element
+     *     The Element to attach event listeners to for the sake of handling
+     *     key or input events.
+     */
+    this.listenTo = function listenTo(element) {
+        // When key pressed
+        element.addEventListener(
+            'keydown',
+            function (e) {
+                // Only intercept if handler set
+                if (!guac_keyboard.onkeydown) return;
+
+                // Ignore events which have already been handled
+                if (!markEvent(e)) return;
+
+                var keydownEvent = new KeydownEvent(e);
+
+                // Ignore (but do not prevent) the "composition" keycode sent by some
+                // browsers when an IME is in use (see: http://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html)
+                if (keydownEvent.keyCode === 229) return;
+
+                // Log event
+                eventLog.push(keydownEvent);
+
+                // Interpret as many events as possible, prevent default if indicated
+                if (interpret_events()) e.preventDefault();
+            },
+            true
+        );
+
+        // When key pressed
+        element.addEventListener(
+            'keypress',
+            function (e) {
+                // Only intercept if handler set
+                if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
+
+                // Ignore events which have already been handled
+                if (!markEvent(e)) return;
+
+                // Log event
+                eventLog.push(new KeypressEvent(e));
+
+                // Interpret as many events as possible, prevent default if indicated
+                if (interpret_events()) e.preventDefault();
+            },
+            true
+        );
+
+        // When key released
+        element.addEventListener(
+            'keyup',
+            function (e) {
+                // Only intercept if handler set
+                if (!guac_keyboard.onkeyup) return;
+
+                // Ignore events which have already been handled
+                if (!markEvent(e)) return;
+
+                e.preventDefault();
+
+                // Log event, call for interpretation
+                eventLog.push(new KeyupEvent(e));
+                interpret_events();
+            },
+            true
+        );
+
+        /**
+         * Handles the given "input" event, typing the data within the input text.
+         * If the event is complete (text is provided), handling of "compositionend"
+         * events is suspended, as such events may conflict with input events.
+         *
+         * @private
+         * @param {!InputEvent} e
+         *     The "input" event to handle.
+         */
+        var handleInput = function handleInput(e) {
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
+
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
+
+            // Type all content written
+            if (e.data && !e.isComposing) {
+                element.removeEventListener('compositionend', handleComposition, false);
+                guac_keyboard.type(e.data);
+            }
+        };
+
+        /**
+         * Handles the given "compositionend" event, typing the data within the
+         * composed text. If the event is complete (composed text is provided),
+         * handling of "input" events is suspended, as such events may conflict
+         * with composition events.
+         *
+         * @private
+         * @param {!CompositionEvent} e
+         *     The "compositionend" event to handle.
+         */
+        var handleComposition = function handleComposition(e) {
+            // Only intercept if handler set
+            if (!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return;
+
+            // Ignore events which have already been handled
+            if (!markEvent(e)) return;
+
+            // Type all content written
+            if (e.data) {
+                element.removeEventListener('input', handleInput, false);
+                guac_keyboard.type(e.data);
+            }
+        };
+
+        // Automatically type text entered into the wrapped field
+        element.addEventListener('input', handleInput, false);
+        element.addEventListener('compositionend', handleComposition, false);
+    };
+
+    // Listen to given element, if any
+    if (element) guac_keyboard.listenTo(element);
+};
+
+/**
+ * The unique numerical identifier to assign to the next Guacamole.Keyboard
+ * instance.
+ *
+ * @private
+ * @type {!number}
+ */
+Guacamole.Keyboard._nextID = 0;
+
+/**
+ * The state of all supported keyboard modifiers.
+ * @constructor
+ */
+Guacamole.Keyboard.ModifierState = function () {
+    /**
+     * Whether shift is currently pressed.
+     *
+     * @type {!boolean}
+     */
+    this.shift = false;
+
+    /**
+     * Whether ctrl is currently pressed.
+     *
+     * @type {!boolean}
+     */
+    this.ctrl = false;
+
+    /**
+     * Whether alt is currently pressed.
+     *
+     * @type {!boolean}
+     */
+    this.alt = false;
+
+    /**
+     * Whether meta (apple key) is currently pressed.
+     *
+     * @type {!boolean}
+     */
+    this.meta = false;
+
+    /**
+     * Whether hyper (windows key) is currently pressed.
+     *
+     * @type {!boolean}
+     */
+    this.hyper = false;
+};
+
+/**
+ * Returns the modifier state applicable to the keyboard event given.
+ *
+ * @param {!KeyboardEvent} e
+ *     The keyboard event to read.
+ *
+ * @returns {!Guacamole.Keyboard.ModifierState}
+ *     The current state of keyboard modifiers.
+ */
+Guacamole.Keyboard.ModifierState.fromKeyboardEvent = function (e) {
+    var state = new Guacamole.Keyboard.ModifierState();
+
+    // Assign states from old flags
+    state.shift = e.shiftKey;
+    state.ctrl = e.ctrlKey;
+    state.alt = e.altKey;
+    state.meta = e.metaKey;
+
+    // Use DOM3 getModifierState() for others
+    if (e.getModifierState) {
+        state.hyper = e.getModifierState('OS') || e.getModifierState('Super') || e.getModifierState('Hyper') || e.getModifierState('Win');
+    }
+
+    return state;
+};
+
+/**
+ * Abstract ordered drawing surface. Each Layer contains a canvas element and
+ * provides simple drawing instructions for drawing to that canvas element,
+ * however unlike the canvas element itself, drawing operations on a Layer are
+ * guaranteed to run in order, even if such an operation must wait for an image
+ * to load before completing.
+ *
+ * @constructor
+ *
+ * @param {!number} width
+ *     The width of the Layer, in pixels. The canvas element backing this Layer
+ *     will be given this width.
+ *
+ * @param {!number} height
+ *     The height of the Layer, in pixels. The canvas element backing this
+ *     Layer will be given this height.
+ */
+Guacamole.Layer = function (width, height) {
+    /**
+     * Reference to this Layer.
+     *
+     * @private
+     * @type {!Guacamole.Layer}
+     */
+    var layer = this;
+
+    /**
+     * The number of pixels the width or height of a layer must change before
+     * the underlying canvas is resized. The underlying canvas will be kept at
+     * dimensions which are integer multiples of this factor.
+     *
+     * @private
+     * @constant
+     * @type {!number}
+     */
+    var CANVAS_SIZE_FACTOR = 64;
+
+    /**
+     * The canvas element backing this Layer.
+     *
+     * @private
+     * @type {!HTMLCanvasElement}
+     */
+    var canvas = document.createElement('canvas');
+
+    /**
+     * The 2D display context of the canvas element backing this Layer.
+     *
+     * @private
+     * @type {!CanvasRenderingContext2D}
+     */
+    var context = canvas.getContext('2d');
+    context.save();
+
+    /**
+     * Whether the layer has not yet been drawn to. Once any draw operation
+     * which affects the underlying canvas is invoked, this flag will be set to
+     * false.
+     *
+     * @private
+     * @type {!boolean}
+     */
+    var empty = true;
+
+    /**
+     * Whether a new path should be started with the next path drawing
+     * operations.
+     *
+     * @private
+     * @type {!boolean}
+     */
+    var pathClosed = true;
+
+    /**
+     * The number of states on the state stack.
+     *
+     * Note that there will ALWAYS be one element on the stack, but that
+     * element is not exposed. It is only used to reset the layer to its
+     * initial state.
+     *
+     * @private
+     * @type {!number}
+     */
+    var stackSize = 0;
+
+    /**
+     * Map of all Guacamole channel masks to HTML5 canvas composite operation
+     * names. Not all channel mask combinations are currently implemented.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var compositeOperation = {
+        /* 0x0 NOT IMPLEMENTED */
+        0x1: 'destination-in',
+        0x2: 'destination-out',
+        /* 0x3 NOT IMPLEMENTED */
+        0x4: 'source-in',
+        /* 0x5 NOT IMPLEMENTED */
+        0x6: 'source-atop',
+        /* 0x7 NOT IMPLEMENTED */
+        0x8: 'source-out',
+        0x9: 'destination-atop',
+        0xa: 'xor',
+        0xb: 'destination-over',
+        0xc: 'copy',
+        /* 0xD NOT IMPLEMENTED */
+        0xe: 'source-over',
+        0xf: 'lighter',
+    };
+
+    /**
+     * Resizes the canvas element backing this Layer. This function should only
+     * be used internally.
+     *
+     * @private
+     * @param {number} [newWidth=0]
+     *     The new width to assign to this Layer.
+     *
+     * @param {number} [newHeight=0]
+     *     The new height to assign to this Layer.
+     */
+    var resize = function resize(newWidth, newHeight) {
+        // Default size to zero
+        newWidth = newWidth || 0;
+        newHeight = newHeight || 0;
+
+        // Calculate new dimensions of internal canvas
+        var canvasWidth = Math.ceil(newWidth / CANVAS_SIZE_FACTOR) * CANVAS_SIZE_FACTOR;
+        var canvasHeight = Math.ceil(newHeight / CANVAS_SIZE_FACTOR) * CANVAS_SIZE_FACTOR;
+
+        // Resize only if canvas dimensions are actually changing
+        if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
+            // Copy old data only if relevant and non-empty
+            var oldData = null;
+            if (!empty && canvas.width !== 0 && canvas.height !== 0) {
+                // Create canvas and context for holding old data
+                oldData = document.createElement('canvas');
+                oldData.width = Math.min(layer.width, newWidth);
+                oldData.height = Math.min(layer.height, newHeight);
+
+                var oldDataContext = oldData.getContext('2d');
+
+                // Copy image data from current
+                oldDataContext.drawImage(canvas, 0, 0, oldData.width, oldData.height, 0, 0, oldData.width, oldData.height);
+            }
+
+            // Preserve composite operation
+            var oldCompositeOperation = context.globalCompositeOperation;
+
+            // Resize canvas
+            canvas.width = canvasWidth;
+            canvas.height = canvasHeight;
+
+            // Redraw old data, if any
+            if (oldData) context.drawImage(oldData, 0, 0, oldData.width, oldData.height, 0, 0, oldData.width, oldData.height);
+
+            // Restore composite operation
+            context.globalCompositeOperation = oldCompositeOperation;
+
+            // Acknowledge reset of stack (happens on resize of canvas)
+            stackSize = 0;
+            context.save();
+        }
+
+        // If the canvas size is not changing, manually force state reset
+        else layer.reset();
+
+        // Assign new layer dimensions
+        layer.width = newWidth;
+        layer.height = newHeight;
+    };
+
+    /**
+     * Given the X and Y coordinates of the upper-left corner of a rectangle
+     * and the rectangle's width and height, resize the backing canvas element
+     * as necessary to ensure that the rectangle fits within the canvas
+     * element's coordinate space. This function will only make the canvas
+     * larger. If the rectangle already fits within the canvas element's
+     * coordinate space, the canvas is left unchanged.
+     *
+     * @private
+     * @param {!number} x
+     *     The X coordinate of the upper-left corner of the rectangle to fit.
+     *
+     * @param {!number} y
+     *     The Y coordinate of the upper-left corner of the rectangle to fit.
+     *
+     * @param {!number} w
+     *     The width of the rectangle to fit.
+     *
+     * @param {!number} h
+     *     The height of the rectangle to fit.
+     */
+    function fitRect(x, y, w, h) {
+        // Calculate bounds
+        var opBoundX = w + x;
+        var opBoundY = h + y;
+
+        // Determine max width
+        var resizeWidth;
+        if (opBoundX > layer.width) resizeWidth = opBoundX;
+        else resizeWidth = layer.width;
+
+        // Determine max height
+        var resizeHeight;
+        if (opBoundY > layer.height) resizeHeight = opBoundY;
+        else resizeHeight = layer.height;
+
+        // Resize if necessary
+        layer.resize(resizeWidth, resizeHeight);
+    }
+
+    /**
+     * Set to true if this Layer should resize itself to accommodate the
+     * dimensions of any drawing operation, and false (the default) otherwise.
+     *
+     * Note that setting this property takes effect immediately, and thus may
+     * take effect on operations that were started in the past but have not
+     * yet completed. If you wish the setting of this flag to only modify
+     * future operations, you will need to make the setting of this flag an
+     * operation with sync().
+     *
+     * @example
+     * // Set autosize to true for all future operations
+     * layer.sync(function() {
+     *     layer.autosize = true;
+     * });
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.autosize = false;
+
+    /**
+     * The current width of this layer.
+     *
+     * @type {!number}
+     */
+    this.width = width;
+
+    /**
+     * The current height of this layer.
+     *
+     * @type {!number}
+     */
+    this.height = height;
+
+    /**
+     * Returns the canvas element backing this Layer. Note that the dimensions
+     * of the canvas may not exactly match those of the Layer, as resizing a
+     * canvas while maintaining its state is an expensive operation.
+     *
+     * @returns {!HTMLCanvasElement}
+     *     The canvas element backing this Layer.
+     */
+    this.getCanvas = function getCanvas() {
+        return canvas;
+    };
+
+    /**
+     * Returns a new canvas element containing the same image as this Layer.
+     * Unlike getCanvas(), the canvas element returned is guaranteed to have
+     * the exact same dimensions as the Layer.
+     *
+     * @returns {!HTMLCanvasElement}
+     *     A new canvas element containing a copy of the image content this
+     *     Layer.
+     */
+    this.toCanvas = function toCanvas() {
+        // Create new canvas having same dimensions
+        var canvas = document.createElement('canvas');
+        canvas.width = layer.width;
+        canvas.height = layer.height;
+
+        // Copy image contents to new canvas
+        var context = canvas.getContext('2d');
+        context.drawImage(layer.getCanvas(), 0, 0);
+
+        return canvas;
+    };
+
+    /**
+     * Changes the size of this Layer to the given width and height. Resizing
+     * is only attempted if the new size provided is actually different from
+     * the current size.
+     *
+     * @param {!number} newWidth
+     *     The new width to assign to this Layer.
+     *
+     * @param {!number} newHeight
+     *     The new height to assign to this Layer.
+     */
+    this.resize = function (newWidth, newHeight) {
+        if (newWidth !== layer.width || newHeight !== layer.height) resize(newWidth, newHeight);
+    };
+
+    /**
+     * Draws the specified image at the given coordinates. The image specified
+     * must already be loaded.
+     *
+     * @param {!number} x
+     *     The destination X coordinate.
+     *
+     * @param {!number} y
+     *     The destination Y coordinate.
+     *
+     * @param {!CanvasImageSource} image
+     *     The image to draw. Note that this is not a URL.
+     */
+    this.drawImage = function (x, y, image) {
+        if (layer.autosize) fitRect(x, y, image.width, image.height);
+        context.drawImage(image, x, y);
+        empty = false;
+    };
+
+    /**
+     * Transfer a rectangle of image data from one Layer to this Layer using the
+     * specified transfer function.
+     *
+     * @param {!Guacamole.Layer} srcLayer
+     *     The Layer to copy image data from.
+     *
+     * @param {!number} srcx
+     *     The X coordinate of the upper-left corner of the rectangle within
+     *     the source Layer's coordinate space to copy data from.
+     *
+     * @param {!number} srcy
+     *     The Y coordinate of the upper-left corner of the rectangle within
+     *     the source Layer's coordinate space to copy data from.
+     *
+     * @param {!number} srcw
+     *     The width of the rectangle within the source Layer's coordinate
+     *     space to copy data from.
+     *
+     * @param {!number} srch
+     *     The height of the rectangle within the source Layer's coordinate
+     *     space to copy data from.
+     *
+     * @param {!number} x
+     *     The destination X coordinate.
+     *
+     * @param {!number} y
+     *     The destination Y coordinate.
+     *
+     * @param {!function} transferFunction
+     *     The transfer function to use to transfer data from source to
+     *     destination.
+     */
+    this.transfer = function (srcLayer, srcx, srcy, srcw, srch, x, y, transferFunction) {
+        var srcCanvas = srcLayer.getCanvas();
+
+        // If entire rectangle outside source canvas, stop
+        if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
+
+        // Otherwise, clip rectangle to area
+        if (srcx + srcw > srcCanvas.width) srcw = srcCanvas.width - srcx;
+
+        if (srcy + srch > srcCanvas.height) srch = srcCanvas.height - srcy;
+
+        // Stop if nothing to draw.
+        if (srcw === 0 || srch === 0) return;
+
+        if (layer.autosize) fitRect(x, y, srcw, srch);
+
+        // Get image data from src and dst
+        var src = srcLayer.getCanvas().getContext('2d').getImageData(srcx, srcy, srcw, srch);
+        var dst = context.getImageData(x, y, srcw, srch);
+
+        // Apply transfer for each pixel
+        for (var i = 0; i < srcw * srch * 4; i += 4) {
+            // Get source pixel environment
+            var src_pixel = new Guacamole.Layer.Pixel(src.data[i], src.data[i + 1], src.data[i + 2], src.data[i + 3]);
+
+            // Get destination pixel environment
+            var dst_pixel = new Guacamole.Layer.Pixel(dst.data[i], dst.data[i + 1], dst.data[i + 2], dst.data[i + 3]);
+
+            // Apply transfer function
+            transferFunction(src_pixel, dst_pixel);
+
+            // Save pixel data
+            dst.data[i] = dst_pixel.red;
+            dst.data[i + 1] = dst_pixel.green;
+            dst.data[i + 2] = dst_pixel.blue;
+            dst.data[i + 3] = dst_pixel.alpha;
+        }
+
+        // Draw image data
+        context.putImageData(dst, x, y);
+        empty = false;
+    };
+
+    /**
+     * Put a rectangle of image data from one Layer to this Layer directly
+     * without performing any alpha blending. Simply copy the data.
+     *
+     * @param {!Guacamole.Layer} srcLayer
+     *     The Layer to copy image data from.
+     *
+     * @param {!number} srcx
+     *     The X coordinate of the upper-left corner of the rectangle within
+     *     the source Layer's coordinate space to copy data from.
+     *
+     * @param {!number} srcy
+     *     The Y coordinate of the upper-left corner of the rectangle within
+     *     the source Layer's coordinate space to copy data from.
+     *
+     * @param {!number} srcw
+     *     The width of the rectangle within the source Layer's coordinate
+     *     space to copy data from.
+     *
+     * @param {!number} srch
+     *     The height of the rectangle within the source Layer's coordinate
+     *     space to copy data from.
+     *
+     * @param {!number} x
+     *     The destination X coordinate.
+     *
+     * @param {!number} y
+     *     The destination Y coordinate.
+     */
+    this.put = function (srcLayer, srcx, srcy, srcw, srch, x, y) {
+        var srcCanvas = srcLayer.getCanvas();
+
+        // If entire rectangle outside source canvas, stop
+        if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
+
+        // Otherwise, clip rectangle to area
+        if (srcx + srcw > srcCanvas.width) srcw = srcCanvas.width - srcx;
+
+        if (srcy + srch > srcCanvas.height) srch = srcCanvas.height - srcy;
+
+        // Stop if nothing to draw.
+        if (srcw === 0 || srch === 0) return;
+
+        if (layer.autosize) fitRect(x, y, srcw, srch);
+
+        // Get image data from src and dst
+        var src = srcLayer.getCanvas().getContext('2d').getImageData(srcx, srcy, srcw, srch);
+        context.putImageData(src, x, y);
+        empty = false;
+    };
+
+    /**
+     * Copy a rectangle of image data from one Layer to this Layer. This
+     * operation will copy exactly the image data that will be drawn once all
+     * operations of the source Layer that were pending at the time this
+     * function was called are complete. This operation will not alter the
+     * size of the source Layer even if its autosize property is set to true.
+     *
+     * @param {!Guacamole.Layer} srcLayer
+     *     The Layer to copy image data from.
+     *
+     * @param {!number} srcx
+     *     The X coordinate of the upper-left corner of the rectangle within
+     *     the source Layer's coordinate space to copy data from.
+     *
+     * @param {!number} srcy
+     *     The Y coordinate of the upper-left corner of the rectangle within
+     *     the source Layer's coordinate space to copy data from.
+     *
+     * @param {!number} srcw
+     *     The width of the rectangle within the source Layer's coordinate
+     *     space to copy data from.
+     *
+     * @param {!number} srch
+     *     The height of the rectangle within the source Layer's coordinate
+     *     space to copy data from.
+     *
+     * @param {!number} x
+     *     The destination X coordinate.
+     *
+     * @param {!number} y
+     *     The destination Y coordinate.
+     */
+    this.copy = function (srcLayer, srcx, srcy, srcw, srch, x, y) {
+        var srcCanvas = srcLayer.getCanvas();
+
+        // If entire rectangle outside source canvas, stop
+        if (srcx >= srcCanvas.width || srcy >= srcCanvas.height) return;
+
+        // Otherwise, clip rectangle to area
+        if (srcx + srcw > srcCanvas.width) srcw = srcCanvas.width - srcx;
+
+        if (srcy + srch > srcCanvas.height) srch = srcCanvas.height - srcy;
+
+        // Stop if nothing to draw.
+        if (srcw === 0 || srch === 0) return;
+
+        if (layer.autosize) fitRect(x, y, srcw, srch);
+        context.drawImage(srcCanvas, srcx, srcy, srcw, srch, x, y, srcw, srch);
+        empty = false;
+    };
+
+    /**
+     * Starts a new path at the specified point.
+     *
+     * @param {!number} x
+     *     The X coordinate of the point to draw.
+     *
+     * @param {!number} y
+     *     The Y coordinate of the point to draw.
+     */
+    this.moveTo = function (x, y) {
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.moveTo(x, y);
+    };
+
+    /**
+     * Add the specified line to the current path.
+     *
+     * @param {!number} x
+     *     The X coordinate of the endpoint of the line to draw.
+     *
+     * @param {!number} y
+     *     The Y coordinate of the endpoint of the line to draw.
+     */
+    this.lineTo = function (x, y) {
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.lineTo(x, y);
+    };
+
+    /**
+     * Add the specified arc to the current path.
+     *
+     * @param {!number} x
+     *     The X coordinate of the center of the circle which will contain the
+     *     arc.
+     *
+     * @param {!number} y
+     *     The Y coordinate of the center of the circle which will contain the
+     *     arc.
+     *
+     * @param {!number} radius
+     *     The radius of the circle.
+     *
+     * @param {!number} startAngle
+     *     The starting angle of the arc, in radians.
+     *
+     * @param {!number} endAngle
+     *     The ending angle of the arc, in radians.
+     *
+     * @param {!boolean} negative
+     *     Whether the arc should be drawn in order of decreasing angle.
+     */
+    this.arc = function (x, y, radius, startAngle, endAngle, negative) {
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.arc(x, y, radius, startAngle, endAngle, negative);
+    };
+
+    /**
+     * Starts a new path at the specified point.
+     *
+     * @param {!number} cp1x
+     *     The X coordinate of the first control point.
+     *
+     * @param {!number} cp1y
+     *     The Y coordinate of the first control point.
+     *
+     * @param {!number} cp2x
+     *     The X coordinate of the second control point.
+     *
+     * @param {!number} cp2y
+     *     The Y coordinate of the second control point.
+     *
+     * @param {!number} x
+     *     The X coordinate of the endpoint of the curve.
+     *
+     * @param {!number} y
+     *     The Y coordinate of the endpoint of the curve.
+     */
+    this.curveTo = function (cp1x, cp1y, cp2x, cp2y, x, y) {
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+
+        if (layer.autosize) fitRect(x, y, 0, 0);
+        context.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
+    };
+
+    /**
+     * Closes the current path by connecting the end point with the start
+     * point (if any) with a straight line.
+     */
+    this.close = function () {
+        context.closePath();
+        pathClosed = true;
+    };
+
+    /**
+     * Add the specified rectangle to the current path.
+     *
+     * @param {!number} x
+     *     The X coordinate of the upper-left corner of the rectangle to draw.
+     *
+     * @param {!number} y
+     *     The Y coordinate of the upper-left corner of the rectangle to draw.
+     *
+     * @param {!number} w
+     *     The width of the rectangle to draw.
+     *
+     * @param {!number} h
+     *     The height of the rectangle to draw.
+     */
+    this.rect = function (x, y, w, h) {
+        // Start a new path if current path is closed
+        if (pathClosed) {
+            context.beginPath();
+            pathClosed = false;
+        }
+
+        if (layer.autosize) fitRect(x, y, w, h);
+        context.rect(x, y, w, h);
+    };
+
+    /**
+     * Clip all future drawing operations by the current path. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as fillColor()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     */
+    this.clip = function () {
+        // Set new clipping region
+        context.clip();
+
+        // Path now implicitly closed
+        pathClosed = true;
+    };
+
+    /**
+     * Stroke the current path with the specified color. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     *
+     * @param {!string} cap
+     *     The line cap style. Can be "round", "square", or "butt".
+     *
+     * @param {!string} join
+     *     The line join style. Can be "round", "bevel", or "miter".
+     *
+     * @param {!number} thickness
+     *     The line thickness in pixels.
+     *
+     * @param {!number} r
+     *     The red component of the color to fill.
+     *
+     * @param {!number} g
+     *     The green component of the color to fill.
+     *
+     * @param {!number} b
+     *     The blue component of the color to fill.
+     *
+     * @param {!number} a
+     *     The alpha component of the color to fill.
+     */
+    this.strokeColor = function (cap, join, thickness, r, g, b, a) {
+        // Stroke with color
+        context.lineCap = cap;
+        context.lineJoin = join;
+        context.lineWidth = thickness;
+        context.strokeStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + a / 255.0 + ')';
+        context.stroke();
+        empty = false;
+
+        // Path now implicitly closed
+        pathClosed = true;
+    };
+
+    /**
+     * Fills the current path with the specified color. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     *
+     * @param {!number} r
+     *     The red component of the color to fill.
+     *
+     * @param {!number} g
+     *     The green component of the color to fill.
+     *
+     * @param {!number} b
+     *     The blue component of the color to fill.
+     *
+     * @param {!number} a
+     *     The alpha component of the color to fill.
+     */
+    this.fillColor = function (r, g, b, a) {
+        // Fill with color
+        context.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + a / 255.0 + ')';
+        context.fill();
+        empty = false;
+
+        // Path now implicitly closed
+        pathClosed = true;
+    };
+
+    /**
+     * Stroke the current path with the image within the specified layer. The
+     * image data will be tiled infinitely within the stroke. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     *
+     * @param {!string} cap
+     *     The line cap style. Can be "round", "square", or "butt".
+     *
+     * @param {!string} join
+     *     The line join style. Can be "round", "bevel", or "miter".
+     *
+     * @param {!number} thickness
+     *     The line thickness in pixels.
+     *
+     * @param {!Guacamole.Layer} srcLayer
+     *     The layer to use as a repeating pattern within the stroke.
+     */
+    this.strokeLayer = function (cap, join, thickness, srcLayer) {
+        // Stroke with image data
+        context.lineCap = cap;
+        context.lineJoin = join;
+        context.lineWidth = thickness;
+        context.strokeStyle = context.createPattern(srcLayer.getCanvas(), 'repeat');
+        context.stroke();
+        empty = false;
+
+        // Path now implicitly closed
+        pathClosed = true;
+    };
+
+    /**
+     * Fills the current path with the image within the specified layer. The
+     * image data will be tiled infinitely within the stroke. The current path
+     * is implicitly closed. The current path can continue to be reused
+     * for other operations (such as clip()) but a new path will be started
+     * once a path drawing operation (path() or rect()) is used.
+     *
+     * @param {!Guacamole.Layer} srcLayer
+     *     The layer to use as a repeating pattern within the fill.
+     */
+    this.fillLayer = function (srcLayer) {
+        // Fill with image data
+        context.fillStyle = context.createPattern(srcLayer.getCanvas(), 'repeat');
+        context.fill();
+        empty = false;
+
+        // Path now implicitly closed
+        pathClosed = true;
+    };
+
+    /**
+     * Push current layer state onto stack.
+     */
+    this.push = function () {
+        // Save current state onto stack
+        context.save();
+        stackSize++;
+    };
+
+    /**
+     * Pop layer state off stack.
+     */
+    this.pop = function () {
+        // Restore current state from stack
+        if (stackSize > 0) {
+            context.restore();
+            stackSize--;
+        }
+    };
+
+    /**
+     * Reset the layer, clearing the stack, the current path, and any transform
+     * matrix.
+     */
+    this.reset = function () {
+        // Clear stack
+        while (stackSize > 0) {
+            context.restore();
+            stackSize--;
+        }
+
+        // Restore to initial state
+        context.restore();
+        context.save();
+
+        // Clear path
+        context.beginPath();
+        pathClosed = false;
+    };
+
+    /**
+     * Sets the given affine transform (defined with six values from the
+     * transform's matrix).
+     *
+     * @param {!number} a
+     *     The first value in the affine transform's matrix.
+     *
+     * @param {!number} b
+     *     The second value in the affine transform's matrix.
+     *
+     * @param {!number} c
+     *     The third value in the affine transform's matrix.
+     *
+     * @param {!number} d
+     *     The fourth value in the affine transform's matrix.
+     *
+     * @param {!number} e
+     *     The fifth value in the affine transform's matrix.
+     *
+     * @param {!number} f
+     *     The sixth value in the affine transform's matrix.
+     */
+    this.setTransform = function (a, b, c, d, e, f) {
+        context.setTransform(
+            a,
+            b,
+            c,
+            d,
+            e,
+            f
+            /*0, 0, 1*/
+        );
+    };
+
+    /**
+     * Applies the given affine transform (defined with six values from the
+     * transform's matrix).
+     *
+     * @param {!number} a
+     *     The first value in the affine transform's matrix.
+     *
+     * @param {!number} b
+     *     The second value in the affine transform's matrix.
+     *
+     * @param {!number} c
+     *     The third value in the affine transform's matrix.
+     *
+     * @param {!number} d
+     *     The fourth value in the affine transform's matrix.
+     *
+     * @param {!number} e
+     *     The fifth value in the affine transform's matrix.
+     *
+     * @param {!number} f
+     *     The sixth value in the affine transform's matrix.
+     */
+    this.transform = function (a, b, c, d, e, f) {
+        context.transform(
+            a,
+            b,
+            c,
+            d,
+            e,
+            f
+            /*0, 0, 1*/
+        );
+    };
+
+    /**
+     * Sets the channel mask for future operations on this Layer.
+     *
+     * The channel mask is a Guacamole-specific compositing operation identifier
+     * with a single bit representing each of four channels (in order): source
+     * image where destination transparent, source where destination opaque,
+     * destination where source transparent, and destination where source
+     * opaque.
+     *
+     * @param {!number} mask
+     *     The channel mask for future operations on this Layer.
+     */
+    this.setChannelMask = function (mask) {
+        context.globalCompositeOperation = compositeOperation[mask];
+    };
+
+    /**
+     * Sets the miter limit for stroke operations using the miter join. This
+     * limit is the maximum ratio of the size of the miter join to the stroke
+     * width. If this ratio is exceeded, the miter will not be drawn for that
+     * joint of the path.
+     *
+     * @param {!number} limit
+     *     The miter limit for stroke operations using the miter join.
+     */
+    this.setMiterLimit = function (limit) {
+        context.miterLimit = limit;
+    };
+
+    // Initialize canvas dimensions
+    resize(width, height);
+
+    // Explicitly render canvas below other elements in the layer (such as
+    // child layers). Chrome and others may fail to render layers properly
+    // without this.
+    canvas.style.zIndex = -1;
+};
+
+/**
+ * Channel mask for the composite operation "rout".
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.ROUT = 0x2;
+
+/**
+ * Channel mask for the composite operation "atop".
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.ATOP = 0x6;
+
+/**
+ * Channel mask for the composite operation "xor".
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.XOR = 0xa;
+
+/**
+ * Channel mask for the composite operation "rover".
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.ROVER = 0xb;
+
+/**
+ * Channel mask for the composite operation "over".
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.OVER = 0xe;
+
+/**
+ * Channel mask for the composite operation "plus".
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.PLUS = 0xf;
+
+/**
+ * Channel mask for the composite operation "rin".
+ * Beware that WebKit-based browsers may leave the contents of the destination
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.RIN = 0x1;
+
+/**
+ * Channel mask for the composite operation "in".
+ * Beware that WebKit-based browsers may leave the contents of the destination
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.IN = 0x4;
+
+/**
+ * Channel mask for the composite operation "out".
+ * Beware that WebKit-based browsers may leave the contents of the destination
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.OUT = 0x8;
+
+/**
+ * Channel mask for the composite operation "ratop".
+ * Beware that WebKit-based browsers may leave the contents of the destination
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.RATOP = 0x9;
+
+/**
+ * Channel mask for the composite operation "src".
+ * Beware that WebKit-based browsers may leave the contents of the destination
+ * layer where the source layer is transparent, despite the definition of this
+ * operation.
+ *
+ * @type {!number}
+ */
+Guacamole.Layer.SRC = 0xc;
+
+/**
+ * Represents a single pixel of image data. All components have a minimum value
+ * of 0 and a maximum value of 255.
+ *
+ * @constructor
+ *
+ * @param {!number} r
+ *     The red component of this pixel.
+ *
+ * @param {!number} g
+ *     The green component of this pixel.
+ *
+ * @param {!number} b
+ *     The blue component of this pixel.
+ *
+ * @param {!number} a
+ *     The alpha component of this pixel.
+ */
+Guacamole.Layer.Pixel = function (r, g, b, a) {
+    /**
+     * The red component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     *
+     * @type {!number}
+     */
+    this.red = r;
+
+    /**
+     * The green component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     *
+     * @type {!number}
+     */
+    this.green = g;
+
+    /**
+     * The blue component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     *
+     * @type {!number}
+     */
+    this.blue = b;
+
+    /**
+     * The alpha component of this pixel, where 0 is the minimum value,
+     * and 255 is the maximum.
+     *
+     * @type {!number}
+     */
+    this.alpha = a;
+};
+
+/**
+ * Provides cross-browser mouse events for a given element. The events of
+ * the given element are automatically populated with handlers that translate
+ * mouse events into a non-browser-specific event provided by the
+ * Guacamole.Mouse instance.
+ *
+ * @example
+ * var mouse = new Guacamole.Mouse(client.getDisplay().getElement());
+ *
+ * // Forward all mouse interaction over Guacamole connection
+ * mouse.onEach(['mousedown', 'mousemove', 'mouseup'], function sendMouseEvent(e) {
+ *     client.sendMouseState(e.state, true);
+ * });
+ *
+ * @example
+ * // Hide software cursor when mouse leaves display
+ * mouse.on('mouseout', function hideCursor() {
+ *     client.getDisplay().showCursor(false);
+ * });
+ *
+ * @constructor
+ * @augments Guacamole.Mouse.Event.Target
+ * @param {!Element} element
+ *     The Element to use to provide mouse events.
+ */
+Guacamole.Mouse = function Mouse(element) {
+    Guacamole.Mouse.Event.Target.call(this);
+
+    /**
+     * Reference to this Guacamole.Mouse.
+     *
+     * @private
+     * @type {!Guacamole.Mouse}
+     */
+    var guac_mouse = this;
+
+    /**
+     * The number of mousemove events to require before re-enabling mouse
+     * event handling after receiving a touch event.
+     *
+     * @type {!number}
+     */
+    this.touchMouseThreshold = 3;
+
+    /**
+     * The minimum amount of pixels scrolled required for a single scroll button
+     * click.
+     *
+     * @type {!number}
+     */
+    this.scrollThreshold = 53;
+
+    /**
+     * The number of pixels to scroll per line.
+     *
+     * @type {!number}
+     */
+    this.PIXELS_PER_LINE = 18;
+
+    /**
+     * The number of pixels to scroll per page.
+     *
+     * @type {!number}
+     */
+    this.PIXELS_PER_PAGE = this.PIXELS_PER_LINE * 16;
+
+    /**
+     * Array of {@link Guacamole.Mouse.State} button names corresponding to the
+     * mouse button indices used by DOM mouse events.
+     *
+     * @private
+     * @type {!string[]}
+     */
+    var MOUSE_BUTTONS = [Guacamole.Mouse.State.Buttons.LEFT, Guacamole.Mouse.State.Buttons.MIDDLE, Guacamole.Mouse.State.Buttons.RIGHT];
+
+    /**
+     * Counter of mouse events to ignore. This decremented by mousemove, and
+     * while non-zero, mouse events will have no effect.
+     *
+     * @private
+     * @type {!number}
+     */
+    var ignore_mouse = 0;
+
+    /**
+     * Cumulative scroll delta amount. This value is accumulated through scroll
+     * events and results in scroll button clicks if it exceeds a certain
+     * threshold.
+     *
+     * @private
+     * @type {!number}
+     */
+    var scroll_delta = 0;
+
+    // Block context menu so right-click gets sent properly
+    element.addEventListener(
+        'contextmenu',
+        function (e) {
+            Guacamole.Event.DOMEvent.cancelEvent(e);
+        },
+        false
+    );
+
+    element.addEventListener(
+        'mousemove',
+        function (e) {
+            // If ignoring events, decrement counter
+            if (ignore_mouse) {
+                Guacamole.Event.DOMEvent.cancelEvent(e);
+                ignore_mouse--;
+                return;
+            }
+
+            guac_mouse.move(Guacamole.Position.fromClientPosition(element, e.clientX, e.clientY), e);
+        },
+        false
+    );
+
+    element.addEventListener(
+        'mousedown',
+        function (e) {
+            // Do not handle if ignoring events
+            if (ignore_mouse) {
+                Guacamole.Event.DOMEvent.cancelEvent(e);
+                return;
+            }
+
+            var button = MOUSE_BUTTONS[e.button];
+            if (button) guac_mouse.press(button, e);
+        },
+        false
+    );
+
+    element.addEventListener(
+        'mouseup',
+        function (e) {
+            // Do not handle if ignoring events
+            if (ignore_mouse) {
+                Guacamole.Event.DOMEvent.cancelEvent(e);
+                return;
+            }
+
+            var button = MOUSE_BUTTONS[e.button];
+            if (button) guac_mouse.release(button, e);
+        },
+        false
+    );
+
+    element.addEventListener(
+        'mouseout',
+        function (e) {
+            // Get parent of the element the mouse pointer is leaving
+            if (!e) e = window.event;
+
+            // Check that mouseout is due to actually LEAVING the element
+            var target = e.relatedTarget || e.toElement;
+            while (target) {
+                if (target === element) return;
+                target = target.parentNode;
+            }
+
+            // Release all buttons and fire mouseout
+            guac_mouse.reset(e);
+            guac_mouse.out(e);
+        },
+        false
+    );
+
+    // Override selection on mouse event element.
+    element.addEventListener(
+        'selectstart',
+        function (e) {
+            Guacamole.Event.DOMEvent.cancelEvent(e);
+        },
+        false
+    );
+
+    // Ignore all pending mouse events when touch events are the apparent source
+    function ignorePendingMouseEvents() {
+        ignore_mouse = guac_mouse.touchMouseThreshold;
+    }
+
+    element.addEventListener('touchmove', ignorePendingMouseEvents, false);
+    element.addEventListener('touchstart', ignorePendingMouseEvents, false);
+    element.addEventListener('touchend', ignorePendingMouseEvents, false);
+
+    // Scroll wheel support
+    function mousewheel_handler(e) {
+        // Determine approximate scroll amount (in pixels)
+        var delta = e.deltaY || -e.wheelDeltaY || -e.wheelDelta;
+
+        // If successfully retrieved scroll amount, convert to pixels if not
+        // already in pixels
+        if (delta) {
+            // Convert to pixels if delta was lines
+            if (e.deltaMode === 1) delta = e.deltaY * guac_mouse.PIXELS_PER_LINE;
+            // Convert to pixels if delta was pages
+            else if (e.deltaMode === 2) delta = e.deltaY * guac_mouse.PIXELS_PER_PAGE;
+        }
+
+        // Otherwise, assume legacy mousewheel event and line scrolling
+        else delta = e.detail * guac_mouse.PIXELS_PER_LINE;
+
+        // Update overall delta
+        scroll_delta += delta;
+
+        // Up
+        if (scroll_delta <= -guac_mouse.scrollThreshold) {
+            // Repeatedly click the up button until insufficient delta remains
+            do {
+                guac_mouse.click(Guacamole.Mouse.State.Buttons.UP);
+                scroll_delta += guac_mouse.scrollThreshold;
+            } while (scroll_delta <= -guac_mouse.scrollThreshold);
+
+            // Reset delta
+            scroll_delta = 0;
+        }
+
+        // Down
+        if (scroll_delta >= guac_mouse.scrollThreshold) {
+            // Repeatedly click the down button until insufficient delta remains
+            do {
+                guac_mouse.click(Guacamole.Mouse.State.Buttons.DOWN);
+                scroll_delta -= guac_mouse.scrollThreshold;
+            } while (scroll_delta >= guac_mouse.scrollThreshold);
+
+            // Reset delta
+            scroll_delta = 0;
+        }
+
+        // All scroll/wheel events must currently be cancelled regardless of
+        // whether the dispatched event is cancelled, as there is no Guacamole
+        // scroll event and thus no way to cancel scroll events that are
+        // smaller than required to produce an up/down click
+        Guacamole.Event.DOMEvent.cancelEvent(e);
+    }
+
+    element.addEventListener('DOMMouseScroll', mousewheel_handler, false);
+    element.addEventListener('mousewheel', mousewheel_handler, false);
+    element.addEventListener('wheel', mousewheel_handler, false);
+
+    /**
+     * Whether the browser supports CSS3 cursor styling, including hotspot
+     * coordinates.
+     *
+     * @private
+     * @type {!boolean}
+     */
+    var CSS3_CURSOR_SUPPORTED = (function () {
+        var div = document.createElement('div');
+
+        // If no cursor property at all, then no support
+        if (!('cursor' in div.style)) return false;
+
+        try {
+            // Apply simple 1x1 PNG
+            div.style.cursor =
+                'url(data:image/png;base64,' +
+                'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' +
+                'AQMAAAAl21bKAAAAA1BMVEX///+nxBvI' +
+                'AAAACklEQVQI12NgAAAAAgAB4iG8MwAA' +
+                'AABJRU5ErkJggg==) 0 0, auto';
+        } catch (e) {
+            return false;
+        }
+
+        // Verify cursor property is set to URL with hotspot
+        return /\burl\([^()]*\)\s+0\s+0\b/.test(div.style.cursor || '');
+    })();
+
+    /**
+     * Changes the local mouse cursor to the given canvas, having the given
+     * hotspot coordinates. This affects styling of the element backing this
+     * Guacamole.Mouse only, and may fail depending on browser support for
+     * setting the mouse cursor.
+     *
+     * If setting the local cursor is desired, it is up to the implementation
+     * to do something else, such as use the software cursor built into
+     * Guacamole.Display, if the local cursor cannot be set.
+     *
+     * @param {!HTMLCanvasElement} canvas
+     *     The cursor image.
+     *
+     * @param {!number} x
+     *     The X-coordinate of the cursor hotspot.
+     *
+     * @param {!number} y
+     *     The Y-coordinate of the cursor hotspot.
+     *
+     * @return {!boolean}
+     *     true if the cursor was successfully set, false if the cursor could
+     *     not be set for any reason.
+     */
+    this.setCursor = function (canvas, x, y) {
+        // Attempt to set via CSS3 cursor styling
+        if (CSS3_CURSOR_SUPPORTED) {
+            var dataURL = canvas.toDataURL('image/png');
+            element.style.cursor = 'url(' + dataURL + ') ' + x + ' ' + y + ', auto';
+            return true;
+        }
+
+        // Otherwise, setting cursor failed
+        return false;
+    };
+};
+
+/**
+ * The current state of a mouse, including position and buttons.
+ *
+ * @constructor
+ * @augments Guacamole.Position
+ * @param {Guacamole.Mouse.State|object} [template={}]
+ *     The object whose properties should be copied within the new
+ *     Guacamole.Mouse.State.
+ */
+Guacamole.Mouse.State = function State(template) {
+    /**
+     * Returns the template object that would be provided to the
+     * Guacamole.Mouse.State constructor to produce a new Guacamole.Mouse.State
+     * object with the properties specified. The order and type of arguments
+     * used by this function are identical to those accepted by the
+     * Guacamole.Mouse.State constructor of Apache Guacamole 1.3.0 and older.
+     *
+     * @private
+     * @param {!number} x
+     *     The X position of the mouse pointer in pixels.
+     *
+     * @param {!number} y
+     *     The Y position of the mouse pointer in pixels.
+     *
+     * @param {!boolean} left
+     *     Whether the left mouse button is pressed.
+     *
+     * @param {!boolean} middle
+     *     Whether the middle mouse button is pressed.
+     *
+     * @param {!boolean} right
+     *     Whether the right mouse button is pressed.
+     *
+     * @param {!boolean} up
+     *     Whether the up mouse button is pressed (the fourth button, usually
+     *     part of a scroll wheel).
+     *
+     * @param {!boolean} down
+     *     Whether the down mouse button is pressed (the fifth button, usually
+     *     part of a scroll wheel).
+     *
+     * @return {!object}
+     *     The equivalent template object that would be passed to the new
+     *     Guacamole.Mouse.State constructor.
+     */
+    var legacyConstructor = function legacyConstructor(x, y, left, middle, right, up, down) {
+        return {
+            x: x,
+            y: y,
+            left: left,
+            middle: middle,
+            right: right,
+            up: up,
+            down: down,
+        };
+    };
+
+    // Accept old-style constructor, as well
+    if (arguments.length > 1) template = legacyConstructor.apply(this, arguments);
+    else template = template || {};
+
+    Guacamole.Position.call(this, template);
+
+    /**
+     * Whether the left mouse button is currently pressed.
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.left = template.left || false;
+
+    /**
+     * Whether the middle mouse button is currently pressed.
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.middle = template.middle || false;
+
+    /**
+     * Whether the right mouse button is currently pressed.
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.right = template.right || false;
+
+    /**
+     * Whether the up mouse button is currently pressed. This is the fourth
+     * mouse button, associated with upward scrolling of the mouse scroll
+     * wheel.
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.up = template.up || false;
+
+    /**
+     * Whether the down mouse button is currently pressed. This is the fifth
+     * mouse button, associated with downward scrolling of the mouse scroll
+     * wheel.
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.down = template.down || false;
+};
+
+/**
+ * All mouse buttons that may be represented by a
+ * {@link Guacamole.Mouse.State}.
+ *
+ * @readonly
+ * @enum
+ */
+Guacamole.Mouse.State.Buttons = {
+    /**
+     * The name of the {@link Guacamole.Mouse.State} property representing the
+     * left mouse button.
+     *
+     * @constant
+     * @type {!string}
+     */
+    LEFT: 'left',
+
+    /**
+     * The name of the {@link Guacamole.Mouse.State} property representing the
+     * middle mouse button.
+     *
+     * @constant
+     * @type {!string}
+     */
+    MIDDLE: 'middle',
+
+    /**
+     * The name of the {@link Guacamole.Mouse.State} property representing the
+     * right mouse button.
+     *
+     * @constant
+     * @type {!string}
+     */
+    RIGHT: 'right',
+
+    /**
+     * The name of the {@link Guacamole.Mouse.State} property representing the
+     * up mouse button (the fourth mouse button, clicked when the mouse scroll
+     * wheel is scrolled up).
+     *
+     * @constant
+     * @type {!string}
+     */
+    UP: 'up',
+
+    /**
+     * The name of the {@link Guacamole.Mouse.State} property representing the
+     * down mouse button (the fifth mouse button, clicked when the mouse scroll
+     * wheel is scrolled up).
+     *
+     * @constant
+     * @type {!string}
+     */
+    DOWN: 'down',
+};
+
+/**
+ * Base event type for all mouse events. The mouse producing the event may be
+ * the user's local mouse (as with {@link Guacamole.Mouse}) or an emulated
+ * mouse (as with {@link Guacamole.Mouse.Touchpad}).
+ *
+ * @constructor
+ * @augments Guacamole.Event.DOMEvent
+ * @param {!string} type
+ *     The type name of the event ("mousedown", "mouseup", etc.)
+ *
+ * @param {!Guacamole.Mouse.State} state
+ *     The current mouse state.
+ *
+ * @param {Event|Event[]} [events=[]]
+ *     The DOM events that are related to this event, if any.
+ */
+Guacamole.Mouse.Event = function MouseEvent(type, state, events) {
+    Guacamole.Event.DOMEvent.call(this, type, events);
+
+    /**
+     * The name of the event handler used by the Guacamole JavaScript API for
+     * this event prior to the migration to Guacamole.Event.Target.
+     *
+     * @private
+     * @constant
+     * @type {!string}
+     */
+    var legacyHandlerName = 'on' + this.type;
+
+    /**
+     * The current mouse state at the time this event was fired.
+     *
+     * @type {!Guacamole.Mouse.State}
+     */
+    this.state = state;
+
+    /**
+     * @inheritdoc
+     */
+    this.invokeLegacyHandler = function invokeLegacyHandler(target) {
+        if (target[legacyHandlerName]) {
+            this.preventDefault();
+            this.stopPropagation();
+
+            target[legacyHandlerName](this.state);
+        }
+    };
+};
+
+/**
+ * An object which can dispatch {@link Guacamole.Mouse.Event} objects
+ * representing mouse events. These mouse events may be produced from an actual
+ * mouse device (as with {@link Guacamole.Mouse}), from an emulated mouse
+ * device (as with {@link Guacamole.Mouse.Touchpad}, or may be programmatically
+ * generated (using functions like [dispatch()]{@link Guacamole.Mouse.Event.Target#dispatch},
+ * [press()]{@link Guacamole.Mouse.Event.Target#press}, and
+ * [release()]{@link Guacamole.Mouse.Event.Target#release}).
+ *
+ * @constructor
+ * @augments Guacamole.Event.Target
+ */
+Guacamole.Mouse.Event.Target = function MouseEventTarget() {
+    Guacamole.Event.Target.call(this);
+
+    /**
+     * The current mouse state. The properties of this state are updated when
+     * mouse events fire. This state object is also passed in as a parameter to
+     * the handler of any mouse events.
+     *
+     * @type {!Guacamole.Mouse.State}
+     */
+    this.currentState = new Guacamole.Mouse.State();
+
+    /**
+     * Fired whenever a mouse button is effectively pressed. Depending on the
+     * object dispatching the event, this can be due to a true mouse button
+     * press ({@link Guacamole.Mouse}), an emulated mouse button press from a
+     * touch gesture ({@link Guacamole.Mouse.Touchpad} and
+     * {@link Guacamole.Mouse.Touchscreen}), or may be programmatically
+     * generated through [dispatch()]{@link Guacamole.Mouse.Event.Target#dispatch},
+     * [press()]{@link Guacamole.Mouse.Event.Target#press}, or
+     * [click()]{@link Guacamole.Mouse.Event.Target#click}.
+     *
+     * @event Guacamole.Mouse.Event.Target#mousedown
+     * @param {!Guacamole.Mouse.Event} event
+     *     The mousedown event that was fired.
+     */
+
+    /**
+     * Fired whenever a mouse button is effectively released. Depending on the
+     * object dispatching the event, this can be due to a true mouse button
+     * release ({@link Guacamole.Mouse}), an emulated mouse button release from
+     * a touch gesture ({@link Guacamole.Mouse.Touchpad} and
+     * {@link Guacamole.Mouse.Touchscreen}), or may be programmatically
+     * generated through [dispatch()]{@link Guacamole.Mouse.Event.Target#dispatch},
+     * [release()]{@link Guacamole.Mouse.Event.Target#release}, or
+     * [click()]{@link Guacamole.Mouse.Event.Target#click}.
+     *
+     * @event Guacamole.Mouse.Event.Target#mouseup
+     * @param {!Guacamole.Mouse.Event} event
+     *     The mouseup event that was fired.
+     */
+
+    /**
+     * Fired whenever the mouse pointer is effectively moved. Depending on the
+     * object dispatching the event, this can be due to true mouse movement
+     * ({@link Guacamole.Mouse}), emulated mouse movement from
+     * a touch gesture ({@link Guacamole.Mouse.Touchpad} and
+     * {@link Guacamole.Mouse.Touchscreen}), or may be programmatically
+     * generated through [dispatch()]{@link Guacamole.Mouse.Event.Target#dispatch},
+     * or [move()]{@link Guacamole.Mouse.Event.Target#move}.
+     *
+     * @event Guacamole.Mouse.Event.Target#mousemove
+     * @param {!Guacamole.Mouse.Event} event
+     *     The mousemove event that was fired.
+     */
+
+    /**
+     * Fired whenever the mouse pointer leaves the boundaries of the element
+     * being monitored for interaction. This will only ever be automatically
+     * fired due to movement of an actual mouse device via
+     * {@link Guacamole.Mouse} unless programmatically generated through
+     * [dispatch()]{@link Guacamole.Mouse.Event.Target#dispatch},
+     * or [out()]{@link Guacamole.Mouse.Event.Target#out}.
+     *
+     * @event Guacamole.Mouse.Event.Target#mouseout
+     * @param {!Guacamole.Mouse.Event} event
+     *     The mouseout event that was fired.
+     */
+
+    /**
+     * Presses the given mouse button, if it isn't already pressed. Valid
+     * button names are defined by {@link Guacamole.Mouse.State.Buttons} and
+     * correspond to the button-related properties of
+     * {@link Guacamole.Mouse.State}.
+     *
+     * @fires Guacamole.Mouse.Event.Target#mousedown
+     *
+     * @param {!string} button
+     *     The name of the mouse button to press, as defined by
+     *     {@link Guacamole.Mouse.State.Buttons}.
+     *
+     * @param {Event|Event[]} [events=[]]
+     *     The DOM events that are related to the mouse button press, if any.
+     */
+    this.press = function press(button, events) {
+        if (!this.currentState[button]) {
+            this.currentState[button] = true;
+            this.dispatch(new Guacamole.Mouse.Event('mousedown', this.currentState, events));
+        }
+    };
+
+    /**
+     * Releases the given mouse button, if it isn't already released. Valid
+     * button names are defined by {@link Guacamole.Mouse.State.Buttons} and
+     * correspond to the button-related properties of
+     * {@link Guacamole.Mouse.State}.
+     *
+     * @fires Guacamole.Mouse.Event.Target#mouseup
+     *
+     * @param {!string} button
+     *     The name of the mouse button to release, as defined by
+     *     {@link Guacamole.Mouse.State.Buttons}.
+     *
+     * @param {Event|Event[]} [events=[]]
+     *     The DOM events related to the mouse button release, if any.
+     */
+    this.release = function release(button, events) {
+        if (this.currentState[button]) {
+            this.currentState[button] = false;
+            this.dispatch(new Guacamole.Mouse.Event('mouseup', this.currentState, events));
+        }
+    };
+
+    /**
+     * Clicks (presses and releases) the given mouse button. Valid button
+     * names are defined by {@link Guacamole.Mouse.State.Buttons} and
+     * correspond to the button-related properties of
+     * {@link Guacamole.Mouse.State}.
+     *
+     * @fires Guacamole.Mouse.Event.Target#mousedown
+     * @fires Guacamole.Mouse.Event.Target#mouseup
+     *
+     * @param {!string} button
+     *     The name of the mouse button to click, as defined by
+     *     {@link Guacamole.Mouse.State.Buttons}.
+     *
+     * @param {Event|Event[]} [events=[]]
+     *     The DOM events related to the click, if any.
+     */
+    this.click = function click(button, events) {
+        this.press(button, events);
+        this.release(button, events);
+    };
+
+    /**
+     * Moves the mouse to the given coordinates.
+     *
+     * @fires Guacamole.Mouse.Event.Target#mousemove
+     *
+     * @param {!(Guacamole.Position|object)} position
+     *     The new coordinates of the mouse pointer. This object may be a
+     *     {@link Guacamole.Position} or any object with "x" and "y"
+     *     properties.
+     *
+     * @param {Event|Event[]} [events=[]]
+     *     The DOM events related to the mouse movement, if any.
+     */
+    this.move = function move(position, events) {
+        if (this.currentState.x !== position.x || this.currentState.y !== position.y) {
+            this.currentState.x = position.x;
+            this.currentState.y = position.y;
+            this.dispatch(new Guacamole.Mouse.Event('mousemove', this.currentState, events));
+        }
+    };
+
+    /**
+     * Notifies event listeners that the mouse pointer has left the boundaries
+     * of the area being monitored for mouse events.
+     *
+     * @fires Guacamole.Mouse.Event.Target#mouseout
+     *
+     * @param {Event|Event[]} [events=[]]
+     *     The DOM events related to the mouse leaving the boundaries of the
+     *     monitored object, if any.
+     */
+    this.out = function out(events) {
+        this.dispatch(new Guacamole.Mouse.Event('mouseout', this.currentState, events));
+    };
+
+    /**
+     * Releases all mouse buttons that are currently pressed. If all mouse
+     * buttons have already been released, this function has no effect.
+     *
+     * @fires Guacamole.Mouse.Event.Target#mouseup
+     *
+     * @param {Event|Event[]} [events=[]]
+     *     The DOM event related to all mouse buttons being released, if any.
+     */
+    this.reset = function reset(events) {
+        for (var button in Guacamole.Mouse.State.Buttons) {
+            this.release(Guacamole.Mouse.State.Buttons[button], events);
+        }
+    };
+};
+
+/**
+ * Provides cross-browser relative touch event translation for a given element.
+ *
+ * Touch events are translated into mouse events as if the touches occurred
+ * on a touchpad (drag to push the mouse pointer, tap to click).
+ *
+ * @example
+ * var touchpad = new Guacamole.Mouse.Touchpad(client.getDisplay().getElement());
+ *
+ * // Emulate a mouse using touchpad-style gestures, forwarding all mouse
+ * // interaction over Guacamole connection
+ * touchpad.onEach(['mousedown', 'mousemove', 'mouseup'], function sendMouseEvent(e) {
+ *
+ *     // Re-show software mouse cursor if possibly hidden by a prior call to
+ *     // showCursor(), such as a "mouseout" event handler that hides the
+ *     // cursor
+ *     client.getDisplay().showCursor(true);
+ *
+ *     client.sendMouseState(e.state, true);
+ *
+ * });
+ *
+ * @constructor
+ * @augments Guacamole.Mouse.Event.Target
+ * @param {!Element} element
+ *     The Element to use to provide touch events.
+ */
+Guacamole.Mouse.Touchpad = function Touchpad(element) {
+    Guacamole.Mouse.Event.Target.call(this);
+
+    /**
+     * The "mouseout" event will never be fired by Guacamole.Mouse.Touchpad.
+     *
+     * @ignore
+     * @event Guacamole.Mouse.Touchpad#mouseout
+     */
+
+    /**
+     * Reference to this Guacamole.Mouse.Touchpad.
+     *
+     * @private
+     * @type {!Guacamole.Mouse.Touchpad}
+     */
+    var guac_touchpad = this;
+
+    /**
+     * The distance a two-finger touch must move per scrollwheel event, in
+     * pixels.
+     *
+     * @type {!number}
+     */
+    this.scrollThreshold = 20 * (window.devicePixelRatio || 1);
+
+    /**
+     * The maximum number of milliseconds to wait for a touch to end for the
+     * gesture to be considered a click.
+     *
+     * @type {!number}
+     */
+    this.clickTimingThreshold = 250;
+
+    /**
+     * The maximum number of pixels to allow a touch to move for the gesture to
+     * be considered a click.
+     *
+     * @type {!number}
+     */
+    this.clickMoveThreshold = 10 * (window.devicePixelRatio || 1);
+
+    /**
+     * The current mouse state. The properties of this state are updated when
+     * mouse events fire. This state object is also passed in as a parameter to
+     * the handler of any mouse events.
+     *
+     * @type {!Guacamole.Mouse.State}
+     */
+    this.currentState = new Guacamole.Mouse.State();
+
+    var touch_count = 0;
+    var last_touch_x = 0;
+    var last_touch_y = 0;
+    var last_touch_time = 0;
+    var pixels_moved = 0;
+
+    var touch_buttons = {
+        1: 'left',
+        2: 'right',
+        3: 'middle',
+    };
+
+    var gesture_in_progress = false;
+    var click_release_timeout = null;
+
+    element.addEventListener(
+        'touchend',
+        function (e) {
+            e.preventDefault();
+
+            // If we're handling a gesture AND this is the last touch
+            if (gesture_in_progress && e.touches.length === 0) {
+                var time = new Date().getTime();
+
+                // Get corresponding mouse button
+                var button = touch_buttons[touch_count];
+
+                // If mouse already down, release anad clear timeout
+                if (guac_touchpad.currentState[button]) {
+                    // Fire button up event
+                    guac_touchpad.release(button, e);
+
+                    // Clear timeout, if set
+                    if (click_release_timeout) {
+                        window.clearTimeout(click_release_timeout);
+                        click_release_timeout = null;
+                    }
+                }
+
+                // If single tap detected (based on time and distance)
+                if (time - last_touch_time <= guac_touchpad.clickTimingThreshold && pixels_moved < guac_touchpad.clickMoveThreshold) {
+                    // Fire button down event
+                    guac_touchpad.press(button, e);
+
+                    // Delay mouse up - mouse up should be canceled if
+                    // touchstart within timeout.
+                    click_release_timeout = window.setTimeout(function () {
+                        // Fire button up event
+                        guac_touchpad.release(button, e);
+
+                        // Gesture now over
+                        gesture_in_progress = false;
+                    }, guac_touchpad.clickTimingThreshold);
+                }
+
+                // If we're not waiting to see if this is a click, stop gesture
+                if (!click_release_timeout) gesture_in_progress = false;
+            }
+        },
+        false
+    );
+
+    element.addEventListener(
+        'touchstart',
+        function (e) {
+            e.preventDefault();
+
+            // Track number of touches, but no more than three
+            touch_count = Math.min(e.touches.length, 3);
+
+            // Clear timeout, if set
+            if (click_release_timeout) {
+                window.clearTimeout(click_release_timeout);
+                click_release_timeout = null;
+            }
+
+            // Record initial touch location and time for touch movement
+            // and tap gestures
+            if (!gesture_in_progress) {
+                // Stop mouse events while touching
+                gesture_in_progress = true;
+
+                // Record touch location and time
+                var starting_touch = e.touches[0];
+                last_touch_x = starting_touch.clientX;
+                last_touch_y = starting_touch.clientY;
+                last_touch_time = new Date().getTime();
+                pixels_moved = 0;
+            }
+        },
+        false
+    );
+
+    element.addEventListener(
+        'touchmove',
+        function (e) {
+            e.preventDefault();
+
+            // Get change in touch location
+            var touch = e.touches[0];
+            var delta_x = touch.clientX - last_touch_x;
+            var delta_y = touch.clientY - last_touch_y;
+
+            // Track pixels moved
+            pixels_moved += Math.abs(delta_x) + Math.abs(delta_y);
+
+            // If only one touch involved, this is mouse move
+            if (touch_count === 1) {
+                // Calculate average velocity in Manhatten pixels per millisecond
+                var velocity = pixels_moved / (new Date().getTime() - last_touch_time);
+
+                // Scale mouse movement relative to velocity
+                var scale = 1 + velocity;
+
+                // Update mouse location
+                var position = new Guacamole.Position(guac_touchpad.currentState);
+                position.x += delta_x * scale;
+                position.y += delta_y * scale;
+
+                // Prevent mouse from leaving screen
+                position.x = Math.min(Math.max(0, position.x), element.offsetWidth - 1);
+                position.y = Math.min(Math.max(0, position.y), element.offsetHeight - 1);
+
+                // Fire movement event, if defined
+                guac_touchpad.move(position, e);
+
+                // Update touch location
+                last_touch_x = touch.clientX;
+                last_touch_y = touch.clientY;
+            }
+
+            // Interpret two-finger swipe as scrollwheel
+            else if (touch_count === 2) {
+                // If change in location passes threshold for scroll
+                if (Math.abs(delta_y) >= guac_touchpad.scrollThreshold) {
+                    // Decide button based on Y movement direction
+                    var button;
+                    if (delta_y > 0) button = 'down';
+                    else button = 'up';
+
+                    guac_touchpad.click(button, e);
+
+                    // Only update touch location after a scroll has been
+                    // detected
+                    last_touch_x = touch.clientX;
+                    last_touch_y = touch.clientY;
+                }
+            }
+        },
+        false
+    );
+};
+
+/**
+ * Provides cross-browser absolute touch event translation for a given element.
+ *
+ * Touch events are translated into mouse events as if the touches occurred
+ * on a touchscreen (tapping anywhere on the screen clicks at that point,
+ * long-press to right-click).
+ *
+ * @example
+ * var touchscreen = new Guacamole.Mouse.Touchscreen(client.getDisplay().getElement());
+ *
+ * // Emulate a mouse using touchscreen-style gestures, forwarding all mouse
+ * // interaction over Guacamole connection
+ * touchscreen.onEach(['mousedown', 'mousemove', 'mouseup'], function sendMouseEvent(e) {
+ *
+ *     // Re-show software mouse cursor if possibly hidden by a prior call to
+ *     // showCursor(), such as a "mouseout" event handler that hides the
+ *     // cursor
+ *     client.getDisplay().showCursor(true);
+ *
+ *     client.sendMouseState(e.state, true);
+ *
+ * });
+ *
+ * @constructor
+ * @augments Guacamole.Mouse.Event.Target
+ * @param {!Element} element
+ *     The Element to use to provide touch events.
+ */
+Guacamole.Mouse.Touchscreen = function Touchscreen(element) {
+    Guacamole.Mouse.Event.Target.call(this);
+
+    /**
+     * The "mouseout" event will never be fired by Guacamole.Mouse.Touchscreen.
+     *
+     * @ignore
+     * @event Guacamole.Mouse.Touchscreen#mouseout
+     */
+
+    /**
+     * Reference to this Guacamole.Mouse.Touchscreen.
+     *
+     * @private
+     * @type {!Guacamole.Mouse.Touchscreen}
+     */
+    var guac_touchscreen = this;
+
+    /**
+     * Whether a gesture is known to be in progress. If false, touch events
+     * will be ignored.
+     *
+     * @private
+     * @type {!boolean}
+     */
+    var gesture_in_progress = false;
+
+    /**
+     * The start X location of a gesture.
+     *
+     * @private
+     * @type {number}
+     */
+    var gesture_start_x = null;
+
+    /**
+     * The start Y location of a gesture.
+     *
+     * @private
+     * @type {number}
+     */
+    var gesture_start_y = null;
+
+    /**
+     * The timeout associated with the delayed, cancellable click release.
+     *
+     * @private
+     * @type {number}
+     */
+    var click_release_timeout = null;
+
+    /**
+     * The timeout associated with long-press for right click.
+     *
+     * @private
+     * @type {number}
+     */
+    var long_press_timeout = null;
+
+    /**
+     * The distance a two-finger touch must move per scrollwheel event, in
+     * pixels.
+     *
+     * @type {!number}
+     */
+    this.scrollThreshold = 20 * (window.devicePixelRatio || 1);
+
+    /**
+     * The maximum number of milliseconds to wait for a touch to end for the
+     * gesture to be considered a click.
+     *
+     * @type {!number}
+     */
+    this.clickTimingThreshold = 250;
+
+    /**
+     * The maximum number of pixels to allow a touch to move for the gesture to
+     * be considered a click.
+     *
+     * @type {!number}
+     */
+    this.clickMoveThreshold = 16 * (window.devicePixelRatio || 1);
+
+    /**
+     * The amount of time a press must be held for long press to be
+     * detected.
+     */
+    this.longPressThreshold = 500;
+
+    /**
+     * Returns whether the given touch event exceeds the movement threshold for
+     * clicking, based on where the touch gesture began.
+     *
+     * @private
+     * @param {!TouchEvent} e
+     *     The touch event to check.
+     *
+     * @return {!boolean}
+     *     true if the movement threshold is exceeded, false otherwise.
+     */
+    function finger_moved(e) {
+        var touch = e.touches[0] || e.changedTouches[0];
+        var delta_x = touch.clientX - gesture_start_x;
+        var delta_y = touch.clientY - gesture_start_y;
+        return Math.sqrt(delta_x * delta_x + delta_y * delta_y) >= guac_touchscreen.clickMoveThreshold;
+    }
+
+    /**
+     * Begins a new gesture at the location of the first touch in the given
+     * touch event.
+     *
+     * @private
+     * @param {!TouchEvent} e
+     *     The touch event beginning this new gesture.
+     */
+    function begin_gesture(e) {
+        var touch = e.touches[0];
+        gesture_in_progress = true;
+        gesture_start_x = touch.clientX;
+        gesture_start_y = touch.clientY;
+    }
+
+    /**
+     * End the current gesture entirely. Wait for all touches to be done before
+     * resuming gesture detection.
+     *
+     * @private
+     */
+    function end_gesture() {
+        window.clearTimeout(click_release_timeout);
+        window.clearTimeout(long_press_timeout);
+        gesture_in_progress = false;
+    }
+
+    element.addEventListener(
+        'touchend',
+        function (e) {
+            // Do not handle if no gesture
+            if (!gesture_in_progress) return;
+
+            // Ignore if more than one touch
+            if (e.touches.length !== 0 || e.changedTouches.length !== 1) {
+                end_gesture();
+                return;
+            }
+
+            // Long-press, if any, is over
+            window.clearTimeout(long_press_timeout);
+
+            // Always release mouse button if pressed
+            guac_touchscreen.release(Guacamole.Mouse.State.Buttons.LEFT, e);
+
+            // If finger hasn't moved enough to cancel the click
+            if (!finger_moved(e)) {
+                e.preventDefault();
+
+                // If not yet pressed, press and start delay release
+                if (!guac_touchscreen.currentState.left) {
+                    var touch = e.changedTouches[0];
+                    guac_touchscreen.move(Guacamole.Position.fromClientPosition(element, touch.clientX, touch.clientY));
+                    guac_touchscreen.press(Guacamole.Mouse.State.Buttons.LEFT, e);
+
+                    // Release button after a delay, if not canceled
+                    click_release_timeout = window.setTimeout(function () {
+                        guac_touchscreen.release(Guacamole.Mouse.State.Buttons.LEFT, e);
+                        end_gesture();
+                    }, guac_touchscreen.clickTimingThreshold);
+                }
+            } // end if finger not moved
+        },
+        false
+    );
+
+    element.addEventListener(
+        'touchstart',
+        function (e) {
+            // Ignore if more than one touch
+            if (e.touches.length !== 1) {
+                end_gesture();
+                return;
+            }
+
+            e.preventDefault();
+
+            // New touch begins a new gesture
+            begin_gesture(e);
+
+            // Keep button pressed if tap after left click
+            window.clearTimeout(click_release_timeout);
+
+            // Click right button if this turns into a long-press
+            long_press_timeout = window.setTimeout(function () {
+                var touch = e.touches[0];
+                guac_touchscreen.move(Guacamole.Position.fromClientPosition(element, touch.clientX, touch.clientY));
+                guac_touchscreen.click(Guacamole.Mouse.State.Buttons.RIGHT, e);
+                end_gesture();
+            }, guac_touchscreen.longPressThreshold);
+        },
+        false
+    );
+
+    element.addEventListener(
+        'touchmove',
+        function (e) {
+            // Do not handle if no gesture
+            if (!gesture_in_progress) return;
+
+            // Cancel long press if finger moved
+            if (finger_moved(e)) window.clearTimeout(long_press_timeout);
+
+            // Ignore if more than one touch
+            if (e.touches.length !== 1) {
+                end_gesture();
+                return;
+            }
+
+            // Update mouse position if dragging
+            if (guac_touchscreen.currentState.left) {
+                e.preventDefault();
+
+                // Update state
+                var touch = e.touches[0];
+                guac_touchscreen.move(Guacamole.Position.fromClientPosition(element, touch.clientX, touch.clientY), e);
+            }
+        },
+        false
+    );
+};
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * An object used by the Guacamole client to house arbitrarily-many named
+ * input and output streams.
+ *
+ * @constructor
+ * @param {!Guacamole.Client} client
+ *     The client owning this object.
+ *
+ * @param {!number} index
+ *     The index of this object.
+ */
+Guacamole.Object = function guacamoleObject(client, index) {
+    /**
+     * Reference to this Guacamole.Object.
+     *
+     * @private
+     * @type {!Guacamole.Object}
+     */
+    var guacObject = this;
+
+    /**
+     * Map of stream name to corresponding queue of callbacks. The queue of
+     * callbacks is guaranteed to be in order of request.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var bodyCallbacks = {};
+
+    /**
+     * Removes and returns the callback at the head of the callback queue for
+     * the stream having the given name. If no such callbacks exist, null is
+     * returned.
+     *
+     * @private
+     * @param {!string} name
+     *     The name of the stream to retrieve a callback for.
+     *
+     * @returns {function}
+     *     The next callback associated with the stream having the given name,
+     *     or null if no such callback exists.
+     */
+    var dequeueBodyCallback = function dequeueBodyCallback(name) {
+        // If no callbacks defined, simply return null
+        var callbacks = bodyCallbacks[name];
+        if (!callbacks) return null;
+
+        // Otherwise, pull off first callback, deleting the queue if empty
+        var callback = callbacks.shift();
+        if (callbacks.length === 0) delete bodyCallbacks[name];
+
+        // Return found callback
+        return callback;
+    };
+
+    /**
+     * Adds the given callback to the tail of the callback queue for the stream
+     * having the given name.
+     *
+     * @private
+     * @param {!string} name
+     *     The name of the stream to associate with the given callback.
+     *
+     * @param {!function} callback
+     *     The callback to add to the queue of the stream with the given name.
+     */
+    var enqueueBodyCallback = function enqueueBodyCallback(name, callback) {
+        // Get callback queue by name, creating first if necessary
+        var callbacks = bodyCallbacks[name];
+        if (!callbacks) {
+            callbacks = [];
+            bodyCallbacks[name] = callbacks;
+        }
+
+        // Add callback to end of queue
+        callbacks.push(callback);
+    };
+
+    /**
+     * The index of this object.
+     *
+     * @type {!number}
+     */
+    this.index = index;
+
+    /**
+     * Called when this object receives the body of a requested input stream.
+     * By default, all objects will invoke the callbacks provided to their
+     * requestInputStream() functions based on the name of the stream
+     * requested. This behavior can be overridden by specifying a different
+     * handler here.
+     *
+     * @event
+     * @param {!Guacamole.InputStream} inputStream
+     *     The input stream of the received body.
+     *
+     * @param {!string} mimetype
+     *     The mimetype of the data being received.
+     *
+     * @param {!string} name
+     *     The name of the stream whose body has been received.
+     */
+    this.onbody = function defaultBodyHandler(inputStream, mimetype, name) {
+        // Call queued callback for the received body, if any
+        var callback = dequeueBodyCallback(name);
+        if (callback) callback(inputStream, mimetype);
+    };
+
+    /**
+     * Called when this object is being undefined. Once undefined, no further
+     * communication involving this object may occur.
+     *
+     * @event
+     */
+    this.onundefine = null;
+
+    /**
+     * Requests read access to the input stream having the given name. If
+     * successful, a new input stream will be created.
+     *
+     * @param {!string} name
+     *     The name of the input stream to request.
+     *
+     * @param {function} [bodyCallback]
+     *     The callback to invoke when the body of the requested input stream
+     *     is received. This callback will be provided a Guacamole.InputStream
+     *     and its mimetype as its two only arguments. If the onbody handler of
+     *     this object is overridden, this callback will not be invoked.
+     */
+    this.requestInputStream = function requestInputStream(name, bodyCallback) {
+        // Queue body callback if provided
+        if (bodyCallback) enqueueBodyCallback(name, bodyCallback);
+
+        // Send request for input stream
+        client.requestObjectInputStream(guacObject.index, name);
+    };
+
+    /**
+     * Creates a new output stream associated with this object and having the
+     * given mimetype and name. The legality of a mimetype and name is dictated
+     * by the object itself.
+     *
+     * @param {!string} mimetype
+     *     The mimetype of the data which will be sent to the output stream.
+     *
+     * @param {!string} name
+     *     The defined name of an output stream within this object.
+     *
+     * @returns {!Guacamole.OutputStream}
+     *     An output stream which will write blobs to the named output stream
+     *     of this object.
+     */
+    this.createOutputStream = function createOutputStream(mimetype, name) {
+        return client.createObjectOutputStream(guacObject.index, mimetype, name);
+    };
+};
+
+/**
+ * The reserved name denoting the root stream of any object. The contents of
+ * the root stream MUST be a JSON map of stream name to mimetype.
+ *
+ * @constant
+ * @type {!string}
+ */
+Guacamole.Object.ROOT_STREAM = '/';
+
+/**
+ * The mimetype of a stream containing JSON which maps available stream names
+ * to their corresponding mimetype. The root stream of a Guacamole.Object MUST
+ * have this mimetype.
+ *
+ * @constant
+ * @type {!string}
+ */
+Guacamole.Object.STREAM_INDEX_MIMETYPE = 'application/vnd.glyptodon.guacamole.stream-index+json';
+
+/**
+ * Dynamic on-screen keyboard. Given the layout object for an on-screen
+ * keyboard, this object will construct a clickable on-screen keyboard with its
+ * own key events.
+ *
+ * @constructor
+ * @param {!Guacamole.OnScreenKeyboard.Layout} layout
+ *     The layout of the on-screen keyboard to display.
+ */
+Guacamole.OnScreenKeyboard = function (layout) {
+    /**
+     * Reference to this Guacamole.OnScreenKeyboard.
+     *
+     * @private
+     * @type {!Guacamole.OnScreenKeyboard}
+     */
+    var osk = this;
+
+    /**
+     * Map of currently-set modifiers to the keysym associated with their
+     * original press. When the modifier is cleared, this keysym must be
+     * released.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var modifierKeysyms = {};
+
+    /**
+     * Map of all key names to their current pressed states. If a key is not
+     * pressed, it may not be in this map at all, but all pressed keys will
+     * have a corresponding mapping to true.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var pressed = {};
+
+    /**
+     * All scalable elements which are part of the on-screen keyboard. Each
+     * scalable element is carefully controlled to ensure the interface layout
+     * and sizing remains constant, even on browsers that would otherwise
+     * experience rounding error due to unit conversions.
+     *
+     * @private
+     * @type {!ScaledElement[]}
+     */
+    var scaledElements = [];
+
+    /**
+     * Adds a CSS class to an element.
+     *
+     * @private
+     * @function
+     * @param {!Element} element
+     *     The element to add a class to.
+     *
+     * @param {!string} classname
+     *     The name of the class to add.
+     */
+    var addClass = function addClass(element, classname) {
+        // If classList supported, use that
+        if (element.classList) element.classList.add(classname);
+        // Otherwise, simply append the class
+        else element.className += ' ' + classname;
+    };
+
+    /**
+     * Removes a CSS class from an element.
+     *
+     * @private
+     * @function
+     * @param {!Element} element
+     *     The element to remove a class from.
+     *
+     * @param {!string} classname
+     *     The name of the class to remove.
+     */
+    var removeClass = function removeClass(element, classname) {
+        // If classList supported, use that
+        if (element.classList) element.classList.remove(classname);
+        // Otherwise, manually filter out classes with given name
+        else {
+            element.className = element.className.replace(/([^ ]+)[ ]*/g, function removeMatchingClasses(match, testClassname) {
+                // If same class, remove
+                if (testClassname === classname) return '';
+
+                // Otherwise, allow
+                return match;
+            });
+        }
+    };
+
+    /**
+     * Counter of mouse events to ignore. This decremented by mousemove, and
+     * while non-zero, mouse events will have no effect.
+     *
+     * @private
+     * @type {!number}
+     */
+    var ignoreMouse = 0;
+
+    /**
+     * Ignores all pending mouse events when touch events are the apparent
+     * source. Mouse events are ignored until at least touchMouseThreshold
+     * mouse events occur without corresponding touch events.
+     *
+     * @private
+     */
+    var ignorePendingMouseEvents = function ignorePendingMouseEvents() {
+        ignoreMouse = osk.touchMouseThreshold;
+    };
+
+    /**
+     * An element whose dimensions are maintained according to an arbitrary
+     * scale. The conversion factor for these arbitrary units to pixels is
+     * provided later via a call to scale().
+     *
+     * @private
+     * @constructor
+     * @param {!Element} element
+     *     The element whose scale should be maintained.
+     *
+     * @param {!number} width
+     *     The width of the element, in arbitrary units, relative to other
+     *     ScaledElements.
+     *
+     * @param {!number} height
+     *     The height of the element, in arbitrary units, relative to other
+     *     ScaledElements.
+     *
+     * @param {boolean} [scaleFont=false]
+     *     Whether the line height and font size should be scaled as well.
+     */
+    var ScaledElement = function ScaledElement(element, width, height, scaleFont) {
+        /**
+         * The width of this ScaledElement, in arbitrary units, relative to
+         * other ScaledElements.
+         *
+         * @type {!number}
+         */
+        this.width = width;
+
+        /**
+         * The height of this ScaledElement, in arbitrary units, relative to
+         * other ScaledElements.
+         *
+         * @type {!number}
+         */
+        this.height = height;
+
+        /**
+         * Resizes the associated element, updating its dimensions according to
+         * the given pixels per unit.
+         *
+         * @param {!number} pixels
+         *     The number of pixels to assign per arbitrary unit.
+         */
+        this.scale = function (pixels) {
+            // Scale element width/height
+            element.style.width = width * pixels + 'px';
+            element.style.height = height * pixels + 'px';
+
+            // Scale font, if requested
+            if (scaleFont) {
+                element.style.lineHeight = height * pixels + 'px';
+                element.style.fontSize = pixels + 'px';
+            }
+        };
+    };
+
+    /**
+     * Returns whether all modifiers having the given names are currently
+     * active.
+     *
+     * @private
+     * @param {!string[]} names
+     *     The names of all modifiers to test.
+     *
+     * @returns {!boolean}
+     *     true if all specified modifiers are pressed, false otherwise.
+     */
+    var modifiersPressed = function modifiersPressed(names) {
+        // If any required modifiers are not pressed, return false
+        for (var i = 0; i < names.length; i++) {
+            // Test whether current modifier is pressed
+            var name = names[i];
+            if (!(name in modifierKeysyms)) return false;
+        }
+
+        // Otherwise, all required modifiers are pressed
+        return true;
+    };
+
+    /**
+     * Returns the single matching Key object associated with the key of the
+     * given name, where that Key object's requirements (such as pressed
+     * modifiers) are all currently satisfied.
+     *
+     * @private
+     * @param {!string} keyName
+     *     The name of the key to retrieve.
+     *
+     * @returns {Guacamole.OnScreenKeyboard.Key}
+     *     The Key object associated with the given name, where that object's
+     *     requirements are all currently satisfied, or null if no such Key
+     *     can be found.
+     */
+    var getActiveKey = function getActiveKey(keyName) {
+        // Get key array for given name
+        var keys = osk.keys[keyName];
+        if (!keys) return null;
+
+        // Find last matching key
+        for (var i = keys.length - 1; i >= 0; i--) {
+            // Get candidate key
+            var candidate = keys[i];
+
+            // If all required modifiers are pressed, use that key
+            if (modifiersPressed(candidate.requires)) return candidate;
+        }
+
+        // No valid key
+        return null;
+    };
+
+    /**
+     * Presses the key having the given name, updating the associated key
+     * element with the "guac-keyboard-pressed" CSS class. If the key is
+     * already pressed, this function has no effect.
+     *
+     * @private
+     * @param {!string} keyName
+     *     The name of the key to press.
+     *
+     * @param {!string} keyElement
+     *     The element associated with the given key.
+     */
+    var press = function press(keyName, keyElement) {
+        // Press key if not yet pressed
+        if (!pressed[keyName]) {
+            addClass(keyElement, 'guac-keyboard-pressed');
+
+            // Get current key based on modifier state
+            var key = getActiveKey(keyName);
+
+            // Update modifier state
+            if (key.modifier) {
+                // Construct classname for modifier
+                var modifierClass = 'guac-keyboard-modifier-' + getCSSName(key.modifier);
+
+                // Retrieve originally-pressed keysym, if modifier was already pressed
+                var originalKeysym = modifierKeysyms[key.modifier];
+
+                // Activate modifier if not pressed
+                if (originalKeysym === undefined) {
+                    addClass(keyboard, modifierClass);
+                    modifierKeysyms[key.modifier] = key.keysym;
+
+                    // Send key event only if keysym is meaningful
+                    if (key.keysym && osk.onkeydown) osk.onkeydown(key.keysym);
+                }
+
+                // Deactivate if not pressed
+                else {
+                    removeClass(keyboard, modifierClass);
+                    delete modifierKeysyms[key.modifier];
+
+                    // Send key event only if original keysym is meaningful
+                    if (originalKeysym && osk.onkeyup) osk.onkeyup(originalKeysym);
+                }
+            }
+
+            // If not modifier, send key event now
+            else if (osk.onkeydown) osk.onkeydown(key.keysym);
+
+            // Mark key as pressed
+            pressed[keyName] = true;
+        }
+    };
+
+    /**
+     * Releases the key having the given name, removing the
+     * "guac-keyboard-pressed" CSS class from the associated element. If the
+     * key is already released, this function has no effect.
+     *
+     * @private
+     * @param {!string} keyName
+     *     The name of the key to release.
+     *
+     * @param {!string} keyElement
+     *     The element associated with the given key.
+     */
+    var release = function release(keyName, keyElement) {
+        // Release key if currently pressed
+        if (pressed[keyName]) {
+            removeClass(keyElement, 'guac-keyboard-pressed');
+
+            // Get current key based on modifier state
+            var key = getActiveKey(keyName);
+
+            // Send key event if not a modifier key
+            if (!key.modifier && osk.onkeyup) osk.onkeyup(key.keysym);
+
+            // Mark key as released
+            pressed[keyName] = false;
+        }
+    };
+
+    // Create keyboard
+    var keyboard = document.createElement('div');
+    keyboard.className = 'guac-keyboard';
+
+    // Do not allow selection or mouse movement to propagate/register.
+    keyboard.onselectstart =
+        keyboard.onmousemove =
+        keyboard.onmouseup =
+        keyboard.onmousedown =
+            function handleMouseEvents(e) {
+                // If ignoring events, decrement counter
+                if (ignoreMouse) ignoreMouse--;
+
+                e.stopPropagation();
+                return false;
+            };
+
+    /**
+     * The number of mousemove events to require before re-enabling mouse
+     * event handling after receiving a touch event.
+     *
+     * @type {!number}
+     */
+    this.touchMouseThreshold = 3;
+
+    /**
+     * Fired whenever the user presses a key on this Guacamole.OnScreenKeyboard.
+     *
+     * @event
+     * @param {!number} keysym
+     *     The keysym of the key being pressed.
+     */
+    this.onkeydown = null;
+
+    /**
+     * Fired whenever the user releases a key on this Guacamole.OnScreenKeyboard.
+     *
+     * @event
+     * @param {!number} keysym
+     *     The keysym of the key being released.
+     */
+    this.onkeyup = null;
+
+    /**
+     * The keyboard layout provided at time of construction.
+     *
+     * @type {!Guacamole.OnScreenKeyboard.Layout}
+     */
+    this.layout = new Guacamole.OnScreenKeyboard.Layout(layout);
+
+    /**
+     * Returns the element containing the entire on-screen keyboard.
+     *
+     * @returns {!Element}
+     *     The element containing the entire on-screen keyboard.
+     */
+    this.getElement = function () {
+        return keyboard;
+    };
+
+    /**
+     * Resizes all elements within this Guacamole.OnScreenKeyboard such that
+     * the width is close to but does not exceed the specified width. The
+     * height of the keyboard is determined based on the width.
+     *
+     * @param {!number} width
+     *     The width to resize this Guacamole.OnScreenKeyboard to, in pixels.
+     */
+    this.resize = function (width) {
+        // Get pixel size of a unit
+        var unit = Math.floor((width * 10) / osk.layout.width) / 10;
+
+        // Resize all scaled elements
+        for (var i = 0; i < scaledElements.length; i++) {
+            var scaledElement = scaledElements[i];
+            scaledElement.scale(unit);
+        }
+    };
+
+    /**
+     * Given the name of a key and its corresponding definition, which may be
+     * an array of keys objects, a number (keysym), a string (key title), or a
+     * single key object, returns an array of key objects, deriving any missing
+     * properties as needed, and ensuring the key name is defined.
+     *
+     * @private
+     * @param {!string} name
+     *     The name of the key being coerced into an array of Key objects.
+     *
+     * @param {!(number|string|Guacamole.OnScreenKeyboard.Key|Guacamole.OnScreenKeyboard.Key[])} object
+     *     The object defining the behavior of the key having the given name,
+     *     which may be the title of the key (a string), the keysym (a number),
+     *     a single Key object, or an array of Key objects.
+     *
+     * @returns {!Guacamole.OnScreenKeyboard.Key[]}
+     *     An array of all keys associated with the given name.
+     */
+    var asKeyArray = function asKeyArray(name, object) {
+        // If already an array, just coerce into a true Key[]
+        if (object instanceof Array) {
+            var keys = [];
+            for (var i = 0; i < object.length; i++) {
+                keys.push(new Guacamole.OnScreenKeyboard.Key(object[i], name));
+            }
+            return keys;
+        }
+
+        // Derive key object from keysym if that's all we have
+        if (typeof object === 'number') {
+            return [
+                new Guacamole.OnScreenKeyboard.Key({
+                    name: name,
+                    keysym: object,
+                }),
+            ];
+        }
+
+        // Derive key object from title if that's all we have
+        if (typeof object === 'string') {
+            return [
+                new Guacamole.OnScreenKeyboard.Key({
+                    name: name,
+                    title: object,
+                }),
+            ];
+        }
+
+        // Otherwise, assume it's already a key object, just not an array
+        return [new Guacamole.OnScreenKeyboard.Key(object, name)];
+    };
+
+    /**
+     * Converts the rather forgiving key mapping allowed by
+     * Guacamole.OnScreenKeyboard.Layout into a rigorous mapping of key name
+     * to key definition, where the key definition is always an array of Key
+     * objects.
+     *
+     * @private
+     * @param {!Object.} keys
+     *     A mapping of key name to key definition, where the key definition is
+     *     the title of the key (a string), the keysym (a number), a single
+     *     Key object, or an array of Key objects.
+     *
+     * @returns {!Object.}
+     *     A more-predictable mapping of key name to key definition, where the
+     *     key definition is always simply an array of Key objects.
+     */
+    var getKeys = function getKeys(keys) {
+        var keyArrays = {};
+
+        // Coerce all keys into individual key arrays
+        for (var name in layout.keys) {
+            keyArrays[name] = asKeyArray(name, keys[name]);
+        }
+
+        return keyArrays;
+    };
+
+    /**
+     * Map of all key names to their corresponding set of keys. Each key name
+     * may correspond to multiple keys due to the effect of modifiers.
+     *
+     * @type {!Object.}
+     */
+    this.keys = getKeys(layout.keys);
+
+    /**
+     * Given an arbitrary string representing the name of some component of the
+     * on-screen keyboard, returns a string formatted for use as a CSS class
+     * name. The result will be lowercase. Word boundaries previously denoted
+     * by CamelCase will be replaced by individual hyphens, as will all
+     * contiguous non-alphanumeric characters.
+     *
+     * @private
+     * @param {!string} name
+     *     An arbitrary string representing the name of some component of the
+     *     on-screen keyboard.
+     *
+     * @returns {!string}
+     *     A string formatted for use as a CSS class name.
+     */
+    var getCSSName = function getCSSName(name) {
+        // Convert name from possibly-CamelCase to hyphenated lowercase
+        var cssName = name
+            .replace(/([a-z])([A-Z])/g, '$1-$2')
+            .replace(/[^A-Za-z0-9]+/g, '-')
+            .toLowerCase();
+
+        return cssName;
+    };
+
+    /**
+     * Appends DOM elements to the given element as dictated by the layout
+     * structure object provided. If a name is provided, an additional CSS
+     * class, prepended with "guac-keyboard-", will be added to the top-level
+     * element.
+     *
+     * If the layout structure object is an array, all elements within that
+     * array will be recursively appended as children of a group, and the
+     * top-level element will be given the CSS class "guac-keyboard-group".
+     *
+     * If the layout structure object is an object, all properties within that
+     * object will be recursively appended as children of a group, and the
+     * top-level element will be given the CSS class "guac-keyboard-group". The
+     * name of each property will be applied as the name of each child object
+     * for the sake of CSS. Each property will be added in sorted order.
+     *
+     * If the layout structure object is a string, the key having that name
+     * will be appended. The key will be given the CSS class
+     * "guac-keyboard-key" and "guac-keyboard-key-NAME", where NAME is the name
+     * of the key. If the name of the key is a single character, this will
+     * first be transformed into the C-style hexadecimal literal for the
+     * Unicode codepoint of that character. For example, the key "A" would
+     * become "guac-keyboard-key-0x41".
+     *
+     * If the layout structure object is a number, a gap of that size will be
+     * inserted. The gap will be given the CSS class "guac-keyboard-gap", and
+     * will be scaled according to the same size units as each key.
+     *
+     * @private
+     * @param {!Element} element
+     *     The element to append elements to.
+     *
+     * @param {!(Array|object|string|number)} object
+     *     The layout structure object to use when constructing the elements to
+     *     append.
+     *
+     * @param {string} [name]
+     *     The name of the top-level element being appended, if any.
+     */
+    var appendElements = function appendElements(element, object, name) {
+        var i;
+
+        // Create div which will become the group or key
+        var div = document.createElement('div');
+
+        // Add class based on name, if name given
+        if (name) addClass(div, 'guac-keyboard-' + getCSSName(name));
+
+        // If an array, append each element
+        if (object instanceof Array) {
+            // Add group class
+            addClass(div, 'guac-keyboard-group');
+
+            // Append all elements of array
+            for (i = 0; i < object.length; i++) appendElements(div, object[i]);
+        }
+
+        // If an object, append each property value
+        else if (object instanceof Object) {
+            // Add group class
+            addClass(div, 'guac-keyboard-group');
+
+            // Append all children, sorted by name
+            var names = Object.keys(object).sort();
+            for (i = 0; i < names.length; i++) {
+                var name = names[i];
+                appendElements(div, object[name], name);
+            }
+        }
+
+        // If a number, create as a gap
+        else if (typeof object === 'number') {
+            // Add gap class
+            addClass(div, 'guac-keyboard-gap');
+
+            // Maintain scale
+            scaledElements.push(new ScaledElement(div, object, object));
+        }
+
+        // If a string, create as a key
+        else if (typeof object === 'string') {
+            // If key name is only one character, use codepoint for name
+            var keyName = object;
+            if (keyName.length === 1) keyName = '0x' + keyName.charCodeAt(0).toString(16);
+
+            // Add key container class
+            addClass(div, 'guac-keyboard-key-container');
+
+            // Create key element which will contain all possible caps
+            var keyElement = document.createElement('div');
+            keyElement.className = 'guac-keyboard-key ' + 'guac-keyboard-key-' + getCSSName(keyName);
+
+            // Add all associated keys as caps within DOM
+            var keys = osk.keys[object];
+            if (keys) {
+                for (i = 0; i < keys.length; i++) {
+                    // Get current key
+                    var key = keys[i];
+
+                    // Create cap element for key
+                    var capElement = document.createElement('div');
+                    capElement.className = 'guac-keyboard-cap';
+                    capElement.textContent = key.title;
+
+                    // Add classes for any requirements
+                    for (var j = 0; j < key.requires.length; j++) {
+                        var requirement = key.requires[j];
+                        addClass(capElement, 'guac-keyboard-requires-' + getCSSName(requirement));
+                        addClass(keyElement, 'guac-keyboard-uses-' + getCSSName(requirement));
+                    }
+
+                    // Add cap to key within DOM
+                    keyElement.appendChild(capElement);
+                }
+            }
+
+            // Add key to DOM, maintain scale
+            div.appendChild(keyElement);
+            scaledElements.push(new ScaledElement(div, osk.layout.keyWidths[object] || 1, 1, true));
+
+            /**
+             * Handles a touch event which results in the pressing of an OSK
+             * key. Touch events will result in mouse events being ignored for
+             * touchMouseThreshold events.
+             *
+             * @private
+             * @param {!TouchEvent} e
+             *     The touch event being handled.
+             */
+            var touchPress = function touchPress(e) {
+                e.preventDefault();
+                ignoreMouse = osk.touchMouseThreshold;
+                press(object, keyElement);
+            };
+
+            /**
+             * Handles a touch event which results in the release of an OSK
+             * key. Touch events will result in mouse events being ignored for
+             * touchMouseThreshold events.
+             *
+             * @private
+             * @param {!TouchEvent} e
+             *     The touch event being handled.
+             */
+            var touchRelease = function touchRelease(e) {
+                e.preventDefault();
+                ignoreMouse = osk.touchMouseThreshold;
+                release(object, keyElement);
+            };
+
+            /**
+             * Handles a mouse event which results in the pressing of an OSK
+             * key. If mouse events are currently being ignored, this handler
+             * does nothing.
+             *
+             * @private
+             * @param {!MouseEvent} e
+             *     The touch event being handled.
+             */
+            var mousePress = function mousePress(e) {
+                e.preventDefault();
+                if (ignoreMouse === 0) press(object, keyElement);
+            };
+
+            /**
+             * Handles a mouse event which results in the release of an OSK
+             * key. If mouse events are currently being ignored, this handler
+             * does nothing.
+             *
+             * @private
+             * @param {!MouseEvent} e
+             *     The touch event being handled.
+             */
+            var mouseRelease = function mouseRelease(e) {
+                e.preventDefault();
+                if (ignoreMouse === 0) release(object, keyElement);
+            };
+
+            // Handle touch events on key
+            keyElement.addEventListener('touchstart', touchPress, true);
+            keyElement.addEventListener('touchend', touchRelease, true);
+
+            // Handle mouse events on key
+            keyElement.addEventListener('mousedown', mousePress, true);
+            keyElement.addEventListener('mouseup', mouseRelease, true);
+            keyElement.addEventListener('mouseout', mouseRelease, true);
+        } // end if object is key name
+
+        // Add newly-created group/key
+        element.appendChild(div);
+    };
+
+    // Create keyboard layout in DOM
+    appendElements(keyboard, layout.layout);
+};
+
+/**
+ * Represents an entire on-screen keyboard layout, including all available
+ * keys, their behaviors, and their relative position and sizing.
+ *
+ * @constructor
+ * @param {!(Guacamole.OnScreenKeyboard.Layout|object)} template
+ *     The object whose identically-named properties will be used to initialize
+ *     the properties of this layout.
+ */
+Guacamole.OnScreenKeyboard.Layout = function (template) {
+    /**
+     * The language of keyboard layout, such as "en_US". This property is for
+     * informational purposes only, but it is recommend to conform to the
+     * [language code]_[country code] format.
+     *
+     * @type {!string}
+     */
+    this.language = template.language;
+
+    /**
+     * The type of keyboard layout, such as "qwerty". This property is for
+     * informational purposes only, and does not conform to any standard.
+     *
+     * @type {!string}
+     */
+    this.type = template.type;
+
+    /**
+     * Map of key name to corresponding keysym, title, or key object. If only
+     * the keysym or title is provided, the key object will be created
+     * implicitly. In all cases, the name property of the key object will be
+     * taken from the name given in the mapping.
+     *
+     * @type {!Object.}
+     */
+    this.keys = template.keys;
+
+    /**
+     * Arbitrarily nested, arbitrarily grouped key names. The contents of the
+     * layout will be traversed to produce an identically-nested grouping of
+     * keys in the DOM tree. All strings will be transformed into their
+     * corresponding sets of keys, while all objects and arrays will be
+     * transformed into named groups and anonymous groups respectively. Any
+     * numbers present will be transformed into gaps of that size, scaled
+     * according to the same units as each key.
+     *
+     * @type {!object}
+     */
+    this.layout = template.layout;
+
+    /**
+     * The width of the entire keyboard, in arbitrary units. The width of each
+     * key is relative to this width, as both width values are assumed to be in
+     * the same units. The conversion factor between these units and pixels is
+     * derived later via a call to resize() on the Guacamole.OnScreenKeyboard.
+     *
+     * @type {!number}
+     */
+    this.width = template.width;
+
+    /**
+     * The width of each key, in arbitrary units, relative to other keys in
+     * this layout. The true pixel size of each key will be determined by the
+     * overall size of the keyboard. If not defined here, the width of each
+     * key will default to 1.
+     *
+     * @type {!Object.}
+     */
+    this.keyWidths = template.keyWidths || {};
+};
+
+/**
+ * Represents a single key, or a single possible behavior of a key. Each key
+ * on the on-screen keyboard must have at least one associated
+ * Guacamole.OnScreenKeyboard.Key, whether that key is explicitly defined or
+ * implied, and may have multiple Guacamole.OnScreenKeyboard.Key if behavior
+ * depends on modifier states.
+ *
+ * @constructor
+ * @param {!(Guacamole.OnScreenKeyboard.Key|object)} template
+ *     The object whose identically-named properties will be used to initialize
+ *     the properties of this key.
+ *
+ * @param {string} [name]
+ *     The name to use instead of any name provided within the template, if
+ *     any. If omitted, the name within the template will be used, assuming the
+ *     template contains a name.
+ */
+Guacamole.OnScreenKeyboard.Key = function (template, name) {
+    /**
+     * The unique name identifying this key within the keyboard layout.
+     *
+     * @type {!string}
+     */
+    this.name = name || template.name;
+
+    /**
+     * The human-readable title that will be displayed to the user within the
+     * key. If not provided, this will be derived from the key name.
+     *
+     * @type {!string}
+     */
+    this.title = template.title || this.name;
+
+    /**
+     * The keysym to be pressed/released when this key is pressed/released. If
+     * not provided, this will be derived from the title if the title is a
+     * single character.
+     *
+     * @type {number}
+     */
+    this.keysym =
+        template.keysym ||
+        (function deriveKeysym(title) {
+            // Do not derive keysym if title is not exactly one character
+            if (!title || title.length !== 1) return null;
+
+            // For characters between U+0000 and U+00FF, the keysym is the codepoint
+            var charCode = title.charCodeAt(0);
+            if (charCode >= 0x0000 && charCode <= 0x00ff) return charCode;
+
+            // For characters between U+0100 and U+10FFFF, the keysym is the codepoint or'd with 0x01000000
+            if (charCode >= 0x0100 && charCode <= 0x10ffff) return 0x01000000 | charCode;
+
+            // Unable to derive keysym
+            return null;
+        })(this.title);
+
+    /**
+     * The name of the modifier set when the key is pressed and cleared when
+     * this key is released, if any. The names of modifiers are distinct from
+     * the names of keys; both the "RightShift" and "LeftShift" keys may set
+     * the "shift" modifier, for example. By default, the key will affect no
+     * modifiers.
+     *
+     * @type {string}
+     */
+    this.modifier = template.modifier;
+
+    /**
+     * An array containing the names of each modifier required for this key to
+     * have an effect. For example, a lowercase letter may require nothing,
+     * while an uppercase letter would require "shift", assuming the Shift key
+     * is named "shift" within the layout. By default, the key will require
+     * no modifiers.
+     *
+     * @type {!string[]}
+     */
+    this.requires = template.requires || [];
+};
+
+/**
+ * Abstract stream which can receive data.
+ *
+ * @constructor
+ * @param {!Guacamole.Client} client
+ *     The client owning this stream.
+ *
+ * @param {!number} index
+ *     The index of this stream.
+ */
+Guacamole.OutputStream = function (client, index) {
+    /**
+     * Reference to this stream.
+     *
+     * @private
+     * @type {!Guacamole.OutputStream}
+     */
+    var guac_stream = this;
+
+    /**
+     * The index of this stream.
+     * @type {!number}
+     */
+    this.index = index;
+
+    /**
+     * Fired whenever an acknowledgement is received from the server, indicating
+     * that a stream operation has completed, or an error has occurred.
+     *
+     * @event
+     * @param {!Guacamole.Status} status
+     *     The status of the operation.
+     */
+    this.onack = null;
+
+    /**
+     * Writes the given base64-encoded data to this stream as a blob.
+     *
+     * @param {!string} data
+     *     The base64-encoded data to send.
+     */
+    this.sendBlob = function (data) {
+        client.sendBlob(guac_stream.index, data);
+    };
+
+    /**
+     * Closes this stream.
+     */
+    this.sendEnd = function () {
+        client.endStream(guac_stream.index);
+    };
+};
+
+/**
+ * Simple Guacamole protocol parser that invokes an oninstruction event when
+ * full instructions are available from data received via receive().
+ *
+ * @constructor
+ */
+Guacamole.Parser = function () {
+    /**
+     * Reference to this parser.
+     * @private
+     */
+    var parser = this;
+
+    /**
+     * Current buffer of received data. This buffer grows until a full
+     * element is available. After a full element is available, that element
+     * is flushed into the element buffer.
+     *
+     * @private
+     */
+    var buffer = '';
+
+    /**
+     * Buffer of all received, complete elements. After an entire instruction
+     * is read, this buffer is flushed, and a new instruction begins.
+     *
+     * @private
+     */
+    var element_buffer = [];
+
+    // The location of the last element's terminator
+    var element_end = -1;
+
+    // Where to start the next length search or the next element
+    var start_index = 0;
+
+    /**
+     * Appends the given instruction data packet to the internal buffer of
+     * this Guacamole.Parser, executing all completed instructions at
+     * the beginning of this buffer, if any.
+     *
+     * @param {!string} packet
+     *     The instruction data to receive.
+     */
+    this.receive = function (packet) {
+        // Truncate buffer as necessary
+        if (start_index > 4096 && element_end >= start_index) {
+            buffer = buffer.substring(start_index);
+
+            // Reset parse relative to truncation
+            element_end -= start_index;
+            start_index = 0;
+        }
+
+        // Append data to buffer
+        buffer += packet;
+
+        // While search is within currently received data
+        while (element_end < buffer.length) {
+            // If we are waiting for element data
+            if (element_end >= start_index) {
+                // We now have enough data for the element. Parse.
+                var element = buffer.substring(start_index, element_end);
+                var terminator = buffer.substring(element_end, element_end + 1);
+
+                // Add element to array
+                element_buffer.push(element);
+
+                // If last element, handle instruction
+                if (terminator == ';') {
+                    // Get opcode
+                    var opcode = element_buffer.shift();
+
+                    // Call instruction handler.
+                    if (parser.oninstruction != null) parser.oninstruction(opcode, element_buffer);
+
+                    // Clear elements
+                    element_buffer.length = 0;
+                } else if (terminator != ',') throw new Error('Illegal terminator.');
+
+                // Start searching for length at character after
+                // element terminator
+                start_index = element_end + 1;
+            }
+
+            // Search for end of length
+            var length_end = buffer.indexOf('.', start_index);
+            if (length_end != -1) {
+                // Parse length
+                var length = parseInt(buffer.substring(element_end + 1, length_end));
+                if (isNaN(length)) throw new Error('Non-numeric character in element length.');
+
+                // Calculate start of element
+                start_index = length_end + 1;
+
+                // Calculate location of element terminator
+                element_end = start_index + length;
+            }
+
+            // If no period yet, continue search when more data
+            // is received
+            else {
+                start_index = buffer.length;
+                break;
+            }
+        } // end parse loop
+    };
+
+    /**
+     * Fired once for every complete Guacamole instruction received, in order.
+     *
+     * @event
+     * @param {!string} opcode
+     *     The Guacamole instruction opcode.
+     *
+     * @param {!string[]} parameters
+     *     The parameters provided for the instruction, if any.
+     */
+    this.oninstruction = null;
+};
+
+/**
+ * A position in 2-D space.
+ *
+ * @constructor
+ * @param {Guacamole.Position|object} [template={}]
+ *     The object whose properties should be copied within the new
+ *     Guacamole.Position.
+ */
+Guacamole.Position = function Position(template) {
+    template = template || {};
+
+    /**
+     * The current X position, in pixels.
+     *
+     * @type {!number}
+     * @default 0
+     */
+    this.x = template.x || 0;
+
+    /**
+     * The current Y position, in pixels.
+     *
+     * @type {!number}
+     * @default 0
+     */
+    this.y = template.y || 0;
+
+    /**
+     * Assigns the position represented by the given element and
+     * clientX/clientY coordinates. The clientX and clientY coordinates are
+     * relative to the browser viewport and are commonly available within
+     * JavaScript event objects. The final position is translated to
+     * coordinates that are relative the given element.
+     *
+     * @param {!Element} element
+     *     The element the coordinates should be relative to.
+     *
+     * @param {!number} clientX
+     *     The viewport-relative X coordinate to translate.
+     *
+     * @param {!number} clientY
+     *     The viewport-relative Y coordinate to translate.
+     */
+    this.fromClientPosition = function fromClientPosition(element, clientX, clientY) {
+        this.x = clientX - element.offsetLeft;
+        this.y = clientY - element.offsetTop;
+
+        // This is all JUST so we can get the position within the element
+        var parent = element.offsetParent;
+        while (parent && !(parent === document.body)) {
+            this.x -= parent.offsetLeft - parent.scrollLeft;
+            this.y -= parent.offsetTop - parent.scrollTop;
+
+            parent = parent.offsetParent;
+        }
+
+        // Element ultimately depends on positioning within document body,
+        // take document scroll into account.
+        if (parent) {
+            var documentScrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft;
+            var documentScrollTop = document.body.scrollTop || document.documentElement.scrollTop;
+
+            this.x -= parent.offsetLeft - documentScrollLeft;
+            this.y -= parent.offsetTop - documentScrollTop;
+        }
+    };
+};
+
+/**
+ * Returns a new {@link Guacamole.Position} representing the relative position
+ * of the given clientX/clientY coordinates within the given element. The
+ * clientX and clientY coordinates are relative to the browser viewport and are
+ * commonly available within JavaScript event objects. The final position is
+ * translated to  coordinates that are relative the given element.
+ *
+ * @param {!Element} element
+ *     The element the coordinates should be relative to.
+ *
+ * @param {!number} clientX
+ *     The viewport-relative X coordinate to translate.
+ *
+ * @param {!number} clientY
+ *     The viewport-relative Y coordinate to translate.
+ *
+ * @returns {!Guacamole.Position}
+ *     A new Guacamole.Position representing the relative position of the given
+ *     client coordinates.
+ */
+Guacamole.Position.fromClientPosition = function fromClientPosition(element, clientX, clientY) {
+    var position = new Guacamole.Position();
+    position.fromClientPosition(element, clientX, clientY);
+    return position;
+};
+
+/**
+ * A description of the format of raw PCM audio, such as that used by
+ * Guacamole.RawAudioPlayer and Guacamole.RawAudioRecorder. This object
+ * describes the number of bytes per sample, the number of channels, and the
+ * overall sample rate.
+ *
+ * @constructor
+ * @param {!(Guacamole.RawAudioFormat|object)} template
+ *     The object whose properties should be copied into the corresponding
+ *     properties of the new Guacamole.RawAudioFormat.
+ */
+Guacamole.RawAudioFormat = function RawAudioFormat(template) {
+    /**
+     * The number of bytes in each sample of audio data. This value is
+     * independent of the number of channels.
+     *
+     * @type {!number}
+     */
+    this.bytesPerSample = template.bytesPerSample;
+
+    /**
+     * The number of audio channels (ie: 1 for mono, 2 for stereo).
+     *
+     * @type {!number}
+     */
+    this.channels = template.channels;
+
+    /**
+     * The number of samples per second, per channel.
+     *
+     * @type {!number}
+     */
+    this.rate = template.rate;
+};
+
+/**
+ * Parses the given mimetype, returning a new Guacamole.RawAudioFormat
+ * which describes the type of raw audio data represented by that mimetype. If
+ * the mimetype is not a supported raw audio data mimetype, null is returned.
+ *
+ * @param {!string} mimetype
+ *     The audio mimetype to parse.
+ *
+ * @returns {Guacamole.RawAudioFormat}
+ *     A new Guacamole.RawAudioFormat which describes the type of raw
+ *     audio data represented by the given mimetype, or null if the given
+ *     mimetype is not supported.
+ */
+Guacamole.RawAudioFormat.parse = function parseFormat(mimetype) {
+    var bytesPerSample;
+
+    // Rate is absolutely required - if null is still present later, the
+    // mimetype must not be supported
+    var rate = null;
+
+    // Default for both "audio/L8" and "audio/L16" is one channel
+    var channels = 1;
+
+    // "audio/L8" has one byte per sample
+    if (mimetype.substring(0, 9) === 'audio/L8;') {
+        mimetype = mimetype.substring(9);
+        bytesPerSample = 1;
+    }
+
+    // "audio/L16" has two bytes per sample
+    else if (mimetype.substring(0, 10) === 'audio/L16;') {
+        mimetype = mimetype.substring(10);
+        bytesPerSample = 2;
+    }
+
+    // All other types are unsupported
+    else return null;
+
+    // Parse all parameters
+    var parameters = mimetype.split(',');
+    for (var i = 0; i < parameters.length; i++) {
+        var parameter = parameters[i];
+
+        // All parameters must have an equals sign separating name from value
+        var equals = parameter.indexOf('=');
+        if (equals === -1) return null;
+
+        // Parse name and value from parameter string
+        var name = parameter.substring(0, equals);
+        var value = parameter.substring(equals + 1);
+
+        // Handle each supported parameter
+        switch (name) {
+            // Number of audio channels
+            case 'channels':
+                channels = parseInt(value);
+                break;
+
+            // Sample rate
+            case 'rate':
+                rate = parseInt(value);
+                break;
+
+            // All other parameters are unsupported
+            default:
+                return null;
+        }
+    }
+
+    // The rate parameter is required
+    if (rate === null) return null;
+
+    // Return parsed format details
+    return new Guacamole.RawAudioFormat({
+        bytesPerSample: bytesPerSample,
+        channels: channels,
+        rate: rate,
+    });
+};
+
+/**
+ * A recording of a Guacamole session. Given a {@link Guacamole.Tunnel} or Blob,
+ * the Guacamole.SessionRecording automatically parses Guacamole instructions
+ * within the recording source as it plays back the recording. Playback of the
+ * recording may be controlled through function calls to the
+ * Guacamole.SessionRecording, even while the recording has not yet finished
+ * being created or downloaded. Parsing of the contents of the recording will
+ * begin immediately and automatically after this constructor is invoked.
+ *
+ * @constructor
+ * @param {!Blob|Guacamole.Tunnel} source
+ *     The Blob from which the instructions of the recording should
+ *     be read.
+ */
+Guacamole.SessionRecording = function SessionRecording(source) {
+    /**
+     * Reference to this Guacamole.SessionRecording.
+     *
+     * @private
+     * @type {!Guacamole.SessionRecording}
+     */
+    var recording = this;
+
+    /**
+     * The Blob from which the instructions of the recording should be read.
+     * Note that this value is initialized far below.
+     *
+     * @private
+     * @type {!Blob}
+     */
+    var recordingBlob;
+
+    /**
+     * The tunnel from which the recording should be read, if the recording is
+     * being read from a tunnel. If the recording was supplied as a Blob, this
+     * will be null.
+     *
+     * @private
+     * @type {Guacamole.Tunnel}
+     */
+    var tunnel = null;
+
+    /**
+     * The number of bytes that this Guacamole.SessionRecording should attempt
+     * to read from the given blob in each read operation. Larger blocks will
+     * generally read the blob more quickly, but may result in excessive
+     * time being spent within the parser, making the page unresponsive
+     * while the recording is loading.
+     *
+     * @private
+     * @constant
+     * @type {Number}
+     */
+    var BLOCK_SIZE = 262144;
+
+    /**
+     * The minimum number of characters which must have been read between
+     * keyframes.
+     *
+     * @private
+     * @constant
+     * @type {Number}
+     */
+    var KEYFRAME_CHAR_INTERVAL = 16384;
+
+    /**
+     * The minimum number of milliseconds which must elapse between keyframes.
+     *
+     * @private
+     * @constant
+     * @type {Number}
+     */
+    var KEYFRAME_TIME_INTERVAL = 5000;
+
+    /**
+     * All frames parsed from the provided blob.
+     *
+     * @private
+     * @type {!Guacamole.SessionRecording._Frame[]}
+     */
+    var frames = [];
+
+    /**
+     * The timestamp of the last frame which was flagged for use as a keyframe.
+     * If no timestamp has yet been flagged, this will be 0.
+     *
+     * @private
+     * @type {!number}
+     */
+    var lastKeyframe = 0;
+
+    /**
+     * Tunnel which feeds arbitrary instructions to the client used by this
+     * Guacamole.SessionRecording for playback of the session recording.
+     *
+     * @private
+     * @type {!Guacamole.SessionRecording._PlaybackTunnel}
+     */
+    var playbackTunnel = new Guacamole.SessionRecording._PlaybackTunnel();
+
+    /**
+     * Guacamole.Client instance used for visible playback of the session
+     * recording.
+     *
+     * @private
+     * @type {!Guacamole.Client}
+     */
+    var playbackClient = new Guacamole.Client(playbackTunnel);
+
+    /**
+     * The current frame rendered within the playback client. If no frame is
+     * yet rendered, this will be -1.
+     *
+     * @private
+     * @type {!number}
+     */
+    var currentFrame = -1;
+
+    /**
+     * The timestamp of the frame when playback began, in milliseconds. If
+     * playback is not in progress, this will be null.
+     *
+     * @private
+     * @type {number}
+     */
+    var startVideoTimestamp = null;
+
+    /**
+     * The real-world timestamp when playback began, in milliseconds. If
+     * playback is not in progress, this will be null.
+     *
+     * @private
+     * @type {number}
+     */
+    var startRealTimestamp = null;
+
+    /**
+     * An object containing a single "aborted" property which is set to
+     * true if the in-progress seek operation should be aborted. If no seek
+     * operation is in progress, this will be null.
+     *
+     * @private
+     * @type {object}
+     */
+    var activeSeek = null;
+
+    /**
+     * The byte offset within the recording blob of the first character of
+     * the first instruction of the current frame. Here, "current frame"
+     * refers to the frame currently being parsed when the provided
+     * recording is initially loading. If the recording is not being
+     * loaded, this value has no meaning.
+     *
+     * @private
+     * @type {!number}
+     */
+    var frameStart = 0;
+
+    /**
+     * The byte offset within the recording blob of the character which
+     * follows the last character of the most recently parsed instruction
+     * of the current frame. Here, "current frame" refers to the frame
+     * currently being parsed when the provided recording is initially
+     * loading. If the recording is not being loaded, this value has no
+     * meaning.
+     *
+     * @private
+     * @type {!number}
+     */
+    var frameEnd = 0;
+
+    /**
+     * Whether the initial loading process has been aborted. If the loading
+     * process has been aborted, no further blocks of data should be read
+     * from the recording.
+     *
+     * @private
+     * @type {!boolean}
+     */
+    var aborted = false;
+
+    /**
+     * The function to invoke when the seek operation initiated by a call
+     * to seek() is cancelled or successfully completed. If no seek
+     * operation is in progress, this will be null.
+     *
+     * @private
+     * @type {function}
+     */
+    var seekCallback = null;
+
+    /**
+     * Parses all Guacamole instructions within the given blob, invoking
+     * the provided instruction callback for each such instruction. Once
+     * the end of the blob has been reached (no instructions remain to be
+     * parsed), the provided completion callback is invoked. If a parse
+     * error prevents reading instructions from the blob, the onerror
+     * callback of the Guacamole.SessionRecording is invoked, and no further
+     * data is handled within the blob.
+     *
+     * @private
+     * @param {!Blob} blob
+     *     The blob to parse Guacamole instructions from.
+     *
+     * @param {function} [instructionCallback]
+     *     The callback to invoke for each Guacamole instruction read from
+     *     the given blob. This function must accept the same arguments
+     *     as the oninstruction handler of Guacamole.Parser.
+     *
+     * @param {function} [completionCallback]
+     *     The callback to invoke once all instructions have been read from
+     *     the given blob.
+     */
+    var parseBlob = function parseBlob(blob, instructionCallback, completionCallback) {
+        // Do not read any further blocks if loading has been aborted
+        if (aborted && blob === recordingBlob) return;
+
+        // Prepare a parser to handle all instruction data within the blob,
+        // automatically invoking the provided instruction callback for all
+        // parsed instructions
+        var parser = new Guacamole.Parser();
+        parser.oninstruction = instructionCallback;
+
+        var offset = 0;
+        var reader = new FileReader();
+
+        /**
+         * Reads the block of data at offset bytes within the blob. If no
+         * such block exists, then the completion callback provided to
+         * parseBlob() is invoked as all data has been read.
+         *
+         * @private
+         */
+        var readNextBlock = function readNextBlock() {
+            // Do not read any further blocks if loading has been aborted
+            if (aborted && blob === recordingBlob) return;
+
+            // Parse all instructions within the block, invoking the
+            // onerror handler if a parse error occurs
+            if (reader.readyState === 2 /* DONE */) {
+                try {
+                    parser.receive(reader.result);
+                } catch (parseError) {
+                    if (recording.onerror) {
+                        recording.onerror(parseError.message);
+                    }
+                    return;
+                }
+            }
+
+            // If no data remains, the read operation is complete and no
+            // further blocks need to be read
+            if (offset >= blob.size) {
+                if (completionCallback) completionCallback();
+            }
+
+            // Otherwise, read the next block
+            else {
+                var block = blob.slice(offset, offset + BLOCK_SIZE);
+                offset += block.size;
+                reader.readAsText(block);
+            }
+        };
+
+        // Read blocks until the end of the given blob is reached
+        reader.onload = readNextBlock;
+        readNextBlock();
+    };
+
+    /**
+     * Calculates the size of the given Guacamole instruction element, in
+     * Unicode characters. The size returned includes the characters which
+     * make up the length, the "." separator between the length and the
+     * element itself, and the "," or ";" terminator which follows the
+     * element.
+     *
+     * @private
+     * @param {!string} value
+     *     The value of the element which has already been parsed (lacks
+     *     the initial length, "." separator, and "," or ";" terminator).
+     *
+     * @returns {!number}
+     *     The number of Unicode characters which would make up the given
+     *     element within a Guacamole instruction.
+     */
+    var getElementSize = function getElementSize(value) {
+        var valueLength = value.length;
+
+        // Calculate base size, assuming at least one digit, the "."
+        // separator, and the "," or ";" terminator
+        var protocolSize = valueLength + 3;
+
+        // Add one character for each additional digit that would occur
+        // in the element length prefix
+        while (valueLength >= 10) {
+            protocolSize++;
+            valueLength = Math.floor(valueLength / 10);
+        }
+
+        return protocolSize;
+    };
+
+    // Start playback client connected
+    playbackClient.connect();
+
+    // Hide cursor unless mouse position is received
+    playbackClient.getDisplay().showCursor(false);
+
+    /**
+     * Handles a newly-received instruction, whether from the main Blob or a
+     * tunnel, adding new frames and keyframes as necessary. Load progress is
+     * reported via onprogress automatically.
+     *
+     * @private
+     * @param {!string} opcode
+     *     The opcode of the instruction to handle.
+     *
+     * @param {!string[]} args
+     *     The arguments of the received instruction, if any.
+     */
+    var loadInstruction = function loadInstruction(opcode, args) {
+        // Advance end of frame by overall length of parsed instruction
+        frameEnd += getElementSize(opcode);
+        for (var i = 0; i < args.length; i++) frameEnd += getElementSize(args[i]);
+
+        // Once a sync is received, store all instructions since the last
+        // frame as a new frame
+        if (opcode === 'sync') {
+            // Parse frame timestamp from sync instruction
+            var timestamp = parseInt(args[0]);
+
+            // Add a new frame containing the instructions read since last frame
+            var frame = new Guacamole.SessionRecording._Frame(timestamp, frameStart, frameEnd);
+            frames.push(frame);
+            frameStart = frameEnd;
+
+            // This frame should eventually become a keyframe if enough data
+            // has been processed and enough recording time has elapsed, or if
+            // this is the absolute first frame
+            if (
+                frames.length === 1 ||
+                (frameEnd - frames[lastKeyframe].start >= KEYFRAME_CHAR_INTERVAL && timestamp - frames[lastKeyframe].timestamp >= KEYFRAME_TIME_INTERVAL)
+            ) {
+                frame.keyframe = true;
+                lastKeyframe = frames.length - 1;
+            }
+
+            // Notify that additional content is available
+            if (recording.onprogress) recording.onprogress(recording.getDuration(), frameEnd);
+        }
+    };
+
+    /**
+     * Notifies that the session recording has been fully loaded. If the onload
+     * handler has not been defined, this function has no effect.
+     *
+     * @private
+     */
+    var notifyLoaded = function notifyLoaded() {
+        if (recording.onload) recording.onload();
+    };
+
+    // Read instructions from provided blob, extracting each frame
+    if (source instanceof Blob) parseBlob(recordingBlob, loadInstruction, notifyLoaded);
+    // If tunnel provided instead of Blob, extract frames, etc. as instructions
+    // are received, buffering things into a Blob for future seeks
+    else {
+        tunnel = source;
+        recordingBlob = new Blob();
+
+        var errorEncountered = false;
+        var instructionBuffer = '';
+
+        // Read instructions from provided tunnel, extracting each frame
+        tunnel.oninstruction = function handleInstruction(opcode, args) {
+            // Reconstitute received instruction
+            instructionBuffer += opcode.length + '.' + opcode;
+            args.forEach(function appendArg(arg) {
+                instructionBuffer += ',' + arg.length + '.' + arg;
+            });
+            instructionBuffer += ';';
+
+            // Append to Blob (creating a new Blob in the process)
+            if (instructionBuffer.length >= BLOCK_SIZE) {
+                recordingBlob = new Blob([recordingBlob, instructionBuffer]);
+                instructionBuffer = '';
+            }
+
+            // Load parsed instruction into recording
+            loadInstruction(opcode, args);
+        };
+
+        // Report any errors encountered
+        tunnel.onerror = function tunnelError(status) {
+            errorEncountered = true;
+            if (recording.onerror) recording.onerror(status.message);
+        };
+
+        tunnel.onstatechange = function tunnelStateChanged(state) {
+            if (state === Guacamole.Tunnel.State.CLOSED) {
+                // Append any remaining instructions
+                if (instructionBuffer.length) {
+                    recordingBlob = new Blob([recordingBlob, instructionBuffer]);
+                    instructionBuffer = '';
+                }
+
+                // Consider recording loaded if tunnel has closed without errors
+                if (!errorEncountered) notifyLoaded();
+            }
+        };
+    }
+
+    /**
+     * Converts the given absolute timestamp to a timestamp which is relative
+     * to the first frame in the recording.
+     *
+     * @private
+     * @param {!number} timestamp
+     *     The timestamp to convert to a relative timestamp.
+     *
+     * @returns {!number}
+     *     The difference in milliseconds between the given timestamp and the
+     *     first frame of the recording, or zero if no frames yet exist.
+     */
+    var toRelativeTimestamp = function toRelativeTimestamp(timestamp) {
+        // If no frames yet exist, all timestamps are zero
+        if (frames.length === 0) return 0;
+
+        // Calculate timestamp relative to first frame
+        return timestamp - frames[0].timestamp;
+    };
+
+    /**
+     * Searches through the given region of frames for the frame having a
+     * relative timestamp closest to the timestamp given.
+     *
+     * @private
+     * @param {!number} minIndex
+     *     The index of the first frame in the region (the frame having the
+     *     smallest timestamp).
+     *
+     * @param {!number} maxIndex
+     *     The index of the last frame in the region (the frame having the
+     *     largest timestamp).
+     *
+     * @param {!number} timestamp
+     *     The relative timestamp to search for, where zero denotes the first
+     *     frame in the recording.
+     *
+     * @returns {!number}
+     *     The index of the frame having a relative timestamp closest to the
+     *     given value.
+     */
+    var findFrame = function findFrame(minIndex, maxIndex, timestamp) {
+        // Do not search if the region contains only one element
+        if (minIndex === maxIndex) return minIndex;
+
+        // Split search region into two halves
+        var midIndex = Math.floor((minIndex + maxIndex) / 2);
+        var midTimestamp = toRelativeTimestamp(frames[midIndex].timestamp);
+
+        // If timestamp is within lesser half, search again within that half
+        if (timestamp < midTimestamp && midIndex > minIndex) return findFrame(minIndex, midIndex - 1, timestamp);
+
+        // If timestamp is within greater half, search again within that half
+        if (timestamp > midTimestamp && midIndex < maxIndex) return findFrame(midIndex + 1, maxIndex, timestamp);
+
+        // Otherwise, we lucked out and found a frame with exactly the
+        // desired timestamp
+        return midIndex;
+    };
+
+    /**
+     * Replays the instructions associated with the given frame, sending those
+     * instructions to the playback client.
+     *
+     * @private
+     * @param {!number} index
+     *     The index of the frame within the frames array which should be
+     *     replayed.
+     *
+     * @param {function} callback
+     *     The callback to invoke once replay of the frame has completed.
+     */
+    var replayFrame = function replayFrame(index, callback) {
+        var frame = frames[index];
+
+        // Replay all instructions within the retrieved frame
+        parseBlob(
+            recordingBlob.slice(frame.start, frame.end),
+            function handleInstruction(opcode, args) {
+                playbackTunnel.receiveInstruction(opcode, args);
+            },
+            function replayCompleted() {
+                // Store client state if frame is flagged as a keyframe
+                if (frame.keyframe && !frame.clientState) {
+                    playbackClient.exportState(function storeClientState(state) {
+                        frame.clientState = new Blob([JSON.stringify(state)]);
+                    });
+                }
+
+                // Update state to correctly represent the current frame
+                currentFrame = index;
+
+                if (callback) callback();
+            }
+        );
+    };
+
+    /**
+     * Moves the playback position to the given frame, resetting the state of
+     * the playback client and replaying frames as necessary. The seek
+     * operation will proceed asynchronously. If a seek operation is already in
+     * progress, that seek is first aborted. The progress of the seek operation
+     * can be observed through the onseek handler and the provided callback.
+     *
+     * @private
+     * @param {!number} index
+     *     The index of the frame which should become the new playback
+     *     position.
+     *
+     * @param {function} callback
+     *     The callback to invoke once the seek operation has completed.
+     *
+     * @param {number} [nextRealTimestamp]
+     *     The timestamp of the point in time that the given frame should be
+     *     displayed, as would be returned by new Date().getTime(). If omitted,
+     *     the frame will be displayed as soon as possible.
+     */
+    var seekToFrame = function seekToFrame(index, callback, nextRealTimestamp) {
+        // Abort any in-progress seek
+        abortSeek();
+
+        // Note that a new seek operation is in progress
+        var thisSeek = (activeSeek = {
+            aborted: false,
+        });
+
+        var startIndex = index;
+
+        // Replay any applicable incremental frames
+        var continueReplay = function continueReplay() {
+            // Notify of changes in position
+            if (recording.onseek && currentFrame > startIndex) {
+                recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp), currentFrame - startIndex, index - startIndex);
+            }
+
+            // Cancel seek if aborted
+            if (thisSeek.aborted) return;
+
+            // If frames remain, replay the next frame
+            if (currentFrame < index) replayFrame(currentFrame + 1, continueReplay);
+            // Otherwise, the seek operation is completed
+            else callback();
+        };
+
+        // Continue replay after requested delay has elapsed, or
+        // immediately if no delay was requested
+        var continueAfterRequiredDelay = function continueAfterRequiredDelay() {
+            var delay = nextRealTimestamp ? Math.max(nextRealTimestamp - new Date().getTime(), 0) : 0;
+            if (delay) window.setTimeout(continueReplay, delay);
+            else continueReplay();
+        };
+
+        // Back up until startIndex represents current state
+        for (; startIndex >= 0; startIndex--) {
+            var frame = frames[startIndex];
+
+            // If we've reached the current frame, startIndex represents
+            // current state by definition
+            if (startIndex === currentFrame) break;
+
+            // If frame has associated absolute state, make that frame the
+            // current state
+            if (frame.clientState) {
+                frame.clientState.text().then(function textReady(text) {
+                    playbackClient.importState(JSON.parse(text));
+                    currentFrame = startIndex;
+                    continueAfterRequiredDelay();
+                });
+                return;
+            }
+        }
+
+        continueAfterRequiredDelay();
+    };
+
+    /**
+     * Aborts the seek operation currently in progress, if any. If no seek
+     * operation is in progress, this function has no effect.
+     *
+     * @private
+     */
+    var abortSeek = function abortSeek() {
+        if (activeSeek) {
+            activeSeek.aborted = true;
+            activeSeek = null;
+        }
+    };
+
+    /**
+     * Advances playback to the next frame in the frames array and schedules
+     * playback of the frame following that frame based on their associated
+     * timestamps. If no frames exist after the next frame, playback is paused.
+     *
+     * @private
+     */
+    var continuePlayback = function continuePlayback() {
+        // If frames remain after advancing, schedule next frame
+        if (currentFrame + 1 < frames.length) {
+            // Pull the upcoming frame
+            var next = frames[currentFrame + 1];
+
+            // Calculate the real timestamp corresponding to when the next
+            // frame begins
+            var nextRealTimestamp = next.timestamp - startVideoTimestamp + startRealTimestamp;
+
+            // Advance to next frame after enough time has elapsed
+            seekToFrame(
+                currentFrame + 1,
+                function frameDelayElapsed() {
+                    continuePlayback();
+                },
+                nextRealTimestamp
+            );
+        }
+
+        // Otherwise stop playback
+        else recording.pause();
+    };
+
+    /**
+     * Fired when loading of this recording has completed and all frames
+     * are available.
+     *
+     * @event
+     */
+    this.onload = null;
+
+    /**
+     * Fired when an error occurs which prevents the recording from being
+     * played back.
+     *
+     * @event
+     * @param {!string} message
+     *     A human-readable message describing the error that occurred.
+     */
+    this.onerror = null;
+
+    /**
+     * Fired when further loading of this recording has been explicitly
+     * aborted through a call to abort().
+     *
+     * @event
+     */
+    this.onabort = null;
+
+    /**
+     * Fired when new frames have become available while the recording is
+     * being downloaded.
+     *
+     * @event
+     * @param {!number} duration
+     *     The new duration of the recording, in milliseconds.
+     *
+     * @param {!number} parsedSize
+     *     The number of bytes that have been loaded/parsed.
+     */
+    this.onprogress = null;
+
+    /**
+     * Fired whenever playback of the recording has started.
+     *
+     * @event
+     */
+    this.onplay = null;
+
+    /**
+     * Fired whenever playback of the recording has been paused. This may
+     * happen when playback is explicitly paused with a call to pause(), or
+     * when playback is implicitly paused due to reaching the end of the
+     * recording.
+     *
+     * @event
+     */
+    this.onpause = null;
+
+    /**
+     * Fired whenever the playback position within the recording changes.
+     *
+     * @event
+     * @param {!number} position
+     *     The new position within the recording, in milliseconds.
+     *
+     * @param {!number} current
+     *     The number of frames that have been seeked through. If not
+     *     seeking through multiple frames due to a call to seek(), this
+     *     will be 1.
+     *
+     * @param {!number} total
+     *     The number of frames that are being seeked through in the
+     *     current seek operation. If not seeking through multiple frames
+     *     due to a call to seek(), this will be 1.
+     */
+    this.onseek = null;
+
+    /**
+     * Connects the underlying tunnel, beginning download of the Guacamole
+     * session. Playback of the Guacamole session cannot occur until at least
+     * one frame worth of instructions has been downloaded. If the underlying
+     * recording source is a Blob, this function has no effect.
+     *
+     * @param {string} [data]
+     *     The data to send to the tunnel when connecting.
+     */
+    this.connect = function connect(data) {
+        if (tunnel) tunnel.connect(data);
+    };
+
+    /**
+     * Disconnects the underlying tunnel, stopping further download of the
+     * Guacamole session. If the underlying recording source is a Blob, this
+     * function has no effect.
+     */
+    this.disconnect = function disconnect() {
+        if (tunnel) tunnel.disconnect();
+    };
+
+    /**
+     * Aborts the loading process, stopping further processing of the
+     * provided data. If the underlying recording source is a Guacamole tunnel,
+     * it will be disconnected.
+     */
+    this.abort = function abort() {
+        if (!aborted) {
+            aborted = true;
+            if (recording.onabort) recording.onabort();
+
+            if (tunnel) tunnel.disconnect();
+        }
+    };
+
+    /**
+     * Returns the underlying display of the Guacamole.Client used by this
+     * Guacamole.SessionRecording for playback. The display contains an Element
+     * which can be added to the DOM, causing the display (and thus playback of
+     * the recording) to become visible.
+     *
+     * @return {!Guacamole.Display}
+     *     The underlying display of the Guacamole.Client used by this
+     *     Guacamole.SessionRecording for playback.
+     */
+    this.getDisplay = function getDisplay() {
+        return playbackClient.getDisplay();
+    };
+
+    /**
+     * Returns whether playback is currently in progress.
+     *
+     * @returns {!boolean}
+     *     true if playback is currently in progress, false otherwise.
+     */
+    this.isPlaying = function isPlaying() {
+        return !!startVideoTimestamp;
+    };
+
+    /**
+     * Returns the current playback position within the recording, in
+     * milliseconds, where zero is the start of the recording.
+     *
+     * @returns {!number}
+     *     The current playback position within the recording, in milliseconds.
+     */
+    this.getPosition = function getPosition() {
+        // Position is simply zero if playback has not started at all
+        if (currentFrame === -1) return 0;
+
+        // Return current position as a millisecond timestamp relative to the
+        // start of the recording
+        return toRelativeTimestamp(frames[currentFrame].timestamp);
+    };
+
+    /**
+     * Returns the duration of this recording, in milliseconds. If the
+     * recording is still being downloaded, this value will gradually increase.
+     *
+     * @returns {!number}
+     *     The duration of this recording, in milliseconds.
+     */
+    this.getDuration = function getDuration() {
+        // If no frames yet exist, duration is zero
+        if (frames.length === 0) return 0;
+
+        // Recording duration is simply the timestamp of the last frame
+        return toRelativeTimestamp(frames[frames.length - 1].timestamp);
+    };
+
+    /**
+     * Begins continuous playback of the recording downloaded thus far.
+     * Playback of the recording will continue until pause() is invoked or
+     * until no further frames exist. Playback is initially paused when a
+     * Guacamole.SessionRecording is created, and must be explicitly started
+     * through a call to this function. If playback is already in progress,
+     * this function has no effect. If a seek operation is in progress,
+     * playback resumes at the current position, and the seek is aborted as if
+     * completed.
+     */
+    this.play = function play() {
+        // If playback is not already in progress and frames remain,
+        // begin playback
+        if (!recording.isPlaying() && currentFrame + 1 < frames.length) {
+            // Notify that playback is starting
+            if (recording.onplay) recording.onplay();
+
+            // Store timestamp of playback start for relative scheduling of
+            // future frames
+            var next = frames[currentFrame + 1];
+            startVideoTimestamp = next.timestamp;
+            startRealTimestamp = new Date().getTime();
+
+            // Begin playback of video
+            continuePlayback();
+        }
+    };
+
+    /**
+     * Seeks to the given position within the recording. If the recording is
+     * currently being played back, playback will continue after the seek is
+     * performed. If the recording is currently paused, playback will be
+     * paused after the seek is performed. If a seek operation is already in
+     * progress, that seek is first aborted. The seek operation will proceed
+     * asynchronously.
+     *
+     * @param {!number} position
+     *     The position within the recording to seek to, in milliseconds.
+     *
+     * @param {function} [callback]
+     *     The callback to invoke once the seek operation has completed.
+     */
+    this.seek = function seek(position, callback) {
+        // Do not seek if no frames exist
+        if (frames.length === 0) return;
+
+        // Abort active seek operation, if any
+        recording.cancel();
+
+        // Pause playback, preserving playback state
+        var originallyPlaying = recording.isPlaying();
+        recording.pause();
+
+        // Restore playback when seek is completed or cancelled
+        seekCallback = function restorePlaybackState() {
+            // Seek is no longer in progress
+            seekCallback = null;
+
+            // Restore playback state
+            if (originallyPlaying) {
+                recording.play();
+                originallyPlaying = null;
+            }
+
+            // Notify that seek has completed
+            if (callback) callback();
+        };
+
+        // Perform seek
+        seekToFrame(findFrame(0, frames.length - 1, position), seekCallback);
+    };
+
+    /**
+     * Cancels the current seek operation, setting the current frame of the
+     * recording to wherever the seek operation was able to reach prior to
+     * being cancelled. If a callback was provided to seek(), that callback
+     * is invoked. If a seek operation is not currently underway, this
+     * function has no effect.
+     */
+    this.cancel = function cancel() {
+        if (seekCallback) {
+            abortSeek();
+            seekCallback();
+        }
+    };
+
+    /**
+     * Pauses playback of the recording, if playback is currently in progress.
+     * If playback is not in progress, this function has no effect. If a seek
+     * operation is in progress, the seek is aborted. Playback is initially
+     * paused when a Guacamole.SessionRecording is created, and must be
+     * explicitly started through a call to play().
+     */
+    this.pause = function pause() {
+        // Abort any in-progress seek / playback
+        abortSeek();
+
+        // Stop playback only if playback is in progress
+        if (recording.isPlaying()) {
+            // Notify that playback is stopping
+            if (recording.onpause) recording.onpause();
+
+            // Playback is stopped
+            startVideoTimestamp = null;
+            startRealTimestamp = null;
+        }
+    };
+};
+
+/**
+ * A single frame of Guacamole session data. Each frame is made up of the set
+ * of instructions used to generate that frame, and the timestamp as dictated
+ * by the "sync" instruction terminating the frame. Optionally, a frame may
+ * also be associated with a snapshot of Guacamole client state, such that the
+ * frame can be rendered without replaying all previous frames.
+ *
+ * @private
+ * @constructor
+ * @param {!number} timestamp
+ *     The timestamp of this frame, as dictated by the "sync" instruction which
+ *     terminates the frame.
+ *
+ * @param {!number} start
+ *     The byte offset within the blob of the first character of the first
+ *     instruction of this frame.
+ *
+ * @param {!number} end
+ *     The byte offset within the blob of character which follows the last
+ *     character of the last instruction of this frame.
+ */
+Guacamole.SessionRecording._Frame = function _Frame(timestamp, start, end) {
+    /**
+     * Whether this frame should be used as a keyframe if possible. This value
+     * is purely advisory. The stored clientState must eventually be manually
+     * set for the frame to be used as a keyframe. By default, frames are not
+     * keyframes.
+     *
+     * @type {!boolean}
+     * @default false
+     */
+    this.keyframe = false;
+
+    /**
+     * The timestamp of this frame, as dictated by the "sync" instruction which
+     * terminates the frame.
+     *
+     * @type {!number}
+     */
+    this.timestamp = timestamp;
+
+    /**
+     * The byte offset within the blob of the first character of the first
+     * instruction of this frame.
+     *
+     * @type {!number}
+     */
+    this.start = start;
+
+    /**
+     * The byte offset within the blob of character which follows the last
+     * character of the last instruction of this frame.
+     *
+     * @type {!number}
+     */
+    this.end = end;
+
+    /**
+     * A snapshot of client state after this frame was rendered, as returned by
+     * a call to exportState(), serialized as JSON, and stored within a Blob.
+     * Use of Blobs here is required to ensure the browser can make use of
+     * larger disk-backed storage if the size of the recording is large. If no
+     * such snapshot has been taken, this will be null.
+     *
+     * @type {Blob}
+     * @default null
+     */
+    this.clientState = null;
+};
+
+/**
+ * A read-only Guacamole.Tunnel implementation which streams instructions
+ * received through explicit calls to its receiveInstruction() function.
+ *
+ * @private
+ * @constructor
+ * @augments {Guacamole.Tunnel}
+ */
+Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() {
+    /**
+     * Reference to this Guacamole.SessionRecording._PlaybackTunnel.
+     *
+     * @private
+     * @type {!Guacamole.SessionRecording._PlaybackTunnel}
+     */
+    var tunnel = this;
+
+    this.connect = function connect(data) {
+        // Do nothing
+    };
+
+    this.sendMessage = function sendMessage(elements) {
+        // Do nothing
+    };
+
+    this.disconnect = function disconnect() {
+        // Do nothing
+    };
+
+    /**
+     * Invokes this tunnel's oninstruction handler, notifying users of this
+     * tunnel (such as a Guacamole.Client instance) that an instruction has
+     * been received. If the oninstruction handler has not been set, this
+     * function has no effect.
+     *
+     * @param {!string} opcode
+     *     The opcode of the Guacamole instruction.
+     *
+     * @param {!string[]} args
+     *     All arguments associated with this Guacamole instruction.
+     */
+    this.receiveInstruction = function receiveInstruction(opcode, args) {
+        if (tunnel.oninstruction) tunnel.oninstruction(opcode, args);
+    };
+};
+
+/**
+ * A Guacamole status. Each Guacamole status consists of a status code, defined
+ * by the protocol, and an optional human-readable message, usually only
+ * included for debugging convenience.
+ *
+ * @constructor
+ * @param {!number} code
+ *     The Guacamole status code, as defined by Guacamole.Status.Code.
+ *
+ * @param {string} [message]
+ *     An optional human-readable message.
+ */
+Guacamole.Status = function (code, message) {
+    /**
+     * Reference to this Guacamole.Status.
+     *
+     * @private
+     * @type {!Guacamole.Status}
+     */
+    var guac_status = this;
+
+    /**
+     * The Guacamole status code.
+     *
+     * @see Guacamole.Status.Code
+     * @type {!number}
+     */
+    this.code = code;
+
+    /**
+     * An arbitrary human-readable message associated with this status, if any.
+     * The human-readable message is not required, and is generally provided
+     * for debugging purposes only. For user feedback, it is better to translate
+     * the Guacamole status code into a message.
+     *
+     * @type {string}
+     */
+    this.message = message;
+
+    /**
+     * Returns whether this status represents an error.
+     *
+     * @returns {!boolean}
+     *     true if this status represents an error, false otherwise.
+     */
+    this.isError = function () {
+        return guac_status.code < 0 || guac_status.code > 0x00ff;
+    };
+};
+
+/**
+ * Enumeration of all Guacamole status codes.
+ */
+Guacamole.Status.Code = {
+    /**
+     * The operation succeeded.
+     *
+     * @type {!number}
+     */
+    SUCCESS: 0x0000,
+
+    /**
+     * The requested operation is unsupported.
+     *
+     * @type {!number}
+     */
+    UNSUPPORTED: 0x0100,
+
+    /**
+     * The operation could not be performed due to an internal failure.
+     *
+     * @type {!number}
+     */
+    SERVER_ERROR: 0x0200,
+
+    /**
+     * The operation could not be performed as the server is busy.
+     *
+     * @type {!number}
+     */
+    SERVER_BUSY: 0x0201,
+
+    /**
+     * The operation could not be performed because the upstream server is not
+     * responding.
+     *
+     * @type {!number}
+     */
+    UPSTREAM_TIMEOUT: 0x0202,
+
+    /**
+     * The operation was unsuccessful due to an error or otherwise unexpected
+     * condition of the upstream server.
+     *
+     * @type {!number}
+     */
+    UPSTREAM_ERROR: 0x0203,
+
+    /**
+     * The operation could not be performed as the requested resource does not
+     * exist.
+     *
+     * @type {!number}
+     */
+    RESOURCE_NOT_FOUND: 0x0204,
+
+    /**
+     * The operation could not be performed as the requested resource is
+     * already in use.
+     *
+     * @type {!number}
+     */
+    RESOURCE_CONFLICT: 0x0205,
+
+    /**
+     * The operation could not be performed as the requested resource is now
+     * closed.
+     *
+     * @type {!number}
+     */
+    RESOURCE_CLOSED: 0x0206,
+
+    /**
+     * The operation could not be performed because the upstream server does
+     * not appear to exist.
+     *
+     * @type {!number}
+     */
+    UPSTREAM_NOT_FOUND: 0x0207,
+
+    /**
+     * The operation could not be performed because the upstream server is not
+     * available to service the request.
+     *
+     * @type {!number}
+     */
+    UPSTREAM_UNAVAILABLE: 0x0208,
+
+    /**
+     * The session within the upstream server has ended because it conflicted
+     * with another session.
+     *
+     * @type {!number}
+     */
+    SESSION_CONFLICT: 0x0209,
+
+    /**
+     * The session within the upstream server has ended because it appeared to
+     * be inactive.
+     *
+     * @type {!number}
+     */
+    SESSION_TIMEOUT: 0x020a,
+
+    /**
+     * The session within the upstream server has been forcibly terminated.
+     *
+     * @type {!number}
+     */
+    SESSION_CLOSED: 0x020b,
+
+    /**
+     * The operation could not be performed because bad parameters were given.
+     *
+     * @type {!number}
+     */
+    CLIENT_BAD_REQUEST: 0x0300,
+
+    /**
+     * Permission was denied to perform the operation, as the user is not yet
+     * authorized (not yet logged in, for example).
+     *
+     * @type {!number}
+     */
+    CLIENT_UNAUTHORIZED: 0x0301,
+
+    /**
+     * Permission was denied to perform the operation, and this permission will
+     * not be granted even if the user is authorized.
+     *
+     * @type {!number}
+     */
+    CLIENT_FORBIDDEN: 0x0303,
+
+    /**
+     * The client took too long to respond.
+     *
+     * @type {!number}
+     */
+    CLIENT_TIMEOUT: 0x0308,
+
+    /**
+     * The client sent too much data.
+     *
+     * @type {!number}
+     */
+    CLIENT_OVERRUN: 0x030d,
+
+    /**
+     * The client sent data of an unsupported or unexpected type.
+     *
+     * @type {!number}
+     */
+    CLIENT_BAD_TYPE: 0x030f,
+
+    /**
+     * The operation failed because the current client is already using too
+     * many resources.
+     *
+     * @type {!number}
+     */
+    CLIENT_TOO_MANY: 0x031d,
+};
+
+/**
+ * Returns the Guacamole protocol status code which most closely
+ * represents the given HTTP status code.
+ *
+ * @param {!number} status
+ *     The HTTP status code to translate into a Guacamole protocol status
+ *     code.
+ *
+ * @returns {!number}
+ *     The Guacamole protocol status code which most closely represents the
+ *     given HTTP status code.
+ */
+Guacamole.Status.Code.fromHTTPCode = function fromHTTPCode(status) {
+    // Translate status codes with known equivalents
+    switch (status) {
+        // HTTP 400 - Bad request
+        case 400:
+            return Guacamole.Status.Code.CLIENT_BAD_REQUEST;
+
+        // HTTP 403 - Forbidden
+        case 403:
+            return Guacamole.Status.Code.CLIENT_FORBIDDEN;
+
+        // HTTP 404 - Resource not found
+        case 404:
+            return Guacamole.Status.Code.RESOURCE_NOT_FOUND;
+
+        // HTTP 429 - Too many requests
+        case 429:
+            return Guacamole.Status.Code.CLIENT_TOO_MANY;
+
+        // HTTP 503 - Server unavailable
+        case 503:
+            return Guacamole.Status.Code.SERVER_BUSY;
+    }
+
+    // Default all other codes to generic internal error
+    return Guacamole.Status.Code.SERVER_ERROR;
+};
+
+/**
+ * Returns the Guacamole protocol status code which most closely
+ * represents the given WebSocket status code.
+ *
+ * @param {!number} code
+ *     The WebSocket status code to translate into a Guacamole protocol
+ *     status code.
+ *
+ * @returns {!number}
+ *     The Guacamole protocol status code which most closely represents the
+ *     given WebSocket status code.
+ */
+Guacamole.Status.Code.fromWebSocketCode = function fromWebSocketCode(code) {
+    // Translate status codes with known equivalents
+    switch (code) {
+        // Successful disconnect (no error)
+        case 1000: // Normal Closure
+            return Guacamole.Status.Code.SUCCESS;
+
+        // Codes which indicate the server is not reachable
+        case 1006: // Abnormal Closure (also signalled by JavaScript when the connection cannot be opened in the first place)
+        case 1015: // TLS Handshake
+            return Guacamole.Status.Code.UPSTREAM_NOT_FOUND;
+
+        // Codes which indicate the server is reachable but busy/unavailable
+        case 1001: // Going Away
+        case 1012: // Service Restart
+        case 1013: // Try Again Later
+        case 1014: // Bad Gateway
+            return Guacamole.Status.Code.UPSTREAM_UNAVAILABLE;
+    }
+
+    // Default all other codes to generic internal error
+    return Guacamole.Status.Code.SERVER_ERROR;
+};
+
+/**
+ * A reader which automatically handles the given input stream, returning
+ * strictly text data. Note that this object will overwrite any installed event
+ * handlers on the given Guacamole.InputStream.
+ *
+ * @constructor
+ * @param {!Guacamole.InputStream} stream
+ *     The stream that data will be read from.
+ */
+Guacamole.StringReader = function (stream) {
+    /**
+     * Reference to this Guacamole.InputStream.
+     *
+     * @private
+     * @type {!Guacamole.StringReader}
+     */
+    var guac_reader = this;
+
+    /**
+     * Parser for received UTF-8 data.
+     *
+     * @type {!Guacamole.UTF8Parser}
+     */
+    var utf8Parser = new Guacamole.UTF8Parser();
+
+    /**
+     * Wrapped Guacamole.ArrayBufferReader.
+     *
+     * @private
+     * @type {!Guacamole.ArrayBufferReader}
+     */
+    var array_reader = new Guacamole.ArrayBufferReader(stream);
+
+    // Receive blobs as strings
+    array_reader.ondata = function (buffer) {
+        // Decode UTF-8
+        var text = utf8Parser.decode(buffer);
+
+        // Call handler, if present
+        if (guac_reader.ontext) guac_reader.ontext(text);
+    };
+
+    // Simply call onend when end received
+    array_reader.onend = function () {
+        if (guac_reader.onend) guac_reader.onend();
+    };
+
+    /**
+     * Fired once for every blob of text data received.
+     *
+     * @event
+     * @param {!string} text
+     *     The data packet received.
+     */
+    this.ontext = null;
+
+    /**
+     * Fired once this stream is finished and no further data will be written.
+     * @event
+     */
+    this.onend = null;
+};
+
+/**
+ * A writer which automatically writes to the given output stream with text
+ * data.
+ *
+ * @constructor
+ * @param {!Guacamole.OutputStream} stream
+ *     The stream that data will be written to.
+ */
+Guacamole.StringWriter = function (stream) {
+    /**
+     * Reference to this Guacamole.StringWriter.
+     *
+     * @private
+     * @type {!Guacamole.StringWriter}
+     */
+    var guac_writer = this;
+
+    /**
+     * Wrapped Guacamole.ArrayBufferWriter.
+     *
+     * @private
+     * @type {!Guacamole.ArrayBufferWriter}
+     */
+    var array_writer = new Guacamole.ArrayBufferWriter(stream);
+
+    /**
+     * Internal buffer for UTF-8 output.
+     *
+     * @private
+     * @type {!Uint8Array}
+     */
+    var buffer = new Uint8Array(8192);
+
+    /**
+     * The number of bytes currently in the buffer.
+     *
+     * @private
+     * @type {!number}
+     */
+    var length = 0;
+
+    // Simply call onack for acknowledgements
+    array_writer.onack = function (status) {
+        if (guac_writer.onack) guac_writer.onack(status);
+    };
+
+    /**
+     * Expands the size of the underlying buffer by the given number of bytes,
+     * updating the length appropriately.
+     *
+     * @private
+     * @param {!number} bytes
+     *     The number of bytes to add to the underlying buffer.
+     */
+    function __expand(bytes) {
+        // Resize buffer if more space needed
+        if (length + bytes >= buffer.length) {
+            var new_buffer = new Uint8Array((length + bytes) * 2);
+            new_buffer.set(buffer);
+            buffer = new_buffer;
+        }
+
+        length += bytes;
+    }
+
+    /**
+     * Appends a single Unicode character to the current buffer, resizing the
+     * buffer if necessary. The character will be encoded as UTF-8.
+     *
+     * @private
+     * @param {!number} codepoint
+     *     The codepoint of the Unicode character to append.
+     */
+    function __append_utf8(codepoint) {
+        var mask;
+        var bytes;
+
+        // 1 byte
+        if (codepoint <= 0x7f) {
+            mask = 0x00;
+            bytes = 1;
+        }
+
+        // 2 byte
+        else if (codepoint <= 0x7ff) {
+            mask = 0xc0;
+            bytes = 2;
+        }
+
+        // 3 byte
+        else if (codepoint <= 0xffff) {
+            mask = 0xe0;
+            bytes = 3;
+        }
+
+        // 4 byte
+        else if (codepoint <= 0x1fffff) {
+            mask = 0xf0;
+            bytes = 4;
+        }
+
+        // If invalid codepoint, append replacement character
+        else {
+            __append_utf8(0xfffd);
+            return;
+        }
+
+        // Offset buffer by size
+        __expand(bytes);
+        var offset = length - 1;
+
+        // Add trailing bytes, if any
+        for (var i = 1; i < bytes; i++) {
+            buffer[offset--] = 0x80 | (codepoint & 0x3f);
+            codepoint >>= 6;
+        }
+
+        // Set initial byte
+        buffer[offset] = mask | codepoint;
+    }
+
+    /**
+     * Encodes the given string as UTF-8, returning an ArrayBuffer containing
+     * the resulting bytes.
+     *
+     * @private
+     * @param {!string} text
+     *     The string to encode as UTF-8.
+     *
+     * @return {!Uint8Array}
+     *     The encoded UTF-8 data.
+     */
+    function __encode_utf8(text) {
+        // Fill buffer with UTF-8
+        for (var i = 0; i < text.length; i++) {
+            var codepoint = text.charCodeAt(i);
+            __append_utf8(codepoint);
+        }
+
+        // Flush buffer
+        if (length > 0) {
+            var out_buffer = buffer.subarray(0, length);
+            length = 0;
+            return out_buffer;
+        }
+    }
+
+    /**
+     * Sends the given text.
+     *
+     * @param {!string} text
+     *     The text to send.
+     */
+    this.sendText = function (text) {
+        if (text.length) array_writer.sendData(__encode_utf8(text));
+    };
+
+    /**
+     * Signals that no further text will be sent, effectively closing the
+     * stream.
+     */
+    this.sendEnd = function () {
+        array_writer.sendEnd();
+    };
+
+    /**
+     * Fired for received data, if acknowledged by the server.
+     *
+     * @event
+     * @param {!Guacamole.Status} status
+     *     The status of the operation.
+     */
+    this.onack = null;
+};
+
+/**
+ * Provides cross-browser multi-touch events for a given element. The events of
+ * the given element are automatically populated with handlers that translate
+ * touch events into a non-browser-specific event provided by the
+ * Guacamole.Touch instance.
+ *
+ * @constructor
+ * @augments Guacamole.Event.Target
+ * @param {!Element} element
+ *     The Element to use to provide touch events.
+ */
+Guacamole.Touch = function Touch(element) {
+    Guacamole.Event.Target.call(this);
+
+    /**
+     * Reference to this Guacamole.Touch.
+     *
+     * @private
+     * @type {!Guacamole.Touch}
+     */
+    var guacTouch = this;
+
+    /**
+     * The default X/Y radius of each touch if the device or browser does not
+     * expose the size of the contact area.
+     *
+     * @private
+     * @constant
+     * @type {!number}
+     */
+    var DEFAULT_CONTACT_RADIUS = Math.floor(16 * window.devicePixelRatio);
+
+    /**
+     * The set of all active touches, stored by their unique identifiers.
+     *
+     * @type {!Object.}
+     */
+    this.touches = {};
+
+    /**
+     * The number of active touches currently stored within
+     * {@link Guacamole.Touch#touches touches}.
+     */
+    this.activeTouches = 0;
+
+    /**
+     * Fired whenever a new touch contact is initiated on the element
+     * associated with this Guacamole.Touch.
+     *
+     * @event Guacamole.Touch#touchstart
+     * @param {!Guacamole.Touch.Event} event
+     *     A {@link Guacamole.Touch.Event} object representing the "touchstart"
+     *     event.
+     */
+
+    /**
+     * Fired whenever an established touch contact moves within the element
+     * associated with this Guacamole.Touch.
+     *
+     * @event Guacamole.Touch#touchmove
+     * @param {!Guacamole.Touch.Event} event
+     *     A {@link Guacamole.Touch.Event} object representing the "touchmove"
+     *     event.
+     */
+
+    /**
+     * Fired whenever an established touch contact is lifted from the element
+     * associated with this Guacamole.Touch.
+     *
+     * @event Guacamole.Touch#touchend
+     * @param {!Guacamole.Touch.Event} event
+     *     A {@link Guacamole.Touch.Event} object representing the "touchend"
+     *     event.
+     */
+
+    element.addEventListener(
+        'touchstart',
+        function touchstart(e) {
+            // Fire "ontouchstart" events for all new touches
+            for (var i = 0; i < e.changedTouches.length; i++) {
+                var changedTouch = e.changedTouches[i];
+                var identifier = changedTouch.identifier;
+
+                // Ignore duplicated touches
+                if (guacTouch.touches[identifier]) continue;
+
+                var touch = (guacTouch.touches[identifier] = new Guacamole.Touch.State({
+                    id: identifier,
+                    radiusX: changedTouch.radiusX || DEFAULT_CONTACT_RADIUS,
+                    radiusY: changedTouch.radiusY || DEFAULT_CONTACT_RADIUS,
+                    angle: changedTouch.angle || 0.0,
+                    force:
+                        changedTouch.force ||
+                        1.0 /* Within JavaScript changedTouch events, a force of 0.0 indicates the device does not support reporting changedTouch force */,
+                }));
+
+                guacTouch.activeTouches++;
+
+                touch.fromClientPosition(element, changedTouch.clientX, changedTouch.clientY);
+                guacTouch.dispatch(new Guacamole.Touch.Event('touchmove', e, touch));
+            }
+        },
+        false
+    );
+
+    element.addEventListener(
+        'touchmove',
+        function touchstart(e) {
+            // Fire "ontouchmove" events for all updated touches
+            for (var i = 0; i < e.changedTouches.length; i++) {
+                var changedTouch = e.changedTouches[i];
+                var identifier = changedTouch.identifier;
+
+                // Ignore any unrecognized touches
+                var touch = guacTouch.touches[identifier];
+                if (!touch) continue;
+
+                // Update force only if supported by browser (otherwise, assume
+                // force is unchanged)
+                if (changedTouch.force) touch.force = changedTouch.force;
+
+                // Update touch area, if supported by browser and device
+                touch.angle = changedTouch.angle || 0.0;
+                touch.radiusX = changedTouch.radiusX || DEFAULT_CONTACT_RADIUS;
+                touch.radiusY = changedTouch.radiusY || DEFAULT_CONTACT_RADIUS;
+
+                // Update with any change in position
+                touch.fromClientPosition(element, changedTouch.clientX, changedTouch.clientY);
+                guacTouch.dispatch(new Guacamole.Touch.Event('touchmove', e, touch));
+            }
+        },
+        false
+    );
+
+    element.addEventListener(
+        'touchend',
+        function touchstart(e) {
+            // Fire "ontouchend" events for all updated touches
+            for (var i = 0; i < e.changedTouches.length; i++) {
+                var changedTouch = e.changedTouches[i];
+                var identifier = changedTouch.identifier;
+
+                // Ignore any unrecognized touches
+                var touch = guacTouch.touches[identifier];
+                if (!touch) continue;
+
+                // Stop tracking this particular touch
+                delete guacTouch.touches[identifier];
+                guacTouch.activeTouches--;
+
+                // Touch has ended
+                touch.force = 0.0;
+
+                // Update with final position
+                touch.fromClientPosition(element, changedTouch.clientX, changedTouch.clientY);
+                guacTouch.dispatch(new Guacamole.Touch.Event('touchend', e, touch));
+            }
+        },
+        false
+    );
+};
+
+/**
+ * The current state of a touch contact.
+ *
+ * @constructor
+ * @augments Guacamole.Position
+ * @param {Guacamole.Touch.State|object} [template={}]
+ *     The object whose properties should be copied within the new
+ *     Guacamole.Touch.State.
+ */
+Guacamole.Touch.State = function State(template) {
+    template = template || {};
+
+    Guacamole.Position.call(this, template);
+
+    /**
+     * An arbitrary integer ID which uniquely identifies this contact relative
+     * to other active contacts.
+     *
+     * @type {!number}
+     * @default 0
+     */
+    this.id = template.id || 0;
+
+    /**
+     * The Y radius of the ellipse covering the general area of the touch
+     * contact, in pixels.
+     *
+     * @type {!number}
+     * @default 0
+     */
+    this.radiusX = template.radiusX || 0;
+
+    /**
+     * The X radius of the ellipse covering the general area of the touch
+     * contact, in pixels.
+     *
+     * @type {!number}
+     * @default 0
+     */
+    this.radiusY = template.radiusY || 0;
+
+    /**
+     * The rough angle of clockwise rotation of the general area of the touch
+     * contact, in degrees.
+     *
+     * @type {!number}
+     * @default 0.0
+     */
+    this.angle = template.angle || 0.0;
+
+    /**
+     * The relative force exerted by the touch contact, where 0 is no force
+     * (the touch has been lifted) and 1 is maximum force (the maximum amount
+     * of force representable by the device).
+     *
+     * @type {!number}
+     * @default 1.0
+     */
+    this.force = template.force || 1.0;
+};
+
+/**
+ * An event which represents a change in state of a single touch contact,
+ * including the creation or removal of that contact. If multiple contacts are
+ * involved in a touch interaction, each contact will be associated with its
+ * own event.
+ *
+ * @constructor
+ * @augments Guacamole.Event.DOMEvent
+ * @param {!string} type
+ *     The name of the touch event type. Possible values are "touchstart",
+ *     "touchmove", and "touchend".
+ *
+ * @param {!TouchEvent} event
+ *     The DOM touch event that produced this Guacamole.Touch.Event.
+ *
+ * @param {!Guacamole.Touch.State} state
+ *     The state of the touch contact associated with this event.
+ */
+Guacamole.Touch.Event = function TouchEvent(type, event, state) {
+    Guacamole.Event.DOMEvent.call(this, type, [event]);
+
+    /**
+     * The state of the touch contact associated with this event.
+     *
+     * @type {!Guacamole.Touch.State}
+     */
+    this.state = state;
+};
+
+/**
+ * Core object providing abstract communication for Guacamole. This object
+ * is a null implementation whose functions do nothing. Guacamole applications
+ * should use {@link Guacamole.HTTPTunnel} instead, or implement their own tunnel based
+ * on this one.
+ *
+ * @constructor
+ * @see Guacamole.HTTPTunnel
+ */
+Guacamole.Tunnel = function () {
+    /**
+     * Connect to the tunnel with the given optional data. This data is
+     * typically used for authentication. The format of data accepted is
+     * up to the tunnel implementation.
+     *
+     * @param {string} [data]
+     *     The data to send to the tunnel when connecting.
+     */
+    this.connect = function (data) {};
+
+    /**
+     * Disconnect from the tunnel.
+     */
+    this.disconnect = function () {};
+
+    /**
+     * Send the given message through the tunnel to the service on the other
+     * side. All messages are guaranteed to be received in the order sent.
+     *
+     * @param {...*} elements
+     *     The elements of the message to send to the service on the other side
+     *     of the tunnel.
+     */
+    this.sendMessage = function (elements) {};
+
+    /**
+     * Changes the stored numeric state of this tunnel, firing the onstatechange
+     * event if the new state is different and a handler has been defined.
+     *
+     * @private
+     * @param {!number} state
+     *     The new state of this tunnel.
+     */
+    this.setState = function (state) {
+        // Notify only if state changes
+        if (state !== this.state) {
+            this.state = state;
+            if (this.onstatechange) this.onstatechange(state);
+        }
+    };
+
+    /**
+     * Changes the stored UUID that uniquely identifies this tunnel, firing the
+     * onuuid event if a handler has been defined.
+     *
+     * @private
+     * @param {string} uuid
+     *     The new state of this tunnel.
+     */
+    this.setUUID = function setUUID(uuid) {
+        this.uuid = uuid;
+        if (this.onuuid) this.onuuid(uuid);
+    };
+
+    /**
+     * Returns whether this tunnel is currently connected.
+     *
+     * @returns {!boolean}
+     *     true if this tunnel is currently connected, false otherwise.
+     */
+    this.isConnected = function isConnected() {
+        return this.state === Guacamole.Tunnel.State.OPEN || this.state === Guacamole.Tunnel.State.UNSTABLE;
+    };
+
+    /**
+     * The current state of this tunnel.
+     *
+     * @type {!number}
+     */
+    this.state = Guacamole.Tunnel.State.CLOSED;
+
+    /**
+     * The maximum amount of time to wait for data to be received, in
+     * milliseconds. If data is not received within this amount of time,
+     * the tunnel is closed with an error. The default value is 15000.
+     *
+     * @type {!number}
+     */
+    this.receiveTimeout = 15000;
+
+    /**
+     * The amount of time to wait for data to be received before considering
+     * the connection to be unstable, in milliseconds. If data is not received
+     * within this amount of time, the tunnel status is updated to warn that
+     * the connection appears unresponsive and may close. The default value is
+     * 1500.
+     *
+     * @type {!number}
+     */
+    this.unstableThreshold = 1500;
+
+    /**
+     * The UUID uniquely identifying this tunnel. If not yet known, this will
+     * be null.
+     *
+     * @type {string}
+     */
+    this.uuid = null;
+
+    /**
+     * Fired when the UUID that uniquely identifies this tunnel is known.
+     *
+     * @event
+     * @param {!string}
+     *     The UUID uniquely identifying this tunnel.
+     */
+    this.onuuid = null;
+
+    /**
+     * Fired whenever an error is encountered by the tunnel.
+     *
+     * @event
+     * @param {!Guacamole.Status} status
+     *     A status object which describes the error.
+     */
+    this.onerror = null;
+
+    /**
+     * Fired whenever the state of the tunnel changes.
+     *
+     * @event
+     * @param {!number} state
+     *     The new state of the client.
+     */
+    this.onstatechange = null;
+
+    /**
+     * Fired once for every complete Guacamole instruction received, in order.
+     *
+     * @event
+     * @param {!string} opcode
+     *     The Guacamole instruction opcode.
+     *
+     * @param {!string[]} parameters
+     *     The parameters provided for the instruction, if any.
+     */
+    this.oninstruction = null;
+};
+
+/**
+ * The Guacamole protocol instruction opcode reserved for arbitrary internal
+ * use by tunnel implementations. The value of this opcode is guaranteed to be
+ * the empty string (""). Tunnel implementations may use this opcode for any
+ * purpose. It is currently used by the HTTP tunnel to mark the end of the HTTP
+ * response, and by the WebSocket tunnel to transmit the tunnel UUID and send
+ * connection stability test pings/responses.
+ *
+ * @constant
+ * @type {!string}
+ */
+Guacamole.Tunnel.INTERNAL_DATA_OPCODE = '';
+
+/**
+ * All possible tunnel states.
+ *
+ * @type {!Object.}
+ */
+Guacamole.Tunnel.State = {
+    /**
+     * A connection is in pending. It is not yet known whether connection was
+     * successful.
+     *
+     * @type {!number}
+     */
+    CONNECTING: 0,
+
+    /**
+     * Connection was successful, and data is being received.
+     *
+     * @type {!number}
+     */
+    OPEN: 1,
+
+    /**
+     * The connection is closed. Connection may not have been successful, the
+     * tunnel may have been explicitly closed by either side, or an error may
+     * have occurred.
+     *
+     * @type {!number}
+     */
+    CLOSED: 2,
+
+    /**
+     * The connection is open, but communication through the tunnel appears to
+     * be disrupted, and the connection may close as a result.
+     *
+     * @type {!number}
+     */
+    UNSTABLE: 3,
+};
+
+/**
+ * Guacamole Tunnel implemented over HTTP via XMLHttpRequest.
+ *
+ * @constructor
+ * @augments Guacamole.Tunnel
+ *
+ * @param {!string} tunnelURL
+ *     The URL of the HTTP tunneling service.
+ *
+ * @param {boolean} [crossDomain=false]
+ *     Whether tunnel requests will be cross-domain, and thus must use CORS
+ *     mechanisms and headers. By default, it is assumed that tunnel requests
+ *     will be made to the same domain.
+ *
+ * @param {object} [extraTunnelHeaders={}]
+ *     Key value pairs containing the header names and values of any additional
+ *     headers to be sent in tunnel requests. By default, no extra headers will
+ *     be added.
+ */
+Guacamole.HTTPTunnel = function (tunnelURL, crossDomain, extraTunnelHeaders) {
+    /**
+     * Reference to this HTTP tunnel.
+     *
+     * @private
+     * @type {!Guacamole.HTTPTunnel}
+     */
+    var tunnel = this;
+
+    var TUNNEL_CONNECT = tunnelURL + '?connect';
+    var TUNNEL_READ = tunnelURL + '?read:';
+    var TUNNEL_WRITE = tunnelURL + '?write:';
+
+    var POLLING_ENABLED = 1;
+    var POLLING_DISABLED = 0;
+
+    // Default to polling - will be turned off automatically if not needed
+    var pollingMode = POLLING_ENABLED;
+
+    var sendingMessages = false;
+    var outputMessageBuffer = '';
+
+    // If requests are expected to be cross-domain, the cookie that the HTTP
+    // tunnel depends on will only be sent if withCredentials is true
+    var withCredentials = !!crossDomain;
+
+    /**
+     * The current receive timeout ID, if any.
+     *
+     * @private
+     * @type {number}
+     */
+    var receive_timeout = null;
+
+    /**
+     * The current connection stability timeout ID, if any.
+     *
+     * @private
+     * @type {number}
+     */
+    var unstableTimeout = null;
+
+    /**
+     * The current connection stability test ping interval ID, if any. This
+     * will only be set upon successful connection.
+     *
+     * @private
+     * @type {number}
+     */
+    var pingInterval = null;
+
+    /**
+     * The number of milliseconds to wait between connection stability test
+     * pings.
+     *
+     * @private
+     * @constant
+     * @type {!number}
+     */
+    var PING_FREQUENCY = 500;
+
+    /**
+     * Additional headers to be sent in tunnel requests. This dictionary can be
+     * populated with key/value header pairs to pass information such as authentication
+     * tokens, etc.
+     *
+     * @private
+     * @type {!object}
+     */
+    var extraHeaders = extraTunnelHeaders || {};
+
+    /**
+     * The name of the HTTP header containing the session token specific to the
+     * HTTP tunnel implementation.
+     *
+     * @private
+     * @constant
+     * @type {!string}
+     */
+    var TUNNEL_TOKEN_HEADER = 'Guacamole-Tunnel-Token';
+
+    /**
+     * The session token currently assigned to this HTTP tunnel. All distinct
+     * HTTP tunnel connections will have their own dedicated session token.
+     *
+     * @private
+     * @type {string}
+     */
+    var tunnelSessionToken = null;
+
+    /**
+     * Adds the configured additional headers to the given request.
+     *
+     * @private
+     * @param {!XMLHttpRequest} request
+     *     The request where the configured extra headers will be added.
+     *
+     * @param {!object} headers
+     *     The headers to be added to the request.
+     */
+    function addExtraHeaders(request, headers) {
+        for (var name in headers) {
+            request.setRequestHeader(name, headers[name]);
+        }
+    }
+
+    /**
+     * Resets the state of timers tracking network activity and stability. If
+     * those timers are not yet started, invoking this function starts them.
+     * This function should be invoked when the tunnel is established and every
+     * time there is network activity on the tunnel, such that the timers can
+     * safely assume the network and/or server are not responding if this
+     * function has not been invoked for a significant period of time.
+     *
+     * @private
+     */
+    var resetTimers = function resetTimers() {
+        // Get rid of old timeouts (if any)
+        window.clearTimeout(receive_timeout);
+        window.clearTimeout(unstableTimeout);
+
+        // Clear unstable status
+        if (tunnel.state === Guacamole.Tunnel.State.UNSTABLE) tunnel.setState(Guacamole.Tunnel.State.OPEN);
+
+        // Set new timeout for tracking overall connection timeout
+        receive_timeout = window.setTimeout(function () {
+            close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_TIMEOUT, 'Server timeout.'));
+        }, tunnel.receiveTimeout);
+
+        // Set new timeout for tracking suspected connection instability
+        unstableTimeout = window.setTimeout(function () {
+            tunnel.setState(Guacamole.Tunnel.State.UNSTABLE);
+        }, tunnel.unstableThreshold);
+    };
+
+    /**
+     * Closes this tunnel, signaling the given status and corresponding
+     * message, which will be sent to the onerror handler if the status is
+     * an error status.
+     *
+     * @private
+     * @param {!Guacamole.Status} status
+     *     The status causing the connection to close;
+     */
+    function close_tunnel(status) {
+        // Get rid of old timeouts (if any)
+        window.clearTimeout(receive_timeout);
+        window.clearTimeout(unstableTimeout);
+
+        // Cease connection test pings
+        window.clearInterval(pingInterval);
+
+        // Ignore if already closed
+        if (tunnel.state === Guacamole.Tunnel.State.CLOSED) return;
+
+        // If connection closed abnormally, signal error.
+        if (status.code !== Guacamole.Status.Code.SUCCESS && tunnel.onerror) {
+            // Ignore RESOURCE_NOT_FOUND if we've already connected, as that
+            // only signals end-of-stream for the HTTP tunnel.
+            if (tunnel.state === Guacamole.Tunnel.State.CONNECTING || status.code !== Guacamole.Status.Code.RESOURCE_NOT_FOUND) tunnel.onerror(status);
+        }
+
+        // Reset output message buffer
+        sendingMessages = false;
+
+        // Mark as closed
+        tunnel.setState(Guacamole.Tunnel.State.CLOSED);
+    }
+
+    this.sendMessage = function () {
+        // Do not attempt to send messages if not connected
+        if (!tunnel.isConnected()) return;
+
+        // Do not attempt to send empty messages
+        if (arguments.length === 0) return;
+
+        /**
+         * Converts the given value to a length/string pair for use as an
+         * element in a Guacamole instruction.
+         *
+         * @private
+         * @param value
+         *     The value to convert.
+         *
+         * @return {!string}
+         *     The converted value.
+         */
+        function getElement(value) {
+            var string = new String(value);
+            return string.length + '.' + string;
+        }
+
+        // Initialized message with first element
+        var message = getElement(arguments[0]);
+
+        // Append remaining elements
+        for (var i = 1; i < arguments.length; i++) message += ',' + getElement(arguments[i]);
+
+        // Final terminator
+        message += ';';
+
+        // Add message to buffer
+        outputMessageBuffer += message;
+
+        // Send if not currently sending
+        if (!sendingMessages) sendPendingMessages();
+    };
+
+    function sendPendingMessages() {
+        // Do not attempt to send messages if not connected
+        if (!tunnel.isConnected()) return;
+
+        if (outputMessageBuffer.length > 0) {
+            sendingMessages = true;
+
+            var message_xmlhttprequest = new XMLHttpRequest();
+            message_xmlhttprequest.open('POST', TUNNEL_WRITE + tunnel.uuid);
+            message_xmlhttprequest.withCredentials = withCredentials;
+            addExtraHeaders(message_xmlhttprequest, extraHeaders);
+            message_xmlhttprequest.setRequestHeader('Content-type', 'application/octet-stream');
+            message_xmlhttprequest.setRequestHeader(TUNNEL_TOKEN_HEADER, tunnelSessionToken);
+
+            // Once response received, send next queued event.
+            message_xmlhttprequest.onreadystatechange = function () {
+                if (message_xmlhttprequest.readyState === 4) {
+                    resetTimers();
+
+                    // If an error occurs during send, handle it
+                    if (message_xmlhttprequest.status !== 200) handleHTTPTunnelError(message_xmlhttprequest);
+                    // Otherwise, continue the send loop
+                    else sendPendingMessages();
+                }
+            };
+
+            message_xmlhttprequest.send(outputMessageBuffer);
+            outputMessageBuffer = ''; // Clear buffer
+        } else sendingMessages = false;
+    }
+
+    function handleHTTPTunnelError(xmlhttprequest) {
+        // Pull status code directly from headers provided by Guacamole
+        var code = parseInt(xmlhttprequest.getResponseHeader('Guacamole-Status-Code'));
+        if (code) {
+            var message = xmlhttprequest.getResponseHeader('Guacamole-Error-Message');
+            close_tunnel(new Guacamole.Status(code, message));
+        }
+
+        // Failing that, derive a Guacamole status code from the HTTP status
+        // code provided by the browser
+        else if (xmlhttprequest.status)
+            close_tunnel(new Guacamole.Status(Guacamole.Status.Code.fromHTTPCode(xmlhttprequest.status), xmlhttprequest.statusText));
+        // Otherwise, assume server is unreachable
+        else close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_NOT_FOUND));
+    }
+
+    function handleResponse(xmlhttprequest) {
+        var interval = null;
+        var nextRequest = null;
+
+        var dataUpdateEvents = 0;
+
+        // The location of the last element's terminator
+        var elementEnd = -1;
+
+        // Where to start the next length search or the next element
+        var startIndex = 0;
+
+        // Parsed elements
+        var elements = new Array();
+
+        function parseResponse() {
+            // Do not handle responses if not connected
+            if (!tunnel.isConnected()) {
+                // Clean up interval if polling
+                if (interval !== null) clearInterval(interval);
+
+                return;
+            }
+
+            // Do not parse response yet if not ready
+            if (xmlhttprequest.readyState < 2) return;
+
+            // Attempt to read status
+            var status;
+            try {
+                status = xmlhttprequest.status;
+            } catch (e) {
+                // If status could not be read, assume successful.
+                status = 200;
+            }
+
+            // Start next request as soon as possible IF request was successful
+            if (!nextRequest && status === 200) nextRequest = makeRequest();
+
+            // Parse stream when data is received and when complete.
+            if (xmlhttprequest.readyState === 3 || xmlhttprequest.readyState === 4) {
+                resetTimers();
+
+                // Also poll every 30ms (some browsers don't repeatedly call onreadystatechange for new data)
+                if (pollingMode === POLLING_ENABLED) {
+                    if (xmlhttprequest.readyState === 3 && !interval) interval = setInterval(parseResponse, 30);
+                    else if (xmlhttprequest.readyState === 4 && interval) clearInterval(interval);
+                }
+
+                // If canceled, stop transfer
+                if (xmlhttprequest.status === 0) {
+                    tunnel.disconnect();
+                    return;
+                }
+
+                // Halt on error during request
+                else if (xmlhttprequest.status !== 200) {
+                    handleHTTPTunnelError(xmlhttprequest);
+                    return;
+                }
+
+                // Attempt to read in-progress data
+                var current;
+                try {
+                    current = xmlhttprequest.responseText;
+                } catch (e) {
+                    // Do not attempt to parse if data could not be read
+                    return;
+                }
+
+                // While search is within currently received data
+                while (elementEnd < current.length) {
+                    // If we are waiting for element data
+                    if (elementEnd >= startIndex) {
+                        // We now have enough data for the element. Parse.
+                        var element = current.substring(startIndex, elementEnd);
+                        var terminator = current.substring(elementEnd, elementEnd + 1);
+
+                        // Add element to array
+                        elements.push(element);
+
+                        // If last element, handle instruction
+                        if (terminator === ';') {
+                            // Get opcode
+                            var opcode = elements.shift();
+
+                            // Call instruction handler.
+                            if (tunnel.oninstruction) tunnel.oninstruction(opcode, elements);
+
+                            // Clear elements
+                            elements.length = 0;
+                        }
+
+                        // Start searching for length at character after
+                        // element terminator
+                        startIndex = elementEnd + 1;
+                    }
+
+                    // Search for end of length
+                    var lengthEnd = current.indexOf('.', startIndex);
+                    if (lengthEnd !== -1) {
+                        // Parse length
+                        var length = parseInt(current.substring(elementEnd + 1, lengthEnd));
+
+                        // If we're done parsing, handle the next response.
+                        if (length === 0) {
+                            // Clean up interval if polling
+                            if (interval) clearInterval(interval);
+
+                            // Clean up object
+                            xmlhttprequest.onreadystatechange = null;
+                            xmlhttprequest.abort();
+
+                            // Start handling next request
+                            if (nextRequest) handleResponse(nextRequest);
+
+                            // Done parsing
+                            break;
+                        }
+
+                        // Calculate start of element
+                        startIndex = lengthEnd + 1;
+
+                        // Calculate location of element terminator
+                        elementEnd = startIndex + length;
+                    }
+
+                    // If no period yet, continue search when more data
+                    // is received
+                    else {
+                        startIndex = current.length;
+                        break;
+                    }
+                } // end parse loop
+            }
+        }
+
+        // If response polling enabled, attempt to detect if still
+        // necessary (via wrapping parseResponse())
+        if (pollingMode === POLLING_ENABLED) {
+            xmlhttprequest.onreadystatechange = function () {
+                // If we receive two or more readyState==3 events,
+                // there is no need to poll.
+                if (xmlhttprequest.readyState === 3) {
+                    dataUpdateEvents++;
+                    if (dataUpdateEvents >= 2) {
+                        pollingMode = POLLING_DISABLED;
+                        xmlhttprequest.onreadystatechange = parseResponse;
+                    }
+                }
+
+                parseResponse();
+            };
+        }
+
+        // Otherwise, just parse
+        else xmlhttprequest.onreadystatechange = parseResponse;
+
+        parseResponse();
+    }
+
+    /**
+     * Arbitrary integer, unique for each tunnel read request.
+     * @private
+     */
+    var request_id = 0;
+
+    function makeRequest() {
+        // Make request, increment request ID
+        var xmlhttprequest = new XMLHttpRequest();
+        xmlhttprequest.open('GET', TUNNEL_READ + tunnel.uuid + ':' + request_id++);
+        xmlhttprequest.setRequestHeader(TUNNEL_TOKEN_HEADER, tunnelSessionToken);
+        xmlhttprequest.withCredentials = withCredentials;
+        addExtraHeaders(xmlhttprequest, extraHeaders);
+        xmlhttprequest.send(null);
+
+        return xmlhttprequest;
+    }
+
+    this.connect = function (data) {
+        // Start waiting for connect
+        resetTimers();
+
+        // Mark the tunnel as connecting
+        tunnel.setState(Guacamole.Tunnel.State.CONNECTING);
+
+        // Start tunnel and connect
+        var connect_xmlhttprequest = new XMLHttpRequest();
+        connect_xmlhttprequest.onreadystatechange = function () {
+            if (connect_xmlhttprequest.readyState !== 4) return;
+
+            // If failure, throw error
+            if (connect_xmlhttprequest.status !== 200) {
+                handleHTTPTunnelError(connect_xmlhttprequest);
+                return;
+            }
+
+            resetTimers();
+
+            // Get UUID and HTTP-specific tunnel session token from response
+            tunnel.setUUID(connect_xmlhttprequest.responseText);
+            tunnelSessionToken = connect_xmlhttprequest.getResponseHeader(TUNNEL_TOKEN_HEADER);
+
+            // Fail connect attempt if token is not successfully assigned
+            if (!tunnelSessionToken) {
+                close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_NOT_FOUND));
+                return;
+            }
+
+            // Mark as open
+            tunnel.setState(Guacamole.Tunnel.State.OPEN);
+
+            // Ping tunnel endpoint regularly to test connection stability
+            pingInterval = setInterval(function sendPing() {
+                tunnel.sendMessage('nop');
+            }, PING_FREQUENCY);
+
+            // Start reading data
+            handleResponse(makeRequest());
+        };
+
+        connect_xmlhttprequest.open('POST', TUNNEL_CONNECT, true);
+        connect_xmlhttprequest.withCredentials = withCredentials;
+        addExtraHeaders(connect_xmlhttprequest, extraHeaders);
+        connect_xmlhttprequest.setRequestHeader('Content-type', 'application/x-www-form-urlencoded; charset=UTF-8');
+        connect_xmlhttprequest.send(data);
+    };
+
+    this.disconnect = function () {
+        close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SUCCESS, 'Manually closed.'));
+    };
+};
+
+Guacamole.HTTPTunnel.prototype = new Guacamole.Tunnel();
+
+/**
+ * Guacamole Tunnel implemented over WebSocket via XMLHttpRequest.
+ *
+ * @constructor
+ * @augments Guacamole.Tunnel
+ * @param {!string} tunnelURL
+ *     The URL of the WebSocket tunneling service.
+ */
+Guacamole.WebSocketTunnel = function (tunnelURL) {
+    /**
+     * Reference to this WebSocket tunnel.
+     *
+     * @private
+     * @type {Guacamole.WebSocketTunnel}
+     */
+    var tunnel = this;
+
+    /**
+     * The WebSocket used by this tunnel.
+     *
+     * @private
+     * @type {WebSocket}
+     */
+    var socket = null;
+
+    /**
+     * The current receive timeout ID, if any.
+     *
+     * @private
+     * @type {number}
+     */
+    var receive_timeout = null;
+
+    /**
+     * The current connection stability timeout ID, if any.
+     *
+     * @private
+     * @type {number}
+     */
+    var unstableTimeout = null;
+
+    /**
+     * The current connection stability test ping timeout ID, if any. This
+     * will only be set upon successful connection.
+     *
+     * @private
+     * @type {number}
+     */
+    var pingTimeout = null;
+
+    /**
+     * The WebSocket protocol corresponding to the protocol used for the current
+     * location.
+     *
+     * @private
+     * @type {!Object.}
+     */
+    var ws_protocol = {
+        'http:': 'ws:',
+        'https:': 'wss:',
+    };
+
+    /**
+     * The number of milliseconds to wait between connection stability test
+     * pings.
+     *
+     * @private
+     * @constant
+     * @type {!number}
+     */
+    var PING_FREQUENCY = 500;
+
+    /**
+     * The timestamp of the point in time that the last connection stability
+     * test ping was sent, in milliseconds elapsed since midnight of January 1,
+     * 1970 UTC.
+     *
+     * @private
+     * @type {!number}
+     */
+    var lastSentPing = 0;
+
+    // Transform current URL to WebSocket URL
+
+    // If not already a websocket URL
+    if (tunnelURL.substring(0, 3) !== 'ws:' && tunnelURL.substring(0, 4) !== 'wss:') {
+        var protocol = ws_protocol[window.location.protocol];
+
+        // If absolute URL, convert to absolute WS URL
+        if (tunnelURL.substring(0, 1) === '/') tunnelURL = protocol + '//' + window.location.host + tunnelURL;
+        // Otherwise, construct absolute from relative URL
+        else {
+            // Get path from pathname
+            var slash = window.location.pathname.lastIndexOf('/');
+            var path = window.location.pathname.substring(0, slash + 1);
+
+            // Construct absolute URL
+            tunnelURL = protocol + '//' + window.location.host + path + tunnelURL;
+        }
+    }
+
+    /**
+     * Sends an internal "ping" instruction to the Guacamole WebSocket
+     * endpoint, verifying network connection stability. If the network is
+     * stable, the Guacamole server will receive this instruction and respond
+     * with an identical ping.
+     *
+     * @private
+     */
+    var sendPing = function sendPing() {
+        var currentTime = new Date().getTime();
+        tunnel.sendMessage(Guacamole.Tunnel.INTERNAL_DATA_OPCODE, 'ping', currentTime);
+        lastSentPing = currentTime;
+    };
+
+    /**
+     * Resets the state of timers tracking network activity and stability. If
+     * those timers are not yet started, invoking this function starts them.
+     * This function should be invoked when the tunnel is established and every
+     * time there is network activity on the tunnel, such that the timers can
+     * safely assume the network and/or server are not responding if this
+     * function has not been invoked for a significant period of time.
+     *
+     * @private
+     */
+    var resetTimers = function resetTimers() {
+        // Get rid of old timeouts (if any)
+        window.clearTimeout(receive_timeout);
+        window.clearTimeout(unstableTimeout);
+        window.clearTimeout(pingTimeout);
+
+        // Clear unstable status
+        if (tunnel.state === Guacamole.Tunnel.State.UNSTABLE) tunnel.setState(Guacamole.Tunnel.State.OPEN);
+
+        // Set new timeout for tracking overall connection timeout
+        receive_timeout = window.setTimeout(function () {
+            close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_TIMEOUT, 'Server timeout.'));
+        }, tunnel.receiveTimeout);
+
+        // Set new timeout for tracking suspected connection instability
+        unstableTimeout = window.setTimeout(function () {
+            tunnel.setState(Guacamole.Tunnel.State.UNSTABLE);
+        }, tunnel.unstableThreshold);
+
+        var currentTime = new Date().getTime();
+        var pingDelay = Math.max(lastSentPing + PING_FREQUENCY - currentTime, 0);
+
+        // Ping tunnel endpoint regularly to test connection stability, sending
+        // the ping immediately if enough time has already elapsed
+        if (pingDelay > 0) pingTimeout = window.setTimeout(sendPing, pingDelay);
+        else sendPing();
+    };
+
+    /**
+     * Closes this tunnel, signaling the given status and corresponding
+     * message, which will be sent to the onerror handler if the status is
+     * an error status.
+     *
+     * @private
+     * @param {!Guacamole.Status} status
+     *     The status causing the connection to close;
+     */
+    function close_tunnel(status) {
+        // Get rid of old timeouts (if any)
+        window.clearTimeout(receive_timeout);
+        window.clearTimeout(unstableTimeout);
+        window.clearTimeout(pingTimeout);
+
+        // Ignore if already closed
+        if (tunnel.state === Guacamole.Tunnel.State.CLOSED) return;
+
+        // If connection closed abnormally, signal error.
+        if (status.code !== Guacamole.Status.Code.SUCCESS && tunnel.onerror) tunnel.onerror(status);
+
+        // Mark as closed
+        tunnel.setState(Guacamole.Tunnel.State.CLOSED);
+
+        socket.close();
+    }
+
+    this.sendMessage = function (elements) {
+        // Do not attempt to send messages if not connected
+        if (!tunnel.isConnected()) return;
+
+        // Do not attempt to send empty messages
+        if (arguments.length === 0) return;
+
+        /**
+         * Converts the given value to a length/string pair for use as an
+         * element in a Guacamole instruction.
+         *
+         * @private
+         * @param {*} value
+         *     The value to convert.
+         *
+         * @return {!string}
+         *     The converted value.
+         */
+        function getElement(value) {
+            var string = new String(value);
+            return string.length + '.' + string;
+        }
+
+        // Initialized message with first element
+        var message = getElement(arguments[0]);
+
+        // Append remaining elements
+        for (var i = 1; i < arguments.length; i++) message += ',' + getElement(arguments[i]);
+
+        // Final terminator
+        message += ';';
+
+        socket.send(message);
+    };
+
+    this.connect = function (data) {
+        resetTimers();
+
+        // Mark the tunnel as connecting
+        tunnel.setState(Guacamole.Tunnel.State.CONNECTING);
+
+        // Connect socket
+        socket = new WebSocket(tunnelURL + '?' + data);
+
+        socket.onopen = function (event) {
+            resetTimers();
+        };
+
+        socket.onclose = function (event) {
+            // Pull status code directly from closure reason provided by Guacamole
+            if (event.reason) close_tunnel(new Guacamole.Status(parseInt(event.reason), event.reason));
+            // Failing that, derive a Guacamole status code from the WebSocket
+            // status code provided by the browser
+            else if (event.code) close_tunnel(new Guacamole.Status(Guacamole.Status.Code.fromWebSocketCode(event.code)));
+            // Otherwise, assume server is unreachable
+            else close_tunnel(new Guacamole.Status(Guacamole.Status.Code.UPSTREAM_NOT_FOUND));
+        };
+
+        socket.onmessage = function (event) {
+            resetTimers();
+
+            var message = event.data;
+            var startIndex = 0;
+            var elementEnd;
+
+            var elements = [];
+
+            do {
+                // Search for end of length
+                var lengthEnd = message.indexOf('.', startIndex);
+                if (lengthEnd !== -1) {
+                    // Parse length
+                    var length = parseInt(message.substring(elementEnd + 1, lengthEnd));
+
+                    // Calculate start of element
+                    startIndex = lengthEnd + 1;
+
+                    // Calculate location of element terminator
+                    elementEnd = startIndex + length;
+                }
+
+                // If no period, incomplete instruction.
+                else close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, 'Incomplete instruction.'));
+
+                // We now have enough data for the element. Parse.
+                var element = message.substring(startIndex, elementEnd);
+                var terminator = message.substring(elementEnd, elementEnd + 1);
+
+                // Add element to array
+                elements.push(element);
+
+                // If last element, handle instruction
+                if (terminator === ';') {
+                    // Get opcode
+                    var opcode = elements.shift();
+
+                    // Update state and UUID when first instruction received
+                    if (tunnel.uuid === null) {
+                        // Associate tunnel UUID if received
+                        if (opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE && elements.length === 1) tunnel.setUUID(elements[0]);
+
+                        // Tunnel is now open and UUID is available
+                        tunnel.setState(Guacamole.Tunnel.State.OPEN);
+                    }
+
+                    // Call instruction handler.
+                    if (opcode !== Guacamole.Tunnel.INTERNAL_DATA_OPCODE && tunnel.oninstruction) tunnel.oninstruction(opcode, elements);
+
+                    // Clear elements
+                    elements.length = 0;
+                }
+
+                // Start searching for length at character after
+                // element terminator
+                startIndex = elementEnd + 1;
+            } while (startIndex < message.length);
+        };
+    };
+
+    this.disconnect = function () {
+        close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SUCCESS, 'Manually closed.'));
+    };
+};
+
+Guacamole.WebSocketTunnel.prototype = new Guacamole.Tunnel();
+
+/**
+ * Guacamole Tunnel which cycles between all specified tunnels until
+ * no tunnels are left. Another tunnel is used if an error occurs but
+ * no instructions have been received. If an instruction has been
+ * received, or no tunnels remain, the error is passed directly out
+ * through the onerror handler (if defined).
+ *
+ * @constructor
+ * @augments Guacamole.Tunnel
+ * @param {...Guacamole.Tunnel} tunnelChain
+ *     The tunnels to use, in order of priority.
+ */
+Guacamole.ChainedTunnel = function (tunnelChain) {
+    /**
+     * Reference to this chained tunnel.
+     * @private
+     */
+    var chained_tunnel = this;
+
+    /**
+     * Data passed in via connect(), to be used for
+     * wrapped calls to other tunnels' connect() functions.
+     * @private
+     */
+    var connect_data;
+
+    /**
+     * Array of all tunnels passed to this ChainedTunnel through the
+     * constructor arguments.
+     * @private
+     */
+    var tunnels = [];
+
+    /**
+     * The tunnel committed via commit_tunnel(), if any, or null if no tunnel
+     * has yet been committed.
+     *
+     * @private
+     * @type {Guacamole.Tunnel}
+     */
+    var committedTunnel = null;
+
+    // Load all tunnels into array
+    for (var i = 0; i < arguments.length; i++) tunnels.push(arguments[i]);
+
+    /**
+     * Sets the current tunnel.
+     *
+     * @private
+     * @param {!Guacamole.Tunnel} tunnel
+     *     The tunnel to set as the current tunnel.
+     */
+    function attach(tunnel) {
+        // Set own functions to tunnel's functions
+        chained_tunnel.disconnect = tunnel.disconnect;
+        chained_tunnel.sendMessage = tunnel.sendMessage;
+
+        /**
+         * Fails the currently-attached tunnel, attaching a new tunnel if
+         * possible.
+         *
+         * @private
+         * @param {Guacamole.Status} [status]
+         *     An object representing the failure that occured in the
+         *     currently-attached tunnel, if known.
+         *
+         * @return {Guacamole.Tunnel}
+         *     The next tunnel, or null if there are no more tunnels to try or
+         *     if no more tunnels should be tried.
+         */
+        var failTunnel = function failTunnel(status) {
+            // Do not attempt to continue using next tunnel on server timeout
+            if (status && status.code === Guacamole.Status.Code.UPSTREAM_TIMEOUT) {
+                tunnels = [];
+                return null;
+            }
+
+            // Get next tunnel
+            var next_tunnel = tunnels.shift();
+
+            // If there IS a next tunnel, try using it.
+            if (next_tunnel) {
+                tunnel.onerror = null;
+                tunnel.oninstruction = null;
+                tunnel.onstatechange = null;
+                attach(next_tunnel);
+            }
+
+            return next_tunnel;
+        };
+
+        /**
+         * Use the current tunnel from this point forward. Do not try any more
+         * tunnels, even if the current tunnel fails.
+         *
+         * @private
+         */
+        function commit_tunnel() {
+            tunnel.onstatechange = chained_tunnel.onstatechange;
+            tunnel.oninstruction = chained_tunnel.oninstruction;
+            tunnel.onerror = chained_tunnel.onerror;
+
+            // Assign UUID if already known
+            if (tunnel.uuid) chained_tunnel.setUUID(tunnel.uuid);
+
+            // Assign any future received UUIDs such that they are
+            // accessible from the main uuid property of the chained tunnel
+            tunnel.onuuid = function uuidReceived(uuid) {
+                chained_tunnel.setUUID(uuid);
+            };
+
+            committedTunnel = tunnel;
+        }
+
+        // Wrap own onstatechange within current tunnel
+        tunnel.onstatechange = function (state) {
+            switch (state) {
+                // If open, use this tunnel from this point forward.
+                case Guacamole.Tunnel.State.OPEN:
+                    commit_tunnel();
+                    if (chained_tunnel.onstatechange) chained_tunnel.onstatechange(state);
+                    break;
+
+                // If closed, mark failure, attempt next tunnel
+                case Guacamole.Tunnel.State.CLOSED:
+                    if (!failTunnel() && chained_tunnel.onstatechange) chained_tunnel.onstatechange(state);
+                    break;
+            }
+        };
+
+        // Wrap own oninstruction within current tunnel
+        tunnel.oninstruction = function (opcode, elements) {
+            // Accept current tunnel
+            commit_tunnel();
+
+            // Invoke handler
+            if (chained_tunnel.oninstruction) chained_tunnel.oninstruction(opcode, elements);
+        };
+
+        // Attach next tunnel on error
+        tunnel.onerror = function (status) {
+            // Mark failure, attempt next tunnel
+            if (!failTunnel(status) && chained_tunnel.onerror) chained_tunnel.onerror(status);
+        };
+
+        // Attempt connection
+        tunnel.connect(connect_data);
+    }
+
+    this.connect = function (data) {
+        // Remember connect data
+        connect_data = data;
+
+        // Get committed tunnel if exists or the first tunnel on the list
+        var next_tunnel = committedTunnel ? committedTunnel : tunnels.shift();
+
+        // Attach first tunnel
+        if (next_tunnel) attach(next_tunnel);
+        // If there IS no first tunnel, error
+        else if (chained_tunnel.onerror) chained_tunnel.onerror(Guacamole.Status.Code.SERVER_ERROR, 'No tunnels to try.');
+    };
+};
+
+Guacamole.ChainedTunnel.prototype = new Guacamole.Tunnel();
+
+/**
+ * Guacamole Tunnel which replays a Guacamole protocol dump from a static file
+ * received via HTTP. Instructions within the file are parsed and handled as
+ * quickly as possible, while the file is being downloaded.
+ *
+ * @constructor
+ * @augments Guacamole.Tunnel
+ * @param {!string} url
+ *     The URL of a Guacamole protocol dump.
+ *
+ * @param {boolean} [crossDomain=false]
+ *     Whether tunnel requests will be cross-domain, and thus must use CORS
+ *     mechanisms and headers. By default, it is assumed that tunnel requests
+ *     will be made to the same domain.
+ *
+ * @param {object} [extraTunnelHeaders={}]
+ *     Key value pairs containing the header names and values of any additional
+ *     headers to be sent in tunnel requests. By default, no extra headers will
+ *     be added.
+ */
+Guacamole.StaticHTTPTunnel = function StaticHTTPTunnel(url, crossDomain, extraTunnelHeaders) {
+    /**
+     * Reference to this Guacamole.StaticHTTPTunnel.
+     *
+     * @private
+     */
+    var tunnel = this;
+
+    /**
+     * AbortController instance which allows the current, in-progress HTTP
+     * request to be aborted. If no request is currently in progress, this will
+     * be null.
+     *
+     * @private
+     * @type {AbortController}
+     */
+    var abortController = null;
+
+    /**
+     * Additional headers to be sent in tunnel requests. This dictionary can be
+     * populated with key/value header pairs to pass information such as authentication
+     * tokens, etc.
+     *
+     * @private
+     * @type {!object}
+     */
+    var extraHeaders = extraTunnelHeaders || {};
+
+    /**
+     * The number of bytes in the file being downloaded, or null if this is not
+     * known.
+     *
+     * @type {number}
+     */
+    this.size = null;
+
+    this.sendMessage = function sendMessage(elements) {
+        // Do nothing
+    };
+
+    this.connect = function connect(data) {
+        // Ensure any existing connection is killed
+        tunnel.disconnect();
+
+        // Connection is now starting
+        tunnel.setState(Guacamole.Tunnel.State.CONNECTING);
+
+        // Create Guacamole protocol and UTF-8 parsers specifically for this
+        // connection
+        var parser = new Guacamole.Parser();
+        var utf8Parser = new Guacamole.UTF8Parser();
+
+        // Invoke tunnel's oninstruction handler for each parsed instruction
+        parser.oninstruction = function instructionReceived(opcode, args) {
+            if (tunnel.oninstruction) tunnel.oninstruction(opcode, args);
+        };
+
+        // Allow new request to be aborted
+        abortController = new AbortController();
+
+        // Stream using the Fetch API
+        fetch(url, {
+            headers: extraHeaders,
+            credentials: crossDomain ? 'include' : 'same-origin',
+            signal: abortController.signal,
+        }).then(function gotResponse(response) {
+            // Reset state and close upon error
+            if (!response.ok) {
+                if (tunnel.onerror) tunnel.onerror(new Guacamole.Status(Guacamole.Status.Code.fromHTTPCode(response.status), response.statusText));
+
+                tunnel.disconnect();
+                return;
+            }
+
+            // Report overall size of stream in bytes, if known
+            tunnel.size = response.headers.get('Content-Length');
+
+            // Connection is open
+            tunnel.setState(Guacamole.Tunnel.State.OPEN);
+
+            var reader = response.body.getReader();
+            var processReceivedText = function processReceivedText(result) {
+                // Clean up and close when done
+                if (result.done) {
+                    tunnel.disconnect();
+                    return;
+                }
+
+                // Parse only the portion of data which is newly received
+                parser.receive(utf8Parser.decode(result.value));
+
+                // Continue parsing when next chunk is received
+                reader.read().then(processReceivedText);
+            };
+
+            // Schedule parse of first chunk
+            reader.read().then(processReceivedText);
+        });
+    };
+
+    this.disconnect = function disconnect() {
+        // Abort any in-progress request
+        if (abortController) {
+            abortController.abort();
+            abortController = null;
+        }
+
+        // Connection is now closed
+        tunnel.setState(Guacamole.Tunnel.State.CLOSED);
+    };
+};
+
+Guacamole.StaticHTTPTunnel.prototype = new Guacamole.Tunnel();
+
+/**
+ * Parser that decodes UTF-8 text from a series of provided ArrayBuffers.
+ * Multi-byte characters that continue from one buffer to the next are handled
+ * correctly.
+ *
+ * @constructor
+ */
+Guacamole.UTF8Parser = function UTF8Parser() {
+    /**
+     * The number of bytes remaining for the current codepoint.
+     *
+     * @private
+     * @type {!number}
+     */
+    var bytesRemaining = 0;
+
+    /**
+     * The current codepoint value, as calculated from bytes read so far.
+     *
+     * @private
+     * @type {!number}
+     */
+    var codepoint = 0;
+
+    /**
+     * Decodes the given UTF-8 data into a Unicode string, returning a string
+     * containing all complete UTF-8 characters within the provided data. The
+     * data may end in the middle of a multi-byte character, in which case the
+     * complete character will be returned from a later call to decode() after
+     * enough bytes have been provided.
+     *
+     * @private
+     * @param {!ArrayBuffer} buffer
+     *     Arbitrary UTF-8 data.
+     *
+     * @return {!string}
+     *     The decoded Unicode string.
+     */
+    this.decode = function decode(buffer) {
+        var text = '';
+
+        var bytes = new Uint8Array(buffer);
+        for (var i = 0; i < bytes.length; i++) {
+            // Get current byte
+            var value = bytes[i];
+
+            // Start new codepoint if nothing yet read
+            if (bytesRemaining === 0) {
+                // 1 byte (0xxxxxxx)
+                if ((value | 0x7f) === 0x7f) text += String.fromCharCode(value);
+                // 2 byte (110xxxxx)
+                else if ((value | 0x1f) === 0xdf) {
+                    codepoint = value & 0x1f;
+                    bytesRemaining = 1;
+                }
+
+                // 3 byte (1110xxxx)
+                else if ((value | 0x0f) === 0xef) {
+                    codepoint = value & 0x0f;
+                    bytesRemaining = 2;
+                }
+
+                // 4 byte (11110xxx)
+                else if ((value | 0x07) === 0xf7) {
+                    codepoint = value & 0x07;
+                    bytesRemaining = 3;
+                }
+
+                // Invalid byte
+                else text += '\uFFFD';
+            }
+
+            // Continue existing codepoint (10xxxxxx)
+            else if ((value | 0x3f) === 0xbf) {
+                codepoint = (codepoint << 6) | (value & 0x3f);
+                bytesRemaining--;
+
+                // Write codepoint if finished
+                if (bytesRemaining === 0) text += String.fromCharCode(codepoint);
+            }
+
+            // Invalid byte
+            else {
+                bytesRemaining = 0;
+                text += '\uFFFD';
+            }
+        }
+
+        return text;
+    };
+};
+
+/**
+ * The unique ID of this version of the Guacamole JavaScript API. This ID will
+ * be the version string of the guacamole-common-js Maven project, and can be
+ * used in downstream applications as a sanity check that the proper version
+ * of the APIs is being used (in case an older version is cached, for example).
+ *
+ * @type {!string}
+ */
+Guacamole.API_VERSION = '1.5.0';
+
+/**
+ * Abstract video player which accepts, queues and plays back arbitrary video
+ * data. It is up to implementations of this class to provide some means of
+ * handling a provided Guacamole.InputStream and rendering the received data to
+ * the provided Guacamole.Display.VisibleLayer. Data received along the
+ * provided stream is to be played back immediately.
+ *
+ * @constructor
+ */
+Guacamole.VideoPlayer = function VideoPlayer() {
+    /**
+     * Notifies this Guacamole.VideoPlayer that all video up to the current
+     * point in time has been given via the underlying stream, and that any
+     * difference in time between queued video data and the current time can be
+     * considered latency.
+     */
+    this.sync = function sync() {
+        // Default implementation - do nothing
+    };
+};
+
+/**
+ * Determines whether the given mimetype is supported by any built-in
+ * implementation of Guacamole.VideoPlayer, and thus will be properly handled
+ * by Guacamole.VideoPlayer.getInstance().
+ *
+ * @param {!string} mimetype
+ *     The mimetype to check.
+ *
+ * @returns {!boolean}
+ *     true if the given mimetype is supported by any built-in
+ *     Guacamole.VideoPlayer, false otherwise.
+ */
+Guacamole.VideoPlayer.isSupportedType = function isSupportedType(mimetype) {
+    // There are currently no built-in video players (and therefore no
+    // supported types)
+    return false;
+};
+
+/**
+ * Returns a list of all mimetypes supported by any built-in
+ * Guacamole.VideoPlayer, in rough order of priority. Beware that only the core
+ * mimetypes themselves will be listed. Any mimetype parameters, even required
+ * ones, will not be included in the list.
+ *
+ * @returns {!string[]}
+ *     A list of all mimetypes supported by any built-in Guacamole.VideoPlayer,
+ *     excluding any parameters.
+ */
+Guacamole.VideoPlayer.getSupportedTypes = function getSupportedTypes() {
+    // There are currently no built-in video players (and therefore no
+    // supported types)
+    return [];
+};
+
+/**
+ * Returns an instance of Guacamole.VideoPlayer providing support for the given
+ * video format. If support for the given video format is not available, null
+ * is returned.
+ *
+ * @param {!Guacamole.InputStream} stream
+ *     The Guacamole.InputStream to read video data from.
+ *
+ * @param {!Guacamole.Display.VisibleLayer} layer
+ *     The destination layer in which this Guacamole.VideoPlayer should play
+ *     the received video data.
+ *
+ * @param {!string} mimetype
+ *     The mimetype of the video data in the provided stream.
+ *
+ * @return {Guacamole.VideoPlayer}
+ *     A Guacamole.VideoPlayer instance supporting the given mimetype and
+ *     reading from the given stream, or null if support for the given mimetype
+ *     is absent.
+ */
+Guacamole.VideoPlayer.getInstance = function getInstance(stream, layer, mimetype) {
+    // There are currently no built-in video players
+    return null;
+};
+export default Guacamole;
diff --git a/mayfly_go_web/src/components/terminal-rdp/guac/screen.js b/mayfly_go_web/src/components/terminal-rdp/guac/screen.js
new file mode 100644
index 00000000..bc2fa618
--- /dev/null
+++ b/mayfly_go_web/src/components/terminal-rdp/guac/screen.js
@@ -0,0 +1,38 @@
+export function launchIntoFullscreen(element) {
+    if (element.requestFullscreen) {
+        element.requestFullscreen();
+    } else if (element.mozRequestFullScreen) {
+        element.mozRequestFullScreen();
+    } else if (element.webkitRequestFullscreen) {
+        element.webkitRequestFullscreen();
+    } else if (element.msRequestFullscreen) {
+        element.msRequestFullscreen();
+    }
+}
+export function exitFullscreen() {
+    if (document.exitFullscreen) {
+        document.exitFullscreen();
+    } else if (document.mozCancelFullScreen) {
+        document.mozCancelFullScreen();
+    } else if (document.webkitExitFullscreen) {
+        document.webkitExitFullscreen();
+    }
+}
+
+export function watchFullscreenChange(callback) {
+    function onFullscreenChange(e) {
+        let isFull = (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) != null;
+        callback(e, isFull);
+    }
+    document.addEventListener('fullscreenchange', onFullscreenChange);
+    document.addEventListener('mozfullscreenchange', onFullscreenChange);
+    document.addEventListener('webkitfullscreenchange', onFullscreenChange);
+    document.addEventListener('msfullscreenchange', onFullscreenChange);
+}
+
+export function unWatchFullscreenChange(callback) {
+    document.removeEventListener('fullscreenchange', callback);
+    document.removeEventListener('mozfullscreenchange', callback);
+    document.removeEventListener('webkitfullscreenchange', callback);
+    document.removeEventListener('msfullscreenchange', callback);
+}
diff --git a/mayfly_go_web/src/components/terminal-rdp/guac/states.js b/mayfly_go_web/src/components/terminal-rdp/guac/states.js
new file mode 100644
index 00000000..5fa2d994
--- /dev/null
+++ b/mayfly_go_web/src/components/terminal-rdp/guac/states.js
@@ -0,0 +1,55 @@
+export default {
+  /**
+   * The Guacamole connection has not yet been attempted.
+   *
+   * @type String
+   */
+  IDLE : "IDLE",
+
+  /**
+   * The Guacamole connection is being established.
+   *
+   * @type String
+   */
+  CONNECTING : "CONNECTING",
+
+  /**
+   * The Guacamole connection has been successfully established, and the
+   * client is now waiting for receipt of initial graphical data.
+   *
+   * @type String
+   */
+  WAITING : "WAITING",
+
+  /**
+   * The Guacamole connection has been successfully established, and
+   * initial graphical data has been received.
+   *
+   * @type String
+   */
+  CONNECTED : "CONNECTED",
+
+  /**
+   * The Guacamole connection has terminated successfully. No errors are
+   * indicated.
+   *
+   * @type String
+   */
+  DISCONNECTED : "DISCONNECTED",
+
+  /**
+   * The Guacamole connection has terminated due to an error reported by
+   * the client. The associated error code is stored in statusCode.
+   *
+   * @type String
+   */
+  CLIENT_ERROR : "CLIENT_ERROR",
+
+  /**
+   * The Guacamole connection has terminated due to an error reported by
+   * the tunnel. The associated error code is stored in statusCode.
+   *
+   * @type String
+   */
+  TUNNEL_ERROR : "TUNNEL_ERROR"
+}
diff --git a/mayfly_go_web/src/components/terminal-rdp/index.ts b/mayfly_go_web/src/components/terminal-rdp/index.ts
new file mode 100644
index 00000000..1ed8d0b3
--- /dev/null
+++ b/mayfly_go_web/src/components/terminal-rdp/index.ts
@@ -0,0 +1,11 @@
+export interface TerminalExpose {
+    /** 连接 */
+    init(width: number, height: number, force: boolean): void;
+
+    /** 短开连接 */
+    close(): void;
+
+    blur(): void;
+
+    focus(): void;
+}
diff --git a/mayfly_go_web/src/components/terminal/TerminalDialog.vue b/mayfly_go_web/src/components/terminal/TerminalDialog.vue
index a54b0a78..975b5d68 100644
--- a/mayfly_go_web/src/components/terminal/TerminalDialog.vue
+++ b/mayfly_go_web/src/components/terminal/TerminalDialog.vue
@@ -2,7 +2,7 @@
     
         
             
 
 
 
diff --git a/mayfly_go_web/src/views/ops/machine/MachineOp.vue b/mayfly_go_web/src/views/ops/machine/MachineOp.vue
index e2f2a5da..7693eb1b 100644
--- a/mayfly_go_web/src/views/ops/machine/MachineOp.vue
+++ b/mayfly_go_web/src/views/ops/machine/MachineOp.vue
@@ -10,8 +10,13 @@
                     :tag-path-node-type="NodeTypeTagPath"
                 >
                     
-                        
-                        
+                        
+                        
+                        
                     
 
                     
@@ -35,7 +40,7 @@
                     >
                         
                             
-                                
+                                
                                     
                                         
@@ -59,13 +64,20 @@
                                 
                             
 
-                            
+                            
                                 
+                                
                             
                         
                     
@@ -102,7 +114,27 @@
 
                     
 
-                    
+                    
+
+                    
+                        
+                    
 
                     
 
@@ -114,24 +146,26 @@
 
 
 
 
diff --git a/mayfly_go_web/src/views/ops/machine/RdpTerminalPage.vue b/mayfly_go_web/src/views/ops/machine/RdpTerminalPage.vue
new file mode 100644
index 00000000..78529832
--- /dev/null
+++ b/mayfly_go_web/src/views/ops/machine/RdpTerminalPage.vue
@@ -0,0 +1,28 @@
+
+    
+        
+    
+
+
+
+
diff --git a/mayfly_go_web/src/views/ops/machine/api.ts b/mayfly_go_web/src/views/ops/machine/api.ts
index 098bbac7..e2da03cc 100644
--- a/mayfly_go_web/src/views/ops/machine/api.ts
+++ b/mayfly_go_web/src/views/ops/machine/api.ts
@@ -42,7 +42,6 @@ export const machineApi = {
     addConf: Api.newPost('/machines/{machineId}/files'),
     // 删除配置的文件or目录
     delConf: Api.newDelete('/machines/{machineId}/files/{id}'),
-    terminal: Api.newGet('/api/machines/{id}/terminal'),
     // 机器终端操作记录列表
     termOpRecs: Api.newGet('/machines/{machineId}/term-recs'),
     // 机器终端操作记录详情
@@ -69,3 +68,7 @@ export const cronJobApi = {
 export function getMachineTerminalSocketUrl(machineId: any) {
     return `${config.baseWsUrl}/machines/${machineId}/terminal?${joinClientParams()}`;
 }
+
+export function getMachineRdpSocketUrl(machineId: any) {
+    return `${config.baseWsUrl}/machines/${machineId}/rdp`;
+}
diff --git a/mayfly_go_web/src/views/ops/machine/file/FileConfList.vue b/mayfly_go_web/src/views/ops/machine/file/FileConfList.vue
index c6c9df69..91efbac4 100755
--- a/mayfly_go_web/src/views/ops/machine/file/FileConfList.vue
+++ b/mayfly_go_web/src/views/ops/machine/file/FileConfList.vue
@@ -44,7 +44,7 @@
             
 
             
-                
+