merge fx-team to mozilla-central a=merge
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Fri, 27 Jun 2014 15:47:39 +0200
changeset 191174 1d7abed6da1bfa33b4d9cebf555c066b9107f09c
parent 191160 cc7244eea5d5a28583571a078002e3cfb9aaf077 (current diff)
parent 191173 fe37b883556e6df22f45c178d518b008558390e1 (diff)
child 191175 22ea396750e85dd46feaa836e76806dd563319c5
child 191186 77b96fc2e6593be48c120b519fa47f3046aeadf7
child 191208 5e3e41601d277811f8ca586aad8aa3dd5747681c
child 191249 b86f48fe5da84a5b441e83c10194d6d8b667f335
push id8436
push usercbook@mozilla.com
push dateFri, 27 Jun 2014 13:56:57 +0000
treeherderb2g-inbound@22ea396750e8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone33.0a1
merge fx-team to mozilla-central a=merge
--- a/b2g/chrome/content/settings.js
+++ b/b2g/chrome/content/settings.js
@@ -467,23 +467,16 @@ SettingsListener.observe('debugger.remot
     dump("Error while initializing devtools: " + e + "\n" + e.stack + "\n");
   }
 
 #ifdef MOZ_WIDGET_GONK
   AdbController.setRemoteDebuggerState(value != 'disabled');
 #endif
 });
 
