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.
7 * Class handling creation and teardown of a remoting client session.
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, e.g. delivers incoming/outgoing signaling
14 * This class should not access the plugin directly, instead it should
15 * do it through ClientPlugin class which abstracts plugin version
21 /** @suppress {duplicate} */
22 var remoting = remoting || {};
25 * @param {remoting.ClientPlugin} plugin
26 * @param {remoting.SignalStrategy} signalStrategy Signal strategy.
27 * @param {boolean} useApiaryForLogging
28 * @param {remoting.ClientSession.EventHandler} listener
31 * @extends {base.EventSourceImpl}
32 * @implements {base.Disposable}
33 * @implements {remoting.ClientPlugin.ConnectionEventHandler}
35 remoting.ClientSession = function(
36 plugin, signalStrategy, useApiaryForLogging, listener) {
37 base.inherits(this, base.EventSourceImpl);
40 this.state_ = remoting.ClientSession.State.INITIALIZING;
42 /** @private {!remoting.Error} */
43 this.error_ = remoting.Error.none();
45 /** @private {remoting.Host} */
48 /** @private {remoting.CredentialsProvider} */
49 this.credentialsProvider_ = null;
55 this.listener_ = listener;
58 this.hasReceivedFrame_ = false;
60 /** @private {remoting.Logger} */
61 this.logger_ = this.createLogger_(useApiaryForLogging, signalStrategy);
64 this.signalStrategy_ = signalStrategy;
66 var state = this.signalStrategy_.getState();
67 console.assert(state == remoting.SignalStrategy.State.CONNECTED,
68 'ClientSession ctor called in state ' + state + '.');
69 this.signalStrategy_.setIncomingStanzaCallback(
70 this.onIncomingMessage_.bind(this));
72 /** @private {remoting.FormatIq} */
73 this.iqFormatter_ = null;
76 * Allow host-offline error reporting to be suppressed in situations where it
77 * would not be useful, for example, when using a cached host JID.
79 * @type {boolean} @private
81 this.logHostOfflineErrors_ = true;
83 /** @private {remoting.ClientPlugin} */
84 this.plugin_ = plugin;
85 plugin.setConnectionEventHandler(this);
88 this.connectedDisposables_ = new base.Disposables();
90 this.defineEvents(Object.keys(remoting.ClientSession.Events));
94 remoting.ClientSession.Events = {
95 videoChannelStateChanged: 'videoChannelStateChanged'
100 * [START]-------> [onConnected] ------> [onDisconnected]
102 * |-----> [OnConnectionFailed]
105 remoting.ClientSession.EventHandler = function() {};
108 * Called when the connection failed before it is connected.
110 * @param {!remoting.Error} error
112 remoting.ClientSession.EventHandler.prototype.onConnectionFailed =
116 * Called when a new session has been connected. The |connectionInfo| will be
117 * valid until onDisconnected() is called.
119 * @param {!remoting.ConnectionInfo} connectionInfo
121 remoting.ClientSession.EventHandler.prototype.onConnected =
122 function(connectionInfo) {};
125 * Called when the current session has been disconnected.
127 * @param {!remoting.Error} reason Reason that the session is disconnected.
128 * Set to remoting.Error.none() if there is no error.
130 remoting.ClientSession.EventHandler.prototype.onDisconnected =
133 // Note that the positive values in both of these enums are copied directly
134 // from connection_to_host.h and must be kept in sync. Code in
135 // chromoting_instance.cc converts the C++ enums into strings that must match
136 // the names given here.
137 // The negative values represent state transitions that occur within the
138 // web-app that have no corresponding plugin state transition.
139 /** @enum {number} */
140 remoting.ClientSession.State = {
141 CONNECTION_CANCELED: -3, // Connection closed (gracefully) before connecting.
142 CONNECTION_DROPPED: -2, // Succeeded, but subsequently closed with an error.
154 * @param {string} state The state name.
155 * @return {remoting.ClientSession.State} The session state enum value.
157 remoting.ClientSession.State.fromString = function(state) {
158 if (!remoting.ClientSession.State.hasOwnProperty(state)) {
159 throw "Invalid ClientSession.State: " + state;
161 return remoting.ClientSession.State[state];
164 /** @enum {number} */
165 remoting.ClientSession.ConnectionError = {
170 INCOMPATIBLE_PROTOCOL: 3,
176 * @param {string} error The connection error name.
177 * @return {remoting.ClientSession.ConnectionError} The connection error enum.
179 remoting.ClientSession.ConnectionError.fromString = function(error) {
180 if (!remoting.ClientSession.ConnectionError.hasOwnProperty(error)) {
181 console.error('Unexpected ClientSession.ConnectionError string: ', error);
182 return remoting.ClientSession.ConnectionError.UNKNOWN;
184 return remoting.ClientSession.ConnectionError[error];
188 * Type used for performance statistics collected by the plugin.
191 remoting.ClientSession.PerfStats = function() {};
192 /** @type {number} */
193 remoting.ClientSession.PerfStats.prototype.videoBandwidth;
194 /** @type {number} */
195 remoting.ClientSession.PerfStats.prototype.videoFrameRate;
196 /** @type {number} */
197 remoting.ClientSession.PerfStats.prototype.captureLatency;
198 /** @type {number} */
199 remoting.ClientSession.PerfStats.prototype.encodeLatency;
200 /** @type {number} */
201 remoting.ClientSession.PerfStats.prototype.decodeLatency;
202 /** @type {number} */
203 remoting.ClientSession.PerfStats.prototype.renderLatency;
204 /** @type {number} */
205 remoting.ClientSession.PerfStats.prototype.roundtripLatency;
207 // Keys for connection statistics.
208 remoting.ClientSession.STATS_KEY_VIDEO_BANDWIDTH = 'videoBandwidth';
209 remoting.ClientSession.STATS_KEY_VIDEO_FRAME_RATE = 'videoFrameRate';
210 remoting.ClientSession.STATS_KEY_CAPTURE_LATENCY = 'captureLatency';
211 remoting.ClientSession.STATS_KEY_ENCODE_LATENCY = 'encodeLatency';
212 remoting.ClientSession.STATS_KEY_DECODE_LATENCY = 'decodeLatency';
213 remoting.ClientSession.STATS_KEY_RENDER_LATENCY = 'renderLatency';
214 remoting.ClientSession.STATS_KEY_ROUNDTRIP_LATENCY = 'roundtripLatency';
217 * Set of capabilities for which hasCapability() can be used to test.
221 remoting.ClientSession.Capability = {
222 // When enabled this capability causes the client to send its screen
223 // resolution to the host once connection has been established. See
224 // this.plugin_.notifyClientResolution().
225 SEND_INITIAL_RESOLUTION: 'sendInitialResolution',
227 // Let the host know that we're interested in knowing whether or not it
228 // rate limits desktop-resize requests.
229 // TODO(kelvinp): This has been supported since M-29. Currently we only have
230 // <1000 users on M-29 or below. Remove this and the capability on the host.
231 RATE_LIMIT_RESIZE_REQUESTS: 'rateLimitResizeRequests',
233 // Indicates native touch input support. If the host does not support
234 // touch then the client will let Chrome synthesize mouse events from touch
235 // input, for compatibility with non-touch-aware systems.
236 TOUCH_EVENTS: 'touchEvents',
238 // Indicates that host/client supports Google Drive integration, and that the
239 // client should send to the host the OAuth tokens to be used by Google Drive
241 GOOGLE_DRIVE: 'googleDrive',
243 // Indicates that the client supports the video frame-recording extension.
244 VIDEO_RECORDER: 'videoRecorder',
246 // Indicates that the client supports 'cast'ing the video stream to a
247 // cast-enabled device.
252 * Connects to |host| using |credentialsProvider| as the credentails.
254 * @param {remoting.Host} host
255 * @param {remoting.CredentialsProvider} credentialsProvider
257 remoting.ClientSession.prototype.connect = function(host, credentialsProvider) {
259 this.credentialsProvider_ = credentialsProvider;
261 new remoting.FormatIq(this.signalStrategy_.getJid(), host.jabberId);
262 this.plugin_.connect(this.host_, this.signalStrategy_.getJid(),
263 credentialsProvider);
267 * Disconnect the current session with a particular |error|. The session will
268 * raise a |stateChanged| event in response to it. The caller should then call
269 * dispose() to remove and destroy the <embed> element.
271 * @param {!remoting.Error} error The reason for the disconnection. Use
272 * remoting.Error.none() if there is no error.
273 * @return {void} Nothing.
275 remoting.ClientSession.prototype.disconnect = function(error) {
276 if (this.isFinished()) {
277 // Do not send the session-terminate Iq if disconnect() is already called or
278 // if it is initiated by the host.
284 'to="' + this.host_.jabberId + '" ' +
286 'id="session-terminate" ' +
287 'xmlns:cli="jabber:client">' +
289 'xmlns="urn:xmpp:jingle:1" ' +
290 'action="session-terminate" ' +
291 'sid="' + this.sessionId_ + '">' +
292 '<reason><success/></reason>' +
296 var state = error.isNone() ?
297 remoting.ClientSession.State.CLOSED :
298 remoting.ClientSession.State.FAILED;
300 this.setState_(state);
304 * Deletes the <embed> element from the container and disconnects.
306 * @return {void} Nothing.
308 remoting.ClientSession.prototype.dispose = function() {
309 base.dispose(this.connectedDisposables_);
310 this.connectedDisposables_ = null;
311 base.dispose(this.plugin_);
316 * @return {remoting.ClientSession.State} The current state.
318 remoting.ClientSession.prototype.getState = function() {
323 * @return {remoting.Logger}.
325 remoting.ClientSession.prototype.getLogger = function() {
330 * @return {!remoting.Error} The current error code.
332 remoting.ClientSession.prototype.getError = function() {
337 * Drop the session when the computer is suspended for more than
338 * |suspendDurationInMS|.
340 * @param {number} suspendDurationInMS maximum duration of suspension allowed
341 * before the session will be dropped.
343 remoting.ClientSession.prototype.dropSessionOnSuspend = function(
344 suspendDurationInMS) {
345 if (this.state_ !== remoting.ClientSession.State.CONNECTED) {
346 console.error('The session is not connected.');
350 var suspendDetector = new remoting.SuspendDetector(suspendDurationInMS);
351 this.connectedDisposables_.add(
354 suspendDetector, remoting.SuspendDetector.Events.resume,
355 this.disconnect.bind(
356 this, new remoting.Error(remoting.Error.Tag.CLIENT_SUSPENDED))));
360 * Called when the client receives its first frame.
362 * @return {void} Nothing.
364 remoting.ClientSession.prototype.onFirstFrameReceived = function() {
365 this.hasReceivedFrame_ = true;
369 * @return {boolean} Whether the client has received a video buffer.
371 remoting.ClientSession.prototype.hasReceivedFrame = function() {
372 return this.hasReceivedFrame_;
376 * @param {boolean} useApiary
377 * @param {remoting.SignalStrategy} signalStrategy
378 * @return {remoting.Logger}
382 remoting.ClientSession.prototype.createLogger_ = function(
383 useApiary, signalStrategy) {
385 var logger = new remoting.SessionLogger(
386 remoting.ChromotingEvent.Role.CLIENT,
387 remoting.TelemetryEventWriter.Client.write
389 signalStrategy.sendConnectionSetupResults(logger);
392 return new remoting.LogToServer(signalStrategy);
397 * Sends a signaling message.
399 * @param {string} message XML string of IQ stanza to send to server.
400 * @return {void} Nothing.
403 remoting.ClientSession.prototype.sendIq_ = function(message) {
404 // Extract the session id, so we can close the session later.
405 var parser = new DOMParser();
406 var iqNode = parser.parseFromString(message, 'text/xml').firstChild;
407 var jingleNode = iqNode.firstChild;
409 var action = jingleNode.getAttribute('action');
410 if (jingleNode.nodeName == 'jingle' && action == 'session-initiate') {
411 this.sessionId_ = jingleNode.getAttribute('sid');
415 console.log(base.timestamp() + this.iqFormatter_.prettifySendIq(message));
416 if (this.signalStrategy_.getState() !=
417 remoting.SignalStrategy.State.CONNECTED) {
418 console.log("Message above is dropped because signaling is not connected.");
422 this.signalStrategy_.sendMessage(message);
426 * @param {string} message XML string of IQ stanza to send to server.
428 remoting.ClientSession.prototype.onOutgoingIq = function(message) {
429 this.sendIq_(message);
433 * @param {string} msg
435 remoting.ClientSession.prototype.onDebugMessage = function(msg) {
436 console.log('plugin: ' + msg.trimRight());
440 * @param {Element} message
443 remoting.ClientSession.prototype.onIncomingMessage_ = function(message) {
447 var formatted = new XMLSerializer().serializeToString(message);
448 console.log(base.timestamp() +
449 this.iqFormatter_.prettifyReceiveIq(formatted));
450 this.plugin_.onIncomingIq(formatted);
454 * Callback that the plugin invokes to indicate that the connection
455 * status has changed.
457 * @param {remoting.ClientSession.State} status The plugin's status.
458 * @param {remoting.ClientSession.ConnectionError} error The plugin's error
461 remoting.ClientSession.prototype.onConnectionStatusUpdate =
462 function(status, error) {
463 if (status == remoting.ClientSession.State.FAILED) {
465 case remoting.ClientSession.ConnectionError.HOST_IS_OFFLINE:
466 this.error_ = new remoting.Error(
467 remoting.Error.Tag.HOST_IS_OFFLINE);
469 case remoting.ClientSession.ConnectionError.SESSION_REJECTED:
470 this.error_ = new remoting.Error(
471 remoting.Error.Tag.INVALID_ACCESS_CODE);
473 case remoting.ClientSession.ConnectionError.INCOMPATIBLE_PROTOCOL:
474 this.error_ = new remoting.Error(
475 remoting.Error.Tag.INCOMPATIBLE_PROTOCOL);
477 case remoting.ClientSession.ConnectionError.NETWORK_FAILURE:
478 this.error_ = new remoting.Error(
479 remoting.Error.Tag.P2P_FAILURE);
481 case remoting.ClientSession.ConnectionError.HOST_OVERLOAD:
482 this.error_ = new remoting.Error(
483 remoting.Error.Tag.HOST_OVERLOAD);
486 this.error_ = remoting.Error.unexpected();
489 this.setState_(status);
493 * Callback that the plugin invokes to indicate that the connection type for
494 * a channel has changed.
496 * @param {string} channel The channel name.
497 * @param {string} connectionType The new connection type.
500 remoting.ClientSession.prototype.onRouteChanged =
501 function(channel, connectionType) {
502 console.log('plugin: Channel ' + channel + ' using ' +
503 connectionType + ' connection.');
504 this.logger_.setConnectionType(connectionType);
508 * Callback that the plugin invokes to indicate when the connection is
511 * @param {boolean} ready True if the connection is ready.
513 remoting.ClientSession.prototype.onConnectionReady = function(ready) {
514 // TODO(jamiewalch): Currently, the logic for determining whether or not the
515 // connection is available is based solely on whether or not any video frames
516 // have been received recently. which leads to poor UX on slow connections.
517 // Re-enable this once crbug.com/435315 has been fixed.
518 var ignoreVideoChannelState = true;
519 if (ignoreVideoChannelState) {
520 console.log('Video channel ' + (ready ? '' : 'not ') + 'ready.');
524 this.raiseEvent(remoting.ClientSession.Events.videoChannelStateChanged,
528 /** @return {boolean} */
529 remoting.ClientSession.prototype.isFinished = function() {
530 var finishedStates = [
531 remoting.ClientSession.State.CLOSED,
532 remoting.ClientSession.State.FAILED,
533 remoting.ClientSession.State.CONNECTION_CANCELED,
534 remoting.ClientSession.State.CONNECTION_DROPPED
536 return finishedStates.indexOf(this.getState()) !== -1;
539 * @param {remoting.ClientSession.State} newState The new state for the session.
540 * @return {void} Nothing.
543 remoting.ClientSession.prototype.setState_ = function(newState) {
544 // If we are at a finished state, ignore further state changes.
545 if (this.isFinished()) {
549 var oldState = this.state_;
550 this.state_ = this.translateState_(oldState, newState);
552 if (newState == remoting.ClientSession.State.CONNECTED) {
553 this.connectedDisposables_.add(
554 new base.RepeatingTimer(this.reportStatistics.bind(this), 1000));
555 if (this.plugin_.hasCapability(
556 remoting.ClientSession.Capability.TOUCH_EVENTS)) {
557 this.plugin_.enableTouchEvents(true);
559 } else if (this.isFinished()) {
560 base.dispose(this.connectedDisposables_);
561 this.connectedDisposables_ = null;
564 this.notifyStateChanges_(oldState, this.state_);
565 this.logger_.logClientSessionStateChange(this.state_, this.error_);
569 * @param {remoting.ClientSession.State} oldState The new state for the session.
570 * @param {remoting.ClientSession.State} newState The new state for the session.
573 remoting.ClientSession.prototype.notifyStateChanges_ =
574 function(oldState, newState) {
575 /** @type {remoting.Error} */
577 switch (this.state_) {
578 case remoting.ClientSession.State.CONNECTED:
579 console.log('Connection established.');
580 var connectionInfo = new remoting.ConnectionInfo(
581 this.host_, this.credentialsProvider_, this, this.plugin_);
582 this.listener_.onConnected(connectionInfo);
585 case remoting.ClientSession.State.CONNECTING:
586 remoting.identity.getEmail().then(function(/** string */ email) {
587 console.log('Connecting as ' + email);
591 case remoting.ClientSession.State.AUTHENTICATED:
592 console.log('Connection authenticated.');
595 case remoting.ClientSession.State.INITIALIZING:
596 console.log('Connection initializing .');
599 case remoting.ClientSession.State.CLOSED:
600 console.log('Connection closed.');
601 this.listener_.onDisconnected(remoting.Error.none());
604 case remoting.ClientSession.State.CONNECTION_CANCELED:
605 case remoting.ClientSession.State.FAILED:
606 error = this.getError();
607 if (!error.isNone()) {
608 console.error('Connection failed: ' + error.toString());
610 this.listener_.onConnectionFailed(error);
613 case remoting.ClientSession.State.CONNECTION_DROPPED:
614 error = this.getError();
615 console.error('Connection dropped: ' + error.toString());
616 this.listener_.onDisconnected(error);
620 console.error('Unexpected client plugin state: ' + newState);
625 * @param {remoting.ClientSession.State} previous
626 * @param {remoting.ClientSession.State} current
627 * @return {remoting.ClientSession.State}
630 remoting.ClientSession.prototype.translateState_ = function(previous, current) {
631 var State = remoting.ClientSession.State;
632 if (previous == State.CONNECTING || previous == State.AUTHENTICATED) {
633 if (current == State.CLOSED) {
634 return remoting.ClientSession.State.CONNECTION_CANCELED;
635 } else if (current == State.FAILED &&
636 this.error_.hasTag(remoting.Error.Tag.HOST_IS_OFFLINE) &&
637 !this.logHostOfflineErrors_) {
638 // The application requested host-offline errors to be suppressed, for
639 // example, because this connection attempt is using a cached host JID.
640 console.log('Suppressing host-offline error.');
641 return State.CONNECTION_CANCELED;
643 } else if (previous == State.CONNECTED && current == State.FAILED) {
644 return State.CONNECTION_DROPPED;
650 remoting.ClientSession.prototype.reportStatistics = function() {
651 this.logger_.logStatistics(this.plugin_.getPerfStats());
655 * Enable or disable logging of connection errors due to a host being offline.
656 * For example, if attempting a connection using a cached JID, host-offline
657 * errors should not be logged because the JID will be refreshed and the
658 * connection retried.
660 * @param {boolean} enable True to log host-offline errors; false to suppress.
662 remoting.ClientSession.prototype.logHostOfflineErrors = function(enable) {
663 this.logHostOfflineErrors_ = enable;