browser/modules/PageActions.jsm
author L10n Bumper Bot <release+l10nbumper@mozilla.com>
Mon, 14 Jan 2019 05:00:14 -0800
changeset 506681 ac40678709f6f3af82e30819cea10260b6e497fc
parent 497215 5f197a31d28fb2524167a8cb92c45b2d7a64ea8a
child 507411 2d0a91485b6bfd028e2f320fdb02862f972013b5
permissions -rw-r--r--
no bug - Bumping Fennec l10n changesets r=release a=l10n-bump DONTBUILD en-CA -> 1df3b5370c30 es-CL -> ff997f81eae6 es-MX -> 7d161fdf265c eu -> 506f20acd46d hi-IN -> 504d2fde3fc7 hu -> 6477fd6ddf64 mai -> d524e539eefb nn-NO -> 656adee29da6 sq -> f7c2ccfb7710 ta -> 047b3e205cca ur -> 03708a03f1b8 zh-CN -> b9c275f71f13 zh-TW -> f2b2ea4cd960

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

var EXPORTED_SYMBOLS = [
  "PageActions",
  // PageActions.Action
  // PageActions.ACTION_ID_BOOKMARK
  // PageActions.ACTION_ID_BOOKMARK_SEPARATOR
  // PageActions.ACTION_ID_BUILT_IN_SEPARATOR
  // PageActions.ACTION_ID_TRANSIENT_SEPARATOR
];

ChromeUtils.import("resource://gre/modules/Services.jsm");

ChromeUtils.defineModuleGetter(this, "AppConstants",
  "resource://gre/modules/AppConstants.jsm");
ChromeUtils.defineModuleGetter(this, "AsyncShutdown",
  "resource://gre/modules/AsyncShutdown.jsm");
ChromeUtils.defineModuleGetter(this, "BinarySearch",
  "resource://gre/modules/BinarySearch.jsm");


const ACTION_ID_BOOKMARK = "bookmark";
const ACTION_ID_BOOKMARK_SEPARATOR = "bookmarkSeparator";
const ACTION_ID_BUILT_IN_SEPARATOR = "builtInSeparator";
const ACTION_ID_TRANSIENT_SEPARATOR = "transientSeparator";

const PREF_PERSISTED_ACTIONS = "browser.pageActions.persistedActions";
const PERSISTED_ACTIONS_CURRENT_VERSION = 1;

// Escapes the given raw URL string, and returns an equivalent CSS url()
// value for it.
function escapeCSSURL(url) {
  return `url("${url.replace(/[\\\s"]/g, encodeURIComponent)}")`;
}

