diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/uitour/UITour.sys.mjs | 2044 |
1 files changed, 2044 insertions, 0 deletions
diff --git a/browser/components/uitour/UITour.sys.mjs b/browser/components/uitour/UITour.sys.mjs new file mode 100644 index 0000000000..048ad86432 --- /dev/null +++ b/browser/components/uitour/UITour.sys.mjs @@ -0,0 +1,2044 @@ +// 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/. + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +// See LOG_LEVELS in Console.sys.mjs. Common examples: "All", "Info", "Warn", & +// "Error". +const PREF_LOG_LEVEL = "browser.uitour.loglevel"; + +const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([ + "forceShowReaderIcon", + "getConfiguration", + "getTreatmentTag", + "hideHighlight", + "hideInfo", + "hideMenu", + "ping", + "registerPageID", + "setConfiguration", + "setTreatmentTag", +]); +const MAX_BUTTONS = 4; + +// Array of which colorway/theme ids can be activated. +XPCOMUtils.defineLazyGetter(lazy, "COLORWAY_IDS", () => + [...lazy.BuiltInThemes.builtInThemeMap.keys()].filter( + id => + id.endsWith("-colorway@mozilla.org") && + !lazy.BuiltInThemes.themeIsExpired(id) + ) +); + +// Prefix for any target matching a search engine. +const TARGET_SEARCHENGINE_PREFIX = "searchEngine-"; + +// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevelPref: PREF_LOG_LEVEL, + prefix: "UITour", + }; + return new ConsoleAPI(consoleOptions); +}); + +export var UITour = { + url: null, + /* Map from browser chrome windows to a Set of <browser>s in which a tour is open (both visible and hidden) */ + tourBrowsersByWindow: new WeakMap(), + // Menus opened by api users explictly through `Mozilla.UITour.showMenu` call + noautohideMenus: new Set(), + availableTargetsCache: new WeakMap(), + clearAvailableTargetsCache() { + this.availableTargetsCache = new WeakMap(); + }, + + _annotationPanelMutationObservers: new WeakMap(), + + highlightEffects: ["random", "wobble", "zoom", "color", "focus-outline"], + targets: new Map([ + [ + "accountStatus", + { + query: "#appMenu-fxa-label2", + // This is a fake widgetName starting with the "appMenu-" prefix so we know + // to automatically open the appMenu when annotating this target. + widgetName: "appMenu-fxa-label2", + }, + ], + [ + "addons", + { + query: "#appMenu-extensions-themes-button", + }, + ], + [ + "appMenu", + { + addTargetListener: (aDocument, aCallback) => { + let panelPopup = aDocument.defaultView.PanelUI.panel; + panelPopup.addEventListener("popupshown", aCallback); + }, + query: "#PanelUI-button", + removeTargetListener: (aDocument, aCallback) => { + let panelPopup = aDocument.defaultView.PanelUI.panel; + panelPopup.removeEventListener("popupshown", aCallback); + }, + }, + ], + ["backForward", { query: "#back-button" }], + ["bookmarks", { query: "#bookmarks-menu-button" }], + [ + "forget", + { + allowAdd: true, + query: "#panic-button", + widgetName: "panic-button", + }, + ], + ["help", { query: "#appMenu-help-button2" }], + ["home", { query: "#home-button" }], + [ + "logins", + { + query: "#appMenu-passwords-button", + }, + ], + [ + "pocket", + { + allowAdd: true, + query: "#save-to-pocket-button", + }, + ], + [ + "privateWindow", + { + query: "#appMenu-new-private-window-button2", + }, + ], + [ + "quit", + { + query: "#appMenu-quit-button2", + }, + ], + ["readerMode-urlBar", { query: "#reader-mode-button" }], + [ + "search", + { + infoPanelOffsetX: 18, + infoPanelPosition: "after_start", + query: "#searchbar", + widgetName: "search-container", + }, + ], + [ + "searchIcon", + { + query: aDocument => { + let searchbar = aDocument.getElementById("searchbar"); + return searchbar.querySelector(".searchbar-search-button"); + }, + widgetName: "search-container", + }, + ], + [ + "selectedTabIcon", + { + query: aDocument => { + let selectedtab = aDocument.defaultView.gBrowser.selectedTab; + let element = selectedtab.iconImage; + if (!element || !UITour.isElementVisible(element)) { + return null; + } + return element; + }, + }, + ], + [ + "urlbar", + { + query: "#urlbar", + widgetName: "urlbar-container", + }, + ], + [ + "pageAction-bookmark", + { + query: aDocument => { + // The bookmark's urlbar page action button is pre-defined in the DOM. + // It would be hidden if toggled off from the urlbar. + let node = aDocument.getElementById("star-button-box"); + return node && !node.hidden ? node : null; + }, + }, + ], + ]), + + init() { + lazy.log.debug("Initializing UITour"); + // Lazy getter is initialized here so it can be replicated any time + // in a test. + delete this.url; + XPCOMUtils.defineLazyGetter(this, "url", function () { + return Services.urlFormatter.formatURLPref("browser.uitour.url"); + }); + + // Clear the availableTargetsCache on widget changes. + let listenerMethods = [ + "onWidgetAdded", + "onWidgetMoved", + "onWidgetRemoved", + "onWidgetReset", + "onAreaReset", + ]; + lazy.CustomizableUI.addListener( + listenerMethods.reduce((listener, method) => { + listener[method] = () => this.clearAvailableTargetsCache(); + return listener; + }, {}) + ); + + Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); + }, + + getNodeFromDocument(aDocument, aQuery) { + let viewCacheTemplate = aDocument.getElementById("appMenu-viewCache"); + return ( + aDocument.querySelector(aQuery) || + viewCacheTemplate.content.querySelector(aQuery) + ); + }, + + onPageEvent(aEvent, aBrowser) { + let browser = aBrowser; + let window = browser.ownerGlobal; + + // Does the window have tabs? We need to make sure since windowless browsers do + // not have tabs. + if (!window.gBrowser) { + // When using windowless browsers we don't have a valid |window|. If that's the case, + // use the most recent window as a target for UITour functions (see Bug 1111022). + window = Services.wm.getMostRecentWindow("navigator:browser"); + } + + lazy.log.debug("onPageEvent:", aEvent.detail); + + if (typeof aEvent.detail != "object") { + lazy.log.warn("Malformed event - detail not an object"); + return false; + } + + let action = aEvent.detail.action; + if (typeof action != "string" || !action) { + lazy.log.warn("Action not defined"); + return false; + } + + let data = aEvent.detail.data; + if (typeof data != "object") { + lazy.log.warn("Malformed event - data not an object"); + return false; + } + + if ( + (aEvent.pageVisibilityState == "hidden" || + aEvent.pageVisibilityState == "unloaded") && + !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action) + ) { + lazy.log.warn( + "Ignoring disallowed action from a hidden page:", + action, + aEvent.pageVisibilityState + ); + return false; + } + + switch (action) { + case "registerPageID": { + break; + } + + case "showHighlight": { + let targetPromise = this.getTarget(window, data.target); + targetPromise + .then(target => { + if (!target.node) { + lazy.log.error( + "UITour: Target could not be resolved: " + data.target + ); + return; + } + let effect = undefined; + if (this.highlightEffects.includes(data.effect)) { + effect = data.effect; + } + this.showHighlight(window, target, effect); + }) + .catch(lazy.log.error); + break; + } + + case "hideHighlight": { + this.hideHighlight(window); + break; + } + + case "showInfo": { + let targetPromise = this.getTarget(window, data.target, true); + targetPromise + .then(target => { + if (!target.node) { + lazy.log.error( + "UITour: Target could not be resolved: " + data.target + ); + return; + } + + let iconURL = null; + if (typeof data.icon == "string") { + iconURL = this.resolveURL(browser, data.icon); + } + + let buttons = []; + if (Array.isArray(data.buttons) && data.buttons.length) { + for (let buttonData of data.buttons) { + if ( + typeof buttonData == "object" && + typeof buttonData.label == "string" && + typeof buttonData.callbackID == "string" + ) { + let callback = buttonData.callbackID; + let button = { + label: buttonData.label, + callback: event => { + this.sendPageCallback(browser, callback); + }, + }; + + if (typeof buttonData.icon == "string") { + button.iconURL = this.resolveURL(browser, buttonData.icon); + } + + if (typeof buttonData.style == "string") { + button.style = buttonData.style; + } + + buttons.push(button); + + if (buttons.length == MAX_BUTTONS) { + lazy.log.warn( + "showInfo: Reached limit of allowed number of buttons" + ); + break; + } + } + } + } + + let infoOptions = {}; + if (typeof data.closeButtonCallbackID == "string") { + infoOptions.closeButtonCallback = () => { + this.sendPageCallback(browser, data.closeButtonCallbackID); + }; + } + if (typeof data.targetCallbackID == "string") { + infoOptions.targetCallback = details => { + this.sendPageCallback(browser, data.targetCallbackID, details); + }; + } + + this.showInfo( + window, + target, + data.title, + data.text, + iconURL, + buttons, + infoOptions + ); + }) + .catch(lazy.log.error); + break; + } + + case "hideInfo": { + this.hideInfo(window); + break; + } + + case "showMenu": { + this.noautohideMenus.add(data.name); + this.showMenu(window, data.name, () => { + if (typeof data.showCallbackID == "string") { + this.sendPageCallback(browser, data.showCallbackID); + } + }); + break; + } + + case "hideMenu": { + this.noautohideMenus.delete(data.name); + this.hideMenu(window, data.name); + break; + } + + case "showNewTab": { + this.showNewTab(window, browser); + break; + } + + case "getConfiguration": { + if (typeof data.configuration != "string") { + lazy.log.warn("getConfiguration: No configuration option specified"); + return false; + } + + this.getConfiguration( + browser, + window, + data.configuration, + data.callbackID + ); + break; + } + + case "setConfiguration": { + if (typeof data.configuration != "string") { + lazy.log.warn("setConfiguration: No configuration option specified"); + return false; + } + + this.setConfiguration(window, data.configuration, data.value); + break; + } + + case "openPreferences": { + if (typeof data.pane != "string" && typeof data.pane != "undefined") { + lazy.log.warn("openPreferences: Invalid pane specified"); + return false; + } + window.openPreferences(data.pane); + break; + } + + case "showFirefoxAccounts": { + Promise.resolve() + .then(() => { + return lazy.FxAccounts.canConnectAccount(); + }) + .then(canConnect => { + if (!canConnect) { + lazy.log.warn("showFirefoxAccounts: can't currently connect"); + return null; + } + return data.email + ? lazy.FxAccounts.config.promiseEmailURI( + data.email, + data.entrypoint || "uitour" + ) + : lazy.FxAccounts.config.promiseConnectAccountURI( + data.entrypoint || "uitour" + ); + }) + .then(uri => { + if (!uri) { + return; + } + const url = new URL(uri); + // Call our helper to validate extraURLParams and populate URLSearchParams + if (!this._populateURLParams(url, data.extraURLParams)) { + lazy.log.warn( + "showFirefoxAccounts: invalid campaign args specified" + ); + return; + } + // We want to replace the current tab. + browser.loadURI(url.URI, { + triggeringPrincipal: + Services.scriptSecurityManager.createNullPrincipal({}), + }); + }); + break; + } + + case "showConnectAnotherDevice": { + lazy.FxAccounts.config + .promiseConnectDeviceURI(data.entrypoint || "uitour") + .then(uri => { + const url = new URL(uri); + // Call our helper to validate extraURLParams and populate URLSearchParams + if (!this._populateURLParams(url, data.extraURLParams)) { + lazy.log.warn( + "showConnectAnotherDevice: invalid campaign args specified" + ); + return; + } + + // We want to replace the current tab. + browser.loadURI(url.URI, { + triggeringPrincipal: + Services.scriptSecurityManager.createNullPrincipal({}), + }); + }); + break; + } + + case "resetFirefox": { + // Open a reset profile dialog window. + if (lazy.ResetProfile.resetSupported()) { + lazy.ResetProfile.openConfirmationDialog(window); + } + break; + } + + case "addNavBarWidget": { + // Add a widget to the toolbar + let targetPromise = this.getTarget(window, data.name); + targetPromise + .then(target => { + this.addNavBarWidget(target, browser, data.callbackID); + }) + .catch(lazy.log.error); + break; + } + + case "setDefaultSearchEngine": { + let enginePromise = this.selectSearchEngine(data.identifier); + enginePromise.catch(console.error); + break; + } + + case "setTreatmentTag": { + let name = data.name; + let value = data.value; + Services.prefs.setStringPref("browser.uitour.treatment." + name, value); + // The notification is only meant to be used in tests. + UITourHealthReport.recordTreatmentTag(name, value).then(() => + this.notify("TreatmentTag:TelemetrySent") + ); + break; + } + + case "getTreatmentTag": { + let name = data.name; + let value; + try { + value = Services.prefs.getStringPref( + "browser.uitour.treatment." + name + ); + } catch (ex) {} + this.sendPageCallback(browser, data.callbackID, { value }); + break; + } + + case "setSearchTerm": { + let targetPromise = this.getTarget(window, "search"); + targetPromise.then(target => { + let searchbar = target.node; + searchbar.value = data.term; + searchbar.updateGoButtonVisibility(); + }); + break; + } + + case "openSearchPanel": { + let targetPromise = this.getTarget(window, "search"); + targetPromise + .then(target => { + let searchbar = target.node; + + if (searchbar.textbox.open) { + this.sendPageCallback(browser, data.callbackID); + } else { + let onPopupShown = () => { + searchbar.textbox.popup.removeEventListener( + "popupshown", + onPopupShown + ); + this.sendPageCallback(browser, data.callbackID); + }; + + searchbar.textbox.popup.addEventListener( + "popupshown", + onPopupShown + ); + searchbar.openSuggestionsPanel(); + } + }) + .catch(console.error); + break; + } + + case "ping": { + if (typeof data.callbackID == "string") { + this.sendPageCallback(browser, data.callbackID); + } + break; + } + + case "forceShowReaderIcon": { + lazy.AboutReaderParent.forceShowReaderIcon(browser); + break; + } + + case "toggleReaderMode": { + let targetPromise = this.getTarget(window, "readerMode-urlBar"); + targetPromise.then(target => { + lazy.AboutReaderParent.toggleReaderMode({ target: target.node }); + }); + break; + } + + case "closeTab": { + // Find the <tabbrowser> element of the <browser> for which the event + // was generated originally. If the browser where the UI tour is loaded + // is windowless, just ignore the request to close the tab. The request + // is also ignored if this is the only tab in the window. + let tabBrowser = browser.ownerGlobal.gBrowser; + if (tabBrowser && tabBrowser.browsers.length > 1) { + tabBrowser.removeTab(tabBrowser.getTabForBrowser(browser)); + } + break; + } + + case "showProtectionReport": { + this.showProtectionReport(window, browser); + break; + } + } + + // For performance reasons, only call initForBrowser if we did something + // that will require a teardownTourForBrowser call later. + // getConfiguration (called from about:home) doesn't require any future + // uninitialization. + if (action != "getConfiguration") { + this.initForBrowser(browser, window); + } + + return true; + }, + + initForBrowser(aBrowser, window) { + let gBrowser = window.gBrowser; + + if (gBrowser) { + gBrowser.tabContainer.addEventListener("TabSelect", this); + } + + if (!this.tourBrowsersByWindow.has(window)) { + this.tourBrowsersByWindow.set(window, new Set()); + } + this.tourBrowsersByWindow.get(window).add(aBrowser); + + Services.obs.addObserver(this, "message-manager-close"); + + window.addEventListener("SSWindowClosing", this); + }, + + handleEvent(aEvent) { + lazy.log.debug("handleEvent: type =", aEvent.type, "event =", aEvent); + switch (aEvent.type) { + case "TabSelect": { + let window = aEvent.target.ownerGlobal; + + // Teardown the browser of the tab we just switched away from. + if (aEvent.detail && aEvent.detail.previousTab) { + let previousTab = aEvent.detail.previousTab; + let openTourWindows = this.tourBrowsersByWindow.get(window); + if (openTourWindows.has(previousTab.linkedBrowser)) { + this.teardownTourForBrowser( + window, + previousTab.linkedBrowser, + false + ); + } + } + + break; + } + + case "SSWindowClosing": { + let window = aEvent.target; + this.teardownTourForWindow(window); + break; + } + } + }, + + observe(aSubject, aTopic, aData) { + lazy.log.debug("observe: aTopic =", aTopic); + switch (aTopic) { + // The browser message manager is disconnected when the <browser> is + // destroyed and we want to teardown at that point. + case "message-manager-close": { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed) { + continue; + } + + let tourBrowsers = this.tourBrowsersByWindow.get(window); + if (!tourBrowsers) { + continue; + } + + for (let browser of tourBrowsers) { + let messageManager = browser.messageManager; + if (!messageManager || aSubject == messageManager) { + this.teardownTourForBrowser(window, browser, true); + } + } + } + break; + } + case lazy.UIState.ON_UPDATE: { + let syncState = lazy.UIState.get(); + this.notify("FxA:SignedInStateChange", { status: syncState.status }); + break; + } + } + }, + + // Given a string that is a JSONified represenation of an object with + // additional "flow_id", "flow_begin_time", "device_id", utm_* URL params + // that should be appended, validate and append them to the passed URL object. + // Returns true if the params were validated and appended, and false if the + // request should be ignored. + _populateURLParams(url, extraURLParams) { + const FLOW_ID_LENGTH = 64; + const FLOW_BEGIN_TIME_LENGTH = 13; + + // We are extra paranoid about what params we allow to be appended. + if (typeof extraURLParams == "undefined") { + // no params, so it's all good. + return true; + } + if (typeof extraURLParams != "string") { + lazy.log.warn("_populateURLParams: extraURLParams is not a string"); + return false; + } + let urlParams; + try { + if (extraURLParams) { + urlParams = JSON.parse(extraURLParams); + if (typeof urlParams != "object") { + lazy.log.warn( + "_populateURLParams: extraURLParams is not a stringified object" + ); + return false; + } + } + } catch (ex) { + lazy.log.warn("_populateURLParams: extraURLParams is not a JSON object"); + return false; + } + if (urlParams) { + // Expected to JSON parse the following for FxA flow parameters: + // + // {String} flow_id - Flow Id, such as '5445b28b8b7ba6cf71e345f8fff4bc59b2a514f78f3e2cc99b696449427fd445' + // {Number} flow_begin_time - Flow begin timestamp, such as 1590780440325 + // {String} device_id - Device Id, such as '7e450f3337d3479b8582ea1c9bb5ba6c' + if ( + (urlParams.flow_begin_time && + urlParams.flow_begin_time.toString().length !== + FLOW_BEGIN_TIME_LENGTH) || + (urlParams.flow_id && urlParams.flow_id.length !== FLOW_ID_LENGTH) + ) { + lazy.log.warn( + "_populateURLParams: flow parameters are not properly structured" + ); + return false; + } + + // The regex that the name of each param must match - there's no + // character restriction on the value - they will be escaped as necessary. + let reSimpleString = /^[-_a-zA-Z0-9]*$/; + for (let name in urlParams) { + let value = urlParams[name]; + const validName = + name.startsWith("utm_") || + name === "entrypoint_experiment" || + name === "entrypoint_variation" || + name === "flow_begin_time" || + name === "flow_id" || + name === "device_id"; + if ( + typeof name != "string" || + !validName || + !reSimpleString.test(name) + ) { + lazy.log.warn("_populateURLParams: invalid campaign param specified"); + return false; + } + url.searchParams.append(name, value); + } + } + return true; + }, + /** + * Tear down a tour from a tab e.g. upon switching/closing tabs. + */ + async teardownTourForBrowser(aWindow, aBrowser, aTourPageClosing = false) { + lazy.log.debug( + "teardownTourForBrowser: aBrowser = ", + aBrowser, + aTourPageClosing + ); + + let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow); + if (aTourPageClosing && openTourBrowsers) { + openTourBrowsers.delete(aBrowser); + } + + this.hideHighlight(aWindow); + this.hideInfo(aWindow); + + await this.removePanelListeners(aWindow); + + this.noautohideMenus.clear(); + + // If there are no more tour tabs left in the window, teardown the tour for the whole window. + if (!openTourBrowsers || openTourBrowsers.size == 0) { + this.teardownTourForWindow(aWindow); + } + }, + + /** + * Remove the listeners to a panel when tearing the tour down. + */ + async removePanelListeners(aWindow) { + let panels = [ + { + name: "appMenu", + node: aWindow.PanelUI.panel, + events: [ + ["popuphidden", this.onPanelHidden], + ["popuphiding", this.onAppMenuHiding], + ["ViewShowing", this.onAppMenuSubviewShowing], + ], + }, + ]; + for (let panel of panels) { + // Ensure the menu panel is hidden and clean up panel listeners after calling hideMenu. + if (panel.node.state != "closed") { + await new Promise(resolve => { + panel.node.addEventListener("popuphidden", resolve, { once: true }); + this.hideMenu(aWindow, panel.name); + }); + } + for (let [name, listener] of panel.events) { + panel.node.removeEventListener(name, listener); + } + } + }, + + /** + * Tear down all tours for a ChromeWindow. + */ + teardownTourForWindow(aWindow) { + lazy.log.debug("teardownTourForWindow"); + aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this); + aWindow.removeEventListener("SSWindowClosing", this); + + this.tourBrowsersByWindow.delete(aWindow); + }, + + // This function is copied to UITourListener. + isSafeScheme(aURI) { + let allowedSchemes = new Set(["https", "about"]); + if (!Services.prefs.getBoolPref("browser.uitour.requireSecure")) { + allowedSchemes.add("http"); + } + + if (!allowedSchemes.has(aURI.scheme)) { + lazy.log.error("Unsafe scheme:", aURI.scheme); + return false; + } + + return true; + }, + + resolveURL(aBrowser, aURL) { + try { + let uri = Services.io.newURI(aURL, null, aBrowser.currentURI); + + if (!this.isSafeScheme(uri)) { + return null; + } + + return uri.spec; + } catch (e) {} + + return null; + }, + + sendPageCallback(aBrowser, aCallbackID, aData = {}) { + let detail = { data: aData, callbackID: aCallbackID }; + lazy.log.debug("sendPageCallback", detail); + let contextToVisit = aBrowser.browsingContext; + let global = contextToVisit.currentWindowGlobal; + let actor = global.getActor("UITour"); + actor.sendAsyncMessage("UITour:SendPageCallback", detail); + }, + + isElementVisible(aElement) { + let targetStyle = aElement.ownerGlobal.getComputedStyle(aElement); + return ( + !aElement.ownerDocument.hidden && + targetStyle.display != "none" && + targetStyle.visibility == "visible" + ); + }, + + getTarget(aWindow, aTargetName, aSticky = false) { + lazy.log.debug("getTarget:", aTargetName); + if (typeof aTargetName != "string" || !aTargetName) { + lazy.log.warn("getTarget: Invalid target name specified"); + return Promise.reject("Invalid target name specified"); + } + + let targetObject = this.targets.get(aTargetName); + if (!targetObject) { + lazy.log.warn( + "getTarget: The specified target name is not in the allowed set" + ); + return Promise.reject( + "The specified target name is not in the allowed set" + ); + } + + return new Promise(resolve => { + let targetQuery = targetObject.query; + aWindow.PanelUI.ensureReady() + .then(() => { + let node; + if (typeof targetQuery == "function") { + try { + node = targetQuery(aWindow.document); + } catch (ex) { + lazy.log.warn("getTarget: Error running target query:", ex); + node = null; + } + } else { + node = this.getNodeFromDocument(aWindow.document, targetQuery); + } + + resolve({ + addTargetListener: targetObject.addTargetListener, + infoPanelOffsetX: targetObject.infoPanelOffsetX, + infoPanelOffsetY: targetObject.infoPanelOffsetY, + infoPanelPosition: targetObject.infoPanelPosition, + node, + removeTargetListener: targetObject.removeTargetListener, + targetName: aTargetName, + widgetName: targetObject.widgetName, + allowAdd: targetObject.allowAdd, + }); + }) + .catch(lazy.log.error); + }); + }, + + targetIsInAppMenu(aTarget) { + let targetElement = aTarget.node; + // Use the widget for filtering if it exists since the target may be the icon inside. + if (aTarget.widgetName) { + let doc = aTarget.node.ownerGlobal.document; + targetElement = + doc.getElementById(aTarget.widgetName) || + lazy.PanelMultiView.getViewNode(doc, aTarget.widgetName); + } + + return targetElement.id.startsWith("appMenu-"); + }, + + /** + * Called before opening or after closing a highlight or an info tooltip to see if + * we need to open or close the menu to see the annotation's anchor. + * + * @param {ChromeWindow} aWindow the chrome window + * @param {bool} aShouldOpen true means we should open the menu, otherwise false + * @param {Object} aOptions Extra config arguments, example `autohide: true`. + */ + _setMenuStateForAnnotation(aWindow, aShouldOpen, aOptions = {}) { + lazy.log.debug( + "_setMenuStateForAnnotation: Menu is expected to be:", + aShouldOpen ? "open" : "closed" + ); + let menu = aWindow.PanelUI.panel; + + // If the panel is in the desired state, we're done. + let panelIsOpen = menu.state != "closed"; + if (aShouldOpen == panelIsOpen) { + lazy.log.debug( + "_setMenuStateForAnnotation: Menu already in expected state" + ); + return Promise.resolve(); + } + + // Actually show or hide the menu + let promise = null; + if (aShouldOpen) { + lazy.log.debug("_setMenuStateForAnnotation: Opening the menu"); + promise = new Promise(resolve => { + this.showMenu(aWindow, "appMenu", resolve, aOptions); + }); + } else if (!this.noautohideMenus.has("appMenu")) { + // If the menu was opened explictly by api user through `Mozilla.UITour.showMenu`, + // it should be closed explictly by api user through `Mozilla.UITour.hideMenu`. + // So we shouldn't get to here to close it for the highlight/info annotation. + lazy.log.debug("_setMenuStateForAnnotation: Closing the menu"); + promise = new Promise(resolve => { + menu.addEventListener("popuphidden", resolve, { once: true }); + this.hideMenu(aWindow, "appMenu"); + }); + } + return promise; + }, + + /** + * Ensure the target's visibility and the open/close states of menus for the target. + * + * @param {ChromeWindow} aChromeWindow The chrome window + * @param {Object} aTarget The target on which we show highlight or show info. + * @param {Object} options Extra config arguments, example `autohide: true`. + */ + async _ensureTarget(aChromeWindow, aTarget, aOptions = {}) { + let shouldOpenAppMenu = false; + if (this.targetIsInAppMenu(aTarget)) { + shouldOpenAppMenu = true; + } + + // Prevent showing a panel at an undefined position, but when it's tucked + // away inside a panel, we skip this check. + if ( + !aTarget.node.closest("panelview") && + !this.isElementVisible(aTarget.node) + ) { + return Promise.reject( + `_ensureTarget: Reject the ${ + aTarget.name || aTarget.targetName + } target since it isn't visible.` + ); + } + + let menuClosePromises = []; + if (!shouldOpenAppMenu) { + menuClosePromises.push( + this._setMenuStateForAnnotation(aChromeWindow, false) + ); + } + + let promise = Promise.all(menuClosePromises); + await promise; + if (shouldOpenAppMenu) { + promise = this._setMenuStateForAnnotation(aChromeWindow, true, aOptions); + } + return promise; + }, + + /** + * The node to which a highlight or notification(-popup) is anchored is sometimes + * obscured because it may be inside an overflow menu. This function should figure + * that out and offer the overflow chevron as an alternative. + * + * @param {ChromeWindow} aChromeWindow The chrome window + * @param {Object} aTarget The target object whose node is supposed to be the anchor + * @type {Node} + */ + async _correctAnchor(aChromeWindow, aTarget) { + // PanelMultiView's like the AppMenu might shuffle the DOM, which might result + // in our anchor being invalidated if it was anonymous content (since the XBL + // binding it belonged to got destroyed). We work around this by re-querying for + // the node and stuffing it into the old anchor structure. + let refreshedTarget = await this.getTarget( + aChromeWindow, + aTarget.targetName + ); + let node = (aTarget.node = refreshedTarget.node); + // If the target is in the overflow panel, just return the overflow button. + if (node.closest("#widget-overflow-mainView")) { + return lazy.CustomizableUI.getWidget(node.id).forWindow(aChromeWindow) + .anchor; + } + return node; + }, + + /** + * @param aChromeWindow The chrome window that the highlight is in. Necessary since some targets + * are in a sub-frame so the defaultView is not the same as the chrome + * window. + * @param aTarget The element to highlight. + * @param aEffect (optional) The effect to use from UITour.highlightEffects or "none". + * @param aOptions (optional) Extra config arguments, example `autohide: true`. + * @see UITour.highlightEffects + */ + async showHighlight(aChromeWindow, aTarget, aEffect = "none", aOptions = {}) { + let showHighlightElement = aAnchorEl => { + let highlighter = this.getHighlightAndMaybeCreate(aChromeWindow.document); + + let effect = aEffect; + if (effect == "random") { + // Exclude "random" from the randomly selected effects. + let randomEffect = + 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1)); + if (randomEffect == this.highlightEffects.length) { + randomEffect--; + } // On the order of 1 in 2^62 chance of this happening. + effect = this.highlightEffects[randomEffect]; + } + // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays. + highlighter.setAttribute("active", "none"); + aChromeWindow.getComputedStyle(highlighter).animationName; + highlighter.setAttribute("active", effect); + highlighter.parentElement.setAttribute("targetName", aTarget.targetName); + highlighter.parentElement.hidden = false; + + let highlightAnchor = aAnchorEl; + let targetRect = highlightAnchor.getBoundingClientRect(); + let highlightHeight = targetRect.height; + let highlightWidth = targetRect.width; + + if (this.targetIsInAppMenu(aTarget)) { + highlighter.classList.remove("rounded-highlight"); + } else { + highlighter.classList.add("rounded-highlight"); + } + if ( + highlightAnchor.classList.contains("toolbarbutton-1") && + highlightAnchor.getAttribute("cui-areatype") === "toolbar" && + highlightAnchor.getAttribute("overflowedItem") !== "true" + ) { + // A toolbar button in navbar has its clickable area an + // inner-contained square while the button component itself is a tall + // rectangle. We adjust the highlight area to a square as well. + highlightHeight = highlightWidth; + } + + highlighter.style.height = highlightHeight + "px"; + highlighter.style.width = highlightWidth + "px"; + + // Close a previous highlight so we can relocate the panel. + if ( + highlighter.parentElement.state == "showing" || + highlighter.parentElement.state == "open" + ) { + lazy.log.debug("showHighlight: Closing previous highlight first"); + highlighter.parentElement.hidePopup(); + } + /* The "overlap" position anchors from the top-left but we want to centre highlights at their + minimum size. */ + let highlightWindow = aChromeWindow; + let highlightStyle = highlightWindow.getComputedStyle(highlighter); + let highlightHeightWithMin = Math.max( + highlightHeight, + parseFloat(highlightStyle.minHeight) + ); + let highlightWidthWithMin = Math.max( + highlightWidth, + parseFloat(highlightStyle.minWidth) + ); + let offsetX = (targetRect.width - highlightWidthWithMin) / 2; + let offsetY = (targetRect.height - highlightHeightWithMin) / 2; + this._addAnnotationPanelMutationObserver(highlighter.parentElement); + highlighter.parentElement.openPopup( + highlightAnchor, + "overlap", + offsetX, + offsetY + ); + }; + + try { + await this._ensureTarget(aChromeWindow, aTarget, aOptions); + let anchorEl = await this._correctAnchor(aChromeWindow, aTarget); + showHighlightElement(anchorEl); + } catch (e) { + lazy.log.warn(e); + } + }, + + _hideHighlightElement(aWindow) { + let highlighter = this.getHighlightAndMaybeCreate(aWindow.document); + this._removeAnnotationPanelMutationObserver(highlighter.parentElement); + highlighter.parentElement.hidePopup(); + highlighter.removeAttribute("active"); + }, + + hideHighlight(aWindow) { + this._hideHighlightElement(aWindow); + this._setMenuStateForAnnotation(aWindow, false); + }, + + /** + * Show an info panel. + * + * @param {ChromeWindow} aChromeWindow + * @param {Node} aAnchor + * @param {String} [aTitle=""] + * @param {String} [aDescription=""] + * @param {String} [aIconURL=""] + * @param {Object[]} [aButtons=[]] + * @param {Object} [aOptions={}] + * @param {String} [aOptions.closeButtonCallback] + * @param {String} [aOptions.targetCallback] + */ + async showInfo( + aChromeWindow, + aAnchor, + aTitle = "", + aDescription = "", + aIconURL = "", + aButtons = [], + aOptions = {} + ) { + let showInfoElement = aAnchorEl => { + aAnchorEl.focus(); + + let document = aChromeWindow.document; + let tooltip = this.getTooltipAndMaybeCreate(document); + let tooltipTitle = document.getElementById("UITourTooltipTitle"); + let tooltipDesc = document.getElementById("UITourTooltipDescription"); + let tooltipIcon = document.getElementById("UITourTooltipIcon"); + let tooltipButtons = document.getElementById("UITourTooltipButtons"); + + if (tooltip.state == "showing" || tooltip.state == "open") { + tooltip.hidePopup(); + } + + tooltipTitle.textContent = aTitle || ""; + tooltipDesc.textContent = aDescription || ""; + tooltipIcon.src = aIconURL || ""; + tooltipIcon.hidden = !aIconURL; + + while (tooltipButtons.firstChild) { + tooltipButtons.firstChild.remove(); + } + + for (let button of aButtons) { + let isButton = button.style != "text"; + let el = document.createXULElement(isButton ? "button" : "label"); + el.setAttribute(isButton ? "label" : "value", button.label); + + if (isButton) { + if (button.iconURL) { + el.setAttribute("image", button.iconURL); + } + + if (button.style == "link") { + el.setAttribute("class", "button-link"); + } + + if (button.style == "primary") { + el.setAttribute("class", "button-primary"); + } + + // Don't close the popup or call the callback for style=text as they + // aren't links/buttons. + let callback = button.callback; + el.addEventListener("command", event => { + tooltip.hidePopup(); + callback(event); + }); + } + + tooltipButtons.appendChild(el); + } + + tooltipButtons.hidden = !aButtons.length; + + let tooltipClose = document.getElementById("UITourTooltipClose"); + let closeButtonCallback = event => { + this.hideInfo(document.defaultView); + if (aOptions && aOptions.closeButtonCallback) { + aOptions.closeButtonCallback(); + } + }; + tooltipClose.addEventListener("command", closeButtonCallback); + + let targetCallback = event => { + let details = { + target: aAnchor.targetName, + type: event.type, + }; + aOptions.targetCallback(details); + }; + if (aOptions.targetCallback && aAnchor.addTargetListener) { + aAnchor.addTargetListener(document, targetCallback); + } + + tooltip.addEventListener( + "popuphiding", + function (event) { + tooltipClose.removeEventListener("command", closeButtonCallback); + if (aOptions.targetCallback && aAnchor.removeTargetListener) { + aAnchor.removeTargetListener(document, targetCallback); + } + }, + { once: true } + ); + + tooltip.setAttribute("targetName", aAnchor.targetName); + + let alignment = "bottomright topright"; + if (aAnchor.infoPanelPosition) { + alignment = aAnchor.infoPanelPosition; + } + + let { infoPanelOffsetX: xOffset, infoPanelOffsetY: yOffset } = aAnchor; + + this._addAnnotationPanelMutationObserver(tooltip); + tooltip.openPopup(aAnchorEl, alignment, xOffset || 0, yOffset || 0); + if (tooltip.state == "closed") { + document.defaultView.addEventListener( + "endmodalstate", + function () { + tooltip.openPopup(aAnchorEl, alignment); + }, + { once: true } + ); + } + }; + + try { + await this._ensureTarget(aChromeWindow, aAnchor); + let anchorEl = await this._correctAnchor(aChromeWindow, aAnchor); + showInfoElement(anchorEl); + } catch (e) { + lazy.log.warn(e); + } + }, + + getHighlightContainerAndMaybeCreate(document) { + let highlightContainer = document.getElementById( + "UITourHighlightContainer" + ); + if (!highlightContainer) { + let wrapper = document.getElementById("UITourHighlightTemplate"); + wrapper.replaceWith(wrapper.content); + highlightContainer = document.getElementById("UITourHighlightContainer"); + } + + return highlightContainer; + }, + + getTooltipAndMaybeCreate(document) { + let tooltip = document.getElementById("UITourTooltip"); + if (!tooltip) { + let wrapper = document.getElementById("UITourTooltipTemplate"); + wrapper.replaceWith(wrapper.content); + tooltip = document.getElementById("UITourTooltip"); + } + return tooltip; + }, + + getHighlightAndMaybeCreate(document) { + let highlight = document.getElementById("UITourHighlight"); + if (!highlight) { + let wrapper = document.getElementById("UITourHighlightTemplate"); + wrapper.replaceWith(wrapper.content); + highlight = document.getElementById("UITourHighlight"); + } + return highlight; + }, + + isInfoOnTarget(aChromeWindow, aTargetName) { + let document = aChromeWindow.document; + let tooltip = this.getTooltipAndMaybeCreate(document); + return ( + tooltip.getAttribute("targetName") == aTargetName && + tooltip.state != "closed" + ); + }, + + _hideInfoElement(aWindow) { + let document = aWindow.document; + let tooltip = this.getTooltipAndMaybeCreate(document); + this._removeAnnotationPanelMutationObserver(tooltip); + tooltip.hidePopup(); + let tooltipButtons = document.getElementById("UITourTooltipButtons"); + while (tooltipButtons.firstChild) { + tooltipButtons.firstChild.remove(); + } + }, + + hideInfo(aWindow) { + this._hideInfoElement(aWindow); + this._setMenuStateForAnnotation(aWindow, false); + }, + + showMenu(aWindow, aMenuName, aOpenCallback = null, aOptions = {}) { + lazy.log.debug("showMenu:", aMenuName); + function openMenuButton(aMenuBtn) { + if (!aMenuBtn || !aMenuBtn.hasMenu() || aMenuBtn.open) { + if (aOpenCallback) { + aOpenCallback(); + } + return; + } + if (aOpenCallback) { + aMenuBtn.addEventListener("popupshown", aOpenCallback, { once: true }); + } + aMenuBtn.openMenu(true); + } + + if (aMenuName == "appMenu") { + let menu = { + onPanelHidden: this.onPanelHidden, + }; + menu.node = aWindow.PanelUI.panel; + menu.onPopupHiding = this.onAppMenuHiding; + menu.onViewShowing = this.onAppMenuSubviewShowing; + menu.show = () => aWindow.PanelUI.show(); + + if (!aOptions.autohide) { + menu.node.setAttribute("noautohide", "true"); + } + // If the popup is already opened, don't recreate the widget as it may cause a flicker. + if (menu.node.state != "open") { + this.recreatePopup(menu.node); + } + if (aOpenCallback) { + menu.node.addEventListener("popupshown", aOpenCallback, { once: true }); + } + menu.node.addEventListener("popuphidden", menu.onPanelHidden); + menu.node.addEventListener("popuphiding", menu.onPopupHiding); + menu.node.addEventListener("ViewShowing", menu.onViewShowing); + menu.show(); + } else if (aMenuName == "bookmarks") { + let menuBtn = aWindow.document.getElementById("bookmarks-menu-button"); + openMenuButton(menuBtn); + } else if (aMenuName == "pocket") { + let button = aWindow.document.getElementById("save-to-pocket-button"); + if (!button) { + lazy.log.error("Can't open the pocket menu without a button"); + return; + } + aWindow.document.addEventListener("ViewShown", aOpenCallback, { + once: true, + }); + button.click(); + } else if (aMenuName == "urlbar") { + let urlbar = aWindow.gURLBar; + if (aOpenCallback) { + urlbar.panel.addEventListener("popupshown", aOpenCallback, { + once: true, + }); + } + urlbar.focus(); + // To demonstrate the ability of searching, we type "Firefox" in advance + // for URLBar's dropdown. To limit the search results on browser-related + // items, we use "Firefox" hard-coded rather than l10n brandShortName + // entity to avoid unrelated or unpredicted results for, like, Nightly + // or translated entites. + const SEARCH_STRING = "Firefox"; + urlbar.value = SEARCH_STRING; + urlbar.select(); + urlbar.startQuery({ + searchString: SEARCH_STRING, + allowAutofill: false, + }); + } + }, + + hideMenu(aWindow, aMenuName) { + lazy.log.debug("hideMenu:", aMenuName); + function closeMenuButton(aMenuBtn) { + if (aMenuBtn && aMenuBtn.hasMenu()) { + aMenuBtn.openMenu(false); + } + } + + if (aMenuName == "appMenu") { + aWindow.PanelUI.hide(); + } else if (aMenuName == "bookmarks") { + let menuBtn = aWindow.document.getElementById("bookmarks-menu-button"); + closeMenuButton(menuBtn); + } else if (aMenuName == "urlbar") { + aWindow.gURLBar.view.close(); + } + }, + + showNewTab(aWindow, aBrowser) { + aWindow.gURLBar.focus(); + let url = "about:newtab"; + aWindow.openLinkIn(url, "current", { + targetBrowser: aBrowser, + triggeringPrincipal: + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(url), + {} + ), + }); + }, + + showProtectionReport(aWindow, aBrowser) { + let url = "about:protections"; + aWindow.openLinkIn(url, "current", { + targetBrowser: aBrowser, + triggeringPrincipal: + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(url), + {} + ), + }); + }, + + _hideAnnotationsForPanel(aEvent, aShouldClosePanel, aTargetPositionCallback) { + let win = aEvent.target.ownerGlobal; + let hideHighlightMethod = null; + let hideInfoMethod = null; + if (aShouldClosePanel) { + hideHighlightMethod = aWin => this.hideHighlight(aWin); + hideInfoMethod = aWin => this.hideInfo(aWin); + } else { + // Don't have to close panel, let's only hide annotation elements + hideHighlightMethod = aWin => this._hideHighlightElement(aWin); + hideInfoMethod = aWin => this._hideInfoElement(aWin); + } + let annotationElements = new Map([ + // [annotationElement (panel), method to hide the annotation] + [ + this.getHighlightContainerAndMaybeCreate(win.document), + hideHighlightMethod, + ], + [this.getTooltipAndMaybeCreate(win.document), hideInfoMethod], + ]); + annotationElements.forEach((hideMethod, annotationElement) => { + if (annotationElement.state != "closed") { + let targetName = annotationElement.getAttribute("targetName"); + UITour.getTarget(win, targetName) + .then(aTarget => { + // Since getTarget is async, we need to make sure that the target hasn't + // changed since it may have just moved to somewhere outside of the app menu. + if ( + annotationElement.getAttribute("targetName") != + aTarget.targetName || + annotationElement.state == "closed" || + !aTargetPositionCallback(aTarget) + ) { + return; + } + hideMethod(win); + }) + .catch(lazy.log.error); + } + }); + }, + + onAppMenuHiding(aEvent) { + UITour._hideAnnotationsForPanel(aEvent, true, UITour.targetIsInAppMenu); + }, + + onAppMenuSubviewShowing(aEvent) { + UITour._hideAnnotationsForPanel(aEvent, false, UITour.targetIsInAppMenu); + }, + + onPanelHidden(aEvent) { + aEvent.target.removeAttribute("noautohide"); + UITour.recreatePopup(aEvent.target); + UITour.clearAvailableTargetsCache(); + }, + + recreatePopup(aPanel) { + // After changing popup attributes that relate to how the native widget is created + // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect. + if (aPanel.hidden) { + // If the panel is already hidden, we don't need to recreate it but flush + // in case someone just hid it. + aPanel.clientWidth; // flush + return; + } + aPanel.hidden = true; + aPanel.clientWidth; // flush + aPanel.hidden = false; + }, + + getConfiguration(aBrowser, aWindow, aConfiguration, aCallbackID) { + switch (aConfiguration) { + case "appinfo": + this.getAppInfo(aBrowser, aWindow, aCallbackID); + break; + case "availableTargets": + this.getAvailableTargets(aBrowser, aWindow, aCallbackID); + break; + case "colorway": + this.sendPageCallback(aBrowser, aCallbackID, lazy.COLORWAY_IDS); + break; + case "search": + case "selectedSearchEngine": + Services.search + .getVisibleEngines() + .then(engines => { + this.sendPageCallback(aBrowser, aCallbackID, { + searchEngineIdentifier: Services.search.defaultEngine.identifier, + engines: engines + .filter(engine => engine.identifier) + .map(engine => TARGET_SEARCHENGINE_PREFIX + engine.identifier), + }); + }) + .catch(() => { + this.sendPageCallback(aBrowser, aCallbackID, { + engines: [], + searchEngineIdentifier: "", + }); + }); + break; + case "fxa": + this.getFxA(aBrowser, aCallbackID); + break; + case "fxaConnections": + this.getFxAConnections(aBrowser, aCallbackID); + break; + + // NOTE: 'sync' is deprecated and should be removed in Firefox 73 (because + // by then, all consumers will have upgraded to use 'fxa' in that version + // and later.) + case "sync": + this.sendPageCallback(aBrowser, aCallbackID, { + setup: Services.prefs.prefHasUserValue("services.sync.username"), + desktopDevices: Services.prefs.getIntPref( + "services.sync.clients.devices.desktop", + 0 + ), + mobileDevices: Services.prefs.getIntPref( + "services.sync.clients.devices.mobile", + 0 + ), + totalDevices: Services.prefs.getIntPref( + "services.sync.numClients", + 0 + ), + }); + break; + case "canReset": + this.sendPageCallback( + aBrowser, + aCallbackID, + lazy.ResetProfile.resetSupported() + ); + break; + default: + lazy.log.error( + "getConfiguration: Unknown configuration requested: " + aConfiguration + ); + break; + } + }, + + async setConfiguration(aWindow, aConfiguration, aValue) { + switch (aConfiguration) { + case "defaultBrowser": + // Ignore aValue in this case because the default browser can only + // be set, not unset. + try { + let shell = aWindow.getShellService(); + if (shell) { + shell.setDefaultBrowser(true, false); + } + } catch (e) {} + break; + case "colorway": + // Potentially revert to a previous theme. + let toEnable = this._prevTheme; + + // Activate the allowed colorway. + if (lazy.COLORWAY_IDS.includes(aValue)) { + // Save the previous theme if this is the first activation. + if (!this._prevTheme) { + this._prevTheme = ( + await lazy.AddonManager.getAddonsByTypes(["theme"]) + ).find(theme => theme.isActive); + } + toEnable = await lazy.AddonManager.getAddonByID(aValue); + } + toEnable?.enable(); + break; + default: + lazy.log.error( + "setConfiguration: Unknown configuration requested: " + aConfiguration + ); + break; + } + }, + + // Get data about the local FxA account. This should be a low-latency request + // - everything returned here can be obtained locally without hitting any + // remote servers. See also `getFxAConnections()` + getFxA(aBrowser, aCallbackID) { + (async () => { + let setup = !!(await lazy.fxAccounts.getSignedInUser()); + let result = { setup }; + if (!setup) { + this.sendPageCallback(aBrowser, aCallbackID, result); + return; + } + // We are signed in so need to build a richer result. + // Each of the "browser services" - currently only "sync" is supported + result.browserServices = {}; + let hasSync = Services.prefs.prefHasUserValue("services.sync.username"); + if (hasSync) { + result.browserServices.sync = { + // We always include 'setup' for b/w compatibility. + setup: true, + desktopDevices: Services.prefs.getIntPref( + "services.sync.clients.devices.desktop", + 0 + ), + mobileDevices: Services.prefs.getIntPref( + "services.sync.clients.devices.mobile", + 0 + ), + totalDevices: Services.prefs.getIntPref( + "services.sync.numClients", + 0 + ), + }; + } + // And the account state. + result.accountStateOK = await lazy.fxAccounts.hasLocalSession(); + this.sendPageCallback(aBrowser, aCallbackID, result); + })().catch(err => { + lazy.log.error(err); + this.sendPageCallback(aBrowser, aCallbackID, {}); + }); + }, + + // Get data about the FxA account "connections" (ie, other devices, other + // apps, etc. Note that this is likely to be a high-latency request - we will + // usually hit the FxA servers to obtain this info. + getFxAConnections(aBrowser, aCallbackID) { + (async () => { + let setup = !!(await lazy.fxAccounts.getSignedInUser()); + let result = { setup }; + if (!setup) { + this.sendPageCallback(aBrowser, aCallbackID, result); + return; + } + // We are signed in so need to build a richer result. + let devices = lazy.fxAccounts.device.recentDeviceList; + // A recent device list is fine, but if we don't even have that we should + // wait for it to be fetched. + if (!devices) { + try { + await lazy.fxAccounts.device.refreshDeviceList(); + } catch (ex) { + lazy.log.warn("failed to fetch device list", ex); + } + devices = lazy.fxAccounts.device.recentDeviceList; + } + if (devices) { + // A falsey `devices` should be impossible, so we omit `devices` from + // the result object so the consuming page can try to differentiate + // between "no additional devices" and "something's wrong" + result.numOtherDevices = Math.max(0, devices.length - 1); + result.numDevicesByType = devices + .filter(d => !d.isCurrentDevice) + .reduce((accum, d) => { + let type = d.type || "unknown"; + accum[type] = (accum[type] || 0) + 1; + return accum; + }, {}); + } + + try { + // Each of the "account services", which we turn into a map keyed by ID. + let attachedClients = await lazy.fxAccounts.listAttachedOAuthClients(); + result.accountServices = attachedClients + .filter(c => !!c.id) + .reduce((accum, c) => { + accum[c.id] = { + id: c.id, + lastAccessedWeeksAgo: c.lastAccessedDaysAgo + ? Math.floor(c.lastAccessedDaysAgo / 7) + : null, + }; + return accum; + }, {}); + } catch (ex) { + lazy.log.warn("Failed to build the attached clients list", ex); + } + this.sendPageCallback(aBrowser, aCallbackID, result); + })().catch(err => { + lazy.log.error(err); + this.sendPageCallback(aBrowser, aCallbackID, {}); + }); + }, + + getAppInfo(aBrowser, aWindow, aCallbackID) { + (async () => { + let appinfo = { version: Services.appinfo.version }; + + // Identifier of the partner repack, as stored in preference "distribution.id" + // and included in Firefox and other update pings. Note this is not the same as + // Services.appinfo.distributionID (value of MOZ_DISTRIBUTION_ID is set at build time). + let distribution = Services.prefs + .getDefaultBranch("distribution.") + .getCharPref("id", "default"); + appinfo.distribution = distribution; + + // Update channel, in a way that preserves 'beta' for RC beta builds: + appinfo.defaultUpdateChannel = lazy.UpdateUtils.getUpdateChannel( + false /* no partner ID */ + ); + + let isDefaultBrowser = null; + try { + let shell = aWindow.getShellService(); + if (shell) { + isDefaultBrowser = shell.isDefaultBrowser(false); + } + } catch (e) {} + appinfo.defaultBrowser = isDefaultBrowser; + + let canSetDefaultBrowserInBackground = true; + if ( + AppConstants.isPlatformAndVersionAtLeast("win", "6.2") || + AppConstants.isPlatformAndVersionAtLeast("macosx", "10.10") + ) { + canSetDefaultBrowserInBackground = false; + } else if (AppConstants.platform == "linux") { + // The ShellService may not exist on some versions of Linux. + try { + aWindow.getShellService(); + } catch (e) { + canSetDefaultBrowserInBackground = null; + } + } + + appinfo.canSetDefaultBrowserInBackground = + canSetDefaultBrowserInBackground; + + // Expose Profile creation and last reset dates in weeks. + const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; + let profileAge = await lazy.ProfileAge(); + let createdDate = await profileAge.created; + let resetDate = await profileAge.reset; + let createdWeeksAgo = Math.floor((Date.now() - createdDate) / ONE_WEEK); + let resetWeeksAgo = null; + if (resetDate) { + resetWeeksAgo = Math.floor((Date.now() - resetDate) / ONE_WEEK); + } + appinfo.profileCreatedWeeksAgo = createdWeeksAgo; + appinfo.profileResetWeeksAgo = resetWeeksAgo; + + this.sendPageCallback(aBrowser, aCallbackID, appinfo); + })().catch(err => { + lazy.log.error(err); + this.sendPageCallback(aBrowser, aCallbackID, {}); + }); + }, + + getAvailableTargets(aBrowser, aChromeWindow, aCallbackID) { + (async () => { + let window = aChromeWindow; + let data = this.availableTargetsCache.get(window); + if (data) { + lazy.log.debug( + "getAvailableTargets: Using cached targets list", + data.targets.join(",") + ); + this.sendPageCallback(aBrowser, aCallbackID, data); + return; + } + + let promises = []; + for (let targetName of this.targets.keys()) { + promises.push(this.getTarget(window, targetName)); + } + let targetObjects = await Promise.all(promises); + + let targetNames = []; + for (let targetObject of targetObjects) { + if (targetObject.node) { + targetNames.push(targetObject.targetName); + } + } + + data = { + targets: targetNames, + }; + this.availableTargetsCache.set(window, data); + this.sendPageCallback(aBrowser, aCallbackID, data); + })().catch(err => { + lazy.log.error(err); + this.sendPageCallback(aBrowser, aCallbackID, { + targets: [], + }); + }); + }, + + addNavBarWidget(aTarget, aBrowser, aCallbackID) { + if (aTarget.node) { + lazy.log.error( + "addNavBarWidget: can't add a widget already present:", + aTarget + ); + return; + } + if (!aTarget.allowAdd) { + lazy.log.error( + "addNavBarWidget: not allowed to add this widget:", + aTarget + ); + return; + } + if (!aTarget.widgetName) { + lazy.log.error( + "addNavBarWidget: can't add a widget without a widgetName property:", + aTarget + ); + return; + } + + lazy.CustomizableUI.addWidgetToArea( + aTarget.widgetName, + lazy.CustomizableUI.AREA_NAVBAR + ); + lazy.BrowserUsageTelemetry.recordWidgetChange( + aTarget.widgetName, + lazy.CustomizableUI.AREA_NAVBAR, + "uitour" + ); + this.sendPageCallback(aBrowser, aCallbackID); + }, + + _addAnnotationPanelMutationObserver(aPanelEl) { + if (AppConstants.platform == "linux") { + let observer = this._annotationPanelMutationObservers.get(aPanelEl); + if (observer) { + return; + } + let win = aPanelEl.ownerGlobal; + observer = new win.MutationObserver(this._annotationMutationCallback); + this._annotationPanelMutationObservers.set(aPanelEl, observer); + let observerOptions = { + attributeFilter: ["height", "width"], + attributes: true, + }; + observer.observe(aPanelEl, observerOptions); + } + }, + + _removeAnnotationPanelMutationObserver(aPanelEl) { + if (AppConstants.platform == "linux") { + let observer = this._annotationPanelMutationObservers.get(aPanelEl); + if (observer) { + observer.disconnect(); + this._annotationPanelMutationObservers.delete(aPanelEl); + } + } + }, + + /** + * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to + * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting + * set on the panel. + */ + _annotationMutationCallback(aMutations) { + for (let mutation of aMutations) { + // Remove both attributes at once and ignore remaining mutations to be proccessed. + mutation.target.removeAttribute("width"); + mutation.target.removeAttribute("height"); + return; + } + }, + + selectSearchEngine(aID) { + return new Promise((resolve, reject) => { + Services.search.getVisibleEngines().then(engines => { + for (let engine of engines) { + if (engine.identifier == aID) { + Services.search + .setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UITOUR) + .finally(resolve); + return; + } + } + reject("selectSearchEngine could not find engine with given ID"); + }); + }); + }, + + notify(eventName, params) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed) { + continue; + } + + let openTourBrowsers = this.tourBrowsersByWindow.get(window); + if (!openTourBrowsers) { + continue; + } + + for (let browser of openTourBrowsers) { + let detail = { + event: eventName, + params, + }; + let contextToVisit = browser.browsingContext; + let global = contextToVisit.currentWindowGlobal; + let actor = global.getActor("UITour"); + actor.sendAsyncMessage("UITour:SendPageNotification", detail); + } + } + }, +}; + +UITour.init(); + +/** + * UITour Health Report + */ +/** + * Public API to be called by the UITour code + */ +const UITourHealthReport = { + recordTreatmentTag(tag, value) { + return lazy.TelemetryController.submitExternalPing( + "uitour-tag", + { + version: 1, + tagName: tag, + tagValue: value, + }, + { + addClientId: true, + addEnvironment: true, + } + ); + }, +}; |