• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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  var eventNatives = requireNative('event_natives');
6  var handleUncaughtException = require('uncaught_exception_handler').handle;
7  var logging = requireNative('logging');
8  var schemaRegistry = requireNative('schema_registry');
9  var sendRequest = require('sendRequest').sendRequest;
10  var utils = require('utils');
11  var validate = require('schemaUtils').validate;
12  var unloadEvent = require('unload_event');
13
14  // Schemas for the rule-style functions on the events API that
15  // only need to be generated occasionally, so populate them lazily.
16  var ruleFunctionSchemas = {
17    // These values are set lazily:
18    // addRules: {},
19    // getRules: {},
20    // removeRules: {}
21  };
22
23  // This function ensures that |ruleFunctionSchemas| is populated.
24  function ensureRuleSchemasLoaded() {
25    if (ruleFunctionSchemas.addRules)
26      return;
27    var eventsSchema = schemaRegistry.GetSchema("events");
28    var eventType = utils.lookup(eventsSchema.types, 'id', 'events.Event');
29
30    ruleFunctionSchemas.addRules =
31        utils.lookup(eventType.functions, 'name', 'addRules');
32    ruleFunctionSchemas.getRules =
33        utils.lookup(eventType.functions, 'name', 'getRules');
34    ruleFunctionSchemas.removeRules =
35        utils.lookup(eventType.functions, 'name', 'removeRules');
36  }
37
38  // A map of event names to the event object that is registered to that name.
39  var attachedNamedEvents = {};
40
41  // An array of all attached event objects, used for detaching on unload.
42  var allAttachedEvents = [];
43
44  // A map of functions that massage event arguments before they are dispatched.
45  // Key is event name, value is function.
46  var eventArgumentMassagers = {};
47
48  // An attachment strategy for events that aren't attached to the browser.
49  // This applies to events with the "unmanaged" option and events without
50  // names.
51  var NullAttachmentStrategy = function(event) {
52    this.event_ = event;
53  };
54  NullAttachmentStrategy.prototype.onAddedListener =
55      function(listener) {
56  };
57  NullAttachmentStrategy.prototype.onRemovedListener =
58      function(listener) {
59  };
60  NullAttachmentStrategy.prototype.detach = function(manual) {
61  };
62  NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
63    // |ids| is for filtered events only.
64    return this.event_.listeners;
65  };
66
67  // Handles adding/removing/dispatching listeners for unfiltered events.
68  var UnfilteredAttachmentStrategy = function(event) {
69    this.event_ = event;
70  };
71
72  UnfilteredAttachmentStrategy.prototype.onAddedListener =
73      function(listener) {
74    // Only attach / detach on the first / last listener removed.
75    if (this.event_.listeners.length == 0)
76      eventNatives.AttachEvent(this.event_.eventName);
77  };
78
79  UnfilteredAttachmentStrategy.prototype.onRemovedListener =
80      function(listener) {
81    if (this.event_.listeners.length == 0)
82      this.detach(true);
83  };
84
85  UnfilteredAttachmentStrategy.prototype.detach = function(manual) {
86    eventNatives.DetachEvent(this.event_.eventName, manual);
87  };
88
89  UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
90    // |ids| is for filtered events only.
91    return this.event_.listeners;
92  };
93
94  var FilteredAttachmentStrategy = function(event) {
95    this.event_ = event;
96    this.listenerMap_ = {};
97  };
98
99  FilteredAttachmentStrategy.idToEventMap = {};
100
101  FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) {
102    var id = eventNatives.AttachFilteredEvent(this.event_.eventName,
103                                              listener.filters || {});
104    if (id == -1)
105      throw new Error("Can't add listener");
106    listener.id = id;
107    this.listenerMap_[id] = listener;
108    FilteredAttachmentStrategy.idToEventMap[id] = this.event_;
109  };
110
111  FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) {
112    this.detachListener(listener, true);
113  };
114
115  FilteredAttachmentStrategy.prototype.detachListener =
116      function(listener, manual) {
117    if (listener.id == undefined)
118      throw new Error("listener.id undefined - '" + listener + "'");
119    var id = listener.id;
120    delete this.listenerMap_[id];
121    delete FilteredAttachmentStrategy.idToEventMap[id];
122    eventNatives.DetachFilteredEvent(id, manual);
123  };
124
125  FilteredAttachmentStrategy.prototype.detach = function(manual) {
126    for (var i in this.listenerMap_)
127      this.detachListener(this.listenerMap_[i], manual);
128  };
129
130  FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
131    var result = [];
132    for (var i = 0; i < ids.length; i++)
133      $Array.push(result, this.listenerMap_[ids[i]]);
134    return result;
135  };
136
137  function parseEventOptions(opt_eventOptions) {
138    function merge(dest, src) {
139      for (var k in src) {
140        if (!$Object.hasOwnProperty(dest, k)) {
141          dest[k] = src[k];
142        }
143      }
144    }
145
146    var options = opt_eventOptions || {};
147    merge(options, {
148      // Event supports adding listeners with filters ("filtered events"), for
149      // example as used in the webNavigation API.
150      //
151      // event.addListener(listener, [filter1, filter2]);
152      supportsFilters: false,
153
154      // Events supports vanilla events. Most APIs use these.
155      //
156      // event.addListener(listener);
157      supportsListeners: true,
158
159      // Event supports adding rules ("declarative events") rather than
160      // listeners, for example as used in the declarativeWebRequest API.
161      //
162      // event.addRules([rule1, rule2]);
163      supportsRules: false,
164
165      // Event is unmanaged in that the browser has no knowledge of its
166      // existence; it's never invoked, doesn't keep the renderer alive, and
167      // the bindings system has no knowledge of it.
168      //
169      // Both events created by user code (new chrome.Event()) and messaging
170      // events are unmanaged, though in the latter case the browser *does*
171      // interact indirectly with them via IPCs written by hand.
172      unmanaged: false,
173    });
174    return options;
175  };
176
177  // Event object.  If opt_eventName is provided, this object represents
178  // the unique instance of that named event, and dispatching an event
179  // with that name will route through this object's listeners. Note that
180  // opt_eventName is required for events that support rules.
181  //
182  // Example:
183  //   var Event = require('event_bindings').Event;
184  //   chrome.tabs.onChanged = new Event("tab-changed");
185  //   chrome.tabs.onChanged.addListener(function(data) { alert(data); });
186  //   Event.dispatch("tab-changed", "hi");
187  // will result in an alert dialog that says 'hi'.
188  //
189  // If opt_eventOptions exists, it is a dictionary that contains the boolean
190  // entries "supportsListeners" and "supportsRules".
191  // If opt_webViewInstanceId exists, it is an integer uniquely identifying a
192  // <webview> tag within the embedder. If it does not exist, then this is an
193  // extension event rather than a <webview> event.
194  var EventImpl = function(opt_eventName, opt_argSchemas, opt_eventOptions,
195                           opt_webViewInstanceId) {
196    this.eventName = opt_eventName;
197    this.argSchemas = opt_argSchemas;
198    this.listeners = [];
199    this.eventOptions = parseEventOptions(opt_eventOptions);
200    this.webViewInstanceId = opt_webViewInstanceId || 0;
201
202    if (!this.eventName) {
203      if (this.eventOptions.supportsRules)
204        throw new Error("Events that support rules require an event name.");
205      // Events without names cannot be managed by the browser by definition
206      // (the browser has no way of identifying them).
207      this.eventOptions.unmanaged = true;
208    }
209
210    // Track whether the event has been destroyed to help track down the cause
211    // of http://crbug.com/258526.
212    // This variable will eventually hold the stack trace of the destroy call.
213    // TODO(kalman): Delete this and replace with more sound logic that catches
214    // when events are used without being *attached*.
215    this.destroyed = null;
216
217    if (this.eventOptions.unmanaged)
218      this.attachmentStrategy = new NullAttachmentStrategy(this);
219    else if (this.eventOptions.supportsFilters)
220      this.attachmentStrategy = new FilteredAttachmentStrategy(this);
221    else
222      this.attachmentStrategy = new UnfilteredAttachmentStrategy(this);
223  };
224
225  // callback is a function(args, dispatch). args are the args we receive from
226  // dispatchEvent(), and dispatch is a function(args) that dispatches args to
227  // its listeners.
228  function registerArgumentMassager(name, callback) {
229    if (eventArgumentMassagers[name])
230      throw new Error("Massager already registered for event: " + name);
231    eventArgumentMassagers[name] = callback;
232  }
233
234  // Dispatches a named event with the given argument array. The args array is
235  // the list of arguments that will be sent to the event callback.
236  function dispatchEvent(name, args, filteringInfo) {
237    var listenerIDs = [];
238
239    if (filteringInfo)
240      listenerIDs = eventNatives.MatchAgainstEventFilter(name, filteringInfo);
241
242    var event = attachedNamedEvents[name];
243    if (!event)
244      return;
245
246    var dispatchArgs = function(args) {
247      var result = event.dispatch_(args, listenerIDs);
248      if (result)
249        logging.DCHECK(!result.validationErrors, result.validationErrors);
250      return result;
251    };
252
253    if (eventArgumentMassagers[name])
254      eventArgumentMassagers[name](args, dispatchArgs);
255    else
256      dispatchArgs(args);
257  }
258
259  // Registers a callback to be called when this event is dispatched.
260  EventImpl.prototype.addListener = function(cb, filters) {
261    if (!this.eventOptions.supportsListeners)
262      throw new Error("This event does not support listeners.");
263    if (this.eventOptions.maxListeners &&
264        this.getListenerCount_() >= this.eventOptions.maxListeners) {
265      throw new Error("Too many listeners for " + this.eventName);
266    }
267    if (filters) {
268      if (!this.eventOptions.supportsFilters)
269        throw new Error("This event does not support filters.");
270      if (filters.url && !(filters.url instanceof Array))
271        throw new Error("filters.url should be an array.");
272      if (filters.serviceType &&
273          !(typeof filters.serviceType === 'string')) {
274        throw new Error("filters.serviceType should be a string.")
275      }
276    }
277    var listener = {callback: cb, filters: filters};
278    this.attach_(listener);
279    $Array.push(this.listeners, listener);
280  };
281
282  EventImpl.prototype.attach_ = function(listener) {
283    this.attachmentStrategy.onAddedListener(listener);
284
285    if (this.listeners.length == 0) {
286      allAttachedEvents[allAttachedEvents.length] = this;
287      if (this.eventName) {
288        if (attachedNamedEvents[this.eventName]) {
289          throw new Error("Event '" + this.eventName +
290                          "' is already attached.");
291        }
292        attachedNamedEvents[this.eventName] = this;
293      }
294    }
295  };
296
297  // Unregisters a callback.
298  EventImpl.prototype.removeListener = function(cb) {
299    if (!this.eventOptions.supportsListeners)
300      throw new Error("This event does not support listeners.");
301
302    var idx = this.findListener_(cb);
303    if (idx == -1)
304      return;
305
306    var removedListener = $Array.splice(this.listeners, idx, 1)[0];
307    this.attachmentStrategy.onRemovedListener(removedListener);
308
309    if (this.listeners.length == 0) {
310      var i = $Array.indexOf(allAttachedEvents, this);
311      if (i >= 0)
312        delete allAttachedEvents[i];
313      if (this.eventName) {
314        if (!attachedNamedEvents[this.eventName]) {
315          throw new Error(
316              "Event '" + this.eventName + "' is not attached.");
317        }
318        delete attachedNamedEvents[this.eventName];
319      }
320    }
321  };
322
323  // Test if the given callback is registered for this event.
324  EventImpl.prototype.hasListener = function(cb) {
325    if (!this.eventOptions.supportsListeners)
326      throw new Error("This event does not support listeners.");
327    return this.findListener_(cb) > -1;
328  };
329
330  // Test if any callbacks are registered for this event.
331  EventImpl.prototype.hasListeners = function() {
332    return this.getListenerCount_() > 0;
333  };
334
335  // Returns the number of listeners on this event.
336  EventImpl.prototype.getListenerCount_ = function() {
337    if (!this.eventOptions.supportsListeners)
338      throw new Error("This event does not support listeners.");
339    return this.listeners.length;
340  };
341
342  // Returns the index of the given callback if registered, or -1 if not
343  // found.
344  EventImpl.prototype.findListener_ = function(cb) {
345    for (var i = 0; i < this.listeners.length; i++) {
346      if (this.listeners[i].callback == cb) {
347        return i;
348      }
349    }
350
351    return -1;
352  };
353
354  EventImpl.prototype.dispatch_ = function(args, listenerIDs) {
355    if (this.destroyed) {
356      throw new Error(this.eventName + ' was already destroyed at: ' +
357                      this.destroyed);
358    }
359    if (!this.eventOptions.supportsListeners)
360      throw new Error("This event does not support listeners.");
361
362    if (this.argSchemas && logging.DCHECK_IS_ON()) {
363      try {
364        validate(args, this.argSchemas);
365      } catch (e) {
366        e.message += ' in ' + this.eventName;
367        throw e;
368      }
369    }
370
371    // Make a copy of the listeners in case the listener list is modified
372    // while dispatching the event.
373    var listeners = $Array.slice(
374        this.attachmentStrategy.getListenersByIDs(listenerIDs));
375
376    var results = [];
377    for (var i = 0; i < listeners.length; i++) {
378      try {
379        var result = this.wrapper.dispatchToListener(listeners[i].callback,
380                                                     args);
381        if (result !== undefined)
382          $Array.push(results, result);
383      } catch (e) {
384        handleUncaughtException(
385          'Error in event handler for ' +
386              (this.eventName ? this.eventName : '(unknown)') +
387              ': ' + e.message + '\nStack trace: ' + e.stack,
388          e);
389      }
390    }
391    if (results.length)
392      return {results: results};
393  }
394
395  // Can be overridden to support custom dispatching.
396  EventImpl.prototype.dispatchToListener = function(callback, args) {
397    return $Function.apply(callback, null, args);
398  }
399
400  // Dispatches this event object to all listeners, passing all supplied
401  // arguments to this function each listener.
402  EventImpl.prototype.dispatch = function(varargs) {
403    return this.dispatch_($Array.slice(arguments), undefined);
404  };
405
406  // Detaches this event object from its name.
407  EventImpl.prototype.detach_ = function() {
408    this.attachmentStrategy.detach(false);
409  };
410
411  EventImpl.prototype.destroy_ = function() {
412    this.listeners.length = 0;
413    this.detach_();
414    this.destroyed = new Error().stack;
415  };
416
417  EventImpl.prototype.addRules = function(rules, opt_cb) {
418    if (!this.eventOptions.supportsRules)
419      throw new Error("This event does not support rules.");
420
421    // Takes a list of JSON datatype identifiers and returns a schema fragment
422    // that verifies that a JSON object corresponds to an array of only these
423    // data types.
424    function buildArrayOfChoicesSchema(typesList) {
425      return {
426        'type': 'array',
427        'items': {
428          'choices': typesList.map(function(el) {return {'$ref': el};})
429        }
430      };
431    };
432
433    // Validate conditions and actions against specific schemas of this
434    // event object type.
435    // |rules| is an array of JSON objects that follow the Rule type of the
436    // declarative extension APIs. |conditions| is an array of JSON type
437    // identifiers that are allowed to occur in the conditions attribute of each
438    // rule. Likewise, |actions| is an array of JSON type identifiers that are
439    // allowed to occur in the actions attribute of each rule.
440    function validateRules(rules, conditions, actions) {
441      var conditionsSchema = buildArrayOfChoicesSchema(conditions);
442      var actionsSchema = buildArrayOfChoicesSchema(actions);
443      $Array.forEach(rules, function(rule) {
444        validate([rule.conditions], [conditionsSchema]);
445        validate([rule.actions], [actionsSchema]);
446      });
447    };
448
449    if (!this.eventOptions.conditions || !this.eventOptions.actions) {
450      throw new Error('Event ' + this.eventName + ' misses ' +
451                      'conditions or actions in the API specification.');
452    }
453
454    validateRules(rules,
455                  this.eventOptions.conditions,
456                  this.eventOptions.actions);
457
458    ensureRuleSchemasLoaded();
459    // We remove the first parameter from the validation to give the user more
460    // meaningful error messages.
461    validate([this.webViewInstanceId, rules, opt_cb],
462             $Array.splice(
463                 $Array.slice(ruleFunctionSchemas.addRules.parameters), 1));
464    sendRequest(
465      "events.addRules",
466      [this.eventName, this.webViewInstanceId, rules,  opt_cb],
467      ruleFunctionSchemas.addRules.parameters);
468  }
469
470  EventImpl.prototype.removeRules = function(ruleIdentifiers, opt_cb) {
471    if (!this.eventOptions.supportsRules)
472      throw new Error("This event does not support rules.");
473    ensureRuleSchemasLoaded();
474    // We remove the first parameter from the validation to give the user more
475    // meaningful error messages.
476    validate([this.webViewInstanceId, ruleIdentifiers, opt_cb],
477             $Array.splice(
478                 $Array.slice(ruleFunctionSchemas.removeRules.parameters), 1));
479    sendRequest("events.removeRules",
480                [this.eventName,
481                 this.webViewInstanceId,
482                 ruleIdentifiers,
483                 opt_cb],
484                ruleFunctionSchemas.removeRules.parameters);
485  }
486
487  EventImpl.prototype.getRules = function(ruleIdentifiers, cb) {
488    if (!this.eventOptions.supportsRules)
489      throw new Error("This event does not support rules.");
490    ensureRuleSchemasLoaded();
491    // We remove the first parameter from the validation to give the user more
492    // meaningful error messages.
493    validate([this.webViewInstanceId, ruleIdentifiers, cb],
494             $Array.splice(
495                 $Array.slice(ruleFunctionSchemas.getRules.parameters), 1));
496
497    sendRequest(
498      "events.getRules",
499      [this.eventName, this.webViewInstanceId, ruleIdentifiers, cb],
500      ruleFunctionSchemas.getRules.parameters);
501  }
502
503  unloadEvent.addListener(function() {
504    for (var i = 0; i < allAttachedEvents.length; ++i) {
505      var event = allAttachedEvents[i];
506      if (event)
507        event.detach_();
508    }
509  });
510
511  var Event = utils.expose('Event', EventImpl, { functions: [
512    'addListener',
513    'removeListener',
514    'hasListener',
515    'hasListeners',
516    'dispatchToListener',
517    'dispatch',
518    'addRules',
519    'removeRules',
520    'getRules'
521  ] });
522
523  // NOTE: Event is (lazily) exposed as chrome.Event from dispatcher.cc.
524  exports.Event = Event;
525
526  exports.dispatchEvent = dispatchEvent;
527  exports.parseEventOptions = parseEventOptions;
528  exports.registerArgumentMassager = registerArgumentMassager;
529