var PageActions = {
  /**
   * Inits.  Call to init.
   */
  init() {
    let callbacks = this._deferredAddActionCalls;
    delete this._deferredAddActionCalls;

    this._loadPersistedActions();

    // Register the built-in actions, which are defined below in this file.
    for (let options of gBuiltInActions) {
      if (!this.actionForID(options.id)) {
        this._registerAction(new Action(options));
      }
    }

    // Now place them all in each window.  Instead of splitting the register and
    // place steps, we could simply call addAction, which does both, but doing
    // it this way means that all windows initially place their actions the same
    // way -- placeAllActions -- regardless of whether they're open when this
    // method is called or opened later.
    for (let bpa of allBrowserPageActions()) {
      bpa.placeAllActions();
    }

    // These callbacks are deferred until init happens and all built-in actions
    // are added.
    while (callbacks && callbacks.length) {
      callbacks.shift()();
    }

    // Purge removed actions from persisted state on shutdown.  The point is not
    // to do it on Action.remove().  That way actions that are removed and
    // re-added while the app is running will have their urlbar placement and
    // other state remembered and restored.  This happens for upgraded and
    // downgraded extensions, for example.
    AsyncShutdown.profileBeforeChange.addBlocker(
      "PageActions: purging unregistered actions from cache",
      () => this._purgeUnregisteredPersistedActions(),
    );
  },

  _deferredAddActionCalls: [],

  /**
   * A list of all Action objects, not in any particular order.  Not live.
   * (array of Action objects)
   */
  get actions() {
    let lists = [
      this._builtInActions,
      this._nonBuiltInActions,
      this._transientActions,
    ];
    return lists.reduce((memo, list) => memo.concat(list), []);
  },

  /**
   * The list of Action objects that should appear in the panel for a given
   * window, sorted in the order in which they appear.  If there are both
   * built-in and non-built-in actions, then the list will include the separator
   * between the two.  The list is not live.  (array of Action objects)
   *
   * @param  browserWindow (DOM window, required)
   *         This window's actions will be returned.
   * @return (array of PageAction.Action objects) The actions currently in the
   *         given window's panel.
   */
  actionsInPanel(browserWindow) {
    function filter(action) {
      return action.shouldShowInPanel(browserWindow);
    }
    let actions = this._builtInActions.filter(filter);
    let nonBuiltInActions = this._nonBuiltInActions.filter(filter);
    if (nonBuiltInActions.length) {
      if (actions.length) {
        actions.push(new Action({
          id: ACTION_ID_BUILT_IN_SEPARATOR,
          _isSeparator: true,
        }));
      }
      actions.push(...nonBuiltInActions);
    }
    let transientActions = this._transientActions.filter(filter);
    if (transientActions.length) {
      if (actions.length) {
        actions.push(new Action({
          id: ACTION_ID_TRANSIENT_SEPARATOR,
          _isSeparator: true,
        }));
      }
      actions.push(...transientActions);
    }
    return actions;
  },

  /**
   * The list of actions currently in the urlbar, sorted in the order in which
   * they appear.  Not live.
   *
   * @param  browserWindow (DOM window, required)
   *         This window's actions will be returned.
   * @return (array of PageAction.Action objects) The actions currently in the
   *         given window's urlbar.
   */
  actionsInUrlbar(browserWindow) {
    // Remember that IDs in idsInUrlbar may belong to actions that aren't
    // currently registered.
    return this._persistedActions.idsInUrlbar.reduce((actions, id) => {
      let action = this.actionForID(id);
      if (action && action.shouldShowInUrlbar(browserWindow)) {
        actions.push(action);
      }
      return actions;
    }, []);
  },

  /**
   * Gets an action.
   *
   * @param  id (string, required)
   *         The ID of the action to get.
   * @return The Action object, or null if none.
   */
  actionForID(id) {
    return this._actionsByID.get(id);
  },

  /**
   * Registers an action.
   *
   * Actions are registered by their IDs.  An error is thrown if an action with
   * the given ID has already been added.  Use actionForID() before calling this
   * method if necessary.
   *
   * Be sure to call remove() on the action if the lifetime of the code that
   * owns it is shorter than the browser's -- if it lives in an extension, for
   * example.
   *
   * @param  action (Action, required)
   *         The Action object to register.
   * @return The given Action.
   */
  addAction(action) {
    if (this._deferredAddActionCalls) {
      // init() hasn't been called yet.  Defer all additions until it's called,
      // at which time _deferredAddActionCalls will be deleted.
      this._deferredAddActionCalls.push(() => this.addAction(action));
      return action;
    }
    this._registerAction(action);
    for (let bpa of allBrowserPageActions()) {
      bpa.placeAction(action);
    }
    return action;
  },

  _registerAction(action) {
    if (this.actionForID(action.id)) {
      throw new Error(`Action with ID '${action.id}' already added`);
    }
    this._actionsByID.set(action.id, action);

    // Insert the action into the appropriate list, either _builtInActions or
    // _nonBuiltInActions.

    // Keep in mind that _insertBeforeActionID may be present but null, which
    // means the action should be appended to the built-ins.
    if ("__insertBeforeActionID" in action) {
      // A "semi-built-in" action, probably an action from an extension
      // bundled with the browser.  Right now we simply assume that no other
      // consumers will use _insertBeforeActionID.
      let index =
        !action.__insertBeforeActionID ? -1 :
        this._builtInActions.findIndex(a => {
          return a.id == action.__insertBeforeActionID;
        });
      if (index < 0) {
        // Append the action (excluding transient actions).
        index = this._builtInActions.filter(a => !a.__transient).length;
      }
      this._builtInActions.splice(index, 0, action);
    } else if (action.__transient) {
      // A transient action.
      this._transientActions.push(action);
    } else if (action._isBuiltIn) {
      // A built-in action. These are mostly added on init before all other
      // actions, one after the other. Extension actions load later and should
      // be at the end, 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.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 pinnedToUrlbar value
      // with the persisted value.  Set the private version of that property
      // so that onActionToggledPinnedToUrlbar isn't called, which happens when
      // the public version is set.
      action._pinnedToUrlbar =
        this._persistedActions.idsInUrlbar.includes(action.id);
    } else {
      // The action is new.  Store it in the persisted actions.
      this._persistedActions.ids.push(action.id);
      this._updateIDsPinnedToUrlbarForAction(action);
    }
  },

  _updateIDsPinnedToUrlbarForAction(action) {
    let index = this._persistedActions.idsInUrlbar.indexOf(action.id);
    if (action.pinnedToUrlbar) {
      if (index < 0) {
        index = action.id == ACTION_ID_BOOKMARK ? -1 :
                this._persistedActions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK);
        if (index < 0) {
          index = this._persistedActions.idsInUrlbar.length;
        }
        this._persistedActions.idsInUrlbar.splice(index, 0, action.id);
      }
    } else if (index >= 0) {
      this._persistedActions.idsInUrlbar.splice(index, 1);
    }
    this._storePersistedActions();
  },

  // These keep track of currently registered actions.
  _builtInActions: [],
  _nonBuiltInActions: [],
  _transientActions: [],
  _actionsByID: new Map(),

  /**
   * Call this when an action is removed.
   *
   * @param  action (Action object, required)
   *         The action that was removed.
   */
  onActionRemoved(action) {
    if (!this.actionForID(action.id)) {
      // The action isn't registered (yet).  Not an error.
      return;
    }

    this._actionsByID.delete(action.id);
    let lists = [
      this._builtInActions,
      this._nonBuiltInActions,
      this._transientActions,
    ];
    for (let list of lists) {
      let index = list.findIndex(a => a.id == action.id);
      if (index >= 0) {
        list.splice(index, 1);
        break;
      }
    }

    for (let bpa of allBrowserPageActions()) {
      bpa.removeAction(action);
    }
  },

  /**
   * Call this when an action's pinnedToUrlbar property changes.
   *
   * @param  action (Action object, required)
   *         The action whose pinnedToUrlbar property changed.
   */
  onActionToggledPinnedToUrlbar(action) {
    if (!this.actionForID(action.id)) {
      // This may be called before the action has been added.
      return;
    }
    this._updateIDsPinnedToUrlbarForAction(action);
    for (let bpa of allBrowserPageActions()) {
      bpa.placeActionInUrlbar(action);
    }
  },

  logTelemetry(type, action, node = null) {
    if (type == "used") {
      type =
        node && node.closest("#urlbar-container") ? "urlbar_used" :
        "panel_used";
    }
    let histogramID = "FX_PAGE_ACTION_" + type.toUpperCase();
    try {
      let histogram = Services.telemetry.getHistogramById(histogramID);
      if (action._isMozillaAction) {
        histogram.add(action.labelForHistogram);
      } else {
        histogram.add("other");
      }
    } catch (ex) {
      Cu.reportError(ex);
    }
  },

  // For tests.  See Bug 1413692.
  _reset() {
    PageActions._purgeUnregisteredPersistedActions();
    PageActions._builtInActions = [];
    PageActions._nonBuiltInActions = [];
    PageActions._transientActions = [];
    PageActions._actionsByID = new Map();
  },

  _storePersistedActions() {
    let json = JSON.stringify(this._persistedActions);
    Services.prefs.setStringPref(PREF_PERSISTED_ACTIONS, json);
  },

  _loadPersistedActions() {
    try {
      let json = Services.prefs.getStringPref(PREF_PERSISTED_ACTIONS);
      this._persistedActions = this._migratePersistedActions(JSON.parse(json));
    } catch (ex) {}
  },

  _purgeUnregisteredPersistedActions() {
    // Remove all action IDs from persisted state that do not correspond to
    // currently registered actions.
    for (let name of ["ids", "idsInUrlbar"]) {
      this._persistedActions[name] = this._persistedActions[name].filter(id => {
        return this.actionForID(id);
      });
    }
    this._storePersistedActions();
  },

  _migratePersistedActions(actions) {
    // Start with actions.version and migrate one version at a time, all the way
    // up to the current version.
    for (let version = actions.version || 0;
         version < PERSISTED_ACTIONS_CURRENT_VERSION;
         version++) {
      let methodName = `_migratePersistedActionsTo${version + 1}`;
      actions = this[methodName](actions);
      actions.version = version + 1;
    }
    return actions;
  },

  _migratePersistedActionsTo1(actions) {
    // The `ids` object is a mapping: action ID => true.  Convert it to an array
    // to save space in the prefs.
    let ids = [];
    for (let id in actions.ids) {
      ids.push(id);
    }
    // Move the bookmark ID to the end of idsInUrlbar.  The bookmark action
    // should always remain at the end of the urlbar, if present.
    let bookmarkIndex = actions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK);
    if (bookmarkIndex >= 0) {
      actions.idsInUrlbar.splice(bookmarkIndex, 1);
      actions.idsInUrlbar.push(ACTION_ID_BOOKMARK);
    }
    return {
      ids,
      idsInUrlbar: actions.idsInUrlbar,
    };
  },

  // This keeps track of all actions, even those that are not currently
  // registered because they have been removed, so long as
  // _purgeUnregisteredPersistedActions has not been called.
  _persistedActions: {
    version: PERSISTED_ACTIONS_CURRENT_VERSION,
    // action IDs that have ever been seen and not removed, order not important
    ids: [],
    // action IDs ordered by position in urlbar
    idsInUrlbar: [],
  },
};


