}
+ */
+ 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;
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+ };
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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);
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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);
+ };
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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);
+ };
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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
+ var 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;
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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.
+ */
+
+/**
+ * The namespace used by the Guacamole JavaScript API. Absolutely all classes
+ * defined by the Guacamole JavaScript API will be within this namespace.
+ *
+ * @namespace
+ */
+var Guacamole = Guacamole || {};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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';
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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 || [];
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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);
+ };
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * Simple Guacamole protocol parser that invokes an oninstruction event when
+ * full instructions are available from data received via receive().
+ *
+ * @constructor
+ */
+Guacamole.Parser = function Parser() {
+
+ /**
+ * Reference to this parser.
+ *
+ * @private
+ * @type {!Guacamole.Parser}
+ */
+ 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
+ * @type {!string}
+ */
+ var buffer = '';
+
+ /**
+ * Buffer of all received, complete elements. After an entire instruction
+ * is read, this buffer is flushed, and a new instruction begins.
+ *
+ * @private
+ * @type {!string[]}
+ */
+ var elementBuffer = [];
+
+ /**
+ * The character offset within the buffer of the current or most recently
+ * parsed element's terminator. If sufficient characters have not yet been
+ * read via calls to receive(), this may point to an offset well beyond the
+ * end of the buffer. If no characters for an element have yet been read,
+ * this will be -1.
+ *
+ * @private
+ * @type {!number}
+ */
+ var elementEnd = -1;
+
+ /**
+ * The character offset within the buffer of the location that the parser
+ * should start looking for the next element length search or next element
+ * value.
+ *
+ * @private
+ * @type {!number}
+ */
+ var startIndex = 0;
+
+ /**
+ * The declared length of the current element being parsed, in Unicode
+ * codepoints.
+ *
+ * @private
+ * @type {!number}
+ */
+ var elementCodepoints = 0;
+
+ /**
+ * The number of parsed characters that must accumulate in the begining of
+ * the parse buffer before processing time is expended to truncate that
+ * buffer and conserve memory.
+ *
+ * @private
+ * @constant
+ * @type {!number}
+ */
+ var BUFFER_TRUNCATION_THRESHOLD = 4096;
+
+ /**
+ * The lowest Unicode codepoint to require a surrogate pair when encoded
+ * with UTF-16. In UTF-16, characters with codepoints at or above this
+ * value are represented with a surrogate pair, while characters with
+ * codepoints below this value are represented with a single character.
+ *
+ * @private
+ * @constant
+ * @type {!number}
+ */
+ var MIN_CODEPOINT_REQUIRES_SURROGATE = 0x10000;
+
+ /**
+ * 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.
+ *
+ * @param {!boolean} [isBuffer=false]
+ * Whether the provided data should be treated as an instruction buffer
+ * that grows continuously. If true, the data provided to receive()
+ * MUST always start with the data provided to the previous call. If
+ * false (the default), only the new data should be provided to
+ * receive(), and previously-received data will automatically be
+ * buffered by the parser as needed.
+ */
+ this.receive = function receive(packet, isBuffer) {
+
+ if(isBuffer)
+ buffer = packet;
+
+ else {
+
+ // Truncate buffer as necessary
+ if(startIndex > BUFFER_TRUNCATION_THRESHOLD && elementEnd >= startIndex) {
+
+ buffer = buffer.substring(startIndex);
+
+ // Reset parse relative to truncation
+ elementEnd -= startIndex;
+ startIndex = 0;
+
+ }
+
+ // Append data to buffer ONLY if there is outstanding data present. It
+ // is otherwise much faster to simply parse the received buffer as-is,
+ // and tunnel implementations can take advantage of this by preferring
+ // to send only complete instructions. Both the HTTP and WebSocket
+ // tunnel implementations included with Guacamole already do this.
+ if(buffer.length)
+ buffer += packet;
+ else
+ buffer = packet;
+
+ }
+
+ // While search is within currently received data
+ while (elementEnd < buffer.length) {
+
+ // If we are waiting for element data
+ if(elementEnd >= startIndex) {
+
+ // If we have enough data in the buffer to fill the element
+ // value, but the number of codepoints in the expected substring
+ // containing the element value value is less that its declared
+ // length, that can only be because the element contains
+ // characters split between high and low surrogates, and the
+ // actual end of the element value is further out. The minimum
+ // number of additional characters that must be read to satisfy
+ // the declared length is simply the difference between the
+ // number of codepoints actually present vs. the expected
+ // length.
+ var codepoints = Guacamole.Parser.codePointCount(buffer, startIndex, elementEnd);
+ if(codepoints < elementCodepoints) {
+ elementEnd += elementCodepoints - codepoints;
+ continue;
+ }
+
+ // If the current element ends with a character involving both
+ // a high and low surrogate, elementEnd points to the low
+ // surrogate and NOT the element terminator. We must shift the
+ // end and reevaluate.
+ else if(elementCodepoints && buffer.codePointAt(elementEnd - 1) >= MIN_CODEPOINT_REQUIRES_SURROGATE) {
+ elementEnd++;
+ continue;
+ }
+
+ // We now have enough data for the element. Parse.
+ var element = buffer.substring(startIndex, elementEnd);
+ var terminator = buffer.substring(elementEnd, elementEnd + 1);
+
+ // Add element to array
+ elementBuffer.push(element);
+
+ // If last element, handle instruction
+ if(terminator === ';') {
+
+ // Get opcode
+ var opcode = elementBuffer.shift();
+
+ // Call instruction handler.
+ if(parser.oninstruction !== null)
+ parser.oninstruction(opcode, elementBuffer);
+
+ // Clear elements
+ elementBuffer = [];
+
+ // Immediately truncate buffer if its contents have been
+ // completely parsed, so that the next call to receive()
+ // need not append to the buffer unnecessarily
+ if(!isBuffer && elementEnd + 1 === buffer.length) {
+ elementEnd = -1;
+ buffer = '';
+ }
+
+ } else if(terminator !== ',')
+ throw new Error('Element terminator of instruction was not ";" nor ",".');
+
+ // Start searching for length at character after
+ // element terminator
+ startIndex = elementEnd + 1;
+
+ }
+
+ // Search for end of length
+ var lengthEnd = buffer.indexOf('.', startIndex);
+ if(lengthEnd !== -1) {
+
+ // Parse length
+ elementCodepoints = parseInt(buffer.substring(elementEnd + 1, lengthEnd));
+ if(isNaN(elementCodepoints))
+ throw new Error('Non-numeric character in element length.');
+
+ // Calculate start of element
+ startIndex = lengthEnd + 1;
+
+ // Calculate location of element terminator
+ elementEnd = startIndex + elementCodepoints;
+
+ }
+
+ // If no period yet, continue search when more data
+ // is received
+ else {
+ startIndex = 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;
+
+};
+
+/**
+ * Returns the number of Unicode codepoints (not code units) within the given
+ * string. If character offsets are provided, only codepoints between those
+ * offsets are counted. Unlike the length property of a string, this function
+ * counts proper surrogate pairs as a single codepoint. High and low surrogate
+ * characters that are not part of a proper surrogate pair are counted
+ * separately as individual codepoints.
+ *
+ * @param {!string} str
+ * The string whose contents should be inspected.
+ *
+ * @param {number} [start=0]
+ * The index of the location in the given string where codepoint counting
+ * should start. If omitted, counting will begin at the start of the
+ * string.
+ *
+ * @param {number} [end]
+ * The index of the first location in the given string after where counting
+ * should stop (the character after the last character being counted). If
+ * omitted, all characters after the start location will be counted.
+ *
+ * @returns {!number}
+ * The number of Unicode codepoints within the requested portion of the
+ * given string.
+ */
+Guacamole.Parser.codePointCount = function codePointCount(str, start, end) {
+
+ // Count only characters within the specified region
+ str = str.substring(start || 0, end);
+
+ // Locate each proper Unicode surrogate pair (one high surrogate followed
+ // by one low surrogate)
+ var surrogatePairs = str.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g);
+
+ // Each surrogate pair represents a single codepoint but is represented by
+ // two characters in a JavaScript string, and thus is counted twice toward
+ // string length. Subtracting the number of surrogate pairs adjusts that
+ // length value such that it gives us the number of codepoints.
+ return str.length - (surrogatePairs ? surrogatePairs.length : 0);
+
+};
+
+/**
+ * Converts each of the values within the given array to strings, formatting
+ * those strings as length-prefixed elements of a complete Guacamole
+ * instruction.
+ *
+ * @param {!Array.<*>} elements
+ * The values that should be encoded as the elements of a Guacamole
+ * instruction. Order of these elements is preserved. This array MUST have
+ * at least one element.
+ *
+ * @returns {!string}
+ * A complete Guacamole instruction consisting of each of the provided
+ * element values, in order.
+ */
+Guacamole.Parser.toInstruction = function toInstruction(elements) {
+
+ /**
+ * 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.
+ */
+ var toElement = function toElement(value) {
+ var str = '' + value;
+ return Guacamole.Parser.codePointCount(str) + '.' + str;
+ };
+
+ var instr = toElement(elements[0]);
+ for (var i = 1; i < elements.length; i++)
+ instr += ',' + toElement(elements[i]);
+
+ return instr + ';';
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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
+ });
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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) {
+ recordingBlob = source;
+ 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);
+ };
+
+};
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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)
+ return;
+
+ // Add message to buffer
+ outputMessageBuffer += Guacamole.Parser.toInstruction(arguments);
+
+ // 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;
+
+ var parser = new Guacamole.Parser();
+ parser.oninstruction = function instructionReceived(opcode, args) {
+
+ // Switch to next request if end-of-stream is signalled
+ if(opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE && args.length === 0) {
+
+ // Reset parser state by simply switching to an entirely new
+ // parser
+ parser = new Guacamole.Parser();
+ parser.oninstruction = instructionReceived;
+
+ // 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);
+
+ }
+
+ // Call instruction handler.
+ else if(opcode !== Guacamole.Tunnel.INTERNAL_DATA_OPCODE && tunnel.oninstruction)
+ tunnel.oninstruction(opcode, args);
+
+ };
+
+ 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;
+ }
+
+ // If status could not be read, assume successful.
+ catch (e) {
+ 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;
+ }
+
+ // Do not attempt to parse if data could not be read
+ catch (e) {
+ return;
+ }
+
+ try {
+ parser.receive(current, true);
+ } catch (e) {
+ close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, e.message));
+ return;
+ }
+
+ }
+
+ }
+
+ // 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 parser that this tunnel will use to parse received Guacamole
+ * instructions. The parser is created when the tunnel is (re-)connected.
+ * Initially, this will be null.
+ *
+ * @private
+ * @type {Guacamole.Parser}
+ */
+ var parser = null;
+
+ /**
+ * 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)
+ return;
+
+ socket.send(Guacamole.Parser.toInstruction(arguments));
+
+ };
+
+ this.connect = function(data) {
+
+ resetTimers();
+
+ // Mark the tunnel as connecting
+ tunnel.setState(Guacamole.Tunnel.State.CONNECTING);
+
+ parser = new Guacamole.Parser();
+ parser.oninstruction = function instructionReceived(opcode, args) {
+
+ // Update state and UUID when first instruction received
+ if(tunnel.uuid === null) {
+
+ // Associate tunnel UUID if received
+ if(opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE && args.length === 1)
+ tunnel.setUUID(args[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, args);
+
+ };
+
+ // Connect socket
+ socket = new WebSocket(tunnelURL + '?' + data, 'guacamole');
+
+ 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();
+
+ try {
+ parser.receive(event.data);
+ } catch (e) {
+ close_tunnel(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, e.message));
+ }
+
+ };
+
+ };
+
+ 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();
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+ };
+
+};
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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.4';
+
+/*
+ * 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.
+ */
+
+var Guacamole = Guacamole || {};
+
+/**
+ * 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;
+
+};
+
+// SETTED
+export default Guacamole;
diff --git a/orion-visor-ui/src/views/host/terminal/handler/base-session.ts b/orion-visor-ui/src/views/host/terminal/handler/base-session.ts
deleted file mode 100644
index 7c55e19a..00000000
--- a/orion-visor-ui/src/views/host/terminal/handler/base-session.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { ITerminalSession, TerminalPanelTabItem, TerminalStatus } from '../types/define';
-import type { Reactive } from 'vue';
-import { reactive } from 'vue';
-import { TerminalSessionStatus } from '@/views/host/terminal/types/const';
-
-// 会话基类
-export default abstract class BaseSession implements ITerminalSession {
-
- public readonly type: string;
- public readonly hostId: number;
- public readonly title: string;
- public readonly address: string;
- public readonly status: Reactive;
- public sessionId: string;
-
- protected constructor(type: string, tab: TerminalPanelTabItem, status: Partial) {
- this.type = type;
- this.hostId = tab.hostId;
- this.title = tab.title;
- this.address = tab.address;
- this.sessionId = tab.sessionId;
- this.status = reactive({
- connectStatus: TerminalSessionStatus.CONNECTING,
- connected: false,
- canWrite: false,
- canReconnect: false,
- ...status,
- } as Status);
- }
-
- // 连接会话
- connect(): void {
- this.status.connectStatus = TerminalSessionStatus.CONNECTING;
- }
-
- // 断开连接
- disconnect(): void {
- // 设置已关闭
- this.setClosed();
- }
-
- // 关闭
- close(): void {
- // 设置已关闭
- this.setClosed();
- }
-
- // 设置是否可写
- setCanWrite(canWrite: boolean): void {
- this.status.canWrite = canWrite;
- }
-
- // 设置已连接
- setConnected(): void {
- this.status.connected = true;
- this.status.connectStatus = TerminalSessionStatus.CONNECTED;
- }
-
- // 设置已关闭
- setClosed(): void {
- this.status.connected = false;
- this.status.canWrite = false;
- this.status.connectStatus = TerminalSessionStatus.CLOSED;
- }
-
-}
diff --git a/orion-visor-ui/src/views/host/terminal/handler/terminal-channel.ts b/orion-visor-ui/src/views/host/terminal/handler/terminal-channel.ts
deleted file mode 100644
index b584a299..00000000
--- a/orion-visor-ui/src/views/host/terminal/handler/terminal-channel.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import type { ITerminalChannel, ITerminalOutputProcessor, ITerminalSessionManager } from '../types/define';
-import type { InputPayload, Protocol } from '@/types/protocol/terminal.protocol';
-import { format, OutputProtocol, parse } from '@/types/protocol/terminal.protocol';
-import { sessionCloseMsg } from '../types/const';
-import { getTerminalAccessToken, openTerminalAccessChannel } from '@/api/asset/terminal';
-import { Message } from '@arco-design/web-vue';
-import TerminalOutputProcessor from './terminal-output-processor';
-
-// 终端通信处理器 实现
-export default class TerminalChannel implements ITerminalChannel {
-
- private client?: WebSocket;
-
- private readonly sessionManager: ITerminalSessionManager;
-
- private readonly processor: ITerminalOutputProcessor;
-
- constructor(sessionManager: ITerminalSessionManager) {
- this.sessionManager = sessionManager;
- this.processor = new TerminalOutputProcessor(sessionManager, this);
- }
-
- // 初始化
- async init() {
- // 获取 access
- const { data: accessToken } = await getTerminalAccessToken();
- // 打开会话
- try {
- this.client = await openTerminalAccessChannel(accessToken);
- } catch (e) {
- Message.error('无法连接至服务器');
- console.error('terminal error', e);
- throw e;
- }
- this.client.onclose = event => {
- console.warn('terminal close', event);
- // 关闭回调
- this.closeCallback();
- };
- this.client.onmessage = this.handlerMessage.bind(this);
- }
-
- // 是否已连接
- isConnected(): boolean {
- return !!this.client && this.client.readyState === WebSocket.OPEN;
- }
-
- // 发送消息
- send(protocol: Protocol, payload: InputPayload): void {
- // 检查是否连接
- if (!this.isConnected()) {
- return;
- }
- // 发送命令
- this.client?.send(format(protocol, payload));
- }
-
- // 处理消息
- private handlerMessage({ data }: MessageEvent) {
- // 解析消息
- const payload = parse(data as string);
- if (!payload) {
- return;
- }
- // 获取消息处理方法
- const processMethod = Object.values(OutputProtocol)
- .find(protocol => protocol.type === payload.type)
- ?.processMethod;
- // 处理消息
- if (processMethod) {
- const processMethodFn = this.processor[processMethod as keyof ITerminalOutputProcessor] as Function;
- processMethodFn && processMethodFn.call(this.processor, payload);
- }
- }
-
- // 关闭回调
- private closeCallback(): void {
- // 关闭时将手动触发 close 消息, 有可能是其他原因关闭的, 没有接收到 close 消息, 导致已断开是终端还是显示已连接
- Object.values(this.sessionManager.sessions).forEach(s => {
- if (!s?.status.connected) {
- return;
- }
- // close 消息
- const data = format(OutputProtocol.CLOSE, {
- type: OutputProtocol.CLOSE.type,
- sessionId: s.sessionId,
- forceClose: 0,
- msg: sessionCloseMsg,
- });
- // 触发 close 消息
- this.handlerMessage({ data } as MessageEvent);
- });
- }
-
- // 关闭
- close(): void {
- // 关闭 client
- if (this.client) {
- if (this.client.readyState === WebSocket.OPEN) {
- this.client.close();
- }
- this.client = undefined;
- }
- }
-
-}
diff --git a/orion-visor-ui/src/views/host/terminal/handler/terminal-output-processor.ts b/orion-visor-ui/src/views/host/terminal/handler/terminal-output-processor.ts
deleted file mode 100644
index 3dc8c51d..00000000
--- a/orion-visor-ui/src/views/host/terminal/handler/terminal-output-processor.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-import type { ISftpSession, ISshSession, ITerminalChannel, ITerminalOutputProcessor, ITerminalSession, ITerminalSessionManager } from '../types/define';
-import type { OutputPayload } from '@/types/protocol/terminal.protocol';
-import { InputProtocol } from '@/types/protocol/terminal.protocol';
-import { PanelSessionType } from '../types/const';
-import { useTerminalStore } from '@/store';
-import { Message } from '@arco-design/web-vue';
-
-// 终端输出消息体处理器实现
-export default class TerminalOutputProcessor implements ITerminalOutputProcessor {
-
- private readonly sessionManager: ITerminalSessionManager;
-
- private readonly channel: ITerminalChannel;
-
- constructor(sessionManager: ITerminalSessionManager, channel: ITerminalChannel) {
- this.sessionManager = sessionManager;
- this.channel = channel;
- }
-
- // 处理检查消息
- processCheck({ sessionId, result, msg }: OutputPayload): void {
- const success = !!Number.parseInt(result);
- const session = this.sessionManager.getSession(sessionId);
- session.status.canReconnect = !success;
- // 处理
- this.processWithType(session, ssh => {
- // ssh 会话
- if (success) {
- // 检查成功发送 connect 命令
- const { preference } = useTerminalStore();
- this.channel.send(InputProtocol.CONNECT, {
- sessionId,
- terminalType: preference.sessionSetting.terminalEmulationType || 'xterm',
- cols: ssh.inst.cols,
- rows: ssh.inst.rows
- });
- } else {
- // 设置已关闭
- session.setClosed();
- // 未成功展示错误信息
- ssh.write(`[91m${msg || ''}[0m\r\n\r\n[91m输入回车重新连接...[0m\r\n\r\n`);
- }
- }, sftp => {
- // sftp 会话
- if (success) {
- // 检查成功发送 connect 命令
- this.channel.send(InputProtocol.CONNECT, {
- sessionId,
- });
- } else {
- // 设置已关闭
- session.setClosed();
- // 未成功提示错误信息
- sftp.resolver?.onClose(false, msg);
- Message.error(msg || '建立 SFTP 失败');
- }
- });
- }
-
- // 处理连接消息
- processConnect({ sessionId, result, msg }: OutputPayload): void {
- const success = !!Number.parseInt(result);
- const session = this.sessionManager.getSession(sessionId);
- session.status.canReconnect = !success;
- if (success) {
- // 设置可写
- session.setCanWrite(true);
- // 设置已连接
- session.setConnected();
- } else {
- // 设置已关闭
- session.setClosed();
- }
- // 处理
- this.processWithType(session, ssh => {
- if (!success) {
- // ssh 会话 未成功展示错误信息
- ssh.write(`[91m${msg || ''}[0m\r\n\r\n[91m输入回车重新连接...[0m\r\n\r\n`);
- }
- }, sftp => {
- if (!success) {
- // sftp 会话 未成功提示错误信息
- sftp.resolver?.onClose(false, msg);
- Message.error(msg || '打开 SFTP 失败');
- }
- });
- }
-
- // 处理关闭消息
- processClose({ sessionId, forceClose, msg }: OutputPayload): void {
- const session = this.sessionManager.getSession(sessionId);
- // 无需处理 (直接关闭 tab)
- if (!session) {
- return;
- }
- const isForceClose = !!Number.parseInt(forceClose);
- session.status.canReconnect = !isForceClose;
- // 设置已关闭
- session.setClosed();
- // 处理
- this.processWithType(session, ssh => {
- // ssh 拼接关闭消息
- ssh.write(`\r\n\r\n[91m${msg || ''}[0m\r\n`);
- if (!isForceClose) {
- ssh.write('[91m输入回车重新连接...[0m\r\n\r\n');
- }
- }, sftp => {
- // sftp 设置状态
- sftp.resolver?.onClose(isForceClose, msg);
- });
- }
-
- // 处理 pong 消息
- processPong(payload: OutputPayload): void {
- // console.log('pong');
- }
-
- // 处理 SSH 输出消息
- processSshOutput({ sessionId, body }: OutputPayload): void {
- const session = this.sessionManager.getSession(sessionId);
- session && session.write(body);
- }
-
- // 处理 SFTP 文件列表
- processSftpList({ sessionId, result, path, msg, body }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveList(path, result, msg, JSON.parse(body));
- }
-
- // 处理 SFTP 创建文件夹
- processSftpMkdir({ sessionId, result, msg }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpMkdir(result, msg);
- }
-
- // 处理 SFTP 创建文件
- processSftpTouch({ sessionId, result, msg }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpTouch(result, msg);
- }
-
- // 处理 SFTP 移动文件
- processSftpMove({ sessionId, result, msg }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpMove(result, msg);
- }
-
- // 处理 SFTP 删除文件
- processSftpRemove({ sessionId, result, msg }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpRemove(result, msg);
- }
-
- // 处理 SFTP 修改文件权限
- processSftpChmod({ sessionId, result, msg }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpChmod(result, msg);
- }
-
- // 处理 SFTP 下载文件夹展开文件
- processDownloadFlatDirectory({ sessionId, currentPath, result, msg, body }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveDownloadFlatDirectory(currentPath, result, msg, JSON.parse(body));
- }
-
- // 处理 SFTP 获取文件内容
- processSftpGetContent({ sessionId, result, msg, token }: OutputPayload): void {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpGetContent(result, msg, token);
- }
-
- // 处理 SFTP 修改文件内容
- processSftpSetContent({ sessionId, result, msg, token }: OutputPayload) {
- // 获取会话
- const session = this.sessionManager.getSession(sessionId);
- session && session.resolver.resolveSftpSetContent(result, msg, token);
- }
-
- // 根据类型处理操作
- private processWithType(session: ITerminalSession,
- sshProcess: (ssh: ISshSession) => any | void,
- sftpProcess: (ssh: ISftpSession) => any | void) {
- if (session?.type === PanelSessionType.SSH.type) {
- // SSH 操作
- return sshProcess(session as ISshSession);
- } else if (session?.type === PanelSessionType.SFTP.type) {
- // SFTP 操作
- return sftpProcess(session as ISftpSession);
- }
- }
-
-}
diff --git a/orion-visor-ui/src/views/host/terminal/handler/terminal-session-manager.ts b/orion-visor-ui/src/views/host/terminal/handler/terminal-session-manager.ts
deleted file mode 100644
index a84f6842..00000000
--- a/orion-visor-ui/src/views/host/terminal/handler/terminal-session-manager.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import type {
- ISftpSession,
- ISftpSessionResolver,
- ITerminalChannel,
- ITerminalSession,
- ITerminalSessionManager,
- TerminalPanelTabItem,
- XtermDomRef
-} from '../types/define';
-import { sleep } from '@/utils';
-import { InputProtocol } from '@/types/protocol/terminal.protocol';
-import { PanelSessionType } from '../types/const';
-import { useDebounceFn } from '@vueuse/core';
-import { addEventListen, removeEventListen } from '@/utils/event';
-import TerminalChannel from './terminal-channel';
-import SshSession from './ssh-session';
-import SftpSession from './sftp-session';
-
-// 终端会话管理器实现
-export default class TerminalSessionManager implements ITerminalSessionManager {
-
- public sessions: Record;
-
- private readonly channel: ITerminalChannel;
-
- private keepAliveTaskId?: any;
-
- private readonly dispatchResizeFn: () => {};
-
- constructor() {
- this.sessions = {};
- this.channel = new TerminalChannel(this);
- this.dispatchResizeFn = useDebounceFn(this.dispatchResize, 300).bind(this);
- }
-
- // 打开 ssh 会话
- async openSsh(tab: TerminalPanelTabItem, domRef: XtermDomRef) {
- // 初始化客户端
- await this.initChannel();
- // 获取会话数量
- const sshSessionLen = Object.values(this.sessions)
- .filter(s => s?.type === PanelSessionType.SSH.type)
- .length;
- // 检查 webgl 数量
- const canUseWebgl = (sshSessionLen || 0) <= (navigator.hardwareConcurrency || 0) / 2;
- // 新建会话
- const session = new SshSession(tab, this.channel, canUseWebgl);
- // 初始化
- session.init(domRef);
- // 等待前端渲染完成
- await sleep(100);
- // 添加会话
- const sessionId = tab.sessionId;
- this.sessions[sessionId] = session;
- // 连接会话
- session.connect();
- return session;
- }
-
- // 打开 sftp 会话
- async openSftp(tab: TerminalPanelTabItem, resolver: ISftpSessionResolver): Promise {
- // 初始化客户端
- await this.initChannel();
- // 新建会话
- const session = new SftpSession(tab, this.channel);
- // 初始化
- session.init(resolver);
- // 添加会话
- const sessionId = tab.sessionId;
- this.sessions[sessionId] = session;
- // 连接会话
- session.connect();
- return session;
- }
-
- // 重新打开会话
- async reOpenSession(sessionId: string, newSessionId: string): Promise {
- // 初始化客户端
- await this.initChannel();
- // 获取会话并且重新设置 sessionId
- const session = this.sessions[sessionId] as ITerminalSession;
- session.sessionId = newSessionId;
- this.sessions[sessionId] = undefined as unknown as ITerminalSession;
- this.sessions[newSessionId] = session;
- // 连接会话
- session.connect();
- }
-
- // 获取终端会话
- getSession(sessionId: string): T {
- return this.sessions[sessionId] as T;
- }
-
- // 关闭终端会话
- closeSession(sessionId: string): void {
- const session = this.sessions[sessionId];
- if (!session) {
- return;
- }
- // 关闭连接
- session.disconnect();
- // 关闭会话
- session.close();
- // 移除 session
- this.sessions[sessionId] = undefined as unknown as ITerminalSession;
- // session 全部关闭后 关闭 channel
- const allClosed = Object.values(this.sessions)
- .filter(Boolean)
- .every(s => !s?.status.connected);
- if (allClosed) {
- this.reset();
- }
- }
-
- // 初始化 channel
- private async initChannel() {
- // 检查 channel 是否已经初始化
- if (this.channel.isConnected()) {
- return;
- }
- // 初始化 channel
- await this.channel.init();
- // 注册 resize 事件
- addEventListen(window, 'resize', this.dispatchResizeFn);
- // 注册 ping 事件
- this.keepAliveTaskId = setInterval(() => {
- this.channel.send(InputProtocol.PING, {});
- }, 15000);
- }
-
- // 调度重置大小
- dispatchResize() {
- // 对所有已连接的会话重置大小
- Object.values(this.sessions)
- .filter(s => s?.type === PanelSessionType.SSH.type)
- .map(s => s as SshSession)
- .forEach(h => h.fit());
- }
-
- // 重置
- reset(): void {
- try {
- this.sessions = {};
- // 关闭 channel
- this.channel.close();
- // 清除 ping 事件
- clearInterval(this.keepAliveTaskId);
- // 移除 resize 事件
- removeEventListen(window, 'resize', this.dispatchResizeFn);
- } catch (e) {
- }
- }
-
-}
diff --git a/orion-visor-ui/src/views/host/terminal/types/define.ts b/orion-visor-ui/src/views/host/terminal/types/define.ts
deleted file mode 100644
index f0a36adb..00000000
--- a/orion-visor-ui/src/views/host/terminal/types/define.ts
+++ /dev/null
@@ -1,444 +0,0 @@
-import type { Terminal } from '@xterm/xterm';
-import type { ISearchOptions } from '@xterm/addon-search';
-import type { CSSProperties, Reactive } from 'vue';
-import type { HostQueryResponse } from '@/api/asset/host';
-import type { InputPayload, OutputPayload, Protocol } from '@/types/protocol/terminal.protocol';
-
-// 终端 tab 元素
-export interface TerminalTabItem {
- key: string;
- title: string;
- icon: string;
-
- [key: string]: unknown;
-}
-
-// 终端面板 tab 元素
-export interface TerminalPanelTabItem extends TerminalTabItem {
- type: string;
- sessionId: string;
- seq: number;
- hostId: number;
- address: string;
- color?: string;
-}
-
-// sidebar 操作类型
-export interface SidebarAction {
- icon: string;
- content: string;
- visible?: boolean;
- disabled?: boolean;
- checked?: boolean;
- active?: boolean;
- iconStyle?: CSSProperties;
- click: () => void;
-}
-
-// 组合操作元素
-export interface CombinedHandlerItem {
- icon: string,
- title: string;
- tab?: TerminalTabItem;
- host?: HostQueryResponse;
-}
-
-// 右键菜单元素
-export interface ContextMenuItem {
- item: string;
- icon: string;
- content: string;
-}
-
-// 快捷键元素
-export interface ShortcutKeyItem {
- item: string;
- content: string;
- type: number;
-}
-
-// session tab
-export interface PanelSessionTabType {
- type: string;
- icon: string;
-}
-
-// 终端 tab 管理器定义
-export interface ITerminalTabManager {
- // 当前 tab
- active: string;
- // 全部 tab
- items: Array;
-
- // 获取当前 tab
- getCurrentTab: () => T | undefined;
- // 获取 tab
- getTab: (key: string) => T;
- // 点击 tab
- clickTab: (key: string) => void;
- // 删除 tab
- deleteTab: (key: string) => void;
- // 打开 tab
- openTab: (tab: T) => void;
- // 切换到前一个 tab
- changeToPrevTab: () => void;
- // 切换到后一个 tab
- changeToNextTab: () => void;
- // 切换索引 tab
- changeToIndex: (index: number) => void;
- // 清空
- clear: () => void;
-}
-
-// 终端面板管理器定义
-export interface ITerminalPanelManager {
- // 当前面板
- active: number;
- // 面板列表
- panels: Array>;
-
- // 获取当前面板
- getCurrentPanel: () => ITerminalTabManager;
- // 设置当前面板
- setCurrentPanel: (active: number) => void;
- // 获取面板
- getPanel: (index: number) => ITerminalTabManager;
- // 移除面板
- removePanel: (index: number) => void;
- // 重置
- reset: () => void;
-}
-
-// 终端会话管理器定义
-export interface ITerminalSessionManager {
- // 全部会话
- sessions: Record;
-
- // 打开 ssh 会话
- openSsh: (tab: TerminalPanelTabItem, domRef: XtermDomRef) => Promise;
- // 打开 sftp 会话
- openSftp: (tab: TerminalPanelTabItem, resolver: ISftpSessionResolver) => Promise;
- // 重新打开会话
- reOpenSession: (sessionId: string, newSessionId: string) => Promise;
- // 获取终端会话
- getSession: (sessionId: string) => T;
- // 关闭终端会话
- closeSession: (sessionId: string) => void;
- // 重置大小
- dispatchResize: () => void;
- // 重置
- reset: () => void;
-}
-
-// 终端通信处理器 定义
-export interface ITerminalChannel {
- // 初始化
- init: () => Promise;
- // 是否已连接
- isConnected: () => boolean;
- // 发送消息
- send: (protocol: Protocol, payload: InputPayload) => void;
- // 关闭
- close: () => void;
-}
-
-// 终端输出消息体处理器定义
-export interface ITerminalOutputProcessor {
- // 处理检查消息
- processCheck: (payload: OutputPayload) => void;
- // 处理连接消息
- processConnect: (payload: OutputPayload) => void;
- // 处理关闭消息
- processClose: (payload: OutputPayload) => void;
- // 处理 pong 消息
- processPong: (payload: OutputPayload) => void;
- // 处理 SSH 输出消息
- processSshOutput: (payload: OutputPayload) => void;
- // 处理 SFTP 文件列表
- processSftpList: (payload: OutputPayload) => void;
- // 处理 SFTP 创建文件夹
- processSftpMkdir: (payload: OutputPayload) => void;
- // 处理 SFTP 创建文件
- processSftpTouch: (payload: OutputPayload) => void;
- // 处理 SFTP 移动文件
- processSftpMove: (payload: OutputPayload) => void;
- // 处理 SFTP 删除文件
- processSftpRemove: (payload: OutputPayload) => void;
- // 处理 SFTP 修改文件权限
- processSftpChmod: (payload: OutputPayload) => void;
- // 处理 SFTP 下载文件夹展开文件
- processDownloadFlatDirectory: (payload: OutputPayload) => void;
- // 处理 SFTP 获取文件内容
- processSftpGetContent: (payload: OutputPayload) => void;
- // 处理 SFTP 修改文件内容
- processSftpSetContent: (payload: OutputPayload) => void;
-}
-
-// xterm dom 元素引用
-export interface XtermDomRef {
- viewport: HTMLElement;
- searchModal: any;
- editorModal: any;
- uploadModal: any;
-}
-
-// 终端状态
-export interface TerminalStatus {
- // 连接状态
- connectStatus: number;
- // 是否已连接
- connected: boolean;
- // 是否可写
- canWrite: boolean;
- // 是否可以重新连接
- canReconnect: boolean;
-}
-
-// 终端会话定义
-export interface ITerminalSession {
- readonly type: string;
- readonly title: string;
- readonly address: string;
- readonly hostId: number;
- // 终端状态
- readonly status: Reactive;
- sessionId: string;
-
- // 连接会话
- connect: () => void;
- // 断开连接
- disconnect: () => void;
- // 关闭
- close: () => void;
-
- // 设置是否可写
- setCanWrite: (canWrite: boolean) => void;
- // 设置已连接
- setConnected: () => void;
- // 设置已关闭
- setClosed: () => void;
-}
-
-// ssh 会话定义
-export interface ISshSession extends ITerminalSession {
- // terminal 实例
- inst: Terminal;
- // 处理器
- handler: ISshSessionHandler;
-
- // 初始化
- init: (domRef: XtermDomRef) => void;
- // 写入数据
- write: (value: string) => void;
- // 修改大小
- resize: (cols: number, rows: number) => void;
- // 聚焦
- focus: () => void;
- // 失焦
- blur: () => void;
- // 自适应
- fit: () => void;
- // 查找
- find: (word: string, next: boolean, options: ISearchOptions) => void;
-}
-
-// ssh 会话处理器定义
-export interface ISshSessionHandler {
- // 检测是否忽略默认行为
- checkPreventDefault: (e: KeyboardEvent) => boolean;
- // 检测是否为内置快捷键
- checkIsBuiltin: (e: KeyboardEvent) => boolean;
- // 启用状态
- enabledStatus: (option: string) => boolean;
- // 调用处理方法
- invokeHandle: (option: string) => void;
- // 获取快捷键
- getShortcutKey: (e: KeyboardEvent) => ShortcutKeyItem | undefined;
-
- // 复制选中
- copy: () => void;
- // 从剪切板粘贴并且去除尾部空格 (如果配置)
- paste: () => void;
- // 粘贴并且去除尾部空格 (如果配置)
- pasteTrimEnd: (value: string) => void;
- // 粘贴原文
- pasteOrigin: (value: string) => void;
- // 选中全部
- selectAll: () => void;
- // 去顶部
- toTop: () => void;
- // 去底部
- toBottom: () => void;
- // 打开搜索
- search: () => void;
- // 增大字号
- fontSizePlus: () => void;
- // 减小字号
- fontSizeSubtract: () => void;
- // 打开命令编辑器
- commandEditor: () => void;
- // 打开 sftp
- openSftp: () => void;
- // 上传文件
- uploadFile: () => void;
- // 中断
- interrupt: () => void;
- // 回车
- enter: () => void;
- // 清空
- clear: () => void;
- // 断开连接
- disconnect: () => void;
- // 截图
- screenshot: () => void;
- // 检查追加缺失的部分
- checkAppendMissing: (value: string) => void;
-}
-
-// sftp 会话定义
-export interface ISftpSession extends ITerminalSession {
- // 接收器
- resolver: ISftpSessionResolver;
-
- // 初始化
- init: (resolver: ISftpSessionResolver) => void;
- // 设置显示隐藏文件
- setShowHiddenFile: (show: boolean) => void;
- // 查询文件列表
- list: (path: string) => void;
- // 创建文件夹
- mkdir: (path: string) => void;
- // 创建文件
- touch: (path: string) => void;
- // 移动文件
- move: (path: string, target: string) => void;
- // 删除文件
- remove: (path: string[]) => void;
- // 修改权限
- chmod: (path: string, mod: number) => void;
- // 下载文件夹展开文件
- downloadFlatDirectory: (currentPath: string, path: string[]) => void;
- // 获取内容
- getContent: (path: string) => void;
- // 修改内容
- setContent: (path: string) => void;
-}
-
-// sftp 会话接收器定义
-export interface ISftpSessionResolver {
- // 设置加载状态
- setLoading: (loading: boolean) => void;
- // 连接后回调
- connectCallback: () => void;
- // 关闭回调
- onClose: (forceClose: boolean, msg: string) => void;
- // 接受文件列表响应
- resolveList: (path: string, result: string, msg: string, list: Array) => void;
- // 接收创建文件夹响应
- resolveSftpMkdir: (result: string, msg: string) => void;
- // 接收创建文件响应
- resolveSftpTouch: (result: string, msg: string) => void;
- // 接收移动文件响应
- resolveSftpMove: (result: string, msg: string) => void;
- // 接收删除文件响应
- resolveSftpRemove: (result: string, msg: string) => void;
- // 接收修改文件权限响应
- resolveSftpChmod: (result: string, msg: string) => void;
- // 接收下载文件夹展开文件响应
- resolveDownloadFlatDirectory: (currentPath: string, result: string, msg: string, list: Array) => void;
- // 接收获取文件内容响应
- resolveSftpGetContent: (result: string, msg: string, token: string) => void;
- // 接收修改文件内容响应
- resolveSftpSetContent: (result: string, msg: string, token: string) => void;
-}
-
-// sftp 文件
-export interface SftpFile {
- name: string;
- path: string;
- suffix: string;
- size: number;
- attr: string;
- isDir: boolean;
- permission: number;
- uid: number;
- gid: number;
- modifyTime: number;
- canPreview: boolean;
-}
-
-// sftp 传输管理器定义
-export interface ISftpTransferManager {
- transferList: Array;
- // 添加上传任务
- addUpload: (hostId: number, parentPath: string, files: Array) => void;
- // 添加下载任务
- addDownload: (hostId: number, currentPath: string, files: Array) => void;
- // 取消传输
- cancelTransfer: (fileId: string) => void;
- // 取消全部传输
- cancelAllTransfer: () => void;
-}
-
-// sftp 传输处理回调定义
-export interface ISftpTransferCallback {
- // 下一分片回调
- onNextPart: () => Promise;
- // 开始回调
- onStart: (channelId: string, token: string) => void;
- // 进度回调
- onProgress: (totalSize: number | undefined, currentSize: number | undefined) => void;
- // 失败回调
- onError: (msg: string | undefined) => void;
- // 完成回调
- onFinish: () => void;
- // 中断回调
- onAbort: () => void;
-}
-
-// sftp 传输处理器定义
-export interface ISftpTransferHandler extends ISftpTransferCallback {
- // 类型
- type: string;
- // 是否完成
- finished: boolean;
- // 是否中断
- aborted: boolean;
- // 开始
- start: () => void;
- // 完成
- finish: () => void;
- // 失败
- error: () => void;
- // 中断
- abort: () => void;
- // 是否有下一个分片
- hasNextPart: () => boolean;
-}
-
-// sftp 上传文件项
-export interface SftpTransferItem {
- fileId: string;
- type: string;
- hostId: number;
- name: string;
- parentPath: string;
- currentSize: number,
- totalSize: number;
- progress: number | string;
- status: string;
- errorMessage?: string;
- file: File;
-}
-
-// 传输操作响应
-export interface TransferOperatorResponse {
- channelId?: string;
- type: string;
- hostId?: number;
- currentSize?: number;
- transferToken?: string;
- success: boolean;
- msg?: string;
- totalSize?: number;
-}
diff --git a/orion-visor-ui/src/views/host/terminal/components/command-bar/index.vue b/orion-visor-ui/src/views/terminal/components/command-bar/index.vue
similarity index 93%
rename from orion-visor-ui/src/views/host/terminal/components/command-bar/index.vue
rename to orion-visor-ui/src/views/terminal/components/command-bar/index.vue
index fbc2dfff..86b876a1 100644
--- a/orion-visor-ui/src/views/host/terminal/components/command-bar/index.vue
+++ b/orion-visor-ui/src/views/terminal/components/command-bar/index.vue
@@ -60,10 +60,10 @@
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/display/terminal-right-menu-block.vue b/orion-visor-ui/src/views/terminal/components/setting/display/terminal-right-menu-block.vue
similarity index 95%
rename from orion-visor-ui/src/views/host/terminal/components/setting/display/terminal-right-menu-block.vue
rename to orion-visor-ui/src/views/terminal/components/setting/display/terminal-right-menu-block.vue
index 7507928c..2d5bb1b1 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/display/terminal-right-menu-block.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/display/terminal-right-menu-block.vue
@@ -24,7 +24,7 @@
@@ -85,11 +85,11 @@
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/extra/host-setting-modal.vue b/orion-visor-ui/src/views/terminal/components/setting/extra/host-extra-modal.vue
similarity index 63%
rename from orion-visor-ui/src/views/host/terminal/components/setting/extra/host-setting-modal.vue
rename to orion-visor-ui/src/views/terminal/components/setting/extra/host-extra-modal.vue
index 14487ac5..6c1940bd 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/extra/host-setting-modal.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/extra/host-extra-modal.vue
@@ -19,17 +19,27 @@
position="left"
type="rounded"
:lazy-load="true">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -38,7 +48,7 @@
@@ -47,25 +57,31 @@
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
- import { ExtraSettingItems } from '../../../types/const';
+ import { ExtraSettingItems } from '@/views/terminal/types/const';
import { updateHostExtra } from '@/api/asset/host-extra';
import { Message } from '@arco-design/web-vue';
- import SshSettingForm from './ssh-setting-form.vue';
- import LabelSettingForm from './label-setting-form.vue';
+ import { HostType } from '@/views/asset/host-list/types/const';
+ import LabelExtraForm from './label-extra-form.vue';
+ import SshExtraForm from './ssh-extra-form.vue';
+ import RdpExtraForm from './rdp-extra-form.vue';
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
- const activeItem = ref
(ExtraSettingItems.SSH);
+ const activeItem = ref(ExtraSettingItems.LABEL);
+ const host = ref();
const title = ref();
const hostId = ref();
- const sshForm = ref();
const labelForm = ref();
+ const sshForm = ref();
+ const rdpForm = ref();
// 打开配置
const open = (record: HostQueryResponse) => {
+ host.value = record;
hostId.value = record.id;
title.value = record.alias || `${record.name} (${record.code})`;
+ activeItem.value = ExtraSettingItems.LABEL;
setVisible(true);
};
@@ -76,12 +92,15 @@
setLoading(true);
try {
let value;
- if (activeItem.value === ExtraSettingItems.SSH) {
- // SSH 配置
- value = await sshForm.value.getValue();
- } else if (activeItem.value === ExtraSettingItems.LABEL) {
+ if (activeItem.value === ExtraSettingItems.LABEL) {
// 标签配置
value = await labelForm.value.getValue();
+ } else if (activeItem.value === ExtraSettingItems.SSH) {
+ // SSH 配置
+ value = await sshForm.value.getValue();
+ } else if (activeItem.value === ExtraSettingItems.RDP) {
+ // RDP 配置
+ value = await rdpForm.value.getValue();
}
if (!value) {
return false;
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/extra/label-setting-form.vue b/orion-visor-ui/src/views/terminal/components/setting/extra/label-extra-form.vue
similarity index 95%
rename from orion-visor-ui/src/views/host/terminal/components/setting/extra/label-setting-form.vue
rename to orion-visor-ui/src/views/terminal/components/setting/extra/label-extra-form.vue
index b04fdcd3..dbe5b53c 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/extra/label-setting-form.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/extra/label-extra-form.vue
@@ -30,20 +30,20 @@
+
+
+
+
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/extra/ssh-setting-form.vue b/orion-visor-ui/src/views/terminal/components/setting/extra/ssh-extra-form.vue
similarity index 94%
rename from orion-visor-ui/src/views/host/terminal/components/setting/extra/ssh-setting-form.vue
rename to orion-visor-ui/src/views/terminal/components/setting/extra/ssh-extra-form.vue
index 21bbbccf..ace45d5f 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/extra/ssh-setting-form.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/extra/ssh-extra-form.vue
@@ -37,7 +37,7 @@
@@ -45,14 +45,14 @@
import type { HostSshExtraSettingModel } from '@/api/asset/host-extra';
import { onMounted, ref } from 'vue';
import { getHostExtraItem } from '@/api/asset/host-extra';
- import { ExtraHostAuthType, extraSshAuthTypeKey } from '../../../types/const';
+ import { ExtraHostAuthType, extraSshAuthTypeKey } from '@/views/terminal/types/const';
import { useDictStore } from '@/store';
import HostKeySelector from '@/components/asset/host-key/selector/index.vue';
import HostIdentitySelector from '@/components/asset/host-identity/selector/index.vue';
const props = defineProps<{
- hostId: number,
- item: string
+ hostId: number;
+ item: string;
}>();
const { toRadioOptions } = useDictStore();
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-general-setting.vue b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-general-setting.vue
similarity index 84%
rename from orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-general-setting.vue
rename to orion-visor-ui/src/views/terminal/components/setting/general/terminal-general-setting.vue
index 26db44b4..2aba404b 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-general-setting.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-general-setting.vue
@@ -9,6 +9,8 @@
+
+
@@ -23,6 +25,7 @@
import TerminalInteractBlock from './terminal-interact-block.vue';
import TerminalPluginsBlock from './terminal-plugins-block.vue';
import TerminalSessionBlock from './terminal-session-block.vue';
+ import TerminalRdpGraphBlock from './terminal-rdp-graph-block.vue';
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-interact-block.vue b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-interact-block.vue
similarity index 99%
rename from orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-interact-block.vue
rename to orion-visor-ui/src/views/terminal/components/setting/general/terminal-interact-block.vue
index 919a3fed..9973f300 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-interact-block.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-interact-block.vue
@@ -70,7 +70,7 @@
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-plugins-block.vue b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-plugins-block.vue
similarity index 100%
rename from orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-plugins-block.vue
rename to orion-visor-ui/src/views/terminal/components/setting/general/terminal-plugins-block.vue
diff --git a/orion-visor-ui/src/views/terminal/components/setting/general/terminal-rdp-graph-block.vue b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-rdp-graph-block.vue
new file mode 100644
index 00000000..278c62ef
--- /dev/null
+++ b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-rdp-graph-block.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+ RDP 设置
+
+
+
+
修改后会立刻保存, 重新打开终端后生效. 配置调整后可能会占用更多的带宽, 若发生卡顿/无法加载请调整配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-session-block.vue b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-session-block.vue
similarity index 91%
rename from orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-session-block.vue
rename to orion-visor-ui/src/views/terminal/components/setting/general/terminal-session-block.vue
index c4feeacb..a69b6c97 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/general/terminal-session-block.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/general/terminal-session-block.vue
@@ -12,17 +12,17 @@
@@ -43,7 +43,7 @@
import { ref, watch } from 'vue';
import { useDictStore, useTerminalStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/terminal';
- import { emulationTypeKey } from '../../../types/const';
+ import { emulationTypeKey } from '@/views/terminal/types/const';
import BlockSettingItem from '../block-setting-item.vue';
const { toOptions } = useDictStore();
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/shortcut/terminal-shortcut-action-block.vue b/orion-visor-ui/src/views/terminal/components/setting/shortcut/terminal-shortcut-action-block.vue
similarity index 100%
rename from orion-visor-ui/src/views/host/terminal/components/setting/shortcut/terminal-shortcut-action-block.vue
rename to orion-visor-ui/src/views/terminal/components/setting/shortcut/terminal-shortcut-action-block.vue
diff --git a/orion-visor-ui/src/views/host/terminal/components/setting/shortcut/terminal-shortcut-keys-block.vue b/orion-visor-ui/src/views/terminal/components/setting/shortcut/terminal-shortcut-keys-block.vue
similarity index 98%
rename from orion-visor-ui/src/views/host/terminal/components/setting/shortcut/terminal-shortcut-keys-block.vue
rename to orion-visor-ui/src/views/terminal/components/setting/shortcut/terminal-shortcut-keys-block.vue
index c1c9c7f2..12f1a3cb 100644
--- a/orion-visor-ui/src/views/host/terminal/components/setting/shortcut/terminal-shortcut-keys-block.vue
+++ b/orion-visor-ui/src/views/terminal/components/setting/shortcut/terminal-shortcut-keys-block.vue
@@ -74,7 +74,7 @@
+
+
+
+
+
+
diff --git a/orion-visor-ui/src/views/terminal/components/view/rdp/rdp-status.vue b/orion-visor-ui/src/views/terminal/components/view/rdp/rdp-status.vue
new file mode 100644
index 00000000..48d04b8c
--- /dev/null
+++ b/orion-visor-ui/src/views/terminal/components/view/rdp/rdp-status.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ session.info.address }}
+
+
+
+
+ {{ session.status.closeCode }}
+
+
+
+
+
+
+ {{ TerminalMessages.loggedElsewhere }} ({{ dateFormat() }})
+
+
+
+ {{ session.status.closeMessage ?? '-' }}
+
+
+
+
+
+
+ 关闭
+
+ 重连
+
+
+
+
+
+
+
+
+
+
+
diff --git a/orion-visor-ui/src/views/terminal/components/view/rdp/rdp-view.vue b/orion-visor-ui/src/views/terminal/components/view/rdp/rdp-view.vue
new file mode 100644
index 00000000..9e3f22b2
--- /dev/null
+++ b/orion-visor-ui/src/views/terminal/components/view/rdp/rdp-view.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
diff --git a/orion-visor-ui/src/views/host/terminal/components/sftp/sftp-chmod-modal.vue b/orion-visor-ui/src/views/terminal/components/view/sftp/sftp-chmod-modal.vue
similarity index 89%
rename from orion-visor-ui/src/views/host/terminal/components/sftp/sftp-chmod-modal.vue
rename to orion-visor-ui/src/views/terminal/components/view/sftp/sftp-chmod-modal.vue
index dec8081f..5269c3c3 100644
--- a/orion-visor-ui/src/views/host/terminal/components/sftp/sftp-chmod-modal.vue
+++ b/orion-visor-ui/src/views/terminal/components/view/sftp/sftp-chmod-modal.vue
@@ -45,17 +45,17 @@
diff --git a/orion-visor-ui/src/views/host/terminal/components/sftp/types/table.columns.ts b/orion-visor-ui/src/views/terminal/components/view/sftp/types/table.columns.ts
similarity index 100%
rename from orion-visor-ui/src/views/host/terminal/components/sftp/types/table.columns.ts
rename to orion-visor-ui/src/views/terminal/components/view/sftp/types/table.columns.ts
diff --git a/orion-visor-ui/src/views/host/terminal/components/ssh/ssh-context-menu.vue b/orion-visor-ui/src/views/terminal/components/view/ssh/ssh-context-menu.vue
similarity index 82%
rename from orion-visor-ui/src/views/host/terminal/components/ssh/ssh-context-menu.vue
rename to orion-visor-ui/src/views/terminal/components/view/ssh/ssh-context-menu.vue
index 4da48dac..c4165a86 100644
--- a/orion-visor-ui/src/views/host/terminal/components/ssh/ssh-context-menu.vue
+++ b/orion-visor-ui/src/views/terminal/components/view/ssh/ssh-context-menu.vue
@@ -31,8 +31,9 @@
diff --git a/orion-visor-ui/src/views/host/terminal/components/ssh/ssh-header.vue b/orion-visor-ui/src/views/terminal/components/view/ssh/ssh-header.vue
similarity index 73%
rename from orion-visor-ui/src/views/host/terminal/components/ssh/ssh-header.vue
rename to orion-visor-ui/src/views/terminal/components/view/ssh/ssh-header.vue
index 83811390..cfac7355 100644
--- a/orion-visor-ui/src/views/host/terminal/components/ssh/ssh-header.vue
+++ b/orion-visor-ui/src/views/terminal/components/view/ssh/ssh-header.vue
@@ -6,9 +6,9 @@
- {{ address }}
+ :title="session?.info?.address"
+ @click="copy(session?.info?.address as string, true)">
+ {{ session?.info?.address }}
@@ -21,10 +21,10 @@
:actions="rightActions"
position="bottom" />
-
+ :status="getDictValue(connectStatusKey, session ? session.status.connectStatus : TerminalStatus.CONNECTING, 'status')"
+ :text="getDictValue(connectStatusKey, session ? session.status.connectStatus : TerminalStatus.CONNECTING)" />
@@ -36,17 +36,17 @@
diff --git a/orion-visor-ui/src/views/host/terminal/index.vue b/orion-visor-ui/src/views/terminal/index.vue
similarity index 88%
rename from orion-visor-ui/src/views/host/terminal/index.vue
rename to orion-visor-ui/src/views/terminal/index.vue
index 7ce3681b..cbf10ed4 100644
--- a/orion-visor-ui/src/views/host/terminal/index.vue
+++ b/orion-visor-ui/src/views/terminal/index.vue
@@ -42,11 +42,11 @@
-
+
-
+
-
+
@@ -57,9 +57,9 @@