Roll src/third_party/WebKit 9301d6f:4619053 (svn 201058:201059)
[chromium-blink-merge.git] / extensions / renderer / resources / event.js
blobbb71297fb01188cd6d808190ce4bb98511ece7e2
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:
17     // addRules: {},
18     // getRules: {},
19     // removeRules: {}
20   };
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');
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   }
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
46   // names.
47   var NullAttachmentStrategy = function(event) {
48     this.event_ = event;
49   };
50   NullAttachmentStrategy.prototype.onAddedListener =
51       function(listener) {
52   };
53   NullAttachmentStrategy.prototype.onRemovedListener =
54       function(listener) {
55   };
56   NullAttachmentStrategy.prototype.detach = function(manual) {
57   };
58   NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
59     // |ids| is for filtered events only.
60     return this.event_.listeners;
61   };
63   // Handles adding/removing/dispatching listeners for unfiltered events.
64   var UnfilteredAttachmentStrategy = function(event) {
65     this.event_ = event;
66   };
68   UnfilteredAttachmentStrategy.prototype.onAddedListener =
69       function(listener) {
70     // Only attach / detach on the first / last listener removed.
71     if (this.event_.listeners.length == 0)
72       eventNatives.AttachEvent(this.event_.eventName);
73   };
75   UnfilteredAttachmentStrategy.prototype.onRemovedListener =
76       function(listener) {
77     if (this.event_.listeners.length == 0)
78       this.detach(true);
79   };
81   UnfilteredAttachmentStrategy.prototype.detach = function(manual) {
82     eventNatives.DetachEvent(this.event_.eventName, manual);
83   };
85   UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
86     // |ids| is for filtered events only.
87     return this.event_.listeners;
88   };
90   var FilteredAttachmentStrategy = function(event) {
91     this.event_ = event;
92     this.listenerMap_ = {};
93   };
95   FilteredAttachmentStrategy.idToEventMap = {};
97   FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) {
98     var id = eventNatives.AttachFilteredEvent(this.event_.eventName,
99                                               listener.filters || {});
100     if (id == -1)
101       throw new Error("Can't add listener");
102     listener.id = id;
103     this.listenerMap_[id] = listener;
104     FilteredAttachmentStrategy.idToEventMap[id] = this.event_;
105   };
107   FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) {
108     this.detachListener(listener, true);
109   };
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);
119   };
121   FilteredAttachmentStrategy.prototype.detach = function(manual) {
122     for (var i in this.listenerMap_)
123       this.detachListener(this.listenerMap_[i], manual);
124   };
126   FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
127     var result = [];
128     for (var i = 0; i < ids.length; i++)
129       $Array.push(result, this.listenerMap_[ids[i]]);
130     return result;
131   };
133   function parseEventOptions(opt_eventOptions) {
134     function merge(dest, src) {
135       for (var k in src) {
136         if (!$Object.hasOwnProperty(dest, k)) {
137           dest[k] = src[k];
138         }
139       }
140     }
142     var options = opt_eventOptions || {};
143     merge(options, {
144       // Event supports adding listeners with filters ("filtered events"), for
145       // example as used in the webNavigation API.
146       //
147       // event.addListener(listener, [filter1, filter2]);
148       supportsFilters: false,
150       // Events supports vanilla events. Most APIs use these.
151       //
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.
157       //
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.
164       //
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.
168       unmanaged: false,
169     });
170     return options;
171   };
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.
177   //
178   // Example:
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'.
184   //
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;
194     this.listeners = [];
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;
204     }
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);
217     else
218       this.attachmentStrategy = new UnfilteredAttachmentStrategy(this);
219   };
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
223   // its listeners.
224   function registerArgumentMassager(name, callback) {
225     if (eventArgumentMassagers[name])
226       throw new Error("Massager already registered for event: " + name);
227     eventArgumentMassagers[name] = callback;
228   }
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 = [];
235     if (filteringInfo)
236       listenerIDs = eventNatives.MatchAgainstEventFilter(name, filteringInfo);
238     var event = attachedNamedEvents[name];
239     if (!event)
240       return;
242     var dispatchArgs = function(args) {
243       var result = event.dispatch_(args, listenerIDs);
244       if (result)
245         logging.DCHECK(!result.validationErrors, result.validationErrors);
246       return result;
247     };
249     if (eventArgumentMassagers[name])
250       eventArgumentMassagers[name](args, dispatchArgs);
251     else
252       dispatchArgs(args);
253   }
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);
262     }
263     if (filters) {
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.")
271       }
272     }
273     var listener = {callback: cb, filters: filters};
274     this.attach_(listener);
275     $Array.push(this.listeners, listener);
276   };
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.");
286         }
287         attachedNamedEvents[this.eventName] = this;
288       }
289     }
290   };
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);
298     if (idx == -1)
299       return;
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]) {
307           throw new Error(
308               "Event '" + this.eventName + "' is not attached.");
309         }
310         delete attachedNamedEvents[this.eventName];
311       }
312     }
313   };
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;
320   };
322   // Test if any callbacks are registered for this event.
323   EventImpl.prototype.hasListeners = function() {
324     return this.getListenerCount_() > 0;
325   };
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;
332   };
334   // Returns the index of the given callback if registered, or -1 if not
335   // found.
336   EventImpl.prototype.findListener_ = function(cb) {
337     for (var i = 0; i < this.listeners.length; i++) {
338       if (this.listeners[i].callback == cb) {
339         return i;
340       }
341     }
343     return -1;
344   };
346   EventImpl.prototype.dispatch_ = function(args, listenerIDs) {
347     if (this.destroyed) {
348       throw new Error(this.eventName + ' was already destroyed at: ' +
349                       this.destroyed);
350     }
351     if (!this.eventOptions.supportsListeners)
352       throw new Error("This event does not support listeners.");
354     if (this.argSchemas && logging.DCHECK_IS_ON()) {
355       try {
356         validate(args, this.argSchemas);
357       } catch (e) {
358         e.message += ' in ' + this.eventName;
359         throw e;
360       }
361     }
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));
368     var results = [];
369     for (var i = 0; i < listeners.length; i++) {
370       try {
371         var result = this.wrapper.dispatchToListener(listeners[i].callback,
372                                                      args);
373         if (result !== undefined)
374           $Array.push(results, result);
375       } catch (e) {
376         exceptionHandler.handle('Error in event handler for ' +
377             (this.eventName ? this.eventName : '(unknown)'),
378           e);
379       }
380     }
381     if (results.length)
382       return {results: results};
383   }
385   // Can be overridden to support custom dispatching.
386   EventImpl.prototype.dispatchToListener = function(callback, args) {
387     return $Function.apply(callback, null, args);
388   }
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);
394   };
396   // Detaches this event object from its name.
397   EventImpl.prototype.detach_ = function() {
398     this.attachmentStrategy.detach(false);
399   };
401   EventImpl.prototype.destroy_ = function() {
402     this.listeners.length = 0;
403     this.detach_();
404     this.destroyed = exceptionHandler.getStackTrace();
405   };
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
413     // data types.
414     function buildArrayOfChoicesSchema(typesList) {
415       return {
416         'type': 'array',
417         'items': {
418           'choices': typesList.map(function(el) {return {'$ref': el};})
419         }
420       };
421     };
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]);
436       });
437     };
439     if (!this.eventOptions.conditions || !this.eventOptions.actions) {
440       throw new Error('Event ' + this.eventName + ' misses ' +
441                       'conditions or actions in the API specification.');
442     }
444     validateRules(rules,
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],
452              $Array.splice(
453                  $Array.slice(ruleFunctionSchemas.addRules.parameters), 1));
454     sendRequest(
455       "events.addRules",
456       [this.eventName, this.webViewInstanceId, rules,  opt_cb],
457       ruleFunctionSchemas.addRules.parameters);
458   }
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],
467              $Array.splice(
468                  $Array.slice(ruleFunctionSchemas.removeRules.parameters), 1));
469     sendRequest("events.removeRules",
470                 [this.eventName,
471                  this.webViewInstanceId,
472                  ruleIdentifiers,
473                  opt_cb],
474                 ruleFunctionSchemas.removeRules.parameters);
475   }
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],
484              $Array.splice(
485                  $Array.slice(ruleFunctionSchemas.getRules.parameters), 1));
487     sendRequest(
488       "events.getRules",
489       [this.eventName, this.webViewInstanceId, ruleIdentifiers, cb],
490       ruleFunctionSchemas.getRules.parameters);
491   }
493   var Event = utils.expose('Event', EventImpl, { functions: [
494     'addListener',
495     'removeListener',
496     'hasListener',
497     'hasListeners',
498     'dispatchToListener',
499     'dispatch',
500     'addRules',
501     'removeRules',
502     'getRules'
503   ] });
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;