Bug 1223907: Refactor cookies in Marionette draft
authorAndreas Tolfsen <ato@mozilla.com>
Fri, 13 Nov 2015 13:35:22 +0000
changeset 308684 4bb826a4f98af86c1accfaf96e1caacefb1fec6e
parent 308683 faf815a0fa9b052a38bce00c0c2aa1e2c9610936
child 511202 961210defc052401d494fff04c2b687ffee3d5a0
push id7513
push useratolfsen@mozilla.com
push dateFri, 13 Nov 2015 14:03:43 +0000
bugs1223907
milestone45.0a1
Bug 1223907: Refactor cookies in Marionette Moves most of the cookie implementation to a new file, testing/marionette/cookies.js. The new Cookies class encapsulates all APIs for manipulating and querying cookies from content space. It communicates with chrome space, where the cookie manager lives, through a new SyncContentSender provided by testing/marionette/proxy.js. This new interface provides synchronous and transparent communication from content to chrome, not dissimilar from how the original listener proxy works. r=dburns
testing/marionette/cookies.js
testing/marionette/driver.js
testing/marionette/error.js
testing/marionette/jar.mn
testing/marionette/listener.js
testing/marionette/proxy.js
new file mode 100644
--- /dev/null
+++ b/testing/marionette/cookies.js
@@ -0,0 +1,131 @@
+/* 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("resource://gre/modules/Log.jsm");
+Cu.import("chrome://marionette/content/error.js");
+
+const logger = Log.repository.getLogger("Marionette");
+
+this.EXPORTED_SYMBOLS = ["Cookies"];
+
+const IPV4_PORT_EXPR = /:\d+$/;
+
+/**
+ * Interface for manipulating cookies from content space.
+ */
+this.Cookies = class {
+
+  /**
+   * @param {function(): Document} documentFn
+   *     Closure that returns the current content document.
+   * @param {Proxy(SyncChromeSender)} chromeProxy
+   *     A synchronous proxy interface to chrome space.
+   */
+  constructor(documentFn, chromeProxy) {
+    this.documentFn_ = documentFn;
+    this.chrome = chromeProxy;
+  }
+
+  get document() {
+    return this.documentFn_();
+  }
+
+  [Symbol.iterator]() {
+    let path = this.document.location.pathname || "/";
+    let cs = this.chrome.getVisibleCookies(path, this.document.location.hostname)[0];
+    return cs[Symbol.iterator]();
+  }
+
+  /**
+   * Add a new cookie to a content document.
+   *
+   * @param {string} name
+   *     Cookie key.
+   * @param {string} value
+   *     Cookie value.
+   * @param {Object.<string, ?>} opts
+   *     An object with the optional fields {@code domain}, {@code path},
+   *     {@code secure}, {@code httpOnly}, and {@code expiry}.
+   *
+   * @return {Object.<string, ?>}
+   *     A serialisation of the cookie that was added.
+   *
+   * @throws UnableToSetCookieError
+   *     If the document's content type isn't HTML, the current document's
+   *     domain is a mismatch to the cookie's provided domain, or there
+   *     otherwise was issues with the input data.
+   */
+  add(name, value, opts={}) {
+    if (typeof this.document == "undefined" || !this.document.contentType.match(/html/i)) {
+      throw new UnableToSetCookieError(
+          "You may only set cookies on HTML documents: " + this.document.contentType);
+    }
+
+    if (!opts.expiry) {
+      // date twenty years into future, in seconds
+      let date = new Date();
+      let now = new Date(Date.now());
+      date.setYear(now.getFullYear() + 20);
+      opts.expiry = date.getTime() / 1000;
+    }
+
+    if (!opts.domain) {
+      opts.domain = this.document.location.host;
+    } else if (this.document.location.host.indexOf(opts.domain) < 0) {
+      throw new InvalidCookieDomainError(
+          "You may only set cookies for the current domain");
+    }
+
+    // remove port from domain, if present.
+    // unfortunately this catches IPv6 addresses by mistake
+    // TODO: Bug 814416
+    opts.domain = opts.domain.replace(IPV4_PORT_EXPR, "");
+
+    let cookie = {
+      domain: opts.domain,
+      path: opts.path,
+      name: name,
+      value: value,
+      secure: opts.secure,
+      httpOnly: opts.httpOnly,
+      session: false,
+      expiry: opts.expiry,
+    };
+    if (!this.chrome.addCookie(cookie)) {
+      throw new UnableToSetCookieError();
+    }
+
+    return cookie;
+  }
+
+  /**
+   * Delete cookie by reference or by name.
+   *
+   * @param {(string|Object.<string, ?>)} cookie
+   *     Name of cookie or cookie object.
+   *
+   * @throws {UnknownError}
+   *     If unable to delete the cookie.
+   */
+  delete(cookie) {
+    let name;
+    if (cookie.hasOwnProperty("name")) {
+      name = cookie.name;
+    } else {
+      name = cookie;
+    }
+
+    for (let candidate of this) {
+      if (candidate.name == name) {
+        if (!this.chrome.deleteCookie(candidate)) {
+          throw new UnknownError("Unable to delete cookie by name: " + name);
+        }
+      }
+    }
+  }
+};
--- a/testing/marionette/driver.js
+++ b/testing/marionette/driver.js
@@ -2286,37 +2286,74 @@ GeckoDriver.prototype.clearElement = fun
 GeckoDriver.prototype.switchToShadowRoot = function(cmd, resp) {
   let id;
   if (cmd.parameters) { id = cmd.parameters.id; }
   yield this.listener.switchToShadowRoot(id);
 };
 
 /** Add a cookie to the document. */
 GeckoDriver.prototype.addCookie = function(cmd, resp) {
-  yield this.listener.addCookie({cookie: cmd.parameters.cookie});
+  let cb = msg => {
+    this.mm.removeMessageListener("Marionette:addCookie", cb);
+    let cookie = msg.json;
+    Services.cookies.add(
+        cookie.domain,
+        cookie.path,
+        cookie.name,
+        cookie.value,
+        cookie.secure,
+        cookie.httpOnly,
+        cookie.session,
+        cookie.expiry);
+    return true;
+  };
+  this.mm.addMessageListener("Marionette:addCookie", cb);
+  yield this.listener.addCookie(cmd.parameters.cookie);
 };
 
 /**
  * Get all the cookies for the current domain.
  *
  * This is the equivalent of calling {@code document.cookie} and parsing
  * the result.
  */
 GeckoDriver.prototype.getCookies = function(cmd, resp) {
   resp.body = yield this.listener.getCookies();
 };
 
 /** Delete all cookies that are visible to a document. */
 GeckoDriver.prototype.deleteAllCookies = function(cmd, resp) {
+  let cb = msg => {
+    let cookie = msg.json;
+    cookieManager.remove(
+        cookie.host,
+        cookie.name,
+        cookie.path,
+        false);
+    return true;
+  };
+  this.mm.addMessageListener("Marionette:deleteCookie", cb);
   yield this.listener.deleteAllCookies();
+  this.mm.removeMessageListener("Marionette:deleteCookie", cb);
 };
 
 /** Delete a cookie by name. */
 GeckoDriver.prototype.deleteCookie = function(cmd, resp) {
-  yield this.listener.deleteCookie({name: cmd.parameters.name});
+  let cb = msg => {
+    this.mm.removeMessageListener("Marionette:deleteCookie", cb);
+    let cookie = msg.json;
+    cookieManager.remove(
+        cookie.host,
+        cookie.name,
+        cookie.path,
+        false);
+    return true;
+  };
+  this.mm.addMessageListener("Marionette:deleteCookie", cb);
+  yield this.listener.deleteCookie(cmd.parameters.name);
 };
 
 /**
  * Close the current window, ending the session if it's the last
  * window currently open.
  *
  * On B2G this method is a noop and will return immediately.
  */
