diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js new file mode 100644 index 000000000..34f8aca4d --- /dev/null +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -0,0 +1,573 @@ +/* + * 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; + + /** + * 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 = []; + + /** + * 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 play the next frame, if playback is in + * progress. If playback is not in progress, the ID stored here (if any) + * will not be valid. + * + * @private + * @type {Number} + */ + var playbackTimeout = 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 + instructions.push(new Guacamole.SessionRecording._Frame.Instruction(opcode, args.slice())); + + // 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); + + // 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; + + }; + + /** + * 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); + } + + }; + + /** + * Moves the playback position to the given frame, resetting the state of + * the playback client and replaying frames as necessary. + * + * @private + * @param {Number} index + * The index of the frame which should become the new playback + * position. + */ + var seekToFrame = function seekToFrame(index) { + + var startIndex; + + // Back up until startIndex represents current state + for (startIndex = index; startIndex > currentFrame; startIndex--) { + + var frame = frames[startIndex]; + + // 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++; + + // Replay any applicable incremental frames + for (; startIndex <= index; startIndex++) + replayFrame(startIndex); + + // Current frame is now at requested index + currentFrame = index; + + // Notify of changes in position + if (recording.onseek) + recording.onseek(recording.getPosition()); + + }; + + /** + * 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() { + + // Advance to next frame + seekToFrame(currentFrame + 1); + + // 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 + playbackTimeout = window.setTimeout(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. + */ + 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(); + + } + + }; + + /** + * Pauses playback of the recording, if playback is currently in progress. + * If playback is not in progress, this function has no effect. Playback is + * initially paused when a Guacamole.SessionRecording is created, and must + * be explicitly started through a call to play(). + */ + this.pause = function pause() { + + // Stop playback only if playback is in progress + if (recording.isPlaying()) { + + // Notify that playback is stopping + if (recording.onpause) + recording.onpause(); + + // Stop playback + window.clearTimeout(playbackTimeout); + 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) { + + /** + * 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} + */ + 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) { + + /** + * The opcode of this Guacamole instruction. + * + * @type {String} + */ + this.opcode = opcode; + + /** + * All arguments associated with this Guacamole instruction. + * + * @type {String[]} + */ + this.args = args; + +}; + +/** + * 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); + }; + +};