browser/modules/UITour.jsm
author Justin Dolske <dolske@mozilla.com>
Tue, 27 Jan 2015 14:16:37 -0800
changeset 243687 b005871a2349
parent 243686 fe2ce463edba
child 243688 756c22d18cdf
permissions -rw-r--r--
Bug 1118831 - Loop: Click to add Hello icon to toolbar. r=mattn, a=sledru

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

"use strict";

this.EXPORTED_SYMBOLS = ["UITour", "UITourMetricsProvider"];

const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Task.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
  "resource://gre/modules/LightweightThemeManager.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ResetProfile",
  "resource://gre/modules/ResetProfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
  "resource:///modules/CustomizableUI.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
  "resource://gre/modules/UITelemetry.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
  "resource:///modules/BrowserUITelemetry.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
  "resource://gre/modules/Metrics.jsm");

// See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
const PREF_LOG_LEVEL      = "browser.uitour.loglevel";
const PREF_SEENPAGEIDS    = "browser.uitour.seenPageIDs";

const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([
  "getConfiguration",
  "getTreatmentTag",
  "ping",
  "registerPageID",
  "setConfiguration",
  "setTreatmentTag",
]);
const MAX_BUTTONS         = 4;

const BUCKET_NAME         = "UITour";
const BUCKET_TIMESTEPS    = [
  1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
  3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
  10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
  60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
];

// Time after which seen Page IDs expire.
const SEENPAGEID_EXPIRY  = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks.

// 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(this, "log", () => {
  let ConsoleAPI = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).ConsoleAPI;
  let consoleOptions = {
    // toLowerCase is because the loglevel values use title case to be compatible with Log.jsm.
    maxLogLevel: Services.prefs.getCharPref(PREF_LOG_LEVEL).toLowerCase(),
    prefix: "UITour",
  };
  return new ConsoleAPI(consoleOptions);
});

