From a97404b6adb71439c23f52fc1880d89f15ae00b7 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Fri, 11 Feb 2022 11:29:58 -0800 Subject: [PATCH] GUACAMOLE-896: Update session recording player to support efficient reading from Blobs. --- .../main/webapp/modules/SessionRecording.js | 685 +++++++++++++----- 1 file changed, 492 insertions(+), 193 deletions(-) diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js index c62461876..cc8f9e6fc 100644 --- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -20,18 +20,20 @@ var Guacamole = Guacamole || {}; /** - * A recording of a Guacamole session. Given a {@link Guacamole.Tunnel}, the - * Guacamole.SessionRecording automatically handles incoming Guacamole - * instructions, storing them for playback. 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. + * 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 {!Guacamole.Tunnel} tunnel - * The Guacamole.Tunnel from which the instructions of the recording should + * @param {!Blob|Guacamole.Tunnel} source + * The Blob from which the instructions of the recording should * be read. */ -Guacamole.SessionRecording = function SessionRecording(tunnel) { +Guacamole.SessionRecording = function SessionRecording(source) { /** * Reference to this Guacamole.SessionRecording. @@ -41,13 +43,45 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { */ 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} + * @type {Number} */ var KEYFRAME_CHAR_INTERVAL = 16384; @@ -56,47 +90,18 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * * @private * @constant - * @type {!number} + * @type {Number} */ var KEYFRAME_TIME_INTERVAL = 5000; /** - * The maximum amount of time to spend in any particular seek operation - * before returning control to the main thread, in milliseconds. Seek - * operations exceeding this amount of time will proceed asynchronously. - * - * @private - * @constant - * @type {!number} - */ - var MAXIMUM_SEEK_TIME = 5; - - /** - * All frames parsed from the provided tunnel. + * All frames parsed from the provided blob. * * @private * @type {!Guacamole.SessionRecording._Frame[]} */ var frames = []; - /** - * All instructions which have been read since the last frame was added to - * the frames array. - * - * @private - * @type {!Guacamole.SessionRecording._Frame.Instruction[]} - */ - var instructions = []; - - /** - * The approximate number of characters which have been read from the - * provided tunnel since the last frame was flagged for use as a keyframe. - * - * @private - * @type {!number} - */ - var charactersSinceLastKeyframe = 0; - /** * 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. @@ -104,7 +109,7 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * @private * @type {!number} */ - var lastKeyframeTimestamp = 0; + var lastKeyframe = 0; /** * Tunnel which feeds arbitrary instructions to the client used by this @@ -152,14 +157,180 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { var startRealTimestamp = null; /** - * The ID of the timeout which will continue the in-progress seek - * operation. If no seek operation is in progress, the ID stored here (if - * any) will not be valid. + * 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 {number} + * @type {object} */ - var seekTimeout = null; + 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(); @@ -167,13 +338,24 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { // Hide cursor unless mouse position is received playbackClient.getDisplay().showCursor(false); - // Read instructions from provided tunnel, extracting each frame - tunnel.oninstruction = function handleInstruction(opcode, args) { + /** + * 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) { - // Store opcode and arguments for received instruction - var instruction = new Guacamole.SessionRecording._Frame.Instruction(opcode, args.slice()); - instructions.push(instruction); - charactersSinceLastKeyframe += instruction.getSize(); + // 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 @@ -183,30 +365,97 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { var timestamp = parseInt(args[0]); // Add a new frame containing the instructions read since last frame - var frame = new Guacamole.SessionRecording._Frame(timestamp, instructions); + 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 || (charactersSinceLastKeyframe >= KEYFRAME_CHAR_INTERVAL - && timestamp - lastKeyframeTimestamp >= KEYFRAME_TIME_INTERVAL)) { + if (frames.length === 1 || (frameEnd - frames[lastKeyframe].start >= KEYFRAME_CHAR_INTERVAL + && timestamp - frames[lastKeyframe].timestamp >= KEYFRAME_TIME_INTERVAL)) { frame.keyframe = true; - lastKeyframeTimestamp = timestamp; - charactersSinceLastKeyframe = 0; + lastKeyframe = frames.length - 1; } - // Clear set of instructions in preparation for next frame - instructions = []; - // Notify that additional content is available if (recording.onprogress) - recording.onprogress(recording.getDuration()); + recording.onprogress(recording.getDuration(), frameEnd); } }; + /** + * Notifies that the session recording has been fully loaded. If the onload + * handler has not been defined, this function has no effect. + * + * @private + */ + var notifyLoaded = function notifyLoaded() { + if (recording.onload) + recording.onload(); + }; + + // Read instructions from provided blob, extracting each frame + if (source instanceof Blob) + parseBlob(recordingBlob, loadInstruction, notifyLoaded); + + // If tunnel provided instead of Blob, extract frames, etc. as instructions + // are received, buffering things into a Blob for future seeks + else { + + tunnel = source; + recordingBlob = new Blob(); + + var errorEncountered = false; + var instructionBuffer = ''; + + // Read instructions from provided tunnel, extracting each frame + tunnel.oninstruction = function handleInstruction(opcode, args) { + + // Reconstitute received instruction + instructionBuffer += opcode.length + '.' + opcode; + args.forEach(function appendArg(arg) { + instructionBuffer += ',' + arg.length + '.' + arg; + }); + instructionBuffer += ';'; + + // Append to Blob (creating a new Blob in the process) + if (instructionBuffer.length >= BLOCK_SIZE) { + recordingBlob = new Blob([recordingBlob, instructionBuffer]); + instructionBuffer = ''; + } + + // Load parsed instruction into recording + loadInstruction(opcode, args); + + }; + + // Report any errors encountered + tunnel.onerror = function tunnelError(status) { + errorEncountered = true; + if (recording.onerror) + recording.onerror(status.message); + }; + + tunnel.onstatechange = function tunnelStateChanged(state) { + if (state === Guacamole.Tunnel.State.CLOSED) { + + // Append to Blob (creating a new Blob in the process) + if (instructionBuffer.length >= BLOCK_SIZE) { + 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. @@ -283,23 +532,33 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * @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) { + var replayFrame = function replayFrame(index, callback) { var frame = frames[index]; // Replay all instructions within the retrieved frame - for (var i = 0; i < frame.instructions.length; i++) { - var instruction = frame.instructions[i]; - playbackTunnel.receiveInstruction(instruction.opcode, instruction.args); - } + 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 = state; - }); - } + // Store client state if frame is flagged as a keyframe + if (frame.keyframe && !frame.clientState) { + playbackClient.exportState(function storeClientState(state) { + frame.clientState = state; + }); + } + + // Update state to correctly represent the current frame + currentFrame = index; + + if (callback) + callback(); + + }); }; @@ -315,7 +574,7 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * The index of the frame which should become the new playback * position. * - * @param {!function} callback + * @param {function} callback * The callback to invoke once the seek operation has completed. * * @param {number} [delay=0] @@ -327,63 +586,62 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { // Abort any in-progress seek abortSeek(); - // Replay frames asynchronously - seekTimeout = window.setTimeout(function continueSeek() { + // Note that a new seek operation is in progress + var thisSeek = activeSeek = { + aborted : false + }; - var startIndex; + var startIndex; - // Back up until startIndex represents current state - for (startIndex = index; startIndex >= 0; startIndex--) { + // Back up until startIndex represents current state + for (startIndex = index; startIndex >= 0; startIndex--) { - var frame = frames[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) { - playbackClient.importState(frame.clientState); - break; - } + // 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) { + playbackClient.importState(frame.clientState); + currentFrame = index; + break; } - // Advance to frame index after current state - startIndex++; + } - var startTime = new Date().getTime(); - - // Replay any applicable incremental frames - for (; startIndex <= index; startIndex++) { - - // Stop seeking if the operation is taking too long - var currentTime = new Date().getTime(); - if (currentTime - startTime >= MAXIMUM_SEEK_TIME) - break; - - replayFrame(startIndex); - } - - // Current frame is now at requested index - currentFrame = startIndex - 1; + // Replay any applicable incremental frames + var continueReplay = function continueReplay() { // Notify of changes in position - if (recording.onseek) - recording.onseek(recording.getPosition()); + if (recording.onseek && currentFrame > startIndex) { + recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp), + currentFrame - startIndex, index - startIndex); + } - // If the seek operation has not yet completed, schedule continuation - if (currentFrame !== index) - seekToFrame(index, callback, - Math.max(delay - (new Date().getTime() - startTime), 0)); + // Cancel seek if aborted + if (thisSeek.aborted) + return; - // Notify that the requested seek has completed + // If frames remain, replay the next frame + if (!thisSeek.aborted && currentFrame < index) + replayFrame(currentFrame + 1, continueReplay); + + // Otherwise, the seek operation is completed else callback(); - }, delay || 0); + }; + + // Continue replay after requested delay has elapsed, or + // immediately if no delay was requested + if (delay) + window.setTimeout(continueReplay, delay); + else + continueReplay(); }; @@ -394,7 +652,10 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * @private */ var abortSeek = function abortSeek() { - window.clearTimeout(seekTimeout); + if (activeSeek) { + activeSeek.aborted = true; + activeSeek = null; + } }; /** @@ -433,6 +694,32 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { }; + /** + * 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. @@ -440,6 +727,9 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * @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; @@ -466,27 +756,59 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * @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. + * 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) { - tunnel.connect(data); + if (tunnel) + tunnel.connect(data); }; /** * Disconnects the underlying tunnel, stopping further download of the - * Guacamole session. + * Guacamole session. If the underlying recording source is a Blob, this + * function has no effect. */ this.disconnect = function disconnect() { - tunnel.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(); + + } }; /** @@ -603,23 +925,48 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { if (frames.length === 0) return; + // Abort active seek operation, if any + recording.cancel(); + // Pause playback, preserving playback state var originallyPlaying = recording.isPlaying(); recording.pause(); - // Perform seek - seekToFrame(findFrame(0, frames.length - 1, position), function restorePlaybackState() { + // Restore playback when seek is completed or cancelled + seekCallback = function restorePlaybackState() { + + // Seek is no longer in progress + seekCallback = null; // Restore playback state - if (originallyPlaying) + 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(); + } }; /** @@ -664,11 +1011,15 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) { * The timestamp of this frame, as dictated by the "sync" instruction which * terminates the frame. * - * @param {!Guacamole.SessionRecording._Frame.Instruction[]} instructions - * All instructions which are necessary to generate this frame relative to - * the previous frame in the Guacamole session. + * @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, instructions) { +Guacamole.SessionRecording._Frame = function _Frame(timestamp, start, end) { /** * Whether this frame should be used as a keyframe if possible. This value @@ -690,12 +1041,20 @@ Guacamole.SessionRecording._Frame = function _Frame(timestamp, instructions) { this.timestamp = timestamp; /** - * All instructions which are necessary to generate this frame relative to - * the previous frame in the Guacamole session. + * The byte offset within the blob of the first character of the first + * instruction of this frame. * - * @type {!Guacamole.SessionRecording._Frame.Instruction[]} + * @type {!number} */ - this.instructions = instructions; + 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 @@ -709,66 +1068,6 @@ Guacamole.SessionRecording._Frame = function _Frame(timestamp, instructions) { }; -/** - * A Guacamole protocol instruction. Each Guacamole protocol instruction is - * made up of an opcode and set of arguments. - * - * @private - * @constructor - * @param {!string} opcode - * The opcode of this Guacamole instruction. - * - * @param {!string[]} args - * All arguments associated with this Guacamole instruction. - */ -Guacamole.SessionRecording._Frame.Instruction = function Instruction(opcode, args) { - - /** - * Reference to this Guacamole.SessionRecording._Frame.Instruction. - * - * @private - * @type {!Guacamole.SessionRecording._Frame.Instruction} - */ - var instruction = this; - - /** - * The opcode of this Guacamole instruction. - * - * @type {!string} - */ - this.opcode = opcode; - - /** - * All arguments associated with this Guacamole instruction. - * - * @type {!string[]} - */ - this.args = args; - - /** - * Returns the approximate number of characters which make up this - * instruction. This value is only approximate as it excludes the length - * prefixes and various delimiters used by the Guacamole protocol; only - * the content of the opcode and each argument is taken into account. - * - * @returns {!number} - * The approximate size of this instruction, in characters. - */ - this.getSize = function getSize() { - - // Init with length of opcode - var size = instruction.opcode.length; - - // Add length of all arguments - for (var i = 0; i < instruction.args.length; i++) - size += instruction.args[i].length; - - return size; - - }; - -}; - /** * A read-only Guacamole.Tunnel implementation which streams instructions * received through explicit calls to its receiveInstruction() function. @@ -816,4 +1115,4 @@ Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() { tunnel.oninstruction(opcode, args); }; -}; +}; \ No newline at end of file