Bug 1395387 - Reconcile WebExtension page actions and Photon page actions: WebExtensions changes. r?mixedpuppy draft
authorDrew Willcoxon <adw@mozilla.com>
Fri, 27 Oct 2017 17:39:57 -0400
changeset 687856 018c7ef91d4c714f67ac60a427ef02d16bccc9da
parent 687855 ab5e4fa906dd1f4cedcfd5e1e4cd09c414eac31b
child 688013 13a07add01abc8e0e035a9733273453a6b084cfb
child 688851 dfd8ed7877db288a68c05187ab8e2da2e9047f1d
child 688896 1ee007964539bef3ce8d5541bc075601bb04ed5f
push id86621
push userdwillcoxon@mozilla.com
push dateFri, 27 Oct 2017 21:40:47 +0000
reviewersmixedpuppy
bugs1395387
milestone58.0a1
Bug 1395387 - Reconcile WebExtension page actions and Photon page actions: WebExtensions changes. r?mixedpuppy MozReview-Commit-ID: n2eR3q1aZF
browser/base/content/browser.css
browser/components/extensions/ExtensionPopups.jsm
browser/components/extensions/ext-pageAction.js
browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js
browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js
browser/components/extensions/test/browser/browser_ext_menus.js
browser/components/extensions/test/browser/head.js
browser/components/extensions/test/browser/head_pageAction.js
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -381,20 +381,16 @@ toolbarpaletteitem > toolbaritem[sdkstyl
     list-style-image: var(--webextension-menupanel-image-light, inherit);
   }
 
   .webextension-browser-action[cui-areatype="menu-panel"]:-moz-lwtheme-darktext,
   toolbarpaletteitem[place="palette"] > .webextension-browser-action:-moz-lwtheme-darktext {
     list-style-image: var(--webextension-menupanel-image-dark, inherit);
   }
 
