Bug 1442133 - FxA messages client implementation. r=markh,tcsc
authorEdouard Oger <eoger@fastmail.com>
Wed, 07 Mar 2018 13:38:12 -0500
changeset 468991 8ef95c56df04726659b7c172dc473cecb4c099fc
parent 468990 93d49d1a2688bfb4f185f4a586ac05b9477dacdc
child 468992 abb06ef37565e6e35b1ebc89f14ec35da31907d1
push id9165
push userasasaki@mozilla.com
push dateThu, 26 Apr 2018 21:04:54 +0000
treeherdermozilla-beta@064c3804de2e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh, tcsc
bugs1442133
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1442133 - FxA messages client implementation. r=markh,tcsc MozReview-Commit-ID: EWYlZLdyUA0
browser/app/profile/firefox.js
browser/base/content/browser-sync.js
browser/components/nsBrowserGlue.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsMessages.js
services/fxaccounts/FxAccountsPush.js
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_messages.js
services/fxaccounts/tests/xpcshell/test_push_service.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
services/sync/modules/engines/clients.js
services/sync/modules/policies.js
tools/lint/eslint/modules.json
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1427,16 +1427,19 @@ pref("identity.mobilepromo.ios", "https:
 // Migrate any existing Firefox Account data from the default profile to the
 // Developer Edition profile.
 #ifdef MOZ_DEV_EDITION
 pref("identity.fxaccounts.migrateToDevEdition", true);
 #else
 pref("identity.fxaccounts.migrateToDevEdition", false);
 #endif
 
+// If activated, send tab will use the new FxA messages backend.
+pref("identity.fxaccounts.messages.enabled", false);
+
 // On GTK, we now default to showing the menubar only when alt is pressed:
 #ifdef MOZ_WIDGET_GTK
 pref("ui.key.menuAccessKeyFocuses", true);
 #endif
 
 #ifdef NIGHTLY_BUILD
 pref("media.eme.vp9-in-mp4.enabled", true);
 #else
--- a/browser/base/content/browser-sync.js
+++ b/browser/base/content/browser-sync.js
@@ -314,20 +314,44 @@ var gSync = {
   },
 
   openSendToDevicePromo() {
     let url = this.PRODUCT_INFO_BASE_URL;
     url += "send-tabs/?utm_source=" + Services.appinfo.name.toLowerCase();
     switchToTabHavingURI(url, true, { replaceQueryString: true });
   },
 
-  sendTabToDevice(url, clientId, title) {
-    Weave.Service.clientsEngine.sendURIToClientForDisplay(url, clientId, title).catch(e => {
-      console.error("Could not send tab to device", e);
-    });
+  async sendTabToDevice(url, clients, title) {
+    let devices;
+    try {
+      devices = await fxAccounts.getDeviceList();
+    } catch (e) {
+      console.error("Could not get the FxA device list", e);
+      devices = []; // We can still run in degraded mode.
+    }
+    const toSendMessages = [];
+    for (const client of clients) {
+      const device = devices.find(d => d.id == client.fxaDeviceId);
+      if (device && fxAccounts.messages.canReceiveSendTabMessages(device)) {
+        toSendMessages.push(device);
+      } else {
+        try {
+          await Weave.Service.clientsEngine.sendURIToClientForDisplay(url, client.id, title);
+        } catch (e) {
+          console.error("Could not send tab to device", e);
+        }
+      }
+    }
+    if (toSendMessages.length) {
+      try {
+        await fxAccounts.messages.sendTab(toSendMessages, {url, title});
+      } catch (e) {
+        console.error("Could not send tab to device", e);
+      }
+    }
   },
 
   populateSendTabToDevicesMenu(devicesPopup, url, title, createDeviceNodeFn) {
     if (!createDeviceNodeFn) {
       createDeviceNodeFn = (clientId, name, clientType, lastModified) => {
         let eltName = name ? "menuitem" : "menuseparator";
         return document.createElement(eltName);
       };
@@ -358,28 +382,33 @@ var gSync = {
       this._appendSendTabVerify(fragment, createDeviceNodeFn);
     } else /* status is STATUS_NOT_CONFIGURED */ {
       this._appendSendTabUnconfigured(fragment, createDeviceNodeFn);
     }
 
     devicesPopup.appendChild(fragment);
   },
 
+  // TODO: once our transition from the old-send tab world is complete,
+  // this list should be built using the FxA device list instead of the client
+  // collection.
   _appendSendTabDeviceList(fragment, createDeviceNodeFn, url, title) {
+    const onSendAllCommand = (event) => {
+      this.sendTabToDevice(url, this.remoteClients, title);
+    };
     const onTargetDeviceCommand = (event) => {
-      let clients = event.target.getAttribute("clientId") ?
-        [event.target.getAttribute("clientId")] :
-        this.remoteClients.map(client => client.id);
-
-      clients.forEach(clientId => this.sendTabToDevice(url, clientId, title));
+      const clientId = event.target.getAttribute("clientId");
+      const client = this.remoteClients.find(c => c.id == clientId);
+      this.sendTabToDevice(url, [client], title);
     };
 
     function addTargetDevice(clientId, name, clientType, lastModified) {
       const targetDevice = createDeviceNodeFn(clientId, name, clientType, lastModified);
-      targetDevice.addEventListener("command", onTargetDeviceCommand, true);
+      targetDevice.addEventListener("command", clientId ? onTargetDeviceCommand :
+                                                          onSendAllCommand, true);
       targetDevice.classList.add("sync-menuitem", "sendtab-target");
       targetDevice.setAttribute("clientId", clientId);
       targetDevice.setAttribute("clientType", clientType);
       targetDevice.setAttribute("label", name);
       fragment.appendChild(targetDevice);
     }
 
     const clients = this.remoteClients;
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -439,16 +439,17 @@ BrowserGlue.prototype = {
         this._onVerifyLoginNotification(JSON.parse(data));
         break;
       case "fxaccounts:device_disconnected":
         data = JSON.parse(data);
         if (data.isLocalDevice) {
           this._onDeviceDisconnected();
         }
         break;
+      case "fxaccounts:messages:display-tabs":
       case "weave:engine:clients:display-uris":
         this._onDisplaySyncURIs(subject);
         break;
       case "session-save":
         this._setPrefToSaveSession(true);
         subject.QueryInterface(Ci.nsISupportsPRBool);
         subject.data = true;
         break;
@@ -593,16 +594,17 @@ BrowserGlue.prototype = {
       os.addObserver(this, "browser-lastwindow-close-requested");
       os.addObserver(this, "browser-lastwindow-close-granted");
     }
     os.addObserver(this, "weave:service:ready");
     os.addObserver(this, "fxaccounts:onverified");
     os.addObserver(this, "fxaccounts:device_connected");
     os.addObserver(this, "fxaccounts:verify_login");
     os.addObserver(this, "fxaccounts:device_disconnected");
+    os.addObserver(this, "fxaccounts:messages:display-tabs");
     os.addObserver(this, "weave:engine:clients:display-uris");
     os.addObserver(this, "session-save");
     os.addObserver(this, "places-init-complete");
     os.addObserver(this, "distribution-customization-complete");
     os.addObserver(this, "handle-xul-text-link");
     os.addObserver(this, "profile-before-change");
     os.addObserver(this, "keyword-search");
     os.addObserver(this, "browser-search-engine-modified");
@@ -635,16 +637,17 @@ BrowserGlue.prototype = {
       os.removeObserver(this, "browser-lastwindow-close-requested");
       os.removeObserver(this, "browser-lastwindow-close-granted");
     }
     os.removeObserver(this, "weave:service:ready");
     os.removeObserver(this, "fxaccounts:onverified");
     os.removeObserver(this, "fxaccounts:device_connected");
     os.removeObserver(this, "fxaccounts:verify_login");
     os.removeObserver(this, "fxaccounts:device_disconnected");
+    os.removeObserver(this, "fxaccounts:messages:display-tabs");
     os.removeObserver(this, "weave:engine:clients:display-uris");
     os.removeObserver(this, "session-save");
     if (this._bookmarksBackupIdleTime) {
       this._idleService.removeIdleObserver(this, this._bookmarksBackupIdleTime);
       delete this._bookmarksBackupIdleTime;
     }
     if (this._lateTasksIdleObserver) {
       this._idleService.removeIdleObserver(this._lateTasksIdleObserver, LATE_TASKS_IDLE_TIME_SEC);
@@ -2538,17 +2541,17 @@ BrowserGlue.prototype = {
         tab.setAttribute("attention", true);
         return tab;
       };
 
       const firstTab = await openTab(URIs[0]);
       await Promise.all(URIs.slice(1).map(URI => openTab(URI)));
 
       let title, body;
-      const deviceName = Weave.Service.clientsEngine.getClientName(URIs[0].clientId);
+      const deviceName = URIs[0].sender.name;
       const bundle = Services.strings.createBundle("chrome://browser/locale/accounts.properties");
       if (URIs.length == 1) {
         // Due to bug 1305895, tabs from iOS may not have device information, so
         // we have separate strings to handle those cases. (See Also
         // unnamedTabsArrivingNotificationNoDevice.body below)
         if (deviceName) {
           title = bundle.formatStringFromName("tabArrivingNotificationWithDevice.title", [deviceName], 1);
         } else {
@@ -2562,17 +2565,17 @@ BrowserGlue.prototype = {
         if (win.gURLBar) {
           body = win.gURLBar.trimValue(body);
         }
         if (wasTruncated) {
           body = bundle.formatStringFromName("singleTabArrivingWithTruncatedURL.body", [body], 1);
         }
       } else {
         title = bundle.GetStringFromName("multipleTabsArrivingNotification.title");
-        const allSameDevice = URIs.every(URI => URI.clientId == URIs[0].clientId);
+        const allSameDevice = URIs.every(URI => URI.sender.id == URIs[0].sender.id);
         const unknownDevice = allSameDevice && !deviceName;
         let tabArrivingBody;
         if (unknownDevice) {
           tabArrivingBody = "unnamedTabsArrivingNotificationNoDevice.body";
         } else if (allSameDevice) {
           tabArrivingBody = "unnamedTabsArrivingNotification2.body";
         } else {
           tabArrivingBody = "unnamedTabsArrivingNotificationMultiple2.body";
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -25,46 +25,52 @@ ChromeUtils.defineModuleGetter(this, "Fx
   "resource://gre/modules/FxAccountsConfig.jsm");
 
 ChromeUtils.defineModuleGetter(this, "jwcrypto",
   "resource://services-crypto/jwcrypto.jsm");
 
 ChromeUtils.defineModuleGetter(this, "FxAccountsOAuthGrantClient",
   "resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
 
+ChromeUtils.defineModuleGetter(this, "FxAccountsMessages",
+  "resource://gre/modules/FxAccountsMessages.js");
+
 ChromeUtils.defineModuleGetter(this, "FxAccountsProfile",
   "resource://gre/modules/FxAccountsProfile.jsm");
 
 ChromeUtils.defineModuleGetter(this, "Utils",
   "resource://services-sync/util.js");
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "FXA_ENABLED",
     "identity.fxaccounts.enabled", true);
 
 // All properties exposed by the public FxAccounts API.
 var publicProperties = [
+  "_withCurrentAccountState", // fxaccounts package only!
   "accountStatus",
   "canGetKeys",
   "checkVerificationStatus",
   "getAccountsClient",
   "getAssertion",
   "getDeviceId",
   "getDeviceList",
   "getKeys",
   "getOAuthToken",
   "getProfileCache",
+  "getPushSubscription",
   "getSignedInUser",
   "getSignedInUserProfile",
   "handleAccountDestroyed",
   "handleDeviceDisconnection",
   "handleEmailUpdated",
   "hasLocalSession",
   "invalidateCertificate",
   "loadAndPoll",
   "localtimeOffsetMsec",
+  "messages",
   "notifyDevices",
   "now",
   "removeCachedOAuthToken",
   "resendVerificationEmail",
   "resetCredentials",
   "sessionStatus",
   "setProfileCache",
   "setSignedInUser",
@@ -405,23 +411,47 @@ FxAccountsInternal.prototype = {
       this._profile = new FxAccountsProfile({
         fxa: this,
         profileServerUrl,
       });
     }
     return this._profile;
   },
 
+  _messages: null,
+  get messages() {
+    if (!this._messages) {
+      this._messages = new FxAccountsMessages(this);
+    }
+    return this._messages;
+  },
+
   // A hook-point for tests who may want a mocked AccountState or mocked storage.
   newAccountState(credentials) {
     let storage = new FxAccountsStorageManager();
     storage.initialize(credentials);
     return new AccountState(storage);
   },
 
+  // "Friend" classes of FxAccounts (e.g. FxAccountsMessages) know about the
+  // "current account state" system. This method allows them to read and write
+  // safely in it.
+  // Example of usage:
+  // fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
+  //   const userData = await getUserData(['device']);
+  //   ...
+  //   await updateUserData({device: null});
+  // });
+  _withCurrentAccountState(func) {
+    const state = this.currentAccountState;
+    const getUserData = (fields) => state.getUserAccountData(fields);
+    const updateUserData = (data) => state.updateUserAccountData(data);
+    return func(getUserData, updateUserData);
+  },
+
   /**
    * Send a message to a set of devices in the same account
    *
    * @return Promise
    */
   notifyDevices(deviceIds, excludedIds, payload, TTL) {
     if (typeof deviceIds == "string") {
       deviceIds = [deviceIds];
@@ -736,16 +766,19 @@ FxAccountsInternal.prototype = {
       log.debug("Polling aborted; Another user signing in");
       clearTimeout(this.currentTimer);
       this.currentTimer = 0;
     }
     if (this._profile) {
       this._profile.tearDown();
       this._profile = null;
     }
+    if (this._messages) {
+      this._messages = null;
+    }
     // We "abort" the accountState and assume our caller is about to throw it
     // away and replace it with a new one.
     return this.currentAccountState.abort();
   },
 
   accountStatus: function accountStatus() {
     return this.currentAccountState.getUserAccountData().then(data => {
       if (!data) {
@@ -989,17 +1022,17 @@ FxAccountsInternal.prototype = {
 
     let data = await currentState.getUserAccountData();
 
     // Sanity check that the user hasn't changed out from under us
     if (data.keyFetchToken !== keyFetchToken) {
       throw new Error("Signed in user changed while fetching keys!");
     }
 
-    // Next statements must be synchronous until we setUserAccountData
+    // Next statements must be synchronous until we updateUserAccountData
     // so that we don't risk getting into a weird state.
     let kBbytes = CryptoUtils.xor(CommonUtils.hexToBytes(data.unwrapBKey),
                                   wrapKB);
 
     if (logPII) {
       log.debug("kBbytes: " + kBbytes);
     }
     let updateData = {
@@ -1644,16 +1677,30 @@ FxAccountsInternal.prototype = {
   },
 
   setProfileCache(profileCache) {
     return this.currentAccountState.updateUserAccountData({
       profileCache
     });
   },
 
+  // @returns Promise<Subscription>.
+  getPushSubscription() {
+    return this.fxaPushService.getSubscription();
+  },
+
+  // Once FxA messages is stable, remove this, hardcode the capabilities,
+  // and reset the device registration version.
+  get deviceCapabilities() {
+    if (Services.prefs.getBoolPref("identity.fxaccounts.messages.enabled", true)) {
+      return [CAPABILITY_MESSAGES, CAPABILITY_MESSAGES_SENDTAB];
+    }
+    return [];
+  },
+
   // If you change what we send to the FxA servers during device registration,
   // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
   // devices to re-register when Firefox updates
   async _registerOrUpdateDevice(signedInUser) {
     const {sessionToken, device: currentDevice} = signedInUser;
     if (!sessionToken) {
       throw new Error("_registerOrUpdateDevice called without a session token");
     }
@@ -1668,30 +1715,32 @@ FxAccountsInternal.prototype = {
         deviceOptions.pushCallback = subscription.endpoint;
         let publicKey = subscription.getKey("p256dh");
         let authKey = subscription.getKey("auth");
         if (publicKey && authKey) {
           deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
           deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
         }
       }
+      deviceOptions.capabilities = this.deviceCapabilities;
 
       let device;
       if (currentDevice && currentDevice.id) {
         log.debug("updating existing device details");
         device = await this.fxAccountsClient.updateDevice(
           sessionToken, currentDevice.id, deviceName, deviceOptions);
       } else {
         log.debug("registering new device details");
         device = await this.fxAccountsClient.registerDevice(
           sessionToken, deviceName, this._getDeviceType(), deviceOptions);
       }
 
       await this.currentAccountState.updateUserAccountData({
         device: {
+          ...currentDevice, // Copy the other properties (e.g. messagesIndex).
           id: device.id,
           registrationVersion: this.DEVICE_REGISTRATION_VERSION
         }
       });
       return device.id;
     } catch (error) {
       return this._handleDeviceError(error, sessionToken);
     }
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -404,16 +404,17 @@ this.FxAccountsClient.prototype = {
 
     if (options.pushCallback) {
       body.pushCallback = options.pushCallback;
     }
     if (options.pushPublicKey && options.pushAuthKey) {
       body.pushPublicKey = options.pushPublicKey;
       body.pushAuthKey = options.pushAuthKey;
     }
+    body.capabilities = options.capabilities;
 
     return this._request(path, "POST", creds, body);
   },
 
   /**
    * Sends a message to other devices. Must conform with the push payload schema:
    * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
    *
@@ -442,27 +443,72 @@ this.FxAccountsClient.prototype = {
     if (excludedIds) {
       body.excluded = excludedIds;
     }
     return this._request("/account/devices/notify", "POST",
       deriveHawkCredentials(sessionTokenHex, "sessionToken"), body);
   },
 
   /**
+   * Retrieves messages from our device's message box.
+   *
+   * @method getMessages
+   * @param  sessionTokenHex - Session token obtained from signIn
+   * @param  [index] - If specified, only messages received after the one who
+   *                   had that index will be retrieved.
+   * @param  [limit] - Maximum number of messages to retrieve.
+   */
+  getMessages(sessionTokenHex, {index, limit}) {
+    const params = new URLSearchParams();
+    if (index != undefined) {
+      params.set("index", index);
+    }
+    if (limit != undefined) {
+      params.set("limit", limit);
+    }
+    const path = `/account/device/messages?${params.toString()}`;
+    return this._request(path, "GET",
+      deriveHawkCredentials(sessionTokenHex, "sessionToken"));
+  },
+
+  /**
+   * Stores a message in the recipient's message box.
+   *
+   * @method sendMessage
+   * @param  sessionTokenHex - Session token obtained from signIn
+   * @param  topic
+   * @param  to - Recipient device ID.
+   * @param  data
+   * @return Promise
+   *         Resolves to the request's response, (which should be an empty object)
+   */
+  sendMessage(sessionTokenHex, topic, to, data) {
+    const body = {
+      topic,
+      to,
+      data
+    };
+    return this._request("/account/devices/messages", "POST",
+      deriveHawkCredentials(sessionTokenHex, "sessionToken"), body);
+  },
+
+  /**
    * Update the session or name for an existing device
    *
    * @method updateDevice
    * @param  sessionTokenHex
    *         Session token obtained from signIn
    * @param  id
    *         Device identifier
    * @param  name
    *         Device name
    * @param  [options]
    *         Extra device options
+   * @param  options.capabilities
+   *         Device capabilities
    * @param  [options.pushCallback]
    *         `pushCallback` push endpoint callback
    * @param  [options.pushPublicKey]
    *         `pushPublicKey` push public key (URLSafe Base64 string)
    * @param  [options.pushAuthKey]
    *         `pushAuthKey` push auth secret (URLSafe Base64 string)
    * @return Promise
    *         Resolves to an object:
@@ -478,16 +524,17 @@ this.FxAccountsClient.prototype = {
     let body = { id, name };
     if (options.pushCallback) {
       body.pushCallback = options.pushCallback;
     }
     if (options.pushPublicKey && options.pushAuthKey) {
       body.pushPublicKey = options.pushPublicKey;
       body.pushAuthKey = options.pushAuthKey;
     }
+    body.capabilities = options.capabilities;
 
     return this._request(path, "POST", creds, body);
   },
 
   /**
    * Get a list of currently registered devices
    *
    * @method getDeviceList
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -69,16 +69,19 @@ exports.ON_ACCOUNT_DESTROYED_NOTIFICATIO
 exports.ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed";
 exports.ON_VERIFY_LOGIN_NOTIFICATION = "fxaccounts:verify_login";
 
 exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
 
 exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange"; // WebChannel
 exports.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange";
 
+exports.CAPABILITY_MESSAGES = "messages";
+exports.CAPABILITY_MESSAGES_SENDTAB = "messages.sendtab";
+
 // UI Requests.
 exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
 exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
 // The OAuth client ID for Firefox Desktop
 exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
 
 // Firefox Accounts WebChannel ID
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsMessages.js
@@ -0,0 +1,223 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["FxAccountsMessages", /* the rest are for testing only */
+                          "FxAccountsMessagesSender", "FxAccountsMessagesReceiver",
+                          "FxAccountsMessagesHandler"];
+
+ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+ChromeUtils.import("resource://gre/modules/Preferences.jsm");
+ChromeUtils.defineModuleGetter(this, "PushCrypto",
+  "resource://gre/modules/PushCrypto.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://services-common/observers.js");
+
+const AES128GCM_ENCODING = "aes128gcm"; // The only Push encoding we support.
+const TOPICS = {
+  SEND_TAB: "sendtab"
+};
+
+class FxAccountsMessages {
+  constructor(fxAccounts, options = {}) {
+    this.fxAccounts = fxAccounts;
+    this.sender = options.sender || new FxAccountsMessagesSender(fxAccounts);
+    this.receiver = options.receiver || new FxAccountsMessagesReceiver(fxAccounts);
+  }
+
+  _isDeviceMessagesAware(device) {
+    return device.capabilities && device.capabilities.includes(CAPABILITY_MESSAGES);
+  }
+
+  canReceiveSendTabMessages(device) {
+    return this._isDeviceMessagesAware(device) &&
+           device.capabilities.includes(CAPABILITY_MESSAGES_SENDTAB);
+  }
+
+  consumeRemoteMessages() {
+    if (!Services.prefs.getBoolPref("identity.fxaccounts.messages.enabled", true)) {
+      return Promise.resolve();
+    }
+    return this.receiver.consumeRemoteMessages();
+  }
+
+  /**
+   * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()).
+   * @param {Object} tab
+   * @param {string} tab.url
+   * @param {string} tab.title
+   */
+  async sendTab(to, tab) {
+    log.info(`Sending a tab to ${to.length} devices.`);
+    const ourDeviceId = await this.fxAccounts.getDeviceId();
+    const payload = {
+      topic: TOPICS.SEND_TAB,
+      data: {
+        from: ourDeviceId,
+        entries: [{title: tab.title, url: tab.url}]
+      }
+    };
+    return this.sender.send(TOPICS.SEND_TAB, to, payload);
+  }
+}
+
+class FxAccountsMessagesSender {
+  constructor(fxAccounts) {
+    this.fxAccounts = fxAccounts;
+  }
+
+  async send(topic, to, data) {
+    const userData = await this.fxAccounts.getSignedInUser();
+    if (!userData) {
+      throw new Error("No user.");
+    }
+    const {sessionToken} = userData;
+    if (!sessionToken) {
+      throw new Error("_send called without a session token.");
+    }
+    const encoder = new TextEncoder("utf8");
+    const client = this.fxAccounts.getAccountsClient();
+    for (const device of to) {
+      try {
+        const bytes = encoder.encode(JSON.stringify(data));
+        const payload = await this._encrypt(bytes, device, encoder);
+        await client.sendMessage(sessionToken, topic, device.id, payload);
+        log.info(`Payload sent to device ${device.id}.`);
+      } catch (e) {
+        log.error(`Could not send data to device ${device.id}.`, e);
+      }
+    }
+  }
+
+  async _encrypt(bytes, device) {
+    let {pushPublicKey, pushAuthKey} = device;
+    if (!pushPublicKey || !pushAuthKey) {
+      throw new Error(`Device ${device.id} does not have push keys.`);
+    }
+    pushPublicKey = ChromeUtils.base64URLDecode(pushPublicKey, {padding: "ignore"});
+    pushAuthKey = ChromeUtils.base64URLDecode(pushAuthKey, {padding: "ignore"});
+    const {ciphertext} = await PushCrypto.encrypt(bytes, pushPublicKey, pushAuthKey);
+    return ChromeUtils.base64URLEncode(ciphertext, {pad: false});
+  }
+}
+
+class FxAccountsMessagesReceiver {
+  constructor(fxAccounts, options = {}) {
+    this.fxAccounts = fxAccounts;
+    this.handler = options.handler || new FxAccountsMessagesHandler(this.fxAccounts);
+  }
+
+  async consumeRemoteMessages() {
+    log.info(`Consuming unread messages.`);
+    const messages = await this._fetchMessages();
+    if (!messages || !messages.length) {
+      log.info(`No new messages.`);
+      return;
+    }
+    const decoder = new TextDecoder("utf8");
+    const keys = await this._getOwnKeys();
+    const payloads = [];
+    for (const {index, data} of messages) {
+      try {
+        const bytes = await this._decrypt(data, keys);
+        const payload = JSON.parse(decoder.decode(bytes));
+        payloads.push(payload);
+      } catch (e) {
+        log.error(`Could not unwrap message ${index}`, e);
+      }
+    }
+    if (payloads.length) {
+      await this.handler.handle(payloads);
+    }
+  }
+
+  async _fetchMessages() {
+    return this.fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
+      const userData = await getUserData(["sessionToken", "device"]);
+      if (!userData) {
+        throw new Error("No user.");
+      }
+      const {sessionToken, device} = userData;
+      if (!sessionToken) {
+        throw new Error("No session token.");
+      }
+      if (!device) {
+        throw new Error("No device registration.");
+      }
+      const opts = {};
+      if (device.messagesIndex) {
+        opts.index = device.messagesIndex;
+      }
+      const client = this.fxAccounts.getAccountsClient();
+      log.info(`Fetching unread messages with ${JSON.stringify(opts)}.`);
+      const {index: newIndex, messages} = await client.getMessages(sessionToken, opts);
+      await updateUserData({
+        device: {...device, messagesIndex: newIndex}
+      });
+      return messages;
+    });
+  }
+
+  async _getOwnKeys() {
+    const subscription = await this.fxAccounts.getPushSubscription();
+    return {
+      pushPrivateKey: subscription.p256dhPrivateKey,
+      pushPublicKey: new Uint8Array(subscription.getKey("p256dh")),
+      pushAuthKey: new Uint8Array(subscription.getKey("auth"))
+    };
+  }
+
+  async _decrypt(ciphertext, {pushPrivateKey, pushPublicKey, pushAuthKey}) {
+    ciphertext = ChromeUtils.base64URLDecode(ciphertext, {padding: "reject"});
+    return PushCrypto.decrypt(pushPrivateKey, pushPublicKey,
+                              pushAuthKey,
+                              {encoding: AES128GCM_ENCODING},
+                              ciphertext);
+  }
+}
+
+class FxAccountsMessagesHandler {
+  constructor(fxAccounts) {
+    this.fxAccounts = fxAccounts;
+  }
+
+  async handle(payloads) {
+    const sendTabPayloads = [];
+    for (const payload of payloads) {
+      switch (payload.topic) {
+        case TOPICS.SEND_TAB:
+          sendTabPayloads.push(payload.data);
+        default:
+          log.info(`Unknown messages topic: ${payload.topic}.`);
+      }
+    }
+
+    // Only one type of payload so far!
+    if (sendTabPayloads.length) {
+      await this._handleSendTabPayloads(sendTabPayloads);
+    }
+  }
+
+  async _handleSendTabPayloads(payloads) {
+    const toDisplay = [];
+    const fxaDevices = await this.fxAccounts.getDeviceList();
+    for (const payload of payloads) {
+      const current = payload.hasOwnProperty("current") ? payload.current :
+                                                          payload.entries.length - 1;
+      const device = fxaDevices.find(d => d.id == payload.from);
+      if (!device) {
+        log.warn("Incoming tab is from an unknown device (maybe disconnected?)");
+      }
+      const sender = {
+        id: device ? device.id : "",
+        name: device ? device.name : ""
+      };
+      const {title, url: uri} = payload.entries[current];
+      toDisplay.push({uri, title, sender});
+    }
+
+    Observers.notify("fxaccounts:messages:display-tabs", toDisplay);
+  }
+}
+
--- a/services/fxaccounts/FxAccountsPush.js
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -153,16 +153,21 @@ FxAccountsPushService.prototype = {
     this.log.trace("FxAccountsPushService _onPushMessage");
     if (!message.data) {
       // Use the empty signal to check the verification state of the account right away
       this.log.debug("empty push message - checking account status");
       this.fxAccounts.checkVerificationStatus();
       return;
     }
     let payload = message.data.json();
+    if (payload.topic) {
+      this.log.debug(`received messages tickle with topic ${payload.topic}`);
+      this.fxAccounts.messages.consumeRemoteMessages();
+      return;
+    }
     this.log.debug(`push command: ${payload.command}`);
     switch (payload.command) {
       case ON_DEVICE_CONNECTED_NOTIFICATION:
         Services.obs.notifyObservers(null, ON_DEVICE_CONNECTED_NOTIFICATION, payload.data.deviceName);
         break;
       case ON_DEVICE_DISCONNECTED_NOTIFICATION:
         this.fxAccounts.handleDeviceDisconnection(payload.data.id);
         return;
@@ -236,13 +241,34 @@ FxAccountsPushService.prototype = {
             }
           } else {
             this.log.warn("FxAccountsPushService failed to unsubscribe", result);
           }
           return resolve(ok);
         });
     });
   },
+
+  /**
+   * Get our Push server subscription.
+   *
+   * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#getSubscription()
+   *
+   * @returns {Promise}
+   */
+  getSubscription() {
+    return new Promise((resolve) => {
+      this.pushService.getSubscription(FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+        Services.scriptSecurityManager.getSystemPrincipal(),
+        (result, subscription) => {
+          if (!subscription) {
+            this.log.info("FxAccountsPushService no subscription found");
+            return resolve(null);
+          }
+          return resolve(subscription);
+        });
+    });
+  },
 };
 
 // Service registration below registers with FxAccountsComponents.manifest
 const components = [FxAccountsPushService];
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -21,14 +21,15 @@ EXTRA_COMPONENTS += [
 ]
 
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccounts.jsm',
   'FxAccountsClient.jsm',
   'FxAccountsCommon.js',
   'FxAccountsConfig.jsm',
+  'FxAccountsMessages.js',
   'FxAccountsOAuthGrantClient.jsm',
   'FxAccountsProfile.jsm',
   'FxAccountsProfileClient.jsm',
   'FxAccountsStorage.jsm',
   'FxAccountsWebChannel.jsm',
 ]
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_messages.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.import("resource://testing-common/Assert.jsm");
+ChromeUtils.import("resource://gre/modules/FxAccountsMessages.js");
+
+add_task(async function test_sendTab() {
+  const fxAccounts = {
+    async getDeviceId() {
+      return "my-device-id";
+    }
+  };
+  const sender = {
+    send: sinon.spy()
+  };
+  const fxAccountsMessages = new FxAccountsMessages(fxAccounts, {sender});
+
+  const to = [{
+    id: "deviceid-1",
+    pushPublicKey: "pubkey-1",
+    pushAuthKey: "authkey-1"
+  }];
+  const tab = {url: "https://foo.com", title: "Foo"};
+  await fxAccountsMessages.sendTab(to, tab);
+  Assert.ok(sender.send.calledOnce);
+  Assert.equal(sender.send.args[0][0], "sendtab");
+  Assert.deepEqual(sender.send.args[0][1], to);
+  Assert.deepEqual(sender.send.args[0][2], {
+    topic: "sendtab",
+    data: {
+      from: "my-device-id",
+      entries: [{title: "Foo", url: "https://foo.com"}]
+    }
+  });
+});
+
+add_task(async function test_consumeRemoteMessages() {
+  const fxAccounts = {};
+  const receiver = {
+    consumeRemoteMessages: sinon.spy()
+  };
+  const fxAccountsMessages = new FxAccountsMessages(fxAccounts, {receiver});
+  fxAccountsMessages.consumeRemoteMessages();
+  Assert.ok(receiver.consumeRemoteMessages.calledOnce);
+});
+
+add_task(async function test_canReceiveSendTabMessages() {
+  const fxAccounts = {};
+  const messages = new FxAccountsMessages(fxAccounts);
+  Assert.ok(!messages.canReceiveSendTabMessages({id: "device-id-1"}));
+  Assert.ok(!messages.canReceiveSendTabMessages({id: "device-id-1", capabilities: []}));
+  Assert.ok(!messages.canReceiveSendTabMessages({id: "device-id-1", capabilities: ["messages"]}));
+  Assert.ok(messages.canReceiveSendTabMessages({id: "device-id-1", capabilities: ["messages", "messages.sendtab"]}));
+});
+
+add_task(async function test_sender_send() {
+  const sandbox = sinon.sandbox.create();
+  const fxaClient = {
+    sendMessage: sinon.spy()
+  };
+  const sessionToken = "toktok";
+  const fxAccounts = {
+    async getSignedInUser() {
+      return {sessionToken};
+    },
+    getAccountsClient() {
+      return fxaClient;
+    }
+  };
+  const sender = new FxAccountsMessagesSender(fxAccounts);
+  sandbox.stub(sender, "_encrypt").callsFake((_, device) => {
+    if (device.pushPublicKey == "pubkey-1") {
+      return "encrypted-text-1";
+    }
+    return "encrypted-text-2";
+  });
+
+  const topic = "mytopic";
+  const to = [{
+    id: "deviceid-1",
+    pushPublicKey: "pubkey-1",
+    pushAuthKey: "authkey-1"
+  }, {
+    id: "deviceid-2",
+    pushPublicKey: "pubkey-2",
+    pushAuthKey: "authkey-2"
+  }];
+  const payload = {foo: "bar"};
+
+  await sender.send(topic, to, payload);
+
+  Assert.ok(fxaClient.sendMessage.calledTwice);
+  const checkCallArgs = (callNum, deviceId, encrypted) => {
+    Assert.equal(fxaClient.sendMessage.args[callNum][0], sessionToken);
+    Assert.equal(fxaClient.sendMessage.args[callNum][1], topic);
+    Assert.equal(fxaClient.sendMessage.args[callNum][2], deviceId);
+    Assert.equal(fxaClient.sendMessage.args[callNum][3], encrypted);
+  };
+  checkCallArgs(0, "deviceid-1", "encrypted-text-1");
+  checkCallArgs(1, "deviceid-2", "encrypted-text-2");
+  sandbox.restore();
+});
+
+add_task(async function test_receiver_consumeRemoteMessages() {
+  const fxaClient = {
+    getMessages: sinon.spy(async () => {
+      return {
+        index: "idx-2",
+        messages: [{
+          index: "idx-1",
+          data: "#giberish#"
+        }, {
+          index: "idx-2",
+          data: "#encrypted#"
+        }]
+      };
+    })
+  };
+  const fxAccounts = {
+    accountState: {sessionToken: "toktok", device: {}},
+    _withCurrentAccountState(fun) {
+      const get = () => this.accountState;
+      const update = (obj) => { this.accountState = {...this.accountState, ...obj}; };
+      return fun(get, update);
+    },
+    getAccountsClient() {
+      return fxaClient;
+    }
+  };
+  const sandbox = sinon.sandbox.create();
+  const messagesHandler = {
+    handle: sinon.spy()
+  };
+  const receiver = new FxAccountsMessagesReceiver(fxAccounts, {
+    handler: messagesHandler
+  });
+  sandbox.stub(receiver, "_getOwnKeys").callsFake(async () => {});
+  sandbox.stub(receiver, "_decrypt").callsFake((ciphertext) => {
+    if (ciphertext == "#encrypted#") {
+      return new TextEncoder("utf-8").encode(JSON.stringify({"foo": "bar"}));
+    }
+    throw new Error("Boom!");
+  });
+
+  await receiver.consumeRemoteMessages();
+
+  Assert.ok(fxaClient.getMessages.calledOnce);
+  Assert.equal(fxaClient.getMessages.args[0][0], "toktok");
+  Assert.deepEqual(fxaClient.getMessages.args[0][1], {});
+  Assert.ok(messagesHandler.handle.calledOnce);
+  Assert.deepEqual(messagesHandler.handle.args[0][0], [{"foo": "bar"}]);
+  fxaClient.getMessages.reset();
+
+  await receiver.consumeRemoteMessages();
+
+  Assert.ok(fxaClient.getMessages.calledOnce);
+  Assert.equal(fxaClient.getMessages.args[0][0], "toktok");
+  Assert.deepEqual(fxaClient.getMessages.args[0][1], {"index": "idx-2"});
+
+  sandbox.restore();
+});
+
+add_task(async function test_handler_handle_sendtab() {
+  const fxAccounts = {
+    async getDeviceList() {
+      return [{id: "1234a", name: "My Computer"}];
+    }
+  };
+  const handler = new FxAccountsMessagesHandler(fxAccounts);
+  const payloads = [{
+    topic: "sendtab",
+    data: {
+      from: "1234a",
+      current: 0,
+      entries: [{title: "Foo", url: "https://foo.com"},
+                {title: "Bar", url: "https://bar.com"}]
+    }
+  }, {
+    topic: "sendtab",
+    data: {
+      from: "unknown_device",
+      entries: [{title: "Foo2", url: "https://foo2.com"},
+                {title: "Bar2", url: "https://bar2.com"}]
+    }
+  }, {
+    topic: "unknowntopic",
+    data: {foo: "bar"}
+  }];
+  const notificationPromise = promiseObserver("fxaccounts:messages:display-tabs");
+  await handler.handle(payloads);
+  const {subject} = await notificationPromise;
+  const toDisplay = subject.wrappedJSObject.object;
+  const expected = [
+    {uri: "https://foo.com", title: "Foo", sender: {id: "1234a", name: "My Computer"}},
+    {uri: "https://bar2.com", title: "Bar2", sender: {id: "", name: ""}}
+  ];
+  Assert.deepEqual(toDisplay, expected);
+});
+
+function promiseObserver(aTopic) {
+  return new Promise(resolve => {
+    Services.obs.addObserver(function onNotification(subject, topic, data) {
+      Services.obs.removeObserver(onNotification, topic);
+        resolve({subject, data});
+      }, aTopic);
+  });
+}
+
--- a/services/fxaccounts/tests/xpcshell/test_push_service.js
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -401,16 +401,46 @@ add_test(function observePushTopicPasswo
 
   pushService._onPasswordChanged = function() {
     run_next_test();
   };
 
   pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
 });
 
+add_task(async function messagesTickle() {
+  let msg = {
+    data: {
+      json: () => ({
+        topic: "sendtab"
+      })
+    },
+    QueryInterface() {
+      return this;
+    }
+  };
+
+  let fxAccountsMock = {};
+  const promiseConsumeRemoteMessagesCalled = new Promise(res => {
+    fxAccountsMock.messages = {
+      consumeRemoteMessages() {
+        res();
+      }
+    };
+  });
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    fxAccounts: fxAccountsMock,
+  });
+
+  pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+  await promiseConsumeRemoteMessagesCalled;
+});
+
 add_test(function observeSubscriptionChangeTopic() {
   let customAccounts = Object.assign(mockFxAccounts, {
     updateDeviceRegistration() {
       // subscription change means updating the device registration
       run_next_test();
     }
   });
 
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -6,16 +6,17 @@ support-files =
   !/services/common/tests/unit/head_http.js
 
 [test_accounts.js]
 [test_accounts_config.js]
 [test_accounts_device_registration.js]
 [test_client.js]
 [test_credentials.js]
 [test_loginmgr_storage.js]
+[test_messages.js]
 [test_oauth_grant_client.js]
 [test_oauth_grant_client_server.js]
 [test_oauth_tokens.js]
 [test_oauth_token_storage.js]
 [test_profile_client.js]
 [test_push_service.js]
 [test_web_channel.js]
 [test_profile.js]
--- a/services/sync/modules/engines/clients.js
+++ b/services/sync/modules/engines/clients.js
@@ -898,33 +898,40 @@ ClientEngine.prototype = {
 
   /**
    * Handle a bunch of received 'displayURI' commands.
    *
    * Interested parties should observe the "weave:engine:clients:display-uris"
    * topic. The callback will receive an array as the subject parameter
    * containing objects with the following keys:
    *
-   *   uri       URI (string) that is requested for display.
-   *   clientId  ID of client that sent the command.
-   *   title     Title of page that loaded URI (likely) corresponds to.
+   *   uri         URI (string) that is requested for display.
+   *   sender.id   ID of client that sent the command.
+   *   sender.name Name of client that sent the command.
+   *   title       Title of page that loaded URI (likely) corresponds to.
    *
    * The 'data' parameter to the callback will not be defined.
    *
    * @param uris
    *        An array containing URI objects to display
    * @param uris[].uri
    *        String URI that was received
    * @param uris[].clientId
    *        ID of client that sent URI
    * @param uris[].title
    *        String title of page that URI corresponds to. Older clients may not
    *        send this.
    */
-  _handleDisplayURIs: function _handleDisplayURIs(uris) {
+  _handleDisplayURIs(uris) {
+    uris.forEach(uri => {
+      uri.sender = {
+        id: uri.clientId,
+        name: this.getClientName(uri.clientId)
+      };
+    });
     Svc.Obs.notify("weave:engine:clients:display-uris", uris);
   },
 
   async _removeRemoteClient(id) {
     delete this._store._remoteClients[id];
     await this._tracker.removeChangedID(id);
     await this._removeClientCommands(id);
     this._modified.delete(id);
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -15,16 +15,18 @@ ChromeUtils.import("resource://services-
 ChromeUtils.import("resource://services-common/logmanager.js");
 ChromeUtils.import("resource://services-common/async.js");
 ChromeUtils.import("resource://services-common/utils.js");
 
 ChromeUtils.defineModuleGetter(this, "Status",
                                "resource://services-sync/status.js");
 ChromeUtils.defineModuleGetter(this, "AddonManager",
                                "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "fxAccounts",
+                               "resource://gre/modules/FxAccounts.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "IdleService",
                                    "@mozilla.org/widget/idleservice;1",
                                    "nsIIdleService");
 XPCOMUtils.defineLazyServiceGetter(this, "CaptivePortalService",
                                    "@mozilla.org/network/captive-portal-service;1",
                                    "nsICaptivePortalService");
 
 // Get the value for an interval that's stored in preferences. To save users
@@ -526,16 +528,21 @@ SyncScheduler.prototype = {
       return;
     }
 
     if (!Async.isAppReady()) {
       this._log.debug("Not initiating sync: app is shutting down");
       return;
     }
     Services.tm.dispatchToMainThread(() => {
+      // Terrible hack below: we do the fxa messages polling in the sync
+      // scheduler to get free post-wake/link-state etc detection.
+      fxAccounts.messages.consumeRemoteMessages().catch(e => {
+        this._log.error("Error while polling for FxA messages.", e);
+      });
       this.service.sync({engines, why});
     });
   },
 
   /**
    * Set a timer for the next sync
    */
   scheduleNextSync(interval, {engines = null, why = null} = {}) {
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -71,17 +71,18 @@
   "forms.jsm": ["FormData"],
   "FormAutofillHeuristics.jsm": ["FormAutofillHeuristics", "LabelUtils"],
   "FormAutofillSync.jsm": ["AddressesEngine", "CreditCardsEngine"],
   "FormAutofillUtils.jsm": ["FormAutofillUtils", "AddressDataLoader"],
   "FrameScriptManager.jsm": ["getNewLoaderID"],
   "fxa_utils.js": ["initializeIdentityWithTokenServerResponse"],
   "fxaccounts.jsm": ["Authentication"],
   "FxAccounts.jsm": ["fxAccounts", "FxAccounts"],
-  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
+  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "CAPABILITY_MESSAGES", "CAPABILITY_MESSAGES_SENDTAB", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
+  "FxAccountsMessages.js": ["FxAccountsMessages", "FxAccountsMessagesSender", "FxAccountsMessagesReceiver", "FxAccountsMessagesHandler"],
   "FxAccountsOAuthGrantClient.jsm": ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"],
   "FxAccountsProfileClient.jsm": ["FxAccountsProfileClient", "FxAccountsProfileClientError"],
   "FxAccountsPush.js": ["FxAccountsPushService"],
   "FxAccountsStorage.jsm": ["FxAccountsStorageManagerCanStoreField", "FxAccountsStorageManager"],
   "FxAccountsWebChannel.jsm": ["EnsureFxAccountsWebChannel"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "Geometry.jsm": ["Point", "Rect"],