merge fx-team to mozilla-central a=merge
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Thu, 07 May 2015 15:28:00 +0200
changeset 274028 6c8b6e1d328fe23812697eeaae4c7df8076e1b65
parent 274001 0df9e0c692a3afd096fa931911ea4c1d6dc4e0e7 (current diff)
parent 274027 019c10ffbe1607f2618d216b86b028d41bceea74 (diff)
child 274079 403e3c2380b52b3e1acd2ddc0d914b7d54b7c131
push id863
push userraliiev@mozilla.com
push dateMon, 03 Aug 2015 13:22:43 +0000
treeherdermozilla-release@f6321b14228d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone40.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
merge fx-team to mozilla-central a=merge
browser/app/profile/firefox.js
browser/base/content/test/general/browser_fxa_profile_channel.html
browser/base/content/test/general/browser_fxa_profile_channel.js
browser/base/content/test/general/browser_fxa_web_channel.html
browser/base/content/test/general/browser_fxa_web_channel.js
browser/devtools/commandline/commands-index.js
browser/locales/en-US/chrome/browser/devtools/gcli.properties
browser/locales/en-US/chrome/browser/devtools/gclicommands.properties
services/fxaccounts/FxAccountsProfileChannel.jsm
services/fxaccounts/FxAccountsWebChannel.jsm
services/fxaccounts/tests/xpcshell/test_profile_channel.js
services/fxaccounts/tests/xpcshell/test_web_channel.js
toolkit/devtools/gcli/commands/index.js
toolkit/locales/en-US/chrome/global/devtools/gcli.properties
toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1340,20 +1340,19 @@ pref("devtools.devedition.promo.url", "h
   pref("devtools.devedition.promo.enabled", true);
 #else
   pref("devtools.devedition.promo.enabled", false);
 #endif
 
 // Disable the error console
 pref("devtools.errorconsole.enabled", false);
 
-// Developer toolbar and GCLI preferences
+// Developer toolbar preferences
 pref("devtools.toolbar.enabled", true);
 pref("devtools.toolbar.visible", false);
-pref("devtools.commands.dir", "");
 
 // Enable the app manager
 pref("devtools.appmanager.enabled", true);
 pref("devtools.appmanager.lastTab", "help");
 pref("devtools.appmanager.manifestEditor.enabled", true);
 
 // Enable DevTools WebIDE by default
 pref("devtools.webide.enabled", true);
@@ -1515,27 +1514,16 @@ pref("devtools.webaudioeditor.inspectorW
 
 // Default theme ("dark" or "light")
 #ifdef MOZ_DEV_EDITION
 sticky_pref("devtools.theme", "dark");
 #else
 sticky_pref("devtools.theme", "light");
 #endif
 
-// Display the introductory text
-pref("devtools.gcli.hideIntro", false);
-
-// How eager are we to show help: never=1, sometimes=2, always=3
-pref("devtools.gcli.eagerHelper", 2);
-
-// Alias to the script URLs for inject command.
-pref("devtools.gcli.jquerySrc", "https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js");
-pref("devtools.gcli.lodashSrc", "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.min.js");
-pref("devtools.gcli.underscoreSrc", "https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js");
-
 // Remember the Web Console filters
 pref("devtools.webconsole.filter.network", true);
 pref("devtools.webconsole.filter.networkinfo", false);
 pref("devtools.webconsole.filter.netwarn", true);
 pref("devtools.webconsole.filter.netxhr", false);
 pref("devtools.webconsole.filter.csserror", true);
 pref("devtools.webconsole.filter.cssparser", false);
 pref("devtools.webconsole.filter.csslog", false);
@@ -1617,21 +1605,16 @@ pref("devtools.editor.autocomplete", tru
 // Enable the Font Inspector
 pref("devtools.fontinspector.enabled", true);
 
 // Pref to store the browser version at the time of a telemetry ping for an
 // opened developer tool. This allows us to ping telemetry just once per browser
 // version for each user.
 pref("devtools.telemetry.tools.opened.version", "{}");
 
-// Set imgur upload client ID
-pref("devtools.gcli.imgurClientID", '0df414e888d7240');
-// Imgur's upload URL
-pref("devtools.gcli.imgurUploadURL", "https://api.imgur.com/3/image");
-
 // Whether the character encoding menu is under the main Firefox button. This
 // preference is a string so that localizers can alter it.
 pref("browser.menu.showCharacterEncoding", "chrome://browser/locale/browser.properties");
 
 // Allow using tab-modal prompts when possible.
 pref("prompts.tab_modal.enabled", true);
 // Whether the Panorama should animate going in/out of tabs
 pref("browser.panorama.animate_zoom", true);
@@ -1802,16 +1785,19 @@ pref("identity.fxaccounts.remote.signup.
 
 // The URL where remote content that forces re-authentication for Firefox Accounts
 // should be fetched.  Must use HTTPS.
 pref("identity.fxaccounts.remote.force_auth.uri", "https://accounts.firefox.com/force_auth?service=sync&context=fx_desktop_v1");
 
 // The remote content URL shown for signin in. Must use HTTPS.
 pref("identity.fxaccounts.remote.signin.uri", "https://accounts.firefox.com/signin?service=sync&context=fx_desktop_v1");
 
+// The remote content URL where FxAccountsWebChannel messages originate.
+pref("identity.fxaccounts.remote.webchannel.uri", "https://accounts.firefox.com/");
+
 // The URL we take the user to when they opt to "manage" their Firefox Account.
 // Note that this will always need to be in the same TLD as the
 // "identity.fxaccounts.remote.signup.uri" pref.
 pref("identity.fxaccounts.settings.uri", "https://accounts.firefox.com/settings");
 
 // The remote URL of the FxA Profile Server
 pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1");
 
--- a/browser/base/content/browser-fxaccounts.js
+++ b/browser/base/content/browser-fxaccounts.js
@@ -83,16 +83,24 @@ let gFxAccounts = {
     addEventListener("activate", this);
     gNavToolbox.addEventListener("customizationstarting", this);
     gNavToolbox.addEventListener("customizationending", this);
 
     // Request the current Legacy-Sync-to-FxA migration status.  We'll be
     // notified of fxa-migration:state-changed in response if necessary.
     Services.obs.notifyObservers(null, "fxa-migration:state-request", null);
 
+    let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri");
+    // The FxAccountsWebChannel listens for events and updates
+    // the state machine accordingly.
+    let fxAccountsWebChannel = new FxAccountsWebChannel({
+      content_uri: contentUri,
+      channel_id: this.FxAccountsCommon.WEBCHANNEL_ID
+    });
+
     this._initialized = true;
 
     this.updateUI();
   },
 
   uninit: function () {
     if (!this._initialized) {
       return;
@@ -398,8 +406,11 @@ let gFxAccounts = {
 };
 
 XPCOMUtils.defineLazyGetter(gFxAccounts, "FxAccountsCommon", function () {
   return Cu.import("resource://gre/modules/FxAccountsCommon.js", {});
 });
 
 XPCOMUtils.defineLazyModuleGetter(gFxAccounts, "fxaMigrator",
   "resource://services-sync/FxaMigrator.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsWebChannel",
+  "resource://gre/modules/FxAccountsWebChannel.jsm");
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -8,17 +8,17 @@ support-files =
   authenticate.sjs
   aboutHome_content_script.js
   browser_bug479408_sample.html
   browser_bug678392-1.html
   browser_bug678392-2.html
   browser_bug970746.xhtml
   browser_fxa_oauth.html
   browser_fxa_oauth_with_keys.html
-  browser_fxa_profile_channel.html
+  browser_fxa_web_channel.html
   browser_registerProtocolHandler_notification.html
   browser_ssl_error_reports_content.js
   browser_star_hsts.sjs
   browser_tab_dragdrop2_frame1.xul
   browser_web_channel.html
   bug592338.html
   bug792517-2.html
   bug792517.html
@@ -297,17 +297,17 @@ skip-if = true # browser_drag.js is disa
 [browser_favicon_change.js]
 [browser_favicon_change_not_in_document.js]
 skip-if = e10s
 [browser_findbarClose.js]
 [browser_fullscreen-window-open.js]
 skip-if = buildapp == 'mulet' || e10s || os == "linux" # Bug 933103 - mochitest's EventUtils.synthesizeMouse functions not e10s friendly. Linux: Intermittent failures - bug 941575.
 [browser_fxa_migrate.js]
 [browser_fxa_oauth.js]
-[browser_fxa_profile_channel.js]
+[browser_fxa_web_channel.js]
 [browser_gestureSupport.js]
 skip-if = e10s # Bug 863514 - no gesture support.
 [browser_getshortcutoruri.js]
 [browser_hide_removing.js]
 [browser_homeDrop.js]
 skip-if = buildapp == 'mulet'
 [browser_identity_UI.js]
 skip-if = e10s && debug # Seeing lots of timeouts (bug 1095517)
rename from browser/base/content/test/general/browser_fxa_profile_channel.html
rename to browser/base/content/test/general/browser_fxa_web_channel.html
--- a/browser/base/content/test/general/browser_fxa_profile_channel.html
+++ b/browser/base/content/test/general/browser_fxa_web_channel.html
@@ -1,26 +1,98 @@
 <!DOCTYPE html>
 <html>
 <head>
   <meta charset="utf-8">
-  <title>fxa_profile_channel_test</title>
+  <title>fxa_web_channel_test</title>
 </head>
 <body>
 <script>
+  var webChannelId = "account_updates_test";
+
   window.onload = function(){
+    var testName = window.location.search.replace(/^\?/, "");
+
+    switch(testName) {
+      case "profile_change":
+        test_profile_change();
+        break;
+      case "login":
+        test_login();
+        break;
+      case "can_link_account":
+        test_can_link_account();
+        break;
+    }
+  };
+
+  function test_profile_change() {
     var event = new window.CustomEvent("WebChannelMessageToChrome", {
       detail: {
-        id: "account_updates",
+        id: webChannelId,
         message: {
           command: "profile:change",
           data: {
             uid: "abc123",
           },
         },
       },
     });
 
     window.dispatchEvent(event);
-  };
+  }
+
+  function test_login() {
+    var event = new window.CustomEvent("WebChannelMessageToChrome", {
+      detail: {
+        id: webChannelId,
+        message: {
+          command: "fxaccounts:login",
+          data: {
+            authAt: Date.now(),
+            email: "testuser@testuser.com",
+            keyFetchToken: 'key_fetch_token',
+            sessionToken: 'session_token',
+            uid: 'uid',
+            unwrapBKey: 'unwrap_b_key',
+            verified: true,
+          },
+          messageId: 1,
+        },
+      },
+    });
+
+    window.dispatchEvent(event);
+  }
+
+  function test_can_link_account() {
+    window.addEventListener("WebChannelMessageToContent", function (e) {
+      // echo any responses from the browser back to the tests on the
+      // fxaccounts_webchannel_response_echo WebChannel. The tests are
+      // listening for events and do the appropriate checks.
+      var event = new window.CustomEvent("WebChannelMessageToChrome", {
+        detail: {
+          id: 'fxaccounts_webchannel_response_echo',
+          message: e.detail.message,
+        }
+      });
+
+      window.dispatchEvent(event);
+    }, true);
+
+    var event = new window.CustomEvent("WebChannelMessageToChrome", {
+      detail: {
+        id: webChannelId,
+        message: {
+          command: "fxaccounts:can_link_account",
+          data: {
+            email: "testuser@testuser.com",
+          },
+          messageId: 2,
+        },
+      },
+    });
+
+    window.dispatchEvent(event);
+  }
 </script>
 </body>
 </html>
rename from browser/base/content/test/general/browser_fxa_profile_channel.js
rename to browser/base/content/test/general/browser_fxa_web_channel.js
--- a/browser/base/content/test/general/browser_fxa_profile_channel.js
+++ b/browser/base/content/test/general/browser_fxa_web_channel.js
@@ -4,49 +4,124 @@
 
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () {
   return Components.utils.import("resource://gre/modules/FxAccountsCommon.js", {});
 });
 
-XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileChannel",
-  "resource://gre/modules/FxAccountsProfileChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
+                                  "resource://gre/modules/WebChannel.jsm");
 
-const HTTP_PATH = "http://example.com";
+XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsWebChannel",
+  "resource://gre/modules/FxAccountsWebChannel.jsm");
+
+const TEST_HTTP_PATH = "http://example.com";
+const TEST_BASE_URL = TEST_HTTP_PATH + "/browser/browser/base/content/test/general/browser_fxa_web_channel.html";
+const TEST_CHANNEL_ID = "account_updates_test";
 
 let gTests = [
   {
-    desc: "FxA Profile Channel - should receive message about account updates",
+    desc: "FxA Web Channel - should receive message about profile changes",
     run: function* () {
-      return new Promise(function(resolve, reject) {
-        let tabOpened = false;
-        let properUrl = "http://example.com/browser/browser/base/content/test/general/browser_fxa_profile_channel.html";
+      let client = new FxAccountsWebChannel({
+        content_uri: TEST_HTTP_PATH,
+        channel_id: TEST_CHANNEL_ID,
+      });
+      let promiseObserver = new Promise((resolve, reject) => {
+        makeObserver(FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+          Assert.equal(data, "abc123");
+          client.tearDown();
+          resolve();
+        });
+      });
+
+      yield BrowserTestUtils.withNewTab({
+        gBrowser: gBrowser,
+        url: TEST_BASE_URL + "?profile_change"
+      }, function* () {
+        yield promiseObserver;
+      });
+    }
+  },
+  {
+    desc: "fxa web channel - login messages should notify the fxAccounts object",
+    run: function* () {
+
+      let promiseLogin = new Promise((resolve, reject) => {
+        let login = (accountData) => {
+          Assert.equal(typeof accountData.authAt, 'number');
+          Assert.equal(accountData.email, 'testuser@testuser.com');
+          Assert.equal(accountData.keyFetchToken, 'key_fetch_token');
+          Assert.equal(accountData.sessionToken, 'session_token');
+          Assert.equal(accountData.uid, 'uid');
+          Assert.equal(accountData.unwrapBKey, 'unwrap_b_key');
+          Assert.equal(accountData.verified, true);
 
-        waitForTab(function (tab) {
-          Assert.ok("Tab successfully opened");
-          let match = gBrowser.currentURI.spec == properUrl;
-          Assert.ok(match);
+          client.tearDown();
+          resolve();
+        };
+
+        let client = new FxAccountsWebChannel({
+          content_uri: TEST_HTTP_PATH,
+          channel_id: TEST_CHANNEL_ID,
+          helpers: {
+            login: login
+          }
+        });
+      });
 
-          tabOpened = true;
+      yield BrowserTestUtils.withNewTab({
+        gBrowser: gBrowser,
+        url: TEST_BASE_URL + "?login"
+      }, function* () {
+        yield promiseLogin;
+      });
+    }
+  },
+  {
+    desc: "fxa web channel - can_link_account messages should respond",
+    run: function* () {
+      let properUrl = TEST_BASE_URL + "?can_link_account";
+
+      let promiseEcho = new Promise((resolve, reject) => {
+
+        let webChannelOrigin = Services.io.newURI(properUrl, null, null);
+        // responses sent to content are echoed back over the
+        // `fxaccounts_webchannel_response_echo` channel. Ensure the
+        // fxaccounts:can_link_account message is responded to.
+        let echoWebChannel = new WebChannel('fxaccounts_webchannel_response_echo', webChannelOrigin);
+        echoWebChannel.listen((webChannelId, message, target) => {
+          Assert.equal(message.command, 'fxaccounts:can_link_account');
+          Assert.equal(message.messageId, 2);
+          Assert.equal(message.data.ok, true);
+
+          client.tearDown();
+          echoWebChannel.stopListening();
+
+          resolve();
         });
 
-        let client = new FxAccountsProfileChannel({
-          content_uri: HTTP_PATH,
+        let client = new FxAccountsWebChannel({
+          content_uri: TEST_HTTP_PATH,
+          channel_id: TEST_CHANNEL_ID,
+          helpers: {
+            shouldAllowRelink(acctName) {
+              return acctName === 'testuser@testuser.com';
+            }
+          }
         });
+      });
 
-        makeObserver(FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
-          Assert.ok(tabOpened);
-          Assert.equal(data, "abc123");
-          resolve();
-          gBrowser.removeCurrentTab();
-        });
-
-        gBrowser.selectedTab = gBrowser.addTab(properUrl);
+      yield BrowserTestUtils.withNewTab({
+        gBrowser: gBrowser,
+        url: properUrl
+      }, function* () {
+        yield promiseEcho;
       });
     }
   }
 ]; // gTests
 
 function makeObserver(aObserveTopic, aObserveFunc) {
   let callback = function (aSubject, aTopic, aData) {
     if (aTopic == aObserveTopic) {
@@ -58,28 +133,16 @@ function makeObserver(aObserveTopic, aOb
   function removeMe() {
     Services.obs.removeObserver(callback, aObserveTopic);
   }
 
   Services.obs.addObserver(callback, aObserveTopic, false);
   return removeMe;
 }
 
-function waitForTab(aCallback) {
-  let container = gBrowser.tabContainer;
-  container.addEventListener("TabOpen", function tabOpener(event) {
-    container.removeEventListener("TabOpen", tabOpener, false);
-    gBrowser.addEventListener("load", function listener() {
-      gBrowser.removeEventListener("load", listener, true);
-      let tab = event.target;
-      aCallback(tab);
-    }, true);
-  }, false);
-}
-
 function test() {
   waitForExplicitFinish();
 
   Task.spawn(function () {
     for (let test of gTests) {
       info("Running: " + test.desc);
       yield test.run();
     }
index bdb2000f2927383991d1849b9e31c30e4a286f41..0f14c9c7e863e8302828929fe00ec6283ff33f0a
GIT binary patch
literal 66178
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
--- a/browser/branding/aurora/branding.nsi
+++ b/browser/branding/aurora/branding.nsi
@@ -21,18 +21,18 @@
 # The installer's certificate name and issuer expected by the stub installer
 !define CertNameDownload   "Mozilla Corporation"
 !define CertIssuerDownload "DigiCert Assured ID Code Signing CA-1"
 
 # Dialog units are used so the UI displays correctly with the system's DPI
 # settings.
 # The dialog units for the bitmap's dimensions should match exactly with the
 # bitmap's width and height in pixels.
-!define APPNAME_BMP_WIDTH_DU 93u
-!define APPNAME_BMP_HEIGHT_DU 44u
+!define APPNAME_BMP_WIDTH_DU 123u
+!define APPNAME_BMP_HEIGHT_DU 56u
 !define INTRO_BLURB_WIDTH_DU "232u"
 !define INTRO_BLURB_EDGE_DU "196u"
 !define INTRO_BLURB_LTR_TOP_DU "16u"
 !define INTRO_BLURB_RTL_TOP_DU "11u"
 
 # UI Colors that can be customized for each channel
 !define FOOTER_CONTROL_TEXT_COLOR_NORMAL 0x000000
 !define FOOTER_CONTROL_TEXT_COLOR_FADED 0x999999
--- a/browser/components/loop/.eslintrc
+++ b/browser/components/loop/.eslintrc
@@ -29,18 +29,16 @@
   "rules": {
     // turn off all kinds of stuff that we actually do want, because
     // right now, we're bootstrapping the linting infrastructure.  We'll
     // want to audit these rules, and start turning them on and fixing the
     // problems they find, one at a time.
 
     // Eslint built-in rules are documented at <http://eslint.org/docs/rules/>
     "camelcase": 0,               // TODO: Remove (use default)
-    "comma-dangle": 0,            // TODO: Remove (use default)
-    "comma-spacing": 0,           // TODO: Remove (use default)
     "consistent-return": 0,       // TODO: Remove (use default)
     "curly": 0,                   // TODO: Remove (use default)
     "dot-notation": 0,            // TODO: Remove (use default)
     "eol-last": 0,                // TODO: Remove (use default)
     "eqeqeq": 0,                  // TBD. Might need to be separate for content & chrome
     "global-strict": 0,           // Leave as zero (this will be unsupported in eslint 1.0.0)
     "key-spacing": 0,             // TODO: Remove (use default)
     "new-cap": 0,                 // TODO: Remove (use default)
--- a/browser/components/loop/content/js/client.js
+++ b/browser/components/loop/content/js/client.js
@@ -110,13 +110,13 @@ loop.Client = (function($) {
 
             cb(null, outgoingCallData);
           } catch (err) {
             console.log("Error requesting call info", err);
             cb(err);
           }
         }.bind(this)
       );
-    },
+    }
   };
 
   return Client;
 })(jQuery);
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -152,17 +152,17 @@ loop.contacts = (function(_, mozL10n) {
   const ContactDropdown = React.createClass({displayName: "ContactDropdown",
     propTypes: {
       handleAction: React.PropTypes.func.isRequired,
       canEdit: React.PropTypes.bool
     },
 
     getInitialState: function () {
       return {
-        openDirUp: false,
+        openDirUp: false
       };
     },
 
     componentDidMount: function () {
       // This method is called once when the dropdown menu is added to the DOM
       // inside the contact item.  If the menu extends outside of the visible
       // area of the scrollable list, it is re-rendered in different direction.
 
@@ -170,17 +170,17 @@ loop.contacts = (function(_, mozL10n) {
       let menuNodeRect = menuNode.getBoundingClientRect();
 
       let listNode = document.getElementsByClassName("contact-list")[0];
       let listNodeRect = listNode.getBoundingClientRect();
 
       if (menuNodeRect.top + menuNodeRect.height >=
           listNodeRect.top + listNodeRect.height) {
         this.setState({
-          openDirUp: true,
+          openDirUp: true
         });
       }
     },
 
     onItemClick: function(event) {
       this.props.handleAction(event.currentTarget.dataset.action);
     },
 
@@ -227,17 +227,17 @@ loop.contacts = (function(_, mozL10n) {
         )
       );
     }
   });
 
   const ContactDetail = React.createClass({displayName: "ContactDetail",
     getInitialState: function() {
       return {
-        showMenu: false,
+        showMenu: false
       };
     },
 
     propTypes: {
       handleContactAction: React.PropTypes.func,
       contact: React.PropTypes.object.isRequired
     },
 
@@ -346,17 +346,17 @@ loop.contacts = (function(_, mozL10n) {
     /**
      * User profile
      */
     _userProfile: null,
 
     getInitialState: function() {
       return {
         importBusy: false,
-        filter: "",
+        filter: ""
       };
     },
 
     refresh: function(callback = function() {}) {
       let contactsAPI = navigator.mozLoop.contacts;
 
       this.handleContactRemoveAll();
 
@@ -628,17 +628,17 @@ loop.contacts = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         contact: null,
         pristine: true,
         name: "",
         email: "",
-        tel: "",
+        tel: ""
       };
     },
 
     initForm: function(contact) {
       let state = this.getInitialState();
       if (contact) {
         state.contact = contact;
         state.name = contact.name[0];
@@ -646,17 +646,17 @@ loop.contacts = (function(_, mozL10n) {
         state.tel = getPreferred(contact, "tel").value;
       }
       this.setState(state);
     },
 
     handleAcceptButtonClick: function() {
       // Allow validity error indicators to be displayed.
       this.setState({
-        pristine: false,
+        pristine: false
       });
 
       let emailInput = this.refs.email.getDOMNode();
       let telInput = this.refs.tel.getDOMNode();
       if (!this.refs.name.getDOMNode().checkValidity() ||
           ((emailInput.required || emailInput.value) && !emailInput.checkValidity()) ||
           ((telInput.required || telInput.value) && !telInput.checkValidity())) {
         return;
@@ -672,17 +672,17 @@ loop.contacts = (function(_, mozL10n) {
           setPreferred(this.state.contact, "email", this.state.email.trim());
           setPreferred(this.state.contact, "tel", this.state.tel.trim());
           contactsAPI.update(this.state.contact, err => {
             if (err) {
               throw err;
             }
           });
           this.setState({
-            contact: null,
+            contact: null
           });
           break;
         case "add":
           var contact = {
             id: navigator.mozLoop.generateUUID(),
             name: [this.state.name.trim()],
             email: [{
               pref: true,
@@ -747,11 +747,11 @@ loop.contacts = (function(_, mozL10n) {
       );
     }
   });
 
   return {
     ContactsList: ContactsList,
     ContactDetailsForm: ContactDetailsForm,
     _getPreferred: getPreferred,
-    _setPreferred: setPreferred,
+    _setPreferred: setPreferred
   };
 })(_, document.mozL10n);
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -152,17 +152,17 @@ loop.contacts = (function(_, mozL10n) {
   const ContactDropdown = React.createClass({
     propTypes: {
       handleAction: React.PropTypes.func.isRequired,
       canEdit: React.PropTypes.bool
     },
 
     getInitialState: function () {
       return {
-        openDirUp: false,
+        openDirUp: false
       };
     },
 
     componentDidMount: function () {
       // This method is called once when the dropdown menu is added to the DOM
       // inside the contact item.  If the menu extends outside of the visible
       // area of the scrollable list, it is re-rendered in different direction.
 
@@ -170,17 +170,17 @@ loop.contacts = (function(_, mozL10n) {
       let menuNodeRect = menuNode.getBoundingClientRect();
 
       let listNode = document.getElementsByClassName("contact-list")[0];
       let listNodeRect = listNode.getBoundingClientRect();
 
       if (menuNodeRect.top + menuNodeRect.height >=
           listNodeRect.top + listNodeRect.height) {
         this.setState({
-          openDirUp: true,
+          openDirUp: true
         });
       }
     },
 
     onItemClick: function(event) {
       this.props.handleAction(event.currentTarget.dataset.action);
     },
 
@@ -227,17 +227,17 @@ loop.contacts = (function(_, mozL10n) {
         </ul>
       );
     }
   });
 
   const ContactDetail = React.createClass({
     getInitialState: function() {
       return {
-        showMenu: false,
+        showMenu: false
       };
     },
 
     propTypes: {
       handleContactAction: React.PropTypes.func,
       contact: React.PropTypes.object.isRequired
     },
 
@@ -346,17 +346,17 @@ loop.contacts = (function(_, mozL10n) {
     /**
      * User profile
      */
     _userProfile: null,
 
     getInitialState: function() {
       return {
         importBusy: false,
-        filter: "",
+        filter: ""
       };
     },
 
     refresh: function(callback = function() {}) {
       let contactsAPI = navigator.mozLoop.contacts;
 
       this.handleContactRemoveAll();
 
@@ -628,17 +628,17 @@ loop.contacts = (function(_, mozL10n) {
     },
 
     getInitialState: function() {
       return {
         contact: null,
         pristine: true,
         name: "",
         email: "",
-        tel: "",
+        tel: ""
       };
     },
 
     initForm: function(contact) {
       let state = this.getInitialState();
       if (contact) {
         state.contact = contact;
         state.name = contact.name[0];
@@ -646,17 +646,17 @@ loop.contacts = (function(_, mozL10n) {
         state.tel = getPreferred(contact, "tel").value;
       }
       this.setState(state);
     },
 
     handleAcceptButtonClick: function() {
       // Allow validity error indicators to be displayed.
       this.setState({
-        pristine: false,
+        pristine: false
       });
 
       let emailInput = this.refs.email.getDOMNode();
       let telInput = this.refs.tel.getDOMNode();
       if (!this.refs.name.getDOMNode().checkValidity() ||
           ((emailInput.required || emailInput.value) && !emailInput.checkValidity()) ||
           ((telInput.required || telInput.value) && !telInput.checkValidity())) {
         return;
@@ -672,17 +672,17 @@ loop.contacts = (function(_, mozL10n) {
           setPreferred(this.state.contact, "email", this.state.email.trim());
           setPreferred(this.state.contact, "tel", this.state.tel.trim());
           contactsAPI.update(this.state.contact, err => {
             if (err) {
               throw err;
             }
           });
           this.setState({
-            contact: null,
+            contact: null
           });
           break;
         case "add":
           var contact = {
             id: navigator.mozLoop.generateUUID(),
             name: [this.state.name.trim()],
             email: [{
               pref: true,
@@ -747,11 +747,11 @@ loop.contacts = (function(_, mozL10n) {
       );
     }
   });
 
   return {
     ContactsList: ContactsList,
     ContactDetailsForm: ContactDetailsForm,
     _getPreferred: getPreferred,
-    _setPreferred: setPreferred,
+    _setPreferred: setPreferred
   };
 })(_, document.mozL10n);
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -134,17 +134,17 @@ loop.conversation = (function(mozL10n) {
     });
     var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
       feedbackClient: feedbackClient
     });
 
     loop.store.StoreMixin.register({
       conversationAppStore: conversationAppStore,
       conversationStore: conversationStore,
-      feedbackStore: feedbackStore,
+      feedbackStore: feedbackStore
     });
 
     // Obtain the windowId and pass it through
     var locationHash = loop.shared.utils.locationData().hash;
     var windowId;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -134,17 +134,17 @@ loop.conversation = (function(mozL10n) {
     });
     var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
       feedbackClient: feedbackClient
     });
 
     loop.store.StoreMixin.register({
       conversationAppStore: conversationAppStore,
       conversationStore: conversationStore,
-      feedbackStore: feedbackStore,
+      feedbackStore: feedbackStore
     });
 
     // Obtain the windowId and pass it through
     var locationHash = loop.shared.utils.locationData().hash;
     var windowId;
 
     var hash = locationHash.match(/#(.*)/);
     if (hash) {
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -148,17 +148,17 @@ loop.conversationViews = (function(mozL1
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       mozLoop: React.PropTypes.object.isRequired,
       // Only for use by the ui-showcase
       showMenu: React.PropTypes.bool
     },
 
     getDefaultProps: function() {
       return {
-        showMenu: false,
+        showMenu: false
       };
     },
 
     componentDidMount: function() {
       this.props.mozLoop.startAlerting();
     },
 
     componentWillUnmount: function() {
@@ -280,17 +280,17 @@ loop.conversationViews = (function(mozL1
 
   /**
    * Incoming call view accept button, renders different primary actions
    * (answer with video / with audio only) based on the props received
    **/
   var AcceptCallButton = React.createClass({displayName: "AcceptCallButton",
 
     propTypes: {
-      mode: React.PropTypes.object.isRequired,
+      mode: React.PropTypes.object.isRequired
     },
 
     render: function() {
       var mode = this.props.mode;
       return (
         React.createElement("div", {className: "btn-chevron-menu-group"}, 
           React.createElement("div", {className: "btn-group"}, 
             React.createElement("button", {className: "btn btn-accept", 
@@ -756,17 +756,17 @@ loop.conversationViews = (function(mozL1
         case CALL_STATES.INIT: {
           // We know what we are, but we haven't got the data yet.
           return null;
         }
         default: {
           return this._renderViewFromCallType();
         }
       }
-    },
+    }
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
     ConversationDetailView: ConversationDetailView,
     CallFailedView: CallFailedView,
     _getContactDisplayName: _getContactDisplayName,
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -148,17 +148,17 @@ loop.conversationViews = (function(mozL1
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       mozLoop: React.PropTypes.object.isRequired,
       // Only for use by the ui-showcase
       showMenu: React.PropTypes.bool
     },
 
     getDefaultProps: function() {
       return {
-        showMenu: false,
+        showMenu: false
       };
     },
 
     componentDidMount: function() {
       this.props.mozLoop.startAlerting();
     },
 
     componentWillUnmount: function() {
@@ -280,17 +280,17 @@ loop.conversationViews = (function(mozL1
 
   /**
    * Incoming call view accept button, renders different primary actions
    * (answer with video / with audio only) based on the props received
    **/
   var AcceptCallButton = React.createClass({
 
     propTypes: {
-      mode: React.PropTypes.object.isRequired,
+      mode: React.PropTypes.object.isRequired
     },
 
     render: function() {
       var mode = this.props.mode;
       return (
         <div className="btn-chevron-menu-group">
           <div className="btn-group">
             <button className="btn btn-accept"
@@ -756,17 +756,17 @@ loop.conversationViews = (function(mozL1
         case CALL_STATES.INIT: {
           // We know what we are, but we haven't got the data yet.
           return null;
         }
         default: {
           return this._renderViewFromCallType();
         }
       }
-    },
+    }
   });
 
   return {
     PendingConversationView: PendingConversationView,
     CallIdentifierView: CallIdentifierView,
     ConversationDetailView: ConversationDetailView,
     CallFailedView: CallFailedView,
     _getContactDisplayName: _getContactDisplayName,
--- a/browser/components/loop/content/js/otconfig.js
+++ b/browser/components/loop/content/js/otconfig.js
@@ -1,10 +1,10 @@
 /* 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/. */
 
 window.OTProperties = {
-  cdnURL: 'loop/',
+  cdnURL: 'loop/'
 };
 window.OTProperties.assetURL = window.OTProperties.cdnURL + 'sdk-content/';
 window.OTProperties.configURL = window.OTProperties.assetURL + 'js/dynamic_config.min.js';
 window.OTProperties.cssURL = window.OTProperties.assetURL + 'css/ot.css';
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -263,17 +263,17 @@ loop.panel = (function(_, mozL10n) {
             React.createElement("a", {href: terms_of_use_url, target: "_blank"}, 
               mozL10n.get("legal_text_tos")
             )
           ),
           "privacy_notice": React.renderToStaticMarkup(
             React.createElement("a", {href: privacy_notice_url, target: "_blank"}, 
               mozL10n.get("legal_text_privacy")
             )
-          ),
+          )
         });
         return (
           React.createElement("div", {id: "powered-by-wrapper"}, 
             this.renderPartnerLogo(), 
             React.createElement("p", {className: "terms-service", 
                dangerouslySetInnerHTML: {__html: tosHTML}, 
                onClick: this.handleLinkClick})
            )
@@ -754,43 +754,43 @@ loop.panel = (function(_, mozL10n) {
       mozLoop: React.PropTypes.object.isRequired,
       roomStore:
         React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || this.props.mozLoop.userProfile,
-        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen"),
+        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
       };
     },
 
     _serviceErrorToShow: function() {
       if (!this.props.mozLoop.errors ||
           !Object.keys(this.props.mozLoop.errors).length) {
         return null;
       }
       // Just get the first error for now since more than one should be rare.
       var firstErrorKey = Object.keys(this.props.mozLoop.errors)[0];
       return {
         type: firstErrorKey,
-        error: this.props.mozLoop.errors[firstErrorKey],
+        error: this.props.mozLoop.errors[firstErrorKey]
       };
     },
 
     updateServiceErrors: function() {
       var serviceError = this._serviceErrorToShow();
       if (serviceError) {
         this.props.notifications.set({
           id: "service-error",
           level: "error",
           message: serviceError.error.friendlyMessage,
           details: serviceError.error.friendlyDetails,
           detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
-          detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback,
+          detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback
         });
       } else {
         this.props.notifications.remove(this.props.notifications.get("service-error"));
       }
     },
 
     _onStatusChanged: function() {
       var profile = this.props.mozLoop.userProfile;
@@ -801,17 +801,17 @@ loop.panel = (function(_, mozL10n) {
         this.selectTab("rooms");
         this.setState({userProfile: profile});
       }
       this.updateServiceErrors();
     },
 
     _gettingStartedSeen: function() {
       this.setState({
-        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen"),
+        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
       });
     },
 
     _UIActionHandler: function(e) {
       switch (e.detail.action) {
         case "selectTab":
           this.selectTab(e.detail.tab);
           break;
@@ -954,13 +954,13 @@ loop.panel = (function(_, mozL10n) {
     AvailabilityDropdown: AvailabilityDropdown,
     GettingStartedView: GettingStartedView,
     NewRoomView: NewRoomView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
-    UserIdentity: UserIdentity,
+    UserIdentity: UserIdentity
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -263,17 +263,17 @@ loop.panel = (function(_, mozL10n) {
             <a href={terms_of_use_url} target="_blank">
               {mozL10n.get("legal_text_tos")}
             </a>
           ),
           "privacy_notice": React.renderToStaticMarkup(
             <a href={privacy_notice_url} target="_blank">
               {mozL10n.get("legal_text_privacy")}
             </a>
-          ),
+          )
         });
         return (
           <div id="powered-by-wrapper">
             {this.renderPartnerLogo()}
             <p className="terms-service"
                dangerouslySetInnerHTML={{__html: tosHTML}}
                onClick={this.handleLinkClick}></p>
            </div>
@@ -754,43 +754,43 @@ loop.panel = (function(_, mozL10n) {
       mozLoop: React.PropTypes.object.isRequired,
       roomStore:
         React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || this.props.mozLoop.userProfile,
-        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen"),
+        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
       };
     },
 
     _serviceErrorToShow: function() {
       if (!this.props.mozLoop.errors ||
           !Object.keys(this.props.mozLoop.errors).length) {
         return null;
       }
       // Just get the first error for now since more than one should be rare.
       var firstErrorKey = Object.keys(this.props.mozLoop.errors)[0];
       return {
         type: firstErrorKey,
-        error: this.props.mozLoop.errors[firstErrorKey],
+        error: this.props.mozLoop.errors[firstErrorKey]
       };
     },
 
     updateServiceErrors: function() {
       var serviceError = this._serviceErrorToShow();
       if (serviceError) {
         this.props.notifications.set({
           id: "service-error",
           level: "error",
           message: serviceError.error.friendlyMessage,
           details: serviceError.error.friendlyDetails,
           detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
-          detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback,
+          detailsButtonCallback: serviceError.error.friendlyDetailsButtonCallback
         });
       } else {
         this.props.notifications.remove(this.props.notifications.get("service-error"));
       }
     },
 
     _onStatusChanged: function() {
       var profile = this.props.mozLoop.userProfile;
@@ -801,17 +801,17 @@ loop.panel = (function(_, mozL10n) {
         this.selectTab("rooms");
         this.setState({userProfile: profile});
       }
       this.updateServiceErrors();
     },
 
     _gettingStartedSeen: function() {
       this.setState({
-        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen"),
+        gettingStartedSeen: this.props.mozLoop.getLoopPref("gettingStarted.seen")
       });
     },
 
     _UIActionHandler: function(e) {
       switch (e.detail.action) {
         case "selectTab":
           this.selectTab(e.detail.tab);
           break;
@@ -954,13 +954,13 @@ loop.panel = (function(_, mozL10n) {
     AvailabilityDropdown: AvailabilityDropdown,
     GettingStartedView: GettingStartedView,
     NewRoomView: NewRoomView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
-    UserIdentity: UserIdentity,
+    UserIdentity: UserIdentity
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/js/roomStore.js
+++ b/browser/components/loop/content/js/roomStore.js
@@ -112,17 +112,17 @@ loop.store = loop.store || {};
     },
 
     getInitialStoreState: function() {
       return {
         activeRoom: this.activeRoomStore ? this.activeRoomStore.getStoreState() : {},
         error: null,
         pendingCreation: false,
         pendingInitialRetrieval: false,
-        rooms: [],
+        rooms: []
       };
     },
 
     /**
      * Registers mozLoop.rooms events.
      */
     startListeningToRoomEvents: function() {
       // Rooms event registration
@@ -253,17 +253,17 @@ loop.store = loop.store || {};
     /**
      * Creates a new room.
      *
      * @param {sharedActions.CreateRoom} actionData The new room information.
      */
     createRoom: function(actionData) {
       this.setStoreState({
         pendingCreation: true,
-        error: null,
+        error: null
       });
 
       var roomCreationData = {
         decryptedContext: {
           roomName:  this._generateNewRoomName(actionData.nameTemplate)
         },
         roomOwner: actionData.roomOwner,
         maxSize:   this.maxRoomCreationSize
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -124,17 +124,17 @@ loop.roomViews = (function(mozL10n) {
           React.createElement("div", {className: shareDropdown}, 
             React.createElement("div", {className: "share-panel-header"}, 
               mozL10n.get("share_panel_header")
             ), 
             React.createElement("div", {className: "share-panel-body"}, 
               
                 mozL10n.get("share_panel_body", {
                   brandShortname: mozL10n.get("brandShortname"),
-                  clientSuperShortname: mozL10n.get("clientSuperShortname"),
+                  clientSuperShortname: mozL10n.get("clientSuperShortname")
                 })
               
             ), 
             React.createElement("button", {className: "btn btn-info btn-toolbar-add", 
                     onClick: this.handleToolbarAddButtonClick}, 
               mozL10n.get("add_to_toolbar_button")
             )
           )
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -124,17 +124,17 @@ loop.roomViews = (function(mozL10n) {
           <div className={shareDropdown}>
             <div className="share-panel-header">
               {mozL10n.get("share_panel_header")}
             </div>
             <div className="share-panel-body">
               {
                 mozL10n.get("share_panel_body", {
                   brandShortname: mozL10n.get("brandShortname"),
-                  clientSuperShortname: mozL10n.get("clientSuperShortname"),
+                  clientSuperShortname: mozL10n.get("clientSuperShortname")
                 })
               }
             </div>
             <button className="btn btn-info btn-toolbar-add"
                     onClick={this.handleToolbarAddButtonClick}>
               {mozL10n.get("add_to_toolbar_button")}
             </button>
           </div>
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -247,17 +247,17 @@ loop.shared.actions = (function() {
     /**
      * Creates a new room.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     CreateRoom: Action.define("createRoom", {
       // The localized template to use to name the new room
       // (eg. "Conversation {{conversationLabel}}").
       nameTemplate: String,
-      roomOwner: String,
+      roomOwner: String
       // See https://wiki.mozilla.org/Loop/Architecture/Context#Format_of_context.value
       // urls: Object - Optional
     }),
 
     /**
      * When a room has been created.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -560,17 +560,17 @@ loop.store.ActiveRoomStore = (function()
 
       if (screenSharingState === SCREEN_SHARE_STATES.INACTIVE) {
         // Screen sharing is still pending, so assume that we need to kick it off.
         var options = {
           videoSource: "browser",
           constraints: {
             browserWindow: windowId,
             scrollWithPage: true
-          },
+          }
         };
         this._sdkDriver.startScreenShare(options);
       } else if (screenSharingState === SCREEN_SHARE_STATES.ACTIVE) {
         // Just update the current share.
         this._sdkDriver.switchAcquiredWindow(windowId);
       } else {
         console.error("Unexpectedly received windowId for browser sharing when pending");
       }
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -181,17 +181,17 @@ loop.shared.mixins = (function() {
     },
 
     hideDropdownMenu: function() {
       this.setState({showMenu: false});
     },
 
     toggleDropdownMenu: function() {
       this[this.state.showMenu ? "hideDropdownMenu" : "showDropdownMenu"]();
-    },
+    }
   };
 
   /**
    * Document visibility mixin. Allows defining the following hooks for when the
    * document visibility status changes:
    *
    * - {Function} onDocumentVisible For when the document becomes visible.
    * - {Function} onDocumentHidden  For when the document becomes hidden.
@@ -512,17 +512,17 @@ loop.shared.mixins = (function() {
       // height set to 100%" to fix video layout on Google Chrome
       // @see https://bugzilla.mozilla.org/show_bug.cgi?id=1020445
       return {
         insertMode: "append",
         fitMode: "contain",
         width: "100%",
         height: "100%",
         publishVideo: options.publishVideo,
-        showControls: false,
+        showControls: false
       };
     },
 
     /**
      * Returns either the required DOMNode
      *
      * @param {String} className The name of the class to get the element for.
      */
--- a/browser/components/loop/content/shared/js/models.js
+++ b/browser/components/loop/content/shared/js/models.js
@@ -358,17 +358,17 @@ loop.shared.models = (function(l10n) {
      */
     _connectionDestroyed: function(event) {
       if (event.reason === "networkDisconnected") {
         this._signalEnd("session:network-disconnected", event);
       } else {
         this._signalEnd("session:peer-hungup", event);
       }
       this.endSession();
-    },
+    }
   });
 
   /**
    * Notification model.
    */
   var NotificationModel = Backbone.Model.extend({
     defaults: {
       details: "",
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -83,17 +83,17 @@ loop.shared.views = (function(_, l10n) {
    *                                 loop.shared.utils.SCREEN_SHARE_STATES
    */
   var ScreenShareControlButton = React.createClass({displayName: "ScreenShareControlButton",
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       visible: React.PropTypes.bool.isRequired,
-      state: React.PropTypes.string.isRequired,
+      state: React.PropTypes.string.isRequired
     },
 
     getInitialState: function() {
       var os = loop.shared.utils.getOS();
       var osVersion = loop.shared.utils.getOSVersion();
       // Disable screensharing on older OSX and Windows versions.
       if ((os.indexOf("mac") > -1 && osVersion.major <= 10 && osVersion.minor <= 6) ||
           (os.indexOf("win") > -1 && osVersion.major <= 5 && osVersion.minor <= 2)) {
@@ -194,17 +194,17 @@ loop.shared.views = (function(_, l10n) {
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       screenShare: React.PropTypes.object,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired,
       hangupButtonLabel: React.PropTypes.string,
-      enableHangup: React.PropTypes.bool,
+      enableHangup: React.PropTypes.bool
     },
 
     handleClickHangup: function() {
       this.props.hangup();
     },
 
     handleToggleVideo: function() {
       this.props.publishStream("video", !this.props.video.enabled);
@@ -555,24 +555,24 @@ loop.shared.views = (function(_, l10n) {
   });
 
   var Button = React.createClass({displayName: "Button",
     propTypes: {
       caption: React.PropTypes.string.isRequired,
       onClick: React.PropTypes.func.isRequired,
       disabled: React.PropTypes.bool,
       additionalClass: React.PropTypes.string,
-      htmlId: React.PropTypes.string,
+      htmlId: React.PropTypes.string
     },
 
     getDefaultProps: function() {
       return {
         disabled: false,
         additionalClass: "",
-        htmlId: "",
+        htmlId: ""
       };
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var classObject = { button: true, disabled: this.props.disabled };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
@@ -591,17 +591,17 @@ loop.shared.views = (function(_, l10n) {
 
   var ButtonGroup = React.createClass({displayName: "ButtonGroup",
     PropTypes: {
       additionalClass: React.PropTypes.string
     },
 
     getDefaultProps: function() {
       return {
-        additionalClass: "",
+        additionalClass: ""
       };
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var classObject = { "button-group": true };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -83,17 +83,17 @@ loop.shared.views = (function(_, l10n) {
    *                                 loop.shared.utils.SCREEN_SHARE_STATES
    */
   var ScreenShareControlButton = React.createClass({
     mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       visible: React.PropTypes.bool.isRequired,
-      state: React.PropTypes.string.isRequired,
+      state: React.PropTypes.string.isRequired
     },
 
     getInitialState: function() {
       var os = loop.shared.utils.getOS();
       var osVersion = loop.shared.utils.getOSVersion();
       // Disable screensharing on older OSX and Windows versions.
       if ((os.indexOf("mac") > -1 && osVersion.major <= 10 && osVersion.minor <= 6) ||
           (os.indexOf("win") > -1 && osVersion.major <= 5 && osVersion.minor <= 2)) {
@@ -194,17 +194,17 @@ loop.shared.views = (function(_, l10n) {
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       video: React.PropTypes.object.isRequired,
       audio: React.PropTypes.object.isRequired,
       screenShare: React.PropTypes.object,
       hangup: React.PropTypes.func.isRequired,
       publishStream: React.PropTypes.func.isRequired,
       hangupButtonLabel: React.PropTypes.string,
-      enableHangup: React.PropTypes.bool,
+      enableHangup: React.PropTypes.bool
     },
 
     handleClickHangup: function() {
       this.props.hangup();
     },
 
     handleToggleVideo: function() {
       this.props.publishStream("video", !this.props.video.enabled);
@@ -555,24 +555,24 @@ loop.shared.views = (function(_, l10n) {
   });
 
   var Button = React.createClass({
     propTypes: {
       caption: React.PropTypes.string.isRequired,
       onClick: React.PropTypes.func.isRequired,
       disabled: React.PropTypes.bool,
       additionalClass: React.PropTypes.string,
-      htmlId: React.PropTypes.string,
+      htmlId: React.PropTypes.string
     },
 
     getDefaultProps: function() {
       return {
         disabled: false,
         additionalClass: "",
-        htmlId: "",
+        htmlId: ""
       };
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var classObject = { button: true, disabled: this.props.disabled };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
@@ -591,17 +591,17 @@ loop.shared.views = (function(_, l10n) {
 
   var ButtonGroup = React.createClass({
     PropTypes: {
       additionalClass: React.PropTypes.string
     },
 
     getDefaultProps: function() {
       return {
-        additionalClass: "",
+        additionalClass: ""
       };
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var classObject = { "button-group": true };
       if (this.props.additionalClass) {
         classObject[this.props.additionalClass] = true;
--- a/browser/components/loop/modules/GoogleImporter.jsm
+++ b/browser/components/loop/modules/GoogleImporter.jsm
@@ -457,17 +457,17 @@ this.GoogleImporter.prototype = {
     extractFieldsFromNode(new Map([
       ["note", "content"]
     ]), entry, null, contact, true);
 
     // Process physical, earthly addresses.
     let addressNodes = entry.getElementsByTagNameNS(kNS_GD, "structuredPostalAddress");
     if (addressNodes.length) {
       contact.adr = [];
-      for (let [,addressNode] of Iterator(addressNodes)) {
+      for (let [, addressNode] of Iterator(addressNodes)) {
         let adr = extractFieldsFromNode(new Map([
           ["countryName", "country"],
           ["locality", "city"],
           ["postalCode", "postcode"],
           ["region", "region"],
           ["streetAddress", "street"]
         ]), addressNode, kNS_GD);
         if (Object.keys(adr).length) {
@@ -477,46 +477,46 @@ this.GoogleImporter.prototype = {
         }
       }
     }
 
     // Process email addresses.
     let emailNodes = entry.getElementsByTagNameNS(kNS_GD, "email");
     if (emailNodes.length) {
       contact.email = [];
-      for (let [,emailNode] of Iterator(emailNodes)) {
+      for (let [, emailNode] of Iterator(emailNodes)) {
         contact.email.push({
           pref: (emailNode.getAttribute("primary") == "true"),
           type: [getFieldType(emailNode)],
           value: emailNode.getAttribute("address")
         });
       }
     }
 
     // Process telephone numbers.
     let phoneNodes = entry.getElementsByTagNameNS(kNS_GD, "phoneNumber");
     if (phoneNodes.length) {
       contact.tel = [];
-      for (let [,phoneNode] of Iterator(phoneNodes)) {
+      for (let [, phoneNode] of Iterator(phoneNodes)) {
         let phoneNumber = phoneNode.hasAttribute("uri") ?
           phoneNode.getAttribute("uri").replace("tel:", "") :
           phoneNode.textContent;
         contact.tel.push({
           pref: (phoneNode.getAttribute("primary") == "true"),
           type: [getFieldType(phoneNode)],
           value: phoneNumber
         });
       }
     }
 
     let orgNodes = entry.getElementsByTagNameNS(kNS_GD, "organization");
     if (orgNodes.length) {
       contact.org = [];
       contact.jobTitle = [];
-      for (let [,orgNode] of Iterator(orgNodes)) {
+      for (let [, orgNode] of Iterator(orgNodes)) {
         let orgElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgName")[0];
         let titleElement = orgNode.getElementsByTagNameNS(kNS_GD, "orgTitle")[0];
         contact.org.push(orgElement ? orgElement.textContent : "");
         contact.jobTitle.push(titleElement ? titleElement.textContent : "");
       }
     }
 
     contact.category = ["google"];
--- a/browser/components/loop/modules/LoopCalls.jsm
+++ b/browser/components/loop/modules/LoopCalls.jsm
@@ -89,17 +89,17 @@ CallProgressSocket.prototype = {
    * Sends a hello message to the server.
    *
    * @param {nsISupports} aContext Not used
    */
   onStart: function() {
     let helloMsg = {
       messageType: "hello",
       callId: this._callId,
-      auth: this._token,
+      auth: this._token
     };
     try { // in case websocket has closed before this handler is run
       this._websocket.sendMsg(JSON.stringify(helloMsg));
     }
     catch (error) {
       this._onError(error);
     }
   },
@@ -177,29 +177,29 @@ CallProgressSocket.prototype = {
    * with a reason of busy.
    */
   sendBusy: function() {
     this._send({
       messageType: "action",
       event: "terminate",
       reason: "busy"
     });
-  },
+  }
 };
 
 /**
  * Internal helper methods and state
  *
  * The registration is a two-part process. First we need to connect to
  * and register with the push server. Then we need to take the result of that
  * and register with the Loop server.
  */
 let LoopCallsInternal = {
   mocks: {
-    webSocket: undefined,
+    webSocket: undefined
   },
 
   conversationInProgress: {},
 
   /**
    * Callback from MozLoopPushHandler - A push notification has been received from
    * the server.
    *
--- a/browser/components/loop/modules/MozLoopPushHandler.jsm
+++ b/browser/components/loop/modules/MozLoopPushHandler.jsm
@@ -202,17 +202,17 @@ PushSocket.prototype = {
     this._onStart = function() {};
     this._onMsg = this._onStart;
     this._onClose = this._onStart;
 
     try {
       this._websocket.close(this._websocket.CLOSE_NORMAL);
     }
     catch (e) {}
-  },
+  }
 };
 
 
 /**
  * Create a RetryManager object. Class to handle retrying a UserAgent
  * to PushServer request following a retry back-off scheme managed by
  * this class. The current delay mechanism is to double the delay
  * each time an operation to be retried until a maximum is met.
@@ -255,17 +255,17 @@ RetryManager.prototype = {
    * Method used to reset the delay back-off logic and clear any currently
    * running delay timeout.
    */
   reset: function() {
     if (this._timeoutID) {
       clearTimeout(this._timeoutID);
       this._timeoutID = null;
     }
-  },
+  }
 };
 
 /**
  * Create a PingMonitor object. An object instance will periodically execute
  * a ping send function and if not reset, will then execute an error function.
  *
  * @param {Function} pingFunc Function that is called after a ping interval
  *                   has expired without being restart.
@@ -308,17 +308,17 @@ PingMonitor.prototype = {
       this._pingTimerID = undefined;
     }
   },
 
   _pingSend: function () {
     consoleLog.info("PushHandler: ping sent");
     this._pingTimerID = setTimeout(this._onTimeout, this._pingTimeout);
     this._pingFunc();
-  },
+  }
 };
 
 
 /**
  * We don't have push notifications on desktop currently, so this is a
  * workaround to get them going for us.
  */
 let MozLoopPushHandler = {
@@ -865,10 +865,10 @@ let MozLoopPushHandler = {
    *
    * @param {string} channelID - identification token to use in registration for this channel.
    */
   _sendRegistration: function(channelID) {
     if (channelID) {
       this._pushSocket.send({messageType: "register",
                              channelID: channelID});
     }
-  },
+  }
 };
--- a/browser/components/loop/modules/MozLoopService.jsm
+++ b/browser/components/loop/modules/MozLoopService.jsm
@@ -7,31 +7,31 @@
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 // Invalid auth token as per
 // https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
 const INVALID_AUTH_TOKEN = 110;
 
 const LOOP_SESSION_TYPE = {
   GUEST: 1,
-  FXA: 2,
+  FXA: 2
 };
 
 /***
  * Values that we segment 2-way media connection length telemetry probes
  * into.
  *
  * @type {{SHORTER_THAN_10S: Number, BETWEEN_10S_AND_30S: Number,
  *   BETWEEN_30S_AND_5M: Number, MORE_THAN_5M: Number}}
  */
 const TWO_WAY_MEDIA_CONN_LENGTH = {
   SHORTER_THAN_10S: 0,
   BETWEEN_10S_AND_30S: 1,
   BETWEEN_30S_AND_5M: 2,
-  MORE_THAN_5M: 3,
+  MORE_THAN_5M: 3
 };
 
 /**
  * Values that we segment sharing state change telemetry probes into.
  *
  * @type {{WINDOW_ENABLED: Number, WINDOW_DISABLED: Number,
  *   BROWSER_ENABLED: Number, BROWSER_DISABLED: Number}}
  */
@@ -118,17 +118,17 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "@mozilla.org/appshell/window-mediator;1",
                                    "nsIWindowMediator");
 
 // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
 XPCOMUtils.defineLazyGetter(this, "log", () => {
   let ConsoleAPI = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).ConsoleAPI;
   let consoleOptions = {
     maxLogLevel: Services.prefs.getCharPref(PREF_LOG_LEVEL).toLowerCase(),
-    prefix: "Loop",
+    prefix: "Loop"
   };
   return new ConsoleAPI(consoleOptions);
 });
 
 function setJSONPref(aName, aValue) {
   let value = !!aValue ? JSON.stringify(aValue) : "";
   Services.prefs.setCharPref(aName, value);
 }
@@ -154,17 +154,17 @@ let gConversationWindowData = new Map();
  * and register with the push server. Then we need to take the result of that
  * and register with the Loop server.
  */
 let MozLoopServiceInternal = {
   conversationContexts: new Map(),
   pushURLs: new Map(),
 
   mocks: {
-    pushHandler: undefined,
+    pushHandler: undefined
   },
 
   /**
    * The current deferreds for the registration processes. This is set if in progress
    * or the registration was successful. This is null if a registration attempt was
    * unsuccessful.
    */
   deferredRegistrations: new Map(),
@@ -270,17 +270,17 @@ let MozLoopServiceInternal = {
     const NETWORK_ERRORS = [
       Cr.NS_ERROR_CONNECTION_REFUSED,
       Cr.NS_ERROR_NET_INTERRUPT,
       Cr.NS_ERROR_NET_RESET,
       Cr.NS_ERROR_NET_TIMEOUT,
       Cr.NS_ERROR_OFFLINE,
       Cr.NS_ERROR_PROXY_CONNECTION_REFUSED,
       Cr.NS_ERROR_UNKNOWN_HOST,
-      Cr.NS_ERROR_UNKNOWN_PROXY_HOST,
+      Cr.NS_ERROR_UNKNOWN_PROXY_HOST
     ];
 
     if (error.code === null && error.errno === null &&
         error.error instanceof Ci.nsIException &&
         NETWORK_ERRORS.indexOf(error.error.result) != -1) {
       // Network error. Override errorType so we can easily clear it on the next succesful request.
       errorType = "network";
       messageString = "could_not_connect";
@@ -760,17 +760,17 @@ let MozLoopServiceInternal = {
         // We have stats and logs.
 
         // Create worker job. ping = saved telemetry ping file header + payload
         //
         // Prepare payload according to https://wiki.mozilla.org/Loop/Telemetry
 
         let ai = Services.appinfo;
         let uuid = uuidgen.generateUUID().toString();
-        uuid = uuid.substr(1,uuid.length-2); // remove uuid curly braces
+        uuid = uuid.substr(1, uuid.length-2); // remove uuid curly braces
 
         let directory = OS.Path.join(OS.Constants.Path.profileDir,
                                      "saved-telemetry-pings");
         let job = {
           directory: directory,
           filename: uuid + ".json",
           ping: {
             reason: "loop",
@@ -959,17 +959,17 @@ let MozLoopServiceInternal = {
 
     gFxAOAuthClientPromise = this.promiseFxAOAuthParameters().then(
       parameters => {
         // Add the fact that we want keys to the parameters.
         parameters.keys = true;
 
         try {
           gFxAOAuthClient = new FxAccountsOAuthClient({
-            parameters: parameters,
+            parameters: parameters
           });
         } catch (ex) {
           gFxAOAuthClientPromise = null;
           throw ex;
         }
         return gFxAOAuthClient;
       },
       error => {
@@ -1015,17 +1015,17 @@ let MozLoopServiceInternal = {
    */
   promiseFxAOAuthToken: function(code, state) {
     if (!code || !state) {
       throw new Error("promiseFxAOAuthToken: code and state are required.");
     }
 
     let payload = {
       code: code,
-      state: state,
+      state: state
     };
     return this.hawkRequestInternal(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/token", "POST", payload).then(response => {
       return JSON.parse(response.body);
     },
     error => { this._hawkRequestError(error); });
   },
 
   /**
@@ -1047,17 +1047,17 @@ let MozLoopServiceInternal = {
    * Called if gFxAOAuthClient fires onError.
    *
    * @param {Deferred} deferred used to reject the gFxAOAuthClientPromise
    * @param {Object} error object returned by FxAOAuthClient
    */
   _fxAOAuthError: function(deferred, err) {
     gFxAOAuthClientPromise = null;
     deferred.reject(err);
-  },
+  }
 };
 Object.freeze(MozLoopServiceInternal);
 
 
 let gInitializeTimerFunc = (deferredInitialization) => {
   // Kick off the push notification service into registering after a timeout.
   // This ensures we're not doing too much straight after the browser's finished
   // starting up.
@@ -1074,17 +1074,17 @@ this.MozLoopService = {
   _DNSService: gDNSService,
   _activeScreenShares: [],
 
   get channelIDs() {
     // Channel ids that will be registered with the PushServer for notifications
     return {
       callsFxA: "25389583-921f-4169-a426-a4673658944b",
       roomsFxA: "6add272a-d316-477c-8335-f00f73dfde71",
-      roomsGuest: "19d3f799-a8f3-4328-9822-b7cd02765832",
+      roomsGuest: "19d3f799-a8f3-4328-9822-b7cd02765832"
     };
   },
 
   set initializeTimerFunc(value) {
     gInitializeTimerFunc = value;
   },
 
   get roomsParticipantsCount() {
@@ -1616,51 +1616,51 @@ this.MozLoopService = {
   },
 
   resumeTour: function(aIncomingConversationState) {
     if (!this.getLoopPref("gettingStarted.resumeOnFirstJoin")) {
       return;
     }
 
     let url = this.getTourURL("resume-with-conversation", {
-      incomingConversation: aIncomingConversationState,
+      incomingConversation: aIncomingConversationState
     });
 
     let win = Services.wm.getMostRecentWindow("navigator:browser");
 
     this.setLoopPref("gettingStarted.resumeOnFirstJoin", false);
 
     // The query parameters of the url can vary but we always want to re-use a Loop tour tab that's
     // already open so we ignore the fragment and query string.
     let hadExistingTab = win.switchToTabHavingURI(url, true, {
       ignoreFragment: true,
-      ignoreQueryString: true,
+      ignoreQueryString: true
     });
 
     // If the tab was already open, send an event instead of using the query
     // parameter above (that we don't replace on existing tabs to avoid a reload).
     if (hadExistingTab) {
       UITour.notify("Loop:IncomingConversation", {
-        conversationOpen: aIncomingConversationState === "open",
+        conversationOpen: aIncomingConversationState === "open"
       });
     }
   },
 
   /**
    * Opens the Getting Started tour in the browser.
    *
    * @param {String} [aSrc] A string representing the entry point to begin the tour, optional.
    */
   openGettingStartedTour: Task.async(function(aSrc = null) {
     try {
       let url = this.getTourURL(aSrc);
       let win = Services.wm.getMostRecentWindow("navigator:browser");
       win.switchToTabHavingURI(url, true, {
         ignoreFragment: true,
-        replaceQueryString: true,
+        replaceQueryString: true
       });
     } catch (ex) {
       log.error("Error opening Getting Started tour", ex);
     }
   }),
 
   /**
    * Opens a URL in a new tab in the browser.
--- a/browser/components/loop/standalone/content/js/multiplexGum.js
+++ b/browser/components/loop/standalone/content/js/multiplexGum.js
@@ -119,17 +119,17 @@ loop.standaloneMedia = (function() {
           this.userMedia.localStream.stop();
         }
       }
       this.userMedia = {
         error: null,
         localStream: null,
         pending: false,
         errorCallbacks: [],
-        successCallbacks: [],
+        successCallbacks: []
       };
     }
   };
 
   var singletonMultiplexGum = new _MultiplexGum();
   function myGetUserMedia() {
     // This function is needed to pull in the instance
     // of the singleton for tests to overwrite the used instance.
@@ -140,11 +140,11 @@ loop.standaloneMedia = (function() {
   patchSymbolIfExtant("navigator", "getUserMedia", myGetUserMedia);
   patchSymbolIfExtant("TBPlugin", "getUserMedia", myGetUserMedia);
 
   return {
     multiplexGum: singletonMultiplexGum,
     _MultiplexGum: _MultiplexGum,
     setSingleton: function(singleton) {
       singletonMultiplexGum = singleton;
-    },
+    }
   };
 })();
--- a/browser/components/loop/standalone/content/js/standaloneClient.js
+++ b/browser/components/loop/standalone/content/js/standaloneClient.js
@@ -123,13 +123,13 @@ loop.StandaloneClient = (function($) {
           cb(null, this._validate(sessionData, expectedCallsProperties));
         } catch (err) {
           console.error("Error requesting call info", err.message);
           cb(err);
         }
       }.bind(this));
 
       req.fail(this._failureHandler.bind(this, cb));
-    },
+    }
   };
 
   return StandaloneClient;
 })(jQuery);
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -200,17 +200,17 @@ loop.standaloneRoomViews = (function(moz
           React.createElement("a", {href: loop.config.legalWebsiteUrl, target: "_blank"}, 
             mozL10n.get("terms_of_use_link_text")
           )
         ),
         "privacy_notice_url": React.renderToStaticMarkup(
           React.createElement("a", {href: loop.config.privacyWebsiteUrl, target: "_blank"}, 
             mozL10n.get("privacy_notice_link_text")
           )
-        ),
+        )
       });
     },
 
     recordClick: function(event) {
       // Check for valid href, as this is clicking on the paragraph -
       // so the user may be clicking on the text rather than the link.
       if (event.target && event.target.href) {
         this.props.dispatcher.dispatch(new sharedActions.RecordClick({
@@ -571,17 +571,17 @@ loop.standaloneRoomViews = (function(moz
         "remote": true,
         "focus-stream": !this.state.receivingScreenShare,
         "remote-inset-stream": this.state.receivingScreenShare
       });
 
       var screenShareStreamClasses = React.addons.classSet({
         "screen": true,
         "focus-stream": this.state.receivingScreenShare,
-        hide: !this.state.receivingScreenShare,
+        hide: !this.state.receivingScreenShare
       });
 
       return (
         React.createElement("div", {className: "room-conversation-wrapper"}, 
           React.createElement("div", {className: "beta-logo"}), 
           React.createElement(StandaloneRoomHeader, {dispatcher: this.props.dispatcher}), 
           React.createElement(StandaloneRoomInfoArea, {roomState: this.state.roomState, 
                                   failureReason: this.state.failureReason, 
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -200,17 +200,17 @@ loop.standaloneRoomViews = (function(moz
           <a href={loop.config.legalWebsiteUrl} target="_blank">
             {mozL10n.get("terms_of_use_link_text")}
           </a>
         ),
         "privacy_notice_url": React.renderToStaticMarkup(
           <a href={loop.config.privacyWebsiteUrl} target="_blank">
             {mozL10n.get("privacy_notice_link_text")}
           </a>
-        ),
+        )
       });
     },
 
     recordClick: function(event) {
       // Check for valid href, as this is clicking on the paragraph -
       // so the user may be clicking on the text rather than the link.
       if (event.target && event.target.href) {
         this.props.dispatcher.dispatch(new sharedActions.RecordClick({
@@ -571,17 +571,17 @@ loop.standaloneRoomViews = (function(moz
         "remote": true,
         "focus-stream": !this.state.receivingScreenShare,
         "remote-inset-stream": this.state.receivingScreenShare
       });
 
       var screenShareStreamClasses = React.addons.classSet({
         "screen": true,
         "focus-stream": this.state.receivingScreenShare,
-        hide: !this.state.receivingScreenShare,
+        hide: !this.state.receivingScreenShare
       });
 
       return (
         <div className="room-conversation-wrapper">
           <div className="beta-logo" />
           <StandaloneRoomHeader dispatcher={this.props.dispatcher} />
           <StandaloneRoomInfoArea roomState={this.state.roomState}
                                   failureReason={this.state.failureReason}
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -853,17 +853,17 @@ loop.webapp = (function($, _, OT, mozL10
      * call view if appropriate.
      *
      * @param {string} loopToken The session token to use.
      */
     _setupWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this.props.conversation.get("progressURL"),
         websocketToken: this.props.conversation.get("websocketToken"),
-        callId: this.props.conversation.get("callId"),
+        callId: this.props.conversation.get("callId")
       });
       this._websocket.promiseConnect().then(function() {
       }.bind(this), function() {
         // XXX Not the ideal response, but bug 1047410 will be replacing
         // this by better "call failed" UI.
         this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
         return;
       }.bind(this));
@@ -925,17 +925,17 @@ loop.webapp = (function($, _, OT, mozL10
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       multiplexGum.reset();
 
       if (this.state.callStatus !== "failure") {
         this.setState({callStatus: "end"});
       }
-    },
+    }
   });
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
    * of the webapp page.
    */
   var WebappRootView = React.createClass({displayName: "WebappRootView",
 
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -853,17 +853,17 @@ loop.webapp = (function($, _, OT, mozL10
      * call view if appropriate.
      *
      * @param {string} loopToken The session token to use.
      */
     _setupWebSocket: function() {
       this._websocket = new loop.CallConnectionWebSocket({
         url: this.props.conversation.get("progressURL"),
         websocketToken: this.props.conversation.get("websocketToken"),
-        callId: this.props.conversation.get("callId"),
+        callId: this.props.conversation.get("callId")
       });
       this._websocket.promiseConnect().then(function() {
       }.bind(this), function() {
         // XXX Not the ideal response, but bug 1047410 will be replacing
         // this by better "call failed" UI.
         this.props.notifications.errorL10n("cannot_start_call_session_not_ready");
         return;
       }.bind(this));
@@ -925,17 +925,17 @@ loop.webapp = (function($, _, OT, mozL10
      * Handles ending a call by resetting the view to the start state.
      */
     _endCall: function() {
       multiplexGum.reset();
 
       if (this.state.callStatus !== "failure") {
         this.setState({callStatus: "end"});
       }
-    },
+    }
   });
 
   /**
    * Webapp Root View. This is the main, single, view that controls the display
    * of the webapp page.
    */
   var WebappRootView = React.createClass({
 
--- a/browser/components/loop/test/desktop-local/contacts_test.js
+++ b/browser/components/loop/test/desktop-local/contacts_test.js
@@ -27,17 +27,17 @@ describe("loop.contacts", function() {
     tel: [{
       "pref": true,
       "type": ["mobile"],
       "value": "+31-6-12345678"
     }],
     category: ["google"],
     published: 1406798311748,
     updated: 1406798311748
-  },{
+  }, {
     id: 2,
     _guid: 2,
     name: ["Bob Banana"],
     email: [{
       "pref": true,
       "type": ["work"],
       "value": "bob@gmail.com"
     }],
@@ -113,17 +113,17 @@ describe("loop.contacts", function() {
         getAll: function(callback) {
           callback(null, [].concat(fakeContacts));
         },
         on: sandbox.stub()
       }
     };
 
     fakeWindow = {
-      close: sandbox.stub(),
+      close: sandbox.stub()
     };
     loop.shared.mixins.setRootObject(fakeWindow);
 
     notifications = new loop.shared.models.NotificationCollection();
 
     document.mozL10n.initialize(navigator.mozLoop);
   });
 
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -157,17 +157,17 @@ describe("loop.conversation", function()
         email: [{
           type: "home",
           value: "fakeEmail",
           pref: true
         }]
       }});
 
       roomStore = new loop.store.RoomStore(dispatcher, {
-        mozLoop: navigator.mozLoop,
+        mozLoop: navigator.mozLoop
       });
       conversationAppStore = new loop.store.ConversationAppStore({
         dispatcher: dispatcher,
         mozLoop: navigator.mozLoop
       });
 
       loop.store.StoreMixin.register({
         conversationAppStore: conversationAppStore,
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -796,17 +796,17 @@ describe("loop.panel", function() {
 
       sinon.assert.calledWith(dispatch, new sharedActions.CreateRoom({
         nameTemplate: "fakeText",
         roomOwner: fakeEmail,
         urls: [{
           location: "http://invalid.com",
           description: "fakeSite",
           thumbnail: "fakeimage.png"
-        }],
+        }]
       }));
     });
 
     it("should disable the create button when pendingOperation is true",
       function() {
         var view = createTestComponent(true);
 
         var buttonNode = view.getDOMNode().querySelector(".new-room-button[disabled]");
--- a/browser/components/loop/test/desktop-local/roomStore_test.js
+++ b/browser/components/loop/test/desktop-local/roomStore_test.js
@@ -196,17 +196,17 @@ describe("loop.store.RoomStore", functio
           expect(store.findNextAvailableRoomNumber(fakeNameTemplate)).eql(2);
         });
 
       it("should not be sensitive to initial list order", function() {
         store.setStoreState({
           rooms: [{
             decryptedContext: {
               roomName: "RoomWord 99"
-            },
+            }
           }, {
             decryptedContext: {
               roomName: "RoomWord 98"
             }
           }]
         });
 
         expect(store.findNextAvailableRoomNumber(fakeNameTemplate)).eql(100);
@@ -264,17 +264,17 @@ describe("loop.store.RoomStore", functio
 
         sinon.assert.calledWith(fakeMozLoop.rooms.create, {
           decryptedContext: {
             roomName: "Conversation 1",
             urls: [{
               location: "http://invalid.com",
               description: "fakeSite",
               thumbnail: "fakeimage.png"
-            }],
+            }]
           },
           roomOwner: fakeOwner,
           maxSize: store.maxRoomCreationSize
         });
       });
 
       it("should switch the pendingCreation state flag to true", function() {
         sandbox.stub(fakeMozLoop.rooms, "create");
--- a/browser/components/loop/test/mochitest/browser_CardDavImporter.js
+++ b/browser/components/loop/test/mochitest/browser_CardDavImporter.js
@@ -76,17 +76,17 @@ let vcards = [
     "REV:2011-07-12T14:43:20Z\n" +
     "UID:pid7\n" +
     "END:VCARD\n",
 
     "VERSION:3.0\n" +
     "EMAIL:anyone@example.com\n" +
     "REV:2011-07-12T14:43:20Z\n" +
     "UID:pid8\n" +
-    "END:VCARD\n",
+    "END:VCARD\n"
 ];
 
 
 const monkeyPatchImporter = function(importer) {
   // Set up the response bodies
   let listPropfind =
     '<?xml version="1.0" encoding="UTF-8"?>\n' +
     '<d:multistatus xmlns:card="urn:ietf:params:xml:ns:carddav"\n' +
@@ -314,13 +314,13 @@ add_task(function* test_CardDavImport() 
       }, (err, result) => { err ? resolve(err) : reject(new Error("Should have failed")); }, mockDb);
   });
   Assert.equal(error.message, "getByServiceId failed", "Database error should propagate");
   mockDb.getByServiceId = tmp;
 
   error = yield new Promise ((resolve, reject) => {
     info("Initiating import");
     importer.startImport({
-        "host": "example.com",
+        "host": "example.com"
       }, (err, result) => { err ? resolve(err) : reject(new Error("Should have failed")); }, mockDb);
   });
   Assert.equal(error.message, "No authentication specified", "Missing parameters should generate error");
 });
--- a/browser/components/loop/test/mochitest/browser_LoopContacts.js
+++ b/browser/components/loop/test/mochitest/browser_LoopContacts.js
@@ -21,17 +21,17 @@ const kContacts = [{
   tel: [{
     "pref": true,
     "type": ["mobile"],
     "value": "+31-6-12345678"
   }],
   category: ["google"],
   published: 1406798311748,
   updated: 1406798311748
-},{
+}, {
   id: 2,
   name: ["Bob Banana"],
   email: [{
     "pref": true,
     "type": ["work"],
     "value": "bob@gmail.com"
   }],
   tel: [{
--- a/browser/components/loop/test/mochitest/browser_fxa_login.js
+++ b/browser/components/loop/test/mochitest/browser_fxa_login.js
@@ -55,17 +55,17 @@ add_task(function* setup() {
 });
 
 add_task(function* checkOAuthParams() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
-    state: "state",
+    state: "state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   let client = yield MozLoopServiceInternal.promiseFxAOAuthClient();
   for (let key of Object.keys(params)) {
     ise(client.parameters[key], params[key], "Check " + key + " was passed to the OAuth client");
   }
   let prefName = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   let padding = "X".repeat(HAWK_TOKEN_LENGTH - params.client_id.length);
@@ -103,17 +103,17 @@ add_task(function* paramsInvalid() {
 add_task(function* params_no_hawk_session() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
-    test_error: "params_no_hawk",
+    test_error: "params_no_hawk"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   let loginPromise = MozLoopService.logInToFxA();
   let caught = false;
   yield loginPromise.catch(() => {
     ok(true, "The login promise should be rejected due to a lack of a hawk session");
     caught = true;
@@ -148,17 +148,17 @@ add_task(function* params_nonJSON() {
 
 add_task(function* invalidState() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
-    state: "invalid_state",
+    state: "invalid_state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.catch((error) => {
     ok(error, "The login promise should be rejected due to invalid state");
   });
 });
 
@@ -175,17 +175,17 @@ add_task(function* basicRegistrationWith
 });
 
 add_task(function* basicRegistration() {
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
-    state: "state",
+    state: "state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   yield resetFxA();
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   let tokenData = yield MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
@@ -196,17 +196,17 @@ add_task(function* basicRegistration() {
 
 add_task(function* registrationWithInvalidState() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
-    state: "invalid_state",
+    state: "invalid_state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   // Create a fake FxA hawk session token
   const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
   Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
@@ -223,17 +223,17 @@ add_task(function* registrationWithInval
 add_task(function* registrationWith401() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
-    test_error: "token_401",
+    test_error: "token_401"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   let tokenPromise = MozLoopServiceInternal.promiseFxAOAuthToken("code1", "state");
   yield tokenPromise.then(body => {
     ok(false, "Promise should have rejected");
   },
   error => {
@@ -272,17 +272,17 @@ add_task(function* registrationWith401()
 
 add_task(function* basicAuthorizationAndRegistration() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
-    state: "state",
+    state: "state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   info("registering");
   mockPushHandler.registrationPushURL = "https://localhost/pushUrl/guest";
   yield MozLoopService.promiseRegisteredWithServers();
 
   let statusChangedPromise = promiseObserverNotified("loop-status-changed");
@@ -335,17 +335,17 @@ add_task(function* basicAuthorizationAnd
 add_task(function* loginWithParams401() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
-    test_error: "params_401",
+    test_error: "params_401"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   yield MozLoopService.promiseRegisteredWithServers();
 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
@@ -397,17 +397,17 @@ add_task(function* logoutWithNoPushURL()
 add_task(function* loginWithRegistration401() {
   yield resetFxA();
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
-    test_error: "token_401",
+    test_error: "token_401"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   let loginPromise = MozLoopService.logInToFxA();
   yield loginPromise.then(tokenData => {
     ok(false, "Promise should have rejected");
   },
   error => {
@@ -427,33 +427,33 @@ add_task(function* openFxASettings() {
   gBrowser.selectedTab = gBrowser.addTab(BASE_URL);
 
   let params = {
     client_id: "client_id",
     content_uri: BASE_URL + "/content",
     oauth_uri: BASE_URL + "/oauth",
     profile_uri: BASE_URL + "/profile",
     state: "state",
-    test_error: "token_401",
+    test_error: "token_401"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   yield new Promise((resolve, reject) => {
     let progressListener = {
       onLocationChange: function onLocationChange(aBrowser) {
         if (aBrowser.currentURI.spec == BASE_URL) {
           // Ignore the changes from the addTab above.
           return;
         }
         gBrowser.removeTabsProgressListener(progressListener);
         let contentURI = Services.io.newURI(params.content_uri, null, null);
         is(aBrowser.currentURI.spec, Services.io.newURI("/settings", null, contentURI).spec,
            "Check settings tab URL");
         resolve();
-      },
+      }
     };
     gBrowser.addTabsProgressListener(progressListener);
 
     MozLoopService.openFxASettings();
   });
 
   while (gBrowser.tabs.length > 1) {
     gBrowser.removeTab(gBrowser.tabs[1]);
--- a/browser/components/loop/test/mochitest/browser_loop_fxa_server.js
+++ b/browser/components/loop/test/mochitest/browser_loop_fxa_server.js
@@ -14,17 +14,17 @@ registerCleanupFunction(function* () {
 });
 
 add_task(function* required_setup_params() {
   let params = {
     client_id: "my_client_id",
     content_uri: "https://example.com/content/",
     oauth_uri: "https://example.com/oauth/",
     profile_uri: "https://example.com/profile/",
-    state: "my_state",
+    state: "my_state"
   };
   let request = yield promiseOAuthParamsSetup(BASE_URL, params);
   is(request.status, 200, "Check /setup_params status");
   request = yield promiseParams();
   is(request.status, 200, "Check /fxa-oauth/params status");
   for (let param of Object.keys(params)) {
     is(request.response[param], params[param], "Check /fxa-oauth/params " + param);
   }
@@ -33,17 +33,17 @@ add_task(function* required_setup_params
 add_task(function* optional_setup_params() {
   let params = {
     action: "signin",
     client_id: "my_client_id",
     content_uri: "https://example.com/content/",
     oauth_uri: "https://example.com/oauth/",
     profile_uri: "https://example.com/profile/",
     scope: "profile",
-    state: "my_state",
+    state: "my_state"
   };
   let request = yield promiseOAuthParamsSetup(BASE_URL, params);
   is(request.status, 200, "Check /setup_params status");
   request = yield promiseParams();
   is(request.status, 200, "Check /fxa-oauth/params status");
   for (let param of Object.keys(params)) {
     is(request.response[param], params[param], "Check /fxa-oauth/params " + param);
   }
@@ -58,34 +58,34 @@ add_task(function* delete_setup_params()
 // Begin /fxa-oauth/token tests
 
 add_task(function* token_request() {
   let params = {
     client_id: "my_client_id",
     content_uri: "https://example.com/content/",
     oauth_uri: "https://example.com/oauth/",
     profile_uri: "https://example.com/profile/",
-    state: "my_state",
+    state: "my_state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
 
   let request = yield promiseToken("my_code", params.state);
   ise(request.status, 200, "Check token response status");
   ise(request.response.access_token, "my_code_access_token", "Check access_token");
   ise(request.response.scope, "profile", "Check scope");
   ise(request.response.token_type, "bearer", "Check token_type");
 });
 
 add_task(function* token_request_invalid_state() {
   let params = {
     client_id: "my_client_id",
     content_uri: "https://example.com/content/",
     oauth_uri: "https://example.com/oauth/",
     profile_uri: "https://example.com/profile/",
-    state: "my_invalid_state",
+    state: "my_invalid_state"
   };
   yield promiseOAuthParamsSetup(BASE_URL, params);
   let request = yield promiseToken("my_code", "my_state");
   ise(request.status, 400, "Check token response status");
   ise(request.response, null, "Check token response body");
 });
 
 
@@ -115,13 +115,13 @@ function promiseToken(code, state) {
     xhr.responseType = "json";
     xhr.addEventListener("load", () => {
       info("/fxa-oauth/token response:\n" + JSON.stringify(xhr.response, null, 4));
       resolve(xhr);
     });
     xhr.addEventListener("error", reject);
     let payload = {
       code: code,
-      state: state,
+      state: state
     };
     xhr.send(JSON.stringify(payload, null, 4));
   });
 }
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -751,17 +751,17 @@ describe("loop.store.ActiveRoomStore", f
         sinon.match.has("sendTwoWayMediaTelemetry", false));
     });
 
     it("should call mozLoop.addConversationContext", function() {
       var actionData = new sharedActions.JoinedRoom(fakeJoinedData);
 
       store.setupWindowData(new sharedActions.SetupWindowData({
         windowId: "42",
-        type: "room",
+        type: "room"
       }));
 
       store.joinedRoom(actionData);
 
       sinon.assert.calledOnce(fakeMozLoop.addConversationContext);
       sinon.assert.calledWithExactly(fakeMozLoop.addConversationContext,
                                      "42", "15263748", "");
     });
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -1051,17 +1051,17 @@ describe("loop.store.ConversationStore",
 
       it("should dispatch a connection failure action on 'terminate' for outgoing calls", function() {
         store.setStoreState({
           outgoing: true
         });
 
         store._websocket.trigger("progress", {
           state: WS_STATES.TERMINATED,
-          reason: WEBSOCKET_REASONS.REJECT,
+          reason: WEBSOCKET_REASONS.REJECT
         });
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         // Can't use instanceof here, as that matches any action
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "connectionFailure"));
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("reason", WEBSOCKET_REASONS.REJECT));
--- a/browser/components/loop/test/shared/models_test.js
+++ b/browser/components/loop/test/shared/models_test.js
@@ -183,17 +183,17 @@ describe("loop.shared.models", function(
         });
 
         it("should call addConversationContext", function() {
           fakeMozLoop.addConversationContext = sandbox.stub();
 
           model.set({
             windowId: "28",
             sessionId: "321456",
-            callId: "142536",
+            callId: "142536"
           });
           model.startSession();
 
           sinon.assert.calledOnce(fakeMozLoop.addConversationContext);
           sinon.assert.calledWithExactly(fakeMozLoop.addConversationContext,
                                          "28", "321456", "142536");
         });
 
--- a/browser/components/loop/test/standalone/multiplexGum_test.js
+++ b/browser/components/loop/test/standalone/multiplexGum_test.js
@@ -287,17 +287,17 @@ describe("loop.standaloneMedia._Multiple
 
       multiplexGum.reset();
 
       expect(multiplexGum.userMedia).to.deep.equal({
           error: null,
           localStream: null,
           pending: false,
           errorCallbacks: [],
-          successCallbacks: [],
+          successCallbacks: []
       });
     });
 
     it("should call all queued error callbacks with 'PERMISSION_DENIED'",
       function(done) {
         sandbox.stub(navigator, "originalGum");
         multiplexGum.getPermsAndCacheMedia(null, function(localStream) {
           sinon.assert.fail(
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -209,17 +209,17 @@ MockWebSocketChannel.prototype = {
   },
 
   stop: function (err) {
     this.listener.onStop(this.context, err || -1);
   },
 
   serverClose: function (err) {
     this.listener.onServerClose(this.context, err || -1);
-  },
+  }
 };
 
 const extend = function(target, source) {
   for (let key of Object.getOwnPropertyNames(source)) {
     target[key] = source[key];
   }
   return target;
 };
--- a/browser/components/loop/test/xpcshell/test_looprooms.js
+++ b/browser/components/loop/test/xpcshell/test_looprooms.js
@@ -156,17 +156,17 @@ const kRoomUpdates = {
   },
   "5": {
     deleted: true
   }
 };
 
 const kCreateRoomProps = {
   decryptedContext: {
-    roomName: "UX Discussion",
+    roomName: "UX Discussion"
   },
   roomOwner: "Alexis",
   maxSize: 2
 };
 
 const kCreateRoomUnencryptedProps = {
   roomName: "UX Discussion",
   roomOwner: "Alexis",
@@ -423,17 +423,17 @@ add_task(function* test_openRoom() {
 
   Assert.equal(windowData.type, "room", "window data should contain room as the type");
   Assert.equal(windowData.roomToken, "fakeToken", "window data should have the roomToken");
 });
 
 // Test if the rooms cache is refreshed after FxA signin or signout.
 add_task(function* test_refresh() {
   // XXX Temporarily whilst FxA encryption isn't handled (bug 1153788).
-  Array.prototype.push.apply(gExpectedAdds, [...kExpectedRooms.values()].slice(1,3));
+  Array.prototype.push.apply(gExpectedAdds, [...kExpectedRooms.values()].slice(1, 3));
   gExpectedRefresh = true;
 
   // Make the switch.
   MozLoopServiceInternal.fxAOAuthTokenData = { token_type: "bearer" };
   MozLoopServiceInternal.fxAOAuthProfile = {
     email: "fake@invalid.com",
     uid: "fake"
   };
--- a/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js
+++ b/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js
@@ -51,27 +51,27 @@ const kExpectedRooms = new Map([
   }],
   ["QzBbvGmIZWU", {
     context: {
       wrappedKey: "AFu7WwFNjhWR5J6L8ks7S6H/1ktYVEw3yt1eIIWVaMabZaB3vh5612/FNzua4oS2oCM=",
       value: "sqj+xRNEty8K3Q1gSMd5bIUYKu34JfiO2+LIMlJrOetFIbJdBoQ+U8JZNaTFl6Qp3RULZ41x0zeSBSk=",
       alg: "AES-GCM"
     },
     decryptedContext: {
-      roomName: "Loopy Discussion",
+      roomName: "Loopy Discussion"
     },
     roomKey: "h2H8Sa9QxLCTTiXNmJVtRA",
     roomToken: "QzBbvGmIZWU",
     roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU"
   }]
 ]);
 
 const kCreateRoomProps = {
   decryptedContext: {
-    roomName: "Say Hello",
+    roomName: "Say Hello"
   },
   roomOwner: "Gavin",
   maxSize: 2
 };
 
 const kCreateRoomData = {
   roomToken: "Vo2BFQqIaAM",
   roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
--- a/browser/components/loop/test/xpcshell/test_looprooms_first_notification.js
+++ b/browser/components/loop/test/xpcshell/test_looprooms_first_notification.js
@@ -34,17 +34,17 @@ const kRoomsResponses = new Map([
     }]
   }],
   ["QzBbvGmIZWU", {
     roomToken: "QzBbvGmIZWU",
     roomName: "Second Room Name",
     roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU",
     maxSize: 2,
     ctime: 140551741
-  }],
+  }]
 ]);
 
 let gRoomsAdded = [];
 let gRoomsUpdated = [];
 
 const onRoomAdded = function(e, room) {
   gRoomsAdded.push(room);
 };
--- a/browser/components/loop/test/xpcshell/test_loopservice_busy.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_busy.js
@@ -49,17 +49,21 @@ add_task(function* test_busy_2fxa_calls(
   });
 });
 
 function run_test() {
   setupFakeLoopServer();
 
   // Setup fake login state so we get FxA requests.
   const MozLoopServiceInternal = Cu.import("resource:///modules/loop/MozLoopService.jsm", {}).MozLoopServiceInternal;
-  MozLoopServiceInternal.fxAOAuthTokenData = {token_type:"bearer",access_token:"1bad3e44b12f77a88fe09f016f6a37c42e40f974bc7a8b432bb0d2f0e37e1752",scope:"profile"};
+  MozLoopServiceInternal.fxAOAuthTokenData = {
+    token_type:"bearer",
+    access_token:"1bad3e44b12f77a88fe09f016f6a37c42e40f974bc7a8b432bb0d2f0e37e1752",
+    scope:"profile"
+  };
   MozLoopServiceInternal.fxAOAuthProfile = {email: "test@example.com", uid: "abcd1234"};
 
   let mockWebSocket = new MockWebSocketChannel();
   mockWebSocket.defaultMsgHandler = msgHandler;
   LoopCallsInternal.mocks.webSocket = mockWebSocket;
 
   Services.io.offline = false;
 
@@ -78,17 +82,17 @@ function run_test() {
              {callId: secondCallId,
               websocketToken: "1deadbeef1",
               progressURL: "wss://localhost:5000/websocket"}]},
     {calls: [{callId: firstCallId,
               websocketToken: "0deadbeef0",
               progressURL: "wss://localhost:5000/websocket"}]},
     {calls: [{callId: secondCallId,
               websocketToken: "1deadbeef1",
-              progressURL: "wss://localhost:5000/websocket"}]},
+              progressURL: "wss://localhost:5000/websocket"}]}
   ];
 
   loopServer.registerPathHandler("/registration", (request, response) => {
     response.setStatusLine(null, 200, "OK");
     response.processAsync();
     response.finish();
   });
 
--- a/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_hawk_errors.js
@@ -18,17 +18,17 @@ const { INVALID_AUTH_TOKEN } = Cu.import
 function errorRequestHandler(request, response) {
   let responseCode = request.path.substring(1);
   response.setStatusLine(null, responseCode, "Error");
   if (responseCode == 401) {
     response.write(JSON.stringify({
       code: parseInt(responseCode),
       errno: INVALID_AUTH_TOKEN,
       error: "INVALID_AUTH_TOKEN",
-      message: "INVALID_AUTH_TOKEN",
+      message: "INVALID_AUTH_TOKEN"
     }));
   }
 }
 
 add_task(function* setup_server() {
   loopServer.registerPathHandler("/401", errorRequestHandler);
   loopServer.registerPathHandler("/404", errorRequestHandler);
   loopServer.registerPathHandler("/500", errorRequestHandler);
--- a/browser/components/loop/test/xpcshell/test_loopservice_restart.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_restart.js
@@ -59,17 +59,17 @@ add_task(function* test_initialize_with_
   // Only need to implement the FxA registration because the previous
   // test registered as a guest.
   loopServer.registerPathHandler("/registration", (request, response) => {
     response.setStatusLine(null, 401, "Unauthorized");
     response.write(JSON.stringify({
       code: 401,
       errno: 110,
       error: "Unauthorized",
-      message: "Unknown credentials",
+      message: "Unknown credentials"
     }));
   });
 
   yield MozLoopService.initialize().then(() => {
     Assert.ok(false, "Initializing with an invalid token should reject the promise");
   },
   (error) => {
     Assert.equal(MozLoopServiceInternal.pushHandler.registrationPushURL, kEndPointUrl, "Push URL should match");
--- a/browser/components/loop/test/xpcshell/test_loopservice_token_invalid.js
+++ b/browser/components/loop/test/xpcshell/test_loopservice_token_invalid.js
@@ -16,17 +16,17 @@ add_test(function test_registration_inva
     // of authorization requests before we reset the token.
     if (request.hasHeader("Authorization")) {
       authorizationAttempts++;
       response.setStatusLine(null, 401, "Unauthorized");
       response.write(JSON.stringify({
         code: 401,
         errno: 110,
         error: "Unauthorized",
-        message: "Unknown credentials",
+        message: "Unknown credentials"
       }));
     } else {
       // We didn't have an authorization header, so check the pref has been cleared.
       Assert.equal(Services.prefs.prefHasUserValue(LOOP_HAWK_PREF), false);
       response.setStatusLine(null, 200, "OK");
       response.setHeader("Hawk-Session-Token", fakeSessionToken2, false);
     }
     response.processAsync();
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -33,17 +33,17 @@ var fakeRooms = [
     "creationTime": 1405517546,
     "ctime": 1405517546,
     "expiresAt": 1405534180,
     "participants": []
   },
   {
     "roomToken": "3jKS_Els9IU",
     "decryptedContext": {
-      "roomName": "UX Discussion",
+      "roomName": "UX Discussion"
     },
     "roomUrl": "http://localhost:3000/rooms/3jKS_Els9IU",
     "roomOwner": "Alexis",
     "maxSize": 2,
     "clientMaxSize": 2,
     "creationTime": 1405517546,
     "ctime": 1405517818,
     "expiresAt": 1405534180,
@@ -66,17 +66,17 @@ var fakeContacts = [{
   tel: [{
     "pref": true,
     "type": ["mobile"],
     "value": "+31-6-12345678"
   }],
   category: ["google"],
   published: 1406798311748,
   updated: 1406798311748
-},{
+}, {
   id: 2,
   _guid: 2,
   name: ["Bob Banana"],
   email: [{
     "pref": true,
     "type": ["work"],
     "value": "bob@gmail.com"
   }],
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -140,17 +140,17 @@
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
   errNotifications.add({
     level: "error",
     message: "Could Not Authenticate",
     details: "Did you change your password?",
-    detailsButtonLabel: "Retry",
+    detailsButtonLabel: "Retry"
   });
 
   var SVGIcon = React.createClass({displayName: "SVGIcon",
     render: function() {
       var sizeUnit = this.props.size.split("x")[0] + "px";
       return (
         React.createElement("span", {className: "svg-icon", style: {
           "backgroundImage": "url(../content/shared/img/icons-" + this.props.size +
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -140,17 +140,17 @@
   });
 
   var notifications = new loop.shared.models.NotificationCollection();
   var errNotifications = new loop.shared.models.NotificationCollection();
   errNotifications.add({
     level: "error",
     message: "Could Not Authenticate",
     details: "Did you change your password?",
-    detailsButtonLabel: "Retry",
+    detailsButtonLabel: "Retry"
   });
 
   var SVGIcon = React.createClass({
     render: function() {
       var sizeUnit = this.props.size.split("x")[0] + "px";
       return (
         <span className="svg-icon" style={{
           "backgroundImage": "url(../content/shared/img/icons-" + this.props.size +
--- a/browser/components/uitour/UITour.jsm
+++ b/browser/components/uitour/UITour.jsm
@@ -186,16 +186,17 @@ this.UITour = {
       query: (aDocument) => {
         let loopBrowser = aDocument.defaultView.LoopUI.browser;
         if (!loopBrowser) {
           return null;
         }
         return loopBrowser.contentDocument.querySelector(".signin-link");
       },
     }],
+    ["pocket", {query: "#pocket-button"}],
     ["privateWindow",  {query: "#privatebrowsing-button"}],
     ["quit",        {query: "#PanelUI-quit"}],
     ["readerMode-urlBar", {query: "#reader-mode-button"}],
     ["search",      {
       infoPanelOffsetX: 18,
       infoPanelPosition: "after_start",
       query: "#searchbar",
       widgetName: "search-container",
--- a/browser/components/uitour/test/browser_UITour_availableTargets.js
+++ b/browser/components/uitour/test/browser_UITour_availableTargets.js
@@ -6,16 +6,36 @@
 let gTestTab;
 let gContentAPI;
 let gContentWindow;
 
 Components.utils.import("resource:///modules/UITour.jsm");
 
 let hasWebIDE = Services.prefs.getBoolPref("devtools.webide.widget.enabled");
 
+let hasPocket = false;
+if (Services.prefs.getBoolPref("browser.pocket.enabled")) {
+  let isEnabledForLocale = true;
+  if (Services.prefs.getBoolPref("browser.pocket.useLocaleList")) {
+    let chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"]
+                           .getService(Ci.nsIXULChromeRegistry);
+    let browserLocale = chromeRegistry.getSelectedLocale("browser");
+    let enabledLocales = [];
+    try {
+      enabledLocales = Services.prefs.getCharPref("browser.pocket.enabledLocales").split(' ');
+    } catch (ex) {
+      Cu.reportError(ex);
+    }
+    isEnabledForLocale = enabledLocales.indexOf(browserLocale) != -1;
+  }
+  if (isEnabledForLocale) {
+    hasPocket = true;
+  }
+}
+
 function test() {
   requestLongerTimeout(2);
   UITourTest();
 }
 
 function searchEngineTargets() {
   let engines = Services.search.getVisibleEngines();
   return ["searchEngine-" + engine.identifier
@@ -32,16 +52,17 @@ let tests = [
         "appMenu",
         "backForward",
         "bookmarks",
         "customize",
         "help",
         "home",
         "loop",
         "devtools",
+        ...(hasPocket ? ["pocket"] : []),
         "privateWindow",
         "quit",
         "readerMode-urlBar",
         "search",
         "searchIcon",
         "urlbar",
         ...searchEngineTargets(),
         ...(hasWebIDE ? ["webide"] : [])
@@ -63,16 +84,17 @@ let tests = [
         "addons",
         "appMenu",
         "backForward",
         "customize",
         "help",
         "loop",
         "devtools",
         "home",
+        ...(hasPocket ? ["pocket"] : []),
         "privateWindow",
         "quit",
         "readerMode-urlBar",
         "search",
         "searchIcon",
         "urlbar",
         ...searchEngineTargets(),
         ...(hasWebIDE ? ["webide"] : [])
@@ -99,16 +121,17 @@ let tests = [
         "appMenu",
         "backForward",
         "bookmarks",
         "customize",
         "help",
         "home",
         "loop",
         "devtools",
+        ...(hasPocket ? ["pocket"] : []),
         "privateWindow",
         "quit",
         "readerMode-urlBar",
         "urlbar",
         ...(hasWebIDE ? ["webide"] : [])
       ]);
 
       CustomizableUI.reset();
--- a/browser/devtools/commandline/moz.build
+++ b/browser/devtools/commandline/moz.build
@@ -1,9 +1,5 @@
 # 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/.
 
-EXTRA_JS_MODULES.devtools.commandline += [
-    'commands-index.js'
-]
-
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
--- a/browser/devtools/commandline/test/browser_cmd_settings.js
+++ b/browser/devtools/commandline/test/browser_cmd_settings.js
@@ -18,17 +18,17 @@ function test() {
 
 function* spawnTest() {
   // Setup
   let options = yield helpers.openTab(TEST_URI);
 
   const { createSystem } = require("gcli/system");
   const system = createSystem({ location: "server" });
 
-  const gcliInit = require("devtools/commandline/commands-index");
+  const gcliInit = require("gcli/commands/index");
   gcliInit.addAllItemsByModule(system);
   yield system.load();
 
   let settings = system.settings;
 
   let hideIntroEnabled = settings.get("devtools.gcli.hideIntro");
   let tabSize = settings.get("devtools.editor.tabsize");
   let remoteHost = settings.get("devtools.debugger.remote-host");
--- a/browser/devtools/framework/toolbox.js
+++ b/browser/devtools/framework/toolbox.js
@@ -65,30 +65,31 @@ XPCOMUtils.defineLazyGetter(this, "oscpu
 
 XPCOMUtils.defineLazyGetter(this, "is64Bit", () => {
   return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo).is64Bit;
 });
 
 // White-list buttons that can be toggled to prevent adding prefs for
 // addons that have manually inserted toolbarbuttons into DOM.
 // (By default, supported target is only local tab)
-const ToolboxButtons = [
+const ToolboxButtons = exports.ToolboxButtons = [
   { id: "command-button-pick",
     isTargetSupported: target =>
       target.getTrait("highlightable")
   },
   { id: "command-button-frames",
     isTargetSupported: target =>
       ( target.activeTab && target.activeTab.traits.frames )
   },
   { id: "command-button-splitconsole",
     isTargetSupported: target => !target.isAddon },
   { id: "command-button-responsive" },
   { id: "command-button-paintflashing" },
-  { id: "command-button-tilt" },
+  { id: "command-button-tilt",
+    commands: "devtools/tilt/tilt-commands" },
   { id: "command-button-scratchpad" },
   { id: "command-button-eyedropper" },
   { id: "command-button-screenshot" },
   { id: "command-button-rulers"}
 ];
 
 /**
  * A "Toolbox" is the component that holds all the tools for one specific
@@ -712,20 +713,23 @@ Toolbox.prototype = {
   /**
    * Add buttons to the UI as specified in the devtools.toolbox.toolbarSpec pref
    */
   _buildButtons: function() {
     if (!this.target.isAddon) {
       this._buildPickerButton();
     }
 
-    // Set the visibility of the built in buttons before adding more buttons
-    // so they are shown before calling into the GCLI actor.
     this.setToolboxButtonsVisibility();
 
+    // Old servers don't have a GCLI Actor, so just return
+    if (!this.target.hasActor("gcli")) {
+      return promise.resolve();
+    }
+
     const options = {
       environment: CommandUtils.createEnvironment(this, '_target')
     };
     return CommandUtils.createRequisition(this.target, options).then(requisition => {
       this._requisition = requisition;
 
       const spec = CommandUtils.getCommandbarSpec("devtools.toolbox.toolbarSpec");
       return CommandUtils.createButtons(spec, this.target, this.doc,
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -112,17 +112,16 @@ browser.jar:
     content/browser/devtools/performance/views/details-memory-call-tree.js  (performance/views/details-memory-call-tree.js)
     content/browser/devtools/performance/views/details-memory-flamegraph.js (performance/views/details-memory-flamegraph.js)
     content/browser/devtools/performance/views/recordings.js           (performance/views/recordings.js)
     content/browser/devtools/performance/views/jit-optimizations.js    (performance/views/jit-optimizations.js)
     content/browser/devtools/responsivedesign/resize-commands.js       (responsivedesign/resize-commands.js)
     content/browser/devtools/commandline.css                           (commandline/commandline.css)
     content/browser/devtools/commandlineoutput.xhtml                   (commandline/commandlineoutput.xhtml)
     content/browser/devtools/commandlinetooltip.xhtml                  (commandline/commandlinetooltip.xhtml)
-    content/browser/devtools/commandline/commands-index.js             (commandline/commands-index.js)
 *   content/browser/devtools/framework/toolbox-window.xul              (framework/toolbox-window.xul)
     content/browser/devtools/framework/toolbox-options.xul             (framework/toolbox-options.xul)
     content/browser/devtools/framework/toolbox-options.js              (framework/toolbox-options.js)
     content/browser/devtools/framework/toolbox.xul                     (framework/toolbox.xul)
     content/browser/devtools/framework/options-panel.css               (framework/options-panel.css)
     content/browser/devtools/framework/toolbox-process-window.xul      (framework/toolbox-process-window.xul)
 *   content/browser/devtools/framework/toolbox-process-window.js       (framework/toolbox-process-window.js)
     content/browser/devtools/framework/dev-edition-promo.xul           (framework/dev-edition-promo/dev-edition-promo.xul)
--- a/browser/devtools/performance/performance.xul
+++ b/browser/devtools/performance/performance.xul
@@ -169,17 +169,17 @@
                   flex="1">
                   <vbox>
                     <label class="console-profile-recording-notice" />
                     <label class="console-profile-stop-notice" />
                   </vbox>
             </hbox>
             <deck id="details-pane" flex="1">
               <hbox id="waterfall-view" flex="1">
-                <vbox id="waterfall-breakdown" flex="1" />
+                <vbox id="waterfall-breakdown" flex="1" class="devtools-main-content" />
                 <splitter class="devtools-side-splitter"/>
                 <vbox id="waterfall-details"
                       class="theme-sidebar"
                       width="150"
                       height="150"/>
               </hbox>
 
               <hbox id="js-profile-view" flex="1">
--- a/browser/devtools/shared/DeveloperToolbar.jsm
+++ b/browser/devtools/shared/DeveloperToolbar.jsm
@@ -36,17 +36,17 @@ XPCOMUtils.defineLazyGetter(this, "prefB
 XPCOMUtils.defineLazyGetter(this, "toolboxStrings", function () {
   return Services.strings.createBundle("chrome://browser/locale/devtools/toolbox.properties");
 });
 
 const Telemetry = require("devtools/shared/telemetry");
 
 XPCOMUtils.defineLazyGetter(this, "gcliInit", function() {
   try {
-    return require("devtools/commandline/commands-index");
+    return require("gcli/commands/index");
   }
   catch (ex) {
     console.log(ex);
   }
 });
 
 XPCOMUtils.defineLazyGetter(this, "util", () => {
   return require("gcli/util/util");
--- a/browser/devtools/shared/moz.build
+++ b/browser/devtools/shared/moz.build
@@ -56,16 +56,18 @@ EXTRA_JS_MODULES.devtools.shared += [
     'observable-object.js',
     'options-view.js',
     'poller.js',
     'source-utils.js',
     'telemetry.js',
     'theme-switching.js',
     'theme.js',
     'undo.js',
+    'worker-helper.js',
+    'worker.js',
 ]
 
 EXTRA_JS_MODULES.devtools.shared.widgets += [
     'widgets/CubicBezierPresets.js',
     'widgets/CubicBezierWidget.js',
     'widgets/FastListWidget.js',
     'widgets/FilterWidget.js',
     'widgets/FlameGraph.js',
--- a/browser/devtools/shared/test/browser.ini
+++ b/browser/devtools/shared/test/browser.ini
@@ -20,16 +20,19 @@ support-files =
 
 [browser_css_color.js]
 [browser_cubic-bezier-01.js]
 [browser_cubic-bezier-02.js]
 [browser_cubic-bezier-03.js]
 [browser_cubic-bezier-04.js]
 [browser_cubic-bezier-05.js]
 [browser_cubic-bezier-06.js]
+[browser_devtools-worker-01.js]
+[browser_devtools-worker-02.js]
+[browser_devtools-worker-03.js]
 [browser_filter-editor-01.js]
 [browser_filter-editor-02.js]
 [browser_filter-editor-03.js]
 [browser_filter-editor-04.js]
 [browser_filter-editor-05.js]
 [browser_filter-editor-06.js]
 [browser_filter-editor-07.js]
 [browser_filter-editor-08.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_devtools-worker-01.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the devtools/shared/worker communicates properly
+// as both CommonJS module and as a JSM.
+
+const WORKER_URL = "resource:///modules/devtools/GraphsWorker.js";
+
+const count = 100000;
+const WORKER_DATA = (function () {
+  let timestamps = [];
+  for (let i = 0; i < count; i++) {
+    timestamps.push(i);
+  }
+  return timestamps;
+})();
+const INTERVAL = 100;
+const DURATION = 1000;
+
+add_task(function*() {
+  // Test both CJS and JSM versions
+  yield testWorker("JSM", () => Cu.import("resource:///modules/devtools/shared/worker.js", {}));
+  yield testWorker("CommonJS", () => devtools.require("devtools/shared/worker"));
+});
+
+function *testWorker (context, workerFactory) {
+  let { DevToolsWorker, workerify } = workerFactory();
+  let worker = new DevToolsWorker(WORKER_URL);
+  let results = yield worker.performTask("plotTimestampsGraph", {
+    timestamps: WORKER_DATA,
+    interval: INTERVAL,
+    duration: DURATION
+  });
+
+  ok(results.plottedData.length,
+    `worker should have returned an object with array properties in ${context}`);
+  
+  let fn = workerify(function (x) { return x * x });
+  is((yield fn(5)), 25, `workerify works in ${context}`);
+  fn.destroy();
+
+
+  worker.destroy();
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_devtools-worker-02.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests errors are handled properly by the DevToolsWorker.
+
+const { DevToolsWorker } = devtools.require("devtools/shared/worker");
+const WORKER_URL = "resource:///modules/devtools/GraphsWorker.js";
+
+add_task(function*() {
+  try {
+    let workerNotFound = new DevToolsWorker("resource://i/dont/exist.js");
+    ok(false, "Creating a DevToolsWorker with an invalid URL throws");
+  } catch (e) {
+    ok(true, "Creating a DevToolsWorker with an invalid URL throws");
+  }
+
+  let worker = new DevToolsWorker(WORKER_URL);
+  try {
+    // plotTimestampsGraph requires timestamp, interval an duration props on the object
+    // passed in so there should be an error thrown in the worker
+    let results = yield worker.performTask("plotTimestampsGraph", {});
+    ok(false, "DevToolsWorker returns a rejected promise when an error occurs in the worker");
+  } catch (e) {
+    ok(true, "DevToolsWorker returns a rejected promise when an error occurs in the worker");
+  }
+
+  try {
+    let results = yield worker.performTask("not a real task");
+    ok(false, "DevToolsWorker returns a rejected promise when task does not exist");
+  } catch (e) {
+    ok(true, "DevToolsWorker returns a rejected promise when task does not exist");
+  }
+
+  worker.destroy();
+  try {
+    let results = yield worker.performTask("plotTimestampsGraph", {
+      timestamps: [0,1,2,3,4,5,6,7,8,9],
+      interval: 1,
+      duration: 1
+    });
+    ok(false, "DevToolsWorker rejects when performing a task on a destroyed worker");
+  } catch (e) {
+    ok(true, "DevToolsWorker rejects when performing a task on a destroyed worker");
+  };
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_devtools-worker-03.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the devtools/shared/worker can handle:
+// returned primitives (or promise or Error)
+//
+// And tests `workerify` by doing so.
+
+const { DevToolsWorker, workerify } = devtools.require("devtools/shared/worker");
+function square (x) {
+  return x * x;
+}
+
+function squarePromise (x) {
+  return new Promise((resolve) => resolve(x*x));
+}
+
+function squareError (x) {
+  return new Error("Nope");
+}
+
+function squarePromiseReject (x) {
+  return new Promise((_, reject) => reject("Nope"));
+}
+
+add_task(function*() {
+  let fn = workerify(square);
+  is((yield fn(5)), 25, "return primitives successful");
+  fn.destroy();
+
+  fn = workerify(squarePromise);
+  is((yield fn(5)), 25, "promise primitives successful");
+  fn.destroy();
+
+  fn = workerify(squareError);
+  try {
+    yield fn(5);
+    ok(false, "return error should reject");
+  } catch (e) {
+    ok(true, "return error should reject");
+  }
+  fn.destroy();
+
+  fn = workerify(squarePromiseReject);
+  try {
+    yield fn(5);
+    ok(false, "returned rejected promise rejects");
+  } catch (e) {
+    ok(true, "returned rejected promise rejects");
+  }
+  fn.destroy();
+});
--- a/browser/devtools/shared/test/head.js
+++ b/browser/devtools/shared/test/head.js
@@ -3,16 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 let {TargetFactory, require} = devtools;
 let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 const {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
 const {Hosts} = require("devtools/framework/toolbox-hosts");
+const {defer} = require("sdk/core/promise");
 
 gDevTools.testing = true;
 SimpleTest.registerCleanupFunction(() => {
   gDevTools.testing = false;
 });
 
 const TEST_URI_ROOT = "http://example.com/browser/browser/devtools/shared/test/";
 const OPTIONS_VIEW_URL = TEST_URI_ROOT + "doc_options-view.xul";
@@ -256,10 +257,8 @@ function waitUntil(predicate, interval =
     return Promise.resolve(true);
   }
   return new Promise(resolve => {
     setTimeout(function() {
       waitUntil(predicate).then(() => resolve(true));
     }, interval);
   });
 }
-
-// EventUtils just doesn't work!
--- a/browser/devtools/shared/widgets/Graphs.jsm
+++ b/browser/devtools/shared/widgets/Graphs.jsm
@@ -4,16 +4,17 @@
 "use strict";
 
 const Cu = Components.utils;
 
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
 const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+const {DevToolsWorker} = Cu.import("resource:///modules/devtools/shared/worker.js", {});
 
 this.EXPORTED_SYMBOLS = [
   "GraphCursor",
   "GraphArea",
   "GraphAreaDragger",
   "GraphAreaResizer",
   "AbstractCanvasGraph",
   "LineGraphWidget",
@@ -2143,50 +2144,24 @@ this.CanvasGraphUtils = {
     });
   },
 
   /**
    * Performs the given task in a chrome worker, assuming it exists.
    *
    * @param string task
    *        The task name. Currently supported: "plotTimestampsGraph".
-   * @param any args
+   * @param any data
    *        Extra arguments to pass to the worker.
-   * @param array transferrable [optional]
-   *        A list of transferrable objects, if any.
    * @return object
    *         A promise that is resolved once the worker finishes the task.
    */
-  _performTaskInWorker: function(task, args, transferrable) {
-    let worker = this._graphUtilsWorker || new ChromeWorker(WORKER_URL);
-    let id = this._graphUtilsTaskId++;
-    worker.postMessage({ task, id, args }, transferrable);
-    return this._waitForWorkerResponse(worker, id);
-  },
-
-  /**
-   * Waits for the specified worker to finish a task.
-   *
-   * @param ChromeWorker worker
-   *        The worker for which to add a message listener.
-   * @param number id
-   *        The worker task id.
-   */
-  _waitForWorkerResponse: function(worker, id) {
-    let deferred = promise.defer();
-
-    worker.addEventListener("message", function listener({ data }) {
-      if (data.id != id) {
-        return;
-      }
-      worker.removeEventListener("message", listener);
-      deferred.resolve(data);
-    });
-
-    return deferred.promise;
+  _performTaskInWorker: function(task, data) {
+    let worker = this._graphUtilsWorker || new DevToolsWorker(WORKER_URL);
+    return worker.performTask(task, data);
   }
 };
 
 /**
  * Maps a value from one range to another.
  * @param number value, istart, istop, ostart, ostop
  * @return number
  */
--- a/browser/devtools/shared/widgets/GraphsWorker.js
+++ b/browser/devtools/shared/widgets/GraphsWorker.js
@@ -1,40 +1,33 @@
 /* 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";
 
-self.onmessage = e => {
-  const { id, task, args } = e.data;
-
-  switch (task) {
-    case "plotTimestampsGraph":
-      plotTimestampsGraph(id, args);
-      break;
-    default:
-      self.postMessage({ id, error: e.message + "\n" + e.stack });
-      break;
-  }
-};
+/**
+ * Import `createTask` to communicate with `devtools/shared/worker`.
+ */
+importScripts("resource://gre/modules/workers/require.js");
+const { createTask } = require("resource:///modules/devtools/shared/worker-helper");
 
 /**
  * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.jsm
  * @param number id
  * @param array timestamps
  * @param number interval
  * @param number duration
  */
-function plotTimestampsGraph(id, args) {
-  let plottedData = plotTimestamps(args.timestamps, args.interval);
-  let plottedMinMaxSum = getMinMaxAvg(plottedData, args.timestamps, args.duration);
+createTask(self, "plotTimestampsGraph", function ({ timestamps, interval, duration }) {
+  let plottedData = plotTimestamps(timestamps, interval);
+  let plottedMinMaxSum = getMinMaxAvg(plottedData, timestamps, duration);
 
-  let response = { id, plottedData, plottedMinMaxSum };
-  self.postMessage(response);
-}
+  return { plottedData, plottedMinMaxSum };
+});
+
 
 /**
  * Gets the min, max and average of the values in an array.
  * @param array source
  * @param array timestamps
  * @param number duration
  * @return object
  */
--- a/browser/devtools/shared/widgets/Spectrum.js
+++ b/browser/devtools/shared/widgets/Spectrum.js
@@ -158,16 +158,20 @@ Spectrum.draggable = function(element, o
 
   function prevent(e) {
     e.stopPropagation();
     e.preventDefault();
   }
 
   function move(e) {
     if (dragging) {
+      if (e.buttons === 0) {
+        // The button is no longer pressed but we did not get a mouseup event.
+        return stop();
+      }
       let pageX = e.pageX;
       let pageY = e.pageY;
 
       let dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
       let dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));
 
       onmove.apply(element, [dragX, dragY]);
     }
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/worker-helper.js
@@ -0,0 +1,100 @@
+/* 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";
+
+/**
+ * This file is to only be included by ChromeWorkers. This exposes
+ * a `createTask` function to workers to register tasks for communication
+ * back to `devtools/shared/worker`.
+ *
+ * Tasks can be send their responses via a return value, either a primitive
+ * or a promise.
+ *
+ * createTask(self, "average", function (data) {
+ *   return data.reduce((sum, val) => sum + val, 0) / data.length;
+ * });
+ *
+ * createTask(self, "average", function (data) {
+ *   return new Promise((resolve, reject) => {
+ *     resolve(data.reduce((sum, val) => sum + val, 0) / data.length);
+ *   });
+ * });
+ *
+ *
+ * Errors:
+ *
+ * Returning an Error value, or if the returned promise is rejected, this
+ * propagates to the DevToolsWorker as a rejected promise. If an error is
+ * thrown in a synchronous function, that error is also propagated.
+ */
+
+/**
+ * Takes a worker's `self` object, a task name, and a function to
+ * be called when that task is called. The task is called with the
+ * passed in data as the first argument
+ *
+ * @param {object} self
+ * @param {string} name
+ * @param {function} fn
+ */
+function createTask (self, name, fn) {
+  // Store a hash of task name to function on the Worker
+  if (!self._tasks) {
+    self._tasks = {};
+  }
+
+  // Create the onmessage handler if not yet created.
+  if (!self.onmessage) {
+    self.onmessage = createHandler(self);
+  }
+
+  // Store the task on the worker.
+  self._tasks[name] = fn;
+}
+
+exports.createTask = createTask;
+
+/**
+ * Creates the `self.onmessage` handler for a Worker.
+ *
+ * @param {object} self
+ * @return {function}
+ */
+function createHandler (self) {
+  return function (e) {
+    let { id, task, data } = e.data;
+    let taskFn = self._tasks[task];
+
+    if (!taskFn) {
+      self.postMessage({ id, error: `Task "${task}" not found in worker.` });
+      return;
+    }
+
+    try {
+      let results;
+      handleResponse(taskFn(data));
+    } catch (e) {
+      handleError(e);
+    }
+
+    function handleResponse (response) {
+      // If a promise
+      if (response && typeof response.then === "function") {
+        response.then(val => self.postMessage({ id, response: val }), handleError);
+      }
+      // If an error object
+      else if (response instanceof Error) {
+        handleError(response);
+      }
+      // If anything else
+      else {
+        self.postMessage({ id, response });
+      }
+    }
+
+    function handleError (e="Error") {
+      self.postMessage({ id, error: e.message || e });
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/worker.js
@@ -0,0 +1,134 @@
+/* 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";
+
+(function (factory) { // Module boilerplate
+  if (this.module && module.id.indexOf("worker") >= 0) { // require
+    const { Cc, Ci, ChromeWorker } = require("chrome");
+    factory.call(this, require, exports, module, { Cc, Ci }, ChromeWorker);
+  } else { // Cu.import
+      const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+      const { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+      this.isWorker = false;
+      this.Promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+      this.console = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).console;
+      factory.call(this, devtools.require, this, { exports: this }, { Cc, Ci }, ChromeWorker);
+      this.EXPORTED_SYMBOLS = ["DevToolsWorker"];
+  }
+}).call(this, function (require, exports, module, { Ci, Cc }, ChromeWorker ) {
+
+let MESSAGE_COUNTER = 0;
+
+/**
+ * Creates a wrapper around a ChromeWorker, providing easy
+ * communication to offload demanding tasks. The corresponding URL
+ * must implement the interface provided by `devtools/shared/worker-helper`.
+ *
+ * @see `./browser/devtools/shared/widgets/GraphsWorker.js`
+ *
+ * @param {string} url
+ *        The URL of the worker.
+ */
+function DevToolsWorker (url) {
+  this._worker = new ChromeWorker(url);
+}
+exports.DevToolsWorker = DevToolsWorker;
+
+/**
+ * Performs the given task in a chrome worker, passing in data.
+ * Returns a promise that resolves when the task is completed, resulting in
+ * the return value of the task.
+ *
+ * @param {string} task
+ *        The name of the task to execute in the worker.
+ * @param {any} data
+ *        Data to be passed into the task implemented by the worker.
+ * @return {Promise}
+ */
+DevToolsWorker.prototype.performTask = function DevToolsWorkerPerformTask (task, data) {
+  if (this._destroyed) {
+    return Promise.reject("Cannot call performTask on a destroyed DevToolsWorker");
+  }
+  let worker = this._worker;
+  let id = ++MESSAGE_COUNTER;
+  worker.postMessage({ task, id, data });
+
+  return new Promise(function (resolve, reject) {
+    worker.addEventListener("message", function listener({ data }) {
+      if (data.id !== id) {
+        return;
+      }
+      worker.removeEventListener("message", listener);
+      if (data.error) {
+        reject(data.error);
+      } else {
+        resolve(data.response);
+      }
+    });
+  });
+}
+
+/**
+ * Terminates the underlying worker. Use when no longer needing the worker.
+ */
+DevToolsWorker.prototype.destroy = function DevToolsWorkerDestroy () {
+  this._worker.terminate();
+  this._worker = null;
+  this._destroyed = true;
+};
+
+/**
+ * Takes a function and returns a Worker-wrapped version of the same function.
+ * Returns a promise upon resolution.
+ * @see `./browser/devtools/shared/test/browser_devtools-worker-03.js
+ *
+ * * * * ! ! ! This should only be used for tests or A/B testing performance ! ! ! * * * * * *
+ *
+ * The original function must:
+ *
+ * Be a pure function, that is, not use any variables not declared within the
+ * function, or its arguments.
+ *
+ * Return a value or a promise.
+ *
+ * Note any state change in the worker will not affect the callee's context.
+ *
+ * @param {function} fn
+ * @return {function}
+ */
+function workerify (fn) {
+  console.warn(`\`workerify\` should only be used in tests or measuring performance.
+  This creates an object URL on the browser window, and should not be used in production.`)
+  // Fetch via window/utils here as we don't want to include
+  // this module normally.
+  let { getMostRecentBrowserWindow } = require("sdk/window/utils");
+  let { URL, Blob } = getMostRecentBrowserWindow();
+  let stringifiedFn = createWorkerString(fn);
+  let blob = new Blob([stringifiedFn]);
+  let url = URL.createObjectURL(blob);
+  let worker = new DevToolsWorker(url);
+
+  let wrapperFn = data => worker.performTask("workerifiedTask", data);
+
+  wrapperFn.destroy = function () {
+    URL.revokeObjectURL(url);
+    worker.destroy();
+  };
+
+  return wrapperFn;
+}
+exports.workerify = workerify;
+
+/**
+ * Takes a function, and stringifies it, attaching the worker-helper.js
+ * boilerplate hooks.
+ */
+function createWorkerString (fn) {
+  return `importScripts("resource://gre/modules/workers/require.js");
+    const { createTask } = require("resource:///modules/devtools/shared/worker-helper");
+    createTask(self, "workerifiedTask", ${fn.toString()});
+  `;
+}
+
+});
--- a/browser/devtools/styleinspector/test/browser.ini
+++ b/browser/devtools/styleinspector/test/browser.ini
@@ -57,16 +57,17 @@ support-files =
 [browser_ruleview_add-rule_03.js]
 [browser_ruleview_colorpicker-and-image-tooltip_01.js]
 [browser_ruleview_colorpicker-and-image-tooltip_02.js]
 [browser_ruleview_colorpicker-appears-on-swatch-click.js]
 [browser_ruleview_colorpicker-commit-on-ENTER.js]
 [browser_ruleview_colorpicker-edit-gradient.js]
 [browser_ruleview_colorpicker-hides-on-tooltip.js]
 [browser_ruleview_colorpicker-multiple-changes.js]
+[browser_ruleview_colorpicker-release-outside-frame.js]
 [browser_ruleview_colorpicker-revert-on-ESC.js]
 [browser_ruleview_colorpicker-swatch-displayed.js]
 [browser_ruleview_completion-existing-property_01.js]
 [browser_ruleview_completion-existing-property_02.js]
 [browser_ruleview_completion-new-property_01.js]
 [browser_ruleview_completion-new-property_02.js]
 [browser_ruleview_content_01.js]
 [browser_ruleview_content_02.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/styleinspector/test/browser_ruleview_colorpicker-release-outside-frame.js
@@ -0,0 +1,64 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that color pickers stops following the pointer if the pointer is
+// released outside the tooltip frame (bug 1160720).
+
+const PAGE_CONTENT = "data:text/html;charset=utf-8," +
+  '<body style="color: red">Test page for bug 1160720';
+
+add_task(function*() {
+  yield addTab(PAGE_CONTENT);
+  let {toolbox, inspector, view} = yield openRuleView();
+
+  let cSwatch = getRuleViewProperty(view, "element", "color").valueSpan
+    .querySelector(".ruleview-colorswatch");
+
+  let picker = yield openColorPickerForSwatch(cSwatch, view);
+  let spectrum = yield picker.spectrum;
+  let change = spectrum.once("changed");
+
+  info("Pressing mouse down over color picker.");
+  EventUtils.synthesizeMouseAtCenter(spectrum.dragger, {
+    type: "mousedown",
+  }, spectrum.dragger.ownerDocument.defaultView);
+
+  let value = yield change;
+  info(`Color changed to ${value} on mousedown.`);
+
+  // If the mousemove below fails to detect that the button is no longer pressed
+  // the spectrum will update and emit changed event synchronously after calling
+  // synthesizeMouse so this handler is executed before the test ends.
+  spectrum.once("changed", (event, newValue) => {
+    is(newValue, value, "Value changed on mousemove without a button pressed.");
+  });
+
+  // Releasing the button pressed by mousedown above on top of a different frame
+  // does not make sense in this test as EventUtils doesn't preserve the context
+  // i.e. the buttons that were pressed down between events.
+
+  info("Moving mouse over color picker without any buttons pressed.");
+  EventUtils.synthesizeMouse(spectrum.dragger, 10, 10, {
+    button: -1, // -1 = no buttons are pressed down
+    type: "mousemove",
+  }, spectrum.dragger.ownerDocument.defaultView);
+});
+
+function* openColorPickerForSwatch(swatch, view) {
+  let cPicker = view.tooltips.colorPicker;
+  ok(cPicker, "The rule-view has the expected colorPicker property");
+
+  let cPickerPanel = cPicker.tooltip.panel;
+  ok(cPickerPanel, "The XUL panel for the color picker exists");
+
+  let onShown = cPicker.tooltip.once("shown");
+  swatch.click();
+  yield onShown;
+
+  ok(true, "The color picker was shown on click of the color swatch");
+
+  return cPicker;
+}
--- a/browser/devtools/tilt/tilt-commands.js
+++ b/browser/devtools/tilt/tilt-commands.js
@@ -43,30 +43,37 @@ exports.items = [
 {
   name: "tilt toggle",
   buttonId: "command-button-tilt",
   buttonClass: "command-button command-button-invertable",
   tooltipText: l10n.lookup("tiltToggleTooltip"),
   hidden: true,
   state: {
     isChecked: function(aTarget) {
+      if (!aTarget.tab) {
+        return false;
+      }
       let browserWindow = aTarget.tab.ownerDocument.defaultView;
       return !!TiltManager.getTiltForBrowser(browserWindow).currentInstance;
     },
     onChange: function(aTarget, aChangeHandler) {
+      if (!aTarget.tab) {
+        return;
+      }
       let browserWindow = aTarget.tab.ownerDocument.defaultView;
       let tilt = TiltManager.getTiltForBrowser(browserWindow);
       tilt.on("change", aChangeHandler);
     },
     offChange: function(aTarget, aChangeHandler) {
-      if (aTarget.tab) {
-        let browserWindow = aTarget.tab.ownerDocument.defaultView;
-        let tilt = TiltManager.getTiltForBrowser(browserWindow);
-        tilt.off("change", aChangeHandler);
+      if (!aTarget.tab) {
+        return;
       }
+      let browserWindow = aTarget.tab.ownerDocument.defaultView;
+      let tilt = TiltManager.getTiltForBrowser(browserWindow);
+      tilt.off("change", aChangeHandler);
     },
   },
   exec: function(args, context) {
     if (isMultiProcess(context)) {
       return l10n.lookupFormat("notAvailableInE10S", [this.name]);
     }
 
     let chromeWindow = context.environment.chromeDocument.defaultView;
--- a/browser/docs/DirectoryLinksProvider.rst
+++ b/browser/docs/DirectoryLinksProvider.rst
@@ -144,16 +144,17 @@ Below is an example directory source fil
                   "mozilla.org",
                   "planet.mozilla.org",
                   "quality.mozilla.org",
                   "support.mozilla.org",
                   "treeherder.mozilla.org",
                   "wiki.mozilla.org"
               ],
               "imageURI": "https://tiles.cdn.mozilla.net/images/9ee2b265678f2775de2e4bf680df600b502e6038.3875.png",
+              "time_limits": {"start": "2014-01-01T00:00:00.000Z", "end": "2014-02-01T00:00:00.000Z"},
               "title": "Thanks for testing!",
               "type": "affiliate",
               "url": "https://www.mozilla.com/firefox/tiles"
           }
       ]
   }
 
 Link Object
@@ -177,16 +178,19 @@ Suggested Link Object Extras
 ----------------------------
 
 A suggested link has additional values:
 
 - ``frecent_sites`` - array of strings of the sites that can trigger showing a
   Suggested Tile if the user has the site in one of the top 100 most-frecent
   pages. Only preapproved array of strings that are hardcoded into the
   DirectoryLinksProvider module are allowed.
+- ``time_limits`` - an object consisting of start and end timestamps specifying
+  when a Suggested Tile may start and has to stop showing in the newtab.
+  The timestamp is expected in ISO_8601 format: '2014-01-10T20:00:00.000Z'
 
 The preapproved arrays follow a policy for determining what topic grouping is
 allowed as well as the composition of a grouping. The topics are broad
 uncontroversial categories, e.g., Mobile Phone, News, Technology, Video Game,
 Web Development. There are at least 5 sites within a grouping, and as many
 popular sites relevant to the topic are included to avoid having one site be
 clearly dominant. These requirements provide some deniability of which site
 actually triggered a suggestion during ping reporting, so it's more difficult to
--- a/browser/locales/jar.mn
+++ b/browser/locales/jar.mn
@@ -39,18 +39,16 @@
     locale/browser/devtools/netmonitor.dtd            (%chrome/browser/devtools/netmonitor.dtd)
     locale/browser/devtools/netmonitor.properties     (%chrome/browser/devtools/netmonitor.properties)
     locale/browser/devtools/shadereditor.dtd          (%chrome/browser/devtools/shadereditor.dtd)
     locale/browser/devtools/shadereditor.properties   (%chrome/browser/devtools/shadereditor.properties)
     locale/browser/devtools/canvasdebugger.dtd        (%chrome/browser/devtools/canvasdebugger.dtd)
     locale/browser/devtools/canvasdebugger.properties (%chrome/browser/devtools/canvasdebugger.properties)
     locale/browser/devtools/webaudioeditor.dtd          (%chrome/browser/devtools/webaudioeditor.dtd)
     locale/browser/devtools/webaudioeditor.properties   (%chrome/browser/devtools/webaudioeditor.properties)
-    locale/browser/devtools/gcli.properties           (%chrome/browser/devtools/gcli.properties)
-    locale/browser/devtools/gclicommands.properties   (%chrome/browser/devtools/gclicommands.properties)
     locale/browser/devtools/webconsole.properties     (%chrome/browser/devtools/webconsole.properties)
     locale/browser/devtools/inspector.properties      (%chrome/browser/devtools/inspector.properties)
     locale/browser/devtools/tilt.properties           (%chrome/browser/devtools/tilt.properties)
     locale/browser/devtools/shared.properties         (%chrome/browser/devtools/shared.properties)
     locale/browser/devtools/scratchpad.properties     (%chrome/browser/devtools/scratchpad.properties)
     locale/browser/devtools/scratchpad.dtd            (%chrome/browser/devtools/scratchpad.dtd)
     locale/browser/devtools/storage.properties        (%chrome/browser/devtools/storage.properties)
     locale/browser/devtools/styleeditor.properties    (%chrome/browser/devtools/styleeditor.properties)
--- a/browser/modules/DirectoryLinksProvider.jsm
+++ b/browser/modules/DirectoryLinksProvider.jsm
@@ -245,16 +245,17 @@ let DirectoryLinksProvider = {
   _cacheSuggestedLinks: function(link) {
     if (!link.frecent_sites || "sponsored" == link.type) {
       // Don't cache links that don't have the expected 'frecent_sites' or are sponsored.
       return;
     }
     for (let suggestedSite of link.frecent_sites) {
       let suggestedMap = this._suggestedLinks.get(suggestedSite) || new Map();
       suggestedMap.set(link.url, link);
+      this._setupStartEndTime(link);
       this._suggestedLinks.set(suggestedSite, suggestedMap);
     }
   },
 
   _fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
     // Replace with the same display locale used for selecting links data
     uri = uri.replace("%LOCALE%", this.locale);
     uri = uri.replace("%CHANNEL%", UpdateChannel.get());
@@ -361,16 +362,107 @@ let DirectoryLinksProvider = {
     },
     error => {
       Cu.reportError(error);
       return emptyOutput;
     });
   },
 
   /**
+   * Translates link.time_limits to UTC miliseconds and sets
+   * link.startTime and link.endTime properties in link object
+   */
+  _setupStartEndTime: function DirectoryLinksProvider_setupStartEndTime(link) {
+    // set start/end limits. Use ISO_8601 format: '2014-01-10T20:20:20.600Z'
+    // (details here http://en.wikipedia.org/wiki/ISO_8601)
+    // Note that if timezone is missing, FX will interpret as local time
+    // meaning that the server can sepecify any time, but if the capmaign
+    // needs to start at same time across multiple timezones, the server
+    // omits timezone indicator
+    if (!link.time_limits) {
+      return;
+    }
+
+    let parsedTime;
+    if (link.time_limits.start) {
+      parsedTime = Date.parse(link.time_limits.start);
+      if (parsedTime && !isNaN(parsedTime)) {
+        link.startTime = parsedTime;
+      }
+    }
+    if (link.time_limits.end) {
+      parsedTime = Date.parse(link.time_limits.end);
+      if (parsedTime && !isNaN(parsedTime)) {
+        link.endTime = parsedTime;
+      }
+    }
+  },
+
+  /*
+   * Handles campaign timeout
+   */
+  _onCampaignTimeout: function DirectoryLinksProvider_onCampaignTimeout() {
+    // _campaignTimeoutID is invalid here, so just set it to null
+    this._campaignTimeoutID = null;
+    this._updateSuggestedTile();
+  },
+
+  /*
+   * Clears capmpaign timeout
+   */
+  _clearCampaignTimeout: function DirectoryLinksProvider_clearCampaignTimeout() {
+    if (this._campaignTimeoutID) {
+      clearTimeout(this._campaignTimeoutID);
+      this._campaignTimeoutID = null;
+    }
+  },
+
+  /**
+   * Setup capmpaign timeout to recompute suggested tiles upon
+   * reaching soonest start or end time for the campaign
+   * @param timeout in milliseconds
+   */
+  _setupCampaignTimeCheck: function DirectoryLinksProvider_setupCampaignTimeCheck(timeout) {
+    // sanity check
+    if (!timeout || timeout <= 0) {
+      return;
+    }
+    this._clearCampaignTimeout();
+    // setup next timeout
+    this._campaignTimeoutID = setTimeout(this._onCampaignTimeout.bind(this), timeout);
+  },
+
+  /**
+   * Test link for campaign time limits: checks if link falls within start/end time
+   * and returns an object containing a use flag and the timeoutDate milliseconds
+   * when the link has to be re-checked for campaign start-ready or end-reach
+   * @param link
+   * @return object {use: true or false, timeoutDate: milliseconds or null}
+   */
+  _testLinkForCampaignTimeLimits: function DirectoryLinksProvider_testLinkForCampaignTimeLimits(link) {
+    let currentTime = Date.now();
+    // test for start time first
+    if (link.startTime && link.startTime > currentTime) {
+      // not yet ready for start
+      return {use: false, timeoutDate: link.startTime};
+    }
+    // otherwise check for end time
+    if (link.endTime) {
+      // passed end time
+      if (link.endTime <= currentTime) {
+        return {use: false};
+      }
+      // otherwise link is still ok, but we need to set timeoutDate
+      return {use: true, timeoutDate: link.endTime};
+    }
+    // if we are here, the link is ok and no timeoutDate needed
+    return {use: true};
+  },
+
+  /**
    * Report some action on a newtab page (view, click)
    * @param sites Array of sites shown on newtab page
    * @param action String of the behavior to report
    * @param triggeringSiteIndex optional Int index of the site triggering action
    * @return download promise
    */
   reportSitesAction: function DirectoryLinksProvider_reportSitesAction(sites, action, triggeringSiteIndex) {
     // Check if the suggested tile was shown
@@ -485,16 +577,17 @@ let DirectoryLinksProvider = {
    * @param aCallback The function that the array of links is passed to.
    */
   getLinks: function DirectoryLinksProvider_getLinks(aCallback) {
     this._readDirectoryLinksFile().then(rawLinks => {
       // Reset the cache of suggested tiles and enhanced images for this new set of links
       this._enhancedLinks.clear();
       this._frequencyCaps.clear();
       this._suggestedLinks.clear();
+      this._clearCampaignTimeout();
 
       let validityFilter = function(link) {
         // Make sure the link url is allowed and images too if they exist
         return this.isURLAllowed(link.url, ALLOWED_LINK_SCHEMES) &&
                this.isURLAllowed(link.imageURI, ALLOWED_IMAGE_SCHEMES) &&
                this.isURLAllowed(link.enhancedImageURI, ALLOWED_IMAGE_SCHEMES);
       }.bind(this);
 
@@ -700,37 +793,55 @@ let DirectoryLinksProvider = {
       return;
     }
 
     // Create a flat list of all possible links we can show as suggested.
     // Note that many top sites may map to the same suggested links, but we only
     // want to count each suggested link once (based on url), thus possibleLinks is a map
     // from url to suggestedLink. Thus, each link has an equal chance of being chosen at
     // random from flattenedLinks if it appears only once.
+    let nextTimeout;
     let possibleLinks = new Map();
     let targetedSites = new Map();
     this._topSitesWithSuggestedLinks.forEach(topSiteWithSuggestedLink => {
       let suggestedLinksMap = this._suggestedLinks.get(topSiteWithSuggestedLink);
       suggestedLinksMap.forEach((suggestedLink, url) => {
         // Skip this link if we've shown it too many times already
         if (this._frequencyCaps.get(url) <= 0) {
           return;
         }
 
+        // as we iterate suggestedLinks, check for campaign start/end
+        // time limits, and set nextTimeout to the closest timestamp
+        let {use, timeoutDate} = this._testLinkForCampaignTimeLimits(suggestedLink);
+        // update nextTimeout is necessary
+        if (timeoutDate && (!nextTimeout || nextTimeout > timeoutDate)) {
+          nextTimeout = timeoutDate;
+        }
+        // Skip link if it falls outside campaign time limits
+        if (!use) {
+          return;
+        }
+
         possibleLinks.set(url, suggestedLink);
 
         // Keep a map of URL to targeted sites. We later use this to show the user
         // what site they visited to trigger this suggestion.
         if (!targetedSites.get(url)) {
           targetedSites.set(url, []);
         }
         targetedSites.get(url).push(topSiteWithSuggestedLink);
       })
     });
 
+    // setup timeout check for starting or ending campaigns
+    if (nextTimeout) {
+      this._setupCampaignTimeCheck(nextTimeout - Date.now());
+    }
+
     // We might have run out of possible links to show
     let numLinks = possibleLinks.size;
     if (numLinks == 0) {
       return;
     }
 
     let flattenedLinks = [...possibleLinks.values()];
 
--- a/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
+++ b/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
@@ -1221,8 +1221,181 @@ add_task(function test_DirectoryLinksPro
 
   // Turn off DNT header
   Services.prefs.clearUserPref("privacy.donottrackheader.enabled");
   checkDefault(true);
 
   // Clean up
   Services.prefs.clearUserPref("privacy.donottrackheader.value");
 });
+
+add_task(function test_timeSensetiveSuggestedTiles() {
+  // make tile json with start and end dates
+  let testStartTime = Date.now();
+  // start date is now + 1 seconds
+  let startDate = new Date(testStartTime + 1000);
+  // end date is now + 3 seconds
+  let endDate = new Date(testStartTime + 3000);
+  let suggestedTile = Object.assign({
+    time_limits: {
+      start: startDate.toISOString(),
+      end: endDate.toISOString(),
+    }
+  }, suggestedTile1);
+
+  // Initial setup
+  let topSites = ["site0.com", "1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+  let data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+  let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+  let testObserver = new TestTimingRun();
+  DirectoryLinksProvider.addObserver(testObserver);
+
+  let origGetFrecentSitesName = DirectoryLinksProvider.getFrecentSitesName;
+  DirectoryLinksProvider.getFrecentSitesName = () => "";
+
+  yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+  let links = yield fetchData();
+
+  let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+  NewTabUtils.isTopPlacesSite = function(site) {
+    return topSites.indexOf(site) >= 0;
+  }
+
+  let origGetProviderLinks = NewTabUtils.getProviderLinks;
+  NewTabUtils.getProviderLinks = function(provider) {
+    return links;
+  }
+
+  let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+  DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+  do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+  // this tester will fire twice: when start limit is reached and when tile link
+  // is removed upon end of the campaign, in which case deleteFlag will be set
+  function TestTimingRun() {
+    this.promise = new Promise(resolve => {
+      this.onLinkChanged = (directoryLinksProvider, link, ignoreFlag, deleteFlag) => {
+        // if we are not deleting, add link to links, so we can catch it's removal
+        if (!deleteFlag) {
+          links.unshift(link);
+        }
+
+        isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], ["hrblock.com", "1040.com"]);
+        do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+        do_check_eq(link.type, "affiliate");
+        do_check_eq(link.url, suggestedTile.url);
+        let timeDelta = Date.now() - testStartTime;
+        if (!deleteFlag) {
+          // this is start timeout corresponding to campaign start
+          // a seconds must pass and targetedSite must be set
+          do_check_true(timeDelta >= 1000);
+          do_check_eq(link.targetedSite, "hrblock.com");
+          do_check_true(DirectoryLinksProvider._campaignTimeoutID);
+        }
+        else {
+          // this is the campaign end timeout, so 3 seconds must pass
+          // and timeout should be cleared
+          do_check_true(timeDelta >= 3000);
+          do_check_false(link.targetedSite);
+          do_check_false(DirectoryLinksProvider._campaignTimeoutID);
+          resolve();
+        }
+      };
+    });
+  }
+
+  // _updateSuggestedTile() is called when fetching directory links.
+  yield testObserver.promise;
+  DirectoryLinksProvider.removeObserver(testObserver);
+
+  // shoudl suggest nothing
+  do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+  // set links back to contain directory tile only
+  links.shift();
+
+  // drop the end time - we should pick up the tile
+  suggestedTile.time_limits.end = null;
+  data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+  dataURI = 'data:application/json,' + JSON.stringify(data);
+
+  // redownload json and getLinks to force time recomputation
+  yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+
+  // ensure that there's a link returned by _updateSuggestedTile and no timeout
+  let deferred = Promise.defer();
+  DirectoryLinksProvider.getLinks(() => {
+    let link = DirectoryLinksProvider._updateSuggestedTile();
+    // we should have a suggested tile and no timeout
+    do_check_eq(link.type, "affiliate");
+    do_check_eq(link.url, suggestedTile.url);
+    do_check_false(DirectoryLinksProvider._campaignTimeoutID);
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // repeat the test for end time only
+  suggestedTile.time_limits.start = null;
+  suggestedTile.time_limits.end = (new Date(Date.now() + 3000)).toISOString();
+
+  data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+  dataURI = 'data:application/json,' + JSON.stringify(data);
+
+  // redownload json and call getLinks() to force time recomputation
+  yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+
+  // ensure that there's a link returned by _updateSuggestedTile and timeout set
+  deferred = Promise.defer();
+  DirectoryLinksProvider.getLinks(() => {
+    let link = DirectoryLinksProvider._updateSuggestedTile();
+    // we should have a suggested tile and timeout set
+    do_check_eq(link.type, "affiliate");
+    do_check_eq(link.url, suggestedTile.url);
+    do_check_true(DirectoryLinksProvider._campaignTimeoutID);
+    DirectoryLinksProvider._clearCampaignTimeout();
+    deferred.resolve();
+  });
+  yield deferred.promise;
+
+  // Cleanup
+  yield promiseCleanDirectoryLinksProvider();
+  DirectoryLinksProvider.getFrecentSitesName = origGetFrecentSitesName;
+  NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+  NewTabUtils.getProviderLinks = origGetProviderLinks;
+  DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+});
+
+add_task(function test_setupStartEndTime() {
+  let currentTime = Date.now();
+  let dt = new Date(currentTime);
+  let link = {
+    time_limits: {
+      start: dt.toISOString()
+    }
+  };
+
+  // test ISO translation
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_eq(link.startTime, currentTime);
+
+  // test localtime translation
+  let shiftedDate = new Date(currentTime - dt.getTimezoneOffset()*60*1000);
+  link.time_limits.start = shiftedDate.toISOString().replace(/Z$/, "");
+
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_eq(link.startTime, currentTime);
+
+  // throw some garbage into date string
+  delete link.startTime;
+  link.time_limits.start = "no date"
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_false(link.startTime);
+
+  link.time_limits.start = "2015-99999-01T00:00:00"
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_false(link.startTime);
+
+  link.time_limits.start = "20150501T00:00:00"
+  DirectoryLinksProvider._setupStartEndTime(link);
+  do_check_false(link.startTime);
+});
--- a/browser/themes/shared/devtools/performance.inc.css
+++ b/browser/themes/shared/devtools/performance.inc.css
@@ -410,16 +410,17 @@
    text-decoration: underline;
 }
 
 #waterfall-details {
   -moz-padding-start: 8px;
   -moz-padding-end: 8px;
   padding-top: 2vh;
   overflow: auto;
+  min-width: 50px;
 }
 
 .marker-details-bullet {
   width: 8px;
   height: 8px;
   border-radius: 1px;
 }
 
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -2627,32 +2627,44 @@ public class BrowserApp extends GeckoApp
         // (see bug 925012). Because of an Android bug (http://code.google.com/p/android/issues/detail?id=61179),
         // calling FragmentTransaction#add immediately after FragmentTransaction#remove won't add the fragment's
         // view to the layout. Calling FragmentManager#executePendingTransactions before re-adding the fragment
         // prevents this issue.
         fm.executePendingTransactions();
 
         fm.beginTransaction().add(R.id.search_container, mBrowserSearch, BROWSER_SEARCH_TAG).commitAllowingStateLoss();
         mBrowserSearch.setUserVisibleHint(true);
+
+        // We want to adjust the window size when the keyboard appears to bring the
+        // SearchEngineBar above the keyboard. However, adjusting the window size
+        // when hiding the keyboard results in graphical glitches where the keyboard was
+        // because nothing was being drawn underneath (bug 933422). This can be
+        // prevented drawing content under the keyboard (i.e. in the Window).
+        //
+        // We do this here because there are glitches when unlocking a device with
+        // BrowserSearch in the foreground if we use BrowserSearch.onStart/Stop.
+        getActivity().getWindow().setBackgroundDrawableResource(android.R.color.white);
     }
 
     private void hideBrowserSearch() {
         if (!mBrowserSearch.getUserVisibleHint()) {
             return;
         }
 
         // To prevent overdraw, the HomePager is hidden when BrowserSearch is displayed:
         // reverse that.
         mHomePagerContainer.setVisibility(View.VISIBLE);
 
         mBrowserSearchContainer.setVisibility(View.INVISIBLE);
 
         getSupportFragmentManager().beginTransaction()
                 .remove(mBrowserSearch).commitAllowingStateLoss();
         mBrowserSearch.setUserVisibleHint(false);
+
+        getWindow().setBackgroundDrawable(null);
     }
 
     /**
      * Hides certain UI elements (e.g. button toast, tabs panel) when the
      * user touches the main layout.
      */
     private class HideOnTouchListener implements TouchEventInterceptor {
         private boolean mIsHidingTabs;
--- a/mobile/android/base/home/BrowserSearch.java
+++ b/mobile/android/base/home/BrowserSearch.java
@@ -60,17 +60,18 @@ import android.widget.AdapterView;
 import android.widget.LinearLayout;
 import android.widget.ListView;
 import android.widget.TextView;
 
 /**
  * Fragment that displays frecency search results in a ListView.
  */
 public class BrowserSearch extends HomeFragment
-                           implements GeckoEventListener {
+                           implements GeckoEventListener,
+                                      SearchEngineBar.OnSearchBarClickListener {
 
     @RobocopTarget
     public interface SuggestClientFactory {
         public SuggestClient getSuggestClient(Context context, String template, int timeout, int max);
     }
 
     @RobocopTarget
     public static class DefaultSuggestClientFactory implements SuggestClientFactory {
@@ -119,16 +120,19 @@ public class BrowserSearch extends HomeF
     private SearchAdapter mAdapter;
 
     // The view shown by the fragment
     private LinearLayout mView;
 
     // The list showing search results
     private HomeListView mList;
 
+    // The bar on the bottom of the screen displaying search engine options.
+    private SearchEngineBar mSearchEngineBar;
+
     // Client that performs search suggestion queries.
     // Public for testing.
     @RobocopTarget
     public volatile SuggestClient mSuggestClient;
 
     // List of search engines from Gecko.
     // Do not mutate this list.
     // Access to this member must only occur from the UI thread.
@@ -222,33 +226,16 @@ public class BrowserSearch extends HomeF
     @Override
     public void onDestroy() {
         super.onDestroy();
 
         mSearchEngines = null;
     }
 
     @Override
-    public void onStart() {
-        super.onStart();
-
-        // Adjusting the window size when showing the keyboard results in the underlying
-        // activity being painted when the keyboard is hidden (bug 933422). This can be
-        // prevented by not resizing the window.
-        getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
-    }
-
-    @Override
-    public void onStop() {
-        super.onStop();
-
-        getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
-    }
-
-    @Override
     public void onResume() {
         super.onResume();
 
         // Fetch engines if we need to.
         if (mSearchEngines.isEmpty() || !Locale.getDefault().equals(mLastLocale)) {
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SearchEngines:GetVisible", null));
         }
 
@@ -263,27 +250,31 @@ public class BrowserSearch extends HomeF
     }
 
     @Override
     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
         // All list views are styled to look the same with a global activity theme.
         // If the style of the list changes, inflate it from an XML.
         mView = (LinearLayout) inflater.inflate(R.layout.browser_search, container, false);
         mList = (HomeListView) mView.findViewById(R.id.home_list_view);
+        mSearchEngineBar = (SearchEngineBar) mView.findViewById(R.id.search_engine_bar);
 
         return mView;
     }
 
     @Override
     public void onDestroyView() {
         super.onDestroyView();
 
         EventDispatcher.getInstance().unregisterGeckoThreadListener(this,
             "SearchEngines:Data");
 
+        mSearchEngineBar.setAdapter(null);
+        mSearchEngineBar = null;
+
         mList.setAdapter(null);
         mList = null;
 
         mView = null;
         mSuggestionsOptInPrompt = null;
         mSuggestClient = null;
     }
 
@@ -345,16 +336,22 @@ public class BrowserSearch extends HomeF
                 }
                 return false;
             }
         });
 
         registerForContextMenu(mList);
         EventDispatcher.getInstance().registerGeckoThreadListener(this,
             "SearchEngines:Data");
+
+        // If the view backed by this Fragment is being recreated, we will not receive
+        // a new search engine data event so refresh the new search engine bar's data
+        // & Views with the data we have.
+        mSearchEngineBar.setSearchEngines(mSearchEngines);
+        mSearchEngineBar.setOnSearchBarClickListener(this);
     }
 
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
         super.onActivityCreated(savedInstanceState);
 
         // Initialize the search adapter
         mAdapter = new SearchAdapter(getActivity());
@@ -582,28 +579,38 @@ public class BrowserSearch extends HomeF
 
             mSearchEngines = Collections.unmodifiableList(searchEngines);
             mLastLocale = Locale.getDefault();
 
             if (mAdapter != null) {
                 mAdapter.notifyDataSetChanged();
             }
 
+            mSearchEngineBar.setSearchEngines(mSearchEngines);
+
             // Show suggestions opt-in prompt only if suggestions are not enabled yet,
             // user hasn't been prompted and we're not on a private browsing tab.
             if (!mSuggestionsEnabled && !suggestionsPrompted && mSuggestClient != null) {
                 showSuggestionsOptIn();
             }
         } catch (JSONException e) {
             Log.e(LOGTAG, "Error getting search engine JSON", e);
         }
 
         filterSuggestions();
     }
 
+    @Override
+    public void onSearchBarClickListener(final SearchEngine searchEngine) {
+        Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM,
+                "searchenginebar");
+
+        mSearchListener.onSearch(searchEngine, mSearchTerm);
+    }
+
     private void maybeSetSuggestClient(final String suggestTemplate, final boolean isPrivate) {
         if (mSuggestClient != null || isPrivate) {
             return;
         }
 
         mSuggestClient = sSuggestClientFactory.getSuggestClient(getActivity(), suggestTemplate, SUGGESTION_TIMEOUT, SUGGESTION_MAX);
     }
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/home/SearchEngineBar.java
@@ -0,0 +1,137 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+/* 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/. */
+
+ package org.mozilla.gecko.home;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.BaseAdapter;
+import android.widget.FrameLayout;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.widget.FaviconView;
+import org.mozilla.gecko.widget.TwoWayView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SearchEngineBar extends TwoWayView
+                             implements AdapterView.OnItemClickListener {
+    private static final String LOGTAG = "Gecko" + SearchEngineBar.class.getSimpleName();
+
+    public interface OnSearchBarClickListener {
+        public void onSearchBarClickListener(SearchEngine searchEngine);
+    }
+
+    private final SearchEngineAdapter adapter;
+    private OnSearchBarClickListener onSearchBarClickListener;
+
+    public SearchEngineBar(final Context context, final AttributeSet attrs) {
+        super(context, attrs);
+
+        adapter = new SearchEngineAdapter();
+        setAdapter(adapter);
+        setOnItemClickListener(this);
+    }
+
+    @Override
+    public void onItemClick(final AdapterView<?> parent, final View view, final int position,
+            final long id) {
+        if (onSearchBarClickListener == null) {
+            throw new IllegalStateException(
+                    OnSearchBarClickListener.class.getSimpleName() + " is not initialized");
+        }
+
+        final SearchEngine searchEngine = adapter.getItem(position);
+        onSearchBarClickListener.onSearchBarClickListener(searchEngine);
+    }
+
+    protected void setOnSearchBarClickListener(final OnSearchBarClickListener listener) {
+        onSearchBarClickListener = listener;
+    }
+
+    protected void setSearchEngines(final List<SearchEngine> searchEngines) {
+        adapter.setSearchEngines(searchEngines);
+    }
+
+    public class SearchEngineAdapter extends BaseAdapter {
+        List<SearchEngine> searchEngines = new ArrayList<>();
+
+        public void setSearchEngines(final List<SearchEngine> searchEngines) {
+            this.searchEngines = searchEngines;
+            notifyDataSetChanged();
+        }
+
+        @Override
+        public int getCount() {
+            return searchEngines.size();
+        }
+
+        @Override
+        public SearchEngine getItem(final int position) {
+            return searchEngines.get(position);
+        }
+
+        @Override
+        public long getItemId(final int position) {
+            return position;
+        }
+
+        @Override
+        public View getView(final int position, final View convertView, final ViewGroup parent) {
+            final View view;
+            if (convertView == null) {
+                view = LayoutInflater.from(getContext()).inflate(R.layout.search_engine_bar_item, parent, false);
+            } else {
+                view = convertView;
+            }
+
+            final FaviconView faviconView = (FaviconView) view.findViewById(R.id.search_engine_icon);
+            final SearchEngine searchEngine = searchEngines.get(position);
+            faviconView.updateAndScaleImage(searchEngine.getIcon(), searchEngine.getEngineIdentifier());
+
+            final View container = view.findViewById(R.id.search_engine_icon_container);
+            final String desc = getResources().getString(R.string.search_bar_item_desc, searchEngine.getEngineIdentifier());
+            container.setContentDescription(desc);
+
+            return view;
+        }
+    }
+
+    /**
+     * A Container to surround the SearchEngineBar. This is necessary so we can draw
+     * a divider across the entire width of the screen, but have the inner list layout
+     * not take up the full width of the screen so it can be centered within this container
+     * if there aren't enough items that it needs to scroll.
+     *
+     * Note: a better implementation would have this View inflating an inner layout so
+     * the containing layout doesn't need two "SearchEngineBar" Views but it wasn't
+     * worth the refactor time.
+     */
+    @SuppressWarnings("unused") // via XML
+    public static class SearchEngineBarContainer extends FrameLayout {
+        private final Paint dividerPaint;
+
+        public SearchEngineBarContainer(final Context context, final AttributeSet attrs) {
+            super(context, attrs);
+
+            dividerPaint = new Paint();
+            dividerPaint.setColor(getResources().getColor(R.color.divider_light));
+        }
+
+        @Override
+        public void onDraw(final Canvas canvas) {
+            super.onDraw(canvas);
+
+            canvas.drawLine(0, 0, getWidth(), 0, dividerPaint);
+        }
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -335,16 +335,17 @@ gbjar.sources += [
     'home/RecentTabsPanel.java',
     'home/RemoteTabsBaseFragment.java',
     'home/RemoteTabsExpandableListFragment.java',
     'home/RemoteTabsExpandableListState.java',
     'home/RemoteTabsPanel.java',
     'home/RemoteTabsSplitPlaneFragment.java',
     'home/RemoteTabsStaticFragment.java',
     'home/SearchEngine.java',
+    'home/SearchEngineBar.java',
     'home/SearchEngineRow.java',
     'home/SearchLoader.java',
     'home/SimpleCursorLoader.java',
     'home/TabMenuStrip.java',
     'home/TabMenuStripLayout.java',
     'home/TopSitesGridItemView.java',
     'home/TopSitesGridView.java',
     'home/TopSitesPanel.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/color/pressed_about_page_header_grey.xml
@@ -0,0 +1,12 @@
+<!-- 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/. -->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item android:state_pressed="true"
+          android:drawable="@color/about_page_header_grey" />
+
+    <item android:drawable="@android:color/transparent"/>
+
+</selector>
--- a/mobile/android/base/resources/layout/browser_search.xml
+++ b/mobile/android/base/resources/layout/browser_search.xml
@@ -14,9 +14,36 @@
               android:layout="@layout/home_suggestion_prompt" />
 
     <view class="org.mozilla.gecko.home.BrowserSearch$HomeSearchListView"
             android:id="@+id/home_list_view"
             android:layout_width="match_parent"
             android:layout_height="0dp"
             android:layout_weight="1" />
 
+    <!-- The window background is set to our desired color, #fff, so
+         reduce overdraw by not drawing the background.
+
+         Note: this needs to be transparent and not null because we
+         draw a divider in onDraw. -->
+    <view class="org.mozilla.gecko.home.SearchEngineBar$SearchEngineBarContainer"
+          android:layout_width="match_parent"
+          android:layout_height="wrap_content"
+          android:background="@android:color/transparent">
+
+        <!-- We add a marginTop so the outer container can draw a divider.
+
+             listSelector is too slow for showing pressed state
+             so we set the pressed colors on the child. -->
+        <org.mozilla.gecko.home.SearchEngineBar
+              android:id="@+id/search_engine_bar"
+              android:layout_width="wrap_content"
+              android:layout_height="48dp"
+              android:layout_marginTop="1dp"
+              android:orientation="horizontal"
+              android:layout_gravity="center_horizontal"
+              android:choiceMode="singleChoice"
+              android:listSelector="@android:color/transparent"
+              android:cacheColorHint="@android:color/transparent"/>
+
+   </view>
+
 </LinearLayout>
--- a/mobile/android/base/resources/layout/gecko_app.xml
+++ b/mobile/android/base/resources/layout/gecko_app.xml
@@ -72,17 +72,16 @@
                                            android:layout_alignParentBottom="true"
                                            style="@style/FindBar"
                                            android:visibility="gone"/>
 
         <FrameLayout android:id="@+id/search_container"
                      android:layout_width="match_parent"
                      android:layout_height="match_parent"
                      android:layout_below="@+id/browser_chrome"
-                     android:background="@android:color/white"
                      android:visibility="invisible"/>
 
         <!-- When focus is cleared from from BrowserToolbar's EditText to
              lower the virtual keyboard, focus will be returned to the root
              view. To make sure the EditText is not the first focusable view in
              the root view, BrowserToolbar should be specified as low in the
              view hierarchy as possible. -->
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout/search_engine_bar_item.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<!-- TwoWayView doesn't let us set the margin around items (except as
+     gecko:itemMargin, but that doesn't increase the hit area) so we
+     have to surround the main View by a ViewGroup to create a pressable margin.
+
+     Note: the layout_height values are shared with the parent
+     View (browser_search at the time of this writing). -->
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/search_engine_icon_container"
+    android:layout_height="match_parent"
+    android:layout_width="72dp"
+    android:background="@color/pressed_about_page_header_grey">
+
+    <!-- Width & height are set to make the Favicons as sharp as possible
+         based on asset size. -->
+    <org.mozilla.gecko.widget.FaviconView
+        android:id="@+id/search_engine_icon"
+        android:layout_width="16dp"
+        android:layout_height="16dp"
+        android:layout_gravity="center"/>
+
+</FrameLayout>
--- a/mobile/android/base/widget/LoginDoorHanger.java
+++ b/mobile/android/base/widget/LoginDoorHanger.java
@@ -169,16 +169,17 @@ public class LoginDoorHanger extends Doo
                                 inputs.put("password", password.getText());
                                 response.put("inputs", inputs);
                             } catch (JSONException e) {
                                 Log.e(LOGTAG, "Error creating doorhanger reply message");
                                 response = null;
                                 Toast.makeText(mContext, mResources.getString(R.string.doorhanger_login_edit_toast_error), Toast.LENGTH_SHORT).show();
                             }
                             mOnButtonClickListener.onButtonClick(response, LoginDoorHanger.this);
+                            dialog.dismiss();
                         }
                     });
                     builder.setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener() {
                         @Override
                         public void onClick(DialogInterface dialog, int which) {
                             dialog.dismiss();
                         }
                     });
--- a/mobile/android/search/manifests/SearchAndroidManifest_activities.xml.in
+++ b/mobile/android/search/manifests/SearchAndroidManifest_activities.xml.in
@@ -1,11 +1,12 @@
         <activity
             android:name="@MOZ_ANDROID_SEARCH_INTENT_CLASS@"
             android:launchMode="singleTop"
+            android:taskAffinity="@ANDROID_PACKAGE_NAME@.SEARCH"
             android:icon="@drawable/search_launcher"
             android:label="@string/search_app_name"
             android:configChanges="orientation|screenSize"
             android:theme="@style/AppTheme">
             <intent-filter>
                 <action android:name="android.intent.action.ASSIST"/>
 
                 <category android:name="android.intent.category.DEFAULT"/>
--- a/mobile/android/tests/browser/robocop/testInputUrlBar.java
+++ b/mobile/android/tests/browser/robocop/testInputUrlBar.java
@@ -83,20 +83,20 @@ public final class testInputUrlBar exten
             public void run() {
                 editText.selectAll();
             }
         });
         mActions.sendKeys("uv");
         assertUrlBarText("uv");
 
         // Dismiss the VKB
-        mActions.sendSpecialKey(Actions.SpecialKey.BACK);
+        mSolo.goBack();
 
         // Dismiss editing mode
-        mActions.sendSpecialKey(Actions.SpecialKey.BACK);
+        mSolo.goBack();
 
         waitForText(mStringHelper.TITLE_PLACE_HOLDER);
 
         // URL bar should have forgotten about "uv" text.
         startEditingMode();
         assertUrlBarText(mStringHelper.ABOUT_HOME_URL);
 
         int width = mDriver.getGeckoWidth() / 2;
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -835,16 +835,35 @@ pref("devtools.remote.wifi.scan", true);
 // whether the UI control to make such a choice is shown to the user
 pref("devtools.remote.wifi.visible", true);
 // Client must complete TLS handshake within this window (ms)
 pref("devtools.remote.tls-handshake-timeout", 10000);
 
 // URL of the remote JSON catalog used for device simulation
 pref("devtools.devices.url", "https://code.cdn.mozilla.net/devices/devices.json");
 
+// Display the introductory text
+pref("devtools.gcli.hideIntro", false);
+
+// How eager are we to show help: never=1, sometimes=2, always=3
+pref("devtools.gcli.eagerHelper", 2);
+
+// Alias to the script URLs for inject command.
+pref("devtools.gcli.jquerySrc", "https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js");
+pref("devtools.gcli.lodashSrc", "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/2.4.1/lodash.min.js");
+pref("devtools.gcli.underscoreSrc", "https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js");
+
+// Set imgur upload client ID
+pref("devtools.gcli.imgurClientID", '0df414e888d7240');
+// Imgur's upload URL
+pref("devtools.gcli.imgurUploadURL", "https://api.imgur.com/3/image");
+
+// GCLI commands directory
+pref("devtools.commands.dir", "");
+
 // view source
 pref("view_source.syntax_highlight", true);
 pref("view_source.wrap_long_lines", false);
 pref("view_source.editor.external", false);
 pref("view_source.editor.path", "");
 // allows to add further arguments to the editor; use the %LINE% placeholder
 // for jumping to a specific line (e.g. "/line:%LINE%" or "--goto %LINE%")
 pref("view_source.editor.args", "");
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -1401,19 +1401,19 @@ FxAccountsInternal.prototype = {
     return reason;
   },
 
   /**
    * Get the user's account and profile data
    *
    * @param options
    *        {
-   *          contentUrl: (string) Used by the FxAccountsProfileChannel.
+   *          contentUrl: (string) Used by the FxAccountsWebChannel.
    *            Defaults to pref identity.fxaccounts.settings.uri
-   *          profileServerUrl: (string) Used by the FxAccountsProfileChannel.
+   *          profileServerUrl: (string) Used by the FxAccountsWebChannel.
    *            Defaults to pref identity.fxaccounts.remote.profile.uri
    *        }
    *
    * @return Promise.<object | Error>
    *        The promise resolves to an accountData object with extra profile
    *        information such as profileImageUrl, or rejects with
    *        an error object ({error: ERROR, details: {}}) of the following:
    *          INVALID_PARAMETER
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -95,18 +95,18 @@ exports.ON_PROFILE_CHANGE_NOTIFICATION =
 
 // 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";
 
-// Profile WebChannel ID
-exports.PROFILE_WEBCHANNEL_ID = "account_updates";
+// Firefox Accounts WebChannel ID
+exports.WEBCHANNEL_ID = "account_updates";
 
 // Server errno.
 // From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
 exports.ERRNO_ACCOUNT_ALREADY_EXISTS         = 101;
 exports.ERRNO_ACCOUNT_DOES_NOT_EXIST         = 102;
 exports.ERRNO_INCORRECT_PASSWORD             = 103;
 exports.ERRNO_UNVERIFIED_ACCOUNT             = 104;
 exports.ERRNO_INVALID_VERIFICATION_CODE      = 105;
--- a/services/fxaccounts/FxAccountsProfile.jsm
+++ b/services/fxaccounts/FxAccountsProfile.jsm
@@ -19,21 +19,16 @@ const {classes: Cc, interfaces: Ci, util
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient",
   "resource://gre/modules/FxAccountsProfileClient.jsm");
 
-XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileChannel",
-  "resource://gre/modules/FxAccountsProfileChannel.jsm");
-
-let fxAccountProfileChannel = null;
-
 // Based off of deepEqual from Assert.jsm
 function deepEqual(actual, expected) {
   if (actual === expected) {
     return true;
   } else if (typeof actual != "object" && typeof expected != "object") {
     return actual == expected;
   } else {
     return objEquiv(actual, expected);
@@ -126,35 +121,20 @@ this.FxAccountsProfile.prototype = {
 
   _fetchAndCacheProfile: function () {
     return this.client.fetchProfile()
       .then(profile => {
         return this._cacheProfile(profile).then(() => profile);
       });
   },
 
-  // Initialize a profile channel to listen for account changes.
-  _listenForProfileChanges: function () {
-    if (! fxAccountProfileChannel) {
-      let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri");
-
-      fxAccountProfileChannel = new FxAccountsProfileChannel({
-        content_uri: contentUri
-      });
-    }
-
-    return fxAccountProfileChannel;
-  },
-
   // Returns cached data right away if available, then fetches the latest profile
   // data in the background. After data is fetched a notification will be sent
   // out if the profile has changed.
   getProfile: function () {
-    this._listenForProfileChanges();
-
     return this._getCachedProfile()
       .then(cachedProfile => {
         if (cachedProfile) {
           this._fetchAndCacheProfile();
           return cachedProfile;
         }
         return this._fetchAndCacheProfile();
       })
rename from services/fxaccounts/FxAccountsProfileChannel.jsm
rename to services/fxaccounts/FxAccountsWebChannel.jsm
--- a/services/fxaccounts/FxAccountsProfileChannel.jsm
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -1,118 +1,313 @@
 /* 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/. */
 
 /**
- * Firefox Accounts Profile update helper.
- * Uses the WebChannel component to receive messages about account changes.
+ * Firefox Accounts Web Channel.
+ *
+ * Uses the WebChannel component to receive messages
+ * about account state changes.
  */
 
-this.EXPORTED_SYMBOLS = ["FxAccountsProfileChannel"];
+this.EXPORTED_SYMBOLS = ["FxAccountsWebChannel"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
                                   "resource://gre/modules/WebChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+                                  "resource://gre/modules/FxAccounts.jsm");
 
-const PROFILE_CHANGE_COMMAND = "profile:change";
+const COMMAND_PROFILE_CHANGE       = "profile:change";
+const COMMAND_CAN_LINK_ACCOUNT     = "fxaccounts:can_link_account";
+const COMMAND_LOGIN                = "fxaccounts:login";
+
+const PREF_LAST_FXA_USER           = "identity.fxaccounts.lastSignedInUserHash";
+const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog";
 
 /**
- * Create a new FxAccountsProfileChannel to listen to profile updates
+ * Create a new FxAccountsWebChannel to listen for account updates
  *
  * @param {Object} options Options
- *   @param {Object} options.parameters
- *     @param {String} options.parameters.content_uri
+ *   @param {Object} options
+ *     @param {String} options.content_uri
  *     The FxA Content server uri
+ *     @param {String} options.channel_id
+ *     The ID of the WebChannel
+ *     @param {String} options.helpers
+ *     Helpers functions. Should only be passed in for testing.
  * @constructor
  */
-this.FxAccountsProfileChannel = function(options) {
+this.FxAccountsWebChannel = function(options) {
   if (!options) {
     throw new Error("Missing configuration options");
   }
   if (!options["content_uri"]) {
     throw new Error("Missing 'content_uri' option");
   }
-  this.parameters = options;
+  this._contentUri = options.content_uri;
+
+  if (!options["channel_id"]) {
+    throw new Error("Missing 'channel_id' option");
+  }
+  this._webChannelId = options.channel_id;
+
+  // options.helpers is only specified by tests.
+  this._helpers = options.helpers || new FxAccountsWebChannelHelpers(options);
 
   this._setupChannel();
 };
 
-this.FxAccountsProfileChannel.prototype = {
-  /**
-   * Configuration object
-   */
-  parameters: null,
+this.FxAccountsWebChannel.prototype = {
   /**
    * WebChannel that is used to communicate with content page
    */
   _channel: null,
+
+  /**
+   * Helpers interface that does the heavy lifting.
+   */
+  _helpers: null,
+
+  /**
+   * WebChannel ID.
+   */
+  _webChannelId: null,
   /**
    * WebChannel origin, used to validate origin of messages
    */
   _webChannelOrigin: null,
 
   /**
    * Release all resources that are in use.
    */
-  tearDown: function() {
+  tearDown() {
     this._channel.stopListening();
     this._channel = null;
     this._channelCallback = null;
   },
 
   /**
    * Configures and registers a new WebChannel
    *
    * @private
    */
-  _setupChannel: function() {
-    // if this.parameters.content_uri is present but not a valid URI, then this will throw an error.
+  _setupChannel() {
+    // if this.contentUri is present but not a valid URI, then this will throw an error.
     try {
-      this._webChannelOrigin = Services.io.newURI(this.parameters.content_uri, null, null);
+      this._webChannelOrigin = Services.io.newURI(this._contentUri, null, null);
       this._registerChannel();
     } catch (e) {
       log.error(e);
       throw e;
     }
   },
 
   /**
    * Create a new channel with the WebChannelBroker, setup a callback listener
    * @private
    */
-  _registerChannel: function() {
+  _registerChannel() {
     /**
      * Processes messages that are called back from the FxAccountsChannel
      *
      * @param webChannelId {String}
      *        Command webChannelId
      * @param message {Object}
      *        Command message
-     * @param target {EventTarget}
-     *        Channel message event target
+     * @param sendingContext {Object}
+     *        Message sending context.
+     *        @param sendingContext.browser {browser}
+     *               The <browser> object that captured the
+     *               WebChannelMessageToChrome.
+     *        @param sendingContext.eventTarget {EventTarget}
+     *               The <EventTarget> where the message was sent.
+     *        @param sendingContext.principal {Principal}
+     *               The <Principal> of the EventTarget where the message was sent.
      * @private
+     *
      */
-    let listener = (webChannelId, message, target) => {
+    let listener = (webChannelId, message, sendingContext) => {
       if (message) {
+        log.debug("FxAccountsWebChannel message received", message);
         let command = message.command;
         let data = message.data;
+
         switch (command) {
-          case PROFILE_CHANGE_COMMAND:
+          case COMMAND_PROFILE_CHANGE:
             Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid);
-          break;
+            break;
+          case COMMAND_LOGIN:
+            this._helpers.login(data);
+            break;
+          case COMMAND_CAN_LINK_ACCOUNT:
+            let canLinkAccount = this._helpers.shouldAllowRelink(data.email);
+
+            let response = {
+              command: command,
+              messageId: message.messageId,
+              data: { ok: canLinkAccount }
+            };
+
+            log.debug("FxAccountsWebChannel response", response);
+            this._channel.send(response, sendingContext);
+            break;
+          default:
+            log.warn("Unrecognized FxAccountsWebChannel command", command);
+            break;
         }
       }
     };
 
     this._channelCallback = listener;
-    this._channel = new WebChannel(PROFILE_WEBCHANNEL_ID, this._webChannelOrigin);
-    this._channel.listen(this._channelCallback);
-    log.debug("Channel registered: " + PROFILE_WEBCHANNEL_ID + " with origin " + this._webChannelOrigin.prePath);
+    this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin);
+    this._channel.listen(listener);
+    log.debug("FxAccountsWebChannel registered: " + this._webChannelId + " with origin " + this._webChannelOrigin.prePath);
   }
+};
 
+this.FxAccountsWebChannelHelpers = function(options) {
+  options = options || {};
+
+  this._fxAccounts = options.fxAccounts || fxAccounts;
 };
+
+this.FxAccountsWebChannelHelpers.prototype = {
+  // If the last fxa account used for sync isn't this account, we display
+  // a modal dialog checking they really really want to do this...
+  // (This is sync-specific, so ideally would be in sync's identity module,
+  // but it's a little more seamless to do here, and sync is currently the
+  // only fxa consumer, so...
+  shouldAllowRelink(acctName) {
+    return !this._needRelinkWarning(acctName) ||
+            this._promptForRelink(acctName);
+  },
+
+  /**
+   * New users are asked in the content server whether they want to
+   * customize which data should be synced. The user is only shown
+   * the dialog listing the possible data types upon verification.
+   *
+   * Save a bit into prefs that is read on verification to see whether
+   * to show the list of data types that can be saved.
+   */
+  setShowCustomizeSyncPref(showCustomizeSyncPref) {
+    Services.prefs.setBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION, showCustomizeSyncPref);
+  },
+
+  getShowCustomizeSyncPref(showCustomizeSyncPref) {
+    return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION);
+  },
+
+  /**
+   * stores sync login info it in the fxaccounts service
+   *
+   * @param accountData the user's account data and credentials
+   */
+  login(accountData) {
+    if (accountData.customizeSync) {
+      this.setShowCustomizeSyncPref(true);
+      delete accountData.customizeSync;
+    }
+
+    // the user has already been shown the "can link account"
+    // screen. No need to keep this data around.
+    delete accountData.verifiedCanLinkAccount;
+
+    // Remember who it was so we can log out next time.
+    this.setPreviousAccountNameHashPref(accountData.email);
+
+    // A sync-specific hack - we want to ensure sync has been initialized
+    // before we set the signed-in user.
+    let xps = Cc["@mozilla.org/weave/service;1"]
+              .getService(Ci.nsISupports)
+              .wrappedJSObject;
+    return xps.whenLoaded().then(() => {
+      return this._fxAccounts.setSignedInUser(accountData);
+    });
+  },
+
+  /**
+   * Get the hash of account name of the previously signed in account
+   */
+  getPreviousAccountNameHashPref() {
+    try {
+      return Services.prefs.getComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString).data;
+    } catch (_) {
+      return "";
+    }
+  },
+
+  /**
+   * Given an account name, set the hash of the previously signed in account
+   *
+   * @param acctName the account name of the user's account.
+   */
+  setPreviousAccountNameHashPref(acctName) {
+    let string = Cc["@mozilla.org/supports-string;1"]
+                 .createInstance(Ci.nsISupportsString);
+    string.data = this.sha256(acctName);
+    Services.prefs.setComplexValue(PREF_LAST_FXA_USER, Ci.nsISupportsString, string);
+  },
+
+  /**
+   * Given a string, returns the SHA265 hash in base64
+   */
+  sha256(str) {
+    let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+                      .createInstance(Ci.nsIScriptableUnicodeConverter);
+    converter.charset = "UTF-8";
+    // Data is an array of bytes.
+    let data = converter.convertToByteArray(str, {});
+    let hasher = Cc["@mozilla.org/security/hash;1"]
+                   .createInstance(Ci.nsICryptoHash);
+    hasher.init(hasher.SHA256);
+    hasher.update(data, data.length);
+
+    return hasher.finish(true);
+  },
+
+  /**
+   * If a user signs in using a different account, the data from the
+   * previous account and the new account will be merged. Ask the user
+   * if they want to continue.
+   *
+   * @private
+   */
+  _needRelinkWarning(acctName) {
+    let prevAcctHash = this.getPreviousAccountNameHashPref();
+    return prevAcctHash && prevAcctHash != this.sha256(acctName);
+  },
+
+  /**
+   * Show the user a warning dialog that the data from the previous account
+   * and the new account will be merged.
+   *
+   * @private
+   */
+  _promptForRelink(acctName) {
+    let sb = Services.strings.createBundle("chrome://browser/locale/syncSetup.properties");
+    let continueLabel = sb.GetStringFromName("continue.label");
+    let title = sb.GetStringFromName("relinkVerify.title");
+    let description = sb.formatStringFromName("relinkVerify.description",
+                                              [acctName], 1);
+    let body = sb.GetStringFromName("relinkVerify.heading") +
+               "\n\n" + description;
+    let ps = Services.prompt;
+    let buttonFlags = (ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) +
+                      (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL) +
+                      ps.BUTTON_POS_1_DEFAULT;
+
+    // If running in context of the browser chrome, window does not exist.
+    var targetWindow = typeof window === 'undefined' ? null : window;
+    let pressed = Services.prompt.confirmEx(targetWindow, title, body, buttonFlags,
+                                       continueLabel, null, null, null,
+                                       {});
+    return pressed === 0; // 0 is the "continue" button
+  }
+};
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -12,18 +12,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/xpcs
 
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccountsClient.jsm',
   'FxAccountsCommon.js',
   'FxAccountsOAuthClient.jsm',
   'FxAccountsOAuthGrantClient.jsm',
   'FxAccountsProfile.jsm',
-  'FxAccountsProfileChannel.jsm',
   'FxAccountsProfileClient.jsm',
+  'FxAccountsWebChannel.jsm',
 ]
 
 EXTRA_PP_JS_MODULES += [
   'FxAccounts.jsm',
 ]
 
 # For now, we will only be using the FxA manager in B2G.
 if CONFIG['MOZ_B2G']:
--- a/services/fxaccounts/tests/xpcshell/test_profile.js
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -149,30 +149,16 @@ add_test(function fetchAndCacheProfile_o
 
   return profile._fetchAndCacheProfile()
     .then(result => {
       do_check_eq(result.avatar, "myimg");
       run_next_test();
     });
 });
 
-
-add_test(function profile_channel() {
-  let profile = new FxAccountsProfile(mockAccountData(), PROFILE_CLIENT_OPTIONS);
-
-  let channel = profile._listenForProfileChanges();
-  do_check_true(!!channel);
-
-  let channel2 = profile._listenForProfileChanges();
-
-  do_check_eq(channel, channel2);
-
-  run_next_test();
-});
-
 add_test(function tearDown_ok() {
   let profile = new FxAccountsProfile(mockAccountData(), PROFILE_CLIENT_OPTIONS);
 
   do_check_true(!!profile.client);
   do_check_true(!!profile.currentAccountState);
 
   profile.tearDown();
   do_check_null(profile.currentAccountState);
@@ -180,68 +166,56 @@ add_test(function tearDown_ok() {
 
   run_next_test();
 });
 
 add_test(function getProfile_ok() {
   let cachedUrl = "myurl";
   let accountData = mockAccountData();
   let didFetch = false;
-  let didListen = false;
 
   let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
   profile._getCachedProfile = function () {
     return Promise.resolve({ avatar: cachedUrl });
   };
 
   profile._fetchAndCacheProfile = function () {
     didFetch = true;
   };
-  profile._listenForProfileChanges = function () {
-    didListen = true;
-  };
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, cachedUrl);
       do_check_true(didFetch);
-      do_check_true(didListen);
       run_next_test();
     });
 });
 
 add_test(function getProfile_no_cache() {
   let fetchedUrl = "newUrl";
   let accountData = mockAccountData();
-  let didListen = false;
 
   let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
   profile._getCachedProfile = function () {
     return Promise.resolve();
   };
 
   profile._fetchAndCacheProfile = function () {
     return Promise.resolve({ avatar: fetchedUrl });
   };
-  profile._listenForProfileChanges = function () {
-    didListen = true;
-  };
 
   return profile.getProfile()
     .then(result => {
       do_check_eq(result.avatar, fetchedUrl);
-      do_check_true(didListen);
       run_next_test();
     });
 });
 
 add_test(function getProfile_has_cached_fetch_deleted() {
   let cachedUrl = "myurl";
-  let didFetch = false;
-  let didListen = false;
 
   let client = mockClient();
   client.fetchProfile = function () {
     return Promise.resolve({ avatar: null });
   };
 
   let accountData = mockAccountData();
   accountData.getUserAccountData = function () {
rename from services/fxaccounts/tests/xpcshell/test_profile_channel.js
rename to services/fxaccounts/tests/xpcshell/test_web_channel.js
--- a/services/fxaccounts/tests/xpcshell/test_profile_channel.js
+++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js
@@ -1,47 +1,208 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
-Cu.import("resource://gre/modules/FxAccountsProfileChannel.jsm");
+const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } =
+    Cu.import("resource://gre/modules/FxAccountsWebChannel.jsm");
 
 const URL_STRING = "https://example.com";
 
+const mockSendingContext = {
+  browser: {},
+  principal: {},
+  eventTarget: {}
+};
+
 add_test(function () {
   validationHelper(undefined,
   "Error: Missing configuration options");
 
-  validationHelper({},
+  validationHelper({
+    channel_id: WEBCHANNEL_ID
+  },
   "Error: Missing 'content_uri' option");
 
-  validationHelper({ content_uri: 'bad uri' },
+  validationHelper({
+    content_uri: 'bad uri',
+    channel_id: WEBCHANNEL_ID
+  },
   /NS_ERROR_MALFORMED_URI/);
 
+  validationHelper({
+    content_uri: URL_STRING
+  },
+  'Error: Missing \'channel_id\' option');
+
   run_next_test();
 });
 
-add_test(function () {
+add_test(function test_profile_image_change_message() {
   var mockMessage = {
     command: "profile:change",
     data: { uid: "foo" }
   };
 
   makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
     do_check_eq(data, "foo");
     run_next_test();
   });
 
-  var channel = new FxAccountsProfileChannel({
+  var channel = new FxAccountsWebChannel({
+    channel_id: WEBCHANNEL_ID,
+    content_uri: URL_STRING
+  });
+
+  channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_login_message() {
+  let mockMessage = {
+    command: 'fxaccounts:login',
+    data: { email: 'testuser@testuser.com' }
+  };
+
+  let channel = new FxAccountsWebChannel({
+    channel_id: WEBCHANNEL_ID,
+    content_uri: URL_STRING,
+    helpers: {
+      login: function (accountData) {
+        do_check_eq(accountData.email, 'testuser@testuser.com');
+        run_next_test();
+      }
+    }
+  });
+
+  channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_can_link_account_message() {
+  let mockMessage = {
+    command: 'fxaccounts:can_link_account',
+    data: { email: 'testuser@testuser.com' }
+  };
+
+  let channel = new FxAccountsWebChannel({
+    channel_id: WEBCHANNEL_ID,
+    content_uri: URL_STRING,
+    helpers: {
+      shouldAllowRelink: function (email) {
+        do_check_eq(email, 'testuser@testuser.com');
+        run_next_test();
+      }
+    }
+  });
+
+  channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_unrecognized_message() {
+  let mockMessage = {
+    command: 'fxaccounts:unrecognized',
+    data: {}
+  };
+
+  let channel = new FxAccountsWebChannel({
+    channel_id: WEBCHANNEL_ID,
     content_uri: URL_STRING
   });
 
-  channel._channelCallback(PROFILE_WEBCHANNEL_ID, mockMessage);
+  // no error is expected.
+  channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+  run_next_test();
+});
+
+
+add_test(function test_helpers_should_allow_relink_same_email() {
+  let helpers = new FxAccountsWebChannelHelpers();
+
+  helpers.setPreviousAccountNameHashPref('testuser@testuser.com');
+  do_check_true(helpers.shouldAllowRelink('testuser@testuser.com'));
+
+  run_next_test();
+});
+
+add_test(function test_helpers_should_allow_relink_different_email() {
+  let helpers = new FxAccountsWebChannelHelpers();
+
+  helpers.setPreviousAccountNameHashPref('testuser@testuser.com');
+
+  helpers._promptForRelink = (acctName) => {
+    return acctName === 'allowed_to_relink@testuser.com';
+  };
+
+  do_check_true(helpers.shouldAllowRelink('allowed_to_relink@testuser.com'));
+  do_check_false(helpers.shouldAllowRelink('not_allowed_to_relink@testuser.com'));
+
+  run_next_test();
+});
+
+add_test(function test_helpers_login_without_customize_sync() {
+  let helpers = new FxAccountsWebChannelHelpers({
+    fxAccounts: {
+      setSignedInUser: function(accountData) {
+        // ensure fxAccounts is informed of the new user being signed in.
+        do_check_eq(accountData.email, 'testuser@testuser.com');
+
+        // verifiedCanLinkAccount should be stripped in the data.
+        do_check_false('verifiedCanLinkAccount' in accountData);
+
+        // the customizeSync pref should not update
+        do_check_false(helpers.getShowCustomizeSyncPref());
+
+        // previously signed in user preference is updated.
+        do_check_eq(helpers.getPreviousAccountNameHashPref(), helpers.sha256('testuser@testuser.com'));
+
+        run_next_test();
+      }
+    }
+  });
+
+  // the show customize sync pref should stay the same
+  helpers.setShowCustomizeSyncPref(false);
+
+  // ensure the previous account pref is overwritten.
+  helpers.setPreviousAccountNameHashPref('lastuser@testuser.com');
+
+  helpers.login({
+    email: 'testuser@testuser.com',
+    verifiedCanLinkAccount: true,
+    customizeSync: false
+  });
+});
+
+add_test(function test_helpers_login_with_customize_sync() {
+  let helpers = new FxAccountsWebChannelHelpers({
+    fxAccounts: {
+      setSignedInUser: function(accountData) {
+        // ensure fxAccounts is informed of the new user being signed in.
+        do_check_eq(accountData.email, 'testuser@testuser.com');
+
+        // customizeSync should be stripped in the data.
+        do_check_false('customizeSync' in accountData);
+
+        // the customizeSync pref should not update
+        do_check_true(helpers.getShowCustomizeSyncPref());
+
+        run_next_test();
+      }
+    }
+  });
+
+  // the customize sync pref should be overwritten
+  helpers.setShowCustomizeSyncPref(false);
+
+  helpers.login({
+    email: 'testuser@testuser.com',
+    verifiedCanLinkAccount: true,
+    customizeSync: true
+  });
 });
 
 function run_test() {
   run_next_test();
 }
 
 function makeObserver(aObserveTopic, aObserveFunc) {
   let callback = function (aSubject, aTopic, aData) {
@@ -58,17 +219,17 @@ function makeObserver(aObserveTopic, aOb
   }
 
   Services.obs.addObserver(callback, aObserveTopic, false);
   return removeMe;
 }
 
 function validationHelper(params, expected) {
   try {
-    new FxAccountsProfileChannel(params);
+    new FxAccountsWebChannel(params);
   } catch (e) {
     if (typeof expected === 'string') {
       return do_check_eq(e.toString(), expected);
     } else {
       return do_check_true(e.toString().match(expected));
     }
   }
   throw new Error("Validation helper error");
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -13,10 +13,11 @@ skip-if = appname == 'b2g' # login manag
 skip-if = appname != 'b2g'
 reason = FxAccountsManager is only available for B2G for now
 [test_oauth_client.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_profile_channel.js]
+[test_web_channel.js]
+skip-if = appname == 'b2g' # fxa web channels only used on desktop
 [test_profile.js]
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -238,16 +238,17 @@ user_pref('toolkit.telemetry.test.pref2'
 // resolves and accepts requests, even if they all fail.
 user_pref('identity.fxaccounts.auth.uri', 'https://%(server)s/fxa-dummy/');
 
 // Ditto for all the other Firefox accounts URIs used for about:accounts et al.:
 user_pref("identity.fxaccounts.remote.signup.uri", "https://%(server)s/fxa-signup");
 user_pref("identity.fxaccounts.remote.force_auth.uri", "https://%(server)s/fxa-force-auth");
 user_pref("identity.fxaccounts.remote.signin.uri", "https://%(server)s/fxa-signin");
 user_pref("identity.fxaccounts.settings.uri", "https://%(server)s/fxa-settings");
+user_pref('identity.fxaccounts.remote.webchannel.uri', 'https://%(server)s/');
 
 // Enable logging of APZ test data (see bug 961289).
 user_pref('apz.test.logging_enabled', true);
 
 // Make sure SSL Error reports don't hit the network
 user_pref("security.ssl.errorReporting.url", "https://example.com/browser/browser/base/content/test/general/pinning_reports.sjs?succeed");
 
 // Make sure Translation won't hit the network.
--- a/toolkit/components/autocomplete/nsAutoCompleteController.cpp
+++ b/toolkit/components/autocomplete/nsAutoCompleteController.cpp
@@ -465,16 +465,23 @@ nsAutoCompleteController::HandleKeyNavig
 
           if (!mInput) {
             // StopSearch() can call PostSearchCleanup() which might result
             // in a blur event, which could null out mInput, so we need to check it
             // again.  See bug #395344 for more details
             return NS_OK;
           }
 
+          // Some script may have changed the value of the text field since our
+          // last keypress or after our focus handler and we don't want to search
+          // for a stale string.
+          nsAutoString value;
+          input->GetTextValue(value);
+          mSearchString = value;
+
           StartSearches();
         }
       }
     }
   } else if (   aKey == nsIDOMKeyEvent::DOM_VK_LEFT
              || aKey == nsIDOMKeyEvent::DOM_VK_RIGHT
 #ifndef XP_MACOSX
              || aKey == nsIDOMKeyEvent::DOM_VK_HOME
--- a/toolkit/content/tests/chrome/chrome.ini
+++ b/toolkit/content/tests/chrome/chrome.ini
@@ -49,16 +49,17 @@ support-files =
   rtltest/content/dirtest.xul
 
 [test_about_networking.html]
 [test_arrowpanel.xul]
 [test_autocomplete2.xul]
 [test_autocomplete3.xul]
 [test_autocomplete4.xul]
 [test_autocomplete5.xul]
+[test_autocomplete_change_after_focus.html]
 [test_autocomplete_delayOnPaste.xul]
 [test_autocomplete_with_composition_on_input.html]
 [test_autocomplete_with_composition_on_textbox.xul]
 [test_autocomplete_placehold_last_complete.xul]
 [test_browser_drop.xul]
 skip-if = buildapp == 'mulet'
 [test_bug253481.xul]
 [test_bug263683.xul]
new file mode 100644
--- /dev/null
+++ b/toolkit/content/tests/chrome/test_autocomplete_change_after_focus.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=998893
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Test for Bug 998893 - Ensure that input.value changes affect autocomplete</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://global/skin"/>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+  <script type="application/javascript">
+  /** Test for Bug 998893 **/
+  SimpleTest.waitForExplicitFinish();
+  SimpleTest.waitForFocus(setup);
+
+  function setup() {
+    SpecialPowers.formHistory.update([
+      { op : "bump", fieldname: "field1", value: "Default text option" },
+      { op : "bump", fieldname: "field1", value: "New value option" },
+    ], {
+      handleCompletion: function() {
+        runTest();
+      },
+    });
+  }
+
+  function handleEnter(evt) {
+    if (evt.keyCode != KeyEvent.DOM_VK_RETURN) {
+      return;
+    }
+    info("RETURN received for phase: " + evt.eventPhase);
+    is(evt.target.value, "New value option", "Check that the correct autocomplete entry was used");
+    SimpleTest.finish();
+  }
+
+  function popupShownListener(evt) {
+    info("popupshown");
+    sendKey("DOWN");
+    sendKey("RETURN"); // select the first entry in the popup
+    sendKey("RETURN"); // try to submit the form with the filled value
+  }
+
+  SpecialPowers.addAutoCompletePopupEventListener(window, "popupshown", popupShownListener);
+
+  function runTest() {
+    var field = document.getElementById("field1");
+    field.addEventListener("focus", function onFocus() {
+      info("field focused");
+      field.value = "New value";
+      sendKey("DOWN");
+    });
+
+    field.addEventListener("keypress", handleEnter, true);
+
+    field.focus();
+  }
+
+  </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=998893">Mozilla Bug 998893</a>
+<p id="display"><input id="field1" value="Default text"></p>
+<div id="content" style="display: none"></div>
+<pre id="test">
+</pre>
+</body>
+</html>
--- a/toolkit/devtools/gcli/commands/highlight.js
+++ b/toolkit/devtools/gcli/commands/highlight.js
@@ -5,17 +5,17 @@
 "use strict";
 
 const {Cc, Ci, Cu} = require("chrome");
 const l10n = require("gcli/l10n");
 require("devtools/server/actors/inspector");
 const {BoxModelHighlighter} = require("devtools/server/actors/highlighter");
 
 XPCOMUtils.defineLazyGetter(this, "nodesSelected", function() {
-  return Services.strings.createBundle("chrome://browser/locale/devtools/gclicommands.properties");
+  return Services.strings.createBundle("chrome://global/locale/devtools/gclicommands.properties");
 });
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm","resource://gre/modules/PluralForm.jsm");
 const events = require("sdk/event/core");
 
 // How many maximum nodes can be highlighted in parallel
 const MAX_HIGHLIGHTED_ELEMENTS = 100;
 
 // Stores the highlighters instances so they can be destroyed later.
rename from browser/devtools/commandline/commands-index.js
rename to toolkit/devtools/gcli/commands/index.js
--- a/browser/devtools/commandline/commands-index.js
+++ b/toolkit/devtools/gcli/commands/index.js
@@ -49,17 +49,16 @@ exports.baseModules = [
   "gcli/commands/pref",
 ];
 
 /**
  * Some commands belong to a tool (see getToolModules). This is a list of the
  * modules that are *not* owned by a tool.
  */
 exports.devtoolsModules = [
-  "devtools/tilt/tilt-commands",
   "gcli/commands/addon",
   "gcli/commands/appcache",
   "gcli/commands/calllog",
   "gcli/commands/cmd",
   "gcli/commands/cookie",
   "gcli/commands/csscoverage",
   "gcli/commands/folder",
   "gcli/commands/highlight",
@@ -74,27 +73,47 @@ exports.devtoolsModules = [
   "gcli/commands/screenshot",
   "gcli/commands/tools",
 ];
 
 /**
  * Register commands from tools with 'command: [ "some/module" ]' definitions.
  * The map/reduce incantation squashes the array of arrays to a single array.
  */
-const defaultTools = require("definitions").defaultTools;
-exports.devtoolsToolModules = defaultTools.map(def => def.commands || [])
-                                 .reduce((prev, curr) => prev.concat(curr), []);
+try {
+  const defaultTools = require("definitions").defaultTools;
+  exports.devtoolsToolModules = defaultTools.map(def => def.commands || [])
+                                   .reduce((prev, curr) => prev.concat(curr), []);
+} catch(e) {
+  // "definitions" is only accessible from Firefox
+  exports.devtoolsToolModules = [];
+}
+
+/**
+ * Register commands from toolbox buttons with 'command: [ "some/module" ]'
+ * definitions.  The map/reduce incantation squashes the array of arrays to a
+ * single array.
+ */
+try {
+  const { ToolboxButtons } = require("devtools/framework/toolbox");
+  exports.devtoolsButtonModules = ToolboxButtons.map(def => def.commands || [])
+                                     .reduce((prev, curr) => prev.concat(curr), []);
+} catch(e) {
+  // "devtools/framework/toolbox" is only accessible from Firefox
+  exports.devtoolsButtonModules = [];
+}
 
 /**
  * Add modules to a system for use in a content process (but don't call load)
  */
 exports.addAllItemsByModule = function(system) {
   system.addItemsByModule(exports.baseModules, { delayedLoad: true });
   system.addItemsByModule(exports.devtoolsModules, { delayedLoad: true });
   system.addItemsByModule(exports.devtoolsToolModules, { delayedLoad: true });
+  system.addItemsByModule(exports.devtoolsButtonModules, { delayedLoad: true });
 
   const { mozDirLoader } = require("gcli/commands/cmd");
   system.addItemsByModule("mozcmd", { delayedLoad: true, loader: mozDirLoader });
 };
 
 /**
  * This is WeakMap<Target, Links> where Links is an object that looks like
  *   { refs: number, promise: Promise<System>, front: GcliFront }
--- a/toolkit/devtools/gcli/commands/paintflashing.js
+++ b/toolkit/devtools/gcli/commands/paintflashing.js
@@ -2,18 +2,23 @@
  * 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 { Cc, Ci, Cu } = require("chrome");
 const TargetFactory = require("resource://gre/modules/devtools/Loader.jsm").devtools.TargetFactory;
 
-const Telemetry = require("devtools/shared/telemetry");
-const telemetry = new Telemetry();
+let telemetry;
+try {
+  const Telemetry = require("devtools/shared/telemetry");
+  telemetry = new Telemetry();
+} catch(e) {
+  // DevTools Telemetry module only available in Firefox
+}
 
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const eventEmitter = new EventEmitter();
 
 const gcli = require("gcli/index");
 const l10n = require("gcli/l10n");
 
 /**
@@ -30,16 +35,19 @@ function onPaintFlashingChanged(target, 
   eventEmitter.emit("changed", { target: target });
   function fireChange() {
     eventEmitter.emit("changed", { target: target });
   }
 
   target.off("navigate", fireChange);
   target.once("navigate", fireChange);
 
+  if (!telemetry) {
+    return;
+  }
   if (value) {
     telemetry.toolOpened("paintflashing");
   } else {
     telemetry.toolClosed("paintflashing");
   }
 }
 
 /**
--- a/toolkit/devtools/gcli/moz.build
+++ b/toolkit/devtools/gcli/moz.build
@@ -8,16 +8,17 @@ EXTRA_JS_MODULES.devtools.gcli.commands 
     'commands/addon.js',
     'commands/appcache.js',
     'commands/calllog.js',
     'commands/cmd.js',
     'commands/cookie.js',
     'commands/csscoverage.js',
     'commands/folder.js',
     'commands/highlight.js',
+    'commands/index.js',
     'commands/inject.js',
     'commands/jsb.js',
     'commands/listen.js',
     'commands/media.js',
     'commands/pagemod.js',
     'commands/paintflashing.js',
     'commands/restart.js',
     'commands/rulers.js',
--- a/toolkit/devtools/gcli/source/docs/writing-commands.md
+++ b/toolkit/devtools/gcli/source/docs/writing-commands.md
@@ -83,17 +83,17 @@ when you call a function, you pass 'argu
 
 There are several ways that GCLI commands can be localized. The best method
 depends on what context you are writing your command for.
 
 ### Firefox Embedding
 
 GCLI supports Mozilla style localization. To add a command that will only ever
 be used embedded in Firefox, this is the way to go. Your strings should be
-stored in ``browser/locales/en-US/chrome/browser/devtools/gclicommands.properties``,
+stored in ``toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties``,
 And you should access them using ``let l10n = require("gcli/l10n")`` and then
 ``l10n.lookup(...)`` or ``l10n.lookupFormat()``
 
 For examples of existing commands, take a look in
 ``browser/devtools/webconsole/GcliCommands.jsm``, which contains most of the
 current GCLI commands. If you will be adding a number of new commands, then
 consider starting a new JSM.
 
@@ -750,9 +750,8 @@ types this is enough detail. There are a
                 { name: 'Yahoo', url: 'http://www.yahoo.com/' }
               ]
             }
 
 * Delegate type. It is generally best to inherit from Delegate in order to
   provide a customization of this type. See settingValue for an example.
 
 See below for more information.
-
--- a/toolkit/devtools/gcli/source/lib/gcli/l10n.js
+++ b/toolkit/devtools/gcli/source/lib/gcli/l10n.js
@@ -21,17 +21,17 @@ var Ci = require('chrome').Ci;
 var Cu = require('chrome').Cu;
 
 var prefSvc = Cc['@mozilla.org/preferences-service;1']
                         .getService(Ci.nsIPrefService);
 var prefBranch = prefSvc.getBranch(null).QueryInterface(Ci.nsIPrefBranch2);
 
 var Services = Cu.import('resource://gre/modules/Services.jsm', {}).Services;
 var stringBundle = Services.strings.createBundle(
-        'chrome://browser/locale/devtools/gclicommands.properties');
+        'chrome://global/locale/devtools/gclicommands.properties');
 
 /**
  * Lookup a string in the GCLI string bundle
  */
 exports.lookup = function(name) {
   try {
     return stringBundle.GetStringFromName(name);
   }
--- a/toolkit/devtools/gcli/source/lib/gcli/util/l10n.js
+++ b/toolkit/devtools/gcli/source/lib/gcli/util/l10n.js
@@ -18,17 +18,17 @@
 
 var Cu = require('chrome').Cu;
 
 var XPCOMUtils = Cu.import('resource://gre/modules/XPCOMUtils.jsm', {}).XPCOMUtils;
 var Services = Cu.import('resource://gre/modules/Services.jsm', {}).Services;
 
 var imports = {};
 XPCOMUtils.defineLazyGetter(imports, 'stringBundle', function () {
-  return Services.strings.createBundle('chrome://browser/locale/devtools/gcli.properties');
+  return Services.strings.createBundle('chrome://global/locale/devtools/gcli.properties');
 });
 
 /*
  * Not supported when embedded - we're doing things the Mozilla way not the
  * require.js way.
  */
 exports.registerStringsSource = function(modulePath) {
   throw new Error('registerStringsSource is not available in mozilla');
--- a/toolkit/devtools/server/actors/gcli.js
+++ b/toolkit/devtools/server/actors/gcli.js
@@ -239,17 +239,17 @@ const GcliActor = ActorClass({
     }
 
     const Requisition = require("gcli/cli").Requisition;
     const tabActor = this._tabActor;
 
     this._system = createSystem({ location: "server" });
     this._system.commands.onCommandsChange.add(this._commandsChanged);
 
-    const gcliInit = require("devtools/commandline/commands-index");
+    const gcliInit = require("gcli/commands/index");
     gcliInit.addAllItemsByModule(this._system);
 
     // this._requisitionPromise should be created synchronously with the call
     // to _getRequisition so that destroy can tell whether there is an async
     // init in progress
     this._requisitionPromise = this._system.load().then(() => {
       const environment = {
         get chromeWindow() {
@@ -277,17 +277,17 @@ const GcliActor = ActorClass({
   _commandsChanged: function() {
     events.emit(this, "commands-changed");
   },
 });
 
 exports.GcliActor = GcliActor;
 
 /**
- * 
+ *
  */
 const GcliFront = exports.GcliFront = FrontClass(GcliActor, {
   initialize: function(client, tabForm) {
     Front.prototype.initialize.call(this, client);
     this.actorID = tabForm.gcliActor;
 
     // XXX: This is the first actor type in its hierarchy to use the protocol
     // library, so we're going to self-own on the client side for now.
rename from browser/locales/en-US/chrome/browser/devtools/gcli.properties
rename to toolkit/locales/en-US/chrome/global/devtools/gcli.properties
rename from browser/locales/en-US/chrome/browser/devtools/gclicommands.properties
rename to toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties
--- a/toolkit/locales/jar.mn
+++ b/toolkit/locales/jar.mn
@@ -33,16 +33,18 @@
   locale/@AB_CD@/global/contentAreaCommands.properties  (%chrome/global/contentAreaCommands.properties)
   locale/@AB_CD@/global/customizeToolbar.dtd            (%chrome/global/customizeToolbar.dtd)
   locale/@AB_CD@/global/customizeToolbar.properties     (%chrome/global/customizeToolbar.properties)
   locale/@AB_CD@/global/datetimepicker.dtd              (%chrome/global/datetimepicker.dtd)
   locale/@AB_CD@/global/dateFormat.properties           (%chrome/global/dateFormat.properties)
   locale/@AB_CD@/global/devtools/csscoverage.properties (%chrome/global/devtools/csscoverage.properties)
   locale/@AB_CD@/global/devtools/csscoverage.dtd        (%chrome/global/devtools/csscoverage.dtd)
   locale/@AB_CD@/global/devtools/debugger.properties    (%chrome/global/devtools/debugger.properties)
+  locale/@AB_CD@/global/devtools/gcli.properties        (%chrome/global/devtools/gcli.properties)
+  locale/@AB_CD@/global/devtools/gclicommands.properties (%chrome/global/devtools/gclicommands.properties)
   locale/@AB_CD@/global/devtools/styleinspector.properties (%chrome/global/devtools/styleinspector.properties)
   locale/@AB_CD@/global/dialogOverlay.dtd               (%chrome/global/dialogOverlay.dtd)
   locale/@AB_CD@/global/editMenuOverlay.dtd             (%chrome/global/editMenuOverlay.dtd)
   locale/@AB_CD@/global/fallbackMenubar.properties      (%chrome/global/fallbackMenubar.properties)
   locale/@AB_CD@/global/filefield.properties            (%chrome/global/filefield.properties)
   locale/@AB_CD@/global/filepicker.dtd                  (%chrome/global/filepicker.dtd)
   locale/@AB_CD@/global/filepicker.properties           (%chrome/global/filepicker.properties)
   locale/@AB_CD@/global/findbar.dtd                     (%chrome/global/findbar.dtd)