this.UITour = {
  url: null,
  seenPageIDs: null,
  pageIDSourceBrowsers: new WeakMap(),
  /* Map from browser chrome windows to a Set of <browser>s in which a tour is open (both visible and hidden) */
  tourBrowsersByWindow: new WeakMap(),
  urlbarCapture: new WeakMap(),
  appMenuOpenForAnnotation: new Set(),
  availableTargetsCache: new WeakMap(),

  _annotationPanelMutationObservers: new WeakMap(),

  highlightEffects: ["random", "wobble", "zoom", "color"],
  targets: new Map([
    ["accountStatus", {
      query: (aDocument) => {
        let statusButton = aDocument.getElementById("PanelUI-fxa-status");
        return aDocument.getAnonymousElementByAttribute(statusButton,
                                                        "class",
                                                        "toolbarbutton-icon");
      },
      widgetName: "PanelUI-fxa-status",
    }],
    ["addons",      {query: "#add-ons-button"}],
    ["appMenu",     {
      addTargetListener: (aDocument, aCallback) => {
        let panelPopup = aDocument.getElementById("PanelUI-popup");
        panelPopup.addEventListener("popupshown", aCallback);
      },
      query: "#PanelUI-button",
      removeTargetListener: (aDocument, aCallback) => {
        let panelPopup = aDocument.getElementById("PanelUI-popup");
        panelPopup.removeEventListener("popupshown", aCallback);
      },
    }],
    ["backForward", {
      query: "#back-button",
      widgetName: "urlbar-container",
    }],
    ["bookmarks",   {query: "#bookmarks-menu-button"}],
    ["customize",   {
      query: (aDocument) => {
        let customizeButton = aDocument.getElementById("PanelUI-customize");
        return aDocument.getAnonymousElementByAttribute(customizeButton,
                                                        "class",
                                                        "toolbarbutton-icon");
      },
      widgetName: "PanelUI-customize",
    }],
    ["devtools",    {query: "#developer-button"}],
    ["help",        {query: "#PanelUI-help"}],
    ["home",        {query: "#home-button"}],
    ["forget", {
      allowAdd: true,
      query: "#panic-button",
      widgetName: "panic-button",
    }],
    ["loop",        {
      allowAdd: true,
      query: "#loop-button",
      widgetName: "loop-button",
    }],
    ["loop-newRoom", {
      infoPanelPosition: "leftcenter topright",
      query: (aDocument) => {
        let loopBrowser = aDocument.querySelector("#loop-notification-panel > #loop");
        if (!loopBrowser) {
          return null;
        }
        // Use the parentElement full-width container of the button so our arrow
        // doesn't overlap the panel contents much.
        return loopBrowser.contentDocument.querySelector(".new-room-button").parentElement;
      },
    }],
    ["loop-roomList", {
      infoPanelPosition: "leftcenter topright",
      query: (aDocument) => {
        let loopBrowser = aDocument.querySelector("#loop-notification-panel > #loop");
        if (!loopBrowser) {
          return null;
        }
        return loopBrowser.contentDocument.querySelector(".room-list");
      },
    }],
    ["loop-selectedRoomButtons", {
      infoPanelOffsetY: -20,
      infoPanelPosition: "start_after",
      query: (aDocument) => {
        let chatbox = aDocument.querySelector("chatbox[src^='about\:loopconversation'][selected]");

        // Check that the real target actually exists
        if (!chatbox || !chatbox.contentDocument ||
            !chatbox.contentDocument.querySelector(".call-action-group")) {
          return null;
        }

        // But anchor on the <browser> in the chatbox so the panel doesn't jump to undefined
        // positions when the copy/email buttons disappear e.g. when the feedback form opens or
        // somebody else joins the room.
        return chatbox.content;
      },
    }],
    ["loop-signInUpLink", {
      query: (aDocument) => {
        let loopBrowser = aDocument.querySelector("#loop-notification-panel > #loop");
        if (!loopBrowser) {
          return null;
        }
        return loopBrowser.contentDocument.querySelector(".signin-link");
      },
    }],
    ["privateWindow",  {query: "#privatebrowsing-button"}],
    ["quit",        {query: "#PanelUI-quit"}],
    ["search",      {
      infoPanelOffsetX: 18,
      infoPanelPosition: "after_start",
      query: "#searchbar",
      widgetName: "search-container",
    }],
    ["searchProvider", {
      query: (aDocument) => {
        let searchbar = aDocument.getElementById("searchbar");
        if (searchbar.hasAttribute("oneoffui")) {
          return null;
        }
        return aDocument.getAnonymousElementByAttribute(searchbar,
                                                        "anonid",
                                                        "searchbar-engine-button");
      },
      widgetName: "search-container",
    }],
    ["searchIcon", {
      query: (aDocument) => {
        let searchbar = aDocument.getElementById("searchbar");
        if (!searchbar.hasAttribute("oneoffui")) {
          return null;
        }
        return aDocument.getAnonymousElementByAttribute(searchbar,
                                                        "anonid",
                                                        "searchbar-search-button");
      },
      widgetName: "search-container",
    }],
    ["searchPrefsLink", {
      query: (aDocument) => {
        let element = null;
        let searchbar = aDocument.getElementById("searchbar");
        if (searchbar.hasAttribute("oneoffui")) {
          let popup = aDocument.getElementById("PopupSearchAutoComplete");
          if (popup.state != "open")
            return null;
          element = aDocument.getAnonymousElementByAttribute(popup,
                                                             "anonid",
                                                             "search-settings");
        } else {
          element = aDocument.getAnonymousElementByAttribute(searchbar,
                                                             "anonid",
                                                             "open-engine-manager");
        }
        if (!element || !UITour.isElementVisible(element)) {
          return null;
        }
        return element;
      },
    }],
    ["selectedTabIcon", {
      query: (aDocument) => {
        let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
        let element = aDocument.getAnonymousElementByAttribute(selectedtab,
                                                               "anonid",
                                                               "tab-icon-image");
        if (!element || !UITour.isElementVisible(element)) {
          return null;
        }
        return element;
      },
    }],
    ["urlbar",      {
      query: "#urlbar",
      widgetName: "urlbar-container",
    }],
    ["webide",      {query: "#webide-button"}],
  ]),

  init: function() {
    log.debug("Initializing UITour");
    // Lazy getter is initialized here so it can be replicated any time
    // in a test.
    delete this.seenPageIDs;
    Object.defineProperty(this, "seenPageIDs", {
      get: this.restoreSeenPageIDs.bind(this),
      configurable: true,
    });

    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",
    ];
    CustomizableUI.addListener(listenerMethods.reduce((listener, method) => {
      listener[method] = () => this.availableTargetsCache.clear();
      return listener;
    }, {}));
  },

  restoreSeenPageIDs: function() {
    delete this.seenPageIDs;

    if (UITelemetry.enabled) {
      let dateThreshold = Date.now() - SEENPAGEID_EXPIRY;

      try {
        let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS);
        data = new Map(JSON.parse(data));

        for (let [pageID, details] of data) {

          if (typeof pageID != "string" ||
              typeof details != "object" ||
              typeof details.lastSeen != "number" ||
              details.lastSeen < dateThreshold) {

            data.delete(pageID);
          }
        }

        this.seenPageIDs = data;
      } catch (e) {}
    }

    if (!this.seenPageIDs)
      this.seenPageIDs = new Map();

    this.persistSeenIDs();

    return this.seenPageIDs;
  },

  addSeenPageID: function(aPageID) {
    if (!UITelemetry.enabled)
      return;

    this.seenPageIDs.set(aPageID, {
      lastSeen: Date.now(),
    });

    this.persistSeenIDs();
  },

  persistSeenIDs: function() {
    if (this.seenPageIDs.size === 0) {
      Services.prefs.clearUserPref(PREF_SEENPAGEIDS);
      return;
    }

    Services.prefs.setCharPref(PREF_SEENPAGEIDS,
                               JSON.stringify([...this.seenPageIDs]));
  },

  onPageEvent: function(aMessage, aEvent) {
    let contentDocument = null;
    let browser = aMessage.target;
    let window = browser.ownerDocument.defaultView;
    let tab = window.gBrowser.getTabForBrowser(browser);
    let messageManager = browser.messageManager;

    log.debug("onPageEvent:", aEvent.detail, aMessage);

    if (typeof aEvent.detail != "object") {
      log.warn("Malformed event - detail not an object");
      return false;
    }

    let action = aEvent.detail.action;
    if (typeof action != "string" || !action) {
      log.warn("Action not defined");
      return false;
    }

    let data = aEvent.detail.data;
    if (typeof data != "object") {
      log.warn("Malformed event - data not an object");
      return false;
    }

    if ((aEvent.pageVisibilityState == "hidden" ||
         aEvent.pageVisibilityState == "unloaded") &&
        !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action)) {
      log.warn("Ignoring disallowed action from a hidden page:", action);
      return false;
    }

    // Do this before bailing if there's no tab, so later we can pick up the pieces:
    window.gBrowser.tabContainer.addEventListener("TabSelect", this);

    if (!window.gMultiProcessBrowser) { // Non-e10s. See bug 1089000.
      contentDocument = browser.contentWindow.document;
    }

    switch (action) {
      case "registerPageID": {
        // This is only relevant if Telemtry is enabled.
        if (!UITelemetry.enabled) {
          log.debug("registerPageID: Telemery disabled, not doing anything");
          break;
        }

        // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
        // pageID, as it could make parsing the telemetry bucket name difficult.
        if (typeof data.pageID != "string" ||
            data.pageID.contains(BrowserUITelemetry.BUCKET_SEPARATOR)) {
          log.warn("registerPageID: Invalid page ID specified");
          break;
        }

        this.addSeenPageID(data.pageID);
        this.pageIDSourceBrowsers.set(browser, data.pageID);
        this.setTelemetryBucket(data.pageID);

        break;
      }

      case "showHighlight": {
        let targetPromise = this.getTarget(window, data.target);
        targetPromise.then(target => {
          if (!target.node) {
            log.error("UITour: Target could not be resolved: " + data.target);
            return;
          }
          let effect = undefined;
          if (this.highlightEffects.indexOf(data.effect) !== -1) {
            effect = data.effect;
          }
          this.showHighlight(window, target, effect);
        }).catch(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) {
            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 > 0) {
            for (let buttonData of data.buttons) {
              if (typeof buttonData == "object" &&
                  typeof buttonData.label == "string" &&
                  typeof buttonData.callbackID == "string") {
                let button = {
                  label: buttonData.label,
                  callbackID: buttonData.callbackID,
                };

                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) {
                  log.warn("showInfo: Reached limit of allowed number of buttons");
                  break;
                }
              }
            }
          }

          let infoOptions = {};

          if (typeof data.closeButtonCallbackID == "string")
            infoOptions.closeButtonCallbackID = data.closeButtonCallbackID;
          if (typeof data.targetCallbackID == "string")
            infoOptions.targetCallbackID = data.targetCallbackID;

          this.showInfo(window, messageManager, target, data.title, data.text, iconURL, buttons, infoOptions);
        }).catch(log.error);
        break;
      }

      case "hideInfo": {
        this.hideInfo(window);
        break;
      }

      case "previewTheme": {
        this.previewTheme(data.theme);
        break;
      }

      case "resetTheme": {
        this.resetTheme();
        break;
      }

      case "showMenu": {
        this.showMenu(window, data.name, () => {
          if (typeof data.showCallbackID == "string")
            this.sendPageCallback(messageManager, data.showCallbackID);
        });
        break;
      }

      case "hideMenu": {
        this.hideMenu(window, data.name);
        break;
      }

      case "startUrlbarCapture": {
        if (typeof data.text != "string" || !data.text ||
            typeof data.url != "string" || !data.url) {
          log.warn("startUrlbarCapture: Text or URL not specified");
          return false;
        }

        let uri = null;
        try {
          uri = Services.io.newURI(data.url, null, null);
        } catch (e) {
          log.warn("startUrlbarCapture: Malformed URL specified");
          return false;
        }

        let secman = Services.scriptSecurityManager;
        let principal = contentDocument.nodePrincipal;
        let flags = secman.DISALLOW_INHERIT_PRINCIPAL;
        try {
          secman.checkLoadURIWithPrincipal(principal, uri, flags);
        } catch (e) {
          log.warn("startUrlbarCapture: Orginating page doesn't have permission to open specified URL");
          return false;
        }

        this.startUrlbarCapture(window, data.text, data.url);
        break;
      }

      case "endUrlbarCapture": {
        this.endUrlbarCapture(window);
        break;
      }

      case "getConfiguration": {
        if (typeof data.configuration != "string") {
          log.warn("getConfiguration: No configuration option specified");
          return false;
        }

        this.getConfiguration(messageManager, window, data.configuration, data.callbackID);
        break;
      }

      case "setConfiguration": {
        if (typeof data.configuration != "string") {
          log.warn("setConfiguration: No configuration option specified");
          return false;
        }

        this.setConfiguration(data.configuration, data.value);
        break;
      }

      case "showFirefoxAccounts": {
        // 'signup' is the only action that makes sense currently, so we don't
        // accept arbitrary actions just to be safe...
        // We want to replace the current tab.
        contentDocument.location.href = "about:accounts?action=signup&entrypoint=uitour";
        break;
      }

      case "resetFirefox": {
        // Open a reset profile dialog window.
        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, messageManager, data.callbackID);
        }).catch(log.error);
        break;
      }

      case "setDefaultSearchEngine": {
        let enginePromise = this.selectSearchEngine(data.identifier);
        enginePromise.catch(Cu.reportError);
        break;
      }

      case "setTreatmentTag": {
        let name = data.name;
        let value = data.value;
        let string = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
        string.data = value;
        Services.prefs.setComplexValue("browser.uitour.treatment." + name,
                                       Ci.nsISupportsString, string);
        UITourHealthReport.recordTreatmentTag(name, value);
        break;
      }

      case "getTreatmentTag": {
        let name = data.name;
        let value;
        try {
          value = Services.prefs.getComplexValue("browser.uitour.treatment." + name,
                                                 Ci.nsISupportsString).data;
        } catch (ex) {}
        this.sendPageCallback(messageManager, data.callbackID, { value: value });
        break;
      }

      case "setSearchTerm": {
        let targetPromise = this.getTarget(window, "search");
        targetPromise.then(target => {
          let searchbar = target.node;
          searchbar.value = data.term;
          searchbar.inputChanged();
        }).then(null, Cu.reportError);
        break;
      }

      case "openSearchPanel": {
        let targetPromise = this.getTarget(window, "search");
        targetPromise.then(target => {
          let searchbar = target.node;

          if (searchbar.textbox.open) {
            this.sendPageCallback(messageManager, data.callbackID);
          } else {
            let onPopupShown = () => {
              searchbar.textbox.popup.removeEventListener("popupshown", onPopupShown);
              this.sendPageCallback(messageManager, data.callbackID);
            };

            searchbar.textbox.popup.addEventListener("popupshown", onPopupShown);
            searchbar.openSuggestionsPanel();
          }
        }).then(null, Cu.reportError);
        break;
      }

      case "ping": {
        if (typeof data.callbackID == "string")
          this.sendPageCallback(messageManager, data.callbackID);
        break;
      }
    }

    if (!this.tourBrowsersByWindow.has(window)) {
      this.tourBrowsersByWindow.set(window, new Set());
    }
    this.tourBrowsersByWindow.get(window).add(browser);

    // We don't have a tab if we're in a <browser> without a tab.
    if (tab) {
      tab.addEventListener("TabClose", this);
    }

    window.addEventListener("SSWindowClosing", this);

    return true;
  },

  handleEvent: function(aEvent) {
    log.debug("handleEvent: type =", aEvent.type, "event =", aEvent);
    switch (aEvent.type) {
      case "pagehide": {
        let window = this.getChromeWindow(aEvent.target);
        this.teardownTourForWindow(window);
        break;
      }

      case "TabClose": {
        let tab = aEvent.target;
        let window = tab.ownerDocument.defaultView;
        this.teardownTourForBrowser(window, tab.linkedBrowser, true);
        break;
      }

      case "TabSelect": {
        let window = aEvent.target.ownerDocument.defaultView;

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

      case "input": {
        if (aEvent.target.id == "urlbar") {
          let window = aEvent.target.ownerDocument.defaultView;
          this.handleUrlbarInput(window);
        }
        break;
      }
    }
  },

  setTelemetryBucket: function(aPageID) {
    let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
    BrowserUITelemetry.setBucket(bucket);
  },

  setExpiringTelemetryBucket: function(aPageID, aType) {
    let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
                 BrowserUITelemetry.BUCKET_SEPARATOR + aType;

    BrowserUITelemetry.setExpiringBucket(bucket,
                                         BUCKET_TIMESTEPS);
  },

  // This is registered with UITelemetry by BrowserUITelemetry, so that UITour
  // can remain lazy-loaded on-demand.
  getTelemetry: function() {
    return {
      seenPageIDs: [...this.seenPageIDs.keys()],
    };
  },

  /**
   * Tear down a tour from a tab e.g. upon switching/closing tabs.
   */
  teardownTourForBrowser: function(aWindow, aBrowser, aTourPageClosing = false) {
    log.debug("teardownTourForBrowser: aBrowser = ", aBrowser, aTourPageClosing);

    if (this.pageIDSourceBrowsers.has(aBrowser)) {
      let pageID = this.pageIDSourceBrowsers.get(aBrowser);
      this.setExpiringTelemetryBucket(pageID, aTourPageClosing ? "closed" : "inactive");
    }

    let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
    if (aTourPageClosing) {
      let tab = aWindow.gBrowser.getTabForBrowser(aBrowser);
      if (tab) { // Handle standalone <browser>
        tab.removeEventListener("TabClose", this);
        if (openTourBrowsers) {
          openTourBrowsers.delete(aBrowser);
        }
      }
    }

    this.hideHighlight(aWindow);
    this.hideInfo(aWindow);
    // Ensure the menu panel is hidden before calling recreatePopup so popup events occur.
    this.hideMenu(aWindow, "appMenu");
    this.hideMenu(aWindow, "loop");

    // Clean up panel listeners after calling hideMenu above.
    aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hideAppMenuAnnotations);
    aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hideAppMenuAnnotations);
    aWindow.PanelUI.panel.removeEventListener("popuphidden", this.onPanelHidden);
    let loopPanel = aWindow.document.getElementById("loop-notification-panel");
    loopPanel.removeEventListener("popuphidden", this.onPanelHidden);
    loopPanel.removeEventListener("popuphiding", this.hideLoopPanelAnnotations);

    this.endUrlbarCapture(aWindow);
    this.resetTheme();

    // 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);
    }
  },

  /**
   * Tear down all tours for a ChromeWindow.
   */
  teardownTourForWindow: function(aWindow) {
    log.debug("teardownTourForWindow");
    aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
    aWindow.removeEventListener("SSWindowClosing", this);

    let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
    if (openTourBrowsers) {
      for (let browser of openTourBrowsers) {
        if (this.pageIDSourceBrowsers.has(browser)) {
          let pageID = this.pageIDSourceBrowsers.get(browser);
          this.setExpiringTelemetryBucket(pageID, "closed");
        }

        let tab = aWindow.gBrowser.getTabForBrowser(browser);
        tab.removeEventListener("TabClose", this);
      }
    }

    this.tourBrowsersByWindow.delete(aWindow);
  },

  getChromeWindow: function(aContentDocument) {
    return aContentDocument.defaultView
                           .window
                           .QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIWebNavigation)
                           .QueryInterface(Ci.nsIDocShellTreeItem)
                           .rootTreeItem
                           .QueryInterface(Ci.nsIInterfaceRequestor)
                           .getInterface(Ci.nsIDOMWindow)
                           .wrappedJSObject;
  },

  // This function is copied to UITourListener.
  isSafeScheme: function(aURI) {
    let allowedSchemes = new Set(["https", "about"]);
    if (!Services.prefs.getBoolPref("browser.uitour.requireSecure"))
      allowedSchemes.add("http");

    if (!allowedSchemes.has(aURI.scheme)) {
      log.error("Unsafe scheme:", aURI.scheme);
      return false;
    }

    return true;
  },

  resolveURL: function(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: function(aMessageManager, aCallbackID, aData = {}) {
    let detail = {data: aData, callbackID: aCallbackID};
    log.debug("sendPageCallback", detail);
    aMessageManager.sendAsyncMessage("UITour:SendPageCallback", detail);
  },

  isElementVisible: function(aElement) {
    let targetStyle = aElement.ownerDocument.defaultView.getComputedStyle(aElement);
    return !aElement.ownerDocument.hidden &&
             targetStyle.display != "none" &&
             targetStyle.visibility == "visible";
  },

  getTarget: function(aWindow, aTargetName, aSticky = false) {
    log.debug("getTarget:", aTargetName);
    let deferred = Promise.defer();
    if (typeof aTargetName != "string" || !aTargetName) {
      log.warn("getTarget: Invalid target name specified");
      deferred.reject("Invalid target name specified");
      return deferred.promise;
    }

    if (aTargetName.startsWith(TARGET_SEARCHENGINE_PREFIX)) {
      let engineID = aTargetName.slice(TARGET_SEARCHENGINE_PREFIX.length);
      return this.getSearchEngineTarget(aWindow, engineID);
    }

    let targetObject = this.targets.get(aTargetName);
    if (!targetObject) {
      log.warn("getTarget: The specified target name is not in the allowed set");
      deferred.reject("The specified target name is not in the allowed set");
      return deferred.promise;
    }

    let targetQuery = targetObject.query;
    aWindow.PanelUI.ensureReady().then(() => {
      let node;
      if (typeof targetQuery == "function") {
        try {
          node = targetQuery(aWindow.document);
        } catch (ex) {
          log.warn("getTarget: Error running target query:", ex);
          node = null;
        }
      } else {
        node = aWindow.document.querySelector(targetQuery);
      }

      deferred.resolve({
        addTargetListener: targetObject.addTargetListener,
        infoPanelOffsetX: targetObject.infoPanelOffsetX,
        infoPanelOffsetY: targetObject.infoPanelOffsetY,
        infoPanelPosition: targetObject.infoPanelPosition,
        node: node,
        removeTargetListener: targetObject.removeTargetListener,
        targetName: aTargetName,
        widgetName: targetObject.widgetName,
        allowAdd: targetObject.allowAdd,
      });
    }).catch(log.error);
    return deferred.promise;
  },

  targetIsInAppMenu: function(aTarget) {
    let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
    if (placement && placement.area == CustomizableUI.AREA_PANEL) {
      return true;
    }

    let targetElement = aTarget.node;
    // Use the widget for filtering if it exists since the target may be the icon inside.
    if (aTarget.widgetName) {
      targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName);
    }

    // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets.
    return targetElement.id.startsWith("PanelUI-")
             && targetElement.id != "PanelUI-button";
  },

  /**
   * Called before opening or after closing a highlight or info panel to see if
   * we need to open or close the appMenu to see the annotation's anchor.
   */
  _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) {
    log.debug("_setAppMenuStateForAnnotation:", aAnnotationType);
    log.debug("_setAppMenuStateForAnnotation: Menu is expected to be:", aShouldOpenForHighlight ? "open" : "closed");

    // If the panel is in the desired state, we're done.
    let panelIsOpen = aWindow.PanelUI.panel.state != "closed";
    if (aShouldOpenForHighlight == panelIsOpen) {
      log.debug("_setAppMenuStateForAnnotation: Panel already in expected state");
      if (aCallback)
        aCallback();
      return;
    }

    // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead).
    if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) {
      log.debug("_setAppMenuStateForAnnotation: Menu not opened by us, not closing");
      if (aCallback)
        aCallback();
      return;
    }

    if (aShouldOpenForHighlight) {
      this.appMenuOpenForAnnotation.add(aAnnotationType);
    } else {
      this.appMenuOpenForAnnotation.delete(aAnnotationType);
    }

    // Actually show or hide the menu
    if (this.appMenuOpenForAnnotation.size) {
      log.debug("_setAppMenuStateForAnnotation: Opening the menu");
      this.showMenu(aWindow, "appMenu", aCallback);
    } else {
      log.debug("_setAppMenuStateForAnnotation: Closing the menu");
      this.hideMenu(aWindow, "appMenu");
      if (aCallback)
        aCallback();
    }

  },

  previewTheme: function(aTheme) {
    let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
    let data = LightweightThemeManager.parseTheme(aTheme, origin);
    if (data)
      LightweightThemeManager.previewTheme(data);
  },

  resetTheme: function() {
    LightweightThemeManager.resetPreview();
  },

  /**
   * @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".
   * @see UITour.highlightEffects
   */
  showHighlight: function(aChromeWindow, aTarget, aEffect = "none") {
    function showHighlightPanel() {
      if (aTarget.targetName.startsWith(TARGET_SEARCHENGINE_PREFIX)) {
        // This won't affect normal higlights done via the panel, so we need to
        // manually hide those.
        this.hideHighlight(aChromeWindow);
        aTarget.node.setAttribute("_moz-menuactive", true);
        return;
      }

      // Conversely, highlights for search engines are highlighted via CSS
      // rather than a panel, so need to be manually removed.
      this._hideSearchEngineHighlight(aChromeWindow);

      let highlighter = aChromeWindow.document.getElementById("UITourHighlight");

      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;
      // If the target is in the overflow panel, just highlight the overflow button.
      if (aTarget.node.getAttribute("overflowedItem")) {
        let doc = aTarget.node.ownerDocument;
        let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
        let areaNode = doc.getElementById(placement.area);
        highlightAnchor = areaNode.overflowable._chevron;
      } else {
        highlightAnchor = aTarget.node;
      }
      let targetRect = highlightAnchor.getBoundingClientRect();
      let highlightHeight = targetRect.height;
      let highlightWidth = targetRect.width;
      let minDimension = Math.min(highlightHeight, highlightWidth);
      let maxDimension = Math.max(highlightHeight, highlightWidth);

      // If the dimensions are within 200% of each other (to include the bookmarks button),
      // make the highlight a circle with the largest dimension as the diameter.
      if (maxDimension / minDimension <= 3.0) {
        highlightHeight = highlightWidth = maxDimension;
        highlighter.style.borderRadius = "100%";
      } else {
        highlighter.style.borderRadius = "";
      }

      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") {
        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 containerStyle = highlightWindow.getComputedStyle(highlighter.parentElement);
      let paddingTopPx = 0 - parseFloat(containerStyle.paddingTop);
      let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft);
      let highlightStyle = highlightWindow.getComputedStyle(highlighter);
      let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
      let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
      let offsetX = paddingTopPx
                      - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
      let offsetY = paddingLeftPx
                      - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
      this._addAnnotationPanelMutationObserver(highlighter.parentElement);
      highlighter.parentElement.openPopup(highlightAnchor, "overlap", offsetX, offsetY);
    }

    // Prevent showing a panel at an undefined position.
    if (!this.isElementVisible(aTarget.node)) {
      log.warn("showHighlight: Not showing a highlight since the target isn't visible", aTarget);
      return;
    }

    this._setAppMenuStateForAnnotation(aChromeWindow, "highlight",
                                       this.targetIsInAppMenu(aTarget),
                                       showHighlightPanel.bind(this));
  },

  hideHighlight: function(aWindow) {
    let highlighter = aWindow.document.getElementById("UITourHighlight");
    this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
    highlighter.parentElement.hidePopup();
    highlighter.removeAttribute("active");

    this._setAppMenuStateForAnnotation(aWindow, "highlight", false);
    this._hideSearchEngineHighlight(aWindow);
  },

  _hideSearchEngineHighlight: function(aWindow) {
    // We special case highlighting items in the search engines dropdown,
    // so just blindly remove any highlight there.
    let searchMenuBtn = null;
    try {
      searchMenuBtn = this.targets.get("searchProvider").query(aWindow.document);
    } catch (e) { /* This is ok to fail. */ }
    if (searchMenuBtn) {
      let searchPopup = aWindow.document
                               .getAnonymousElementByAttribute(searchMenuBtn,
                                                               "anonid",
                                                               "searchbar-popup");
      for (let menuItem of searchPopup.children)
        menuItem.removeAttribute("_moz-menuactive");
    }
  },

  /**
   * Show an info panel.
   *
   * @param {ChromeWindow} aChromeWindow
   * @param {nsIMessageSender} aMessageManager
   * @param {Node}     aAnchor
   * @param {String}   [aTitle=""]
   * @param {String}   [aDescription=""]
   * @param {String}   [aIconURL=""]
   * @param {Object[]} [aButtons=[]]
   * @param {Object}   [aOptions={}]
   * @param {String}   [aOptions.closeButtonCallbackID]
   */
  showInfo: function(aChromeWindow, aMessageManager, aAnchor, aTitle = "", aDescription = "", aIconURL = "",
                     aButtons = [], aOptions = {}) {
    function showInfoPanel(aAnchorEl) {
      aAnchorEl.focus();

      let document = aChromeWindow.document;
      let tooltip = document.getElementById("UITourTooltip");
      let tooltipTitle = document.getElementById("UITourTooltipTitle");
      let tooltipDesc = document.getElementById("UITourTooltipDescription");
      let tooltipIcon = document.getElementById("UITourTooltipIcon");
      let tooltipIconContainer = document.getElementById("UITourTooltipIconContainer");
      let tooltipButtons = document.getElementById("UITourTooltipButtons");

      if (tooltip.state == "showing" || tooltip.state == "open") {
        tooltip.hidePopup();
      }

      tooltipTitle.textContent = aTitle || "";
      tooltipDesc.textContent = aDescription || "";
      tooltipIcon.src = aIconURL || "";
      tooltipIconContainer.hidden = !aIconURL;

      while (tooltipButtons.firstChild)
        tooltipButtons.firstChild.remove();

      for (let button of aButtons) {
        let el = document.createElement("button");
        el.setAttribute("label", button.label);
        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");

        let callbackID = button.callbackID;
        el.addEventListener("command", event => {
          tooltip.hidePopup();
          this.sendPageCallback(aMessageManager, callbackID);
        });

        tooltipButtons.appendChild(el);
      }

      tooltipButtons.hidden = !aButtons.length;

      let tooltipClose = document.getElementById("UITourTooltipClose");
      let closeButtonCallback = (event) => {
        this.hideInfo(document.defaultView);
        if (aOptions && aOptions.closeButtonCallbackID)
          this.sendPageCallback(aMessageManager, aOptions.closeButtonCallbackID);
      };
      tooltipClose.addEventListener("command", closeButtonCallback);

      let targetCallback = (event) => {
        let details = {
          target: aAnchor.targetName,
          type: event.type,
        };
        this.sendPageCallback(aMessageManager, aOptions.targetCallbackID, details);
      };
      if (aOptions.targetCallbackID && aAnchor.addTargetListener) {
        aAnchor.addTargetListener(document, targetCallback);
      }

      tooltip.addEventListener("popuphiding", function tooltipHiding(event) {
        tooltip.removeEventListener("popuphiding", tooltipHiding);
        tooltipClose.removeEventListener("command", closeButtonCallback);
        if (aOptions.targetCallbackID && aAnchor.removeTargetListener) {
          aAnchor.removeTargetListener(document, targetCallback);
        }
      });

      tooltip.setAttribute("targetName", aAnchor.targetName);
      tooltip.hidden = false;
      let alignment = "bottomcenter 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 endModalStateHandler() {
          document.defaultView.removeEventListener("endmodalstate", endModalStateHandler);
          tooltip.openPopup(aAnchorEl, alignment);
        }, false);
      }
    }

    // Prevent showing a panel at an undefined position.
    if (!this.isElementVisible(aAnchor.node)) {
      log.warn("showInfo: Not showing since the target isn't visible", aAnchor);
      return;
    }

    // Due to a platform limitation, we can't anchor a panel to an element in a
    // <menupopup>. So we can't support showing info panels for search engines.
    if (aAnchor.targetName.startsWith(TARGET_SEARCHENGINE_PREFIX))
      return;

    this._setAppMenuStateForAnnotation(aChromeWindow, "info",
                                       this.targetIsInAppMenu(aAnchor),
                                       showInfoPanel.bind(this, aAnchor.node));
  },

  hideInfo: function(aWindow) {
    let document = aWindow.document;

    let tooltip = document.getElementById("UITourTooltip");
    this._removeAnnotationPanelMutationObserver(tooltip);
    tooltip.hidePopup();
    this._setAppMenuStateForAnnotation(aWindow, "info", false);

    let tooltipButtons = document.getElementById("UITourTooltipButtons");
    while (tooltipButtons.firstChild)
      tooltipButtons.firstChild.remove();
  },

  showMenu: function(aWindow, aMenuName, aOpenCallback = null) {
    log.debug("showMenu:", aMenuName);
    function openMenuButton(aMenuBtn) {
      if (!aMenuBtn || !aMenuBtn.boxObject || aMenuBtn.open) {
        if (aOpenCallback)
          aOpenCallback();
        return;
      }
      if (aOpenCallback)
        aMenuBtn.addEventListener("popupshown", onPopupShown);
      aMenuBtn.boxObject.openMenu(true);
    }
    function onPopupShown(event) {
      this.removeEventListener("popupshown", onPopupShown);
      aOpenCallback(event);
    }

    if (aMenuName == "appMenu") {
      aWindow.PanelUI.panel.setAttribute("noautohide", "true");
      // If the popup is already opened, don't recreate the widget as it may cause a flicker.
      if (aWindow.PanelUI.panel.state != "open") {
        this.recreatePopup(aWindow.PanelUI.panel);
      }
      aWindow.PanelUI.panel.addEventListener("popuphiding", this.hideAppMenuAnnotations);
      aWindow.PanelUI.panel.addEventListener("ViewShowing", this.hideAppMenuAnnotations);
      aWindow.PanelUI.panel.addEventListener("popuphidden", this.onPanelHidden);
      if (aOpenCallback) {
        aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown);
      }
      aWindow.PanelUI.show();
    } else if (aMenuName == "bookmarks") {
      let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
      openMenuButton(menuBtn);
    } else if (aMenuName == "loop") {
      let toolbarButton = aWindow.LoopUI.toolbarButton;
      // It's possible to have a node that isn't placed anywhere
      if (!toolbarButton || !toolbarButton.node ||
          !CustomizableUI.getPlacementOfWidget(toolbarButton.node.id)) {
        log.debug("Can't show the Loop menu since the toolbarButton isn't placed");
        return;
      }

      let panel = aWindow.document.getElementById("loop-notification-panel");
      panel.setAttribute("noautohide", true);
      if (panel.state != "open") {
        this.recreatePopup(panel);
        this.availableTargetsCache.clear();
      }

      // An event object is expected but we don't want to toggle the panel with a click if the panel
      // is already open.
      aWindow.LoopUI.openCallPanel({ target: toolbarButton.node, }, "rooms").then(() => {
        if (aOpenCallback) {
          aOpenCallback();
        }
      });
      panel.addEventListener("popuphidden", this.onPanelHidden);
      panel.addEventListener("popuphiding", this.hideLoopPanelAnnotations);
    } else if (aMenuName == "searchEngines") {
      this.getTarget(aWindow, "searchProvider").then(target => {
        openMenuButton(target.node);
      }).catch(log.error);
    }
  },

  hideMenu: function(aWindow, aMenuName) {
    log.debug("hideMenu:", aMenuName);
    function closeMenuButton(aMenuBtn) {
      if (aMenuBtn && aMenuBtn.boxObject)
        aMenuBtn.boxObject.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 == "loop") {
      let panel = aWindow.document.getElementById("loop-notification-panel");
      panel.hidePopup();
    } else if (aMenuName == "searchEngines") {
      let menuBtn = this.targets.get("searchProvider").query(aWindow.document);
      closeMenuButton(menuBtn);
    }
  },

  hideAnnotationsForPanel: function(aEvent, aTargetPositionCallback) {
    let win = aEvent.target.ownerDocument.defaultView;
    let annotationElements = new Map([
      // [annotationElement (panel), method to hide the annotation]
      [win.document.getElementById("UITourHighlightContainer"), UITour.hideHighlight.bind(UITour)],
      [win.document.getElementById("UITourTooltip"), UITour.hideInfo.bind(UITour)],
    ]);
    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(log.error);
      }
    });
    UITour.appMenuOpenForAnnotation.clear();
  },

  hideAppMenuAnnotations: function(aEvent) {
    UITour.hideAnnotationsForPanel(aEvent, UITour.targetIsInAppMenu);
  },

  hideLoopPanelAnnotations: function(aEvent) {
    UITour.hideAnnotationsForPanel(aEvent, (aTarget) => {
      return aTarget.targetName.startsWith("loop-") && aTarget.targetName != "loop-selectedRoomButtons";
    });
  },

  onPanelHidden: function(aEvent) {
    aEvent.target.removeAttribute("noautohide");
    UITour.recreatePopup(aEvent.target);
  },

  recreatePopup: function(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;
  },

  startUrlbarCapture: function(aWindow, aExpectedText, aUrl) {
    let urlbar = aWindow.document.getElementById("urlbar");
    this.urlbarCapture.set(aWindow, {
      expected: aExpectedText.toLocaleLowerCase(),
      url: aUrl
    });
    urlbar.addEventListener("input", this);
  },

  endUrlbarCapture: function(aWindow) {
    let urlbar = aWindow.document.getElementById("urlbar");
    urlbar.removeEventListener("input", this);
    this.urlbarCapture.delete(aWindow);
  },

  handleUrlbarInput: function(aWindow) {
    if (!this.urlbarCapture.has(aWindow))
      return;

    let urlbar = aWindow.document.getElementById("urlbar");

    let {expected, url} = this.urlbarCapture.get(aWindow);

    if (urlbar.value.toLocaleLowerCase().localeCompare(expected) != 0)
      return;

    urlbar.handleRevert();

    let tab = aWindow.gBrowser.addTab(url, {
      owner: aWindow.gBrowser.selectedTab,
      relatedToCurrent: true
    });
    aWindow.gBrowser.selectedTab = tab;
  },

  getConfiguration: function(aMessageManager, aWindow, aConfiguration, aCallbackID) {
    switch (aConfiguration) {
      case "availableTargets":
        this.getAvailableTargets(aMessageManager, aWindow, aCallbackID);
        break;
      case "sync":
        this.sendPageCallback(aMessageManager, aCallbackID, {
          setup: Services.prefs.prefHasUserValue("services.sync.username"),
        });
        break;
      case "appinfo":
        let props = ["defaultUpdateChannel", "version"];
        let appinfo = {};
        props.forEach(property => appinfo[property] = Services.appinfo[property]);
        this.sendPageCallback(aMessageManager, aCallbackID, appinfo);
        break;
      case "selectedSearchEngine":
        Services.search.init(rv => {
          let engine;
          if (Components.isSuccessCode(rv)) {
            engine = Services.search.defaultEngine;
          } else {
            engine = { identifier: "" };
          }
          this.sendPageCallback(aMessageManager, aCallbackID, {
            searchEngineIdentifier: engine.identifier
          });
        });
        break;
      default:
        log.error("getConfiguration: Unknown configuration requested: " + aConfiguration);
        break;
    }
  },

  setConfiguration: function(aConfiguration, aValue) {
    switch (aConfiguration) {
      case "Loop:ResumeTourOnFirstJoin":
        // Ignore aValue in this case to avoid accidentally setting it to false.
        Services.prefs.setBoolPref("loop.gettingStarted.resumeOnFirstJoin", true);
        break;
      default:
        log.error("setConfiguration: Unknown configuration requested: " + aConfiguration);
        break;
    }
  },

  getAvailableTargets: function(aMessageManager, aChromeWindow, aCallbackID) {
    Task.spawn(function*() {
      let window = aChromeWindow;
      let data = this.availableTargetsCache.get(window);
      if (data) {
        log.debug("getAvailableTargets: Using cached targets list", data.targets.join(","));
        this.sendPageCallback(aMessageManager, aCallbackID, data);
        return;
      }

      let promises = [];
      for (let targetName of this.targets.keys()) {
        promises.push(this.getTarget(window, targetName));
      }
      let targetObjects = yield Promise.all(promises);

      let targetNames = [];
      for (let targetObject of targetObjects) {
        if (targetObject.node)
          targetNames.push(targetObject.targetName);
      }

      targetNames = targetNames.concat(
        yield this.getAvailableSearchEngineTargets(window)
      );

      data = {
        targets: targetNames,
      };
      this.availableTargetsCache.set(window, data);
      this.sendPageCallback(aMessageManager, aCallbackID, data);
    }.bind(this)).catch(err => {
      log.error(err);
      this.sendPageCallback(aMessageManager, aCallbackID, {
        targets: [],
      });
    });
  },

  addNavBarWidget: function (aTarget, aMessageManager, aCallbackID) {
    if (aTarget.node) {
      log.error("addNavBarWidget: can't add a widget already present:", aTarget);
      return;
    }
    if (!aTarget.allowAdd) {
      log.error("addNavBarWidget: not allowed to add this widget:", aTarget);
      return;
    }
    if (!aTarget.widgetName) {
      log.error("addNavBarWidget: can't add a widget without a widgetName property:", aTarget);
      return;
    }

    CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR);
    this.sendPageCallback(aMessageManager, aCallbackID);
  },

  _addAnnotationPanelMutationObserver: function(aPanelEl) {
#ifdef XP_LINUX
    let observer = this._annotationPanelMutationObservers.get(aPanelEl);
    if (observer) {
      return;
    }
    let win = aPanelEl.ownerDocument.defaultView;
    observer = new win.MutationObserver(this._annotationMutationCallback);
    this._annotationPanelMutationObservers.set(aPanelEl, observer);
    let observerOptions = {
      attributeFilter: ["height", "width"],
      attributes: true,
    };
    observer.observe(aPanelEl, observerOptions);
#endif
  },

  _removeAnnotationPanelMutationObserver: function(aPanelEl) {
#ifdef XP_LINUX
    let observer = this._annotationPanelMutationObservers.get(aPanelEl);
    if (observer) {
      observer.disconnect();
      this._annotationPanelMutationObservers.delete(aPanelEl);
    }
#endif
  },

