Bug 1197422 - Part 2: [webext] Implement the pageAction API. r=billm ui-r=bwinton
authorKris Maglione <maglione.k@gmail.com>
Thu, 15 Oct 2015 15:15:04 -0700
changeset 303496 84df75fc0206c25ef794ffdebf7104c23b30d4de
parent 303495 c39ec146b6a056b15ea157b126934b69c2a6cc94
child 303497 958f04b7804fa587506b35b30ec616f986b637ca
push id1001
push userraliiev@mozilla.com
push dateMon, 18 Jan 2016 19:06:03 +0000
treeherdermozilla-release@8b89261f3ac4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbillm, bwinton
bugs1197422
milestone44.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1197422 - Part 2: [webext] Implement the pageAction API. r=billm ui-r=bwinton
browser/components/extensions/ext-browserAction.js
browser/components/extensions/ext-pageAction.js
browser/components/extensions/ext-utils.js
browser/components/extensions/jar.mn
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_browserAction_icon.js
browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js
browser/components/extensions/test/browser/browser_ext_pageAction_context.js
browser/components/extensions/test/browser/browser_ext_pageAction_popup.js
browser/components/nsBrowserGlue.js
browser/themes/linux/browser.css
browser/themes/osx/browser.css
browser/themes/windows/browser.css
testing/mochitest/tests/SimpleTest/SimpleTest.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionUtils.jsm
--- a/browser/components/extensions/ext-browserAction.js
+++ b/browser/components/extensions/ext-browserAction.js
@@ -1,57 +1,48 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
                                   "resource:///modules/CustomizableUI.jsm");
 
 Cu.import("resource://gre/modules/devtools/shared/event-emitter.js");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   EventManager,
   DefaultWeakMap,
   ignoreEvent,
   runSafe,
 } = ExtensionUtils;
 
 // WeakMap[Extension -> BrowserAction]
 var browserActionMap = new WeakMap();
 
-// WeakMap[Extension -> docshell]
-// This map is a cache of the windowless browser that's used to render ImageData
-// for the browser_action icon.
-var imageRendererMap = new WeakMap();
-
 function browserActionOf(extension)
 {
   return browserActionMap.get(extension);
 }
 
