• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview
7 * Class handling creation and teardown of a remoting client session.
8 *
9 * The ClientSession class controls lifetime of the client plugin
10 * object and provides the plugin with the functionality it needs to
11 * establish connection. Specifically it:
12 *  - Delivers incoming/outgoing signaling messages,
13 *  - Adjusts plugin size and position when destop resolution changes,
14 *
15 * This class should not access the plugin directly, instead it should
16 * do it through ClientPlugin class which abstracts plugin version
17 * differences.
18 */
19
20'use strict';
21
22/** @suppress {duplicate} */
23var remoting = remoting || {};
24
25/**
26 * @param {string} accessCode The IT2Me access code. Blank for Me2Me.
27 * @param {function(boolean, function(string): void): void} fetchPin
28 *     Called by Me2Me connections when a PIN needs to be obtained
29 *     interactively.
30 * @param {function(string, string, string,
31 *                  function(string, string): void): void}
32 *     fetchThirdPartyToken Called by Me2Me connections when a third party
33 *     authentication token must be obtained.
34 * @param {string} authenticationMethods Comma-separated list of
35 *     authentication methods the client should attempt to use.
36 * @param {string} hostId The host identifier for Me2Me, or empty for IT2Me.
37 *     Mixed into authentication hashes for some authentication methods.
38 * @param {string} hostJid The jid of the host to connect to.
39 * @param {string} hostPublicKey The base64 encoded version of the host's
40 *     public key.
41 * @param {remoting.ClientSession.Mode} mode The mode of this connection.
42 * @param {string} clientPairingId For paired Me2Me connections, the
43 *     pairing id for this client, as issued by the host.
44 * @param {string} clientPairedSecret For paired Me2Me connections, the
45 *     paired secret for this client, as issued by the host.
46 * @constructor
47 * @extends {base.EventSource}
48 */
49remoting.ClientSession = function(accessCode, fetchPin, fetchThirdPartyToken,
50                                  authenticationMethods,
51                                  hostId, hostJid, hostPublicKey, mode,
52                                  clientPairingId, clientPairedSecret) {
53  /** @private */
54  this.state_ = remoting.ClientSession.State.CREATED;
55
56  /** @private */
57  this.error_ = remoting.Error.NONE;
58
59  /** @private */
60  this.hostJid_ = hostJid;
61  /** @private */
62  this.hostPublicKey_ = hostPublicKey;
63  /** @private */
64  this.accessCode_ = accessCode;
65  /** @private */
66  this.fetchPin_ = fetchPin;
67  /** @private */
68  this.fetchThirdPartyToken_ = fetchThirdPartyToken;
69  /** @private */
70  this.authenticationMethods_ = authenticationMethods;
71  /** @private */
72  this.hostId_ = hostId;
73  /** @private */
74  this.mode_ = mode;
75  /** @private */
76  this.clientPairingId_ = clientPairingId;
77  /** @private */
78  this.clientPairedSecret_ = clientPairedSecret;
79  /** @private */
80  this.sessionId_ = '';
81  /** @type {remoting.ClientPlugin}
82    * @private */
83  this.plugin_ = null;
84  /** @private */
85  this.shrinkToFit_ = true;
86  /** @private */
87  this.resizeToClient_ = true;
88  /** @private */
89  this.remapKeys_ = '';
90  /** @private */
91  this.hasReceivedFrame_ = false;
92  this.logToServer = new remoting.LogToServer();
93
94  /** @type {number?} @private */
95  this.notifyClientResolutionTimer_ = null;
96  /** @type {number?} @private */
97  this.bumpScrollTimer_ = null;
98
99  /**
100   * Allow host-offline error reporting to be suppressed in situations where it
101   * would not be useful, for example, when using a cached host JID.
102   *
103   * @type {boolean} @private
104   */
105  this.logHostOfflineErrors_ = true;
106
107  /** @private */
108  this.callPluginLostFocus_ = this.pluginLostFocus_.bind(this);
109  /** @private */
110  this.callPluginGotFocus_ = this.pluginGotFocus_.bind(this);
111  /** @private */
112  this.callSetScreenMode_ = this.onSetScreenMode_.bind(this);
113  /** @private */
114  this.callToggleFullScreen_ = remoting.fullscreen.toggle.bind(
115      remoting.fullscreen);
116  /** @private */
117  this.callOnFullScreenChanged_ = this.onFullScreenChanged_.bind(this)
118
119  /** @private */
120  this.screenOptionsMenu_ = new remoting.MenuButton(
121      document.getElementById('screen-options-menu'),
122      this.onShowOptionsMenu_.bind(this));
123  /** @private */
124  this.sendKeysMenu_ = new remoting.MenuButton(
125      document.getElementById('send-keys-menu')
126  );
127
128  /** @type {HTMLMediaElement} @private */
129  this.video_ = null;
130
131  /** @type {Element} @private */
132  this.container_ = document.getElementById('video-container');
133
134  /** @type {Element} @private */
135  this.mouseCursorOverlay_ =
136      this.container_.querySelector('.mouse-cursor-overlay');
137
138  /** @type {Element} */
139  var img = this.mouseCursorOverlay_;
140  /** @param {Event} event @private */
141  this.updateMouseCursorPosition_ = function(event) {
142    img.style.top = event.y + 'px';
143    img.style.left = event.x + 'px';
144  };
145
146  /** @type {HTMLElement} @private */
147  this.resizeToClientButton_ =
148      document.getElementById('screen-resize-to-client');
149  /** @type {HTMLElement} @private */
150  this.shrinkToFitButton_ = document.getElementById('screen-shrink-to-fit');
151  /** @type {HTMLElement} @private */
152  this.fullScreenButton_ = document.getElementById('toggle-full-screen');
153
154  /** @type {remoting.GnubbyAuthHandler} @private */
155  this.gnubbyAuthHandler_ = null;
156
157  if (this.mode_ == remoting.ClientSession.Mode.IT2ME) {
158    // Resize-to-client is not supported for IT2Me hosts.
159    this.resizeToClientButton_.hidden = true;
160  } else {
161    this.resizeToClientButton_.hidden = false;
162    this.resizeToClientButton_.addEventListener(
163        'click', this.callSetScreenMode_, false);
164  }
165
166  this.shrinkToFitButton_.addEventListener(
167      'click', this.callSetScreenMode_, false);
168  this.fullScreenButton_.addEventListener(
169      'click', this.callToggleFullScreen_, false);
170  this.defineEvents(Object.keys(remoting.ClientSession.Events));
171};
172
173base.extend(remoting.ClientSession, base.EventSource);
174
175/** @enum {string} */
176remoting.ClientSession.Events = {
177  stateChanged: 'stateChanged',
178  videoChannelStateChanged: 'videoChannelStateChanged'
179};
180
181/**
182 * Called when the window or desktop size or the scaling settings change,
183 * to set the scroll-bar visibility.
184 *
185 * TODO(jamiewalch): crbug.com/252796: Remove this once crbug.com/240772 is
186 * fixed.
187 */
188remoting.ClientSession.prototype.updateScrollbarVisibility = function() {
189  var needsVerticalScroll = false;
190  var needsHorizontalScroll = false;
191  if (!this.shrinkToFit_) {
192    // Determine whether or not horizontal or vertical scrollbars are
193    // required, taking into account their width.
194    var clientArea = this.getClientArea_();
195    needsVerticalScroll = clientArea.height < this.plugin_.desktopHeight;
196    needsHorizontalScroll = clientArea.width < this.plugin_.desktopWidth;
197    var kScrollBarWidth = 16;
198    if (needsHorizontalScroll && !needsVerticalScroll) {
199      needsVerticalScroll =
200          clientArea.height - kScrollBarWidth < this.plugin_.desktopHeight;
201    } else if (!needsHorizontalScroll && needsVerticalScroll) {
202      needsHorizontalScroll =
203          clientArea.width - kScrollBarWidth < this.plugin_.desktopWidth;
204    }
205  }
206
207  var scroller = document.getElementById('scroller');
208  if (needsHorizontalScroll) {
209    scroller.classList.remove('no-horizontal-scroll');
210  } else {
211    scroller.classList.add('no-horizontal-scroll');
212  }
213  if (needsVerticalScroll) {
214    scroller.classList.remove('no-vertical-scroll');
215  } else {
216    scroller.classList.add('no-vertical-scroll');
217  }
218};
219
220// Note that the positive values in both of these enums are copied directly
221// from chromoting_scriptable_object.h and must be kept in sync. The negative
222// values represent state transitions that occur within the web-app that have
223// no corresponding plugin state transition.
224/** @enum {number} */
225remoting.ClientSession.State = {
226  CONNECTION_CANCELED: -3,  // Connection closed (gracefully) before connecting.
227  CONNECTION_DROPPED: -2,  // Succeeded, but subsequently closed with an error.
228  CREATED: -1,
229  UNKNOWN: 0,
230  CONNECTING: 1,
231  INITIALIZING: 2,
232  CONNECTED: 3,
233  CLOSED: 4,
234  FAILED: 5
235};
236
237/**
238 * @param {string} state The state name.
239 * @return {remoting.ClientSession.State} The session state enum value.
240 */
241remoting.ClientSession.State.fromString = function(state) {
242  if (!remoting.ClientSession.State.hasOwnProperty(state)) {
243    throw "Invalid ClientSession.State: " + state;
244  }
245  return remoting.ClientSession.State[state];
246};
247
248/**
249  @constructor
250  @param {remoting.ClientSession.State} current
251  @param {remoting.ClientSession.State} previous
252*/
253remoting.ClientSession.StateEvent = function(current, previous) {
254  /** @type {remoting.ClientSession.State} */
255  this.previous = previous
256
257  /** @type {remoting.ClientSession.State} */
258  this.current = current;
259};
260
261/** @enum {number} */
262remoting.ClientSession.ConnectionError = {
263  UNKNOWN: -1,
264  NONE: 0,
265  HOST_IS_OFFLINE: 1,
266  SESSION_REJECTED: 2,
267  INCOMPATIBLE_PROTOCOL: 3,
268  NETWORK_FAILURE: 4,
269  HOST_OVERLOAD: 5
270};
271
272/**
273 * @param {string} error The connection error name.
274 * @return {remoting.ClientSession.ConnectionError} The connection error enum.
275 */
276remoting.ClientSession.ConnectionError.fromString = function(error) {
277  if (!remoting.ClientSession.ConnectionError.hasOwnProperty(error)) {
278    console.error('Unexpected ClientSession.ConnectionError string: ', error);
279    return remoting.ClientSession.ConnectionError.UNKNOWN;
280  }
281  return remoting.ClientSession.ConnectionError[error];
282}
283
284// The mode of this session.
285/** @enum {number} */
286remoting.ClientSession.Mode = {
287  IT2ME: 0,
288  ME2ME: 1
289};
290
291/**
292 * Type used for performance statistics collected by the plugin.
293 * @constructor
294 */
295remoting.ClientSession.PerfStats = function() {};
296/** @type {number} */
297remoting.ClientSession.PerfStats.prototype.videoBandwidth;
298/** @type {number} */
299remoting.ClientSession.PerfStats.prototype.videoFrameRate;
300/** @type {number} */
301remoting.ClientSession.PerfStats.prototype.captureLatency;
302/** @type {number} */
303remoting.ClientSession.PerfStats.prototype.encodeLatency;
304/** @type {number} */
305remoting.ClientSession.PerfStats.prototype.decodeLatency;
306/** @type {number} */
307remoting.ClientSession.PerfStats.prototype.renderLatency;
308/** @type {number} */
309remoting.ClientSession.PerfStats.prototype.roundtripLatency;
310
311// Keys for connection statistics.
312remoting.ClientSession.STATS_KEY_VIDEO_BANDWIDTH = 'videoBandwidth';
313remoting.ClientSession.STATS_KEY_VIDEO_FRAME_RATE = 'videoFrameRate';
314remoting.ClientSession.STATS_KEY_CAPTURE_LATENCY = 'captureLatency';
315remoting.ClientSession.STATS_KEY_ENCODE_LATENCY = 'encodeLatency';
316remoting.ClientSession.STATS_KEY_DECODE_LATENCY = 'decodeLatency';
317remoting.ClientSession.STATS_KEY_RENDER_LATENCY = 'renderLatency';
318remoting.ClientSession.STATS_KEY_ROUNDTRIP_LATENCY = 'roundtripLatency';
319
320// Keys for per-host settings.
321remoting.ClientSession.KEY_REMAP_KEYS = 'remapKeys';
322remoting.ClientSession.KEY_RESIZE_TO_CLIENT = 'resizeToClient';
323remoting.ClientSession.KEY_SHRINK_TO_FIT = 'shrinkToFit';
324
325/**
326 * The id of the client plugin
327 *
328 * @const
329 */
330remoting.ClientSession.prototype.PLUGIN_ID = 'session-client-plugin';
331
332/**
333 * Set of capabilities for which hasCapability_() can be used to test.
334 *
335 * @enum {string}
336 */
337remoting.ClientSession.Capability = {
338  // When enabled this capability causes the client to send its screen
339  // resolution to the host once connection has been established. See
340  // this.plugin_.notifyClientResolution().
341  SEND_INITIAL_RESOLUTION: 'sendInitialResolution',
342  RATE_LIMIT_RESIZE_REQUESTS: 'rateLimitResizeRequests'
343};
344
345/**
346 * The set of capabilities negotiated between the client and host.
347 * @type {Array.<string>}
348 * @private
349 */
350remoting.ClientSession.prototype.capabilities_ = null;
351
352/**
353 * @param {remoting.ClientSession.Capability} capability The capability to test
354 *     for.
355 * @return {boolean} True if the capability has been negotiated between
356 *     the client and host.
357 * @private
358 */
359remoting.ClientSession.prototype.hasCapability_ = function(capability) {
360  if (this.capabilities_ == null)
361    return false;
362
363  return this.capabilities_.indexOf(capability) > -1;
364};
365
366/**
367 * @param {Element} container The element to add the plugin to.
368 * @param {string} id Id to use for the plugin element .
369 * @param {function(string, string):boolean} onExtensionMessage The handler for
370 *     protocol extension messages. Returns true if a message is recognized;
371 *     false otherwise.
372 * @return {remoting.ClientPlugin} Create plugin object for the locally
373 * installed plugin.
374 */
375remoting.ClientSession.prototype.createClientPlugin_ =
376    function(container, id, onExtensionMessage) {
377  var plugin = /** @type {remoting.ViewerPlugin} */
378      document.createElement('embed');
379
380  plugin.id = id;
381  if (remoting.settings.CLIENT_PLUGIN_TYPE == 'pnacl') {
382    plugin.src = 'remoting_client_pnacl.nmf';
383    plugin.type = 'application/x-pnacl';
384  } else if (remoting.settings.CLIENT_PLUGIN_TYPE == 'nacl') {
385    plugin.src = 'remoting_client_nacl.nmf';
386    plugin.type = 'application/x-nacl';
387  } else {
388    plugin.src = 'about://none';
389    plugin.type = 'application/vnd.chromium.remoting-viewer';
390  }
391
392  plugin.width = 0;
393  plugin.height = 0;
394  plugin.tabIndex = 0;  // Required, otherwise focus() doesn't work.
395  container.appendChild(plugin);
396
397  return new remoting.ClientPlugin(plugin, onExtensionMessage);
398};
399
400/**
401 * Callback function called when the plugin element gets focus.
402 */
403remoting.ClientSession.prototype.pluginGotFocus_ = function() {
404  remoting.clipboard.initiateToHost();
405};
406
407/**
408 * Callback function called when the plugin element loses focus.
409 */
410remoting.ClientSession.prototype.pluginLostFocus_ = function() {
411  if (this.plugin_) {
412    // Release all keys to prevent them becoming 'stuck down' on the host.
413    this.plugin_.releaseAllKeys();
414    if (this.plugin_.element()) {
415      // Focus should stay on the element, not (for example) the toolbar.
416      // Due to crbug.com/246335, we can't restore the focus immediately,
417      // otherwise the plugin gets confused about whether or not it has focus.
418      window.setTimeout(
419          this.plugin_.element().focus.bind(this.plugin_.element()),
420          0);
421    }
422  }
423};
424
425/**
426 * Adds <embed> element to |container| and readies the sesion object.
427 *
428 * @param {Element} container The element to add the plugin to.
429 * @param {function(string, string):boolean} onExtensionMessage The handler for
430 *     protocol extension messages. Returns true if a message is recognized;
431 *     false otherwise.
432 */
433remoting.ClientSession.prototype.createPluginAndConnect =
434    function(container, onExtensionMessage) {
435  this.plugin_ = this.createClientPlugin_(container, this.PLUGIN_ID,
436                                          onExtensionMessage);
437  remoting.HostSettings.load(this.hostId_,
438                             this.onHostSettingsLoaded_.bind(this));
439};
440
441/**
442 * @param {Object.<string>} options The current options for the host, or {}
443 *     if this client has no saved settings for the host.
444 * @private
445 */
446remoting.ClientSession.prototype.onHostSettingsLoaded_ = function(options) {
447  if (remoting.ClientSession.KEY_REMAP_KEYS in options &&
448      typeof(options[remoting.ClientSession.KEY_REMAP_KEYS]) ==
449          'string') {
450    this.remapKeys_ = /** @type {string} */
451        options[remoting.ClientSession.KEY_REMAP_KEYS];
452  }
453  if (remoting.ClientSession.KEY_RESIZE_TO_CLIENT in options &&
454      typeof(options[remoting.ClientSession.KEY_RESIZE_TO_CLIENT]) ==
455          'boolean') {
456    this.resizeToClient_ = /** @type {boolean} */
457        options[remoting.ClientSession.KEY_RESIZE_TO_CLIENT];
458  }
459  if (remoting.ClientSession.KEY_SHRINK_TO_FIT in options &&
460      typeof(options[remoting.ClientSession.KEY_SHRINK_TO_FIT]) ==
461          'boolean') {
462    this.shrinkToFit_ = /** @type {boolean} */
463        options[remoting.ClientSession.KEY_SHRINK_TO_FIT];
464  }
465
466  /** @param {boolean} result */
467  this.plugin_.initialize(this.onPluginInitialized_.bind(this));
468};
469
470/**
471 * Constrains the focus to the plugin element.
472 * @private
473 */
474remoting.ClientSession.prototype.setFocusHandlers_ = function() {
475  this.plugin_.element().addEventListener(
476      'focus', this.callPluginGotFocus_, false);
477  this.plugin_.element().addEventListener(
478      'blur', this.callPluginLostFocus_, false);
479  this.plugin_.element().focus();
480};
481
482/**
483 * @param {remoting.Error} error
484 */
485remoting.ClientSession.prototype.resetWithError_ = function(error) {
486  this.plugin_.cleanup();
487  delete this.plugin_;
488  this.error_ = error;
489  this.setState_(remoting.ClientSession.State.FAILED);
490}
491
492/**
493 * @param {boolean} initialized
494 */
495remoting.ClientSession.prototype.onPluginInitialized_ = function(initialized) {
496  if (!initialized) {
497    console.error('ERROR: remoting plugin not loaded');
498    this.resetWithError_(remoting.Error.MISSING_PLUGIN);
499    return;
500  }
501
502  if (!this.plugin_.isSupportedVersion()) {
503    this.resetWithError_(remoting.Error.BAD_PLUGIN_VERSION);
504    return;
505  }
506
507  // Show the Send Keys menu only if the plugin has the injectKeyEvent feature,
508  // and the Ctrl-Alt-Del button only in Me2Me mode.
509  if (!this.plugin_.hasFeature(
510          remoting.ClientPlugin.Feature.INJECT_KEY_EVENT)) {
511    var sendKeysElement = document.getElementById('send-keys-menu');
512    sendKeysElement.hidden = true;
513  } else if (this.mode_ != remoting.ClientSession.Mode.ME2ME) {
514    var sendCadElement = document.getElementById('send-ctrl-alt-del');
515    sendCadElement.hidden = true;
516  }
517
518  // Apply customized key remappings if the plugin supports remapKeys.
519  if (this.plugin_.hasFeature(remoting.ClientPlugin.Feature.REMAP_KEY)) {
520    this.applyRemapKeys_(true);
521  }
522
523
524  // Enable MediaSource-based rendering on Chrome 37 and above.
525  var chromeVersionMajor =
526      parseInt((remoting.getChromeVersion() || '0').split('.')[0], 10);
527  if (chromeVersionMajor >= 37 &&
528      this.plugin_.hasFeature(
529          remoting.ClientPlugin.Feature.MEDIA_SOURCE_RENDERING)) {
530    this.video_ = /** @type {HTMLMediaElement} */(
531        document.getElementById('mediasource-video-output'));
532    // Make sure that the <video> element is hidden until we get the first
533    // frame.
534    this.video_.style.width = '0px';
535    this.video_.style.height = '0px';
536
537    var renderer = new remoting.MediaSourceRenderer(this.video_);
538    this.plugin_.enableMediaSourceRendering(renderer);
539    /** @type {HTMLElement} */(document.getElementById('video-container'))
540        .classList.add('mediasource-rendering');
541  } else {
542    /** @type {HTMLElement} */(document.getElementById('video-container'))
543        .classList.remove('mediasource-rendering');
544  }
545
546  /** @param {string} msg The IQ stanza to send. */
547  this.plugin_.onOutgoingIqHandler = this.sendIq_.bind(this);
548  /** @param {string} msg The message to log. */
549  this.plugin_.onDebugMessageHandler = function(msg) {
550    console.log('plugin: ' + msg.trimRight());
551  };
552
553  this.plugin_.onConnectionStatusUpdateHandler =
554      this.onConnectionStatusUpdate_.bind(this);
555  this.plugin_.onConnectionReadyHandler = this.onConnectionReady_.bind(this);
556  this.plugin_.onDesktopSizeUpdateHandler =
557      this.onDesktopSizeChanged_.bind(this);
558  this.plugin_.onSetCapabilitiesHandler = this.onSetCapabilities_.bind(this);
559  this.plugin_.onGnubbyAuthHandler = this.processGnubbyAuthMessage_.bind(this);
560  this.plugin_.updateMouseCursorImage = this.updateMouseCursorImage_.bind(this);
561  this.initiateConnection_();
562};
563
564/**
565 * Deletes the <embed> element from the container, without sending a
566 * session_terminate request.  This is to be called when the session was
567 * disconnected by the Host.
568 *
569 * @return {void} Nothing.
570 */
571remoting.ClientSession.prototype.removePlugin = function() {
572  if (this.plugin_) {
573    this.plugin_.element().removeEventListener(
574        'focus', this.callPluginGotFocus_, false);
575    this.plugin_.element().removeEventListener(
576        'blur', this.callPluginLostFocus_, false);
577    this.plugin_.cleanup();
578    this.plugin_ = null;
579  }
580
581  // Delete event handlers that aren't relevent when not connected.
582  this.resizeToClientButton_.removeEventListener(
583      'click', this.callSetScreenMode_, false);
584  this.shrinkToFitButton_.removeEventListener(
585      'click', this.callSetScreenMode_, false);
586  this.fullScreenButton_.removeEventListener(
587      'click', this.callToggleFullScreen_, false);
588
589  // Leave full-screen mode, and stop listening for related events.
590  var listener = this.callOnFullScreenChanged_;
591  remoting.fullscreen.syncWithMaximize(false);
592  remoting.fullscreen.activate(
593      false,
594      function() {
595        remoting.fullscreen.removeListener(listener);
596      });
597  if (remoting.windowFrame) {
598    remoting.windowFrame.setConnected(false);
599  }
600
601  // Remove mediasource-rendering class from video-contained - this will also
602  // hide the <video> element.
603  /** @type {HTMLElement} */(document.getElementById('video-container'))
604      .classList.remove('mediasource-rendering');
605
606  this.container_.removeEventListener('mousemove',
607                                      this.updateMouseCursorPosition_,
608                                      true);
609};
610
611/**
612 * Disconnect the current session with a particular |error|.  The session will
613 * raise a |stateChanged| event in response to it.  The caller should then call
614 * |cleanup| to remove and destroy the <embed> element.
615 *
616 * @param {remoting.Error} error The reason for the disconnection.  Use
617 *    remoting.Error.NONE if there is no error.
618 * @return {void} Nothing.
619 */
620remoting.ClientSession.prototype.disconnect = function(error) {
621  var state = (error == remoting.Error.NONE) ?
622                  remoting.ClientSession.State.CLOSED :
623                  remoting.ClientSession.State.FAILED;
624
625  // The plugin won't send a state change notification, so we explicitly log
626  // the fact that the connection has closed.
627  this.logToServer.logClientSessionStateChange(state, error, this.mode_);
628  this.error_ = error;
629  this.setState_(state);
630};
631
632/**
633 * Deletes the <embed> element from the container and disconnects.
634 *
635 * @return {void} Nothing.
636 */
637remoting.ClientSession.prototype.cleanup = function() {
638  remoting.wcsSandbox.setOnIq(null);
639  this.sendIq_(
640      '<cli:iq ' +
641          'to="' + this.hostJid_ + '" ' +
642          'type="set" ' +
643          'id="session-terminate" ' +
644          'xmlns:cli="jabber:client">' +
645        '<jingle ' +
646            'xmlns="urn:xmpp:jingle:1" ' +
647            'action="session-terminate" ' +
648            'sid="' + this.sessionId_ + '">' +
649          '<reason><success/></reason>' +
650        '</jingle>' +
651      '</cli:iq>');
652  this.removePlugin();
653};
654
655/**
656 * @return {remoting.ClientSession.Mode} The current state.
657 */
658remoting.ClientSession.prototype.getMode = function() {
659  return this.mode_;
660};
661
662/**
663 * @return {remoting.ClientSession.State} The current state.
664 */
665remoting.ClientSession.prototype.getState = function() {
666  return this.state_;
667};
668
669/**
670 * @return {remoting.Error} The current error code.
671 */
672remoting.ClientSession.prototype.getError = function() {
673  return this.error_;
674};
675
676/**
677 * Sends a key combination to the remoting client, by sending down events for
678 * the given keys, followed by up events in reverse order.
679 *
680 * @private
681 * @param {[number]} keys Key codes to be sent.
682 * @return {void} Nothing.
683 */
684remoting.ClientSession.prototype.sendKeyCombination_ = function(keys) {
685  for (var i = 0; i < keys.length; i++) {
686    this.plugin_.injectKeyEvent(keys[i], true);
687  }
688  for (var i = 0; i < keys.length; i++) {
689    this.plugin_.injectKeyEvent(keys[i], false);
690  }
691}
692
693/**
694 * Sends a Ctrl-Alt-Del sequence to the remoting client.
695 *
696 * @return {void} Nothing.
697 */
698remoting.ClientSession.prototype.sendCtrlAltDel = function() {
699  this.sendKeyCombination_([0x0700e0, 0x0700e2, 0x07004c]);
700}
701
702/**
703 * Sends a Print Screen keypress to the remoting client.
704 *
705 * @return {void} Nothing.
706 */
707remoting.ClientSession.prototype.sendPrintScreen = function() {
708  this.sendKeyCombination_([0x070046]);
709}
710
711/**
712 * Sets and stores the key remapping setting for the current host.
713 *
714 * @param {string} remappings Comma separated list of key remappings.
715 */
716remoting.ClientSession.prototype.setRemapKeys = function(remappings) {
717  // Cancel any existing remappings and apply the new ones.
718  this.applyRemapKeys_(false);
719  this.remapKeys_ = remappings;
720  this.applyRemapKeys_(true);
721
722  // Save the new remapping setting.
723  var options = {};
724  options[remoting.ClientSession.KEY_REMAP_KEYS] = this.remapKeys_;
725  remoting.HostSettings.save(this.hostId_, options);
726}
727
728/**
729 * Applies the configured key remappings to the session, or resets them.
730 *
731 * @param {boolean} apply True to apply remappings, false to cancel them.
732 */
733remoting.ClientSession.prototype.applyRemapKeys_ = function(apply) {
734  // By default, under ChromeOS, remap the right Control key to the right
735  // Win / Cmd key.
736  var remapKeys = this.remapKeys_;
737  if (remapKeys == '' && remoting.runningOnChromeOS()) {
738    remapKeys = '0x0700e4>0x0700e7';
739  }
740
741  if (remapKeys == '') {
742    return;
743  }
744
745  var remappings = remapKeys.split(',');
746  for (var i = 0; i < remappings.length; ++i) {
747    var keyCodes = remappings[i].split('>');
748    if (keyCodes.length != 2) {
749      console.log('bad remapKey: ' + remappings[i]);
750      continue;
751    }
752    var fromKey = parseInt(keyCodes[0], 0);
753    var toKey = parseInt(keyCodes[1], 0);
754    if (!fromKey || !toKey) {
755      console.log('bad remapKey code: ' + remappings[i]);
756      continue;
757    }
758    if (apply) {
759      console.log('remapKey 0x' + fromKey.toString(16) +
760                  '>0x' + toKey.toString(16));
761      this.plugin_.remapKey(fromKey, toKey);
762    } else {
763      console.log('cancel remapKey 0x' + fromKey.toString(16));
764      this.plugin_.remapKey(fromKey, fromKey);
765    }
766  }
767}
768
769/**
770 * Callback for the two "screen mode" related menu items: Resize desktop to
771 * fit and Shrink to fit.
772 *
773 * @param {Event} event The click event indicating which mode was selected.
774 * @return {void} Nothing.
775 * @private
776 */
777remoting.ClientSession.prototype.onSetScreenMode_ = function(event) {
778  var shrinkToFit = this.shrinkToFit_;
779  var resizeToClient = this.resizeToClient_;
780  if (event.target == this.shrinkToFitButton_) {
781    shrinkToFit = !shrinkToFit;
782  }
783  if (event.target == this.resizeToClientButton_) {
784    resizeToClient = !resizeToClient;
785  }
786  this.setScreenMode_(shrinkToFit, resizeToClient);
787};
788
789/**
790 * Set the shrink-to-fit and resize-to-client flags and save them if this is
791 * a Me2Me connection.
792 *
793 * @param {boolean} shrinkToFit True if the remote desktop should be scaled
794 *     down if it is larger than the client window; false if scroll-bars
795 *     should be added in this case.
796 * @param {boolean} resizeToClient True if window resizes should cause the
797 *     host to attempt to resize its desktop to match the client window size;
798 *     false to disable this behaviour for subsequent window resizes--the
799 *     current host desktop size is not restored in this case.
800 * @return {void} Nothing.
801 * @private
802 */
803remoting.ClientSession.prototype.setScreenMode_ =
804    function(shrinkToFit, resizeToClient) {
805  if (resizeToClient && !this.resizeToClient_) {
806    var clientArea = this.getClientArea_();
807    this.plugin_.notifyClientResolution(clientArea.width,
808                                        clientArea.height,
809                                        window.devicePixelRatio);
810  }
811
812  // If enabling shrink, reset bump-scroll offsets.
813  var needsScrollReset = shrinkToFit && !this.shrinkToFit_;
814
815  this.shrinkToFit_ = shrinkToFit;
816  this.resizeToClient_ = resizeToClient;
817  this.updateScrollbarVisibility();
818
819  if (this.hostId_ != '') {
820    var options = {};
821    options[remoting.ClientSession.KEY_SHRINK_TO_FIT] = this.shrinkToFit_;
822    options[remoting.ClientSession.KEY_RESIZE_TO_CLIENT] = this.resizeToClient_;
823    remoting.HostSettings.save(this.hostId_, options);
824  }
825
826  this.updateDimensions();
827  if (needsScrollReset) {
828    this.resetScroll_();
829  }
830
831}
832
833/**
834 * Called when the client receives its first frame.
835 *
836 * @return {void} Nothing.
837 */
838remoting.ClientSession.prototype.onFirstFrameReceived = function() {
839  this.hasReceivedFrame_ = true;
840};
841
842/**
843 * @return {boolean} Whether the client has received a video buffer.
844 */
845remoting.ClientSession.prototype.hasReceivedFrame = function() {
846  return this.hasReceivedFrame_;
847};
848
849/**
850 * Sends an IQ stanza via the http xmpp proxy.
851 *
852 * @private
853 * @param {string} msg XML string of IQ stanza to send to server.
854 * @return {void} Nothing.
855 */
856remoting.ClientSession.prototype.sendIq_ = function(msg) {
857  // Extract the session id, so we can close the session later.
858  var parser = new DOMParser();
859  var iqNode = parser.parseFromString(msg, 'text/xml').firstChild;
860  var jingleNode = iqNode.firstChild;
861  if (jingleNode) {
862    var action = jingleNode.getAttribute('action');
863    if (jingleNode.nodeName == 'jingle' && action == 'session-initiate') {
864      this.sessionId_ = jingleNode.getAttribute('sid');
865    }
866  }
867
868  // HACK: Add 'x' prefix to the IDs of the outgoing messages to make sure that
869  // stanza IDs used by host and client do not match. This is necessary to
870  // workaround bug in the signaling endpoint used by chromoting.
871  // TODO(sergeyu): Remove this hack once the server-side bug is fixed.
872  var type = iqNode.getAttribute('type');
873  if (type == 'set') {
874    var id = iqNode.getAttribute('id');
875    iqNode.setAttribute('id', 'x' + id);
876    msg = (new XMLSerializer()).serializeToString(iqNode);
877  }
878
879  console.log(remoting.timestamp(), remoting.formatIq.prettifySendIq(msg));
880
881  // Send the stanza.
882  remoting.wcsSandbox.sendIq(msg);
883};
884
885remoting.ClientSession.prototype.initiateConnection_ = function() {
886  /** @type {remoting.ClientSession} */
887  var that = this;
888
889  remoting.wcsSandbox.connect(onWcsConnected, this.resetWithError_.bind(this));
890
891  /** @param {string} localJid Local JID. */
892  function onWcsConnected(localJid) {
893    that.connectPluginToWcs_(localJid);
894    that.getSharedSecret_(onSharedSecretReceived.bind(null, localJid));
895  }
896
897  /** @param {string} localJid Local JID.
898    * @param {string} sharedSecret Shared secret. */
899  function onSharedSecretReceived(localJid, sharedSecret) {
900    that.plugin_.connect(
901        that.hostJid_, that.hostPublicKey_, localJid, sharedSecret,
902        that.authenticationMethods_, that.hostId_, that.clientPairingId_,
903        that.clientPairedSecret_);
904  };
905}
906
907/**
908 * Connects the plugin to WCS.
909 *
910 * @private
911 * @param {string} localJid Local JID.
912 * @return {void} Nothing.
913 */
914remoting.ClientSession.prototype.connectPluginToWcs_ = function(localJid) {
915  remoting.formatIq.setJids(localJid, this.hostJid_);
916  var forwardIq = this.plugin_.onIncomingIq.bind(this.plugin_);
917  /** @param {string} stanza The IQ stanza received. */
918  var onIncomingIq = function(stanza) {
919    // HACK: Remove 'x' prefix added to the id in sendIq_().
920    try {
921      var parser = new DOMParser();
922      var iqNode = parser.parseFromString(stanza, 'text/xml').firstChild;
923      var type = iqNode.getAttribute('type');
924      var id = iqNode.getAttribute('id');
925      if (type != 'set' && id.charAt(0) == 'x') {
926        iqNode.setAttribute('id', id.substr(1));
927        stanza = (new XMLSerializer()).serializeToString(iqNode);
928      }
929    } catch (err) {
930      // Pass message as is when it is malformed.
931    }
932
933    console.log(remoting.timestamp(),
934                remoting.formatIq.prettifyReceiveIq(stanza));
935    forwardIq(stanza);
936  };
937  remoting.wcsSandbox.setOnIq(onIncomingIq);
938}
939
940/**
941 * Gets shared secret to be used for connection.
942 *
943 * @param {function(string)} callback Callback called with the shared secret.
944 * @return {void} Nothing.
945 * @private
946 */
947remoting.ClientSession.prototype.getSharedSecret_ = function(callback) {
948  /** @type remoting.ClientSession */
949  var that = this;
950  if (this.plugin_.hasFeature(remoting.ClientPlugin.Feature.THIRD_PARTY_AUTH)) {
951    /** @type{function(string, string, string): void} */
952    var fetchThirdPartyToken = function(tokenUrl, hostPublicKey, scope) {
953      that.fetchThirdPartyToken_(
954          tokenUrl, hostPublicKey, scope,
955          that.plugin_.onThirdPartyTokenFetched.bind(that.plugin_));
956    };
957    this.plugin_.fetchThirdPartyTokenHandler = fetchThirdPartyToken;
958  }
959  if (this.accessCode_) {
960    // Shared secret was already supplied before connecting (It2Me case).
961    callback(this.accessCode_);
962  } else if (this.plugin_.hasFeature(
963      remoting.ClientPlugin.Feature.ASYNC_PIN)) {
964    // Plugin supports asynchronously asking for the PIN.
965    this.plugin_.useAsyncPinDialog();
966    /** @param {boolean} pairingSupported */
967    var fetchPin = function(pairingSupported) {
968      that.fetchPin_(pairingSupported,
969                     that.plugin_.onPinFetched.bind(that.plugin_));
970    };
971    this.plugin_.fetchPinHandler = fetchPin;
972    callback('');
973  } else {
974    // Clients that don't support asking for a PIN asynchronously also don't
975    // support pairing, so request the PIN now without offering to remember it.
976    this.fetchPin_(false, callback);
977  }
978};
979
980/**
981 * Callback that the plugin invokes to indicate that the connection
982 * status has changed.
983 *
984 * @private
985 * @param {number} status The plugin's status.
986 * @param {number} error The plugin's error state, if any.
987 */
988remoting.ClientSession.prototype.onConnectionStatusUpdate_ =
989    function(status, error) {
990  if (status == remoting.ClientSession.State.CONNECTED) {
991    this.setFocusHandlers_();
992    this.onDesktopSizeChanged_();
993    if (this.resizeToClient_) {
994      var clientArea = this.getClientArea_();
995      this.plugin_.notifyClientResolution(clientArea.width,
996                                          clientArea.height,
997                                          window.devicePixelRatio);
998    }
999    // Activate full-screen related UX.
1000    remoting.fullscreen.addListener(this.callOnFullScreenChanged_);
1001    remoting.fullscreen.syncWithMaximize(true);
1002    if (remoting.windowFrame) {
1003      remoting.windowFrame.setConnected(true);
1004    }
1005
1006    this.container_.addEventListener('mousemove',
1007                                     this.updateMouseCursorPosition_,
1008                                     true);
1009
1010  } else if (status == remoting.ClientSession.State.FAILED) {
1011    switch (error) {
1012      case remoting.ClientSession.ConnectionError.HOST_IS_OFFLINE:
1013        this.error_ = remoting.Error.HOST_IS_OFFLINE;
1014        break;
1015      case remoting.ClientSession.ConnectionError.SESSION_REJECTED:
1016        this.error_ = remoting.Error.INVALID_ACCESS_CODE;
1017        break;
1018      case remoting.ClientSession.ConnectionError.INCOMPATIBLE_PROTOCOL:
1019        this.error_ = remoting.Error.INCOMPATIBLE_PROTOCOL;
1020        break;
1021      case remoting.ClientSession.ConnectionError.NETWORK_FAILURE:
1022        this.error_ = remoting.Error.P2P_FAILURE;
1023        break;
1024      case remoting.ClientSession.ConnectionError.HOST_OVERLOAD:
1025        this.error_ = remoting.Error.HOST_OVERLOAD;
1026        break;
1027      default:
1028        this.error_ = remoting.Error.UNEXPECTED;
1029    }
1030  }
1031  this.setState_(/** @type {remoting.ClientSession.State} */ (status));
1032};
1033
1034/**
1035 * Callback that the plugin invokes to indicate when the connection is
1036 * ready.
1037 *
1038 * @private
1039 * @param {boolean} ready True if the connection is ready.
1040 */
1041remoting.ClientSession.prototype.onConnectionReady_ = function(ready) {
1042  var container = /** @type {HTMLMediaElement} */(
1043      document.getElementById('video-container'));
1044  if (!ready) {
1045    container.classList.add('session-client-inactive');
1046  } else {
1047    container.classList.remove('session-client-inactive');
1048  }
1049
1050  this.raiseEvent(remoting.ClientSession.Events.videoChannelStateChanged,
1051                  ready);
1052};
1053
1054/**
1055 * Called when the client-host capabilities negotiation is complete.
1056 *
1057 * @param {!Array.<string>} capabilities The set of capabilities negotiated
1058 *     between the client and host.
1059 * @return {void} Nothing.
1060 * @private
1061 */
1062remoting.ClientSession.prototype.onSetCapabilities_ = function(capabilities) {
1063  if (this.capabilities_ != null) {
1064    console.error('onSetCapabilities_() is called more than once');
1065    return;
1066  }
1067
1068  this.capabilities_ = capabilities;
1069  if (this.hasCapability_(
1070      remoting.ClientSession.Capability.SEND_INITIAL_RESOLUTION)) {
1071    var clientArea = this.getClientArea_();
1072    this.plugin_.notifyClientResolution(clientArea.width,
1073                                        clientArea.height,
1074                                        window.devicePixelRatio);
1075  }
1076};
1077
1078/**
1079 * @private
1080 * @param {remoting.ClientSession.State} newState The new state for the session.
1081 * @return {void} Nothing.
1082 */
1083remoting.ClientSession.prototype.setState_ = function(newState) {
1084  var oldState = this.state_;
1085  this.state_ = newState;
1086  var state = this.state_;
1087  if (oldState == remoting.ClientSession.State.CONNECTING) {
1088    if (this.state_ == remoting.ClientSession.State.CLOSED) {
1089      state = remoting.ClientSession.State.CONNECTION_CANCELED;
1090    } else if (this.state_ == remoting.ClientSession.State.FAILED &&
1091        this.error_ == remoting.Error.HOST_IS_OFFLINE &&
1092        !this.logHostOfflineErrors_) {
1093      // The application requested host-offline errors to be suppressed, for
1094      // example, because this connection attempt is using a cached host JID.
1095      console.log('Suppressing host-offline error.');
1096      state = remoting.ClientSession.State.CONNECTION_CANCELED;
1097    }
1098  } else if (oldState == remoting.ClientSession.State.CONNECTED &&
1099             this.state_ == remoting.ClientSession.State.FAILED) {
1100    state = remoting.ClientSession.State.CONNECTION_DROPPED;
1101  }
1102  this.logToServer.logClientSessionStateChange(state, this.error_, this.mode_);
1103  if (this.state_ == remoting.ClientSession.State.CONNECTED) {
1104    this.createGnubbyAuthHandler_();
1105  }
1106
1107  this.raiseEvent(remoting.ClientSession.Events.stateChanged,
1108    new remoting.ClientSession.StateEvent(newState, oldState)
1109  );
1110};
1111
1112/**
1113 * This is a callback that gets called when the window is resized.
1114 *
1115 * @return {void} Nothing.
1116 */
1117remoting.ClientSession.prototype.onResize = function() {
1118  this.updateDimensions();
1119
1120  if (this.notifyClientResolutionTimer_) {
1121    window.clearTimeout(this.notifyClientResolutionTimer_);
1122    this.notifyClientResolutionTimer_ = null;
1123  }
1124
1125  // Defer notifying the host of the change until the window stops resizing, to
1126  // avoid overloading the control channel with notifications.
1127  if (this.resizeToClient_) {
1128    var kResizeRateLimitMs = 1000;
1129    if (this.hasCapability_(
1130        remoting.ClientSession.Capability.RATE_LIMIT_RESIZE_REQUESTS)) {
1131      kResizeRateLimitMs = 250;
1132    }
1133    var clientArea = this.getClientArea_();
1134    this.notifyClientResolutionTimer_ = window.setTimeout(
1135        this.plugin_.notifyClientResolution.bind(this.plugin_,
1136                                                 clientArea.width,
1137                                                 clientArea.height,
1138                                                 window.devicePixelRatio),
1139        kResizeRateLimitMs);
1140  }
1141
1142  // If bump-scrolling is enabled, adjust the plugin margins to fully utilize
1143  // the new window area.
1144  this.resetScroll_();
1145
1146  this.updateScrollbarVisibility();
1147};
1148
1149/**
1150 * Requests that the host pause or resume video updates.
1151 *
1152 * @param {boolean} pause True to pause video, false to resume.
1153 * @return {void} Nothing.
1154 */
1155remoting.ClientSession.prototype.pauseVideo = function(pause) {
1156  if (this.plugin_) {
1157    this.plugin_.pauseVideo(pause);
1158  }
1159};
1160
1161/**
1162 * Requests that the host pause or resume audio.
1163 *
1164 * @param {boolean} pause True to pause audio, false to resume.
1165 * @return {void} Nothing.
1166 */
1167remoting.ClientSession.prototype.pauseAudio = function(pause) {
1168  if (this.plugin_) {
1169    this.plugin_.pauseAudio(pause)
1170  }
1171}
1172
1173/**
1174 * This is a callback that gets called when the plugin notifies us of a change
1175 * in the size of the remote desktop.
1176 *
1177 * @private
1178 * @return {void} Nothing.
1179 */
1180remoting.ClientSession.prototype.onDesktopSizeChanged_ = function() {
1181  console.log('desktop size changed: ' +
1182              this.plugin_.desktopWidth + 'x' +
1183              this.plugin_.desktopHeight +' @ ' +
1184              this.plugin_.desktopXDpi + 'x' +
1185              this.plugin_.desktopYDpi + ' DPI');
1186  this.updateDimensions();
1187  this.updateScrollbarVisibility();
1188};
1189
1190/**
1191 * Refreshes the plugin's dimensions, taking into account the sizes of the
1192 * remote desktop and client window, and the current scale-to-fit setting.
1193 *
1194 * @return {void} Nothing.
1195 */
1196remoting.ClientSession.prototype.updateDimensions = function() {
1197  if (this.plugin_.desktopWidth == 0 ||
1198      this.plugin_.desktopHeight == 0) {
1199    return;
1200  }
1201
1202  var clientArea = this.getClientArea_();
1203  var desktopWidth = this.plugin_.desktopWidth;
1204  var desktopHeight = this.plugin_.desktopHeight;
1205
1206  // When configured to display a host at its original size, we aim to display
1207  // it as close to its physical size as possible, without losing data:
1208  // - If client and host have matching DPI, render the host pixel-for-pixel.
1209  // - If the host has higher DPI then still render pixel-for-pixel.
1210  // - If the host has lower DPI then let Chrome up-scale it to natural size.
1211
1212  // We specify the plugin dimensions in Density-Independent Pixels, so to
1213  // render pixel-for-pixel we need to down-scale the host dimensions by the
1214  // devicePixelRatio of the client. To match the host pixel density, we choose
1215  // an initial scale factor based on the client devicePixelRatio and host DPI.
1216
1217  // Determine the effective device pixel ratio of the host, based on DPI.
1218  var hostPixelRatioX = Math.ceil(this.plugin_.desktopXDpi / 96);
1219  var hostPixelRatioY = Math.ceil(this.plugin_.desktopYDpi / 96);
1220  var hostPixelRatio = Math.min(hostPixelRatioX, hostPixelRatioY);
1221
1222  // Down-scale by the smaller of the client and host ratios.
1223  var scale = 1.0 / Math.min(window.devicePixelRatio, hostPixelRatio);
1224
1225  if (this.shrinkToFit_) {
1226    // Reduce the scale, if necessary, to fit the whole desktop in the window.
1227    var scaleFitWidth = Math.min(scale, 1.0 * clientArea.width / desktopWidth);
1228    var scaleFitHeight =
1229        Math.min(scale, 1.0 * clientArea.height / desktopHeight);
1230    scale = Math.min(scaleFitHeight, scaleFitWidth);
1231
1232    // If we're running full-screen then try to handle common side-by-side
1233    // multi-monitor combinations more intelligently.
1234    if (remoting.fullscreen.isActive()) {
1235      // If the host has two monitors each the same size as the client then
1236      // scale-to-fit will have the desktop occupy only 50% of the client area,
1237      // in which case it would be preferable to down-scale less and let the
1238      // user bump-scroll around ("scale-and-pan").
1239      // Triggering scale-and-pan if less than 65% of the client area would be
1240      // used adds enough fuzz to cope with e.g. 1280x800 client connecting to
1241      // a (2x1280)x1024 host nicely.
1242      // Note that we don't need to account for scrollbars while fullscreen.
1243      if (scale <= scaleFitHeight * 0.65) {
1244        scale = scaleFitHeight;
1245      }
1246      if (scale <= scaleFitWidth * 0.65) {
1247        scale = scaleFitWidth;
1248      }
1249    }
1250  }
1251
1252  var pluginWidth = Math.round(desktopWidth * scale);
1253  var pluginHeight = Math.round(desktopHeight * scale);
1254
1255  if (this.video_) {
1256    this.video_.style.width = pluginWidth + 'px';
1257    this.video_.style.height = pluginHeight + 'px';
1258  }
1259
1260  // Resize the plugin if necessary.
1261  // TODO(wez): Handle high-DPI to high-DPI properly (crbug.com/135089).
1262  this.plugin_.element().style.width = pluginWidth + 'px';
1263  this.plugin_.element().style.height = pluginHeight + 'px';
1264
1265  // Position the container.
1266  // Note that clientWidth/Height take into account scrollbars.
1267  var clientWidth = document.documentElement.clientWidth;
1268  var clientHeight = document.documentElement.clientHeight;
1269  var parentNode = this.plugin_.element().parentNode;
1270
1271  console.log('plugin dimensions: ' +
1272              parentNode.style.left + ',' +
1273              parentNode.style.top + '-' +
1274              pluginWidth + 'x' + pluginHeight + '.');
1275};
1276
1277/**
1278 * Returns an associative array with a set of stats for this connection.
1279 *
1280 * @return {remoting.ClientSession.PerfStats} The connection statistics.
1281 */
1282remoting.ClientSession.prototype.getPerfStats = function() {
1283  return this.plugin_.getPerfStats();
1284};
1285
1286/**
1287 * Logs statistics.
1288 *
1289 * @param {remoting.ClientSession.PerfStats} stats
1290 */
1291remoting.ClientSession.prototype.logStatistics = function(stats) {
1292  this.logToServer.logStatistics(stats, this.mode_);
1293};
1294
1295/**
1296 * Enable or disable logging of connection errors due to a host being offline.
1297 * For example, if attempting a connection using a cached JID, host-offline
1298 * errors should not be logged because the JID will be refreshed and the
1299 * connection retried.
1300 *
1301 * @param {boolean} enable True to log host-offline errors; false to suppress.
1302 */
1303remoting.ClientSession.prototype.logHostOfflineErrors = function(enable) {
1304  this.logHostOfflineErrors_ = enable;
1305};
1306
1307/**
1308 * Request pairing with the host for PIN-less authentication.
1309 *
1310 * @param {string} clientName The human-readable name of the client.
1311 * @param {function(string, string):void} onDone Callback to receive the
1312 *     client id and shared secret when they are available.
1313 */
1314remoting.ClientSession.prototype.requestPairing = function(clientName, onDone) {
1315  if (this.plugin_) {
1316    this.plugin_.requestPairing(clientName, onDone);
1317  }
1318};
1319
1320/**
1321 * Called when the full-screen status has changed, either via the
1322 * remoting.Fullscreen class, or via a system event such as the Escape key
1323 *
1324 * @param {boolean} fullscreen True if the app is entering full-screen mode;
1325 *     false if it is leaving it.
1326 * @private
1327 */
1328remoting.ClientSession.prototype.onFullScreenChanged_ = function (fullscreen) {
1329  var htmlNode = /** @type {HTMLElement} */ (document.documentElement);
1330  this.enableBumpScroll_(fullscreen);
1331  if (fullscreen) {
1332    htmlNode.classList.add('full-screen');
1333  } else {
1334    htmlNode.classList.remove('full-screen');
1335  }
1336};
1337
1338/**
1339 * Updates the options menu to reflect the current scale-to-fit and full-screen
1340 * settings.
1341 * @return {void} Nothing.
1342 * @private
1343 */
1344remoting.ClientSession.prototype.onShowOptionsMenu_ = function() {
1345  remoting.MenuButton.select(this.resizeToClientButton_, this.resizeToClient_);
1346  remoting.MenuButton.select(this.shrinkToFitButton_, this.shrinkToFit_);
1347  remoting.MenuButton.select(this.fullScreenButton_,
1348                             remoting.fullscreen.isActive());
1349};
1350
1351/**
1352 * Scroll the client plugin by the specified amount, keeping it visible.
1353 * Note that this is only used in content full-screen mode (not windowed or
1354 * browser full-screen modes), where window.scrollBy and the scrollTop and
1355 * scrollLeft properties don't work.
1356 * @param {number} dx The amount by which to scroll horizontally. Positive to
1357 *     scroll right; negative to scroll left.
1358 * @param {number} dy The amount by which to scroll vertically. Positive to
1359 *     scroll down; negative to scroll up.
1360 * @return {boolean} True if the requested scroll had no effect because both
1361 *     vertical and horizontal edges of the screen have been reached.
1362 * @private
1363 */
1364remoting.ClientSession.prototype.scroll_ = function(dx, dy) {
1365  var plugin = this.plugin_.element();
1366  var style = plugin.style;
1367
1368  /**
1369   * Helper function for x- and y-scrolling
1370   * @param {number|string} curr The current margin, eg. "10px".
1371   * @param {number} delta The requested scroll amount.
1372   * @param {number} windowBound The size of the window, in pixels.
1373   * @param {number} pluginBound The size of the plugin, in pixels.
1374   * @param {{stop: boolean}} stop Reference parameter used to indicate when
1375   *     the scroll has reached one of the edges and can be stopped in that
1376   *     direction.
1377   * @return {string} The new margin value.
1378   */
1379  var adjustMargin = function(curr, delta, windowBound, pluginBound, stop) {
1380    var minMargin = Math.min(0, windowBound - pluginBound);
1381    var result = (curr ? parseFloat(curr) : 0) - delta;
1382    result = Math.min(0, Math.max(minMargin, result));
1383    stop.stop = (result == 0 || result == minMargin);
1384    return result + 'px';
1385  };
1386
1387  var stopX = { stop: false };
1388  var clientArea = this.getClientArea_();
1389  style.marginLeft = adjustMargin(style.marginLeft, dx,
1390                                  clientArea.width, plugin.clientWidth, stopX);
1391
1392  var stopY = { stop: false };
1393  style.marginTop = adjustMargin(
1394      style.marginTop, dy, clientArea.height, plugin.clientHeight, stopY);
1395  return stopX.stop && stopY.stop;
1396};
1397
1398remoting.ClientSession.prototype.resetScroll_ = function() {
1399  if (this.plugin_) {
1400    var plugin = this.plugin_.element();
1401    plugin.style.marginTop = '0px';
1402    plugin.style.marginLeft = '0px';
1403  }
1404};
1405
1406/**
1407 * Enable or disable bump-scrolling. When disabling bump scrolling, also reset
1408 * the scroll offsets to (0, 0).
1409 * @private
1410 * @param {boolean} enable True to enable bump-scrolling, false to disable it.
1411 */
1412remoting.ClientSession.prototype.enableBumpScroll_ = function(enable) {
1413  var element = /*@type{HTMLElement} */ document.documentElement;
1414  if (enable) {
1415    /** @type {null|function(Event):void} */
1416    this.onMouseMoveRef_ = this.onMouseMove_.bind(this);
1417    element.addEventListener('mousemove', this.onMouseMoveRef_, false);
1418  } else {
1419    element.removeEventListener('mousemove', this.onMouseMoveRef_, false);
1420    this.onMouseMoveRef_ = null;
1421    this.resetScroll_();
1422  }
1423};
1424
1425/**
1426 * @param {Event} event The mouse event.
1427 * @private
1428 */
1429remoting.ClientSession.prototype.onMouseMove_ = function(event) {
1430  if (this.bumpScrollTimer_) {
1431    window.clearTimeout(this.bumpScrollTimer_);
1432    this.bumpScrollTimer_ = null;
1433  }
1434
1435  /**
1436   * Compute the scroll speed based on how close the mouse is to the edge.
1437   * @param {number} mousePos The mouse x- or y-coordinate
1438   * @param {number} size The width or height of the content area.
1439   * @return {number} The scroll delta, in pixels.
1440   */
1441  var computeDelta = function(mousePos, size) {
1442    var threshold = 10;
1443    if (mousePos >= size - threshold) {
1444      return 1 + 5 * (mousePos - (size - threshold)) / threshold;
1445    } else if (mousePos <= threshold) {
1446      return -1 - 5 * (threshold - mousePos) / threshold;
1447    }
1448    return 0;
1449  };
1450
1451  var clientArea = this.getClientArea_();
1452  var dx = computeDelta(event.x, clientArea.width);
1453  var dy = computeDelta(event.y, clientArea.height);
1454
1455  if (dx != 0 || dy != 0) {
1456    /** @type {remoting.ClientSession} */
1457    var that = this;
1458    /**
1459     * Scroll the view, and schedule a timer to do so again unless we've hit
1460     * the edges of the screen. This timer is cancelled when the mouse moves.
1461     * @param {number} expected The time at which we expect to be called.
1462     */
1463    var repeatScroll = function(expected) {
1464      /** @type {number} */
1465      var now = new Date().getTime();
1466      /** @type {number} */
1467      var timeout = 10;
1468      var lateAdjustment = 1 + (now - expected) / timeout;
1469      if (!that.scroll_(lateAdjustment * dx, lateAdjustment * dy)) {
1470        that.bumpScrollTimer_ = window.setTimeout(
1471            function() { repeatScroll(now + timeout); },
1472            timeout);
1473      }
1474    };
1475    repeatScroll(new Date().getTime());
1476  }
1477};
1478
1479/**
1480 * Sends a clipboard item to the host.
1481 *
1482 * @param {string} mimeType The MIME type of the clipboard item.
1483 * @param {string} item The clipboard item.
1484 */
1485remoting.ClientSession.prototype.sendClipboardItem = function(mimeType, item) {
1486  if (!this.plugin_)
1487    return;
1488  this.plugin_.sendClipboardItem(mimeType, item);
1489};
1490
1491/**
1492 * Send a gnubby-auth extension message to the host.
1493 * @param {Object} data The gnubby-auth message data.
1494 */
1495remoting.ClientSession.prototype.sendGnubbyAuthMessage = function(data) {
1496  if (!this.plugin_)
1497    return;
1498  this.plugin_.sendClientMessage('gnubby-auth', JSON.stringify(data));
1499};
1500
1501/**
1502 * Process a remote gnubby auth request.
1503 * @param {string} data Remote gnubby request data.
1504 * @private
1505 */
1506remoting.ClientSession.prototype.processGnubbyAuthMessage_ = function(data) {
1507  if (this.gnubbyAuthHandler_) {
1508    try {
1509      this.gnubbyAuthHandler_.onMessage(data);
1510    } catch (err) {
1511      console.error('Failed to process gnubby message: ',
1512          /** @type {*} */ (err));
1513    }
1514  } else {
1515    console.error('Received unexpected gnubby message');
1516  }
1517};
1518
1519/**
1520 * Create a gnubby auth handler and inform the host that gnubby auth is
1521 * supported.
1522 * @private
1523 */
1524remoting.ClientSession.prototype.createGnubbyAuthHandler_ = function() {
1525  if (this.mode_ == remoting.ClientSession.Mode.ME2ME) {
1526    this.gnubbyAuthHandler_ = new remoting.GnubbyAuthHandler(this);
1527    // TODO(psj): Move to more generic capabilities mechanism.
1528    this.sendGnubbyAuthMessage({'type': 'control', 'option': 'auth-v1'});
1529  }
1530};
1531
1532/**
1533 * @return {{width: number, height: number}} The height of the window's client
1534 *     area. This differs between apps v1 and apps v2 due to the custom window
1535 *     borders used by the latter.
1536 * @private
1537 */
1538remoting.ClientSession.prototype.getClientArea_ = function() {
1539  return remoting.windowFrame ?
1540      remoting.windowFrame.getClientArea() :
1541      { 'width': window.innerWidth, 'height': window.innerHeight };
1542};
1543
1544/**
1545 * @param {string} url
1546 * @param {number} hotspotX
1547 * @param {number} hotspotY
1548 */
1549remoting.ClientSession.prototype.updateMouseCursorImage_ =
1550    function(url, hotspotX, hotspotY) {
1551  this.mouseCursorOverlay_.hidden = !url;
1552  if (url) {
1553    this.mouseCursorOverlay_.style.marginLeft = '-' + hotspotX + 'px';
1554    this.mouseCursorOverlay_.style.marginTop = '-' + hotspotY + 'px';
1555    this.mouseCursorOverlay_.src = url;
1556  }
1557 };
1558