summaryrefslogtreecommitdiffstats
path: root/browser/modules/PageActions.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'browser/modules/PageActions.jsm')
-rw-r--r--browser/modules/PageActions.jsm1266
1 files changed, 1266 insertions, 0 deletions
diff --git a/browser/modules/PageActions.jsm b/browser/modules/PageActions.jsm
new file mode 100644
index 0000000000..c3dc272823
--- /dev/null
+++ b/browser/modules/PageActions.jsm
@@ -0,0 +1,1266 @@
+/* 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_BUILT_IN_SEPARATOR
+ // PageActions.ACTION_ID_TRANSIENT_SEPARATOR
+];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const ACTION_ID_BOOKMARK = "bookmark";
+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 = {
+ /**
+ * Initializes PageActions.
+ *
+ * @param {boolean} addShutdownBlocker
+ * This param exists only for tests. Normally the default value of true
+ * must be used.
+ */
+ init(addShutdownBlocker = true) {
+ this._initBuiltInActions();
+
+ 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 in the
+ // urlbar the same way -- placeAllActions -- regardless of whether they're
+ // open when this method is called or opened later.
+ for (let bpa of allBrowserPageActions()) {
+ bpa.placeAllActionsInUrlbar();
+ }
+
+ // These callbacks are deferred until init happens and all built-in actions
+ // are added.
+ while (callbacks && callbacks.length) {
+ callbacks.shift()();
+ }
+
+ if (addShutdownBlocker) {
+ // 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.
+ lazy.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 = lazy.BinarySearch.insertionIndexOf(
+ (a1, a2) => {
+ return a1.getTitle().localeCompare(a2.getTitle());
+ },
+ this._nonBuiltInActions,
+ action
+ );
+ this._nonBuiltInActions.splice(index, 0, action);
+ }
+
+ let isNew = !this._persistedActions.ids.includes(action.id);
+ if (isNew) {
+ // The action is new. Store it in the persisted actions.
+ this._persistedActions.ids.push(action.id);
+ }
+
+ // Actions are always pinned to the urlbar, except for panel separators.
+ action._pinnedToUrlbar = !action.__isSeparator;
+ 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);
+ }
+ },
+
+ // 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() {
+ let actions;
+ try {
+ let json = Services.prefs.getStringPref(PREF_PERSISTED_ACTIONS);
+ actions = this._migratePersistedActions(JSON.parse(json));
+ } catch (ex) {}
+
+ // Handle migrating to and from Proton. We want to gracefully handle
+ // downgrades from Proton, and since Proton is controlled by a pref, we also
+ // don't want to assume that a downgrade is possible only by downgrading the
+ // app. That makes it hard to use the normal migration approach of creating
+ // a new persisted actions version, so we handle Proton migration specially.
+ // We try-catch it separately from the earlier _migratePersistedActions call
+ // because it should not be short-circuited when the pref load or usual
+ // migration fails.
+ try {
+ actions = this._migratePersistedActionsProton(actions);
+ } catch (ex) {}
+
+ // If `actions` is still not defined, then this._persistedActions will
+ // remain its default value.
+ if (actions) {
+ this._persistedActions = actions;
+ }
+ },
+
+ _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,
+ };
+ },
+
+ _migratePersistedActionsProton(actions) {
+ if (actions?.idsInUrlbarPreProton) {
+ // continue with Proton
+ } else if (actions) {
+ // upgrade to Proton
+ actions.idsInUrlbarPreProton = [...(actions.idsInUrlbar || [])];
+ } else {
+ // new profile with Proton
+ actions = {
+ ids: [],
+ idsInUrlbar: [],
+ idsInUrlbarPreProton: [],
+ version: PERSISTED_ACTIONS_CURRENT_VERSION,
+ };
+ }
+ return actions;
+ },
+
+ // 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, optional)
+ * The action's title. It is optional for built in actions.
+ * @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 isBadged (bool, optional)
+ * If true, the toolbarbutton for this action will get a
+ * "badged" attribute.
+ * @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.
+ * @param disablePrivateBrowsing (bool, optional)
+ * Pass true to prevent the action from showing in a private browsing window.
+ */
+function Action(options) {
+ setProperties(this, options, {
+ id: true,
+ title: false,
+ anchorIDOverride: false,
+ disabled: false,
+ extensionID: false,
+ iconURL: false,
+ isBadged: 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,
+ onPinToUrlbarToggled: false,
+ pinnedToUrlbar: false,
+ tooltip: false,
+ urlbarIDOverride: false,
+ wantsIframe: false,
+ wantsSubview: false,
+ disablePrivateBrowsing: 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;
+ },
+
+ get disablePrivateBrowsing() {
+ return !!this._disablePrivateBrowsing;
+ },
+
+ /**
+ * Verifies that the action can be shown in a private window. For
+ * extensions, verifies the extension has access to the window.
+ */
+ canShowInWindow(browserWindow) {
+ if (this._extensionID) {
+ let policy = WebExtensionPolicy.getByID(this._extensionID);
+ if (!policy.canAccessWindow(browserWindow)) {
+ return false;
+ }
+ }
+ return !(
+ this.disablePrivateBrowsing &&
+ lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow)
+ );
+ },
+
+ /**
+ * 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);
+ this.onPinToUrlbarToggled();
+ }
+ },
+
+ /**
+ * 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). Note, built in actions will
+ * not have a title property.
+ */
+ 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 isBadged() {
+ return this._isBadged || 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);
+ }
+ },
+ /**
+ * Call this when an icon in the url is pinned or unpinned.
+ */
+ onPinToUrlbarToggled() {
+ if (this._onPinToUrlbarToggled) {
+ this._onPinToUrlbarToggled();
+ }
+ },
+
+ /**
+ * 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) {
+ // When Proton is enabled, the extension page actions should behave similarly
+ // to a transient action, and be hidden from the urlbar overflow menu if they
+ // are disabled (as in the urlbar when the overflow menu isn't available)
+ //
+ // TODO(Bug 1704139): as a follow up we may look into just set on all
+ // extensions pageActions `_transient: true`, at least once we sunset
+ // the proton preference and we don't need the pre-Proton behavior anymore,
+ // and remove this special case.
+ const isProtonExtensionAction = this.extensionID;
+
+ return (
+ (!(this.__transient || isProtonExtensionAction) ||
+ !this.getDisabled(browserWindow)) &&
+ this.canShowInWindow(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) &&
+ this.canShowInWindow(browserWindow)
+ );
+ },
+
+ get _isBuiltIn() {
+ let builtInIDs = ["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";
+ },
+};
+
+PageActions.Action = Action;
+
+PageActions.ACTION_ID_BUILT_IN_SEPARATOR = ACTION_ID_BUILT_IN_SEPARATOR;
+PageActions.ACTION_ID_TRANSIENT_SEPARATOR = ACTION_ID_TRANSIENT_SEPARATOR;
+
+// These are only necessary so that the test can use them.
+PageActions.ACTION_ID_BOOKMARK = ACTION_ID_BOOKMARK;
+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;
+
+PageActions._initBuiltInActions = function () {
+ gBuiltInActions = [
+ // bookmark
+ {
+ id: ACTION_ID_BOOKMARK,
+ urlbarIDOverride: "star-button-box",
+ _urlbarNodeInMarkup: true,
+ pinnedToUrlbar: true,
+ onShowingInPanel(buttonNode) {
+ browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode);
+ },
+ onCommand(event, buttonNode) {
+ browserPageActions(buttonNode).bookmark.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}'`);
+ }
+ }
+}