/**
 * A single page action.
 *
 * 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 extensionID (string, optional)
 *        If the action lives in an extension, pass its ID.
 * @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 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 onIframeShowing (function, optional)
 *        Called when the action's iframe is showing to the user:
 *        onIframeShowing(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 onRemovedFromWindow (function, optional)
 *        Called after the action is removed from a browser window:
 *        onRemovedFromWindow(browserWindow)
 *        * browserWindow: The browser window that the action was removed from.
 * @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 onSubviewPlaced (function, optional)
 *        Called when the action's subview is added to its parent panel in a
 *        browser window:
 *        onSubviewPlaced(panelViewNode)
 *        * panelViewNode: The subview's panelview node.
 * @param onSubviewShowing (function, optional)
 *        Called when the action's subview is showing in a browser window:
 *        onSubviewShowing(panelViewNode)
 *        * panelViewNode: The subview's panelview node.
 * @param pinnedToUrlbar (bool, optional)
 *        Pass true to pin the action to the urlbar.  An action is shown in the
 *        urlbar if it's pinned and not disabled.  False by default.
 * @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.
 * @param wantsSubview (bool, optional)
 *        Pass true to make an action that shows a panel subview when clicked.
 */
function Action(options) {
  setProperties(this, options, {
    id: true,
    title: !options._isSeparator,
    anchorIDOverride: false,
    disabled: false,
    extensionID: false,
    iconURL: false,
    labelForHistogram: false,
    onBeforePlacedInWindow: false,
    onCommand: false,
    onIframeHiding: false,
    onIframeHidden: false,
    onIframeShowing: false,
    onLocationChange: false,
    onPlacedInPanel: false,
    onPlacedInUrlbar: false,
    onRemovedFromWindow: false,
    onShowingInPanel: false,
    onSubviewPlaced: false,
    onSubviewShowing: false,
    pinnedToUrlbar: false,
    tooltip: false,
    urlbarIDOverride: false,
    wantsIframe: false,
    wantsSubview: false,

    // private

    // (string, optional)
    // The ID of another action before which to insert this new action in the
    // panel.
    _insertBeforeActionID: false,

    // (bool, optional)
    // True if this isn't really an action but a separator to be shown in the
    // page action panel.
    _isSeparator: false,

    // (bool, optional)
    // Transient actions have a couple of special properties: (1) They stick to
    // the bottom of the panel, and (2) they're hidden in the panel when they're
    // disabled.  Other than that they behave like other actions.
    _transient: false,

    // (bool, optional)
    // True if the action's urlbar button is defined in markup.  In that case, a
    // node with the action's urlbar node ID should already exist in the DOM
    // (either the auto-generated ID or urlbarIDOverride).  That node will be
    // shown when the action is added to the urlbar and hidden when the action
    // is removed from the urlbar.
    _urlbarNodeInMarkup: false,
  });

  /**
   * A cache of the pre-computed CSS variable values for a given icon
   * URLs object, as passed to _createIconProperties.
   */
  this._iconProperties = new WeakMap();

  /**
   * The global values for the action properties.
   */
  this._globalProps = {
    disabled: this._disabled,
    iconURL: this._iconURL,
    iconProps: this._createIconProperties(this._iconURL),
    title: this._title,
    tooltip: this._tooltip,
    wantsSubview: this._wantsSubview,
  };

  /**
   * A mapping of window-specific action property objects, each of which
   * derives from the _globalProps object.
   */
  this._windowProps = new WeakMap();
}

