Bug 1530402 - Refactor desktop's ext-{page,browser}Action. r=mixedpuppy
☠☠ backed out by 8f3cbd66bbc3 ☠ ☠
authorAgi Sferro <agi@sferro.dev>
Wed, 13 Nov 2019 20:28:56 +0000
changeset 501837 3638fcff5bed7cbfff4762b8a8529a64b762ef3b
parent 501836 9630bed0ca2cf583b56b3bc597906ecbbf8e87f8
child 501838 cf16e02e62cda5c70aca83c51bb446e5e4556c0e
push id114172
push userdluca@mozilla.com
push dateTue, 19 Nov 2019 11:31:10 +0000
treeherdermozilla-inbound@b5c5ba07d3db [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy
bugs1530402
milestone72.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 1530402 - Refactor desktop's ext-{page,browser}Action. r=mixedpuppy This patch refactors Desktop's implementation of Page and Browser actions to decouple the logic inside a common Action object that can be used to implement actions on other platforms, like mobile, without re-implementing it. A follow-up commit will use this common logic on the mobile implementation. Differential Revision: https://phabricator.services.mozilla.com/D49036
browser/components/extensions/parent/ext-browserAction.js
browser/components/extensions/parent/ext-pageAction.js
browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.js
toolkit/components/extensions/ExtensionActions.jsm
toolkit/components/extensions/moz.build
--- a/browser/components/extensions/parent/ext-browserAction.js
+++ b/browser/components/extensions/parent/ext-browserAction.js
@@ -27,141 +27,140 @@ ChromeUtils.defineModuleGetter(
   "resource://gre/modules/Timer.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "ViewPopup",
   "resource:///modules/ExtensionPopups.jsm"
 );
 
-var { DefaultWeakMap, ExtensionError } = ExtensionUtils;
+var { DefaultWeakMap } = ExtensionUtils;
 
 var { ExtensionParent } = ChromeUtils.import(
   "resource://gre/modules/ExtensionParent.jsm"
 );
+var { BrowserActionBase } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionActions.jsm"
+);
 
 var { IconDetails, StartupCache } = ExtensionParent;
 
-XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
-
 const POPUP_PRELOAD_TIMEOUT_MS = 200;
 
 // WeakMap[Extension -> BrowserAction]
 const browserActionMap = new WeakMap();
 
 XPCOMUtils.defineLazyGetter(this, "browserAreas", () => {
   return {
     navbar: CustomizableUI.AREA_NAVBAR,
     menupanel: CustomizableUI.AREA_FIXED_OVERFLOW_PANEL,
     tabstrip: CustomizableUI.AREA_TABSTRIP,
     personaltoolbar: CustomizableUI.AREA_BOOKMARKS,
   };
 });
 
