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 341640 1e0dc444f8e3
parent 341639 3c9874eb6cc3
child 341641 76acb5c47210
push id37193
push usermixedpuppy@gmail.com
push date2017-02-09 23:38 +0000
treeherderautoland@1e0dc444f8e3 [default view] [failures only]
reviewersGijs, kmag
bugs1208596
milestone54.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 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) {
 }