Action.prototype = {
  /**
   * The ID of the action's parent extension (string)
   */
  get extensionID() {
    return this._extensionID;
  },

  /**
   * The action's ID (string)
   */
  get id() {
    return this._id;
  },

  /**
   * True if the action is pinned to the urlbar.  The action is shown in the
   * urlbar if it's pinned and not disabled.  (bool)
   */
  get pinnedToUrlbar() {
    return this._pinnedToUrlbar || false;
  },
  set pinnedToUrlbar(shown) {
    if (this.pinnedToUrlbar != shown) {
      this._pinnedToUrlbar = shown;
      PageActions.onActionToggledPinnedToUrlbar(this);
    }
    return this.pinnedToUrlbar;
  },

  /**
   * The action's disabled state (bool)
   */
  getDisabled(browserWindow = null) {
    return !!this._getProperties(browserWindow).disabled;
  },
  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)
   */
  getIconURL(browserWindow = null) {
    return this._getProperties(browserWindow).iconURL;
  },
  setIconURL(value, browserWindow = null) {
    let props = this._getProperties(browserWindow, !!browserWindow);
    props.iconURL = value;
    props.iconProps = this._createIconProperties(value);

    this._updateProperty("iconURL", props.iconProps, browserWindow);
    return value;
  },

  /**
   * The set of CSS variables which define the action's icons in various
   * sizes. This is generated automatically from the iconURL property.
   */
  getIconProperties(browserWindow = null) {
    return this._getProperties(browserWindow).iconProps;
  },

  _createIconProperties(urls) {
    if (urls && typeof urls == "object") {
      let props = this._iconProperties.get(urls);
      if (!props) {
        props = Object.freeze({
          "--pageAction-image-16px": escapeCSSURL(this._iconURLForSize(urls, 16)),
          "--pageAction-image-32px": escapeCSSURL(this._iconURLForSize(urls, 32)),
        });
        this._iconProperties.set(urls, props);
      }
      return props;
    }

    let cssURL = urls ? escapeCSSURL(urls) : null;
    return Object.freeze({
      "--pageAction-image-16px": cssURL,
      "--pageAction-image-32px": cssURL,
    });
  },

  /**
   * The action's title (string)
   */
  getTitle(browserWindow = null) {
    return this._getProperties(browserWindow).title;
  },
  setTitle(value, browserWindow = null) {
    return this._setProperty("title", value, browserWindow);
  },

  /**
   * The action's tooltip (string)
   */
  getTooltip(browserWindow = null) {
    return this._getProperties(browserWindow).tooltip;
  },
  setTooltip(value, browserWindow = null) {
    return this._setProperty("tooltip", value, browserWindow);
  },

  /**
   * Whether the action wants a subview (bool)
   */
  getWantsSubview(browserWindow = null) {
    return !!this._getProperties(browserWindow).wantsSubview;
  },
  setWantsSubview(value, browserWindow = null) {
    return this._setProperty("wantsSubview", !!value, browserWindow);
  },

  /**
   * 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) {
    let props = this._getProperties(browserWindow, !!browserWindow);
    props[name] = value;

    this._updateProperty(name, value, browserWindow);
    return value;
  },

  _updateProperty(name, value, browserWindow) {
    // 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, { value });
      }
    }
  },

  /**
   * Returns the properties object for the given window, if it exists,
   * or the global properties object if no window-specific properties
   * exist.
   *
   * @param {Window?} window
   *        The window for which to return the properties object, or
   *        null to return the global properties object.
   * @param {bool} [forceWindowSpecific = false]
   *        If true, always returns a window-specific properties object.
   *        If a properties object does not exist for the given window,
   *        one is created and cached.
   * @returns {object}
   */
  _getProperties(window, forceWindowSpecific = false) {
    let props = window && this._windowProps.get(window);

    if (!props && forceWindowSpecific) {
      props = Object.create(this._globalProps);
      this._windowProps.set(window, props);
    }

    return props || this._globalProps;
  },

  /**
   * Override for the ID of the action's activated-action panel anchor (string)
   */
  get anchorIDOverride() {
    return this._anchorIDOverride;
  },

  /**
   * Override for the ID of the action's urlbar node (string)
   */
  get urlbarIDOverride() {
    return this._urlbarIDOverride;
  },

  /**
   * True if the action is shown in an iframe (bool)
   */
  get wantsIframe() {
    return this._wantsIframe || false;
  },

  get labelForHistogram() {
    // The histogram label value has a length limit of 20 and restricted to a
    // pattern. See MAX_LABEL_LENGTH and CPP_IDENTIFIER_PATTERN in
    // toolkit/components/telemetry/parse_histograms.py
    return this._labelForHistogram
      || this._id.replace(/_\w{1}/g, match => match[1].toUpperCase()).substr(0, 20);
  },

  /**
   * Selects the best matching icon from the given URLs object for the
   * given preferred size.
   *
   * @param {object} urls
   *        An object containing square icons of various sizes. The name
   *        of each property is its width, and the value is its image URL.
   * @param {integer} peferredSize
   *        The preferred icon width. The most appropriate icon in the
   *        urls object will be chosen to match that size. An exact
   *        match will be preferred, followed by an icon exactly double
   *        the size, followed by the smallest icon larger than the
   *        preferred size, followed by the largest available icon.
   * @returns {string}
   *        The chosen icon URL.
   */
  _iconURLForSize(urls, preferredSize) {
    // 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 (urls[preferredSize]) {
      bestSize = preferredSize;
    } else if (urls[2 * preferredSize]) {
      bestSize = 2 * preferredSize;
    } else {
      let sizes = Object.keys(urls)
                        .map(key => parseInt(key, 10))
                        .sort((a, b) => a - b);
      bestSize = sizes.find(candidate => candidate > preferredSize) || sizes.pop();
    }
    return urls[bestSize];
  },

  /**
   * 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) {
    browserPageActions(browserWindow).doCommandForAction(this);
  },

  /**
   * Call this when before placing the action in the window.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window the action will be placed in.
   */
  onBeforePlacedInWindow(browserWindow) {
    if (this._onBeforePlacedInWindow) {
      this._onBeforePlacedInWindow(browserWindow);
    }
  },

  /**
   * Call this when the user activates the action.
   *
   * @param  event (DOM event, required)
   *         The triggering event.
   * @param  buttonNode (DOM node, required)
   *         The action's panel or urlbar button node that was clicked.
   */
  onCommand(event, buttonNode) {
    if (this._onCommand) {
      this._onCommand(event, buttonNode);
    }
  },

  /**
   * Call this when the action's iframe is hiding.
   *
   * @param  iframeNode (DOM node, required)
   *         The iframe that's hiding.
   * @param  parentPanelNode (DOM node, required)
   *         The panel in which the iframe is hiding.
   */
  onIframeHiding(iframeNode, parentPanelNode) {
    if (this._onIframeHiding) {
      this._onIframeHiding(iframeNode, parentPanelNode);
    }
  },

  /**
   * Call this when the action's iframe is hidden.
   *
   * @param  iframeNode (DOM node, required)
   *         The iframe that's being hidden.
   * @param  parentPanelNode (DOM node, required)
   *         The panel in which the iframe is hidden.
   */
  onIframeHidden(iframeNode, parentPanelNode) {
    if (this._onIframeHidden) {
      this._onIframeHidden(iframeNode, parentPanelNode);
    }
  },

  /**
   * Call this when the action's iframe is showing.
   *
   * @param  iframeNode (DOM node, required)
   *         The iframe that's being shown.
   * @param  parentPanelNode (DOM node, required)
   *         The panel in which the iframe is shown.
   */
  onIframeShowing(iframeNode, parentPanelNode) {
    if (this._onIframeShowing) {
      this._onIframeShowing(iframeNode, parentPanelNode);
    }
  },

  /**
   * Call this on tab switch or when the current <browser>'s location changes.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window containing the tab switch or changed <browser>.
   */
  onLocationChange(browserWindow) {
    if (this._onLocationChange) {
      this._onLocationChange(browserWindow);
    }
  },

  /**
   * Call this when a DOM node for the action is added to the page action panel.
   *
   * @param  buttonNode (DOM node, required)
   *         The action's panel button node.
   */
  onPlacedInPanel(buttonNode) {
    if (this._onPlacedInPanel) {
      this._onPlacedInPanel(buttonNode);
    }
  },

  /**
   * Call this when a DOM node for the action is added to the urlbar.
   *
   * @param  buttonNode (DOM node, required)
   *         The action's urlbar button node.
   */
  onPlacedInUrlbar(buttonNode) {
    if (this._onPlacedInUrlbar) {
      this._onPlacedInUrlbar(buttonNode);
    }
  },

  /**
   * Call this when the DOM nodes for the action are removed from a browser
   * window.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window the action was removed from.
   */
  onRemovedFromWindow(browserWindow) {
    if (this._onRemovedFromWindow) {
      this._onRemovedFromWindow(browserWindow);
    }
  },

  /**
   * Call this when the action's button is shown in the page action panel.
   *
   * @param  buttonNode (DOM node, required)
   *         The action's panel button node.
   */
  onShowingInPanel(buttonNode) {
    if (this._onShowingInPanel) {
      this._onShowingInPanel(buttonNode);
    }
  },

  /**
   * Call this when a panelview node for the action's subview is added to the
   * DOM.
   *
   * @param  panelViewNode (DOM node, required)
   *         The subview's panelview node.
   */
  onSubviewPlaced(panelViewNode) {
    if (this._onSubviewPlaced) {
      this._onSubviewPlaced(panelViewNode);
    }
  },

  /**
   * Call this when a panelview node for the action's subview is showing.
   *
   * @param  panelViewNode (DOM node, required)
   *         The subview's panelview node.
   */
  onSubviewShowing(panelViewNode) {
    if (this._onSubviewShowing) {
      this._onSubviewShowing(panelViewNode);
    }
  },

  /**
   * Removes the action's DOM nodes from all browser windows.
   *
   * PageActions will remember the action's urlbar placement, if any, after this
   * method is called until app shutdown.  If the action is not added again
   * before shutdown, then PageActions will discard the placement, and the next
   * time the action is added, its placement will be reset.
   */
  remove() {
    PageActions.onActionRemoved(this);
  },

  /**
   * Returns whether the action should be shown in a given window's panel.
   *
   * @param  browserWindow (DOM window, required)
   *         The window.
   * @return True if the action should be shown and false otherwise.  Actions
   *         are always shown in the panel unless they're both transient and
   *         disabled.
   */
  shouldShowInPanel(browserWindow) {
    return !this.__transient || !this.getDisabled(browserWindow);
  },

  /**
   * Returns whether the action should be shown in a given window's urlbar.
   *
   * @param  browserWindow (DOM window, required)
   *         The window.
   * @return True if the action should be shown and false otherwise.  The action
   *         should be shown if it's both pinned and not disabled.
   */
  shouldShowInUrlbar(browserWindow) {
    return this.pinnedToUrlbar && !this.getDisabled(browserWindow);
  },

  get _isBuiltIn() {
    let builtInIDs = [
      "pocket",
      "screenshots_mozilla_org",
    ].concat(gBuiltInActions.filter(a => !a.__isSeparator).map(a => a.id));
    return builtInIDs.includes(this.id);
  },

  get _isMozillaAction() {
    return this._isBuiltIn || this.id == "webcompat-reporter_mozilla_org";
  },
};

