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} */ 17 var remoting = remoting || {}; 18 19 /** 20 * @param {remoting.ViewerPlugin} plugin The plugin embed element. 21 * @constructor 22 * @implements {remoting.ClientPlugin} 23 */ 24 remoting.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 */ 89 remoting.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 */ 99 remoting.ClientPluginAsync.prototype.API_MIN_VERSION_ = 5; 100 101 /** 102 * @param {string} messageStr Message from the plugin. 103 */ 104 remoting.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 */ 326 remoting.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 */ 333 remoting.ClientPluginAsync.prototype.element = function() { 334 return this.plugin; 335 }; 336 337 /** 338 * @param {function(boolean): void} onDone 339 */ 340 remoting.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 */ 351 remoting.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 */ 365 remoting.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 */ 377 remoting.ClientPluginAsync.prototype.isInjectKeyEventSupported = function() { 378 return this.pluginApiVersion_ >= 6; 379 }; 380 381 /** 382 * @param {string} iq Incoming IQ stanza. 383 */ 384 remoting.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 */ 412 remoting.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 */ 434 remoting.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 */ 445 remoting.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 */ 460 remoting.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 */ 475 remoting.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 */ 488 remoting.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 */ 498 remoting.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 */ 514 remoting.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 */ 536 remoting.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 */ 549 remoting.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 */ 562 remoting.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 */ 574 remoting.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 */ 589 remoting.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 */ 603 remoting.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 */ 619 remoting.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 */ 636 remoting.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