-  .webextension-page-action {
-    list-style-image: var(--webextension-urlbar-image, inherit);
-  }
-
   .webextension-menuitem {
     list-style-image: var(--webextension-menuitem-image, inherit);
   }
 }
 
 @media (min-resolution: 1.1dppx) {
   .webextension-browser-action {
     list-style-image: var(--webextension-toolbar-image-2x, inherit);
@@ -417,20 +413,16 @@ toolbarpaletteitem > toolbaritem[sdkstyl
     list-style-image: var(--webextension-menupanel-image-2x-light, inherit);
   }
 
   .webextension-browser-action[cui-areatype="menu-panel"]:-moz-lwtheme-darktext,
   toolbarpaletteitem[place="palette"] > .webextension-browser-action:-moz-lwtheme-darktext {
     list-style-image: var(--webextension-menupanel-image-2x-dark, inherit);
   }
 
-  .webextension-page-action {
-    list-style-image: var(--webextension-urlbar-image-2x, inherit);
-  }
-
   .webextension-menuitem {
     list-style-image: var(--webextension-menuitem-image-2x, inherit);
   }
 }
 
 toolbarbutton.webextension-menuitem > .toolbarbutton-icon {
   width: 16px;
   height: 16px;
--- a/browser/components/extensions/ExtensionPopups.jsm
+++ b/browser/components/extensions/ExtensionPopups.jsm
@@ -369,42 +369,38 @@ class BasePopup {
 /**
  * A map of active popups for a given browser window.
  *
  * WeakMap[window -> WeakMap[Extension -> BasePopup]]
  */
 BasePopup.instances = new DefaultWeakMap(() => new WeakMap());
 
 class PanelPopup extends BasePopup {
-  constructor(extension, imageNode, popupURL, browserStyle) {
-    let document = imageNode.ownerDocument;
-
+  constructor(extension, document, popupURL, browserStyle) {
     let panel = document.createElement("panel");
     panel.setAttribute("id", makeWidgetId(extension.id) + "-panel");
     panel.setAttribute("class", "browser-extension-panel");
     panel.setAttribute("tabspecific", "true");
     panel.setAttribute("type", "arrow");
     panel.setAttribute("role", "group");
     if (extension.remote) {
       panel.setAttribute("remote", "true");
     }
 
     document.getElementById("mainPopupSet").appendChild(panel);
 
-    super(extension, panel, popupURL, browserStyle);
-
-    this.contentReady.then(() => {
-      panel.openPopup(imageNode, "bottomcenter topright", 0, 0, false, false);
-
+    panel.addEventListener("popupshowing", () => {
       let event = new this.window.CustomEvent("WebExtPopupLoaded", {
         bubbles: true,
         detail: {extension},
       });
       this.browser.dispatchEvent(event);
-    });
+    }, {once: true});
+
+    super(extension, panel, popupURL, browserStyle);
   }
 
   get DESTROY_EVENT() {
     return "popuphidden";
   }
 
   destroy() {
     super.destroy();
--- a/browser/components/extensions/ext-pageAction.js
+++ b/browser/components/extensions/ext-pageAction.js
@@ -1,26 +1,24 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 // The ext-* files are imported into the same scopes.
 /* import-globals-from ext-browserAction.js */
 /* import-globals-from ext-browser.js */
 
+XPCOMUtils.defineLazyModuleGetter(this, "PageActions",
+                                  "resource:///modules/PageActions.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PanelPopup",
                                   "resource:///modules/ExtensionPopups.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch",
                                   "resource://gre/modules/TelemetryStopwatch.jsm");
 
 
-var {
-  DefaultWeakMap,
-} = ExtensionUtils;
-
 Cu.import("resource://gre/modules/ExtensionParent.jsm");
 
 var {
   IconDetails,
   StartupCache,
 } = ExtensionParent;
 
 const popupOpenTimingHistogram = "WEBEXT_PAGEACTION_POPUP_OPEN_MS";
@@ -32,19 +30,18 @@ this.pageAction = class extends Extensio
   static for(extension) {
     return pageActionMap.get(extension);
   }
 
   async onManifestEntry(entryName) {
     let {extension} = this;
     let options = extension.manifest.page_action;
 
-    this.iconData = new DefaultWeakMap(icons => this.getIconData(icons));
-
-    this.id = makeWidgetId(extension.id) + "-page-action";
+    let widgetId = makeWidgetId(extension.id);
+    this.id = widgetId + "-page-action";
 
     this.tabManager = extension.tabManager;
 
     this.defaults = {
       show: false,
       title: options.default_title || extension.name,
       popup: options.default_popup || "",
     };
@@ -55,42 +52,44 @@ this.pageAction = class extends Extensio
                                  "or not in your page_action options.");
     }
 
     this.tabContext = new TabContext(tab => Object.create(this.defaults),
                                      extension);
 
     this.tabContext.on("location-change", this.handleLocationChange.bind(this)); // eslint-disable-line mozilla/balanced-listeners
 
-    // WeakMap[ChromeWindow -> <xul:image>]
-    this.buttons = new WeakMap();
-
     pageActionMap.set(extension, this);
 
     this.defaults.icon = await StartupCache.get(
       extension, ["pageAction", "default_icon"],
       () => IconDetails.normalize({path: options.default_icon}, extension));
 
-    this.iconData.set(
-      this.defaults.icon,
-      await StartupCache.get(
-        extension, ["pageAction", "default_icon_data"],
-        () => this.getIconData(this.defaults.icon)));
+    if (!this.browserPageAction) {
+      this.browserPageAction = PageActions.addAction(new PageActions.Action({
+        id: widgetId,
+        title: this.defaults.title,
+        iconURL: this.defaults.icon,
+        shownInUrlbar: true,
+        disabled: true,
+        onCommand: (event, buttonNode) => {
+          this.handleClick(event.target.ownerGlobal);
+        },
+      }));
+    }
   }
 
   onShutdown(reason) {
     pageActionMap.delete(this.extension);
 
     this.tabContext.shutdown();
 
-    for (let window of windowTracker.browserWindows()) {
-      if (this.buttons.has(window)) {
-        this.buttons.get(window).remove();
-        window.document.removeEventListener("popupshowing", this);
-      }
+    if (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];
   }
@@ -112,91 +111,34 @@ this.pageAction = class extends Extensio
     }
   }
 
   // Updates the page action button in the given window to reflect the
   // properties of the currently selected tab:
   //
   // Updates "tooltiptext" and "aria-label" to match "title" property.
   // Updates "image" to match the "icon" property.
-  // Shows or hides the icon, based on the "show" property.
+  // Enables or disables the icon, based on the "show" property.
   updateButton(window) {
     let tabData = this.tabContext.get(window.gBrowser.selectedTab);
-
-    if (!(tabData.show || this.buttons.has(window))) {
-      // Don't bother creating a button for a window until it actually
-      // needs to be shown.
-      return;
-    }
-
-    window.requestAnimationFrame(() => {
-      let button = this.getButton(window);
-
-      if (tabData.show) {
-        // Update the title and icon only if the button is visible.
-
-        let title = tabData.title || this.extension.name;
-        button.setAttribute("tooltiptext", title);
-        button.setAttribute("aria-label", title);
-        button.classList.add("webextension-page-action");
-
-        let {style} = this.iconData.get(tabData.icon);
-
-        button.setAttribute("style", style);
-      }
-
-      button.hidden = !tabData.show;
-    });
-  }
-
-  getIconData(icons) {
-    let getIcon = size => {
-      let {icon} = IconDetails.getPreferredIcon(icons, this.extension, size);
-      // TODO: implement theme based icon for pageAction (Bug 1398156)
-      return IconDetails.escapeUrl(icon);
-    };
+    let title = tabData.title || this.extension.name;
+    this.browserPageAction.setTitle(title, window);
+    this.browserPageAction.setTooltip(title, window);
+    this.browserPageAction.setDisabled(!tabData.show, window);
 
-    let style = `
-      --webextension-urlbar-image: url("${getIcon(16)}");
-      --webextension-urlbar-image-2x: url("${getIcon(32)}");
-    `;
-
-    return {style};
-  }
-
-  // Create an |image| node and add it to the |page-action-buttons|
-  // container in the given window.
-  addButton(window) {
-    let document = window.document;
-
-    let button = document.createElement("image");
-    button.id = this.id;
-    button.setAttribute("class", "urlbar-icon");
-
-    button.addEventListener("click", this); // eslint-disable-line mozilla/balanced-listeners
-
-    if (this.extension.hasPermission("menus") ||
-        this.extension.hasPermission("contextMenus")) {
-      document.addEventListener("popupshowing", this);
+    let iconURL;
+    if (typeof(tabData.icon) == "string") {
+      iconURL = IconDetails.escapeUrl(tabData.icon);
+    } else {
+      iconURL = Object.entries(tabData.icon).reduce((memo, [size, url]) => {
+        memo[size] = IconDetails.escapeUrl(url);
+        return memo;
+      }, {});
     }
-
-    document.getElementById("page-action-buttons").appendChild(button);
-
-    return button;
-  }
-
-  // Returns the page action button for the given window, creating it if
-  // it doesn't already exist.
-  getButton(window) {
-    if (!this.buttons.has(window)) {
-      let button = this.addButton(window);
-      this.buttons.set(window, button);
-    }
-
-    return this.buttons.get(window);
+    this.browserPageAction.setIconURL(iconURL, window);
   }
 
   /**
    * 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.
    *
@@ -204,41 +146,16 @@ this.pageAction = class extends Extensio
    */
   triggerAction(window) {
     let pageAction = pageActionMap.get(this.extension);
     if (pageAction.getProperty(window.gBrowser.selectedTab, "show")) {
       pageAction.handleClick(window);
     }
   }
 
-  handleEvent(event) {
-    const window = event.target.ownerGlobal;
-
-    switch (event.type) {
-      case "click":
-        if (event.button === 0) {
-          this.handleClick(window);
-        }
-        break;
-
-      case "popupshowing":
-        const menu = event.target;
-        const trigger = menu.triggerNode;
-
-        if (menu.id === "toolbar-context-menu" && trigger && trigger.id === this.id) {
-          global.actionContextMenu({
-            extension: this.extension,
-            onPageAction: true,
-            menu: menu,
-          });
-        }
-        break;
-    }
-  }
-
   // Handles a click event on the page action button for the given
   // window.
   // If the page action has a |popup| property, a panel is opened to
   // that URL. Otherwise, a "click" event is emitted, and dispatched to
   // the any click listeners in the add-on.
   async handleClick(window) {
     TelemetryStopwatch.start(popupOpenTimingHistogram, this);
     let tab = window.gBrowser.selectedTab;
@@ -246,19 +163,21 @@ this.pageAction = class extends Extensio
 
     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) {
-      let popup = new PanelPopup(this.extension, this.getButton(window),
-                                 popupURL, this.browserStyle);
+      let popup = new PanelPopup(this.extension, window.document, popupURL,
+                                 this.browserStyle);
       await popup.contentReady;
+      window.BrowserPageActions.togglePanelForAction(this.browserPageAction,
+                                                     popup.panel);
       TelemetryStopwatch.finish(popupOpenTimingHistogram, this);
     } else {
       TelemetryStopwatch.cancel(popupOpenTimingHistogram, this);
       this.emit("click", tab);
     }
   }
 
   handleLocationChange(eventType, tab, fromBrowse) {
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js
@@ -255,17 +255,17 @@ add_task(async function testDetailsObjec
       "data/a-x2.png": imageBuffer,
     },
   });
 
   const RESOLUTION_PREF = "layout.css.devPixelsPerPx";
 
   await extension.startup();
 
-  let pageActionId = `${makeWidgetId(extension.id)}-page-action`;
+  let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(makeWidgetId(extension.id));
   let browserActionWidget = getBrowserActionWidget(extension);
 
   let tests = await extension.awaitMessage("ready");
   for (let test of tests) {
     extension.sendMessage("setIcon", test);
     await extension.awaitMessage("iconSet");
 
     await promiseAnimationFrame();
@@ -355,17 +355,17 @@ add_task(async function testPageActionIc
   });
 
   await extension.startup();
 
   await extension.awaitMessage("ready");
 
   await promiseAnimationFrame();
 
-  let pageActionId = `${makeWidgetId(extension.id)}-page-action`;
+  let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(makeWidgetId(extension.id));
   let pageActionImage = document.getElementById(pageActionId);
 
   const iconURL = new URL(getListStyleImage(pageActionImage));
 
   is(iconURL.pathname, "/common_cached_icon.png", "Got the expected pageAction icon url");
 
   await extension.unload();
 });
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js
@@ -97,17 +97,17 @@ add_task(async function testDefaultDetai
         "baz/quux.png": imageBuffer,
         "baz/quux@2x.png": imageBuffer,
       },
     });
 
     await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
 
     let browserActionId = makeWidgetId(extension.id) + "-browser-action";