this.PageActions.Action = Action;

this.PageActions.ACTION_ID_BUILT_IN_SEPARATOR = ACTION_ID_BUILT_IN_SEPARATOR;
this.PageActions.ACTION_ID_TRANSIENT_SEPARATOR = ACTION_ID_TRANSIENT_SEPARATOR;

// These are only necessary so that Pocket and the test can use them.
this.PageActions.ACTION_ID_BOOKMARK = ACTION_ID_BOOKMARK;
this.PageActions.ACTION_ID_BOOKMARK_SEPARATOR = ACTION_ID_BOOKMARK_SEPARATOR;
this.PageActions.PREF_PERSISTED_ACTIONS = PREF_PERSISTED_ACTIONS;


// Sorted in the order in which they should appear in the page action panel.
// Does not include the page actions of extensions bundled with the browser.
// They're added by the relevant extension code.
// NOTE: If you add items to this list (or system add-on actions that we
// want to keep track of), make sure to also update Histograms.json for the
// 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: "",
    pinnedToUrlbar: true,
    onShowingInPanel(buttonNode) {
      browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode);
    },
    onCommand(event, buttonNode) {
      browserPageActions(buttonNode).bookmark.onCommand(event, buttonNode);
    },
  },

  // separator
  {
    id: ACTION_ID_BOOKMARK_SEPARATOR,
    _isSeparator: true,
  },

  // copy URL
  {
    id: "copyURL",
    title: "copyURL-title",
    onBeforePlacedInWindow(browserWindow) {
      browserPageActions(browserWindow).copyURL
        .onBeforePlacedInWindow(browserWindow);
    },
    onCommand(event, buttonNode) {
      browserPageActions(buttonNode).copyURL.onCommand(event, buttonNode);
    },
  },

  // email link
  {
    id: "emailLink",
    title: "emailLink-title",
    onBeforePlacedInWindow(browserWindow) {
      browserPageActions(browserWindow).emailLink
        .onBeforePlacedInWindow(browserWindow);
    },
    onCommand(event, buttonNode) {
      browserPageActions(buttonNode).emailLink.onCommand(event, buttonNode);
    },
  },

  // add search engine
  {
    id: "addSearchEngine",
    // The title is set in browser-pageActions.js.
    title: "",
    _transient: true,
    onShowingInPanel(buttonNode) {
      browserPageActions(buttonNode).addSearchEngine.onShowingInPanel();
    },
    onCommand(event, buttonNode) {
      browserPageActions(buttonNode).addSearchEngine
        .onCommand(event, buttonNode);
    },
    onSubviewShowing(panelViewNode) {
      browserPageActions(panelViewNode).addSearchEngine
        .onSubviewShowing(panelViewNode);
    },
  },
];

