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 // Shim that simulates a <webview> tag via Mutation Observers. 6 // 7 // The actual tag is implemented via the browser plugin. The internals of this 8 // are hidden via Shadow DOM. 9 10 var addTagWatcher = require('tagWatcher').addTagWatcher; 11 var eventBindings = require('event_bindings'); 12 13 /** @type {Array.<string>} */ 14 var WEB_VIEW_ATTRIBUTES = ['name', 'src', 'partition', 'autosize', 'minheight', 15 'minwidth', 'maxheight', 'maxwidth']; 16 17 var WEB_VIEW_EVENTS = { 18 'sizechanged': ['oldHeight', 'oldWidth', 'newHeight', 'newWidth'], 19 }; 20 21 var webViewInstanceIdCounter = 0; 22 23 var createEvent = function(name) { 24 var eventOpts = {supportsListeners: true, supportsFilters: true}; 25 return new eventBindings.Event(name, undefined, eventOpts); 26 }; 27 28 var WEB_VIEW_EXT_EVENTS = { 29 'close': { 30 evt: createEvent('webview.onClose'), 31 fields: [] 32 }, 33 'consolemessage': { 34 evt: createEvent('webview.onConsoleMessage'), 35 fields: ['level', 'message', 'line', 'sourceId'] 36 }, 37 'contentload': { 38 evt: createEvent('webview.onContentLoad'), 39 fields: [] 40 }, 41 'exit': { 42 evt: createEvent('webview.onExit'), 43 fields: ['processId', 'reason'] 44 }, 45 'loadabort': { 46 evt: createEvent('webview.onLoadAbort'), 47 fields: ['url', 'isTopLevel', 'reason'] 48 }, 49 'loadcommit': { 50 customHandler: function(webview, event, webviewEvent) { 51 webview.currentEntryIndex_ = event.currentEntryIndex; 52 webview.entryCount_ = event.entryCount; 53 webview.processId_ = event.processId; 54 if (event.isTopLevel) { 55 webview.browserPluginNode_.setAttribute('src', event.url); 56 } 57 webview.webviewNode_.dispatchEvent(webviewEvent); 58 }, 59 evt: createEvent('webview.onLoadCommit'), 60 fields: ['url', 'isTopLevel'] 61 }, 62 'loadredirect': { 63 evt: createEvent('webview.onLoadRedirect'), 64 fields: ['isTopLevel', 'oldUrl', 'newUrl'] 65 }, 66 'loadstart': { 67 evt: createEvent('webview.onLoadStart'), 68 fields: ['url', 'isTopLevel'] 69 }, 70 'loadstop': { 71 evt: createEvent('webview.onLoadStop'), 72 fields: [] 73 }, 74 'newwindow': { 75 cancelable: true, 76 customHandler: function(webview, event, webviewEvent) { 77 webview.setupExtNewWindowEvent_(event, webviewEvent); 78 }, 79 evt: createEvent('webview.onNewWindow'), 80 fields: [ 81 'initialHeight', 82 'initialWidth', 83 'targetUrl', 84 'windowOpenDisposition', 85 'name' 86 ] 87 }, 88 'permissionrequest': { 89 cancelable: true, 90 customHandler: function(webview, event, webviewEvent) { 91 webview.setupExtPermissionEvent_(event, webviewEvent); 92 }, 93 evt: createEvent('webview.onPermissionRequest'), 94 fields: [ 95 'lastUnlockedBySelf', 96 'permission', 97 'requestMethod', 98 'url', 99 'userGesture' 100 ] 101 }, 102 'responsive': { 103 evt: createEvent('webview.onResponsive'), 104 fields: ['processId'] 105 }, 106 'unresponsive': { 107 evt: createEvent('webview.onUnresponsive'), 108 fields: ['processId'] 109 } 110 }; 111 112 addTagWatcher('WEBVIEW', function(addedNode) { new WebView(addedNode); }); 113 114 /** @type {number} */ 115 WebView.prototype.entryCount_; 116 117 /** @type {number} */ 118 WebView.prototype.currentEntryIndex_; 119 120 /** @type {number} */ 121 WebView.prototype.processId_; 122 123 /** 124 * @constructor 125 */ 126 function WebView(webviewNode) { 127 this.webviewNode_ = webviewNode; 128 this.browserPluginNode_ = this.createBrowserPluginNode_(); 129 var shadowRoot = this.webviewNode_.webkitCreateShadowRoot(); 130 shadowRoot.appendChild(this.browserPluginNode_); 131 132 this.setupFocusPropagation_(); 133 this.setupWebviewNodeMethods_(); 134 this.setupWebviewNodeProperties_(); 135 this.setupWebviewNodeAttributes_(); 136 this.setupWebviewNodeEvents_(); 137 138 // Experimental API 139 this.maybeSetupExperimentalAPI_(); 140 } 141 142 /** 143 * @private 144 */ 145 WebView.prototype.createBrowserPluginNode_ = function() { 146 var browserPluginNode = document.createElement('object'); 147 browserPluginNode.type = 'application/browser-plugin'; 148 // The <object> node fills in the <webview> container. 149 browserPluginNode.style.width = '100%'; 150 browserPluginNode.style.height = '100%'; 151 $Array.forEach(WEB_VIEW_ATTRIBUTES, function(attributeName) { 152 // Only copy attributes that have been assigned values, rather than copying 153 // a series of undefined attributes to BrowserPlugin. 154 if (this.webviewNode_.hasAttribute(attributeName)) { 155 browserPluginNode.setAttribute( 156 attributeName, this.webviewNode_.getAttribute(attributeName)); 157 } else if (this.webviewNode_[attributeName]){ 158 // Reading property using has/getAttribute does not work on 159 // document.DOMContentLoaded event (but works on 160 // window.DOMContentLoaded event). 161 // So copy from property if copying from attribute fails. 162 browserPluginNode.setAttribute( 163 attributeName, this.webviewNode_[attributeName]); 164 } 165 }, this); 166 167 return browserPluginNode; 168 }; 169 170 /** 171 * @private 172 */ 173 WebView.prototype.setupFocusPropagation_ = function() { 174 if (!this.webviewNode_.hasAttribute('tabIndex')) { 175 // <webview> needs a tabIndex in order to respond to keyboard focus. 176 // TODO(fsamuel): This introduces unexpected tab ordering. We need to find 177 // a way to take keyboard focus without messing with tab ordering. 178 // See http://crbug.com/231664. 179 this.webviewNode_.setAttribute('tabIndex', 0); 180 } 181 var self = this; 182 this.webviewNode_.addEventListener('focus', function(e) { 183 // Focus the BrowserPlugin when the <webview> takes focus. 184 self.browserPluginNode_.focus(); 185 }); 186 this.webviewNode_.addEventListener('blur', function(e) { 187 // Blur the BrowserPlugin when the <webview> loses focus. 188 self.browserPluginNode_.blur(); 189 }); 190 }; 191 192 /** 193 * @private 194 */ 195 WebView.prototype.setupWebviewNodeMethods_ = function() { 196 // this.browserPluginNode_[apiMethod] are not necessarily defined immediately 197 // after the shadow object is appended to the shadow root. 198 var webviewNode = this.webviewNode_; 199 var browserPluginNode = this.browserPluginNode_; 200 var self = this; 201 202 webviewNode['canGoBack'] = function() { 203 return self.entryCount_ > 1 && self.currentEntryIndex_ > 0; 204 }; 205 206 webviewNode['canGoForward'] = function() { 207 return self.currentEntryIndex_ >=0 && 208 self.currentEntryIndex_ < (self.entryCount_ - 1); 209 }; 210 211 webviewNode['back'] = function() { 212 webviewNode.go(-1); 213 }; 214 215 webviewNode['forward'] = function() { 216 webviewNode.go(1); 217 }; 218 219 webviewNode['getProcessId'] = function() { 220 return self.processId_; 221 }; 222 223 webviewNode['go'] = function(relativeIndex) { 224 var instanceId = browserPluginNode.getGuestInstanceId(); 225 if (!instanceId) { 226 return; 227 } 228 chrome.webview.go(instanceId, relativeIndex); 229 }; 230 231 webviewNode['reload'] = function() { 232 var instanceId = browserPluginNode.getGuestInstanceId(); 233 if (!instanceId) { 234 return; 235 } 236 chrome.webview.reload(instanceId); 237 }; 238 239 webviewNode['stop'] = function() { 240 var instanceId = browserPluginNode.getGuestInstanceId(); 241 if (!instanceId) { 242 return; 243 } 244 chrome.webview.stop(instanceId); 245 }; 246 247 webviewNode['terminate'] = function() { 248 var instanceId = browserPluginNode.getGuestInstanceId(); 249 if (!instanceId) { 250 return; 251 } 252 chrome.webview.terminate(instanceId); 253 }; 254 255 this.setupExecuteCodeAPI_(); 256 }; 257 258 /** 259 * @private 260 */ 261 WebView.prototype.setupWebviewNodeProperties_ = function() { 262 var ERROR_MSG_CONTENTWINDOW_NOT_AVAILABLE = '<webview>: ' + 263 'contentWindow is not available at this time. It will become available ' + 264 'when the page has finished loading.'; 265 266 var browserPluginNode = this.browserPluginNode_; 267 // Expose getters and setters for the attributes. 268 $Array.forEach(WEB_VIEW_ATTRIBUTES, function(attributeName) { 269 Object.defineProperty(this.webviewNode_, attributeName, { 270 get: function() { 271 return browserPluginNode[attributeName]; 272 }, 273 set: function(value) { 274 browserPluginNode[attributeName] = value; 275 }, 276 enumerable: true 277 }); 278 }, this); 279 280 // We cannot use {writable: true} property descriptor because we want dynamic 281 // getter value. 282 Object.defineProperty(this.webviewNode_, 'contentWindow', { 283 get: function() { 284 if (browserPluginNode.contentWindow) 285 return browserPluginNode.contentWindow; 286 console.error(ERROR_MSG_CONTENTWINDOW_NOT_AVAILABLE); 287 }, 288 // No setter. 289 enumerable: true 290 }); 291 }; 292 293 /** 294 * @private 295 */ 296 WebView.prototype.setupWebviewNodeAttributes_ = function() { 297 this.setupWebviewNodeObservers_(); 298 this.setupBrowserPluginNodeObservers_(); 299 }; 300 301 /** 302 * @private 303 */ 304 WebView.prototype.setupWebviewNodeObservers_ = function() { 305 // Map attribute modifications on the <webview> tag to property changes in 306 // the underlying <object> node. 307 var handleMutation = $Function.bind(function(mutation) { 308 this.handleWebviewAttributeMutation_(mutation); 309 }, this); 310 var observer = new MutationObserver(function(mutations) { 311 $Array.forEach(mutations, handleMutation); 312 }); 313 observer.observe( 314 this.webviewNode_, 315 {attributes: true, attributeFilter: WEB_VIEW_ATTRIBUTES}); 316 }; 317 318 /** 319 * @private 320 */ 321 WebView.prototype.setupBrowserPluginNodeObservers_ = function() { 322 var handleMutation = $Function.bind(function(mutation) { 323 this.handleBrowserPluginAttributeMutation_(mutation); 324 }, this); 325 var objectObserver = new MutationObserver(function(mutations) { 326 $Array.forEach(mutations, handleMutation); 327 }); 328 objectObserver.observe( 329 this.browserPluginNode_, 330 {attributes: true, attributeFilter: WEB_VIEW_ATTRIBUTES}); 331 }; 332 333 /** 334 * @private 335 */ 336 WebView.prototype.handleWebviewAttributeMutation_ = function(mutation) { 337 // This observer monitors mutations to attributes of the <webview> and 338 // updates the BrowserPlugin properties accordingly. In turn, updating 339 // a BrowserPlugin property will update the corresponding BrowserPlugin 340 // attribute, if necessary. See BrowserPlugin::UpdateDOMAttribute for more 341 // details. 342 this.browserPluginNode_[mutation.attributeName] = 343 this.webviewNode_.getAttribute(mutation.attributeName); 344 }; 345 346 /** 347 * @private 348 */ 349 WebView.prototype.handleBrowserPluginAttributeMutation_ = function(mutation) { 350 // This observer monitors mutations to attributes of the BrowserPlugin and 351 // updates the <webview> attributes accordingly. 352 if (!this.browserPluginNode_.hasAttribute(mutation.attributeName)) { 353 // If an attribute is removed from the BrowserPlugin, then remove it 354 // from the <webview> as well. 355 this.webviewNode_.removeAttribute(mutation.attributeName); 356 } else { 357 // Update the <webview> attribute to match the BrowserPlugin attribute. 358 // Note: Calling setAttribute on <webview> will trigger its mutation 359 // observer which will then propagate that attribute to BrowserPlugin. In 360 // cases where we permit assigning a BrowserPlugin attribute the same value 361 // again (such as navigation when crashed), this could end up in an infinite 362 // loop. Thus, we avoid this loop by only updating the <webview> attribute 363 // if the BrowserPlugin attributes differs from it. 364 var oldValue = this.webviewNode_.getAttribute(mutation.attributeName); 365 var newValue = this.browserPluginNode_.getAttribute(mutation.attributeName); 366 if (newValue != oldValue) { 367 this.webviewNode_.setAttribute(mutation.attributeName, newValue); 368 } 369 } 370 }; 371 372 /** 373 * @private 374 */ 375 WebView.prototype.getWebviewExtEvents_ = function() { 376 var experimentalExtEvents = this.maybeGetWebviewExperimentalExtEvents_(); 377 for (var eventName in experimentalExtEvents) { 378 WEB_VIEW_EXT_EVENTS[eventName] = experimentalExtEvents[eventName]; 379 } 380 return WEB_VIEW_EXT_EVENTS; 381 }; 382 383 /** 384 * @private 385 */ 386 WebView.prototype.setupWebviewNodeEvents_ = function() { 387 var self = this; 388 this.viewInstanceId_ = ++webViewInstanceIdCounter; 389 var onInstanceIdAllocated = function(e) { 390 var detail = e.detail ? JSON.parse(e.detail) : {}; 391 self.instanceId_ = detail.windowId; 392 var params = { 393 'api': 'webview', 394 'instanceId': self.viewInstanceId_ 395 }; 396 self.browserPluginNode_['-internal-attach'](params); 397 398 var extEvents = self.getWebviewExtEvents_(); 399 for (var eventName in extEvents) { 400 self.setupExtEvent_(eventName, extEvents[eventName]); 401 } 402 }; 403 this.browserPluginNode_.addEventListener('-internal-instanceid-allocated', 404 onInstanceIdAllocated); 405 406 for (var eventName in WEB_VIEW_EVENTS) { 407 this.setupEvent_(eventName, WEB_VIEW_EVENTS[eventName]); 408 } 409 }; 410 411 /** 412 * @private 413 */ 414 WebView.prototype.setupExtEvent_ = function(eventName, eventInfo) { 415 var self = this; 416 var webviewNode = this.webviewNode_; 417 eventInfo.evt.addListener(function(event) { 418 var details = {bubbles:true}; 419 if (eventInfo.cancelable) 420 details.cancelable = true; 421 var webviewEvent = new Event(eventName, details); 422 $Array.forEach(eventInfo.fields, function(field) { 423 if (event[field] !== undefined) { 424 webviewEvent[field] = event[field]; 425 } 426 }); 427 if (eventInfo.customHandler) { 428 eventInfo.customHandler(self, event, webviewEvent); 429 return; 430 } 431 webviewNode.dispatchEvent(webviewEvent); 432 }, {instanceId: self.instanceId_}); 433 }; 434 435 /** 436 * @private 437 */ 438 WebView.prototype.setupEvent_ = function(eventName, attribs) { 439 var webviewNode = this.webviewNode_; 440 var internalname = '-internal-' + eventName; 441 this.browserPluginNode_.addEventListener(internalname, function(e) { 442 var evt = new Event(eventName, { bubbles: true }); 443 var detail = e.detail ? JSON.parse(e.detail) : {}; 444 $Array.forEach(attribs, function(attribName) { 445 evt[attribName] = detail[attribName]; 446 }); 447 webviewNode.dispatchEvent(evt); 448 }); 449 }; 450 451 /** 452 * @private 453 */ 454 WebView.prototype.setupExtNewWindowEvent_ = function(event, webviewEvent) { 455 var ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN = '<webview>: ' + 456 'An action has already been taken for this "newwindow" event.'; 457 458 var ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH = '<webview>: ' + 459 'Unable to attach the new window to the provided webview.'; 460 461 var ERROR_MSG_WEBVIEW_EXPECTED = '<webview> element expected.'; 462 463 var showWarningMessage = function() { 464 var WARNING_MSG_NEWWINDOW_BLOCKED = '<webview>: A new window was blocked.'; 465 console.warn(WARNING_MSG_NEWWINDOW_BLOCKED); 466 }; 467 468 var self = this; 469 var browserPluginNode = this.browserPluginNode_; 470 var webviewNode = this.webviewNode_; 471 472 var requestId = event.requestId; 473 var actionTaken = false; 474 475 var onTrackedObjectGone = function(requestId, e) { 476 var detail = e.detail ? JSON.parse(e.detail) : {}; 477 if (detail.id != requestId) { 478 return; 479 } 480 481 // Avoid showing a warning message if the decision has already been made. 482 if (actionTaken) { 483 return; 484 } 485 486 chrome.webview.setPermission(self.instanceId_, requestId, false, ''); 487 showWarningMessage(); 488 }; 489 490 491 var validateCall = function () { 492 if (actionTaken) { 493 throw new Error(ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN); 494 } 495 actionTaken = true; 496 }; 497 498 var window = { 499 attach: function(webview) { 500 validateCall(); 501 if (!webview) 502 throw new Error(ERROR_MSG_WEBVIEW_EXPECTED); 503 // Attach happens asynchronously to give the tagWatcher an opportunity 504 // to pick up the new webview before attach operates on it, if it hasn't 505 // been attached to the DOM already. 506 // Note: Any subsequent errors cannot be exceptions because they happen 507 // asynchronously. 508 setTimeout(function() { 509 var attached = 510 browserPluginNode['-internal-attachWindowTo'](webview, 511 event.windowId); 512 if (!attached) { 513 console.error(ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH); 514 } 515 // If the object being passed into attach is not a valid <webview> 516 // then we will fail and it will be treated as if the new window 517 // was rejected. The permission API plumbing is used here to clean 518 // up the state created for the new window if attaching fails. 519 chrome.webview.setPermission(self.instanceId_, requestId, attached, ''); 520 }, 0); 521 }, 522 discard: function() { 523 validateCall(); 524 chrome.webview.setPermission(self.instanceId_, requestId, false, ''); 525 } 526 }; 527 webviewEvent.window = window; 528 529 var defaultPrevented = !webviewNode.dispatchEvent(webviewEvent); 530 if (actionTaken) { 531 return; 532 } 533 534 if (defaultPrevented) { 535 // Make browser plugin track lifetime of |window|. 536 var onTrackedObjectGoneWithRequestId = 537 $Function.bind(onTrackedObjectGone, self, requestId); 538 browserPluginNode.addEventListener('-internal-trackedobjectgone', 539 onTrackedObjectGoneWithRequestId); 540 browserPluginNode['-internal-trackObjectLifetime'](window, requestId); 541 } else { 542 actionTaken = true; 543 // The default action is to discard the window. 544 chrome.webview.setPermission(self.instanceId_, requestId, false, ''); 545 showWarningMessage(); 546 } 547 }; 548 549 /** 550 * @private 551 */ 552 WebView.prototype.setupExecuteCodeAPI_ = function() { 553 var ERROR_MSG_CANNOT_INJECT_SCRIPT = '<webview>: ' + 554 'Script cannot be injected into content until the page has loaded.'; 555 556 var self = this; 557 var validateCall = function() { 558 if (!self.browserPluginNode_.getGuestInstanceId()) { 559 throw new Error(ERROR_MSG_CANNOT_INJECT_SCRIPT); 560 } 561 }; 562 563 this.webviewNode_['executeScript'] = function(var_args) { 564 validateCall(); 565 var args = $Array.concat([self.browserPluginNode_.getGuestInstanceId()], 566 $Array.slice(arguments)); 567 $Function.apply(chrome.webview.executeScript, null, args); 568 } 569 this.webviewNode_['insertCSS'] = function(var_args) { 570 validateCall(); 571 var args = $Array.concat([self.browserPluginNode_.getGuestInstanceId()], 572 $Array.slice(arguments)); 573 $Function.apply(chrome.webview.insertCSS, null, args); 574 } 575 }; 576 577 /** 578 * @private 579 */ 580 WebView.prototype.getPermissionTypes_ = function() { 581 return ['media', 'geolocation', 'pointerLock', 'download']; 582 }; 583 584 WebView.prototype.setupExtPermissionEvent_ = function(event, webviewEvent) { 585 var ERROR_MSG_PERMISSION_ALREADY_DECIDED = '<webview>: ' + 586 'Permission has already been decided for this "permissionrequest" event.'; 587 588 var showWarningMessage = function(permission) { 589 var WARNING_MSG_PERMISSION_DENIED = '<webview>: ' + 590 'The permission request for "%1" has been denied.'; 591 console.warn(WARNING_MSG_PERMISSION_DENIED.replace('%1', permission)); 592 }; 593 594 var PERMISSION_TYPES = this.getPermissionTypes_(); 595 596 var self = this; 597 var browserPluginNode = this.browserPluginNode_; 598 var webviewNode = this.webviewNode_; 599 600 var requestId = event.requestId; 601 var decisionMade = false; 602 603 var onTrackedObjectGone = function(requestId, permission, e) { 604 var detail = e.detail ? JSON.parse(e.detail) : {}; 605 if (detail.id != requestId) { 606 return; 607 } 608 609 // Avoid showing a warning message if the decision has already been made. 610 if (decisionMade) { 611 return; 612 } 613 614 chrome.webview.setPermission(self.instanceId_, requestId, false, ''); 615 showWarningMessage(permission); 616 }; 617 618 var validateCall = function() { 619 if (decisionMade) { 620 throw new Error(ERROR_MSG_PERMISSION_ALREADY_DECIDED); 621 } 622 decisionMade = true; 623 }; 624 625 // Construct the event.request object. 626 var request = { 627 allow: function() { 628 validateCall(); 629 chrome.webview.setPermission(self.instanceId_, requestId, true, ''); 630 }, 631 deny: function() { 632 validateCall(); 633 chrome.webview.setPermission(self.instanceId_, requestId, false, ''); 634 } 635 }; 636 webviewEvent.request = request; 637 638 var defaultPrevented = !webviewNode.dispatchEvent(webviewEvent); 639 if (decisionMade) { 640 return; 641 } 642 643 if (defaultPrevented) { 644 // Make browser plugin track lifetime of |request|. 645 var onTrackedObjectGoneWithRequestId = 646 $Function.bind( 647 onTrackedObjectGone, self, requestId, event.permission); 648 browserPluginNode.addEventListener('-internal-trackedobjectgone', 649 onTrackedObjectGoneWithRequestId); 650 browserPluginNode['-internal-trackObjectLifetime'](request, requestId); 651 } else { 652 decisionMade = true; 653 chrome.webview.setPermission(self.instanceId_, requestId, false, ''); 654 showWarningMessage(event.permission); 655 } 656 }; 657 658 /** 659 * Implemented when the experimental API is available. 660 * @private 661 */ 662 WebView.prototype.maybeSetupExperimentalAPI_ = function() {}; 663 664 /** 665 * Implemented when the experimental API is available. 666 * @private 667 */ 668 WebView.prototype.maybeSetupExtDialogEvent_ = function() {}; 669 670 /** 671 * Implemented when the experimental API is available. 672 * @private 673 */ 674 WebView.prototype.maybeGetWebviewExperimentalExtEvents_ = function() {}; 675 676 exports.WebView = WebView; 677 exports.CreateEvent = createEvent; 678