Bug 1395387 - Reconcile WebExtension page actions and Photon page actions: Photon page actions changes. r?Gijs draft
authorDrew Willcoxon <adw@mozilla.com>
Fri, 27 Oct 2017 17:39:38 -0400
changeset 687854 7d6c03a99ba1929a2cc2d9815b65d62bb876d625
parent 686801 0d1e55d87931fe70ec1d007e886bcd58015ff770
child 687855 ab5e4fa906dd1f4cedcfd5e1e4cd09c414eac31b
push id86621
push userdwillcoxon@mozilla.com
push dateFri, 27 Oct 2017 21:40:47 +0000
reviewersGijs
bugs1395387
milestone58.0a1
Bug 1395387 - Reconcile WebExtension page actions and Photon page actions: Photon page actions changes. r?Gijs MozReview-Commit-ID: 5NOc9N2idRE
browser/base/content/browser-pageActions.js
browser/base/content/browser.css
browser/base/content/test/urlbar/browser_page_action_menu.js
browser/extensions/pocket/bootstrap.js
browser/modules/PageActions.jsm
browser/modules/test/browser/browser_PageActions.js
browser/themes/shared/urlbar-searchbar.inc.css
--- a/browser/base/content/browser-pageActions.js
+++ b/browser/base/content/browser-pageActions.js
@@ -90,29 +90,30 @@ var BrowserPageActions = {
   /**
    * Adds or removes as necessary DOM nodes for the action in the panel.
    *
    * @param  action (PageActions.Action, required)
    *         The action to place.
    */
   placeActionInPanel(action) {
     let insertBeforeID = PageActions.nextActionIDInPanel(action);
-    let id = this._panelButtonNodeIDForActionID(action.id);
+    let id = this.panelButtonNodeIDForActionID(action.id);
     let node = document.getElementById(id);
     if (!node) {
       let panelViewNode;
       [node, panelViewNode] = this._makePanelButtonNodeForAction(action);
       node.id = id;
       let insertBeforeNode = null;
       if (insertBeforeID) {
         let insertBeforeNodeID =
-          this._panelButtonNodeIDForActionID(insertBeforeID);
+          this.panelButtonNodeIDForActionID(insertBeforeID);
         insertBeforeNode = document.getElementById(insertBeforeNodeID);
       }
       this.mainViewBodyNode.insertBefore(node, insertBeforeNode);
+      this.updateAction(action);
       action.onPlacedInPanel(node);
       if (panelViewNode) {
         action.subview.onPlaced(panelViewNode);
       }
     }
   },
 
   _makePanelButtonNodeForAction(action) {
@@ -122,20 +123,16 @@ var BrowserPageActions = {
     }
 
     let buttonNode = document.createElement("toolbarbutton");
     buttonNode.classList.add(
       "subviewbutton",
       "subviewbutton-iconic",
       "pageAction-panel-button"
     );
-    buttonNode.setAttribute("label", action.title);
-    if (action.iconURL) {
-      buttonNode.style.listStyleImage = `url('${action.iconURL}')`;
-    }
     if (action.nodeAttributes) {
       for (let name in action.nodeAttributes) {
         buttonNode.setAttribute(name, action.nodeAttributes[name]);
       }
     }
     let panelViewNode = null;
     if (action.subview) {
       buttonNode.classList.add("subviewbutton-nav");
@@ -172,28 +169,59 @@ var BrowserPageActions = {
       buttonNode.addEventListener("command", event => {
         button.onCommand(event, buttonNode);
       });
       bodyNode.appendChild(buttonNode);
     }
     return panelViewNode;
   },
 
-  _toggleActivatedActionPanelForAction(action) {
-    let panelNode = this.activatedActionPanelNode;
+  /**
+   * Shows or hides a panel for an action.  You can supply your own panel;
+   * otherwise one is created.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action for which to toggle the panel.  If the action is in the
+   *         urlbar, then the panel will be anchored to it.  Otherwise, a
+   *         suitable anchor will be used.
+   * @param  panelNode (DOM node, optional)
+   *         The panel to use.  This method takes a hands-off approach with
+   *         regard to your panel in terms of attributes, styling, etc.
+   */
+  togglePanelForAction(action, panelNode = null) {
+    let aaPanelNode = this.activatedActionPanelNode;
     if (panelNode) {
-      panelNode.hidePopup();
-      return null;
+      if (panelNode.state != "closed") {
+        panelNode.hidePopup();
+        return;
+      }
+      if (aaPanelNode) {
+        aaPanelNode.hidePopup();
+      }
+    } else if (aaPanelNode) {
+      aaPanelNode.hidePopup();
+      return;
+    } else {
+      panelNode = this._makeActivatedActionPanelForAction(action);
     }
 
-    // Before creating the panel, get the anchor node for it because it'll throw
-    // if there isn't one (which shouldn't happen, but still).
+    // Hide the main panel before showing the action's panel.
+    this.panelNode.hidePopup();
+
     let anchorNode = this.panelAnchorNodeForAction(action);
+    anchorNode.setAttribute("open", "true");
+    panelNode.addEventListener("popuphiding", () => {
+      anchorNode.removeAttribute("open");
+    }, { once: true });
 
-    panelNode = document.createElement("panel");
+    panelNode.openPopup(anchorNode, "bottomcenter topright");
+  },
+
+  _makeActivatedActionPanelForAction(action) {
+    let panelNode = document.createElement("panel");
     panelNode.id = this._activatedActionPanelID;
     panelNode.classList.add("cui-widget-panel");
     panelNode.setAttribute("actionID", action.id);
     panelNode.setAttribute("role", "group");
     panelNode.setAttribute("type", "arrow");
     panelNode.setAttribute("flip", "slide");
     panelNode.setAttribute("noautofocus", "true");
     panelNode.setAttribute("tabspecific", "true");
@@ -221,38 +249,30 @@ var BrowserPageActions = {
     popupSet.appendChild(panelNode);
     panelNode.addEventListener("popuphidden", () => {
       if (iframeNode) {
         action.onIframeHidden(iframeNode, panelNode);
       }
       panelNode.remove();
     }, { once: true });
 
-    panelNode.addEventListener("popuphiding", () => {
-      if (iframeNode) {
+    if (iframeNode) {
+      panelNode.addEventListener("popupshown", () => {
+        action.onIframeShown(iframeNode, panelNode);
+      }, { once: true });
+      panelNode.addEventListener("popuphiding", () => {
         action.onIframeHiding(iframeNode, panelNode);
-      }
-      anchorNode.removeAttribute("open");
-    }, { once: true });
+      }, { once: true });
+    }
 
     if (panelViewNode) {
       action.subview.onPlaced(panelViewNode);
       action.subview.onShowing(panelViewNode);
     }
 
-    // Hide the main page action panel before showing the activated-action
-    // panel.
-    this.panelNode.hidePopup();
-    panelNode.openPopup(anchorNode, "bottomcenter topright");
-    anchorNode.setAttribute("open", "true");
-
-    if (iframeNode) {
-      action.onIframeShown(iframeNode, panelNode);
-    }
-
     return panelNode;
   },
 
   // For tests.
   get _disablePanelAnimations() {
     return this.__disablePanelAnimations || false;
   },
   set _disablePanelAnimations(val) {
@@ -269,24 +289,24 @@ var BrowserPageActions = {
    * be anchored.  If the action is null, a sensible anchor is returned.
    *
    * @param  action (PageActions.Action, optional)
    *         The action you want to anchor.
    * @return (DOM node, nonnull) The node to which the action should be
    *         anchored.
    */
   panelAnchorNodeForAction(action, event) {
-    // Try each of the following nodes in order, using the first that's visible.
     if (event && event.target.closest("panel")) {
       return this.mainButtonNode;
     }
 
+    // Try each of the following nodes in order, using the first that's visible.
     let potentialAnchorNodeIDs = [
       action && action.anchorIDOverride,
-      action && this._urlbarButtonNodeIDForActionID(action.id),
+      action && this.urlbarButtonNodeIDForActionID(action.id),
       this.mainButtonNode.id,
       "identity-icon",
     ];
     let dwu = window.QueryInterface(Ci.nsIInterfaceRequestor)
                     .getInterface(Ci.nsIDOMWindowUtils);
     for (let id of potentialAnchorNodeIDs) {
       if (id) {
         let node = document.getElementById(id);
@@ -313,17 +333,17 @@ var BrowserPageActions = {
   /**
    * Adds or removes as necessary a DOM node for the given action in the urlbar.
    *
    * @param  action (PageActions.Action, required)
    *         The action to place.
    */
   placeActionInUrlbar(action) {
     let insertBeforeID = PageActions.nextActionIDInUrlbar(action);
-    let id = this._urlbarButtonNodeIDForActionID(action.id);
+    let id = this.urlbarButtonNodeIDForActionID(action.id);
     let node = document.getElementById(id);
 
     if (!action.shownInUrlbar) {
       if (node) {
         if (action.__urlbarNodeInMarkup) {
           node.hidden = true;
         } else {
           node.remove();
@@ -342,46 +362,41 @@ var BrowserPageActions = {
       node.id = id;
     }
 
     if (newlyPlaced) {
       let parentNode = this.mainButtonNode.parentNode;
       let insertBeforeNode = null;
       if (insertBeforeID) {
         let insertBeforeNodeID =
-          this._urlbarButtonNodeIDForActionID(insertBeforeID);
+          this.urlbarButtonNodeIDForActionID(insertBeforeID);
         insertBeforeNode = document.getElementById(insertBeforeNodeID);
       }
       parentNode.insertBefore(node, insertBeforeNode);
+      this.updateAction(action);
       action.onPlacedInUrlbar(node);
 
       // urlbar buttons should always have tooltips, so if the node doesn't have
       // one, then as a last resort use the label of the corresponding panel
       // button.  Why not set tooltiptext to action.title when the node is
       // created?  Because the consumer may set a title dynamically.
       if (!node.hasAttribute("tooltiptext")) {
-        let panelNodeID = this._panelButtonNodeIDForActionID(action.id);
+        let panelNodeID = this.panelButtonNodeIDForActionID(action.id);
         let panelNode = document.getElementById(panelNodeID);
         if (panelNode) {
           node.setAttribute("tooltiptext", panelNode.getAttribute("label"));
         }
       }
     }
   },
 
   _makeUrlbarButtonNode(action) {
     let buttonNode = document.createElement("image");
     buttonNode.classList.add("urlbar-icon", "urlbar-page-action");
     buttonNode.setAttribute("role", "button");
-    if (action.tooltip) {
-      buttonNode.setAttribute("tooltiptext", action.tooltip);
-    }
-    if (action.iconURL) {
-      buttonNode.style.listStyleImage = `url('${action.iconURL}')`;
-    }
     buttonNode.setAttribute("context", "pageActionPanelContextMenu");
     buttonNode.addEventListener("contextmenu", event => {
       BrowserPageActions.onContextMenu(event);
     });
     if (action.nodeAttributes) {
       for (let name in action.nodeAttributes) {
         buttonNode.setAttribute(name, action.nodeAttributes[name]);
       }
@@ -399,85 +414,142 @@ var BrowserPageActions = {
    *         The action to remove.
    */
   removeAction(action) {
     this._removeActionFromPanel(action);
     this._removeActionFromUrlbar(action);
   },
 
   _removeActionFromPanel(action) {
-    let id = this._panelButtonNodeIDForActionID(action.id);
+    let id = this.panelButtonNodeIDForActionID(action.id);
     let node = document.getElementById(id);
     if (node) {
       node.remove();
     }
     if (action.subview) {
       let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
       let panelViewNode = document.getElementById(panelViewNodeID);
       if (panelViewNode) {
         panelViewNode.remove();
       }
     }
     // If there are now no more non-built-in actions, remove the separator
     // between the built-ins and non-built-ins.
     if (!PageActions.nonBuiltInActions.length) {
       let separator = document.getElementById(
-        this._panelButtonNodeIDForActionID(
+        this.panelButtonNodeIDForActionID(
           PageActions.ACTION_ID_BUILT_IN_SEPARATOR
         )
       );
       if (separator) {
         separator.remove();
       }
     }
   },
 
   _removeActionFromUrlbar(action) {
-    let id = this._urlbarButtonNodeIDForActionID(action.id);
+    let id = this.urlbarButtonNodeIDForActionID(action.id);
     let node = document.getElementById(id);
     if (node) {
       node.remove();
     }
   },
 
   /**
-   * Updates the DOM nodes of an action to reflect its changed iconURL.
+   * Updates the DOM nodes of an action to reflect either a changed property or
+   * all properties.
    *
    * @param  action (PageActions.Action, required)
    *         The action to update.
+   * @param  nameToUpdate (string, optional)
+   *         The property's name.  If not given, then DOM nodes will be updated
+   *         to reflect the current values of all properties.
    */
-  updateActionIconURL(action) {
-    let url = action.iconURL ? `url('${action.iconURL}')` : null;
+  updateAction(action, nameToUpdate = null) {
+    let names = nameToUpdate ? [nameToUpdate] : [
+      "disabled",
+      "iconURL",
+      "title",
+      "tooltip",
+    ];
+    for (let name of names) {
+      let upper = name[0].toUpperCase() + name.substr(1);
+      this[`_updateAction${upper}`](action);
+    }
+  },
+
+  _updateActionDisabled(action) {
     let nodeIDs = [
-      this._panelButtonNodeIDForActionID(action.id),
-      this._urlbarButtonNodeIDForActionID(action.id),
+      this.panelButtonNodeIDForActionID(action.id),
+      this.urlbarButtonNodeIDForActionID(action.id),
     ];
     for (let nodeID of nodeIDs) {
       let node = document.getElementById(nodeID);
       if (node) {
-        if (url) {
-          node.style.listStyleImage = url;
+        if (action.getDisabled(window)) {
+          node.setAttribute("disabled", "true");
         } else {
-          node.style.removeProperty("list-style-image");
+          node.removeAttribute("disabled");
         }
       }
     }
   },
 
-  /**
-   * Updates the DOM nodes of an action to reflect its changed title.
-   *
-   * @param  action (PageActions.Action, required)
-   *         The action to update.
-   */
-  updateActionTitle(action) {
-    let id = this._panelButtonNodeIDForActionID(action.id);
-    let node = document.getElementById(id);
+  _updateActionIconURL(action) {
+    let nodeIDs = [
+      this.panelButtonNodeIDForActionID(action.id),
+      this.urlbarButtonNodeIDForActionID(action.id),
+    ];
+    for (let nodeID of nodeIDs) {
+      let node = document.getElementById(nodeID);
+      if (node) {
+        for (let size of [16, 32]) {
+          let url = action.iconURLForSize(size, window);
+          let prop = `--pageAction-image-${size}px`;
+          if (url) {
+            node.style.setProperty(prop, `url("${url}")`);
+          } else {
+            node.style.removeProperty(prop);
+          }
+        }
+      }
+    }
+  },
+
+  _updateActionTitle(action) {
+    let title = action.getTitle(window);
+    if (!title) {
+      // `title` is a required action property, but the bookmark action's is an
+      // empty string since its actual title is set via
+      // BookmarkingUI.updateBookmarkPageMenuItem().  The purpose of this early
+      // return is to ignore that empty title.
+      return;
+    }
+    let attrNamesByNodeIDFnName = {
+      panelButtonNodeIDForActionID: "label",
+      urlbarButtonNodeIDForActionID: "aria-label",
+    };
+    for (let [fnName, attrName] of Object.entries(attrNamesByNodeIDFnName)) {
+      let nodeID = this[fnName](action.id);
+      let node = document.getElementById(nodeID);
+      if (node) {
+        node.setAttribute(attrName, title);
+      }
+    }
+    // tooltiptext falls back to the title, so update it, too.
+    this._updateActionTooltip(action);
+  },
+
+  _updateActionTooltip(action) {
+    let node = document.getElementById(
+      this.urlbarButtonNodeIDForActionID(action.id)
+    );
     if (node) {
-      node.setAttribute("label", action.title);
+      let tooltip = action.getTooltip(window) || action.getTitle(window);
+      node.setAttribute("tooltiptext", tooltip);
     }
   },
 
   doCommandForAction(action, event, buttonNode) {
     if (event && event.type == "click" && event.button != 0) {
       return;
     }
     PageActions.logTelemetry("used", action, buttonNode);
@@ -491,17 +563,17 @@ var BrowserPageActions = {
       this.multiViewNode.showSubView(panelViewNode, buttonNode);
       return;
     }
     // Otherwise, hide the main popup in case it was open:
     this.panelNode.hidePopup();
 
     // Toggle the activated action's panel if necessary
     if (action.subview || action.wantsIframe) {
-      this._toggleActivatedActionPanelForAction(action);
+      this.togglePanelForAction(action);
       return;
     }
 
     // Otherwise, run the action.
     action.onCommand(event, buttonNode);
   },
 
   /**
@@ -530,23 +602,35 @@ var BrowserPageActions = {
         }
         actionID = this._actionIDForNodeID(n.id);
         action = PageActions.actionForID(actionID);
       }
     }
     return action;
   },
 
-  // The ID of the given action's top-level button in the panel.
-  _panelButtonNodeIDForActionID(actionID) {
+  /**
+   * The ID of the given action's top-level button in the main panel.
+   *
+   * @param  actionID (string, required)
+   *         The action ID.
+   * @return (string) The ID of the action's button in the main panel.
+   */
+  panelButtonNodeIDForActionID(actionID) {
     return `pageAction-panel-${actionID}`;
   },
 
-  // The ID of the given action's button in the urlbar.
-  _urlbarButtonNodeIDForActionID(actionID) {
+  /**
+   * The ID of the given action's button in the urlbar.
+   *
+   * @param  actionID (string, required)
+   *         The action ID.
+   * @return (string) The ID of the action's urlbar button node.
+   */
+  urlbarButtonNodeIDForActionID(actionID) {
     let action = PageActions.actionForID(actionID);
     if (action && action.urlbarIDOverride) {
       return action.urlbarIDOverride;
     }
     return `pageAction-urlbar-${actionID}`;
   },
 
   // The ID of the given action's panelview.
@@ -613,17 +697,17 @@ var BrowserPageActions = {
    * Show the page action panel
    *
    * @param  event (DOM event, optional)
    *         The event that triggers showing the panel. (such as a mouse click,
    *         if the user clicked something to open the panel)
    */
   showPanel(event = null) {
     for (let action of PageActions.actions) {
-      let buttonNodeID = this._panelButtonNodeIDForActionID(action.id);
+      let buttonNodeID = this.panelButtonNodeIDForActionID(action.id);
       let buttonNode = document.getElementById(buttonNodeID);
       action.onShowingInPanel(buttonNode);
     }
 
     this.panelNode.hidden = false;
     this.panelNode.addEventListener("popuphiding", () => {
       this.mainButtonNode.removeAttribute("open");
     }, {once: true});
@@ -688,30 +772,47 @@ var BrowserPageActions = {
     let telemetryType = this._contextAction.shownInUrlbar ? "removed" : "added";
     PageActions.logTelemetry(telemetryType, this._contextAction);
     this._contextAction.shownInUrlbar = !this._contextAction.shownInUrlbar;
   },
 
   _contextAction: null,
 
   /**
-   * A bunch of strings (labels for actions and the like) are defined in DTD,
-   * but actions are created in JS.  So what we do is add a bunch of attributes
-   * to the page action panel's definition in the markup, whose values hold
-   * these DTD strings.  Then when each built-in action is set up, we get the
-   * related strings from the panel node and set up the action's node with them.
+   * Titles for a few of the built-in actions are defined in DTD, but the
+   * actions are created in JS.  So what we do is for each title, set an
+   * attribute in markup on the main page action panel whose value is the DTD
+   * string.  In gBuiltInActions, where the built-in actions are defined, we set
+   * the action's initial title to the name of this attribute.  Then when the
+   * action is set up, we get the action's current title, and then get the
+   * attribute on the main panel whose name is that title.  If the attribute
+   * exists, then its value is the actual title, and we update the action with
+   * this title.  Otherwise the action's title has already been set up in this
+   * manner.
    *
-   * The convention is to set for example the "title" property in an action's JS
-   * definition to the name of the attribute on the panel node that holds the
-   * actual title string.  Then call this function, passing the action's related
-   * DOM node and the name of the attribute that you are setting on the DOM
-   * node -- "label" or "title" in this example (either will do).
+   * @param  action (PageActions.Action, required)
+   *         The action whose title you're setting.
+   */
+  takeActionTitleFromPanel(action) {
+    let titleOrAttrNameOnPanel = action.getTitle();
+    let attrValueOnPanel = this.panelNode.getAttribute(titleOrAttrNameOnPanel);
+    if (attrValueOnPanel) {
+      this.panelNode.removeAttribute(titleOrAttrNameOnPanel);
+      action.setTitle(attrValueOnPanel);
+    }
+  },
+
+  /**
+   * This is similar to takeActionTitleFromPanel, except it sets an attribute on
+   * a DOM node instead of setting the title on an action.  The point is to map
+   * attributes on the node to strings on the main panel.  Use this for DOM
+   * nodes that don't correspond to actions, like buttons in subviews.
    *
    * @param  node (DOM node, required)
-   *         The node of an action you're setting up.
+   *         The node you're setting up.
    * @param  attrName (string, required)
    *         The name of the attribute *on the node you're setting up*.
    */
   takeNodeAttributeFromPanel(node, attrName) {
     let panelAttrName = node.getAttribute(attrName);
     if (!panelAttrName && attrName == "title") {
       attrName = "label";
       panelAttrName = node.getAttribute(attrName);
@@ -729,16 +830,17 @@ var BrowserPageActions = {
    */
   onLocationChange() {
     for (let action of PageActions.actions) {
       action.onLocationChange(window);
     }
   },
 };
 
+
 var BrowserPageActionFeedback = {
   /**
    * The feedback page action panel DOM node (DOM node)
    */
   get panelNode() {
     delete this.panelNode;
     return this.panelNode = document.getElementById("pageActionFeedback");
   },
@@ -773,16 +875,17 @@ var BrowserPageActionFeedback = {
       }, Services.prefs.getIntPref("browser.pageActions.feedbackTimeoutMS", 1120));
     }, {once: true});
     this.panelNode.addEventListener("popuphidden", () => {
       this.feedbackAnimationBox.removeAttribute("animate");
     }, {once: true});
   },
 };
 
+
 // built-in actions below //////////////////////////////////////////////////////
 
 // bookmark
 BrowserPageActions.bookmark = {
   onShowingInPanel(buttonNode) {
     // Update the button label via the bookmark observer.
     BookmarkingUI.updateBookmarkPageMenuItem();
   },
@@ -791,45 +894,48 @@ BrowserPageActions.bookmark = {
     BrowserPageActions.panelNode.hidePopup();
     BookmarkingUI.onStarCommand(event);
   },
 };
 
 // copy URL
 BrowserPageActions.copyURL = {
   onPlacedInPanel(buttonNode) {
-    BrowserPageActions.takeNodeAttributeFromPanel(buttonNode, "title");
+    let action = PageActions.actionForID("copyURL");
+    BrowserPageActions.takeActionTitleFromPanel(action);
   },
 
   onCommand(event, buttonNode) {
     BrowserPageActions.panelNode.hidePopup();
     Cc["@mozilla.org/widget/clipboardhelper;1"]
       .getService(Ci.nsIClipboardHelper)
       .copyString(gURLBar.makeURIReadable(gBrowser.selectedBrowser.currentURI).displaySpec);
     let action = PageActions.actionForID("copyURL");
     BrowserPageActionFeedback.show(action, event);
   },
 };
 
 // email link
 BrowserPageActions.emailLink = {
   onPlacedInPanel(buttonNode) {
-    BrowserPageActions.takeNodeAttributeFromPanel(buttonNode, "title");
+    let action = PageActions.actionForID("emailLink");
+    BrowserPageActions.takeActionTitleFromPanel(action);
   },
 
   onCommand(event, buttonNode) {
     BrowserPageActions.panelNode.hidePopup();
     MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);
   },
 };
 
 // send to device
 BrowserPageActions.sendToDevice = {
   onPlacedInPanel(buttonNode) {
-    BrowserPageActions.takeNodeAttributeFromPanel(buttonNode, "title");
+    let action = PageActions.actionForID("sendToDevice");
+    BrowserPageActions.takeActionTitleFromPanel(action);
   },
 
   onSubviewPlaced(panelViewNode) {
     let bodyNode = panelViewNode.firstChild;
     for (let node of bodyNode.childNodes) {
       BrowserPageActions.takeNodeAttributeFromPanel(node, "title");
       BrowserPageActions.takeNodeAttributeFromPanel(node, "shortcut");
     }
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -1429,16 +1429,37 @@ toolbarpaletteitem[place="palette"][hidd
 }
 
 /* Page action panel */
 #pageAction-panel-sendToDevice-subview-body:not([state="notready"]) > #pageAction-panel-sendToDevice-notReady,
 #pageAction-urlbar-sendToDevice-subview-body:not([state="notready"]) > #pageAction-urlbar-sendToDevice-notReady {
   display: none;
 }
 
+/* Page action buttons */
+.pageAction-panel-button > .toolbarbutton-icon {
+  list-style-image: var(--pageAction-image-16px, inherit);
+}
+.urlbar-page-action {
+  list-style-image: var(--pageAction-image-16px, inherit);
+}
+@media (min-resolution: 1.1dppx) {
+  .pageAction-panel-button > .toolbarbutton-icon {
+    list-style-image: var(--pageAction-image-32px, inherit);
+  }
+  .urlbar-page-action {
+    list-style-image: var(--pageAction-image-32px, inherit);
+  }
+}
+
+.urlbar-page-action[disabled] {
+  pointer-events: none;
+  -moz-user-focus: ignore;
+}
+
 /* WebExtension Sidebars */
 #sidebar-box[sidebarcommand$="-sidebar-action"] > #sidebar-header > #sidebar-switcher-target > #sidebar-icon {
   list-style-image: var(--webextension-menuitem-image, inherit);
   -moz-context-properties: fill;
   fill: currentColor;
   width: 16px;
   height: 16px;
 }
--- a/browser/base/content/test/urlbar/browser_page_action_menu.js
+++ b/browser/base/content/test/urlbar/browser_page_action_menu.js
@@ -543,17 +543,17 @@ add_task(async function sendToDevice_inU
     registerCleanupFunction(cleanUp);
 
     // Add Send to Device to the urlbar.
     let action = PageActions.actionForID("sendToDevice");
     action.shownInUrlbar = true;
 
     // Click it to open its panel.
     let urlbarButton = document.getElementById(
-      BrowserPageActions._urlbarButtonNodeIDForActionID(action.id)
+      BrowserPageActions.urlbarButtonNodeIDForActionID(action.id)
     );
     Assert.ok(!urlbarButton.disabled);
     let panelPromise =
       promisePanelShown(BrowserPageActions._activatedActionPanelID);
     EventUtils.synthesizeMouseAtCenter(urlbarButton, {});
     await panelPromise;
     Assert.equal(urlbarButton.getAttribute("open"), "true",
       "Button has open attribute");
--- a/browser/extensions/pocket/bootstrap.js
+++ b/browser/extensions/pocket/bootstrap.js
@@ -213,17 +213,17 @@ var PocketPageAction = {
   // Sets or removes the "pocketed" attribute on the Pocket urlbar button as
   // necessary.
   updateUrlbarNodeState(browserWindow) {
     if (!this.pageAction) {
       return;
     }
     let {BrowserPageActions} = browserWindow;
     let urlbarNode = browserWindow.document.getElementById(
-      BrowserPageActions._urlbarButtonNodeIDForActionID(this.pageAction.id)
+      BrowserPageActions.urlbarButtonNodeIDForActionID(this.pageAction.id)
     );
     if (!urlbarNode) {
       return;
     }
     let browser = browserWindow.gBrowser.selectedBrowser;
     let pocketedInnerWindowID = this.innerWindowIDsByBrowser.get(browser);
     if (pocketedInnerWindowID == browser.innerWindowID) {
       // The current window in this browser is pocketed.
--- a/browser/modules/PageActions.jsm
+++ b/browser/modules/PageActions.jsm
@@ -205,17 +205,17 @@ this.PageActions = {
     } else if (gBuiltInActions.find(a => a.id == action.id)) {
       // A built-in action.  These are always added on init before all other
       // actions, one after the other, so just push onto the array.
       this._builtInActions.push(action);
     } else {
       // A non-built-in action, like a non-bundled extension potentially.
       // Keep this list sorted by title.
       let index = BinarySearch.insertionIndexOf((a1, a2) => {
-        return a1.title.localeCompare(a2.title);
+        return a1.getTitle().localeCompare(a2.getTitle());
       }, this._nonBuiltInActions, action);
       this._nonBuiltInActions.splice(index, 0, action);
     }
 
     if (this._persistedActions.ids.includes(action.id)) {
       // The action has been seen before.  Override its shownInUrlbar value
       // with the persisted value.  Set the private version of that property
       // so that onActionToggledShownInUrlbar isn't called, which happens when
@@ -346,48 +346,16 @@ this.PageActions = {
     this._storePersistedActions();
 
     for (let bpa of allBrowserPageActions()) {
       bpa.removeAction(action);
     }
   },
 
   /**
-   * Call this when an action's iconURL changes.
-   *
-   * @param  action (Action object, required)
-   *         The action whose iconURL property changed.
-   */
-  onActionSetIconURL(action) {
-    if (!this.actionForID(action.id)) {
-      // This may be called before the action has been added.
-      return;
-    }
-    for (let bpa of allBrowserPageActions()) {
-      bpa.updateActionIconURL(action);
-    }
-  },
-
-  /**
-   * Call this when an action's title changes.
-   *
-   * @param  action (Action object, required)
-   *         The action whose title property changed.
-   */
-  onActionSetTitle(action) {
-    if (!this.actionForID(action.id)) {
-      // This may be called before the action has been added.
-      return;
-    }
-    for (let bpa of allBrowserPageActions()) {
-      bpa.updateActionTitle(action);
-    }
-  },
-
-  /**
    * Call this when an action's shownInUrlbar property changes.
    *
    * @param  action (Action object, required)
    *         The action whose shownInUrlbar property changed.
    */
   onActionToggledShownInUrlbar(action) {
     if (!this.actionForID(action.id)) {
       // This may be called before the action has been added.
@@ -472,103 +440,114 @@ this.PageActions = {
     // action IDs ordered by position in urlbar
     idsInUrlbar: [],
   },
 };
 
 /**
  * A single page action.
  *
- * @param  options (object, required)
- *         An object with the following properties:
- *         @param id (string, required)
- *                The action's ID.  Treat this like the ID of a DOM node.
- *         @param title (string, required)
- *                The action's title.
- *         @param anchorIDOverride (string, optional)
- *                Pass a string for this property if to override which element
- *                that the temporary panel is anchored to.
- *         @param iconURL (string, optional)
- *                The URL of the action's icon.  Usually you want to specify an
- *                icon in CSS, but this option is useful if that would be a pain
- *                for some reason -- like your code is in an embedded
- *                WebExtension.
- *         @param nodeAttributes (object, optional)
- *                An object of name-value pairs.  Each pair will be added as
- *                an attribute to DOM nodes created for this action.
- *         @param onBeforePlacedInWindow (function, optional)
- *                Called before the action is placed in the window:
- *                onBeforePlacedInWindow(window)
- *                * window: The window that the action will be placed in.
- *         @param onCommand (function, optional)
- *                Called when the action is clicked, but only if it has neither
- *                a subview nor an iframe:
- *                onCommand(event, buttonNode)
- *                * event: The triggering event.
- *                * buttonNode: The button node that was clicked.
- *         @param onIframeHiding (function, optional)
- *                Called when the action's iframe is hiding:
- *                onIframeHiding(iframeNode, parentPanelNode)
- *                * iframeNode: The iframe.
- *                * parentPanelNode: The panel node in which the iframe is
- *                  shown.
- *         @param onIframeHidden (function, optional)
- *                Called when the action's iframe is hidden:
- *                onIframeHidden(iframeNode, parentPanelNode)
- *                * iframeNode: The iframe.
- *                * parentPanelNode: The panel node in which the iframe is
- *                  shown.
- *         @param onIframeShown (function, optional)
- *                Called when the action's iframe is shown to the user:
- *                onIframeShown(iframeNode, parentPanelNode)
- *                * iframeNode: The iframe.
- *                * parentPanelNode: The panel node in which the iframe is
- *                  shown.
- *         @param onLocationChange (function, optional)
- *                Called after tab switch or when the current <browser>'s
- *                location changes:
- *                onLocationChange(browserWindow)
- *                * browserWindow: The browser window containing the tab switch
- *                  or changed <browser>.
- *         @param onPlacedInPanel (function, optional)
- *                Called when the action is added to the page action panel in
- *                a browser window:
- *                onPlacedInPanel(buttonNode)
- *                * buttonNode: The action's node in the page action panel.
- *         @param onPlacedInUrlbar (function, optional)
- *                Called when the action is added to the urlbar in a browser
- *                window:
- *                onPlacedInUrlbar(buttonNode)
- *                * buttonNode: The action's node in the urlbar.
- *         @param onShowingInPanel (function, optional)
- *                Called when a browser window's page action panel is showing:
- *                onShowingInPanel(buttonNode)
- *                * buttonNode: The action's node in the page action panel.
- *         @param shownInUrlbar (bool, optional)
- *                Pass true to show the action in the urlbar, false otherwise.
- *                False by default.
- *         @param subview (object, optional)
- *                An options object suitable for passing to the Subview
- *                constructor, if you'd like the action to have a subview.  See
- *                the subview constructor for info on this object's properties.
- *         @param tooltip (string, optional)
- *                The action's button tooltip text.
- *         @param urlbarIDOverride (string, optional)
- *                Usually the ID of the action's button in the urlbar will be
- *                generated automatically.  Pass a string for this property to
- *                override that with your own ID.
- *         @param wantsIframe (bool, optional)
- *                Pass true to make an action that shows an iframe in a panel
- *                when clicked.
+ * Each action can have both per-browser-window state and global state.
+ * Per-window state takes precedence over global state.  This is reflected in
+ * the title, tooltip, disabled, and icon properties.  Each of these properties
+ * has a getter method and setter method that takes a browser window.  Pass null
+ * to get the action's global state.  Pass a browser window to get the per-
+ * window state.  However, if you pass a window and the action has no state for
+ * that window, then the global state will be returned.
+ *
+ * `options` is a required object with the following properties.  Regarding the
+ * properties discussed in the previous paragraph, the values in `options` set
+ * global state.
+ *
+ * @param id (string, required)
+ *        The action's ID.  Treat this like the ID of a DOM node.
+ * @param title (string, required)
+ *        The action's title.
+ * @param anchorIDOverride (string, optional)
+ *        Pass a string to override the node to which the action's activated-
+ *        action panel is anchored.
+ * @param disabled (bool, optional)
+ *        Pass true to cause the action to be disabled initially in all browser
+ *        windows.  False by default.
+ * @param iconURL (string or object, optional)
+ *        The URL string of the action's icon.  Usually you want to specify an
+ *        icon in CSS, but this option is useful if that would be a pain for
+ *        some reason.  You can also pass an object that maps pixel sizes to
+ *        URLs, like { 16: url16, 32: url32 }.  The best size for the user's
+ *        screen will be used.
+ * @param nodeAttributes (object, optional)
+ *        An object of name-value pairs.  Each pair will be added as an
+ *        attribute to DOM nodes created for this action.
+ * @param onBeforePlacedInWindow (function, optional)
+ *        Called before the action is placed in the window:
+ *        onBeforePlacedInWindow(window)
+ *        * window: The window that the action will be placed in.
+ * @param onCommand (function, optional)
+ *        Called when the action is clicked, but only if it has neither a
+ *        subview nor an iframe:
+ *        onCommand(event, buttonNode)
+ *        * event: The triggering event.
+ *        * buttonNode: The button node that was clicked.
+ * @param onIframeHiding (function, optional)
+ *        Called when the action's iframe is hiding:
+ *        onIframeHiding(iframeNode, parentPanelNode)
+ *        * iframeNode: The iframe.
+ *        * parentPanelNode: The panel node in which the iframe is shown.
+ * @param onIframeHidden (function, optional)
+ *        Called when the action's iframe is hidden:
+ *        onIframeHidden(iframeNode, parentPanelNode)
+ *        * iframeNode: The iframe.
+ *        * parentPanelNode: The panel node in which the iframe is shown.
+ * @param onIframeShown (function, optional)
+ *        Called when the action's iframe is shown to the user:
+ *        onIframeShown(iframeNode, parentPanelNode)
+ *        * iframeNode: The iframe.
+ *        * parentPanelNode: The panel node in which the iframe is shown.
+ * @param onLocationChange (function, optional)
+ *        Called after tab switch or when the current <browser>'s location
+ *        changes:
+ *        onLocationChange(browserWindow)
+ *        * browserWindow: The browser window containing the tab switch or
+ *          changed <browser>.
+ * @param onPlacedInPanel (function, optional)
+ *        Called when the action is added to the page action panel in a browser
+ *        window:
+ *        onPlacedInPanel(buttonNode)
+ *        * buttonNode: The action's node in the page action panel.
+ * @param onPlacedInUrlbar (function, optional)
+ *        Called when the action is added to the urlbar in a browser window:
+ *        onPlacedInUrlbar(buttonNode)
+ *        * buttonNode: The action's node in the urlbar.
+ * @param onShowingInPanel (function, optional)
+ *        Called when a browser window's page action panel is showing:
+ *        onShowingInPanel(buttonNode)
+ *        * buttonNode: The action's node in the page action panel.
+ * @param shownInUrlbar (bool, optional)
+ *        Pass true to show the action in the urlbar, false otherwise.  False by
+ *        default.
+ * @param subview (object, optional)
+ *        An options object suitable for passing to the Subview constructor, if
+ *        you'd like the action to have a subview.  See the subview constructor
+ *        for info on this object's properties.
+ * @param tooltip (string, optional)
+ *        The action's button tooltip text.
+ * @param urlbarIDOverride (string, optional)
+ *        Usually the ID of the action's button in the urlbar will be generated
+ *        automatically.  Pass a string for this property to override that with
+ *        your own ID.
+ * @param wantsIframe (bool, optional)
+ *        Pass true to make an action that shows an iframe in a panel when
+ *        clicked.
  */
 function Action(options) {
   setProperties(this, options, {
     id: true,
     title: !options._isSeparator,
     anchorIDOverride: false,
+    disabled: false,
     iconURL: false,
     labelForHistogram: false,
     nodeAttributes: false,
     onBeforePlacedInWindow: false,
     onCommand: false,
     onIframeHiding: false,
     onIframeHidden: false,
     onIframeShown: false,
@@ -604,28 +583,16 @@ function Action(options) {
   });
   if (this._subview) {
     this._subview = new Subview(options.subview);
   }
 }
 
 Action.prototype = {
   /**
-   * The action's icon URL (string, nullable)
-   */
-  get iconURL() {
-    return this._iconURL;
-  },
-  set iconURL(url) {
-    this._iconURL = url;
-    PageActions.onActionSetIconURL(this);
-    return this._iconURL;
-  },
-
-  /**
    * The action's ID (string, nonnull)
    */
   get id() {
     return this._id;
   },
 
   /**
    * Attribute name => value mapping to set on nodes created for this action
@@ -645,36 +612,123 @@ Action.prototype = {
     if (this.shownInUrlbar != shown) {
       this._shownInUrlbar = shown;
       PageActions.onActionToggledShownInUrlbar(this);
     }
     return this.shownInUrlbar;
   },
 
   /**
+   * The action's disabled state (bool, nonnull)
+   */
+  getDisabled(browserWindow = null) {
+    return !!this._getProperty("disabled", browserWindow);
+  },
+  setDisabled(value, browserWindow = null) {
+    return this._setProperty("disabled", !!value, browserWindow);
+  },
+
+  /**
+   * The action's icon URL string, or an object mapping sizes to URL strings
+   * (string or object, nullable)
+   */
+  getIconURL(browserWindow = null) {
+    return this._getProperty("iconURL", browserWindow);
+  },
+  setIconURL(value, browserWindow = null) {
+    return this._setProperty("iconURL", value, browserWindow);
+  },
+
+  /**
    * The action's title (string, nonnull)
    */
-  get title() {
-    return this._title;
+  getTitle(browserWindow = null) {
+    return this._getProperty("title", browserWindow);
   },
-  set title(title) {
-    this._title = title || "";
-    PageActions.onActionSetTitle(this);
-    return this._title;
+  setTitle(value, browserWindow = null) {
+    return this._setProperty("title", value, browserWindow);
   },
 
   /**
    * The action's tooltip (string, nullable)
    */
-  get tooltip() {
-    return this._tooltip;
+  getTooltip(browserWindow = null) {
+    return this._getProperty("tooltip", browserWindow);
+  },
+  setTooltip(value, browserWindow = null) {
+    return this._setProperty("tooltip", value, browserWindow);
   },
 
   /**
-   * Override for the ID of the action's temporary panel anchor (string, nullable)
+   * Sets a property, optionally for a particular browser window.
+   *
+   * @param  name (string, required)
+   *         The (non-underscored) name of the property.
+   * @param  value
+   *         The value.
+   * @param  browserWindow (DOM window, optional)
+   *         If given, then the property will be set in this window's state, not
+   *         globally.
+   */
+  _setProperty(name, value, browserWindow) {
+    if (!browserWindow) {
+      // Set the global state.
+      this[`_${name}`] = value;
+    } else {
+      // Set the per-window state.
+      let props = this._propertiesByBrowserWindow.get(browserWindow);
+      if (!props) {
+        props = {};
+        this._propertiesByBrowserWindow.set(browserWindow, props);
+      }
+      props[name] = value;
+    }
+    // This may be called before the action has been added.
+    if (PageActions.actionForID(this.id)) {
+      for (let bpa of allBrowserPageActions(browserWindow)) {
+        bpa.updateAction(this, name);
+      }
+    }
+    return value;
+  },
+
+  /**
+   * Gets a property, optionally for a particular browser window.
+   *
+   * @param  name (string, required)
+   *         The (non-underscored) name of the property.
+   * @param  browserWindow (DOM window, optional)
+   *         If given, then the property will be fetched from this window's
+   *         state.  If the property does not exist in the window's state, or if
+   *         no window is given, then the global value is returned.
+   * @return The property value.
+   */
+  _getProperty(name, browserWindow) {
+    if (browserWindow) {
+      // Try the per-window state.
+      let props = this._propertiesByBrowserWindow.get(browserWindow);
+      if (props && name in props) {
+        return props[name];
+      }
+    }
+    // Fall back to the global state.
+    return this[`_${name}`];
+  },
+
+  // maps browser windows => object with properties for that window
+  get _propertiesByBrowserWindow() {
+    if (!this.__propertiesByBrowserWindow) {
+      this.__propertiesByBrowserWindow = new WeakMap();
+    }
+    return this.__propertiesByBrowserWindow;
+  },
+
+  /**
+   * Override for the ID of the action's activated-action panel anchor (string,
+   * nullable)
    */
   get anchorIDOverride() {
     return this._anchorIDOverride;
   },
 
   /**
    * Override for the ID of the action's urlbar node (string, nullable)
    */
@@ -696,16 +750,53 @@ Action.prototype = {
     return this._subview;
   },
 
   get labelForHistogram() {
     return this._labelForHistogram || this._id;
   },
 
   /**
+   * Returns the URL of the best icon to use given a preferred size.  The best
+   * icon is the one with the smallest size that's equal to or bigger than the
+   * preferred size.  Returns null if the action has no icon URL.
+   *
+   * @param  peferredSize (number, required)
+   *         The icon size you prefer.
+   * @return The URL of the best icon, or null.
+   */
+  iconURLForSize(preferredSize, browserWindow) {
+    let iconURL = this.getIconURL(browserWindow);
+    if (!iconURL) {
+      return null;
+    }
+    if (typeof(iconURL) == "string") {
+      return iconURL;
+    }
+    if (typeof(iconURL) == "object") {
+      // This case is copied from ExtensionParent.jsm so that our image logic is
+      // the same, so that WebExtensions page action tests that deal with icons
+      // pass.
+      let bestSize = null;
+      if (iconURL[preferredSize]) {
+        bestSize = preferredSize;
+      } else if (iconURL[2 * preferredSize]) {
+        bestSize = 2 * preferredSize;
+      } else {
+        let sizes = Object.keys(iconURL)
+                          .map(key => parseInt(key, 10))
+                          .sort((a, b) => a - b);
+        bestSize = sizes.find(candidate => candidate > preferredSize) || sizes.pop();
+      }
+      return iconURL[bestSize];
+    }
+    return null;
+  },
+
+  /**
    * Performs the command for an action.  If the action has an onCommand
    * handler, then it's called.  If the action has a subview or iframe, then a
    * panel is opened, displaying the subview or iframe.
    *
    * @param  browserWindow (DOM window, required)
    *         The browser window in which to perform the action.
    */
   doCommand(browserWindow) {
@@ -841,35 +932,32 @@ Action.prototype = {
   }
 };
 
 this.PageActions.Action = Action;
 
 
 /**
  * A Subview represents a PanelUI panelview that your actions can show.
+ * `options` is a required object with the following properties.
  *
- * @param  options (object, required)
- *         An object with the following properties:
- *         @param buttons (array, optional)
- *                An array of buttons to show in the subview.  Each item in the
- *                array must be an options object suitable for passing to the
- *                Button constructor.  See the Button constructor for
- *                information on these objects' properties.
- *         @param onPlaced (function, optional)
- *                Called when the subview is added to its parent panel in a
- *                browser window:
- *                onPlaced(panelViewNode)
- *                * panelViewNode: The panelview node represented by this
- *                  Subview.
- *         @param onShowing (function, optional)
- *                Called when the subview is showing in a browser window:
- *                onShowing(panelViewNode)
- *                * panelViewNode: The panelview node represented by this
- *                  Subview.
+ * @param buttons (array, optional)
+ *        An array of buttons to show in the subview.  Each item in the array
+ *        must be an options object suitable for passing to the Button
+ *        constructor.  See the Button constructor for information on these
+ *        objects' properties.
+ * @param onPlaced (function, optional)
+ *        Called when the subview is added to its parent panel in a browser
+ *        window:
+ *        onPlaced(panelViewNode)
+ *        * panelViewNode: The panelview node represented by this Subview.
+ * @param onShowing (function, optional)
+ *        Called when the subview is showing in a browser window:
+ *        onShowing(panelViewNode)
+ *        * panelViewNode: The panelview node represented by this Subview.
  */
 function Subview(options) {
   setProperties(this, options, {
     buttons: false,
     onPlaced: false,
     onShowing: false,
   });
   this._buttons = (this._buttons || []).map(buttonOptions => {
@@ -909,36 +997,34 @@ Subview.prototype = {
     }
   }
 };
 
 this.PageActions.Subview = Subview;
 
 
 /**
- * A button that can be shown in a subview.
+ * A button that can be shown in a subview.  `options` is a required object with
+ * the following properties.
  *
- * @param  options (object, required)
- *         An object with the following properties:
- *         @param id (string, required)
- *                The button's ID.  This will not become the ID of a DOM node by
- *                itself, but it will be used to generate DOM node IDs.  But in
- *                terms of spaces and weird characters and such, do treat this
- *                like a DOM node ID.
- *         @param title (string, required)
- *                The button's title.
- *         @param disabled (bool, required)
- *                Pass true to disable the button.
- *         @param onCommand (function, optional)
- *                Called when the button is clicked:
- *                onCommand(event, buttonNode)
- *                * event: The triggering event.
- *                * buttonNode: The node that was clicked.
- *         @param shortcut (string, optional)
- *                The button's shortcut text.
+ * @param id (string, required)
+ *        The button's ID.  This will not become the ID of a DOM node by itself,
+ *        but it will be used to generate DOM node IDs.  But in terms of spaces
+ *        and weird characters and such, do treat this like a DOM node ID.
+ * @param title (string, required)
+ *        The button's title.
+ * @param disabled (bool, required)
+ *        Pass true to disable the button.
+ * @param onCommand (function, optional)
+ *        Called when the button is clicked:
+ *        onCommand(event, buttonNode)
+ *        * event: The triggering event.
+ *        * buttonNode: The node that was clicked.
+ * @param shortcut (string, optional)
+ *        The button's shortcut text.
  */
 function Button(options) {
   setProperties(this, options, {
     id: true,
     title: true,
     disabled: false,
     onCommand: false,
     shortcut: false,
@@ -1009,16 +1095,18 @@ this.PageActions.ACTION_ID_BUILT_IN_SEPA
 // new actions.
 var gBuiltInActions = [
 
   // bookmark
   {
     id: ACTION_ID_BOOKMARK,
     urlbarIDOverride: "star-button-box",
     _urlbarNodeInMarkup: true,
+    // The title is set in browser-pageActions.js by calling
+    // BookmarkingUI.updateBookmarkPageMenuItem().
     title: "",
     shownInUrlbar: true,
     nodeAttributes: {
       observes: "bookmarkThisPageBroadcaster",
     },
     onShowingInPanel(buttonNode) {
       browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode);
     },
@@ -1100,26 +1188,43 @@ function browserPageActions(obj) {
   if (obj.BrowserPageActions) {
     return obj.BrowserPageActions;
   }
   return obj.ownerGlobal.BrowserPageActions;
 }
 
 /**
  * A generator function for all open browser windows.
+ *
+ * @param browserWindow (DOM window, optional)
+ *        If given, then only this window will be yielded.  That may sound
+ *        pointless, but it can make callers nicer to write since they don't
+ *        need two separate cases, one where a window is given and another where
+ *        it isn't.
  */
-function* allBrowserWindows() {
+function* allBrowserWindows(browserWindow = null) {
+  if (browserWindow) {
+    yield browserWindow;
+    return;
+  }
   let windows = Services.wm.getEnumerator("navigator:browser");
   while (windows.hasMoreElements()) {
     yield windows.getNext();
   }
 }
 
-function* allBrowserPageActions() {
-  for (let win of allBrowserWindows()) {
+/**
+ * A generator function for BrowserPageActions objects in all open windows.
+ *
+ * @param browserWindow (DOM window, optional)
+ *        If given, then the BrowserPageActions for only this window will be
+ *        yielded.
+ */
+function* allBrowserPageActions(browserWindow = null) {
+  for (let win of allBrowserWindows(browserWindow)) {
     yield browserPageActions(win);
   }
 }
 
 /**
  * A simple function that sets properties on a given object while doing basic
  * required-properties checking.  If a required property isn't specified in the
  * given options object, or if the options object has properties that aren't in
--- a/browser/modules/test/browser/browser_PageActions.js
+++ b/browser/modules/test/browser/browser_PageActions.js
@@ -35,18 +35,18 @@ add_task(async function simple() {
   let tooltip = "Test simple tooltip";
 
   let onCommandCallCount = 0;
   let onPlacedInPanelCallCount = 0;
   let onPlacedInUrlbarCallCount = 0;
   let onShowingInPanelCallCount = 0;
   let onCommandExpectedButtonID;
 
-  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
-  let urlbarButtonID = BrowserPageActions._urlbarButtonNodeIDForActionID(id);
+  let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+  let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
 
   let initialActions = PageActions.actions;
 
   let action = PageActions.addAction(new PageActions.Action({
     iconURL,
     id,
     nodeAttributes,
     title,
@@ -69,23 +69,23 @@ add_task(async function simple() {
     },
     onShowingInPanel(buttonNode) {
       onShowingInPanelCallCount++;
       Assert.ok(buttonNode, "buttonNode should be non-null: " + buttonNode);
       Assert.equal(buttonNode.id, panelButtonID, "buttonNode.id");
     },
   }));
 
-  Assert.equal(action.iconURL, iconURL, "iconURL");
+  Assert.equal(action.getIconURL(), iconURL, "iconURL");
   Assert.equal(action.id, id, "id");
   Assert.deepEqual(action.nodeAttributes, nodeAttributes, "nodeAttributes");
   Assert.equal(action.shownInUrlbar, false, "shownInUrlbar");
   Assert.equal(action.subview, null, "subview");
-  Assert.equal(action.title, title, "title");
-  Assert.equal(action.tooltip, tooltip, "tooltip");
+  Assert.equal(action.getTitle(), title, "title");
+  Assert.equal(action.getTooltip(), tooltip, "tooltip");
   Assert.equal(action.urlbarIDOverride, null, "urlbarIDOverride");
   Assert.equal(action.wantsIframe, false, "wantsIframe");
 
   Assert.ok(!("__insertBeforeActionID" in action), "__insertBeforeActionID");
   Assert.ok(!("__isSeparator" in action), "__isSeparator");
   Assert.ok(!("__urlbarNodeInMarkup" in action), "__urlbarNodeInMarkup");
 
   Assert.equal(onPlacedInPanelCallCount, 1,
@@ -110,32 +110,33 @@ add_task(async function simple() {
                    "actionForID should be action");
 
   Assert.ok(PageActions._persistedActions.ids.includes(action.id),
             "PageActions should record action in its list of seen actions");
 
   // The action's panel button should have been created.
   let panelButtonNode = document.getElementById(panelButtonID);
   Assert.notEqual(panelButtonNode, null, "panelButtonNode");
-  Assert.equal(panelButtonNode.getAttribute("label"), action.title, "label");
+  Assert.equal(panelButtonNode.getAttribute("label"), action.getTitle(),
+               "label");
   for (let name in action.nodeAttributes) {
     Assert.ok(panelButtonNode.hasAttribute(name), "Has attribute: " + name);
     Assert.equal(panelButtonNode.getAttribute(name),
                  action.nodeAttributes[name],
                  "Equal attribute: " + name);
   }
 
   // The panel button should be the last node in the panel, and its previous
   // sibling should be the separator between the built-in actions and non-built-
   // in actions.
   Assert.equal(panelButtonNode.nextSibling, null, "nextSibling");
   Assert.notEqual(panelButtonNode.previousSibling, null, "previousSibling");
   Assert.equal(
     panelButtonNode.previousSibling.id,
-    BrowserPageActions._panelButtonNodeIDForActionID(
+    BrowserPageActions.panelButtonNodeIDForActionID(
       PageActions.ACTION_ID_BUILT_IN_SEPARATOR
     ),
     "previousSibling.id"
   );
 
   // The action's urlbar button should not have been created.
   let urlbarButtonNode = document.getElementById(urlbarButtonID);
   Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
@@ -173,19 +174,20 @@ add_task(async function simple() {
 
   // Click the urlbar button.
   onCommandExpectedButtonID = urlbarButtonID;
   EventUtils.synthesizeMouseAtCenter(urlbarButtonNode, {});
   Assert.equal(onCommandCallCount, 2, "onCommandCallCount should be inc'ed");
 
   // Set a new title.
   let newTitle = title + " new title";
-  action.title = newTitle;
-  Assert.equal(action.title, newTitle, "New title");
-  Assert.equal(panelButtonNode.getAttribute("label"), action.title, "New label");
+  action.setTitle(newTitle);
+  Assert.equal(action.getTitle(), newTitle, "New title");
+  Assert.equal(panelButtonNode.getAttribute("label"), action.getTitle(),
+               "New label");
 
   // Now that shownInUrlbar has been toggled, make sure that it sticks across
   // app restarts.  Simulate that by "unregistering" the action (not by removing
   // it, which is more permanent) and then registering it again.
 
   // unregister
   PageActions._actionsByID.delete(action.id);
   let index = PageActions._nonBuiltInActions.findIndex(a => a.id == action.id);
@@ -218,17 +220,17 @@ add_task(async function simple() {
                "actionForID should be null");
 
   Assert.ok(!PageActions._persistedActions.ids.includes(action.id),
             "PageActions should remove action from its list of seen actions");
 
   // The separator between the built-in actions and non-built-in actions should
   // be gone now, too.
   let separatorNode = document.getElementById(
-    BrowserPageActions._panelButtonNodeIDForActionID(
+    BrowserPageActions.panelButtonNodeIDForActionID(
       PageActions.ACTION_ID_BUILT_IN_SEPARATOR
     )
   );
   Assert.equal(separatorNode, null, "No separator");
   Assert.ok(!BrowserPageActions.mainViewBodyNode
             .lastChild.localName.includes("separator"),
             "Last child should not be separator");
 });
@@ -240,18 +242,18 @@ add_task(async function withSubview() {
 
   let onActionCommandCallCount = 0;
   let onActionPlacedInPanelCallCount = 0;
   let onActionPlacedInUrlbarCallCount = 0;
   let onSubviewPlacedCount = 0;
   let onSubviewShowingCount = 0;
   let onButtonCommandCallCount = 0;
 
-  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
-  let urlbarButtonID = BrowserPageActions._urlbarButtonNodeIDForActionID(id);
+  let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+  let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
 
   let panelViewIDPanel =
     BrowserPageActions._panelViewNodeIDForActionID(id, false);
   let panelViewIDUrlbar =
     BrowserPageActions._panelViewNodeIDForActionID(id, true);
 
   let onSubviewPlacedExpectedPanelViewID = panelViewIDPanel;
   let onSubviewShowingExpectedPanelViewID;
@@ -428,18 +430,18 @@ add_task(async function withSubview() {
 add_task(async function withIframe() {
   let id = "test-iframe";
 
   let onCommandCallCount = 0;
   let onPlacedInPanelCallCount = 0;
   let onPlacedInUrlbarCallCount = 0;
   let onIframeShownCount = 0;
 
-  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
-  let urlbarButtonID = BrowserPageActions._urlbarButtonNodeIDForActionID(id);
+  let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
+  let urlbarButtonID = BrowserPageActions.urlbarButtonNodeIDForActionID(id);
 
   let action = PageActions.addAction(new PageActions.Action({
     iconURL: "chrome://browser/skin/mail.svg",
     id,
     shownInUrlbar: true,
     title: "Test iframe",
     wantsIframe: true,
     onCommand(event, buttonNode) {
@@ -552,17 +554,17 @@ add_task(async function withIframe() {
   urlbarButtonNode = document.getElementById(urlbarButtonID);
   Assert.equal(urlbarButtonNode, null, "urlbarButtonNode");
 });
 
 
 // Tests an action with the _insertBeforeActionID option set.
 add_task(async function insertBeforeActionID() {
   let id = "test-insertBeforeActionID";
-  let panelButtonID = BrowserPageActions._panelButtonNodeIDForActionID(id);
+  let panelButtonID = BrowserPageActions.panelButtonNodeIDForActionID(id);
 
   let initialActions = PageActions.actions;
   let initialBuiltInActions = PageActions.builtInActions;
   let initialNonBuiltInActions = PageActions.nonBuiltInActions;
   let initialBookmarkSeparatorIndex = PageActions.actions.findIndex(a => {
     return a.id == PageActions.ACTION_ID_BOOKMARK_SEPARATOR;
   });
 
@@ -601,27 +603,27 @@ add_task(async function insertBeforeActi
   let panelButtonNode = document.getElementById(panelButtonID);
   Assert.notEqual(panelButtonNode, null, "panelButtonNode");
 
   // The button's next sibling should be the bookmark separator.
   Assert.notEqual(panelButtonNode.nextSibling, null,
                   "panelButtonNode.nextSibling");
   Assert.equal(
     panelButtonNode.nextSibling.id,
-    BrowserPageActions._panelButtonNodeIDForActionID(
+    BrowserPageActions.panelButtonNodeIDForActionID(
       PageActions.ACTION_ID_BOOKMARK_SEPARATOR
     ),
     "panelButtonNode.nextSibling.id"
   );
 
   // The separator between the built-in and non-built-in actions should not have
   // been created.
   Assert.equal(
     document.getElementById(
-      BrowserPageActions._panelButtonNodeIDForActionID(
+      BrowserPageActions.panelButtonNodeIDForActionID(
         PageActions.ACTION_ID_BUILT_IN_SEPARATOR
       )
     ),
     null,
     "Separator should be gone"
   );
 
   action.remove();
@@ -664,48 +666,48 @@ add_task(async function multipleNonBuilt
     let actualAction = PageActions.nonBuiltInActions[i];
     Assert.equal(actualAction.id, idPrefix + expectedIndex,
                  "actualAction.id for index: " + i);
   }
 
   // Check the button nodes in the panel.
   let expectedIndex = 1;
   let buttonNode = document.getElementById(
-    BrowserPageActions._panelButtonNodeIDForActionID(idPrefix + expectedIndex)
+    BrowserPageActions.panelButtonNodeIDForActionID(idPrefix + expectedIndex)
   );
   Assert.notEqual(buttonNode, null, "buttonNode");
   Assert.notEqual(buttonNode.previousSibling, null,
                   "buttonNode.previousSibling");
   Assert.equal(
     buttonNode.previousSibling.id,
-    BrowserPageActions._panelButtonNodeIDForActionID(
+    BrowserPageActions.panelButtonNodeIDForActionID(
       PageActions.ACTION_ID_BUILT_IN_SEPARATOR
     ),
     "buttonNode.previousSibling.id"
   );
   for (let i = 0; i < actions.length; i++) {
     Assert.notEqual(buttonNode, null, "buttonNode at index: " + i);
     Assert.equal(
       buttonNode.id,
-      BrowserPageActions._panelButtonNodeIDForActionID(idPrefix + expectedIndex),
+      BrowserPageActions.panelButtonNodeIDForActionID(idPrefix + expectedIndex),
       "buttonNode.id at index: " + i
     );
     buttonNode = buttonNode.nextSibling;
     expectedIndex++;
   }
   Assert.equal(buttonNode, null, "Nothing should come after the last button");
 
   for (let action of actions) {
     action.remove();
   }
 
   // The separator between the built-in and non-built-in actions should be gone.
   Assert.equal(
     document.getElementById(
-      BrowserPageActions._panelButtonNodeIDForActionID(
+      BrowserPageActions.panelButtonNodeIDForActionID(
         PageActions.ACTION_ID_BUILT_IN_SEPARATOR
       )
     ),
     null,
     "Separator should be gone"
   );
 });
 
@@ -745,17 +747,17 @@ add_task(async function nonBuiltFirst() 
   Assert.deepEqual(PageActions.builtInActions.map(a => a.id), [],
                    "PageActions.builtInActions should be empty");
   Assert.deepEqual(PageActions.nonBuiltInActions.map(a => a.id), [action.id],
                    "Action should be in PageActions.nonBuiltInActions");
 
   // Check the panel.
   Assert.deepEqual(
     Array.map(BrowserPageActions.mainViewBodyNode.childNodes, n => n.id),
-    [BrowserPageActions._panelButtonNodeIDForActionID(action.id)],
+    [BrowserPageActions.panelButtonNodeIDForActionID(action.id)],
     "Action should be in panel"
   );
 
   // Now add back all the actions.
   for (let a of initialActions) {
     PageActions.addAction(a);
   }
 
@@ -780,17 +782,17 @@ add_task(async function nonBuiltFirst() 
   );
 
   // Check the panel.
   Assert.deepEqual(
     Array.map(BrowserPageActions.mainViewBodyNode.childNodes, n => n.id),
     initialActions.map(a => a.id).concat(
       [PageActions.ACTION_ID_BUILT_IN_SEPARATOR],
       [action.id]
-    ).map(id => BrowserPageActions._panelButtonNodeIDForActionID(id)),
+    ).map(id => BrowserPageActions.panelButtonNodeIDForActionID(id)),
     "Panel should contain all actions"
   );
 
   // Remove the test action.
   action.remove();
 
   // Check the actions.
   Assert.deepEqual(
@@ -807,17 +809,17 @@ add_task(async function nonBuiltFirst() 
     PageActions.nonBuiltInActions.map(a => a.id),
     [],
     "Action should no longer be in PageActions.nonBuiltInActions"
   );
 
   // Check the panel.
   Assert.deepEqual(
     Array.map(BrowserPageActions.mainViewBodyNode.childNodes, n => n.id),
-    initialActions.map(a => BrowserPageActions._panelButtonNodeIDForActionID(a.id)),
+    initialActions.map(a => BrowserPageActions.panelButtonNodeIDForActionID(a.id)),
     "Action should no longer be in panel"
   );
 });
 
 
 // Makes sure that urlbar nodes appear in the correct order in a new window.
 add_task(async function urlbarOrderNewWindow() {
   // Make some new actions.
@@ -879,17 +881,17 @@ add_task(async function urlbarOrderNewWi
        node;
        node = node.nextSibling) {
     actualUrlbarNodeIDs.push(node.id);
   }
 
   // Now check that they're in the right order.
   Assert.deepEqual(
     actualUrlbarNodeIDs,
-    ids.map(id => win.BrowserPageActions._urlbarButtonNodeIDForActionID(id)),
+    ids.map(id => win.BrowserPageActions.urlbarButtonNodeIDForActionID(id)),
     "Expected actions in new window's urlbar"
   );
 
   // Done, clean up.
   await BrowserTestUtils.closeWindow(win);
   for (let action of actions) {
     action.remove();
   }
@@ -959,26 +961,90 @@ add_task(async function migrate1() {
        node;
        node = node.nextSibling) {
     actualUrlbarNodeIDs.push(node.id);
   }
 
   // Now check that they're in the right order.
   Assert.deepEqual(
     actualUrlbarNodeIDs,
-    orderedIDs.map(id => win.BrowserPageActions._urlbarButtonNodeIDForActionID(id)),
+    orderedIDs.map(id => win.BrowserPageActions.urlbarButtonNodeIDForActionID(id)),
     "Expected actions in new window's urlbar"
   );
 
   // Done, clean up.
   await BrowserTestUtils.closeWindow(win);
   Services.prefs.clearUserPref(PageActions.PREF_PERSISTED_ACTIONS);
   PageActions.actionForID("copyURL")._shownInUrlbar = false;
 });
 
+
+// Opens a new browser window and makes sure per-window state works right.
+add_task(async function perWindowState() {
+  // Add a test action.
+  let title = "Test perWindowState";
+  let action = PageActions.addAction(new PageActions.Action({
+    iconURL: "chrome://browser/skin/mail.svg",
+    id: "test-perWindowState",
+    shownInUrlbar: true,
+    title,
+  }));
+
+  // Open a new browser window and load an actionable page so that the action
+  // shows up in it.
+  let newWindow = await BrowserTestUtils.openNewBrowserWindow();
+  await BrowserTestUtils.openNewForegroundTab({
+    gBrowser: newWindow.gBrowser,
+    url: "http://example.com/",
+  });
+
+  // Set a new title globally.
+  let newGlobalTitle = title + " new title";
+  action.setTitle(newGlobalTitle);
+  Assert.equal(action.getTitle(), newGlobalTitle,
+               "Title: global");
+  Assert.equal(action.getTitle(window), newGlobalTitle,
+               "Title: old window");
+  Assert.equal(action.getTitle(newWindow), newGlobalTitle,
+               "Title: new window");
+
+  // The action's panel button nodes should be updated in both windows.
+  let panelButtonID =
+    BrowserPageActions.panelButtonNodeIDForActionID(action.id);
+  for (let win of [window, newWindow]) {
+    let panelButtonNode = win.document.getElementById(panelButtonID);
+    Assert.equal(panelButtonNode.getAttribute("label"), newGlobalTitle,
+                 "Panel button label should be global title");
+  }
+
+  // Set a new title in the new window.
+  let newPerWinTitle = title + " new title in new window";
+  action.setTitle(newPerWinTitle, newWindow);
+  Assert.equal(action.getTitle(), newGlobalTitle,
+               "Title: global should remain same");
+  Assert.equal(action.getTitle(window), newGlobalTitle,
+               "Title: old window should remain same");
+  Assert.equal(action.getTitle(newWindow), newPerWinTitle,
+               "Title: new window should be new");
+
+  // The action's panel button node should be updated in the new window but the
+  // same in the old window.
+  let panelButtonNode1 = document.getElementById(panelButtonID);
+  Assert.equal(panelButtonNode1.getAttribute("label"), newGlobalTitle,
+               "Panel button label in old window");
+  let panelButtonNode2 = newWindow.document.getElementById(panelButtonID);
+  Assert.equal(panelButtonNode2.getAttribute("label"), newPerWinTitle,
+               "Panel button label in new window");
+
+  // Done, clean up.
+  await BrowserTestUtils.closeWindow(newWindow);
+  action.remove();
+});
+
+
 function promisePageActionPanelOpen() {
   let dwu = window.QueryInterface(Ci.nsIInterfaceRequestor)
                   .getInterface(Ci.nsIDOMWindowUtils);
   return BrowserTestUtils.waitForCondition(() => {
     // Wait for the main page action button to become visible.  It's hidden for
     // some URIs, so depending on when this is called, it may not yet be quite
     // visible.  It's up to the caller to make sure it will be visible.
     info("Waiting for main page action button to have non-0 size");
--- a/browser/themes/shared/urlbar-searchbar.inc.css
+++ b/browser/themes/shared/urlbar-searchbar.inc.css
@@ -191,16 +191,20 @@
   height: 28px;
   padding: var(--urlbar-icon-padding);
   -moz-context-properties: fill, fill-opacity;
   fill: currentColor;
   fill-opacity: 0.6;
   color: inherit;
 }
 
+.urlbar-page-action[disabled] {
+  fill-opacity: 0.3;
+}
+
 :root[uidensity=compact] .urlbar-icon {
   width: 24px;
   height: 24px;
 }
 
 :root[uidensity=touch] .urlbar-icon {
   width: 30px;
   height: 30px;