// send to device
if (Services.prefs.getBoolPref("identity.fxaccounts.enabled")) {
  gBuiltInActions.push(
  {
    id: "sendToDevice",
    // The actual title is set by each window, per window, and depends on the
    // number of tabs that are selected.
    title: "sendToDevice",
    onBeforePlacedInWindow(browserWindow) {
      browserPageActions(browserWindow).sendToDevice
        .onBeforePlacedInWindow(browserWindow);
    },
    onLocationChange(browserWindow) {
      browserPageActions(browserWindow).sendToDevice.onLocationChange();
    },
    wantsSubview: true,
    onSubviewPlaced(panelViewNode) {
      browserPageActions(panelViewNode).sendToDevice
        .onSubviewPlaced(panelViewNode);
    },
    onSubviewShowing(panelViewNode) {
      browserPageActions(panelViewNode).sendToDevice
        .onShowingSubview(panelViewNode);
    },
  });
}

// share URL
if (AppConstants.platform == "macosx") {
  gBuiltInActions.push(
  {
    id: "shareURL",
    title: "shareURL-title",
    onShowingInPanel(buttonNode) {
      browserPageActions(buttonNode).shareURL.onShowingInPanel(buttonNode);
    },
    onBeforePlacedInWindow(browserWindow) {
      browserPageActions(browserWindow).shareURL
        .onBeforePlacedInWindow(browserWindow);
    },
    wantsSubview: true,
    onSubviewShowing(panelViewNode) {
        browserPageActions(panelViewNode).shareURL
          .onShowingSubview(panelViewNode);
    },
  });
}

