| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| var exceptionHandler = require('uncaught_exception_handler'); |
| var eventNatives = requireNative('event_natives'); |
| var logging = requireNative('logging'); |
| var schemaRegistry = requireNative('schema_registry'); |
| var sendRequest = require('sendRequest').sendRequest; |
| var utils = require('utils'); |
| var validate = require('schemaUtils').validate; |
| |
| // Schemas for the rule-style functions on the events API that |
| // only need to be generated occasionally, so populate them lazily. |
| var ruleFunctionSchemas = { |
| // These values are set lazily: |
| // addRules: {}, |
| // getRules: {}, |
| // removeRules: {} |
| }; |
| |
| // This function ensures that |ruleFunctionSchemas| is populated. |
| function ensureRuleSchemasLoaded() { |
| if (ruleFunctionSchemas.addRules) |
| return; |
| var eventsSchema = schemaRegistry.GetSchema("events"); |
| var eventType = utils.lookup(eventsSchema.types, 'id', 'events.Event'); |
| |
| ruleFunctionSchemas.addRules = |
| utils.lookup(eventType.functions, 'name', 'addRules'); |
| ruleFunctionSchemas.getRules = |
| utils.lookup(eventType.functions, 'name', 'getRules'); |
| ruleFunctionSchemas.removeRules = |
| utils.lookup(eventType.functions, 'name', 'removeRules'); |
| } |
| |
| // A map of event names to the event object that is registered to that name. |
| var attachedNamedEvents = {}; |
| |
| // A map of functions that massage event arguments before they are dispatched. |
| // Key is event name, value is function. |
| var eventArgumentMassagers = {}; |
| |
| // An attachment strategy for events that aren't attached to the browser. |
| // This applies to events with the "unmanaged" option and events without |
| // names. |
| var NullAttachmentStrategy = function(event) { |
| this.event_ = event; |
| }; |
| NullAttachmentStrategy.prototype.onAddedListener = |
| function(listener) { |
| }; |
| NullAttachmentStrategy.prototype.onRemovedListener = |
| function(listener) { |
| }; |
| NullAttachmentStrategy.prototype.detach = function(manual) { |
| }; |
| NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) { |
| // |ids| is for filtered events only. |
| return this.event_.listeners; |
| }; |
| |
| // Handles adding/removing/dispatching listeners for unfiltered events. |
| var UnfilteredAttachmentStrategy = function(event) { |
| this.event_ = event; |
| }; |
| |
| UnfilteredAttachmentStrategy.prototype.onAddedListener = |
| function(listener) { |
| // Only attach / detach on the first / last listener removed. |
| if (this.event_.listeners.length == 0) |
| eventNatives.AttachEvent(this.event_.eventName); |
| }; |
| |
| UnfilteredAttachmentStrategy.prototype.onRemovedListener = |
| function(listener) { |
| if (this.event_.listeners.length == 0) |
| this.detach(true); |
| }; |
| |
| UnfilteredAttachmentStrategy.prototype.detach = function(manual) { |
| eventNatives.DetachEvent(this.event_.eventName, manual); |
| }; |
| |
| UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) { |
| // |ids| is for filtered events only. |
| return this.event_.listeners; |
| }; |
| |
| var FilteredAttachmentStrategy = function(event) { |
| this.event_ = event; |
| this.listenerMap_ = {}; |
| }; |
| |
| FilteredAttachmentStrategy.idToEventMap = {}; |
| |
| FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) { |
| var id = eventNatives.AttachFilteredEvent(this.event_.eventName, |
| listener.filters || {}); |
| if (id == -1) |
| throw new Error("Can't add listener"); |
| listener.id = id; |
| this.listenerMap_[id] = listener; |
| FilteredAttachmentStrategy.idToEventMap[id] = this.event_; |
| }; |
| |
| FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) { |
| this.detachListener(listener, true); |
| }; |
| |
| FilteredAttachmentStrategy.prototype.detachListener = |
| function(listener, manual) { |
| if (listener.id == undefined) |
| throw new Error("listener.id undefined - '" + listener + "'"); |
| var id = listener.id; |
| delete this.listenerMap_[id]; |
| delete FilteredAttachmentStrategy.idToEventMap[id]; |
| eventNatives.DetachFilteredEvent(id, manual); |
| }; |
| |
| FilteredAttachmentStrategy.prototype.detach = function(manual) { |
| for (var i in this.listenerMap_) |
| this.detachListener(this.listenerMap_[i], manual); |
| }; |
| |
| FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) { |
| var result = []; |
| for (var i = 0; i < ids.length; i++) |
| $Array.push(result, this.listenerMap_[ids[i]]); |
| return result; |
| }; |
| |
| function parseEventOptions(opt_eventOptions) { |
| function merge(dest, src) { |
| for (var k in src) { |
| if (!$Object.hasOwnProperty(dest, k)) { |
| dest[k] = src[k]; |
| } |
| } |
| } |
| |
| var options = opt_eventOptions || {}; |
| merge(options, { |
| // Event supports adding listeners with filters ("filtered events"), for |
| // example as used in the webNavigation API. |
| // |
| // event.addListener(listener, [filter1, filter2]); |
| supportsFilters: false, |
| |
| // Events supports vanilla events. Most APIs use these. |
| // |
| // event.addListener(listener); |
| supportsListeners: true, |
| |
| // Event supports adding rules ("declarative events") rather than |
| // listeners, for example as used in the declarativeWebRequest API. |
| // |
| // event.addRules([rule1, rule2]); |
| supportsRules: false, |
| |
| // Event is unmanaged in that the browser has no knowledge of its |
| // existence; it's never invoked, doesn't keep the renderer alive, and |
| // the bindings system has no knowledge of it. |
| // |
| // Both events created by user code (new chrome.Event()) and messaging |
| // events are unmanaged, though in the latter case the browser *does* |
| // interact indirectly with them via IPCs written by hand. |
| unmanaged: false, |
| }); |
| return options; |
| }; |
| |
| // Event object. If opt_eventName is provided, this object represents |
| // the unique instance of that named event, and dispatching an event |
| // with that name will route through this object's listeners. Note that |
| // opt_eventName is required for events that support rules. |
| // |
| // Example: |
| // var Event = require('event_bindings').Event; |
| // chrome.tabs.onChanged = new Event("tab-changed"); |
| // chrome.tabs.onChanged.addListener(function(data) { alert(data); }); |
| // Event.dispatch("tab-changed", "hi"); |
| // will result in an alert dialog that says 'hi'. |
| // |
| // If opt_eventOptions exists, it is a dictionary that contains the boolean |
| // entries "supportsListeners" and "supportsRules". |
| // If opt_webViewInstanceId exists, it is an integer uniquely identifying a |
| // <webview> tag within the embedder. If it does not exist, then this is an |
| // extension event rather than a <webview> event. |
| var EventImpl = function(opt_eventName, opt_argSchemas, opt_eventOptions, |
| opt_webViewInstanceId) { |
| this.eventName = opt_eventName; |
| this.argSchemas = opt_argSchemas; |
| this.listeners = []; |
| this.eventOptions = parseEventOptions(opt_eventOptions); |
| this.webViewInstanceId = opt_webViewInstanceId || 0; |
| |
| if (!this.eventName) { |
| if (this.eventOptions.supportsRules) |
| throw new Error("Events that support rules require an event name."); |
| // Events without names cannot be managed by the browser by definition |
| // (the browser has no way of identifying them). |
| this.eventOptions.unmanaged = true; |
| } |
| |
| // Track whether the event has been destroyed to help track down the cause |
| // of http://crbug.com/258526. |
| // This variable will eventually hold the stack trace of the destroy call. |
| // TODO(kalman): Delete this and replace with more sound logic that catches |
| // when events are used without being *attached*. |
| this.destroyed = null; |
| |
| if (this.eventOptions.unmanaged) |
| this.attachmentStrategy = new NullAttachmentStrategy(this); |
| else if (this.eventOptions.supportsFilters) |
| this.attachmentStrategy = new FilteredAttachmentStrategy(this); |
| else |
| this.attachmentStrategy = new UnfilteredAttachmentStrategy(this); |
| }; |
| |
| // callback is a function(args, dispatch). args are the args we receive from |
| // dispatchEvent(), and dispatch is a function(args) that dispatches args to |
| // its listeners. |
| function registerArgumentMassager(name, callback) { |
| if (eventArgumentMassagers[name]) |
| throw new Error("Massager already registered for event: " + name); |
| eventArgumentMassagers[name] = callback; |
| } |
| |
| // Dispatches a named event with the given argument array. The args array is |
| // the list of arguments that will be sent to the event callback. |
| function dispatchEvent(name, args, filteringInfo) { |
| var listenerIDs = []; |
| |
| if (filteringInfo) |
| listenerIDs = eventNatives.MatchAgainstEventFilter(name, filteringInfo); |
| |
| var event = attachedNamedEvents[name]; |
| if (!event) |
| return; |
| |
| var dispatchArgs = function(args) { |
| var result = event.dispatch_(args, listenerIDs); |
| if (result) |
| logging.DCHECK(!result.validationErrors, result.validationErrors); |
| return result; |
| }; |
| |
| if (eventArgumentMassagers[name]) |
| eventArgumentMassagers[name](args, dispatchArgs); |
| else |
| dispatchArgs(args); |
| } |
| |
| // Registers a callback to be called when this event is dispatched. |
| EventImpl.prototype.addListener = function(cb, filters) { |
| if (!this.eventOptions.supportsListeners) |
| throw new Error("This event does not support listeners."); |
| if (this.eventOptions.maxListeners && |
| this.getListenerCount_() >= this.eventOptions.maxListeners) { |
| throw new Error("Too many listeners for " + this.eventName); |
| } |
| if (filters) { |
| if (!this.eventOptions.supportsFilters) |
| throw new Error("This event does not support filters."); |
| if (filters.url && !(filters.url instanceof Array)) |
| throw new Error("filters.url should be an array."); |
| if (filters.serviceType && |
| !(typeof filters.serviceType === 'string')) { |
| throw new Error("filters.serviceType should be a string.") |
| } |
| } |
| var listener = {callback: cb, filters: filters}; |
| this.attach_(listener); |
| $Array.push(this.listeners, listener); |
| }; |
| |
| EventImpl.prototype.attach_ = function(listener) { |
| this.attachmentStrategy.onAddedListener(listener); |
| |
| if (this.listeners.length == 0) { |
| if (this.eventName) { |
| if (attachedNamedEvents[this.eventName]) { |
| throw new Error("Event '" + this.eventName + |
| "' is already attached."); |
| } |
| attachedNamedEvents[this.eventName] = this; |
| } |
| } |
| }; |
| |
| // Unregisters a callback. |
| EventImpl.prototype.removeListener = function(cb) { |
| if (!this.eventOptions.supportsListeners) |
| throw new Error("This event does not support listeners."); |
| |
| var idx = this.findListener_(cb); |
| if (idx == -1) |
| return; |
| |
| var removedListener = $Array.splice(this.listeners, idx, 1)[0]; |
| this.attachmentStrategy.onRemovedListener(removedListener); |
| |
| if (this.listeners.length == 0) { |
| if (this.eventName) { |
| if (!attachedNamedEvents[this.eventName]) { |
| throw new Error( |
| "Event '" + this.eventName + "' is not attached."); |
| } |
| delete attachedNamedEvents[this.eventName]; |
| } |
| } |
| }; |
| |
| // Test if the given callback is registered for this event. |
| EventImpl.prototype.hasListener = function(cb) { |
| if (!this.eventOptions.supportsListeners) |
| throw new Error("This event does not support listeners."); |
| return this.findListener_(cb) > -1; |
| }; |
| |
| // Test if any callbacks are registered for this event. |
| EventImpl.prototype.hasListeners = function() { |
| return this.getListenerCount_() > 0; |
| }; |
| |
| // Returns the number of listeners on this event. |
| EventImpl.prototype.getListenerCount_ = function() { |
| if (!this.eventOptions.supportsListeners) |
| throw new Error("This event does not support listeners."); |
| return this.listeners.length; |
| }; |
| |
| // Returns the index of the given callback if registered, or -1 if not |
| // found. |
| EventImpl.prototype.findListener_ = function(cb) { |
| for (var i = 0; i < this.listeners.length; i++) { |
| if (this.listeners[i].callback == cb) { |
| return i; |
| } |
| } |
| |
| return -1; |
| }; |
| |
| EventImpl.prototype.dispatch_ = function(args, listenerIDs) { |
| if (this.destroyed) { |
| throw new Error(this.eventName + ' was already destroyed at: ' + |
| this.destroyed); |
| } |
| if (!this.eventOptions.supportsListeners) |
| throw new Error("This event does not support listeners."); |
| |
| if (this.argSchemas && logging.DCHECK_IS_ON()) { |
| try { |
| validate(args, this.argSchemas); |
| } catch (e) { |
| e.message += ' in ' + this.eventName; |
| throw e; |
| } |
| } |
| |
| // Make a copy of the listeners in case the listener list is modified |
| // while dispatching the event. |
| var listeners = $Array.slice( |
| this.attachmentStrategy.getListenersByIDs(listenerIDs)); |
| |
| var results = []; |
| for (var i = 0; i < listeners.length; i++) { |
| try { |
| var result = this.wrapper.dispatchToListener(listeners[i].callback, |
| args); |
| if (result !== undefined) |
| $Array.push(results, result); |
| } catch (e) { |
| exceptionHandler.handle('Error in event handler for ' + |
| (this.eventName ? this.eventName : '(unknown)'), |
| e); |
| } |
| } |
| if (results.length) |
| return {results: results}; |
| } |
| |
| // Can be overridden to support custom dispatching. |
| EventImpl.prototype.dispatchToListener = function(callback, args) { |
| return $Function.apply(callback, null, args); |
| } |
| |
| // Dispatches this event object to all listeners, passing all supplied |
| // arguments to this function each listener. |
| EventImpl.prototype.dispatch = function(varargs) { |
| return this.dispatch_($Array.slice(arguments), undefined); |
| }; |
| |
| // Detaches this event object from its name. |
| EventImpl.prototype.detach_ = function() { |
| this.attachmentStrategy.detach(false); |
| }; |
| |
| EventImpl.prototype.destroy_ = function() { |
| this.listeners.length = 0; |
| this.detach_(); |
| this.destroyed = exceptionHandler.getStackTrace(); |
| }; |
| |
| EventImpl.prototype.addRules = function(rules, opt_cb) { |
| if (!this.eventOptions.supportsRules) |
| throw new Error("This event does not support rules."); |
| |
| // Takes a list of JSON datatype identifiers and returns a schema fragment |
| // that verifies that a JSON object corresponds to an array of only these |
| // data types. |
| function buildArrayOfChoicesSchema(typesList) { |
| return { |
| 'type': 'array', |
| 'items': { |
| 'choices': typesList.map(function(el) {return {'$ref': el};}) |
| } |
| }; |
| }; |
| |
| // Validate conditions and actions against specific schemas of this |
| // event object type. |
| // |rules| is an array of JSON objects that follow the Rule type of the |
| // declarative extension APIs. |conditions| is an array of JSON type |
| // identifiers that are allowed to occur in the conditions attribute of each |
| // rule. Likewise, |actions| is an array of JSON type identifiers that are |
| // allowed to occur in the actions attribute of each rule. |
| function validateRules(rules, conditions, actions) { |
| var conditionsSchema = buildArrayOfChoicesSchema(conditions); |
| var actionsSchema = buildArrayOfChoicesSchema(actions); |
| $Array.forEach(rules, function(rule) { |
| validate([rule.conditions], [conditionsSchema]); |
| validate([rule.actions], [actionsSchema]); |
| }); |
| }; |
| |
| if (!this.eventOptions.conditions || !this.eventOptions.actions) { |
| throw new Error('Event ' + this.eventName + ' misses ' + |
| 'conditions or actions in the API specification.'); |
| } |
| |
| validateRules(rules, |
| this.eventOptions.conditions, |
| this.eventOptions.actions); |
| |
| ensureRuleSchemasLoaded(); |
| // We remove the first parameter from the validation to give the user more |
| // meaningful error messages. |
| validate([this.webViewInstanceId, rules, opt_cb], |
| $Array.splice( |
| $Array.slice(ruleFunctionSchemas.addRules.parameters), 1)); |
| sendRequest( |
| "events.addRules", |
| [this.eventName, this.webViewInstanceId, rules, opt_cb], |
| ruleFunctionSchemas.addRules.parameters); |
| } |
| |
| EventImpl.prototype.removeRules = function(ruleIdentifiers, opt_cb) { |
| if (!this.eventOptions.supportsRules) |
| throw new Error("This event does not support rules."); |
| ensureRuleSchemasLoaded(); |
| // We remove the first parameter from the validation to give the user more |
| // meaningful error messages. |
| validate([this.webViewInstanceId, ruleIdentifiers, opt_cb], |
| $Array.splice( |
| $Array.slice(ruleFunctionSchemas.removeRules.parameters), 1)); |
| sendRequest("events.removeRules", |
| [this.eventName, |
| this.webViewInstanceId, |
| ruleIdentifiers, |
| opt_cb], |
| ruleFunctionSchemas.removeRules.parameters); |
| } |
| |
| EventImpl.prototype.getRules = function(ruleIdentifiers, cb) { |
| if (!this.eventOptions.supportsRules) |
| throw new Error("This event does not support rules."); |
| ensureRuleSchemasLoaded(); |
| // We remove the first parameter from the validation to give the user more |
| // meaningful error messages. |
| validate([this.webViewInstanceId, ruleIdentifiers, cb], |
| $Array.splice( |
| $Array.slice(ruleFunctionSchemas.getRules.parameters), 1)); |
| |
| sendRequest( |
| "events.getRules", |
| [this.eventName, this.webViewInstanceId, ruleIdentifiers, cb], |
| ruleFunctionSchemas.getRules.parameters); |
| } |
| |
| var Event = utils.expose('Event', EventImpl, { functions: [ |
| 'addListener', |
| 'removeListener', |
| 'hasListener', |
| 'hasListeners', |
| 'dispatchToListener', |
| 'dispatch', |
| 'addRules', |
| 'removeRules', |
| 'getRules' |
| ] }); |
| |
| // NOTE: Event is (lazily) exposed as chrome.Event from dispatcher.cc. |
| exports.Event = Event; |
| |
| exports.dispatchEvent = dispatchEvent; |
| exports.parseEventOptions = parseEventOptions; |
| exports.registerArgumentMassager = registerArgumentMassager; |