-function makeWidgetId(id)
-{
-  id = id.toLowerCase();
-  return id.replace(/[^a-z0-9_-]/g, "_");
-}
-
 var nextActionId = 0;
 
 // Responsible for the browser_action section of the manifest as well
 // as the associated popup.
 function BrowserAction(options, extension)
 {
   this.extension = extension;
   this.id = makeWidgetId(extension.id) + "-browser-action";
   this.widget = null;
 
   this.title = new DefaultWeakMap(extension.localize(options.default_title));
   this.badgeText = new DefaultWeakMap();
   this.badgeBackgroundColor = new DefaultWeakMap();
-  this.icon = new DefaultWeakMap(options.default_icon);
+  this.icon = new DefaultWeakMap(IconDetails.normalize({path: options.default_icon}, extension));
   this.popup = new DefaultWeakMap(options.default_popup);
-
-  this.context = null;
 }
 
 BrowserAction.prototype = {
   build() {
     let widget = CustomizableUI.createWidget({
       id: this.id,
       type: "custom",
       removable: true,
@@ -90,77 +81,17 @@ BrowserAction.prototype = {
       let instance = CustomizableUI.getWidget(this.id).forWindow(window);
       if (instance) {
         this.updateTab(tabbrowser.selectedTab, instance.node);
       }
     }
   },
 
   togglePopup(node, popupResource) {
-    let popupURL = this.extension.baseURI.resolve(popupResource);
-
-    let document = node.ownerDocument;
-    let panel = document.createElement("panel");
-    panel.setAttribute("class", "browser-action-panel");
-    panel.setAttribute("type", "arrow");
-    panel.setAttribute("flip", "slide");
-    node.appendChild(panel);
-
-    panel.addEventListener("popuphidden", () => {
-      this.context.unload();
-      this.context = null;
-      panel.remove();
-    });
-
-    const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
-    let browser = document.createElementNS(XUL_NS, "browser");
-    browser.setAttribute("type", "content");
-    browser.setAttribute("disableglobalhistory", "true");
-    panel.appendChild(browser);
-
-    let loadListener = () => {
-      panel.removeEventListener("load", loadListener);
-
-      this.context = new ExtensionPage(this.extension, {
-        type: "popup",
-        contentWindow: browser.contentWindow,
-        uri: Services.io.newURI(popupURL, null, null),
-        docShell: browser.docShell,
-      });
-      GlobalManager.injectInDocShell(browser.docShell, this.extension, this.context);
-      browser.setAttribute("src", popupURL);
-
-      let contentLoadListener = () => {
-        browser.removeEventListener("load", contentLoadListener);
-
-        let contentViewer = browser.docShell.contentViewer;
-        let width = {}, height = {};
-        try {
-          contentViewer.getContentSize(width, height);
-          [width, height] = [width.value, height.value];
-        } catch (e) {
-          // getContentSize can throw
-          [width, height] = [400, 400];
-        }
-
-        let window = document.defaultView;
-        width /= window.devicePixelRatio;
-        height /= window.devicePixelRatio;
-        width = Math.min(width, 800);
-        height = Math.min(height, 800);
-
-        browser.setAttribute("width", width);
-        browser.setAttribute("height", height);
-
-        let anchor = document.getAnonymousElementByAttribute(node, "class", "toolbarbutton-icon");
-        panel.openPopup(anchor, "bottomcenter topright", 0, 0, false, false);
-      };
-      browser.addEventListener("load", contentLoadListener, true);
-    };
-    panel.addEventListener("load", loadListener);
+    openPanel(node, popupResource, this.extension);
   },
 
   // Initialize the toolbar icon and popup given that |tab| is the
   // current tab and |node| is the CustomizableUI node. Note: |tab|
   // will be null if we don't know the current tab yet (during
   // initialization).
   updateTab(tab, node) {
     let window = node.ownerDocument.defaultView;
@@ -187,46 +118,28 @@ BrowserAction.prototype = {
 
     let badgeNode = node.ownerDocument.getAnonymousElementByAttribute(node,
                                         'class', 'toolbarbutton-badge');
     if (badgeNode) {
       let color = this.badgeBackgroundColor.get(tab);
       if (Array.isArray(color)) {
         color = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
       }
-      badgeNode.style.backgroundColor = color;
+      badgeNode.style.backgroundColor = color || "";
     }
 
     let iconURL = this.getIcon(tab, node);
     node.setAttribute("image", iconURL);
   },
 
   // Note: tab is allowed to be null here.
   getIcon(tab, node) {
     let icon = this.icon.get(tab);
-
-    let url;
-    if (typeof(icon) != "object") {
-      url = icon;
-    } else {
-      let window = node.ownerDocument.defaultView;
-      let utils = window.QueryInterface(Ci.nsIInterfaceRequestor)
-                        .getInterface(Components.interfaces.nsIDOMWindowUtils);
-      let res = {value: 1}
-      utils.getResolution(res);
-
-      let size = res.value == 1 ? 19 : 38;
-      url = icon[size];
-    }
-
-    if (url) {
-      return this.extension.baseURI.resolve(url);
-    } else {
-      return "chrome://browser/content/extension.svg";
-    }
+    return IconDetails.getURL(icon, node.ownerDocument.defaultView,
+                              this.extension);
   },
 
   // Update the toolbar button for a given window.
   updateWindow(window) {
     let tab = window.gBrowser ? window.gBrowser.selectedTab : null;
     let node = CustomizableUI.getWidget(this.id).forWindow(window).node;
     this.updateTab(tab, node);
   },
@@ -283,47 +196,18 @@ extensions.on("manifest_browser_action",
   browserActionMap.set(extension, browserAction);
 });
 
 extensions.on("shutdown", (type, extension) => {
   if (browserActionMap.has(extension)) {
     browserActionMap.get(extension).shutdown();
     browserActionMap.delete(extension);
   }
-  imageRendererMap.delete(extension);
 });
 
-function convertImageDataToPNG(extension, imageData)
-{
-  let webNav = imageRendererMap.get(extension);
-  if (!webNav) {
-    webNav = Services.appShell.createWindowlessBrowser(false);
-    let principal = Services.scriptSecurityManager.createCodebasePrincipal(extension.baseURI,
-                                                                           {addonId: extension.id});
-    let interfaceRequestor = webNav.QueryInterface(Ci.nsIInterfaceRequestor);
-    let docShell = interfaceRequestor.getInterface(Ci.nsIDocShell);
-
-    GlobalManager.injectInDocShell(docShell, extension, null);
-
-    docShell.createAboutBlankContentViewer(principal);
-  }
-
-  let document = webNav.document;
-  let canvas = document.createElement("canvas");
-  canvas.width = imageData.width;
-  canvas.height = imageData.height;
-  canvas.getContext("2d").putImageData(imageData, 0, 0);
-
-  let url = canvas.toDataURL("image/png");
-
-  canvas.remove();
-
-  return url;
-}
-
 extensions.registerAPI((extension, context) => {
   return {
     browserAction: {
       onClicked: new EventManager(context, "browserAction.onClicked", fire => {
         let listener = () => {
           let tab = TabManager.activeTab;
           fire(TabManager.convert(extension, tab));
         };
@@ -341,22 +225,18 @@ extensions.registerAPI((extension, conte
       getTitle: function(details, callback) {
         let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
         let title = browserActionOf(extension).getProperty(tab, "title");
         runSafe(context, callback, title);
       },
 
       setIcon: function(details, callback) {
         let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
-        if (details.imageData) {
-          let url = convertImageDataToPNG(extension, details.imageData);
-          browserActionOf(extension).setProperty(tab, "icon", url);
-        } else {
-          browserActionOf(extension).setProperty(tab, "icon", details.path);
-        }
+        let icon = IconDetails.normalize(details, extension, context);
+        browserActionOf(extension).setProperty(tab, "icon", icon);
       },
 
       setBadgeText: function(details) {
         let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
         browserActionOf(extension).setProperty(tab, "badgeText", details.text);
       },
 
       getBadgeText: function(details, callback) {
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-pageAction.js
@@ -0,0 +1,246 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+var {
+  EventManager,
+  DefaultWeakMap,
+  ignoreEvent,
+  runSafe,
+} = ExtensionUtils;
+
+// WeakMap[Extension -> PageAction]
+var pageActionMap = new WeakMap();
+
+
+// Handles URL bar icons, including the |page_action| manifest entry
+// and associated API.
+function PageAction(options, extension)
+{
+  this.extension = extension;
+  this.id = makeWidgetId(extension.id) + "-page-action";
+
+  let title = extension.localize(options.default_title || "");
+  let popup = extension.localize(options.default_popup || "");
+  if (popup) {
+    popup = extension.baseURI.resolve(popup);
+  }
+
+  this.defaults = {
+    show: false,
+    title: title,
+    icon: IconDetails.normalize({ path: options.default_icon }, extension,
+                                null, true),
+    popup: popup && extension.baseURI.resolve(popup),
+  };
+
+  this.tabContext = new TabContext(tab => Object.create(this.defaults),
+                                   extension);
+
+  this.tabContext.on("location-change", this.handleLocationChange.bind(this));
+
+  // WeakMap[ChromeWindow -> <xul:image>]
+  this.buttons = new WeakMap();
+
+  EventEmitter.decorate(this);
+}
+
+PageAction.prototype = {
+  // Returns the value of the property |prop| for the given tab, where
+  // |prop| is one of "show", "title", "icon", "popup".
+  getProperty(tab, prop) {
+    return this.tabContext.get(tab)[prop];
+  },
+
+  // Sets the value of the property |prop| for the given tab to the
+  // given value, symmetrically to |getProperty|.
+  //
+  // If |tab| is currently selected, updates the page action button to
+  // reflect the new value.
+  setProperty(tab, prop, value) {
+    this.tabContext.get(tab)[prop] = value;
+    if (tab.selected) {
+      this.updateButton(tab.ownerDocument.defaultView);
+    }
+  },
+
+  // Updates the page action button in the given window to reflect the
+  // properties of the currently selected tab:
+  //
+  // Updates "tooltiptext" and "aria-label" to match "title" property.
+  // Updates "image" to match the "icon" property.
+  // Shows or hides the icon, based on the "show" property.
+  updateButton(window) {
+    let tabData = this.tabContext.get(window.gBrowser.selectedTab);
+
+    if (!(tabData.show || this.buttons.has(window))) {
+      // Don't bother creating a button for a window until it actually
+      // needs to be shown.
+      return;
+    }
+
+    let button = this.getButton(window);
+
+    if (tabData.show) {
+      // Update the title and icon only if the button is visible.
+
+      if (tabData.title) {
+        button.setAttribute("tooltiptext", tabData.title);
+        button.setAttribute("aria-label", tabData.title);
+      } else {
+        button.removeAttribute("tooltiptext");
+        button.removeAttribute("aria-label");
+      }
+
+      let icon = IconDetails.getURL(tabData.icon, window, this.extension);
+      button.setAttribute("src", icon);
+    }
+
+    button.hidden = !tabData.show;
+  },
+
+  // Create an |image| node and add it to the |urlbar-icons|
+  // container in the given window.
+  addButton(window) {
+    let document = window.document;
+
+    let button = document.createElement("image");
+    button.id = this.id;
+    button.setAttribute("class", "urlbar-icon");
+
+    button.addEventListener("click", event => {
+      if (event.button == 0) {
+        this.handleClick(window);
+      }
+    });
+
+    document.getElementById("urlbar-icons").appendChild(button);
+
+    return button;
+  },
+
+  // Returns the page action button for the given window, creating it if
+  // it doesn't already exist.
+  getButton(window) {
+    if (!this.buttons.has(window)) {
+      let button = this.addButton(window);
+      this.buttons.set(window, button);
+    }
+
+    return this.buttons.get(window);
+  },
+
+  // Handles a click event on the page action button for the given
+  // window.
+  // If the page action has a |popup| property, a panel is opened to
+  // that URL. Otherwise, a "click" event is emitted, and dispatched to
+  // the any click listeners in the add-on.
+  handleClick(window) {
+    let tab = window.gBrowser.selectedTab;
+    let popup = this.tabContext.get(tab).popup;
+
+    if (popup) {
+      openPanel(this.getButton(window), popup, this.extension);
+    } else {
+      this.emit("click", tab);
+    }
+  },
+
+  handleLocationChange(eventType, tab, fromBrowse) {
+    if (fromBrowse) {
+      this.tabContext.clear(tab);
+    }
+    this.updateButton(tab.ownerDocument.defaultView);
+  },
+
+  shutdown() {
+    this.tabContext.shutdown();
+
+    for (let window of WindowListManager.browserWindows()) {
+      if (this.buttons.has(window)) {
+        this.buttons.get(window).remove();
+      }
+    }
+  },
+};
+
+PageAction.for = extension => {
+  return pageActionMap.get(extension);
+};
+
+
+extensions.on("manifest_page_action", (type, directive, extension, manifest) => {
+  let pageAction = new PageAction(manifest.page_action, extension);
+  pageActionMap.set(extension, pageAction);
+});
+
+extensions.on("shutdown", (type, extension) => {
+  if (pageActionMap.has(extension)) {
+    pageActionMap.get(extension).shutdown();
+    pageActionMap.delete(extension);
+  }
+});
+
+
+extensions.registerAPI((extension, context) => {
+  return {
+    pageAction: {
+      onClicked: new EventManager(context, "pageAction.onClicked", fire => {
+        let listener = (evt, tab) => {
+          fire(TabManager.convert(extension, tab));
+        };
+        let pageAction = PageAction.for(extension);
+
+        pageAction.on("click", listener);
+        return () => {
+          pageAction.off("click", listener);
+        };
+      }).api(),
+
+      show(tabId) {
+        let tab = TabManager.getTab(tabId);
+        PageAction.for(extension).setProperty(tab, "show", true);
+      },
+
+      hide(tabId) {
+        let tab = TabManager.getTab(tabId);
+        PageAction.for(extension).setProperty(tab, "show", false);
+      },
+
+      setTitle(details) {
+        let tab = TabManager.getTab(details.tabId);
+        PageAction.for(extension).setProperty(tab, "title", details.title);
+      },
+
+      getTitle(details, callback) {
+        let tab = TabManager.getTab(details.tabId);
+        let title = PageAction.for(extension).getProperty(tab, "title");
+        runSafe(context, callback, title);
+      },
+
+      setIcon(details, callback) {
+        let tab = TabManager.getTab(details.tabId);
+        let icon = IconDetails.normalize(details, extension, context);
+        PageAction.for(extension).setProperty(tab, "icon", icon);
+      },
+
+      setPopup(details) {
+        let tab = TabManager.getTab(details.tabId);
+        // Note: Chrome resolves arguments to setIcon relative to the calling
+        // context, but resolves arguments to setPopup relative to the extension
+        // root.
+        // For internal consistency, we currently resolve both relative to the
+        // calling context.
+        let url = details.popup && context.uri.resolve(details.popup);
+        PageAction.for(extension).setProperty(tab, "popup", url);
+      },
+
+      getPopup(details, callback) {
+        let tab = TabManager.getTab(details.tabId);
+        let popup = PageAction.for(extension).getProperty(tab, "popup");
+        runSafe(context, callback, popup);
+      },
+    }
+  };
+});
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -1,20 +1,257 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   EventManager,
 } = ExtensionUtils;
 
 // This file provides some useful code for the |tabs| and |windows|
 // modules. All of the code is installed on |global|, which is a scope
 // shared among the different ext-*.js scripts.
 
+
+// Manages icon details for toolbar buttons in the |pageAction| and
+// |browserAction| APIs.
+global.IconDetails = {
+  // Accepted icon sizes.
+  SIZES: ["19", "38"],
+
+  // Normalizes the various acceptable input formats into an object
+  // with two properties, "19" and "38", containing icon URLs.
+  normalize(details, extension, context=null, localize=false) {
+    let result = {};
+
+    if (details.imageData) {
+      let imageData = details.imageData;
+
+      if (imageData instanceof Cu.getGlobalForObject(imageData).ImageData) {
+        imageData = {"19": imageData};
+      }
+
+      for (let size of this.SIZES) {
+        if (size in imageData) {
+          result[size] = this.convertImageDataToPNG(imageData[size], context);
+        }
+      }
+    }
+
+    if (details.path) {
+      let path = details.path;
+      if (typeof path != "object") {
+        path = {"19": path};
+      }
+
+      let baseURI = context ? context.uri : extension.baseURI;
+
+      for (let size of this.SIZES) {
+        if (size in path) {
+          let url = path[size];
+          if (localize) {
+            url = extension.localize(url);
+          }
+
+          url = baseURI.resolve(path[size]);
+
+          // The Chrome documentation specifies these parameters as
+          // relative paths. We currently accept absolute URLs as well,
+          // which means we need to check that the extension is allowed
+          // to load them.
+          try {
+            Services.scriptSecurityManager.checkLoadURIStrWithPrincipal(
+              extension.principal, url,
+              Services.scriptSecurityManager.DISALLOW_SCRIPT);
+          } catch (e if !context) {
+            // If there's no context, it's because we're handling this
+            // as a manifest directive. Log a warning rather than
+            // raising an error, but don't accept the URL in any case.
+            extension.manifestError(`Access to URL '${url}' denied`);
+            continue;
+          }
+
+          result[size] = url;
+        }
+      }
+    }
+
+    return result;
+  },
+
+  // Returns the appropriate icon URL for the given icons object and the
+  // screen resolution of the given window.
+  getURL(icons, window, extension) {
+    const DEFAULT = "chrome://browser/content/extension.svg";
+
+    // Use the higher resolution image if we're doing any up-scaling
+    // for high resolution monitors.
+    let res = window.devicePixelRatio;
+    let size = res > 1 ? "38" : "19";
+
+    return icons[size] || icons["19"] || icons["38"] || DEFAULT;
+  },
+
+  convertImageDataToPNG(imageData, context) {
+    let document = context.contentWindow.document;
+    let canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+    canvas.width = imageData.width;
+    canvas.height = imageData.height;
+    canvas.getContext("2d").putImageData(imageData, 0, 0);
+
+    return canvas.toDataURL("image/png");
+  }
+};
+
+global.makeWidgetId = id => {
+  id = id.toLowerCase();
+  // FIXME: This allows for collisions.
+  return id.replace(/[^a-z0-9_-]/g, "_");
+}
+
+// Open a panel anchored to the given node, containing a browser opened
+// to the given URL, owned by the given extension. If |popupURL| is not
+// an absolute URL, it is resolved relative to the given extension's
+// base URL.
+global.openPanel = (node, popupURL, extension) => {
+  let document = node.ownerDocument;
+
+  let popupURI = Services.io.newURI(popupURL, null, extension.baseURI);
+
+  Services.scriptSecurityManager.checkLoadURIWithPrincipal(
+    extension.principal, popupURI,
+    Services.scriptSecurityManager.DISALLOW_SCRIPT);
+
+  let panel = document.createElement("panel");
+  panel.setAttribute("id", makeWidgetId(extension.id) + "-panel");
+  panel.setAttribute("class", "browser-extension-panel");
+  panel.setAttribute("type", "arrow");
+  panel.setAttribute("flip", "slide");
+
+  let anchor;
+  if (node.localName == "toolbarbutton") {
+    // Toolbar buttons are a special case. The panel becomes a child of
+    // the button, and is anchored to the button's icon.
+    node.appendChild(panel);
+    anchor = document.getAnonymousElementByAttribute(node, "class", "toolbarbutton-icon");
+  } else {
+    // In all other cases, the panel is anchored to the target node
+    // itself, and is a child of a popupset node.
+    document.getElementById("mainPopupSet").appendChild(panel);
+    anchor = node;
+  }
+
+  let context;
+  panel.addEventListener("popuphidden", () => {
+    context.unload();
+    panel.remove();
+  });
+
+  const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+  let browser = document.createElementNS(XUL_NS, "browser");
+  browser.setAttribute("type", "content");
+  browser.setAttribute("disableglobalhistory", "true");
+  panel.appendChild(browser);
+
+  let loadListener = () => {
+    panel.removeEventListener("load", loadListener);
+
+    context = new ExtensionPage(extension, {
+      type: "popup",
+      contentWindow: browser.contentWindow,
+      uri: popupURI,
+      docShell: browser.docShell,
+    });
+    GlobalManager.injectInDocShell(browser.docShell, extension, context);
+    browser.setAttribute("src", context.uri.spec);
+
+    let contentLoadListener = () => {
+      browser.removeEventListener("load", contentLoadListener, true);
+
+      let contentViewer = browser.docShell.contentViewer;
+      let width = {}, height = {};
+      try {
+        contentViewer.getContentSize(width, height);
+        [width, height] = [width.value, height.value];
+      } catch (e) {
+        // getContentSize can throw
+        [width, height] = [400, 400];
+      }
+
+      let window = document.defaultView;
+      width /= window.devicePixelRatio;
+      height /= window.devicePixelRatio;
+      width = Math.min(width, 800);
+      height = Math.min(height, 800);
+
+      browser.setAttribute("width", width);
+      browser.setAttribute("height", height);
+
+      panel.openPopup(anchor, "bottomcenter topright", 0, 0, false, false);
+    };
+    browser.addEventListener("load", contentLoadListener, true);
+  };
+  panel.addEventListener("load", loadListener);
+
+  return panel;
+}
+
+// Manages tab-specific context data, and dispatching tab select events
+// across all windows.
+global.TabContext = function TabContext(getDefaults, extension) {
+  this.extension = extension;
+  this.getDefaults = getDefaults;
+
+  this.tabData = new WeakMap();
+
+  AllWindowEvents.addListener("progress", this);
+  AllWindowEvents.addListener("TabSelect", this);
+
+  EventEmitter.decorate(this);
+}
+
+TabContext.prototype = {
+  get(tab) {
+    if (!this.tabData.has(tab)) {
+      this.tabData.set(tab, this.getDefaults(tab));
+    }
+
+    return this.tabData.get(tab);
+  },
+
+  clear(tab) {
+    this.tabData.delete(tab);
+  },
+
+  handleEvent(event) {
+    if (event.type == "TabSelect") {
+      let tab = event.target;
+      this.emit("tab-select", tab);
+      this.emit("location-change", tab);
+    }
+  },
+
+  onLocationChange(browser, webProgress, request, locationURI, flags) {
+    let gBrowser = browser.ownerDocument.defaultView.gBrowser;
+    if (browser === gBrowser.selectedBrowser) {
+      let tab = gBrowser.getTabForBrowser(browser);
+      this.emit("location-change", tab, true);
+    }
+  },
+
+  shutdown() {
+    AllWindowEvents.removeListener("progress", this);
+    AllWindowEvents.removeListener("TabSelect", this);
+  },
+};
+
 // Manages mapping between XUL tabs and extension tab IDs.
 global.TabManager = {
   _tabs: new WeakMap(),
   _nextId: 1,
 
   getId(tab) {
     if (this._tabs.has(tab)) {
       return this._tabs.get(tab);
@@ -34,19 +271,17 @@ global.TabManager = {
         return this.getId(tab);
       }
     }
     return -1;
   },
 
   getTab(tabId) {
     // FIXME: Speed this up without leaking memory somehow.
-    let e = Services.wm.getEnumerator("navigator:browser");
-    while (e.hasMoreElements()) {
-      let window = e.getNext();
+    for (let window of WindowListManager.browserWindows()) {
       if (!window.gBrowser) {
         continue;
       }
       for (let tab of window.gBrowser.tabs) {
         if (this.getId(tab) == tabId) {
           return tab;
         }
       }
@@ -127,19 +362,17 @@ global.WindowManager = {
       return this._windows.get(window);
     }
     let id = this._nextId++;
     this._windows.set(window, id);
     return id;
   },
 
   getWindow(id) {
-    let e = Services.wm.getEnumerator("navigator:browser");
-    while (e.hasMoreElements()) {
-      let window = e.getNext();
+    for (let window of WindowListManager.browserWindows(true)) {
       if (this.getId(window) == id) {
         return window;
       }
     }
     return null;
   },
 
   convert(extension, window, getInfo) {
@@ -167,25 +400,35 @@ global.WindowManager = {
 
 // Manages listeners for window opening and closing. A window is
 // considered open when the "load" event fires on it. A window is
 // closed when a "domwindowclosed" notification fires for it.
 global.WindowListManager = {
   _openListeners: new Set(),
   _closeListeners: new Set(),
 
+  // Returns an iterator for all browser windows. Unless |includeIncomplete| is
+  // true, only fully-loaded windows are returned.
+  *browserWindows(includeIncomplete = false) {
+    let e = Services.wm.getEnumerator("navigator:browser");
+    while (e.hasMoreElements()) {
+      let window = e.getNext();
+      if (includeIncomplete || window.document.readyState == "complete") {
+        yield window;
+      }
+    }
+  },
+
   addOpenListener(listener, fireOnExisting = true) {
     if (this._openListeners.length == 0 && this._closeListeners.length == 0) {
       Services.ww.registerNotification(this);
     }
     this._openListeners.add(listener);
 
-    let e = Services.wm.getEnumerator("navigator:browser");
-    while (e.hasMoreElements()) {
-      let window = e.getNext();
+    for (let window of this.browserWindows(true)) {
       if (window.document.readyState != "complete") {
         window.addEventListener("load", this);
       } else if (fireOnExisting) {
         listener(window);
       }
     }
   },
 
@@ -258,17 +501,21 @@ global.AllWindowEvents = {
 
     if (!this._listeners.has(type)) {
       this._listeners.set(type, new Set());
     }
     let list = this._listeners.get(type);
     list.add(listener);
 
     if (needOpenListener) {
-      WindowListManager.addOpenListener(this.openListener);
+      WindowListManager.addOpenListener(this.openListener, false);
+    }
+
+    for (let window of WindowListManager.browserWindows()) {
+      this.addWindowListener(window, type, listener);
     }
   },
 
   removeListener(type, listener) {
     if (type == "domwindowopened") {
       return WindowListManager.removeOpenListener(listener);
     } else if (type == "domwindowclosed") {
       return WindowListManager.removeCloseListener(listener);
@@ -278,36 +525,38 @@ global.AllWindowEvents = {
     listeners.delete(listener);
     if (listeners.length == 0) {
       this._listeners.delete(type);
       if (this._listeners.size == 0) {
         WindowListManager.removeOpenListener(this.openListener);
       }
     }
 
-    let e = Services.wm.getEnumerator("navigator:browser");
-    while (e.hasMoreElements()) {
-      let window = e.getNext();
+    for (let window of WindowListManager.browserWindows()) {
       if (type == "progress") {
         window.gBrowser.removeTabsProgressListener(listener);
       } else {
         window.removeEventListener(type, listener);
       }
     }
   },
 
+  addWindowListener(window, eventType, listener) {
+    if (eventType == "progress") {
+      window.gBrowser.addTabsProgressListener(listener);
+    } else {
+      window.addEventListener(eventType, listener);
+    }
+  },
+
   // Runs whenever the "load" event fires for a new window.
   openListener(window) {
     for (let [eventType, listeners] of AllWindowEvents._listeners) {
       for (let listener of listeners) {
-        if (eventType == "progress") {
-          window.gBrowser.addTabsProgressListener(listener);
-        } else {
-          window.addEventListener(eventType, listener);
-        }
+        this.addWindowListener(window, eventType, listener);
       }
     }
   },
 };
 
 // Subclass of EventManager where we just need to call
 // add/removeEventListener on each XUL window.
 global.WindowEventManager = function(context, name, event, listener)
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -2,11 +2,12 @@
 # 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/.
 
 browser.jar:
     content/browser/extension.svg
     content/browser/ext-utils.js
     content/browser/ext-contextMenus.js
     content/browser/ext-browserAction.js
+    content/browser/ext-pageAction.js
     content/browser/ext-tabs.js
     content/browser/ext-windows.js
     content/browser/ext-bookmarks.js
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -2,17 +2,19 @@
 support-files =
   head.js
   context.html
   ctxmenu-image.png
 
 [browser_ext_simple.js]
 [browser_ext_currentWindow.js]
 [browser_ext_browserAction_simple.js]
-[browser_ext_browserAction_icon.js]
+[browser_ext_browserAction_pageAction_icon.js]
+[browser_ext_pageAction_context.js]
+[browser_ext_pageAction_popup.js]
 [browser_ext_contextMenus.js]
 [browser_ext_getViews.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_windows_update.js]
 [browser_ext_contentscript_connect.js]
rename from browser/components/extensions/test/browser/browser_ext_browserAction_icon.js
rename to browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_icon.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js
@@ -1,40 +1,345 @@
-add_task(function* () {
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Test that various combinations of icon details specs, for both paths
+// and ImageData objects, result in the correct image being displayed in
+// all display resolutions.
+add_task(function* testDetailsObjects() {
+  function background() {
+    function getImageData(color) {
+      var canvas = document.createElement("canvas");
+      canvas.width = 2;
+      canvas.height = 2;
+      var canvasContext = canvas.getContext("2d");
+
+      canvasContext.clearRect(0, 0, canvas.width, canvas.height);
+      canvasContext.fillStyle = color;
+      canvasContext.fillRect(0, 0, 1, 1);
+
+      return {
+        url: canvas.toDataURL("image/png"),
+        imageData: canvasContext.getImageData(0, 0, canvas.width, canvas.height),
+      };
+    }
+
+    var imageData = {
+      red: getImageData("red"),
+      green: getImageData("green"),
+    };
+
+    var iconDetails = [
+      // Only paths.
+      { details: { "path": "a.png" },
+        resolutions: {
+          "1": browser.runtime.getURL("data/a.png"),
+          "2": browser.runtime.getURL("data/a.png"), } },
+      { details: { "path": "/a.png" },
+        resolutions: {
+          "1": browser.runtime.getURL("a.png"),
+          "2": browser.runtime.getURL("a.png"), } },
+      { details: { "path": { "19": "a.png" } },
+        resolutions: {
+          "1": browser.runtime.getURL("data/a.png"),
+          "2": browser.runtime.getURL("data/a.png"), } },
+      { details: { "path": { "38": "a.png" } },
+        resolutions: {
+          "1": browser.runtime.getURL("data/a.png"),
+          "2": browser.runtime.getURL("data/a.png"), } },
+      { details: { "path": { "19": "a.png", "38": "a-x2.png" } },
+        resolutions: {
+          "1": browser.runtime.getURL("data/a.png"),
+          "2": browser.runtime.getURL("data/a-x2.png"), } },
+
+      // Only ImageData objects.
+      { details: { "imageData": imageData.red.imageData },
+        resolutions: {
+          "1": imageData.red.url,
+          "2": imageData.red.url, } },
+      { details: { "imageData": { "19": imageData.red.imageData } },
+        resolutions: {
+          "1": imageData.red.url,
+          "2": imageData.red.url, } },
+      { details: { "imageData": { "38": imageData.red.imageData } },
+        resolutions: {
+          "1": imageData.red.url,
+          "2": imageData.red.url, } },
+      { details: { "imageData": {
+          "19": imageData.red.imageData,
+          "38": imageData.green.imageData } },
+        resolutions: {
+          "1": imageData.red.url,
+          "2": imageData.green.url, } },
+
+      // Mixed path and imageData objects.
+      //
+      // The behavior is currently undefined if both |path| and
+      // |imageData| specify icons of the same size.
+      { details: {
+          "path": { "19": "a.png" },
+          "imageData": { "38": imageData.red.imageData } },
+        resolutions: {
+          "1": browser.runtime.getURL("data/a.png"),
+          "2": imageData.red.url, } },
+      { details: {
+          "path": { "38": "a.png" },
+          "imageData": { "19": imageData.red.imageData } },
+        resolutions: {
+          "1": imageData.red.url,
+          "2": browser.runtime.getURL("data/a.png"), } },
+
+      // A path or ImageData object by itself is treated as a 19px icon.
+      { details: {
+          "path": "a.png",
+          "imageData": { "38": imageData.red.imageData } },
+        resolutions: {
+          "1": browser.runtime.getURL("data/a.png"),
+          "2": imageData.red.url, } },
+      { details: {
+          "path": { "38": "a.png" },
+          "imageData": imageData.red.imageData, },
+        resolutions: {
+          "1": imageData.red.url,
+          "2": browser.runtime.getURL("data/a.png"), } },
+    ];
+
+    // Allow serializing ImageData objects for logging.
+    ImageData.prototype.toJSON = () => "<ImageData>";
+
+    var tabId;
+
+    browser.test.onMessage.addListener((msg, test) => {
+      if (msg != "setIcon") {
+        browser.test.fail("expecting 'setIcon' message");
+      }
+
+      var details = iconDetails[test.index];
+      var expectedURL = details.resolutions[test.resolution];
+
+      var detailString = JSON.stringify(details);
+      browser.test.log(`Setting browerAction/pageAction to ${detailString} expecting URL ${expectedURL}`)
+
+      browser.browserAction.setIcon(Object.assign({tabId}, details.details));
+      browser.pageAction.setIcon(Object.assign({tabId}, details.details));
+
+      browser.test.sendMessage("imageURL", expectedURL);
+    });
+
+    // Generate a list of tests and resolutions to send back to the test
+    // context.
+    //
+    // This process is a bit convoluted, because the outer test context needs
+    // to handle checking the button nodes and changing the screen resolution,
+    // but it can't pass us icon definitions with ImageData objects. This
+    // shouldn't be a problem, since structured clones should handle ImageData
+    // objects without issue. Unfortunately, |cloneInto| implements a slightly
+    // different algorithm than we use in web APIs, and does not handle them
+    // correctly.
+    var tests = [];
+    for (var [idx, icon] of iconDetails.entries()) {
+      for (var res of Object.keys(icon.resolutions)) {
+        tests.push({ index: idx, resolution: Number(res) });
+      }
+    }
+
+    // Sort by resolution, so we don't needlessly switch back and forth
+    // between each test.
+    tests.sort(test => test.resolution);
+
+    browser.tabs.query({ active: true, currentWindow: true }, tabs => {
+      tabId = tabs[0].id;
+      browser.pageAction.show(tabId);
+
+      browser.test.sendMessage("ready", tests);
+    });
+  }
+
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       "browser_action": {},
+      "page_action": {},
       "background": {
-        "page": "background.html",
+        "page": "data/background.html",
       }
     },
 
     files: {
-      "background.html": `<canvas id="canvas" width="2" height="2">
-        <script src="background.js"></script>`,
-
-      "background.js": function() {
-        var canvas = document.getElementById("canvas");
-        var canvasContext = canvas.getContext("2d");
-
-        canvasContext.clearRect(0, 0, canvas.width, canvas.height);
-        canvasContext.fillStyle = "green";
-        canvasContext.fillRect(0, 0, 1, 1);
-
-        var url = canvas.toDataURL("image/png");
-        var imageData = canvasContext.getImageData(0, 0, canvas.width, canvas.height);
-        browser.browserAction.setIcon({imageData});
-
-        browser.test.sendMessage("imageURL", url);
-      }
+      "data/background.html": `<script src="background.js"></script>`,
+      "data/background.js": background,
     },
   });
 
-  let [_, url] = yield Promise.all([extension.startup(), extension.awaitMessage("imageURL")]);
+  const RESOLUTION_PREF = "layout.css.devPixelsPerPx";
+  registerCleanupFunction(() => {
+    SpecialPowers.clearUserPref(RESOLUTION_PREF);
+  });
+
+  let browserActionId = makeWidgetId(extension.id) + "-browser-action";
+  let pageActionId = makeWidgetId(extension.id) + "-page-action";
+
+  let [, tests] = yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
 
-  let widgetId = makeWidgetId(extension.id) + "-browser-action";
-  let node = CustomizableUI.getWidget(widgetId).forWindow(window).node;
+  for (let test of tests) {
+    SpecialPowers.setCharPref(RESOLUTION_PREF, String(test.resolution));
+    is(window.devicePixelRatio, test.resolution, "window has the required resolution");
+
+    extension.sendMessage("setIcon", test);
 
-  let image = node.getAttribute("image");
-  is(image, url, "image is correct");
+    let imageURL = yield extension.awaitMessage("imageURL");
+
+    let browserActionButton = document.getElementById(browserActionId);
+    is(browserActionButton.getAttribute("image"), imageURL, "browser action has the correct image");
+
+    let pageActionImage = document.getElementById(pageActionId);
+    is(pageActionImage.src, imageURL, "page action has the correct image");
+  }
 
   yield extension.unload();
 });
+
+// Test that default icon details in the manifest.json file are handled
+// correctly.
+add_task(function *testDefaultDetails() {
+  // TODO: Test localized variants.
+  let icons = [
+    "foo/bar.png",
+    "/foo/bar.png",
+    { "19": "foo/bar.png" },
+    { "38": "foo/bar.png" },
+    { "19": "foo/bar.png", "38": "baz/quux.png" },
+  ];
+
+  let expectedURL = new RegExp(String.raw`^moz-extension://[^/]+/foo/bar\.png$`);
+
+  for (let icon of icons) {
+    let extension = ExtensionTestUtils.loadExtension({
+      manifest: {
+        "browser_action": { "default_icon": icon },
+        "page_action": { "default_icon": icon },
+      },
+
+      background: function () {
+        browser.tabs.query({ active: true, currentWindow: true }, tabs => {
+          var tabId = tabs[0].id;
+
+          browser.pageAction.show(tabId);
+          browser.test.sendMessage("ready");
+        });
+      }
+    });
+
+    yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+    let browserActionId = makeWidgetId(extension.id) + "-browser-action";
+    let pageActionId = makeWidgetId(extension.id) + "-page-action";
+
+    let browserActionButton = document.getElementById(browserActionId);
+    let image = browserActionButton.getAttribute("image");
+
+    ok(expectedURL.test(image), `browser action image ${image} matches ${expectedURL}`);
+
+    let pageActionImage = document.getElementById(pageActionId);
+    image = pageActionImage.src;
+
+    ok(expectedURL.test(image), `page action image ${image} matches ${expectedURL}`);
+
+    yield extension.unload();
+
+    let node = document.getElementById(pageActionId);
+    is(node, undefined, "pageAction image removed from document");
+  }
+});
+
+
+// Check that attempts to load a privileged URL as an icon image fail.
+add_task(function* testSecureURLsDenied() {
+
+  // Test URLs passed to setIcon.
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "browser_action": {},
+      "page_action": {},
+    },
+
+    background: function () {
+      browser.tabs.query({ active: true, currentWindow: true }, tabs => {
+        var tabId = tabs[0].id;
+
+        var urls = ["chrome://browser/content/browser.xul",
+                    "javascript:true"];
+
+        for (var url of urls) {
+          for (var api of ["pageAction", "browserAction"]) {
+            try {
+              browser[api].setIcon({tabId, path: url});
+
+              browser.test.fail(`Load of '${url}' succeeded. Expected failure.`);
+              browser.test.notifyFail("setIcon security tests");
+              return;
+            } catch (e) {
+              // We can't actually inspect the error here, since the
+              // error object belongs to the privileged scope of the API,
+              // rather than to the extension scope that calls into it.
+              // Just assume it's the expected security error, for now.
+              browser.test.succeed(`Load of '${url}' failed. Expected failure.`);
+            }
+          }
+        }
+
+        browser.test.notifyPass("setIcon security tests");
+      });
+    },
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitFinish();
+  yield extension.unload();
+
+
+  // Test URLs included in the manifest.
+
+  let urls = ["chrome://browser/content/browser.xul",
+              "javascript:true"];
+
+  let matchURLForbidden = url => ({
+    message: new RegExp(`Loading extension.*Access to.*'${url}' denied`),
+  });
+
+  let messages = [matchURLForbidden(urls[0]),
+                  matchURLForbidden(urls[1]),
+                  matchURLForbidden(urls[0]),
+                  matchURLForbidden(urls[1])];
+
+  let waitForConsole = new Promise(resolve => {
+    // Not necessary in browser-chrome tests, but monitorConsole gripes
+    // if we don't call it.
+    SimpleTest.waitForExplicitFinish();
+
+    SimpleTest.monitorConsole(resolve, messages);
+  });
+
+  extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "browser_action": {
+        "default_icon": {
+          "19": urls[0],
+          "38": urls[1],
+        },
+      },
+      "page_action": {
+        "default_icon": {
+          "19": urls[0],
+          "38": urls[1],
+        },
+      },
+    },
+  });
+
+  yield extension.startup();
+  yield extension.unload();
+
+  SimpleTest.endMonitorConsole();
+  yield waitForConsole;
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_pageAction_context.js
@@ -0,0 +1,209 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(function* testTabSwitchContext() {
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "page_action": {
+        "default_icon": "default.png",
+        "default_popup": "default.html",
+        "default_title": "Default Title",
+      },
+      "permissions": ["tabs"],
+    },
+
+    background: function () {
+      var details = [
+        { "icon": browser.runtime.getURL("default.png"),
+          "popup": browser.runtime.getURL("default.html"),
+          "title": "Default Title" },
+        { "icon": browser.runtime.getURL("1.png"),
+          "popup": browser.runtime.getURL("default.html"),
+          "title": "Default Title" },
+        { "icon": browser.runtime.getURL("2.png"),
+          "popup": browser.runtime.getURL("2.html"),
+          "title": "Title 2" },
+      ];
+
+      var tabs = [];
+
+      var tests = [
+        expect => {
+          browser.test.log("Initial state. No icon visible.");
+          expect(null);
+        },
+        expect => {
+          browser.test.log("Show the icon on the first tab, expect default properties.");
+          browser.pageAction.show(tabs[0]);
+          expect(details[0]);
+        },
+        expect => {
+          browser.test.log("Change the icon. Expect default properties excluding the icon.");
+          browser.pageAction.setIcon({ tabId: tabs[0], path: "1.png" });
+          expect(details[1]);
+        },
+        expect => {
+          browser.test.log("Create a new tab. No icon visible.");
+          browser.tabs.create({ active: true, url: "about:blank?0" }, tab => {
+            tabs.push(tab.id);
+            expect(null);
+          });
+        },
+        expect => {
+          browser.test.log("Change properties. Expect new properties.");
+          var tabId = tabs[1];
+          browser.pageAction.show(tabId);
+          browser.pageAction.setIcon({ tabId, path: "2.png" });
+          browser.pageAction.setPopup({ tabId, popup: "2.html" });
+          browser.pageAction.setTitle({ tabId, title: "Title 2" });
+
+          expect(details[2]);
+        },
+        expect => {
+          browser.test.log("Navigate to a new page. Expect icon hidden.");
+
+          // TODO: This listener should not be necessary, but the |tabs.update|
+          // callback currently fires too early in e10s windows.
+          browser.tabs.onUpdated.addListener(function listener(tabId, changed) {
+            if (tabId == tabs[1] && changed.url) {
+              browser.tabs.onUpdated.removeListener(listener);
+              expect(null);
+            }
+          });
+
+          browser.tabs.update(tabs[1], { url: "about:blank?1" });
+        },
+        expect => {
+          browser.test.log("Show the icon. Expect default properties again.");
+          browser.pageAction.show(tabs[1]);
+          expect(details[0]);
+        },
+        expect => {
+          browser.test.log("Switch back to the first tab. Expect previously set properties.");
+          browser.tabs.update(tabs[0], { active: true }, () => {
+            expect(details[1]);
+          });
+        },
+        expect => {
+          browser.test.log("Hide the icon on tab 2. Switch back, expect hidden.");
+          browser.pageAction.hide(tabs[1]);
+          browser.tabs.update(tabs[1], { active: true }, () => {
+            expect(null);
+          });
+        },
+        expect => {
+          browser.test.log("Switch back to tab 1. Expect previous results again.");
+          browser.tabs.remove(tabs[1], () => {
+            expect(details[1]);
+          });
+        },
+        expect => {
+          browser.test.log("Hide the icon. Expect hidden.");
+          browser.pageAction.hide(tabs[0]);
+          expect(null);
+        },
+      ];
+
+      // Gets the current details of the page action, and returns a
+      // promise that resolves to an object containing them.
+      function getDetails() {
+        return new Promise(resolve => {
+          return browser.tabs.query({ active: true, currentWindow: true }, resolve);
+        }).then(tabs => {
+          var tabId = tabs[0].id;
+
+          return Promise.all([
+            new Promise(resolve => browser.pageAction.getTitle({tabId}, resolve)),
+            new Promise(resolve => browser.pageAction.getPopup({tabId}, resolve))])
+        }).then(details => {
+          return Promise.resolve({ title: details[0],
+                                   popup: details[1] });
+        });
+      }
+
+
+      // Runs the next test in the `tests` array, checks the results,
+      // and passes control back to the outer test scope.
+      function nextTest() {
+        var test = tests.shift();
+
+        test(expecting => {
+          function finish() {
+            // Check that the actual icon has the expected values, then
+            // run the next test.
+            browser.test.sendMessage("nextTest", expecting, tests.length);
+          }
+
+          if (expecting) {
+            // Check that the API returns the expected values, and then
+            // run the next test.
+            getDetails().then(details => {
+              browser.test.assertEq(expecting.title, details.title,
+                                    "expected value from getTitle");
+
+              browser.test.assertEq(expecting.popup, details.popup,
+                                    "expected value from getPopup");
+
+              finish();
+            });
+          } else {
+            finish();
+          }
+        });
+      }
+
+      browser.test.onMessage.addListener((msg) => {
+        if (msg != "runNextTest") {
+          browser.test.fail("Expecting 'runNextTest' message");
+        }
+
+        nextTest();
+      });
+
+      browser.tabs.query({ active: true, currentWindow: true }, resultTabs => {
+        tabs[0] = resultTabs[0].id;
+
+        nextTest();
+      });
+    },
+  });
+
+  let pageActionId = makeWidgetId(extension.id) + "-page-action";
+
+  function checkDetails(details) {
+    let image = document.getElementById(pageActionId);
+    if (details == null) {
+      ok(image == null || image.hidden, "image is hidden");
+    } else {
+      ok(image, "image exists");
+
+      is(image.src, details.icon, "icon URL is correct");
+      is(image.getAttribute("tooltiptext"), details.title, "image title is correct");
+      is(image.getAttribute("aria-label"), details.title, "image aria-label is correct");
+      // TODO: Popup URL.
+    }
+  }
+
+  let awaitFinish = new Promise(resolve => {
+    extension.onMessage("nextTest", (expecting, testsRemaining) => {
+      checkDetails(expecting);
+
+      if (testsRemaining) {
+        extension.sendMessage("runNextTest")
+      } else {
+        resolve();
+      }
+    });
+  });
+
+  yield extension.startup();
+
+  yield awaitFinish;
+
+  yield extension.unload();
+
+  let node = document.getElementById(pageActionId);
+  is(node, undefined, "pageAction image removed from document");
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js
@@ -0,0 +1,215 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function promisePopupShown(popup) {
+  return new Promise(resolve => {
+    if (popup.popupOpen) {
+      resolve();
+    } else {
+      let onPopupShown = event => {
+        popup.removeEventListener("popupshown", onPopupShown);
+        resolve();
+      };
+      popup.addEventListener("popupshown", onPopupShown);
+    }
+  });
+}
+
+add_task(function* testPageActionPopup() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "background": {
+        "page": "data/background.html"
+      },
+      "page_action": {
+        "default_popup": "popup-a.html"
+      }
+    },
+
+    files: {
+      "popup-a.html": `<script src="popup-a.js"></script>`,
+      "popup-a.js": function() {
+        browser.runtime.sendMessage("from-popup-a");
+      },
+
+      "data/popup-b.html": `<script src="popup-b.js"></script>`,
+      "data/popup-b.js": function() {
+        browser.runtime.sendMessage("from-popup-b");
+      },
+
+      "data/background.html": `<script src="background.js"></script>`,
+
+      "data/background.js": function() {
+        var tabId;
+
+        var tests = [
+          () => {
+            sendClick({ expectEvent: false, expectPopup: "a" });
+          },
+          () => {
+            sendClick({ expectEvent: false, expectPopup: "a" });
+          },
+          () => {
+            browser.pageAction.setPopup({ tabId, popup: "popup-b.html" });
+            sendClick({ expectEvent: false, expectPopup: "b" });
+          },
+          () => {
+            sendClick({ expectEvent: false, expectPopup: "b" });
+          },
+          () => {
+            browser.pageAction.setPopup({ tabId, popup: "" });
+            sendClick({ expectEvent: true, expectPopup: null });
+          },
+          () => {
+            sendClick({ expectEvent: true, expectPopup: null });
+          },
+          () => {
+            browser.pageAction.setPopup({ tabId, popup: "/popup-a.html" });
+            sendClick({ expectEvent: false, expectPopup: "a" });
+          },
+        ];
+
+        var expect = {};
+        function sendClick({ expectEvent, expectPopup }) {
+          expect = { event: expectEvent, popup: expectPopup };
+          browser.test.sendMessage("send-click");
+        }
+
+        browser.runtime.onMessage.addListener(msg => {
+          if (expect.popup) {
+            browser.test.assertEq(msg, `from-popup-${expect.popup}`,
+                                  "expected popup opened");
+          } else {
+            browser.test.fail("unexpected popup");
+          }
+
+          expect.popup = null;
+          browser.test.sendMessage("next-test");
+        });
+
+        browser.pageAction.onClicked.addListener(() => {
+          if (expect.event) {
+            browser.test.succeed("expected click event received");
+          } else {
+            browser.test.fail("unexpected click event");
+          }
+
+          expect.event = false;
+          browser.test.sendMessage("next-test");
+        });
+
+        browser.test.onMessage.addListener((msg) => {
+          if (msg != "next-test") {
+            browser.test.fail("Expecting 'next-test' message");
+          }
+
+          if (tests.length) {
+            var test = tests.shift();
+            test();
+          } else {
+            browser.test.notifyPass("pageaction-tests-done");
+          }
+        });
+
+        browser.tabs.query({ active: true, currentWindow: true }, tabs => {
+          tabId = tabs[0].id;
+
+          browser.pageAction.show(tabId);
+          browser.test.sendMessage("next-test");
+        });
+      },
+    },
+  });
+
+  let pageActionId = makeWidgetId(extension.id) + "-page-action";
+  let panelId = makeWidgetId(extension.id) + "-panel";
+
+  extension.onMessage("send-click", () => {
+    let image = document.getElementById(pageActionId);
+
+    let evt = new MouseEvent("click", {});
+    image.dispatchEvent(evt);
+  });
+
+  extension.onMessage("next-test", Task.async(function* () {
+    let panel = document.getElementById(panelId);
+    if (panel) {
+      yield promisePopupShown(panel);
+      panel.hidePopup();
+
+      panel = document.getElementById(panelId);
+      is(panel, undefined, "panel successfully removed from document after hiding");
+    }
+
+    extension.sendMessage("next-test");
+  }));
+
+
+  yield Promise.all([extension.startup(), extension.awaitFinish("pageaction-tests-done")]);
+
+  yield extension.unload();
+
+  let node = document.getElementById(pageActionId);
+  is(node, undefined, "pageAction image removed from document");
+
+  let panel = document.getElementById(panelId);
+  is(panel, undefined, "pageAction panel removed from document");
+});
+
+
+add_task(function* testPageActionSecurity() {
+  const URL = "chrome://browser/content/browser.xul";
+
+  let matchURLForbidden = url => ({
+    message: new RegExp(`Loading extension.*Access to.*'${URL}' denied`),
+  });
+
+  let messages = [/Access to restricted URI denied/,
+                  /Access to restricted URI denied/];
+
+  let waitForConsole = new Promise(resolve => {
+    // Not necessary in browser-chrome tests, but monitorConsole gripes
+    // if we don't call it.
+    SimpleTest.waitForExplicitFinish();
+
+    SimpleTest.monitorConsole(resolve, messages);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "browser_action": { "default_popup": URL },
+      "page_action": { "default_popup": URL },
+    },
+
+    background: function () {
+      browser.tabs.query({ active: true, currentWindow: true }, tabs => {
+        var tabId = tabs[0].id;
+
+        browser.pageAction.show(tabId);
+        browser.test.sendMessage("ready");
+      });
+    },
+  });
+
+  yield Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+  let browserActionId = makeWidgetId(extension.id) + "-browser-action";
+  let pageActionId = makeWidgetId(extension.id) + "-page-action";
+
+  let browserAction = document.getElementById(browserActionId);
+  let evt = new CustomEvent("command", {});
+  browserAction.dispatchEvent(evt);
+
+  let pageAction = document.getElementById(pageActionId);
+  evt = new MouseEvent("click", {});
+  pageAction.dispatchEvent(evt);
+
+  yield extension.unload();
+
+  let node = document.getElementById(pageActionId);
+  is(node, undefined, "pageAction image removed from document");
+
+  SimpleTest.endMonitorConsole();
+  yield waitForConsole;
+});
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -637,16 +637,17 @@ BrowserGlue.prototype = {
     os.addObserver(this, "restart-in-safe-mode", false);
     os.addObserver(this, "flash-plugin-hang", false);
     os.addObserver(this, "xpi-signature-changed", false);
     os.addObserver(this, "autocomplete-did-enter-text", false);
     os.addObserver(this, "tablet-mode-change", false);
 
     ExtensionManagement.registerScript("chrome://browser/content/ext-utils.js");
     ExtensionManagement.registerScript("chrome://browser/content/ext-browserAction.js");
+    ExtensionManagement.registerScript("chrome://browser/content/ext-pageAction.js");
     ExtensionManagement.registerScript("chrome://browser/content/ext-contextMenus.js");
     ExtensionManagement.registerScript("chrome://browser/content/ext-tabs.js");
     ExtensionManagement.registerScript("chrome://browser/content/ext-windows.js");
     ExtensionManagement.registerScript("chrome://browser/content/ext-bookmarks.js");
 
     this._flashHangCount = 0;
   },
 
--- a/browser/themes/linux/browser.css
+++ b/browser/themes/linux/browser.css
@@ -932,16 +932,19 @@ toolbar .toolbarbutton-1:-moz-any(@prima
 }
 
 #urlbar-icons {
   -moz-box-align: center;
 }
 
 .urlbar-icon {
   padding: 0 3px;
+  /* 16x16 icon with border-box sizing */
+  width: 22px;
+  height: 16px;
 }
 
 #urlbar-search-footer {
   border-top: 1px solid hsla(210,4%,10%,.14);
   background-color: hsla(210,4%,10%,.07);
 }
 
 #urlbar-search-settings {
@@ -1937,13 +1940,13 @@ toolbarbutton.chevron > .toolbarbutton-i
 
 #context-navigation > .menuitem-iconic > .menu-iconic-left {
   visibility: visible;
   /* override toolkit/themes/linux/global/menu.css */
   -moz-padding-end: 0 !important;
   -moz-margin-end: 0 !important;
 }
 
-.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent {
+.browser-extension-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
 %include ../shared/usercontext/usercontext.inc.css
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -1723,16 +1723,19 @@ toolbar .toolbarbutton-1 > .toolbarbutto
 }
 
 #urlbar-icons {
   -moz-box-align: center;
 }
 
 .urlbar-icon {
   padding: 0 3px;
+  /* 16x16 icon with border-box sizing */
+  width: 22px;
+  height: 16px;
 }
 
 #urlbar-search-footer {
   border-top: 1px solid hsla(210,4%,10%,.14);
   background-color: hsla(210,4%,10%,.07);
 }
 
 #urlbar-search-settings {
@@ -2010,17 +2013,16 @@ richlistitem[type~="action"][actiontype=
 #page-report-button[open="true"] {
   -moz-image-region: rect(0, 32px, 16px, 16px);
 }
 
 @media (min-resolution: 2dppx) {
   #page-report-button {
     list-style-image: url("chrome://browser/skin/urlbar-popup-blocked@2x.png");
     -moz-image-region: rect(0, 32px, 32px, 0);
-    width: 22px;
   }
 
   #page-report-button:hover:active,
   #page-report-button[open="true"] {
     -moz-image-region: rect(0, 64px, 32px, 32px);
   }
 }
 
@@ -3612,13 +3614,13 @@ notification[value="loop-sharing-notific
 
 %include ../shared/contextmenu.inc.css
 
 #context-navigation > .menuitem-iconic {
   padding-left: 0;
   padding-right: 0;
 }
 
-.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent {
+.browser-extension-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
 %include ../shared/usercontext/usercontext.inc.css
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -1320,16 +1320,19 @@ html|*.urlbar-input:-moz-lwtheme::-moz-p
 }
 
 #urlbar-icons {
   -moz-box-align: center;
 }
 
 .urlbar-icon {
   padding: 0 3px;
+  /* 16x16 icon with border-box sizing */
+  width: 22px;
+  height: 16px;
 }
 
 .search-go-container {
   padding: 2px 2px;
 }
 
 #urlbar-search-footer {
   border-top: 1px solid hsla(210,4%,10%,.14);
@@ -2797,13 +2800,13 @@ notification[value="loop-sharing-notific
   margin-top: -4px;
 }
 
 
 @media not all and (-moz-os-version: windows-xp) {
 %include browser-aero.css
 }
 
-.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent {
+.browser-extension-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
 %include ../shared/usercontext/usercontext.inc.css
--- a/testing/mochitest/tests/SimpleTest/SimpleTest.js
+++ b/testing/mochitest/tests/SimpleTest/SimpleTest.js
@@ -1120,17 +1120,17 @@ SimpleTest.finish = function() {
  * SimpleTest.waitForExplicitFinish.
  */
 SimpleTest.monitorConsole = function (continuation, msgs, forbidUnexpectedMsgs) {
   if (SimpleTest._stopOnLoad) {
     ok(false, "Console monitoring requires use of waitForExplicitFinish.");
   }
 
   function msgMatches(msg, pat) {
-    for (k in pat) {
+    for (var k in pat) {
       if (!(k in msg)) {
         return false;
       }
       if (pat[k] instanceof RegExp && typeof(msg[k]) === 'string') {
         if (!pat[k].test(msg[k])) {
           return false;
         }
       } else if (msg[k] !== pat[k]) {
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -18,17 +18,16 @@ const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/devtools/shared/event-emitter.js");
 
-
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Log",
                                   "resource://gre/modules/Log.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
@@ -436,22 +435,20 @@ this.Extension.generate = function(id, d
 
   // Needs to be in microseconds for some reason.
   let time = Date.now() * 1000;
 
   function generateFile(filename) {
     let components = filename.split("/");
     let path = "";
     for (let component of components.slice(0, -1)) {
-      path += component;
+      path += component + "/";
       if (!zipW.hasEntry(path)) {
         zipW.addEntryDirectory(path, time, false);
       }
-
-      path += "/";
     }
   }
 
   for (let filename in files) {
     let script = files[filename];
     if (typeof(script) == "function") {
       script = "(" + script.toString() + ")()";
     }
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -40,29 +40,31 @@ function runSafeWithoutClone(f, ...args)
 
 // Run a function, cloning arguments into context.cloneScope, and
 // report exceptions. |f| is expected to be in context.cloneScope.
 function runSafeSync(context, f, ...args)
 {
   try {
     args = Cu.cloneInto(args, context.cloneScope);
   } catch (e) {
-    dump(`runSafe failure\n${context.cloneScope}\n${Error().stack}`);
+    Cu.reportError(e);
+    dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${Error().stack}`);
   }
   return runSafeSyncWithoutClone(f, ...args);
 }
 
 // Run a function, cloning arguments into context.cloneScope, and
 // report exceptions. |f| is expected to be in context.cloneScope.
 function runSafe(context, f, ...args)
 {
   try {
     args = Cu.cloneInto(args, context.cloneScope);
   } catch (e) {
-    dump(`runSafe failure\n${context.cloneScope}\n${Error().stack}`);
+    Cu.reportError(e);
+    dump(`runSafe failure: cloning into ${context.cloneScope}: ${e}\n\n${Error().stack}`);
   }
   return runSafeWithoutClone(f, ...args);
 }
 
 // Similar to a WeakMap, but returns a particular default value for
 // |get| if a key is not present.
 function DefaultWeakMap(defaultValue)
 {