summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/parent/ext-browserAction.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/extensions/parent/ext-browserAction.js')
-rw-r--r--browser/components/extensions/parent/ext-browserAction.js689
1 files changed, 689 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/ext-browserAction.js b/browser/components/extensions/parent/ext-browserAction.js
new file mode 100644
index 0000000000..8351ab7ddf
--- /dev/null
+++ b/browser/components/extensions/parent/ext-browserAction.js
@@ -0,0 +1,689 @@
+/* -*- 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.defineModuleGetter(
+ this,
+ "clearTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionTelemetry",
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ViewPopup",
+ "resource:///modules/ExtensionPopups.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserUsageTelemetry",
+ "resource:///modules/BrowserUsageTelemetry.jsm"
+);
+
+var { DefaultWeakMap } = 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", () => {
+ return {
+ navbar: CustomizableUI.AREA_NAVBAR,
+ menupanel: CustomizableUI.AREA_FIXED_OVERFLOW_PANEL,
+ tabstrip: CustomizableUI.AREA_TABSTRIP,
+ personaltoolbar: CustomizableUI.AREA_BOOKMARKS,
+ };
+});
+
+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;
+ }
+}
+
+this.browserAction = class extends ExtensionAPI {
+ static for(extension) {
+ return browserActionMap.get(extension);
+ }
+
+ async onManifestEntry(entryName) {
+ let { extension } = this;
+
+ let options = extension.manifest.browser_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}-browser-action-view`;
+ 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)) {
+ // 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 widget = CustomizableUI.createWidget({
+ id: this.id,
+ viewId: this.viewId,
+ type: "view",
+ removable: true,
+ label: this.action.getProperty(null, "title"),
+ tooltiptext: this.action.getProperty(null, "title"),
+ defaultArea: browserAreas[this.action.getDefaultArea()],
+ showInPrivateBrowsing: this.extension.privateBrowsingAllowed,
+
+ // Don't attempt to load properties from the built-in widget string
+ // bundle.
+ localized: false,
+
+ onBeforeCreated: document => {
+ let view = document.createXULElement("panelview");
+ view.id = this.viewId;
+ view.setAttribute("flex", "1");
+ view.setAttribute("extension", 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 => {
+ node.classList.add("panel-no-padding");
+ node.classList.add("webextension-browser-action");
+ node.setAttribute("badged", "true");
+ node.setAttribute("constrain-size", "true");
+ node.setAttribute("data-extensionid", this.extension.id);
+
+ node.onmousedown = event => this.handleEvent(event);
+ node.onmouseover = event => this.handleEvent(event);
+ node.onmouseout = event => this.handleEvent(event);
+ node.onauxclick = event => this.handleEvent(event);
+
+ this.updateButton(node, this.action.getContextData(null), true);
+ },
+
+ onBeforeCommand: event => {
+ this.lastClickInfo = {
+ button: event.button || 0,
+ modifiers: clickModifiersFromEvent(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.action.getProperty(tab, "popup");
+ this.tabManager.addActiveTabPermission(tab);
+
+ // Popups are shown only if a popup URL is defined; otherwise
+ // a "click" event is dispatched. This is done for compatibility with the
+ // Google Chrome onClicked extension API.
+ 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();
+ this.emit("click", tabbrowser.selectedBrowser);
+ // 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;
+ }
+
+ /**
+ * 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
+ */
+ async triggerAction(window) {
+ let popup = ViewPopup.for(this.extension, window);
+ if (!this.pendingPopup && popup) {
+ popup.closePopup();
+ return;
+ }
+
+ let widget = this.widget.forWindow(window);
+ let tab = window.gBrowser.selectedTab;
+
+ if (!widget.node || !this.action.getProperty(tab, "enabled")) {
+ return;
+ }
+
+ // Popups are shown only if a popup URL is defined; otherwise
+ // a "click" event is dispatched. This is done for compatibility with the
+ // Google Chrome onClicked extension API.
+ if (this.action.getProperty(tab, "popup")) {
+ if (this.widget.areaType == CustomizableUI.TYPE_MENU_PANEL) {
+ await window.document.getElementById("nav-bar").overflowable.show();
+ }
+
+ let event = new window.CustomEvent("command", {
+ bubbles: true,
+ cancelable: true,
+ });
+ widget.node.dispatchEvent(event);
+ } else {
+ this.tabManager.addActiveTabPermission(tab);
+ this.lastClickInfo = { button: 0, modifiers: [] };
+ this.emit("click");
+ }
+ }
+
+ handleEvent(event) {
+ let button = event.target;
+ let window = button.ownerGlobal;
+
+ switch (event.type) {
+ case "mousedown":
+ if (event.button == 0) {
+ // 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.getProperty(tab, "popup");
+ let enabled = this.action.getProperty(tab, "enabled");
+
+ if (
+ popupURL &&
+ enabled &&
+ (this.pendingPopup || !ViewPopup.for(this.extension, window))
+ ) {
+ this.eventQueue.push("Mousedown");
+ // Add permission for the active tab so it will exist for the popup.
+ // Store the tab to revoke the permission during clearPopup.
+ if (!this.tabManager.hasActiveTabPermission(tab)) {
+ this.tabManager.addActiveTabPermission(tab);
+ this.tabToRevokeDuringClearPopup = tab;
+ }
+
+ 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 "mouseover": {
+ // 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.getProperty(tab, "popup");
+ let enabled = this.action.getProperty(tab, "enabled");
+
+ if (
+ popupURL &&
+ enabled &&
+ (this.pendingPopup || !ViewPopup.for(this.extension, window))
+ ) {
+ this.eventQueue.push("Hover");
+ this.pendingPopup = this.getPopup(window, popupURL, true);
+ }
+ break;
+ }
+
+ case "mouseout":
+ 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)) {
+ global.actionContextMenu({
+ extension: this.extension,
+ onBrowserAction: true,
+ menu: menu,
+ });
+ }
+ break;
+
+ case "auxclick":
+ if (event.button !== 1) {
+ return;
+ }
+
+ let { gBrowser } = window;
+ if (this.action.getProperty(gBrowser.selectedTab, "enabled")) {
+ this.lastClickInfo = {
+ button: 1,
+ modifiers: clickModifiersFromEvent(event),
+ };
+
+ this.emit("click", gBrowser.selectedBrowser);
+ // Ensure we close any popups this node was in:
+ CustomizableUI.hidePanelForNode(event.target);
+ }
+ break;
+ }
+ }
+
+ /**
+ * 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();
+ }
+
+ let fixedWidth =
+ this.widget.areaType == CustomizableUI.TYPE_MENU_PANEL ||
+ this.widget.forWindow(window).overflowed;
+ return new ViewPopup(
+ this.extension,
+ window,
+ popupURL,
+ this.browserStyle,
+ fixedWidth,
+ blockParser
+ );
+ }
+
+ /**
+ * Clears any pending pre-loaded popup and related timeouts.
+ */
+ clearPopup() {
+ this.clearPopupTimeout();
+ if (this.pendingPopup) {
+ if (this.tabToRevokeDuringClearPopup) {
+ this.tabManager.revokeActiveTabPermission(
+ this.tabToRevokeDuringClearPopup
+ );
+ }
+ this.pendingPopup.destroy();
+ this.pendingPopup = null;
+ }
+ this.tabToRevokeDuringClearPopup = 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) {
+ let title = tabData.title || this.extension.name;
+ let callback = () => {
+ node.setAttribute("tooltiptext", title);
+ node.setAttribute("label", title);
+
+ if (tabData.badgeText) {
+ node.setAttribute("badge", tabData.badgeText);
+ } else {
+ node.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})`;
+ node.setAttribute(
+ "badgeStyle",
+ [
+ `background-color: ${serializeColor(tabData.badgeBackgroundColor)}`,
+ `color: ${serializeColor(this.action.getTextColor(tabData))}`,
+ ].join("; ")
+ );
+
+ let style = this.iconData.get(tabData.icon);
+ node.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;
+ 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));
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+ let { tabManager } = extension;
+ let { action } = this;
+
+ return {
+ browserAction: {
+ ...action.api(context),
+
+ onClicked: new EventManager({
+ context,
+ name: "browserAction.onClicked",
+ inputHandling: true,
+ register: fire => {
+ let listener = (event, browser) => {
+ context.withPendingBrowser(browser, () =>
+ fire.sync(
+ tabManager.convert(tabTracker.activeTab),
+ this.lastClickInfo
+ )
+ );
+ };
+ this.on("click", listener);
+ return () => {
+ this.off("click", listener);
+ };
+ },
+ }).api(),
+
+ openPopup: () => {
+ let window = windowTracker.topWindow;
+ this.triggerAction(window);
+ },
+ },
+ };
+ }
+};
+
+global.browserActionFor = this.browserAction.for;