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