/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("chrome://marionette/content/error.js"); Cu.import("chrome://marionette/content/modal.js"); this.EXPORTED_SYMBOLS = ["proxy"]; const uuidgen = Cc["@mozilla.org/uuid-generator;1"] .getService(Ci.nsIUUIDGenerator); // Proxy handler that traps requests to get a property. Will prioritise // properties that exist on the object's own prototype. var ownPriorityGetterTrap = { get: (obj, prop) => { if (obj.hasOwnProperty(prop)) { return obj[prop]; } return (...args) => obj.send(prop, args); } }; this.proxy = {}; /** * Creates a transparent interface between the chrome- and content * contexts. * * Calls to this object will be proxied via the message manager to a * content frame script, and responses are returend as promises. * * The argument sequence is serialised and passed as an array, unless it * consists of a single object type that isn't null, in which case it's * passed literally. The latter specialisation is temporary to achieve * backwards compatibility with listener.js. * * @param {function(): (nsIMessageSender|nsIMessageBroadcaster)} mmFn * Closure function returning the current message manager. * @param {function(string, Object, number)} sendAsyncFn * Callback for sending async messages. */ proxy.toListener = function (mmFn, sendAsyncFn) { let sender = new proxy.AsyncMessageChannel(mmFn, sendAsyncFn); return new Proxy(sender, ownPriorityGetterTrap); }; /** * Provides a transparent interface between chrome- and content space. * * The AsyncMessageChannel is an abstraction of the message manager * IPC architecture allowing calls to be made to any registered message * listener in Marionette. The {@code #send(...)} method returns a promise * that gets resolved when the message handler calls {@code .reply(...)}. */ proxy.AsyncMessageChannel = class { constructor(mmFn, sendAsyncFn) { this.sendAsync = sendAsyncFn; // TODO(ato): Bug 1242595 this.activeMessageId = null; this.mmFn_ = mmFn; this.listeners_ = new Map(); this.dialogueObserver_ = null; } get mm() { return this.mmFn_(); } /** * Send a message across the channel. The name of the function to * call must be registered as a message listener. * * Usage: * * let channel = new AsyncMessageChannel( * messageManager, sendAsyncMessage.bind(this)); * let rv = yield channel.send("remoteFunction", ["argument"]); * * @param {string} name * Function to call in the listener, e.g. for the message listener * "Marionette:foo8", use "foo". * @param {Array.<?>=} args * Argument list to pass the function. If args has a single entry * that is an object, we assume it's an old style dispatch, and * the object will passed literally. * * @return {Promise} * A promise that resolves to the result of the command. * @throws {TypeError} * If an unsupported reply type is received. * @throws {WebDriverError} * If an error is returned over the channel. */ send(name, args = []) { let uuid = uuidgen.generateUUID().toString(); // TODO(ato): Bug 1242595 this.activeMessageId = uuid; return new Promise((resolve, reject) => { let path = proxy.AsyncMessageChannel.makePath(uuid); let cb = msg => { this.activeMessageId = null; switch (msg.json.type) { case proxy.AsyncMessageChannel.ReplyType.Ok: case proxy.AsyncMessageChannel.ReplyType.Value: resolve(msg.json.data); break; case proxy.AsyncMessageChannel.ReplyType.Error: let err = WebDriverError.fromJSON(msg.json.data); reject(err); break; default: throw new TypeError( `Unknown async response type: ${msg.json.type}`); } }; this.dialogueObserver_ = (subject, topic) => { this.cancelAll(); resolve(); }; // start content message listener // and install observers for global- and tab modal dialogues this.addListener_(path, cb); modal.addHandler(this.dialogueObserver_); // sendAsync is GeckoDriver#sendAsync this.sendAsync(name, marshal(args), uuid); }); } /** * Reply to an asynchronous request. * * Passing an WebDriverError prototype will cause the receiving channel * to throw this error. * * Usage: * * let channel = proxy.AsyncMessageChannel( * messageManager, sendAsyncMessage.bind(this)); * * // throws in requester: * channel.reply(uuid, new WebDriverError()); * * // returns with value: * channel.reply(uuid, "hello world!"); * * // returns with undefined: * channel.reply(uuid); * * @param {UUID} uuid * Unique identifier of the request. * @param {?=} obj * Message data to reply with. */ reply(uuid, obj = undefined) { // TODO(ato): Eventually the uuid will be hidden in the dispatcher // in listener, and passing it explicitly to this function will be // unnecessary. if (typeof obj == "undefined") { this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Ok); } else if (error.isError(obj)) { let err = error.wrap(obj); this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Error, err); } else { this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Value, obj); } } sendReply_(uuid, type, data = undefined) { const path = proxy.AsyncMessageChannel.makePath(uuid); let payload; if (data && typeof data.toJSON == "function") { payload = data.toJSON(); } else { payload = data; } const msg = {type: type, data: payload}; // here sendAsync is actually the content frame's // sendAsyncMessage(path, message) global this.sendAsync(path, msg); } /** * Produces a path, or a name, for the message listener handler that * listens for a reply. * * @param {UUID} uuid * Unique identifier of the channel request. * * @return {string} * Path to be used for nsIMessageListener.addMessageListener. */ static makePath(uuid) { return "Marionette:asyncReply:" + uuid; } /** * Abort listening for responses, remove all modal dialogue handlers, * and cancel any ongoing requests in the listener. */ cancelAll() { this.removeAllListeners_(); modal.removeHandler(this.dialogueObserver_); // TODO(ato): It's not ideal to have listener specific behaviour here: this.sendAsync("cancelRequest"); } addListener_(path, callback) { let autoRemover = msg => { this.removeListener_(path); modal.removeHandler(this.dialogueObserver_); callback(msg); }; this.mm.addMessageListener(path, autoRemover); this.listeners_.set(path, autoRemover); } removeListener_(path) { if (!this.listeners_.has(path)) { return true; } let l = this.listeners_.get(path); this.mm.removeMessageListener(path, l[1]); return this.listeners_.delete(path); } removeAllListeners_() { let ok = true; for (let [p, cb] of this.listeners_) { ok |= this.removeListener_(p); } return ok; } }; proxy.AsyncMessageChannel.ReplyType = { Ok: 0, Value: 1, Error: 2, }; /** * A transparent content-to-chrome RPC interface where responses are * presented as promises. * * @param {nsIFrameMessageManager} frameMessageManager * The content frame's message manager, which itself is usually an * implementor of. */ proxy.toChromeAsync = function (frameMessageManager) { let sender = new AsyncChromeSender(frameMessageManager); return new Proxy(sender, ownPriorityGetterTrap); }; /** * Sends asynchronous RPC messages to chrome space using a frame's * sendAsyncMessage (nsIAsyncMessageSender) function. * * Example on how to use from a frame content script: * * let sender = new AsyncChromeSender(messageManager); * let promise = sender.send("runEmulatorCmd", "my command"); * let rv = yield promise; */ this.AsyncChromeSender = class { constructor(frameMessageManager) { this.mm = frameMessageManager; } /** * Call registered function in chrome context. * * @param {string} name * Function to call in the chrome, e.g. for "Marionette:foo", use * "foo". * @param {?} args * Argument list to pass the function. Must be JSON serialisable. * * @return {Promise} * A promise that resolves to the value sent back. */ send(name, args) { let uuid = uuidgen.generateUUID().toString(); let proxy = new Promise((resolve, reject) => { let responseListener = msg => { if (msg.json.id != uuid) { return; } this.mm.removeMessageListener( "Marionette:listenerResponse", responseListener); if ("value" in msg.json) { resolve(msg.json.value); } else if ("error" in msg.json) { reject(msg.json.error); } else { throw new TypeError( `Unexpected response: ${msg.name} ${JSON.stringify(msg.json)}`); } }; let msg = {arguments: marshal(args), id: uuid}; this.mm.addMessageListener( "Marionette:listenerResponse", responseListener); this.mm.sendAsyncMessage("Marionette:" + name, msg); }); return proxy; } }; /** * Creates a transparent interface from the content- to the chrome context. * * Calls to this object will be proxied via the frame's sendSyncMessage * (nsISyncMessageSender) function. Since the message is synchronous, * the return value is presented as a return value. * * Example on how to use from a frame content script: * * let chrome = proxy.toChrome(sendSyncMessage.bind(this)); * let cookie = chrome.getCookie("foo"); * * @param {nsISyncMessageSender} sendSyncMessageFn * The frame message manager's sendSyncMessage function. */ proxy.toChrome = function (sendSyncMessageFn) { let sender = new proxy.SyncChromeSender(sendSyncMessageFn); return new Proxy(sender, ownPriorityGetterTrap); }; /** * The SyncChromeSender sends synchronous RPC messages to the chrome * context, using a frame's sendSyncMessage (nsISyncMessageSender) * function. * * Example on how to use from a frame content script: * * let sender = new SyncChromeSender(sendSyncMessage.bind(this)); * let res = sender.send("addCookie", cookie); */ proxy.SyncChromeSender = class { constructor(sendSyncMessage) { this.sendSyncMessage_ = sendSyncMessage; } send(func, args) { let name = "Marionette:" + func.toString(); return this.sendSyncMessage_(name, marshal(args)); } }; var marshal = function (args) { if (args.length == 1 && typeof args[0] == "object") { return args[0]; } return args; };