/**
 * 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: function(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.init((rv) => {
        if (!Components.isSuccessCode(rv)) {
          reject("selectSearchEngine: search service init failed: " + rv);
          return;
        }

        let engines = Services.search.getVisibleEngines();
        for (let engine of engines) {
          if (engine.identifier == aID) {
            Services.search.defaultEngine = engine;
            return resolve();
          }
        }
        reject("selectSearchEngine could not find engine with given ID");
      });
    });
  },

  getAvailableSearchEngineTargets(aWindow) {
    return new Promise(resolve => {
      this.getTarget(aWindow, "search").then(searchTarget => {
        if (!searchTarget.node || this.targetIsInAppMenu(searchTarget))
          return resolve([]);

        Services.search.init(() => {
          let engines = Services.search.getVisibleEngines();
          resolve([TARGET_SEARCHENGINE_PREFIX + engine.identifier
                   for (engine of engines)
                   if (engine.identifier)]);
        });
      }).catch(() => resolve([]));
    });
  },

  // We only allow matching based on a search engine's identifier - this gives
  // us a non-changing ID and guarentees we only match against app-provided
  // engines.
  getSearchEngineTarget(aWindow, aIdentifier) {
    return new Promise((resolve, reject) => {
      Task.spawn(function*() {
        let searchTarget = yield this.getTarget(aWindow, "search");
        // We're not supporting having the searchbar in the app-menu, because
        // popups within popups gets crazy. This restriction should be lifted
        // once bug 988151 is implemented, as the page can then be responsible
        // for opening each menu when appropriate.
        if (!searchTarget.node || this.targetIsInAppMenu(searchTarget))
          return reject("Search engine not available");

        yield Services.search.init();

        let searchPopup = searchTarget.node._popup;
        for (let engineNode of searchPopup.children) {
          let engine = engineNode.engine;
          if (engine && engine.identifier == aIdentifier) {
            return resolve({
              targetName: TARGET_SEARCHENGINE_PREFIX + engine.identifier,
              node: engineNode,
            });
          }
        }
        reject("Search engine not available");
      }.bind(this)).catch(() => {
        reject("Search engine not available");
      });
    });
  },

  notify(eventName, params) {
    let winEnum = Services.wm.getEnumerator("navigator:browser");
    while (winEnum.hasMoreElements()) {
      let window = winEnum.getNext();
      if (window.closed)
        continue;

      let openTourBrowsers = this.tourBrowsersByWindow.get(window);
      if (!openTourBrowsers)
        continue;

      for (let browser of openTourBrowsers) {
        let messageManager = browser.messageManager;
        if (!messageManager) {
          log.error("notify: Trying to notify a browser without a messageManager", browser);
          continue;
        }
        let detail = {
          event: eventName,
          params: params,
        };
        messageManager.sendAsyncMessage("UITour:SendPageNotification", detail);
      }
    }
  },
};

this.UITour.init();

/**
 * UITour Health Report
 */
