// 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} accessCode The IT2Me access code. Blank for Me2Me. * @param {function(boolean, function(string): void): void} fetchPin * Called by Me2Me connections when a PIN needs to be obtained * interactively. * @param {function(string, string, string, * function(string, string): void): void} * fetchThirdPartyToken Called by Me2Me connections when a third party * authentication token must be obtained. * @param {string} authenticationMethods Comma-separated list of * authentication methods the client should attempt to use. * @param {string} hostId The host identifier for Me2Me, or empty for IT2Me. * Mixed into authentication hashes for some authentication methods. * @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 {remoting.ClientSession.Mode} mode The mode of this connection. * @param {string} clientPairingId For paired Me2Me connections, the * pairing id for this client, as issued by the host. * @param {string} clientPairedSecret For paired Me2Me connections, the * paired secret for this client, as issued by the host. * @constructor * @extends {base.EventSource} */ remoting.ClientSession = function(accessCode, fetchPin, fetchThirdPartyToken, authenticationMethods, hostId, hostJid, hostPublicKey, mode, clientPairingId, clientPairedSecret) { /** @private */ this.state_ = remoting.ClientSession.State.CREATED; /** @private */ this.error_ = remoting.Error.NONE; /** @private */ this.hostJid_ = hostJid; /** @private */ this.hostPublicKey_ = hostPublicKey; /** @private */ this.accessCode_ = accessCode; /** @private */ this.fetchPin_ = fetchPin; /** @private */ this.fetchThirdPartyToken_ = fetchThirdPartyToken; /** @private */ this.authenticationMethods_ = authenticationMethods; /** @private */ this.hostId_ = hostId; /** @private */ this.mode_ = mode; /** @private */ this.clientPairingId_ = clientPairingId; /** @private */ this.clientPairedSecret_ = clientPairedSecret; /** @private */ this.sessionId_ = ''; /** @type {remoting.ClientPlugin} * @private */ this.plugin_ = null; /** @private */ this.shrinkToFit_ = true; /** @private */ this.resizeToClient_ = true; /** @private */ this.remapKeys_ = ''; /** @private */ this.hasReceivedFrame_ = false; this.logToServer = new remoting.LogToServer(); /** @type {number?} @private */ this.notifyClientResolutionTimer_ = null; /** @type {number?} @private */ this.bumpScrollTimer_ = null; /** * Allow host-offline error reporting to be suppressed in situations where it * would not be useful, for example, when using a cached host JID. * * @type {boolean} @private */ this.logHostOfflineErrors_ = true; /** @private */ this.callPluginLostFocus_ = this.pluginLostFocus_.bind(this); /** @private */ this.callPluginGotFocus_ = this.pluginGotFocus_.bind(this); /** @private */ this.callSetScreenMode_ = this.onSetScreenMode_.bind(this); /** @private */ this.callToggleFullScreen_ = remoting.fullscreen.toggle.bind( remoting.fullscreen); /** @private */ this.callOnFullScreenChanged_ = this.onFullScreenChanged_.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 {HTMLMediaElement} @private */ this.video_ = null; /** @type {HTMLElement} @private */ this.resizeToClientButton_ = document.getElementById('screen-resize-to-client'); /** @type {HTMLElement} @private */ this.shrinkToFitButton_ = document.getElementById('screen-shrink-to-fit'); /** @type {HTMLElement} @private */ this.fullScreenButton_ = document.getElementById('toggle-full-screen'); /** @type {remoting.GnubbyAuthHandler} @private */ this.gnubbyAuthHandler_ = null; if (this.mode_ == remoting.ClientSession.Mode.IT2ME) { // Resize-to-client is not supported for IT2Me hosts. this.resizeToClientButton_.hidden = true; } else { this.resizeToClientButton_.hidden = false; this.resizeToClientButton_.addEventListener( 'click', this.callSetScreenMode_, false); } this.shrinkToFitButton_.addEventListener( 'click', this.callSetScreenMode_, false); this.fullScreenButton_.addEventListener( 'click', this.callToggleFullScreen_, false); this.defineEvents(Object.keys(remoting.ClientSession.Events)); }; base.extend(remoting.ClientSession, base.EventSource); /** @enum {string} */ remoting.ClientSession.Events = { stateChanged: 'stateChanged', videoChannelStateChanged: 'videoChannelStateChanged' }; /** * Called when the window or desktop size or the scaling settings change, * to set the scroll-bar visibility. * * TODO(jamiewalch): crbug.com/252796: Remove this once crbug.com/240772 is * fixed. */ remoting.ClientSession.prototype.updateScrollbarVisibility = function() { var needsVerticalScroll = false; var needsHorizontalScroll = false; if (!this.shrinkToFit_) { // Determine whether or not horizontal or vertical scrollbars are // required, taking into account their width. var clientArea = this.getClientArea_(); needsVerticalScroll = clientArea.height < this.plugin_.desktopHeight; needsHorizontalScroll = clientArea.width < this.plugin_.desktopWidth; var kScrollBarWidth = 16; if (needsHorizontalScroll && !needsVerticalScroll) { needsVerticalScroll = clientArea.height - kScrollBarWidth < this.plugin_.desktopHeight; } else if (!needsHorizontalScroll && needsVerticalScroll) { needsHorizontalScroll = clientArea.width - kScrollBarWidth < this.plugin_.desktopWidth; } } var scroller = document.getElementById('scroller'); if (needsHorizontalScroll) { scroller.classList.remove('no-horizontal-scroll'); } else { scroller.classList.add('no-horizontal-scroll'); } if (needsVerticalScroll) { scroller.classList.remove('no-vertical-scroll'); } else { scroller.classList.add('no-vertical-scroll'); } }; // 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 state transitions that occur within the web-app that have // no corresponding plugin state transition. /** @enum {number} */ remoting.ClientSession.State = { CONNECTION_CANCELED: -3, // Connection closed (gracefully) before connecting. CONNECTION_DROPPED: -2, // Succeeded, but subsequently closed with an error. CREATED: -1, UNKNOWN: 0, CONNECTING: 1, INITIALIZING: 2, CONNECTED: 3, CLOSED: 4, FAILED: 5 }; /** * @param {string} state The state name. * @return {remoting.ClientSession.State} The session state enum value. */ remoting.ClientSession.State.fromString = function(state) { if (!remoting.ClientSession.State.hasOwnProperty(state)) { throw "Invalid ClientSession.State: " + state; } return remoting.ClientSession.State[state]; }; /** @constructor @param {remoting.ClientSession.State} current @param {remoting.ClientSession.State} previous */ remoting.ClientSession.StateEvent = function(current, previous) { /** @type {remoting.ClientSession.State} */ this.previous = previous /** @type {remoting.ClientSession.State} */ this.current = current; }; /** @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 }; /** * @param {string} error The connection error name. * @return {remoting.ClientSession.ConnectionError} The connection error enum. */ remoting.ClientSession.ConnectionError.fromString = function(error) { if (!remoting.ClientSession.ConnectionError.hasOwnProperty(error)) { console.error('Unexpected ClientSession.ConnectionError string: ', error); return remoting.ClientSession.ConnectionError.UNKNOWN; } return remoting.ClientSession.ConnectionError[error]; } // 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'; // Keys for per-host settings. remoting.ClientSession.KEY_REMAP_KEYS = 'remapKeys'; remoting.ClientSession.KEY_RESIZE_TO_CLIENT = 'resizeToClient'; remoting.ClientSession.KEY_SHRINK_TO_FIT = 'shrinkToFit'; /** * The id of the client plugin * * @const */ remoting.ClientSession.prototype.PLUGIN_ID = 'session-client-plugin'; /** * Set of capabilities for which hasCapability_() can be used to test. * * @enum {string} */ remoting.ClientSession.Capability = { // When enabled this capability causes the client to send its screen // resolution to the host once connection has been established. See // this.plugin_.notifyClientResolution(). SEND_INITIAL_RESOLUTION: 'sendInitialResolution', RATE_LIMIT_RESIZE_REQUESTS: 'rateLimitResizeRequests' }; /** * The set of capabilities negotiated between the client and host. * @type {Array.} * @private */ remoting.ClientSession.prototype.capabilities_ = null; /** * @param {remoting.ClientSession.Capability} capability The capability to test * for. * @return {boolean} True if the capability has been negotiated between * the client and host. * @private */ remoting.ClientSession.prototype.hasCapability_ = function(capability) { if (this.capabilities_ == null) return false; return this.capabilities_.indexOf(capability) > -1; }; /** * @param {Element} container The element to add the plugin to. * @param {string} id Id to use for the plugin element . * @param {function(string, string):boolean} onExtensionMessage The handler for * protocol extension messages. Returns true if a message is recognized; * false otherwise. * @return {remoting.ClientPlugin} Create plugin object for the locally * installed plugin. */ remoting.ClientSession.prototype.createClientPlugin_ = function(container, id, onExtensionMessage) { var plugin = /** @type {remoting.ViewerPlugin} */ document.createElement('embed'); plugin.id = id; if (remoting.settings.CLIENT_PLUGIN_TYPE == 'pnacl') { plugin.src = 'remoting_client_pnacl.nmf'; plugin.type = 'application/x-pnacl'; } else if (remoting.settings.CLIENT_PLUGIN_TYPE == 'nacl') { plugin.src = 'remoting_client_nacl.nmf'; plugin.type = 'application/x-nacl'; } else { 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.ClientPlugin(plugin, onExtensionMessage); }; /** * 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. // Due to crbug.com/246335, we can't restore the focus immediately, // otherwise the plugin gets confused about whether or not it has focus. window.setTimeout( this.plugin_.element().focus.bind(this.plugin_.element()), 0); } } }; /** * Adds element to |container| and readies the sesion object. * * @param {Element} container The element to add the plugin to. * @param {function(string, string):boolean} onExtensionMessage The handler for * protocol extension messages. Returns true if a message is recognized; * false otherwise. */ remoting.ClientSession.prototype.createPluginAndConnect = function(container, onExtensionMessage) { this.plugin_ = this.createClientPlugin_(container, this.PLUGIN_ID, onExtensionMessage); remoting.HostSettings.load(this.hostId_, this.onHostSettingsLoaded_.bind(this)); }; /** * @param {Object.} options The current options for the host, or {} * if this client has no saved settings for the host. * @private */ remoting.ClientSession.prototype.onHostSettingsLoaded_ = function(options) { if (remoting.ClientSession.KEY_REMAP_KEYS in options && typeof(options[remoting.ClientSession.KEY_REMAP_KEYS]) == 'string') { this.remapKeys_ = /** @type {string} */ options[remoting.ClientSession.KEY_REMAP_KEYS]; } if (remoting.ClientSession.KEY_RESIZE_TO_CLIENT in options && typeof(options[remoting.ClientSession.KEY_RESIZE_TO_CLIENT]) == 'boolean') { this.resizeToClient_ = /** @type {boolean} */ options[remoting.ClientSession.KEY_RESIZE_TO_CLIENT]; } if (remoting.ClientSession.KEY_SHRINK_TO_FIT in options && typeof(options[remoting.ClientSession.KEY_SHRINK_TO_FIT]) == 'boolean') { this.shrinkToFit_ = /** @type {boolean} */ options[remoting.ClientSession.KEY_SHRINK_TO_FIT]; } /** @param {boolean} result */ this.plugin_.initialize(this.onPluginInitialized_.bind(this)); }; /** * Constrains the focus to the plugin element. * @private */ remoting.ClientSession.prototype.setFocusHandlers_ = function() { this.plugin_.element().addEventListener( 'focus', this.callPluginGotFocus_, false); this.plugin_.element().addEventListener( 'blur', this.callPluginLostFocus_, false); this.plugin_.element().focus(); }; /** * @param {remoting.Error} error */ remoting.ClientSession.prototype.resetWithError_ = function(error) { this.plugin_.cleanup(); delete this.plugin_; this.error_ = error; this.setState_(remoting.ClientSession.State.FAILED); } /** * @param {boolean} initialized */ remoting.ClientSession.prototype.onPluginInitialized_ = function(initialized) { if (!initialized) { console.error('ERROR: remoting plugin not loaded'); this.resetWithError_(remoting.Error.MISSING_PLUGIN); return; } if (!this.plugin_.isSupportedVersion()) { this.resetWithError_(remoting.Error.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; } // Apply customized key remappings if the plugin supports remapKeys. if (this.plugin_.hasFeature(remoting.ClientPlugin.Feature.REMAP_KEY)) { this.applyRemapKeys_(true); } // Enable MediaSource-based rendering if available. if (remoting.settings.USE_MEDIA_SOURCE_RENDERING && this.plugin_.hasFeature( remoting.ClientPlugin.Feature.MEDIA_SOURCE_RENDERING)) { this.video_ = /** @type {HTMLMediaElement} */( document.getElementById('mediasource-video-output')); // Make sure that the