summaryrefslogtreecommitdiffstats
path: root/browser/components/uitour/UITour.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/uitour/UITour.sys.mjs')
-rw-r--r--browser/components/uitour/UITour.sys.mjs2043
1 files changed, 2043 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..fef68a5a95
--- /dev/null
+++ b/browser/components/uitour/UITour.sys.mjs
@@ -0,0 +1,2043 @@
+// 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 { 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",
+ BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.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",
+});
+
+ChromeUtils.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.
+ChromeUtils.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.
+ChromeUtils.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;
+ ChromeUtils.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} aOptions 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 {ChromeWindow} 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 {DOMElement} aTarget
+ * The element to highlight.
+ * @param {string} [aEffect]
+ * The effect to use from UITour.highlightEffects or "none".
+ * @param {object} [aOptions]
+ * 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) {
+ await shell.setDefaultBrowser(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.platform == "win" ||
+ 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,
+ }
+ );
+ },
+};