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.
5 var exceptionHandler = require('uncaught_exception_handler');
6 var eventNatives = requireNative('event_natives');
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;
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:
22 // This function ensures that |ruleFunctionSchemas| is populated.
23 function ensureRuleSchemasLoaded() {
24 if (ruleFunctionSchemas.addRules)
26 var eventsSchema = schemaRegistry.GetSchema("events");
27 var eventType = utils.lookup(eventsSchema.types, 'id', 'events.Event');
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');
37 // A map of event names to the event object that is registered to that name.
38 var attachedNamedEvents = {};
40 // A map of functions that massage event arguments before they are dispatched.
41 // Key is event name, value is function.
42 var eventArgumentMassagers = {};
44 // An attachment strategy for events that aren't attached to the browser.
45 // This applies to events with the "unmanaged" option and events without
47 var NullAttachmentStrategy = function(event) {
50 NullAttachmentStrategy.prototype.onAddedListener =
53 NullAttachmentStrategy.prototype.onRemovedListener =
56 NullAttachmentStrategy.prototype.detach = function(manual) {
58 NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
59 // |ids| is for filtered events only.
60 return this.event_.listeners;
63 // Handles adding/removing/dispatching listeners for unfiltered events.
64 var UnfilteredAttachmentStrategy = function(event) {
68 UnfilteredAttachmentStrategy.prototype.onAddedListener =
70 // Only attach / detach on the first / last listener removed.
71 if (this.event_.listeners.length == 0)
72 eventNatives.AttachEvent(this.event_.eventName);
75 UnfilteredAttachmentStrategy.prototype.onRemovedListener =
77 if (this.event_.listeners.length == 0)
81 UnfilteredAttachmentStrategy.prototype.detach = function(manual) {
82 eventNatives.DetachEvent(this.event_.eventName, manual);
85 UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
86 // |ids| is for filtered events only.
87 return this.event_.listeners;
90 var FilteredAttachmentStrategy = function(event) {
92 this.listenerMap_ = {};
95 FilteredAttachmentStrategy.idToEventMap = {};
97 FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) {
98 var id = eventNatives.AttachFilteredEvent(this.event_.eventName,
99 listener.filters || {});
101 throw new Error("Can't add listener");
103 this.listenerMap_[id] = listener;
104 FilteredAttachmentStrategy.idToEventMap[id] = this.event_;
107 FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) {
108 this.detachListener(listener, true);
111 FilteredAttachmentStrategy.prototype.detachListener =
112 function(listener, manual) {
113 if (listener.id == undefined)
114 throw new Error("listener.id undefined - '" + listener + "'");
115 var id = listener.id;
116 delete this.listenerMap_[id];
117 delete FilteredAttachmentStrategy.idToEventMap[id];
118 eventNatives.DetachFilteredEvent(id, manual);
121 FilteredAttachmentStrategy.prototype.detach = function(manual) {
122 for (var i in this.listenerMap_)
123 this.detachListener(this.listenerMap_[i], manual);
126 FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
128 for (var i = 0; i < ids.length; i++)
129 $Array.push(result, this.listenerMap_[ids[i]]);
133 function parseEventOptions(opt_eventOptions) {
134 function merge(dest, src) {
136 if (!$Object.hasOwnProperty(dest, k)) {
142 var options = opt_eventOptions || {};
144 // Event supports adding listeners with filters ("filtered events"), for
145 // example as used in the webNavigation API.
147 // event.addListener(listener, [filter1, filter2]);
148 supportsFilters: false,
150 // Events supports vanilla events. Most APIs use these.
152 // event.addListener(listener);
153 supportsListeners: true,
155 // Event supports adding rules ("declarative events") rather than
156 // listeners, for example as used in the declarativeWebRequest API.
158 // event.addRules([rule1, rule2]);
159 supportsRules: false,
161 // Event is unmanaged in that the browser has no knowledge of its
162 // existence; it's never invoked, doesn't keep the renderer alive, and
163 // the bindings system has no knowledge of it.
165 // Both events created by user code (new chrome.Event()) and messaging
166 // events are unmanaged, though in the latter case the browser *does*
167 // interact indirectly with them via IPCs written by hand.
173 // Event object. If opt_eventName is provided, this object represents
174 // the unique instance of that named event, and dispatching an event
175 // with that name will route through this object's listeners. Note that
176 // opt_eventName is required for events that support rules.
179 // var Event = require('event_bindings').Event;
180 // chrome.tabs.onChanged = new Event("tab-changed");
181 // chrome.tabs.onChanged.addListener(function(data) { alert(data); });
182 // Event.dispatch("tab-changed", "hi");
183 // will result in an alert dialog that says 'hi'.
185 // If opt_eventOptions exists, it is a dictionary that contains the boolean
186 // entries "supportsListeners" and "supportsRules".
187 // If opt_webViewInstanceId exists, it is an integer uniquely identifying a
188 // <webview> tag within the embedder. If it does not exist, then this is an
189 // extension event rather than a <webview> event.
190 var EventImpl = function(opt_eventName, opt_argSchemas, opt_eventOptions,
191 opt_webViewInstanceId) {
192 this.eventName = opt_eventName;
193 this.argSchemas = opt_argSchemas;
195 this.eventOptions = parseEventOptions(opt_eventOptions);
196 this.webViewInstanceId = opt_webViewInstanceId || 0;
198 if (!this.eventName) {
199 if (this.eventOptions.supportsRules)
200 throw new Error("Events that support rules require an event name.");
201 // Events without names cannot be managed by the browser by definition
202 // (the browser has no way of identifying them).
203 this.eventOptions.unmanaged = true;
206 // Track whether the event has been destroyed to help track down the cause
207 // of http://crbug.com/258526.
208 // This variable will eventually hold the stack trace of the destroy call.
209 // TODO(kalman): Delete this and replace with more sound logic that catches
210 // when events are used without being *attached*.
211 this.destroyed = null;
213 if (this.eventOptions.unmanaged)
214 this.attachmentStrategy = new NullAttachmentStrategy(this);
215 else if (this.eventOptions.supportsFilters)
216 this.attachmentStrategy = new FilteredAttachmentStrategy(this);
218 this.attachmentStrategy = new UnfilteredAttachmentStrategy(this);
221 // callback is a function(args, dispatch). args are the args we receive from
222 // dispatchEvent(), and dispatch is a function(args) that dispatches args to
224 function registerArgumentMassager(name, callback) {
225 if (eventArgumentMassagers[name])
226 throw new Error("Massager already registered for event: " + name);
227 eventArgumentMassagers[name] = callback;
230 // Dispatches a named event with the given argument array. The args array is
231 // the list of arguments that will be sent to the event callback.
232 function dispatchEvent(name, args, filteringInfo) {
233 var listenerIDs = [];
236 listenerIDs = eventNatives.MatchAgainstEventFilter(name, filteringInfo);
238 var event = attachedNamedEvents[name];
242 var dispatchArgs = function(args) {
243 var result = event.dispatch_(args, listenerIDs);
245 logging.DCHECK(!result.validationErrors, result.validationErrors);
249 if (eventArgumentMassagers[name])
250 eventArgumentMassagers[name](args, dispatchArgs);
255 // Registers a callback to be called when this event is dispatched.
256 EventImpl.prototype.addListener = function(cb, filters) {
257 if (!this.eventOptions.supportsListeners)
258 throw new Error("This event does not support listeners.");
259 if (this.eventOptions.maxListeners &&
260 this.getListenerCount_() >= this.eventOptions.maxListeners) {
261 throw new Error("Too many listeners for " + this.eventName);
264 if (!this.eventOptions.supportsFilters)
265 throw new Error("This event does not support filters.");
266 if (filters.url && !(filters.url instanceof Array))
267 throw new Error("filters.url should be an array.");
268 if (filters.serviceType &&
269 !(typeof filters.serviceType === 'string')) {
270 throw new Error("filters.serviceType should be a string.")
273 var listener = {callback: cb, filters: filters};
274 this.attach_(listener);
275 $Array.push(this.listeners, listener);
278 EventImpl.prototype.attach_ = function(listener) {
279 this.attachmentStrategy.onAddedListener(listener);
281 if (this.listeners.length == 0) {
282 if (this.eventName) {
283 if (attachedNamedEvents[this.eventName]) {
284 throw new Error("Event '" + this.eventName +
285 "' is already attached.");
287 attachedNamedEvents[this.eventName] = this;
292 // Unregisters a callback.
293 EventImpl.prototype.removeListener = function(cb) {
294 if (!this.eventOptions.supportsListeners)
295 throw new Error("This event does not support listeners.");
297 var idx = this.findListener_(cb);
301 var removedListener = $Array.splice(this.listeners, idx, 1)[0];
302 this.attachmentStrategy.onRemovedListener(removedListener);
304 if (this.listeners.length == 0) {
305 if (this.eventName) {
306 if (!attachedNamedEvents[this.eventName]) {
308 "Event '" + this.eventName + "' is not attached.");
310 delete attachedNamedEvents[this.eventName];
315 // Test if the given callback is registered for this event.
316 EventImpl.prototype.hasListener = function(cb) {
317 if (!this.eventOptions.supportsListeners)
318 throw new Error("This event does not support listeners.");
319 return this.findListener_(cb) > -1;
322 // Test if any callbacks are registered for this event.
323 EventImpl.prototype.hasListeners = function() {
324 return this.getListenerCount_() > 0;
327 // Returns the number of listeners on this event.
328 EventImpl.prototype.getListenerCount_ = function() {
329 if (!this.eventOptions.supportsListeners)
330 throw new Error("This event does not support listeners.");
331 return this.listeners.length;
334 // Returns the index of the given callback if registered, or -1 if not
336 EventImpl.prototype.findListener_ = function(cb) {
337 for (var i = 0; i < this.listeners.length; i++) {
338 if (this.listeners[i].callback == cb) {
346 EventImpl.prototype.dispatch_ = function(args, listenerIDs) {
347 if (this.destroyed) {
348 throw new Error(this.eventName + ' was already destroyed at: ' +
351 if (!this.eventOptions.supportsListeners)
352 throw new Error("This event does not support listeners.");
354 if (this.argSchemas && logging.DCHECK_IS_ON()) {
356 validate(args, this.argSchemas);
358 e.message += ' in ' + this.eventName;
363 // Make a copy of the listeners in case the listener list is modified
364 // while dispatching the event.
365 var listeners = $Array.slice(
366 this.attachmentStrategy.getListenersByIDs(listenerIDs));
369 for (var i = 0; i < listeners.length; i++) {
371 var result = this.wrapper.dispatchToListener(listeners[i].callback,
373 if (result !== undefined)
374 $Array.push(results, result);
376 exceptionHandler.handle('Error in event handler for ' +
377 (this.eventName ? this.eventName : '(unknown)'),
382 return {results: results};
385 // Can be overridden to support custom dispatching.
386 EventImpl.prototype.dispatchToListener = function(callback, args) {
387 return $Function.apply(callback, null, args);
390 // Dispatches this event object to all listeners, passing all supplied
391 // arguments to this function each listener.
392 EventImpl.prototype.dispatch = function(varargs) {
393 return this.dispatch_($Array.slice(arguments), undefined);
396 // Detaches this event object from its name.
397 EventImpl.prototype.detach_ = function() {
398 this.attachmentStrategy.detach(false);
401 EventImpl.prototype.destroy_ = function() {
402 this.listeners.length = 0;
404 this.destroyed = exceptionHandler.getStackTrace();
407 EventImpl.prototype.addRules = function(rules, opt_cb) {
408 if (!this.eventOptions.supportsRules)
409 throw new Error("This event does not support rules.");
411 // Takes a list of JSON datatype identifiers and returns a schema fragment
412 // that verifies that a JSON object corresponds to an array of only these
414 function buildArrayOfChoicesSchema(typesList) {
418 'choices': typesList.map(function(el) {return {'$ref': el};})
423 // Validate conditions and actions against specific schemas of this
424 // event object type.
425 // |rules| is an array of JSON objects that follow the Rule type of the
426 // declarative extension APIs. |conditions| is an array of JSON type
427 // identifiers that are allowed to occur in the conditions attribute of each
428 // rule. Likewise, |actions| is an array of JSON type identifiers that are
429 // allowed to occur in the actions attribute of each rule.
430 function validateRules(rules, conditions, actions) {
431 var conditionsSchema = buildArrayOfChoicesSchema(conditions);
432 var actionsSchema = buildArrayOfChoicesSchema(actions);
433 $Array.forEach(rules, function(rule) {
434 validate([rule.conditions], [conditionsSchema]);
435 validate([rule.actions], [actionsSchema]);
439 if (!this.eventOptions.conditions || !this.eventOptions.actions) {
440 throw new Error('Event ' + this.eventName + ' misses ' +
441 'conditions or actions in the API specification.');
445 this.eventOptions.conditions,
446 this.eventOptions.actions);
448 ensureRuleSchemasLoaded();
449 // We remove the first parameter from the validation to give the user more
450 // meaningful error messages.
451 validate([this.webViewInstanceId, rules, opt_cb],
453 $Array.slice(ruleFunctionSchemas.addRules.parameters), 1));
456 [this.eventName, this.webViewInstanceId, rules, opt_cb],
457 ruleFunctionSchemas.addRules.parameters);
460 EventImpl.prototype.removeRules = function(ruleIdentifiers, opt_cb) {
461 if (!this.eventOptions.supportsRules)
462 throw new Error("This event does not support rules.");
463 ensureRuleSchemasLoaded();
464 // We remove the first parameter from the validation to give the user more
465 // meaningful error messages.
466 validate([this.webViewInstanceId, ruleIdentifiers, opt_cb],
468 $Array.slice(ruleFunctionSchemas.removeRules.parameters), 1));
469 sendRequest("events.removeRules",
471 this.webViewInstanceId,
474 ruleFunctionSchemas.removeRules.parameters);
477 EventImpl.prototype.getRules = function(ruleIdentifiers, cb) {
478 if (!this.eventOptions.supportsRules)
479 throw new Error("This event does not support rules.");
480 ensureRuleSchemasLoaded();
481 // We remove the first parameter from the validation to give the user more
482 // meaningful error messages.
483 validate([this.webViewInstanceId, ruleIdentifiers, cb],
485 $Array.slice(ruleFunctionSchemas.getRules.parameters), 1));
489 [this.eventName, this.webViewInstanceId, ruleIdentifiers, cb],
490 ruleFunctionSchemas.getRules.parameters);
493 var Event = utils.expose('Event', EventImpl, { functions: [
498 'dispatchToListener',
505 // NOTE: Event is (lazily) exposed as chrome.Event from dispatcher.cc.
506 exports.Event = Event;
508 exports.dispatchEvent = dispatchEvent;
509 exports.parseEventOptions = parseEventOptions;
510 exports.registerArgumentMassager = registerArgumentMassager;