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