// 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 */ 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 {?function(remoting.ClientSession.State, remoting.ClientSession.State):void} */ this.onStateChange_ = null; /** @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_ = 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.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'); 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); }; /** * @param {?function(remoting.ClientSession.State, remoting.ClientSession.State):void} onStateChange * The callback to invoke when the session changes state. */ remoting.ClientSession.prototype.setOnStateChange = function(onStateChange) { this.onStateChange_ = onStateChange; }; /** * 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. needsVerticalScroll = window.innerHeight < this.plugin_.desktopHeight; needsHorizontalScroll = window.innerWidth < this.plugin_.desktopWidth; var kScrollBarWidth = 16; if (needsHorizontalScroll && !needsVerticalScroll) { needsVerticalScroll = window.innerHeight - kScrollBarWidth < this.plugin_.desktopHeight; } else if (!needsHorizontalScroll && needsVerticalScroll) { needsHorizontalScroll = window.innerWidth - kScrollBarWidth < this.plugin_.desktopWidth; } } var htmlNode = /** @type {HTMLElement} */ (document.body.parentNode); if (needsHorizontalScroll) { htmlNode.classList.remove('no-horizontal-scroll'); } else { htmlNode.classList.add('no-horizontal-scroll'); } if (needsVerticalScroll) { htmlNode.classList.remove('no-vertical-scroll'); } else { htmlNode.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 }; /** @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'; // 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.<string>} * @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 . * @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 <embed> element to |container| and readies the sesion object. * * @param {Element} container The element to add the plugin to. */ remoting.ClientSession.prototype.createPluginAndConnect = function(container) { this.plugin_ = this.createClientPlugin_(container, this.PLUGIN_ID); remoting.HostSettings.load(this.hostId_, this.onHostSettingsLoaded_.bind(this)); }; /** * @param {Object.<string>} 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); } /** @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.plugin_.onSetCapabilitiesHandler = this.onSetCapabilities_.bind(this); this.initiateConnection_(); }; /** * Deletes the <embed> 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; } // Delete event handlers that aren't relevent when not connected. this.resizeToClientButton_.removeEventListener( 'click', this.callSetScreenMode_, false); this.shrinkToFitButton_.removeEventListener( 'click', this.callSetScreenMode_, false); this.fullScreenButton_.removeEventListener( 'click', this.callToggleFullScreen_, false); // In case the user had selected full-screen mode, cancel it now. document.webkitCancelFullScreen(); }; /** * Deletes the <embed> element from the container and disconnects. * * @param {boolean} isUserInitiated True for user-initiated disconnects, False * for disconnects due to connection failures. * @return {void} Nothing. */ remoting.ClientSession.prototype.disconnect = function(isUserInitiated) { if (isUserInitiated) { // 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.Error.NONE, this.mode_); } remoting.wcsSandbox.setOnIq(null); this.sendIq_( '<cli:iq ' + 'to="' + this.hostJid_ + '" ' + 'type="set" ' + 'id="session-terminate" ' + 'xmlns:cli="jabber:client">' + '<jingle ' + 'xmlns="urn:xmpp:jingle:1" ' + 'action="session-terminate" ' + 'sid="' + this.sessionId_ + '">' + '<reason><success/></reason>' + '</jingle>' + '</cli:iq>'); this.removePlugin(); }; /** * @return {remoting.ClientSession.Mode} The current state. */ remoting.ClientSession.prototype.getMode = function() { return this.mode_; }; /** * @return {remoting.ClientSession.State} The current state. */ remoting.ClientSession.prototype.getState = function() { return this.state_; }; /** * @return {remoting.Error} The current error code. */ remoting.ClientSession.prototype.getError = function() { return this.error_; }; /** * 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]); } /** * Sets and stores the key remapping setting for the current host. * * @param {string} remappings Comma separated list of key remappings. */ remoting.ClientSession.prototype.setRemapKeys = function(remappings) { // Cancel any existing remappings and apply the new ones. this.applyRemapKeys_(false); this.remapKeys_ = remappings; this.applyRemapKeys_(true); // Save the new remapping setting. var options = {}; options[remoting.ClientSession.KEY_REMAP_KEYS] = this.remapKeys_; remoting.HostSettings.save(this.hostId_, options); } /** * Applies the configured key remappings to the session, or resets them. * * @param {boolean} apply True to apply remappings, false to cancel them. */ remoting.ClientSession.prototype.applyRemapKeys_ = function(apply) { // By default, under ChromeOS, remap the right Control key to the right // Win / Cmd key. var remapKeys = this.remapKeys_; if (remapKeys == '' && remoting.runningOnChromeOS()) { remapKeys = '0x0700e4>0x0700e7'; } var remappings = remapKeys.split(','); for (var i = 0; i < remappings.length; ++i) { var keyCodes = remappings[i].split('>'); if (keyCodes.length != 2) { console.log('bad remapKey: ' + remappings[i]); continue; } var fromKey = parseInt(keyCodes[0], 0); var toKey = parseInt(keyCodes[1], 0); if (!fromKey || !toKey) { console.log('bad remapKey code: ' + remappings[i]); continue; } if (apply) { console.log('remapKey 0x' + fromKey.toString(16) + '>0x' + toKey.toString(16)); this.plugin_.remapKey(fromKey, toKey); } else { console.log('cancel remapKey 0x' + fromKey.toString(16)); this.plugin_.remapKey(fromKey, fromKey); } } } /** * Callback for the two "screen mode" related menu items: Resize desktop to * fit and Shrink to fit. * * @param {Event} event The click event indicating which mode was selected. * @return {void} Nothing. * @private */ remoting.ClientSession.prototype.onSetScreenMode_ = function(event) { var shrinkToFit = this.shrinkToFit_; var resizeToClient = this.resizeToClient_; if (event.target == this.shrinkToFitButton_) { shrinkToFit = !shrinkToFit; } if (event.target == this.resizeToClientButton_) { resizeToClient = !resizeToClient; } this.setScreenMode_(shrinkToFit, resizeToClient); }; /** * Set the shrink-to-fit and resize-to-client flags and save them if this is * a Me2Me connection. * * @param {boolean} shrinkToFit True if the remote desktop should be scaled * down if it is larger than the client window; false if scroll-bars * should be added in this case. * @param {boolean} resizeToClient True if window resizes should cause the * host to attempt to resize its desktop to match the client window size; * false to disable this behaviour for subsequent window resizes--the * current host desktop size is not restored in this case. * @return {void} Nothing. * @private */ remoting.ClientSession.prototype.setScreenMode_ = function(shrinkToFit, resizeToClient) { if (resizeToClient && !this.resizeToClient_) { this.plugin_.notifyClientResolution(window.innerWidth, window.innerHeight, window.devicePixelRatio); } // If enabling shrink, reset bump-scroll offsets. var needsScrollReset = shrinkToFit && !this.shrinkToFit_; this.shrinkToFit_ = shrinkToFit; this.resizeToClient_ = resizeToClient; this.updateScrollbarVisibility(); if (this.hostId_ != '') { var options = {}; options[remoting.ClientSession.KEY_SHRINK_TO_FIT] = this.shrinkToFit_; options[remoting.ClientSession.KEY_RESIZE_TO_CLIENT] = this.resizeToClient_; remoting.HostSettings.save(this.hostId_, options); } this.updateDimensions(); if (needsScrollReset) { this.scroll_(0, 0); } } /** * 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) { // 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'); } } // HACK: Add 'x' prefix to the IDs of the outgoing messages to make sure that // stanza IDs used by host and client do not match. This is necessary to // workaround bug in the signaling endpoint used by chromoting. // TODO(sergeyu): Remove this hack once the server-side bug is fixed. var type = iqNode.getAttribute('type'); if (type == 'set') { var id = iqNode.getAttribute('id'); iqNode.setAttribute('id', 'x' + id); msg = (new XMLSerializer()).serializeToString(iqNode); } console.log(remoting.timestamp(), remoting.formatIq.prettifySendIq(msg)); // Send the stanza. remoting.wcsSandbox.sendIq(msg); }; remoting.ClientSession.prototype.initiateConnection_ = function() { /** @type {remoting.ClientSession} */ var that = this; remoting.wcsSandbox.connect(onWcsConnected, this.resetWithError_.bind(this)); /** @param {string} localJid Local JID. */ function onWcsConnected(localJid) { that.connectPluginToWcs_(localJid); that.getSharedSecret_(onSharedSecretReceived.bind(null, localJid)); } /** @param {string} localJid Local JID. * @param {string} sharedSecret Shared secret. */ function onSharedSecretReceived(localJid, sharedSecret) { that.plugin_.connect( that.hostJid_, that.hostPublicKey_, localJid, sharedSecret, that.authenticationMethods_, that.hostId_, that.clientPairingId_, that.clientPairedSecret_); }; } /** * Connects the plugin to WCS. * * @private * @param {string} localJid Local JID. * @return {void} Nothing. */ remoting.ClientSession.prototype.connectPluginToWcs_ = function(localJid) { remoting.formatIq.setJids(localJid, this.hostJid_); var forwardIq = this.plugin_.onIncomingIq.bind(this.plugin_); /** @param {string} stanza The IQ stanza received. */ var onIncomingIq = function(stanza) { // HACK: Remove 'x' prefix added to the id in sendIq_(). try { var parser = new DOMParser(); var iqNode = parser.parseFromString(stanza, 'text/xml').firstChild; var type = iqNode.getAttribute('type'); var id = iqNode.getAttribute('id'); if (type != 'set' && id.charAt(0) == 'x') { iqNode.setAttribute('id', id.substr(1)); stanza = (new XMLSerializer()).serializeToString(iqNode); } } catch (err) { // Pass message as is when it is malformed. } console.log(remoting.timestamp(), remoting.formatIq.prettifyReceiveIq(stanza)); forwardIq(stanza); }; remoting.wcsSandbox.setOnIq(onIncomingIq); } /** * Gets shared secret to be used for connection. * * @param {function(string)} callback Callback called with the shared secret. * @return {void} Nothing. * @private */ remoting.ClientSession.prototype.getSharedSecret_ = function(callback) { /** @type remoting.ClientSession */ var that = this; if (this.plugin_.hasFeature(remoting.ClientPlugin.Feature.THIRD_PARTY_AUTH)) { /** @type{function(string, string, string): void} */ var fetchThirdPartyToken = function(tokenUrl, hostPublicKey, scope) { that.fetchThirdPartyToken_( tokenUrl, hostPublicKey, scope, that.plugin_.onThirdPartyTokenFetched.bind(that.plugin_)); }; this.plugin_.fetchThirdPartyTokenHandler = fetchThirdPartyToken; } if (this.accessCode_) { // Shared secret was already supplied before connecting (It2Me case). callback(this.accessCode_); } else if (this.plugin_.hasFeature( remoting.ClientPlugin.Feature.ASYNC_PIN)) { // Plugin supports asynchronously asking for the PIN. this.plugin_.useAsyncPinDialog(); /** @param {boolean} pairingSupported */ var fetchPin = function(pairingSupported) { that.fetchPin_(pairingSupported, that.plugin_.onPinFetched.bind(that.plugin_)); }; this.plugin_.fetchPinHandler = fetchPin; callback(''); } else { // Clients that don't support asking for a PIN asynchronously also don't // support pairing, so request the PIN now without offering to remember it. this.fetchPin_(false, callback); } }; /** * 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.setFocusHandlers_(); this.onDesktopSizeChanged_(); if (this.resizeToClient_) { this.plugin_.notifyClientResolution(window.innerWidth, window.innerHeight, window.devicePixelRatio); } } else if (status == remoting.ClientSession.State.FAILED) { switch (error) { case remoting.ClientSession.ConnectionError.HOST_IS_OFFLINE: this.error_ = remoting.Error.HOST_IS_OFFLINE; break; case remoting.ClientSession.ConnectionError.SESSION_REJECTED: this.error_ = remoting.Error.INVALID_ACCESS_CODE; break; case remoting.ClientSession.ConnectionError.INCOMPATIBLE_PROTOCOL: this.error_ = remoting.Error.INCOMPATIBLE_PROTOCOL; break; case remoting.ClientSession.ConnectionError.NETWORK_FAILURE: this.error_ = remoting.Error.P2P_FAILURE; break; case remoting.ClientSession.ConnectionError.HOST_OVERLOAD: this.error_ = remoting.Error.HOST_OVERLOAD; break; default: this.error_ = remoting.Error.UNEXPECTED; } } 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"); } }; /** * Called when the client-host capabilities negotiation is complete. * * @param {!Array.<string>} capabilities The set of capabilities negotiated * between the client and host. * @return {void} Nothing. * @private */ remoting.ClientSession.prototype.onSetCapabilities_ = function(capabilities) { if (this.capabilities_ != null) { console.error('onSetCapabilities_() is called more than once'); return; } this.capabilities_ = capabilities; if (this.hasCapability_( remoting.ClientSession.Capability.SEND_INITIAL_RESOLUTION)) { this.plugin_.notifyClientResolution(window.innerWidth, window.innerHeight, window.devicePixelRatio); } }; /** * @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; var state = this.state_; if (oldState == remoting.ClientSession.State.CONNECTING) { if (this.state_ == remoting.ClientSession.State.CLOSED) { state = remoting.ClientSession.State.CONNECTION_CANCELED; } else if (this.state_ == remoting.ClientSession.State.FAILED && this.error_ == remoting.Error.HOST_IS_OFFLINE && !this.logHostOfflineErrors_) { // The application requested host-offline errors to be suppressed, for // example, because this connection attempt is using a cached host JID. console.log('Suppressing host-offline error.'); state = remoting.ClientSession.State.CONNECTION_CANCELED; } } else if (oldState == remoting.ClientSession.State.CONNECTED && this.state_ == remoting.ClientSession.State.FAILED) { state = remoting.ClientSession.State.CONNECTION_DROPPED; } this.logToServer.logClientSessionStateChange(state, this.error_, this.mode_); if (this.onStateChange_) { this.onStateChange_(oldState, newState); } }; /** * This is a callback that gets called when the window is resized. * * @return {void} Nothing. */ remoting.ClientSession.prototype.onResize = function() { this.updateDimensions(); if (this.notifyClientResolutionTimer_) { window.clearTimeout(this.notifyClientResolutionTimer_); this.notifyClientResolutionTimer_ = null; } // Defer notifying the host of the change until the window stops resizing, to // avoid overloading the control channel with notifications. if (this.resizeToClient_) { var kResizeRateLimitMs = 1000; if (this.hasCapability_( remoting.ClientSession.Capability.RATE_LIMIT_RESIZE_REQUESTS)) { kResizeRateLimitMs = 250; } this.notifyClientResolutionTimer_ = window.setTimeout( this.plugin_.notifyClientResolution.bind(this.plugin_, window.innerWidth, window.innerHeight, window.devicePixelRatio), kResizeRateLimitMs); } // If bump-scrolling is enabled, adjust the plugin margins to fully utilize // the new window area. this.scroll_(0, 0); this.updateScrollbarVisibility(); }; /** * 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) } } /** * Requests that the host pause or resume audio. * * @param {boolean} pause True to pause audio, false to resume. * @return {void} Nothing. */ remoting.ClientSession.prototype.pauseAudio = function(pause) { if (this.plugin_) { this.plugin_.pauseAudio(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(); this.updateScrollbarVisibility(); }; /** * 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 desktopWidth = this.plugin_.desktopWidth; var desktopHeight = this.plugin_.desktopHeight; // When configured to display a host at its original size, we aim to display // it as close to its physical size as possible, without losing data: // - If client and host have matching DPI, render the host pixel-for-pixel. // - If the host has higher DPI then still render pixel-for-pixel. // - If the host has lower DPI then let Chrome up-scale it to natural size. // We specify the plugin dimensions in Density-Independent Pixels, so to // render pixel-for-pixel we need to down-scale the host dimensions by the // devicePixelRatio of the client. To match the host pixel density, we choose // an initial scale factor based on the client devicePixelRatio and host DPI. // Determine the effective device pixel ratio of the host, based on DPI. var hostPixelRatioX = Math.ceil(this.plugin_.desktopXDpi / 96); var hostPixelRatioY = Math.ceil(this.plugin_.desktopYDpi / 96); var hostPixelRatio = Math.min(hostPixelRatioX, hostPixelRatioY); // Down-scale by the smaller of the client and host ratios. var scale = 1.0 / Math.min(window.devicePixelRatio, hostPixelRatio); if (this.shrinkToFit_) { // Reduce the scale, if necessary, to fit the whole desktop in the window. var scaleFitWidth = Math.min(scale, 1.0 * windowWidth / desktopWidth); var scaleFitHeight = Math.min(scale, 1.0 * windowHeight / desktopHeight); scale = Math.min(scaleFitHeight, scaleFitWidth); // If we're running full-screen then try to handle common side-by-side // multi-monitor combinations more intelligently. if (document.webkitIsFullScreen) { // If the host has two monitors each the same size as the client then // scale-to-fit will have the desktop occupy only 50% of the client area, // in which case it would be preferable to down-scale less and let the // user bump-scroll around ("scale-and-pan"). // Triggering scale-and-pan if less than 65% of the client area would be // used adds enough fuzz to cope with e.g. 1280x800 client connecting to // a (2x1280)x1024 host nicely. // Note that we don't need to account for scrollbars while fullscreen. if (scale <= scaleFitHeight * 0.65) { scale = scaleFitHeight; } if (scale <= scaleFitWidth * 0.65) { scale = scaleFitWidth; } } } var pluginWidth = desktopWidth * scale; var pluginHeight = desktopHeight * scale; // Resize the plugin if necessary. // TODO(wez): Handle high-DPI to high-DPI properly (crbug.com/135089). this.plugin_.element().width = pluginWidth; this.plugin_.element().height = pluginHeight; // Position the container. // Note that clientWidth/Height take into account scrollbars. var clientWidth = document.documentElement.clientWidth; var clientHeight = document.documentElement.clientHeight; var parentNode = this.plugin_.element().parentNode; if (pluginWidth < clientWidth) { parentNode.style.left = (clientWidth - pluginWidth) / 2 + 'px'; } else { parentNode.style.left = '0'; } if (pluginHeight < clientHeight) { parentNode.style.top = (clientHeight - pluginHeight) / 2 + 'px'; } else { parentNode.style.top = '0'; } console.log('plugin dimensions: ' + parentNode.style.left + ',' + parentNode.style.top + '-' + pluginWidth + 'x' + pluginHeight + '.'); }; /** * 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 due to a host being offline. * For example, if attempting a connection using a cached JID, host-offline * errors should not be logged because the JID will be refreshed and the * connection retried. * * @param {boolean} enable True to log host-offline errors; false to suppress. */ remoting.ClientSession.prototype.logHostOfflineErrors = function(enable) { this.logHostOfflineErrors_ = enable; }; /** * Request pairing with the host for PIN-less authentication. * * @param {string} clientName The human-readable name of the client. * @param {function(string, string):void} onDone Callback to receive the * client id and shared secret when they are available. */ remoting.ClientSession.prototype.requestPairing = function(clientName, onDone) { if (this.plugin_) { this.plugin_.requestPairing(clientName, onDone); } }; /** * 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.resizeToClientButton_, this.resizeToClient_); remoting.MenuButton.select(this.shrinkToFitButton_, this.shrinkToFit_); remoting.MenuButton.select(this.fullScreenButton_, 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. When disabling bump scrolling, also reset * the scroll offsets to (0, 0). * @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; this.plugin_.element().style.marginLeft = 0; this.plugin_.element().style.marginTop = 0; } }; /** * @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()); } }; /** * Sends a clipboard item to the host. * * @param {string} mimeType The MIME type of the clipboard item. * @param {string} item The clipboard item. */ remoting.ClientSession.prototype.sendClipboardItem = function(mimeType, item) { if (!this.plugin_) return; this.plugin_.sendClipboardItem(mimeType, item) };