Bug 1208596 implement sidebar api for webextensions, f?kmag, gijs r=Gijs,kmag
authorShane Caraveo <scaraveo@mozilla.com>
Thu, 09 Feb 2017 15:32:50 -0800
changeset 341729 1e0dc444f8e3
parent 341728 3c9874eb6cc3
child 341730 76acb5c47210
push id31342
push usercbook@mozilla.com
push date2017-02-10 12:48 +0000
treeherdermozilla-central@b83e2b2524c9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs, kmag
bugs1208596
milestone54.0a1
Bug 1208596 implement sidebar api for webextensions, f?kmag, gijs r=Gijs,kmag

MozReview-Commit-ID: 6GMdU5kcrFR
browser/base/content/browser-sidebar.js
browser/base/content/browser.css
browser/base/content/browser.js
browser/base/content/webext-panels.js
browser/base/content/webext-panels.xul
browser/base/jar.mn
browser/components/customizableui/CustomizableWidgets.jsm
browser/components/extensions/ext-browserAction.js
browser/components/extensions/ext-sidebarAction.js
browser/components/extensions/extensions-browser.manifest
browser/components/extensions/jar.mn
browser/components/extensions/schemas/jar.mn
browser/components/extensions/schemas/sidebar_action.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_sidebarAction.js
browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js
browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js
browser/components/privatebrowsing/test/browser/head.js
testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/schemas/extension.json
toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
--- a/browser/base/content/browser-sidebar.js
+++ b/browser/base/content/browser-sidebar.js
@@ -26,34 +26,16 @@ var SidebarUI = {
   _title: null,
   _splitter: null,
 
   init() {
     this._box = document.getElementById("sidebar-box");
     this.browser = document.getElementById("sidebar");
     this._title = document.getElementById("sidebar-title");
     this._splitter = document.getElementById("sidebar-splitter");
-
-    if (!this.adoptFromWindow(window.opener)) {
-      let commandID = this._box.getAttribute("sidebarcommand");
-      if (commandID) {
-        let command = document.getElementById(commandID);
-        if (command) {
-          this._delayedLoad = true;
-          this._box.hidden = false;
-          this._splitter.hidden = false;
-          command.setAttribute("checked", "true");
-        } else {
-          // Remove the |sidebarcommand| attribute, because the element it
-          // refers to no longer exists, so we should assume this sidebar
-          // panel has been uninstalled. (249883)
-          this._box.removeAttribute("sidebarcommand");
-        }
-      }
-    }
   },
 
   uninit() {
     let enumerator = Services.wm.getEnumerator(null);
     enumerator.getNext();
     if (!enumerator.hasMoreElements()) {
       document.persist("sidebar-box", "sidebarcommand");
       document.persist("sidebar-box", "width");
@@ -64,24 +46,16 @@ var SidebarUI = {
 
   /**
    * Try and adopt the status of the sidebar from another window.
    * @param {Window} sourceWindow - Window to use as a source for sidebar status.
    * @return true if we adopted the state, or false if the caller should
    * initialize the state itself.
    */
   adoptFromWindow(sourceWindow) {
-    // No source window, or it being closed, or not chrome, or in a different
-    // private-browsing context means we can't adopt.
-    if (!sourceWindow || sourceWindow.closed ||
-        !sourceWindow.document.documentURIObject.schemeIs("chrome") ||
-        PrivateBrowsingUtils.isWindowPrivate(window) != PrivateBrowsingUtils.isWindowPrivate(sourceWindow)) {
-      return false;
-    }
-
     // If the opener had a sidebar, open the same sidebar in our window.
     // The opener can be the hidden window too, if we're coming from the state
     // where no windows are open, and the hidden window has no sidebar box.
     let sourceUI = sourceWindow.SidebarUI;
     if (!sourceUI || !sourceUI._box) {
       // no source UI or no _box means we also can't adopt the state.
       return false;
     }
@@ -103,33 +77,65 @@ var SidebarUI = {
                              sourceUI._title.getAttribute("value"));
     this._box.setAttribute("width", sourceUI._box.boxObject.width);
 
     this._box.setAttribute("sidebarcommand", commandID);
     // Note: we're setting 'src' on this._box, which is a <vbox>, not on
     // the <browser id="sidebar">. This lets us delay the actual load until
     // delayedStartup().
     this._box.setAttribute("src", sourceUI.browser.getAttribute("src"));
-    this._delayedLoad = true;
 
     this._box.hidden = false;
     this._splitter.hidden = false;
     commandElem.setAttribute("checked", "true");
+    this.browser.setAttribute("src", this._box.getAttribute("src"));
     return true;
   },
 
+  windowPrivacyMatches(w1, w2) {
+    return PrivateBrowsingUtils.isWindowPrivate(w1) === PrivateBrowsingUtils.isWindowPrivate(w2);
+  },
+
   /**
    * If loading a sidebar was delayed on startup, start the load now.
    */
   startDelayedLoad() {
-    if (!this._delayedLoad) {
+    let sourceWindow = window.opener;
+    // No source window means this is the initial window.  If we're being
+    // opened from another window, check that it is one we might open a sidebar
+    // for.
+    if (sourceWindow) {
+      if (sourceWindow.closed || sourceWindow.location.protocol != "chrome:" ||
+        !this.windowPrivacyMatches(sourceWindow, window)) {
+        return;
+      }
+      // Try to adopt the sidebar state from the source window
+      if (this.adoptFromWindow(sourceWindow)) {
+        return;
+      }
+    }
+
+    // If we're not adopting settings from a parent window, set them now.
+    let commandID = this._box.getAttribute("sidebarcommand");
+    if (!commandID) {
       return;
     }
 
-    this.browser.setAttribute("src", this._box.getAttribute("src"));
+    let command = document.getElementById(commandID);
+    if (command) {
+      this._box.hidden = false;
+      this._splitter.hidden = false;
+      command.setAttribute("checked", "true");
+      this.browser.setAttribute("src", this._box.getAttribute("src"));
+    } else {
+      // Remove the |sidebarcommand| attribute, because the element it
+      // refers to no longer exists, so we should assume this sidebar
+      // panel has been uninstalled. (249883)
+      this._box.removeAttribute("sidebarcommand");
+    }
   },
 
   /**
    * Fire a "SidebarFocused" event on the sidebar's |window| to give the sidebar
    * a chance to adjust focus as needed. An additional event is needed, because
    * we don't want to focus the sidebar when it's opened on startup or in a new
    * window, only when the user opens the sidebar.
    */
@@ -230,31 +236,28 @@ var SidebarUI = {
       // We set this attribute here in addition to setting it on the <browser>
       // element itself, because the code in SidebarUI.uninit() persists this
       // attribute, not the "src" of the <browser id="sidebar">. The reason it
       // does that is that we want to delay sidebar load a bit when a browser
       // window opens. See delayedStartup() and SidebarUI.startDelayedLoad().
       this._box.setAttribute("src", url);
 
       if (this.browser.contentDocument.location.href != url) {
-        let onLoad = event => {
-          this.browser.removeEventListener("load", onLoad, true);
+        this.browser.addEventListener("load", event => {
 
           // We're handling the 'load' event before it bubbles up to the usual
           // (non-capturing) event handlers. Let it bubble up before firing the
           // SidebarFocused event.
           setTimeout(() => this._fireFocusedEvent(), 0);
 
           // Run the original function for backwards compatibility.
           sidebarOnLoad(event);
 
           resolve();
-        };
-
-        this.browser.addEventListener("load", onLoad, true);
+        }, {capture: true, once: true});
       } else {
         // Older code handled this case, so we do it too.
         this._fireFocusedEvent();
         resolve();
       }
 
       let selBrowser = gBrowser.selectedBrowser;
       selBrowser.messageManager.sendAsyncMessage("Sidebar:VisibilityChange",
@@ -298,35 +301,35 @@ var SidebarUI = {
     selBrowser.messageManager.sendAsyncMessage("Sidebar:VisibilityChange",
       {commandID, isOpen: false}
     );
     BrowserUITelemetry.countSidebarEvent(commandID, "hide");
   },
 };
 
 /**
- * This exists for backards compatibility - it will be called once a sidebar is
+ * This exists for backwards compatibility - it will be called once a sidebar is
  * ready, following any request to show it.
  *
  * @deprecated
  */
 function fireSidebarFocusedEvent() {}
 
 /**
- * This exists for backards compatibility - it gets called when a sidebar has
+ * This exists for backwards compatibility - it gets called when a sidebar has
  * been loaded.
  *
  * @deprecated
  */
 function sidebarOnLoad(event) {}
 
 /**
- * This exists for backards compatibility, and is equivilent to
+ * This exists for backwards compatibility, and is equivilent to
  * SidebarUI.toggle() without the forceOpen param. With forceOpen set to true,
- * it is equalivent to SidebarUI.show().
+ * it is equivalent to SidebarUI.show().
  *
  * @deprecated
  */
 function toggleSidebar(commandID, forceOpen = false) {
   Deprecated.warning("toggleSidebar() is deprecated, please use SidebarUI.toggle() or SidebarUI.show() instead",
                      "https://developer.mozilla.org/en-US/Add-ons/Code_snippets/Sidebar");
 
   if (forceOpen) {
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -319,31 +319,39 @@ toolbarpaletteitem > toolbaritem[sdkstyl
   .webextension-browser-action[cui-areatype="menu-panel"],
   toolbarpaletteitem[place="palette"] > .webextension-browser-action {
     list-style-image: var(--webextension-menupanel-image);
   }
 
   .webextension-page-action {
     list-style-image: var(--webextension-urlbar-image);
   }
+
+  .webextension-menuitem {
+    list-style-image: var(--webextension-menuitem-image);
+  }
 }
 
 @media (min-resolution: 1.1dppx) {
   .webextension-browser-action {
     list-style-image: var(--webextension-toolbar-image-2x);
   }
 
   .webextension-browser-action[cui-areatype="menu-panel"],
   toolbarpaletteitem[place="palette"] > .webextension-browser-action {
     list-style-image: var(--webextension-menupanel-image-2x);
   }
 
   .webextension-page-action {
     list-style-image: var(--webextension-urlbar-image-2x);
   }
+
+  .webextension-menuitem {
+    list-style-image: var(--webextension-menuitem-image-2x);
+  }
 }
 
 toolbarpaletteitem[removable="false"] {
   opacity: 0.5;
   cursor: default;
 }
 
 %ifndef XP_MACOSX
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1280,18 +1280,16 @@ var gBrowserInit = {
 
     // Initialize the full zoom setting.
     // We do this before the session restore service gets initialized so we can
     // apply full zoom settings to tabs restored by the session restore service.
     FullZoom.init();
     PanelUI.init();
     LightweightThemeListener.init();
 
-    SidebarUI.startDelayedLoad();
-
     UpdateUrlbarSearchSplitterState();
 
     if (!(isBlankPageURL(uriToLoad) || uriToLoad == "about:privatebrowsing") ||
         !focusAndSelectUrlBar()) {
       if (gBrowser.selectedBrowser.isRemoteBrowser) {
         // If the initial browser is remote, in order to optimize for first paint,
         // we'll defer switching focus to that browser until it has painted.
         let focusedElement = document.commandDispatcher.focusedElement;
@@ -1455,16 +1453,17 @@ var gBrowserInit = {
       // Bail out if the window has been closed in the meantime.
       if (window.closed) {
         return;
       }
 
       // Enable the Restore Last Session command if needed
       RestoreLastSessionObserver.init();
 
+      SidebarUI.startDelayedLoad();
       SocialUI.init();
 
       // Start monitoring slow add-ons
       AddonWatcher.init();
 
       // Telemetry for master-password - we do this after 5 seconds as it
       // can cause IO if NSS/PSM has not already initialized.
       setTimeout(() => {
new file mode 100644
--- /dev/null
+++ b/browser/base/content/webext-panels.js
@@ -0,0 +1,32 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */
+/* 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/. */
+
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionParent",
+                                  "resource://gre/modules/ExtensionParent.jsm");
+
+function loadWebPanel() {
+  let sidebarURI = new URL(location);
+  let uri = sidebarURI.searchParams.get("panel");
+  let remote = sidebarURI.searchParams.get("remote");
+  let browser = document.getElementById("webext-panels-browser");
+  if (remote) {
+    let remoteType = E10SUtils.getRemoteTypeForURI(uri, true,
+                                                   E10SUtils.EXTENSION_REMOTE_TYPE);
+    browser.setAttribute("remote", "true");
+    browser.setAttribute("remoteType", remoteType);
+  } else {
+    browser.removeAttribute("remote");
+    browser.removeAttribute("remoteType");
+  }
+  browser.loadURI(uri);
+}
+
+function load() {
+  let browser = document.getElementById("webext-panels-browser");
+  browser.messageManager.loadFrameScript("chrome://browser/content/content.js", true);
+  ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+  this.loadWebPanel();
+}
new file mode 100644
--- /dev/null
+++ b/browser/base/content/webext-panels.xul
@@ -0,0 +1,73 @@
+<?xml version="1.0"?>
+
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?>
+<?xul-overlay href="chrome://browser/content/places/placesOverlay.xul"?>
+
+<!DOCTYPE page [
+<!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd">
+%browserDTD;
+<!ENTITY % textcontextDTD SYSTEM "chrome://global/locale/textcontext.dtd">
+%textcontextDTD;
+]>
+
+<page id="webextpanels-window"
+        xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+        xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        onload="load()">
+  <script type="application/javascript" src="chrome://global/content/contentAreaUtils.js"/>
+  <script type="application/javascript" src="chrome://browser/content/browser.js"/>
+  <script type="application/javascript" src="chrome://browser/content/browser-places.js"/>
+  <script type="application/javascript" src="chrome://browser/content/browser-social.js"/>
+  <script type="application/javascript" src="chrome://browser/content/browser-fxaccounts.js"/>
+  <script type="application/javascript" src="chrome://browser/content/nsContextMenu.js"/>
+  <script type="application/javascript" src="chrome://browser/content/webext-panels.js"/>
+
+  <stringbundleset id="stringbundleset">
+    <stringbundle id="bundle_browser" src="chrome://browser/locale/browser.properties"/>
+  </stringbundleset>
+
+  <broadcasterset id="mainBroadcasterSet">
+    <broadcaster id="isFrameImage"/>
+  </broadcasterset>
+
+  <commandset id="mainCommandset">
+    <command id="Browser:Back"
+             oncommand="getPanelBrowser().webNavigation.goBack();"
+             disabled="true"/>
+    <command id="Browser:Forward"
+             oncommand="getPanelBrowser().webNavigation.goForward();"
+             disabled="true"/>
+    <command id="Browser:Stop" oncommand="PanelBrowserStop();"/>
+    <command id="Browser:Reload" oncommand="PanelBrowserReload();"/>
+  </commandset>
+
+  <popupset id="mainPopupSet">
+    <tooltip id="aHTMLTooltip" page="true"/>
+    <menupopup id="contentAreaContextMenu" pagemenu="start"
+               onpopupshowing="if (event.target != this)
+                                 return true;
+                               gContextMenu = new nsContextMenu(this, event.shiftKey);
+                               if (gContextMenu.shouldDisplay)
+                                 document.popupNode = this.triggerNode;
+                               return gContextMenu.shouldDisplay;"
+               onpopuphiding="if (event.target != this)
+                                return;
+                              gContextMenu.hiding();
+                              gContextMenu = null;">
+#include browser-context.inc
+    </menupopup>
+  </popupset>
+
+  <commandset id="editMenuCommands"/>
+  <browser id="webext-panels-browser"
+           type="content" flex="1"
+           webextension-view-type="sidebar"
+           context="contentAreaContextMenu" tooltip="aHTMLTooltip"
+           onclick="window.parent.contentAreaClick(event, true);"/>
+</page>
--- a/browser/base/jar.mn
+++ b/browser/base/jar.mn
@@ -150,16 +150,18 @@ browser.jar:
         content/browser/contentSearchUI.css           (content/contentSearchUI.css)
         content/browser/tabbrowser.css                (content/tabbrowser.css)
         content/browser/tabbrowser.xml                (content/tabbrowser.xml)
         content/browser/urlbarBindings.xml            (content/urlbarBindings.xml)
         content/browser/utilityOverlay.js             (content/utilityOverlay.js)
         content/browser/usercontext.svg               (content/usercontext.svg)
         content/browser/web-panels.js                 (content/web-panels.js)
 *       content/browser/web-panels.xul                (content/web-panels.xul)
+        content/browser/webext-panels.js              (content/webext-panels.js)
+*       content/browser/webext-panels.xul             (content/webext-panels.xul)
 *       content/browser/baseMenuOverlay.xul           (content/baseMenuOverlay.xul)
 *       content/browser/nsContextMenu.js              (content/nsContextMenu.js)
 # XXX: We should exclude this one as well (bug 71895)
 *       content/browser/hiddenWindow.xul              (content/hiddenWindow.xul)
 #ifdef XP_MACOSX
 *       content/browser/macBrowserOverlay.xul         (content/macBrowserOverlay.xul)
 *       content/browser/softwareUpdateOverlay.xul  (content/softwareUpdateOverlay.xul)
 #endif
--- a/browser/components/customizableui/CustomizableWidgets.jsm
+++ b/browser/components/customizableui/CustomizableWidgets.jsm
@@ -97,17 +97,17 @@ function updateCombinedWidgetStyle(aNode
       continue;
     setAttributes(aNode.childNodes[i], attrs);
   }
 }
 
 function fillSubviewFromMenuItems(aMenuItems, aSubview) {
   let attrs = ["oncommand", "onclick", "label", "key", "disabled",
                "command", "observes", "hidden", "class", "origin",
-               "image", "checked"];
+               "image", "checked", "style"];
 
   let doc = aSubview.ownerDocument;
   let fragment = doc.createDocumentFragment();
   for (let menuChild of aMenuItems) {
     if (menuChild.hidden)
       continue;
 
     let subviewItem;
--- a/browser/components/extensions/ext-browserAction.js
+++ b/browser/components/extensions/ext-browserAction.js
@@ -350,27 +350,23 @@ BrowserAction.prototype = {
 
       if (result.size % 18 == 0) {
         baseSize = 18;
         icon = result.icon;
         node.classList.add(LEGACY_CLASS);
       }
     }
 
-    // These URLs should already be properly escaped, but make doubly sure CSS
-    // string escape characters are escaped here, since they could lead to a
-    // sandbox break.
-    let escape = str => str.replace(/[\\\s"]/g, encodeURIComponent);
-
-    let getIcon = size => escape(IconDetails.getPreferredIcon(tabData.icon, this.extension, size).icon);
+    let getIcon = size => IconDetails.escapeUrl(
+      IconDetails.getPreferredIcon(tabData.icon, this.extension, size).icon);
 
     node.setAttribute("style", `
       --webextension-menupanel-image: url("${getIcon(32)}");
       --webextension-menupanel-image-2x: url("${getIcon(64)}");
-      --webextension-toolbar-image: url("${escape(icon)}");
+      --webextension-toolbar-image: url("${IconDetails.escapeUrl(icon)}");
       --webextension-toolbar-image-2x: url("${getIcon(baseSize * 2)}");
     `);
   },
 
   // Update the toolbar button for a given window.
   updateWindow(window) {
     let widget = this.widget.forWindow(window);
     if (widget) {
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-sidebarAction.js
@@ -0,0 +1,349 @@
+/* -*- 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");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+                                  "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
+                                  "resource:///modules/CustomizableUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
+let {
+  ExtensionError,
+  IconDetails,
+} = ExtensionUtils;
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+// WeakMap[Extension -> SidebarAction]
+let sidebarActionMap = new WeakMap();
+
+const sidebarURL = "chrome://browser/content/webext-panels.xul";
+
+/**
+ * Responsible for the sidebar_action section of the manifest as well
+ * as the associated sidebar browser.
+ */
+class SidebarAction {
+  constructor(options, extension) {
+    this.extension = extension;
+
+    // Add the extension to the sidebar menu.  The sidebar widget will copy
+    // from that when it is viewed, so we shouldn't need to update that.
+    let widgetId = makeWidgetId(extension.id);
+    this.id = `${widgetId}-sidebar-action`;
+    this.menuId = `menu_${this.id}`;
+
+    this.defaults = {
+      enabled: true,
+      title: options.default_title || extension.name,
+      icon: IconDetails.normalize({path: options.default_icon}, extension),
+      panel: options.default_panel || "",
+    };
+
+    this.tabContext = new TabContext(tab => Object.create(this.defaults),
+                                     extension);
+
+    // We need to ensure our elements are available before session restore.
+    this.windowOpenListener = (window) => {
+      this.createMenuItem(window, this.defaults);
+    };
+    windowTracker.addOpenListener(this.windowOpenListener);
+  }
+
+  build() {
+    this.tabContext.on("tab-select", // eslint-disable-line mozilla/balanced-listeners
+                       (evt, tab) => { this.updateWindow(tab.ownerGlobal); });
+
+    let install = this.extension.startupReason === "ADDON_INSTALL";
+    for (let window of windowTracker.browserWindows()) {
+      this.updateWindow(window);
+      if (install) {
+        let {SidebarUI} = window;
+        SidebarUI.show(this.id);
+      }
+    }
+
+    // Bug 1331507: UX review/analysis of sidebar-button injection.
+    if (AppConstants.RELEASE_OR_BETA) {
+      return;
+    }
+
+    if (install && !Services.prefs.prefHasUserValue("extensions.sidebar-button.shown")) {
+      Services.prefs.setBoolPref("extensions.sidebar-button.shown", true);
+      // If the sidebar button has never been moved to the toolbar, move it now
+      // so the user can see/access the sidebars.
+      let widget = CustomizableUI.getWidget("sidebar-button");
+      if (!widget.areaType) {
+        CustomizableUI.addWidgetToArea("sidebar-button", CustomizableUI.AREA_NAVBAR, 0);
+      }
+    }
+  }
+
+  sidebarUrl(panel) {
+    if (this.extension.remote) {
+      return `${sidebarURL}?remote=1&panel=${encodeURIComponent(panel)}`;
+    }
+    return `${sidebarURL}?&panel=${encodeURIComponent(panel)}`;
+  }
+
+  createMenuItem(window, details) {
+    let {document} = window;
+
+    // Use of the broadcaster allows browser-sidebar.js to properly manage the
+    // checkmarks in the menus.
+    let broadcaster = document.createElementNS(XUL_NS, "broadcaster");
+    broadcaster.setAttribute("id", this.id);
+    broadcaster.setAttribute("autoCheck", "false");
+    broadcaster.setAttribute("type", "checkbox");
+    broadcaster.setAttribute("group", "sidebar");
+    broadcaster.setAttribute("label", details.title);
+    broadcaster.setAttribute("sidebarurl", this.sidebarUrl(details.panel));
+    // oncommand gets attached to menuitem, so we use the observes attribute to
+    // get the command id we pass to SidebarUI.
+    broadcaster.setAttribute("oncommand", "SidebarUI.toggle(this.getAttribute('observes'))");
+
+    let menuitem = document.createElementNS(XUL_NS, "menuitem");
+    menuitem.setAttribute("id", this.menuId);
+    menuitem.setAttribute("observes", this.id);
+    menuitem.setAttribute("class", "menuitem-iconic webextension-menuitem");
+
+    document.getElementById("mainBroadcasterSet").appendChild(broadcaster);
+    document.getElementById("viewSidebarMenu").appendChild(menuitem);
+
+    return menuitem;
+  }
+
+  /**
+   * Update the broadcaster and menuitem `node` with the tab context data
+   * in `tabData`.
+   *
+   * @param {ChromeWindow} window
+   *        Browser chrome window.
+   * @param {object} tabData
+   *        Tab specific sidebar configuration.
+   */
+  updateButton(window, tabData) {
+    let {document, SidebarUI} = window;
+    let title = tabData.title || this.extension.name;
+    let menu = document.getElementById(this.menuId);
+    if (!menu) {
+      menu = this.createMenuItem(window, tabData);
+    }
+
+    // Update the broadcaster first, it will update both menus.
+    let broadcaster = document.getElementById(this.id);
+    broadcaster.setAttribute("tooltiptext", title);
+    broadcaster.setAttribute("label", title);
+
+    let url = this.sidebarUrl(tabData.panel);
+    let urlChanged = url !== broadcaster.getAttribute("sidebarurl");
+    if (urlChanged) {
+      broadcaster.setAttribute("sidebarurl", url);
+    }
+
+    let getIcon = size => IconDetails.escapeUrl(
+      IconDetails.getPreferredIcon(tabData.icon, this.extension, size).icon);
+
+    menu.setAttribute("style", `
+      --webextension-menuitem-image: url("${getIcon(16)}");
+      --webextension-menuitem-image-2x: url("${getIcon(32)}");
+    `);
+
+    // Update the sidebar if this extension is the current sidebar.
+    if (SidebarUI.currentID === this.id) {
+      SidebarUI.title = title;
+      if (SidebarUI.isOpen && urlChanged) {
+        SidebarUI.show(this.id);
+      }
+    }
+  }
+
+  /**
+   * Update the broadcaster and menuitem for a given window.
+   *
+   * @param {ChromeWindow} window
+   *        Browser chrome window.
+   */
+  updateWindow(window) {
+    let nativeTab = window.gBrowser.selectedTab;
+    this.updateButton(window, this.tabContext.get(nativeTab));
+  }
+
+  /**
+   * Update the broadcaster and menuitem when the extension changes the icon,
+   * title, url, etc. If it only changes a parameter for a single
+   * tab, `tab` will be that tab. Otherwise it will be null.
+   *
+   * @param {XULElement|null} nativeTab
+   *        Browser tab, may be null.
+   */
+  updateOnChange(nativeTab) {
+    if (nativeTab) {
+      if (nativeTab.selected) {
+        this.updateWindow(nativeTab.ownerGlobal);
+      }
+    } else {
+      for (let window of windowTracker.browserWindows()) {
+        this.updateWindow(window);
+      }
+    }
+  }
+
+  /**
+   * Set a default or tab specific property.
+   *
+   * @param {XULElement|null} nativeTab
+   *        Webextension tab object, may be null.
+   * @param {string} prop
+   *        String property to retrieve ["icon", "title", or "panel"].
+   * @param {string} value
+   *        Value for property.
+   */
+  setProperty(nativeTab, prop, value) {
+    if (nativeTab === null) {
+      this.defaults[prop] = value;
+    } else if (value !== null) {
+      this.tabContext.get(nativeTab)[prop] = value;
+    } else {
+      delete this.tabContext.get(nativeTab)[prop];
+    }
+
+    this.updateOnChange(nativeTab);
+  }
+
+  /**
+   * Retrieve a property from the tab or defaults if tab is null.
+   *
+   * @param {XULElement|null} nativeTab
+   *        Browser tab object, may be null.
+   * @param {string} prop
+   *        String property to retrieve ["icon", "title", or "panel"]
+   * @returns {string} value
+   *          Value for prop.
+   */
+  getProperty(nativeTab, prop) {
+    if (nativeTab === null) {
+      return this.defaults[prop];
+    }
+    return this.tabContext.get(nativeTab)[prop];
+  }
+
+  shutdown() {
+    this.tabContext.shutdown();
+    for (let window of windowTracker.browserWindows()) {
+      let {document, SidebarUI} = window;
+      if (SidebarUI.currentID === this.id) {
+        SidebarUI.hide();
+      }
+      let menu = document.getElementById(this.menuId);
+      if (menu) {
+        menu.remove();
+      }
+      let broadcaster = document.getElementById(this.id);
+      if (broadcaster) {
+        broadcaster.remove();
+      }
+    }
+    windowTracker.removeOpenListener(this.windowOpenListener);
+  }
+}
+
+SidebarAction.for = (extension) => {
+  return sidebarActionMap.get(extension);
+};
+
+global.sidebarActionFor = SidebarAction.for;
+
+/* eslint-disable mozilla/balanced-listeners */
+extensions.on("manifest_sidebar_action", (type, directive, extension, manifest) => {
+  let sidebarAction = new SidebarAction(manifest.sidebar_action, extension);
+  sidebarActionMap.set(extension, sidebarAction);
+});
+
+extensions.on("ready", (type, extension) => {
+  // We build sidebars during ready to ensure the background scripts are ready.
+  if (sidebarActionMap.has(extension)) {
+    sidebarActionMap.get(extension).build();
+  }
+});
+
+extensions.on("shutdown", (type, extension) => {
+  if (sidebarActionMap.has(extension)) {
+    // Don't remove everything on app shutdown so session restore can handle
+    // restoring open sidebars.
+    if (extension.shutdownReason !== "APP_SHUTDOWN") {
+      sidebarActionMap.get(extension).shutdown();
+    }
+    sidebarActionMap.delete(extension);
+  }
+});
+/* eslint-enable mozilla/balanced-listeners */
+
+extensions.registerSchemaAPI("sidebarAction", "addon_parent", context => {
+  let {extension} = context;
+
+  function getTab(tabId) {
+    if (tabId !== null) {
+      return tabTracker.getTab(tabId);
+    }
+    return null;
+  }
+
+  return {
+    sidebarAction: {
+      async setTitle(details) {
+        let nativeTab = getTab(details.tabId);
+
+        let title = details.title;
+        // Clear the tab-specific title when given a null string.
+        if (nativeTab && title === "") {
+          title = null;
+        }
+        SidebarAction.for(extension).setProperty(nativeTab, "title", title);
+      },
+
+      getTitle(details) {
+        let nativeTab = getTab(details.tabId);
+
+        let title = SidebarAction.for(extension).getProperty(nativeTab, "title");
+        return Promise.resolve(title);
+      },
+
+      async setIcon(details) {
+        let nativeTab = getTab(details.tabId);
+
+        let icon = IconDetails.normalize(details, extension, context);
+        SidebarAction.for(extension).setProperty(nativeTab, "icon", icon);
+      },
+
+      async setPanel(details) {
+        let nativeTab = getTab(details.tabId);
+
+        let url;
+        // Clear the tab-specific url when given a null string.
+        if (nativeTab && details.panel === "") {
+          url = null;
+        } else if (details.panel !== "") {
+          url = context.uri.resolve(details.panel);
+        } else {
+          throw new ExtensionError("Invalid url for sidebar panel.");
+        }
+
+        SidebarAction.for(extension).setProperty(nativeTab, "panel", url);
+      },
+
+      getPanel(details) {
+        let nativeTab = getTab(details.tabId);
+
+        let panel = SidebarAction.for(extension).getProperty(nativeTab, "panel");
+        return Promise.resolve(panel);
+      },
+    },
+  };
+});
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -7,16 +7,17 @@ category webextension-scripts contextMen
 category webextension-scripts desktop-runtime chrome://browser/content/ext-desktop-runtime.js
 category webextension-scripts devtools chrome://browser/content/ext-devtools.js
 category webextension-scripts devtools-inspectedWindow chrome://browser/content/ext-devtools-inspectedWindow.js
 category webextension-scripts devtools-network chrome://browser/content/ext-devtools-network.js
 category webextension-scripts history chrome://browser/content/ext-history.js
 category webextension-scripts omnibox chrome://browser/content/ext-omnibox.js
 category webextension-scripts pageAction chrome://browser/content/ext-pageAction.js
 category webextension-scripts sessions chrome://browser/content/ext-sessions.js
+category webextension-scripts sidebarAction chrome://browser/content/ext-sidebarAction.js
 category webextension-scripts tabs chrome://browser/content/ext-tabs.js
 category webextension-scripts theme chrome://browser/content/ext-theme.js
 category webextension-scripts url-overrides chrome://browser/content/ext-url-overrides.js
 category webextension-scripts utils chrome://browser/content/ext-utils.js
 category webextension-scripts windows chrome://browser/content/ext-windows.js
 
 # scripts specific for devtools extension contexts.
 category webextension-scripts-devtools devtools-inspectedWindow chrome://browser/content/ext-c-devtools-inspectedWindow.js
@@ -35,12 +36,13 @@ category webextension-schemas context_me
 category webextension-schemas context_menus_internal chrome://browser/content/schemas/context_menus_internal.json
 category webextension-schemas devtools chrome://browser/content/schemas/devtools.json
 category webextension-schemas devtools_inspected_window chrome://browser/content/schemas/devtools_inspected_window.json
 category webextension-schemas devtools_network chrome://browser/content/schemas/devtools_network.json
 category webextension-schemas history chrome://browser/content/schemas/history.json
 category webextension-schemas omnibox chrome://browser/content/schemas/omnibox.json
 category webextension-schemas page_action chrome://browser/content/schemas/page_action.json
 category webextension-schemas sessions chrome://browser/content/schemas/sessions.json
+category webextension-schemas sidebar_action chrome://browser/content/schemas/sidebar_action.json
 category webextension-schemas tabs chrome://browser/content/schemas/tabs.json
 category webextension-schemas theme chrome://browser/content/schemas/theme.json
 category webextension-schemas url_overrides chrome://browser/content/schemas/url_overrides.json
 category webextension-schemas windows chrome://browser/content/schemas/windows.json
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -20,16 +20,17 @@ browser.jar:
     content/browser/ext-desktop-runtime.js
     content/browser/ext-devtools.js
     content/browser/ext-devtools-inspectedWindow.js
     content/browser/ext-devtools-network.js
     content/browser/ext-history.js
     content/browser/ext-omnibox.js
     content/browser/ext-pageAction.js
     content/browser/ext-sessions.js
+    content/browser/ext-sidebarAction.js
     content/browser/ext-tabs.js
     content/browser/ext-theme.js
     content/browser/ext-url-overrides.js
     content/browser/ext-utils.js
     content/browser/ext-windows.js
     content/browser/ext-c-contextMenus.js
     content/browser/ext-c-devtools-inspectedWindow.js
     content/browser/ext-c-omnibox.js
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -11,12 +11,13 @@ browser.jar:
     content/browser/schemas/context_menus_internal.json
     content/browser/schemas/devtools.json
     content/browser/schemas/devtools_inspected_window.json
     content/browser/schemas/devtools_network.json
     content/browser/schemas/history.json
     content/browser/schemas/omnibox.json
     content/browser/schemas/page_action.json
     content/browser/schemas/sessions.json
+    content/browser/schemas/sidebar_action.json
     content/browser/schemas/tabs.json
     content/browser/schemas/theme.json
     content/browser/schemas/url_overrides.json
     content/browser/schemas/windows.json
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/sidebar_action.json
@@ -0,0 +1,183 @@
+/* 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/. */
+
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "sidebar_action": {
+            "type": "object",
+            "additionalProperties": { "$ref": "UnrecognizedProperty" },
+            "properties": {
+              "default_title": {
+                "type": "string",
+                "optional": true,
+                "preprocess": "localize"
+              },
+              "default_icon": {
+                "$ref": "IconPath",
+                "optional": true
+              },
+              "default_panel": {
+                "type": "string",
+                "format": "strictRelativeUrl",
+                "preprocess": "localize"
+              }
+            },
+            "optional": true
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "sidebarAction",
+    "description": "Use sidebar actions to add a sidebar to Firefox.",
+    "permissions": ["manifest:sidebar_action"],
+    "types": [
+      {
+        "id": "ImageDataType",
+        "type": "object",
+        "isInstanceOf": "ImageData",
+        "additionalProperties": { "type": "any" },
+        "postprocess": "convertImageDataToURL",
+        "description": "Pixel data for an image. Must be an ImageData object (for example, from a <code>canvas</code> element)."
+      }
+    ],
+    "functions": [
+      {
+        "name": "setTitle",
+        "type": "function",
+        "description": "Sets the title of the sidebar action. This shows up in the tooltip.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "title": {
+                "type": "string",
+                "description": "The string the sidebar action should display when moused over."
+              },
+              "tabId": {
+                "type": "integer",
+                "optional": true,
+                "description": "Sets the sidebar title for the tab specified by tabId. Automatically resets when the tab is closed."
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "getTitle",
+        "type": "function",
+        "description": "Gets the title of the sidebar action.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "tabId": {
+                "type": "integer",
+                "optional": true,
+                "description": "Specify the tab to get the title from. If no tab is specified, the non-tab-specific title is returned."
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "setIcon",
+        "type": "function",
+        "description": "Sets the icon for the sidebar action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the <strong>path</strong> or the <strong>imageData</strong> property must be specified.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "imageData": {
+                "choices": [
+                  { "$ref": "ImageDataType" },
+                  {
+                    "type": "object",
+                    "patternProperties": {
+                      "^[1-9]\\d*$": { "$ref": "ImageDataType" }
+                    },
+                    "additionalProperties": false
+                  }
+                ],
+                "optional": true,
+                "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'"
+              },
+              "path": {
+                "choices": [
+                  { "type": "string" },
+                  {
+                    "type": "object",
+                    "additionalProperties": {"type": "string"}
+                  }
+                ],
+                "optional": true,
+                "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals <code>scale</code>, then image with size <code>scale</code> * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'"
+              },
+              "tabId": {
+                "type": "integer",
+                "optional": true,
+                "description": "Sets the sidebar icon for the tab specified by tabId. Automatically resets when the tab is closed."
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "setPanel",
+        "type": "function",
+        "description": "Sets the url to the html document to be opened in the sidebar when the user clicks on the sidebar action's icon.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "tabId": {
+                "type": "integer",
+                "optional": true,
+                "minimum": 0,
+                "description": "Sets the sidebar url for the tab specified by tabId. Automatically resets when the tab is closed."
+              },
+              "panel": {
+                "type": "string",
+                "description": "The url to the html file to show in a sidebar.  If set to the empty string (''), no sidebar is shown."
+              }
+            }
+          }
+        ]
+      },
+      {
+        "name": "getPanel",
+        "type": "function",
+        "description": "Gets the url to the html document set as the panel for this sidebar action.",
+        "async": true,
+        "parameters": [
+          {
+            "name": "details",
+            "type": "object",
+            "properties": {
+              "tabId": {
+                "type": "integer",
+                "optional": true,
+                "description": "Specify the tab to get the sidebar from. If no tab is specified, the non-tab-specific sidebar is returned."
+              }
+            }
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -72,16 +72,18 @@ support-files =
 [browser_ext_popup_shutdown.js]
 [browser_ext_runtime_openOptionsPage.js]
 [browser_ext_runtime_openOptionsPage_uninstall.js]
 [browser_ext_runtime_setUninstallURL.js]
 [browser_ext_sessions_getRecentlyClosed.js]
 [browser_ext_sessions_getRecentlyClosed_private.js]
 [browser_ext_sessions_getRecentlyClosed_tabs.js]
 [browser_ext_sessions_restore.js]
+[browser_ext_sidebarAction.js]
+[browser_ext_sidebarAction_context.js]
 [browser_ext_simple.js]
 [browser_ext_tab_runtimeConnect.js]
 [browser_ext_tabs_audio.js]
 [browser_ext_tabs_captureVisibleTab.js]
 [browser_ext_tabs_create.js]
 [browser_ext_tabs_create_invalid_url.js]
 [browser_ext_tabs_detectLanguage.js]
 [browser_ext_tabs_duplicate.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js
@@ -0,0 +1,122 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let extData = {
+  manifest: {
+    "sidebar_action": {
+      "default_panel": "sidebar.html",
+    },
+  },
+  useAddonManager: "temporary",
+
+  files: {
+    "sidebar.html": `
+      <!DOCTYPE html>
+      <html>
+      <head><meta charset="utf-8"/>
+      <script src="sidebar.js"></script>
+      </head>
+      <body>
+      A Test Sidebar
+      </body></html>
+    `,
+
+    "sidebar.js": function() {
+      window.onload = () => {
+        browser.test.sendMessage("sidebar");
+      };
+    },
+  },
+
+  background: function() {
+    browser.test.onMessage.addListener(msg => {
+      if (msg === "set-panel") {
+        browser.sidebarAction.setPanel({panel: ""}).then(() => {
+          browser.test.notifyFail("empty panel settable");
+        }).catch(() => {
+          browser.test.notifyPass("unable to set empty panel");
+        });
+      }
+    });
+  },
+};
+
+add_task(function* sidebar_initial_install() {
+  ok(document.getElementById("sidebar-box").hidden, "sidebar box is not visible");
+  let extension = ExtensionTestUtils.loadExtension(extData);
+  yield extension.startup();
+  // Test sidebar is opened on install
+  yield extension.awaitMessage("sidebar");
+  ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible");
+  // Test toolbar button is available
+  ok(document.getElementById("sidebar-button"), "sidebar button is in UI");
+
+  yield extension.unload();
+  // Test that the sidebar was closed on unload.
+  ok(document.getElementById("sidebar-box").hidden, "sidebar box is not visible");
+
+  // Move toolbar button back to customization.
+  CustomizableUI.removeWidgetFromArea("sidebar-button", CustomizableUI.AREA_NAVBAR);
+  ok(!document.getElementById("sidebar-button"), "sidebar button is not in UI");
+});
+
+
+add_task(function* sidebar_two_sidebar_addons() {
+  let extension2 = ExtensionTestUtils.loadExtension(extData);
+  yield extension2.startup();
+  // Test sidebar is opened on install
+  yield extension2.awaitMessage("sidebar");
+  ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible");
+  // Test toolbar button is NOT available after first install
+  ok(!document.getElementById("sidebar-button"), "sidebar button is not in UI");
+
+  // Test second sidebar install opens new sidebar
+  let extension3 = ExtensionTestUtils.loadExtension(extData);
+  yield extension3.startup();
+  // Test sidebar is opened on install
+  yield extension3.awaitMessage("sidebar");
+  ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible");
+  yield extension3.unload();
+
+  // We just close the sidebar on uninstall of the current sidebar.
+  ok(document.getElementById("sidebar-box").hidden, "sidebar box is not visible");
+
+  yield extension2.unload();
+});
+
+add_task(function* sidebar_windows() {
+  let extension = ExtensionTestUtils.loadExtension(extData);
+  yield extension.startup();
+  // Test sidebar is opened on install
+  yield extension.awaitMessage("sidebar");
+  ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible in first window");
+
+  let secondSidebar = extension.awaitMessage("sidebar");
+
+  // SidebarUI relies on window.opener being set, which is normal behavior when
+  // using menu or key commands to open a new browser window.
+  let win = yield BrowserTestUtils.openNewBrowserWindow({opener: window});
+
+  yield secondSidebar;
+  ok(!win.document.getElementById("sidebar-box").hidden, "sidebar box is visible in second window");
+
+  yield extension.unload();
+  yield BrowserTestUtils.closeWindow(win);
+});
+
+add_task(function* sidebar_empty_panel() {
+  let extension = ExtensionTestUtils.loadExtension(extData);
+  yield extension.startup();
+  // Test sidebar is opened on install
+  yield extension.awaitMessage("sidebar");
+  ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible in first window");
+  extension.sendMessage("set-panel");
+  yield extension.awaitFinish();
+  yield extension.unload();
+});
+
+add_task(function* cleanup() {
+  // This is set on initial sidebar install.
+  Services.prefs.clearUserPref("extensions.sidebar-button.shown");
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js
@@ -0,0 +1,381 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+
+SpecialPowers.pushPrefEnv({
+  // Ignore toolbarbutton stuff, other test covers it.
+  set: [["extensions.sidebar-button.shown", true]],
+});
+
+function* runTests(options) {
+  async function background(getTests) {
+    async function checkDetails(expecting, tabId) {
+      let title = await browser.sidebarAction.getTitle({tabId});
+      browser.test.assertEq(expecting.title, title,
+                            "expected value from getTitle");
+
+      let panel = await browser.sidebarAction.getPanel({tabId});
+      browser.test.assertEq(expecting.panel, panel,
+                            "expected value from getPanel");
+    }
+
+    let expectDefaults = expecting => {
+      return checkDetails(expecting);
+    };
+
+    let tabs = [];
+    let tests = getTests(tabs, expectDefaults);
+
+    {
+      let tabId = 0xdeadbeef;
+      let calls = [
+        () => browser.sidebarAction.setTitle({tabId, title: "foo"}),
+        () => browser.sidebarAction.setIcon({tabId, path: "foo.png"}),
+        () => browser.sidebarAction.setPanel({tabId, panel: "foo.html"}),
+      ];
+
+      for (let call of calls) {
+        await browser.test.assertRejects(
+          new Promise(resolve => resolve(call())),
+          RegExp(`Invalid tab ID: ${tabId}`),
+          "Expected invalid tab ID error");
+      }
+    }
+
+    // Runs the next test in the `tests` array, checks the results,
+    // and passes control back to the outer test scope.
+    function nextTest() {
+      let test = tests.shift();
+
+      test(async expecting => {
+        // Check that the API returns the expected values, and then
+        // run the next test.
+        let tabs = await browser.tabs.query({active: true, currentWindow: true});
+        await checkDetails(expecting, tabs[0].id);
+
+        // Check that the actual icon has the expected values, then
+        // run the next test.
+        browser.test.sendMessage("nextTest", expecting, tests.length);
+      });
+    }
+
+    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;
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: options.manifest,
+    useAddonManager: "temporary",
+
+    files: options.files || {},
+
+    background: `(${background})(${options.getTests})`,
+  });
+
+  let sidebarActionId;
+  function checkDetails(details) {
+    if (!sidebarActionId) {
+      sidebarActionId = `${makeWidgetId(extension.id)}-sidebar-action`;
+    }
+
+    let command = document.getElementById(sidebarActionId);
+    ok(command, "command exists");
+
+    let menuId = `menu_${sidebarActionId}`;
+    let menu = document.getElementById(menuId);
+    ok(menu, "menu exists");
+
+    let title = details.title || options.manifest.name;
+
+    is(getListStyleImage(menu), details.icon, "icon URL is correct");
+    is(menu.getAttribute("label"), title, "image label is correct");
+  }
+
+  let awaitFinish = new Promise(resolve => {
+    extension.onMessage("nextTest", (expecting, testsRemaining) => {
+      checkDetails(expecting);
+
+      if (testsRemaining) {
+        extension.sendMessage("runNextTest");
+      } else {
+        resolve();
+      }
+    });
+  });
+
+  // Wait for initial sidebar load to start tests.
+  SidebarUI.browser.addEventListener("load", event => {
+    extension.sendMessage("runNextTest");
+  }, {capture: true, once: true});
+
+  yield extension.startup();
+  yield awaitFinish;
+  yield extension.unload();
+}
+
+let sidebar = `
+  <!DOCTYPE html>
+  <html>
+  <head><meta charset="utf-8"/></head>
+  <body>
+  A Test Sidebar
+  </body></html>
+`;
+
+add_task(function* testTabSwitchContext() {
+  yield runTests({
+    manifest: {
+      "sidebar_action": {
+        "default_icon": "default.png",
+        "default_panel": "__MSG_panel__",
+        "default_title": "Default __MSG_title__",
+      },
+
+      "default_locale": "en",
+
+      "permissions": ["tabs"],
+    },
+
+    "files": {
+      "default.html": sidebar,
+      "default-2.html": sidebar,
+      "2.html": sidebar,
+
+      "_locales/en/messages.json": {
+        "panel": {
+          "message": "default.html",
+          "description": "Panel",
+        },
+
+        "title": {
+          "message": "Title",
+          "description": "Title",
+        },
+      },
+
+      "default.png": imageBuffer,
+      "default-2.png": imageBuffer,
+      "1.png": imageBuffer,
+      "2.png": imageBuffer,
+    },
+
+    getTests(tabs, expectDefaults) {
+      let details = [
+        {"icon": browser.runtime.getURL("default.png"),
+         "panel": browser.runtime.getURL("default.html"),
+         "title": "Default Title",
+        },
+        {"icon": browser.runtime.getURL("1.png"),
+         "panel": browser.runtime.getURL("default.html"),
+         "title": "Default Title",
+        },
+        {"icon": browser.runtime.getURL("2.png"),
+         "panel": browser.runtime.getURL("2.html"),
+         "title": "Title 2",
+        },
+        {"icon": browser.runtime.getURL("1.png"),
+         "panel": browser.runtime.getURL("default-2.html"),
+         "title": "Default Title 2",
+        },
+        {"icon": browser.runtime.getURL("1.png"),
+         "panel": browser.runtime.getURL("default-2.html"),
+         "title": "Default Title 2",
+        },
+        {"icon": browser.runtime.getURL("default-2.png"),
+         "panel": browser.runtime.getURL("default-2.html"),
+         "title": "Default Title 2",
+        },
+        {"icon": browser.runtime.getURL("1.png"),
+         "panel": browser.runtime.getURL("2.html"),
+         "title": "Default Title 2",
+        },
+      ];
+
+      return [
+        async expect => {
+          browser.test.log("Initial state, expect default properties.");
+
+          await expectDefaults(details[0]);
+          expect(details[0]);
+        },
+        async expect => {
+          browser.test.log("Change the icon in the current tab. Expect default properties excluding the icon.");
+          await browser.sidebarAction.setIcon({tabId: tabs[0], path: "1.png"});
+
+          await expectDefaults(details[0]);
+          expect(details[1]);
+        },
+        async expect => {
+          browser.test.log("Create a new tab. Expect default properties.");
+          let tab = await browser.tabs.create({active: true, url: "about:blank?0"});
+          tabs.push(tab.id);
+
+          await expectDefaults(details[0]);
+          expect(details[0]);
+        },
+        async expect => {
+          browser.test.log("Change properties. Expect new properties.");
+          let tabId = tabs[1];
+          await Promise.all([
+            browser.sidebarAction.setIcon({tabId, path: "2.png"}),
+            browser.sidebarAction.setPanel({tabId, panel: "2.html"}),
+            browser.sidebarAction.setTitle({tabId, title: "Title 2"}),
+          ]);
+          await expectDefaults(details[0]);
+          expect(details[2]);
+        },
+        expect => {
+          browser.test.log("Navigate to a new page. Expect no changes.");
+
+          // 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(details[2]);
+            }
+          });
+
+          browser.tabs.update(tabs[1], {url: "about:blank?1"});
+        },
+        async expect => {
+          browser.test.log("Switch back to the first tab. Expect previously set properties.");
+          await browser.tabs.update(tabs[0], {active: true});
+          expect(details[1]);
+        },
+        async expect => {
+          browser.test.log("Change default values, expect those changes reflected.");
+          await Promise.all([
+            browser.sidebarAction.setIcon({path: "default-2.png"}),
+            browser.sidebarAction.setPanel({panel: "default-2.html"}),
+            browser.sidebarAction.setTitle({title: "Default Title 2"}),
+          ]);
+
+          await expectDefaults(details[3]);
+          expect(details[3]);
+        },
+        async expect => {
+          browser.test.log("Switch back to tab 2. Expect former value, unaffected by changes to defaults in previous step.");
+          await browser.tabs.update(tabs[1], {active: true});
+
+          await expectDefaults(details[3]);
+          expect(details[2]);
+        },
+        async expect => {
+          browser.test.log("Delete tab, switch back to tab 1. Expect previous results again.");
+          await browser.tabs.remove(tabs[1]);
+          expect(details[4]);
+        },
+        async expect => {
+          browser.test.log("Create a new tab. Expect new default properties.");
+          let tab = await browser.tabs.create({active: true, url: "about:blank?2"});
+          tabs.push(tab.id);
+          expect(details[5]);
+        },
+        async expect => {
+          browser.test.log("Delete tab.");
+          await browser.tabs.remove(tabs[2]);
+          expect(details[4]);
+        },
+        async expect => {
+          browser.test.log("Change tab panel.");
+          let tabId = tabs[0];
+          await browser.sidebarAction.setPanel({tabId, panel: "2.html"});
+          expect(details[6]);
+        },
+        async expect => {
+          browser.test.log("Revert tab panel.");
+          let tabId = tabs[0];
+          await browser.sidebarAction.setPanel({tabId, panel: ""});
+          expect(details[4]);
+        },
+      ];
+    },
+  });
+});
+
+add_task(function* testDefaultTitle() {
+  yield runTests({
+    manifest: {
+      "name": "Foo Extension",
+
+      "sidebar_action": {
+        "default_icon": "icon.png",
+        "default_panel": "sidebar.html",
+      },
+
+      "permissions": ["tabs"],
+    },
+
+    files: {
+      "sidebar.html": sidebar,
+      "icon.png": imageBuffer,
+    },
+
+    getTests(tabs, expectDefaults) {
+      let details = [
+        {"title": "Foo Extension",
+         "panel": browser.runtime.getURL("sidebar.html"),
+         "icon": browser.runtime.getURL("icon.png")},
+        {"title": "Foo Title",
+         "panel": browser.runtime.getURL("sidebar.html"),
+         "icon": browser.runtime.getURL("icon.png")},
+        {"title": "Bar Title",
+         "panel": browser.runtime.getURL("sidebar.html"),
+         "icon": browser.runtime.getURL("icon.png")},
+        {"title": "",
+         "panel": browser.runtime.getURL("sidebar.html"),
+         "icon": browser.runtime.getURL("icon.png")},
+      ];
+
+      return [
+        async expect => {
+          browser.test.log("Initial state. Expect extension title as default title.");
+
+          await expectDefaults(details[0]);
+          expect(details[0]);
+        },
+        async expect => {
+          browser.test.log("Change the title. Expect new title.");
+          browser.sidebarAction.setTitle({tabId: tabs[0], title: "Foo Title"});
+
+          await expectDefaults(details[0]);
+          expect(details[1]);
+        },
+        async expect => {
+          browser.test.log("Change the default. Expect same properties.");
+          browser.sidebarAction.setTitle({title: "Bar Title"});
+
+          await expectDefaults(details[2]);
+          expect(details[1]);
+        },
+        async expect => {
+          browser.test.log("Clear the title. Expect new default title.");
+          browser.sidebarAction.setTitle({tabId: tabs[0], title: ""});
+
+          await expectDefaults(details[2]);
+          expect(details[2]);
+        },
+        async expect => {
+          browser.test.log("Set default title to null string. Expect null string from API, extension title in UI.");
+          browser.sidebarAction.setTitle({title: ""});
+
+          await expectDefaults(details[3]);
+          expect(details[3]);
+        },
+      ];
+    },
+  });
+});
--- a/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js
+++ b/browser/components/privatebrowsing/test/browser/browser_privatebrowsing_sidebar.js
@@ -5,42 +5,19 @@
 // This test makes sure that Sidebars do not migrate across windows with
 // different privacy states
 
 // See Bug 885054: https://bugzilla.mozilla.org/show_bug.cgi?id=885054
 
 function test() {
   waitForExplicitFinish();
 
-  let { utils: Cu } = Components;
-
-  let { Promise: { defer } } = Cu.import("resource://gre/modules/Promise.jsm", {});
-
   // opens a sidebar
   function openSidebar(win) {
-    let { promise, resolve } = defer();
-    let doc = win.document;
-
-    let sidebarID = 'viewBookmarksSidebar';
-
-    let sidebar = doc.getElementById('sidebar');
-
-    let sidebarurl = doc.getElementById(sidebarID).getAttribute('sidebarurl');
-
-    sidebar.addEventListener('load', function onSidebarLoad() {
-      if (sidebar.contentWindow.location.href != sidebarurl)
-        return;
-      sidebar.removeEventListener('load', onSidebarLoad, true);
-
-      resolve(win);
-    }, true);
-
-    win.SidebarUI.show(sidebarID);
-
-    return promise;
+    return win.SidebarUI.show("viewBookmarksSidebar").then(() => win);
   }
 
   let windowCache = [];
   function cacheWindow(w) {
     windowCache.push(w);
     return w;
   }
   function closeCachedWindows () {
--- a/browser/components/privatebrowsing/test/browser/head.js
+++ b/browser/components/privatebrowsing/test/browser/head.js
@@ -11,27 +11,20 @@ function whenNewWindowLoaded(aOptions, a
   let startupFinished = TestUtils.topicObserved("browser-delayed-startup-finished",
                                                 subject => subject == win).then(() => win);
   Promise.all([focused, startupFinished])
     .then(results => executeSoon(() => aCallback(results[1])));
 
   return win;
 }
 
-function openWindow(aParent, aOptions, a3) {
-  let { Promise: { defer } } = Components.utils.import("resource://gre/modules/Promise.jsm", {});
-  let { promise, resolve } = defer();
-
+function openWindow(aParent, aOptions) {
   let win = aParent.OpenBrowserWindow(aOptions);
-
-  win.addEventListener("load", function() {
-    resolve(win);
-  }, {once: true});
-
-  return promise;
+  return TestUtils.topicObserved("browser-delayed-startup-finished",
+                                 subject => subject == win).then(() => win);
 }
 
 function newDirectory() {
   let FileUtils =
     Cu.import("resource://gre/modules/FileUtils.jsm", {}).FileUtils;
   let tmpDir = FileUtils.getDir("TmpD", [], true);
   let dir = tmpDir.clone();
   dir.append("testdir");
--- a/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm
+++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtils.jsm
@@ -474,16 +474,21 @@ this.BrowserTestUtils = {
    * @return {Promise}
    *         Resolves with the new window once it is loaded.
    */
   openNewBrowserWindow: Task.async(function*(options={}) {
     let argString = Cc["@mozilla.org/supports-string;1"].
                     createInstance(Ci.nsISupportsString);
     argString.data = "";
     let features = "chrome,dialog=no,all";
+    let opener = null;
+
+    if (options.opener) {
+      opener = options.opener;
+    }
 
     if (options.private) {
       features += ",private";
     }
 
     if (options.width) {
       features += ",width=" + options.width;
     }
@@ -492,17 +497,17 @@ this.BrowserTestUtils = {
     }
 
     if (options.hasOwnProperty("remote")) {
       let remoteState = options.remote ? "remote" : "non-remote";
       features += `,${remoteState}`;
     }
 
     let win = Services.ww.openWindow(
-      null, Services.prefs.getCharPref("browser.chromeURL"), "_blank",
+      opener, Services.prefs.getCharPref("browser.chromeURL"), "_blank",
       features, argString);
 
     // Wait for browser-delayed-startup-finished notification, it indicates
     // that the window has loaded completely and is ready to be used for
     // testing.
     let startupPromise =
       TestUtils.topicObserved("browser-delayed-startup-finished",
                               subject => subject == win).then(() => win);
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -920,17 +920,18 @@ this.Extension = class extends Extension
       // We can't delete this file until everyone using it has
       // closed it (because Windows is dumb). So we wait for all the
       // child processes (including the parent) to flush their JAR
       // caches. These caches may keep the file open.
       file.remove(false);
     });
   }
 
-  shutdown() {
+  shutdown(reason) {
+    this.shutdownReason = reason;
     this.hasShutdown = true;
 
     Services.ppmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this);
 
     if (!this.manifest) {
       ExtensionManagement.shutdownExtension(this.uuid);
 
       this.cleanupGeneratedFile();
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -760,17 +760,17 @@ class ExtensionBaseContextChild extends 
    * This ExtensionBaseContextChild represents an addon execution environment
    * that is running in an addon or devtools child process.
    *
    * @param {BrowserExtensionContent} extension This context's owner.
    * @param {object} params
    * @param {string} params.envType One of "addon_child" or "devtools_child".
    * @param {nsIDOMWindow} params.contentWindow The window where the addon runs.
    * @param {string} params.viewType One of "background", "popup", "tab",
-   *   "devtools_page" or "devtools_panel".
+   *   "sidebar", "devtools_page" or "devtools_panel".
    * @param {number} [params.tabId] This tab's ID, used if viewType is "tab".
    */
   constructor(extension, params) {
     if (!params.envType) {
       throw new Error("Missing envType");
     }
 
     super(params.envType, extension);
@@ -812,17 +812,17 @@ class ExtensionBaseContextChild extends 
     return this.contentWindow;
   }
 
   get principal() {
     return this.contentWindow.document.nodePrincipal;
   }
 
   get windowId() {
-    if (this.viewType == "tab" || this.viewType == "popup") {
+    if (["tab", "popup", "sidebar"].includes(this.viewType)) {
       let globalView = ExtensionChild.contentGlobals.get(this.messageManager);
       return globalView ? globalView.windowId : -1;
     }
   }
 
   // Called when the extension shuts down.
   shutdown() {
     this.unload();
@@ -864,18 +864,18 @@ class ExtensionPageContextChild extends 
    * APIs (provided that the correct permissions have been requested).
    *
    * This is the child side of the ExtensionPageContextParent class
    * defined in ExtensionParent.jsm.
    *
    * @param {BrowserExtensionContent} extension This context's owner.
    * @param {object} params
    * @param {nsIDOMWindow} params.contentWindow The window where the addon runs.
-   * @param {string} params.viewType One of "background", "popup" or "tab".
-   *     "background" and "tab" are used by `browser.extension.getViews`.
+   * @param {string} params.viewType One of "background", "popup", "sidebar" or "tab".
+   *     "background", "sidebar" and "tab" are used by `browser.extension.getViews`.
    *     "popup" is only used internally to identify page action and browser
    *     action popups and options_ui pages.
    * @param {number} [params.tabId] This tab's ID, used if viewType is "tab".
    */
   constructor(extension, params) {
     super(extension, Object.assign(params, {envType: "addon_child"}));
 
     this.extension.views.add(this);
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -312,16 +312,23 @@ let IconDetails = {
 
         ctx.drawImage(this, 0, 0, this.width, this.height, dx, dy, dWidth, dHeight);
         resolve(canvas.toDataURL("image/png"));
       };
       image.onerror = reject;
       image.src = imageURL;
     });
   },
+
+  // These URLs should already be properly escaped, but make doubly sure CSS
+  // string escape characters are escaped here, since they could lead to a
+  // sandbox break.
+  escapeUrl(url) {
+    return url.replace(/[\\\s"]/g, encodeURIComponent);
+  },
 };
 
 const LISTENERS = Symbol("listeners");
 
 class EventEmitter {
   constructor() {
     this[LISTENERS] = new Map();
   }
--- a/toolkit/components/extensions/schemas/extension.json
+++ b/toolkit/components/extensions/schemas/extension.json
@@ -26,17 +26,17 @@
         "allowedContexts": ["content", "devtools"],
         "description": "True for content scripts running inside incognito tabs, and for extension pages running inside an incognito process. The latter only applies to extensions with 'split' incognito_behavior."
       }
     },
     "types": [
       {
         "id": "ViewType",
         "type": "string",
-        "enum": ["tab", "notification", "popup"],
+        "enum": ["tab", "notification", "popup", "sidebar"],
         "description": "The type of extension view."
       }
     ],
     "functions": [
       {
         "name": "getURL",
         "type": "function",
         "allowedContexts": ["content", "devtools"],
--- a/toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
+++ b/toolkit/mozapps/extensions/internal/WebExtensionBootstrap.js
@@ -25,13 +25,13 @@ function install(data, reason) {
 }
 
 function startup(data, reason) {
   extension = new Extension(data, BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
   extension.startup();
 }
 
 function shutdown(data, reason) {
-  extension.shutdown();
+  extension.shutdown(BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
 }
 
 function uninstall(data, reason) {
 }