-    let pageActionId = makeWidgetId(extension.id) + "-page-action";
+    let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(makeWidgetId(extension.id));
 
     await promiseAnimationFrame();
 
     let browserActionButton = document.getElementById(browserActionId);
     let image = getListStyleImage(browserActionButton);
 
     ok(expectedURL.test(image), `browser action image ${image} matches ${expectedURL}`);
 
--- a/browser/components/extensions/test/browser/browser_ext_menus.js
+++ b/browser/components/extensions/test/browser/browser_ext_menus.js
@@ -63,17 +63,20 @@ add_task(async function test_actionConte
   }
 
   const extension = ExtensionTestUtils.loadExtension({manifest, background});
   const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
 
   await extension.startup();
   const tabId = await extension.awaitMessage("ready");
 
-  for (const kind of ["page", "browser"]) {
+  // TODO bug 1412170: Allow WebExtensions to hook into the browser page action
+  // context menu.
+//   for (const kind of ["page", "browser"]) {
+  for (const kind of ["browser"]) {
     const menu = await openActionContextMenu(extension, kind);
     const [submenu, second, , , , last, separator] = menu.children;
 
     is(submenu.tagName, "menu", "Correct submenu type");
     is(submenu.label, "parent", "Correct submenu title");
 
     const popup = await openSubmenu(submenu);
     is(popup, submenu.firstChild, "Correct submenu opened");
--- a/browser/components/extensions/test/browser/head.js
+++ b/browser/components/extensions/test/browser/head.js
@@ -388,17 +388,20 @@ function closeChromeContextMenu(menuId, 
   }
   return hidden;
 }
 
 async function openActionContextMenu(extension, kind, win = window) {
   // See comment from clickPageAction below.
   SetPageProxyState("valid");
   await promiseAnimationFrame(win);
-  const id = `#${makeWidgetId(extension.id)}-${kind}-action`;
+  const id =
+    kind == "page" ?
+    `#${BrowserPageActions.urlbarButtonNodeIDForActionID(makeWidgetId(extension.id))}` :
+    `#${makeWidgetId(extension.id)}-${kind}-action`;
   return openChromeContextMenu("toolbar-context-menu", id, win);
 }
 
 function closeActionContextMenu(itemToSelect, win = window) {
   return closeChromeContextMenu("toolbar-context-menu", itemToSelect, win);
 }
 
 function openTabContextMenu(win = window) {
@@ -420,17 +423,18 @@ async function clickPageAction(extension
   // identity info and icons such as page action buttons.
   //
   // Unfortunately, that doesn't happen automatically in browser chrome
   // tests.
   SetPageProxyState("valid");
 
   await promiseAnimationFrame(win);
 
-  let pageActionId = makeWidgetId(extension.id) + "-page-action";
+  let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(makeWidgetId(extension.id));
+
   let elem = win.document.getElementById(pageActionId);
 
   EventUtils.synthesizeMouseAtCenter(elem, {}, win);
   return new Promise(SimpleTest.executeSoon);
 }
 
 function closePageAction(extension, win = window) {
   let node = getPageActionPopup(extension, win);
--- a/browser/components/extensions/test/browser/head_pageAction.js
+++ b/browser/components/extensions/test/browser/head_pageAction.js
@@ -100,17 +100,17 @@ async function runTests(options) {
 
   let pageActionId;
   let currentWindow = window;
   let windows = [];
 
   function checkDetails(details) {
     let image = currentWindow.document.getElementById(pageActionId);
     if (details == null) {
-      ok(image == null || image.hidden, "image is hidden");
+      ok(image == null || image.getAttribute("disabled") == "true", "image is disabled");
     } else {
       ok(image, "image exists");
 
       is(getListStyleImage(image), details.icon, "icon URL is correct");
 
       let title = details.title || options.manifest.name;
       is(image.getAttribute("tooltiptext"), title, "image title is correct");
       is(image.getAttribute("aria-label"), title, "image aria-label is correct");
@@ -118,17 +118,17 @@ async function runTests(options) {
     }
   }
 
   let testNewWindows = 1;
 
   let awaitFinish = new Promise(resolve => {
     extension.onMessage("nextTest", async (expecting, testsRemaining) => {
       if (!pageActionId) {
-        pageActionId = `${makeWidgetId(extension.id)}-page-action`;
+        pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(makeWidgetId(extension.id));
       }
 
       await promiseAnimationFrame(currentWindow);
 
       checkDetails(expecting);
 
       if (testsRemaining) {
         extension.sendMessage("runNextTest");