if (AppConstants.isPlatformAndVersionAtLeast("win", "6.4")) {
  gBuiltInActions.push(
  // Share URL
  {
    id: "shareURL",
    title: "shareURL-title",
    onBeforePlacedInWindow(buttonNode) {
      browserPageActions(buttonNode).shareURL.onBeforePlacedInWindow(buttonNode);
    },
    onCommand(event, buttonNode) {
      browserPageActions(buttonNode).shareURL.onCommand(event, buttonNode);
    },
  });
}

/**
 * Gets a BrowserPageActions object in a browser window.
 *
 * @param  obj
 *         Either a DOM node or a browser window.
 * @return The BrowserPageActions object in the browser window related to the
 *         given object.
 */
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(browserWindow = null) {
  if (browserWindow) {
    yield browserWindow;
    return;
  }
  yield* Services.wm.getEnumerator("navigator:browser");
}

/**
 * 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
 * the given schema, then an error is thrown.
 *
 * @param  obj
 *         The object to set properties on.
 * @param  options
 *         An options object supplied by the consumer.
 * @param  schema
 *         An object a property for each required and optional property.  The
 *         keys are property names; the value of a key is a bool that is true if
 *         the property is required.
 */
function setProperties(obj, options, schema) {
  for (let name in schema) {
    let required = schema[name];
    if (required && !(name in options)) {
      throw new Error(`'${name}' must be specified`);
    }
    let nameInObj = "_" + name;
    if (name[0] == "_") {
      // The property is "private".  If it's defined in the options, then define
      // it on obj exactly as it's defined on options.
      if (name in options) {
        obj[nameInObj] = options[name];
      }
    } else {
      // The property is "public".  Make sure the property is defined on obj.
      obj[nameInObj] = options[name] || null;
    }
  }
  for (let name in options) {
    if (!(name in schema)) {
      throw new Error(`Unrecognized option '${name}'`);
    }
  }
}