@@ -2816,17 +2853,17 @@ GeckoDriver.prototype.receiveMessage = f
         if (message.json.storePrevious) {
           this.previousFrameElement = this.currentFrameElement;
         }
         this.currentFrameElement = message.json.frameValue;
       }
       break;
 
     case "Marionette:getVisibleCookies":
-      let [currentPath, host] = message.json.value;
+      let [currentPath, host] = message.json;
       let isForCurrentPath = path => currentPath.indexOf(path) != -1;
       let results = [];
 
       let en = cookieManager.getCookiesFromHost(host);
       while (en.hasMoreElements()) {
         let cookie = en.getNext().QueryInterface(Ci.nsICookie2);
         // take the hostname and progressively shorten
         let hostname = host;
@@ -2844,38 +2881,16 @@ GeckoDriver.prototype.receiveMessage = f
             });
             break;
           }
           hostname = hostname.replace(/^.*?\./, "");
         } while (hostname.indexOf(".") != -1);
       }
       return results;
 
-    case "Marionette:addCookie":
-      let cookieToAdd = message.json.value;
-      Services.cookies.add(
-          cookieToAdd.domain,
-          cookieToAdd.path,
-          cookieToAdd.name,
-          cookieToAdd.value,
-          cookieToAdd.secure,
-          cookieToAdd.httpOnly,
-          false,
-          cookieToAdd.expiry);
-      return true;
-
-    case "Marionette:deleteCookie":
-      let cookieToDelete = message.json.value;
-      cookieManager.remove(
-          cookieToDelete.host,
-          cookieToDelete.name,
-          cookieToDelete.path,
-          false);
-      return true;
-
     case "Marionette:getFiles":
       // Generates file objects to send back to the content script
       // for handling file uploads.
       let val = message.json.value;
       let command_id = message.json.command_id;
       Cu.importGlobalProperties(["File"]);
       try {
         let file = new File(val);
--- a/testing/marionette/error.js
+++ b/testing/marionette/error.js
@@ -26,58 +26,33 @@ const errors = [
   "UnknownCommandError",
   "UnknownError",
   "UnsupportedOperationError",
   "WebDriverError",
 ];
 
 this.EXPORTED_SYMBOLS = ["error"].concat(errors);
 
-// Because XPCOM is a cesspool of undocumented odd behaviour,
-// Object.getPrototypeOf(err) causes another exception if err is an XPCOM
-// exception, and cannot be used to determine if err is a prototypal Error.
-//
-// Consequently we need to check for properties in its prototypal chain
-// (using in, instead of err.hasOwnProperty because that causes other
-// issues).
-//
-// Since the input is arbitrary it might _not_ be an Error, and can as
-// such be an object with a "result" property without it being considered to
-// be an exception.  The solution is to build a lookup table of XPCOM
-// exceptions from Components.results and check if the value of err#results
-// is in that table.
-const XPCOM_EXCEPTIONS = [];
-{
-  for (let prop in Cr) {
-    XPCOM_EXCEPTIONS.push(Cr[prop]);
-  }
-}
-
 this.error = {};
 
 /**
- * Determines if the given status is successful.
- */
-error.isSuccess = status => status === "success";
-
-/**
  * Checks if obj is an instance of the Error prototype in a safe manner.
  * Prefer using this over using instanceof since the Error prototype
  * isn't unique across browsers, and XPCOM exceptions are special
  * snowflakes.
  *
  * @param {*} val
  *     Any value that should be undergo the test for errorness.
  * @return {boolean}
  *     True if error, false otherwise.
  */
 error.isError = function(val) {
   if (val === null || typeof val != "object") {
     return false;
-  } else if ("result" in val && val.result in XPCOM_EXCEPTIONS) {
+  } else if ("result" in val) {
     return true;
   } else {
     return Object.getPrototypeOf(val) == "Error";
   }
 };
 
 /**
  * Checks if obj is an object in the WebDriverError prototypal chain.
--- a/testing/marionette/jar.mn
+++ b/testing/marionette/jar.mn
@@ -17,16 +17,17 @@ marionette.jar:
   content/ChromeUtils.js  (ChromeUtils.js)
   content/error.js (error.js)
   content/command.js (command.js)
   content/dispatcher.js (dispatcher.js)
   content/emulator.js (emulator.js)
   content/modal.js (modal.js)
   content/proxy.js (proxy.js)
   content/capture.js (capture.js)
+  content/cookies.js (cookies.js)
 #ifdef ENABLE_TESTS
   content/test.xul  (client/marionette/chrome/test.xul)
   content/test2.xul  (client/marionette/chrome/test2.xul)
   content/test_nested_iframe.xul  (client/marionette/chrome/test_nested_iframe.xul)
   content/test_anonymous_content.xul  (client/marionette/chrome/test_anonymous_content.xul)
 #endif
 
 % content specialpowers %content/
--- a/testing/marionette/listener.js
+++ b/testing/marionette/listener.js
@@ -9,18 +9,21 @@ var uuidGen = Cc["@mozilla.org/uuid-gene
 
 var loader = Cc["@mozilla.org/moz/jssubscript-loader;1"]
                .getService(Ci.mozIJSSubScriptLoader);
 
 loader.loadSubScript("chrome://marionette/content/simpletest.js");
 loader.loadSubScript("chrome://marionette/content/common.js");
 loader.loadSubScript("chrome://marionette/content/actions.js");
 Cu.import("chrome://marionette/content/capture.js");
+Cu.import("chrome://marionette/content/cookies.js");
 Cu.import("chrome://marionette/content/elements.js");
 Cu.import("chrome://marionette/content/error.js");
+Cu.import("chrome://marionette/content/proxy.js");
+
 Cu.import("resource://gre/modules/FileUtils.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 var utils = {};
 utils.window = content;
 // Load Event/ChromeUtils for use with JS scripts:
 loader.loadSubScript("chrome://marionette/content/EventUtils.js", utils);
@@ -72,16 +75,19 @@ var readyStateTimer = Cc["@mozilla.org/t
 // timer for navigation commands.
 var navTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
 var onDOMContentLoaded;
 // Send move events about this often
 var EVENT_INTERVAL = 30; // milliseconds
 // last touch for each fingerId
 var multiLast = {};
 
+var chrome = proxy.toChrome(sendSyncMessage.bind(this));
+var cookies = new Cookies(() => curContainer.frame.document, chrome);
+
 Cu.import("resource://gre/modules/Log.jsm");
 var logger = Log.repository.getLogger("Marionette");
 logger.info("loaded listener.js");
 var modalHandler = function() {
   // This gets called on the system app only since it receives the mozbrowserprompt event
   sendSyncMessage("Marionette:switchedToFrame", { frameValue: null, storePrevious: true });
   let isLocal = sendSyncMessage("MarionetteFrame:handleModal", {})[0].value;
   if (isLocal) {
@@ -213,16 +219,19 @@ var findElementsContentFn = dispatch(fin
 var isElementSelectedFn = dispatch(isElementSelected);
 var clearElementFn = dispatch(clearElement);
 var isElementDisplayedFn = dispatch(isElementDisplayed);
 var getElementValueOfCssPropertyFn = dispatch(getElementValueOfCssProperty);
 var switchToShadowRootFn = dispatch(switchToShadowRoot);
 var getCookiesFn = dispatch(getCookies);
 var singleTapFn = dispatch(singleTap);
 var takeScreenshotFn = dispatch(takeScreenshot);
+var addCookieFn = dispatch(addCookie);
+var deleteCookieFn = dispatch(deleteCookie);
+var deleteAllCookiesFn = dispatch(deleteAllCookies);
 
 /**
  * Start all message listeners
  */
 function startListeners() {
   addMessageListenerId("Marionette:receiveFiles", receiveFiles);
   addMessageListenerId("Marionette:newSession", newSession);
   addMessageListenerId("Marionette:executeScript", executeScript);
@@ -259,20 +268,20 @@ function startListeners() {
   addMessageListenerId("Marionette:switchToShadowRoot", switchToShadowRootFn);
   addMessageListenerId("Marionette:deleteSession", deleteSession);
   addMessageListenerId("Marionette:sleepSession", sleepSession);
   addMessageListenerId("Marionette:emulatorCmdResult", emulatorCmdResult);
   addMessageListenerId("Marionette:importScript", importScript);
   addMessageListenerId("Marionette:getAppCacheStatus", getAppCacheStatus);
   addMessageListenerId("Marionette:setTestName", setTestName);
   addMessageListenerId("Marionette:takeScreenshot", takeScreenshotFn);
-  addMessageListenerId("Marionette:addCookie", addCookie);
+  addMessageListenerId("Marionette:addCookie", addCookieFn);
   addMessageListenerId("Marionette:getCookies", getCookiesFn);
-  addMessageListenerId("Marionette:deleteAllCookies", deleteAllCookies);
-  addMessageListenerId("Marionette:deleteCookie", deleteCookie);
+  addMessageListenerId("Marionette:deleteAllCookies", deleteAllCookiesFn);
+  addMessageListenerId("Marionette:deleteCookie", deleteCookieFn);
 }
 
 /**
  * Used during newSession and restart, called to set up the modal dialog listener in b2g
  */
 function waitForReady() {
   if (content.document.readyState == 'complete') {
     readyStateTimer.cancel();
@@ -364,20 +373,20 @@ function deleteSession(msg) {
   removeMessageListenerId("Marionette:switchToShadowRoot", switchToShadowRootFn);
   removeMessageListenerId("Marionette:deleteSession", deleteSession);
   removeMessageListenerId("Marionette:sleepSession", sleepSession);
   removeMessageListenerId("Marionette:emulatorCmdResult", emulatorCmdResult);
   removeMessageListenerId("Marionette:importScript", importScript);
   removeMessageListenerId("Marionette:getAppCacheStatus", getAppCacheStatus);
   removeMessageListenerId("Marionette:setTestName", setTestName);
   removeMessageListenerId("Marionette:takeScreenshot", takeScreenshotFn);
-  removeMessageListenerId("Marionette:addCookie", addCookie);
+  removeMessageListenerId("Marionette:addCookie", addCookieFn);
   removeMessageListenerId("Marionette:getCookies", getCookiesFn);
-  removeMessageListenerId("Marionette:deleteAllCookies", deleteAllCookies);
-  removeMessageListenerId("Marionette:deleteCookie", deleteCookie);
+  removeMessageListenerId("Marionette:deleteAllCookies", deleteAllCookiesFn);
+  removeMessageListenerId("Marionette:deleteCookie", deleteCookieFn);
   if (isB2G) {
     content.removeEventListener("mozbrowsershowmodalprompt", modalHandler, false);
   }
   elementManager.reset();
   // reset container frame to the top-most frame
   curContainer = { frame: content, shadowRoot: null };
   curContainer.frame.focus();
   actions.touchIds = {};
@@ -1828,65 +1837,26 @@ function switchToFrame(msg) {
     curContainer.frame = curContainer.frame.contentWindow;
     if (msg.json.focus)
       curContainer.frame.focus();
     checkTimer.initWithCallback(checkLoad, 100, Ci.nsITimer.TYPE_ONE_SHOT);
   }
 
   sendResponse({value: rv}, command_id);
 }
- /**
-  * Add a cookie to the document
-  */
-function addCookie(msg) {
-  let cookie = msg.json.cookie;
-  if (!cookie.expiry) {
-    var date = new Date();
-    var thePresent = new Date(Date.now());
-    date.setYear(thePresent.getFullYear() + 20);
-    cookie.expiry = date.getTime() / 1000;  // Stored in seconds.
-  }
 
-  if (!cookie.domain) {
-    var location = curContainer.frame.document.location;
-    cookie.domain = location.hostname;
-  } else {
-    var currLocation = curContainer.frame.location;
-    var currDomain = currLocation.host;
-    if (currDomain.indexOf(cookie.domain) == -1) {
-      sendError(new InvalidCookieDomainError("You may only set cookies for the current domain"), msg.json.command_id);
-    }
-  }
-
-  // The cookie's domain may include a port. Which is bad. Remove it
-  // We'll catch ip6 addresses by mistake. Since no-one uses those
-  // this will be okay for now. See Bug 814416
-  if (cookie.domain.match(/:\d+$/)) {
-    cookie.domain = cookie.domain.replace(/:\d+$/, '');
-  }
-
-  var document = curContainer.frame.document;
-  if (!document || !document.contentType.match(/html/i)) {
-    sendError(new UnableToSetCookieError("You may only set cookies on html documents"), msg.json.command_id);
-  }
-
-  let added = sendSyncMessage("Marionette:addCookie", {value: cookie});
-  if (added[0] !== true) {
-    sendError(new UnableToSetCookieError(), msg.json.command_id);
-    return;
-  }
-  sendOk(msg.json.command_id);
+function addCookie(cookie) {
+  cookies.add(cookie.name, cookie.value, cookie);
 }
 
 /**
  * Get all cookies for the current domain.
  */
 function getCookies() {
   let rv = [];
-  let cookies = getVisibleCookies(curContainer.frame.location);
 
   for (let cookie of cookies) {
     let expires = cookie.expires;
     // session cookie, don't return an expiry
     if (expires == 0) {
       expires = null;
     // date before epoch time, cap to epoch
     } else if (expires == 1) {
@@ -1902,57 +1872,29 @@ function getCookies() {
       'expiry': expires
     });
   }
 
   return rv;
 }
 
 /**
- * Delete a cookie by name
+ * Delete a cookie by name.
  */
-function deleteCookie(msg) {
-  let toDelete = msg.json.name;
-  let cookies = getVisibleCookies(curContainer.frame.location);
-  for (let cookie of cookies) {
-    if (cookie.name == toDelete) {
-      let deleted = sendSyncMessage("Marionette:deleteCookie", {value: cookie});
-      if (deleted[0] !== true) {
-        sendError(new UnknownError("Could not delete cookie: " + msg.json.name), msg.json.command_id);
-        return;
-      }
-    }
-  }
-
-  sendOk(msg.json.command_id);
+function deleteCookie(name) {
+  cookies.delete(name);
 }
 
 /**
- * Delete all the visibile cookies on a page
+ * Delete all the visibile cookies on a page.
  */
-function deleteAllCookies(msg) {
-  let cookies = getVisibleCookies(curContainer.frame.location);
+function deleteAllCookies() {
   for (let cookie of cookies) {
-    let deleted = sendSyncMessage("Marionette:deleteCookie", {value: cookie});
-    if (!deleted[0]) {
-      sendError(new UnknownError("Could not delete cookie: " + JSON.stringify(cookie)), msg.json.command_id);
-      return;
-    }
+    cookies.delete(cookie);
   }
-  sendOk(msg.json.command_id);
-}
-
-/**
- * Get all the visible cookies from a location
- */
-function getVisibleCookies(location) {
-  let currentPath = location.pathname || '/';
-  let result = sendSyncMessage("Marionette:getVisibleCookies",
-                               {value: [currentPath, location.hostname]});
-  return result[0];
 }
 
 function getAppCacheStatus(msg) {
   sendResponse({ value: curContainer.frame.applicationCache.status },
                msg.json.command_id);
 }
 
 // emulator callbacks
--- a/testing/marionette/proxy.js
+++ b/testing/marionette/proxy.js
@@ -1,164 +1,202 @@
 /* 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";
 
-var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("chrome://marionette/content/modal.js");
 
 this.EXPORTED_SYMBOLS = ["proxy"];
 
 const MARIONETTE_OK = "Marionette:ok";
 const MARIONETTE_DONE = "Marionette:done";
 const MARIONETTE_ERROR = "Marionette:error";
 
 const logger = Log.repository.getLogger("Marionette");
 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
- * processes.
+ * contexts.
  *
  * Calls to this object will be proxied via the message manager to the active
  * browsing context (content) and responses will be provided back 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
  *     Function returning the current message manager.
  * @param {function(string, Object, number)} sendAsyncFn
  *     Callback for sending async messages to the current listener.
  */
 proxy.toListener = function(mmFn, sendAsyncFn) {
-  let sender = new ContentSender(mmFn, sendAsyncFn);
-  let handler = {
-    get: (obj, prop) => {
-      if (obj.hasOwnProperty(prop)) {
-        return obj[prop];
-      }
-      return (...args) => obj.send(prop, args);
-    }
-  };
-  return new Proxy(sender, handler);
+  let sender = new AsyncContentSender(mmFn, sendAsyncFn);
+  return new Proxy(sender, ownPriorityGetterTrap);
 };
 
 /**
- * The ContentSender allows one to make synchronous calls to the
+ * The AsyncContentSender allows one to make synchronous calls to the
  * message listener of the content frame of the current browsing context.
  *
  * Presumptions about the responses from content space are made so we
  * can provide a nicer API on top of the message listener primitives that
  * make calls from chrome- to content space seem synchronous by leveraging
  * promises.
  *
  * The promise is guaranteed not to resolve until the execution of the
  * command in content space is complete.
- *
- * @param {function(): (nsIMessageSender|nsIMessageBroadcaster)} mmFn
- *     Function returning the current message manager.
- * @param {function(string, Object, number)} sendAsyncFn
- *     Callback for sending async messages to the current listener.
  */
-var ContentSender = function(mmFn, sendAsyncFn) {
-  this.curId = null;
-  this.sendAsync = sendAsyncFn;
-  this.mmFn_ = mmFn;
-  this._listeners = [];
-};
-
-Object.defineProperty(ContentSender.prototype, "mm", {
-  get: function() { return this.mmFn_(); }
-});
-
-ContentSender.prototype.removeListeners = function () {
-  this._listeners.map(l => this.mm.removeMessageListener(l[0], l[1]));
-  this._listeners = [];
-}
+this.AsyncContentSender = class {
+  constructor(mmFn, sendAsyncFn) {
+    this.curId = null;
+    this.sendAsync = sendAsyncFn;
+    this.mmFn_ = mmFn;
+    this._listeners = [];
+  }
 
-/**
- * Call registered function in the frame script environment of the
- * current browsing context's content frame.
- *
- * @param {string} name
- *     Function to call in the listener, e.g. for "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.
- */
-ContentSender.prototype.send = function(name, args) {
-  if (this._listeners[0]) {
-    // A prior (probably timed-out) request has left listeners behind.
-    // Remove them before proceeding.
-    logger.warn("A previous failed command left content listeners behind!");
-    this.removeListeners();
+  get mm() {
+    return this.mmFn_();
+  }
+
+  removeListeners() {
+    this._listeners.map(l => this.mm.removeMessageListener(l[0], l[1]));
+    this._listeners = [];
   }
 
-  this.curId = uuidgen.generateUUID().toString();
+  /**
+   * Call registered function in the frame script environment of the
+   * current browsing context's content frame.
+   *
+   * @param {string} name
+   *     Function to call in the listener, e.g. for "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.
+   */
+  send(name, args) {
+    if (this._listeners[0]) {
+      // A prior (probably timed-out) request has left listeners behind.
+      // Remove them before proceeding.
+      logger.warn("A previous failed command left content listeners behind!");
+      this.removeListeners();
+    }
+
+    this.curId = uuidgen.generateUUID().toString();
 
-  let proxy = new Promise((resolve, reject) => {
-    let removeListeners = (n, fn) => {
-      let rmFn = msg => {
-        if (this.curId !== msg.json.command_id) {
-          logger.warn("Skipping out-of-sync response from listener: " +
-              `Expected response to ${name} with ID ${this.curId}, ` +
-              "but got: " + msg.name + msg.json.toSource());
-          return;
-        }
+    let proxy = new Promise((resolve, reject) => {
+      let removeListeners = (n, fn) => {
+        let rmFn = msg => {
+          if (this.curId !== msg.json.command_id) {
+            logger.warn("Skipping out-of-sync response from listener: " +
+                `Expected response to ${name} with ID ${this.curId}, ` +
+                "but got: " + msg.name + msg.json.toSource());
+            return;
+          }
+
+          this.removeListeners();
+          modal.removeHandler(handleDialog);
 
-        this.removeListeners();
-        modal.removeHandler(handleDialog);
+          fn(msg);
+          this.curId = null;
+        };
+
+        this._listeners.push([n, rmFn]);
+        return rmFn;
+      };
 
-        fn(msg);
-        this.curId = null;
+      let okListener = () => resolve();
+      let valListener = msg => resolve(msg.json.value);
+      let errListener = msg => reject(msg.objects.error);
+
+      let handleDialog = (subject, topic) => {
+        this.removeListeners()
+        modal.removeHandler(handleDialog);
+        this.sendAsync("cancelRequest");
+        resolve();
       };
 
-      this._listeners.push([n, rmFn]);
-      return rmFn;
-    };
-
-    let okListener = () => resolve();
-    let valListener = msg => resolve(msg.json.value);
-    let errListener = msg => reject(msg.objects.error);
-
-    let handleDialog = (subject, topic) => {
-      this.removeListeners()
-      modal.removeHandler(handleDialog);
-      this.sendAsync("cancelRequest");
-      resolve();
-    };
+      // start content process listeners, and install observers for global-
+      // and tab modal dialogues
+      this.mm.addMessageListener(MARIONETTE_OK, removeListeners(MARIONETTE_OK, okListener));
+      this.mm.addMessageListener(MARIONETTE_DONE, removeListeners(MARIONETTE_DONE, valListener));
+      this.mm.addMessageListener(MARIONETTE_ERROR, removeListeners(MARIONETTE_ERROR, errListener));
+      modal.addHandler(handleDialog);
 
-    // start content process listeners, and install observers for global-
-    // and tab modal dialogues
-    this.mm.addMessageListener(MARIONETTE_OK, removeListeners(MARIONETTE_OK, okListener));
-    this.mm.addMessageListener(MARIONETTE_DONE, removeListeners(MARIONETTE_DONE, valListener));
-    this.mm.addMessageListener(MARIONETTE_ERROR, removeListeners(MARIONETTE_ERROR, errListener));
-    modal.addHandler(handleDialog);
+      this.sendAsync(name, marshal(args), this.curId);
+    });
 
-    // new style dispatches are arrays of arguments, old style dispatches
-    // are key-value objects
-    let msg = args;
-    if (args.length == 1 && typeof args[0] == "object") {
-      msg = args[0];
-    }
-
-    this.sendAsync(name, msg, this.curId);
-  });
-
-  return proxy;
+    return proxy;
+  }
 };
 
-proxy.ContentSender = ContentSender;
+/**
+ * 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 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);
+ */
+this.SyncChromeSender = class {
+  constructor(sendSyncMessage) {
+    this.sendSyncMessage_ = sendSyncMessage;
+  }
+
+  send(func, args) {
+    let name = "Marionette:" + func;
+    return this.sendSyncMessage_(name, marshal(args));
+  }
+};
+
+function marshal(args) {
+  if (args.length == 1 && typeof args[0] == "object") {
+    return args[0];
+  }
+  return args;
+}