blob: 52126d45df008521d26c95737d4d621032970560 [file] [log] [blame]
// Copyright (c) 2012 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 <webview> tag via Mutation Observers.
//
// The actual tag is implemented via the browser plugin. The internals of this
// are hidden via Shadow DOM.
var addTagWatcher = require('tagWatcher').addTagWatcher;
var eventBindings = require('event_bindings');
/** @type {Array.<string>} */
var WEB_VIEW_ATTRIBUTES = ['name', 'src', 'partition', 'autosize', 'minheight',
'minwidth', 'maxheight', 'maxwidth'];
var WEB_VIEW_EVENTS = {
'sizechanged': ['oldHeight', 'oldWidth', 'newHeight', 'newWidth'],
};
var createEvent = function(name) {
var eventOpts = {supportsListeners: true, supportsFilters: true};
return new eventBindings.Event(name, undefined, eventOpts);
};
var WEB_VIEW_EXT_EVENTS = {
'close': {
evt: createEvent('webview.onClose'),
fields: []
},
'consolemessage': {
evt: createEvent('webview.onConsoleMessage'),
fields: ['level', 'message', 'line', 'sourceId']
},
'contentload': {
evt: createEvent('webview.onContentLoad'),
fields: []
},
'exit': {
evt: createEvent('webview.onExit'),
fields: ['processId', 'reason']
},
'loadabort': {
evt: createEvent('webview.onLoadAbort'),
fields: ['url', 'isTopLevel', 'reason']
},
'loadcommit': {
customHandler: function(webview, event) {
webview.currentEntryIndex_ = event.currentEntryIndex;
webview.entryCount_ = event.entryCount;
webview.processId_ = event.processId;
if (event.isTopLevel) {
webview.browserPluginNode_.setAttribute('src', event.url);
}
},
evt: createEvent('webview.onLoadCommit'),
fields: ['url', 'isTopLevel']
},
'loadredirect': {
evt: createEvent('webview.onLoadRedirect'),
fields: ['isTopLevel', 'oldUrl', 'newUrl']
},
'loadstart': {
evt: createEvent('webview.onLoadStart'),
fields: ['url', 'isTopLevel']
},
'loadstop': {
evt: createEvent('webview.onLoadStop'),
fields: []
},
'responsive': {
evt: createEvent('webview.onResponsive'),
fields: ['processId']
},
'unresponsive': {
evt: createEvent('webview.onUnresponsive'),
fields: ['processId']
}
};
addTagWatcher('WEBVIEW', function(addedNode) { new WebView(addedNode); });
/** @type {number} */
WebView.prototype.entryCount_;
/** @type {number} */
WebView.prototype.currentEntryIndex_;
/** @type {number} */
WebView.prototype.processId_;
/**
* @constructor
*/
function WebView(webviewNode) {
this.webviewNode_ = webviewNode;
this.browserPluginNode_ = this.createBrowserPluginNode_();
var shadowRoot = this.webviewNode_.webkitCreateShadowRoot();
shadowRoot.appendChild(this.browserPluginNode_);
this.setupFocusPropagation_();
this.setupWebviewNodeMethods_();
this.setupWebviewNodeProperties_();
this.setupWebviewNodeAttributes_();
this.setupWebviewNodeEvents_();
// Experimental API
this.maybeSetupExperimentalAPI_();
}
/**
* @private
*/
WebView.prototype.createBrowserPluginNode_ = function() {
var browserPluginNode = document.createElement('object');
browserPluginNode.type = 'application/browser-plugin';
// The <object> node fills in the <webview> container.
browserPluginNode.style.width = '100%';
browserPluginNode.style.height = '100%';
$Array.forEach(WEB_VIEW_ATTRIBUTES, function(attributeName) {
// Only copy attributes that have been assigned values, rather than copying
// a series of undefined attributes to BrowserPlugin.
if (this.webviewNode_.hasAttribute(attributeName)) {
browserPluginNode.setAttribute(
attributeName, this.webviewNode_.getAttribute(attributeName));
} else if (this.webviewNode_[attributeName]){
// Reading property using has/getAttribute does not work on
// document.DOMContentLoaded event (but works on
// window.DOMContentLoaded event).
// So copy from property if copying from attribute fails.
browserPluginNode.setAttribute(
attributeName, this.webviewNode_[attributeName]);
}
}, this);
return browserPluginNode;
};
/**
* @private
*/
WebView.prototype.setupFocusPropagation_ = function() {
if (!this.webviewNode_.hasAttribute('tabIndex')) {
// <webview> needs a tabIndex in order to respond to keyboard focus.
// TODO(fsamuel): This introduces unexpected tab ordering. We need to find
// a way to take keyboard focus without messing with tab ordering.
// See http://crbug.com/231664.
this.webviewNode_.setAttribute('tabIndex', 0);
}
var self = this;
this.webviewNode_.addEventListener('focus', function(e) {
// Focus the BrowserPlugin when the <webview> takes focus.
self.browserPluginNode_.focus();
});
this.webviewNode_.addEventListener('blur', function(e) {
// Blur the BrowserPlugin when the <webview> loses focus.
self.browserPluginNode_.blur();
});
};
/**
* @private
*/
WebView.prototype.setupWebviewNodeMethods_ = function() {
// this.browserPluginNode_[apiMethod] are not necessarily defined immediately
// after the shadow object is appended to the shadow root.
var webviewNode = this.webviewNode_;
var browserPluginNode = this.browserPluginNode_;
var self = this;
webviewNode['canGoBack'] = function() {
return self.entryCount_ > 1 && self.currentEntryIndex_ > 0;
};
webviewNode['canGoForward'] = function() {
return self.currentEntryIndex_ >=0 &&
self.currentEntryIndex_ < (self.entryCount_ - 1);
};
webviewNode['back'] = function() {
webviewNode.go(-1);
};
webviewNode['forward'] = function() {
webviewNode.go(1);
};
webviewNode['getProcessId'] = function() {
return self.processId_;
};
webviewNode['go'] = function(relativeIndex) {
var instanceId = browserPluginNode.getGuestInstanceId();
if (!instanceId) {
return;
}
chrome.webview.go(instanceId, relativeIndex);
};
webviewNode['reload'] = function() {
var instanceId = browserPluginNode.getGuestInstanceId();
if (!instanceId) {
return;
}
chrome.webview.reload(instanceId);
};
webviewNode['stop'] = function() {
var instanceId = browserPluginNode.getGuestInstanceId();
if (!instanceId) {
return;
}
chrome.webview.stop(instanceId);
};
webviewNode['terminate'] = function() {
var instanceId = browserPluginNode.getGuestInstanceId();
if (!instanceId) {
return;
}
chrome.webview.terminate(instanceId);
};
this.setupExecuteCodeAPI_();
};
/**
* @private
*/
WebView.prototype.setupWebviewNodeProperties_ = function() {
var ERROR_MSG_CONTENTWINDOW_NOT_AVAILABLE = '<webview>: ' +
'contentWindow is not available at this time. It will become available ' +
'when the page has finished loading.';
var browserPluginNode = this.browserPluginNode_;
// Expose getters and setters for the attributes.
$Array.forEach(WEB_VIEW_ATTRIBUTES, function(attributeName) {
Object.defineProperty(this.webviewNode_, attributeName, {
get: function() {
return browserPluginNode[attributeName];
},
set: function(value) {
browserPluginNode[attributeName] = value;
},
enumerable: true
});
}, this);
// We cannot use {writable: true} property descriptor because we want dynamic
// getter value.
Object.defineProperty(this.webviewNode_, 'contentWindow', {
get: function() {
if (browserPluginNode.contentWindow)
return browserPluginNode.contentWindow;
console.error(ERROR_MSG_CONTENTWINDOW_NOT_AVAILABLE);
},
// No setter.
enumerable: true
});
};
/**
* @private
*/
WebView.prototype.setupWebviewNodeAttributes_ = function() {
this.setupWebviewNodeObservers_();
this.setupBrowserPluginNodeObservers_();
};
/**
* @private
*/
WebView.prototype.setupWebviewNodeObservers_ = function() {
// Map attribute modifications on the <webview> tag to property changes in
// the underlying <object> node.
var handleMutation = $Function.bind(function(mutation) {
this.handleWebviewAttributeMutation_(mutation);
}, this);
var observer = new MutationObserver(function(mutations) {
$Array.forEach(mutations, handleMutation);
});
observer.observe(
this.webviewNode_,
{attributes: true, attributeFilter: WEB_VIEW_ATTRIBUTES});
};
/**
* @private
*/
WebView.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: WEB_VIEW_ATTRIBUTES});
};
/**
* @private
*/
WebView.prototype.handleWebviewAttributeMutation_ = function(mutation) {
// This observer monitors mutations to attributes of the <webview> 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.webviewNode_.getAttribute(mutation.attributeName);
};
/**
* @private
*/
WebView.prototype.handleBrowserPluginAttributeMutation_ = function(mutation) {
// This observer monitors mutations to attributes of the BrowserPlugin and
// updates the <webview> attributes accordingly.
if (!this.browserPluginNode_.hasAttribute(mutation.attributeName)) {
// If an attribute is removed from the BrowserPlugin, then remove it
// from the <webview> as well.
this.webviewNode_.removeAttribute(mutation.attributeName);
} else {
// Update the <webview> attribute to match the BrowserPlugin attribute.
// Note: Calling setAttribute on <webview> 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 <webview> attribute
// if the BrowserPlugin attributes differs from it.
var oldValue = this.webviewNode_.getAttribute(mutation.attributeName);
var newValue = this.browserPluginNode_.getAttribute(mutation.attributeName);
if (newValue != oldValue) {
this.webviewNode_.setAttribute(mutation.attributeName, newValue);
}
}
};
/**
* @private
*/
WebView.prototype.setupWebviewNodeEvents_ = function() {
var self = this;
var onInstanceIdAllocated = function(e) {
var detail = e.detail ? JSON.parse(e.detail) : {};
self.instanceId_ = detail.windowId;
var params = {
'api': 'webview'
};
self.browserPluginNode_['-internal-attach'](params);
for (var eventName in WEB_VIEW_EXT_EVENTS) {
self.setupExtEvent_(eventName, WEB_VIEW_EXT_EVENTS[eventName]);
}
};
this.browserPluginNode_.addEventListener('-internal-instanceid-allocated',
onInstanceIdAllocated);
for (var eventName in WEB_VIEW_EVENTS) {
this.setupEvent_(eventName, WEB_VIEW_EVENTS[eventName]);
}
this.setupNewWindowEvent_();
this.setupPermissionEvent_();
};
/**
* @private
*/
WebView.prototype.setupExtEvent_ = function(eventName, eventInfo) {
var self = this;
var webviewNode = this.webviewNode_;
eventInfo.evt.addListener(function(event) {
var webviewEvent = new Event(eventName, {bubbles: true});
$Array.forEach(eventInfo.fields, function(field) {
webviewEvent[field] = event[field];
});
if (eventInfo.customHandler) {
eventInfo.customHandler(self, event);
}
webviewNode.dispatchEvent(webviewEvent);
}, {instanceId: self.instanceId_});
};
/**
* @private
*/
WebView.prototype.setupEvent_ = function(eventName, attribs) {
var webviewNode = this.webviewNode_;
var internalname = '-internal-' + eventName;
this.browserPluginNode_.addEventListener(internalname, function(e) {
var evt = new Event(eventName, { bubbles: true });
var detail = e.detail ? JSON.parse(e.detail) : {};
$Array.forEach(attribs, function(attribName) {
evt[attribName] = detail[attribName];
});
webviewNode.dispatchEvent(evt);
});
};
/**
* @private
*/
WebView.prototype.setupNewWindowEvent_ = function() {
var ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN = '<webview>: ' +
'An action has already been taken for this "newwindow" event.';
var ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH = '<webview>: ' +
'Unable to attach the new window to the provided webview.';
var ERROR_MSG_WEBVIEW_EXPECTED = '<webview> element expected.';
var showWarningMessage = function() {
var WARNING_MSG_NEWWINDOW_BLOCKED = '<webview>: A new window was blocked.';
console.warn(WARNING_MSG_NEWWINDOW_BLOCKED);
};
var NEW_WINDOW_EVENT_ATTRIBUTES = [
'initialHeight',
'initialWidth',
'targetUrl',
'windowOpenDisposition',
'name'
];
var self = this;
var node = this.webviewNode_;
var browserPluginNode = this.browserPluginNode_;
var onTrackedObjectGone = function(requestId, e) {
var detail = e.detail ? JSON.parse(e.detail) : {};
if (detail.id != requestId) {
return;
}
// If the request was pending then show a warning indiciating that a dialog
// was blocked.
if (browserPluginNode['-internal-setPermission'](requestId, false, '')) {
showWarningMessage();
}
};
browserPluginNode.addEventListener('-internal-newwindow', function(e) {
var evt = new Event('newwindow', { bubbles: true, cancelable: true });
var detail = e.detail ? JSON.parse(e.detail) : {};
$Array.forEach(NEW_WINDOW_EVENT_ATTRIBUTES, function(attribName) {
evt[attribName] = detail[attribName];
});
var requestId = detail.requestId;
var actionTaken = false;
var validateCall = function () {
if (actionTaken) {
throw new Error(ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN);
}
actionTaken = true;
};
var window = {
attach: function(webview) {
validateCall();
if (!webview)
throw new Error(ERROR_MSG_WEBVIEW_EXPECTED);
// Attach happens asynchronously to give the tagWatcher an opportunity
// to pick up the new webview before attach operates on it, if it hasn't
// been attached to the DOM already.
// Note: Any subsequent errors cannot be exceptions because they happen
// asynchronously.
setTimeout(function() {
var attached =
browserPluginNode['-internal-attachWindowTo'](webview,
detail.windowId);
if (!attached) {
console.error(ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH);
}
// If the object being passed into attach is not a valid <webview>
// then we will fail and it will be treated as if the new window
// was rejected. The permission API plumbing is used here to clean
// up the state created for the new window if attaching fails.
browserPluginNode['-internal-setPermission'](requestId, attached, '');
}, 0);
},
discard: function() {
validateCall();
browserPluginNode['-internal-setPermission'](requestId, false, '');
}
};
evt.window = window;
var defaultPrevented = !node.dispatchEvent(evt);
if (actionTaken) {
return;
}
if (defaultPrevented) {
// Make browser plugin track lifetime of |window|.
var onTrackedObjectGoneWithRequestId =
$Function.bind(onTrackedObjectGone, self, requestId);
browserPluginNode.addEventListener('-internal-trackedobjectgone',
onTrackedObjectGoneWithRequestId);
browserPluginNode['-internal-trackObjectLifetime'](window, requestId);
} else {
actionTaken = true;
// The default action is to discard the window.
browserPluginNode['-internal-setPermission'](requestId, false, '');
showWarningMessage();
}
});
};
/**
* @private
*/
WebView.prototype.setupExecuteCodeAPI_ = function() {
var ERROR_MSG_CANNOT_INJECT_SCRIPT = '<webview>: ' +
'Script cannot be injected into content until the page has loaded.';
var self = this;
var validateCall = function() {
if (!self.browserPluginNode_.getGuestInstanceId()) {
throw new Error(ERROR_MSG_CANNOT_INJECT_SCRIPT);
}
};
this.webviewNode_['executeScript'] = function(var_args) {
validateCall();
var args = $Array.concat([self.browserPluginNode_.getGuestInstanceId()],
$Array.slice(arguments));
$Function.apply(chrome.webview.executeScript, null, args);
}
this.webviewNode_['insertCSS'] = function(var_args) {
validateCall();
var args = $Array.concat([self.browserPluginNode_.getGuestInstanceId()],
$Array.slice(arguments));
$Function.apply(chrome.webview.insertCSS, null, args);
}
};
/**
* @param {!Object} detail The event details, originated from <object>.
* @private
*/
WebView.prototype.setupPermissionEvent_ = function() {
var ERROR_MSG_PERMISSION_ALREADY_DECIDED = '<webview>: ' +
'Permission has already been decided for this "permissionrequest" event.';
var showWarningMessage = function(permission) {
var WARNING_MSG_PERMISSION_DENIED = '<webview>: ' +
'The permission request for "%1" has been denied.';
console.warn(WARNING_MSG_PERMISSION_DENIED.replace('%1', permission));
};
var PERMISSION_TYPES = ['media', 'geolocation', 'pointerLock', 'download'];
var EXPOSED_PERMISSION_EVENT_ATTRIBS = [
'lastUnlockedBySelf',
'permission',
'requestMethod',
'url',
'userGesture'
];
var self = this;
var node = this.webviewNode_;
var browserPluginNode = this.browserPluginNode_;
var internalevent = '-internal-permissionrequest';
var onTrackedObjectGone = function(requestId, permission, e) {
var detail = e.detail ? JSON.parse(e.detail) : {};
if (detail.id != requestId)
return;
// If the request was pending then show a warning indiciating that the
// permission was denied.
if (browserPluginNode['-internal-setPermission'](requestId, false, '')) {
showWarningMessage(permission);
}
};
browserPluginNode.addEventListener(internalevent, function(e) {
var evt = new Event('permissionrequest', {bubbles: true, cancelable: true});
var detail = e.detail ? JSON.parse(e.detail) : {};
$Array.forEach(EXPOSED_PERMISSION_EVENT_ATTRIBS, function(attribName) {
if (detail[attribName] !== undefined)
evt[attribName] = detail[attribName];
});
var requestId = detail.requestId;
if (detail.requestId == undefined ||
PERMISSION_TYPES.indexOf(detail.permission) < 0) {
return;
}
// TODO(lazyboy): Also fill in evt.details (see webview specs).
// http://crbug.com/141197.
var decisionMade = false;
var validateCall = function() {
if (decisionMade) {
throw new Error(ERROR_MSG_PERMISSION_ALREADY_DECIDED);
}
decisionMade = true;
};
// Construct the event.request object.
var request = {
allow: function() {
validateCall();
browserPluginNode['-internal-setPermission'](requestId, true, '');
},
deny: function() {
validateCall();
browserPluginNode['-internal-setPermission'](requestId, false, '');
}
};
evt.request = request;
var defaultPrevented = !node.dispatchEvent(evt);
if (decisionMade) {
return;
}
if (defaultPrevented) {
// Make browser plugin track lifetime of |request|.
var onTrackedObjectGoneWithRequestId =
$Function.bind(
onTrackedObjectGone, self, requestId, detail.permission);
browserPluginNode.addEventListener('-internal-trackedobjectgone',
onTrackedObjectGoneWithRequestId);
browserPluginNode['-internal-trackObjectLifetime'](request, requestId);
} else {
decisionMade = true;
browserPluginNode['-internal-setPermission'](requestId, false, '');
showWarningMessage(detail.permission);
}
});
};
/**
* Implemented when the experimental API is available.
* @private
*/
WebView.prototype.maybeSetupExperimentalAPI_ = function() {};
exports.WebView = WebView;