1// Copyright 2014 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// Event management for WebViewInternal.
6
7var EventBindings = require('event_bindings');
8var MessagingNatives = requireNative('messaging_natives');
9var WebView = require('webViewInternal').WebView;
10
11var CreateEvent = function(name) {
12  var eventOpts = {supportsListeners: true, supportsFilters: true};
13  return new EventBindings.Event(name, undefined, eventOpts);
14};
15
16var FrameNameChangedEvent = CreateEvent('webViewInternal.onFrameNameChanged');
17var PluginDestroyedEvent = CreateEvent('webViewInternal.onPluginDestroyed');
18
19// WEB_VIEW_EVENTS is a map of stable <webview> DOM event names to their
20//     associated extension event descriptor objects.
21// An event listener will be attached to the extension event |evt| specified in
22//     the descriptor.
23// |fields| specifies the public-facing fields in the DOM event that are
24//     accessible to <webview> developers.
25// |customHandler| allows a handler function to be called each time an extension
26//     event is caught by its event listener. The DOM event should be dispatched
27//     within this handler function. With no handler function, the DOM event
28//     will be dispatched by default each time the extension event is caught.
29// |cancelable| (default: false) specifies whether the event's default
30//     behavior can be canceled. If the default action associated with the event
31//     is prevented, then its dispatch function will return false in its event
32//     handler. The event must have a custom handler for this to be meaningful.
33var WEB_VIEW_EVENTS = {
34  'close': {
35    evt: CreateEvent('webViewInternal.onClose'),
36    fields: []
37  },
38  'consolemessage': {
39    evt: CreateEvent('webViewInternal.onConsoleMessage'),
40    fields: ['level', 'message', 'line', 'sourceId']
41  },
42  'contentload': {
43    evt: CreateEvent('webViewInternal.onContentLoad'),
44    fields: []
45  },
46  'dialog': {
47    cancelable: true,
48    customHandler: function(handler, event, webViewEvent) {
49      handler.handleDialogEvent(event, webViewEvent);
50    },
51    evt: CreateEvent('webViewInternal.onDialog'),
52    fields: ['defaultPromptText', 'messageText', 'messageType', 'url']
53  },
54  'exit': {
55     evt: CreateEvent('webViewInternal.onExit'),
56     fields: ['processId', 'reason']
57  },
58  'findupdate': {
59    evt: CreateEvent('webViewInternal.onFindReply'),
60    fields: [
61      'searchText',
62      'numberOfMatches',
63      'activeMatchOrdinal',
64      'selectionRect',
65      'canceled',
66      'finalUpdate'
67    ]
68  },
69  'loadabort': {
70    cancelable: true,
71    customHandler: function(handler, event, webViewEvent) {
72      handler.handleLoadAbortEvent(event, webViewEvent);
73    },
74    evt: CreateEvent('webViewInternal.onLoadAbort'),
75    fields: ['url', 'isTopLevel', 'reason']
76  },
77  'loadcommit': {
78    customHandler: function(handler, event, webViewEvent) {
79      handler.handleLoadCommitEvent(event, webViewEvent);
80    },
81    evt: CreateEvent('webViewInternal.onLoadCommit'),
82    fields: ['url', 'isTopLevel']
83  },
84  'loadprogress': {
85    evt: CreateEvent('webViewInternal.onLoadProgress'),
86    fields: ['url', 'progress']
87  },
88  'loadredirect': {
89    evt: CreateEvent('webViewInternal.onLoadRedirect'),
90    fields: ['isTopLevel', 'oldUrl', 'newUrl']
91  },
92  'loadstart': {
93    evt: CreateEvent('webViewInternal.onLoadStart'),
94    fields: ['url', 'isTopLevel']
95  },
96  'loadstop': {
97    evt: CreateEvent('webViewInternal.onLoadStop'),
98    fields: []
99  },
100  'newwindow': {
101    cancelable: true,
102    customHandler: function(handler, event, webViewEvent) {
103      handler.handleNewWindowEvent(event, webViewEvent);
104    },
105    evt: CreateEvent('webViewInternal.onNewWindow'),
106    fields: [
107      'initialHeight',
108      'initialWidth',
109      'targetUrl',
110      'windowOpenDisposition',
111      'name'
112    ]
113  },
114  'permissionrequest': {
115    cancelable: true,
116    customHandler: function(handler, event, webViewEvent) {
117      handler.handlePermissionEvent(event, webViewEvent);
118    },
119    evt: CreateEvent('webViewInternal.onPermissionRequest'),
120    fields: [
121      'identifier',
122      'lastUnlockedBySelf',
123      'name',
124      'permission',
125      'requestMethod',
126      'url',
127      'userGesture'
128    ]
129  },
130  'responsive': {
131    evt: CreateEvent('webViewInternal.onResponsive'),
132    fields: ['processId']
133  },
134  'sizechanged': {
135    evt: CreateEvent('webViewInternal.onSizeChanged'),
136    customHandler: function(handler, event, webViewEvent) {
137      handler.handleSizeChangedEvent(event, webViewEvent);
138    },
139    fields: ['oldHeight', 'oldWidth', 'newHeight', 'newWidth']
140  },
141  'unresponsive': {
142    evt: CreateEvent('webViewInternal.onUnresponsive'),
143    fields: ['processId']
144  },
145  'zoomchange': {
146    evt: CreateEvent('webViewInternal.onZoomChange'),
147    fields: ['oldZoomFactor', 'newZoomFactor']
148  }
149};
150
151// Constructor.
152function WebViewEvents(webViewInternal, viewInstanceId) {
153  this.webViewInternal = webViewInternal;
154  this.viewInstanceId = viewInstanceId;
155  this.setup();
156}
157
158// Sets up events.
159WebViewEvents.prototype.setup = function() {
160  this.setupFrameNameChangedEvent();
161  this.setupPluginDestroyedEvent();
162  this.webViewInternal.maybeSetupChromeWebViewEvents();
163  this.webViewInternal.setupExperimentalContextMenus();
164
165  var events = this.getEvents();
166  for (var eventName in events) {
167    this.setupEvent(eventName, events[eventName]);
168  }
169};
170
171WebViewEvents.prototype.setupFrameNameChangedEvent = function() {
172  FrameNameChangedEvent.addListener(function(e) {
173    this.webViewInternal.onFrameNameChanged(e.name);
174  }.bind(this), {instanceId: this.viewInstanceId});
175};
176
177WebViewEvents.prototype.setupPluginDestroyedEvent = function() {
178  PluginDestroyedEvent.addListener(function(e) {
179    this.webViewInternal.onPluginDestroyed();
180  }.bind(this), {instanceId: this.viewInstanceId});
181};
182
183WebViewEvents.prototype.getEvents = function() {
184  var experimentalEvents = this.webViewInternal.maybeGetExperimentalEvents();
185  for (var eventName in experimentalEvents) {
186    WEB_VIEW_EVENTS[eventName] = experimentalEvents[eventName];
187  }
188  var chromeEvents = this.webViewInternal.maybeGetChromeWebViewEvents();
189  for (var eventName in chromeEvents) {
190    WEB_VIEW_EVENTS[eventName] = chromeEvents[eventName];
191  }
192  return WEB_VIEW_EVENTS;
193};
194
195WebViewEvents.prototype.setupEvent = function(name, info) {
196  info.evt.addListener(function(e) {
197    var details = {bubbles:true};
198    if (info.cancelable) {
199      details.cancelable = true;
200    }
201    var webViewEvent = new Event(name, details);
202    $Array.forEach(info.fields, function(field) {
203      if (e[field] !== undefined) {
204        webViewEvent[field] = e[field];
205      }
206    }.bind(this));
207    if (info.customHandler) {
208      info.customHandler(this, e, webViewEvent);
209      return;
210    }
211    this.webViewInternal.dispatchEvent(webViewEvent);
212  }.bind(this), {instanceId: this.viewInstanceId});
213
214  this.webViewInternal.setupEventProperty(name);
215};
216
217
218WebViewEvents.prototype.handleDialogEvent = function(event, webViewEvent) {
219  var showWarningMessage = function(dialogType) {
220    var VOWELS = ['a', 'e', 'i', 'o', 'u'];
221    var WARNING_MSG_DIALOG_BLOCKED = '<webview>: %1 %2 dialog was blocked.';
222    var article = (VOWELS.indexOf(dialogType.charAt(0)) >= 0) ? 'An' : 'A';
223    var output = WARNING_MSG_DIALOG_BLOCKED.replace('%1', article);
224    output = output.replace('%2', dialogType);
225    window.console.warn(output);
226  };
227
228  var requestId = event.requestId;
229  var actionTaken = false;
230
231  var validateCall = function() {
232    var ERROR_MSG_DIALOG_ACTION_ALREADY_TAKEN = '<webview>: ' +
233        'An action has already been taken for this "dialog" event.';
234
235    if (actionTaken) {
236      throw new Error(ERROR_MSG_DIALOG_ACTION_ALREADY_TAKEN);
237    }
238    actionTaken = true;
239  };
240
241  var getGuestInstanceId = function() {
242    return this.webViewInternal.getGuestInstanceId();
243  }.bind(this);
244
245  var dialog = {
246    ok: function(user_input) {
247      validateCall();
248      user_input = user_input || '';
249      WebView.setPermission(getGuestInstanceId(), requestId, 'allow',
250                            user_input);
251    },
252    cancel: function() {
253      validateCall();
254      WebView.setPermission(getGuestInstanceId(), requestId, 'deny');
255    }
256  };
257  webViewEvent.dialog = dialog;
258
259  var defaultPrevented = !this.webViewInternal.dispatchEvent(webViewEvent);
260  if (actionTaken) {
261    return;
262  }
263
264  if (defaultPrevented) {
265    // Tell the JavaScript garbage collector to track lifetime of |dialog| and
266    // call back when the dialog object has been collected.
267    MessagingNatives.BindToGC(dialog, function() {
268      // Avoid showing a warning message if the decision has already been made.
269      if (actionTaken) {
270        return;
271      }
272      WebView.setPermission(
273          getGuestInstanceId(), requestId, 'default', '', function(allowed) {
274        if (allowed) {
275          return;
276        }
277        showWarningMessage(event.messageType);
278      });
279    });
280  } else {
281    actionTaken = true;
282    // The default action is equivalent to canceling the dialog.
283    WebView.setPermission(
284        getGuestInstanceId(), requestId, 'default', '', function(allowed) {
285      if (allowed) {
286        return;
287      }
288      showWarningMessage(event.messageType);
289    });
290  }
291};
292
293WebViewEvents.prototype.handleLoadAbortEvent = function(event, webViewEvent) {
294  var showWarningMessage = function(reason) {
295    var WARNING_MSG_LOAD_ABORTED = '<webview>: ' +
296        'The load has aborted with reason "%1".';
297    window.console.warn(WARNING_MSG_LOAD_ABORTED.replace('%1', reason));
298  };
299  if (this.webViewInternal.dispatchEvent(webViewEvent)) {
300    showWarningMessage(event.reason);
301  }
302};
303
304WebViewEvents.prototype.handleLoadCommitEvent = function(event, webViewEvent) {
305  this.webViewInternal.onLoadCommit(event.baseUrlForDataUrl,
306                                    event.currentEntryIndex, event.entryCount,
307                                    event.processId, event.url,
308                                    event.isTopLevel);
309  this.webViewInternal.dispatchEvent(webViewEvent);
310};
311
312WebViewEvents.prototype.handleNewWindowEvent = function(event, webViewEvent) {
313  var ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN = '<webview>: ' +
314      'An action has already been taken for this "newwindow" event.';
315
316  var ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH = '<webview>: ' +
317      'Unable to attach the new window to the provided webViewInternal.';
318
319  var ERROR_MSG_WEBVIEW_EXPECTED = '<webview> element expected.';
320
321  var showWarningMessage = function() {
322    var WARNING_MSG_NEWWINDOW_BLOCKED = '<webview>: A new window was blocked.';
323    window.console.warn(WARNING_MSG_NEWWINDOW_BLOCKED);
324  };
325
326  var requestId = event.requestId;
327  var actionTaken = false;
328  var getGuestInstanceId = function() {
329    return this.webViewInternal.getGuestInstanceId();
330  }.bind(this);
331
332  var validateCall = function () {
333    if (actionTaken) {
334      throw new Error(ERROR_MSG_NEWWINDOW_ACTION_ALREADY_TAKEN);
335    }
336    actionTaken = true;
337  };
338
339  var windowObj = {
340    attach: function(webview) {
341      validateCall();
342      if (!webview || !webview.tagName || webview.tagName != 'WEBVIEW')
343        throw new Error(ERROR_MSG_WEBVIEW_EXPECTED);
344      // Attach happens asynchronously to give the tagWatcher an opportunity
345      // to pick up the new webview before attach operates on it, if it hasn't
346      // been attached to the DOM already.
347      // Note: Any subsequent errors cannot be exceptions because they happen
348      // asynchronously.
349      setTimeout(function() {
350        var webViewInternal = privates(webview).internal;
351        // Update the partition.
352        if (event.storagePartitionId) {
353          webViewInternal.onAttach(event.storagePartitionId);
354        }
355
356        var attached = webViewInternal.attachWindow(event.windowId, true);
357
358        if (!attached) {
359          window.console.error(ERROR_MSG_NEWWINDOW_UNABLE_TO_ATTACH);
360        }
361
362        var guestInstanceId = getGuestInstanceId();
363        if (!guestInstanceId) {
364          // If the opener is already gone, then we won't have its
365          // guestInstanceId.
366          return;
367        }
368
369        // If the object being passed into attach is not a valid <webview>
370        // then we will fail and it will be treated as if the new window
371        // was rejected. The permission API plumbing is used here to clean
372        // up the state created for the new window if attaching fails.
373        WebView.setPermission(
374            guestInstanceId, requestId, attached ? 'allow' : 'deny');
375      }, 0);
376    },
377    discard: function() {
378      validateCall();
379      var guestInstanceId = getGuestInstanceId();
380      if (!guestInstanceId) {
381        // If the opener is already gone, then we won't have its
382        // guestInstanceId.
383        return;
384      }
385      WebView.setPermission(guestInstanceId, requestId, 'deny');
386    }
387  };
388  webViewEvent.window = windowObj;
389
390  var defaultPrevented = !this.webViewInternal.dispatchEvent(webViewEvent);
391  if (actionTaken) {
392    return;
393  }
394
395  if (defaultPrevented) {
396    // Make browser plugin track lifetime of |windowObj|.
397    MessagingNatives.BindToGC(windowObj, function() {
398      // Avoid showing a warning message if the decision has already been made.
399      if (actionTaken) {
400        return;
401      }
402
403      var guestInstanceId = getGuestInstanceId();
404      if (!guestInstanceId) {
405        // If the opener is already gone, then we won't have its
406        // guestInstanceId.
407        return;
408      }
409
410      WebView.setPermission(
411          guestInstanceId, requestId, 'default', '', function(allowed) {
412            if (allowed) {
413              return;
414            }
415            showWarningMessage();
416          });
417    });
418  } else {
419    actionTaken = true;
420    // The default action is to discard the window.
421    WebView.setPermission(
422        getGuestInstanceId(), requestId, 'default', '', function(allowed) {
423      if (allowed) {
424        return;
425      }
426      showWarningMessage();
427    });
428  }
429};
430
431WebViewEvents.prototype.getPermissionTypes = function() {
432  var permissions =
433      ['media',
434      'geolocation',
435      'pointerLock',
436      'download',
437      'loadplugin',
438      'filesystem'];
439  return permissions.concat(
440      this.webViewInternal.maybeGetExperimentalPermissions());
441};
442
443WebViewEvents.prototype.handlePermissionEvent =
444    function(event, webViewEvent) {
445  var ERROR_MSG_PERMISSION_ALREADY_DECIDED = '<webview>: ' +
446      'Permission has already been decided for this "permissionrequest" event.';
447
448  var showWarningMessage = function(permission) {
449    var WARNING_MSG_PERMISSION_DENIED = '<webview>: ' +
450        'The permission request for "%1" has been denied.';
451    window.console.warn(
452        WARNING_MSG_PERMISSION_DENIED.replace('%1', permission));
453  };
454
455  var requestId = event.requestId;
456  var getGuestInstanceId = function() {
457    return this.webViewInternal.getGuestInstanceId();
458  }.bind(this);
459
460  if (this.getPermissionTypes().indexOf(event.permission) < 0) {
461    // The permission type is not allowed. Trigger the default response.
462    WebView.setPermission(
463        getGuestInstanceId(), requestId, 'default', '', function(allowed) {
464      if (allowed) {
465        return;
466      }
467      showWarningMessage(event.permission);
468    });
469    return;
470  }
471
472  var decisionMade = false;
473  var validateCall = function() {
474    if (decisionMade) {
475      throw new Error(ERROR_MSG_PERMISSION_ALREADY_DECIDED);
476    }
477    decisionMade = true;
478  };
479
480  // Construct the event.request object.
481  var request = {
482    allow: function() {
483      validateCall();
484      WebView.setPermission(getGuestInstanceId(), requestId, 'allow');
485    },
486    deny: function() {
487      validateCall();
488      WebView.setPermission(getGuestInstanceId(), requestId, 'deny');
489    }
490  };
491  webViewEvent.request = request;
492
493  var defaultPrevented = !this.webViewInternal.dispatchEvent(webViewEvent);
494  if (decisionMade) {
495    return;
496  }
497
498  if (defaultPrevented) {
499    // Make browser plugin track lifetime of |request|.
500    MessagingNatives.BindToGC(request, function() {
501      // Avoid showing a warning message if the decision has already been made.
502      if (decisionMade) {
503        return;
504      }
505      WebView.setPermission(
506          getGuestInstanceId(), requestId, 'default', '', function(allowed) {
507        if (allowed) {
508          return;
509        }
510        showWarningMessage(event.permission);
511      });
512    });
513  } else {
514    decisionMade = true;
515    WebView.setPermission(
516        getGuestInstanceId(), requestId, 'default', '',
517        function(allowed) {
518          if (allowed) {
519            return;
520          }
521          showWarningMessage(event.permission);
522        });
523  }
524};
525
526WebViewEvents.prototype.handleSizeChangedEvent = function(
527    event, webViewEvent) {
528  this.webViewInternal.onSizeChanged(webViewEvent);
529};
530
531exports.WebViewEvents = WebViewEvents;
532exports.CreateEvent = CreateEvent;
533