/* * Copyright (C) 2013 Glyptodon LLC * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ var Guacamole = Guacamole || {}; /** * Guacamole protocol client. Given a display element and {@link Guacamole.Tunnel}, * automatically handles incoming and outgoing Guacamole instructions via the * provided tunnel, updating the display using one or more canvas elements. * * @constructor * @param {Guacamole.Tunnel} tunnel The tunnel to use to send and receive * Guacamole instructions. */ Guacamole.Client = function(tunnel) { var guac_client = this; var STATE_IDLE = 0; var STATE_CONNECTING = 1; var STATE_WAITING = 2; var STATE_CONNECTED = 3; var STATE_DISCONNECTING = 4; var STATE_DISCONNECTED = 5; var currentState = STATE_IDLE; var currentTimestamp = 0; var pingInterval = null; var displayWidth = 0; var displayHeight = 0; var displayScale = 1; /** * Translation from Guacamole protocol line caps to Layer line caps. * @private */ var lineCap = { 0: "butt", 1: "round", 2: "square" }; /** * Translation from Guacamole protocol line caps to Layer line caps. * @private */ var lineJoin = { 0: "bevel", 1: "miter", 2: "round" }; // Create bounding div var bounds = document.createElement("div"); bounds.style.position = "relative"; bounds.style.width = (displayWidth*displayScale) + "px"; bounds.style.height = (displayHeight*displayScale) + "px"; // Create display var display = document.createElement("div"); display.style.position = "relative"; display.style.width = displayWidth + "px"; display.style.height = displayHeight + "px"; // Ensure transformations on display originate at 0,0 display.style.transformOrigin = display.style.webkitTransformOrigin = display.style.MozTransformOrigin = display.style.OTransformOrigin = display.style.msTransformOrigin = "0 0"; // Create default layer var default_layer_container = new Guacamole.Client.LayerContainer(0, displayWidth, displayHeight); // Position default layer var default_layer_container_element = default_layer_container.getElement(); default_layer_container_element.style.position = "absolute"; default_layer_container_element.style.left = "0px"; default_layer_container_element.style.top = "0px"; default_layer_container_element.style.overflow = "hidden"; // Create cursor layer var cursor = new Guacamole.Client.LayerContainer(null, 0, 0); cursor.getLayer().setChannelMask(Guacamole.Layer.SRC); cursor.getLayer().autoflush = true; // Position cursor layer var cursor_element = cursor.getElement(); cursor_element.style.position = "absolute"; cursor_element.style.left = "0px"; cursor_element.style.top = "0px"; // Add default layer and cursor to display display.appendChild(default_layer_container.getElement()); display.appendChild(cursor.getElement()); // Add display to bounds bounds.appendChild(display); // Initially, only default layer exists var layers = [default_layer_container]; // No initial buffers var buffers = []; // No initial parsers var parsers = []; // No initial audio channels var audio_channels = []; // No initial streams var streams = []; // Pool of available stream indices var stream_indices = new Guacamole.IntegerPool(); // Array of allocated output streams by index var output_streams = []; tunnel.onerror = function(message) { if (guac_client.onerror) guac_client.onerror(message); }; function setState(state) { if (state != currentState) { currentState = state; if (guac_client.onstatechange) guac_client.onstatechange(currentState); } } function isConnected() { return currentState == STATE_CONNECTED || currentState == STATE_WAITING; } var cursorHotspotX = 0; var cursorHotspotY = 0; var cursorX = 0; var cursorY = 0; function moveCursor(x, y) { // Move cursor layer cursor.translate(x - cursorHotspotX, y - cursorHotspotY); // Update stored position cursorX = x; cursorY = y; } /** * Returns an element containing the display of this Guacamole.Client. * Adding the element returned by this function to an element in the body * of a document will cause the client's display to be visible. * * @return {Element} An element containing ths display of this * Guacamole.Client. */ this.getDisplay = function() { return bounds; }; /** * Sends the current size of the screen. * * @param {Number} width The width of the screen. * @param {Number} height The height of the screen. */ this.sendSize = function(width, height) { // Do not send requests if not connected if (!isConnected()) return; tunnel.sendMessage("size", width, height); }; /** * Sends a key event having the given properties as if the user * pressed or released a key. * * @param {Boolean} pressed Whether the key is pressed (true) or released * (false). * @param {Number} keysym The keysym of the key being pressed or released. */ this.sendKeyEvent = function(pressed, keysym) { // Do not send requests if not connected if (!isConnected()) return; tunnel.sendMessage("key", keysym, pressed); }; /** * Sends a mouse event having the properties provided by the given mouse * state. * * @param {Guacamole.Mouse.State} mouseState The state of the mouse to send * in the mouse event. */ this.sendMouseState = function(mouseState) { // Do not send requests if not connected if (!isConnected()) return; // Update client-side cursor moveCursor( Math.floor(mouseState.x), Math.floor(mouseState.y) ); // Build mask var buttonMask = 0; if (mouseState.left) buttonMask |= 1; if (mouseState.middle) buttonMask |= 2; if (mouseState.right) buttonMask |= 4; if (mouseState.up) buttonMask |= 8; if (mouseState.down) buttonMask |= 16; // Send message tunnel.sendMessage("mouse", Math.floor(mouseState.x), Math.floor(mouseState.y), buttonMask); }; /** * Sets the clipboard of the remote client to the given text data. * * @param {String} data The data to send as the clipboard contents. */ this.setClipboard = function(data) { // Do not send requests if not connected if (!isConnected()) return; tunnel.sendMessage("clipboard", data); }; /** * Opens a new file for writing, having the given index, mimetype and * filename. * * @param {Number} index The index of the file to write to. This index must * be unused. * @param {String} mimetype The mimetype of the file being sent. * @param {String} filename The filename of the file being sent. */ this.beginFileStream = function(index, mimetype, filename) { // Do not send requests if not connected if (!isConnected()) return; tunnel.sendMessage("file", index, mimetype, filename); }; /** * Given the index of a file, writes a blob of data to that file. * * @param {Number} index The index of the file to write to. * @param {String} data Base64-encoded data to write to the file. */ this.sendBlob = function(index, data) { // Do not send requests if not connected if (!isConnected()) return; tunnel.sendMessage("blob", index, data); }; /** * Marks a currently-open stream as complete. * * @param {Number} index The index of the stream to end. */ this.endStream = function(index) { // Do not send requests if not connected if (!isConnected()) return; tunnel.sendMessage("end", index); }; /** * Opens a new file for writing, having the given index, mimetype and * filename. * * @param {String} mimetype The mimetype of the file being sent. * @param {String} filename The filename of the file being sent. */ this.createFileStream = function(mimetype, filename) { // Allocate index var index = stream_indices.next(); // Create new stream guac_client.beginFileStream(index, mimetype, filename); var stream = output_streams[index] = new Guacamole.OutputStream(guac_client, index); // Override close() of stream to automatically free index var old_close = stream.close; stream.close = function() { old_close(); stream_indices.free(index); delete output_streams[index]; }; // Return new, overridden stream return stream; }; /** * Fired whenever the state of this Guacamole.Client changes. * * @event * @param {Number} state The new state of the client. */ this.onstatechange = null; /** * Fired when the remote client sends a name update. * * @event * @param {String} name The new name of this client. */ this.onname = null; /** * Fired when an error is reported by the remote client, and the connection * is being closed. * * @event * @param {String} reason A human-readable reason describing the error. * @param {Number} code The error code associated with the error. */ this.onerror = null; /** * Fired when the clipboard of the remote client is changing. * * @event * @param {String} data The new text data of the remote clipboard. */ this.onclipboard = null; /** * Fired when the default layer (and thus the entire Guacamole display) * is resized. * * @event * @param {Number} width The new width of the Guacamole display. * @param {Number} height The new height of the Guacamole display. */ this.onresize = null; /** * Fired when a file stream is created. The stream provided to this event * handler will contain its own event handlers for received data and the * close event. * * @event * @param {String} filename The name of the file received. * @param {Guacamole.InputStream} stream A stream that will receive * data from the server. */ this.onfile = null; /** * Fired whenever a sync instruction is received from the server, indicating * that the server is finished processing any input from the client and * has sent any results. * * @event * @param {Number} timestamp The timestamp associated with the sync * instruction. */ this.onsync = null; // Layers function getBufferLayer(index) { index = -1 - index; var buffer = buffers[index]; // Create buffer if necessary if (buffer == null) { buffer = new Guacamole.Layer(0, 0); buffer.autoflush = 1; buffer.autosize = 1; buffers[index] = buffer; } return buffer; } function getLayerContainer(index) { var layer = layers[index]; if (layer == null) { // Add new layer layer = new Guacamole.Client.LayerContainer(index, displayWidth, displayHeight); layers[index] = layer; // Get and position layer var layer_element = layer.getElement(); layer_element.style.position = "absolute"; layer_element.style.left = "0px"; layer_element.style.top = "0px"; layer_element.style.overflow = "hidden"; // Add to default layer container default_layer_container.getElement().appendChild(layer_element); } return layer; } function getLayer(index) { // If buffer, just get layer if (index < 0) return getBufferLayer(index); // Otherwise, retrieve layer from layer container return getLayerContainer(index).getLayer(); } function getParser(index) { var parser = parsers[index]; // If parser not yet created, create it, and tie to the // oninstruction handler of the tunnel. if (parser == null) { parser = parsers[index] = new Guacamole.Parser(); parser.oninstruction = tunnel.oninstruction; } return parser; } function getAudioChannel(index) { var audio_channel = audio_channels[index]; // If audio channel not yet created, create it if (audio_channel == null) audio_channel = audio_channels[index] = new Guacamole.AudioChannel(); return audio_channel; } /** * Handlers for all defined layer properties. * @private */ var layerPropertyHandlers = { "miter-limit": function(layer, value) { layer.setMiterLimit(parseFloat(value)); } }; /** * Handlers for all instruction opcodes receivable by a Guacamole protocol * client. * @private */ var instructionHandlers = { "ack": function(parameters) { var stream_index = parseInt(parameters[0]); var reason = parameters[1]; var code = parameters[2]; // Get stream var stream = output_streams[stream_index]; if (stream) { // If code is an error, invalidate stream if (code >= 0x0100) { // Signal error if (stream.onerror) stream.onerror(reason, code); stream_indices.free(stream_index); delete output_streams[stream_index]; } // Signal error if handler defined else if (stream.onack) stream.onack(reason, code); } }, "arc": function(parameters) { var layer = getLayer(parseInt(parameters[0])); var x = parseInt(parameters[1]); var y = parseInt(parameters[2]); var radius = parseInt(parameters[3]); var startAngle = parseFloat(parameters[4]); var endAngle = parseFloat(parameters[5]); var negative = parseInt(parameters[6]); layer.arc(x, y, radius, startAngle, endAngle, negative != 0); }, "audio": function(parameters) { var stream_index = parseInt(parameters[0]); var channel = getAudioChannel(parseInt(parameters[1])); var mimetype = parameters[2]; var duration = parseFloat(parameters[3]); // Create stream var stream = streams[stream_index] = new Guacamole.InputStream(mimetype); stream.onclose = function() { channel.play(mimetype, duration, stream.getBlob()); }; // Send success response tunnel.sendMessage("ack", stream_index, "OK", 0x0000); }, "blob": function(parameters) { // Get stream var stream_index = parseInt(parameters[0]); var data = parameters[1]; var stream = streams[stream_index]; // Convert to ArrayBuffer var binary = window.atob(data); var arrayBuffer = new ArrayBuffer(binary.length); var bufferView = new Uint8Array(arrayBuffer); for (var i=0; i 0) { // Remove from parent var layer_container = getLayerContainer(layer_index); layer_container.dispose(); // Delete reference delete layers[layer_index]; } // If buffer, just delete reference else if (layer_index < 0) delete buffers[-1 - layer_index]; // Attempting to dispose the root layer currently has no effect. }, "distort": function(parameters) { var layer_index = parseInt(parameters[0]); var a = parseFloat(parameters[1]); var b = parseFloat(parameters[2]); var c = parseFloat(parameters[3]); var d = parseFloat(parameters[4]); var e = parseFloat(parameters[5]); var f = parseFloat(parameters[6]); // Only valid for visible layers (not buffers) if (layer_index >= 0) { // Get container element var layer_container = getLayerContainer(layer_index).getElement(); // Set layer transform layer_container.transform(a, b, c, d, e, f); } }, "error": function(parameters) { var reason = parameters[0]; var code = parameters[1]; // Call handler if defined if (guac_client.onerror) guac_client.onerror(reason, code); guac_client.disconnect(); }, "end": function(parameters) { // Get stream var stream_index = parseInt(parameters[0]); var stream = streams[stream_index]; // Close stream stream.close(); }, "file": function(parameters) { var stream_index = parseInt(parameters[0]); var mimetype = parameters[1]; var filename = parameters[2]; // Create stream var stream = streams[stream_index] = new Guacamole.InputStream(mimetype); // Call handler now that file stream is created if (guac_client.onfile) guac_client.onfile(filename, stream); // Send success response tunnel.sendMessage("ack", stream_index, "OK", 0x0000); }, "identity": function(parameters) { var layer = getLayer(parseInt(parameters[0])); layer.setTransform(1, 0, 0, 1, 0, 0); }, "lfill": function(parameters) { var channelMask = parseInt(parameters[0]); var layer = getLayer(parseInt(parameters[1])); var srcLayer = getLayer(parseInt(parameters[2])); layer.setChannelMask(channelMask); layer.fillLayer(srcLayer); }, "line": function(parameters) { var layer = getLayer(parseInt(parameters[0])); var x = parseInt(parameters[1]); var y = parseInt(parameters[2]); layer.lineTo(x, y); }, "lstroke": function(parameters) { var channelMask = parseInt(parameters[0]); var layer = getLayer(parseInt(parameters[1])); var srcLayer = getLayer(parseInt(parameters[2])); layer.setChannelMask(channelMask); layer.strokeLayer(srcLayer); }, "move": function(parameters) { var layer_index = parseInt(parameters[0]); var parent_index = parseInt(parameters[1]); var x = parseInt(parameters[2]); var y = parseInt(parameters[3]); var z = parseInt(parameters[4]); // Only valid for non-default layers if (layer_index > 0 && parent_index >= 0) { // Get container element var layer_container = getLayerContainer(layer_index); var parent = getLayerContainer(parent_index); // Move layer layer_container.move(parent, x, y, z); } }, "name": function(parameters) { if (guac_client.onname) guac_client.onname(parameters[0]); }, "nest": function(parameters) { var parser = getParser(parseInt(parameters[0])); parser.receive(parameters[1]); }, "png": function(parameters) { var channelMask = parseInt(parameters[0]); var layer = getLayer(parseInt(parameters[1])); var x = parseInt(parameters[2]); var y = parseInt(parameters[3]); var data = parameters[4]; layer.setChannelMask(channelMask); layer.draw( x, y, "data:image/png;base64," + data ); }, "pop": function(parameters) { var layer = getLayer(parseInt(parameters[0])); layer.pop(); }, "push": function(parameters) { var layer = getLayer(parseInt(parameters[0])); layer.push(); }, "rect": function(parameters) { var layer = getLayer(parseInt(parameters[0])); var x = parseInt(parameters[1]); var y = parseInt(parameters[2]); var w = parseInt(parameters[3]); var h = parseInt(parameters[4]); layer.rect(x, y, w, h); }, "reset": function(parameters) { var layer = getLayer(parseInt(parameters[0])); layer.reset(); }, "set": function(parameters) { var layer = getLayer(parseInt(parameters[0])); var name = parameters[1]; var value = parameters[2]; // Call property handler if defined var handler = layerPropertyHandlers[name]; if (handler) handler(layer, value); }, "shade": function(parameters) { var layer_index = parseInt(parameters[0]); var a = parseInt(parameters[1]); // Only valid for visible layers (not buffers) if (layer_index >= 0) { var layer_container = getLayerContainer(layer_index); layer_container.shade(a); } }, "size": function(parameters) { var layer_index = parseInt(parameters[0]); var width = parseInt(parameters[1]); var height = parseInt(parameters[2]); // If not buffer, resize layer and container if (layer_index >= 0) { // Resize layer var layer_container = getLayerContainer(layer_index); layer_container.resize(width, height); // If layer is default, resize display if (layer_index == 0) { displayWidth = width; displayHeight = height; // Update (set) display size display.style.width = displayWidth + "px"; display.style.height = displayHeight + "px"; // Update bounds size bounds.style.width = (displayWidth*displayScale) + "px"; bounds.style.height = (displayHeight*displayScale) + "px"; // Call resize event handler if defined if (guac_client.onresize) guac_client.onresize(width, height); } } // If buffer, resize layer only else { var layer = getBufferLayer(parseInt(parameters[0])); layer.resize(width, height); } }, "start": function(parameters) { var layer = getLayer(parseInt(parameters[0])); var x = parseInt(parameters[1]); var y = parseInt(parameters[2]); layer.moveTo(x, y); }, "sync": function(parameters) { var timestamp = parameters[0]; // When all layers have finished rendering all instructions // UP TO THIS POINT IN TIME, send sync response. var layersToSync = 0; function syncLayer() { layersToSync--; // Send sync response when layers are finished if (layersToSync == 0) { if (timestamp != currentTimestamp) { tunnel.sendMessage("sync", timestamp); currentTimestamp = timestamp; } } } // Count active, not-ready layers and install sync tracking hooks for (var i=0; i