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 that wraps low-level details of interacting with the client plugin.
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.
16 /** @suppress {duplicate} */
17 var remoting = remoting || {};
20 * @param {remoting.ViewerPlugin} plugin The plugin embed element.
21 * @param {function(string, string):boolean} onExtensionMessage The handler for
22 * protocol extension messages. Returns true if a message is recognized;
26 remoting.ClientPlugin = function(plugin, onExtensionMessage) {
28 this.onExtensionMessage_ = onExtensionMessage;
30 this.desktopWidth = 0;
31 this.desktopHeight = 0;
32 this.desktopXDpi = 96;
33 this.desktopYDpi = 96;
35 /** @param {string} iq The Iq stanza received from the host. */
36 this.onOutgoingIqHandler = function (iq) {};
37 /** @param {string} message Log message. */
38 this.onDebugMessageHandler = function (message) {};
40 * @param {number} state The connection state.
41 * @param {number} error The error code, if any.
43 this.onConnectionStatusUpdateHandler = function(state, error) {};
44 /** @param {boolean} ready Connection ready state. */
45 this.onConnectionReadyHandler = function(ready) {};
48 * @param {string} tokenUrl Token-request URL, received from the host.
49 * @param {string} hostPublicKey Public key for the host.
50 * @param {string} scope OAuth scope to request the token for.
52 this.fetchThirdPartyTokenHandler = function(
53 tokenUrl, hostPublicKey, scope) {};
54 this.onDesktopSizeUpdateHandler = function () {};
55 /** @param {!Array.<string>} capabilities The negotiated capabilities. */
56 this.onSetCapabilitiesHandler = function (capabilities) {};
57 this.fetchPinHandler = function (supportsPairing) {};
58 /** @param {string} data Remote gnubbyd data. */
59 this.onGnubbyAuthHandler = function(data) {};
61 /** @type {remoting.MediaSourceRenderer} */
62 this.mediaSourceRenderer_ = null;
65 this.pluginApiVersion_ = -1;
66 /** @type {Array.<string>} */
67 this.pluginApiFeatures_ = [];
69 this.pluginApiMinVersion_ = -1;
70 /** @type {!Array.<string>} */
71 this.capabilities_ = [];
72 /** @type {boolean} */
73 this.helloReceived_ = false;
74 /** @type {function(boolean)|null} */
75 this.onInitializedCallback_ = null;
76 /** @type {function(string, string):void} */
77 this.onPairingComplete_ = function(clientId, sharedSecret) {};
78 /** @type {remoting.ClientSession.PerfStats} */
79 this.perfStats_ = new remoting.ClientSession.PerfStats();
81 /** @type {remoting.ClientPlugin} */
83 /** @param {Event} event Message event from the plugin. */
84 this.plugin.addEventListener('message', function(event) {
85 that.handleMessage_(event.data);
87 window.setTimeout(this.showPluginForClickToPlay_.bind(this), 500);
91 * Set of features for which hasFeature() can be used to test.
95 remoting.ClientPlugin.Feature = {
96 INJECT_KEY_EVENT: 'injectKeyEvent',
97 NOTIFY_CLIENT_RESOLUTION: 'notifyClientResolution',
98 ASYNC_PIN: 'asyncPin',
99 PAUSE_VIDEO: 'pauseVideo',
100 PAUSE_AUDIO: 'pauseAudio',
101 REMAP_KEY: 'remapKey',
102 SEND_CLIPBOARD_ITEM: 'sendClipboardItem',
103 THIRD_PARTY_AUTH: 'thirdPartyAuth',
105 PINLESS_AUTH: 'pinlessAuth',
106 EXTENSION_MESSAGE: 'extensionMessage',
107 MEDIA_SOURCE_RENDERING: 'mediaSourceRendering',
108 VIDEO_CONTROL: 'videoControl'
112 * Chromoting session API version (for this javascript).
113 * This is compared with the plugin API version to verify that they are
119 remoting.ClientPlugin.prototype.API_VERSION_ = 6;
122 * The oldest API version that we support.
123 * This will differ from the |API_VERSION_| if we maintain backward
124 * compatibility with older API versions.
129 remoting.ClientPlugin.prototype.API_MIN_VERSION_ = 5;
132 * @param {string|{method:string, data:Object.<string,*>}}
133 * rawMessage Message from the plugin.
136 remoting.ClientPlugin.prototype.handleMessage_ = function(rawMessage) {
138 /** @type {{method:string, data:Object.<string,*>}} */
139 ((typeof(rawMessage) == 'string') ? jsonParseSafe(rawMessage)
142 if (!message || !('method' in message) || !('data' in message)) {
143 console.error('Received invalid message from the plugin:', rawMessage);
148 this.handleMessageMethod_(message);
150 console.error(/** @type {*} */ (e));
155 * @param {{method:string, data:Object.<string,*>}}
156 * message Parsed message from the plugin.
159 remoting.ClientPlugin.prototype.handleMessageMethod_ = function(message) {
161 * Splits a string into a list of words delimited by spaces.
162 * @param {string} str String that should be split.
163 * @return {!Array.<string>} List of words.
165 var tokenize = function(str) {
166 /** @type {Array.<string>} */
167 var tokens = str.match(/\S+/g);
168 return tokens ? tokens : [];
171 if (message.method == 'hello') {
172 // Resize in case we had to enlarge it to support click-to-play.
173 this.hidePluginForClickToPlay_();
174 this.pluginApiVersion_ = getNumberAttr(message.data, 'apiVersion');
175 this.pluginApiMinVersion_ = getNumberAttr(message.data, 'apiMinVersion');
177 if (this.pluginApiVersion_ >= 7) {
178 this.pluginApiFeatures_ =
179 tokenize(getStringAttr(message.data, 'apiFeatures'));
181 // Negotiate capabilities.
183 /** @type {!Array.<string>} */
184 var requestedCapabilities = [];
185 if ('requestedCapabilities' in message.data) {
186 requestedCapabilities =
187 tokenize(getStringAttr(message.data, 'requestedCapabilities'));
190 /** @type {!Array.<string>} */
191 var supportedCapabilities = [];
192 if ('supportedCapabilities' in message.data) {
193 supportedCapabilities =
194 tokenize(getStringAttr(message.data, 'supportedCapabilities'));
197 // At the moment the webapp does not recognize any of
198 // 'requestedCapabilities' capabilities (so they all should be disabled)
199 // and do not care about any of 'supportedCapabilities' capabilities (so
200 // they all can be enabled).
201 this.capabilities_ = supportedCapabilities;
203 // Let the host know that the webapp can be requested to always send
204 // the client's dimensions.
205 this.capabilities_.push(
206 remoting.ClientSession.Capability.SEND_INITIAL_RESOLUTION);
208 // Let the host know that we're interested in knowing whether or not
209 // it rate-limits desktop-resize requests.
210 this.capabilities_.push(
211 remoting.ClientSession.Capability.RATE_LIMIT_RESIZE_REQUESTS);
212 } else if (this.pluginApiVersion_ >= 6) {
213 this.pluginApiFeatures_ = ['highQualityScaling', 'injectKeyEvent'];
215 this.pluginApiFeatures_ = ['highQualityScaling'];
217 this.helloReceived_ = true;
218 if (this.onInitializedCallback_ != null) {
219 this.onInitializedCallback_(true);
220 this.onInitializedCallback_ = null;
223 } else if (message.method == 'sendOutgoingIq') {
224 this.onOutgoingIqHandler(getStringAttr(message.data, 'iq'));
226 } else if (message.method == 'logDebugMessage') {
227 this.onDebugMessageHandler(getStringAttr(message.data, 'message'));
229 } else if (message.method == 'onConnectionStatus') {
230 var state = remoting.ClientSession.State.fromString(
231 getStringAttr(message.data, 'state'))
232 var error = remoting.ClientSession.ConnectionError.fromString(
233 getStringAttr(message.data, 'error'));
234 this.onConnectionStatusUpdateHandler(state, error);
236 } else if (message.method == 'onDesktopSize') {
237 this.desktopWidth = getNumberAttr(message.data, 'width');
238 this.desktopHeight = getNumberAttr(message.data, 'height');
239 this.desktopXDpi = getNumberAttr(message.data, 'x_dpi', 96);
240 this.desktopYDpi = getNumberAttr(message.data, 'y_dpi', 96);
241 this.onDesktopSizeUpdateHandler();
243 } else if (message.method == 'onPerfStats') {
244 // Return value is ignored. These calls will throw an error if the value
246 getNumberAttr(message.data, 'videoBandwidth');
247 getNumberAttr(message.data, 'videoFrameRate');
248 getNumberAttr(message.data, 'captureLatency');
249 getNumberAttr(message.data, 'encodeLatency');
250 getNumberAttr(message.data, 'decodeLatency');
251 getNumberAttr(message.data, 'renderLatency');
252 getNumberAttr(message.data, 'roundtripLatency');
254 /** @type {remoting.ClientSession.PerfStats} */ message.data;
256 } else if (message.method == 'injectClipboardItem') {
257 var mimetype = getStringAttr(message.data, 'mimeType');
258 var item = getStringAttr(message.data, 'item');
259 if (remoting.clipboard) {
260 remoting.clipboard.fromHost(mimetype, item);
263 } else if (message.method == 'onFirstFrameReceived') {
264 if (remoting.clientSession) {
265 remoting.clientSession.onFirstFrameReceived();
268 } else if (message.method == 'onConnectionReady') {
269 var ready = getBooleanAttr(message.data, 'ready');
270 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 var pairingSupported = getBooleanAttr(message.data, 'pairingSupported',
278 this.fetchPinHandler(pairingSupported);
280 } else if (message.method == 'setCapabilities') {
281 /** @type {!Array.<string>} */
282 var capabilities = tokenize(getStringAttr(message.data, 'capabilities'));
283 this.onSetCapabilitiesHandler(capabilities);
285 } else if (message.method == 'fetchThirdPartyToken') {
286 var tokenUrl = getStringAttr(message.data, 'tokenUrl');
287 var hostPublicKey = getStringAttr(message.data, 'hostPublicKey');
288 var scope = getStringAttr(message.data, 'scope');
289 this.fetchThirdPartyTokenHandler(tokenUrl, hostPublicKey, scope);
291 } else if (message.method == 'pairingResponse') {
292 var clientId = getStringAttr(message.data, 'clientId');
293 var sharedSecret = getStringAttr(message.data, 'sharedSecret');
294 this.onPairingComplete_(clientId, sharedSecret);
296 } else if (message.method == 'extensionMessage') {
297 var extMsgType = getStringAttr(message.data, 'type');
298 var extMsgData = getStringAttr(message.data, 'data');
299 switch (extMsgType) {
301 this.onGnubbyAuthHandler(extMsgData);
303 case 'test-echo-reply':
304 console.log('Got echo reply: ' + extMsgData);
307 if (!this.onExtensionMessage_(extMsgType, extMsgData)) {
308 console.log('Unexpected message received: ' +
309 extMsgType + ': ' + extMsgData);
313 } else if (message.method == 'mediaSourceReset') {
314 if (!this.mediaSourceRenderer_) {
315 console.error('Unexpected mediaSourceReset.');
318 this.mediaSourceRenderer_.reset(getStringAttr(message.data, 'format'))
320 } else if (message.method == 'mediaSourceData') {
321 if (!(message.data['buffer'] instanceof ArrayBuffer)) {
322 console.error('Invalid mediaSourceData message:', message.data);
325 if (!this.mediaSourceRenderer_) {
326 console.error('Unexpected mediaSourceData.');
329 // keyframe flag may be absent from the message.
330 var keyframe = !!message.data['keyframe'];
331 this.mediaSourceRenderer_.onIncomingData(
332 (/** @type {ArrayBuffer} */ message.data['buffer']), keyframe);
337 * Deletes the plugin.
339 remoting.ClientPlugin.prototype.cleanup = function() {
340 this.plugin.parentNode.removeChild(this.plugin);
344 * @return {HTMLEmbedElement} HTML element that correspods to the plugin.
346 remoting.ClientPlugin.prototype.element = function() {
351 * @param {function(boolean): void} onDone
353 remoting.ClientPlugin.prototype.initialize = function(onDone) {
354 if (this.helloReceived_) {
357 this.onInitializedCallback_ = onDone;
362 * @return {boolean} True if the plugin and web-app versions are compatible.
364 remoting.ClientPlugin.prototype.isSupportedVersion = function() {
365 if (!this.helloReceived_) {
367 "isSupportedVersion() is called before the plugin is initialized.");
370 return this.API_VERSION_ >= this.pluginApiMinVersion_ &&
371 this.pluginApiVersion_ >= this.API_MIN_VERSION_;
375 * @param {remoting.ClientPlugin.Feature} feature The feature to test for.
376 * @return {boolean} True if the plugin supports the named feature.
378 remoting.ClientPlugin.prototype.hasFeature = function(feature) {
379 if (!this.helloReceived_) {
381 "hasFeature() is called before the plugin is initialized.");
384 return this.pluginApiFeatures_.indexOf(feature) > -1;
388 * @return {boolean} True if the plugin supports the injectKeyEvent API.
390 remoting.ClientPlugin.prototype.isInjectKeyEventSupported = function() {
391 return this.pluginApiVersion_ >= 6;
395 * @param {string} iq Incoming IQ stanza.
397 remoting.ClientPlugin.prototype.onIncomingIq = function(iq) {
398 if (this.plugin && this.plugin.postMessage) {
399 this.plugin.postMessage(JSON.stringify(
400 { method: 'incomingIq', data: { iq: iq } }));
402 // plugin.onIq may not be set after the plugin has been shut
403 // down. Particularly this happens when we receive response to
404 // session-terminate stanza.
405 console.warn('plugin.onIq is not set so dropping incoming message.');
410 * @param {string} hostJid The jid of the host to connect to.
411 * @param {string} hostPublicKey The base64 encoded version of the host's
413 * @param {string} localJid Local jid.
414 * @param {string} sharedSecret The access code for IT2Me or the PIN
416 * @param {string} authenticationMethods Comma-separated list of
417 * authentication methods the client should attempt to use.
418 * @param {string} authenticationTag A host-specific tag to mix into
419 * authentication hashes.
420 * @param {string} clientPairingId For paired Me2Me connections, the
421 * pairing id for this client, as issued by the host.
422 * @param {string} clientPairedSecret For paired Me2Me connections, the
423 * paired secret for this client, as issued by the host.
425 remoting.ClientPlugin.prototype.connect = function(
426 hostJid, hostPublicKey, localJid, sharedSecret,
427 authenticationMethods, authenticationTag,
428 clientPairingId, clientPairedSecret) {
430 if (navigator.platform.indexOf('Mac') == -1) {
432 } else if (navigator.userAgent.match(/\bCrOS\b/)) {
435 this.plugin.postMessage(JSON.stringify(
436 { method: 'connect', data: {
438 hostPublicKey: hostPublicKey,
440 sharedSecret: sharedSecret,
441 authenticationMethods: authenticationMethods,
442 authenticationTag: authenticationTag,
443 capabilities: this.capabilities_.join(" "),
444 clientPairingId: clientPairingId,
445 clientPairedSecret: clientPairedSecret,
452 * Release all currently pressed keys.
454 remoting.ClientPlugin.prototype.releaseAllKeys = function() {
455 this.plugin.postMessage(JSON.stringify(
456 { method: 'releaseAllKeys', data: {} }));
460 * Send a key event to the host.
462 * @param {number} usbKeycode The USB-style code of the key to inject.
463 * @param {boolean} pressed True to inject a key press, False for a release.
465 remoting.ClientPlugin.prototype.injectKeyEvent =
466 function(usbKeycode, pressed) {
467 this.plugin.postMessage(JSON.stringify(
468 { method: 'injectKeyEvent', data: {
469 'usbKeycode': usbKeycode,
475 * Remap one USB keycode to another in all subsequent key events.
477 * @param {number} fromKeycode The USB-style code of the key to remap.
478 * @param {number} toKeycode The USB-style code to remap the key to.
480 remoting.ClientPlugin.prototype.remapKey =
481 function(fromKeycode, toKeycode) {
482 this.plugin.postMessage(JSON.stringify(
483 { method: 'remapKey', data: {
484 'fromKeycode': fromKeycode,
485 'toKeycode': toKeycode}
490 * Enable/disable redirection of the specified key to the web-app.
492 * @param {number} keycode The USB-style code of the key.
493 * @param {Boolean} trap True to enable trapping, False to disable.
495 remoting.ClientPlugin.prototype.trapKey = function(keycode, trap) {
496 this.plugin.postMessage(JSON.stringify(
497 { method: 'trapKey', data: {
504 * Returns an associative array with a set of stats for this connecton.
506 * @return {remoting.ClientSession.PerfStats} The connection statistics.
508 remoting.ClientPlugin.prototype.getPerfStats = function() {
509 return this.perfStats_;
513 * Sends a clipboard item to the host.
515 * @param {string} mimeType The MIME type of the clipboard item.
516 * @param {string} item The clipboard item.
518 remoting.ClientPlugin.prototype.sendClipboardItem =
519 function(mimeType, item) {
520 if (!this.hasFeature(remoting.ClientPlugin.Feature.SEND_CLIPBOARD_ITEM))
522 this.plugin.postMessage(JSON.stringify(
523 { method: 'sendClipboardItem',
524 data: { mimeType: mimeType, item: item }}));
528 * Notifies the host that the client has the specified size and pixel density.
530 * @param {number} width The available client width in DIPs.
531 * @param {number} height The available client height in DIPs.
532 * @param {number} device_scale The number of device pixels per DIP.
534 remoting.ClientPlugin.prototype.notifyClientResolution =
535 function(width, height, device_scale) {
536 if (this.hasFeature(remoting.ClientPlugin.Feature.NOTIFY_CLIENT_RESOLUTION)) {
537 var dpi = Math.floor(device_scale * 96);
538 this.plugin.postMessage(JSON.stringify(
539 { method: 'notifyClientResolution',
540 data: { width: Math.floor(width * device_scale),
541 height: Math.floor(height * device_scale),
542 x_dpi: dpi, y_dpi: dpi }}));
547 * Requests that the host pause or resume sending video updates.
549 * @param {boolean} pause True to suspend video updates, false otherwise.
551 remoting.ClientPlugin.prototype.pauseVideo =
553 if (this.hasFeature(remoting.ClientPlugin.Feature.VIDEO_CONTROL)) {
554 this.plugin.postMessage(JSON.stringify(
555 { method: 'videoControl', data: { pause: pause }}));
556 } else if (this.hasFeature(remoting.ClientPlugin.Feature.PAUSE_VIDEO)) {
557 this.plugin.postMessage(JSON.stringify(
558 { method: 'pauseVideo', data: { pause: pause }}));
563 * Requests that the host pause or resume sending audio updates.
565 * @param {boolean} pause True to suspend audio updates, false otherwise.
567 remoting.ClientPlugin.prototype.pauseAudio =
569 if (!this.hasFeature(remoting.ClientPlugin.Feature.PAUSE_AUDIO)) {
572 this.plugin.postMessage(JSON.stringify(
573 { method: 'pauseAudio', data: { pause: pause }}));
577 * Requests that the host configure the video codec for lossless encode.
579 * @param {boolean} wantLossless True to request lossless encoding.
581 remoting.ClientPlugin.prototype.setLosslessEncode =
582 function(wantLossless) {
583 if (!this.hasFeature(remoting.ClientPlugin.Feature.VIDEO_CONTROL)) {
586 this.plugin.postMessage(JSON.stringify(
587 { method: 'videoControl', data: { losslessEncode: wantLossless }}));
591 * Requests that the host configure the video codec for lossless color.
593 * @param {boolean} wantLossless True to request lossless color.
595 remoting.ClientPlugin.prototype.setLosslessColor =
596 function(wantLossless) {
597 if (!this.hasFeature(remoting.ClientPlugin.Feature.VIDEO_CONTROL)) {
600 this.plugin.postMessage(JSON.stringify(
601 { method: 'videoControl', data: { losslessColor: wantLossless }}));
605 * Called when a PIN is obtained from the user.
607 * @param {string} pin The PIN.
609 remoting.ClientPlugin.prototype.onPinFetched =
611 if (!this.hasFeature(remoting.ClientPlugin.Feature.ASYNC_PIN)) {
614 this.plugin.postMessage(JSON.stringify(
615 { method: 'onPinFetched', data: { pin: pin }}));
619 * Tells the plugin to ask for the PIN asynchronously.
621 remoting.ClientPlugin.prototype.useAsyncPinDialog =
623 if (!this.hasFeature(remoting.ClientPlugin.Feature.ASYNC_PIN)) {
626 this.plugin.postMessage(JSON.stringify(
627 { method: 'useAsyncPinDialog', data: {} }));
631 * Sets the third party authentication token and shared secret.
633 * @param {string} token The token received from the token URL.
634 * @param {string} sharedSecret Shared secret received from the token URL.
636 remoting.ClientPlugin.prototype.onThirdPartyTokenFetched = function(
637 token, sharedSecret) {
638 this.plugin.postMessage(JSON.stringify(
639 { method: 'onThirdPartyTokenFetched',
640 data: { token: token, sharedSecret: sharedSecret}}));
644 * Request pairing with the host for PIN-less authentication.
646 * @param {string} clientName The human-readable name of the client.
647 * @param {function(string, string):void} onDone, Callback to receive the
648 * client id and shared secret when they are available.
650 remoting.ClientPlugin.prototype.requestPairing =
651 function(clientName, onDone) {
652 if (!this.hasFeature(remoting.ClientPlugin.Feature.PINLESS_AUTH)) {
655 this.onPairingComplete_ = onDone;
656 this.plugin.postMessage(JSON.stringify(
657 { method: 'requestPairing', data: { clientName: clientName } }));
661 * Send an extension message to the host.
663 * @param {string} type The message type.
664 * @param {string} message The message payload.
666 remoting.ClientPlugin.prototype.sendClientMessage =
667 function(type, message) {
668 if (!this.hasFeature(remoting.ClientPlugin.Feature.EXTENSION_MESSAGE)) {
671 this.plugin.postMessage(JSON.stringify(
672 { method: 'extensionMessage',
673 data: { type: type, data: message } }));
678 * Request MediaStream-based rendering.
680 * @param {remoting.MediaSourceRenderer} mediaSourceRenderer
682 remoting.ClientPlugin.prototype.enableMediaSourceRendering =
683 function(mediaSourceRenderer) {
684 if (!this.hasFeature(remoting.ClientPlugin.Feature.MEDIA_SOURCE_RENDERING)) {
687 this.mediaSourceRenderer_ = mediaSourceRenderer;
688 this.plugin.postMessage(JSON.stringify(
689 { method: 'enableMediaSourceRendering', data: {} }));
693 * If we haven't yet received a "hello" message from the plugin, change its
694 * size so that the user can confirm it if click-to-play is enabled, or can
695 * see the "this plugin is disabled" message if it is actually disabled.
698 remoting.ClientPlugin.prototype.showPluginForClickToPlay_ = function() {
699 if (!this.helloReceived_) {
702 this.plugin.style.width = width + 'px';
703 this.plugin.style.height = height + 'px';
704 // Center the plugin just underneath the "Connnecting..." dialog.
705 var dialog = document.getElementById('client-dialog');
706 var dialogRect = dialog.getBoundingClientRect();
707 this.plugin.style.top = (dialogRect.bottom + 16) + 'px';
708 this.plugin.style.left = (window.innerWidth - width) / 2 + 'px';
709 this.plugin.style.position = 'fixed';
714 * Undo the CSS rules needed to make the plugin clickable for click-to-play.
717 remoting.ClientPlugin.prototype.hidePluginForClickToPlay_ = function() {
718 this.plugin.style.width = '';
719 this.plugin.style.height = '';
720 this.plugin.style.top = '';
721 this.plugin.style.left = '';
722 this.plugin.style.position = '';