diff --git a/browser/components/extensions/ext-tabs.js b/browser/components/extensions/ext-tabs.js index 2e68b8b82dda..1b9c88381e2a 100644 --- a/browser/components/extensions/ext-tabs.js +++ b/browser/components/extensions/ext-tabs.js @@ -653,7 +653,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { }; return context.sendMessage(browser.messageManager, "Extension:Capture", - message, recipient); + message, {recipient}); }, detectLanguage: function(tabId) { @@ -666,7 +666,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { let recipient = {innerWindowID: browser.innerWindowID}; return context.sendMessage(browser.messageManager, "Extension:DetectLanguage", - {}, recipient); + {}, {recipient}); }, _execute: function(tabId, details, kind, method) { @@ -724,7 +724,7 @@ extensions.registerSchemaAPI("tabs", null, (extension, context) => { options.run_at = "document_idle"; } - return context.sendMessage(mm, "Extension:Execute", {options}, recipient); + return context.sendMessage(mm, "Extension:Execute", {options}, {recipient}); }, executeScript: function(tabId, details) { diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js index e68a40390819..ae9cfee66367 100644 --- a/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js +++ b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js @@ -15,28 +15,35 @@ add_task(function* tabsSendMessageReply() { }, background: function() { + let firstTab; let promiseResponse = new Promise(resolve => { browser.runtime.onMessage.addListener((msg, sender, respond) => { if (msg == "content-script-ready") { let tabId = sender.tab.id; browser.tabs.sendMessage(tabId, "respond-never", response => { - browser.test.fail("Got unexpected response callback"); + browser.test.fail(`Got unexpected response callback: ${response}`); browser.test.notifyFail("sendMessage"); }); Promise.all([ promiseResponse, + browser.tabs.sendMessage(tabId, "respond-now"), + browser.tabs.sendMessage(tabId, "respond-now-2"), new Promise(resolve => browser.tabs.sendMessage(tabId, "respond-soon", resolve)), browser.tabs.sendMessage(tabId, "respond-promise"), browser.tabs.sendMessage(tabId, "respond-never"), + browser.tabs.sendMessage(tabId, "respond-error").catch(error => Promise.resolve({error})), browser.tabs.sendMessage(tabId, "throw-error").catch(error => Promise.resolve({error})), - ]).then(([response, respondNow, respondSoon, respondPromise, respondNever, respondError, throwError]) => { + + browser.tabs.sendMessage(firstTab, "no-listener").catch(error => Promise.resolve({error})), + ]).then(([response, respondNow, respondNow2, respondSoon, respondPromise, respondNever, respondError, throwError, noListener]) => { browser.test.assertEq("expected-response", response, "Content script got the expected response"); browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response"); + browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener"); browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response"); browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response"); browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution"); @@ -44,6 +51,10 @@ add_task(function* tabsSendMessageReply() { browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response"); browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response"); + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", + noListener.error.message, + "Got the expected no listener response"); + return browser.tabs.remove(tabId); }).then(() => { browser.test.notifyPass("sendMessage"); @@ -56,7 +67,10 @@ add_task(function* tabsSendMessageReply() { }); }); - browser.tabs.create({url: "http://example.com/"}); + browser.tabs.query({currentWindow: true, active: true}).then(tabs => { + firstTab = tabs[0].id; + browser.tabs.create({url: "http://example.com/"}); + }); }, files: { @@ -77,6 +91,13 @@ add_task(function* tabsSendMessageReply() { throw new Error(msg); } }); + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); browser.runtime.sendMessage("content-script-ready").then(response => { browser.runtime.sendMessage(["got-response", response]); }); @@ -107,7 +128,7 @@ add_task(function* tabsSendMessageNoExceptionOnNonExistentTab() { exception = e; } - browser.test.assertEq(undefined, exception, "no exception should be raised on tabs.sendMessage to unexistent tabs"); + browser.test.assertEq(undefined, exception, "no exception should be raised on tabs.sendMessage to nonexistent tabs"); browser.tabs.remove(tab.id, function() { browser.test.notifyPass("tabs.sendMessage"); }); diff --git a/toolkit/components/extensions/Extension.jsm b/toolkit/components/extensions/Extension.jsm index 21e892ea8df0..5d5764d4ebcc 100644 --- a/toolkit/components/extensions/Extension.jsm +++ b/toolkit/components/extensions/Extension.jsm @@ -92,7 +92,6 @@ Cu.import("resource://gre/modules/ExtensionUtils.jsm"); var { BaseContext, LocaleData, - MessageBroker, Messenger, injectAPI, instanceOf, @@ -217,12 +216,6 @@ var Management = { }, }; -// A MessageBroker that's used to send and receive messages for -// extension pages (which run in the chrome process). -var globalBroker = new MessageBroker([Services.mm, Services.ppmm]); - -var gContextId = 0; - // An extension page is an execution context for any extension content // that runs in the chrome process. It's used for background pages // (type="background"), popups (type="popup"), and any extension @@ -244,7 +237,6 @@ ExtensionPage = class extends BaseContext { this.contentWindow = contentWindow || null; this.uri = uri || extension.baseURI; this.incognito = params.incognito || false; - this.contextId = gContextId++; this.unloaded = false; // This is the MessageSender property passed to extension. @@ -261,7 +253,7 @@ ExtensionPage = class extends BaseContext { // Properties in |filter| must match those in the |recipient| // parameter of sendMessage. let filter = {extensionId: extension.id}; - this.messenger = new Messenger(this, globalBroker, sender, filter, delegate); + this.messenger = new Messenger(this, [Services.mm, Services.ppmm], sender, filter, delegate); this.extension.views.add(this); } @@ -274,17 +266,6 @@ ExtensionPage = class extends BaseContext { return this.contentWindow.document.nodePrincipal; } - // A wrapper around MessageChannel.sendMessage which adds the extension ID - // to the recipient object, and ensures replies are not processed after the - // context has been unloaded. - sendMessage(target, messageName, data, recipient = {}, sender = {}) { - recipient.extensionId = this.extension.id; - sender.extensionId = this.extension.id; - sender.contextId = this.contextId; - - return MessageChannel.sendMessage(target, messageName, data, recipient, sender); - } - // Called when the extension shuts down. shutdown() { Management.emit("page-shutdown", this); @@ -303,16 +284,11 @@ ExtensionPage = class extends BaseContext { this.unloaded = true; - MessageChannel.abortResponses({ - extensionId: this.extension.id, - contextId: this.contextId, - }); + super.unload(); Management.emit("page-unload", this); this.extension.views.delete(this); - - super.unload(); } }; diff --git a/toolkit/components/extensions/ExtensionContent.jsm b/toolkit/components/extensions/ExtensionContent.jsm index a2bb518148a7..7a1f437b8e1b 100644 --- a/toolkit/components/extensions/ExtensionContent.jsm +++ b/toolkit/components/extensions/ExtensionContent.jsm @@ -43,7 +43,6 @@ var { runSafeSyncWithoutClone, BaseContext, LocaleData, - MessageBroker, Messenger, injectAPI, flushJarCache, @@ -325,13 +324,12 @@ class ExtensionContext extends BaseContext { }; let url = contentWindow.location.href; - let broker = ExtensionContent.getBroker(mm); // The |sender| parameter is passed directly to the extension. let sender = {id: this.extension.uuid, frameId, url}; // Properties in |filter| must match those in the |recipient| // parameter of sendMessage. let filter = {extensionId, frameId}; - this.messenger = new Messenger(this, broker, sender, filter, delegate); + this.messenger = new Messenger(this, [mm], sender, filter, delegate); this.chromeObj = Cu.createObjectIn(this.sandbox, {defineAs: "browser"}); @@ -739,8 +737,6 @@ class ExtensionGlobal { MessageChannel.addListener(global, "WebNavigation:GetFrame", this); MessageChannel.addListener(global, "WebNavigation:GetAllFrames", this); - this.broker = new MessageBroker([global]); - this.windowId = global.content .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils) @@ -753,7 +749,7 @@ class ExtensionGlobal { this.global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId: this.windowId}); } - get messageFilter() { + get messageFilterStrict() { return { innerWindowID: windowId(this.global.content), }; @@ -858,10 +854,6 @@ this.ExtensionContent = { this.globals.get(global).uninit(); this.globals.delete(global); }, - - getBroker(messageManager) { - return this.globals.get(messageManager).broker; - }, }; ExtensionManager.init(); diff --git a/toolkit/components/extensions/ExtensionUtils.jsm b/toolkit/components/extensions/ExtensionUtils.jsm index bfdff778ba65..fd8318aa22e4 100644 --- a/toolkit/components/extensions/ExtensionUtils.jsm +++ b/toolkit/components/extensions/ExtensionUtils.jsm @@ -20,6 +20,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector", "resource:///modules/translation/LanguageDetector.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Locale", "resource://gre/modules/Locale.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel", + "resource://gre/modules/MessageChannel.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm"); @@ -125,11 +127,14 @@ class SpreadArgs extends Array { } } +let gContextId = 0; + class BaseContext { constructor() { this.onClose = new Set(); this.checkedLastError = false; this._lastError = null; + this.contextId = ++gContextId; } get cloneScope() { @@ -170,6 +175,22 @@ class BaseContext { this.onClose.delete(obj); } + /** + * A wrapper around MessageChannel.sendMessage which adds the extension ID + * to the recipient object, and ensures replies are not processed after the + * context has been unloaded. + */ + sendMessage(target, messageName, data, options = {}) { + options.recipient = options.recipient || {}; + options.sender = options.sender || {}; + + options.recipient.extensionId = this.extension.id; + options.sender.extensionId = this.extension.id; + options.sender.contextId = this.contextId; + + return MessageChannel.sendMessage(target, messageName, data, options); + } + get lastError() { this.checkedLastError = true; return this._lastError; @@ -267,6 +288,11 @@ class BaseContext { } unload() { + MessageChannel.abortResponses({ + extensionId: this.extension.id, + contextId: this.contextId, + }); + for (let obj of this.onClose) { obj.close(); } @@ -656,101 +682,7 @@ function promiseDocumentReady(doc) { * Messaging primitives. */ -var nextBrokerId = 1; - -var MESSAGES = [ - "Extension:Message", - "Extension:Connect", -]; - -// Receives messages from multiple message managers and directs them -// to a set of listeners. On the child side: one broker per frame -// script. On the parent side: one broker total, covering both the -// global MM and the ppmm. Message must be tagged with a recipient, -// which is an object with properties. Listeners can filter for -// messages that have a certain value for a particular property in the -// recipient. (If a message doesn't specify the given property, it's -// considered a match.) -function MessageBroker(messageManagers) { - this.messageManagers = messageManagers; - for (let mm of this.messageManagers) { - for (let message of MESSAGES) { - mm.addMessageListener(message, this); - } - } - - this.listeners = {message: [], connect: []}; -} - -MessageBroker.prototype = { - uninit() { - for (let mm of this.messageManagers) { - for (let message of MESSAGES) { - mm.removeMessageListener(message, this); - } - } - - this.listeners = null; - }, - - makeId() { - return nextBrokerId++; - }, - - addListener(type, listener, filter) { - this.listeners[type].push({filter, listener}); - }, - - removeListener(type, listener) { - for (let i = 0; i < this.listeners[type].length; i++) { - if (this.listeners[type][i].listener == listener) { - this.listeners[type].splice(i, 1); - return; - } - } - }, - - runListeners(type, target, data) { - let listeners = []; - for (let {listener, filter} of this.listeners[type]) { - let pass = true; - for (let prop in filter) { - if (prop in data.recipient && filter[prop] != data.recipient[prop]) { - pass = false; - break; - } - } - - // Save up the list of listeners to call in case they modify the - // set of listeners. - if (pass) { - listeners.push(listener); - } - } - - for (let listener of listeners) { - listener(type, target, data.message, data.sender, data.recipient); - } - }, - - receiveMessage({name, data, target}) { - switch (name) { - case "Extension:Message": - this.runListeners("message", target, data); - break; - - case "Extension:Connect": - this.runListeners("connect", target, data); - break; - } - }, - - sendMessage(messageManager, type, message, sender, recipient) { - let data = {message, sender, recipient}; - let names = {message: "Extension:Message", connect: "Extension:Connect"}; - messageManager.sendAsyncMessage(names[type], data); - }, -}; +var nextPortId = 1; // Abstraction for a Port object in the extension API. Each port has a unique ID. function Port(context, messageManager, name, id, sender) { @@ -863,128 +795,122 @@ function getMessageManager(target) { // basics of sendMessage, onMessage, connect, and onConnect. // // |context| is the extension scope. -// |broker| is a MessageBroker used to receive and send messages. +// |messageManagers| is an array of MessageManagers used to receive messages. // |sender| is an object describing the sender (usually giving its extension id, tabId, etc.) // |filter| is a recipient filter to apply to incoming messages from the broker. // |delegate| is an object that must implement a few methods: // getSender(context, messageManagerTarget, sender): returns a MessageSender // See https://developer.chrome.com/extensions/runtime#type-MessageSender. -function Messenger(context, broker, sender, filter, delegate) { +function Messenger(context, messageManagers, sender, filter, delegate) { this.context = context; - this.broker = broker; + this.messageManagers = messageManagers; this.sender = sender; this.filter = filter; this.delegate = delegate; } Messenger.prototype = { + _sendMessage(messageManager, message, data, recipient) { + let options = { + recipient, + sender: this.sender, + responseType: MessageChannel.RESPONSE_FIRST, + }; + + return this.context.sendMessage(messageManager, message, data, options); + }, + sendMessage(messageManager, msg, recipient, responseCallback) { - let id = this.broker.makeId(); - let replyName = `Extension:Reply-${id}`; - recipient.messageId = id; - this.broker.sendMessage(messageManager, "message", msg, this.sender, recipient); - - let promise = new Promise((resolve, reject) => { - let onClose; - let listener = ({data: response}) => { - messageManager.removeMessageListener(replyName, listener); - this.context.forgetOnClose(onClose); - - if (response.gotData) { - resolve(response.data); - } else if (response.error) { - reject(response.error); - } else if (!responseCallback) { - // As a special case, we don't call the callback variant if we - // receive no response, but the promise needs to resolve or - // reject in either case. - resolve(); + let promise = this._sendMessage(messageManager, "Extension:Message", msg, recipient) + .catch(error => { + if (error.result == MessageChannel.RESULT_NO_HANDLER) { + return Promise.reject({message: "Could not establish connection. Receiving end does not exist."}); + } else if (error.result == MessageChannel.RESULT_NO_RESPONSE) { + if (responseCallback) { + // As a special case, we don't call the callback variant if we + // receive no response. So return a promise which will never + // resolve. + return new Promise(() => {}); + } + } else { + return Promise.reject({message: error.message}); } - }; - onClose = { - close() { - messageManager.removeMessageListener(replyName, listener); - }, - }; - - messageManager.addMessageListener(replyName, listener); - this.context.callOnClose(onClose); - }); + }); return this.context.wrapPromise(promise, responseCallback); }, onMessage(name) { return new SingletonEventManager(this.context, name, callback => { - let listener = (type, target, message, sender, recipient) => { - message = Cu.cloneInto(message, this.context.cloneScope); - if (this.delegate) { - this.delegate.getSender(this.context, target, sender); - } - sender = Cu.cloneInto(sender, this.context.cloneScope); + let listener = { + messageFilterPermissive: this.filter, - let mm = getMessageManager(target); - let replyName = `Extension:Reply-${recipient.messageId}`; + receiveMessage: ({target, data: message, sender, recipient}) => { + if (this.delegate) { + this.delegate.getSender(this.context, target, sender); + } - new Promise((resolve, reject) => { - let sendResponse = Cu.exportFunction(resolve, this.context.cloneScope); + let sendResponse; + let response = undefined; + let promise = new Promise(resolve => { + sendResponse = value => { + resolve(value); + response = promise; + }; + }); + + message = Cu.cloneInto(message, this.context.cloneScope); + sender = Cu.cloneInto(sender, this.context.cloneScope); + sendResponse = Cu.exportFunction(sendResponse, this.context.cloneScope); // Note: We intentionally do not use runSafe here so that any // errors are propagated to the message sender. let result = callback(message, sender, sendResponse); if (result instanceof Promise) { - resolve(result); - } else if (result !== true) { - reject(); + return result; + } else if (result === true) { + return promise; } - }).then( - data => { - mm.sendAsyncMessage(replyName, {data, gotData: true}); - }, - error => { - if (error) { - // The result needs to be structured-clonable, which - // ordinary Error objects are not. - try { - error = {message: String(error.message), stack: String(error.stack)}; - } catch (e) { - error = {message: String(error)}; - } - } - mm.sendAsyncMessage(replyName, {error, gotData: false}); - }); + return response; + }, }; - this.broker.addListener("message", listener, this.filter); + MessageChannel.addListener(this.messageManagers, "Extension:Message", listener); return () => { - this.broker.removeListener("message", listener); + MessageChannel.removeListener(this.messageManagers, "Extension:Message", listener); }; }).api(); }, connect(messageManager, name, recipient) { - let portId = this.broker.makeId(); + let portId = nextPortId++; let port = new Port(this.context, messageManager, name, portId, null); let msg = {name, portId}; - this.broker.sendMessage(messageManager, "connect", msg, this.sender, recipient); + // TODO: Disconnect the port if no response? + this._sendMessage(messageManager, "Extension:Connect", msg, recipient); return port.api(); }, onConnect(name) { - return new EventManager(this.context, name, fire => { - let listener = (type, target, message, sender, recipient) => { - let {name, portId} = message; - let mm = getMessageManager(target); - if (this.delegate) { - this.delegate.getSender(this.context, target, sender); - } - let port = new Port(this.context, mm, name, portId, sender); - fire.withoutClone(port.api()); + return new SingletonEventManager(this.context, name, callback => { + let listener = { + messageFilterPermissive: this.filter, + + receiveMessage: ({target, data: message, sender, recipient}) => { + let {name, portId} = message; + let mm = getMessageManager(target); + if (this.delegate) { + this.delegate.getSender(this.context, target, sender); + } + let port = new Port(this.context, mm, name, portId, sender); + runSafeSyncWithoutClone(callback, port.api()); + return true; + }, }; - this.broker.addListener("connect", listener, this.filter); + MessageChannel.addListener(this.messageManagers, "Extension:Connect", listener); return () => { - this.broker.removeListener("connect", listener); + MessageChannel.removeListener(this.messageManagers, "Extension:Connect", listener); }; }).api(); }, @@ -1042,7 +968,6 @@ this.ExtensionUtils = { DefaultWeakMap, EventManager, LocaleData, - MessageBroker, Messenger, PlatformInfo, SingletonEventManager, diff --git a/toolkit/components/extensions/MessageChannel.jsm b/toolkit/components/extensions/MessageChannel.jsm index 7f581ee4b657..e2787bae96bb 100644 --- a/toolkit/components/extensions/MessageChannel.jsm +++ b/toolkit/components/extensions/MessageChannel.jsm @@ -11,9 +11,9 @@ * are no matching listeners, or the message manager disconnects before * a reply is received, the caller is returned an error. * - * Since each message must have only one recipient, the listener end may - * specify filters for the messages it wishes to receive, and the sender - * end likewise may specify recipient tags to match the filters. + * The listener end may specify filters for the messages it wishes to + * receive, and the sender end likewise may specify recipient tags to + * match the filters. * * The message handler on the listener side may return its response * value directly, or may return a promise, the resolution or rejection @@ -34,10 +34,14 @@ * messageManager, "ContentScript:TouchContent", * this); * - * this.messageFilter = { + * this.messageFilterStrict = { * innerWindowID: getInnerWindowID(window), * extensionID: extensionID, * }; + * + * this.messageFilterPermissive = { + * outerWindowID: getOuterWindowID(window), + * }; * }, * * receiveMessage({ target, messageName, sender, recipient, data }) { @@ -61,7 +65,7 @@ * * MessageChannel.sendMessage( * tab.linkedBrowser.messageManager, "ContentScript:TouchContent", - * data, recipient, sender + * data, {recipient, sender} * ).then(result => { * alert(result.touchResult); * }); @@ -152,19 +156,8 @@ class FilteringMessageManager { receiveMessage({data, target}) { let handlers = Array.from(this.getHandlers(data.messageName, data.recipient)); - let result = {}; - if (handlers.length == 0) { - result.error = {result: MessageChannel.RESULT_NO_HANDLER, - message: "No matching message handler"}; - } else if (handlers.length > 1) { - result.error = {result: MessageChannel.RESULT_MULTIPLE_HANDLERS, - message: `Multiple matching handlers for ${data.messageName}`}; - } else { - result.handler = handlers[0]; - } - data.target = target; - this.callback(result, data); + this.callback(handlers, data); } /** @@ -179,7 +172,8 @@ class FilteringMessageManager { * getHandlers(messageName, recipient) { let handlers = this.handlers.get(messageName) || new Set(); for (let handler of handlers) { - if (MessageChannel.matchesFilter(handler.messageFilter, recipient)) { + if (MessageChannel.matchesFilter(handler.messageFilterStrict || {}, recipient) && + MessageChannel.matchesFilter(handler.messageFilterPermissive || {}, recipient, false)) { yield handler; } } @@ -191,9 +185,12 @@ class FilteringMessageManager { * @param {string} messageName * The internal message name for which to register the handler. * @param {object} handler - * An opaque handler object. The object must have a `messageFilter` - * property on which to filter messages. Final dispatching is handled - * by the message callback passed to the constructor. + * An opaque handler object. The object may have a + * `messageFilterStrict` and/or a `messageFilterPermissive` + * property on which to filter messages. + * + * Final dispatching is handled by the message callback passed to + * the constructor. */ addHandler(messageName, handler) { if (!this.handlers.has(messageName)) { @@ -298,6 +295,7 @@ this.MessageChannel = { RESULT_NO_HANDLER: 2, RESULT_MULTIPLE_HANDLERS: 3, RESULT_ERROR: 4, + RESULT_NO_RESPONSE: 5, REASON_DISCONNECTED: { result: this.RESULT_DISCONNECTED, @@ -305,27 +303,72 @@ this.MessageChannel = { }, /** - * Returns true if the given `data` object matches the given `filter` - * object. The objects match if every property of `filter` is present - * in `data`, and the values in both objects are strictly equal. + * Specifies that only a single listener matching the specified + * recipient tag may be listening for the given message, at the other + * end of the target message manager. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If multiple matching listeners exist, a + * RESULT_MULTIPLE_HANDLERS error will be returned. + */ + RESPONSE_SINGLE: 0, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, but only + * the first response or error is returned. + * + * Only handlers which return a value other than `undefined` are + * considered to have responded. Returning a Promise which evaluates + * to `undefined` is interpreted as an explicit response. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If no listeners return a response, a RESULT_NO_RESPONSE + * error will be returned. + */ + RESPONSE_FIRST: 1, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, and all + * responses are returned as an array, once all listeners have + * replied. + */ + RESPONSE_ALL: 2, + + /** + * Returns true if the peroperties of the `data` object match those in + * the `filter` object. Matching is done on a strict equality basis, + * and the behavior varies depending on the value of the `strict` + * parameter. * * @param {object} filter * The filter object to match against. * @param {object} data * The data object being matched. + * @param {boolean} [strict=false] + * If true, all properties in the `filter` object have a + * corresponding property in `data` with the same value. If + * false, properties present in both objects must have the same + * balue. * @returns {bool} True if the objects match. */ - matchesFilter(filter, data) { + matchesFilter(filter, data, strict = true) { + if (strict) { + return Object.keys(filter).every(key => { + return key in data && data[key] === filter[key]; + }); + } return Object.keys(filter).every(key => { - return key in data && data[key] === filter[key]; + return !(key in data) || data[key] === filter[key]; }); }, /** * Adds a message listener to the given message manager. * - * @param {nsIMessageSender} target - * The message manager on which to listen. + * @param {nsIMessageSender|[nsIMessageSender]} targets + * The message managers on which to listen. * @param {string|number} messageName * The name of the message to listen for. * @param {MessageReceiver} handler @@ -363,28 +406,41 @@ this.MessageChannel = { * resolution or rejection value of which will likewise be * returned to the message sender. * - * messageFilter: + * messageFilterStrict: * An object containing arbitrary properties on which to filter * received messages. Messages will only be dispatched to this * object if the `recipient` object passed to `sendMessage` - * matches this filter, as determined by `matchesFilter`. + * matches this filter, as determined by `matchesFilter` with + * `strict=true`. + * + * messageFilterPermissive: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=false`. */ - addListener(target, messageName, handler) { - this.messageManagers.get(target).addHandler(messageName, handler); + addListener(targets, messageName, handler) { + for (let target of [].concat(targets)) { + this.messageManagers.get(target).addHandler(messageName, handler); + } }, /** * Removes a message listener from the given message manager. * * @param {nsIMessageSender} target - * The message manager on which to stop listening. + * @param {nsIMessageSender|[nsIMessageSender]} targets + * The message managers on which to stop listening. * @param {string|number} messageName * The name of the message to stop listening for. * @param {MessageReceiver} handler * The handler to stop dispatching to. */ - removeListener(target, messageName, handler) { - this.messageManagers.get(target).removeListener(messageName, handler); + removeListener(targets, messageName, handler) { + for (let target of [].concat(targets)) { + this.messageManagers.get(target).removeHandler(messageName, handler); + } }, /** @@ -401,23 +457,32 @@ this.MessageChannel = { * @param {object} data * A structured-clone-compatible object to send to the message * recipient. - * @param {object} [recipient] + * @param {object} [options] + * An object containing any of the following properties: + * @param {object} [options.recipient] * A structured-clone-compatible object to identify the message - * recipient. The object must match the `messageFilter` defined by - * recipients in order for the message to be received. - * @param {object} [sender] + * recipient. The object must match the `messageFilterStrict` and + * `messageFilterPermissive` filters defined by recipients in order + * for the message to be received. + * @param {object} [options.sender] * A structured-clone-compatible object to identify the message * sender. This object may also be used as a filter to prematurely * abort responses when the sender is being destroyed. * @see `abortResponses`. + * @param {integer} [options.responseType=RESPONSE_SINGLE] + * Specifies the type of response expected. See the `RESPONSE_*` + * contents for details. * @returns Promise */ - sendMessage(target, messageName, data, recipient = {}, sender = {}) { + sendMessage(target, messageName, data, options = {}) { + let sender = options.sender || {}; + let recipient = options.recipient || {}; + let responseType = options.responseType || this.RESPONSE_SINGLE; + let channelId = gChannelId++; - let message = {messageName, channelId, sender, recipient, data}; + let message = {messageName, channelId, sender, recipient, data, responseType}; let deferred = PromiseUtils.defer(); - deferred.messageFilter = {}; deferred.sender = recipient; deferred.messageManager = target; @@ -438,6 +503,54 @@ this.MessageChannel = { return deferred.promise; }, + _callHandlers(handlers, data) { + let responseType = data.responseType; + + // At least one handler is required for all response types but + // RESPONSE_ALL. + if (handlers.length == 0 && responseType != this.RESPONSE_ALL) { + return Promise.reject({result: MessageChannel.RESULT_NO_HANDLER, + message: "No matching message handler"}); + } + + if (responseType == this.RESPONSE_SINGLE) { + if (handlers.length > 1) { + return Promise.reject({result: MessageChannel.RESULT_MULTIPLE_HANDLERS, + message: `Multiple matching handlers for ${data.messageName}`}); + } + + // Note: We use `new Promise` rather than `Promise.resolve` here + // so that errors from the handler are trapped and converted into + // rejected promises. + return new Promise(resolve => { + resolve(handlers[0].receiveMessage(data)); + }); + } + + let responses = handlers.map(handler => { + try { + return handler.receiveMessage(data); + } catch (e) { + return Promise.reject(e); + } + }); + responses = responses.filter(response => response !== undefined); + + switch (responseType) { + case this.RESPONSE_FIRST: + if (responses.length == 0) { + return Promise.reject({result: MessageChannel.RESULT_NO_RESPONSE, + message: "No handler returned a response"}); + } + + return Promise.race(responses); + + case this.RESPONSE_ALL: + return Promise.all(responses); + } + return Promise.reject({message: "Invalid response type"}); + }, + /** * Handles dispatching message callbacks from the message brokers to their * appropriate `MessageReceivers`, and routing the responses back to the @@ -446,7 +559,7 @@ this.MessageChannel = { * Each handler object is a `MessageReceiver` object as passed to * `addListener`. */ - _handleMessage({handler, error}, data) { + _handleMessage(handlers, data) { // The target passed to `receiveMessage` is sometimes a message manager // owner instead of a message manager, so make sure to convert it to a // message manager first if necessary. @@ -462,12 +575,7 @@ this.MessageChannel = { deferred.promise = new Promise((resolve, reject) => { deferred.reject = reject; - if (handler) { - let result = handler.receiveMessage(data); - resolve(result); - } else { - reject(error); - } + this._callHandlers(handlers, data).then(resolve, reject); }).then( value => { let response = { @@ -513,15 +621,17 @@ this.MessageChannel = { * Each handler object is a deferred object created by `sendMessage`, and * should be resolved or rejected based on the contents of the response. */ - _handleResponse({handler, error}, data) { - if (error) { - // If we have an error at this point, we have handler to report it to, - // so just log it. - Cu.reportError(error.message); + _handleResponse(handlers, data) { + // If we have an error at this point, we have handler to report it to, + // so just log it. + if (handlers.length == 0) { + Cu.reportError(`No matching message response handler for ${data.messageName}`); + } else if (handlers.length > 1) { + Cu.reportError(`Multiple matching response handlers for ${data.messageName}`); } else if (data.result === this.RESULT_SUCCESS) { - handler.resolve(data.value); + handlers[0].resolve(data.value); } else { - handler.reject(data.error); + handlers[0].reject(data.error); } }, diff --git a/toolkit/components/extensions/ext-webNavigation.js b/toolkit/components/extensions/ext-webNavigation.js index 2c50515d65b6..df21f6a90b89 100644 --- a/toolkit/components/extensions/ext-webNavigation.js +++ b/toolkit/components/extensions/ext-webNavigation.js @@ -90,7 +90,7 @@ extensions.registerSchemaAPI("webNavigation", "webNavigation", (extension, conte let {innerWindowID, messageManager} = tab.linkedBrowser; let recipient = {innerWindowID}; - return context.sendMessage(messageManager, "WebNavigation:GetAllFrames", {}, recipient) + return context.sendMessage(messageManager, "WebNavigation:GetAllFrames", {}, {recipient}) .then((results) => results.map(convertGetFrameResult.bind(null, details.tabId))); }, getFrame(details) { @@ -104,7 +104,7 @@ extensions.registerSchemaAPI("webNavigation", "webNavigation", (extension, conte }; let mm = tab.linkedBrowser.messageManager; - return context.sendMessage(mm, "WebNavigation:GetFrame", {options: details}, recipient) + return context.sendMessage(mm, "WebNavigation:GetFrame", {options: details}, {recipient}) .then((result) => { return result ? convertGetFrameResult(details.tabId, result) : diff --git a/toolkit/components/extensions/test/mochitest/mochitest.ini b/toolkit/components/extensions/test/mochitest/mochitest.ini index c2c49e75c120..75bae1f18b4b 100644 --- a/toolkit/components/extensions/test/mochitest/mochitest.ini +++ b/toolkit/components/extensions/test/mochitest/mochitest.ini @@ -47,6 +47,7 @@ skip-if = buildapp == 'b2g' # port.sender.tab is undefined on b2g. skip-if = buildapp == 'b2g' # port.sender.tab is undefined on b2g. [test_ext_runtime_disconnect.html] [test_ext_runtime_getPlatformInfo.html] +[test_ext_runtime_sendMessage.html] [test_ext_sandbox_var.html] [test_ext_sendmessage_reply.html] skip-if = buildapp == 'b2g' # sender.tab is undefined on b2g. diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_sendMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_sendMessage.html new file mode 100644 index 000000000000..199307c6ecb9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_sendMessage.html @@ -0,0 +1,85 @@ + + + + WebExtension test + + + + + + + + + + + +