Bug 1395387 - Reconcile WebExtension page actions and Photon page actions: Photon page actions changes. r=Gijs
authorDrew Willcoxon <adw@mozilla.com>
Fri, 27 Oct 2017 17:39:38 -0400
changeset 389048 b5b0ac43e2e89c84a670d590862b4d0d22d069d1
parent 389047 6a3ca81dc56ab7687ca70b1b98fbe397aa3fa388
child 389049 d887685b52ae3890123648911f884873de91c42a
push id32777
push userarchaeopteryx@coole-files.de
push dateMon, 30 Oct 2017 22:44:45 +0000
treeherdermozilla-central@dd0f265a1300 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1395387
milestone58.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 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
@@ -1430,16 +1430,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;