Source: SessionRecording.js

  1. /*
  2. * Licensed to the Apache Software Foundation (ASF) under one
  3. * or more contributor license agreements. See the NOTICE file
  4. * distributed with this work for additional information
  5. * regarding copyright ownership. The ASF licenses this file
  6. * to you under the Apache License, Version 2.0 (the
  7. * "License"); you may not use this file except in compliance
  8. * with the License. You may obtain a copy of the License at
  9. *
  10. * http://www.apache.org/licenses/LICENSE-2.0
  11. *
  12. * Unless required by applicable law or agreed to in writing,
  13. * software distributed under the License is distributed on an
  14. * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  15. * KIND, either express or implied. See the License for the
  16. * specific language governing permissions and limitations
  17. * under the License.
  18. */
  19. var Guacamole = Guacamole || {};
  20. /**
  21. * A recording of a Guacamole session. Given a {@link Guacamole.Tunnel}, the
  22. * Guacamole.SessionRecording automatically handles incoming Guacamole
  23. * instructions, storing them for playback. Playback of the recording may be
  24. * controlled through function calls to the Guacamole.SessionRecording, even
  25. * while the recording has not yet finished being created or downloaded.
  26. *
  27. * @constructor
  28. * @param {Guacamole.Tunnel} tunnel
  29. * The Guacamole.Tunnel from which the instructions of the recording should
  30. * be read.
  31. */
  32. Guacamole.SessionRecording = function SessionRecording(tunnel) {
  33. /**
  34. * Reference to this Guacamole.SessionRecording.
  35. *
  36. * @private
  37. * @type {Guacamole.SessionRecording}
  38. */
  39. var recording = this;
  40. /**
  41. * The minimum number of characters which must have been read between
  42. * keyframes.
  43. *
  44. * @private
  45. * @constant
  46. * @type {Number}
  47. */
  48. var KEYFRAME_CHAR_INTERVAL = 16384;
  49. /**
  50. * The minimum number of milliseconds which must elapse between keyframes.
  51. *
  52. * @private
  53. * @constant
  54. * @type {Number}
  55. */
  56. var KEYFRAME_TIME_INTERVAL = 5000;
  57. /**
  58. * All frames parsed from the provided tunnel.
  59. *
  60. * @private
  61. * @type {Guacamole.SessionRecording._Frame[]}
  62. */
  63. var frames = [];
  64. /**
  65. * All instructions which have been read since the last frame was added to
  66. * the frames array.
  67. *
  68. * @private
  69. * @type {Guacamole.SessionRecording._Frame.Instruction[]}
  70. */
  71. var instructions = [];
  72. /**
  73. * The approximate number of characters which have been read from the
  74. * provided tunnel since the last frame was flagged for use as a keyframe.
  75. *
  76. * @private
  77. * @type {Number}
  78. */
  79. var charactersSinceLastKeyframe = 0;
  80. /**
  81. * The timestamp of the last frame which was flagged for use as a keyframe.
  82. * If no timestamp has yet been flagged, this will be 0.
  83. *
  84. * @private
  85. * @type {Number}
  86. */
  87. var lastKeyframeTimestamp = 0;
  88. /**
  89. * Tunnel which feeds arbitrary instructions to the client used by this
  90. * Guacamole.SessionRecording for playback of the session recording.
  91. *
  92. * @private
  93. * @type {Guacamole.SessionRecording._PlaybackTunnel}
  94. */
  95. var playbackTunnel = new Guacamole.SessionRecording._PlaybackTunnel();
  96. /**
  97. * Guacamole.Client instance used for visible playback of the session
  98. * recording.
  99. *
  100. * @private
  101. * @type {Guacamole.Client}
  102. */
  103. var playbackClient = new Guacamole.Client(playbackTunnel);
  104. /**
  105. * The current frame rendered within the playback client. If no frame is
  106. * yet rendered, this will be -1.
  107. *
  108. * @private
  109. * @type {Number}
  110. */
  111. var currentFrame = -1;
  112. /**
  113. * The timestamp of the frame when playback began, in milliseconds. If
  114. * playback is not in progress, this will be null.
  115. *
  116. * @private
  117. * @type {Number}
  118. */
  119. var startVideoTimestamp = null;
  120. /**
  121. * The real-world timestamp when playback began, in milliseconds. If
  122. * playback is not in progress, this will be null.
  123. *
  124. * @private
  125. * @type {Number}
  126. */
  127. var startRealTimestamp = null;
  128. /**
  129. * The ID of the timeout which will play the next frame, if playback is in
  130. * progress. If playback is not in progress, the ID stored here (if any)
  131. * will not be valid.
  132. *
  133. * @private
  134. * @type {Number}
  135. */
  136. var playbackTimeout = null;
  137. // Start playback client connected
  138. playbackClient.connect();
  139. // Hide cursor unless mouse position is received
  140. playbackClient.getDisplay().showCursor(false);
  141. // Read instructions from provided tunnel, extracting each frame
  142. tunnel.oninstruction = function handleInstruction(opcode, args) {
  143. // Store opcode and arguments for received instruction
  144. var instruction = new Guacamole.SessionRecording._Frame.Instruction(opcode, args.slice());
  145. instructions.push(instruction);
  146. charactersSinceLastKeyframe += instruction.getSize();
  147. // Once a sync is received, store all instructions since the last
  148. // frame as a new frame
  149. if (opcode === 'sync') {
  150. // Parse frame timestamp from sync instruction
  151. var timestamp = parseInt(args[0]);
  152. // Add a new frame containing the instructions read since last frame
  153. var frame = new Guacamole.SessionRecording._Frame(timestamp, instructions);
  154. frames.push(frame);
  155. // This frame should eventually become a keyframe if enough data
  156. // has been processed and enough recording time has elapsed, or if
  157. // this is the absolute first frame
  158. if (frames.length === 1 || (charactersSinceLastKeyframe >= KEYFRAME_CHAR_INTERVAL
  159. && timestamp - lastKeyframeTimestamp >= KEYFRAME_TIME_INTERVAL)) {
  160. frame.keyframe = true;
  161. lastKeyframeTimestamp = timestamp;
  162. charactersSinceLastKeyframe = 0;
  163. }
  164. // Clear set of instructions in preparation for next frame
  165. instructions = [];
  166. // Notify that additional content is available
  167. if (recording.onprogress)
  168. recording.onprogress(recording.getDuration());
  169. }
  170. };
  171. /**
  172. * Converts the given absolute timestamp to a timestamp which is relative
  173. * to the first frame in the recording.
  174. *
  175. * @private
  176. * @param {Number} timestamp
  177. * The timestamp to convert to a relative timestamp.
  178. *
  179. * @returns {Number}
  180. * The difference in milliseconds between the given timestamp and the
  181. * first frame of the recording, or zero if no frames yet exist.
  182. */
  183. var toRelativeTimestamp = function toRelativeTimestamp(timestamp) {
  184. // If no frames yet exist, all timestamps are zero
  185. if (frames.length === 0)
  186. return 0;
  187. // Calculate timestamp relative to first frame
  188. return timestamp - frames[0].timestamp;
  189. };
  190. /**
  191. * Searches through the given region of frames for the frame having a
  192. * relative timestamp closest to the timestamp given.
  193. *
  194. * @private
  195. * @param {Number} minIndex
  196. * The index of the first frame in the region (the frame having the
  197. * smallest timestamp).
  198. *
  199. * @param {Number} maxIndex
  200. * The index of the last frame in the region (the frame having the
  201. * largest timestamp).
  202. *
  203. * @param {Number} timestamp
  204. * The relative timestamp to search for, where zero denotes the first
  205. * frame in the recording.
  206. *
  207. * @returns {Number}
  208. * The index of the frame having a relative timestamp closest to the
  209. * given value.
  210. */
  211. var findFrame = function findFrame(minIndex, maxIndex, timestamp) {
  212. // Do not search if the region contains only one element
  213. if (minIndex === maxIndex)
  214. return minIndex;
  215. // Split search region into two halves
  216. var midIndex = Math.floor((minIndex + maxIndex) / 2);
  217. var midTimestamp = toRelativeTimestamp(frames[midIndex].timestamp);
  218. // If timestamp is within lesser half, search again within that half
  219. if (timestamp < midTimestamp && midIndex > minIndex)
  220. return findFrame(minIndex, midIndex - 1, timestamp);
  221. // If timestamp is within greater half, search again within that half
  222. if (timestamp > midTimestamp && midIndex < maxIndex)
  223. return findFrame(midIndex + 1, maxIndex, timestamp);
  224. // Otherwise, we lucked out and found a frame with exactly the
  225. // desired timestamp
  226. return midIndex;
  227. };
  228. /**
  229. * Replays the instructions associated with the given frame, sending those
  230. * instructions to the playback client.
  231. *
  232. * @private
  233. * @param {Number} index
  234. * The index of the frame within the frames array which should be
  235. * replayed.
  236. */
  237. var replayFrame = function replayFrame(index) {
  238. var frame = frames[index];
  239. // Replay all instructions within the retrieved frame
  240. for (var i = 0; i < frame.instructions.length; i++) {
  241. var instruction = frame.instructions[i];
  242. playbackTunnel.receiveInstruction(instruction.opcode, instruction.args);
  243. }
  244. // Store client state if frame is flagged as a keyframe
  245. if (frame.keyframe && !frame.clientState) {
  246. playbackClient.exportState(function storeClientState(state) {
  247. frame.clientState = state;
  248. });
  249. }
  250. };
  251. /**
  252. * Moves the playback position to the given frame, resetting the state of
  253. * the playback client and replaying frames as necessary.
  254. *
  255. * @private
  256. * @param {Number} index
  257. * The index of the frame which should become the new playback
  258. * position.
  259. */
  260. var seekToFrame = function seekToFrame(index) {
  261. var startIndex;
  262. // Back up until startIndex represents current state
  263. for (startIndex = index; startIndex >= 0; startIndex--) {
  264. var frame = frames[startIndex];
  265. // If we've reached the current frame, startIndex represents
  266. // current state by definition
  267. if (startIndex === currentFrame)
  268. break;
  269. // If frame has associated absolute state, make that frame the
  270. // current state
  271. if (frame.clientState) {
  272. playbackClient.importState(frame.clientState);
  273. break;
  274. }
  275. }
  276. // Advance to frame index after current state
  277. startIndex++;
  278. // Replay any applicable incremental frames
  279. for (; startIndex <= index; startIndex++)
  280. replayFrame(startIndex);
  281. // Current frame is now at requested index
  282. currentFrame = index;
  283. // Notify of changes in position
  284. if (recording.onseek)
  285. recording.onseek(recording.getPosition());
  286. };
  287. /**
  288. * Advances playback to the next frame in the frames array and schedules
  289. * playback of the frame following that frame based on their associated
  290. * timestamps. If no frames exist after the next frame, playback is paused.
  291. *
  292. * @private
  293. */
  294. var continuePlayback = function continuePlayback() {
  295. // Advance to next frame
  296. seekToFrame(currentFrame + 1);
  297. // If frames remain after advancing, schedule next frame
  298. if (currentFrame + 1 < frames.length) {
  299. // Pull the upcoming frame
  300. var next = frames[currentFrame + 1];
  301. // Calculate the real timestamp corresponding to when the next
  302. // frame begins
  303. var nextRealTimestamp = next.timestamp - startVideoTimestamp + startRealTimestamp;
  304. // Calculate the relative delay between the current time and
  305. // the next frame start
  306. var delay = Math.max(nextRealTimestamp - new Date().getTime(), 0);
  307. // Advance to next frame after enough time has elapsed
  308. playbackTimeout = window.setTimeout(function frameDelayElapsed() {
  309. continuePlayback();
  310. }, delay);
  311. }
  312. // Otherwise stop playback
  313. else
  314. recording.pause();
  315. };
  316. /**
  317. * Fired when new frames have become available while the recording is
  318. * being downloaded.
  319. *
  320. * @event
  321. * @param {Number} duration
  322. * The new duration of the recording, in milliseconds.
  323. */
  324. this.onprogress = null;
  325. /**
  326. * Fired whenever playback of the recording has started.
  327. *
  328. * @event
  329. */
  330. this.onplay = null;
  331. /**
  332. * Fired whenever playback of the recording has been paused. This may
  333. * happen when playback is explicitly paused with a call to pause(), or
  334. * when playback is implicitly paused due to reaching the end of the
  335. * recording.
  336. *
  337. * @event
  338. */
  339. this.onpause = null;
  340. /**
  341. * Fired whenever the playback position within the recording changes.
  342. *
  343. * @event
  344. * @param {Number} position
  345. * The new position within the recording, in milliseconds.
  346. */
  347. this.onseek = null;
  348. /**
  349. * Connects the underlying tunnel, beginning download of the Guacamole
  350. * session. Playback of the Guacamole session cannot occur until at least
  351. * one frame worth of instructions has been downloaded.
  352. *
  353. * @param {String} data
  354. * The data to send to the tunnel when connecting.
  355. */
  356. this.connect = function connect(data) {
  357. tunnel.connect(data);
  358. };
  359. /**
  360. * Disconnects the underlying tunnel, stopping further download of the
  361. * Guacamole session.
  362. */
  363. this.disconnect = function disconnect() {
  364. tunnel.disconnect();
  365. };
  366. /**
  367. * Returns the underlying display of the Guacamole.Client used by this
  368. * Guacamole.SessionRecording for playback. The display contains an Element
  369. * which can be added to the DOM, causing the display (and thus playback of
  370. * the recording) to become visible.
  371. *
  372. * @return {Guacamole.Display}
  373. * The underlying display of the Guacamole.Client used by this
  374. * Guacamole.SessionRecording for playback.
  375. */
  376. this.getDisplay = function getDisplay() {
  377. return playbackClient.getDisplay();
  378. };
  379. /**
  380. * Returns whether playback is currently in progress.
  381. *
  382. * @returns {Boolean}
  383. * true if playback is currently in progress, false otherwise.
  384. */
  385. this.isPlaying = function isPlaying() {
  386. return !!startVideoTimestamp;
  387. };
  388. /**
  389. * Returns the current playback position within the recording, in
  390. * milliseconds, where zero is the start of the recording.
  391. *
  392. * @returns {Number}
  393. * The current playback position within the recording, in milliseconds.
  394. */
  395. this.getPosition = function getPosition() {
  396. // Position is simply zero if playback has not started at all
  397. if (currentFrame === -1)
  398. return 0;
  399. // Return current position as a millisecond timestamp relative to the
  400. // start of the recording
  401. return toRelativeTimestamp(frames[currentFrame].timestamp);
  402. };
  403. /**
  404. * Returns the duration of this recording, in milliseconds. If the
  405. * recording is still being downloaded, this value will gradually increase.
  406. *
  407. * @returns {Number}
  408. * The duration of this recording, in milliseconds.
  409. */
  410. this.getDuration = function getDuration() {
  411. // If no frames yet exist, duration is zero
  412. if (frames.length === 0)
  413. return 0;
  414. // Recording duration is simply the timestamp of the last frame
  415. return toRelativeTimestamp(frames[frames.length - 1].timestamp);
  416. };
  417. /**
  418. * Begins continuous playback of the recording downloaded thus far.
  419. * Playback of the recording will continue until pause() is invoked or
  420. * until no further frames exist. Playback is initially paused when a
  421. * Guacamole.SessionRecording is created, and must be explicitly started
  422. * through a call to this function. If playback is already in progress,
  423. * this function has no effect.
  424. */
  425. this.play = function play() {
  426. // If playback is not already in progress and frames remain,
  427. // begin playback
  428. if (!recording.isPlaying() && currentFrame + 1 < frames.length) {
  429. // Notify that playback is starting
  430. if (recording.onplay)
  431. recording.onplay();
  432. // Store timestamp of playback start for relative scheduling of
  433. // future frames
  434. var next = frames[currentFrame + 1];
  435. startVideoTimestamp = next.timestamp;
  436. startRealTimestamp = new Date().getTime();
  437. // Begin playback of video
  438. continuePlayback();
  439. }
  440. };
  441. /**
  442. * Seeks to the given position within the recording. If the recording is
  443. * currently being played back, playback will continue after the seek is
  444. * performed. If the recording is currently paused, playback will be
  445. * paused after the seek is performed.
  446. *
  447. * @param {Number} position
  448. * The position within the recording to seek to, in milliseconds.
  449. */
  450. this.seek = function seek(position) {
  451. // Do not seek if no frames exist
  452. if (frames.length === 0)
  453. return;
  454. // Pause playback, preserving playback state
  455. var originallyPlaying = recording.isPlaying();
  456. recording.pause();
  457. // Perform seek
  458. seekToFrame(findFrame(0, frames.length - 1, position));
  459. // Restore playback state
  460. if (originallyPlaying)
  461. recording.play();
  462. };
  463. /**
  464. * Pauses playback of the recording, if playback is currently in progress.
  465. * If playback is not in progress, this function has no effect. Playback is
  466. * initially paused when a Guacamole.SessionRecording is created, and must
  467. * be explicitly started through a call to play().
  468. */
  469. this.pause = function pause() {
  470. // Stop playback only if playback is in progress
  471. if (recording.isPlaying()) {
  472. // Notify that playback is stopping
  473. if (recording.onpause)
  474. recording.onpause();
  475. // Stop playback
  476. window.clearTimeout(playbackTimeout);
  477. startVideoTimestamp = null;
  478. startRealTimestamp = null;
  479. }
  480. };
  481. };
  482. /**
  483. * A single frame of Guacamole session data. Each frame is made up of the set
  484. * of instructions used to generate that frame, and the timestamp as dictated
  485. * by the "sync" instruction terminating the frame. Optionally, a frame may
  486. * also be associated with a snapshot of Guacamole client state, such that the
  487. * frame can be rendered without replaying all previous frames.
  488. *
  489. * @private
  490. * @constructor
  491. * @param {Number} timestamp
  492. * The timestamp of this frame, as dictated by the "sync" instruction which
  493. * terminates the frame.
  494. *
  495. * @param {Guacamole.SessionRecording._Frame.Instruction[]} instructions
  496. * All instructions which are necessary to generate this frame relative to
  497. * the previous frame in the Guacamole session.
  498. */
  499. Guacamole.SessionRecording._Frame = function _Frame(timestamp, instructions) {
  500. /**
  501. * Whether this frame should be used as a keyframe if possible. This value
  502. * is purely advisory. The stored clientState must eventually be manually
  503. * set for the frame to be used as a keyframe. By default, frames are not
  504. * keyframes.
  505. *
  506. * @type {Boolean}
  507. * @default false
  508. */
  509. this.keyframe = false;
  510. /**
  511. * The timestamp of this frame, as dictated by the "sync" instruction which
  512. * terminates the frame.
  513. *
  514. * @type {Number}
  515. */
  516. this.timestamp = timestamp;
  517. /**
  518. * All instructions which are necessary to generate this frame relative to
  519. * the previous frame in the Guacamole session.
  520. *
  521. * @type {Guacamole.SessionRecording._Frame.Instruction[]}
  522. */
  523. this.instructions = instructions;
  524. /**
  525. * A snapshot of client state after this frame was rendered, as returned by
  526. * a call to exportState(). If no such snapshot has been taken, this will
  527. * be null.
  528. *
  529. * @type {Object}
  530. * @default null
  531. */
  532. this.clientState = null;
  533. };
  534. /**
  535. * A Guacamole protocol instruction. Each Guacamole protocol instruction is
  536. * made up of an opcode and set of arguments.
  537. *
  538. * @private
  539. * @constructor
  540. * @param {String} opcode
  541. * The opcode of this Guacamole instruction.
  542. *
  543. * @param {String[]} args
  544. * All arguments associated with this Guacamole instruction.
  545. */
  546. Guacamole.SessionRecording._Frame.Instruction = function Instruction(opcode, args) {
  547. /**
  548. * Reference to this Guacamole.SessionRecording._Frame.Instruction.
  549. *
  550. * @private
  551. * @type {Guacamole.SessionRecording._Frame.Instruction}
  552. */
  553. var instruction = this;
  554. /**
  555. * The opcode of this Guacamole instruction.
  556. *
  557. * @type {String}
  558. */
  559. this.opcode = opcode;
  560. /**
  561. * All arguments associated with this Guacamole instruction.
  562. *
  563. * @type {String[]}
  564. */
  565. this.args = args;
  566. /**
  567. * Returns the approximate number of characters which make up this
  568. * instruction. This value is only approximate as it excludes the length
  569. * prefixes and various delimiters used by the Guacamole protocol; only
  570. * the content of the opcode and each argument is taken into account.
  571. *
  572. * @returns {Number}
  573. * The approximate size of this instruction, in characters.
  574. */
  575. this.getSize = function getSize() {
  576. // Init with length of opcode
  577. var size = instruction.opcode.length;
  578. // Add length of all arguments
  579. for (var i = 0; i < instruction.args.length; i++)
  580. size += instruction.args[i].length;
  581. return size;
  582. };
  583. };
  584. /**
  585. * A read-only Guacamole.Tunnel implementation which streams instructions
  586. * received through explicit calls to its receiveInstruction() function.
  587. *
  588. * @private
  589. * @constructor
  590. * @augments {Guacamole.Tunnel}
  591. */
  592. Guacamole.SessionRecording._PlaybackTunnel = function _PlaybackTunnel() {
  593. /**
  594. * Reference to this Guacamole.SessionRecording._PlaybackTunnel.
  595. *
  596. * @private
  597. * @type {Guacamole.SessionRecording._PlaybackTunnel}
  598. */
  599. var tunnel = this;
  600. this.connect = function connect(data) {
  601. // Do nothing
  602. };
  603. this.sendMessage = function sendMessage(elements) {
  604. // Do nothing
  605. };
  606. this.disconnect = function disconnect() {
  607. // Do nothing
  608. };
  609. /**
  610. * Invokes this tunnel's oninstruction handler, notifying users of this
  611. * tunnel (such as a Guacamole.Client instance) that an instruction has
  612. * been received. If the oninstruction handler has not been set, this
  613. * function has no effect.
  614. *
  615. * @param {String} opcode
  616. * The opcode of the Guacamole instruction.
  617. *
  618. * @param {String[]} args
  619. * All arguments associated with this Guacamole instruction.
  620. */
  621. this.receiveInstruction = function receiveInstruction(opcode, args) {
  622. if (tunnel.oninstruction)
  623. tunnel.oninstruction(opcode, args);
  624. };
  625. };