-// If debug access to certified apps is allowed, we need to preserve system
-// sources so that they are visible in the debugger.
-let forbidCertified =
-  Services.prefs.getBoolPref('devtools.debugger.forbid-certified-apps');
-Services.prefs.setBoolPref('javascript.options.discardSystemSource',
-                           forbidCertified);
-
 // =================== Device Storage ====================
 SettingsListener.observe('device.storage.writable.name', 'sdcard', function(value) {
   if (Services.prefs.getPrefType('device.storage.writable.name') != Ci.nsIPrefBranch.PREF_STRING) {
     // We clear the pref because it used to be erroneously written as a bool
     // and we need to clear it before we can change it to have the correct type.
     Services.prefs.clearUserPref('device.storage.writable.name');
   }
   Services.prefs.setCharPref('device.storage.writable.name', value);
--- a/browser/devtools/commandline/test/browser_cmd_csscoverage_oneshot.js
+++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_oneshot.js
@@ -28,23 +28,21 @@ let test = asyncTest(function*() {
   yield helpers.closeToolbar(options);
   yield helpers.closeTab(options);
 });
 
 /**
  * Just check current page
  */
 function* navigate(usage, options) {
-  let running = yield usage._testOnly_isRunning();
-  ok(!running, "csscoverage not is running");
+  ok(!usage.isRunning(), "csscoverage is not running");
 
   yield usage.oneshot();
 
-  running = yield usage._testOnly_isRunning();
-  ok(!running, "csscoverage not is running");
+  ok(!usage.isRunning(), "csscoverage is still not running");
 }
 
 /**
  * Check the expected pages have been visited
  */
 function* checkPages(usage) {
   let expectedVisited = [ PAGE_3 ];
   let actualVisited = yield usage._testOnly_visitedPages();
--- a/browser/devtools/commandline/test/browser_cmd_csscoverage_startstop.js
+++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_startstop.js
@@ -30,32 +30,30 @@ let test = asyncTest(function*() {
 });
 
 /**
  * Visit all the pages in the test
  */
 function* navigate(usage, options) {
   yield usage.start();
 
-  let running = yield usage._testOnly_isRunning();
-  ok(running, "csscoverage is running");
+  ok(usage.isRunning(), "csscoverage is running");
 
   yield helpers.navigate(PAGE_1, options);
 
   // Wait for the test pages to auto-cycle
   let ev = yield helpers.listenOnce(options.browser, "load", true);
   is(ev.target.location.href, PAGE_1, "page 1 loaded");
 
   ev = yield helpers.listenOnce(options.browser, "load", true);
   is(ev.target.location.href, PAGE_3, "page 3 loaded");
 
   yield usage.stop();
 
-  running = yield usage._testOnly_isRunning();
-  ok(!running, "csscoverage not is running");
+  ok(!usage.isRunning(), "csscoverage not is running");
 }
 
 /**
  * Check the expected pages have been visited
  */
 function* checkPages(usage) {
   // 'load' event order. '' is for the initial location
   let expectedVisited = [ '', PAGE_2, PAGE_1, PAGE_3 ];
--- a/browser/devtools/shared/theme-switching.js
+++ b/browser/devtools/shared/theme-switching.js
@@ -1,26 +1,27 @@
 /* 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/. */
 
 (function() {
   const DEVTOOLS_SKIN_URL = "chrome://browser/skin/devtools/";
+  let documentElement = document.documentElement;
 
   function forceStyle() {
-    let computedStyle = window.getComputedStyle(document.documentElement);
+    let computedStyle = window.getComputedStyle(documentElement);
     if (!computedStyle) {
       // Null when documentElement is not ready. This method is anyways not
       // required then as scrollbars would be in their state without flushing.
       return;
     }
     let display = computedStyle.display; // Save display value
-    document.documentElement.style.display = "none";
-    window.getComputedStyle(document.documentElement).display; // Flush
-    document.documentElement.style.display = display; // Restore
+    documentElement.style.display = "none";
+    window.getComputedStyle(documentElement).display; // Flush
+    documentElement.style.display = display; // Restore
   }
 
   function switchTheme(newTheme, oldTheme) {
     if (newTheme === oldTheme) {
       return;
     }
 
     if (oldTheme && newTheme != oldTheme) {
@@ -56,33 +57,36 @@
           window,
           scrollbarsUrl,
           "agent"
         );
       }
       forceStyle();
     }
 
-    document.documentElement.classList.remove("theme-" + oldTheme);
-    document.documentElement.classList.add("theme-" + newTheme);
+    documentElement.classList.remove("theme-" + oldTheme);
+    documentElement.classList.add("theme-" + newTheme);
   }
 
   function handlePrefChange(event, data) {
     if (data.pref == "devtools.theme") {
       switchTheme(data.newValue, data.oldValue);
     }
   }
 
   const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
   Cu.import("resource://gre/modules/Services.jsm");
   Cu.import("resource:///modules/devtools/gDevTools.jsm");
   const {devtools} = Components.utils.import("resource://gre/modules/devtools/Loader.jsm", {});
   const StylesheetUtils = devtools.require("sdk/stylesheet/utils");
 
-  let theme = Services.prefs.getCharPref("devtools.theme");
-  switchTheme(theme);
+  if (documentElement.hasAttribute("force-theme")) {
+    switchTheme(documentElement.getAttribute("force-theme"));
+  } else {
+    switchTheme(Services.prefs.getCharPref("devtools.theme"));
 
-  gDevTools.on("pref-changed", handlePrefChange);
-  window.addEventListener("unload", function() {
-    gDevTools.off("pref-changed", handlePrefChange);
-  });
+    gDevTools.on("pref-changed", handlePrefChange);
+    window.addEventListener("unload", function() {
+      gDevTools.off("pref-changed", handlePrefChange);
+    });
+  }
 })();
--- a/browser/devtools/sourceeditor/editor.js
+++ b/browser/devtools/sourceeditor/editor.js
@@ -152,16 +152,17 @@ function Editor(config) {
     contextMenu:       null,
     matchBrackets:     true,
     extraKeys:         {},
     indentWithTabs:    useTabs,
     styleActiveLine:   true,
     autoCloseBrackets: "()[]{}''\"\"",
     autoCloseEnabled:  useAutoClose,
     theme:             "mozilla",
+    themeSwitching:    true,
     autocomplete:      false
   };
 
   // Additional shortcuts.
   this.config.extraKeys[Editor.keyFor("jumpToLine")] = () => this.jumpToLine();
   this.config.extraKeys[Editor.keyFor("moveLineUp", { noaccel: true })] = () => this.moveLineUp();
   this.config.extraKeys[Editor.keyFor("moveLineDown", { noaccel: true })] = () => this.moveLineDown();
   this.config.extraKeys[Editor.keyFor("toggleComment")] = "toggleComment";
@@ -253,16 +254,19 @@ Editor.prototype = {
 
     let onLoad = () => {
       // Once the iframe is loaded, we can inject CodeMirror
       // and its dependencies into its DOM.
 
       env.removeEventListener("load", onLoad, true);
       let win = env.contentWindow.wrappedJSObject;
 
+      if (!this.config.themeSwitching)
+        win.document.documentElement.setAttribute("force-theme", "light");
+
       CM_SCRIPTS.forEach((url) =>
         Services.scriptloader.loadSubScript(url, win, "utf8"));
 
       // Replace the propertyKeywords, colorKeywords and valueKeywords
       // properties of the CSS MIME type with the values provided by Gecko.
       let cssSpec = win.CodeMirror.resolveMode("text/css");
       cssSpec.propertyKeywords = cssProperties;
       cssSpec.colorKeywords = cssColors;
--- a/browser/devtools/styleinspector/rule-view.js
+++ b/browser/devtools/styleinspector/rule-view.js
@@ -2864,17 +2864,23 @@ function getParentTextPropertyHolder(nod
  * @param {DOMNode} node The node to start from
  * @return {TextProperty}
  */
 function getParentTextProperty(node) {
   let parent = getParentTextPropertyHolder(node);
   if (!parent) {
     return null;
   }
-  return parent.querySelector(".ruleview-propertyvalue").textProperty;
+
+  let propValue = parent.querySelector(".ruleview-propertyvalue");
+  if (!propValue) {
+    return null;
+  }
+
+  return propValue.textProperty;
 }
 
 /**
  * Walker up the DOM from a given node until a parent property holder is found,
  * and return the textContent for the name and value nodes.
  * Stops at the first property found, so if node is inside the computed property
  * list, the computed property will be returned
  * @param {DOMNode} node The node to start from
--- a/browser/devtools/webconsole/test/browser.ini
+++ b/browser/devtools/webconsole/test/browser.ini
@@ -265,16 +265,17 @@ run-if = os == "mac"
 [browser_webconsole_split.js]
 [browser_webconsole_split_escape_key.js]
 [browser_webconsole_view_source.js]
 [browser_webconsole_reflow.js]
 [browser_webconsole_log_file_filter.js]
 [browser_webconsole_expandable_timestamps.js]
 [browser_webconsole_autocomplete_in_debugger_stackframe.js]
 [browser_webconsole_autocomplete_popup_close_on_tab_switch.js]
+[browser_webconsole_autocomplete-properties-with-non-alphanumeric-names.js]
 [browser_console_hide_jsterm_when_devtools_chrome_enabled_false.js]
 [browser_webconsole_output_01.js]
 [browser_webconsole_output_02.js]
 [browser_webconsole_output_03.js]
 [browser_webconsole_output_04.js]
 [browser_webconsole_output_05.js]
 [browser_webconsole_output_dom_elements_01.js]
 [browser_webconsole_output_dom_elements_02.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webconsole/test/browser_webconsole_autocomplete-properties-with-non-alphanumeric-names.js
@@ -0,0 +1,42 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/*
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+// Test that properties starting with underscores or dollars can be
+// autocompleted (bug 967468).
+
+function test() {
+  const TEST_URI = "data:text/html;charset=utf8,test autocompletion with $ or _";
+  Task.spawn(runner).then(finishTest);
+
+  function* runner() {
+    function autocomplete(term) {
+      let deferred = promise.defer();
+
+      jsterm.setInputValue(term);
+      jsterm.complete(jsterm.COMPLETE_HINT_ONLY, deferred.resolve);
+
+      yield deferred.promise;
+
+      ok(popup.itemCount > 0, "There's suggestions for '" + term + "'");
+    }
+
+    yield addTab(TEST_URI);
+    let { jsterm } = yield openConsole(tab);
+    let popup = jsterm.autocompletePopup;
+
+    jsterm.execute("let testObject = {$$aaab: '', $$aaac: ''}");
+
+    // Should work with bug 967468.
+    yield autocomplete("Object.__d");
+    yield autocomplete("testObject.$$a");
+
+    // Here's when things go wrong in bug 967468.
+    yield autocomplete("Object.__de");
+    yield autocomplete("testObject.$$aa");
+  }
+}
--- a/browser/devtools/webconsole/webconsole.js
+++ b/browser/devtools/webconsole/webconsole.js
@@ -4287,18 +4287,18 @@ JSTerm.prototype = {
     // Check if last character is non-alphanumeric
     if (!/[a-zA-Z0-9]$/.test(input) || frameActor != this._lastFrameActorId) {
       this._autocompleteQuery = null;
       this._autocompleteCache = null;
     }
 
     if (this._autocompleteQuery && input.startsWith(this._autocompleteQuery)) {
       let filterBy = input;
-      // Find the last non-alphanumeric if exists.
-      let lastNonAlpha = input.match(/[^a-zA-Z0-9][a-zA-Z0-9]*$/);
+      // Find the last non-alphanumeric other than _ or $ if it exists.
+      let lastNonAlpha = input.match(/[^a-zA-Z0-9_$][a-zA-Z0-9_$]*$/);
       // If input contains non-alphanumerics, use the part after the last one
       // to filter the cache
       if (lastNonAlpha) {
         filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
       }
 
       let newList = cache.sort().filter(function(l) {
         return l.startsWith(filterBy);
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -228,17 +228,25 @@ let UI = {
     nbox.removeAllNotifications(true);
     nbox.appendNotification(text, "webide:errornotification", null,
                             nbox.PRIORITY_WARNING_LOW, buttons);
   },
 
   /********** RUNTIME **********/
 
   updateRuntimeList: function() {
+    let wifiHeaderNode = document.querySelector("#runtime-header-wifi-devices");
+    if (AppManager.isWiFiScanningEnabled) {
+      wifiHeaderNode.removeAttribute("hidden");
+    } else {
+      wifiHeaderNode.setAttribute("hidden", "true");
+    }
+
     let USBListNode = document.querySelector("#runtime-panel-usbruntime");
+    let WiFiListNode = document.querySelector("#runtime-panel-wifi-devices");
     let simulatorListNode = document.querySelector("#runtime-panel-simulators");
     let customListNode = document.querySelector("#runtime-panel-custom");
 
     let noHelperNode = document.querySelector("#runtime-panel-noadbhelper");
     let noUSBNode = document.querySelector("#runtime-panel-nousbdevice");
     let noSimulatorNode = document.querySelector("#runtime-panel-nosimulator");
 
     if (Devices.helperAddonInstalled) {
@@ -256,16 +264,17 @@ let UI = {
     if (AppManager.runtimeList.simulator.length > 0) {
       noSimulatorNode.setAttribute("hidden", "true");
     } else {
       noSimulatorNode.removeAttribute("hidden");
     }
 
     for (let [type, parent] of [
       ["usb", USBListNode],
+      ["wifi", WiFiListNode],
       ["simulator", simulatorListNode],
       ["custom", customListNode],
     ]) {
       while (parent.hasChildNodes()) {
         parent.firstChild.remove();
       }
       for (let runtime of AppManager.runtimeList[type]) {
         let panelItemNode = document.createElement("toolbarbutton");
@@ -770,16 +779,18 @@ let Cmds = {
         };
       }, true);
     }
 
     return deferred.promise;
   },
 
   showRuntimePanel: function() {
+    AppManager.scanForWiFiRuntimes();
+
     let panel = document.querySelector("#runtime-panel");
     let anchor = document.querySelector("#runtime-panel-button > .panel-button-anchor");
 
     let deferred = promise.defer();
     function onPopupShown() {
       panel.removeEventListener("popupshown", onPopupShown);
       deferred.resolve();
     }
--- a/browser/devtools/webide/content/webide.xul
+++ b/browser/devtools/webide/content/webide.xul
@@ -139,16 +139,18 @@
 
     <!-- Runtime panel -->
     <panel id="runtime-panel" type="arrow" position="bottomcenter topright" consumeoutsideclicks="true" animate="false">
       <vbox flex="1">
         <label class="panel-header">&runtimePanel_USBDevices;</label>
         <toolbarbutton class="panel-item-help" label="&runtimePanel_nousbdevice;" id="runtime-panel-nousbdevice" command="cmd_showTroubleShooting"/>
         <toolbarbutton class="panel-item-help" label="&runtimePanel_noadbhelper;" id="runtime-panel-noadbhelper" command="cmd_showAddons"/>
         <vbox id="runtime-panel-usbruntime"></vbox>
+        <label class="panel-header" id="runtime-header-wifi-devices">&runtimePanel_WiFiDevices;</label>
+        <vbox id="runtime-panel-wifi-devices"></vbox>
         <label class="panel-header">&runtimePanel_simulators;</label>
         <toolbarbutton class="panel-item-help" label="&runtimePanel_nosimulator;" id="runtime-panel-nosimulator" command="cmd_showAddons"/>
         <vbox id="runtime-panel-simulators"></vbox>
         <label class="panel-header">&runtimePanel_custom;</label>
         <vbox id="runtime-panel-custom"></vbox>
         <vbox flex="1" id="runtime-actions" hidden="true">
           <toolbarbutton class="panel-item" id="runtime-details" command="cmd_showRuntimeDetails"/>
           <toolbarbutton class="panel-item" id="runtime-permissions" command="cmd_showPermissionsTable"/>
--- a/browser/devtools/webide/locales/en-US/webide.dtd
+++ b/browser/devtools/webide/locales/en-US/webide.dtd
@@ -54,16 +54,17 @@
 <!-- show toolbox -->
 <!ENTITY key_toggleToolbox "VK_F12">
 <!-- toggle sidebar -->
 <!ENTITY key_toggleEditor "B">
 
 <!ENTITY projectPanel_myProjects "My Projects">
 <!ENTITY projectPanel_runtimeApps "Runtime Apps">
 <!ENTITY runtimePanel_USBDevices "USB Devices">
+<!ENTITY runtimePanel_WiFiDevices "WiFi Devices">
 <!ENTITY runtimePanel_simulators "Simulators">
 <!ENTITY runtimePanel_custom "Custom">
 <!ENTITY runtimePanel_nosimulator "Install Simulator">
 <!ENTITY runtimePanel_noadbhelper "Install ADB Helper">
 <!ENTITY runtimePanel_nousbdevice "Can't see your device?">
 
 <!-- Lense -->
 <!ENTITY details_valid_header "valid">
--- a/browser/devtools/webide/modules/app-manager.js
+++ b/browser/devtools/webide/modules/app-manager.js
@@ -15,20 +15,24 @@ const {TextEncoder, OS}  = Cu.import("re
 const {AppProjects} = require("devtools/app-manager/app-projects");
 const WebappsStore = require("devtools/app-manager/webapps-store");
 const {AppValidator} = require("devtools/app-manager/app-validator");
 const {ConnectionManager, Connection} = require("devtools/client/connection-manager");
 const AppActorFront = require("devtools/app-actor-front");
 const {getDeviceFront} = require("devtools/server/actors/device");
 const {setTimeout} = require("sdk/timers");
 const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
-const {USBRuntime, SimulatorRuntime, gLocalRuntime, gRemoteRuntime} = require("devtools/webide/runtimes");
+const {USBRuntime, WiFiRuntime, SimulatorRuntime,
+       gLocalRuntime, gRemoteRuntime} = require("devtools/webide/runtimes");
+const discovery = require("devtools/toolkit/discovery/discovery");
 
 const Strings = Services.strings.createBundle("chrome://webide/content/webide.properties");
 
+const WIFI_SCANNING_PREF = "devtools.remote.wifi.scan";
+
 exports.AppManager = AppManager = {
 
   // FIXME: will break when devtools/app-manager will be removed:
   DEFAULT_PROJECT_ICON: "chrome://browser/skin/devtools/app-manager/default-app-icon.png",
   DEFAULT_PROJECT_NAME: "--",
 
   init: function() {
     let host = Services.prefs.getCharPref("devtools.debugger.remote-host");
@@ -37,39 +41,60 @@ exports.AppManager = AppManager = {
     this.connection = ConnectionManager.createConnection("localhost", port);
     this.onConnectionChanged = this.onConnectionChanged.bind(this);
     this.connection.on(Connection.Events.STATUS_CHANGED, this.onConnectionChanged);
 
     this.onWebAppsStoreready = this.onWebAppsStoreready.bind(this);
     this.webAppsStore = new WebappsStore(this.connection);
     this.webAppsStore.on("store-ready", this.onWebAppsStoreready);
 
-    this.runtimeList = {usb: [], simulator: [], custom: [gRemoteRuntime]};
+    this.runtimeList = {
+      usb: [],
+      wifi: [],
+      simulator: [],
+      custom: [gRemoteRuntime]
+    };
     if (Services.prefs.getBoolPref("devtools.webide.enableLocalRuntime")) {
       this.runtimeList.custom.push(gLocalRuntime);
     }
     this.trackUSBRuntimes();
+    this.trackWiFiRuntimes();
     this.trackSimulatorRuntimes();
+
+    this.observe = this.observe.bind(this);
+    Services.prefs.addObserver(WIFI_SCANNING_PREF, this, false);
   },
 
   uninit: function() {
     this._unlistenToApps();
     this.selectedProject = null;
     this.selectedRuntime = null;
     this.untrackUSBRuntimes();
+    this.untrackWiFiRuntimes();
     this.untrackSimulatorRuntimes();
     this._runningApps.clear();
     this.runtimeList = null;
     this.webAppsStore.off("store-ready", this.onWebAppsStoreready);
     this.webAppsStore.destroy();
     this.webAppsStore = null;
     this.connection.off(Connection.Events.STATUS_CHANGED, this.onConnectionChanged);
     this._listTabsResponse = null;
     this.connection.disconnect();
     this.connection = null;
+    Services.prefs.removeObserver(WIFI_SCANNING_PREF, this);
+  },
+
+  observe: function(subject, topic, data) {
+    if (data !== WIFI_SCANNING_PREF) {
+      return;
+    }
+    // Cycle WiFi tracking to reflect the new value
+    this.untrackWiFiRuntimes();
+    this.trackWiFiRuntimes();
+    this._updateWiFiRuntimes();
   },
 
   update: function(what, details) {
     // Anything we want to forward to the UI
     this.emit("app-manager-update", what, details);
   },
 
   reportError: function(l10nProperty, ...l10nArgs) {
@@ -500,16 +525,50 @@ exports.AppManager = AppManager = {
   _updateUSBRuntimes: function() {
     this.runtimeList.usb = [];
     for (let id of Devices.available()) {
       this.runtimeList.usb.push(new USBRuntime(id));
     }
     this.update("runtimelist");
   },
 
+  get isWiFiScanningEnabled() {
+    return Services.prefs.getBoolPref(WIFI_SCANNING_PREF);
+  },
+  scanForWiFiRuntimes: function() {
+    if (!this.isWiFiScanningEnabled) {
+      return;
+    }
+    discovery.scan();
+  },
+  trackWiFiRuntimes: function() {
+    if (!this.isWiFiScanningEnabled) {
+      return;
+    }
+    this._updateWiFiRuntimes = this._updateWiFiRuntimes.bind(this);
+    discovery.on("devtools-device-added", this._updateWiFiRuntimes);
+    discovery.on("devtools-device-updated", this._updateWiFiRuntimes);
+    discovery.on("devtools-device-removed", this._updateWiFiRuntimes);
+  },
+  untrackWiFiRuntimes: function() {
+    if (!this.isWiFiScanningEnabled) {
+      return;
+    }
+    discovery.off("devtools-device-added", this._updateWiFiRuntimes);
+    discovery.off("devtools-device-updated", this._updateWiFiRuntimes);
+    discovery.off("devtools-device-removed", this._updateWiFiRuntimes);
+  },
+  _updateWiFiRuntimes: function() {
+    this.runtimeList.wifi = [];
+    for (let device of discovery.getRemoteDevicesWithService("devtools")) {
+      this.runtimeList.wifi.push(new WiFiRuntime(device));
+    }
+    this.update("runtimelist");
+  },
+
   trackSimulatorRuntimes: function() {
     this._updateSimulatorRuntimes = this._updateSimulatorRuntimes.bind(this);
     Simulator.on("register", this._updateSimulatorRuntimes);
     Simulator.on("unregister", this._updateSimulatorRuntimes);
     this._updateSimulatorRuntimes();
   },
   untrackSimulatorRuntimes: function() {
     Simulator.off("register", this._updateSimulatorRuntimes);
--- a/browser/devtools/webide/modules/runtimes.js
+++ b/browser/devtools/webide/modules/runtimes.js
@@ -3,53 +3,77 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 const {Cu} = require("chrome");
 const {Devices} = Cu.import("resource://gre/modules/devtools/Devices.jsm");
 const {Services} = Cu.import("resource://gre/modules/Services.jsm");
 const {Simulator} = Cu.import("resource://gre/modules/devtools/Simulator.jsm");
 const {ConnectionManager, Connection} = require("devtools/client/connection-manager");
 const {DebuggerServer} = require("resource://gre/modules/devtools/dbg-server.jsm");
+const discovery = require("devtools/toolkit/discovery/discovery");
 
 const Strings = Services.strings.createBundle("chrome://webide/content/webide.properties");
 
 function USBRuntime(id) {
   this.id = id;
 }
 
 USBRuntime.prototype = {
   connect: function(connection) {
     let device = Devices.getByName(this.id);
     if (!device) {
-      return promise.reject("Can't find device: " + id);
+      return promise.reject("Can't find device: " + this.getName());
     }
     return device.connect().then((port) => {
       connection.host = "localhost";
       connection.port = port;
       connection.connect();
     });
   },
   getID: function() {
     return this.id;
   },
   getName: function() {
     return this.id;
   },
 }
 
+function WiFiRuntime(deviceName) {
+  this.deviceName = deviceName;
+}
+
+WiFiRuntime.prototype = {
+  connect: function(connection) {
+    let service = discovery.getRemoteService("devtools", this.deviceName);
+    if (!service) {
+      return promise.reject("Can't find device: " + this.getName());
+    }
+    connection.host = service.host;
+    connection.port = service.port;
+    connection.connect();
+    return promise.resolve();
+  },
+  getID: function() {
+    return this.deviceName;
+  },
+  getName: function() {
+    return this.deviceName;
+  },
+}
+
 function SimulatorRuntime(version) {
   this.version = version;
 }
 
 SimulatorRuntime.prototype = {
   connect: function(connection) {
     let port = ConnectionManager.getFreeTCPPort();
     let simulator = Simulator.getByVersion(this.version);
     if (!simulator || !simulator.launch) {
-      return promise.reject("Can't find simulator: " + this.version);
+      return promise.reject("Can't find simulator: " + this.getName());
     }
     return simulator.launch({port: port}).then(() => {
       connection.port = port;
       connection.keepConnecting = true;
       connection.connect();
     });
   },
   getID: function() {
@@ -97,11 +121,12 @@ let gRemoteRuntime = {
     return promise.resolve();
   },
   getName: function() {
     return Strings.GetStringFromName("remote_runtime");
   },
 }
 
 exports.USBRuntime = USBRuntime;
+exports.WiFiRuntime = WiFiRuntime;
 exports.SimulatorRuntime = SimulatorRuntime;
 exports.gRemoteRuntime = gRemoteRuntime;
 exports.gLocalRuntime = gLocalRuntime;
--- a/browser/devtools/webide/themes/webide.css
+++ b/browser/devtools/webide/themes/webide.css
@@ -182,24 +182,26 @@ panel > .panel-arrowcontainer > .panel-a
 
 #runtime-panel-custom {
   margin-bottom: 12px;
 }
 
 #runtime-permissions,
 #runtime-screenshot,
 .runtime-panel-item-usb,
+.runtime-panel-item-wifi,
 .runtime-panel-item-custom,
 .runtime-panel-item-simulator {
   list-style-image: url("icons.png");
 }
 
 #runtime-screenshot             { -moz-image-region: rect(200px, 640px, 240px, 600px) }
 #runtime-permissions            { -moz-image-region: rect(100px, 840px, 140px, 800px) }
 .runtime-panel-item-usb         { -moz-image-region: rect(100px, 640px, 140px, 600px) }
+.runtime-panel-item-wifi        { -moz-image-region: rect(100px, 640px, 140px, 600px) }
 .runtime-panel-item-custom      { -moz-image-region: rect(100px, 640px, 140px, 600px) }
 .runtime-panel-item-simulator   { -moz-image-region: rect(100px, 740px, 140px, 700px) }
 
 #runtime-actions {
   border-top: 1px solid rgba(221,221,221,1);
 }
 
 
--- a/mobile/android/base/background/common/GlobalConstants.java.in
+++ b/mobile/android/base/background/common/GlobalConstants.java.in
@@ -22,18 +22,18 @@ public class GlobalConstants {
   public static final boolean MOZ_OFFICIAL_BRANDING = false;
 #endif
 
   public static final long BUILD_TIMESTAMP_SECONDS = @MOZ_BUILD_TIMESTAMP@L;
   public static final long BUILD_TIMESTAMP_MSEC = 1000L * @MOZ_BUILD_TIMESTAMP@L;
 
   public static final String MOZ_APP_DISPLAYNAME = "@MOZ_APP_DISPLAYNAME@";
   public static final String MOZ_APP_VERSION = "@MOZ_APP_VERSION@";
-  public static final String BROWSER_INTENT_PACKAGE = "org.mozilla.gecko";
-  public static final String BROWSER_INTENT_CLASS = BROWSER_INTENT_PACKAGE + ".BrowserApp";
+  public static final String BROWSER_INTENT_PACKAGE = "@ANDROID_PACKAGE_NAME@";
+  public static final String BROWSER_INTENT_CLASS = "org.mozilla.gecko.BrowserApp";
 
   /**
    * Bug 800244: this signing-level permission protects broadcast intents that
    * should be received only by the Firefox versions with the given Android
    * package name.
    */
   public static final String PER_ANDROID_PACKAGE_PERMISSION = "@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE";
 
--- a/mobile/android/components/TabSource.js
+++ b/mobile/android/components/TabSource.js
@@ -35,20 +35,20 @@ TabSource.prototype = {
 
     let prompt = new Prompt({
       title: title,
       window: null
     }).setSingleChoiceItems(tabs.map(function(tab) {
       let label;
       if (tab.browser.contentTitle)
         label = tab.browser.contentTitle;
-      else if (tab.browser.contentURI && tab.browser.contentURI.spec)
+      else if (tab.browser.contentURI)
         label = tab.browser.contentURI.spec;
       else
-        label = tab.originalURI;
+        label = tab.originalURI.spec;
       return { label: label,
                icon: "thumbnail:" + tab.id }
     }));
 
     let result = null;
     prompt.show(function(data) {
       result = data.button;
     });
--- a/modules/libpref/src/init/all.js
+++ b/modules/libpref/src/init/all.js
@@ -633,16 +633,21 @@ pref("devtools.debugger.prompt-connectio
 pref("devtools.debugger.forbid-certified-apps", true);
 
 // DevTools default color unit
 pref("devtools.defaultColorUnit", "hex");
 
 // Used for devtools debugging
 pref("devtools.dump.emit", false);
 
+// Disable device discovery logging
+pref("devtools.discovery.log", false);
+// Disable scanning for DevTools devices via WiFi
+pref("devtools.remote.wifi.scan", false);
+
 // 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/testing/xpcshell/xpcshell_android.ini
+++ b/testing/xpcshell/xpcshell_android.ini
@@ -36,8 +36,9 @@
 [include:intl/uconv/tests/unit/xpcshell.ini]
 [include:uriloader/exthandler/tests/unit/xpcshell.ini]
 [include:storage/test/unit/xpcshell.ini]
 [include:docshell/test/unit/xpcshell.ini]
 [include:js/xpconnect/tests/unit/xpcshell.ini]
 [include:js/jsd/test/xpcshell.ini]
 [include:security/manager/ssl/tests/unit/xpcshell.ini]
 [include:toolkit/devtools/qrcode/tests/unit/xpcshell.ini]
+[include:toolkit/devtools/discovery/tests/unit/xpcshell.ini]
--- a/testing/xpcshell/xpcshell_b2g.ini
+++ b/testing/xpcshell/xpcshell_b2g.ini
@@ -15,8 +15,9 @@
 [include:toolkit/devtools/sourcemap/tests/unit/xpcshell.ini]
 [include:toolkit/mozapps/downloads/tests/unit/xpcshell.ini]
 [include:toolkit/mozapps/update/tests/unit_aus_update/xpcshell.ini]
 [include:toolkit/mozapps/update/tests/unit_base_updater/xpcshell.ini]
 [include:toolkit/mozapps/update/tests/unit_timermanager/xpcshell.ini]
 [include:ipc/testshell/tests/xpcshell.ini]
 [include:b2g/components/test/unit/xpcshell.ini]
 [include:security/manager/ssl/tests/unit/xpcshell.ini]
+[include:toolkit/devtools/discovery/tests/unit/xpcshell.ini]
--- a/toolkit/devtools/apps/app-actor-front.js
+++ b/toolkit/devtools/apps/app-actor-front.js
@@ -1,28 +1,32 @@
 const {Ci, Cc, Cu, Cr} = require("chrome");
 Cu.import("resource://gre/modules/osfile.jsm");
 const {Services} = Cu.import("resource://gre/modules/Services.jsm");
 const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm");
 const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm");
 const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+const EventEmitter = require("devtools/toolkit/event-emitter");
 
 // XXX: bug 912476 make this module a real protocol.js front
 // by converting webapps actor to protocol.js
 
 const PR_USEC_PER_MSEC = 1000;
 const PR_RDWR = 0x04;
 const PR_CREATE_FILE = 0x08;
 const PR_TRUNCATE = 0x20;
 
 const CHUNK_SIZE = 10000;
 
 const appTargets = new Map();
 
+const AppActorFront = exports;
+EventEmitter.decorate(AppActorFront);
+
 function addDirToZip(writer, dir, basePath) {
   let files = dir.directoryEntries;
 
   while (files.hasMoreElements()) {
     let file = files.getNext().QueryInterface(Ci.nsIFile);
 
     if (file.isHidden() ||
         file.isSpecial() ||
@@ -97,25 +101,44 @@ function uploadPackageJSON(client, webap
   let request = {
     to: webappsActor,
     type: "uploadPackage"
   };
   client.request(request, (res) => {
     openFile(res.actor);
   });
 
+  let fileSize;
+  let bytesRead = 0;
+
+  function emitProgress() {
+    emitInstallProgress({
+      bytesSent: bytesRead,
+      totalBytes: fileSize
+    });
+  }
+
   function openFile(actor) {
+    let openedFile;
     OS.File.open(packageFile.path)
-      .then(function (file) {
-        uploadChunk(actor, file);
+      .then(file => {
+        openedFile = file;
+        return openedFile.stat();
+      })
+      .then(fileInfo => {
+        fileSize = fileInfo.size;
+        emitProgress();
+        uploadChunk(actor, openedFile);
       });
   }
   function uploadChunk(actor, file) {
     file.read(CHUNK_SIZE)
         .then(function (bytes) {
+          bytesRead += bytes.length;
+          emitProgress();
           // To work around the fact that JSON.stringify translates the typed
           // array to object, we are encoding the typed array here into a string
           let chunk = String.fromCharCode.apply(null, bytes);
 
           let request = {
             to: actor,
             type: "chunk",
             chunk: chunk
@@ -163,17 +186,21 @@ function uploadPackageBulk(client, webap
     let request = client.startBulkRequest({
       actor: actor,
       type: "stream",
       length: fileSize
     });
 
     request.on("bulk-send-ready", ({copyFrom}) => {
       NetUtil.asyncFetch(packageFile, function(inputStream) {
-        copyFrom(inputStream).then(() => {
+        let copying = copyFrom(inputStream);
+        copying.on("progress", (e, progress) => {
+          emitInstallProgress(progress);
+        });
+        copying.then(() => {
           console.log("Bulk upload done");
           inputStream.close();
           deferred.resolve(actor);
         });
       });
     });
   }
 
@@ -231,16 +258,26 @@ function installPackaged(client, webapps
             () => removeServerTemporaryFile(client, fileActor),
             () => removeServerTemporaryFile(client, fileActor));
         });
   });
   return deferred.promise;
 }
 exports.installPackaged = installPackaged;
 
+/**
+ * Emits numerous events as packaged app installation proceeds.
+ * The progress object contains:
+ *  * bytesSent:  The number of bytes sent so far
+ *  * totalBytes: The total number of bytes to send
+ */
+function emitInstallProgress(progress) {
+  AppActorFront.emit("install-progress", progress);
+}
+
 function installHosted(client, webappsActor, appId, metadata, manifest) {
   let deferred = promise.defer();
   let request = {
     to: webappsActor,
     type: "install",
     appId: appId,
     metadata: metadata,
     manifest: manifest
--- a/toolkit/devtools/apps/tests/unit/test_webappsActor.js
+++ b/toolkit/devtools/apps/tests/unit/test_webappsActor.js
@@ -1,14 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 const {require} = devtools;
-const {installHosted, installPackaged} = require("devtools/app-actor-front");
+const AppActorFront = require("devtools/app-actor-front");
+const {installHosted, installPackaged} = AppActorFront;
+const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
 
 let gAppId = "actor-test";
 const APP_ORIGIN = "app://" + gAppId;
 
 add_test(function testLaunchInexistantApp() {
   let request = {type: "launch", manifestURL: "http://foo.com"};
   webappActorRequest(request, function (aResponse) {
     do_check_eq(aResponse.error, "NO_SUCH_APP");
@@ -173,39 +175,64 @@ add_test(function testUninstall() {
 });
 
 add_test(function testFileUploadInstall() {
   let packageFile = do_get_file("data/app.zip");
 
   // Disable the bulk trait temporarily to test the JSON upload path
   gClient.traits.bulk = false;
 
-  installPackaged(gClient, gActor, packageFile.path, gAppId)
+  let progressDeferred = promise.defer();
+  // Ensure we get at least one progress event at the end
+  AppActorFront.on("install-progress", function onProgress(e, progress) {
+    if (progress.bytesSent == progress.totalBytes) {
+      AppActorFront.off("install-progress", onProgress);
+      progressDeferred.resolve();
+    }
+  });
+
+  let installed =
+    installPackaged(gClient, gActor, packageFile.path, gAppId)
     .then(function ({ appId }) {
       do_check_eq(appId, gAppId);
+    }, function (e) {
+      do_throw("Failed install uploaded packaged app: " + e.error + ": " + e.message);
+    });
 
+  promise.all([progressDeferred.promise, installed])
+    .then(() => {
       // Restore default bulk trait value
       gClient.traits.bulk = true;
-
       run_next_test();
-    }, function (e) {
-      do_throw("Failed install uploaded packaged app: " + e.error + ": " + e.message);
     });
 });
 
 add_test(function testBulkUploadInstall() {
   let packageFile = do_get_file("data/app.zip");
   do_check_true(gClient.traits.bulk);
-  installPackaged(gClient, gActor, packageFile.path, gAppId)
+
+  let progressDeferred = promise.defer();
+  // Ensure we get at least one progress event at the end
+  AppActorFront.on("install-progress", function onProgress(e, progress) {
+    if (progress.bytesSent == progress.totalBytes) {
+      AppActorFront.off("install-progress", onProgress);
+      progressDeferred.resolve();
+    }
+  });
+
+  let installed =
+    installPackaged(gClient, gActor, packageFile.path, gAppId)
     .then(function ({ appId }) {
       do_check_eq(appId, gAppId);
-      run_next_test();
     }, function (e) {
       do_throw("Failed bulk install uploaded packaged app: " + e.error + ": " + e.message);
     });
+
+  promise.all([progressDeferred.promise, installed])
+    .then(run_next_test);
 });
 
 add_test(function testInstallHosted() {
   gAppId = "hosted-app";
   let metadata = {
     origin: "http://foo.com",
     installOrigin: "http://metadata.foo.com",
     manifestURL: "http://foo.com/metadata/manifest.webapp"
@@ -245,9 +272,8 @@ add_test(function testCheckHostedApp() {
   });
 });
 
 function run_test() {
   setup();
 
   run_next_test();
 }
-
--- a/toolkit/devtools/client/dbg-client.jsm
+++ b/toolkit/devtools/client/dbg-client.jsm
@@ -658,16 +658,18 @@ DebuggerClient.prototype = {
    *           * copyTo: A helper function for getting your data out of the
    *                     stream that meets the stream handling requirements
    *                     above, and has the following signature:
    *             @param  output nsIAsyncOutputStream
    *                     The stream to copy to.
    *             @return Promise
    *                     The promise is resolved when copying completes or
    *                     rejected if any (unexpected) errors occur.
+   *                     This object also emits "progress" events for each chunk
+   *                     that is copied.  See stream-utils.js.
    */
   request: function (aRequest, aOnResponse) {
     if (!this.mainRoot) {
       throw Error("Have not yet received a hello packet from the server.");
     }
     if (!aRequest.to) {
       let type = aRequest.type || "";
       throw Error("'" + type + "' request packet has no destination.");
@@ -723,16 +725,18 @@ DebuggerClient.prototype = {
    *           * copyFrom: A helper function for getting your data onto the
    *                       stream that meets the stream handling requirements
    *                       above, and has the following signature:
    *             @param  input nsIAsyncInputStream
    *                     The stream to copy from.
    *             @return Promise
    *                     The promise is resolved when copying completes or
    *                     rejected if any (unexpected) errors occur.
+   *                     This object also emits "progress" events for each chunk
+   *                     that is copied.  See stream-utils.js.
    *         * json-reply: The server replied with a JSON packet, which is
    *           passed as event data.
    *         * bulk-reply: The server replied with bulk data, which you can read
    *           using the event data object containing:
    *           * actor:  Name of actor that received the packet
    *           * type:   Name of actor's method that was called on receipt
    *           * length: Size of the data to be read
    *           * stream: This input stream should only be used directly if you
@@ -748,16 +752,18 @@ DebuggerClient.prototype = {
    *           * copyTo: A helper function for getting your data out of the
    *                     stream that meets the stream handling requirements
    *                     above, and has the following signature:
    *             @param  output nsIAsyncOutputStream
    *                     The stream to copy to.
    *             @return Promise
    *                     The promise is resolved when copying completes or
    *                     rejected if any (unexpected) errors occur.
+   *                     This object also emits "progress" events for each chunk
+   *                     that is copied.  See stream-utils.js.
    */
   startBulkRequest: function(request) {
     if (!this.traits.bulk) {
       throw Error("Server doesn't support bulk transfers");
     }
     if (!this.mainRoot) {
       throw Error("Have not yet received a hello packet from the server.");
     }
@@ -927,16 +933,18 @@ DebuggerClient.prototype = {
    *        * copyTo: A helper function for getting your data out of the stream
    *                  that meets the stream handling requirements above, and has
    *                  the following signature:
    *          @param  output nsIAsyncOutputStream
    *                  The stream to copy to.
    *          @return Promise
    *                  The promise is resolved when copying completes or rejected
    *                  if any (unexpected) errors occur.
+   *                  This object also emits "progress" events for each chunk
+   *                  that is copied.  See stream-utils.js.
    */
   onBulkPacket: function(packet) {
     let { actor, type, length } = packet;
 
     if (!actor) {
       DevToolsUtils.reportException(
         "onBulkPacket",
         new Error("Server did not specify an actor, dropping bulk packet: " +
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/discovery/discovery.js
@@ -0,0 +1,399 @@
+/* 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 implements a UDP mulitcast device discovery protocol that:
+ *   * Is optimized for mobile devices
+ *   * Doesn't require any special schema for service info
+ *
+ * To ensure it works well on mobile devices, there is no heartbeat or other
+ * recurring transmission.
+ *
+ * Devices are typically in one of two groups: scanning for services or
+ * providing services (though they may be in both groups as well).
+ *
+ * Scanning devices listen on UPDATE_PORT for UDP multicast traffic.  When the
+ * scanning device wants to force an update of the services available, it sends
+ * a status packet to SCAN_PORT.
+ *
+ * Service provider devices listen on SCAN_PORT for any packets from scanning
+ * devices.  If one is recevied, the provider device sends a status packet
+ * (listing the services it offers) to UPDATE_PORT.
+ *
+ * Scanning devices purge any previously known devices after REPLY_TIMEOUT ms
+ * from that start of a scan if no reply is received during the most recent
+ * scan.
+ *
+ * When a service is registered, is supplies a regular object with any details
+ * about itself (a port number, for example) in a service-defined format, which
+ * is then available to scanning devices.
+ */
+
+const { Cu, CC, Cc, Ci } = require("chrome");
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const { setTimeout, clearTimeout } = require("sdk/timers");
+
+const UDPSocket = CC("@mozilla.org/network/udp-socket;1",
+                     "nsIUDPSocket",
+                     "init");
+
+// TODO Bug 1027456: May need to reserve these with IANA
+const SCAN_PORT = 50624;
+const UPDATE_PORT = 50625;
+const ADDRESS = "224.0.0.200";
+const REPLY_TIMEOUT = 5000;
+
+const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+
+XPCOMUtils.defineLazyGetter(this, "converter", () => {
+  let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+             createInstance(Ci.nsIScriptableUnicodeConverter);
+  conv.charset = "utf8";
+  return conv;
+});
+
+XPCOMUtils.defineLazyGetter(this, "sysInfo", () => {
+  return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2);
+});
+
+XPCOMUtils.defineLazyGetter(this, "libcutils", function () {
+  Cu.import("resource://gre/modules/systemlibs.js");
+  return libcutils;
+});
+
+let logging = Services.prefs.getBoolPref("devtools.discovery.log");
+function log(msg) {
+  if (logging) {
+    console.log("DISCOVERY: " + msg);
+  }
+}
+
+/**
+ * Each Transport instance owns a single UDPSocket.
+ * @param port integer
+ *        The port to listen on for incoming UDP multicast packets.
+ */
+function Transport(port) {
+  EventEmitter.decorate(this);
+  try {
+    this.socket = new UDPSocket(port, false);
+    this.socket.joinMulticast(ADDRESS);
+    this.socket.asyncListen(this);
+  } catch(e) {
+    log("Failed to start new socket: " + e);
+  }
+}
+
+Transport.prototype = {
+
+  /**
+   * Send a object to some UDP port.
+   * @param object object
+   *        Object which is the message to send
+   * @param port integer
+   *        UDP port to send the message to
+   */
+  send: function(object, port) {
+    if (logging) {
+      log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
+    }
+    let message = JSON.stringify(object);
+    let rawMessage = converter.convertToByteArray(message);
+    try {
+      this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
+    } catch(e) {
+      log("Failed to send message: " + e);
+    }
+  },
+
+  destroy: function() {
+    this.socket.close();
+  },
+
+  // nsIUDPSocketListener
+
+  onPacketReceived: function(socket, message) {
+    let messageData = message.data;
+    let object = JSON.parse(messageData);
+    object.from = message.fromAddr.address;
+    let port = message.fromAddr.port;
+    if (port == this.socket.port) {
+      log("Ignoring looped message");
+      return;
+    }
+    if (logging) {
+      log("Recv on " + this.socket.port + ":\n" +
+          JSON.stringify(object, null, 2));
+    }
+    this.emit("message", object);
+  },
+
+  onStopListening: function() {}
+
+};
+
+function Discovery() {
+  EventEmitter.decorate(this);
+
+  this.localServices = {};
+  this.remoteServices = {};
+  this.device = { name: "unknown" };
+  this.replyTimeout = REPLY_TIMEOUT;
+
+  // Defaulted to Transport, but can be altered by tests
+  this._factories = { Transport: Transport };
+
+  this._transports = {
+    scan: null,
+    update: null
+  };
+  this._expectingReplies = {
+    from: new Set()
+  };
+
+  this._onRemoteScan = this._onRemoteScan.bind(this);
+  this._onRemoteUpdate = this._onRemoteUpdate.bind(this);
+  this._purgeMissingDevices = this._purgeMissingDevices.bind(this);
+
+  this._getSystemInfo();
+}
+
+Discovery.prototype = {
+
+  /**
+   * Add a new service offered by this device.
+   * @param service string
+   *        Name of the service
+   * @param info object
+   *        Arbitrary data about the service to announce to scanning devices
+   */
+  addService: function(service, info) {
+    log("ADDING LOCAL SERVICE");
+    if (Object.keys(this.localServices).length === 0) {
+      this._startListeningForScan();
+    }
+    this.localServices[service] = info;
+  },
+
+  /**
+   * Remove a service offered by this device.
+   * @param service string
+   *        Name of the service
+   */
+  removeService: function(service) {
+    delete this.localServices[service];
+    if (Object.keys(this.localServices).length === 0) {
+      this._stopListeningForScan();
+    }
+  },
+
+  /**
+   * Scan for service updates from other devices.
+   */
+  scan: function() {
+    this._startListeningForUpdate();
+    this._waitForReplies();
+    // TODO Bug 1027457: Use timer to debounce
+    this._sendStatusTo(SCAN_PORT);
+  },
+
+  /**
+   * Get a list of all remote devices currently offering some service.:w
+   */
+  getRemoteDevices: function() {
+    let devices = new Set();
+    for (let service in this.remoteServices) {
+      for (let device in this.remoteServices[service]) {
+        devices.add(device);
+      }
+    }
+    return [...devices];
+  },
+
+  /**
+   * Get a list of all remote devices currently offering a particular service.
+   */
+  getRemoteDevicesWithService: function(service) {
+    let devicesWithService = this.remoteServices[service] || {};
+    return Object.keys(devicesWithService);
+  },
+
+  /**
+   * Get service info (any details registered by the remote device) for a given
+   * service on a device.
+   */
+  getRemoteService: function(service, device) {
+    let devicesWithService = this.remoteServices[service] || {};
+    return devicesWithService[device];
+  },
+
+  _waitForReplies: function() {
+    clearTimeout(this._expectingReplies.timer);
+    this._expectingReplies.from = new Set(this.getRemoteDevices());
+    this._expectingReplies.timer =
+      setTimeout(this._purgeMissingDevices, this.replyTimeout);
+  },
+
+  /**
+   * Determine a unique name to identify the current device.
+   */
+  _getSystemInfo: function() {
+    // TODO Bug 1027787: Uniquify device name somehow?
+    try {
+      if (Services.appinfo.widgetToolkit == "gonk") {
+        this.device.name = libcutils.property_get("ro.product.device");
+      } else {
+        this.device.name = sysInfo.get("host");
+      }
+      log("Device: " + this.device.name);
+    } catch(e) {
+      log("Failed to get system info");
+      this.device.name = "unknown";
+    }
+  },
+
+  get Transport() {
+    return this._factories.Transport;
+  },
+
+  _startListeningForScan: function() {
+    if (this._transports.scan) {
+      return; // Already listening
+    }
+    log("LISTEN FOR SCAN");
+    this._transports.scan = new this.Transport(SCAN_PORT);
+    this._transports.scan.on("message", this._onRemoteScan);
+  },
+
+  _stopListeningForScan: function() {
+    if (!this._transports.scan) {
+      return; // Not listening
+    }
+    this._transports.scan.off("message", this._onRemoteScan);
+    this._transports.scan.destroy();
+    this._transports.scan = null;
+  },
+
+  _startListeningForUpdate: function() {
+    if (this._transports.update) {
+      return; // Already listening
+    }
+    log("LISTEN FOR UPDATE");
+    this._transports.update = new this.Transport(UPDATE_PORT);
+    this._transports.update.on("message", this._onRemoteUpdate);
+  },
+
+  _stopListeningForUpdate: function() {
+    if (!this._transports.update) {
+      return; // Not listening
+    }
+    this._transports.update.off("message", this._onRemoteUpdate);
+    this._transports.update.destroy();
+    this._transports.update = null;
+  },
+
+  /**
+   * When sending message, we can use either transport, so just pick the first
+   * one currently alive.
+   */
+  get _outgoingTransport() {
+    if (this._transports.scan) {
+      return this._transports.scan;
+    }
+    if (this._transports.update) {
+      return this._transports.update;
+    }
+    return null;
+  },
+
+  _sendStatusTo: function(port) {
+    let status = {
+      device: this.device.name,
+      services: this.localServices
+    };
+    this._outgoingTransport.send(status, port);
+  },
+
+  _onRemoteScan: function() {
+    // Send my own status in response
+    log("GOT SCAN REQUEST");
+    this._sendStatusTo(UPDATE_PORT);
+  },
+
+  _onRemoteUpdate: function(e, update) {
+    log("GOT REMOTE UPDATE");
+
+    let remoteDevice = update.device;
+    let remoteHost = update.from;
+
+    // First, loop over the known services
+    for (let service in this.remoteServices) {
+      let devicesWithService = this.remoteServices[service];
+      let hadServiceForDevice = !!devicesWithService[remoteDevice];
+      let haveServiceForDevice = service in update.services;
+      // If the remote device used to have service, but doesn't any longer, then
+      // it was deleted, so we remove it here.
+      if (hadServiceForDevice && !haveServiceForDevice) {
+        delete devicesWithService[remoteDevice];
+        log("REMOVED " + service + ", DEVICE " + remoteDevice);
+        this.emit(service + "-device-removed", remoteDevice);
+      }
+    }
+
+    // Second, loop over the services in the received update
+    for (let service in update.services) {
+      // Detect if this is a new device for this service
+      let newDevice = !this.remoteServices[service] ||
+                      !this.remoteServices[service][remoteDevice];
+
+      // Look up the service info we may have received previously from the same
+      // remote device
+      let devicesWithService = this.remoteServices[service] || {};
+      let oldDeviceInfo = devicesWithService[remoteDevice];
+
+      // Store the service info from the remote device
+      let newDeviceInfo = Cu.cloneInto(update.services[service], {});
+      newDeviceInfo.host = remoteHost;
+      devicesWithService[remoteDevice] = newDeviceInfo;
+      this.remoteServices[service] = devicesWithService;
+
+      // If this is a new service for the remote device, announce the addition
+      if (newDevice) {
+        log("ADDED " + service + ", DEVICE " + remoteDevice);
+        this.emit(service + "-device-added", remoteDevice, newDeviceInfo);
+      }
+
+      // If we've seen this service from the remote device, but the details have
+      // changed, announce the update
+      if (!newDevice &&
+          JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo)) {
+        log("UPDATED " + service + ", DEVICE " + remoteDevice);
+        this.emit(service + "-device-updated", remoteDevice, newDeviceInfo);
+      }
+    }
+  },
+
+  _purgeMissingDevices: function() {
+    log("PURGING MISSING DEVICES");
+    for (let service in this.remoteServices) {
+      let devicesWithService = this.remoteServices[service];
+      for (let remoteDevice in devicesWithService) {
+        // If we're still expecting a reply from a remote device when it's time
+        // to purge, then the service is removed.
+        if (this._expectingReplies.from.has(remoteDevice)) {
+          delete devicesWithService[remoteDevice];
+          log("REMOVED " + service + ", DEVICE " + remoteDevice);
+          this.emit(service + "-device-removed", remoteDevice);
+        }
+      }
+    }
+  }
+
+};
+
+let discovery = new Discovery();
+
+module.exports = discovery;
copy from toolkit/devtools/moz.build
copy to toolkit/devtools/discovery/moz.build
--- a/toolkit/devtools/moz.build
+++ b/toolkit/devtools/discovery/moz.build
@@ -1,23 +1,13 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
-PARALLEL_DIRS += [
-    'server',
-    'client',
-    'gcli',
-    'sourcemap',
-    'webconsole',
-    'apps',
-    'styleinspector',
-    'acorn',
-    'pretty-fast',
-    'qrcode',
-    'transport',
-    'tern',
+TEST_DIRS += ['tests']
+
+JS_MODULES_PATH = 'modules/devtools/discovery'
+
+EXTRA_JS_MODULES += [
+    'discovery.js',
 ]
-
-MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
-XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
copy from toolkit/devtools/moz.build
copy to toolkit/devtools/discovery/tests/moz.build
--- a/toolkit/devtools/moz.build
+++ b/toolkit/devtools/discovery/tests/moz.build
@@ -1,23 +1,7 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
-PARALLEL_DIRS += [
-    'server',
-    'client',
-    'gcli',
-    'sourcemap',
-    'webconsole',
-    'apps',
-    'styleinspector',
-    'acorn',
-    'pretty-fast',
-    'qrcode',
-    'transport',
-    'tern',
-]
-
-MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
-XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
+XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini']
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/discovery/tests/unit/test_discovery.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const Cu = Components.utils;
+
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+Services.prefs.setBoolPref("devtools.discovery.log", true);
+
+do_register_cleanup(() => {
+  Services.prefs.clearUserPref("devtools.discovery.log");
+});
+
+const { devtools } =
+  Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+const { Promise: promise } =
+  Cu.import("resource://gre/modules/Promise.jsm", {});
+const { require } = devtools;
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const discovery = require("devtools/toolkit/discovery/discovery");
+const { setTimeout, clearTimeout } = require("sdk/timers");
+
+function log(msg) {
+  do_print("DISCOVERY: " + msg);
+}
+
+// Global map of actively listening ports to TestTransport instances
+let gTestTransports = {};
+
+/**
+ * Implements the same API as Transport in discovery.js.  Here, no UDP sockets
+ * are used.  Instead, messages are delivered immediately.
+ */
+function TestTransport(port) {
+  EventEmitter.decorate(this);
+  this.port = port;
+  gTestTransports[this.port] = this;
+}
+
+TestTransport.prototype = {
+
+  send: function(object, port) {
+    log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
+    if (!gTestTransports[port]) {
+      log("No listener on port " + port);
+      return;
+    }
+    let message = JSON.stringify(object);
+    gTestTransports[port].onPacketReceived(null, message);
+  },
+
+  destroy: function() {
+    delete gTestTransports[this.port];
+  },
+
+  // nsIUDPSocketListener
+
+  onPacketReceived: function(socket, message) {
+    let object = JSON.parse(message);
+    object.from = "localhost";
+    log("Recv on " + this.port + ":\n" + JSON.stringify(object, null, 2));
+    this.emit("message", object);
+  },
+
+  onStopListening: function(socket, status) {}
+
+};
+
+// Use TestTransport instead of the usual Transport
+discovery._factories.Transport = TestTransport;
+discovery.device.name = "test-device";
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function*() {
+  // At startup, no remote devices are known
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), []);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  discovery.scan();
+
+  // No services added yet, still empty
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), []);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  discovery.addService("devtools", { port: 1234 });
+
+  // Changes not visible until next scan
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), []);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  yield scanForChange("devtools", "added");
+
+  // Now we see the new service
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), ["test-device"]);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), []);
+
+  discovery.addService("penguins", { tux: true });
+  yield scanForChange("penguins", "added");
+
+  deepEqual(discovery.getRemoteDevicesWithService("devtools"), ["test-device"]);
+  deepEqual(discovery.getRemoteDevicesWithService("penguins"), ["test-device"]);
+  deepEqual(discovery.getRemoteDevices(), ["test-device"]);
+
+  deepEqual(discovery.getRemoteService("devtools", "test-device"),
+            { port: 1234, host: "localhost" });
+  deepEqual(discovery.getRemoteService("penguins", "test-device"),
+            { tux: true,  host: "localhost" });
+
+  discovery.removeService("devtools");
+  yield scanForChange("devtools", "removed");
+
+  discovery.addService("penguins", { tux: false });
+  yield scanForChange("penguins", "updated");
+
+  // Split the scanning side from the service side to simulate the machine with
+  // the service becoming unreachable
+  gTestTransports = {};
+
+  discovery.removeService("penguins");
+  yield scanForChange("penguins", "removed");
+});
+
+function scanForChange(service, changeType) {
+  let deferred = promise.defer();
+  let timer = setTimeout(() => {
+    deferred.reject(new Error("Reply never arrived"));
+  }, discovery.replyTimeout + 500);
+  discovery.on(service + "-device-" + changeType, function onChange() {
+    discovery.off(service + "-device-" + changeType, onChange);
+    clearTimeout(timer);
+    deferred.resolve();
+  });
+  discovery.scan();
+  return deferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/discovery/tests/unit/xpcshell.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+head =
+tail =
+
+[test_discovery.js]
--- a/toolkit/devtools/gcli/commands/csscoverage.js
+++ b/toolkit/devtools/gcli/commands/csscoverage.js
@@ -66,30 +66,44 @@ exports.items = [
       yield usage.oneshot();
       yield gDevTools.showToolbox(target, "styleeditor");
     }
   },
   {
     name: "csscoverage toggle",
     hidden: true,
     description: l10n.lookup("csscoverageToggleDesc2"),
+    state: {
+      isChecked: function(target) {
+        return csscoverage.getUsage(target).then(usage => {
+          return usage.isRunning();
+        });
+      },
+      onChange: function(target, handler) {
+        csscoverage.getUsage(target).then(usage => {
+          this.handler = ev => { handler("state-change", ev); };
+          usage.on("state-change", this.handler);
+        });
+      },
+      offChange: function(target, handler) {
+        csscoverage.getUsage(target).then(usage => {
+          usage.off("state-change", this.handler);
+          this.handler = undefined;
+        });
+      },
+    },
     exec: function*(args, context) {
       let target = context.environment.target;
       let usage = yield csscoverage.getUsage(target);
       if (usage == null) {
         throw new Error(l10n.lookup("csscoverageNoRemoteError"));
       }
 
-      let running = yield usage.toggle();
-      if (running) {
-        return l10n.lookup("csscoverageRunningReply");
-      }
-
-      yield usage.stop();
-      yield gDevTools.showToolbox(target, "styleeditor");
+      yield usage.toggle(context.environment.chromeWindow,
+                         context.environment.target);
     }
   },
   {
     name: "csscoverage report",
     hidden: true,
     description: l10n.lookup("csscoverageReportDesc2"),
     exec: function*(args, context) {
       let usage = yield csscoverage.getUsage(context.environment.target);
--- a/toolkit/devtools/moz.build
+++ b/toolkit/devtools/moz.build
@@ -12,12 +12,13 @@ PARALLEL_DIRS += [
     'webconsole',
     'apps',
     'styleinspector',
     'acorn',
     'pretty-fast',
     'qrcode',
     'transport',
     'tern',
+    'discovery'
 ]
 
 MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
 XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
--- a/toolkit/devtools/server/actors/csscoverage.js
+++ b/toolkit/devtools/server/actors/csscoverage.js
@@ -2,17 +2,19 @@
  * 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 Services = require("Services");
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
 
+const events = require("sdk/event/core");
 const protocol = require("devtools/server/protocol");
 const { method, custom, RetVal, Arg } = protocol;
 
 loader.lazyGetter(this, "gDevTools", () => {
   return require("resource:///modules/devtools/gDevTools.jsm").gDevTools;
 });
 loader.lazyGetter(this, "DOMUtils", () => {
   return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils)
@@ -70,24 +72,34 @@ const l10n = exports.l10n = {
  *         preLoadOn: Set([ "http://eg.com/page1.html" ]),
  *         isError: false,
  *       }, ...
  *     });
  */
 let UsageReportActor = protocol.ActorClass({
   typeName: "usageReport",
 
+  events: {
+    "state-change" : {
+      type: "stateChange",
+      stateChange: Arg(0, "json")
+    }
+  },
+
   initialize: function(conn, tabActor) {
     protocol.Actor.prototype.initialize.call(this, conn);
 
     this._tabActor = tabActor;
     this._running = false;
 
     this._onTabLoad = this._onTabLoad.bind(this);
     this._onChange = this._onChange.bind(this);
+
+    this._notifyOn = Ci.nsIWebProgress.NOTIFY_STATUS |
+                     Ci.nsIWebProgress.NOTIFY_STATE_ALL
   },
 
   destroy: function() {
     this._tabActor = undefined;
 
     delete this._onTabLoad;
     delete this._onChange;
 
@@ -102,45 +114,66 @@ let UsageReportActor = protocol.ActorCla
       throw new Error(l10n.lookup("csscoverageRunningError"));
     }
 
     this._visitedPages = new Set();
     this._knownRules = new Map();
     this._running = true;
     this._tooManyUnused = false;
 
-    this._tabActor.browser.addEventListener("load", this._onTabLoad, true);
+    this._progressListener = {
+      QueryInterface: XPCOMUtils.generateQI([ Ci.nsIWebProgressListener,
+                                              Ci.nsISupportsWeakReference ]),
+
+      onStateChange: (progress, request, flags, status) => {
+        let isStop = flags & Ci.nsIWebProgressListener.STATE_STOP;
+        let isWindow = flags & Ci.nsIWebProgressListener.STATE_IS_WINDOW;
 
-    this._observeMutations(this._tabActor.window.document);
+        if (isStop && isWindow) {
+          this._onTabLoad(progress.DOMWindow.document);
+        }
+      },
+
+      onLocationChange: () => {},
+      onProgressChange: () => {},
+      onSecurityChange: () => {},
+      onStatusChange: () => {},
+      destroy: () => {}
+    };
+
+    this._progress = this._tabActor.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+                                            .getInterface(Ci.nsIWebProgress);
+    this._progress.addProgressListener(this._progressListener, this._notifyOn);
 
     this._populateKnownRules(this._tabActor.window.document);
     this._updateUsage(this._tabActor.window.document, false);
+
+    events.emit(this, "state-change", { isRunning: true });
   }),
 
   /**
    * Cease recording usage data
    */
   stop: method(function() {
     if (!this._running) {
       throw new Error(l10n.lookup("csscoverageNotRunningError"));
     }
 
-    this._tabActor.browser.removeEventListener("load", this._onTabLoad, true);
+    this._progress.removeProgressListener(this._progressListener, this._notifyOn);
+    this._progress = undefined;
+
     this._running = false;
+    events.emit(this, "state-change", { isRunning: false });
   }),
 
   /**
    * Start/stop recording usage data depending on what we're currently doing.
    */
   toggle: method(function() {
-    return this._running ?
-        this.stop().then(() => false) :
-        this.start().then(() => true);
-  }, {
-    response: RetVal("boolean"),
+    return this._running ? this.stop() : this.start();
   }),
 
   /**
    * Running start() quickly followed by stop() does a bunch of unnecessary
    * work, so this cuts all that out
    */
   oneshot: method(function() {
     if (this._running) {
@@ -150,20 +183,19 @@ let UsageReportActor = protocol.ActorCla
     this._visitedPages = new Set();
     this._knownRules = new Map();
 
     this._populateKnownRules(this._tabActor.window.document);
     this._updateUsage(this._tabActor.window.document, false);
   }),
 
   /**
-   * Called from the tab "load" event
+   * Called by the ProgressListener to simulate a "load" event
    */
-  _onTabLoad: function(ev) {
-    let document = ev.target;
+  _onTabLoad: function(document) {
     this._populateKnownRules(document);
     this._updateUsage(document, true);
 
     this._observeMutations(document);
   },
 
   /**
    * Setup a MutationObserver on the current document
@@ -431,25 +463,16 @@ let UsageReportActor = protocol.ActorCla
       preload: preload,
       unused: unused
     };
   }, {
     response: RetVal("json")
   }),
 
   /**
-   * For testing only. Is css coverage running.
-   */
-  _testOnly_isRunning: method(function() {
-    return this._running;
-  }, {
-    response: { value: RetVal("boolean") }
-  }),
-
-  /**
    * For testing only. What pages did we visit.
    */
   _testOnly_visitedPages: method(function() {
     return [...this._visitedPages];
   }, {
     response: { value: RetVal("array:string") }
   }),
 });
@@ -693,69 +716,99 @@ const sheetToUrl = exports.sheetToUrl = 
     let index = sheets.indexOf(stylesheet.ownerNode);
     return getURL(document) + ' → <style> index ' + index;
   }
 
   throw new Error("Unknown sheet source");
 }
 
 /**
+ * Running more than one usage report at a time is probably bad for performance
+ * and it isn't particularly useful, and it's confusing from a notification POV
+ * so we only allow one.
+ */
+let isRunning = false;
+let notification;
+let target;
+let chromeWindow;
+
+/**
  * Front for UsageReportActor
  */
 const UsageReportFront = protocol.FrontClass(UsageReportActor, {
   initialize: function(client, form) {
     protocol.Front.prototype.initialize.call(this, client, form);
     this.actorID = form.usageReportActor;
     this.manage(this);
   },
 
-  /**
-   * Server-side start is above. Client-side start adds a notification box
-   */
-  start: custom(function(chromeWindow, target) {
-    if (chromeWindow != null) {
+  _onStateChange: protocol.preEvent("state-change", function(ev) {
+    isRunning = ev.isRunning;
+    ev.target = target;
+
+    if (isRunning) {
       let gnb = chromeWindow.document.getElementById("global-notificationbox");
-      this.notification = gnb.getNotificationWithValue("csscoverage-running");
+      notification = gnb.getNotificationWithValue("csscoverage-running");
 
-      if (this.notification == null) {
-        let notifyStop = ev => {
-          if (ev == "removed") {
+      if (notification == null) {
+        let notifyStop = reason => {
+          if (reason == "removed") {
             this.stop();
-            gDevTools.showToolbox(target, "styleeditor");
           }
         };
 
         let msg = l10n.lookup("csscoverageRunningReply");
-        this.notification = gnb.appendNotification(msg,
-                                                   "csscoverage-running",
-                                                   "", // i.e. no image
-                                                   gnb.PRIORITY_INFO_HIGH,
-                                                   null, // i.e. no buttons
-                                                   notifyStop);
+        notification = gnb.appendNotification(msg, "csscoverage-running",
+                                              "", // i.e. no image
+                                              gnb.PRIORITY_INFO_HIGH,
+                                              null, // i.e. no buttons
+                                              notifyStop);
       }
     }
+    else {
+      if (notification) {
+        notification.remove();
+        notification = undefined;
+      }
+
+      gDevTools.showToolbox(target, "styleeditor");
+      target = undefined;
+    }
+  }),
+
+  /**
+   * Server-side start is above. Client-side start adds a notification box
+   */
+  start: custom(function(newChromeWindow, newTarget) {
+    target = newTarget;
+    chromeWindow = newChromeWindow;
 
     return this._start();
   }, {
     impl: "_start"
   }),
 
   /**
-   * Client-side stop also removes the notification box
+   * Server-side start is above. Client-side start adds a notification box
    */
-  stop: custom(function() {
-    if (this.notification != null) {
-      this.notification.remove();
-      this.notification = undefined;
-    }
+  toggle: custom(function(newChromeWindow, newTarget) {
+    target = newTarget;
+    chromeWindow = newChromeWindow;
 
-    return this._stop();
+    return this._toggle();
   }, {
-    impl: "_stop"
+    impl: "_toggle"
   }),
+
+  /**
+   * We count STARTING and STOPPING as 'running'
+   */
+  isRunning: function() {
+    return isRunning;
+  }
 });
 
 exports.UsageReportFront = UsageReportFront;
 
 /**
  * Registration / De-registration
  */
 exports.register = function(handle) {
--- a/toolkit/devtools/server/actors/script.js
+++ b/toolkit/devtools/server/actors/script.js
@@ -34,17 +34,18 @@ let addonManager = null;
 
 /**
  * This is a wrapper around amIAddonManager.mapURIToAddonID which always returns
  * false on B2G to avoid loading the add-on manager there and reports any
  * exceptions rather than throwing so that the caller doesn't have to worry
  * about them.
  */
 function mapURIToAddonID(uri, id) {
-  if ((Services.appinfo.ID || undefined) == B2G_ID) {
+  if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT ||
+      (Services.appinfo.ID || undefined) == B2G_ID) {
     return false;
   }
 
   if (!addonManager) {
     addonManager = Cc["@mozilla.org/addons/integration;1"].
                    getService(Ci.amIAddonManager);
   }
 
@@ -5053,17 +5054,23 @@ ThreadSources.prototype = {
     // conservative and only use Debugger.Source.prototype.text if we get a
     // normal .js file.
     if (aScript.url) {
       try {
         const url = Services.io.newURI(aScript.url, null, null)
           .QueryInterface(Ci.nsIURL);
         if (url.fileExtension === "js") {
           spec.contentType = "text/javascript";
-          spec.text = aScript.source.text;
+          // If the Debugger API wasn't able to load the source,
+          // because sources were discarded
+          // (javascript.options.discardSystemSource == true),
+          // give source() a chance to fetch them.
+          if (aScript.source.text != "[no source]") {
+            spec.text = aScript.source.text;
+          }
         }
       } catch(ex) {
         // Not a valid URI.
       }
     }
 
     return this.source(spec);
   },
--- a/toolkit/devtools/server/main.js
+++ b/toolkit/devtools/server/main.js
@@ -1194,16 +1194,18 @@ DebuggerServerConnection.prototype = {
    *        * copyTo: A helper function for getting your data out of the stream
    *                  that meets the stream handling requirements above, and has
    *                  the following signature:
    *          @param  output nsIAsyncOutputStream
    *                  The stream to copy to.
    *          @return Promise
    *                  The promise is resolved when copying completes or rejected
    *                  if any (unexpected) errors occur.
+   *                  This object also emits "progress" events for each chunk
+   *                  that is copied.  See stream-utils.js.
    */
   onBulkPacket: function(packet) {
     let { actor: actorKey, type, length } = packet;
 
     let actor = this._getOrCreateActor(actorKey);
     if (!actor) {
       return;
     }
--- a/toolkit/devtools/transport/packets.js
+++ b/toolkit/devtools/transport/packets.js
@@ -23,16 +23,17 @@
  *   * destroy()
  *     Called to clean up at the end of use
  */
 
 const { Cc, Ci, Cu } = require("chrome");
 const DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
 const { dumpn, dumpv } = DevToolsUtils;
 const StreamUtils = require("devtools/toolkit/transport/stream-utils");
+const EventEmitter = require("devtools/toolkit/event-emitter");
 
 DevToolsUtils.defineLazyGetter(this, "unicodeConverter", () => {
   const unicodeConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                            .createInstance(Ci.nsIScriptableUnicodeConverter);
   unicodeConverter.charset = "UTF-8";
   return unicodeConverter;
 });
 
@@ -269,18 +270,19 @@ BulkPacket.prototype.read = function(str
   let deferred = promise.defer();
 
   this._transport._onBulkReadReady({
     actor: this.actor,
     type: this.type,
     length: this.length,
     copyTo: (output) => {
       dumpv("CT length: " + this.length);
-      deferred.resolve(StreamUtils.copyStream(stream, output, this.length));
-      return deferred.promise;
+      let copying = StreamUtils.copyStream(stream, output, this.length);
+      deferred.resolve(copying);
+      return copying;
     },
     stream: stream,
     done: deferred
   });
 
   // Await the result of reading from the stream
   deferred.promise.then(() => {
     dumpv("onReadDone called, ending bulk mode");
@@ -318,18 +320,19 @@ BulkPacket.prototype.write = function(st
   // Temporarily pause the monitoring of the output stream
   this._transport.pauseOutgoing();
 
   let deferred = promise.defer();
 
   this._readyForWriting.resolve({
     copyFrom: (input) => {
       dumpv("CF length: " + this.length);
-      deferred.resolve(StreamUtils.copyStream(input, stream, this.length));
-      return deferred.promise;
+      let copying = StreamUtils.copyStream(input, stream, this.length);
+      deferred.resolve(copying);
+      return copying;
     },
     stream: stream,
     done: deferred
   });
 
   // Await the result of writing to the stream
   deferred.promise.then(() => {
     dumpv("onWriteDone called, ending bulk mode");
--- a/toolkit/devtools/transport/stream-utils.js
+++ b/toolkit/devtools/transport/stream-utils.js
@@ -3,16 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { Ci, Cc, Cu, Cr, CC } = require("chrome");
 const Services = require("Services");
 const DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
 const { dumpv } = DevToolsUtils;
+const EventEmitter = require("devtools/toolkit/event-emitter");
 
 DevToolsUtils.defineLazyGetter(this, "IOUtil", () => {
   return Cc["@mozilla.org/io-util;1"].getService(Ci.nsIIOUtil);
 });
 
 DevToolsUtils.defineLazyGetter(this, "ScriptableInputStream", () => {
   return CC("@mozilla.org/scriptableinputstream;1",
             "nsIScriptableInputStream", "init");
@@ -54,52 +55,62 @@ const BUFFER_SIZE = 0x8000;
  *         (unexpected) errors occur.
  */
 function copyStream(input, output, length) {
   let copier = new StreamCopier(input, output, length);
   return copier.copy();
 }
 
 function StreamCopier(input, output, length) {
+  EventEmitter.decorate(this);
   this._id = StreamCopier._nextId++;
   this.input = input;
   // Save off the base output stream, since we know it's async as we've required
   this.baseAsyncOutput = output;
   if (IOUtil.outputStreamIsBuffered(output)) {
     this.output = output;
   } else {
     this.output = Cc["@mozilla.org/network/buffered-output-stream;1"].
                   createInstance(Ci.nsIBufferedOutputStream);
     this.output.init(output, BUFFER_SIZE);
   }
+  this._length = length;
   this._amountLeft = length;
   this._deferred = promise.defer();
 
   this._copy = this._copy.bind(this);
   this._flush = this._flush.bind(this);
   this._destroy = this._destroy.bind(this);
-  this._deferred.promise.then(this._destroy, this._destroy);
+
+  // Copy promise's then method up to this object.
+  // Allows the copier to offer a promise interface for the simple succeed or
+  // fail scenarios, but also emit events (due to the EventEmitter) for other
+  // states, like progress.
+  this.then = this._deferred.promise.then.bind(this._deferred.promise);
+  this.then(this._destroy, this._destroy);
 
   // Stream ready callback starts as |_copy|, but may switch to |_flush| at end
   // if flushing would block the output stream.
   this._streamReadyCallback = this._copy;
 }
 StreamCopier._nextId = 0;
 
 StreamCopier.prototype = {
 
-  get copied() { return this._deferred.promise; },
-
   copy: function() {
-    try {
-      this._copy();
-    } catch(e) {
-      this._deferred.reject(e);
-    }
-    return this.copied;
+    // Dispatch to the next tick so that it's possible to attach a progress
+    // event listener, even for extremely fast copies (like when testing).
+    Services.tm.currentThread.dispatch(() => {
+      try {
+        this._copy();
+      } catch(e) {
+        this._deferred.reject(e);
+      }
+    }, 0);
+    return this;
   },
 
   _copy: function() {
     let bytesAvailable = this.input.available();
     let amountToCopy = Math.min(bytesAvailable, this._amountLeft);
     this._debug("Trying to copy: " + amountToCopy);
 
     let bytesCopied;
@@ -110,27 +121,35 @@ StreamCopier.prototype = {
       this._debug("Waiting for output stream");
       this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread);
       return;
     }
 
     this._amountLeft -= bytesCopied;
     this._debug("Copied: " + bytesCopied +
                 ", Left: " + this._amountLeft);
+    this._emitProgress();
 
     if (this._amountLeft === 0) {
       this._debug("Copy done!");
       this._flush();
       return;
     }
 
     this._debug("Waiting for input stream");
     this.input.asyncWait(this, 0, 0, Services.tm.currentThread);
   },
 
+  _emitProgress: function() {
+    this.emit("progress", {
+      bytesSent: this._length - this._amountLeft,
+      totalBytes: this._length
+    });
+  },
+
   _flush: function() {
     try {
       this.output.flush();
     } catch(e if e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK ||
                  e.result == Cr.NS_ERROR_FAILURE) {
       this._debug("Flush would block, will retry");
       this._streamReadyCallback = this._flush;
       this._debug("Waiting for output stream");
--- a/toolkit/devtools/transport/transport.js
+++ b/toolkit/devtools/transport/transport.js
@@ -83,16 +83,18 @@ const PACKET_HEADER_MAX = 200;
  *   * copyTo: A helper function for getting your data out of the stream that
  *             meets the stream handling requirements above, and has the
  *             following signature:
  *     @param  output nsIAsyncOutputStream
  *             The stream to copy to.
  *     @return Promise
  *             The promise is resolved when copying completes or rejected if any
  *             (unexpected) errors occur.
+ *             This object also emits "progress" events for each chunk that is
+ *             copied.  See stream-utils.js.
  *
  * - onClosed(reason) - called when the connection is closed. |reason| is
  *   an optional nsresult or object, typically passed when the transport is
  *   closed due to some error in a underlying stream.
  *
  * See ./packets.js and the Remote Debugging Protocol specification for more
  * details on the format of these packets.
  */
@@ -167,16 +169,18 @@ DebuggerTransport.prototype = {
    *           * copyFrom: A helper function for getting your data onto the
    *                       stream that meets the stream handling requirements
    *                       above, and has the following signature:
    *             @param  input nsIAsyncInputStream
    *                     The stream to copy from.
    *             @return Promise
    *                     The promise is resolved when copying completes or
    *                     rejected if any (unexpected) errors occur.
+   *                     This object also emits "progress" events for each chunk
+   *                     that is copied.  See stream-utils.js.
    */
   startBulkSend: function(header) {
     let packet = new BulkPacket(this);
     packet.header = header;
     this._outgoing.push(packet);
     this._flushOutgoing();
     return packet.streamReadyForWriting;
   },
@@ -571,19 +575,20 @@ LocalDebuggerTransport.prototype = {
       // Receiver
       let deferred = promise.defer();
 
       this.other.hooks.onBulkPacket({
         actor: actor,
         type: type,
         length: length,
         copyTo: (output) => {
-          deferred.resolve(
-            StreamUtils.copyStream(pipe.inputStream, output, length));
-          return deferred.promise;
+          let copying =
+            StreamUtils.copyStream(pipe.inputStream, output, length);
+          deferred.resolve(copying);
+          return copying;
         },
         stream: pipe.inputStream,
         done: deferred
       });
 
       // Await the result of reading from the stream
       deferred.promise.then(() => pipe.inputStream.close(), this.close);
     }, "LocalDebuggerTransport instance's this.other.hooks.onBulkPacket"));
@@ -593,19 +598,20 @@ LocalDebuggerTransport.prototype = {
 
     // The remote transport is not capable of resolving immediately here, so we
     // shouldn't be able to either.
     DevToolsUtils.executeSoon(() => {
       let copyDeferred = promise.defer();
 
       sendDeferred.resolve({
         copyFrom: (input) => {
-          copyDeferred.resolve(
-            StreamUtils.copyStream(input, pipe.outputStream, length));
-          return copyDeferred.promise;
+          let copying =
+            StreamUtils.copyStream(input, pipe.outputStream, length);
+          copyDeferred.resolve(copying);
+          return copying;
         },
         stream: pipe.outputStream,
         done: copyDeferred
       });
 
       // Await the result of writing to the stream
       copyDeferred.promise.then(() => pipe.outputStream.close(), this.close);
     });