blob: 38532c647171ce95770bd578a086f5a2f382b49a [file] [log] [blame]
// 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, e.g. delivers incoming/outgoing signaling
* messages.
*
* 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 {remoting.ClientPlugin} plugin
* @param {remoting.SignalStrategy} signalStrategy Signal strategy.
* @param {remoting.SessionLogger} logger
* @param {remoting.ClientSession.EventHandler} listener
*
* @constructor
* @extends {base.EventSourceImpl}
* @implements {base.Disposable}
* @implements {remoting.ClientPlugin.ConnectionEventHandler}
*/
remoting.ClientSession = function(
plugin, signalStrategy, logger, listener) {
base.inherits(this, base.EventSourceImpl);
/** @private */
this.state_ = remoting.ClientSession.State.INITIALIZING;
/** @private {!remoting.Error} */
this.error_ = remoting.Error.none();
/** @private {remoting.Host} */
this.host_ = null;
/** @private {remoting.CredentialsProvider} */
this.credentialsProvider_ = null;
/** @private */
this.sessionId_ = '';
/** @private */
this.listener_ = listener;
/** @private */
this.hasReceivedFrame_ = false;
/** @private */
this.logger_ = logger;
/** @private */
this.signalStrategy_ = signalStrategy;
var state = this.signalStrategy_.getState();
console.assert(state == remoting.SignalStrategy.State.CONNECTED,
'ClientSession ctor called in state ' + state + '.');
this.signalStrategy_.setIncomingStanzaCallback(
this.onIncomingMessage_.bind(this));
/** @private {remoting.FormatIq} */
this.iqFormatter_ = null;
/** @private {remoting.XmppErrorCache} */
this.xmppErrorCache_ = new remoting.XmppErrorCache();
/** @private {remoting.ClientPlugin} */
this.plugin_ = plugin;
plugin.setConnectionEventHandler(this);
/** @private */
this.connectedDisposables_ = new base.Disposables();
this.defineEvents(Object.keys(remoting.ClientSession.Events));
};
/** @enum {string} */
remoting.ClientSession.Events = {
videoChannelStateChanged: 'videoChannelStateChanged'
};
/**
* @interface
* [START]-------> [onConnected] ------> [onDisconnected]
* |
* |-----> [OnConnectionFailed]
*
*/
remoting.ClientSession.EventHandler = function() {};
/**
* Called when the connection failed before it is connected.
*
* @param {!remoting.Error} error
*/
remoting.ClientSession.EventHandler.prototype.onConnectionFailed =
function(error) {};
/**
* Called when a new session has been connected. The |connectionInfo| will be
* valid until onDisconnected() is called.
*
* @param {!remoting.ConnectionInfo} connectionInfo
*/
remoting.ClientSession.EventHandler.prototype.onConnected =
function(connectionInfo) {};
/**
* Called when the current session has been disconnected.
*
* @param {!remoting.Error} reason Reason that the session is disconnected.
* Set to remoting.Error.none() if there is no error.
*/
remoting.ClientSession.EventHandler.prototype.onDisconnected =
function(reason) {};
// Note that the positive values in both of these enums are copied directly
// from connection_to_host.h and must be kept in sync. Code in
// chromoting_instance.cc converts the C++ enums into strings that must match
// the names given here.
// The negative values represent state transitions that occur within the
// web-app that have no corresponding plugin state transition.
//
// TODO(kelvinp): Merge this enum with remoting.ChromotingEvent.SessionState
// once we have migrated away from XMPP-based logging (crbug.com/523423).
//
// NOTE: The enums here correspond to the Chromoting.Connections enumerated
// histogram defined in src/tools/metrics/histograms/histograms.xml. UMA
// histograms don't work well with negative values, so only non-negative values
// have been used for Chromoting.Connections.
// The maximum values for the UMA enumerated histogram is included here for use
// when uploading values to UMA.
// The 2 lists should be kept in sync, and any new enums should be append-only.
/** @enum {number} */
remoting.ClientSession.State = {
MIN_STATE_ENUM: -3,
CONNECTION_CANCELED: -3, // Connection closed (gracefully) before connecting.
CONNECTION_DROPPED: -2, // Succeeded, but subsequently closed with an error.
CREATED: -1,
UNKNOWN: 0,
INITIALIZING: 1,
CONNECTING: 2,
AUTHENTICATED: 3,
CONNECTED: 4,
CLOSED: 5,
FAILED: 6,
MAX_STATE_ENUM: 6,
};
/**
* @param {string} state The state name.
* @return {remoting.ClientSession.State} The session state enum value.
*/
remoting.ClientSession.State.fromString = function(state) {
if (!remoting.ClientSession.State.hasOwnProperty(state)) {
throw "Invalid ClientSession.State: " + state;
}
return remoting.ClientSession.State[state];
};
/** @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,
MAX_SESSION_LENGTH: 6,
HOST_CONFIGURATION_ERROR: 7
};
/**
* @param {string} error The connection error name.
* @return {remoting.ClientSession.ConnectionError} The connection error enum.
*/
remoting.ClientSession.ConnectionError.fromString = function(error) {
if (!remoting.ClientSession.ConnectionError.hasOwnProperty(error)) {
console.error('Unexpected ClientSession.ConnectionError string: ', error);
return remoting.ClientSession.ConnectionError.UNKNOWN;
}
return remoting.ClientSession.ConnectionError[error];
}
/**
* 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';
/**
* 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',
// Let the host know that we're interested in knowing whether or not it
// rate limits desktop-resize requests.
// TODO(kelvinp): This has been supported since M-29. Currently we only have
// <1000 users on M-29 or below. Remove this and the capability on the host.
RATE_LIMIT_RESIZE_REQUESTS: 'rateLimitResizeRequests',
// Indicates native touch input support. If the host does not support
// touch then the client will let Chrome synthesize mouse events from touch
// input, for compatibility with non-touch-aware systems.
TOUCH_EVENTS: 'touchEvents',
// Indicates that host/client supports Google Drive integration, and that the
// client should send to the host the OAuth tokens to be used by Google Drive
// on the host.
GOOGLE_DRIVE: 'googleDrive',
// Indicates that the client supports the video frame-recording extension.
VIDEO_RECORDER: 'videoRecorder',
// Indicates that the client supports 'cast'ing the video stream to a
// cast-enabled device.
CAST: 'casting',
// Indicates desktop shape support.
DESKTOP_SHAPE: 'desktopShape',
};
/**
* Connects to |host| using |credentialsProvider| as the credentails.
*
* @param {remoting.Host} host
* @param {remoting.CredentialsProvider} credentialsProvider
*/
remoting.ClientSession.prototype.connect = function(host, credentialsProvider) {
this.host_ = host;
this.credentialsProvider_ = credentialsProvider;
this.iqFormatter_ =
new remoting.FormatIq(this.signalStrategy_.getJid(), host.jabberId);
this.plugin_.connect(this.host_, this.signalStrategy_.getJid(),
credentialsProvider);
};
/**
* Disconnect the current session with a particular |error|. The session will
* raise a |stateChanged| event in response to it. The caller should then call
* dispose() to remove and destroy the <embed> element.
*
* @param {!remoting.Error} error The reason for the disconnection. Use
* remoting.Error.none() if there is no error.
* @return {void} Nothing.
*/
remoting.ClientSession.prototype.disconnect = function(error) {
if (this.isFinished()) {
// Do not send the session-terminate Iq if disconnect() is already called or
// if it is initiated by the host.
return;
}
console.assert(this.host_ != null, 'disconnect() is called before connect()');
this.sendIq_(
'<cli:iq ' +
'to="' + this.host_.jabberId + '" ' +
'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>');
var state = error.isNone() ?
remoting.ClientSession.State.CLOSED :
remoting.ClientSession.State.FAILED;
this.error_ = error;
this.setState_(state);
};
/**
* Deletes the <embed> element from the container and disconnects.
*
* @return {void} Nothing.
*/
remoting.ClientSession.prototype.dispose = function() {
base.dispose(this.connectedDisposables_);
this.connectedDisposables_ = null;
base.dispose(this.plugin_);
this.plugin_ = null;
};
/**
* @return {remoting.ClientSession.State} The current state.
*/
remoting.ClientSession.prototype.getState = function() {
return this.state_;
};
/**
* @return {remoting.SessionLogger}.
*/
remoting.ClientSession.prototype.getLogger = function() {
return this.logger_;
};
/**
* @return {!remoting.Error} The current error code.
*/
remoting.ClientSession.prototype.getError = function() {
return this.error_;
};
/**
* Drop the session when the computer is suspended for more than
* |suspendDurationInMS|.
*
* @param {number} suspendDurationInMS maximum duration of suspension allowed
* before the session will be dropped.
*/
remoting.ClientSession.prototype.dropSessionOnSuspend = function(
suspendDurationInMS) {
if (this.state_ !== remoting.ClientSession.State.CONNECTED) {
console.error('The session is not connected.');
return;
}
var suspendDetector = new remoting.SuspendDetector(suspendDurationInMS);
this.connectedDisposables_.add(
suspendDetector,
new base.EventHook(
suspendDetector, remoting.SuspendDetector.Events.resume,
this.disconnect.bind(
this, new remoting.Error(remoting.Error.Tag.CLIENT_SUSPENDED))));
};
/**
* 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 a signaling message.
*
* @param {string} message XML string of IQ stanza to send to server.
* @return {void} Nothing.
* @private
*/
remoting.ClientSession.prototype.sendIq_ = function(message) {
// Extract the session id, so we can close the session later.
var parser = new DOMParser();
var iqNode = parser.parseFromString(message, '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');
}
}
console.log(base.timestamp() + this.iqFormatter_.prettifySendIq(message));
if (this.signalStrategy_.getState() !=
remoting.SignalStrategy.State.CONNECTED) {
console.log("Message above is dropped because signaling is not connected.");
return;
}
this.signalStrategy_.sendMessage(message);
};
/**
* @param {string} message XML string of IQ stanza to send to server.
*/
remoting.ClientSession.prototype.onOutgoingIq = function(message) {
this.sendIq_(message);
};
/**
* @param {string} msg
*/
remoting.ClientSession.prototype.onDebugMessage = function(msg) {
console.log('plugin: ' + msg.trimRight());
};
/**
* @param {Element} message
* @private
*/
remoting.ClientSession.prototype.onIncomingMessage_ = function(message) {
if (!this.plugin_) {
return;
}
var formatted = new XMLSerializer().serializeToString(message);
console.log(base.timestamp() +
this.iqFormatter_.prettifyReceiveIq(formatted));
this.xmppErrorCache_.processStanza(message);
this.plugin_.onIncomingIq(formatted);
};
/**
* Callback that the plugin invokes to indicate that the connection
* status has changed.
*
* @param {remoting.ClientSession.State} status The plugin's status.
* @param {remoting.ClientSession.ConnectionError} error The plugin's error
* state, if any.
*/
remoting.ClientSession.prototype.onConnectionStatusUpdate =
function(status, error) {
if (status == remoting.ClientSession.State.FAILED) {
var errorTag = remoting.Error.Tag.UNEXPECTED;
switch (error) {
case remoting.ClientSession.ConnectionError.HOST_IS_OFFLINE:
errorTag = remoting.Error.Tag.HOST_IS_OFFLINE;
break;
case remoting.ClientSession.ConnectionError.SESSION_REJECTED:
errorTag = remoting.Error.Tag.INVALID_ACCESS_CODE;
break;
case remoting.ClientSession.ConnectionError.INCOMPATIBLE_PROTOCOL:
errorTag = remoting.Error.Tag.INCOMPATIBLE_PROTOCOL;
break;
case remoting.ClientSession.ConnectionError.NETWORK_FAILURE:
errorTag = remoting.Error.Tag.P2P_FAILURE;
break;
case remoting.ClientSession.ConnectionError.HOST_OVERLOAD:
errorTag = remoting.Error.Tag.HOST_OVERLOAD;
break;
case remoting.ClientSession.ConnectionError.MAX_SESSION_LENGTH:
errorTag = remoting.Error.Tag.MAX_SESSION_LENGTH;
break;
case remoting.ClientSession.ConnectionError.HOST_CONFIGURATION_ERROR:
errorTag = remoting.Error.Tag.HOST_CONFIGURATION_ERROR;
break;
default:
this.error_ = remoting.Error.unexpected();
}
this.error_ = new remoting.Error(
errorTag, this.xmppErrorCache_.getFirstErrorStanza());
}
this.setState_(status);
};
/**
* Callback that the plugin invokes to indicate that the connection type for
* a channel has changed.
*
* @param {string} channel The channel name.
* @param {string} connectionType The new connection type.
* @private
*/
remoting.ClientSession.prototype.onRouteChanged = function(channel,
connectionType) {
this.logger_.setConnectionType(connectionType);
};
/**
* Callback that the plugin invokes to indicate when the connection is
* ready.
*
* @param {boolean} ready True if the connection is ready.
*/
remoting.ClientSession.prototype.onConnectionReady = function(ready) {
// TODO(jamiewalch): Currently, the logic for determining whether or not the
// connection is available is based solely on whether or not any video frames
// have been received recently. which leads to poor UX on slow connections.
// Re-enable this once crbug.com/435315 has been fixed.
var ignoreVideoChannelState = true;
if (ignoreVideoChannelState) {
console.log('Video channel ' + (ready ? '' : 'not ') + 'ready.');
return;
}
this.raiseEvent(remoting.ClientSession.Events.videoChannelStateChanged,
ready);
};
/** @return {boolean} */
remoting.ClientSession.prototype.isFinished = function() {
var finishedStates = [
remoting.ClientSession.State.CLOSED,
remoting.ClientSession.State.FAILED,
remoting.ClientSession.State.CONNECTION_CANCELED,
remoting.ClientSession.State.CONNECTION_DROPPED
];
return finishedStates.indexOf(this.getState()) !== -1;
};
/**
* @param {remoting.ClientSession.State} newState The new state for the session.
* @return {void} Nothing.
* @private
*/
remoting.ClientSession.prototype.setState_ = function(newState) {
// If we are at a finished state, ignore further state changes.
if (this.isFinished()) {
return;
}
var oldState = this.state_;
this.state_ = this.translateState_(oldState, newState);
if (newState == remoting.ClientSession.State.CONNECTED) {
this.connectedDisposables_.add(
new base.RepeatingTimer(this.reportStatistics.bind(this), 1000));
if (this.plugin_.hasCapability(
remoting.ClientSession.Capability.TOUCH_EVENTS)) {
this.plugin_.enableTouchEvents(true);
}
} else if (this.isFinished()) {
base.dispose(this.connectedDisposables_);
this.connectedDisposables_ = null;
}
this.logAuthMethod_();
this.notifyStateChanges_(oldState, this.state_);
// Record state count in an UMA enumerated histogram.
recordState(this.state_);
this.logger_.logSessionStateChange(toSessionState(this.state_), this.error_);
};
/** @private */
remoting.ClientSession.prototype.logAuthMethod_ = function() {
// The AuthMethod is undefined before the AUTHENTICATED stage for a
// successful connection or the FAILED stage for a failed connection.
if (this.state_ == remoting.ClientSession.State.AUTHENTICATED ||
this.state_ == remoting.ClientSession.State.FAILED) {
var authMethod = this.credentialsProvider_.getAuthMethod();
if (authMethod != null) {
this.logger_.setAuthMethod(authMethod);
}
}
};
/**
* Records a Chromoting Connection State, stored in an UMA enumerated histogram.
* @param {remoting.ClientSession.State} state State identifier.
*/
function recordState(state) {
// According to src/base/metrics/histogram.h, for a UMA enumerated histogram,
// the upper limit should be 1 above the max-enum.
var histogram_max = remoting.ClientSession.State.MAX_STATE_ENUM -
remoting.ClientSession.State.MIN_STATE_ENUM + 1;
var metricDescription = {
metricName: 'Chromoting.Connections',
type: 'histogram-linear',
// According to histogram.h, minimum should be 1. Values less than minimum
// end up in the 0th bucket.
min: 1,
max: histogram_max,
// The # of buckets should include 1 for underflow.
buckets: histogram_max + 1
};
chrome.metricsPrivate.recordValue(metricDescription, state -
remoting.ClientSession.State.MIN_STATE_ENUM);
}
/**
* @param {remoting.ClientSession.State} oldState The new state for the session.
* @param {remoting.ClientSession.State} newState The new state for the session.
* @private
*/
remoting.ClientSession.prototype.notifyStateChanges_ =
function(oldState, newState) {
/** @type {remoting.Error} */
var error;
switch (this.state_) {
case remoting.ClientSession.State.CONNECTED:
console.log('Connection established.');
var connectionInfo = new remoting.ConnectionInfo(
this.host_, this.credentialsProvider_, this, this.plugin_);
this.listener_.onConnected(connectionInfo);
break;
case remoting.ClientSession.State.CONNECTING:
remoting.identity.getEmail().then(function(/** string */ email) {
console.log('Connecting as ' + email);
});
break;
case remoting.ClientSession.State.AUTHENTICATED:
console.log('Connection authenticated.');
break;
case remoting.ClientSession.State.INITIALIZING:
console.log('Connection initializing .');
break;
case remoting.ClientSession.State.CLOSED:
console.log('Connection closed.');
this.listener_.onDisconnected(remoting.Error.none());
break;
case remoting.ClientSession.State.CONNECTION_CANCELED:
case remoting.ClientSession.State.FAILED:
error = this.getError();
if (!error.isNone()) {
console.error('Connection failed: ' + error.toString());
}
this.listener_.onConnectionFailed(error);
break;
case remoting.ClientSession.State.CONNECTION_DROPPED:
error = this.getError();
console.error('Connection dropped: ' + error.toString());
this.listener_.onDisconnected(error);
break;
default:
console.error('Unexpected client plugin state: ' + newState);
}
};
/**
* TODO(kelvinp): Consolidate the two enums (crbug.com/504200)
* @param {remoting.ClientSession.State} state
* @return {remoting.ChromotingEvent.SessionState}
*/
function toSessionState(state) {
var SessionState = remoting.ChromotingEvent.SessionState;
switch(state) {
case remoting.ClientSession.State.UNKNOWN:
return SessionState.UNKNOWN;
case remoting.ClientSession.State.INITIALIZING:
return SessionState.INITIALIZING;
case remoting.ClientSession.State.CONNECTING:
return SessionState.CONNECTING;
case remoting.ClientSession.State.AUTHENTICATED:
return SessionState.AUTHENTICATED;
case remoting.ClientSession.State.CONNECTED:
return SessionState.CONNECTED;
case remoting.ClientSession.State.CLOSED:
return SessionState.CLOSED;
case remoting.ClientSession.State.FAILED:
return SessionState.CONNECTION_FAILED;
case remoting.ClientSession.State.CONNECTION_DROPPED:
return SessionState.CONNECTION_DROPPED;
case remoting.ClientSession.State.CONNECTION_CANCELED:
return SessionState.CONNECTION_CANCELED;
default:
throw new Error('Unknown session state : ' + state);
}
}
/**
* @param {remoting.ClientSession.State} previous
* @param {remoting.ClientSession.State} current
* @return {remoting.ClientSession.State}
* @private
*/
remoting.ClientSession.prototype.translateState_ = function(previous, current) {
var State = remoting.ClientSession.State;
if (previous == State.CONNECTING || previous == State.AUTHENTICATED) {
if (current == State.CLOSED) {
return remoting.ClientSession.State.CONNECTION_CANCELED;
}
} else if (previous == State.CONNECTED && current == State.FAILED) {
return State.CONNECTION_DROPPED;
}
return current;
};
/** @private */
remoting.ClientSession.prototype.reportStatistics = function() {
this.logger_.logStatistics(this.plugin_.getPerfStats());
};