// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // Shim that simulates a <adview> tag via Mutation Observers. // // The actual tag is implemented via the browser plugin. The internals of this // are hidden via Shadow DOM. // TODO(rpaquay): This file is currently very similar to "web_view.js". Do we // want to refactor to extract common pieces? var eventBindings = require('event_bindings'); var process = requireNative('process'); var addTagWatcher = require('tagWatcher').addTagWatcher; /** * Define "allowCustomAdNetworks" function such that the * "kEnableAdviewSrcAttribute" flag is respected. */ function allowCustomAdNetworks() { return process.HasSwitch('enable-adview-src-attribute'); } /** * List of attribute names to "blindly" sync between <adview> tag and internal * browser plugin. */ var AD_VIEW_ATTRIBUTES = [ 'name', ]; /** * List of custom attributes (and their behavior). * * name: attribute name. * onMutation(adview, mutation): callback invoked when attribute is mutated. * isProperty: True if the attribute should be exposed as a property. */ var AD_VIEW_CUSTOM_ATTRIBUTES = [ { name: 'ad-network', onMutation: function(adview, mutation) { adview.handleAdNetworkMutation(mutation); }, isProperty: function() { return true; } }, { name: 'src', onMutation: function(adview, mutation) { adview.handleSrcMutation(mutation); }, isProperty: function() { return allowCustomAdNetworks(); } } ]; /** * List of api methods. These are forwarded to the browser plugin. */ var AD_VIEW_API_METHODS = [ // Empty for now. ]; var createEvent = function(name) { var eventOpts = {supportsListeners: true, supportsFilters: true}; return new eventBindings.Event(name, undefined, eventOpts); }; var AdviewLoadAbortEvent = createEvent('adview.onLoadAbort'); var AdviewLoadCommitEvent = createEvent('adview.onLoadCommit'); var AD_VIEW_EXT_EVENTS = { 'loadabort': { evt: AdviewLoadAbortEvent, fields: ['url', 'isTopLevel', 'reason'] }, 'loadcommit': { customHandler: function(adview, event) { if (event.isTopLevel) { adview.browserPluginNode_.setAttribute('src', event.url); } }, evt: AdviewLoadCommitEvent, fields: ['url', 'isTopLevel'] } }; /** * List of supported ad-networks. * * name: identifier of the ad-network, corresponding to a valid value * of the "ad-network" attribute of an <adview> element. * url: url to navigate to when initially displaying the <adview>. * origin: origin of urls the <adview> is allowed navigate to. */ var AD_VIEW_AD_NETWORKS_WHITELIST = [ { name: 'admob', url: 'https://admob-sdk.doubleclick.net/chromeapps', origin: 'https://double.net' }, ]; /** * Return the whitelisted ad-network entry named |name|. */ function getAdNetworkInfo(name) { var result = null; $Array.forEach(AD_VIEW_AD_NETWORKS_WHITELIST, function(item) { if (item.name === name) result = item; }); return result; } /** * @constructor */ function AdView(adviewNode) { this.adviewNode_ = adviewNode; this.browserPluginNode_ = this.createBrowserPluginNode_(); var shadowRoot = this.adviewNode_.webkitCreateShadowRoot(); shadowRoot.appendChild(this.browserPluginNode_); this.setupCustomAttributes_(); this.setupAdviewNodeObservers_(); this.setupAdviewNodeMethods_(); this.setupAdviewNodeProperties_(); this.setupAdviewNodeEvents_(); this.setupBrowserPluginNodeObservers_(); } /** * @private */ AdView.prototype.createBrowserPluginNode_ = function() { var browserPluginNode = document.createElement('object'); browserPluginNode.type = 'application/browser-plugin'; // The <object> node fills in the <adview> container. browserPluginNode.style.width = '100%'; browserPluginNode.style.height = '100%'; $Array.forEach(AD_VIEW_ATTRIBUTES, function(attributeName) { // Only copy attributes that have been assigned values, rather than copying // a series of undefined attributes to BrowserPlugin. if (this.adviewNode_.hasAttribute(attributeName)) { browserPluginNode.setAttribute( attributeName, this.adviewNode_.getAttribute(attributeName)); } }, this); return browserPluginNode; } /** * @private */ AdView.prototype.setupCustomAttributes_ = function() { $Array.forEach(AD_VIEW_CUSTOM_ATTRIBUTES, function(attributeInfo) { if (attributeInfo.onMutation) { attributeInfo.onMutation(this); } }, this); } /** * @private */ AdView.prototype.setupAdviewNodeMethods_ = function() { // this.browserPluginNode_[apiMethod] are not necessarily defined immediately // after the shadow object is appended to the shadow root. var self = this; $Array.forEach(AD_VIEW_API_METHODS, function(apiMethod) { self.adviewNode_[apiMethod] = function(var_args) { return $Function.apply(self.browserPluginNode_[apiMethod], self.browserPluginNode_, arguments); }; }, this); } /** * @private */ AdView.prototype.setupAdviewNodeObservers_ = function() { // Map attribute modifications on the <adview> tag to property changes in // the underlying <object> node. var handleMutation = $Function.bind(function(mutation) { this.handleAdviewAttributeMutation_(mutation); }, this); var observer = new MutationObserver(function(mutations) { $Array.forEach(mutations, handleMutation); }); observer.observe( this.adviewNode_, {attributes: true, attributeFilter: AD_VIEW_ATTRIBUTES}); this.setupAdviewNodeCustomObservers_(); } /** * @private */ AdView.prototype.setupAdviewNodeCustomObservers_ = function() { var handleMutation = $Function.bind(function(mutation) { this.handleAdviewCustomAttributeMutation_(mutation); }, this); var observer = new MutationObserver(function(mutations) { $Array.forEach(mutations, handleMutation); }); var customAttributeNames = AD_VIEW_CUSTOM_ATTRIBUTES.map(function(item) { return item.name; }); observer.observe( this.adviewNode_, {attributes: true, attributeFilter: customAttributeNames}); } /** * @private */ AdView.prototype.setupBrowserPluginNodeObservers_ = function() { var handleMutation = $Function.bind(function(mutation) { this.handleBrowserPluginAttributeMutation_(mutation); }, this); var objectObserver = new MutationObserver(function(mutations) { $Array.forEach(mutations, handleMutation); }); objectObserver.observe( this.browserPluginNode_, {attributes: true, attributeFilter: AD_VIEW_ATTRIBUTES}); } /** * @private */ AdView.prototype.setupAdviewNodeProperties_ = function() { var browserPluginNode = this.browserPluginNode_; // Expose getters and setters for the attributes. $Array.forEach(AD_VIEW_ATTRIBUTES, function(attributeName) { Object.defineProperty(this.adviewNode_, attributeName, { get: function() { return browserPluginNode[attributeName]; }, set: function(value) { browserPluginNode[attributeName] = value; }, enumerable: true }); }, this); // Expose getters and setters for the custom attributes. var adviewNode = this.adviewNode_; $Array.forEach(AD_VIEW_CUSTOM_ATTRIBUTES, function(attributeInfo) { if (attributeInfo.isProperty()) { var attributeName = attributeInfo.name; Object.defineProperty(this.adviewNode_, attributeName, { get: function() { return adviewNode.getAttribute(attributeName); }, set: function(value) { adviewNode.setAttribute(attributeName, value); }, enumerable: true }); } }, this); this.setupAdviewContentWindowProperty_(); } /** * @private */ AdView.prototype.setupAdviewContentWindowProperty_ = function() { var browserPluginNode = this.browserPluginNode_; // We cannot use {writable: true} property descriptor because we want dynamic // getter value. Object.defineProperty(this.adviewNode_, 'contentWindow', { get: function() { // TODO(fsamuel): This is a workaround to enable // contentWindow.postMessage until http://crbug.com/152006 is fixed. if (browserPluginNode.contentWindow) return browserPluginNode.contentWindow.self; console.error('contentWindow is not available at this time. ' + 'It will become available when the page has finished loading.'); }, // No setter. enumerable: true }); } /** * @private */ AdView.prototype.handleAdviewAttributeMutation_ = function(mutation) { // This observer monitors mutations to attributes of the <adview> and // updates the BrowserPlugin properties accordingly. In turn, updating // a BrowserPlugin property will update the corresponding BrowserPlugin // attribute, if necessary. See BrowserPlugin::UpdateDOMAttribute for more // details. this.browserPluginNode_[mutation.attributeName] = this.adviewNode_.getAttribute(mutation.attributeName); }; /** * @private */ AdView.prototype.handleAdviewCustomAttributeMutation_ = function(mutation) { $Array.forEach(AD_VIEW_CUSTOM_ATTRIBUTES, function(item) { if (mutation.attributeName.toUpperCase() == item.name.toUpperCase()) { if (item.onMutation) { $Function.bind(item.onMutation, item)(this, mutation); } } }, this); }; /** * @private */ AdView.prototype.handleBrowserPluginAttributeMutation_ = function(mutation) { // This observer monitors mutations to attributes of the BrowserPlugin and // updates the <adview> attributes accordingly. if (!this.browserPluginNode_.hasAttribute(mutation.attributeName)) { // If an attribute is removed from the BrowserPlugin, then remove it // from the <adview> as well. this.adviewNode_.removeAttribute(mutation.attributeName); } else { // Update the <adview> attribute to match the BrowserPlugin attribute. // Note: Calling setAttribute on <adview> will trigger its mutation // observer which will then propagate that attribute to BrowserPlugin. In // cases where we permit assigning a BrowserPlugin attribute the same value // again (such as navigation when crashed), this could end up in an infinite // loop. Thus, we avoid this loop by only updating the <adview> attribute // if the BrowserPlugin attributes differs from it. var oldValue = this.adviewNode_.getAttribute(mutation.attributeName); var newValue = this.browserPluginNode_.getAttribute(mutation.attributeName); if (newValue != oldValue) { this.adviewNode_.setAttribute(mutation.attributeName, newValue); } } }; /** * @private */ AdView.prototype.navigateToUrl_ = function(url) { var newValue = url; var oldValue = this.browserPluginNode_.getAttribute('src'); if (newValue === oldValue) return; if (url != null) { // Note: Setting the 'src' property directly, as calling setAttribute has no // effect due to implementation details of BrowserPlugin. this.browserPluginNode_['src'] = url; if (allowCustomAdNetworks()) { this.adviewNode_.setAttribute('src', url); } } else { // Note: Setting the 'src' property directly, as calling setAttribute has no // effect due to implementation details of BrowserPlugin. // TODO(rpaquay): Due to another implementation detail of BrowserPlugin, // this line will leave the "src" attribute value untouched. this.browserPluginNode_['src'] = null; if (allowCustomAdNetworks()) { this.adviewNode_.removeAttribute('src'); } } } /** * @public */ AdView.prototype.handleAdNetworkMutation = function(mutation) { if (this.adviewNode_.hasAttribute('ad-network')) { var value = this.adviewNode_.getAttribute('ad-network'); var item = getAdNetworkInfo(value); if (item) { this.navigateToUrl_(item.url); } else if (allowCustomAdNetworks()) { console.log('The ad-network "' + value + '" is not recognized, ' + 'but custom ad-networks are enabled.'); if (mutation) { this.navigateToUrl_(''); } } else { // Ignore the new attribute value and set it to empty string. // Avoid infinite loop by checking for empty string as new value. if (value != '') { console.error('The ad-network "' + value + '" is not recognized.'); this.adviewNode_.setAttribute('ad-network', ''); } this.navigateToUrl_(''); } } else { this.navigateToUrl_(''); } } /** * @public */ AdView.prototype.handleSrcMutation = function(mutation) { if (allowCustomAdNetworks()) { if (this.adviewNode_.hasAttribute('src')) { var newValue = this.adviewNode_.getAttribute('src'); // Note: Setting the 'src' property directly, as calling setAttribute has // no effect due to implementation details of BrowserPlugin. this.browserPluginNode_['src'] = newValue; } else { // If an attribute is removed from the <adview>, then remove it // from the BrowserPlugin as well. // Note: Setting the 'src' property directly, as calling setAttribute has // no effect due to implementation details of BrowserPlugin. // TODO(rpaquay): Due to another implementation detail of BrowserPlugin, // this line will leave the "src" attribute value untouched. this.browserPluginNode_['src'] = null; } } else { if (this.adviewNode_.hasAttribute('src')) { var value = this.adviewNode_.getAttribute('src'); // Ignore the new attribute value and set it to empty string. // Avoid infinite loop by checking for empty string as new value. if (value != '') { console.error('Setting the "src" attribute of an <adview> ' + 'element is not supported. Use the "ad-network" attribute ' + 'instead.'); this.adviewNode_.setAttribute('src', ''); } } } } /** * @private */ AdView.prototype.setupAdviewNodeEvents_ = function() { var self = this; var onInstanceIdAllocated = function(e) { var detail = e.detail ? JSON.parse(e.detail) : {}; self.instanceId_ = detail.windowId; var params = { 'api': 'adview' }; self.browserPluginNode_['-internal-attach'](params); for (var eventName in AD_VIEW_EXT_EVENTS) { self.setupExtEvent_(eventName, AD_VIEW_EXT_EVENTS[eventName]); } }; this.browserPluginNode_.addEventListener('-internal-instanceid-allocated', onInstanceIdAllocated); } /** * @private */ AdView.prototype.setupExtEvent_ = function(eventName, eventInfo) { var self = this; var adviewNode = this.adviewNode_; eventInfo.evt.addListener(function(event) { var adviewEvent = new Event(eventName, {bubbles: true}); $Array.forEach(eventInfo.fields, function(field) { adviewEvent[field] = event[field]; }); if (eventInfo.customHandler) { eventInfo.customHandler(self, event); } adviewNode.dispatchEvent(adviewEvent); }, {instanceId: self.instanceId_}); }; /** * @public */ AdView.prototype.dispatchEvent = function(eventname, detail) { // Create event object. var evt = new Event(eventname, { bubbles: true }); for(var item in detail) { evt[item] = detail[item]; } // Dispatch event. this.adviewNode_.dispatchEvent(evt); } addTagWatcher('ADVIEW', function(addedNode) { new AdView(addedNode); });