+class BrowserAction extends BrowserActionBase {
+  constructor(extension, buttonDelegate) {
+    let tabContext = new TabContext(target => {
+      let window = target.ownerGlobal;
+      if (target === window) {
+        return this.getContextData(null);
+      }
+      return tabContext.get(window);
+    });
+    super(tabContext, extension);
+    this.buttonDelegate = buttonDelegate;
+  }
+
+  updateOnChange(target) {
+    if (target) {
+      let window = target.ownerGlobal;
+      if (target === window || target.selected) {
+        this.buttonDelegate.updateWindow(window);
+      }
+    } else {
+      for (let window of windowTracker.browserWindows()) {
+        this.buttonDelegate.updateWindow(window);
+      }
+    }
+  }
+
+  getTab(tabId) {
+    if (tabId !== null) {
+      return tabTracker.getTab(tabId);
+    }
+    return null;
+  }
+
+  getWindow(windowId) {
+    if (windowId !== null) {
+      return windowTracker.getWindow(windowId);
+    }
+    return null;
+  }
+}
+
 this.browserAction = class extends ExtensionAPI {
   static for(extension) {
     return browserActionMap.get(extension);
   }
 
   async onManifestEntry(entryName) {
     let { extension } = this;
 
     let options = extension.manifest.browser_action;
 
+    this.action = new BrowserAction(extension, this);
+    await this.action.loadIconData();
+
     this.iconData = new DefaultWeakMap(icons => this.getIconData(icons));
+    this.iconData.set(
+      this.action.getIcon(),
+      await StartupCache.get(
+        extension,
+        ["browserAction", "default_icon_data"],
+        () => this.getIconData(this.action.getIcon())
+      )
+    );
 
     let widgetId = makeWidgetId(extension.id);
     this.id = `${widgetId}-browser-action`;
     this.viewId = `PanelUI-webext-${widgetId}-browser-action-view`;
     this.widget = null;
 
     this.pendingPopup = null;
     this.pendingPopupTimeout = null;
     this.eventQueue = [];
 
     this.tabManager = extension.tabManager;
-
-    this.defaults = {
-      enabled: true,
-      title: options.default_title || extension.name,
-      badgeText: "",
-      badgeBackgroundColor: [0xd9, 0, 0, 255],
-      badgeDefaultColor: [255, 255, 255, 255],
-      badgeTextColor: null,
-      popup: options.default_popup || "",
-      area: browserAreas[options.default_area || "navbar"],
-    };
-    this.globals = Object.create(this.defaults);
-
     this.browserStyle = options.browser_style;
 
     browserActionMap.set(extension, this);
 
-    this.defaults.icon = await StartupCache.get(
-      extension,
-      ["browserAction", "default_icon"],
-      () =>
-        IconDetails.normalize(
-          {
-            path: options.default_icon || extension.manifest.icons,
-            iconType: "browserAction",
-            themeIcons: options.theme_icons,
-          },
-          extension
-        )
-    );
-
-    this.iconData.set(
-      this.defaults.icon,
-      await StartupCache.get(
-        extension,
-        ["browserAction", "default_icon_data"],
-        () => this.getIconData(this.defaults.icon)
-      )
-    );
-
-    this.tabContext = new TabContext(target => {
-      let window = target.ownerGlobal;
-      if (target === window) {
-        return this.globals;
-      }
-      return this.tabContext.get(window);
-    });
-
-    // eslint-disable-next-line mozilla/balanced-listeners
-    this.tabContext.on("location-change", this.handleLocationChange.bind(this));
-
     this.build();
   }
 
-  handleLocationChange(eventType, tab, fromBrowse) {
-    if (fromBrowse) {
-      this.tabContext.clear(tab);
-      this.updateOnChange(tab);
-    }
-  }
-
   onShutdown() {
     browserActionMap.delete(this.extension);
+    this.action.onShutdown();
 
-    this.tabContext.shutdown();
     CustomizableUI.destroyWidget(this.id);
 
     this.clearPopup();
   }
 
   build() {
     let widget = CustomizableUI.createWidget({
       id: this.id,
       viewId: this.viewId,
       type: "view",
       removable: true,
-      label: this.defaults.title || this.extension.name,
-      tooltiptext: this.defaults.title || "",
-      defaultArea: this.defaults.area,
+      label: this.action.getProperty(null, "title"),
+      tooltiptext: this.action.getProperty(null, "title"),
+      defaultArea: browserAreas[this.action.getDefaultArea()],
       showInPrivateBrowsing: this.extension.privateBrowsingAllowed,
 
       // Don't attempt to load properties from the built-in widget string
       // bundle.
       localized: false,
 
       onBeforeCreated: document => {
         let view = document.createXULElement("panelview");
@@ -197,17 +196,17 @@ this.browserAction = class extends Exten
         node.setAttribute("constrain-size", "true");
         node.setAttribute("data-extensionid", this.extension.id);
 
         node.onmousedown = event => this.handleEvent(event);
         node.onmouseover = event => this.handleEvent(event);
         node.onmouseout = event => this.handleEvent(event);
         node.onauxclick = event => this.handleEvent(event);
 
-        this.updateButton(node, this.globals, true);
+        this.updateButton(node, this.action.getContextData(null), true);
       },
 
       onBeforeCommand: event => {
         this.lastClickInfo = {
           button: event.button || 0,
           modifiers: clickModifiersFromEvent(event),
         };
       },
@@ -218,17 +217,17 @@ this.browserAction = class extends Exten
         ExtensionTelemetry.browserActionPopupOpen.stopwatchStart(
           extension,
           this
         );
         let document = event.target.ownerDocument;
         let tabbrowser = document.defaultView.gBrowser;
 
         let tab = tabbrowser.selectedTab;
-        let popupURL = this.getProperty(tab, "popup");
+        let popupURL = this.action.getProperty(tab, "popup");
         this.tabManager.addActiveTabPermission(tab);
 
         // Popups are shown only if a popup URL is defined; otherwise
         // a "click" event is dispatched. This is done for compatibility with the
         // Google Chrome onClicked extension API.
         if (popupURL) {
           try {
             let popup = this.getPopup(document.defaultView, popupURL);
@@ -264,21 +263,16 @@ this.browserAction = class extends Exten
           event.preventDefault();
           this.emit("click", tabbrowser.selectedBrowser);
           // Ensure we close any popups this node was in:
           CustomizableUI.hidePanelForNode(event.target);
         }
       },
     });
 
-    // eslint-disable-next-line mozilla/balanced-listeners
-    this.tabContext.on("tab-select", (evt, tab) => {
-      this.updateWindow(tab.ownerGlobal);
-    });
-
     this.widget = widget;
   }
 
   /**
    * Triggers this browser action for the given window, with the same effects as
    * if it were clicked by a user.
    *
    * This has no effect if the browser action is disabled for, or not
@@ -291,24 +285,24 @@ this.browserAction = class extends Exten
     if (!this.pendingPopup && popup) {
       popup.closePopup();
       return;
     }
 
     let widget = this.widget.forWindow(window);
     let tab = window.gBrowser.selectedTab;
 
-    if (!widget.node || !this.getProperty(tab, "enabled")) {
+    if (!widget.node || !this.action.getProperty(tab, "enabled")) {
       return;
     }
 
     // Popups are shown only if a popup URL is defined; otherwise
     // a "click" event is dispatched. This is done for compatibility with the
     // Google Chrome onClicked extension API.
-    if (this.getProperty(tab, "popup")) {
+    if (this.action.getProperty(tab, "popup")) {
       if (this.widget.areaType == CustomizableUI.TYPE_MENU_PANEL) {
         await window.document.getElementById("nav-bar").overflowable.show();
       }
 
       let event = new window.CustomEvent("command", {
         bubbles: true,
         cancelable: true,
       });
@@ -325,18 +319,18 @@ this.browserAction = class extends Exten
     let window = button.ownerGlobal;
 
     switch (event.type) {
       case "mousedown":
         if (event.button == 0) {
           // Begin pre-loading the browser for the popup, so it's more likely to
           // be ready by the time we get a complete click.
           let tab = window.gBrowser.selectedTab;
-          let popupURL = this.getProperty(tab, "popup");
-          let enabled = this.getProperty(tab, "enabled");
+          let popupURL = this.action.getProperty(tab, "popup");
+          let enabled = this.action.getProperty(tab, "enabled");
 
           if (
             popupURL &&
             enabled &&
             (this.pendingPopup || !ViewPopup.for(this.extension, window))
           ) {
             this.eventQueue.push("Mousedown");
             // Add permission for the active tab so it will exist for the popup.
@@ -372,18 +366,18 @@ this.browserAction = class extends Exten
           }
         }
         break;
 
       case "mouseover": {
         // Begin pre-loading the browser for the popup, so it's more likely to
         // be ready by the time we get a complete click.
         let tab = window.gBrowser.selectedTab;
-        let popupURL = this.getProperty(tab, "popup");
-        let enabled = this.getProperty(tab, "enabled");
+        let popupURL = this.action.getProperty(tab, "popup");
+        let enabled = this.action.getProperty(tab, "enabled");
 
         if (
           popupURL &&
           enabled &&
           (this.pendingPopup || !ViewPopup.for(this.extension, window))
         ) {
           this.eventQueue.push("Hover");
           this.pendingPopup = this.getPopup(window, popupURL, true);
@@ -423,17 +417,17 @@ this.browserAction = class extends Exten
         break;
 
       case "auxclick":
         if (event.button !== 1) {
           return;
         }
 
         let { gBrowser } = window;
-        if (this.getProperty(gBrowser.selectedTab, "enabled")) {
+        if (this.action.getProperty(gBrowser.selectedTab, "enabled")) {
           this.lastClickInfo = {
             button: 1,
             modifiers: clickModifiersFromEvent(event),
           };
 
           this.emit("click", gBrowser.selectedBrowser);
           // Ensure we close any popups this node was in:
           CustomizableUI.hidePanelForNode(event.target);
@@ -542,17 +536,17 @@ this.browserAction = class extends Exten
       }
 
       let serializeColor = ([r, g, b, a]) =>
         `rgba(${r}, ${g}, ${b}, ${a / 255})`;
       node.setAttribute(
         "badgeStyle",
         [
           `background-color: ${serializeColor(tabData.badgeBackgroundColor)}`,
-          `color: ${serializeColor(this.getTextColor(tabData))}`,
+          `color: ${serializeColor(this.action.getTextColor(tabData))}`,
         ].join("; ")
       );
 
       let style = this.iconData.get(tabData.icon);
       node.setAttribute("style", style);
     };
     if (sync) {
       callback();
@@ -592,318 +586,51 @@ this.browserAction = class extends Exten
    *
    * @param {ChromeWindow} window
    *        Browser chrome window.
    */
   updateWindow(window) {
     let node = this.widget.forWindow(window).node;
     if (node) {
       let tab = window.gBrowser.selectedTab;
-      this.updateButton(node, this.tabContext.get(tab));
-    }
-  }
-
-  /**
-   * Update the toolbar button when the extension changes the icon, title, url, etc.
-   * If it only changes a parameter for a single tab, `target` will be that tab.
-   * If it only changes a parameter for a single window, `target` will be that window.
-   * Otherwise `target` will be null.
-   *
-   * @param {XULElement|ChromeWindow|null} target
-   *        Browser tab or browser chrome window, may be null.
-   */
-  updateOnChange(target) {
-    if (target) {
-      let window = target.ownerGlobal;
-      if (target === window || target.selected) {
-        this.updateWindow(window);
-      }
-    } else {
-      for (let window of windowTracker.browserWindows()) {
-        this.updateWindow(window);
-      }
-    }
-  }
-
-  /**
-   * Gets the target object corresponding to the `details` parameter of the various
-   * get* and set* API methods.
-   *
-   * @param {Object} details
-   *        An object with optional `tabId` or `windowId` properties.
-   * @throws if both `tabId` and `windowId` are specified, or if they are invalid.
-   * @returns {XULElement|ChromeWindow|null}
-   *        If a `tabId` was specified, the corresponding XULElement tab.
-   *        If a `windowId` was specified, the corresponding ChromeWindow.
-   *        Otherwise, `null`.
-   */
-  getTargetFromDetails({ tabId, windowId }) {
-    if (tabId != null && windowId != null) {
-      throw new ExtensionError(
-        "Only one of tabId and windowId can be specified."
-      );
-    }
-    if (tabId != null) {
-      return tabTracker.getTab(tabId);
-    } else if (windowId != null) {
-      return windowTracker.getWindow(windowId);
-    }
-    return null;
-  }
-
-  /**
-   * Gets the data associated with a tab, window, or the global one.
-   *
-   * @param {XULElement|ChromeWindow|null} target
-   *        A XULElement tab, a ChromeWindow, or null for the global data.
-   * @returns {Object}
-   *        The icon, title, badge, etc. associated with the target.
-   */
-  getContextData(target) {
-    if (target) {
-      return this.tabContext.get(target);
+      this.updateButton(node, this.action.getContextData(tab));
     }
-    return this.globals;
-  }
-
-  /**
-   * Set a global, window specific or tab specific property.
-   *
-   * @param {XULElement|ChromeWindow|null} target
-   *        A XULElement tab, a ChromeWindow, or null for the global data.
-   * @param {string} prop
-   *        String property to set. Should should be one of "icon", "title", "badgeText",
-   *        "popup", "badgeBackgroundColor", "badgeTextColor" or "enabled".
-   * @param {string} value
-   *        Value for prop.
-   * @returns {Object}
-   *        The object to which the property has been set.
-   */
-  setProperty(target, prop, value) {
-    let values = this.getContextData(target);
-    if (value === null) {
-      delete values[prop];
-    } else {
-      values[prop] = value;
-    }
-
-    this.updateOnChange(target);
-    return values;
-  }
-
-  /**
-   * Retrieve the value of a global, window specific or tab specific property.
-   *
-   * @param {XULElement|ChromeWindow|null} target
-   *        A XULElement tab, a ChromeWindow, or null for the global data.
-   * @param {string} prop
-   *        String property to retrieve. Should should be one of "icon", "title",
-   *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
-   * @returns {string} value
-   *          Value of prop.
-   */
-  getProperty(target, prop) {
-    return this.getContextData(target)[prop];
-  }
-
-  setPropertyFromDetails(details, prop, value) {
-    return this.setProperty(this.getTargetFromDetails(details), prop, value);
-  }
-
-  getPropertyFromDetails(details, prop) {
-    return this.getProperty(this.getTargetFromDetails(details), prop);
-  }
-
-  /**
-   * Determines the text badge color to be used in a tab, window, or globally.
-   *
-   * @param {Object} values
-   *        The values associated with the tab or window, or global values.
-   * @returns {ColorArray}
-   */
-  getTextColor(values) {
-    // If a text color has been explicitly provided, use it.
-    let { badgeTextColor } = values;
-    if (badgeTextColor) {
-      return badgeTextColor;
-    }
-
-    // Otherwise, check if the default color to be used has been cached previously.
-    let { badgeDefaultColor } = values;
-    if (badgeDefaultColor) {
-      return badgeDefaultColor;
-    }
-
-    // Choose a color among white and black, maximizing contrast with background
-    // according to https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-procedure
-    let [r, g, b] = values.badgeBackgroundColor
-      .slice(0, 3)
-      .map(function(channel) {
-        channel /= 255;
-        if (channel <= 0.03928) {
-          return channel / 12.92;
-        }
-        return ((channel + 0.055) / 1.055) ** 2.4;
-      });
-    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
-
-    // The luminance is 0 for black, 1 for white, and `lum` for the background color.
-    // Since `0 <= lum`, the contrast ratio for black is `c0 = (lum + 0.05) / 0.05`.
-    // Since `lum <= 1`, the contrast ratio for white is `c1 = 1.05 / (lum + 0.05)`.
-    // We want to maximize contrast, so black is chosen if `c1 < c0`, that is, if
-    // `1.05 * 0.05 < (L + 0.05) ** 2`. Otherwise white is chosen.
-    let channel = 1.05 * 0.05 < (lum + 0.05) ** 2 ? 0 : 255;
-    let result = [channel, channel, channel, 255];
-
-    // Cache the result as high as possible in the prototype chain
-    while (!Object.getOwnPropertyDescriptor(values, "badgeDefaultColor")) {
-      values = Object.getPrototypeOf(values);
-    }
-    values.badgeDefaultColor = result;
-    return result;
   }
 
   getAPI(context) {
     let { extension } = context;
     let { tabManager } = extension;
-
-    let browserAction = this;
-
-    function parseColor(color, kind) {
-      if (typeof color == "string") {
-        let rgba = InspectorUtils.colorToRGBA(color);
-        if (!rgba) {
-          throw new ExtensionError(`Invalid badge ${kind} color: "${color}"`);
-        }
-        color = [rgba.r, rgba.g, rgba.b, Math.round(rgba.a * 255)];
-      }
-      return color;
-    }
+    let { action } = this;
 
     return {
       browserAction: {
+        ...action.api(context),
+
         onClicked: new EventManager({
           context,
           name: "browserAction.onClicked",
           inputHandling: true,
           register: fire => {
             let listener = (event, browser) => {
               context.withPendingBrowser(browser, () =>
                 fire.sync(
                   tabManager.convert(tabTracker.activeTab),
-                  browserAction.lastClickInfo
+                  this.lastClickInfo
                 )
               );
             };
-            browserAction.on("click", listener);
+            this.on("click", listener);
             return () => {
-              browserAction.off("click", listener);
+              this.off("click", listener);
             };
           },
         }).api(),
 
-        enable: function(tabId) {
-          browserAction.setPropertyFromDetails({ tabId }, "enabled", true);
-        },
-
-        disable: function(tabId) {
-          browserAction.setPropertyFromDetails({ tabId }, "enabled", false);
-        },
-
-        isEnabled: function(details) {
-          return browserAction.getPropertyFromDetails(details, "enabled");
-        },
-
-        setTitle: function(details) {
-          browserAction.setPropertyFromDetails(details, "title", details.title);
-        },
-
-        getTitle: function(details) {
-          return browserAction.getPropertyFromDetails(details, "title");
-        },
-
-        setIcon: function(details) {
-          details.iconType = "browserAction";
-
-          let icon = IconDetails.normalize(details, extension, context);
-          if (!Object.keys(icon).length) {
-            icon = null;
-          }
-          browserAction.setPropertyFromDetails(details, "icon", icon);
-        },
-
-        setBadgeText: function(details) {
-          browserAction.setPropertyFromDetails(
-            details,
-            "badgeText",
-            details.text
-          );
-        },
-
-        getBadgeText: function(details) {
-          return browserAction.getPropertyFromDetails(details, "badgeText");
-        },
-
-        setPopup: function(details) {
-          // Note: Chrome resolves arguments to setIcon relative to the calling
-          // context, but resolves arguments to setPopup relative to the extension
-          // root.
-          // For internal consistency, we currently resolve both relative to the
-          // calling context.
-          let url = details.popup && context.uri.resolve(details.popup);
-          if (url && !context.checkLoadURL(url)) {
-            return Promise.reject({ message: `Access denied for URL ${url}` });
-          }
-          browserAction.setPropertyFromDetails(details, "popup", url);
-        },
-
-        getPopup: function(details) {
-          return browserAction.getPropertyFromDetails(details, "popup");
-        },
-
-        setBadgeBackgroundColor: function(details) {
-          let color = parseColor(details.color, "background");
-          let values = browserAction.setPropertyFromDetails(
-            details,
-            "badgeBackgroundColor",
-            color
-          );
-          if (color === null) {
-            // Let the default text color inherit after removing background color
-            delete values.badgeDefaultColor;
-          } else {
-            // Invalidate a cached default color calculated with the old background
-            values.badgeDefaultColor = null;
-          }
-        },
-
-        getBadgeBackgroundColor: function(details, callback) {
-          return browserAction.getPropertyFromDetails(
-            details,
-            "badgeBackgroundColor"
-          );
-        },
-
-        setBadgeTextColor: function(details) {
-          let color = parseColor(details.color, "text");
-          browserAction.setPropertyFromDetails(
-            details,
-            "badgeTextColor",
-            color
-          );
-        },
-
-        getBadgeTextColor: function(details) {
-          let target = browserAction.getTargetFromDetails(details);
-          let values = browserAction.getContextData(target);
-          return browserAction.getTextColor(values);
-        },
-
-        openPopup: function() {
+        openPopup: () => {
           let window = windowTracker.topWindow;
-          browserAction.triggerAction(window);
+          this.triggerAction(window);
         },
       },
     };
   }
 };
 
 global.browserActionFor = this.browserAction.for;
--- a/browser/components/extensions/parent/ext-pageAction.js
+++ b/browser/components/extensions/parent/ext-pageAction.js
@@ -17,87 +17,65 @@ ChromeUtils.defineModuleGetter(
   "resource:///modules/PageActions.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "PanelPopup",
   "resource:///modules/ExtensionPopups.jsm"
 );
 
-var { ExtensionParent } = ChromeUtils.import(
-  "resource://gre/modules/ExtensionParent.jsm"
-);
+var { DefaultWeakMap } = ExtensionUtils;
 
-var { IconDetails, StartupCache } = ExtensionParent;
-
-var { DefaultWeakMap } = ExtensionUtils;
+var { PageActionBase } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionActions.jsm"
+);
 
 // WeakMap[Extension -> PageAction]
 let pageActionMap = new WeakMap();
 