const DAILY_DISCRETE_TEXT_FIELD = Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT;

/**
 * Public API to be called by the UITour code
 */
const UITourHealthReport = {
  recordTreatmentTag: function(tag, value) {
#ifdef MOZ_SERVICES_HEALTHREPORT
    Task.spawn(function*() {
      let reporter = Cc["@mozilla.org/datareporting/service;1"]
                       .getService()
                       .wrappedJSObject
                       .healthReporter;

      // This can happen if the FHR component of the data reporting service is
      // disabled. This is controlled by a pref that most will never use.
      if (!reporter) {
        return;
      }

      yield reporter.onInit();

      // Get the UITourMetricsProvider instance from the Health Reporter
      reporter.getProvider("org.mozilla.uitour").recordTreatmentTag(tag, value);
    });
#endif
  }
};

this.UITourMetricsProvider = function() {
  Metrics.Provider.call(this);
}

UITourMetricsProvider.prototype = Object.freeze({
  __proto__: Metrics.Provider.prototype,

  name: "org.mozilla.uitour",

  measurementTypes: [
    UITourTreatmentMeasurement1,
  ],

  recordTreatmentTag: function(tag, value) {
    let m = this.getMeasurement(UITourTreatmentMeasurement1.prototype.name,
                                UITourTreatmentMeasurement1.prototype.version);
    let field = tag;

    if (this.storage.hasFieldFromMeasurement(m.id, field,
                                             DAILY_DISCRETE_TEXT_FIELD)) {
      let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
      return this.enqueueStorageOperation(function recordKnownField() {
        return this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
      }.bind(this));
    }

    // Otherwise, we first need to create the field.
    return this.enqueueStorageOperation(function recordField() {
      // This function has to return a promise.
      return Task.spawn(function () {
        let fieldID = yield this.storage.registerField(m.id, field,
                                                       DAILY_DISCRETE_TEXT_FIELD);
        yield this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
      }.bind(this));
    }.bind(this));
  },
});

function UITourTreatmentMeasurement1() {
  Metrics.Measurement.call(this);

  this._serializers = {};
  this._serializers[this.SERIALIZE_JSON] = {
    //singular: We don't need a singular serializer because we have none of this data
    daily: this._serializeJSONDaily.bind(this)
  };

}

UITourTreatmentMeasurement1.prototype = Object.freeze({
  __proto__: Metrics.Measurement.prototype,

  name: "treatment",
  version: 1,

  // our fields are dynamic
  fields: { },

  // We need a custom serializer because the default one doesn't accept unknown fields
  _serializeJSONDaily: function(data) {
    let result = {_v: this.version };

    for (let [field, data] of data) {
      result[field] = data;
    }

    return result;
  }
});