/* -*- 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;