diff --git a/docker/guacd/Dockerfile b/docker/guacd/Dockerfile index eb639087..5bbaf75c 100644 --- a/docker/guacd/Dockerfile +++ b/docker/guacd/Dockerfile @@ -1,4 +1,4 @@ -FROM guacamole/guacd:1.5.5 +FROM guacamole/guacd:1.6.0 USER root # 系统时区 ARG TZ=Asia/Shanghai diff --git a/orion-visor-dependencies/pom.xml b/orion-visor-dependencies/pom.xml index f617b4c6..6688829d 100644 --- a/orion-visor-dependencies/pom.xml +++ b/orion-visor-dependencies/pom.xml @@ -34,7 +34,7 @@ 4.11.0 1.0.7 7.2.11.RELEASE - 1.5.5 + 1.6.0 diff --git a/orion-visor-ui/libs/guacamole-common-js.js b/orion-visor-ui/libs/guacamole-common-js.js index 845dd632..09363803 100644 --- a/orion-visor-ui/libs/guacamole-common-js.js +++ b/orion-visor-ui/libs/guacamole-common-js.js @@ -58,13 +58,27 @@ Guacamole.ArrayBufferReader = function(stream) { // Receive blobs as array buffers stream.onblob = function(data) { - // Convert to ArrayBuffer - var binary = window.atob(data); - var arrayBuffer = new ArrayBuffer(binary.length); - var bufferView = new Uint8Array(arrayBuffer); + var arrayBuffer, bufferView; - for (var i = 0; i < binary.length; i++) - bufferView[i] = binary.charCodeAt(i); + // Use native methods for directly decoding base64 to an array buffer + // when possible + if(Uint8Array.fromBase64) { + bufferView = Uint8Array.fromBase64(data); + arrayBuffer = bufferView.buffer; + } + + // Rely on binary strings and manual conversions where native methods + // like fromBase64() are not available + else { + + var binary = window.atob(data); + arrayBuffer = new ArrayBuffer(binary.length); + bufferView = new Uint8Array(arrayBuffer); + + for (var i = 0; i < binary.length; i++) + bufferView[i] = binary.charCodeAt(i); + + } // Call handler, if present if(guac_reader.ondata) @@ -1833,14 +1847,7 @@ 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 currentState = Guacamole.Client.State.IDLE; var currentTimestamp = 0; @@ -1962,8 +1969,8 @@ Guacamole.Client = function(tunnel) { } function isConnected() { - return currentState == STATE_CONNECTED - || currentState == STATE_WAITING; + return currentState == Guacamole.Client.State.CONNECTED + || currentState == Guacamole.Client.State.WAITING; } /** @@ -2728,6 +2735,12 @@ Guacamole.Client = function(tunnel) { * @event * @param {!number} timestamp * The timestamp associated with the sync instruction. + * + * @param {!number} frames + * The number of frames that were considered or combined to produce the + * frame associated with this sync instruction, or zero if this value + * is not known or the remote desktop server provides no concept of + * frames. */ this.onsync = null; @@ -3456,6 +3469,7 @@ Guacamole.Client = function(tunnel) { 'sync': function(parameters) { var timestamp = parseInt(parameters[0]); + var frames = parameters[1] ? parseInt(parameters[1]) : 0; // Flush display, send sync when done display.flush(function displaySyncComplete() { @@ -3473,15 +3487,15 @@ Guacamole.Client = function(tunnel) { currentTimestamp = timestamp; } - }); + }, timestamp, frames); // If received first update, no longer waiting. - if(currentState === STATE_WAITING) - setState(STATE_CONNECTED); + if(currentState === Guacamole.Client.State.WAITING) + setState(Guacamole.Client.State.CONNECTED); // Call sync handler if defined if(guac_client.onsync) - guac_client.onsync(timestamp); + guac_client.onsync(timestamp, frames); }, @@ -3633,10 +3647,10 @@ Guacamole.Client = function(tunnel) { this.disconnect = function() { // Only attempt disconnection not disconnected. - if(currentState != STATE_DISCONNECTED - && currentState != STATE_DISCONNECTING) { + if(currentState != Guacamole.Client.State.DISCONNECTED + && currentState != Guacamole.Client.State.DISCONNECTING) { - setState(STATE_DISCONNECTING); + setState(Guacamole.Client.State.DISCONNECTING); // Stop sending keep-alive messages stopKeepAlive(); @@ -3644,7 +3658,7 @@ Guacamole.Client = function(tunnel) { // Send disconnect message and disconnect tunnel.sendMessage('disconnect'); tunnel.disconnect(); - setState(STATE_DISCONNECTED); + setState(Guacamole.Client.State.DISCONNECTED); } @@ -3663,12 +3677,12 @@ Guacamole.Client = function(tunnel) { */ this.connect = function(data) { - setState(STATE_CONNECTING); + setState(Guacamole.Client.State.CONNECTING); try { tunnel.connect(data); } catch (status) { - setState(STATE_IDLE); + setState(Guacamole.Client.State.IDLE); throw status; } @@ -3676,38 +3690,61 @@ Guacamole.Client = function(tunnel) { // still here, even if not active scheduleKeepAlive(); - setState(STATE_WAITING); + setState(Guacamole.Client.State.WAITING); }; }; -// SETTED +/** + * All possible Guacamole Client states. + * + * @type {!Object.} + */ Guacamole.Client.State = { + /** * The client is idle, with no active connection. + * + * @type number */ 'IDLE': 0, + /** * The client is in the process of establishing a connection. + * + * @type {!number} */ 'CONNECTING': 1, + /** * The client is waiting on further information or a remote server to * establish the connection. + * + * @type {!number} */ 'WAITING': 2, + /** * The client is actively connected to a remote server. + * + * @type {!number} */ 'CONNECTED': 3, + /** * The client is in the process of disconnecting from the remote server. + * + * @type {!number} */ 'DISCONNECTING': 4, + /** * The client has completed the connection and is no longer connected. + * + * @type {!number} */ - 'DISCONNECTED': 5, + 'DISCONNECTED': 5 + }; /** @@ -4059,6 +4096,17 @@ Guacamole.Display = function() { */ this.cursorY = 0; + /** + * The number of milliseconds over which display rendering statistics + * should be gathered, dispatching {@link #onstatistics} events as those + * statistics are available. If set to zero, no statistics will be + * gathered. + * + * @default 0 + * @type {!number} + */ + this.statisticWindow = 0; + /** * Fired when the default layer (and thus the entire Guacamole display) * is resized. @@ -4089,6 +4137,18 @@ Guacamole.Display = function() { */ this.oncursor = null; + /** + * Fired whenever performance statistics are available for recently- + * rendered frames. This event will fire only if {@link #statisticWindow} + * is non-zero. + * + * @event + * @param {!Guacamole.Display.Statistics} stats + * An object containing general rendering performance statistics for + * the remote desktop, Guacamole server, and Guacamole client. + */ + this.onstatistics = null; + /** * The queue of all pending Tasks. Tasks will be run in order, with new * tasks added at the end of the queue and old tasks removed from the @@ -4110,11 +4170,20 @@ Guacamole.Display = function() { var frames = []; /** - * Flushes all pending frames. + * Flushes all pending frames synchronously. This function will block until + * all pending frames have rendered. If a frame is currently blocked by an + * asynchronous operation like an image load, this function will return + * after reaching that operation and the flush operation will + * automamtically resume after that operation completes. + * * @private */ - function __flush_frames() { + var syncFlush = function syncFlush() { + var localTimestamp = 0; + var remoteTimestamp = 0; + + var renderedLogicalFrames = 0; var rendered_frames = 0; // Draw all pending frames, if ready @@ -4125,6 +4194,10 @@ Guacamole.Display = function() { break; frame.flush(); + + localTimestamp = frame.localTimestamp; + remoteTimestamp = frame.remoteTimestamp; + renderedLogicalFrames += frame.logicalFrames; rendered_frames++; } @@ -4132,6 +4205,112 @@ Guacamole.Display = function() { // Remove rendered frames from array frames.splice(0, rendered_frames); + if(rendered_frames) + notifyFlushed(localTimestamp, remoteTimestamp, renderedLogicalFrames); + + }; + + /** + * Recently-gathered display render statistics, as made available by calls + * to notifyFlushed(). The contents of this array will be trimmed to + * contain only up to {@link #statisticWindow} milliseconds of statistics. + * + * @private + * @type {Guacamole.Display.Statistics[]} + */ + var statistics = []; + + /** + * Notifies that one or more frames have been successfully rendered + * (flushed) to the display. + * + * @private + * @param {!number} localTimestamp + * The local timestamp of the point in time at which the most recent, + * flushed frame was received by the display, in milliseconds since the + * Unix Epoch. + * + * @param {!number} remoteTimestamp + * The remote timestamp of sync instruction associated with the most + * recent, flushed frame received by the display. This timestamp is in + * milliseconds, but is arbitrary, having meaning only relative to + * other timestamps in the same connection. + * + * @param {!number} logicalFrames + * The number of remote desktop frames that were flushed. + */ + var notifyFlushed = function notifyFlushed(localTimestamp, remoteTimestamp, logicalFrames) { + + // Ignore if statistics are not being gathered + if(!guac_display.statisticWindow) + return; + + var current = new Date().getTime(); + + // Find the first statistic that is still within the configured time + // window + for (var first = 0; first < statistics.length; first++) { + if(current - statistics[first].timestamp <= guac_display.statisticWindow) + break; + } + + // Remove all statistics except those within the time window + statistics.splice(0, first - 1); + + // Record statistics for latest frame + statistics.push({ + localTimestamp: localTimestamp, + remoteTimestamp: remoteTimestamp, + timestamp: current, + frames: logicalFrames + }); + + // Determine the actual time interval of the available statistics (this + // will not perfectly match the configured interval, which is an upper + // bound) + var statDuration = (statistics[statistics.length - 1].timestamp - statistics[0].timestamp) / 1000; + + // Determine the amount of time that elapsed remotely (within the + // remote desktop) + var remoteDuration = (statistics[statistics.length - 1].remoteTimestamp - statistics[0].remoteTimestamp) / 1000; + + // Calculate the number of frames that have been rendered locally + // within the configured time interval + var localFrames = statistics.length; + + // Calculate the number of frames actually received from the remote + // desktop by the Guacamole server + var remoteFrames = statistics.reduce(function sumFrames(prev, stat) { + return prev + stat.frames; + }, 0); + + // Calculate the number of frames that the Guacamole server had to + // drop or combine with other frames + var drops = statistics.reduce(function sumDrops(prev, stat) { + return prev + Math.max(0, stat.frames - 1); + }, 0); + + // Produce lag and FPS statistics from above raw measurements + var stats = new Guacamole.Display.Statistics({ + processingLag: current - localTimestamp, + desktopFps: (remoteDuration && remoteFrames) ? remoteFrames / remoteDuration : null, + clientFps: statDuration ? localFrames / statDuration : null, + serverFps: remoteDuration ? localFrames / remoteDuration : null, + dropRate: remoteDuration ? drops / remoteDuration : null + }); + + // Notify of availability of new statistics + if(guac_display.onstatistics) + guac_display.onstatistics(stats); + + }; + + /** + * Flushes all pending frames. + * @private + */ + function __flush_frames() { + syncFlush(); } /** @@ -4145,8 +4324,43 @@ Guacamole.Display = function() { * * @param {!Task[]} tasks * The set of tasks which must be executed to render this frame. + * + * @param {number} [timestamp] + * The remote timestamp of sync instruction associated with this frame. + * This timestamp is in milliseconds, but is arbitrary, having meaning + * only relative to other remote timestamps in the same connection. If + * omitted, a compatible but local timestamp will be used instead. + * + * @param {number} [logicalFrames=0] + * The number of remote desktop frames that were combined to produce + * this frame, or zero if this value is unknown or inapplicable. */ - function Frame(callback, tasks) { + var Frame = function Frame(callback, tasks, timestamp, logicalFrames) { + + /** + * The local timestamp of the point in time at which this frame was + * received by the display, in milliseconds since the Unix Epoch. + * + * @type {!number} + */ + this.localTimestamp = new Date().getTime(); + + /** + * The remote timestamp of sync instruction associated with this frame. + * This timestamp is in milliseconds, but is arbitrary, having meaning + * only relative to other remote timestamps in the same connection. + * + * @type {!number} + */ + this.remoteTimestamp = timestamp || this.localTimestamp; + + /** + * The number of remote desktop frames that were combined to produce + * this frame. If unknown or not applicable, this will be zero. + * + * @type {!number} + */ + this.logicalFrames = logicalFrames || 0; /** * Cancels rendering of this frame and all associated tasks. The @@ -4201,7 +4415,7 @@ Guacamole.Display = function() { }; - } + }; /** * A container for an task handler. Each operation which must be ordered @@ -4250,7 +4464,10 @@ Guacamole.Display = function() { this.unblock = function() { if(task.blocked) { task.blocked = false; - __flush_frames(); + + if(frames.length) + __flush_frames(); + } }; @@ -4378,11 +4595,20 @@ Guacamole.Display = function() { * @param {function} [callback] * The function to call when this frame is flushed. This may happen * immediately, or later when blocked tasks become unblocked. + * + * @param {number} timestamp + * The remote timestamp of sync instruction associated with this frame. + * This timestamp is in milliseconds, but is arbitrary, having meaning + * only relative to other remote timestamps in the same connection. + * + * @param {number} logicalFrames + * The number of remote desktop frames that were combined to produce + * this frame. */ - this.flush = function(callback) { + this.flush = function(callback, timestamp, logicalFrames) { // Add frame, reset tasks - frames.push(new Frame(callback, tasks)); + frames.push(new Frame(callback, tasks, timestamp, logicalFrames)); tasks = []; // Attempt flush @@ -4666,17 +4892,38 @@ Guacamole.Display = function() { */ this.drawStream = function drawStream(layer, x, y, stream, mimetype) { - // If createImageBitmap() is available, load the image as a blob so - // that function can be used - if(window.createImageBitmap) { - var reader = new Guacamole.BlobReader(stream, mimetype); - reader.onend = function drawImageBlob() { - guac_display.drawBlob(layer, x, y, reader.getBlob()); - }; + // Leverage ImageDecoder to decode the image stream as it is received + // whenever possible, as this reduces latency that might otherwise be + // caused by waiting for the full image to be received + if(window.ImageDecoder && window.ReadableStream) { + + var imageDecoder = new ImageDecoder({ + type: mimetype, + data: stream.toReadableStream() + }); + + var decodedFrame = null; + + // Draw image once loaded + var task = scheduleTask(function drawImageBitmap() { + layer.drawImage(x, y, decodedFrame); + }, true); + + imageDecoder.decode({ completeFramesOnly: true }).then(function bitmapLoaded(result) { + decodedFrame = result.image; + task.unblock(); + }); + } - // Lacking createImageBitmap(), fall back to data URIs and the Image - // object + // NOTE: We do not use Blobs and createImageBitmap() here, as doing so + // is very latent compared to the old data URI method and the new + // ImageDecoder object. The new ImageDecoder object is currently + // supported by most browsers, with other browsers being much faster if + // data URIs are used. The iOS version of Safari is particularly laggy + // if Blobs and createImageBitmap() are used instead. + + // Lacking ImageDecoder, fall back to data URIs and the Image object else { var reader = new Guacamole.DataURIReader(stream, mimetype); reader.onend = function drawImageDataURI() { @@ -5803,6 +6050,82 @@ Guacamole.Display.VisibleLayer = function(width, height) { */ Guacamole.Display.VisibleLayer.__next_id = 0; +/** + * A set of Guacamole display performance statistics, describing the speed at + * which the remote desktop, Guacamole server, and Guacamole client are + * rendering frames. + * + * @constructor + * @param {Guacamole.Display.Statistics|Object} [template={}] + * The object whose properties should be copied within the new + * Guacamole.Display.Statistics. + */ +Guacamole.Display.Statistics = function Statistics(template) { + + template = template || {}; + + /** + * The amount of time that the Guacamole client is taking to render + * individual frames, in milliseconds, if known. If this value is unknown, + * such as if the there are insufficient frame statistics recorded to + * calculate this value, this will be null. + * + * @type {?number} + */ + this.processingLag = template.processingLag; + + /** + * The framerate of the remote desktop currently being viewed within the + * relevant Gucamole.Display, independent of Guacamole, in frames per + * second. This represents the speed at which the remote desktop is + * producing frame data for the Guacamole server to consume. If this + * value is unknown, such as if the remote desktop server does not actually + * define frame boundaries, this will be null. + * + * @type {?number} + */ + this.desktopFps = template.desktopFps; + + /** + * The rate at which the Guacamole server is generating frames for the + * Guacamole client to consume, in frames per second. If the Guacamole + * server is correctly adjusting for variance in client/browser processing + * power, this rate should closely match the client rate, and should remain + * independent of any network latency. If this value is unknown, such as if + * the there are insufficient frame statistics recorded to calculate this + * value, this will be null. + * + * @type {?number} + */ + this.serverFps = template.serverFps; + + /** + * The rate at which the Guacamole client is consuming frames generated by + * the Guacamole server, in frames per second. If the Guacamole server is + * correctly adjusting for variance in client/browser processing power, + * this rate should closely match the server rate, regardless of any + * latency on the network between the server and client. If this value is + * unknown, such as if the there are insufficient frame statistics recorded + * to calculate this value, this will be null. + * + * @type {?number} + */ + this.clientFps = template.clientFps; + + /** + * The rate at which the Guacamole server is dropping or combining frames + * received from the remote desktop server to compensate for variance in + * client/browser processing power, in frames per second. This value may + * also be non-zero if the server is compensating for variances in its own + * processing power, or relative slowness in image compression vs. the rate + * that inbound frames are received. If this value is unknown, such as if + * the remote desktop server does not actually define frame boundaries, + * this will be null. + */ + this.dropRate = template.dropRate; + +}; + /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -6338,6 +6661,66 @@ Guacamole.InputStream = function(client, index) { client.sendAck(guac_stream.index, message, code); }; + /** + * Creates a new ReadableStream that receives the data sent to this stream + * by the Guacamole server. This function may be invoked at most once per + * stream, and invoking this function will overwrite any installed event + * handlers on this stream. + * + * A ReadableStream is a JavaScript object defined by the "Streams" + * standard. It is supported by most browsers, but not necessarily all + * browsers. The caller should verify this support is present before + * invoking this function. The behavior of this function when the browser + * does not support ReadableStream is not defined. + * + * @see {@link https://streams.spec.whatwg.org/#rs-class} + * + * @returns {!ReadableStream} + * A new ReadableStream that receives the bytes sent along this stream + * by the Guacamole server. + */ + this.toReadableStream = function toReadableStream() { + return new ReadableStream({ + type: 'bytes', + start: function startStream(controller) { + + var reader = new Guacamole.ArrayBufferReader(guac_stream); + + // Provide any received blocks of data to the ReadableStream + // controller, such that they will be read by whatever is + // consuming the ReadableStream + reader.ondata = function dataReceived(data) { + + if(controller.byobRequest) { + + var view = controller.byobRequest.view; + var length = Math.min(view.byteLength, data.byteLength); + var byobBlock = new Uint8Array(data, 0, length); + + view.buffer.set(byobBlock); + controller.byobRequest.respond(length); + + if(length < data.byteLength) { + controller.enqueue(data.slice(length)); + } + + } else { + controller.enqueue(new Uint8Array(data)); + } + + }; + + // Notify the ReadableStream when the end of the stream is + // reached + reader.onend = function dataComplete() { + controller.close(); + }; + + } + }); + + }; + }; /* @@ -6832,6 +7215,10 @@ Guacamole.Keyboard = function Keyboard(element) { // Determine whether default action for Alt+combinations must be prevented var prevent_alt = !this.modifiers.ctrl && !quirks.altIsTypableOnly; + // If alt is typeable only, and this is actually an alt key event, treat as AltGr instead + if(quirks.altIsTypableOnly && (this.keysym === 0xFFE9 || this.keysym === 0xFFEA)) + this.keysym = 0xFE03; + // Determine whether default action for Ctrl+combinations must be prevented var prevent_ctrl = !this.modifiers.alt; @@ -6932,7 +7319,7 @@ Guacamole.Keyboard = function Keyboard(element) { 13: [0xFF0D], // enter 16: [0xFFE1, 0xFFE1, 0xFFE2], // shift 17: [0xFFE3, 0xFFE3, 0xFFE4], // ctrl - 18: [0xFFE9, 0xFFE9, 0xFE03], // alt + 18: [0xFFE9, 0xFFE9, 0xFFEA], // alt 19: [0xFF13], // pause/break 20: [0xFFE5], // caps lock 27: [0xFF1B], // escape @@ -6993,7 +7380,7 @@ Guacamole.Keyboard = function Keyboard(element) { 'Again': [0xFF66], 'AllCandidates': [0xFF3D], 'Alphanumeric': [0xFF30], - 'Alt': [0xFFE9, 0xFFE9, 0xFE03], + 'Alt': [0xFFE9, 0xFFE9, 0xFFEA], 'Attn': [0xFD0E], 'AltGraph': [0xFE03], 'ArrowDown': [0xFF54], @@ -7004,7 +7391,7 @@ Guacamole.Keyboard = function Keyboard(element) { 'CapsLock': [0xFFE5], 'Cancel': [0xFF69], 'Clear': [0xFF0B], - 'Convert': [0xFF21], + 'Convert': [0xFF23], 'Copy': [0xFD15], 'Crsel': [0xFD1C], 'CrSel': [0xFD1C], @@ -7071,6 +7458,7 @@ Guacamole.Keyboard = function Keyboard(element) { 'Left': [0xFF51], 'Meta': [0xFFE7, 0xFFE7, 0xFFE8], 'ModeChange': [0xFF7E], + 'NonConvert': [0xFF22], 'NumLock': [0xFF7F], 'PageDown': [0xFF56], 'PageUp': [0xFF55], @@ -7080,6 +7468,7 @@ Guacamole.Keyboard = function Keyboard(element) { 'PrintScreen': [0xFF61], 'Redo': [0xFF66], 'Right': [0xFF53], + 'Romaji': [0xFF24], 'RomanCharacters': null, 'Scroll': [0xFF14], 'Select': [0xFF60], @@ -7851,9 +8240,10 @@ Guacamole.Keyboard = function Keyboard(element) { 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) + // Ignore (but do not prevent) the event if explicitly marked as composing, + // or when 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(e.isComposing || keydownEvent.keyCode === 229) return; // Log event @@ -7902,8 +8292,6 @@ Guacamole.Keyboard = function Keyboard(element) { /** * 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 @@ -7918,24 +8306,37 @@ Guacamole.Keyboard = function Keyboard(element) { if(!markEvent(e)) return; // Type all content written - if(e.data && !e.isComposing) { - element.removeEventListener('compositionend', handleComposition, false); + if(e.data && !e.isComposing) guac_keyboard.type(e.data); - } + + }; + + /** + * Handles the given "compositionstart" event, automatically removing + * the "input" event handler, as "input" events should only be handled + * if composition events are not provided by the browser. + * + * @private + * @param {!CompositionEvent} e + * The "compositionstart" event to handle. + */ + var handleCompositionStart = function handleCompositionStart(e) { + + // Remove the "input" event handler now that the browser is known + // to send composition events + element.removeEventListener('input', handleInput, false); }; /** * 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. + * composed text. * * @private * @param {!CompositionEvent} e * The "compositionend" event to handle. */ - var handleComposition = function handleComposition(e) { + var handleCompositionEnd = function handleCompositionEnd(e) { // Only intercept if handler set if(!guac_keyboard.onkeydown && !guac_keyboard.onkeyup) return; @@ -7944,16 +8345,15 @@ Guacamole.Keyboard = function Keyboard(element) { if(!markEvent(e)) return; // Type all content written - if(e.data) { - element.removeEventListener('input', handleInput, false); + if(e.data) guac_keyboard.type(e.data); - } }; // Automatically type text entered into the wrapped field element.addEventListener('input', handleInput, false); - element.addEventListener('compositionend', handleComposition, false); + element.addEventListener('compositionend', handleCompositionEnd, false); + element.addEventListener('compositionstart', handleCompositionStart, false); }; @@ -8067,6 +8467,343 @@ Guacamole.Keyboard.ModifierState.fromKeyboardEvent = function(e) { var Guacamole = Guacamole || {}; +/** + * An object that will accept raw key events and produce a chronologically + * ordered array of key event objects. These events can be obtained by + * calling getEvents(). + * + * @constructor + * @param {number} [startTimestamp=0] + * The starting timestamp for the recording being intepreted. If provided, + * the timestamp of each intepreted event will be relative to this timestamp. + * If not provided, the raw recording timestamp will be used. + */ +Guacamole.KeyEventInterpreter = function KeyEventInterpreter(startTimestamp) { + + // Default to 0 seconds to keep the raw timestamps + if(startTimestamp === undefined || startTimestamp === null) + startTimestamp = 0; + + /** + * A precursor array to the KNOWN_KEYS map. The objects contained within + * will be constructed into full KeyDefinition objects. + * + * @constant + * @private + * @type {Object[]} + */ + var _KNOWN_KEYS = [ + { keysym: 0xFE03, name: 'AltGr' }, + { keysym: 0xFF08, name: 'Backspace' }, + { keysym: 0xFF09, name: 'Tab' }, + { keysym: 0xFF0B, name: 'Clear' }, + { keysym: 0xFF0D, name: 'Return', value: '\n' }, + { keysym: 0xFF13, name: 'Pause' }, + { keysym: 0xFF14, name: 'Scroll' }, + { keysym: 0xFF15, name: 'SysReq' }, + { keysym: 0xFF1B, name: 'Escape' }, + { keysym: 0xFF50, name: 'Home' }, + { keysym: 0xFF51, name: 'Left' }, + { keysym: 0xFF52, name: 'Up' }, + { keysym: 0xFF53, name: 'Right' }, + { keysym: 0xFF54, name: 'Down' }, + { keysym: 0xFF55, name: 'Page Up' }, + { keysym: 0xFF56, name: 'Page Down' }, + { keysym: 0xFF57, name: 'End' }, + { keysym: 0xFF63, name: 'Insert' }, + { keysym: 0xFF65, name: 'Undo' }, + { keysym: 0xFF6A, name: 'Help' }, + { keysym: 0xFF7F, name: 'Num' }, + { keysym: 0xFF80, name: 'Space', value: ' ' }, + { keysym: 0xFF8D, name: 'Enter', value: '\n' }, + { keysym: 0xFF95, name: 'Home' }, + { keysym: 0xFF96, name: 'Left' }, + { keysym: 0xFF97, name: 'Up' }, + { keysym: 0xFF98, name: 'Right' }, + { keysym: 0xFF99, name: 'Down' }, + { keysym: 0xFF9A, name: 'Page Up' }, + { keysym: 0xFF9B, name: 'Page Down' }, + { keysym: 0xFF9C, name: 'End' }, + { keysym: 0xFF9E, name: 'Insert' }, + { keysym: 0xFFAA, name: '*', value: '*' }, + { keysym: 0xFFAB, name: '+', value: '+' }, + { keysym: 0xFFAD, name: '-', value: '-' }, + { keysym: 0xFFAE, name: '.', value: '.' }, + { keysym: 0xFFAF, name: '/', value: '/' }, + { keysym: 0xFFB0, name: '0', value: '0' }, + { keysym: 0xFFB1, name: '1', value: '1' }, + { keysym: 0xFFB2, name: '2', value: '2' }, + { keysym: 0xFFB3, name: '3', value: '3' }, + { keysym: 0xFFB4, name: '4', value: '4' }, + { keysym: 0xFFB5, name: '5', value: '5' }, + { keysym: 0xFFB6, name: '6', value: '6' }, + { keysym: 0xFFB7, name: '7', value: '7' }, + { keysym: 0xFFB8, name: '8', value: '8' }, + { keysym: 0xFFB9, name: '9', value: '9' }, + { keysym: 0xFFBE, name: 'F1' }, + { keysym: 0xFFBF, name: 'F2' }, + { keysym: 0xFFC0, name: 'F3' }, + { keysym: 0xFFC1, name: 'F4' }, + { keysym: 0xFFC2, name: 'F5' }, + { keysym: 0xFFC3, name: 'F6' }, + { keysym: 0xFFC4, name: 'F7' }, + { keysym: 0xFFC5, name: 'F8' }, + { keysym: 0xFFC6, name: 'F9' }, + { keysym: 0xFFC7, name: 'F10' }, + { keysym: 0xFFC8, name: 'F11' }, + { keysym: 0xFFC9, name: 'F12' }, + { keysym: 0xFFCA, name: 'F13' }, + { keysym: 0xFFCB, name: 'F14' }, + { keysym: 0xFFCC, name: 'F15' }, + { keysym: 0xFFCD, name: 'F16' }, + { keysym: 0xFFCE, name: 'F17' }, + { keysym: 0xFFCF, name: 'F18' }, + { keysym: 0xFFD0, name: 'F19' }, + { keysym: 0xFFD1, name: 'F20' }, + { keysym: 0xFFD2, name: 'F21' }, + { keysym: 0xFFD3, name: 'F22' }, + { keysym: 0xFFD4, name: 'F23' }, + { keysym: 0xFFD5, name: 'F24' }, + { keysym: 0xFFE1, name: 'Shift' }, + { keysym: 0xFFE2, name: 'Shift' }, + { keysym: 0xFFE3, name: 'Ctrl' }, + { keysym: 0xFFE4, name: 'Ctrl' }, + { keysym: 0xFFE5, name: 'Caps' }, + { keysym: 0xFFE7, name: 'Meta' }, + { keysym: 0xFFE8, name: 'Meta' }, + { keysym: 0xFFE9, name: 'Alt' }, + { keysym: 0xFFEA, name: 'Alt' }, + { keysym: 0xFFEB, name: 'Super' }, + { keysym: 0xFFEC, name: 'Super' }, + { keysym: 0xFFED, name: 'Hyper' }, + { keysym: 0xFFEE, name: 'Hyper' }, + { keysym: 0xFFFF, name: 'Delete' } + ]; + + /** + * All known keys, as a map of X11 keysym to KeyDefinition. + * + * @constant + * @private + * @type {Object.} + */ + var KNOWN_KEYS = {}; + _KNOWN_KEYS.forEach(function createKeyDefinitionMap(keyDefinition) { + + // Construct a map of keysym to KeyDefinition object + KNOWN_KEYS[keyDefinition.keysym] = ( + new Guacamole.KeyEventInterpreter.KeyDefinition(keyDefinition)); + + }); + + /** + * All key events parsed as of the most recent handleKeyEvent() invocation. + * + * @private + * @type {!Guacamole.KeyEventInterpreter.KeyEvent[]} + */ + var parsedEvents = []; + + /** + * If the provided keysym corresponds to a valid UTF-8 character, return + * a KeyDefinition for that keysym. Otherwise, return null. + * + * @private + * @param {Number} keysym + * The keysym to produce a UTF-8 KeyDefinition for, if valid. + * + * @returns {Guacamole.KeyEventInterpreter.KeyDefinition} + * A KeyDefinition for the provided keysym, if it's a valid UTF-8 + * keysym, or null otherwise. + */ + function getUnicodeKeyDefinition(keysym) { + + // Translate only if keysym maps to Unicode + if(keysym < 0x00 || (keysym > 0xFF && (keysym | 0xFFFF) != 0x0100FFFF)) + return null; + + // Convert to UTF8 string + var codepoint = keysym & 0xFFFF; + var name = String.fromCharCode(codepoint); + + // Create and return the definition + return new Guacamole.KeyEventInterpreter.KeyDefinition({ + keysym: keysym, name: name, value: name + }); + + } + + /** + * Return a KeyDefinition corresponding to the provided keysym. + * + * @private + * @param {Number} keysym + * The keysym to return a KeyDefinition for. + * + * @returns {KeyDefinition} + * A KeyDefinition corresponding to the provided keysym. + */ + function getKeyDefinitionByKeysym(keysym) { + + // If it's a known type, return the existing definition + if(keysym in KNOWN_KEYS) + return KNOWN_KEYS[keysym]; + + // Return a UTF-8 KeyDefinition, if valid + var definition = getUnicodeKeyDefinition(keysym); + if(definition != null) + return definition; + + // If it's not UTF-8, return an unknown definition, with the name + // just set to the hex value of the keysym + return new Guacamole.KeyEventInterpreter.KeyDefinition({ + keysym: keysym, + name: '0x' + String(keysym.toString(16)) + }); + + } + + /** + * Handles a raw key event, appending a new key event object for every + * handled raw event. + * + * @param {!string[]} args + * The arguments of the key event. + */ + this.handleKeyEvent = function handleKeyEvent(args) { + + // The X11 keysym + var keysym = parseInt(args[0]); + + // Either 1 or 0 for pressed or released, respectively + var pressed = parseInt(args[1]); + + // The timestamp when this key event occured + var timestamp = parseInt(args[2]); + + // The timestamp relative to the provided initial timestamp + var relativeTimestap = timestamp - startTimestamp; + + // Known information about the parsed key + var definition = getKeyDefinitionByKeysym(keysym); + + // Push the latest parsed event into the list + parsedEvents.push(new Guacamole.KeyEventInterpreter.KeyEvent({ + definition: definition, + pressed: pressed, + timestamp: relativeTimestap + })); + + }; + + /** + * Return the current batch of typed text. Note that the batch may be + * incomplete, as more key events might be processed before the next + * batch starts. + * + * @returns {Guacamole.KeyEventInterpreter.KeyEvent[]} + * The current batch of text. + */ + this.getEvents = function getEvents() { + return parsedEvents; + }; + +}; + +/** + * A definition for a known key. + * + * @constructor + * @param {Guacamole.KeyEventInterpreter.KeyDefinition|object} [template={}] + * The object whose properties should be copied within the new + * KeyDefinition. + */ +Guacamole.KeyEventInterpreter.KeyDefinition = function KeyDefinition(template) { + + // Use empty object by default + template = template || {}; + + /** + * The X11 keysym of the key. + * @type {!number} + */ + this.keysym = parseInt(template.keysym); + + /** + * A human-readable name for the key. + * @type {!String} + */ + this.name = template.name; + + /** + * The value which would be typed in a typical text editor, if any. If the + * key is not associated with any typeable value, this will be undefined. + * @type {String} + */ + this.value = template.value; + +}; + +/** + * A granular description of an extracted key event, including a human-readable + * text representation of the event, whether the event is directly typed or not, + * and the timestamp when the event occured. + * + * @constructor + * @param {Guacamole.KeyEventInterpreter.KeyEvent|object} [template={}] + * The object whose properties should be copied within the new + * KeyEvent. + */ +Guacamole.KeyEventInterpreter.KeyEvent = function KeyEvent(template) { + + // Use empty object by default + template = template || {}; + + /** + * The key definition for the pressed key. + * + * @type {!Guacamole.KeyEventInterpreter.KeyDefinition} + */ + this.definition = template.definition; + + /** + * True if the key was pressed to create this event, or false if it was + * released. + * + * @type {!boolean} + */ + this.pressed = !!template.pressed; + + /** + * The timestamp from the recording when this event occured. + * + * @type {!Number} + */ + this.timestamp = template.timestamp; + +}; + +/* + * 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, @@ -9461,9 +10198,15 @@ Guacamole.Mouse = function Mouse(element) { } - element.addEventListener('DOMMouseScroll', mousewheel_handler, false); - element.addEventListener('mousewheel', mousewheel_handler, false); - element.addEventListener('wheel', mousewheel_handler, false); + if(window.WheelEvent) { + // All modern browsers support wheel events. + element.addEventListener('wheel', mousewheel_handler, false); + } else { + // Legacy FireFox wheel events. + element.addEventListener('DOMMouseScroll', mousewheel_handler, false); + // Legacy Chrome/IE/other wheel events. + element.addEventListener('mousewheel', mousewheel_handler, false); + } /** * Whether the browser supports CSS3 cursor styling, including hotspot @@ -12377,8 +13120,18 @@ var Guacamole = Guacamole || {}; * @param {!Blob|Guacamole.Tunnel} source * The Blob from which the instructions of the recording should * be read. + * @param {number} [refreshInterval=1000] + * The minimum number of milliseconds between updates to the recording + * position through the provided onseek() callback. If non-positive, this + * parameter will be ignored, and the recording position will only be + * updated when seek requests are made, or when new frames are rendered. + * If not specified, refreshInterval will default to 1000 milliseconds. */ -Guacamole.SessionRecording = function SessionRecording(source) { +Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) { + + // Default the refresh interval to 1 second if not specified otherwise + if(refreshInterval === undefined) + refreshInterval = 1000; /** * Reference to this Guacamole.SessionRecording. @@ -12484,13 +13237,13 @@ Guacamole.SessionRecording = function SessionRecording(source) { var currentFrame = -1; /** - * The timestamp of the frame when playback began, in milliseconds. If + * The position of the recording when playback began, in milliseconds. If * playback is not in progress, this will be null. * * @private * @type {number} */ - var startVideoTimestamp = null; + var startVideoPosition = null; /** * The real-world timestamp when playback began, in milliseconds. If @@ -12501,6 +13254,14 @@ Guacamole.SessionRecording = function SessionRecording(source) { */ var startRealTimestamp = null; + /** + * The current position within the recording, in milliseconds. + * + * @private + * @type {!number} + */ + var currentPosition = 0; + /** * An object containing a single "aborted" property which is set to * true if the in-progress seek operation should be aborted. If no seek @@ -12556,6 +13317,25 @@ Guacamole.SessionRecording = function SessionRecording(source) { */ var seekCallback = null; + /** + * Any current timeout associated with scheduling frame replay, or updating + * the current position, or null if no frame position increment is currently + * scheduled. + * + * @private + * @type {number} + */ + var updateTimeout = null; + + /** + * The browser timestamp of the last time that currentPosition was updated + * while playing, or null if the recording is not currently playing. + * + * @private + * @type {number} + */ + var lastUpdateTimestamp = null; + /** * Parses all Guacamole instructions within the given blob, invoking * the provided instruction callback for each such instruction. Once @@ -12682,6 +13462,28 @@ Guacamole.SessionRecording = function SessionRecording(source) { // Hide cursor unless mouse position is received playbackClient.getDisplay().showCursor(false); + /** + * A key event interpreter to split all key events in this recording into + * human-readable batches of text. Constrcution is deferred until the first + * event is processed, to enable recording-relative timestamps. + * + * @type {!Guacamole.KeyEventInterpreter} + */ + var keyEventInterpreter = null; + + /** + * Initialize the key interpreter. This function should be called only once + * with the first timestamp in the recording as an argument. + * + * @private + * @param {!number} startTimestamp + * The timestamp of the first frame in the recording, i.e. the start of + * the recording. + */ + function initializeKeyInterpreter(startTimestamp) { + keyEventInterpreter = new Guacamole.KeyEventInterpreter(startTimestamp); + } + /** * Handles a newly-received instruction, whether from the main Blob or a * tunnel, adding new frames and keyframes as necessary. Load progress is @@ -12713,6 +13515,11 @@ Guacamole.SessionRecording = function SessionRecording(source) { frames.push(frame); frameStart = frameEnd; + // If this is the first frame, intialize the key event interpreter + // with the timestamp of the first frame + if(frames.length === 1) + initializeKeyInterpreter(timestamp); + // 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 @@ -12720,14 +13527,15 @@ Guacamole.SessionRecording = function SessionRecording(source) { && 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); - } - + } else if(opcode === 'key') + keyEventInterpreter.handleKeyEvent(args); }; /** @@ -12794,6 +13602,11 @@ Guacamole.SessionRecording = function SessionRecording(source) { instructionBuffer = ''; } + // Now that the recording is fully processed, and all key events + // have been extracted, call the onkeyevents handler if defined + if(recording.onkeyevents) + recording.onkeyevents(keyEventInterpreter.getEvents()); + // Consider recording loaded if tunnel has closed without errors if(!errorEncountered) notifyLoaded(); @@ -12826,8 +13639,9 @@ Guacamole.SessionRecording = function SessionRecording(source) { }; /** - * Searches through the given region of frames for the frame having a - * relative timestamp closest to the timestamp given. + * Searches through the given region of frames for the closest frame + * having a relative timestamp less than or equal to the to the given + * relative timestamp. * * @private * @param {!number} minIndex @@ -12848,9 +13662,22 @@ Guacamole.SessionRecording = function SessionRecording(source) { */ var findFrame = function findFrame(minIndex, maxIndex, timestamp) { - // Do not search if the region contains only one element - if(minIndex === maxIndex) - return minIndex; + // The region has only one frame - determine if it is before or after + // the requested timestamp + if(minIndex === maxIndex) { + + // Skip checking if this is the very first frame - no frame could + // possibly be earlier + if(minIndex === 0) + return minIndex; + + // If the closest frame occured after the requested timestamp, + // return the previous frame, which will be the closest with a + // timestamp before the requested timestamp + if(toRelativeTimestamp(frames[minIndex].timestamp) > timestamp) + return minIndex - 1; + + } // Split search region into two halves var midIndex = Math.floor((minIndex + maxIndex) / 2); @@ -12943,10 +13770,11 @@ Guacamole.SessionRecording = function SessionRecording(source) { // Replay any applicable incremental frames var continueReplay = function continueReplay() { - // Notify of changes in position + // Set the current position and notify changes if(recording.onseek && currentFrame > startIndex) { - recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp), - currentFrame - startIndex, index - startIndex); + currentPosition = toRelativeTimestamp(frames[currentFrame].timestamp); + recording.onseek(currentPosition, currentFrame - startIndex, + index - startIndex); } // Cancel seek if aborted @@ -12967,9 +13795,18 @@ Guacamole.SessionRecording = function SessionRecording(source) { // 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 + if(delay) { + + // Clear any already-scheduled update before scheduling again + // to avoid multiple updates in flight at the same time + updateTimeout && clearTimeout(updateTimeout); + + // Schedule with the appropriate delay + updateTimeout = window.setTimeout(function timeoutComplete() { + updateTimeout = null; + continueReplay(); + }, delay); + } else continueReplay(); }; @@ -13022,20 +13859,73 @@ Guacamole.SessionRecording = function SessionRecording(source) { */ var continuePlayback = function continuePlayback() { + // Do not continue playback if the recording is paused + if(!recording.isPlaying()) + return; + // 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; + // The number of elapsed milliseconds on the clock since playback began + var realLifePlayTime = Date.now() - startRealTimestamp; - // Advance to next frame after enough time has elapsed - seekToFrame(currentFrame + 1, function frameDelayElapsed() { - continuePlayback(); - }, nextRealTimestamp); + // The number of milliseconds between the recording position when + // playback started and the position of the next frame + var timestampOffset = ( + toRelativeTimestamp(next.timestamp) - startVideoPosition); + + // The delay until the next frame should be rendered, taking into + // account any accumulated delays from rendering frames so far + var nextFrameDelay = timestampOffset - realLifePlayTime; + + // The delay until the refresh interval would induce an update to + // the current recording position, rounded to the nearest whole + // multiple of refreshInterval to ensure consistent timing for + // refresh intervals even with inconsistent frame timing + var nextRefreshDelay = refreshInterval >= 0 + ? (refreshInterval * (Math.floor( + (currentPosition + refreshInterval) / refreshInterval)) + ) - currentPosition + : nextFrameDelay; + + // If the next frame will occur before the next refresh interval, + // advance to the frame after the appropriate delay + if(nextFrameDelay <= nextRefreshDelay) + + seekToFrame(currentFrame + 1, function frameDelayElapsed() { + + // Record when the timestamp was updated and continue on + lastUpdateTimestamp = Date.now(); + continuePlayback(); + + }, Date.now() + nextFrameDelay); + + // The position needs to be incremented before the next frame + else { + + // Clear any existing update timeout + updateTimeout && window.clearTimeout(updateTimeout); + + updateTimeout = window.setTimeout(function incrementPosition() { + + updateTimeout = null; + + // Update the position + currentPosition += nextRefreshDelay; + + // Notifiy the new position using the onseek handler + if(recording.onseek) + recording.onseek(currentPosition); + + // Record when the timestamp was updated and continue on + lastUpdateTimestamp = Date.now(); + continuePlayback(); + + }, nextRefreshDelay); + } } @@ -13101,6 +13991,17 @@ Guacamole.SessionRecording = function SessionRecording(source) { */ this.onpause = null; + /** + * Fired with all extracted key events when the recording is fully + * processed. The callback will be invoked with an empty list + * if no key events were extracted. + * + * @event + * @param {!Guacamole.KeyEventInterpreter.KeyEvent[]} batch + * The extracted key events. + */ + this.onkeyevents = null; + /** * Fired whenever the playback position within the recording changes. * @@ -13183,7 +14084,7 @@ Guacamole.SessionRecording = function SessionRecording(source) { * true if playback is currently in progress, false otherwise. */ this.isPlaying = function isPlaying() { - return !!startVideoTimestamp; + return !!startRealTimestamp; }; /** @@ -13195,13 +14096,7 @@ Guacamole.SessionRecording = function SessionRecording(source) { */ 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); + return currentPosition; }; @@ -13245,11 +14140,11 @@ Guacamole.SessionRecording = function SessionRecording(source) { // Store timestamp of playback start for relative scheduling of // future frames - var next = frames[currentFrame + 1]; - startVideoTimestamp = next.timestamp; - startRealTimestamp = new Date().getTime(); + startVideoPosition = currentPosition; + startRealTimestamp = Date.now(); // Begin playback of video + lastUpdateTimestamp = Date.now(); continuePlayback(); } @@ -13301,8 +14196,23 @@ Guacamole.SessionRecording = function SessionRecording(source) { }; - // Perform seek - seekToFrame(findFrame(0, frames.length - 1, position), seekCallback); + // Find the index of the closest frame at or before the requested position + var closestFrame = findFrame(0, frames.length - 1, position); + + // Seek to the closest frame before or at the requested position + seekToFrame(closestFrame, function seekComplete() { + + // Update the current position to the requested position + // and invoke the the onseek callback. Note that this is the + // position provided to this function, NOT the position of the + // frame that was just seeked + currentPosition = position; + if(recording.onseek) + recording.onseek(position); + + seekCallback(); + + }); }; @@ -13332,6 +14242,13 @@ Guacamole.SessionRecording = function SessionRecording(source) { // Abort any in-progress seek / playback abortSeek(); + // Cancel any currently-scheduled updates + updateTimeout && clearTimeout(updateTimeout); + + // Increment the current position by the amount of time passed since the + // the last time it was updated + currentPosition += Date.now() - lastUpdateTimestamp; + // Stop playback only if playback is in progress if(recording.isPlaying()) { @@ -13340,7 +14257,8 @@ Guacamole.SessionRecording = function SessionRecording(source) { recording.onpause(); // Playback is stopped - startVideoTimestamp = null; + lastUpdateTimestamp = null; + startVideoPosition = null; startRealTimestamp = null; } @@ -13469,6 +14387,7 @@ Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() { }; }; + /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -15907,7 +16826,7 @@ var Guacamole = Guacamole || {}; * * @type {!string} */ -Guacamole.API_VERSION = '1.5.4'; +Guacamole.API_VERSION = '1.6.0'; /* * Licensed to the Apache Software Foundation (ASF) under one