+class PageAction extends PageActionBase {
+  constructor(extension, buttonDelegate) {
+    let tabContext = new TabContext(tab => this.getContextData(null));
+    super(tabContext, extension);
+    this.buttonDelegate = buttonDelegate;
+  }
+
+  updateOnChange(target) {
+    this.buttonDelegate.updateButton(target.ownerGlobal);
+  }
+
+  getTab(tabId) {
+    if (tabId !== null) {
+      return tabTracker.getTab(tabId);
+    }
+    return null;
+  }
+}
+
 this.pageAction = class extends ExtensionAPI {
   static for(extension) {
     return pageActionMap.get(extension);
   }
 
   async onManifestEntry(entryName) {
     let { extension } = this;
     let options = extension.manifest.page_action;
 
+    this.action = new PageAction(extension, this);
+    await this.action.loadIconData();
+
     let widgetId = makeWidgetId(extension.id);
     this.id = widgetId + "-page-action";
 
     this.tabManager = extension.tabManager;
 
-    // `show` can have three different values:
-    // - `false`. This means the page action is not shown.
-    //   It's set as default if show_matches is empty. Can also be set in a tab via
-    //   `pageAction.hide(tabId)`, e.g. in order to override show_matches.
-    // - `true`. This means the page action is shown.
-    //   It's never set as default because <all_urls> doesn't really match all URLs
-    //   (e.g. "about:" URLs). But can be set in a tab via `pageAction.show(tabId)`.
-    // - `undefined`.
-    //   This is the default value when there are some patterns in show_matches.
-    //   Can't be set as a tab-specific value.
-    let show, showMatches, hideMatches;
-    let show_matches = options.show_matches || [];
-    let hide_matches = options.hide_matches || [];
-    if (!show_matches.length) {
-      // Always hide by default. No need to do any pattern matching.
-      show = false;
-    } else {
-      // Might show or hide depending on the URL. Enable pattern matching.
-      const { restrictSchemes } = extension;
-      showMatches = new MatchPatternSet(show_matches, { restrictSchemes });
-      hideMatches = new MatchPatternSet(hide_matches, { restrictSchemes });
-    }
-
-    this.defaults = {
-      show,
-      showMatches,
-      hideMatches,
-      title: options.default_title || extension.name,
-      popup: options.default_popup || "",
-      pinned: options.pinned,
-    };
-
     this.browserStyle = options.browser_style;
 
-    this.tabContext = new TabContext(tab => this.defaults);
-
-    this.tabContext.on("location-change", this.handleLocationChange.bind(this)); // eslint-disable-line mozilla/balanced-listeners
-
     pageActionMap.set(extension, this);
 
-    this.defaults.icon = await StartupCache.get(
-      extension,
-      ["pageAction", "default_icon"],
-      () => this.normalize({ path: options.default_icon || "" })
-    );
-
     this.lastValues = new DefaultWeakMap(() => ({}));
 
     if (!this.browserPageAction) {
       let onPlacedHandler = (buttonNode, isPanel) => {
         // eslint-disable-next-line mozilla/balanced-listeners
         buttonNode.addEventListener("auxclick", event => {
           if (event.button !== 1 || event.target.disabled) {
             return;
@@ -117,20 +95,20 @@ this.pageAction = class extends Extensio
           this.emit("click", tab);
         });
       };
 
       this.browserPageAction = PageActions.addAction(
         new PageActions.Action({
           id: widgetId,
           extensionID: extension.id,
-          title: this.defaults.title,
-          iconURL: this.defaults.icon,
-          pinnedToUrlbar: this.defaults.pinned,
-          disabled: !this.defaults.show,
+          title: this.action.getProperty(null, "title"),
+          iconURL: this.action.getProperty(null, "title"),
+          pinnedToUrlbar: this.action.getPinned(),
+          disabled: !this.action.getProperty(null, "enabled"),
           onCommand: (event, buttonNode) => {
             this.lastClickInfo = {
               button: event.button || 0,
               modifiers: clickModifiersFromEvent(event),
             };
             this.handleClick(event.target.ownerGlobal);
           },
           onBeforePlacedInWindow: browserWindow => {
@@ -146,143 +124,90 @@ this.pageAction = class extends Extensio
           onRemovedFromWindow: browserWindow => {
             browserWindow.document.removeEventListener("popupshowing", this);
           },
         })
       );
 
       // If the page action is only enabled in some URLs, do pattern matching in
       // the active tabs and update the button if necessary.
-      if (show === undefined) {
+      if (this.action.getProperty(null, "enabled") === undefined) {
         for (let window of windowTracker.browserWindows()) {
           let tab = window.gBrowser.selectedTab;
-          if (this.isShown(tab)) {
+          if (this.action.isShownForTab(tab)) {
             this.updateButton(window);
           }
         }
       }
     }
   }
 
   onShutdown(isAppShutdown) {
     pageActionMap.delete(this.extension);
-
-    this.tabContext.shutdown();
+    this.action.onShutdown();
 
     // Removing the browser page action causes PageActions to forget about it
     // across app restarts, so don't remove it on app shutdown, but do remove
     // it on all other shutdowns since there's no guarantee the action will be
     // coming back.
     if (!isAppShutdown && this.browserPageAction) {
       this.browserPageAction.remove();
       this.browserPageAction = null;
     }
   }
 
-  // Returns the value of the property |prop| for the given tab, where
-  // |prop| is one of "show", "title", "icon", "popup".
-  getProperty(tab, prop) {
-    return this.tabContext.get(tab)[prop];
-  }
-
-  // Sets the value of the property |prop| for the given tab to the
-  // given value, symmetrically to |getProperty|.
-  //
-  // If |tab| is currently selected, updates the page action button to
-  // reflect the new value.
-  setProperty(tab, prop, value) {
-    if (value != null) {
-      this.tabContext.get(tab)[prop] = value;
-    } else {
-      delete this.tabContext.get(tab)[prop];
-    }
-
-    if (tab.selected) {
-      this.updateButton(tab.ownerGlobal);
-    }
-  }
-
-  normalize(details, context = null) {
-    let icon = IconDetails.normalize(details, this.extension, context);
-    if (!Object.keys(icon).length) {
-      icon = null;
-    }
-    return icon;
-  }
-
   // Updates the page action button in the given window to reflect the
   // properties of the currently selected tab:
   //
   // Updates "tooltiptext" and "aria-label" to match "title" property.
   // Updates "image" to match the "icon" property.
-  // Enables or disables the icon, based on the "show" and "patternMatching" properties.
+  // Enables or disables the icon, based on the "enabled" and "patternMatching" properties.
   updateButton(window) {
     let tab = window.gBrowser.selectedTab;
-    let tabData = this.tabContext.get(tab);
+    let tabData = this.action.getContextData(tab);
     let last = this.lastValues.get(window);
 
     window.requestAnimationFrame(() => {
       // If we get called just before shutdown, we might have been destroyed by
       // this point.
       if (!this.browserPageAction) {
         return;
       }
 
       let title = tabData.title || this.extension.name;
       if (last.title !== title) {
         this.browserPageAction.setTitle(title, window);
         last.title = title;
       }
 
-      let show = tabData.show != null ? tabData.show : tabData.patternMatching;
-      if (last.show !== show) {
-        this.browserPageAction.setDisabled(!show, window);
-        last.show = show;
+      let enabled =
+        tabData.enabled != null ? tabData.enabled : tabData.patternMatching;
+      if (last.enabled !== enabled) {
+        this.browserPageAction.setDisabled(!enabled, window);
+        last.enabled = enabled;
       }
 
       let icon = tabData.icon;
       if (last.icon !== icon) {
         this.browserPageAction.setIconURL(icon, window);
         last.icon = icon;
       }
     });
   }
 
-  // Checks whether the tab action is shown when the specified tab becomes active.
-  // Does pattern matching if necessary, and caches the result as a tab-specific value.
-  // @param {XULElement} tab
-  //        The tab to be checked
-  // @return boolean
-  isShown(tab) {
-    let tabData = this.tabContext.get(tab);
-
-    // If there is a "show" value, return it. Can be due to show(), hide() or empty show_matches.
-    if (tabData.show !== undefined) {
-      return tabData.show;
-    }
-
-    // Otherwise pattern matching must have been configured. Do it, caching the result.
-    if (tabData.patternMatching === undefined) {
-      let uri = tab.linkedBrowser.currentURI;
-      tabData.patternMatching =
-        tabData.showMatches.matches(uri) && !tabData.hideMatches.matches(uri);
-    }
-    return tabData.patternMatching;
-  }
-
   /**
    * Triggers this page action for the given window, with the same effects as
    * if it were clicked by a user.
    *
    * This has no effect if the page action is hidden for the selected tab.
    *
    * @param {Window} window
    */
   triggerAction(window) {
-    if (this.isShown(window.gBrowser.selectedTab)) {
+    if (this.action.isShownForTab(window.gBrowser.selectedTab)) {
       this.lastClickInfo = { button: 0, modifiers: [] };
       this.handleClick(window);
     }
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "popupshowing":
@@ -310,17 +235,17 @@ this.pageAction = class extends Extensio
   // If the page action has a |popup| property, a panel is opened to
   // that URL. Otherwise, a "click" event is emitted, and dispatched to
   // the any click listeners in the add-on.
   async handleClick(window) {
     const { extension } = this;
 
     ExtensionTelemetry.pageActionPopupOpen.stopwatchStart(extension, this);
     let tab = window.gBrowser.selectedTab;
-    let popupURL = this.tabContext.get(tab).popup;
+    let popupURL = this.action.getProperty(tab, "popup");
 
     this.tabManager.addActiveTabPermission(tab);
 
     // If the widget has a popup URL defined, we open a popup, but do not
     // dispatch a click event to the extension.
     // If it has no popup URL defined, we dispatch a click event, but do not
     // open a popup.
     if (popupURL) {
@@ -355,135 +280,45 @@ this.pageAction = class extends Extensio
       );
       ExtensionTelemetry.pageActionPopupOpen.stopwatchFinish(extension, this);
     } else {
       ExtensionTelemetry.pageActionPopupOpen.stopwatchCancel(extension, this);
       this.emit("click", tab);
     }
   }
 
-  /**
-   * Updates the `tabData` for any location change, however it only updates the button
-   * when the selected tab has a location change, or the selected tab has changed.
-   *
-   * @param {string} eventType
-   *        The type of the event, should be "location-change".
-   * @param {XULElement} tab
-   *        The tab whose location changed, or which has become selected.
-   * @param {boolean} [fromBrowse]
-   *        - `true` if navigation occurred in `tab`.
-   *        - `false` if the location changed but no navigation occurred, e.g. due to
-               a hash change or `history.pushState`.
-   *        - Omitted if TabSelect has occurred, tabData does not need to be updated.
-   */
-  handleLocationChange(eventType, tab, fromBrowse) {
-    if (fromBrowse === true) {
-      // Clear tab data on navigation.
-      this.tabContext.clear(tab);
-    } else if (fromBrowse === false) {
-      // Clear pattern matching cache when URL changes.
-      let tabData = this.tabContext.get(tab);
-      if (tabData.patternMatching !== undefined) {
-        tabData.patternMatching = undefined;
-      }
-    }
-
-    if (tab.selected) {
-      // isShown will do pattern matching (if necessary) and store the result
-      // so that updateButton knows whether the page action should be shown.
-      this.isShown(tab);
-      this.updateButton(tab.ownerGlobal);
-    }
-  }
-
   getAPI(context) {
-    let { extension } = context;
-
+    const { extension } = context;
     const { tabManager } = extension;
-    const pageAction = this;
+    const { action } = this;
 
     return {
       pageAction: {
+        ...action.api(context),
+
         onClicked: new EventManager({
           context,
           name: "pageAction.onClicked",
           inputHandling: true,
           register: fire => {
             let listener = (evt, tab) => {
               context.withPendingBrowser(tab.linkedBrowser, () =>
                 fire.sync(tabManager.convert(tab), this.lastClickInfo)
               );
             };
 
-            pageAction.on("click", listener);
+            this.on("click", listener);
             return () => {
-              pageAction.off("click", listener);
+              this.off("click", listener);
             };
           },
         }).api(),
 
-        show(tabId) {
-          let tab = tabTracker.getTab(tabId);
-          pageAction.setProperty(tab, "show", true);
-        },
-
-        hide(tabId) {
-          let tab = tabTracker.getTab(tabId);
-          pageAction.setProperty(tab, "show", false);
-        },
-
-        isShown(details) {
-          let tab = tabTracker.getTab(details.tabId);
-          return pageAction.isShown(tab);
-        },
-
-        setTitle(details) {
-          let tab = tabTracker.getTab(details.tabId);
-          pageAction.setProperty(tab, "title", details.title);
-        },
-
-        getTitle(details) {
-          let tab = tabTracker.getTab(details.tabId);
-
-          let title = pageAction.getProperty(tab, "title");
-          return Promise.resolve(title);
-        },
-
-        setIcon(details) {
-          let tab = tabTracker.getTab(details.tabId);
-
-          let icon = pageAction.normalize(details, context);
-
-          pageAction.setProperty(tab, "icon", icon);
-        },
-
-        setPopup(details) {
-          let tab = tabTracker.getTab(details.tabId);
-
-          // Note: Chrome resolves arguments to setIcon relative to the calling
-          // context, but resolves arguments to setPopup relative to the extension
-          // root.
-          // For internal consistency, we currently resolve both relative to the
-          // calling context.
-          let url = details.popup && context.uri.resolve(details.popup);
-          if (url && !context.checkLoadURL(url)) {
-            return Promise.reject({ message: `Access denied for URL ${url}` });
-          }
-          pageAction.setProperty(tab, "popup", url);
-        },
-
-        getPopup(details) {
-          let tab = tabTracker.getTab(details.tabId);
-
-          let popup = pageAction.getProperty(tab, "popup");
-          return Promise.resolve(popup);
-        },
-
-        openPopup: function() {
+        openPopup: () => {
           let window = windowTracker.topWindow;
-          pageAction.triggerAction(window);
+          this.triggerAction(window);
         },
       },
     };
   }
 };
 
 global.pageActionFor = this.pageAction.for;
--- a/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.js
+++ b/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.js
@@ -17,21 +17,21 @@ this.pageActionExtras = class extends Ex
     const {
       Management: {
         global: { windowTracker },
       },
     } = ChromeUtils.import("resource://gre/modules/Extension.jsm", null);
     return {
       pageActionExtras: {
         async setDefaultTitle(title) {
-          pageActionAPI.defaults.title = title;
+          pageActionAPI.action.getContextData(null).title = title;
           // Make sure the new default title is considered right away
           for (const window of windowTracker.browserWindows()) {
             const tab = window.gBrowser.selectedTab;
-            if (pageActionAPI.isShown(tab)) {
+            if (pageActionAPI.action.isShownForTab(tab)) {
               pageActionAPI.updateButton(window);
             }
           }
         },
         async setLabelForHistogram(label) {
           pageActionAPI.browserPageAction._labelForHistogram = label;
         },
         async setTooltipText(text) {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionActions.jsm
@@ -0,0 +1,510 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["BrowserActionBase", "PageActionBase"];
+
+const { ExtensionUtils } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { ExtensionError } = ExtensionUtils;
+
+const { ExtensionParent } = ChromeUtils.import(
+  "resource://gre/modules/ExtensionParent.jsm"
+);
+const { IconDetails, StartupCache } = ExtensionParent;
+
+function parseColor(color, kind) {
+  if (typeof color == "string") {
+    let rgba = InspectorUtils.colorToRGBA(color);
+    if (!rgba) {
+      throw new ExtensionError(`Invalid badge ${kind} color: "${color}"`);
+    }
+    color = [rgba.r, rgba.g, rgba.b, Math.round(rgba.a * 255)];
+  }
+  return color;
+}
+
+/** Common base class for Page and Browser actions. */
+class PanelActionBase {
+  constructor(options, tabContext, extension) {
+    this.tabContext = tabContext;
+    this.extension = extension;
+
+    // These are always defined on the action
+    this.defaults = {
+      enabled: true,
+      title: options.default_title || extension.name,
+      popup: options.default_popup || "",
+    };
+    this.globals = Object.create(this.defaults);
+
+    // eslint-disable-next-line mozilla/balanced-listeners
+    this.tabContext.on("location-change", this.handleLocationChange.bind(this));
+
+    // eslint-disable-next-line mozilla/balanced-listeners
+    this.tabContext.on("tab-select", (evt, tab) => {
+      this.updateOnChange(tab);
+    });
+  }
+
+  onShutdown() {
+    this.tabContext.shutdown();
+  }
+
+  setPropertyFromDetails(details, prop, value) {
+    return this.setProperty(this.getTargetFromDetails(details), prop, value);
+  }
+
+  /**
+   * Set a global, window specific or tab specific property.
+   *
+   * @param {XULElement|ChromeWindow|null} target
+   *        A XULElement tab, a ChromeWindow, or null for the global data.
+   * @param {string} prop
+   *        String property to set. Should should be one of "icon", "title", "badgeText",
+   *        "popup", "badgeBackgroundColor", "badgeTextColor" or "enabled".
+   * @param {string} value
+   *        Value for prop.
+   * @returns {Object}
+   *        The object to which the property has been set.
+   */
+  setProperty(target, prop, value) {
+    let values = this.getContextData(target);
+    if (value === null) {
+      delete values[prop];
+    } else {
+      values[prop] = value;
+    }
+
+    this.updateOnChange(target);
+    return values;
+  }
+
+  /**
+   * Gets the data associated with a tab, window, or the global one.
+   *
+   * @param {XULElement|ChromeWindow|null} target
+   *        A XULElement tab, a ChromeWindow, or null for the global data.
+   * @returns {Object}
+   *        The icon, title, badge, etc. associated with the target.
+   */
+  getContextData(target) {
+    if (target) {
+      return this.tabContext.get(target);
+    }
+    return this.globals;
+  }
+
+  /**
+   * Retrieve the value of a global, window specific or tab specific property.
+   *
+   * @param {XULElement|ChromeWindow|null} target
+   *        A XULElement tab, a ChromeWindow, or null for the global data.
+   * @param {string} prop
+   *        String property to retrieve. Should should be one of "icon", "title",
+   *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+   * @returns {string} value
+   *          Value of prop.
+   */
+  getProperty(target, prop) {
+    return this.getContextData(target)[prop];
+  }
+
+  getPropertyFromDetails(details, prop) {
+    return this.getProperty(this.getTargetFromDetails(details), prop);
+  }
+
+  enable(tabId) {
+    this.setPropertyFromDetails({ tabId }, "enabled", true);
+  }
+
+  disable(tabId) {
+    this.setPropertyFromDetails({ tabId }, "enabled", false);
+  }
+
+  getIcon(details = {}) {
+    return this.getPropertyFromDetails(details, "icon");
+  }
+
+  normalizeIcon(details, extension, context) {
+    let icon = IconDetails.normalize(details, extension, context);
+    if (!Object.keys(icon).length) {
+      return null;
+    }
+    return icon;
+  }
+
+  /**
+   * Updates the `tabData` for any location change, however it only updates the button
+   * when the selected tab has a location change, or the selected tab has changed.
+   *
+   * @param {string} eventType
+   *        The type of the event, should be "location-change".
+   * @param {XULElement} tab
+   *        The tab whose location changed, or which has become selected.
+   * @param {boolean} [fromBrowse]
+   *        - `true` if navigation occurred in `tab`.
+   *        - `false` if the location changed but no navigation occurred, e.g. due to
+               a hash change or `history.pushState`.
+   *        - Omitted if TabSelect has occurred, tabData does not need to be updated.
+   */
+  handleLocationChange(eventType, tab, fromBrowse) {
+    if (fromBrowse) {
+      this.tabContext.clear(tab);
+    }
+  }
+
+  api(context) {
+    let { extension } = context;
+    return {
+      setTitle: details => {
+        this.setPropertyFromDetails(details, "title", details.title);
+      },
+      getTitle: details => {
+        return this.getPropertyFromDetails(details, "title");
+      },
+      setIcon: details => {
+        details.iconType = "browserAction";
+        this.setPropertyFromDetails(
+          details,
+          "icon",
+          this.normalizeIcon(details, extension, context)
+        );
+      },
+      setPopup: details => {
+        // Note: Chrome resolves arguments to setIcon relative to the calling
+        // context, but resolves arguments to setPopup relative to the extension
+        // root.
+        // For internal consistency, we currently resolve both relative to the
+        // calling context.
+        let url = details.popup && context.uri.resolve(details.popup);
+        if (url && !context.checkLoadURL(url)) {
+          return Promise.reject({ message: `Access denied for URL ${url}` });
+        }
+        this.setPropertyFromDetails(details, "popup", url);
+      },
+      getPopup: details => {
+        return this.getPropertyFromDetails(details, "popup");
+      },
+    };
+  }
+
+  // Override these
+
+  /**
+   * Update the toolbar button when the extension changes the icon, title, url, etc.
+   * If it only changes a parameter for a single tab, `target` will be that tab.
+   * If it only changes a parameter for a single window, `target` will be that window.
+   * Otherwise `target` will be null.
+   *
+   * @param {XULElement|ChromeWindow|null} target
+   *        Browser tab or browser chrome window, may be null.
+   */
+  updateOnChange(target) {}
+
+  /**
+   * Get tab object from tabId.
+   *
+   * @param {string} tabId
+   *        Internal id of the tab to get.
+   */
+  getTab(tabId) {}
+
+  /**
+   * Get window object from windowId
+   *
+   * @param {string} windowId
+   *        Internal id of the window to get.
+   */
+  getWindow(windowId) {}
+
+  /**
+   * Gets the target object corresponding to the `details` parameter of the various
+   * get* and set* API methods.
+   *
+   * @param {Object} details
+   *        An object with optional `tabId` or `windowId` properties.
+   * @throws if both `tabId` and `windowId` are specified, or if they are invalid.
+   * @returns {XULElement|ChromeWindow|null}
+   *        If a `tabId` was specified, the corresponding XULElement tab.
+   *        If a `windowId` was specified, the corresponding ChromeWindow.
+   *        Otherwise, `null`.
+   */
+  getTargetFromDetails({ tabId, windowId }) {
+    return null;
+  }
+}
+
+class PageActionBase extends PanelActionBase {
+  constructor(tabContext, extension) {
+    const options = extension.manifest.page_action;
+    super(options, tabContext, extension);
+
+    // `enabled` can have three different values:
+    // - `false`. This means the page action is not shown.
+    //   It's set as default if show_matches is empty. Can also be set in a tab via
+    //   `pageAction.hide(tabId)`, e.g. in order to override show_matches.
+    // - `true`. This means the page action is shown.
+    //   It's never set as default because <all_urls> doesn't really match all URLs
+    //   (e.g. "about:" URLs). But can be set in a tab via `pageAction.show(tabId)`.
+    // - `undefined`.
+    //   This is the default value when there are some patterns in show_matches.
+    //   Can't be set as a tab-specific value.
+    let enabled, showMatches, hideMatches;
+    let show_matches = options.show_matches || [];
+    let hide_matches = options.hide_matches || [];
+    if (!show_matches.length) {
+      // Always hide by default. No need to do any pattern matching.
+      enabled = false;
+    } else {
+      // Might show or hide depending on the URL. Enable pattern matching.
+      const { restrictSchemes } = extension;
+      showMatches = new MatchPatternSet(show_matches, { restrictSchemes });
+      hideMatches = new MatchPatternSet(hide_matches, { restrictSchemes });
+    }
+
+    this.defaults = {
+      ...this.defaults,
+      enabled,
+      showMatches,
+      hideMatches,
+      pinned: options.pinned,
+    };
+    this.globals = Object.create(this.defaults);
+  }
+
+  handleLocationChange(eventType, tab, fromBrowse) {
+    super.handleLocationChange(eventType, tab, fromBrowse);
+    if (fromBrowse === false) {
+      // Clear pattern matching cache when URL changes.
+      let tabData = this.tabContext.get(tab);
+      if (tabData.patternMatching !== undefined) {
+        tabData.patternMatching = undefined;
+      }
+    }
+
+    if (tab.selected) {
+      // isShownForTab will do pattern matching (if necessary) and store the result
+      // so that updateButton knows whether the page action should be shown.
+      this.isShownForTab(tab);
+      this.updateOnChange(tab);
+    }
+  }
+
+  // Checks whether the tab action is shown when the specified tab becomes active.
+  // Does pattern matching if necessary, and caches the result as a tab-specific value.
+  // @param {XULElement} tab
+  //        The tab to be checked
+  // @return boolean
+  isShownForTab(tab) {
+    let tabData = this.getContextData(tab);
+
+    // If there is a "show" value, return it. Can be due to show(), hide() or empty show_matches.
+    if (tabData.enabled !== undefined) {
+      return tabData.enabled;
+    }
+
+    // Otherwise pattern matching must have been configured. Do it, caching the result.
+    if (tabData.patternMatching === undefined) {
+      let uri = tab.linkedBrowser.currentURI;
+      tabData.patternMatching =
+        tabData.showMatches.matches(uri) && !tabData.hideMatches.matches(uri);
+    }
+    return tabData.patternMatching;
+  }
+
+  async loadIconData() {
+    const { extension } = this;
+    const options = extension.manifest.page_action;
+    this.defaults.icon = await StartupCache.get(
+      extension,
+      ["pageAction", "default_icon"],
+      () =>
+        this.normalizeIcon(
+          { path: options.default_icon || "" },
+          extension,
+          null
+        )
+    );
+  }
+
+  getPinned() {
+    return this.globals.pinned;
+  }
+
+  getTargetFromDetails({ tabId, windowId }) {
+    // PageActionBase doesn't support |windowId|
+    if (tabId != null) {
+      return this.getTab(tabId);
+    }
+    return null;
+  }
+
+  api(context) {
+    return {
+      ...super.api(context),
+      show: (...args) => this.enable(...args),
+      hide: (...args) => this.disable(...args),
+      isShown: ({ tabId }) => {
+        let tab = this.getTab(tabId);
+        return this.isShownForTab(tab);
+      },
+    };
+  }
+}
+
+class BrowserActionBase extends PanelActionBase {
+  constructor(tabContext, extension) {
+    const options = extension.manifest.browser_action;
+    super(options, tabContext, extension);
+
+    this.defaults = {
+      ...this.defaults,
+      badgeText: "",
+      badgeBackgroundColor: [0xd9, 0, 0, 255],
+      badgeDefaultColor: [255, 255, 255, 255],
+      badgeTextColor: null,
+      default_area: options.default_area || "navbar",
+    };
+    this.globals = Object.create(this.defaults);
+  }
+
+  async loadIconData() {
+    const { extension } = this;
+    const options = extension.manifest.browser_action;
+    this.defaults.icon = await StartupCache.get(
+      extension,
+      ["browserAction", "default_icon"],
+      () =>
+        IconDetails.normalize(
+          {
+            path: options.default_icon || extension.manifest.icons,
+            iconType: "browserAction",
+            themeIcons: options.theme_icons,
+          },
+          extension
+        )
+    );
+  }
+
+  handleLocationChange(eventType, tab, fromBrowse) {
+    super.handleLocationChange(eventType, tab, fromBrowse);
+    if (fromBrowse) {
+      this.updateOnChange(tab);
+    }
+  }
+
+  getTargetFromDetails({ tabId, windowId }) {
+    if (tabId != null && windowId != null) {
+      throw new ExtensionError(
+        "Only one of tabId and windowId can be specified."
+      );
+    }
+    if (tabId != null) {
+      return this.getTab(tabId);
+    } else if (windowId != null) {
+      return this.getWindow(windowId);
+    }
+    return null;
+  }
+
+  getDefaultArea() {
+    return this.globals.default_area;
+  }
+
+  /**
+   * Determines the text badge color to be used in a tab, window, or globally.
+   *
+   * @param {Object} values
+   *        The values associated with the tab or window, or global values.
+   * @returns {ColorArray}
+   */
+  getTextColor(values) {
+    // If a text color has been explicitly provided, use it.
+    let { badgeTextColor } = values;
+    if (badgeTextColor) {
+      return badgeTextColor;
+    }
+
+    // Otherwise, check if the default color to be used has been cached previously.
+    let { badgeDefaultColor } = values;
+    if (badgeDefaultColor) {
+      return badgeDefaultColor;
+    }
+
+    // Choose a color among white and black, maximizing contrast with background
+    // according to https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-procedure
+    let [r, g, b] = values.badgeBackgroundColor
+      .slice(0, 3)
+      .map(function(channel) {
+        channel /= 255;
+        if (channel <= 0.03928) {
+          return channel / 12.92;
+        }
+        return ((channel + 0.055) / 1.055) ** 2.4;
+      });
+    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+    // The luminance is 0 for black, 1 for white, and `lum` for the background color.
+    // Since `0 <= lum`, the contrast ratio for black is `c0 = (lum + 0.05) / 0.05`.
+    // Since `lum <= 1`, the contrast ratio for white is `c1 = 1.05 / (lum + 0.05)`.
+    // We want to maximize contrast, so black is chosen if `c1 < c0`, that is, if
+    // `1.05 * 0.05 < (L + 0.05) ** 2`. Otherwise white is chosen.
+    let channel = 1.05 * 0.05 < (lum + 0.05) ** 2 ? 0 : 255;
+    let result = [channel, channel, channel, 255];
+
+    // Cache the result as high as possible in the prototype chain
+    while (!Object.getOwnPropertyDescriptor(values, "badgeDefaultColor")) {
+      values = Object.getPrototypeOf(values);
+    }
+    values.badgeDefaultColor = result;
+    return result;
+  }
+
+  api(context) {
+    return {
+      ...super.api(context),
+      enable: (...args) => this.enable(...args),
+      disable: (...args) => this.disable(...args),
+      isEnabled: details => {
+        return this.getPropertyFromDetails(details, "enabled");
+      },
+      setBadgeText: details => {
+        this.setPropertyFromDetails(details, "badgeText", details.text);
+      },
+      getBadgeText: details => {
+        return this.getPropertyFromDetails(details, "badgeText");
+      },
+      setBadgeBackgroundColor: details => {
+        let color = parseColor(details.color, "background");
+        let values = this.setPropertyFromDetails(
+          details,
+          "badgeBackgroundColor",
+          color
+        );
+        if (color === null) {
+          // Let the default text color inherit after removing background color
+          delete values.badgeDefaultColor;
+        } else {
+          // Invalidate a cached default color calculated with the old background
+          values.badgeDefaultColor = null;
+        }
+      },
+      getBadgeBackgroundColor: details => {
+        return this.getPropertyFromDetails(details, "badgeBackgroundColor");
+      },
+      setBadgeTextColor: details => {
+        let color = parseColor(details.color, "text");
+        this.setPropertyFromDetails(details, "badgeTextColor", color);
+      },
+      getBadgeTextColor: details => {
+        let target = this.getTargetFromDetails(details);
+        let values = this.getContextData(target);
+        return this.getTextColor(values);
+      },
+    };
+  }
+}
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -5,16 +5,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 
 with Files('**'):
     BUG_COMPONENT = ('WebExtensions', 'General')
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
+    'ExtensionActions.jsm',
     'ExtensionActivityLog.jsm',
     'ExtensionChild.jsm',
     'ExtensionChildDevToolsUtils.jsm',
     'ExtensionCommon.jsm',
     'ExtensionContent.jsm',
     'ExtensionPageChild.jsm',
     'ExtensionParent.jsm',
     'ExtensionPermissions.jsm',