We had a number of tests that assumed that when adding a browser_action without specifying the default_area, that the button would enter the navbar. The previous patch in this series changes that assumption when the Unified Extensions UI is enabled. Instead of updating all of these tests to add additional steps to move the browser_action's out to the navbar after adding them, I've gone ahead and updated them to default their browser_action's to the navbar instead. Differential Revision: https://phabricator.services.mozilla.com/D161721
1037 lines
30 KiB
JavaScript
1037 lines
30 KiB
JavaScript
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set sts=2 sw=2 et tw=80: */
|
|
/* 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";
|
|
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"CustomizableUI",
|
|
"resource:///modules/CustomizableUI.jsm"
|
|
);
|
|
ChromeUtils.defineESModuleGetters(this, {
|
|
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
setTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
});
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"ExtensionTelemetry",
|
|
"resource://gre/modules/ExtensionTelemetry.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"ViewPopup",
|
|
"resource:///modules/ExtensionPopups.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"BrowserUsageTelemetry",
|
|
"resource:///modules/BrowserUsageTelemetry.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"OriginControls",
|
|
"resource://gre/modules/ExtensionPermissions.jsm"
|
|
);
|
|
|
|
var { DefaultWeakMap, ExtensionError } = ExtensionUtils;
|
|
|
|
var { ExtensionParent } = ChromeUtils.import(
|
|
"resource://gre/modules/ExtensionParent.jsm"
|
|
);
|
|
var { BrowserActionBase } = ChromeUtils.import(
|
|
"resource://gre/modules/ExtensionActions.jsm"
|
|
);
|
|
|
|
var { IconDetails, StartupCache } = ExtensionParent;
|
|
|
|
const POPUP_PRELOAD_TIMEOUT_MS = 200;
|
|
|
|
// WeakMap[Extension -> BrowserAction]
|
|
const browserActionMap = new WeakMap();
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "browserAreas", () => {
|
|
let panelArea = gUnifiedExtensionsEnabled
|
|
? CustomizableUI.AREA_ADDONS
|
|
: CustomizableUI.AREA_FIXED_OVERFLOW_PANEL;
|
|
return {
|
|
navbar: CustomizableUI.AREA_NAVBAR,
|
|
menupanel: panelArea,
|
|
tabstrip: CustomizableUI.AREA_TABSTRIP,
|
|
personaltoolbar: CustomizableUI.AREA_BOOKMARKS,
|
|
};
|
|
});
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"gUnifiedExtensionsEnabled",
|
|
"extensions.unifiedExtensions.enabled",
|
|
false
|
|
);
|
|
|
|
function actionWidgetId(widgetId) {
|
|
return `${widgetId}-browser-action`;
|
|
}
|
|
|
|
class BrowserAction extends BrowserActionBase {
|
|
constructor(extension, buttonDelegate) {
|
|
let tabContext = new TabContext(target => {
|
|
let window = target.ownerGlobal;
|
|
if (target === window) {
|
|
return this.getContextData(null);
|
|
}
|
|
return tabContext.get(window);
|
|
});
|
|
super(tabContext, extension);
|
|
this.buttonDelegate = buttonDelegate;
|
|
}
|
|
|
|
updateOnChange(target) {
|
|
if (target) {
|
|
let window = target.ownerGlobal;
|
|
if (target === window || target.selected) {
|
|
this.buttonDelegate.updateWindow(window);
|
|
}
|
|
} else {
|
|
for (let window of windowTracker.browserWindows()) {
|
|
this.buttonDelegate.updateWindow(window);
|
|
}
|
|
}
|
|
}
|
|
|
|
getTab(tabId) {
|
|
if (tabId !== null) {
|
|
return tabTracker.getTab(tabId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getWindow(windowId) {
|
|
if (windowId !== null) {
|
|
return windowTracker.getWindow(windowId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
dispatchClick(tab, clickInfo) {
|
|
this.buttonDelegate.emit("click", tab, clickInfo);
|
|
}
|
|
}
|
|
|
|
this.browserAction = class extends ExtensionAPIPersistent {
|
|
static for(extension) {
|
|
return browserActionMap.get(extension);
|
|
}
|
|
|
|
async onManifestEntry(entryName) {
|
|
let { extension } = this;
|
|
|
|
let options =
|
|
extension.manifest.browser_action || extension.manifest.action;
|
|
|
|
this.action = new BrowserAction(extension, this);
|
|
await this.action.loadIconData();
|
|
|
|
this.iconData = new DefaultWeakMap(icons => this.getIconData(icons));
|
|
this.iconData.set(
|
|
this.action.getIcon(),
|
|
await StartupCache.get(
|
|
extension,
|
|
["browserAction", "default_icon_data"],
|
|
() => this.getIconData(this.action.getIcon())
|
|
)
|
|
);
|
|
|
|
let widgetId = makeWidgetId(extension.id);
|
|
this.id = actionWidgetId(widgetId);
|
|
this.viewId = `PanelUI-webext-${widgetId}-BAV`;
|
|
this.widget = null;
|
|
|
|
this.pendingPopup = null;
|
|
this.pendingPopupTimeout = null;
|
|
this.eventQueue = [];
|
|
|
|
this.tabManager = extension.tabManager;
|
|
this.browserStyle = options.browser_style;
|
|
|
|
browserActionMap.set(extension, this);
|
|
|
|
this.build();
|
|
}
|
|
|
|
static onUpdate(id, manifest) {
|
|
if (!("browser_action" in manifest || "action" in manifest)) {
|
|
// If the new version has no browser action then mark this widget as
|
|
// hidden in the telemetry. If it is already marked hidden then this will
|
|
// do nothing.
|
|
BrowserUsageTelemetry.recordWidgetChange(
|
|
actionWidgetId(makeWidgetId(id)),
|
|
null,
|
|
"addon"
|
|
);
|
|
}
|
|
}
|
|
|
|
static onDisable(id) {
|
|
BrowserUsageTelemetry.recordWidgetChange(
|
|
actionWidgetId(makeWidgetId(id)),
|
|
null,
|
|
"addon"
|
|
);
|
|
}
|
|
|
|
static onUninstall(id) {
|
|
// If the telemetry already has this widget as hidden then this will not
|
|
// record anything.
|
|
BrowserUsageTelemetry.recordWidgetChange(
|
|
actionWidgetId(makeWidgetId(id)),
|
|
null,
|
|
"addon"
|
|
);
|
|
}
|
|
|
|
onShutdown() {
|
|
browserActionMap.delete(this.extension);
|
|
this.action.onShutdown();
|
|
|
|
CustomizableUI.destroyWidget(this.id);
|
|
|
|
this.clearPopup();
|
|
}
|
|
|
|
build() {
|
|
let { extension } = this;
|
|
let widgetId = makeWidgetId(extension.id);
|
|
let widget = CustomizableUI.createWidget({
|
|
id: this.id,
|
|
viewId: this.viewId,
|
|
type: "custom",
|
|
webExtension: true,
|
|
removable: true,
|
|
label: this.action.getProperty(null, "title"),
|
|
tooltiptext: this.action.getProperty(null, "title"),
|
|
defaultArea: browserAreas[this.action.getDefaultArea()],
|
|
showInPrivateBrowsing: extension.privateBrowsingAllowed,
|
|
disallowSubView: true,
|
|
|
|
// Don't attempt to load properties from the built-in widget string
|
|
// bundle.
|
|
localized: false,
|
|
|
|
// Build a custom widget that looks like a `unified-extensions-item`
|
|
// custom element.
|
|
onBuild(document) {
|
|
let viewId = widgetId + "-BAP";
|
|
let button = document.createXULElement("toolbarbutton");
|
|
button.setAttribute("id", viewId);
|
|
// Ensure the extension context menuitems are available by setting this
|
|
// on all button children and the item.
|
|
button.setAttribute("data-extensionid", extension.id);
|
|
button.classList.add(
|
|
"toolbarbutton-1",
|
|
"unified-extensions-item-action",
|
|
"subviewbutton"
|
|
);
|
|
|
|
if (gUnifiedExtensionsEnabled) {
|
|
let contents = document.createXULElement("vbox");
|
|
contents.classList.add("unified-extensions-item-contents");
|
|
contents.setAttribute("move-after-stack", "true");
|
|
|
|
let name = document.createXULElement("label");
|
|
name.classList.add("unified-extensions-item-name");
|
|
contents.appendChild(name);
|
|
|
|
let messageDefault = document.createXULElement("label");
|
|
messageDefault.classList.add(
|
|
"unified-extensions-item-message",
|
|
"unified-extensions-item-message-default"
|
|
);
|
|
contents.appendChild(messageDefault);
|
|
|
|
let messageHover = document.createXULElement("label");
|
|
messageHover.classList.add(
|
|
"unified-extensions-item-message",
|
|
"unified-extensions-item-message-hover"
|
|
);
|
|
messageHover.setAttribute(
|
|
"data-l10n-id",
|
|
"unified-extensions-item-message-manage"
|
|
);
|
|
contents.appendChild(messageHover);
|
|
|
|
button.appendChild(contents);
|
|
}
|
|
|
|
let menuButton = document.createXULElement("toolbarbutton");
|
|
menuButton.classList.add(
|
|
"unified-extensions-item-open-menu",
|
|
"subviewbutton",
|
|
"subviewbutton-iconic"
|
|
);
|
|
|
|
if (gUnifiedExtensionsEnabled) {
|
|
menuButton.setAttribute(
|
|
"data-l10n-id",
|
|
"unified-extensions-item-open-menu"
|
|
);
|
|
// Allow the users to quickly move between extension items using
|
|
// the arrow keys, see: `PanelMultiView._isNavigableWithTabOnly()`.
|
|
menuButton.setAttribute("data-navigable-with-tab-only", true);
|
|
}
|
|
|
|
menuButton.setAttribute("data-extensionid", extension.id);
|
|
menuButton.setAttribute("closemenu", "none");
|
|
|
|
let node = document.createXULElement("toolbaritem");
|
|
node.setAttribute(
|
|
"unified-extensions",
|
|
String(gUnifiedExtensionsEnabled)
|
|
);
|
|
node.classList.add(
|
|
"toolbaritem-combined-buttons",
|
|
"unified-extensions-item"
|
|
);
|
|
node.setAttribute("view-button-id", viewId);
|
|
node.setAttribute("data-extensionid", extension.id);
|
|
node.append(button, menuButton);
|
|
node.viewButton = button;
|
|
|
|
return node;
|
|
},
|
|
|
|
onBeforeCreated: document => {
|
|
let view = document.createXULElement("panelview");
|
|
view.id = this.viewId;
|
|
view.setAttribute("flex", "1");
|
|
view.setAttribute("extension", true);
|
|
view.setAttribute("neverhidden", true);
|
|
view.setAttribute("disallowSubView", true);
|
|
|
|
document.getElementById("appMenu-viewCache").appendChild(view);
|
|
|
|
if (
|
|
this.extension.hasPermission("menus") ||
|
|
this.extension.hasPermission("contextMenus")
|
|
) {
|
|
document.addEventListener("popupshowing", this);
|
|
}
|
|
},
|
|
|
|
onDestroyed: document => {
|
|
document.removeEventListener("popupshowing", this);
|
|
|
|
let view = document.getElementById(this.viewId);
|
|
if (view) {
|
|
this.clearPopup();
|
|
CustomizableUI.hidePanelForNode(view);
|
|
view.remove();
|
|
}
|
|
},
|
|
|
|
onCreated: node => {
|
|
let actionButton = node.querySelector(
|
|
".unified-extensions-item-action"
|
|
);
|
|
actionButton.classList.add("panel-no-padding");
|
|
actionButton.classList.add("webextension-browser-action");
|
|
actionButton.setAttribute("badged", "true");
|
|
actionButton.setAttribute("constrain-size", "true");
|
|
actionButton.setAttribute("data-extensionid", this.extension.id);
|
|
|
|
actionButton.onmousedown = event => this.handleEvent(event);
|
|
actionButton.onmouseover = event => this.handleEvent(event);
|
|
actionButton.onmouseout = event => this.handleEvent(event);
|
|
actionButton.onauxclick = event => this.handleEvent(event);
|
|
|
|
if (gUnifiedExtensionsEnabled) {
|
|
const menuButton = node.querySelector(
|
|
".unified-extensions-item-open-menu"
|
|
);
|
|
menuButton.setAttribute(
|
|
"data-l10n-args",
|
|
JSON.stringify({ extensionName: this.extension.name })
|
|
);
|
|
|
|
menuButton.onblur = event => this.handleMenuButtonEvent(event);
|
|
menuButton.onfocus = event => this.handleMenuButtonEvent(event);
|
|
menuButton.onmouseout = event => this.handleMenuButtonEvent(event);
|
|
menuButton.onmouseover = event => this.handleMenuButtonEvent(event);
|
|
|
|
actionButton.onblur = event => this.handleEvent(event);
|
|
actionButton.onfocus = event => this.handleEvent(event);
|
|
}
|
|
|
|
this.updateButton(node, this.action.getContextData(null), true, false);
|
|
},
|
|
|
|
onBeforeCommand: (event, node) => {
|
|
this.lastClickInfo = {
|
|
button: event.button || 0,
|
|
modifiers: clickModifiersFromEvent(event),
|
|
};
|
|
|
|
// The openPopupWithoutUserInteraction flag may be set by openPopup.
|
|
this.openPopupWithoutUserInteraction =
|
|
event.detail?.openPopupWithoutUserInteraction === true;
|
|
|
|
if (event.target.classList.contains("unified-extensions-item-action")) {
|
|
return "view";
|
|
} else if (
|
|
event.target.classList.contains("unified-extensions-item-open-menu")
|
|
) {
|
|
return "command";
|
|
}
|
|
},
|
|
|
|
onCommand: event => {
|
|
const { target } = event;
|
|
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
// Open the unified extensions context menu when the pref is enabled.
|
|
// This context menu only has the relevant menu items for the unified
|
|
// extensions UI.
|
|
const popup = target.ownerDocument.getElementById(
|
|
gUnifiedExtensionsEnabled
|
|
? "unified-extensions-context-menu"
|
|
: "customizationPanelItemContextMenu"
|
|
);
|
|
popup.openPopup(
|
|
target,
|
|
"after_end",
|
|
0,
|
|
0,
|
|
true /* isContextMenu */,
|
|
false /* attributesOverride */,
|
|
event
|
|
);
|
|
},
|
|
|
|
onViewShowing: async event => {
|
|
const { extension } = this;
|
|
|
|
ExtensionTelemetry.browserActionPopupOpen.stopwatchStart(
|
|
extension,
|
|
this
|
|
);
|
|
let document = event.target.ownerDocument;
|
|
let tabbrowser = document.defaultView.gBrowser;
|
|
|
|
let tab = tabbrowser.selectedTab;
|
|
|
|
let popupURL = !this.openPopupWithoutUserInteraction
|
|
? this.action.triggerClickOrPopup(tab, this.lastClickInfo)
|
|
: this.action.getPopupUrl(tab);
|
|
|
|
if (popupURL) {
|
|
try {
|
|
let popup = this.getPopup(document.defaultView, popupURL);
|
|
let attachPromise = popup.attach(event.target);
|
|
event.detail.addBlocker(attachPromise);
|
|
await attachPromise;
|
|
ExtensionTelemetry.browserActionPopupOpen.stopwatchFinish(
|
|
extension,
|
|
this
|
|
);
|
|
if (this.eventQueue.length) {
|
|
ExtensionTelemetry.browserActionPreloadResult.histogramAdd({
|
|
category: "popupShown",
|
|
extension,
|
|
});
|
|
this.eventQueue = [];
|
|
}
|
|
} catch (e) {
|
|
ExtensionTelemetry.browserActionPopupOpen.stopwatchCancel(
|
|
extension,
|
|
this
|
|
);
|
|
Cu.reportError(e);
|
|
event.preventDefault();
|
|
}
|
|
} else {
|
|
ExtensionTelemetry.browserActionPopupOpen.stopwatchCancel(
|
|
extension,
|
|
this
|
|
);
|
|
// This isn't not a hack, but it seems to provide the correct behavior
|
|
// with the fewest complications.
|
|
event.preventDefault();
|
|
// Ensure we close any popups this node was in:
|
|
CustomizableUI.hidePanelForNode(event.target);
|
|
}
|
|
},
|
|
});
|
|
|
|
if (this.extension.startupReason != "APP_STARTUP") {
|
|
// Make sure the browser telemetry has the correct state for this widget.
|
|
// Defer loading BrowserUsageTelemetry until after startup is complete.
|
|
ExtensionParent.browserStartupPromise.then(() => {
|
|
let placement = CustomizableUI.getPlacementOfWidget(widget.id);
|
|
BrowserUsageTelemetry.recordWidgetChange(
|
|
widget.id,
|
|
placement?.area || null,
|
|
"addon"
|
|
);
|
|
});
|
|
}
|
|
|
|
this.widget = widget;
|
|
}
|
|
|
|
/**
|
|
* Shows the popup. The caller is expected to check if a popup is set before
|
|
* this is called.
|
|
*
|
|
* @param {Window} window Window to show the popup for
|
|
* @param {boolean} openPopupWithoutUserInteraction
|
|
* If the popup was opened without user interaction
|
|
*/
|
|
async openPopup(window, openPopupWithoutUserInteraction = false) {
|
|
const widgetForWindow = this.widget.forWindow(window);
|
|
|
|
if (!widgetForWindow.node) {
|
|
return;
|
|
}
|
|
|
|
// We want to focus hidden or minimized windows (both for the API, and to
|
|
// avoid an issue where showing the popup in a non-focused window
|
|
// immediately triggers a popuphidden event)
|
|
window.focus();
|
|
|
|
if (widgetForWindow.node.firstElementChild.open) {
|
|
return;
|
|
}
|
|
|
|
if (this.widget.areaType == CustomizableUI.TYPE_PANEL) {
|
|
if (gUnifiedExtensionsEnabled) {
|
|
await window.gUnifiedExtensions.togglePanel();
|
|
} else {
|
|
await window.document.getElementById("nav-bar").overflowable.show();
|
|
}
|
|
}
|
|
|
|
// This should already have been checked by callers, but acts as an
|
|
// an additional safeguard. It also makes sure we don't dispatch a click
|
|
// if the URL is removed while waiting for the overflow to show above.
|
|
if (!this.action.getPopupUrl(window.gBrowser.selectedTab)) {
|
|
return;
|
|
}
|
|
|
|
const event = new window.CustomEvent("command", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
detail: {
|
|
openPopupWithoutUserInteraction,
|
|
},
|
|
});
|
|
widgetForWindow.node.firstElementChild.dispatchEvent(event);
|
|
}
|
|
|
|
/**
|
|
* Triggers this browser action for the given window, with the same effects as
|
|
* if it were clicked by a user.
|
|
*
|
|
* This has no effect if the browser action is disabled for, or not
|
|
* present in, the given window.
|
|
*
|
|
* @param {Window} window
|
|
*/
|
|
triggerAction(window) {
|
|
let popup = ViewPopup.for(this.extension, window);
|
|
if (!this.pendingPopup && popup) {
|
|
popup.closePopup();
|
|
return;
|
|
}
|
|
|
|
let tab = window.gBrowser.selectedTab;
|
|
|
|
let popupUrl = this.action.triggerClickOrPopup(tab, {
|
|
button: 0,
|
|
modifiers: [],
|
|
});
|
|
if (popupUrl) {
|
|
this.openPopup(window);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles events on the (secondary) menu/cog button in an extension widget.
|
|
*
|
|
* @param {Event} event
|
|
*/
|
|
handleMenuButtonEvent(event) {
|
|
let window = event.target.ownerGlobal;
|
|
let { node } = window.gBrowser && this.widget.forWindow(window);
|
|
|
|
switch (event.type) {
|
|
case "focus":
|
|
case "mouseover": {
|
|
if (node) {
|
|
node.setAttribute("secondary-button-hovered", true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "blur":
|
|
case "mouseout": {
|
|
if (node) {
|
|
node.removeAttribute("secondary-button-hovered");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
handleEvent(event) {
|
|
// This button is the action/primary button in the custom widget.
|
|
let button = event.target;
|
|
let window = button.ownerGlobal;
|
|
|
|
switch (event.type) {
|
|
case "mousedown":
|
|
if (event.button == 0) {
|
|
let tab = window.gBrowser.selectedTab;
|
|
|
|
// Begin pre-loading the browser for the popup, so it's more likely to
|
|
// be ready by the time we get a complete click.
|
|
let popupURL = this.action.getPopupUrl(tab);
|
|
if (
|
|
popupURL &&
|
|
(this.pendingPopup || !ViewPopup.for(this.extension, window))
|
|
) {
|
|
// Add permission for the active tab so it will exist for the popup.
|
|
this.action.setActiveTabForPreload(tab);
|
|
this.eventQueue.push("Mousedown");
|
|
this.pendingPopup = this.getPopup(window, popupURL);
|
|
window.addEventListener("mouseup", this, true);
|
|
} else {
|
|
this.clearPopup();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "mouseup":
|
|
if (event.button == 0) {
|
|
this.clearPopupTimeout();
|
|
// If we have a pending pre-loaded popup, cancel it after we've waited
|
|
// long enough that we can be relatively certain it won't be opening.
|
|
if (this.pendingPopup) {
|
|
let node = window.gBrowser && this.widget.forWindow(window).node;
|
|
if (node && node.contains(event.originalTarget)) {
|
|
this.pendingPopupTimeout = setTimeout(
|
|
() => this.clearPopup(),
|
|
POPUP_PRELOAD_TIMEOUT_MS
|
|
);
|
|
} else {
|
|
this.clearPopup();
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "focus":
|
|
case "mouseover": {
|
|
let { node } = window.gBrowser && this.widget.forWindow(window);
|
|
if (gUnifiedExtensionsEnabled && node) {
|
|
const policy = WebExtensionPolicy.getByID(this.extension.id);
|
|
const messages = OriginControls.getStateMessageIDs(
|
|
policy,
|
|
window.gBrowser.currentURI
|
|
);
|
|
|
|
if (messages?.onHover) {
|
|
node.ownerDocument.l10n.setAttributes(
|
|
node.querySelector(".unified-extensions-item-message-default"),
|
|
messages.onHover
|
|
);
|
|
}
|
|
}
|
|
|
|
// We don't want to preload the popup on focus (for now).
|
|
if (event.type === "focus") {
|
|
break;
|
|
}
|
|
|
|
// Begin pre-loading the browser for the popup, so it's more likely to
|
|
// be ready by the time we get a complete click.
|
|
let tab = window.gBrowser.selectedTab;
|
|
let popupURL = this.action.getPopupUrl(tab);
|
|
|
|
if (
|
|
popupURL &&
|
|
(this.pendingPopup || !ViewPopup.for(this.extension, window))
|
|
) {
|
|
this.eventQueue.push("Hover");
|
|
this.pendingPopup = this.getPopup(window, popupURL, true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "blur":
|
|
case "mouseout": {
|
|
let { node } = window.gBrowser && this.widget.forWindow(window);
|
|
if (gUnifiedExtensionsEnabled && node) {
|
|
const policy = WebExtensionPolicy.getByID(this.extension.id);
|
|
const messages = OriginControls.getStateMessageIDs(
|
|
policy,
|
|
window.gBrowser.currentURI
|
|
);
|
|
|
|
if (messages?.default) {
|
|
node.ownerDocument.l10n.setAttributes(
|
|
node.querySelector(".unified-extensions-item-message-default"),
|
|
messages.default
|
|
);
|
|
}
|
|
}
|
|
|
|
// We don't want to clear the popup on blur for now.
|
|
if (event.type === "blur") {
|
|
break;
|
|
}
|
|
|
|
if (this.pendingPopup) {
|
|
if (this.eventQueue.length) {
|
|
ExtensionTelemetry.browserActionPreloadResult.histogramAdd({
|
|
category: `clearAfter${this.eventQueue.pop()}`,
|
|
extension: this.extension,
|
|
});
|
|
this.eventQueue = [];
|
|
}
|
|
this.clearPopup();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "popupshowing":
|
|
const menu = event.target;
|
|
const trigger = menu.triggerNode;
|
|
const node = window.document.getElementById(this.id);
|
|
const contexts = [
|
|
"toolbar-context-menu",
|
|
"customizationPanelItemContextMenu",
|
|
];
|
|
|
|
if (contexts.includes(menu.id) && node && node.contains(trigger)) {
|
|
this.updateContextMenu(menu);
|
|
}
|
|
break;
|
|
|
|
case "auxclick":
|
|
if (event.button !== 1) {
|
|
return;
|
|
}
|
|
|
|
let tab = window.gBrowser.selectedTab;
|
|
if (this.action.getProperty(tab, "enabled")) {
|
|
this.action.setActiveTabForPreload(null);
|
|
this.tabManager.addActiveTabPermission(tab);
|
|
this.action.dispatchClick(tab, {
|
|
button: 1,
|
|
modifiers: clickModifiersFromEvent(event),
|
|
});
|
|
// Ensure we close any popups this node was in:
|
|
CustomizableUI.hidePanelForNode(event.target);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the given context menu with the extension's actions.
|
|
*
|
|
* @param {Element} menu
|
|
* The context menu element that should be updated.
|
|
*/
|
|
updateContextMenu(menu) {
|
|
const action =
|
|
this.extension.manifestVersion < 3 ? "onBrowserAction" : "onAction";
|
|
|
|
global.actionContextMenu({
|
|
extension: this.extension,
|
|
[action]: true,
|
|
menu,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a potentially pre-loaded popup for the given URL in the given
|
|
* window. If a matching pre-load popup already exists, returns that.
|
|
* Otherwise, initializes a new one.
|
|
*
|
|
* If a pre-load popup exists which does not match, it is destroyed before a
|
|
* new one is created.
|
|
*
|
|
* @param {Window} window
|
|
* The browser window in which to create the popup.
|
|
* @param {string} popupURL
|
|
* The URL to load into the popup.
|
|
* @param {boolean} [blockParser = false]
|
|
* True if the HTML parser should initially be blocked.
|
|
* @returns {ViewPopup}
|
|
*/
|
|
getPopup(window, popupURL, blockParser = false) {
|
|
this.clearPopupTimeout();
|
|
let { pendingPopup } = this;
|
|
this.pendingPopup = null;
|
|
|
|
if (pendingPopup) {
|
|
if (
|
|
pendingPopup.window === window &&
|
|
pendingPopup.popupURL === popupURL
|
|
) {
|
|
if (!blockParser) {
|
|
pendingPopup.unblockParser();
|
|
}
|
|
|
|
return pendingPopup;
|
|
}
|
|
pendingPopup.destroy();
|
|
}
|
|
|
|
return new ViewPopup(
|
|
this.extension,
|
|
window,
|
|
popupURL,
|
|
this.browserStyle,
|
|
false,
|
|
blockParser
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clears any pending pre-loaded popup and related timeouts.
|
|
*/
|
|
clearPopup() {
|
|
this.clearPopupTimeout();
|
|
this.action.setActiveTabForPreload(null);
|
|
if (this.pendingPopup) {
|
|
this.pendingPopup.destroy();
|
|
this.pendingPopup = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears any pending timeouts to clear stale, pre-loaded popups.
|
|
*/
|
|
clearPopupTimeout() {
|
|
if (this.pendingPopup) {
|
|
this.pendingPopup.window.removeEventListener("mouseup", this, true);
|
|
}
|
|
|
|
if (this.pendingPopupTimeout) {
|
|
clearTimeout(this.pendingPopupTimeout);
|
|
this.pendingPopupTimeout = null;
|
|
}
|
|
}
|
|
|
|
// Update the toolbar button |node| with the tab context data
|
|
// in |tabData|.
|
|
updateButton(node, tabData, sync = false, attention = false) {
|
|
// This is the primary/action button in the custom widget.
|
|
let button = node.querySelector(".unified-extensions-item-action");
|
|
let title = tabData.title || this.extension.name;
|
|
|
|
let messages;
|
|
if (gUnifiedExtensionsEnabled) {
|
|
let policy = WebExtensionPolicy.getByID(this.extension.id);
|
|
messages = OriginControls.getStateMessageIDs(
|
|
policy,
|
|
node.ownerGlobal.gBrowser.currentURI
|
|
);
|
|
}
|
|
|
|
let callback = () => {
|
|
button.setAttribute("tooltiptext", title);
|
|
button.setAttribute("label", title);
|
|
|
|
// This is set on the node so that it looks good in the toolbar.
|
|
node.toggleAttribute("attention", attention);
|
|
|
|
if (gUnifiedExtensionsEnabled) {
|
|
button.querySelector(
|
|
".unified-extensions-item-name"
|
|
).textContent = this.extension?.name;
|
|
|
|
if (messages) {
|
|
const messageElement = button.querySelector(
|
|
".unified-extensions-item-message-default"
|
|
);
|
|
node.ownerDocument.l10n.setAttributes(
|
|
messageElement,
|
|
messages.default
|
|
);
|
|
|
|
// TODO: Bug 1799694 - adjust min-height property on the
|
|
// `.unified-extensions-item-contents` element.
|
|
}
|
|
}
|
|
|
|
if (tabData.badgeText) {
|
|
button.setAttribute("badge", tabData.badgeText);
|
|
} else {
|
|
button.removeAttribute("badge");
|
|
}
|
|
|
|
if (tabData.enabled) {
|
|
node.removeAttribute("disabled");
|
|
} else {
|
|
node.setAttribute("disabled", "true");
|
|
}
|
|
|
|
let serializeColor = ([r, g, b, a]) =>
|
|
`rgba(${r}, ${g}, ${b}, ${a / 255})`;
|
|
button.setAttribute(
|
|
"badgeStyle",
|
|
[
|
|
`background-color: ${serializeColor(tabData.badgeBackgroundColor)}`,
|
|
`color: ${serializeColor(this.action.getTextColor(tabData))}`,
|
|
].join("; ")
|
|
);
|
|
|
|
let style = this.iconData.get(tabData.icon);
|
|
button.setAttribute("style", style);
|
|
};
|
|
if (sync) {
|
|
callback();
|
|
} else {
|
|
node.ownerGlobal.requestAnimationFrame(callback);
|
|
}
|
|
}
|
|
|
|
getIconData(icons) {
|
|
let getIcon = (icon, theme) => {
|
|
if (typeof icon === "object") {
|
|
return IconDetails.escapeUrl(icon[theme]);
|
|
}
|
|
return IconDetails.escapeUrl(icon);
|
|
};
|
|
|
|
let getStyle = (name, icon) => {
|
|
return `
|
|
--webextension-${name}: url("${getIcon(icon, "default")}");
|
|
--webextension-${name}-light: url("${getIcon(icon, "light")}");
|
|
--webextension-${name}-dark: url("${getIcon(icon, "dark")}");
|
|
`;
|
|
};
|
|
|
|
let icon16 = IconDetails.getPreferredIcon(icons, this.extension, 16).icon;
|
|
let icon32 = IconDetails.getPreferredIcon(icons, this.extension, 32).icon;
|
|
let icon64 = IconDetails.getPreferredIcon(icons, this.extension, 64).icon;
|
|
|
|
if (gUnifiedExtensionsEnabled) {
|
|
return `
|
|
${getStyle("menupanel-image", icon32)}
|
|
${getStyle("menupanel-image-2x", icon64)}
|
|
${getStyle("toolbar-image", icon32)}
|
|
${getStyle("toolbar-image-2x", icon64)}
|
|
`;
|
|
}
|
|
|
|
return `
|
|
${getStyle("menupanel-image", icon16)}
|
|
${getStyle("menupanel-image-2x", icon32)}
|
|
${getStyle("toolbar-image", icon16)}
|
|
${getStyle("toolbar-image-2x", icon32)}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Update the toolbar button for a given window.
|
|
*
|
|
* @param {ChromeWindow} window
|
|
* Browser chrome window.
|
|
*/
|
|
updateWindow(window) {
|
|
let node = this.widget.forWindow(window).node;
|
|
if (node) {
|
|
let tab = window.gBrowser.selectedTab;
|
|
this.updateButton(
|
|
node,
|
|
this.action.getContextData(tab),
|
|
false,
|
|
OriginControls.getAttention(this.extension.policy, window)
|
|
);
|
|
}
|
|
}
|
|
|
|
PERSISTENT_EVENTS = {
|
|
onClicked({ context, fire }) {
|
|
const { extension } = this;
|
|
const { tabManager } = extension;
|
|
async function listener(_event, tab, clickInfo) {
|
|
if (fire.wakeup) {
|
|
await fire.wakeup();
|
|
}
|
|
// TODO: we should double-check if the tab is already being closed by the time
|
|
// the background script got started and we converted the primed listener.
|
|
context?.withPendingBrowser(tab.linkedBrowser, () =>
|
|
fire.sync(tabManager.convert(tab), clickInfo)
|
|
);
|
|
}
|
|
this.on("click", listener);
|
|
return {
|
|
unregister: () => {
|
|
this.off("click", listener);
|
|
},
|
|
convert(newFire, extContext) {
|
|
fire = newFire;
|
|
context = extContext;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
getAPI(context) {
|
|
let { extension } = context;
|
|
let { action } = this;
|
|
let namespace = extension.manifestVersion < 3 ? "browserAction" : "action";
|
|
|
|
return {
|
|
[namespace]: {
|
|
...action.api(context),
|
|
|
|
onClicked: new EventManager({
|
|
context,
|
|
// module name is "browserAction" because it the name used in the
|
|
// ext-browser.json, indipendently from the manifest version.
|
|
module: "browserAction",
|
|
event: "onClicked",
|
|
inputHandling: true,
|
|
extensionApi: this,
|
|
}).api(),
|
|
|
|
openPopup: async options => {
|
|
const isHandlingUserInput =
|
|
context.callContextData?.isHandlingUserInput;
|
|
|
|
if (
|
|
!Services.prefs.getBoolPref(
|
|
"extensions.openPopupWithoutUserGesture.enabled"
|
|
) &&
|
|
!isHandlingUserInput
|
|
) {
|
|
throw new ExtensionError("openPopup requires a user gesture");
|
|
}
|
|
|
|
const window =
|
|
typeof options?.windowId === "number"
|
|
? windowTracker.getWindow(options.windowId, context)
|
|
: windowTracker.getTopNormalWindow(context);
|
|
|
|
if (this.action.getPopupUrl(window.gBrowser.selectedTab, true)) {
|
|
await this.openPopup(window, !isHandlingUserInput);
|
|
}
|
|
},
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
global.browserActionFor = this.browserAction.for;
|