PageActions.sys.mjs (42388B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", 9 ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", 10 BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs", 11 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 12 setTimeout: "resource://gre/modules/Timer.sys.mjs", 13 }); 14 15 const ACTION_ID_BOOKMARK = "bookmark"; 16 const ACTION_ID_BUILT_IN_SEPARATOR = "builtInSeparator"; 17 const ACTION_ID_TRANSIENT_SEPARATOR = "transientSeparator"; 18 19 const PREF_PERSISTED_ACTIONS = "browser.pageActions.persistedActions"; 20 const PERSISTED_ACTIONS_CURRENT_VERSION = 1; 21 22 // Escapes the given raw URL string, and returns an equivalent CSS url() 23 // value for it. 24 function escapeCSSURL(url) { 25 return `url("${url.replace(/[\\\s"]/g, encodeURIComponent)}")`; 26 } 27 28 export var PageActions = { 29 /** 30 * Initializes PageActions. 31 * 32 * @param {boolean} addShutdownBlocker 33 * This param exists only for tests. Normally the default value of true 34 * must be used. 35 */ 36 init(addShutdownBlocker = true) { 37 this._initBuiltInActions(); 38 39 let callbacks = this._deferredAddActionCalls; 40 delete this._deferredAddActionCalls; 41 42 this._loadPersistedActions(); 43 44 // Register the built-in actions, which are defined below in this file. 45 for (let options of gBuiltInActions) { 46 if (!this.actionForID(options.id)) { 47 this._registerAction(new Action(options)); 48 } 49 } 50 51 // Now place them all in each window. Instead of splitting the register and 52 // place steps, we could simply call addAction, which does both, but doing 53 // it this way means that all windows initially place their actions in the 54 // urlbar the same way -- placeAllActions -- regardless of whether they're 55 // open when this method is called or opened later. 56 for (let bpa of allBrowserPageActions()) { 57 bpa.placeAllActionsInUrlbar(); 58 } 59 60 // These callbacks are deferred until init happens and all built-in actions 61 // are added. 62 while (callbacks && callbacks.length) { 63 callbacks.shift()(); 64 } 65 66 if (addShutdownBlocker) { 67 // Purge removed actions from persisted state on shutdown. The point is 68 // not to do it on Action.remove(). That way actions that are removed and 69 // re-added while the app is running will have their urlbar placement and 70 // other state remembered and restored. This happens for upgraded and 71 // downgraded extensions, for example. 72 lazy.AsyncShutdown.profileBeforeChange.addBlocker( 73 "PageActions: purging unregistered actions from cache", 74 () => this._purgeUnregisteredPersistedActions() 75 ); 76 } 77 }, 78 79 _deferredAddActionCalls: [], 80 81 /** 82 * A list of all Action objects, not in any particular order. Not live. 83 * (array of Action objects) 84 */ 85 get actions() { 86 let lists = [ 87 this._builtInActions, 88 this._nonBuiltInActions, 89 this._transientActions, 90 ]; 91 return lists.reduce((memo, list) => memo.concat(list), []); 92 }, 93 94 /** 95 * The list of Action objects that should appear in the panel for a given 96 * window, sorted in the order in which they appear. If there are both 97 * built-in and non-built-in actions, then the list will include the separator 98 * between the two. The list is not live. (array of Action objects) 99 * 100 * @param browserWindow (DOM window, required) 101 * This window's actions will be returned. 102 * @return (array of PageAction.Action objects) The actions currently in the 103 * given window's panel. 104 */ 105 actionsInPanel(browserWindow) { 106 function filter(action) { 107 return action.shouldShowInPanel(browserWindow); 108 } 109 let actions = this._builtInActions.filter(filter); 110 let nonBuiltInActions = this._nonBuiltInActions.filter(filter); 111 if (nonBuiltInActions.length) { 112 if (actions.length) { 113 actions.push( 114 new Action({ 115 id: ACTION_ID_BUILT_IN_SEPARATOR, 116 _isSeparator: true, 117 }) 118 ); 119 } 120 actions.push(...nonBuiltInActions); 121 } 122 let transientActions = this._transientActions.filter(filter); 123 if (transientActions.length) { 124 if (actions.length) { 125 actions.push( 126 new Action({ 127 id: ACTION_ID_TRANSIENT_SEPARATOR, 128 _isSeparator: true, 129 }) 130 ); 131 } 132 actions.push(...transientActions); 133 } 134 return actions; 135 }, 136 137 /** 138 * The list of actions currently in the urlbar, sorted in the order in which 139 * they appear. Not live. 140 * 141 * @param browserWindow (DOM window, required) 142 * This window's actions will be returned. 143 * @return (array of PageAction.Action objects) The actions currently in the 144 * given window's urlbar. 145 */ 146 actionsInUrlbar(browserWindow) { 147 // Remember that IDs in idsInUrlbar may belong to actions that aren't 148 // currently registered. 149 return this._persistedActions.idsInUrlbar.reduce((actions, id) => { 150 let action = this.actionForID(id); 151 if (action && action.shouldShowInUrlbar(browserWindow)) { 152 actions.push(action); 153 } 154 return actions; 155 }, []); 156 }, 157 158 /** 159 * Gets an action. 160 * 161 * @param id (string, required) 162 * The ID of the action to get. 163 * @return The Action object, or null if none. 164 */ 165 actionForID(id) { 166 return this._actionsByID.get(id); 167 }, 168 169 /** 170 * Registers an action. 171 * 172 * Actions are registered by their IDs. An error is thrown if an action with 173 * the given ID has already been added. Use actionForID() before calling this 174 * method if necessary. 175 * 176 * Be sure to call remove() on the action if the lifetime of the code that 177 * owns it is shorter than the browser's -- if it lives in an extension, for 178 * example. 179 * 180 * @param action (Action, required) 181 * The Action object to register. 182 * @return The given Action. 183 */ 184 addAction(action) { 185 if (this._deferredAddActionCalls) { 186 // init() hasn't been called yet. Defer all additions until it's called, 187 // at which time _deferredAddActionCalls will be deleted. 188 this._deferredAddActionCalls.push(() => this.addAction(action)); 189 return action; 190 } 191 this._registerAction(action); 192 for (let bpa of allBrowserPageActions()) { 193 bpa.placeAction(action); 194 } 195 return action; 196 }, 197 198 _registerAction(action) { 199 if (this.actionForID(action.id)) { 200 throw new Error(`Action with ID '${action.id}' already added`); 201 } 202 this._actionsByID.set(action.id, action); 203 204 // Insert the action into the appropriate list, either _builtInActions or 205 // _nonBuiltInActions. 206 207 // Keep in mind that _insertBeforeActionID may be present but null, which 208 // means the action should be appended to the built-ins. 209 if ("__insertBeforeActionID" in action) { 210 // A "semi-built-in" action, probably an action from an extension 211 // bundled with the browser. Right now we simply assume that no other 212 // consumers will use _insertBeforeActionID. 213 let index = !action.__insertBeforeActionID 214 ? -1 215 : this._builtInActions.findIndex(a => { 216 return a.id == action.__insertBeforeActionID; 217 }); 218 if (index < 0) { 219 // Append the action (excluding transient actions). 220 index = this._builtInActions.filter(a => !a.__transient).length; 221 } 222 this._builtInActions.splice(index, 0, action); 223 } else if (action.__transient) { 224 // A transient action. 225 this._transientActions.push(action); 226 } else if (action._isBuiltIn) { 227 // A built-in action. These are mostly added on init before all other 228 // actions, one after the other. Extension actions load later and should 229 // be at the end, so just push onto the array. 230 this._builtInActions.push(action); 231 } else { 232 // A non-built-in action, like a non-bundled extension potentially. 233 // Keep this list sorted by title. 234 let index = lazy.BinarySearch.insertionIndexOf( 235 (a1, a2) => { 236 return a1.getTitle().localeCompare(a2.getTitle()); 237 }, 238 this._nonBuiltInActions, 239 action 240 ); 241 this._nonBuiltInActions.splice(index, 0, action); 242 } 243 244 let isNew = !this._persistedActions.ids.includes(action.id); 245 if (isNew) { 246 // The action is new. Store it in the persisted actions. 247 this._persistedActions.ids.push(action.id); 248 } 249 250 // Actions are always pinned to the urlbar, except for panel separators. 251 action._pinnedToUrlbar = !action.__isSeparator; 252 this._updateIDsPinnedToUrlbarForAction(action); 253 }, 254 255 _updateIDsPinnedToUrlbarForAction(action) { 256 let index = this._persistedActions.idsInUrlbar.indexOf(action.id); 257 if (action.pinnedToUrlbar) { 258 if (index < 0) { 259 index = 260 action.id == ACTION_ID_BOOKMARK 261 ? -1 262 : this._persistedActions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK); 263 if (index < 0) { 264 index = this._persistedActions.idsInUrlbar.length; 265 } 266 this._persistedActions.idsInUrlbar.splice(index, 0, action.id); 267 } 268 } else if (index >= 0) { 269 this._persistedActions.idsInUrlbar.splice(index, 1); 270 } 271 this._storePersistedActions(); 272 }, 273 274 // These keep track of currently registered actions. 275 _builtInActions: [], 276 _nonBuiltInActions: [], 277 _transientActions: [], 278 _actionsByID: new Map(), 279 280 /** 281 * Call this when an action is removed. 282 * 283 * @param action (Action object, required) 284 * The action that was removed. 285 */ 286 onActionRemoved(action) { 287 if (!this.actionForID(action.id)) { 288 // The action isn't registered (yet). Not an error. 289 return; 290 } 291 292 this._actionsByID.delete(action.id); 293 let lists = [ 294 this._builtInActions, 295 this._nonBuiltInActions, 296 this._transientActions, 297 ]; 298 for (let list of lists) { 299 let index = list.findIndex(a => a.id == action.id); 300 if (index >= 0) { 301 list.splice(index, 1); 302 break; 303 } 304 } 305 306 for (let bpa of allBrowserPageActions()) { 307 bpa.removeAction(action); 308 } 309 }, 310 311 /** 312 * Call this when an action's pinnedToUrlbar property changes. 313 * 314 * @param action (Action object, required) 315 * The action whose pinnedToUrlbar property changed. 316 */ 317 onActionToggledPinnedToUrlbar(action) { 318 if (!this.actionForID(action.id)) { 319 // This may be called before the action has been added. 320 return; 321 } 322 this._updateIDsPinnedToUrlbarForAction(action); 323 for (let bpa of allBrowserPageActions()) { 324 bpa.placeActionInUrlbar(action); 325 } 326 }, 327 328 // For tests. See Bug 1413692. 329 _reset() { 330 PageActions._purgeUnregisteredPersistedActions(); 331 PageActions._builtInActions = []; 332 PageActions._nonBuiltInActions = []; 333 PageActions._transientActions = []; 334 PageActions._actionsByID = new Map(); 335 }, 336 337 _storePersistedActions() { 338 let json = JSON.stringify(this._persistedActions); 339 Services.prefs.setStringPref(PREF_PERSISTED_ACTIONS, json); 340 }, 341 342 _loadPersistedActions() { 343 let actions; 344 try { 345 let json = Services.prefs.getStringPref(PREF_PERSISTED_ACTIONS); 346 actions = this._migratePersistedActions(JSON.parse(json)); 347 } catch (ex) {} 348 349 // Handle migrating to and from Proton. We want to gracefully handle 350 // downgrades from Proton, and since Proton is controlled by a pref, we also 351 // don't want to assume that a downgrade is possible only by downgrading the 352 // app. That makes it hard to use the normal migration approach of creating 353 // a new persisted actions version, so we handle Proton migration specially. 354 // We try-catch it separately from the earlier _migratePersistedActions call 355 // because it should not be short-circuited when the pref load or usual 356 // migration fails. 357 try { 358 actions = this._migratePersistedActionsProton(actions); 359 } catch (ex) {} 360 361 // If `actions` is still not defined, then this._persistedActions will 362 // remain its default value. 363 if (actions) { 364 this._persistedActions = actions; 365 } 366 }, 367 368 _purgeUnregisteredPersistedActions() { 369 // Remove all action IDs from persisted state that do not correspond to 370 // currently registered actions. 371 for (let name of ["ids", "idsInUrlbar"]) { 372 this._persistedActions[name] = this._persistedActions[name].filter(id => { 373 return this.actionForID(id); 374 }); 375 } 376 this._storePersistedActions(); 377 }, 378 379 _migratePersistedActions(actions) { 380 // Start with actions.version and migrate one version at a time, all the way 381 // up to the current version. 382 for ( 383 let version = actions.version || 0; 384 version < PERSISTED_ACTIONS_CURRENT_VERSION; 385 version++ 386 ) { 387 let methodName = `_migratePersistedActionsTo${version + 1}`; 388 actions = this[methodName](actions); 389 actions.version = version + 1; 390 } 391 return actions; 392 }, 393 394 _migratePersistedActionsTo1(actions) { 395 // The `ids` object is a mapping: action ID => true. Convert it to an array 396 // to save space in the prefs. 397 let ids = []; 398 for (let id in actions.ids) { 399 ids.push(id); 400 } 401 // Move the bookmark ID to the end of idsInUrlbar. The bookmark action 402 // should always remain at the end of the urlbar, if present. 403 let bookmarkIndex = actions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK); 404 if (bookmarkIndex >= 0) { 405 actions.idsInUrlbar.splice(bookmarkIndex, 1); 406 actions.idsInUrlbar.push(ACTION_ID_BOOKMARK); 407 } 408 return { 409 ids, 410 idsInUrlbar: actions.idsInUrlbar, 411 }; 412 }, 413 414 _migratePersistedActionsProton(actions) { 415 if (actions?.idsInUrlbarPreProton) { 416 // continue with Proton 417 } else if (actions) { 418 // upgrade to Proton 419 actions.idsInUrlbarPreProton = [...(actions.idsInUrlbar || [])]; 420 } else { 421 // new profile with Proton 422 actions = { 423 ids: [], 424 idsInUrlbar: [], 425 idsInUrlbarPreProton: [], 426 version: PERSISTED_ACTIONS_CURRENT_VERSION, 427 }; 428 } 429 return actions; 430 }, 431 432 /** 433 * Send an ASRouter trigger to possibly show messaging related to the page 434 * action that was placed in the urlbar. 435 * 436 * @param {Element} buttonNode The page action button node. 437 */ 438 sendPlacedInUrlbarTrigger(buttonNode) { 439 lazy.setTimeout(async () => { 440 await lazy.ASRouter.waitForInitialized; 441 let win = buttonNode?.ownerGlobal; 442 if (!win || buttonNode.hidden) { 443 return; 444 } 445 await lazy.ASRouter.sendTriggerMessage({ 446 browser: win.gBrowser.selectedBrowser, 447 id: "pageActionInUrlbar", 448 context: { pageAction: buttonNode.id }, 449 }); 450 }, 500); 451 }, 452 453 // This keeps track of all actions, even those that are not currently 454 // registered because they have been removed, so long as 455 // _purgeUnregisteredPersistedActions has not been called. 456 _persistedActions: { 457 version: PERSISTED_ACTIONS_CURRENT_VERSION, 458 // action IDs that have ever been seen and not removed, order not important 459 ids: [], 460 // action IDs ordered by position in urlbar 461 idsInUrlbar: [], 462 }, 463 }; 464 465 /** 466 * A single page action. 467 * 468 * Each action can have both per-browser-window state and global state. 469 * Per-window state takes precedence over global state. This is reflected in 470 * the title, tooltip, disabled, and icon properties. Each of these properties 471 * has a getter method and setter method that takes a browser window. Pass null 472 * to get the action's global state. Pass a browser window to get the per- 473 * window state. However, if you pass a window and the action has no state for 474 * that window, then the global state will be returned. 475 * 476 * `options` is a required object with the following properties. Regarding the 477 * properties discussed in the previous paragraph, the values in `options` set 478 * global state. 479 * 480 * @param id (string, required) 481 * The action's ID. Treat this like the ID of a DOM node. 482 * @param title (string, optional) 483 * The action's title. It is optional for built in actions. 484 * @param anchorIDOverride (string, optional) 485 * Pass a string to override the node to which the action's activated- 486 * action panel is anchored. 487 * @param disabled (bool, optional) 488 * Pass true to cause the action to be disabled initially in all browser 489 * windows. False by default. 490 * @param extensionID (string, optional) 491 * If the action lives in an extension, pass its ID. 492 * @param iconURL (string or object, optional) 493 * The URL string of the action's icon. Usually you want to specify an 494 * icon in CSS, but this option is useful if that would be a pain for 495 * some reason. You can also pass an object that maps pixel sizes to 496 * URLs, like { 16: url16, 32: url32 }. The best size for the user's 497 * screen will be used. 498 * @param isBadged (bool, optional) 499 * If true, the toolbarbutton for this action will get a 500 * "badged" attribute. 501 * @param onBeforePlacedInWindow (function, optional) 502 * Called before the action is placed in the window: 503 * onBeforePlacedInWindow(window) 504 * * window: The window that the action will be placed in. 505 * @param onCommand (function, optional) 506 * Called when the action is clicked, but only if it has neither a 507 * subview nor an iframe: 508 * onCommand(event, buttonNode) 509 * * event: The triggering event. 510 * * buttonNode: The button node that was clicked. 511 * @param onIframeHiding (function, optional) 512 * Called when the action's iframe is hiding: 513 * onIframeHiding(iframeNode, parentPanelNode) 514 * * iframeNode: The iframe. 515 * * parentPanelNode: The panel node in which the iframe is shown. 516 * @param onIframeHidden (function, optional) 517 * Called when the action's iframe is hidden: 518 * onIframeHidden(iframeNode, parentPanelNode) 519 * * iframeNode: The iframe. 520 * * parentPanelNode: The panel node in which the iframe is shown. 521 * @param onIframeShowing (function, optional) 522 * Called when the action's iframe is showing to the user: 523 * onIframeShowing(iframeNode, parentPanelNode) 524 * * iframeNode: The iframe. 525 * * parentPanelNode: The panel node in which the iframe is shown. 526 * @param onLocationChange (function, optional) 527 * Called after tab switch or when the current <browser>'s location 528 * changes: 529 * onLocationChange(browserWindow) 530 * * browserWindow: The browser window containing the tab switch or 531 * changed <browser>. 532 * @param onPlacedInPanel (function, optional) 533 * Called when the action is added to the page action panel in a browser 534 * window: 535 * onPlacedInPanel(buttonNode) 536 * * buttonNode: The action's node in the page action panel. 537 * @param onPlacedInUrlbar (function, optional) 538 * Called when the action is added to the urlbar in a browser window: 539 * onPlacedInUrlbar(buttonNode) 540 * * buttonNode: The action's node in the urlbar. 541 * @param onRemovedFromWindow (function, optional) 542 * Called after the action is removed from a browser window: 543 * onRemovedFromWindow(browserWindow) 544 * * browserWindow: The browser window that the action was removed from. 545 * @param onShowingInPanel (function, optional) 546 * Called when a browser window's page action panel is showing: 547 * onShowingInPanel(buttonNode) 548 * * buttonNode: The action's node in the page action panel. 549 * @param onSubviewPlaced (function, optional) 550 * Called when the action's subview is added to its parent panel in a 551 * browser window: 552 * onSubviewPlaced(panelViewNode) 553 * * panelViewNode: The subview's panelview node. 554 * @param onSubviewShowing (function, optional) 555 * Called when the action's subview is showing in a browser window: 556 * onSubviewShowing(panelViewNode) 557 * * panelViewNode: The subview's panelview node. 558 * @param pinnedToUrlbar (bool, optional) 559 * Pass true to pin the action to the urlbar. An action is shown in the 560 * urlbar if it's pinned and not disabled. False by default. 561 * @param tooltip (string, optional) 562 * The action's button tooltip text. 563 * @param urlbarIDOverride (string, optional) 564 * Usually the ID of the action's button in the urlbar will be generated 565 * automatically. Pass a string for this property to override that with 566 * your own ID. 567 * @param wantsIframe (bool, optional) 568 * Pass true to make an action that shows an iframe in a panel when 569 * clicked. 570 * @param wantsSubview (bool, optional) 571 * Pass true to make an action that shows a panel subview when clicked. 572 * @param disablePrivateBrowsing (bool, optional) 573 * Pass true to prevent the action from showing in a private browsing window. 574 */ 575 function Action(options) { 576 setProperties(this, options, { 577 id: true, 578 title: false, 579 anchorIDOverride: false, 580 disabled: false, 581 extensionID: false, 582 iconURL: false, 583 isBadged: false, 584 labelForHistogram: false, 585 onBeforePlacedInWindow: false, 586 onCommand: false, 587 onIframeHiding: false, 588 onIframeHidden: false, 589 onIframeShowing: false, 590 onLocationChange: false, 591 onPlacedInPanel: false, 592 onPlacedInUrlbar: false, 593 onRemovedFromWindow: false, 594 onShowingInPanel: false, 595 onSubviewPlaced: false, 596 onSubviewShowing: false, 597 onPinToUrlbarToggled: false, 598 pinnedToUrlbar: false, 599 tooltip: false, 600 urlbarIDOverride: false, 601 wantsIframe: false, 602 wantsSubview: false, 603 disablePrivateBrowsing: false, 604 605 // private 606 607 // (string, optional) 608 // The ID of another action before which to insert this new action in the 609 // panel. 610 _insertBeforeActionID: false, 611 612 // (bool, optional) 613 // True if this isn't really an action but a separator to be shown in the 614 // page action panel. 615 _isSeparator: false, 616 617 // (bool, optional) 618 // Transient actions have a couple of special properties: (1) They stick to 619 // the bottom of the panel, and (2) they're hidden in the panel when they're 620 // disabled. Other than that they behave like other actions. 621 _transient: false, 622 623 // (bool, optional) 624 // True if the action's urlbar button is defined in markup. In that case, a 625 // node with the action's urlbar node ID should already exist in the DOM 626 // (either the auto-generated ID or urlbarIDOverride). That node will be 627 // shown when the action is added to the urlbar and hidden when the action 628 // is removed from the urlbar. 629 _urlbarNodeInMarkup: false, 630 }); 631 632 /** 633 * A cache of the pre-computed CSS variable values for a given icon 634 * URLs object, as passed to _createIconProperties. 635 */ 636 this._iconProperties = new WeakMap(); 637 638 /** 639 * The global values for the action properties. 640 */ 641 this._globalProps = { 642 disabled: this._disabled, 643 iconURL: this._iconURL, 644 iconProps: this._createIconProperties(this._iconURL), 645 title: this._title, 646 tooltip: this._tooltip, 647 wantsSubview: this._wantsSubview, 648 }; 649 650 /** 651 * A mapping of window-specific action property objects, each of which 652 * derives from the _globalProps object. 653 */ 654 this._windowProps = new WeakMap(); 655 } 656 657 Action.prototype = { 658 /** 659 * The ID of the action's parent extension (string) 660 */ 661 get extensionID() { 662 return this._extensionID; 663 }, 664 665 /** 666 * The action's ID (string) 667 */ 668 get id() { 669 return this._id; 670 }, 671 672 get disablePrivateBrowsing() { 673 return !!this._disablePrivateBrowsing; 674 }, 675 676 /** 677 * Verifies that the action can be shown in a private window. For 678 * extensions, verifies the extension has access to the window. 679 */ 680 canShowInWindow(browserWindow) { 681 if (this._extensionID) { 682 let policy = WebExtensionPolicy.getByID(this._extensionID); 683 if (!policy.canAccessWindow(browserWindow)) { 684 return false; 685 } 686 } 687 return !( 688 this.disablePrivateBrowsing && 689 lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow) 690 ); 691 }, 692 693 /** 694 * True if the action is pinned to the urlbar. The action is shown in the 695 * urlbar if it's pinned and not disabled. (bool) 696 */ 697 get pinnedToUrlbar() { 698 return this._pinnedToUrlbar || false; 699 }, 700 set pinnedToUrlbar(shown) { 701 if (this.pinnedToUrlbar != shown) { 702 this._pinnedToUrlbar = shown; 703 PageActions.onActionToggledPinnedToUrlbar(this); 704 this.onPinToUrlbarToggled(); 705 } 706 }, 707 708 /** 709 * The action's disabled state (bool) 710 */ 711 getDisabled(browserWindow = null) { 712 return !!this._getProperties(browserWindow).disabled; 713 }, 714 setDisabled(value, browserWindow = null) { 715 return this._setProperty("disabled", !!value, browserWindow); 716 }, 717 718 /** 719 * The action's icon URL string, or an object mapping sizes to URL strings 720 * (string or object) 721 */ 722 getIconURL(browserWindow = null) { 723 return this._getProperties(browserWindow).iconURL; 724 }, 725 setIconURL(value, browserWindow = null) { 726 let props = this._getProperties(browserWindow, !!browserWindow); 727 props.iconURL = value; 728 props.iconProps = this._createIconProperties(value); 729 730 this._updateProperty("iconURL", props.iconProps, browserWindow); 731 return value; 732 }, 733 734 /** 735 * The set of CSS variables which define the action's icons in various 736 * sizes. This is generated automatically from the iconURL property. 737 */ 738 getIconProperties(browserWindow = null) { 739 return this._getProperties(browserWindow).iconProps; 740 }, 741 742 _createIconProperties(urls) { 743 if (urls && typeof urls == "object") { 744 let props = this._iconProperties.get(urls); 745 if (!props) { 746 props = Object.freeze({ 747 "--pageAction-image": `image-set( 748 ${escapeCSSURL(this._iconURLForSize(urls, 16))}, 749 ${escapeCSSURL(this._iconURLForSize(urls, 32))} 2x 750 )`, 751 }); 752 this._iconProperties.set(urls, props); 753 } 754 return props; 755 } 756 757 let cssURL = urls ? escapeCSSURL(urls) : null; 758 return Object.freeze({ 759 "--pageAction-image": cssURL, 760 }); 761 }, 762 763 /** 764 * The action's title (string). Note, built in actions will 765 * not have a title property. 766 */ 767 getTitle(browserWindow = null) { 768 return this._getProperties(browserWindow).title; 769 }, 770 setTitle(value, browserWindow = null) { 771 return this._setProperty("title", value, browserWindow); 772 }, 773 774 /** 775 * The action's tooltip (string) 776 */ 777 getTooltip(browserWindow = null) { 778 return this._getProperties(browserWindow).tooltip; 779 }, 780 setTooltip(value, browserWindow = null) { 781 return this._setProperty("tooltip", value, browserWindow); 782 }, 783 784 /** 785 * Whether the action wants a subview (bool) 786 */ 787 getWantsSubview(browserWindow = null) { 788 return !!this._getProperties(browserWindow).wantsSubview; 789 }, 790 setWantsSubview(value, browserWindow = null) { 791 return this._setProperty("wantsSubview", !!value, browserWindow); 792 }, 793 794 /** 795 * Sets a property, optionally for a particular browser window. 796 * 797 * @param name (string, required) 798 * The (non-underscored) name of the property. 799 * @param value 800 * The value. 801 * @param browserWindow (DOM window, optional) 802 * If given, then the property will be set in this window's state, not 803 * globally. 804 */ 805 _setProperty(name, value, browserWindow) { 806 let props = this._getProperties(browserWindow, !!browserWindow); 807 props[name] = value; 808 809 this._updateProperty(name, value, browserWindow); 810 return value; 811 }, 812 813 _updateProperty(name, value, browserWindow) { 814 // This may be called before the action has been added. 815 if (PageActions.actionForID(this.id)) { 816 for (let bpa of allBrowserPageActions(browserWindow)) { 817 bpa.updateAction(this, name, { value }); 818 } 819 } 820 }, 821 822 /** 823 * Returns the properties object for the given window, if it exists, 824 * or the global properties object if no window-specific properties 825 * exist. 826 * 827 * @param {Window?} window 828 * The window for which to return the properties object, or 829 * null to return the global properties object. 830 * @param {bool} [forceWindowSpecific = false] 831 * If true, always returns a window-specific properties object. 832 * If a properties object does not exist for the given window, 833 * one is created and cached. 834 * @returns {object} 835 */ 836 _getProperties(window, forceWindowSpecific = false) { 837 let props = window && this._windowProps.get(window); 838 839 if (!props && forceWindowSpecific) { 840 props = Object.create(this._globalProps); 841 this._windowProps.set(window, props); 842 } 843 844 return props || this._globalProps; 845 }, 846 847 /** 848 * Override for the ID of the action's activated-action panel anchor (string) 849 */ 850 get anchorIDOverride() { 851 return this._anchorIDOverride; 852 }, 853 854 /** 855 * Override for the ID of the action's urlbar node (string) 856 */ 857 get urlbarIDOverride() { 858 return this._urlbarIDOverride; 859 }, 860 861 /** 862 * True if the action is shown in an iframe (bool) 863 */ 864 get wantsIframe() { 865 return this._wantsIframe || false; 866 }, 867 868 get isBadged() { 869 return this._isBadged || false; 870 }, 871 872 get labelForHistogram() { 873 // The histogram label value has a length limit of 20 and restricted to a 874 // pattern. See MAX_LABEL_LENGTH and CPP_IDENTIFIER_PATTERN in 875 // toolkit/components/telemetry/parse_histograms.py 876 return ( 877 this._labelForHistogram || 878 this._id.replace(/_\w{1}/g, match => match[1].toUpperCase()).substr(0, 20) 879 ); 880 }, 881 882 /** 883 * Selects the best matching icon from the given URLs object for the 884 * given preferred size. 885 * 886 * @param {object} urls 887 * An object containing square icons of various sizes. The name 888 * of each property is its width, and the value is its image URL. 889 * @param {integer} peferredSize 890 * The preferred icon width. The most appropriate icon in the 891 * urls object will be chosen to match that size. An exact 892 * match will be preferred, followed by an icon exactly double 893 * the size, followed by the smallest icon larger than the 894 * preferred size, followed by the largest available icon. 895 * @returns {string} 896 * The chosen icon URL. 897 */ 898 _iconURLForSize(urls, preferredSize) { 899 // This case is copied from ExtensionParent.sys.mjs so that our image logic is 900 // the same, so that WebExtensions page action tests that deal with icons 901 // pass. 902 let bestSize = null; 903 if (urls[preferredSize]) { 904 bestSize = preferredSize; 905 } else if (urls[2 * preferredSize]) { 906 bestSize = 2 * preferredSize; 907 } else { 908 let sizes = Object.keys(urls) 909 .map(key => parseInt(key, 10)) 910 .sort((a, b) => a - b); 911 bestSize = 912 sizes.find(candidate => candidate > preferredSize) || sizes.pop(); 913 } 914 return urls[bestSize]; 915 }, 916 917 /** 918 * Performs the command for an action. If the action has an onCommand 919 * handler, then it's called. If the action has a subview or iframe, then a 920 * panel is opened, displaying the subview or iframe. 921 * 922 * @param browserWindow (DOM window, required) 923 * The browser window in which to perform the action. 924 */ 925 doCommand(browserWindow) { 926 browserPageActions(browserWindow).doCommandForAction(this); 927 }, 928 929 /** 930 * Call this when before placing the action in the window. 931 * 932 * @param browserWindow (DOM window, required) 933 * The browser window the action will be placed in. 934 */ 935 onBeforePlacedInWindow(browserWindow) { 936 if (this._onBeforePlacedInWindow) { 937 this._onBeforePlacedInWindow(browserWindow); 938 } 939 }, 940 941 /** 942 * Call this when the user activates the action. 943 * 944 * @param event (DOM event, required) 945 * The triggering event. 946 * @param buttonNode (DOM node, required) 947 * The action's panel or urlbar button node that was clicked. 948 */ 949 onCommand(event, buttonNode) { 950 if (this._onCommand) { 951 this._onCommand(event, buttonNode); 952 } 953 }, 954 955 /** 956 * Call this when the action's iframe is hiding. 957 * 958 * @param iframeNode (DOM node, required) 959 * The iframe that's hiding. 960 * @param parentPanelNode (DOM node, required) 961 * The panel in which the iframe is hiding. 962 */ 963 onIframeHiding(iframeNode, parentPanelNode) { 964 if (this._onIframeHiding) { 965 this._onIframeHiding(iframeNode, parentPanelNode); 966 } 967 }, 968 969 /** 970 * Call this when the action's iframe is hidden. 971 * 972 * @param iframeNode (DOM node, required) 973 * The iframe that's being hidden. 974 * @param parentPanelNode (DOM node, required) 975 * The panel in which the iframe is hidden. 976 */ 977 onIframeHidden(iframeNode, parentPanelNode) { 978 if (this._onIframeHidden) { 979 this._onIframeHidden(iframeNode, parentPanelNode); 980 } 981 }, 982 983 /** 984 * Call this when the action's iframe is showing. 985 * 986 * @param iframeNode (DOM node, required) 987 * The iframe that's being shown. 988 * @param parentPanelNode (DOM node, required) 989 * The panel in which the iframe is shown. 990 */ 991 onIframeShowing(iframeNode, parentPanelNode) { 992 if (this._onIframeShowing) { 993 this._onIframeShowing(iframeNode, parentPanelNode); 994 } 995 }, 996 997 /** 998 * Call this on tab switch or when the current <browser>'s location changes. 999 * 1000 * @param browserWindow (DOM window, required) 1001 * The browser window containing the tab switch or changed <browser>. 1002 */ 1003 onLocationChange(browserWindow) { 1004 if (this._onLocationChange) { 1005 this._onLocationChange(browserWindow); 1006 } 1007 }, 1008 1009 /** 1010 * Call this when a DOM node for the action is added to the page action panel. 1011 * 1012 * @param buttonNode (DOM node, required) 1013 * The action's panel button node. 1014 */ 1015 onPlacedInPanel(buttonNode) { 1016 if (this._onPlacedInPanel) { 1017 this._onPlacedInPanel(buttonNode); 1018 } 1019 }, 1020 1021 /** 1022 * Call this when a DOM node for the action is added to the urlbar. 1023 * 1024 * @param buttonNode (DOM node, required) 1025 * The action's urlbar button node. 1026 */ 1027 onPlacedInUrlbar(buttonNode) { 1028 if (this._onPlacedInUrlbar) { 1029 this._onPlacedInUrlbar(buttonNode); 1030 } 1031 }, 1032 1033 /** 1034 * Call this when the DOM nodes for the action are removed from a browser 1035 * window. 1036 * 1037 * @param browserWindow (DOM window, required) 1038 * The browser window the action was removed from. 1039 */ 1040 onRemovedFromWindow(browserWindow) { 1041 if (this._onRemovedFromWindow) { 1042 this._onRemovedFromWindow(browserWindow); 1043 } 1044 }, 1045 1046 /** 1047 * Call this when the action's button is shown in the page action panel. 1048 * 1049 * @param buttonNode (DOM node, required) 1050 * The action's panel button node. 1051 */ 1052 onShowingInPanel(buttonNode) { 1053 if (this._onShowingInPanel) { 1054 this._onShowingInPanel(buttonNode); 1055 } 1056 }, 1057 1058 /** 1059 * Call this when a panelview node for the action's subview is added to the 1060 * DOM. 1061 * 1062 * @param panelViewNode (DOM node, required) 1063 * The subview's panelview node. 1064 */ 1065 onSubviewPlaced(panelViewNode) { 1066 if (this._onSubviewPlaced) { 1067 this._onSubviewPlaced(panelViewNode); 1068 } 1069 }, 1070 1071 /** 1072 * Call this when a panelview node for the action's subview is showing. 1073 * 1074 * @param panelViewNode (DOM node, required) 1075 * The subview's panelview node. 1076 */ 1077 onSubviewShowing(panelViewNode) { 1078 if (this._onSubviewShowing) { 1079 this._onSubviewShowing(panelViewNode); 1080 } 1081 }, 1082 /** 1083 * Call this when an icon in the url is pinned or unpinned. 1084 */ 1085 onPinToUrlbarToggled() { 1086 if (this._onPinToUrlbarToggled) { 1087 this._onPinToUrlbarToggled(); 1088 } 1089 }, 1090 1091 /** 1092 * Removes the action's DOM nodes from all browser windows. 1093 * 1094 * PageActions will remember the action's urlbar placement, if any, after this 1095 * method is called until app shutdown. If the action is not added again 1096 * before shutdown, then PageActions will discard the placement, and the next 1097 * time the action is added, its placement will be reset. 1098 */ 1099 remove() { 1100 PageActions.onActionRemoved(this); 1101 }, 1102 1103 /** 1104 * Returns whether the action should be shown in a given window's panel. 1105 * 1106 * @param browserWindow (DOM window, required) 1107 * The window. 1108 * @return True if the action should be shown and false otherwise. Actions 1109 * are always shown in the panel unless they're both transient and 1110 * disabled. 1111 */ 1112 shouldShowInPanel(browserWindow) { 1113 // When Proton is enabled, the extension page actions should behave similarly 1114 // to a transient action, and be hidden from the urlbar overflow menu if they 1115 // are disabled (as in the urlbar when the overflow menu isn't available) 1116 // 1117 // TODO(Bug 1704139): as a follow up we may look into just set on all 1118 // extensions pageActions `_transient: true`, at least once we sunset 1119 // the proton preference and we don't need the pre-Proton behavior anymore, 1120 // and remove this special case. 1121 const isProtonExtensionAction = this.extensionID; 1122 1123 return ( 1124 (!(this.__transient || isProtonExtensionAction) || 1125 !this.getDisabled(browserWindow)) && 1126 this.canShowInWindow(browserWindow) 1127 ); 1128 }, 1129 1130 /** 1131 * Returns whether the action should be shown in a given window's urlbar. 1132 * 1133 * @param browserWindow (DOM window, required) 1134 * The window. 1135 * @return True if the action should be shown and false otherwise. The action 1136 * should be shown if it's both pinned and not disabled. 1137 */ 1138 shouldShowInUrlbar(browserWindow) { 1139 return ( 1140 this.pinnedToUrlbar && 1141 !this.getDisabled(browserWindow) && 1142 this.canShowInWindow(browserWindow) 1143 ); 1144 }, 1145 1146 get _isBuiltIn() { 1147 let builtInIDs = ["screenshots_mozilla_org"].concat( 1148 gBuiltInActions.filter(a => !a.__isSeparator).map(a => a.id) 1149 ); 1150 return builtInIDs.includes(this.id); 1151 }, 1152 1153 get _isMozillaAction() { 1154 return this._isBuiltIn || this.id == "webcompat-reporter_mozilla_org"; 1155 }, 1156 }; 1157 1158 PageActions.Action = Action; 1159 1160 PageActions.ACTION_ID_BUILT_IN_SEPARATOR = ACTION_ID_BUILT_IN_SEPARATOR; 1161 PageActions.ACTION_ID_TRANSIENT_SEPARATOR = ACTION_ID_TRANSIENT_SEPARATOR; 1162 1163 // These are only necessary so that the test can use them. 1164 PageActions.ACTION_ID_BOOKMARK = ACTION_ID_BOOKMARK; 1165 PageActions.PREF_PERSISTED_ACTIONS = PREF_PERSISTED_ACTIONS; 1166 1167 // Sorted in the order in which they should appear in the page action panel. 1168 // Does not include the page actions of extensions bundled with the browser. 1169 // They're added by the relevant extension code. 1170 // NOTE: If you add items to this list (or system add-on actions that we 1171 // want to keep track of), make sure to also update Histograms.json for the 1172 // new actions. 1173 var gBuiltInActions; 1174 1175 PageActions._initBuiltInActions = function () { 1176 gBuiltInActions = [ 1177 // bookmark 1178 { 1179 id: ACTION_ID_BOOKMARK, 1180 urlbarIDOverride: "star-button-box", 1181 _urlbarNodeInMarkup: true, 1182 pinnedToUrlbar: true, 1183 onShowingInPanel(buttonNode) { 1184 browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode); 1185 }, 1186 onCommand(event, buttonNode) { 1187 browserPageActions(buttonNode).bookmark.onCommand(event); 1188 }, 1189 }, 1190 ]; 1191 }; 1192 1193 /** 1194 * Gets a BrowserPageActions object in a browser window. 1195 * 1196 * @param obj 1197 * Either a DOM node or a browser window. 1198 * @return The BrowserPageActions object in the browser window related to the 1199 * given object. 1200 */ 1201 function browserPageActions(obj) { 1202 if (obj.BrowserPageActions) { 1203 return obj.BrowserPageActions; 1204 } 1205 return obj.ownerGlobal.BrowserPageActions; 1206 } 1207 1208 /** 1209 * A generator function for all open browser windows. 1210 * 1211 * @param browserWindow (DOM window, optional) 1212 * If given, then only this window will be yielded. That may sound 1213 * pointless, but it can make callers nicer to write since they don't 1214 * need two separate cases, one where a window is given and another where 1215 * it isn't. 1216 */ 1217 function* allBrowserWindows(browserWindow = null) { 1218 if (browserWindow) { 1219 yield browserWindow; 1220 return; 1221 } 1222 yield* Services.wm.getEnumerator("navigator:browser"); 1223 } 1224 1225 /** 1226 * A generator function for BrowserPageActions objects in all open windows. 1227 * 1228 * @param browserWindow (DOM window, optional) 1229 * If given, then the BrowserPageActions for only this window will be 1230 * yielded. 1231 */ 1232 function* allBrowserPageActions(browserWindow = null) { 1233 for (let win of allBrowserWindows(browserWindow)) { 1234 yield browserPageActions(win); 1235 } 1236 } 1237 1238 /** 1239 * A simple function that sets properties on a given object while doing basic 1240 * required-properties checking. If a required property isn't specified in the 1241 * given options object, or if the options object has properties that aren't in 1242 * the given schema, then an error is thrown. 1243 * 1244 * @param obj 1245 * The object to set properties on. 1246 * @param options 1247 * An options object supplied by the consumer. 1248 * @param schema 1249 * An object a property for each required and optional property. The 1250 * keys are property names; the value of a key is a bool that is true if 1251 * the property is required. 1252 */ 1253 function setProperties(obj, options, schema) { 1254 for (let name in schema) { 1255 let required = schema[name]; 1256 if (required && !(name in options)) { 1257 throw new Error(`'${name}' must be specified`); 1258 } 1259 let nameInObj = "_" + name; 1260 if (name[0] == "_") { 1261 // The property is "private". If it's defined in the options, then define 1262 // it on obj exactly as it's defined on options. 1263 if (name in options) { 1264 obj[nameInObj] = options[name]; 1265 } 1266 } else { 1267 // The property is "public". Make sure the property is defined on obj. 1268 obj[nameInObj] = options[name] || null; 1269 } 1270 } 1271 for (let name in options) { 1272 if (!(name in schema)) { 1273 throw new Error(`Unrecognized option '${name}'`); 1274 } 1275 } 1276 }