web_view.js revision 7d4cd473f85ac64c3747c96c277f9e506a0d2246
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 10var watchForTag = require('tagWatcher').watchForTag; 11 12/** @type {Array.<string>} */ 13var WEB_VIEW_ATTRIBUTES = ['name', 'src', 'partition', 'autosize', 'minheight', 14 'minwidth', 'maxheight', 'maxwidth']; 15 16 17// All exposed api methods for <webview>, these are forwarded to the browser 18// plugin. 19var WEB_VIEW_API_METHODS = [ 20 'back', 21 'canGoBack', 22 'canGoForward', 23 'forward', 24 'getProcessId', 25 'go', 26 'reload', 27 'stop', 28 'terminate' 29]; 30 31var WEB_VIEW_EVENTS = { 32 'close': [], 33 'consolemessage': ['level', 'message', 'line', 'sourceId'], 34 'contentload' : [], 35 'exit' : ['processId', 'reason'], 36 'loadabort' : ['url', 'isTopLevel', 'reason'], 37 'loadcommit' : ['url', 'isTopLevel'], 38 'loadredirect' : ['oldUrl', 'newUrl', 'isTopLevel'], 39 'loadstart' : ['url', 'isTopLevel'], 40 'loadstop' : [], 41 'responsive' : ['processId'], 42 'sizechanged': ['oldHeight', 'oldWidth', 'newHeight', 'newWidth'], 43 'unresponsive' : ['processId'] 44}; 45 46window.addEventListener('DOMContentLoaded', function() { 47 watchForTag('WEBVIEW', function(addedNode) { new WebView(addedNode); }); 48}); 49 50/** 51 * @constructor 52 */ 53function WebView(webviewNode) { 54 this.webviewNode_ = webviewNode; 55 this.browserPluginNode_ = this.createBrowserPluginNode_(); 56 var shadowRoot = this.webviewNode_.webkitCreateShadowRoot(); 57 shadowRoot.appendChild(this.browserPluginNode_); 58 59 this.setupFocusPropagation_(); 60 this.setupWebviewNodeMethods_(); 61 this.setupWebviewNodeProperties_(); 62 this.setupWebviewNodeAttributes_(); 63 this.setupWebviewNodeEvents_(); 64 65 // Experimental API 66 this.maybeSetupExperimentalAPI_(); 67} 68 69/** 70 * @private 71 */ 72WebView.prototype.createBrowserPluginNode_ = function() { 73 var browserPluginNode = document.createElement('object'); 74 browserPluginNode.type = 'application/browser-plugin'; 75 // The <object> node fills in the <webview> container. 76 browserPluginNode.style.width = '100%'; 77 browserPluginNode.style.height = '100%'; 78 $Array.forEach(WEB_VIEW_ATTRIBUTES, function(attributeName) { 79 // Only copy attributes that have been assigned values, rather than copying 80 // a series of undefined attributes to BrowserPlugin. 81 if (this.webviewNode_.hasAttribute(attributeName)) { 82 browserPluginNode.setAttribute( 83 attributeName, this.webviewNode_.getAttribute(attributeName)); 84 } else if (this.webviewNode_[attributeName]){ 85 // Reading property using has/getAttribute does not work on 86 // document.DOMContentLoaded event (but works on 87 // window.DOMContentLoaded event). 88 // So copy from property if copying from attribute fails. 89 browserPluginNode.setAttribute( 90 attributeName, this.webviewNode_[attributeName]); 91 } 92 }, this); 93 94 return browserPluginNode; 95}; 96 97/** 98 * @private 99 */ 100WebView.prototype.setupFocusPropagation_ = function() { 101 if (!this.webviewNode_.hasAttribute('tabIndex')) { 102 // <webview> needs a tabIndex in order to respond to keyboard focus. 103 // TODO(fsamuel): This introduces unexpected tab ordering. We need to find 104 // a way to take keyboard focus without messing with tab ordering. 105 // See http://crbug.com/231664. 106 this.webviewNode_.setAttribute('tabIndex', 0); 107 } 108 var self = this; 109 this.webviewNode_.addEventListener('focus', function(e) { 110 // Focus the BrowserPlugin when the <webview> takes focus. 111 self.browserPluginNode_.focus(); 112 }); 113 this.webviewNode_.addEventListener('blur', function(e) { 114 // Blur the BrowserPlugin when the <webview> loses focus. 115 self.browserPluginNode_.blur(); 116 }); 117}; 118 119/** 120 * @private 121 */ 122WebView.prototype.setupWebviewNodeMethods_ = function() { 123 // this.browserPluginNode_[apiMethod] are not necessarily defined immediately 124 // after the shadow object is appended to the shadow root. 125 var self = this; 126 $Array.forEach(WEB_VIEW_API_METHODS, function(apiMethod) { 127 self.webviewNode_[apiMethod] = function(var_args) { 128 return self.browserPluginNode_[apiMethod].apply( 129 self.browserPluginNode_, arguments); 130 }; 131 }, this); 132 this.setupExecuteCodeAPI_(); 133}; 134 135/** 136 * @private 137 */ 138WebView.prototype.setupWebviewNodeProperties_ = function() { 139 var ERROR_MSG_CONTENTWINDOW_NOT_AVAILABLE = '<webview>: ' + 140 'contentWindow is not available at this time. It will become available ' + 141 'when the page has finished loading.'; 142 143 var browserPluginNode = this.browserPluginNode_; 144 // Expose getters and setters for the attributes. 145 $Array.forEach(WEB_VIEW_ATTRIBUTES, function(attributeName) { 146 Object.defineProperty(this.webviewNode_, attributeName, { 147 get: function() { 148 return browserPluginNode[attributeName]; 149 }, 150 set: function(value) { 151 browserPluginNode[attributeName] = value; 152 }, 153 enumerable: true 154 }); 155 }, this); 156 157 // We cannot use {writable: true} property descriptor because we want dynamic 158 // getter value. 159 Object.defineProperty(this.webviewNode_, 'contentWindow', { 160 get: function() { 161 // TODO(fsamuel): This is a workaround to enable 162 // contentWindow.postMessage until http://crbug.com/152006 is fixed. 163 if (browserPluginNode.contentWindow) 164 return browserPluginNode.contentWindow.self; 165 console.error(ERROR_MSG_CONTENTWINDOW_NOT_AVAILABLE); 166 }, 167 // No setter. 168 enumerable: true 169 }); 170}; 171 172/** 173 * @private 174 */ 175WebView.prototype.setupWebviewNodeAttributes_ = function() { 176 this.setupWebviewNodeObservers_(); 177 this.setupBrowserPluginNodeObservers_(); 178}; 179 180/** 181 * @private 182 */ 183WebView.prototype.setupWebviewNodeObservers_ = function() { 184 // Map attribute modifications on the <webview> tag to property changes in 185 // the underlying <object> node. 186 var handleMutation = $Function.bind(function(mutation) { 187 this.handleWebviewAttributeMutation_(mutation); 188 }, this); 189 var observer = new WebKitMutationObserver(function(mutations) { 190 $Array.forEach(mutations, handleMutation); 191 }); 192 observer.observe( 193 this.webviewNode_, 194 {attributes: true, attributeFilter: WEB_VIEW_ATTRIBUTES}); 195}; 196 197/** 198 * @private 199 */ 200WebView.prototype.setupBrowserPluginNodeObservers_ = function() { 201 var handleMutation = $Function.bind(function(mutation) { 202 this.handleBrowserPluginAttributeMutation_(mutation); 203 }, this); 204 var objectObserver = new WebKitMutationObserver(function(mutations) { 205 $Array.forEach(mutations, handleMutation); 206 }); 207 objectObserver.observe( 208 this.browserPluginNode_, 209 {attributes: true, attributeFilter: WEB_VIEW_ATTRIBUTES}); 210}; 211 212/** 213 * @private 214 */ 215WebView.prototype.handleWebviewAttributeMutation_ = function(mutation) { 216 // This observer monitors mutations to attributes of the <webview> and 217 // updates the BrowserPlugin properties accordingly. In turn, updating 218 // a BrowserPlugin property will update the corresponding BrowserPlugin 219 // attribute, if necessary. See BrowserPlugin::UpdateDOMAttribute for more 220 // details. 221 this.browserPluginNode_[mutation.attributeName] = 222 this.webviewNode_.getAttribute(mutation.attributeName); 223}; 224 225/** 226 * @private 227 */ 228WebView.prototype.handleBrowserPluginAttributeMutation_ = function(mutation) { 229 // This observer monitors mutations to attributes of the BrowserPlugin and 230 // updates the <webview> attributes accordingly. 231 if (!this.browserPluginNode_.hasAttribute(mutation.attributeName)) { 232 // If an attribute is removed from the BrowserPlugin, then remove it 233 // from the <webview> as well. 234 this.webviewNode_.removeAttribute(mutation.attributeName); 235 } else { 236 // Update the <webview> attribute to match the BrowserPlugin attribute. 237 // Note: Calling setAttribute on <webview> will trigger its mutation 238 // observer which will then propagate that attribute to BrowserPlugin. In 239 // cases where we permit assigning a BrowserPlugin attribute the same value 240 // again (such as navigation when crashed), this could end up in an infinite 241 // loop. Thus, we avoid this loop by only updating the <webview> attribute 242 // if the BrowserPlugin attributes differs from it. 243 var oldValue = this.webviewNode_.getAttribute(mutation.attributeName); 244 var newValue = this.browserPluginNode_.getAttribute(mutation.attributeName); 245 if (newValue != oldValue) { 246 this.webviewNode_.setAttribute(mutation.attributeName, newValue); 247 } 248 } 249}; 250 251/** 252 * @private 253 */ 254WebView.prototype.setupWebviewNodeEvents_ = function() { 255 for (var eventName in WEB_VIEW_EVENTS) { 256 this.setupEvent_(eventName, WEB_VIEW_EVENTS[eventName]); 257 } 258 this.setupNewWindowEvent_(); 259 this.setupPermissionEvent_(); 260}; 261 262/** 263 * @private 264 */ 265WebView.prototype.setupEvent_ = function(eventname, attribs) { 266 var webviewNode = this.webviewNode_; 267 var internalname = '-internal-' + eventname; 268 this.browserPluginNode_.addEventListener(internalname, function(e) { 269 var evt = new Event(eventname, { bubbles: true }); 270 var detail = e.detail ? JSON.parse(e.detail) : {}; 271 $Array.forEach(attribs, function(attribName) { 272 evt[attribName] = detail[attribName]; 273 }); 274 webviewNode.dispatchEvent(evt); 275 }); 276}; 277 278/** 279 * @private 280 */ 281WebView.prototype.setupNewWindowEvent_ = function() { 282 var ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN = '<webview>: ' + 283 'An action has already been taken for this "newwindow" event.'; 284 285 var ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH = '<webview>: ' + 286 'Unable to attach the new window to the provided webview.'; 287 288 var ERROR_MSG_WEBVIEW_EXPECTED = '<webview> element expected.'; 289 290 var WARNING_MSG_NEWWINDOW_BLOCKED = '<webview>: A new window was blocked.'; 291 292 var NEW_WINDOW_EVENT_ATTRIBUTES = [ 293 'initialHeight', 294 'initialWidth', 295 'targetUrl', 296 'windowOpenDisposition', 297 'name' 298 ]; 299 300 var node = this.webviewNode_; 301 var browserPluginNode = this.browserPluginNode_; 302 browserPluginNode.addEventListener('-internal-newwindow', function(e) { 303 var evt = new Event('newwindow', { bubbles: true, cancelable: true }); 304 var detail = e.detail ? JSON.parse(e.detail) : {}; 305 306 NEW_WINDOW_EVENT_ATTRIBUTES.forEach(function(attribName) { 307 evt[attribName] = detail[attribName]; 308 }); 309 var requestId = detail.requestId; 310 var actionTaken = false; 311 312 var validateCall = function () { 313 if (actionTaken) 314 throw new Error(ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN); 315 actionTaken = true; 316 }; 317 318 var window = { 319 attach: function(webview) { 320 validateCall(); 321 if (!webview) 322 throw new Error(ERROR_MSG_WEBVIEW_EXPECTED); 323 // Attach happens asynchronously to give the tagWatcher an opportunity 324 // to pick up the new webview before attach operates on it, if it hasn't 325 // been attached to the DOM already. 326 // Note: Any subsequent errors cannot be exceptions because they happen 327 // asynchronously. 328 setTimeout(function() { 329 var attached = 330 browserPluginNode['-internal-attachWindowTo'](webview, 331 detail.windowId); 332 if (!attached) { 333 console.error(ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH); 334 } 335 // If the object being passed into attach is not a valid <webview> 336 // then we will fail and it will be treated as if the new window 337 // was rejected. The permission API plumbing is used here to clean 338 // up the state created for the new window if attaching fails. 339 browserPluginNode['-internal-setPermission'](requestId, attached); 340 }, 0); 341 }, 342 discard: function() { 343 validateCall(); 344 browserPluginNode['-internal-setPermission'](requestId, false); 345 } 346 }; 347 evt.window = window; 348 // Make browser plugin track lifetime of |window|. 349 browserPluginNode['-internal-persistObject']( 350 window, detail.permission, requestId); 351 352 var defaultPrevented = !node.dispatchEvent(evt); 353 if (!actionTaken && !defaultPrevented) { 354 actionTaken = true; 355 // The default action is to discard the window. 356 browserPluginNode['-internal-setPermission'](requestId, false); 357 console.warn(WARNING_MSG_NEWWINDOW_BLOCKED); 358 } 359 }); 360}; 361 362/** 363 * @private 364 */ 365WebView.prototype.setupExecuteCodeAPI_ = function() { 366 var ERROR_MSG_CANNOT_INJECT_SCRIPT = '<webview>: ' + 367 'Script cannot be injected into content until the page has loaded.'; 368 369 var self = this; 370 var validateCall = function() { 371 if (!self.browserPluginNode_.getGuestInstanceId()) { 372 throw new Error(ERROR_MSG_CANNOT_INJECT_SCRIPT); 373 } 374 }; 375 376 this.webviewNode_['executeScript'] = function(var_args) { 377 validateCall(); 378 var args = [self.browserPluginNode_.getGuestInstanceId()].concat( 379 Array.prototype.slice.call(arguments)); 380 chrome.webview.executeScript.apply(null, args); 381 } 382 this.webviewNode_['insertCSS'] = function(var_args) { 383 validateCall(); 384 var args = [self.browserPluginNode_.getGuestInstanceId()].concat( 385 Array.prototype.slice.call(arguments)); 386 chrome.webview.insertCSS.apply(null, args); 387 } 388}; 389 390/** 391 * @private 392 */ 393WebView.prototype.getPermissionTypes_ = function() { 394 var PERMISSION_TYPES = ['media', 'geolocation', 'pointerLock']; 395 return PERMISSION_TYPES.concat(this.maybeGetExperimentalPermissionTypes_()); 396}; 397 398/** 399 * @param {!Object} detail The event details, originated from <object>. 400 * @private 401 */ 402WebView.prototype.setupPermissionEvent_ = function() { 403 var PERMISSION_TYPES = this.getPermissionTypes_(); 404 405 var EXPOSED_PERMISSION_EVENT_ATTRIBS = [ 406 'lastUnlockedBySelf', 407 'permission', 408 'requestMethod', 409 'url', 410 'userGesture' 411 ]; 412 413 var ERROR_MSG_PERMISSION_ALREADY_DECIDED = '<webview>: ' + 414 'Permission has already been decided for this "permissionrequest" event.'; 415 416 var node = this.webviewNode_; 417 var browserPluginNode = this.browserPluginNode_; 418 var internalevent = '-internal-permissionrequest'; 419 browserPluginNode.addEventListener(internalevent, function(e) { 420 var evt = new Event('permissionrequest', {bubbles: true, cancelable: true}); 421 var detail = e.detail ? JSON.parse(e.detail) : {}; 422 $Array.forEach(EXPOSED_PERMISSION_EVENT_ATTRIBS, function(attribName) { 423 if (detail[attribName] !== undefined) 424 evt[attribName] = detail[attribName]; 425 }); 426 var requestId = detail.requestId; 427 428 if (detail.requestId !== undefined && 429 PERMISSION_TYPES.indexOf(detail.permission) >= 0) { 430 // TODO(lazyboy): Also fill in evt.details (see webview specs). 431 // http://crbug.com/141197. 432 var decisionMade = false; 433 // Construct the event.request object. 434 var request = { 435 allow: function() { 436 if (decisionMade) { 437 throw new Error(ERROR_MSG_PERMISSION_ALREADY_DECIDED); 438 } else { 439 browserPluginNode['-internal-setPermission'](requestId, true); 440 decisionMade = true; 441 } 442 }, 443 deny: function() { 444 if (decisionMade) { 445 throw new Error(ERROR_MSG_PERMISSION_ALREADY_DECIDED); 446 } else { 447 browserPluginNode['-internal-setPermission'](requestId, false); 448 decisionMade = true; 449 } 450 } 451 }; 452 evt.request = request; 453 454 // Make browser plugin track lifetime of |request|. 455 browserPluginNode['-internal-persistObject']( 456 request, detail.permission, requestId); 457 458 var defaultPrevented = !node.dispatchEvent(evt); 459 if (!decisionMade && !defaultPrevented) { 460 decisionMade = true; 461 browserPluginNode['-internal-setPermission'](requestId, false); 462 } 463 } 464 }); 465}; 466 467/** 468 * Implemented when the experimental API is available. 469 * @private 470 */ 471WebView.prototype.maybeGetExperimentalPermissionTypes_ = function() { 472 return []; 473}; 474 475/** 476 * Implemented when the experimental API is available. 477 * @private 478 */ 479WebView.prototype.maybeSetupExperimentalAPI_ = function() {}; 480 481exports.WebView = WebView; 482