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 if (typeof(message.data['type']) != 'string' || 318 typeof(message.data['data']) != 'string') { 319 console.error('Invalid extension message:', message.data); 320 return; 321 } 322 switch (message.data['type']) { 323 case 'test-echo-reply': 324 console.log('Got echo reply: ' + message.data['data']); 325 break; 326 default: 327 console.log('Unexpected message received: ' + 328 message.data['type'] + ': ' + message.data['data']); 329 } 330 } 331}; 332 333/** 334 * Deletes the plugin. 335 */ 336remoting.ClientPluginAsync.prototype.cleanup = function() { 337 this.plugin.parentNode.removeChild(this.plugin); 338}; 339 340/** 341 * @return {HTMLEmbedElement} HTML element that correspods to the plugin. 342 */ 343remoting.ClientPluginAsync.prototype.element = function() { 344 return this.plugin; 345}; 346 347/** 348 * @param {function(boolean): void} onDone 349 */ 350remoting.ClientPluginAsync.prototype.initialize = function(onDone) { 351 if (this.helloReceived_) { 352 onDone(true); 353 } else { 354 this.onInitializedCallback_ = onDone; 355 } 356}; 357 358/** 359 * @return {boolean} True if the plugin and web-app versions are compatible. 360 */ 361remoting.ClientPluginAsync.prototype.isSupportedVersion = function() { 362 if (!this.helloReceived_) { 363 console.error( 364 "isSupportedVersion() is called before the plugin is initialized."); 365 return false; 366 } 367 return this.API_VERSION_ >= this.pluginApiMinVersion_ && 368 this.pluginApiVersion_ >= this.API_MIN_VERSION_; 369}; 370 371/** 372 * @param {remoting.ClientPlugin.Feature} feature The feature to test for. 373 * @return {boolean} True if the plugin supports the named feature. 374 */ 375remoting.ClientPluginAsync.prototype.hasFeature = function(feature) { 376 if (!this.helloReceived_) { 377 console.error( 378 "hasFeature() is called before the plugin is initialized."); 379 return false; 380 } 381 return this.pluginApiFeatures_.indexOf(feature) > -1; 382}; 383 384/** 385 * @return {boolean} True if the plugin supports the injectKeyEvent API. 386 */ 387remoting.ClientPluginAsync.prototype.isInjectKeyEventSupported = function() { 388 return this.pluginApiVersion_ >= 6; 389}; 390 391/** 392 * @param {string} iq Incoming IQ stanza. 393 */ 394remoting.ClientPluginAsync.prototype.onIncomingIq = function(iq) { 395 if (this.plugin && this.plugin.postMessage) { 396 this.plugin.postMessage(JSON.stringify( 397 { method: 'incomingIq', data: { iq: iq } })); 398 } else { 399 // plugin.onIq may not be set after the plugin has been shut 400 // down. Particularly this happens when we receive response to 401 // session-terminate stanza. 402 console.warn('plugin.onIq is not set so dropping incoming message.'); 403 } 404}; 405 406/** 407 * @param {string} hostJid The jid of the host to connect to. 408 * @param {string} hostPublicKey The base64 encoded version of the host's 409 * public key. 410 * @param {string} localJid Local jid. 411 * @param {string} sharedSecret The access code for IT2Me or the PIN 412 * for Me2Me. 413 * @param {string} authenticationMethods Comma-separated list of 414 * authentication methods the client should attempt to use. 415 * @param {string} authenticationTag A host-specific tag to mix into 416 * authentication hashes. 417 * @param {string} clientPairingId For paired Me2Me connections, the 418 * pairing id for this client, as issued by the host. 419 * @param {string} clientPairedSecret For paired Me2Me connections, the 420 * paired secret for this client, as issued by the host. 421 */ 422remoting.ClientPluginAsync.prototype.connect = function( 423 hostJid, hostPublicKey, localJid, sharedSecret, 424 authenticationMethods, authenticationTag, 425 clientPairingId, clientPairedSecret) { 426 this.plugin.postMessage(JSON.stringify( 427 { method: 'connect', data: { 428 hostJid: hostJid, 429 hostPublicKey: hostPublicKey, 430 localJid: localJid, 431 sharedSecret: sharedSecret, 432 authenticationMethods: authenticationMethods, 433 authenticationTag: authenticationTag, 434 capabilities: this.capabilities_.join(" "), 435 clientPairingId: clientPairingId, 436 clientPairedSecret: clientPairedSecret 437 } 438 })); 439}; 440 441/** 442 * Release all currently pressed keys. 443 */ 444remoting.ClientPluginAsync.prototype.releaseAllKeys = function() { 445 this.plugin.postMessage(JSON.stringify( 446 { method: 'releaseAllKeys', data: {} })); 447}; 448 449/** 450 * Send a key event to the host. 451 * 452 * @param {number} usbKeycode The USB-style code of the key to inject. 453 * @param {boolean} pressed True to inject a key press, False for a release. 454 */ 455remoting.ClientPluginAsync.prototype.injectKeyEvent = 456 function(usbKeycode, pressed) { 457 this.plugin.postMessage(JSON.stringify( 458 { method: 'injectKeyEvent', data: { 459 'usbKeycode': usbKeycode, 460 'pressed': pressed} 461 })); 462}; 463 464/** 465 * Remap one USB keycode to another in all subsequent key events. 466 * 467 * @param {number} fromKeycode The USB-style code of the key to remap. 468 * @param {number} toKeycode The USB-style code to remap the key to. 469 */ 470remoting.ClientPluginAsync.prototype.remapKey = 471 function(fromKeycode, toKeycode) { 472 this.plugin.postMessage(JSON.stringify( 473 { method: 'remapKey', data: { 474 'fromKeycode': fromKeycode, 475 'toKeycode': toKeycode} 476 })); 477}; 478 479/** 480 * Enable/disable redirection of the specified key to the web-app. 481 * 482 * @param {number} keycode The USB-style code of the key. 483 * @param {Boolean} trap True to enable trapping, False to disable. 484 */ 485remoting.ClientPluginAsync.prototype.trapKey = function(keycode, trap) { 486 this.plugin.postMessage(JSON.stringify( 487 { method: 'trapKey', data: { 488 'keycode': keycode, 489 'trap': trap} 490 })); 491}; 492 493/** 494 * Returns an associative array with a set of stats for this connecton. 495 * 496 * @return {remoting.ClientSession.PerfStats} The connection statistics. 497 */ 498remoting.ClientPluginAsync.prototype.getPerfStats = function() { 499 return this.perfStats_; 500}; 501 502/** 503 * Sends a clipboard item to the host. 504 * 505 * @param {string} mimeType The MIME type of the clipboard item. 506 * @param {string} item The clipboard item. 507 */ 508remoting.ClientPluginAsync.prototype.sendClipboardItem = 509 function(mimeType, item) { 510 if (!this.hasFeature(remoting.ClientPlugin.Feature.SEND_CLIPBOARD_ITEM)) 511 return; 512 this.plugin.postMessage(JSON.stringify( 513 { method: 'sendClipboardItem', 514 data: { mimeType: mimeType, item: item }})); 515}; 516 517/** 518 * Notifies the host that the client has the specified size and pixel density. 519 * 520 * @param {number} width The available client width in DIPs. 521 * @param {number} height The available client height in DIPs. 522 * @param {number} device_scale The number of device pixels per DIP. 523 */ 524remoting.ClientPluginAsync.prototype.notifyClientResolution = 525 function(width, height, device_scale) { 526 if (this.hasFeature(remoting.ClientPlugin.Feature.NOTIFY_CLIENT_RESOLUTION)) { 527 var dpi = Math.floor(device_scale * 96); 528 this.plugin.postMessage(JSON.stringify( 529 { method: 'notifyClientResolution', 530 data: { width: Math.floor(width * device_scale), 531 height: Math.floor(height * device_scale), 532 x_dpi: dpi, y_dpi: dpi }})); 533 } 534}; 535 536/** 537 * Requests that the host pause or resume sending video updates. 538 * 539 * @param {boolean} pause True to suspend video updates, false otherwise. 540 */ 541remoting.ClientPluginAsync.prototype.pauseVideo = 542 function(pause) { 543 if (!this.hasFeature(remoting.ClientPlugin.Feature.PAUSE_VIDEO)) 544 return; 545 this.plugin.postMessage(JSON.stringify( 546 { method: 'pauseVideo', data: { pause: pause }})); 547}; 548 549/** 550 * Requests that the host pause or resume sending audio updates. 551 * 552 * @param {boolean} pause True to suspend audio updates, false otherwise. 553 */ 554remoting.ClientPluginAsync.prototype.pauseAudio = 555 function(pause) { 556 if (!this.hasFeature(remoting.ClientPlugin.Feature.PAUSE_AUDIO)) 557 return; 558 this.plugin.postMessage(JSON.stringify( 559 { method: 'pauseAudio', data: { pause: pause }})); 560}; 561 562/** 563 * Called when a PIN is obtained from the user. 564 * 565 * @param {string} pin The PIN. 566 */ 567remoting.ClientPluginAsync.prototype.onPinFetched = 568 function(pin) { 569 if (!this.hasFeature(remoting.ClientPlugin.Feature.ASYNC_PIN)) { 570 return; 571 } 572 this.plugin.postMessage(JSON.stringify( 573 { method: 'onPinFetched', data: { pin: pin }})); 574}; 575 576/** 577 * Tells the plugin to ask for the PIN asynchronously. 578 */ 579remoting.ClientPluginAsync.prototype.useAsyncPinDialog = 580 function() { 581 if (!this.hasFeature(remoting.ClientPlugin.Feature.ASYNC_PIN)) { 582 return; 583 } 584 this.plugin.postMessage(JSON.stringify( 585 { method: 'useAsyncPinDialog', data: {} })); 586}; 587 588/** 589 * Sets the third party authentication token and shared secret. 590 * 591 * @param {string} token The token received from the token URL. 592 * @param {string} sharedSecret Shared secret received from the token URL. 593 */ 594remoting.ClientPluginAsync.prototype.onThirdPartyTokenFetched = function( 595 token, sharedSecret) { 596 this.plugin.postMessage(JSON.stringify( 597 { method: 'onThirdPartyTokenFetched', 598 data: { token: token, sharedSecret: sharedSecret}})); 599}; 600 601/** 602 * Request pairing with the host for PIN-less authentication. 603 * 604 * @param {string} clientName The human-readable name of the client. 605 * @param {function(string, string):void} onDone, Callback to receive the 606 * client id and shared secret when they are available. 607 */ 608remoting.ClientPluginAsync.prototype.requestPairing = 609 function(clientName, onDone) { 610 if (!this.hasFeature(remoting.ClientPlugin.Feature.PINLESS_AUTH)) { 611 return; 612 } 613 this.onPairingComplete_ = onDone; 614 this.plugin.postMessage(JSON.stringify( 615 { method: 'requestPairing', data: { clientName: clientName } })); 616}; 617 618/** 619 * Send an extension message to the host. 620 * 621 * @param {string} type The message type. 622 * @param {Object} message The message payload. 623 */ 624remoting.ClientPluginAsync.prototype.sendClientMessage = 625 function(type, message) { 626 if (!this.hasFeature(remoting.ClientPlugin.Feature.EXTENSION_MESSAGE)) { 627 return; 628 } 629 this.plugin.postMessage(JSON.stringify( 630 { method: 'extensionMessage', 631 data: { type: type, data: JSON.stringify(message) } })); 632 633}; 634 635/** 636 * If we haven't yet received a "hello" message from the plugin, change its 637 * size so that the user can confirm it if click-to-play is enabled, or can 638 * see the "this plugin is disabled" message if it is actually disabled. 639 * @private 640 */ 641remoting.ClientPluginAsync.prototype.showPluginForClickToPlay_ = function() { 642 if (!this.helloReceived_) { 643 var width = 200; 644 var height = 200; 645 this.plugin.width = width; 646 this.plugin.height = height; 647 // Center the plugin just underneath the "Connnecting..." dialog. 648 var parentNode = this.plugin.parentNode; 649 var dialog = document.getElementById('client-dialog'); 650 var dialogRect = dialog.getBoundingClientRect(); 651 parentNode.style.top = (dialogRect.bottom + 16) + 'px'; 652 parentNode.style.left = (window.innerWidth - width) / 2 + 'px'; 653 } 654}; 655