ext-menus.js (43116B)
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set sts=2 sw=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 5 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 "use strict"; 8 9 ChromeUtils.defineESModuleGetters(this, { 10 ExtensionMenus: "resource://gre/modules/ExtensionMenus.sys.mjs", 11 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 12 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 13 }); 14 15 var { DefaultMap, ExtensionError, parseMatchPatterns } = ExtensionUtils; 16 17 var { ExtensionParent } = ChromeUtils.importESModule( 18 "resource://gre/modules/ExtensionParent.sys.mjs" 19 ); 20 21 var { IconDetails } = ExtensionParent; 22 23 const ACTION_MENU_TOP_LEVEL_LIMIT = 6; 24 25 // Map[Extension -> Map[ID -> MenuItem]] 26 // Note: we want to enumerate all the menu items so 27 // this cannot be a weak map. 28 var gMenuMap = new Map(); 29 30 // Map[Extension -> MenuItem] 31 var gRootItems = new Map(); 32 33 // Map[Extension -> ID[]] 34 // Menu IDs that were eligible for being shown in the current menu. 35 var gShownMenuItems = new DefaultMap(() => []); 36 37 // Map[Extension -> Set[Contexts]] 38 // A DefaultMap (keyed by extension) which keeps track of the 39 // contexts with a subscribed onShown event listener. 40 var gOnShownSubscribers = new DefaultMap(() => new Set()); 41 42 // If id is not specified for an item we use an integer. 43 var gNextMenuItemID = 0; 44 45 // Used to assign unique names to radio groups. 46 var gNextRadioGroupID = 0; 47 48 // The max length of a menu item's label. 49 var gMaxLabelLength = 64; 50 51 var gMenuBuilder = { 52 // When a new menu is opened, this function is called and 53 // we populate the |xulMenu| with all the items from extensions 54 // to be displayed. We always clear all the items again when 55 // popuphidden fires. 56 build(contextData) { 57 contextData = this.maybeOverrideContextData(contextData); 58 let xulMenu = contextData.menu; 59 xulMenu.addEventListener("popuphidden", this); 60 this.xulMenu = xulMenu; 61 for (let [, root] of gRootItems) { 62 this.createAndInsertTopLevelElements(root, contextData, null); 63 } 64 this.afterBuildingMenu(contextData); 65 66 if ( 67 contextData.webExtContextData && 68 !contextData.webExtContextData.showDefaults 69 ) { 70 // Wait until nsContextMenu.js has toggled the visibility of the default 71 // menu items before hiding the default items. 72 Promise.resolve().then(() => this.hideDefaultMenuItems()); 73 } 74 }, 75 76 maybeOverrideContextData(contextData) { 77 let { webExtContextData } = contextData; 78 if (!webExtContextData || !webExtContextData.overrideContext) { 79 return contextData; 80 } 81 let contextDataBase = { 82 menu: contextData.menu, 83 // eslint-disable-next-line no-use-before-define 84 originalViewType: getContextViewType(contextData), 85 originalViewUrl: contextData.inFrame 86 ? contextData.frameUrl 87 : contextData.pageUrl, 88 webExtContextData, 89 }; 90 if (webExtContextData.overrideContext === "bookmark") { 91 return { 92 ...contextDataBase, 93 bookmarkId: webExtContextData.bookmarkId, 94 onBookmark: true, 95 }; 96 } 97 if (webExtContextData.overrideContext === "tab") { 98 // TODO: Handle invalid tabs more gracefully (instead of throwing). 99 let tab = tabTracker.getTab(webExtContextData.tabId); 100 return { 101 ...contextDataBase, 102 tab, 103 pageUrl: tab.linkedBrowser.currentURI.spec, 104 onTab: true, 105 }; 106 } 107 throw new Error( 108 `Unexpected overrideContext: ${webExtContextData.overrideContext}` 109 ); 110 }, 111 112 canAccessContext(extension, contextData) { 113 if (!extension.privateBrowsingAllowed) { 114 let nativeTab = contextData.tab; 115 if ( 116 nativeTab && 117 PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser) 118 ) { 119 return false; 120 } else if ( 121 PrivateBrowsingUtils.isWindowPrivate(contextData.menu.ownerGlobal) 122 ) { 123 return false; 124 } 125 } 126 return true; 127 }, 128 129 createAndInsertTopLevelElements(root, contextData, nextSibling) { 130 let rootElements; 131 if (!this.canAccessContext(root.extension, contextData)) { 132 return; 133 } 134 if ( 135 contextData.onAction || 136 contextData.onBrowserAction || 137 contextData.onPageAction 138 ) { 139 if (contextData.extension.id !== root.extension.id) { 140 return; 141 } 142 rootElements = this.buildTopLevelElements( 143 root, 144 contextData, 145 ACTION_MENU_TOP_LEVEL_LIMIT, 146 false 147 ); 148 149 // Action menu items are prepended to the menu, followed by a separator. 150 nextSibling = nextSibling || this.xulMenu.firstElementChild; 151 if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) { 152 rootElements.push( 153 this.xulMenu.ownerDocument.createXULElement("menuseparator") 154 ); 155 } 156 } else if (contextData.webExtContextData) { 157 let { extensionId, showDefaults, overrideContext } = 158 contextData.webExtContextData; 159 if (extensionId === root.extension.id) { 160 rootElements = this.buildTopLevelElements( 161 root, 162 contextData, 163 Infinity, 164 false 165 ); 166 if (!nextSibling) { 167 // The extension menu should be rendered at the top. If we use 168 // a navigation group (on non-macOS), the extension menu should 169 // come after that to avoid styling issues. 170 if (AppConstants.platform == "macosx") { 171 nextSibling = this.xulMenu.firstElementChild; 172 } else { 173 nextSibling = this.xulMenu.querySelector( 174 ":scope > #context-sep-navigation + *" 175 ); 176 } 177 } 178 if ( 179 rootElements.length && 180 showDefaults && 181 !this.itemsToCleanUp.has(nextSibling) 182 ) { 183 rootElements.push( 184 this.xulMenu.ownerDocument.createXULElement("menuseparator") 185 ); 186 } 187 } else if (!showDefaults && !overrideContext) { 188 // When the default menu items should be hidden, menu items from other 189 // extensions should be hidden too. 190 return; 191 } 192 // Fall through to show default extension menu items. 193 } 194 if (!rootElements) { 195 rootElements = this.buildTopLevelElements(root, contextData, 1, true); 196 if ( 197 rootElements.length && 198 !this.itemsToCleanUp.has(this.xulMenu.lastElementChild) 199 ) { 200 // All extension menu items are appended at the end. 201 // Prepend separator if this is the first extension menu item. 202 rootElements.unshift( 203 this.xulMenu.ownerDocument.createXULElement("menuseparator") 204 ); 205 } 206 } 207 208 if (!rootElements.length) { 209 return; 210 } 211 212 if (nextSibling) { 213 nextSibling.before(...rootElements); 214 } else { 215 this.xulMenu.append(...rootElements); 216 } 217 for (let item of rootElements) { 218 this.itemsToCleanUp.add(item); 219 } 220 }, 221 222 buildElementWithChildren(item, contextData) { 223 const element = this.buildSingleElement(item, contextData); 224 const children = this.buildChildren(item, contextData); 225 if (children.length) { 226 element.firstElementChild.append(...children); 227 } 228 return element; 229 }, 230 231 buildChildren(item, contextData) { 232 let groupName; 233 let children = []; 234 for (let child of item.children) { 235 if (child.type == "radio" && !child.groupName) { 236 if (!groupName) { 237 groupName = `webext-radio-group-${gNextRadioGroupID++}`; 238 } 239 child.groupName = groupName; 240 } else { 241 groupName = null; 242 } 243 244 if (child.enabledForContext(contextData)) { 245 children.push(this.buildElementWithChildren(child, contextData)); 246 } 247 } 248 return children; 249 }, 250 251 buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) { 252 let children = this.buildChildren(root, contextData); 253 254 // TODO: Fix bug 1492969 and remove this whole if block. 255 if ( 256 children.length === 1 && 257 maxCount === 1 && 258 forceManifestIcons && 259 AppConstants.platform === "linux" && 260 children[0].getAttribute("type") === "checkbox" 261 ) { 262 // Keep single checkbox items in the submenu on Linux since 263 // the extension icon overlaps the checkbox otherwise. 264 maxCount = 0; 265 } 266 267 if (children.length > maxCount) { 268 // Move excess items into submenu. 269 let rootElement = this.buildSingleElement(root, contextData); 270 rootElement.setAttribute("ext-type", "top-level-menu"); 271 rootElement.firstElementChild.append(...children.splice(maxCount - 1)); 272 children.push(rootElement); 273 } 274 275 if (forceManifestIcons) { 276 for (let rootElement of children) { 277 // Display the extension icon on the root element. 278 if ( 279 root.extension.manifest.icons && 280 rootElement.getAttribute("type") !== "checkbox" 281 ) { 282 this.setMenuItemIcon( 283 rootElement, 284 root.extension, 285 contextData, 286 root.extension.manifest.icons 287 ); 288 } else { 289 this.removeMenuItemIcon(rootElement); 290 } 291 } 292 } 293 return children; 294 }, 295 296 buildSingleElement(item, contextData) { 297 let doc = contextData.menu.ownerDocument; 298 let element; 299 if (item.children.length) { 300 element = this.createMenuElement(doc, item); 301 } else if (item.type == "separator") { 302 element = doc.createXULElement("menuseparator"); 303 } else { 304 element = doc.createXULElement("menuitem"); 305 } 306 307 return this.customizeElement(element, item, contextData); 308 }, 309 310 createMenuElement(doc) { 311 let element = doc.createXULElement("menu"); 312 // Menu elements need to have a menupopup child for its menu items. 313 let menupopup = doc.createXULElement("menupopup"); 314 element.appendChild(menupopup); 315 return element; 316 }, 317 318 customizeElement(element, item, contextData) { 319 let label = item.title; 320 if (label) { 321 let accessKey; 322 label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => { 323 if (nextChar === "&") { 324 return "&"; 325 } 326 if (accessKey === undefined) { 327 if (nextChar === "%" && label.charAt(i + 2) === "s") { 328 accessKey = ""; 329 } else { 330 accessKey = nextChar; 331 } 332 } 333 return nextChar; 334 }); 335 element.setAttribute("accesskey", accessKey || ""); 336 337 if (contextData.isTextSelected && label.indexOf("%s") > -1) { 338 let selection = contextData.selectionText.trim(); 339 // The rendering engine will truncate the title if it's longer than 64 characters. 340 // But if it makes sense let's try truncate selection text only, to handle cases like 341 // 'look up "%s" in MyDictionary' more elegantly. 342 343 let codePointsToRemove = 0; 344 345 let selectionArray = Array.from(selection); 346 347 let completeLabelLength = label.length - 2 + selectionArray.length; 348 if (completeLabelLength > gMaxLabelLength) { 349 codePointsToRemove = completeLabelLength - gMaxLabelLength; 350 } 351 352 if (codePointsToRemove) { 353 codePointsToRemove += 1; 354 selection = 355 selectionArray.slice(0, -codePointsToRemove).join("") + 356 Services.locale.ellipsis; 357 } 358 359 label = label.replace(/%s/g, selection); 360 } 361 362 element.setAttribute("label", label); 363 } 364 365 element.setAttribute("id", item.elementId); 366 367 if ("icons" in item) { 368 if (item.icons) { 369 this.setMenuItemIcon(element, item.extension, contextData, item.icons); 370 } else { 371 this.removeMenuItemIcon(element); 372 } 373 } 374 375 if (item.type == "checkbox") { 376 element.setAttribute("type", "checkbox"); 377 if (item.checked) { 378 element.setAttribute("checked", "true"); 379 } 380 } else if (item.type == "radio") { 381 element.setAttribute("type", "radio"); 382 element.setAttribute("name", item.groupName); 383 if (item.checked) { 384 element.setAttribute("checked", "true"); 385 } 386 } 387 388 if (!item.enabled) { 389 element.setAttribute("disabled", "true"); 390 } 391 392 element.addEventListener( 393 "command", 394 event => { 395 if (event.target !== event.currentTarget) { 396 return; 397 } 398 const wasChecked = item.checked; 399 if (item.type == "checkbox") { 400 item.checked = !item.checked; 401 } else if (item.type == "radio") { 402 // Deselect all radio items in the current radio group. 403 for (let child of item.parent.children) { 404 if (child.type == "radio" && child.groupName == item.groupName) { 405 child.checked = false; 406 } 407 } 408 // Select the clicked radio item. 409 item.checked = true; 410 } 411 412 let { webExtContextData } = contextData; 413 if ( 414 contextData.tab && 415 // If the menu context was overridden by the extension, do not grant 416 // activeTab since the extension also controls the tabId. 417 (!webExtContextData || 418 webExtContextData.extensionId !== item.extension.id) 419 ) { 420 item.tabManager.addActiveTabPermission(contextData.tab); 421 } 422 423 let info = item.getClickInfo(contextData, wasChecked); 424 info.modifiers = clickModifiersFromEvent(event); 425 426 info.button = event.button; 427 428 let _execute_action = 429 item.extension.manifestVersion < 3 430 ? "_execute_browser_action" 431 : "_execute_action"; 432 433 // Allow menus to open various actions supported in webext prior 434 // to notifying onclicked. 435 let actionFor = { 436 [_execute_action]: global.browserActionFor, 437 _execute_page_action: global.pageActionFor, 438 _execute_sidebar_action: global.sidebarActionFor, 439 }[item.command]; 440 if (actionFor) { 441 let win = event.target.ownerGlobal; 442 actionFor(item.extension).triggerAction(win); 443 return; 444 } 445 446 item.extension.emit( 447 "webext-menu-menuitem-click", 448 info, 449 contextData.tab 450 ); 451 }, 452 { once: true } 453 ); 454 455 // Don't publish the ID of the root because the root element is 456 // auto-generated. 457 if (item.parent) { 458 gShownMenuItems.get(item.extension).push(item.id); 459 } 460 461 return element; 462 }, 463 464 setMenuItemIcon(element, extension, contextData, icons) { 465 let parentWindow = contextData.menu.ownerGlobal; 466 467 let { icon } = IconDetails.getPreferredIcon( 468 icons, 469 extension, 470 16 * parentWindow.devicePixelRatio 471 ); 472 473 // The extension icons in the manifest are not pre-resolved, since 474 // they're sometimes used by the add-on manager when the extension is 475 // not enabled, and its URLs are not resolvable. 476 let resolvedURL = extension.baseURI.resolve(icon); 477 478 if (element.localName == "menu") { 479 element.setAttribute("class", "menu-iconic"); 480 } else if (element.localName == "menuitem") { 481 element.setAttribute("class", "menuitem-iconic"); 482 } 483 484 element.setAttribute("image", ChromeUtils.encodeURIForSrcset(resolvedURL)); 485 }, 486 487 // Undo changes from setMenuItemIcon. 488 removeMenuItemIcon(element) { 489 element.removeAttribute("class"); 490 element.removeAttribute("image"); 491 }, 492 493 rebuildMenu(extension) { 494 let { contextData } = this; 495 if (!contextData) { 496 // This happens if the menu is not visible. 497 return; 498 } 499 500 // Find the group of existing top-level items (usually 0 or 1 items) 501 // and remember its position for when the new items are inserted. 502 let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`; 503 let nextSibling = null; 504 for (let item of this.itemsToCleanUp) { 505 if (item.id && item.id.startsWith(elementIdPrefix)) { 506 nextSibling = item.nextSibling; 507 item.remove(); 508 this.itemsToCleanUp.delete(item); 509 } 510 } 511 512 let root = gRootItems.get(extension); 513 if (root) { 514 this.createAndInsertTopLevelElements(root, contextData, nextSibling); 515 } 516 517 this.xulMenu.showHideSeparators?.(); 518 }, 519 520 // This should be called once, after constructing the top-level menus, if any. 521 afterBuildingMenu(contextData) { 522 let dispatchOnShownEvent = extension => { 523 if (!this.canAccessContext(extension, contextData)) { 524 return; 525 } 526 527 // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the 528 // extension to be stored in the map even if there are currently no 529 // shown menu items. This ensures that the onHidden event can be fired 530 // when the menu is closed. 531 let menuIds = gShownMenuItems.get(extension); 532 extension.emit("webext-menu-shown", menuIds, contextData); 533 }; 534 535 if ( 536 contextData.onAction || 537 contextData.onBrowserAction || 538 contextData.onPageAction 539 ) { 540 dispatchOnShownEvent(contextData.extension); 541 } else { 542 for (const extension of gOnShownSubscribers.keys()) { 543 dispatchOnShownEvent(extension); 544 } 545 } 546 547 this.contextData = contextData; 548 }, 549 550 hideDefaultMenuItems() { 551 for (let item of this.xulMenu.children) { 552 if (!this.itemsToCleanUp.has(item)) { 553 item.hidden = true; 554 } 555 } 556 557 if (this.xulMenu.showHideSeparators) { 558 this.xulMenu.showHideSeparators(); 559 } 560 }, 561 562 handleEvent(event) { 563 if (this.xulMenu != event.target || event.type != "popuphidden") { 564 return; 565 } 566 567 delete this.xulMenu; 568 delete this.contextData; 569 570 let target = event.target; 571 target.removeEventListener("popuphidden", this); 572 for (let item of this.itemsToCleanUp) { 573 item.remove(); 574 } 575 this.itemsToCleanUp.clear(); 576 for (let extension of gShownMenuItems.keys()) { 577 extension.emit("webext-menu-hidden"); 578 } 579 gShownMenuItems.clear(); 580 }, 581 582 itemsToCleanUp: new Set(), 583 }; 584 585 // Called from pageAction or browserAction popup. 586 global.actionContextMenu = function (contextData) { 587 contextData.tab = tabTracker.activeTab; 588 contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec; 589 gMenuBuilder.build(contextData); 590 }; 591 592 const contextsMap = { 593 onAudio: "audio", 594 onEditable: "editable", 595 inFrame: "frame", 596 onImage: "image", 597 onLink: "link", 598 onPassword: "password", 599 isTextSelected: "selection", 600 onVideo: "video", 601 602 onBookmark: "bookmark", 603 onAction: "action", 604 onBrowserAction: "browser_action", 605 onPageAction: "page_action", 606 onTab: "tab", 607 inToolsMenu: "tools_menu", 608 }; 609 610 const getMenuContexts = contextData => { 611 let contexts = new Set(); 612 613 for (const [key, value] of Object.entries(contextsMap)) { 614 if (contextData[key]) { 615 contexts.add(value); 616 } 617 } 618 619 if (contexts.size === 0) { 620 contexts.add("page"); 621 } 622 623 // New non-content contexts supported in Firefox are not part of "all". 624 if ( 625 !contextData.onBookmark && 626 !contextData.onTab && 627 !contextData.inToolsMenu 628 ) { 629 contexts.add("all"); 630 } 631 632 return contexts; 633 }; 634 635 function getContextViewType(contextData) { 636 if ("originalViewType" in contextData) { 637 return contextData.originalViewType; 638 } 639 if ( 640 contextData.webExtBrowserType === "popup" || 641 contextData.webExtBrowserType === "sidebar" 642 ) { 643 return contextData.webExtBrowserType; 644 } 645 if (contextData.tab && contextData.menu.id === "contentAreaContextMenu") { 646 return "tab"; 647 } 648 return undefined; 649 } 650 651 function addMenuEventInfo(info, contextData, extension, includeSensitiveData) { 652 info.viewType = getContextViewType(contextData); 653 if (contextData.onVideo) { 654 info.mediaType = "video"; 655 } else if (contextData.onAudio) { 656 info.mediaType = "audio"; 657 } else if (contextData.onImage) { 658 info.mediaType = "image"; 659 } 660 if (contextData.frameId !== undefined) { 661 info.frameId = contextData.frameId; 662 } 663 if (contextData.onBookmark) { 664 info.bookmarkId = contextData.bookmarkId; 665 } 666 info.editable = contextData.onEditable || false; 667 if (includeSensitiveData) { 668 // menus.getTargetElement requires the "menus" permission, so do not set 669 // targetElementId for extensions with only the "contextMenus" permission. 670 if (contextData.timeStamp && extension.hasPermission("menus")) { 671 // Convert to integer, in case the DOMHighResTimeStamp has a fractional part. 672 info.targetElementId = Math.floor(contextData.timeStamp); 673 } 674 if (contextData.onLink) { 675 info.linkText = contextData.linkText; 676 info.linkUrl = contextData.linkUrl; 677 } 678 if (contextData.onAudio || contextData.onImage || contextData.onVideo) { 679 info.srcUrl = contextData.srcUrl; 680 } 681 if (!contextData.onBookmark) { 682 info.pageUrl = contextData.pageUrl; 683 } 684 if (contextData.inFrame) { 685 info.frameUrl = contextData.frameUrl; 686 } 687 if (contextData.isTextSelected) { 688 info.selectionText = contextData.selectionText; 689 } 690 } 691 // If the context was overridden, then frameUrl should be the URL of the 692 // document in which the menu was opened (instead of undefined, even if that 693 // document is not in a frame). 694 if (contextData.originalViewUrl) { 695 info.frameUrl = contextData.originalViewUrl; 696 } 697 } 698 699 class MenuItem { 700 constructor(extension, createProperties, isRoot = false) { 701 this.extension = extension; 702 this.children = []; 703 this.parent = null; 704 this.tabManager = extension.tabManager; 705 706 this.setDefaults(); 707 this.setProps(createProperties); 708 709 if (!this.hasOwnProperty("_id")) { 710 this.id = gNextMenuItemID++; 711 } 712 // If the item is not the root and has no parent 713 // it must be a child of the root. 714 if (!isRoot && !this.parent) { 715 this.root.addChild(this); 716 } 717 } 718 719 setProps(createProperties) { 720 ExtensionMenus.mergeMenuProperties(this, createProperties); 721 722 if (createProperties.documentUrlPatterns != null) { 723 this.documentUrlMatchPattern = parseMatchPatterns( 724 this.documentUrlPatterns, 725 { 726 restrictSchemes: this.extension.restrictSchemes, 727 } 728 ); 729 } 730 731 if (createProperties.targetUrlPatterns != null) { 732 this.targetUrlMatchPattern = parseMatchPatterns(this.targetUrlPatterns, { 733 // restrictSchemes default to false when matching links instead of pages 734 // (see Bug 1280370 for a rationale). 735 restrictSchemes: false, 736 }); 737 } 738 739 // If a child MenuItem does not specify any contexts, then it should 740 // inherit the contexts specified from its parent. 741 if (createProperties.parentId && !createProperties.contexts) { 742 this.contexts = this.parent.contexts; 743 } 744 } 745 746 setDefaults() { 747 this.setProps({ 748 type: "normal", 749 checked: false, 750 contexts: ["all"], 751 enabled: true, 752 visible: true, 753 }); 754 } 755 756 set id(id) { 757 if (this.hasOwnProperty("_id")) { 758 throw new ExtensionError("ID of a MenuItem cannot be changed"); 759 } 760 let isIdUsed = gMenuMap.get(this.extension).has(id); 761 if (isIdUsed) { 762 throw new ExtensionError(`ID already exists: ${id}`); 763 } 764 this._id = id; 765 } 766 767 get id() { 768 return this._id; 769 } 770 771 get elementId() { 772 let id = this.id; 773 // If the ID is an integer, it is auto-generated and globally unique. 774 // If the ID is a string, it is only unique within one extension and the 775 // ID needs to be concatenated with the extension ID. 776 if (typeof id !== "number") { 777 // To avoid collisions with numeric IDs, add a prefix to string IDs. 778 id = `_${id}`; 779 } 780 return `${makeWidgetId(this.extension.id)}-menuitem-${id}`; 781 } 782 783 ensureValidParentId(parentId) { 784 if (parentId === undefined) { 785 return; 786 } 787 let menuMap = gMenuMap.get(this.extension); 788 if (!menuMap.has(parentId)) { 789 throw new ExtensionError(`Cannot find menu item with id ${parentId}`); 790 } 791 for (let item = menuMap.get(parentId); item; item = item.parent) { 792 if (item === this) { 793 throw new ExtensionError( 794 "MenuItem cannot be an ancestor (or self) of its new parent." 795 ); 796 } 797 } 798 } 799 800 set parentId(parentId) { 801 this.ensureValidParentId(parentId); 802 803 if (this.parent) { 804 this.parent.detachChild(this); 805 } 806 807 if (parentId === undefined) { 808 this.root.addChild(this); 809 } else { 810 let menuMap = gMenuMap.get(this.extension); 811 menuMap.get(parentId).addChild(this); 812 } 813 } 814 815 get parentId() { 816 return this.parent ? this.parent.id : undefined; 817 } 818 819 addChild(child) { 820 if (child.parent) { 821 throw new Error("Child MenuItem already has a parent."); 822 } 823 this.children.push(child); 824 child.parent = this; 825 } 826 827 detachChild(child) { 828 let idx = this.children.indexOf(child); 829 if (idx < 0) { 830 throw new Error("Child MenuItem not found, it cannot be removed."); 831 } 832 this.children.splice(idx, 1); 833 child.parent = null; 834 } 835 836 get root() { 837 let extension = this.extension; 838 if (!gRootItems.has(extension)) { 839 let root = new MenuItem( 840 extension, 841 { title: extension.name }, 842 /* isRoot = */ true 843 ); 844 gRootItems.set(extension, root); 845 } 846 847 return gRootItems.get(extension); 848 } 849 850 get descendantIds() { 851 return this.children 852 ? this.children.flatMap(m => [m.id, ...m.descendantIds]) 853 : []; 854 } 855 856 remove() { 857 if (this.parent) { 858 this.parent.detachChild(this); 859 } 860 let children = this.children.slice(0); 861 for (let child of children) { 862 child.remove(); 863 } 864 865 let menuMap = gMenuMap.get(this.extension); 866 menuMap.delete(this.id); 867 if (this.root == this) { 868 gRootItems.delete(this.extension); 869 } 870 } 871 872 getClickInfo(contextData, wasChecked) { 873 let info = { 874 menuItemId: this.id, 875 }; 876 if (this.parent) { 877 info.parentMenuItemId = this.parentId; 878 } 879 880 addMenuEventInfo(info, contextData, this.extension, true); 881 882 if (this.type === "checkbox" || this.type === "radio") { 883 info.checked = this.checked; 884 info.wasChecked = wasChecked; 885 } 886 887 return info; 888 } 889 890 enabledForContext(contextData) { 891 if (!this.visible) { 892 return false; 893 } 894 let contexts = getMenuContexts(contextData); 895 if (!this.contexts.some(n => contexts.has(n))) { 896 return false; 897 } 898 899 if ( 900 this.viewTypes && 901 !this.viewTypes.includes(getContextViewType(contextData)) 902 ) { 903 return false; 904 } 905 906 let docPattern = this.documentUrlMatchPattern; 907 // When viewTypes is specified, the menu item is expected to be restricted 908 // to documents. So let documentUrlPatterns always apply to the URL of the 909 // document in which the menu was opened. When maybeOverrideContextData 910 // changes the context, contextData.pageUrl does not reflect that URL any 911 // more, so use contextData.originalViewUrl instead. 912 if (docPattern && this.viewTypes && contextData.originalViewUrl) { 913 if ( 914 !docPattern.matches(Services.io.newURI(contextData.originalViewUrl)) 915 ) { 916 return false; 917 } 918 docPattern = null; // Null it so that it won't be used with pageURI below. 919 } 920 921 if (contextData.onBookmark) { 922 return this.extension.hasPermission("bookmarks"); 923 } 924 925 let pageURI = Services.io.newURI( 926 contextData[contextData.inFrame ? "frameUrl" : "pageUrl"] 927 ); 928 if (docPattern && !docPattern.matches(pageURI)) { 929 return false; 930 } 931 932 let targetPattern = this.targetUrlMatchPattern; 933 if (targetPattern) { 934 let targetURIs = []; 935 if (contextData.onImage || contextData.onAudio || contextData.onVideo) { 936 // TODO: double check if srcUrl is always set when we need it 937 targetURIs.push(Services.io.newURI(contextData.srcUrl)); 938 } 939 // contextData.linkURI may be null despite contextData.onLink, when 940 // contextData.linkUrl is an invalid URL. 941 if (contextData.onLink && contextData.linkURI) { 942 targetURIs.push(contextData.linkURI); 943 } 944 if (!targetURIs.some(targetURI => targetPattern.matches(targetURI))) { 945 return false; 946 } 947 } 948 949 return true; 950 } 951 } 952 953 // windowTracker only looks as browser windows, but we're also interested in 954 // the Library window. Helper for menuTracker below. 955 const libraryTracker = { 956 libraryWindowType: "Places:Organizer", 957 958 isLibraryWindow(window) { 959 let winType = window.document.documentElement.getAttribute("windowtype"); 960 return winType === this.libraryWindowType; 961 }, 962 963 init(listener) { 964 this._listener = listener; 965 Services.ww.registerNotification(this); 966 967 // See WindowTrackerBase#*browserWindows in ext-tabs-base.js for why we 968 // can't use the enumerator's windowtype filter. 969 for (let window of Services.wm.getEnumerator("")) { 970 if (windowTracker.isBrowserWindowInitialized(window)) { 971 if (this.isLibraryWindow(window)) { 972 this.notify(window); 973 } 974 } else { 975 window.addEventListener("load", this, { once: true }); 976 } 977 } 978 }, 979 980 // cleanupWindow is called on any library window that's open. 981 uninit(cleanupWindow) { 982 Services.ww.unregisterNotification(this); 983 984 for (let window of Services.wm.getEnumerator("")) { 985 window.removeEventListener("load", this); 986 try { 987 if (this.isLibraryWindow(window)) { 988 cleanupWindow(window); 989 } 990 } catch (e) { 991 Cu.reportError(e); 992 } 993 } 994 }, 995 996 // Gets notifications from Services.ww.registerNotification. 997 // Defer actually doing anything until the window's loaded, though. 998 observe(window, topic) { 999 if (topic === "domwindowopened") { 1000 window.addEventListener("load", this, { once: true }); 1001 } 1002 }, 1003 1004 // Gets the load event for new windows(registered in observe()). 1005 handleEvent(event) { 1006 let window = event.target.defaultView; 1007 if (this.isLibraryWindow(window)) { 1008 this.notify(window); 1009 } 1010 }, 1011 1012 notify(window) { 1013 try { 1014 this._listener.call(null, window); 1015 } catch (e) { 1016 Cu.reportError(e); 1017 } 1018 }, 1019 }; 1020 1021 // While any extensions are active, this Tracker registers to observe/listen 1022 // for menu events from both Tools and context menus, both content and chrome. 1023 const menuTracker = { 1024 menuIds: ["placesContext", "menu_ToolsPopup", "tabContextMenu"], 1025 1026 register() { 1027 Services.obs.addObserver(this, "on-build-contextmenu"); 1028 for (const window of windowTracker.browserWindows()) { 1029 this.onWindowOpen(window); 1030 } 1031 windowTracker.addOpenListener(this.onWindowOpen); 1032 libraryTracker.init(this.onLibraryOpen); 1033 }, 1034 1035 unregister() { 1036 Services.obs.removeObserver(this, "on-build-contextmenu"); 1037 for (const window of windowTracker.browserWindows()) { 1038 this.cleanupWindow(window); 1039 } 1040 windowTracker.removeOpenListener(this.onWindowOpen); 1041 libraryTracker.uninit(this.cleanupLibrary); 1042 }, 1043 1044 observe(subject) { 1045 subject = subject.wrappedJSObject; 1046 gMenuBuilder.build(subject); 1047 }, 1048 1049 async onWindowOpen(window) { 1050 for (const id of menuTracker.menuIds) { 1051 const menu = window.document.getElementById(id); 1052 menu.addEventListener("popupshowing", menuTracker); 1053 } 1054 1055 const sidebarHeader = window.document.getElementById( 1056 "sidebar-switcher-target" 1057 ); 1058 sidebarHeader.addEventListener("SidebarShown", menuTracker.onSidebarShown); 1059 1060 await window.SidebarController.promiseInitialized; 1061 1062 if ( 1063 !window.closed && 1064 window.SidebarController.currentID === "viewBookmarksSidebar" 1065 ) { 1066 menuTracker.onSidebarShown({ currentTarget: sidebarHeader }); 1067 } 1068 }, 1069 1070 cleanupWindow(window) { 1071 for (const id of this.menuIds) { 1072 const menu = window.document.getElementById(id); 1073 menu.removeEventListener("popupshowing", this); 1074 } 1075 1076 const sidebarHeader = window.document.getElementById( 1077 "sidebar-switcher-target" 1078 ); 1079 sidebarHeader.removeEventListener("SidebarShown", this.onSidebarShown); 1080 1081 if (window.SidebarController.currentID === "viewBookmarksSidebar") { 1082 let sidebarBrowser = window.SidebarController.browser; 1083 sidebarBrowser.removeEventListener("load", this.onSidebarShown); 1084 const menu = 1085 sidebarBrowser.contentDocument.getElementById("placesContext"); 1086 menu.removeEventListener("popupshowing", this.onBookmarksContextMenu); 1087 } 1088 }, 1089 1090 onSidebarShown(event) { 1091 // The event target is an element in a browser window, so |window| will be 1092 // the browser window that contains the sidebar. 1093 const window = event.currentTarget.ownerGlobal; 1094 if (window.SidebarController.currentID === "viewBookmarksSidebar") { 1095 let sidebarBrowser = window.SidebarController.browser; 1096 if (sidebarBrowser.contentDocument.readyState !== "complete") { 1097 // SidebarController.currentID may be updated before the bookmark sidebar's 1098 // document has finished loading. This sometimes happens when the 1099 // sidebar is automatically shown when a new window is opened. 1100 sidebarBrowser.addEventListener("load", menuTracker.onSidebarShown, { 1101 once: true, 1102 }); 1103 return; 1104 } 1105 const menu = 1106 sidebarBrowser.contentDocument.getElementById("placesContext"); 1107 menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu); 1108 } 1109 }, 1110 1111 onLibraryOpen(window) { 1112 const menu = window.document.getElementById("placesContext"); 1113 menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu); 1114 }, 1115 1116 cleanupLibrary(window) { 1117 const menu = window.document.getElementById("placesContext"); 1118 menu.removeEventListener( 1119 "popupshowing", 1120 menuTracker.onBookmarksContextMenu 1121 ); 1122 }, 1123 1124 handleEvent(event) { 1125 const menu = event.target; 1126 1127 if (menu.id === "placesContext") { 1128 const trigger = menu.triggerNode; 1129 if (!trigger._placesNode?.bookmarkGuid) { 1130 return; 1131 } 1132 1133 gMenuBuilder.build({ 1134 menu, 1135 bookmarkId: trigger._placesNode.bookmarkGuid, 1136 onBookmark: true, 1137 }); 1138 } 1139 if (menu.id === "menu_ToolsPopup") { 1140 const tab = tabTracker.activeTab; 1141 const pageUrl = tab.linkedBrowser.currentURI.spec; 1142 gMenuBuilder.build({ menu, tab, pageUrl, inToolsMenu: true }); 1143 } 1144 if (menu.id === "tabContextMenu") { 1145 const tab = menu.ownerGlobal.TabContextMenu.contextTab; 1146 const pageUrl = tab.linkedBrowser.currentURI.spec; 1147 gMenuBuilder.build({ menu, tab, pageUrl, onTab: true }); 1148 } 1149 }, 1150 1151 onBookmarksContextMenu(event) { 1152 const menu = event.target; 1153 const tree = menu.triggerNode.parentElement; 1154 const cell = tree.getCellAt(event.x, event.y); 1155 const node = tree.view.nodeForTreeIndex(cell.row); 1156 const bookmarkId = node && PlacesUtils.getConcreteItemGuid(node); 1157 1158 if (!bookmarkId || PlacesUtils.isVirtualLeftPaneItem(bookmarkId)) { 1159 return; 1160 } 1161 1162 gMenuBuilder.build({ menu, bookmarkId, onBookmark: true }); 1163 }, 1164 }; 1165 1166 this.menusInternal = class extends ExtensionAPIPersistent { 1167 #promiseInitialized = null; 1168 1169 constructor(extension) { 1170 super(extension); 1171 1172 if (!gMenuMap.size) { 1173 menuTracker.register(); 1174 } 1175 gMenuMap.set(extension, new Map()); 1176 } 1177 1178 async initExtensionMenus() { 1179 let { extension } = this; 1180 1181 await ExtensionMenus.asyncInitForExtension(extension); 1182 1183 if ( 1184 extension.hasShutdown || 1185 !ExtensionMenus.shouldPersistMenus(extension) 1186 ) { 1187 return; 1188 } 1189 1190 // Used for testing 1191 const notifyMenusCreated = () => 1192 extension.emit("webext-menus-created", gMenuMap.get(extension)); 1193 1194 const menus = ExtensionMenus.getMenus(extension); 1195 if (!menus.size) { 1196 notifyMenusCreated(); 1197 return; 1198 } 1199 1200 let createErrorMenuIds = []; 1201 for (let createProperties of menus.values()) { 1202 // The order of menu creation is significant: 1203 // When creating and reparenting the menu we ensure parents exist 1204 // in the persisted menus map before children. That allows the 1205 // menus to be recreated in the correct sequence on startup. 1206 // 1207 // For details, see ExtensionMenusManager's updateMenus in 1208 // ExtensionMenus.sys.mjs 1209 try { 1210 let menuItem = new MenuItem(extension, createProperties); 1211 gMenuMap.get(extension).set(menuItem.id, menuItem); 1212 } catch (err) { 1213 Cu.reportError( 1214 `Unexpected error on recreating persisted menu ${createProperties?.id} for ${extension.id}: ${err}` 1215 ); 1216 createErrorMenuIds.push(createProperties.id); 1217 } 1218 } 1219 1220 if (createErrorMenuIds.length) { 1221 ExtensionMenus.deleteMenus(extension, createErrorMenuIds); 1222 } 1223 1224 notifyMenusCreated(); 1225 } 1226 1227 onStartup() { 1228 this.#promiseInitialized = this.initExtensionMenus(); 1229 } 1230 1231 onShutdown() { 1232 let { extension } = this; 1233 1234 if (gMenuMap.has(extension)) { 1235 gMenuMap.delete(extension); 1236 gRootItems.delete(extension); 1237 gShownMenuItems.delete(extension); 1238 gOnShownSubscribers.delete(extension); 1239 if (!gMenuMap.size) { 1240 menuTracker.unregister(); 1241 } 1242 } 1243 } 1244 1245 PERSISTENT_EVENTS = { 1246 onShown({ fire }) { 1247 let { extension } = this; 1248 let listener = (event, menuIds, contextData) => { 1249 let info = { 1250 menuIds, 1251 contexts: Array.from(getMenuContexts(contextData)), 1252 }; 1253 1254 let nativeTab = contextData.tab; 1255 1256 // The menus.onShown event is fired before the user has consciously 1257 // interacted with an extension, so we require permissions before 1258 // exposing sensitive contextual data. 1259 let contextUrl = contextData.inFrame 1260 ? contextData.frameUrl 1261 : contextData.pageUrl; 1262 let includeSensitiveData = 1263 (nativeTab && 1264 extension.tabManager.hasActiveTabPermission(nativeTab)) || 1265 (contextUrl && extension.allowedOrigins.matches(contextUrl)); 1266 1267 addMenuEventInfo(info, contextData, extension, includeSensitiveData); 1268 1269 let tab = nativeTab && extension.tabManager.convert(nativeTab); 1270 fire.sync(info, tab); 1271 }; 1272 gOnShownSubscribers.get(extension).add(listener); 1273 extension.on("webext-menu-shown", listener); 1274 return { 1275 unregister() { 1276 const listeners = gOnShownSubscribers.get(extension); 1277 listeners.delete(listener); 1278 if (listeners.size === 0) { 1279 gOnShownSubscribers.delete(extension); 1280 } 1281 extension.off("webext-menu-shown", listener); 1282 }, 1283 convert(_fire) { 1284 fire = _fire; 1285 }, 1286 }; 1287 }, 1288 onHidden({ fire }) { 1289 let { extension } = this; 1290 let listener = () => { 1291 fire.sync(); 1292 }; 1293 extension.on("webext-menu-hidden", listener); 1294 return { 1295 unregister() { 1296 extension.off("webext-menu-hidden", listener); 1297 }, 1298 convert(_fire) { 1299 fire = _fire; 1300 }, 1301 }; 1302 }, 1303 onClicked({ context, fire }) { 1304 let { extension } = this; 1305 let listener = async (event, info, nativeTab) => { 1306 let { linkedBrowser } = nativeTab || tabTracker.activeTab; 1307 let tab = nativeTab && extension.tabManager.convert(nativeTab); 1308 if (fire.wakeup) { 1309 // force the wakeup, thus the call to convert to get the context. 1310 await fire.wakeup(); 1311 // If while waiting the tab disappeared we bail out. 1312 if ( 1313 !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser) 1314 ) { 1315 Cu.reportError( 1316 `menus.onClicked: target tab closed during background startup.` 1317 ); 1318 return; 1319 } 1320 } 1321 context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab)); 1322 }; 1323 1324 extension.on("webext-menu-menuitem-click", listener); 1325 return { 1326 unregister() { 1327 extension.off("webext-menu-menuitem-click", listener); 1328 }, 1329 convert(_fire, _context) { 1330 fire = _fire; 1331 context = _context; 1332 }, 1333 }; 1334 }, 1335 }; 1336 1337 getAPI(context) { 1338 let { extension } = context; 1339 1340 const menus = { 1341 refresh() { 1342 gMenuBuilder.rebuildMenu(extension); 1343 }, 1344 1345 onShown: new EventManager({ 1346 context, 1347 module: "menusInternal", 1348 event: "onShown", 1349 name: "menus.onShown", 1350 extensionApi: this, 1351 }).api(), 1352 onHidden: new EventManager({ 1353 context, 1354 module: "menusInternal", 1355 event: "onHidden", 1356 name: "menus.onHidden", 1357 extensionApi: this, 1358 }).api(), 1359 }; 1360 1361 return { 1362 contextMenus: menus, 1363 menus, 1364 menusInternal: { 1365 create: async createProperties => { 1366 await this.#promiseInitialized; 1367 if (extension.hasShutdown) { 1368 return; 1369 } 1370 1371 // event pages require id 1372 if (ExtensionMenus.shouldPersistMenus(extension)) { 1373 if (!createProperties.id) { 1374 throw new ExtensionError( 1375 "menus.create requires an id for non-persistent background scripts." 1376 ); 1377 } 1378 if (gMenuMap.get(extension).has(createProperties.id)) { 1379 throw new ExtensionError( 1380 `The menu id ${createProperties.id} already exists in menus.create.` 1381 ); 1382 } 1383 } 1384 1385 // Note that the id is required by the schema. If the addon did not set 1386 // it, the implementation of menus.create in the child will add it for 1387 // extensions with persistent backgrounds, but not otherwise. 1388 1389 let menuItem = new MenuItem(extension, createProperties); 1390 ExtensionMenus.addMenu(extension, createProperties); 1391 gMenuMap.get(extension).set(menuItem.id, menuItem); 1392 }, 1393 1394 update: async (id, updateProperties) => { 1395 await this.#promiseInitialized; 1396 if (extension.hasShutdown) { 1397 return; 1398 } 1399 1400 let menuItem = gMenuMap.get(extension).get(id); 1401 if (!menuItem) { 1402 throw new ExtensionError(`Cannot find menu item with id ${id}`); 1403 } 1404 1405 menuItem.setProps(updateProperties); 1406 ExtensionMenus.updateMenu(extension, id, updateProperties); 1407 }, 1408 1409 remove: async id => { 1410 await this.#promiseInitialized; 1411 if (extension.hasShutdown) { 1412 return; 1413 } 1414 1415 let menuItem = gMenuMap.get(extension).get(id); 1416 if (!menuItem) { 1417 throw new ExtensionError(`Cannot find menu item with id ${id}`); 1418 } 1419 1420 const menuIds = [menuItem.id, ...menuItem.descendantIds]; 1421 menuItem.remove(); 1422 ExtensionMenus.deleteMenus(extension, menuIds); 1423 }, 1424 1425 removeAll: async () => { 1426 await this.#promiseInitialized; 1427 if (extension.hasShutdown) { 1428 return; 1429 } 1430 1431 let root = gRootItems.get(extension); 1432 if (root) { 1433 root.remove(); 1434 } 1435 ExtensionMenus.deleteAllMenus(extension); 1436 }, 1437 1438 onClicked: new EventManager({ 1439 context, 1440 module: "menusInternal", 1441 event: "onClicked", 1442 name: "menus.onClicked", 1443 extensionApi: this, 1444 }).api(), 1445 }, 1446 }; 1447 } 1448 };