| /* |
| * 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}, 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. |
| * |
| * @constructor |
| * @param {Guacamole.Tunnel} tunnel |
| * The Guacamole.Tunnel from which the instructions of the recording should |
| * be read. |
| */ |
| Guacamole.SessionRecording = function SessionRecording(tunnel) { |
| |
| /** |
| * Reference to this Guacamole.SessionRecording. |
| * |
| * @private |
| * @type {Guacamole.SessionRecording} |
| */ |
| var recording = this; |
| |
| /** |
| * 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; |
| |
| /** |
| * 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. |
| * |
| * @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. |
| * |
| * @private |
| * @type {Number} |
| */ |
| var lastKeyframeTimestamp = 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; |
| |
| /** |
| * 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. |
| * |
| * @private |
| * @type {Number} |
| */ |
| var seekTimeout = null; |
| |
| // Start playback client connected |
| playbackClient.connect(); |
| |
| // 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) { |
| |
| // Store opcode and arguments for received instruction |
| var instruction = new Guacamole.SessionRecording._Frame.Instruction(opcode, args.slice()); |
| instructions.push(instruction); |
| charactersSinceLastKeyframe += instruction.getSize(); |
| |
| // 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, instructions); |
| frames.push(frame); |
| |
| // 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)) { |
| frame.keyframe = true; |
| lastKeyframeTimestamp = timestamp; |
| charactersSinceLastKeyframe = 0; |
| } |
| |
| // Clear set of instructions in preparation for next frame |
| instructions = []; |
| |
| // Notify that additional content is available |
| if (recording.onprogress) |
| recording.onprogress(recording.getDuration()); |
| |
| } |
| |
| }; |
| |
| /** |
| * 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. |
| */ |
| var replayFrame = function replayFrame(index) { |
| |
| 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); |
| } |
| |
| // Store client state if frame is flagged as a keyframe |
| if (frame.keyframe && !frame.clientState) { |
| playbackClient.exportState(function storeClientState(state) { |
| frame.clientState = state; |
| }); |
| } |
| |
| }; |
| |
| /** |
| * 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(); |
| |
| // Replay frames asynchronously |
| seekTimeout = window.setTimeout(function continueSeek() { |
| |
| 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) { |
| playbackClient.importState(frame.clientState); |
| 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; |
| |
| // Notify of changes in position |
| if (recording.onseek) |
| recording.onseek(recording.getPosition()); |
| |
| // If the seek operation has not yet completed, schedule continuation |
| if (currentFrame !== index) |
| seekToFrame(index, callback, |
| Math.max(delay - (new Date().getTime() - startTime), 0)); |
| |
| // Notify that the requested seek has completed |
| else |
| callback(); |
| |
| }, delay || 0); |
| |
| }; |
| |
| /** |
| * 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() { |
| window.clearTimeout(seekTimeout); |
| }; |
| |
| /** |
| * 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 new frames have become available while the recording is |
| * being downloaded. |
| * |
| * @event |
| * @param {Number} duration |
| * The new duration of the recording, in milliseconds. |
| */ |
| 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. |
| */ |
| 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. |
| * |
| * @param {String} data |
| * The data to send to the tunnel when connecting. |
| */ |
| this.connect = function connect(data) { |
| tunnel.connect(data); |
| }; |
| |
| /** |
| * Disconnects the underlying tunnel, stopping further download of the |
| * Guacamole session. |
| */ |
| this.disconnect = function disconnect() { |
| 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; |
| |
| // Pause playback, preserving playback state |
| var originallyPlaying = recording.isPlaying(); |
| recording.pause(); |
| |
| // Perform seek |
| seekToFrame(findFrame(0, frames.length - 1, position), function restorePlaybackState() { |
| |
| // Restore playback state |
| if (originallyPlaying) |
| recording.play(); |
| |
| // Notify that seek has completed |
| if (callback) |
| callback(); |
| |
| }); |
| |
| }; |
| |
| /** |
| * 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 {Guacamole.SessionRecording._Frame.Instruction[]} instructions |
| * All instructions which are necessary to generate this frame relative to |
| * the previous frame in the Guacamole session. |
| */ |
| Guacamole.SessionRecording._Frame = function _Frame(timestamp, instructions) { |
| |
| /** |
| * 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; |
| |
| /** |
| * All instructions which are necessary to generate this frame relative to |
| * the previous frame in the Guacamole session. |
| * |
| * @type {Guacamole.SessionRecording._Frame.Instruction[]} |
| */ |
| this.instructions = instructions; |
| |
| /** |
| * A snapshot of client state after this frame was rendered, as returned by |
| * a call to exportState(). If no such snapshot has been taken, this will |
| * be null. |
| * |
| * @type {Object} |
| * @default null |
| */ |
| this.clientState = null; |
| |
| }; |
| |
| /** |
| * 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. |
| * |
| * @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); |
| }; |
| |
| }; |