diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/modules/PageActions.jsm | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/modules/PageActions.jsm')
-rw-r--r-- | browser/modules/PageActions.jsm | 1266 |
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..d20933155f --- /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}'`); + } + } +} |