/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ var Guacamole = Guacamole || {}; /** * A recording of a Guacamole session. Given a {@link Guacamole.Tunnel} or Blob, * the Guacamole.SessionRecording automatically parses Guacamole instructions * within the recording source as it plays back the recording. Playback of the * recording may be controlled through function calls to the * Guacamole.SessionRecording, even while the recording has not yet finished * being created or downloaded. Parsing of the contents of the recording will * begin immediately and automatically after this constructor is invoked. * * @constructor * @param {!Blob|Guacamole.Tunnel} source * The Blob from which the instructions of the recording should * be read. */ Guacamole.SessionRecording = function SessionRecording(source) { /** * Reference to this Guacamole.SessionRecording. * * @private * @type {!Guacamole.SessionRecording} */ var recording = this; /** * The Blob from which the instructions of the recording should be read. * Note that this value is initialized far below. * * @private * @type {!Blob} */ var recordingBlob; /** * The tunnel from which the recording should be read, if the recording is * being read from a tunnel. If the recording was supplied as a Blob, this * will be null. * * @private * @type {Guacamole.Tunnel} */ var tunnel = null; /** * The number of bytes that this Guacamole.SessionRecording should attempt * to read from the given blob in each read operation. Larger blocks will * generally read the blob more quickly, but may result in excessive * time being spent within the parser, making the page unresponsive * while the recording is loading. * * @private * @constant * @type {Number} */ var BLOCK_SIZE = 262144; /** * The minimum number of characters which must have been read between * keyframes. * * @private * @constant * @type {Number} */ var KEYFRAME_CHAR_INTERVAL = 16384; /** * The minimum number of milliseconds which must elapse between keyframes. * * @private * @constant * @type {Number} */ var KEYFRAME_TIME_INTERVAL = 5000; /** * All frames parsed from the provided blob. * * @private * @type {!Guacamole.SessionRecording._Frame[]} */ var frames = []; /** * The timestamp of the last frame which was flagged for use as a keyframe. * If no timestamp has yet been flagged, this will be 0. * * @private * @type {!number} */ var lastKeyframe = 0; /** * Tunnel which feeds arbitrary instructions to the client used by this * Guacamole.SessionRecording for playback of the session recording. * * @private * @type {!Guacamole.SessionRecording._PlaybackTunnel} */ var playbackTunnel = new Guacamole.SessionRecording._PlaybackTunnel(); /** * Guacamole.Client instance used for visible playback of the session * recording. * * @private * @type {!Guacamole.Client} */ var playbackClient = new Guacamole.Client(playbackTunnel); /** * The current frame rendered within the playback client. If no frame is * yet rendered, this will be -1. * * @private * @type {!number} */ var currentFrame = -1; /** * The timestamp of the frame when playback began, in milliseconds. If * playback is not in progress, this will be null. * * @private * @type {number} */ var startVideoTimestamp = null; /** * The real-world timestamp when playback began, in milliseconds. If * playback is not in progress, this will be null. * * @private * @type {number} */ var startRealTimestamp = null; /** * An object containing a single "aborted" property which is set to * true if the in-progress seek operation should be aborted. If no seek * operation is in progress, this will be null. * * @private * @type {object} */ var activeSeek = null; /** * The byte offset within the recording blob of the first character of * the first instruction of the current frame. Here, "current frame" * refers to the frame currently being parsed when the provided * recording is initially loading. If the recording is not being * loaded, this value has no meaning. * * @private * @type {!number} */ var frameStart = 0; /** * The byte offset within the recording blob of the character which * follows the last character of the most recently parsed instruction * of the current frame. Here, "current frame" refers to the frame * currently being parsed when the provided recording is initially * loading. If the recording is not being loaded, this value has no * meaning. * * @private * @type {!number} */ var frameEnd = 0; /** * Whether the initial loading process has been aborted. If the loading * process has been aborted, no further blocks of data should be read * from the recording. * * @private * @type {!boolean} */ var aborted = false; /** * The function to invoke when the seek operation initiated by a call * to seek() is cancelled or successfully completed. If no seek * operation is in progress, this will be null. * * @private * @type {function} */ var seekCallback = null; /** * Parses all Guacamole instructions within the given blob, invoking * the provided instruction callback for each such instruction. Once * the end of the blob has been reached (no instructions remain to be * parsed), the provided completion callback is invoked. If a parse * error prevents reading instructions from the blob, the onerror * callback of the Guacamole.SessionRecording is invoked, and no further * data is handled within the blob. * * @private * @param {!Blob} blob * The blob to parse Guacamole instructions from. * * @param {function} [instructionCallback] * The callback to invoke for each Guacamole instruction read from * the given blob. This function must accept the same arguments * as the oninstruction handler of Guacamole.Parser. * * @param {function} [completionCallback] * The callback to invoke once all instructions have been read from * the given blob. */ var parseBlob = function parseBlob(blob, instructionCallback, completionCallback) { // Do not read any further blocks if loading has been aborted if (aborted && blob === recordingBlob) return; // Prepare a parser to handle all instruction data within the blob, // automatically invoking the provided instruction callback for all // parsed instructions var parser = new Guacamole.Parser(); parser.oninstruction = instructionCallback; var offset = 0; var reader = new FileReader(); /** * Reads the block of data at offset bytes within the blob. If no * such block exists, then the completion callback provided to * parseBlob() is invoked as all data has been read. * * @private */ var readNextBlock = function readNextBlock() { // Do not read any further blocks if loading has been aborted if (aborted && blob === recordingBlob) return; // Parse all instructions within the block, invoking the // onerror handler if a parse error occurs if (reader.readyState === 2 /* DONE */) { try { parser.receive(reader.result); } catch (parseError) { if (recording.onerror) { recording.onerror(parseError.message); } return; } } // If no data remains, the read operation is complete and no // further blocks need to be read if (offset >= blob.size) { if (completionCallback) completionCallback(); } // Otherwise, read the next block else { var block = blob.slice(offset, offset + BLOCK_SIZE); offset += block.size; reader.readAsText(block); } }; // Read blocks until the end of the given blob is reached reader.onload = readNextBlock; readNextBlock(); }; /** * Calculates the size of the given Guacamole instruction element, in * Unicode characters. The size returned includes the characters which * make up the length, the "." separator between the length and the * element itself, and the "," or ";" terminator which follows the * element. * * @private * @param {!string} value * The value of the element which has already been parsed (lacks * the initial length, "." separator, and "," or ";" terminator). * * @returns {!number} * The number of Unicode characters which would make up the given * element within a Guacamole instruction. */ var getElementSize = function getElementSize(value) { var valueLength = value.length; // Calculate base size, assuming at least one digit, the "." // separator, and the "," or ";" terminator var protocolSize = valueLength + 3; // Add one character for each additional digit that would occur // in the element length prefix while (valueLength >= 10) { protocolSize++; valueLength = Math.floor(valueLength / 10); } return protocolSize; }; // Start playback client connected playbackClient.connect(); // Hide cursor unless mouse position is received playbackClient.getDisplay().showCursor(false); /** * Handles a newly-received instruction, whether from the main Blob or a * tunnel, adding new frames and keyframes as necessary. Load progress is * reported via onprogress automatically. * * @private * @param {!string} opcode * The opcode of the instruction to handle. * * @param {!string[]} args * The arguments of the received instruction, if any. */ var loadInstruction = function loadInstruction(opcode, args) { // Advance end of frame by overall length of parsed instruction frameEnd += getElementSize(opcode); for (var i = 0; i < args.length; i++) frameEnd += getElementSize(args[i]); // Once a sync is received, store all instructions since the last // frame as a new frame if (opcode === 'sync') { // Parse frame timestamp from sync instruction var timestamp = parseInt(args[0]); // Add a new frame containing the instructions read since last frame var frame = new Guacamole.SessionRecording._Frame(timestamp, frameStart, frameEnd); frames.push(frame); frameStart = frameEnd; // This frame should eventually become a keyframe if enough data // has been processed and enough recording time has elapsed, or if // this is the absolute first frame if (frames.length === 1 || (frameEnd - frames[lastKeyframe].start >= KEYFRAME_CHAR_INTERVAL && timestamp - frames[lastKeyframe].timestamp >= KEYFRAME_TIME_INTERVAL)) { frame.keyframe = true; lastKeyframe = frames.length - 1; } // Notify that additional content is available if (recording.onprogress) recording.onprogress(recording.getDuration(), frameEnd); } }; /** * Notifies that the session recording has been fully loaded. If the onload * handler has not been defined, this function has no effect. * * @private */ var notifyLoaded = function notifyLoaded() { if (recording.onload) recording.onload(); }; // Read instructions from provided blob, extracting each frame if (source instanceof Blob) parseBlob(recordingBlob, loadInstruction, notifyLoaded); // If tunnel provided instead of Blob, extract frames, etc. as instructions // are received, buffering things into a Blob for future seeks else { tunnel = source; recordingBlob = new Blob(); var errorEncountered = false; var instructionBuffer = ''; // Read instructions from provided tunnel, extracting each frame tunnel.oninstruction = function handleInstruction(opcode, args) { // Reconstitute received instruction instructionBuffer += opcode.length + '.' + opcode; args.forEach(function appendArg(arg) { instructionBuffer += ',' + arg.length + '.' + arg; }); instructionBuffer += ';'; // Append to Blob (creating a new Blob in the process) if (instructionBuffer.length >= BLOCK_SIZE) { recordingBlob = new Blob([recordingBlob, instructionBuffer]); instructionBuffer = ''; } // Load parsed instruction into recording loadInstruction(opcode, args); }; // Report any errors encountered tunnel.onerror = function tunnelError(status) { errorEncountered = true; if (recording.onerror) recording.onerror(status.message); }; tunnel.onstatechange = function tunnelStateChanged(state) { if (state === Guacamole.Tunnel.State.CLOSED) { // Append any remaining instructions if (instructionBuffer.length) { recordingBlob = new Blob([recordingBlob, instructionBuffer]); instructionBuffer = ''; } // Consider recording loaded if tunnel has closed without errors if (!errorEncountered) notifyLoaded(); } }; } /** * Converts the given absolute timestamp to a timestamp which is relative * to the first frame in the recording. * * @private * @param {!number} timestamp * The timestamp to convert to a relative timestamp. * * @returns {!number} * The difference in milliseconds between the given timestamp and the * first frame of the recording, or zero if no frames yet exist. */ var toRelativeTimestamp = function toRelativeTimestamp(timestamp) { // If no frames yet exist, all timestamps are zero if (frames.length === 0) return 0; // Calculate timestamp relative to first frame return timestamp - frames[0].timestamp; }; /** * Searches through the given region of frames for the frame having a * relative timestamp closest to the timestamp given. * * @private * @param {!number} minIndex * The index of the first frame in the region (the frame having the * smallest timestamp). * * @param {!number} maxIndex * The index of the last frame in the region (the frame having the * largest timestamp). * * @param {!number} timestamp * The relative timestamp to search for, where zero denotes the first * frame in the recording. * * @returns {!number} * The index of the frame having a relative timestamp closest to the * given value. */ var findFrame = function findFrame(minIndex, maxIndex, timestamp) { // Do not search if the region contains only one element if (minIndex === maxIndex) return minIndex; // Split search region into two halves var midIndex = Math.floor((minIndex + maxIndex) / 2); var midTimestamp = toRelativeTimestamp(frames[midIndex].timestamp); // If timestamp is within lesser half, search again within that half if (timestamp < midTimestamp && midIndex > minIndex) return findFrame(minIndex, midIndex - 1, timestamp); // If timestamp is within greater half, search again within that half if (timestamp > midTimestamp && midIndex < maxIndex) return findFrame(midIndex + 1, maxIndex, timestamp); // Otherwise, we lucked out and found a frame with exactly the // desired timestamp return midIndex; }; /** * Replays the instructions associated with the given frame, sending those * instructions to the playback client. * * @private * @param {!number} index * The index of the frame within the frames array which should be * replayed. * * @param {function} callback * The callback to invoke once replay of the frame has completed. */ var replayFrame = function replayFrame(index, callback) { var frame = frames[index]; // Replay all instructions within the retrieved frame parseBlob(recordingBlob.slice(frame.start, frame.end), function handleInstruction(opcode, args) { playbackTunnel.receiveInstruction(opcode, args); }, function replayCompleted() { // Store client state if frame is flagged as a keyframe if (frame.keyframe && !frame.clientState) { playbackClient.exportState(function storeClientState(state) { frame.clientState = new Blob([JSON.stringify(state)]); }); } // Update state to correctly represent the current frame currentFrame = index; if (callback) callback(); }); }; /** * Moves the playback position to the given frame, resetting the state of * the playback client and replaying frames as necessary. The seek * operation will proceed asynchronously. If a seek operation is already in * progress, that seek is first aborted. The progress of the seek operation * can be observed through the onseek handler and the provided callback. * * @private * @param {!number} index * The index of the frame which should become the new playback * position. * * @param {function} callback * The callback to invoke once the seek operation has completed. * * @param {number} [delay=0] * The number of milliseconds that the seek operation should be * scheduled to take. */ var seekToFrame = function seekToFrame(index, callback, delay) { // Abort any in-progress seek abortSeek(); // Note that a new seek operation is in progress var thisSeek = activeSeek = { aborted : false }; var startIndex; // Back up until startIndex represents current state for (startIndex = index; startIndex >= 0; startIndex--) { var frame = frames[startIndex]; // If we've reached the current frame, startIndex represents // current state by definition if (startIndex === currentFrame) break; // If frame has associated absolute state, make that frame the // current state if (frame.clientState) { frame.clientState.text().then(function textReady(text) { // Cancel seek if aborted if (thisSeek.aborted) return; playbackClient.importState(JSON.parse(text)); currentFrame = startIndex; }); break; } } // Replay any applicable incremental frames var continueReplay = function continueReplay() { // Notify of changes in position if (recording.onseek && currentFrame > startIndex) { recording.onseek(toRelativeTimestamp(frames[currentFrame].timestamp), currentFrame - startIndex, index - startIndex); } // Cancel seek if aborted if (thisSeek.aborted) return; // If frames remain, replay the next frame if (currentFrame < index) replayFrame(currentFrame + 1, continueReplay); // Otherwise, the seek operation is completed else callback(); }; // Continue replay after requested delay has elapsed, or // immediately if no delay was requested if (delay) window.setTimeout(continueReplay, delay); else continueReplay(); }; /** * Aborts the seek operation currently in progress, if any. If no seek * operation is in progress, this function has no effect. * * @private */ var abortSeek = function abortSeek() { if (activeSeek) { activeSeek.aborted = true; activeSeek = null; } }; /** * Advances playback to the next frame in the frames array and schedules * playback of the frame following that frame based on their associated * timestamps. If no frames exist after the next frame, playback is paused. * * @private */ var continuePlayback = function continuePlayback() { // If frames remain after advancing, schedule next frame if (currentFrame + 1 < frames.length) { // Pull the upcoming frame var next = frames[currentFrame + 1]; // Calculate the real timestamp corresponding to when the next // frame begins var nextRealTimestamp = next.timestamp - startVideoTimestamp + startRealTimestamp; // Calculate the relative delay between the current time and // the next frame start var delay = Math.max(nextRealTimestamp - new Date().getTime(), 0); // Advance to next frame after enough time has elapsed seekToFrame(currentFrame + 1, function frameDelayElapsed() { continuePlayback(); }, delay); } // Otherwise stop playback else recording.pause(); }; /** * Fired when loading of this recording has completed and all frames * are available. * * @event */ this.onload = null; /** * Fired when an error occurs which prevents the recording from being * played back. * * @event * @param {!string} message * A human-readable message describing the error that occurred. */ this.onerror = null; /** * Fired when further loading of this recording has been explicitly * aborted through a call to abort(). * * @event */ this.onabort = null; /** * Fired when new frames have become available while the recording is * being downloaded. * * @event * @param {!number} duration * The new duration of the recording, in milliseconds. * * @param {!number} parsedSize * The number of bytes that have been loaded/parsed. */ this.onprogress = null; /** * Fired whenever playback of the recording has started. * * @event */ this.onplay = null; /** * Fired whenever playback of the recording has been paused. This may * happen when playback is explicitly paused with a call to pause(), or * when playback is implicitly paused due to reaching the end of the * recording. * * @event */ this.onpause = null; /** * Fired whenever the playback position within the recording changes. * * @event * @param {!number} position * The new position within the recording, in milliseconds. * * @param {!number} current * The number of frames that have been seeked through. If not * seeking through multiple frames due to a call to seek(), this * will be 1. * * @param {!number} total * The number of frames that are being seeked through in the * current seek operation. If not seeking through multiple frames * due to a call to seek(), this will be 1. */ this.onseek = null; /** * Connects the underlying tunnel, beginning download of the Guacamole * session. Playback of the Guacamole session cannot occur until at least * one frame worth of instructions has been downloaded. If the underlying * recording source is a Blob, this function has no effect. * * @param {string} [data] * The data to send to the tunnel when connecting. */ this.connect = function connect(data) { if (tunnel) tunnel.connect(data); }; /** * Disconnects the underlying tunnel, stopping further download of the * Guacamole session. If the underlying recording source is a Blob, this * function has no effect. */ this.disconnect = function disconnect() { if (tunnel) tunnel.disconnect(); }; /** * Aborts the loading process, stopping further processing of the * provided data. If the underlying recording source is a Guacamole tunnel, * it will be disconnected. */ this.abort = function abort() { if (!aborted) { aborted = true; if (recording.onabort) recording.onabort(); if (tunnel) tunnel.disconnect(); } }; /** * Returns the underlying display of the Guacamole.Client used by this * Guacamole.SessionRecording for playback. The display contains an Element * which can be added to the DOM, causing the display (and thus playback of * the recording) to become visible. * * @return {!Guacamole.Display} * The underlying display of the Guacamole.Client used by this * Guacamole.SessionRecording for playback. */ this.getDisplay = function getDisplay() { return playbackClient.getDisplay(); }; /** * Returns whether playback is currently in progress. * * @returns {!boolean} * true if playback is currently in progress, false otherwise. */ this.isPlaying = function isPlaying() { return !!startVideoTimestamp; }; /** * Returns the current playback position within the recording, in * milliseconds, where zero is the start of the recording. * * @returns {!number} * The current playback position within the recording, in milliseconds. */ this.getPosition = function getPosition() { // Position is simply zero if playback has not started at all if (currentFrame === -1) return 0; // Return current position as a millisecond timestamp relative to the // start of the recording return toRelativeTimestamp(frames[currentFrame].timestamp); }; /** * Returns the duration of this recording, in milliseconds. If the * recording is still being downloaded, this value will gradually increase. * * @returns {!number} * The duration of this recording, in milliseconds. */ this.getDuration = function getDuration() { // If no frames yet exist, duration is zero if (frames.length === 0) return 0; // Recording duration is simply the timestamp of the last frame return toRelativeTimestamp(frames[frames.length - 1].timestamp); }; /** * Begins continuous playback of the recording downloaded thus far. * Playback of the recording will continue until pause() is invoked or * until no further frames exist. Playback is initially paused when a * Guacamole.SessionRecording is created, and must be explicitly started * through a call to this function. If playback is already in progress, * this function has no effect. If a seek operation is in progress, * playback resumes at the current position, and the seek is aborted as if * completed. */ this.play = function play() { // If playback is not already in progress and frames remain, // begin playback if (!recording.isPlaying() && currentFrame + 1 < frames.length) { // Notify that playback is starting if (recording.onplay) recording.onplay(); // Store timestamp of playback start for relative scheduling of // future frames var next = frames[currentFrame + 1]; startVideoTimestamp = next.timestamp; startRealTimestamp = new Date().getTime(); // Begin playback of video continuePlayback(); } }; /** * Seeks to the given position within the recording. If the recording is * currently being played back, playback will continue after the seek is * performed. If the recording is currently paused, playback will be * paused after the seek is performed. If a seek operation is already in * progress, that seek is first aborted. The seek operation will proceed * asynchronously. * * @param {!number} position * The position within the recording to seek to, in milliseconds. * * @param {function} [callback] * The callback to invoke once the seek operation has completed. */ this.seek = function seek(position, callback) { // Do not seek if no frames exist if (frames.length === 0) return; // Abort active seek operation, if any recording.cancel(); // Pause playback, preserving playback state var originallyPlaying = recording.isPlaying(); recording.pause(); // Restore playback when seek is completed or cancelled seekCallback = function restorePlaybackState() { // Seek is no longer in progress seekCallback = null; // Restore playback state if (originallyPlaying) { recording.play(); originallyPlaying = null; } // Notify that seek has completed if (callback) callback(); }; // Perform seek seekToFrame(findFrame(0, frames.length - 1, position), seekCallback); }; /** * Cancels the current seek operation, setting the current frame of the * recording to wherever the seek operation was able to reach prior to * being cancelled. If a callback was provided to seek(), that callback * is invoked. If a seek operation is not currently underway, this * function has no effect. */ this.cancel = function cancel() { if (seekCallback) { abortSeek(); seekCallback(); } }; /** * Pauses playback of the recording, if playback is currently in progress. * If playback is not in progress, this function has no effect. If a seek * operation is in progress, the seek is aborted. Playback is initially * paused when a Guacamole.SessionRecording is created, and must be * explicitly started through a call to play(). */ this.pause = function pause() { // Abort any in-progress seek / playback abortSeek(); // Stop playback only if playback is in progress if (recording.isPlaying()) { // Notify that playback is stopping if (recording.onpause) recording.onpause(); // Playback is stopped startVideoTimestamp = null; startRealTimestamp = null; } }; }; /** * A single frame of Guacamole session data. Each frame is made up of the set * of instructions used to generate that frame, and the timestamp as dictated * by the "sync" instruction terminating the frame. Optionally, a frame may * also be associated with a snapshot of Guacamole client state, such that the * frame can be rendered without replaying all previous frames. * * @private * @constructor * @param {!number} timestamp * The timestamp of this frame, as dictated by the "sync" instruction which * terminates the frame. * * @param {!number} start * The byte offset within the blob of the first character of the first * instruction of this frame. * * @param {!number} end * The byte offset within the blob of character which follows the last * character of the last instruction of this frame. */ Guacamole.SessionRecording._Frame = function _Frame(timestamp, start, end) { /** * Whether this frame should be used as a keyframe if possible. This value * is purely advisory. The stored clientState must eventually be manually * set for the frame to be used as a keyframe. By default, frames are not * keyframes. * * @type {!boolean} * @default false */ this.keyframe = false; /** * The timestamp of this frame, as dictated by the "sync" instruction which * terminates the frame. * * @type {!number} */ this.timestamp = timestamp; /** * The byte offset within the blob of the first character of the first * instruction of this frame. * * @type {!number} */ this.start = start; /** * The byte offset within the blob of character which follows the last * character of the last instruction of this frame. * * @type {!number} */ this.end = end; /** * A snapshot of client state after this frame was rendered, as returned by * a call to exportState(), serialized as JSON, and stored within a Blob. * Use of Blobs here is required to ensure the browser can make use of * larger disk-backed storage if the size of the recording is large. If no * such snapshot has been taken, this will be null. * * @type {Blob} * @default null */ this.clientState = null; }; /** * A read-only Guacamole.Tunnel implementation which streams instructions * received through explicit calls to its receiveInstruction() function. * * @private * @constructor * @augments {Guacamole.Tunnel} */ Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() { /** * Reference to this Guacamole.SessionRecording._PlaybackTunnel. * * @private * @type {!Guacamole.SessionRecording._PlaybackTunnel} */ var tunnel = this; this.connect = function connect(data) { // Do nothing }; this.sendMessage = function sendMessage(elements) { // Do nothing }; this.disconnect = function disconnect() { // Do nothing }; /** * Invokes this tunnel's oninstruction handler, notifying users of this * tunnel (such as a Guacamole.Client instance) that an instruction has * been received. If the oninstruction handler has not been set, this * function has no effect. * * @param {!string} opcode * The opcode of the Guacamole instruction. * * @param {!string[]} args * All arguments associated with this Guacamole instruction. */ this.receiveInstruction = function receiveInstruction(opcode, args) { if (tunnel.oninstruction) tunnel.oninstruction(opcode, args); }; };