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 that wraps low-level details of interacting with the client plugin.
8 *
9 * This abstracts a <embed> element and controls the plugin which does
10 * the actual remoting work. It also handles differences between
11 * client plugins versions when it is necessary.
12 */
13
14'use strict';
15
16/** @suppress {duplicate} */
17var remoting = remoting || {};
18
19/**
20 * @param {remoting.ViewerPlugin} plugin The plugin embed element.
21 * @constructor
22 * @implements {remoting.ClientPlugin}
23 */
24remoting.ClientPluginAsync = function(plugin) {
25  this.plugin = plugin;
26
27  this.desktopWidth = 0;
28  this.desktopHeight = 0;
29  this.desktopXDpi = 96;
30  this.desktopYDpi = 96;
31
32  /** @param {string} iq The Iq stanza received from the host. */
33  this.onOutgoingIqHandler = function (iq) {};
34  /** @param {string} message Log message. */
35  this.onDebugMessageHandler = function (message) {};
36  /**
37   * @param {number} state The connection state.
38   * @param {number} error The error code, if any.
39   */
40  this.onConnectionStatusUpdateHandler = function(state, error) {};
41  /** @param {boolean} ready Connection ready state. */
42  this.onConnectionReadyHandler = function(ready) {};
43  /**
44   * @param {string} tokenUrl Token-request URL, received from the host.
45   * @param {string} hostPublicKey Public key for the host.
46   * @param {string} scope OAuth scope to request the token for.
47   */
48  this.fetchThirdPartyTokenHandler = function(
49    tokenUrl, hostPublicKey, scope) {};
50  this.onDesktopSizeUpdateHandler = function () {};
51  /** @param {!Array.<string>} capabilities The negotiated capabilities. */
52  this.onSetCapabilitiesHandler = function (capabilities) {};
53  this.fetchPinHandler = function (supportsPairing) {};
54
55  /** @type {number} */
56  this.pluginApiVersion_ = -1;
57  /** @type {Array.<string>} */
58  this.pluginApiFeatures_ = [];
59  /** @type {number} */
60  this.pluginApiMinVersion_ = -1;
61  /** @type {!Array.<string>} */
62  this.capabilities_ = [];
63  /** @type {boolean} */
64  this.helloReceived_ = false;
65  /** @type {function(boolean)|null} */
66  this.onInitializedCallback_ = null;
67  /** @type {function(string, string):void} */
68  this.onPairingComplete_ = function(clientId, sharedSecret) {};
69  /** @type {remoting.ClientSession.PerfStats} */
70  this.perfStats_ = new remoting.ClientSession.PerfStats();
71
72  /** @type {remoting.ClientPluginAsync} */
73  var that = this;
74  /** @param {Event} event Message event from the plugin. */
75  this.plugin.addEventListener('message', function(event) {
76      that.handleMessage_(event.data);
77    }, false);
78  window.setTimeout(this.showPluginForClickToPlay_.bind(this), 500);
79};
80
81/**
82 * Chromoting session API version (for this javascript).
83 * This is compared with the plugin API version to verify that they are
84 * compatible.
85 *
86 * @const
87 * @private
88 */
89remoting.ClientPluginAsync.prototype.API_VERSION_ = 6;
90
91/**
92 * The oldest API version that we support.
93 * This will differ from the |API_VERSION_| if we maintain backward
94 * compatibility with older API versions.
95 *
96 * @const
97 * @private
98 */
99remoting.ClientPluginAsync.prototype.API_MIN_VERSION_ = 5;
100
101/**
102 * @param {string} messageStr Message from the plugin.
103 */
104remoting.ClientPluginAsync.prototype.handleMessage_ = function(messageStr) {
105  var message = /** @type {{method:string, data:Object.<string,string>}} */
106      jsonParseSafe(messageStr);
107
108  if (!message || !('method' in message) || !('data' in message)) {
109    console.error('Received invalid message from the plugin: ' + messageStr);
110    return;
111  }
112
113  /**
114   * Splits a string into a list of words delimited by spaces.
115   * @param {string} str String that should be split.
116   * @return {!Array.<string>} List of words.
117   */
118  var tokenize = function(str) {
119    /** @type {Array.<string>} */
120    var tokens = str.match(/\S+/g);
121    return tokens ? tokens : [];
122  };
123
124  if (message.method == 'hello') {
125    // Reset the size in case we had to enlarge it to support click-to-play.
126    this.plugin.width = 0;
127    this.plugin.height = 0;
128    if (typeof message.data['apiVersion'] != 'number' ||
129        typeof message.data['apiMinVersion'] != 'number') {
130      console.error('Received invalid hello message: ' + messageStr);
131      return;
132    }
133    this.pluginApiVersion_ = /** @type {number} */ message.data['apiVersion'];
134
135    if (this.pluginApiVersion_ >= 7) {
136      if (typeof message.data['apiFeatures'] != 'string') {
137        console.error('Received invalid hello message: ' + messageStr);
138        return;
139      }
140      this.pluginApiFeatures_ =
141          /** @type {Array.<string>} */ tokenize(message.data['apiFeatures']);
142
143      // Negotiate capabilities.
144
145      /** @type {!Array.<string>} */
146      var requestedCapabilities = [];
147      if ('requestedCapabilities' in message.data) {
148        if (typeof message.data['requestedCapabilities'] != 'string') {
149          console.error('Received invalid hello message: ' + messageStr);
150          return;
151        }
152        requestedCapabilities = tokenize(message.data['requestedCapabilities']);
153      }
154
155      /** @type {!Array.<string>} */
156      var supportedCapabilities = [];
157      if ('supportedCapabilities' in message.data) {
158        if (typeof message.data['supportedCapabilities'] != 'string') {
159          console.error('Received invalid hello message: ' + messageStr);
160          return;
161        }
162        supportedCapabilities = tokenize(message.data['supportedCapabilities']);
163      }
164
165      // At the moment the webapp does not recognize any of
166      // 'requestedCapabilities' capabilities (so they all should be disabled)
167      // and do not care about any of 'supportedCapabilities' capabilities (so
168      // they all can be enabled).
169      this.capabilities_ = supportedCapabilities;
170
171      // Let the host know that the webapp can be requested to always send
172      // the client's dimensions.
173      this.capabilities_.push(
174          remoting.ClientSession.Capability.SEND_INITIAL_RESOLUTION);
175
176      // Let the host know that we're interested in knowing whether or not
177      // it rate-limits desktop-resize requests.
178      this.capabilities_.push(
179          remoting.ClientSession.Capability.RATE_LIMIT_RESIZE_REQUESTS);
180    } else if (this.pluginApiVersion_ >= 6) {
181      this.pluginApiFeatures_ = ['highQualityScaling', 'injectKeyEvent'];
182    } else {
183      this.pluginApiFeatures_ = ['highQualityScaling'];
184    }
185    this.pluginApiMinVersion_ =
186        /** @type {number} */ message.data['apiMinVersion'];
187    this.helloReceived_ = true;
188    if (this.onInitializedCallback_ != null) {
189      this.onInitializedCallback_(true);
190      this.onInitializedCallback_ = null;
191    }
192  } else if (message.method == 'sendOutgoingIq') {
193    if (typeof message.data['iq'] != 'string') {
194      console.error('Received invalid sendOutgoingIq message: ' + messageStr);
195      return;
196    }
197    this.onOutgoingIqHandler(message.data['iq']);
198  } else if (message.method == 'logDebugMessage') {
199    if (typeof message.data['message'] != 'string') {
200      console.error('Received invalid logDebugMessage message: ' + messageStr);
201      return;
202    }
203    this.onDebugMessageHandler(message.data['message']);
204  } else if (message.method == 'onConnectionStatus') {
205    if (typeof message.data['state'] != 'string' ||
206        !remoting.ClientSession.State.hasOwnProperty(message.data['state']) ||
207        typeof message.data['error'] != 'string') {
208      console.error('Received invalid onConnectionState message: ' +
209                    messageStr);
210      return;
211    }
212
213    /** @type {remoting.ClientSession.State} */
214    var state = remoting.ClientSession.State[message.data['state']];
215    var error;
216    if (remoting.ClientSession.ConnectionError.hasOwnProperty(
217        message.data['error'])) {
218      error = /** @type {remoting.ClientSession.ConnectionError} */
219          remoting.ClientSession.ConnectionError[message.data['error']];
220    } else {
221      error = remoting.ClientSession.ConnectionError.UNKNOWN;
222    }
223
224    this.onConnectionStatusUpdateHandler(state, error);
225  } else if (message.method == 'onDesktopSize') {
226    if (typeof message.data['width'] != 'number' ||
227        typeof message.data['height'] != 'number') {
228      console.error('Received invalid onDesktopSize message: ' + messageStr);
229      return;
230    }
231    this.desktopWidth = /** @type {number} */ message.data['width'];
232    this.desktopHeight = /** @type {number} */ message.data['height'];
233    this.desktopXDpi = (typeof message.data['x_dpi'] == 'number') ?
234        /** @type {number} */ (message.data['x_dpi']) : 96;
235    this.desktopYDpi = (typeof message.data['y_dpi'] == 'number') ?
236        /** @type {number} */ (message.data['y_dpi']) : 96;
237    this.onDesktopSizeUpdateHandler();
238  } else if (message.method == 'onPerfStats') {
239    if (typeof message.data['videoBandwidth'] != 'number' ||
240        typeof message.data['videoFrameRate'] != 'number' ||
241        typeof message.data['captureLatency'] != 'number' ||
242        typeof message.data['encodeLatency'] != 'number' ||
243        typeof message.data['decodeLatency'] != 'number' ||
244        typeof message.data['renderLatency'] != 'number' ||
245        typeof message.data['roundtripLatency'] != 'number') {
246      console.error('Received incorrect onPerfStats message: ' + messageStr);
247      return;
248    }
249    this.perfStats_ =
250        /** @type {remoting.ClientSession.PerfStats} */ message.data;
251  } else if (message.method == 'injectClipboardItem') {
252    if (typeof message.data['mimeType'] != 'string' ||
253        typeof message.data['item'] != 'string') {
254      console.error('Received incorrect injectClipboardItem message.');
255      return;
256    }
257    if (remoting.clipboard) {
258      remoting.clipboard.fromHost(message.data['mimeType'],
259                                  message.data['item']);
260    }
261  } else if (message.method == 'onFirstFrameReceived') {
262    if (remoting.clientSession) {
263      remoting.clientSession.onFirstFrameReceived();
264    }
265  } else if (message.method == 'onConnectionReady') {
266    if (typeof message.data['ready'] != 'boolean') {
267      console.error('Received incorrect onConnectionReady message.');
268      return;
269    }
270    var ready = /** @type {boolean} */ message.data['ready'];
271    this.onConnectionReadyHandler(ready);
272  } else if (message.method == 'fetchPin') {
273    // The pairingSupported value in the dictionary indicates whether both
274    // client and host support pairing. If the client doesn't support pairing,
275    // then the value won't be there at all, so give it a default of false.
276    /** @type {boolean} */
277    var pairingSupported = false;
278    if ('pairingSupported' in message.data) {
279      pairingSupported =
280          /** @type {boolean} */ message.data['pairingSupported'];
281      if (typeof pairingSupported != 'boolean') {
282        console.error('Received incorrect fetchPin message.');
283        return;
284      }
285    }
286    this.fetchPinHandler(pairingSupported);
287  } else if (message.method == 'setCapabilities') {
288    if (typeof message.data['capabilities'] != 'string') {
289      console.error('Received incorrect setCapabilities message.');
290      return;
291    }
292
293    /** @type {!Array.<string>} */
294    var capabilities = tokenize(message.data['capabilities']);
295    this.onSetCapabilitiesHandler(capabilities);
296  } else if (message.method == 'fetchThirdPartyToken') {
297    if (typeof message.data['tokenUrl'] != 'string' ||
298        typeof message.data['hostPublicKey'] != 'string' ||
299        typeof message.data['scope'] != 'string') {
300      console.error('Received incorrect fetchThirdPartyToken message.');
301      return;
302    }
303    var tokenUrl = /** @type {string} */ message.data['tokenUrl'];
304    var hostPublicKey =
305        /** @type {string} */ message.data['hostPublicKey'];
306    var scope = /** @type {string} */ message.data['scope'];
307    this.fetchThirdPartyTokenHandler(tokenUrl, hostPublicKey, scope);
308  } else if (message.method == 'pairingResponse') {
309    var clientId = /** @type {string} */ message.data['clientId'];
310    var sharedSecret = /** @type {string} */ message.data['sharedSecret'];
311    if (typeof clientId != 'string' || typeof sharedSecret != 'string') {
312      console.error('Received incorrect pairingResponse message.');
313      return;
314    }
315    this.onPairingComplete_(clientId, sharedSecret);
316  } else if (message.method == 'extensionMessage') {
317    // No messages currently supported.
318    console.log('Unexpected message received: ' +
319                message.data.type + ': ' + message.data.data);
320  }
321};
322
323/**
324 * Deletes the plugin.
325 */
326remoting.ClientPluginAsync.prototype.cleanup = function() {
327  this.plugin.parentNode.removeChild(this.plugin);
328};
329
330/**
331 * @return {HTMLEmbedElement} HTML element that correspods to the plugin.
332 */
333remoting.ClientPluginAsync.prototype.element = function() {
334  return this.plugin;
335};
336
337/**
338 * @param {function(boolean): void} onDone
339 */
340remoting.ClientPluginAsync.prototype.initialize = function(onDone) {
341  if (this.helloReceived_) {
342    onDone(true);
343  } else {
344    this.onInitializedCallback_ = onDone;
345  }
346};
347
348/**
349 * @return {boolean} True if the plugin and web-app versions are compatible.
350 */
351remoting.ClientPluginAsync.prototype.isSupportedVersion = function() {
352  if (!this.helloReceived_) {
353    console.error(
354        "isSupportedVersion() is called before the plugin is initialized.");
355    return false;
356  }
357  return this.API_VERSION_ >= this.pluginApiMinVersion_ &&
358      this.pluginApiVersion_ >= this.API_MIN_VERSION_;
359};
360
361/**
362 * @param {remoting.ClientPlugin.Feature} feature The feature to test for.
363 * @return {boolean} True if the plugin supports the named feature.
364 */
365remoting.ClientPluginAsync.prototype.hasFeature = function(feature) {
366  if (!this.helloReceived_) {
367    console.error(
368        "hasFeature() is called before the plugin is initialized.");
369    return false;
370  }
371  return this.pluginApiFeatures_.indexOf(feature) > -1;
372};
373
374/**
375 * @return {boolean} True if the plugin supports the injectKeyEvent API.
376 */
377remoting.ClientPluginAsync.prototype.isInjectKeyEventSupported = function() {
378  return this.pluginApiVersion_ >= 6;
379};
380
381/**
382 * @param {string} iq Incoming IQ stanza.
383 */
384remoting.ClientPluginAsync.prototype.onIncomingIq = function(iq) {
385  if (this.plugin && this.plugin.postMessage) {
386    this.plugin.postMessage(JSON.stringify(
387        { method: 'incomingIq', data: { iq: iq } }));
388  } else {
389    // plugin.onIq may not be set after the plugin has been shut
390    // down. Particularly this happens when we receive response to
391    // session-terminate stanza.
392    console.warn('plugin.onIq is not set so dropping incoming message.');
393  }
394};
395
396/**
397 * @param {string} hostJid The jid of the host to connect to.
398 * @param {string} hostPublicKey The base64 encoded version of the host's
399 *     public key.
400 * @param {string} localJid Local jid.
401 * @param {string} sharedSecret The access code for IT2Me or the PIN
402 *     for Me2Me.
403 * @param {string} authenticationMethods Comma-separated list of
404 *     authentication methods the client should attempt to use.
405 * @param {string} authenticationTag A host-specific tag to mix into
406 *     authentication hashes.
407 * @param {string} clientPairingId For paired Me2Me connections, the
408 *     pairing id for this client, as issued by the host.
409 * @param {string} clientPairedSecret For paired Me2Me connections, the
410 *     paired secret for this client, as issued by the host.
411 */
412remoting.ClientPluginAsync.prototype.connect = function(
413    hostJid, hostPublicKey, localJid, sharedSecret,
414    authenticationMethods, authenticationTag,
415    clientPairingId, clientPairedSecret) {
416  this.plugin.postMessage(JSON.stringify(
417    { method: 'connect', data: {
418        hostJid: hostJid,
419        hostPublicKey: hostPublicKey,
420        localJid: localJid,
421        sharedSecret: sharedSecret,
422        authenticationMethods: authenticationMethods,
423        authenticationTag: authenticationTag,
424        capabilities: this.capabilities_.join(" "),
425        clientPairingId: clientPairingId,
426        clientPairedSecret: clientPairedSecret
427      }
428    }));
429};
430
431/**
432 * Release all currently pressed keys.
433 */
434remoting.ClientPluginAsync.prototype.releaseAllKeys = function() {
435  this.plugin.postMessage(JSON.stringify(
436      { method: 'releaseAllKeys', data: {} }));
437};
438
439/**
440 * Send a key event to the host.
441 *
442 * @param {number} usbKeycode The USB-style code of the key to inject.
443 * @param {boolean} pressed True to inject a key press, False for a release.
444 */
445remoting.ClientPluginAsync.prototype.injectKeyEvent =
446    function(usbKeycode, pressed) {
447  this.plugin.postMessage(JSON.stringify(
448      { method: 'injectKeyEvent', data: {
449          'usbKeycode': usbKeycode,
450          'pressed': pressed}
451      }));
452};
453
454/**
455 * Remap one USB keycode to another in all subsequent key events.
456 *
457 * @param {number} fromKeycode The USB-style code of the key to remap.
458 * @param {number} toKeycode The USB-style code to remap the key to.
459 */
460remoting.ClientPluginAsync.prototype.remapKey =
461    function(fromKeycode, toKeycode) {
462  this.plugin.postMessage(JSON.stringify(
463      { method: 'remapKey', data: {
464          'fromKeycode': fromKeycode,
465          'toKeycode': toKeycode}
466      }));
467};
468
469/**
470 * Enable/disable redirection of the specified key to the web-app.
471 *
472 * @param {number} keycode The USB-style code of the key.
473 * @param {Boolean} trap True to enable trapping, False to disable.
474 */
475remoting.ClientPluginAsync.prototype.trapKey = function(keycode, trap) {
476  this.plugin.postMessage(JSON.stringify(
477      { method: 'trapKey', data: {
478          'keycode': keycode,
479          'trap': trap}
480      }));
481};
482
483/**
484 * Returns an associative array with a set of stats for this connecton.
485 *
486 * @return {remoting.ClientSession.PerfStats} The connection statistics.
487 */
488remoting.ClientPluginAsync.prototype.getPerfStats = function() {
489  return this.perfStats_;
490};
491
492/**
493 * Sends a clipboard item to the host.
494 *
495 * @param {string} mimeType The MIME type of the clipboard item.
496 * @param {string} item The clipboard item.
497 */
498remoting.ClientPluginAsync.prototype.sendClipboardItem =
499    function(mimeType, item) {
500  if (!this.hasFeature(remoting.ClientPlugin.Feature.SEND_CLIPBOARD_ITEM))
501    return;
502  this.plugin.postMessage(JSON.stringify(
503      { method: 'sendClipboardItem',
504        data: { mimeType: mimeType, item: item }}));
505};
506
507/**
508 * Notifies the host that the client has the specified size and pixel density.
509 *
510 * @param {number} width The available client width in DIPs.
511 * @param {number} height The available client height in DIPs.
512 * @param {number} device_scale The number of device pixels per DIP.
513 */
514remoting.ClientPluginAsync.prototype.notifyClientResolution =
515    function(width, height, device_scale) {
516  if (this.hasFeature(remoting.ClientPlugin.Feature.NOTIFY_CLIENT_RESOLUTION)) {
517    var dpi = device_scale * 96;
518    this.plugin.postMessage(JSON.stringify(
519        { method: 'notifyClientResolution',
520          data: { width: width * device_scale,
521                  height: height * device_scale,
522                  x_dpi: dpi, y_dpi: dpi }}));
523  } else if (this.hasFeature(
524                 remoting.ClientPlugin.Feature.NOTIFY_CLIENT_DIMENSIONS)) {
525    this.plugin.postMessage(JSON.stringify(
526        { method: 'notifyClientDimensions',
527          data: { width: width, height: height }}));
528  }
529};
530
531/**
532 * Requests that the host pause or resume sending video updates.
533 *
534 * @param {boolean} pause True to suspend video updates, false otherwise.
535 */
536remoting.ClientPluginAsync.prototype.pauseVideo =
537    function(pause) {
538  if (!this.hasFeature(remoting.ClientPlugin.Feature.PAUSE_VIDEO))
539    return;
540  this.plugin.postMessage(JSON.stringify(
541      { method: 'pauseVideo', data: { pause: pause }}));
542};
543
544/**
545 * Requests that the host pause or resume sending audio updates.
546 *
547 * @param {boolean} pause True to suspend audio updates, false otherwise.
548 */
549remoting.ClientPluginAsync.prototype.pauseAudio =
550    function(pause) {
551  if (!this.hasFeature(remoting.ClientPlugin.Feature.PAUSE_AUDIO))
552    return;
553  this.plugin.postMessage(JSON.stringify(
554      { method: 'pauseAudio', data: { pause: pause }}));
555};
556
557/**
558 * Called when a PIN is obtained from the user.
559 *
560 * @param {string} pin The PIN.
561 */
562remoting.ClientPluginAsync.prototype.onPinFetched =
563    function(pin) {
564  if (!this.hasFeature(remoting.ClientPlugin.Feature.ASYNC_PIN)) {
565    return;
566  }
567  this.plugin.postMessage(JSON.stringify(
568      { method: 'onPinFetched', data: { pin: pin }}));
569};
570
571/**
572 * Tells the plugin to ask for the PIN asynchronously.
573 */
574remoting.ClientPluginAsync.prototype.useAsyncPinDialog =
575    function() {
576  if (!this.hasFeature(remoting.ClientPlugin.Feature.ASYNC_PIN)) {
577    return;
578  }
579  this.plugin.postMessage(JSON.stringify(
580      { method: 'useAsyncPinDialog', data: {} }));
581};
582
583/**
584 * Sets the third party authentication token and shared secret.
585 *
586 * @param {string} token The token received from the token URL.
587 * @param {string} sharedSecret Shared secret received from the token URL.
588 */
589remoting.ClientPluginAsync.prototype.onThirdPartyTokenFetched = function(
590    token, sharedSecret) {
591  this.plugin.postMessage(JSON.stringify(
592    { method: 'onThirdPartyTokenFetched',
593      data: { token: token, sharedSecret: sharedSecret}}));
594};
595
596/**
597 * Request pairing with the host for PIN-less authentication.
598 *
599 * @param {string} clientName The human-readable name of the client.
600 * @param {function(string, string):void} onDone, Callback to receive the
601 *     client id and shared secret when they are available.
602 */
603remoting.ClientPluginAsync.prototype.requestPairing =
604    function(clientName, onDone) {
605  if (!this.hasFeature(remoting.ClientPlugin.Feature.PINLESS_AUTH)) {
606    return;
607  }
608  this.onPairingComplete_ = onDone;
609  this.plugin.postMessage(JSON.stringify(
610      { method: 'requestPairing', data: { clientName: clientName } }));
611};
612
613/**
614 * Send an extension message to the host.
615 *
616 * @param {string} type The message type.
617 * @param {Object} message The message payload.
618 */
619remoting.ClientPluginAsync.prototype.sendClientMessage =
620    function(type, message) {
621  if (!this.hasFeature(remoting.ClientPlugin.Feature.EXTENSION_MESSAGE)) {
622    return;
623  }
624  this.plugin.postMessage(JSON.stringify(
625    { method: 'extensionMessage',
626      data: { type: type, data: JSON.stringify(message) } }));
627
628};
629
630/**
631 * If we haven't yet received a "hello" message from the plugin, change its
632 * size so that the user can confirm it if click-to-play is enabled, or can
633 * see the "this plugin is disabled" message if it is actually disabled.
634 * @private
635 */
636remoting.ClientPluginAsync.prototype.showPluginForClickToPlay_ = function() {
637  if (!this.helloReceived_) {
638    var width = 200;
639    var height = 200;
640    this.plugin.width = width;
641    this.plugin.height = height;
642    // Center the plugin just underneath the "Connnecting..." dialog.
643    var parentNode = this.plugin.parentNode;
644    var dialog = document.getElementById('client-dialog');
645    var dialogRect = dialog.getBoundingClientRect();
646    parentNode.style.top = (dialogRect.bottom + 16) + 'px';
647    parentNode.style.left = (window.innerWidth - width) / 2 + 'px';
648  }
649};
650