blob: a6d64cf6fd98ec7eea428be9aaa66c63c390f9cc [file] [log] [blame]
// 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 = $Object.assign({}, 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': $Array.map(typesList, 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.$set('Event', Event);
exports.$set('dispatchEvent', dispatchEvent);
exports.$set('parseEventOptions', parseEventOptions);
exports.$set('registerArgumentMassager', registerArgumentMassager);