// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview * Class handling creation and teardown of a remoting client session. * * The ClientSession class controls lifetime of the client plugin * object and provides the plugin with the functionality it needs to * establish connection. Specifically it: * - Delivers incoming/outgoing signaling messages, * - Adjusts plugin size and position when destop resolution changes, * * This class should not access the plugin directly, instead it should * do it through ClientPlugin class which abstracts plugin version * differences. */ 'use strict'; /** @suppress {duplicate} */ var remoting = remoting || {}; /** * @param {string} hostJid The jid of the host to connect to. * @param {string} hostPublicKey The base64 encoded version of the host's * public key. * @param {string} sharedSecret The access code for IT2Me or the PIN * for Me2Me. * @param {string} authenticationMethods Comma-separated list of * authentication methods the client should attempt to use. * @param {string} authenticationTag A host-specific tag to mix into * authentication hashes. * @param {string} email The username for the talk network. * @param {remoting.ClientSession.Mode} mode The mode of this connection. * @param {function(remoting.ClientSession.State, remoting.ClientSession.State):void} onStateChange * The callback to invoke when the session changes state. * @constructor */ remoting.ClientSession = function(hostJid, hostPublicKey, sharedSecret, authenticationMethods, authenticationTag, email, mode, onStateChange) { this.state = remoting.ClientSession.State.CREATED; this.hostJid = hostJid; this.hostPublicKey = hostPublicKey; this.sharedSecret = sharedSecret; this.authenticationMethods = authenticationMethods; this.authenticationTag = authenticationTag; this.email = email; this.mode = mode; this.clientJid = ''; this.sessionId = ''; /** @type {remoting.ClientPlugin} */ this.plugin = null; this.scaleToFit = false; this.hasReceivedFrame_ = false; this.logToServer = new remoting.LogToServer(); this.onStateChange = onStateChange; /** @type {number?} @private */ this.notifyClientDimensionsTimer_ = null; /** @private */ this.callPluginLostFocus_ = this.pluginLostFocus_.bind(this); /** @private */ this.callPluginGotFocus_ = this.pluginGotFocus_.bind(this); /** @private */ this.callEnableShrink_ = this.setScaleToFit.bind(this, true); /** @private */ this.callDisableShrink_ = this.setScaleToFit.bind(this, false); /** @private */ this.callToggleFullScreen_ = this.toggleFullScreen_.bind(this); /** @private */ this.screenOptionsMenu_ = new remoting.MenuButton( document.getElementById('screen-options-menu'), this.onShowOptionsMenu_.bind(this)); /** @private */ this.sendKeysMenu_ = new remoting.MenuButton( document.getElementById('send-keys-menu') ); /** @type {HTMLElement} @private */ this.shrinkToFit_ = document.getElementById('enable-shrink-to-fit'); /** @type {HTMLElement} @private */ this.originalSize_ = document.getElementById('disable-shrink-to-fit'); /** @type {HTMLElement} @private */ this.fullScreen_ = document.getElementById('toggle-full-screen'); this.shrinkToFit_.addEventListener('click', this.callEnableShrink_, false); this.originalSize_.addEventListener('click', this.callDisableShrink_, false); this.fullScreen_.addEventListener('click', this.callToggleFullScreen_, false); /** @type {number?} @private */ this.bumpScrollTimer_ = null; /** * Allow error reporting to be suppressed in situations where it would not * be useful, for example, when the device is offline. * * @type {boolean} @private */ this.logErrors_ = true; }; // Note that the positive values in both of these enums are copied directly // from chromoting_scriptable_object.h and must be kept in sync. The negative // values represent states transitions that occur within the web-app that have // no corresponding plugin state transition. /** @enum {number} */ remoting.ClientSession.State = { CREATED: -3, BAD_PLUGIN_VERSION: -2, UNKNOWN_PLUGIN_ERROR: -1, UNKNOWN: 0, CONNECTING: 1, INITIALIZING: 2, CONNECTED: 3, CLOSED: 4, FAILED: 5 }; /** @enum {number} */ remoting.ClientSession.ConnectionError = { UNKNOWN: -1, NONE: 0, HOST_IS_OFFLINE: 1, SESSION_REJECTED: 2, INCOMPATIBLE_PROTOCOL: 3, NETWORK_FAILURE: 4, HOST_OVERLOAD: 5 }; // The mode of this session. /** @enum {number} */ remoting.ClientSession.Mode = { IT2ME: 0, ME2ME: 1 }; /** * Type used for performance statistics collected by the plugin. * @constructor */ remoting.ClientSession.PerfStats = function() {}; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.videoBandwidth; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.videoFrameRate; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.captureLatency; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.encodeLatency; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.decodeLatency; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.renderLatency; /** @type {number} */ remoting.ClientSession.PerfStats.prototype.roundtripLatency; // Keys for connection statistics. remoting.ClientSession.STATS_KEY_VIDEO_BANDWIDTH = 'videoBandwidth'; remoting.ClientSession.STATS_KEY_VIDEO_FRAME_RATE = 'videoFrameRate'; remoting.ClientSession.STATS_KEY_CAPTURE_LATENCY = 'captureLatency'; remoting.ClientSession.STATS_KEY_ENCODE_LATENCY = 'encodeLatency'; remoting.ClientSession.STATS_KEY_DECODE_LATENCY = 'decodeLatency'; remoting.ClientSession.STATS_KEY_RENDER_LATENCY = 'renderLatency'; remoting.ClientSession.STATS_KEY_ROUNDTRIP_LATENCY = 'roundtripLatency'; /** * The current state of the session. * @type {remoting.ClientSession.State} */ remoting.ClientSession.prototype.state = remoting.ClientSession.State.UNKNOWN; /** * The last connection error. Set when state is set to FAILED. * @type {remoting.ClientSession.ConnectionError} */ remoting.ClientSession.prototype.error = remoting.ClientSession.ConnectionError.NONE; /** * The id of the client plugin * * @const */ remoting.ClientSession.prototype.PLUGIN_ID = 'session-client-plugin'; /** * Callback to invoke when the state is changed. * * @param {remoting.ClientSession.State} oldState The previous state. * @param {remoting.ClientSession.State} newState The current state. */ remoting.ClientSession.prototype.onStateChange = function(oldState, newState) { }; /** * @param {Element} container The element to add the plugin to. * @param {string} id Id to use for the plugin element . * @return {remoting.ClientPlugin} Create plugin object for the locally * installed plugin. */ remoting.ClientSession.prototype.createClientPlugin_ = function(container, id) { var plugin = /** @type {remoting.ViewerPlugin} */ document.createElement('embed'); plugin.id = id; plugin.src = 'about://none'; plugin.type = 'application/vnd.chromium.remoting-viewer'; plugin.width = 0; plugin.height = 0; plugin.tabIndex = 0; // Required, otherwise focus() doesn't work. container.appendChild(plugin); return new remoting.ClientPluginAsync(plugin); }; /** * Callback function called when the plugin element gets focus. */ remoting.ClientSession.prototype.pluginGotFocus_ = function() { remoting.clipboard.initiateToHost(); }; /** * Callback function called when the plugin element loses focus. */ remoting.ClientSession.prototype.pluginLostFocus_ = function() { if (this.plugin) { // Release all keys to prevent them becoming 'stuck down' on the host. this.plugin.releaseAllKeys(); if (this.plugin.element()) { // Focus should stay on the element, not (for example) the toolbar. this.plugin.element().focus(); } } }; /** * Adds element to |container| and readies the sesion object. * * @param {Element} container The element to add the plugin to. * @param {string} oauth2AccessToken A valid OAuth2 access token. */ remoting.ClientSession.prototype.createPluginAndConnect = function(container, oauth2AccessToken) { this.plugin = this.createClientPlugin_(container, this.PLUGIN_ID); this.plugin.element().focus(); /** @param {boolean} result */ this.plugin.initialize( this.onPluginInitialized_.bind(this, oauth2AccessToken)); this.plugin.element().addEventListener( 'focus', this.callPluginGotFocus_, false); this.plugin.element().addEventListener( 'blur', this.callPluginLostFocus_, false); }; /** * @param {string} oauth2AccessToken * @param {boolean} initialized */ remoting.ClientSession.prototype.onPluginInitialized_ = function(oauth2AccessToken, initialized) { if (!initialized) { console.error('ERROR: remoting plugin not loaded'); this.plugin.cleanup(); delete this.plugin; this.setState_(remoting.ClientSession.State.UNKNOWN_PLUGIN_ERROR); return; } if (!this.plugin.isSupportedVersion()) { this.plugin.cleanup(); delete this.plugin; this.setState_(remoting.ClientSession.State.BAD_PLUGIN_VERSION); return; } // Show the Send Keys menu only if the plugin has the injectKeyEvent feature, // and the Ctrl-Alt-Del button only in Me2Me mode. if (!this.plugin.hasFeature(remoting.ClientPlugin.Feature.INJECT_KEY_EVENT)) { var sendKeysElement = document.getElementById('send-keys-menu'); sendKeysElement.hidden = true; } else if (this.mode != remoting.ClientSession.Mode.ME2ME) { var sendCadElement = document.getElementById('send-ctrl-alt-del'); sendCadElement.hidden = true; } // Remap the right Control key to the right Win / Cmd key on ChromeOS // platforms, if the plugin has the remapKey feature. if (this.plugin.hasFeature(remoting.ClientPlugin.Feature.REMAP_KEY) && remoting.runningOnChromeOS()) { this.plugin.remapKey(0x0700e4, 0x0700e7); } // Enable scale-to-fit if and only if the plugin is new enough for // high-quality scaling. this.setScaleToFit(this.plugin.hasFeature( remoting.ClientPlugin.Feature.HIGH_QUALITY_SCALING)); /** @param {string} msg The IQ stanza to send. */ this.plugin.onOutgoingIqHandler = this.sendIq_.bind(this); /** @param {string} msg The message to log. */ this.plugin.onDebugMessageHandler = function(msg) { console.log('plugin: ' + msg); }; this.plugin.onConnectionStatusUpdateHandler = this.onConnectionStatusUpdate_.bind(this); this.plugin.onConnectionReadyHandler = this.onConnectionReady_.bind(this); this.plugin.onDesktopSizeUpdateHandler = this.onDesktopSizeChanged_.bind(this); this.connectPluginToWcs_(oauth2AccessToken); }; /** * Deletes the element from the container, without sending a * session_terminate request. This is to be called when the session was * disconnected by the Host. * * @return {void} Nothing. */ remoting.ClientSession.prototype.removePlugin = function() { if (this.plugin) { this.plugin.element().removeEventListener( 'focus', this.callPluginGotFocus_, false); this.plugin.element().removeEventListener( 'blur', this.callPluginLostFocus_, false); this.plugin.cleanup(); this.plugin = null; } this.shrinkToFit_.removeEventListener('click', this.callEnableShrink_, false); this.originalSize_.removeEventListener('click', this.callDisableShrink_, false); this.fullScreen_.removeEventListener('click', this.callToggleFullScreen_, false); }; /** * Deletes the element from the container and disconnects. * * @return {void} Nothing. */ remoting.ClientSession.prototype.disconnect = function() { // The plugin won't send a state change notification, so we explicitly log // the fact that the connection has closed. this.logToServer.logClientSessionStateChange( remoting.ClientSession.State.CLOSED, remoting.ClientSession.ConnectionError.NONE, this.mode); if (remoting.wcs) { remoting.wcs.setOnIq(function(stanza) {}); this.sendIq_( '' + '' + '' + '' + ''); } this.removePlugin(); }; /** * Sends a key combination to the remoting client, by sending down events for * the given keys, followed by up events in reverse order. * * @private * @param {[number]} keys Key codes to be sent. * @return {void} Nothing. */ remoting.ClientSession.prototype.sendKeyCombination_ = function(keys) { for (var i = 0; i < keys.length; i++) { this.plugin.injectKeyEvent(keys[i], true); } for (var i = 0; i < keys.length; i++) { this.plugin.injectKeyEvent(keys[i], false); } } /** * Sends a Ctrl-Alt-Del sequence to the remoting client. * * @return {void} Nothing. */ remoting.ClientSession.prototype.sendCtrlAltDel = function() { this.sendKeyCombination_([0x0700e0, 0x0700e2, 0x07004c]); } /** * Sends a Print Screen keypress to the remoting client. * * @return {void} Nothing. */ remoting.ClientSession.prototype.sendPrintScreen = function() { this.sendKeyCombination_([0x070046]); } /** * Enables or disables the client's scale-to-fit feature. * * @param {boolean} scaleToFit True to enable scale-to-fit, false otherwise. * @return {void} Nothing. */ remoting.ClientSession.prototype.setScaleToFit = function(scaleToFit) { this.scaleToFit = scaleToFit; this.updateDimensions(); // If enabling scaling, reset bump-scroll offsets. if (scaleToFit) { this.scroll_(0, 0); } } /** * Returns whether the client is currently scaling the host to fit the tab. * * @return {boolean} The current scale-to-fit setting. */ remoting.ClientSession.prototype.getScaleToFit = function() { return this.scaleToFit; } /** * Called when the client receives its first frame. * * @return {void} Nothing. */ remoting.ClientSession.prototype.onFirstFrameReceived = function() { this.hasReceivedFrame_ = true; }; /** * @return {boolean} Whether the client has received a video buffer. */ remoting.ClientSession.prototype.hasReceivedFrame = function() { return this.hasReceivedFrame_; }; /** * Sends an IQ stanza via the http xmpp proxy. * * @private * @param {string} msg XML string of IQ stanza to send to server. * @return {void} Nothing. */ remoting.ClientSession.prototype.sendIq_ = function(msg) { console.log(remoting.timestamp(), remoting.formatIq.prettifySendIq(msg)); // Extract the session id, so we can close the session later. var parser = new DOMParser(); var iqNode = parser.parseFromString(msg, 'text/xml').firstChild; var jingleNode = iqNode.firstChild; if (jingleNode) { var action = jingleNode.getAttribute('action'); if (jingleNode.nodeName == 'jingle' && action == 'session-initiate') { this.sessionId = jingleNode.getAttribute('sid'); } } // Send the stanza. if (remoting.wcs) { remoting.wcs.sendIq(msg); } else { console.error('Tried to send IQ before WCS was ready.'); this.setState_(remoting.ClientSession.State.FAILED); } }; /** * Connects the plugin to WCS. * * @private * @param {string} oauth2AccessToken A valid OAuth2 access token. * @return {void} Nothing. */ remoting.ClientSession.prototype.connectPluginToWcs_ = function(oauth2AccessToken) { this.clientJid = remoting.wcs.getJid(); if (this.clientJid == '') { console.error('Tried to connect without a full JID.'); } remoting.formatIq.setJids(this.clientJid, this.hostJid); var plugin = this.plugin; var forwardIq = plugin.onIncomingIq.bind(plugin); /** @param {string} stanza The IQ stanza received. */ var onIncomingIq = function(stanza) { console.log(remoting.timestamp(), remoting.formatIq.prettifyReceiveIq(stanza)); forwardIq(stanza); } remoting.wcs.setOnIq(onIncomingIq); this.plugin.connect(this.hostJid, this.hostPublicKey, this.clientJid, this.sharedSecret, this.authenticationMethods, this.authenticationTag); }; /** * Callback that the plugin invokes to indicate that the connection * status has changed. * * @private * @param {number} status The plugin's status. * @param {number} error The plugin's error state, if any. */ remoting.ClientSession.prototype.onConnectionStatusUpdate_ = function(status, error) { if (status == remoting.ClientSession.State.CONNECTED) { this.onDesktopSizeChanged_(); this.plugin.notifyClientDimensions(window.innerWidth, window.innerHeight) } else if (status == remoting.ClientSession.State.FAILED) { this.error = /** @type {remoting.ClientSession.ConnectionError} */ (error); } this.setState_(/** @type {remoting.ClientSession.State} */ (status)); }; /** * Callback that the plugin invokes to indicate when the connection is * ready. * * @private * @param {boolean} ready True if the connection is ready. */ remoting.ClientSession.prototype.onConnectionReady_ = function(ready) { if (!ready) { this.plugin.element().classList.add("session-client-inactive"); } else { this.plugin.element().classList.remove("session-client-inactive"); } } /** * @private * @param {remoting.ClientSession.State} newState The new state for the session. * @return {void} Nothing. */ remoting.ClientSession.prototype.setState_ = function(newState) { var oldState = this.state; this.state = newState; if (this.onStateChange) { this.onStateChange(oldState, newState); } // If connection errors are being suppressed from the logs, translate // FAILED to CLOSED here. This ensures that the duration is still logged. var state = this.state; if (this.state == remoting.ClientSession.State.FAILED && !this.logErrors_) { console.log('Suppressing error.'); state = remoting.ClientSession.State.CLOSED; } this.logToServer.logClientSessionStateChange(state, this.error, this.mode); }; /** * This is a callback that gets called when the window is resized. * * @return {void} Nothing. */ remoting.ClientSession.prototype.onResize = function() { this.updateDimensions(); if (this.notifyClientDimensionsTimer_) { window.clearTimeout(this.notifyClientDimensionsTimer_); this.notifyClientDimensionsTimer_ = null; } // Defer notifying the host of the change until the window stops resizing, to // avoid overloading the control channel with notifications. this.notifyClientDimensionsTimer_ = window.setTimeout( this.plugin.notifyClientDimensions.bind(this.plugin, window.innerWidth, window.innerHeight), 1000); // If bump-scrolling is enabled, adjust the plugin margins to fully utilize // the new window area. this.scroll_(0, 0); }; /** * Requests that the host pause or resume video updates. * * @param {boolean} pause True to pause video, false to resume. * @return {void} Nothing. */ remoting.ClientSession.prototype.pauseVideo = function(pause) { if (this.plugin) { this.plugin.pauseVideo(pause) } } /** * This is a callback that gets called when the plugin notifies us of a change * in the size of the remote desktop. * * @private * @return {void} Nothing. */ remoting.ClientSession.prototype.onDesktopSizeChanged_ = function() { console.log('desktop size changed: ' + this.plugin.desktopWidth + 'x' + this.plugin.desktopHeight +' @ ' + this.plugin.desktopXDpi + 'x' + this.plugin.desktopYDpi + ' DPI'); this.updateDimensions(); }; /** * Refreshes the plugin's dimensions, taking into account the sizes of the * remote desktop and client window, and the current scale-to-fit setting. * * @return {void} Nothing. */ remoting.ClientSession.prototype.updateDimensions = function() { if (this.plugin.desktopWidth == 0 || this.plugin.desktopHeight == 0) { return; } var windowWidth = window.innerWidth; var windowHeight = window.innerHeight; var scale = 1.0; if (this.getScaleToFit()) { var scaleFitWidth = 1.0 * windowWidth / this.plugin.desktopWidth; var scaleFitHeight = 1.0 * windowHeight / this.plugin.desktopHeight; scale = Math.min(1.0, scaleFitHeight, scaleFitWidth); } var width = this.plugin.desktopWidth * scale; var height = this.plugin.desktopHeight * scale; // Resize the plugin if necessary. this.plugin.element().width = width; this.plugin.element().height = height; // Position the container. // TODO(wez): We should take into account scrollbars when positioning. var parentNode = this.plugin.element().parentNode; if (width < windowWidth) { parentNode.style.left = (windowWidth - width) / 2 + 'px'; } else { parentNode.style.left = '0'; } if (height < windowHeight) { parentNode.style.top = (windowHeight - height) / 2 + 'px'; } else { parentNode.style.top = '0'; } console.log('plugin dimensions: ' + parentNode.style.left + ',' + parentNode.style.top + '-' + width + 'x' + height + '.'); }; /** * Returns an associative array with a set of stats for this connection. * * @return {remoting.ClientSession.PerfStats} The connection statistics. */ remoting.ClientSession.prototype.getPerfStats = function() { return this.plugin.getPerfStats(); }; /** * Logs statistics. * * @param {remoting.ClientSession.PerfStats} stats */ remoting.ClientSession.prototype.logStatistics = function(stats) { this.logToServer.logStatistics(stats, this.mode); }; /** * Enable or disable logging of connection errors. For example, if attempting * a connection using a cached JID, errors should not be logged because the * JID will be refreshed and the connection retried. * * @param {boolean} enable True to log errors; false to suppress them. */ remoting.ClientSession.prototype.logErrors = function(enable) { this.logErrors_ = enable; }; /** * Toggles between full-screen and windowed mode. * @return {void} Nothing. * @private */ remoting.ClientSession.prototype.toggleFullScreen_ = function() { if (document.webkitIsFullScreen) { document.webkitCancelFullScreen(); this.enableBumpScroll_(false); } else { document.body.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT); // Don't enable bump scrolling immediately because it can result in // onMouseMove firing before the webkitIsFullScreen property can be // read safely (crbug.com/132180). window.setTimeout(this.enableBumpScroll_.bind(this, true), 0); } }; /** * Updates the options menu to reflect the current scale-to-fit and full-screen * settings. * @return {void} Nothing. * @private */ remoting.ClientSession.prototype.onShowOptionsMenu_ = function() { remoting.MenuButton.select(this.shrinkToFit_, this.scaleToFit); remoting.MenuButton.select(this.originalSize_, !this.scaleToFit); remoting.MenuButton.select(this.fullScreen_, document.webkitIsFullScreen); }; /** * Scroll the client plugin by the specified amount, keeping it visible. * Note that this is only used in content full-screen mode (not windowed or * browser full-screen modes), where window.scrollBy and the scrollTop and * scrollLeft properties don't work. * @param {number} dx The amount by which to scroll horizontally. Positive to * scroll right; negative to scroll left. * @param {number} dy The amount by which to scroll vertically. Positive to * scroll down; negative to scroll up. * @return {boolean} True if the requested scroll had no effect because both * vertical and horizontal edges of the screen have been reached. * @private */ remoting.ClientSession.prototype.scroll_ = function(dx, dy) { var plugin = this.plugin.element(); var style = plugin.style; /** * Helper function for x- and y-scrolling * @param {number|string} curr The current margin, eg. "10px". * @param {number} delta The requested scroll amount. * @param {number} windowBound The size of the window, in pixels. * @param {number} pluginBound The size of the plugin, in pixels. * @param {{stop: boolean}} stop Reference parameter used to indicate when * the scroll has reached one of the edges and can be stopped in that * direction. * @return {string} The new margin value. */ var adjustMargin = function(curr, delta, windowBound, pluginBound, stop) { var minMargin = Math.min(0, windowBound - pluginBound); var result = (curr ? parseFloat(curr) : 0) - delta; result = Math.min(0, Math.max(minMargin, result)); stop.stop = (result == 0 || result == minMargin); return result + "px"; }; var stopX = { stop: false }; style.marginLeft = adjustMargin(style.marginLeft, dx, window.innerWidth, plugin.width, stopX); var stopY = { stop: false }; style.marginTop = adjustMargin(style.marginTop, dy, window.innerHeight, plugin.height, stopY); return stopX.stop && stopY.stop; } /** * Enable or disable bump-scrolling. * @private * @param {boolean} enable True to enable bump-scrolling, false to disable it. */ remoting.ClientSession.prototype.enableBumpScroll_ = function(enable) { if (enable) { /** @type {null|function(Event):void} */ this.onMouseMoveRef_ = this.onMouseMove_.bind(this); this.plugin.element().addEventListener('mousemove', this.onMouseMoveRef_, false); } else { this.plugin.element().removeEventListener('mousemove', this.onMouseMoveRef_, false); this.onMouseMoveRef_ = null; } }; /** * @param {Event} event The mouse event. * @private */ remoting.ClientSession.prototype.onMouseMove_ = function(event) { if (this.bumpScrollTimer_) { window.clearTimeout(this.bumpScrollTimer_); this.bumpScrollTimer_ = null; } // It's possible to leave content full-screen mode without using the Screen // Options menu, so we disable bump scrolling as soon as we detect this. if (!document.webkitIsFullScreen) { this.enableBumpScroll_(false); } /** * Compute the scroll speed based on how close the mouse is to the edge. * @param {number} mousePos The mouse x- or y-coordinate * @param {number} size The width or height of the content area. * @return {number} The scroll delta, in pixels. */ var computeDelta = function(mousePos, size) { var threshold = 10; if (mousePos >= size - threshold) { return 1 + 5 * (mousePos - (size - threshold)) / threshold; } else if (mousePos <= threshold) { return -1 - 5 * (threshold - mousePos) / threshold; } return 0; }; var dx = computeDelta(event.x, window.innerWidth); var dy = computeDelta(event.y, window.innerHeight); if (dx != 0 || dy != 0) { /** @type {remoting.ClientSession} */ var that = this; /** * Scroll the view, and schedule a timer to do so again unless we've hit * the edges of the screen. This timer is cancelled when the mouse moves. * @param {number} expected The time at which we expect to be called. */ var repeatScroll = function(expected) { /** @type {number} */ var now = new Date().getTime(); /** @type {number} */ var timeout = 10; var lateAdjustment = 1 + (now - expected) / timeout; if (!that.scroll_(lateAdjustment * dx, lateAdjustment * dy)) { that.bumpScrollTimer_ = window.setTimeout( function() { repeatScroll(now + timeout); }, timeout); } }; repeatScroll(new Date().getTime()); } };