/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

ChromeUtils.defineModuleGetter(
  this,
  "BrowserUIUtils",
  "resource:///modules/BrowserUIUtils.jsm"
);
ChromeUtils.defineESModuleGetters(this, {
  DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
  ExtensionControlledPopup:
    "resource:///modules/ExtensionControlledPopup.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
  SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
});

XPCOMUtils.defineLazyGetter(this, "strBundle", function () {
  return Services.strings.createBundle(
    "chrome://global/locale/extensions.properties"
  );
});

var { DefaultMap, ExtensionError } = ExtensionUtils;

const TAB_HIDE_CONFIRMED_TYPE = "tabHideNotification";

const TAB_ID_NONE = -1;

XPCOMUtils.defineLazyGetter(this, "tabHidePopup", () => {
  return new ExtensionControlledPopup({
    confirmedType: TAB_HIDE_CONFIRMED_TYPE,
    anchorId: "alltabs-button",
    popupnotificationId: "extension-tab-hide-notification",
    descriptionId: "extension-tab-hide-notification-description",
    descriptionMessageId: "tabHideControlled.message",
    getLocalizedDescription: (doc, message, addonDetails) => {
      let image = doc.createXULElement("image");
      image.setAttribute("class", "extension-controlled-icon alltabs-icon");
      return BrowserUIUtils.getLocalizedFragment(
        doc,
        message,
        addonDetails,
        image
      );
    },
    learnMoreMessageId: "tabHideControlled.learnMore",
    learnMoreLink: "extension-hiding-tabs",
  });
});

function showHiddenTabs(id) {
  for (let win of Services.wm.getEnumerator("navigator:browser")) {
    if (win.closed || !win.gBrowser) {
      continue;
    }

    for (let tab of win.gBrowser.tabs) {
      if (
        tab.hidden &&
        tab.ownerGlobal &&
        SessionStore.getCustomTabValue(tab, "hiddenBy") === id
      ) {
        win.gBrowser.showTab(tab);
      }
    }
  }
}

let tabListener = {
  tabReadyInitialized: false,
  // Map[tab -> Promise]
  tabBlockedPromises: new WeakMap(),
  // Map[tab -> Deferred]
  tabReadyPromises: new WeakMap(),
  initializingTabs: new WeakSet(),

  initTabReady() {
    if (!this.tabReadyInitialized) {
      windowTracker.addListener("progress", this);

      this.tabReadyInitialized = true;
    }
  },

  onLocationChange(browser, webProgress, request, locationURI, flags) {
    if (webProgress.isTopLevel) {
      let { gBrowser } = browser.ownerGlobal;
      let nativeTab = gBrowser.getTabForBrowser(browser);

      // Now we are certain that the first page in the tab was loaded.
      this.initializingTabs.delete(nativeTab);

      // browser.innerWindowID is now set, resolve the promises if any.
      let deferred = this.tabReadyPromises.get(nativeTab);
      if (deferred) {
        deferred.resolve(nativeTab);
        this.tabReadyPromises.delete(nativeTab);
      }
    }
  },

  blockTabUntilRestored(nativeTab) {
    let promise = ExtensionUtils.promiseEvent(nativeTab, "SSTabRestored").then(
      ({ target }) => {
        this.tabBlockedPromises.delete(target);
        return target;
      }
    );

    this.tabBlockedPromises.set(nativeTab, promise);
  },

  /**
   * Returns a promise that resolves when the tab is ready.
   * Tabs created via the `tabs.create` method are "ready" once the location
   * changes to the requested URL. Other tabs are assumed to be ready once their
   * inner window ID is known.
   *
   * @param {XULElement} nativeTab The <tab> element.
   * @returns {Promise} Resolves with the given tab once ready.
   */
  awaitTabReady(nativeTab) {
    let deferred = this.tabReadyPromises.get(nativeTab);
    if (!deferred) {
      let promise = this.tabBlockedPromises.get(nativeTab);
      if (promise) {
        return promise;
      }
      deferred = PromiseUtils.defer();
      if (
        !this.initializingTabs.has(nativeTab) &&
        (nativeTab.linkedBrowser.innerWindowID ||
          nativeTab.linkedBrowser.currentURI.spec === "about:blank")
      ) {
        deferred.resolve(nativeTab);
      } else {
        this.initTabReady();
        this.tabReadyPromises.set(nativeTab, deferred);
      }
    }
    return deferred.promise;
  },
};

const allAttrs = new Set([
  "attention",
  "audible",
  "favIconUrl",
  "mutedInfo",
  "sharingState",
  "title",
]);
const allProperties = new Set([
  "attention",
  "audible",
  "discarded",
  "favIconUrl",
  "hidden",
  "isArticle",
  "mutedInfo",
  "pinned",
  "sharingState",
  "status",
  "title",
  "url",
]);
const restricted = new Set(["url", "favIconUrl", "title"]);

this.tabs = class extends ExtensionAPIPersistent {
  static onUpdate(id, manifest) {
    if (!manifest.permissions || !manifest.permissions.includes("tabHide")) {
      showHiddenTabs(id);
    }
  }

  static onDisable(id) {
    showHiddenTabs(id);
    tabHidePopup.clearConfirmation(id);
  }

  static onUninstall(id) {
    tabHidePopup.clearConfirmation(id);
  }

  tabEventRegistrar({ event, listener }) {
    let { extension } = this;
    let { tabManager } = extension;
    return ({ fire }) => {
      let listener2 = (eventName, eventData, ...args) => {
        if (!tabManager.canAccessTab(eventData.nativeTab)) {
          return;
        }

        listener(fire, eventData, ...args);
      };

      tabTracker.on(event, listener2);
      return {
        unregister() {
          tabTracker.off(event, listener2);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    };
  }

  PERSISTENT_EVENTS = {
    onActivated: this.tabEventRegistrar({
      event: "tab-activated",
      listener: (fire, event) => {
        let { extension } = this;
        let { tabId, windowId, previousTabId, previousTabIsPrivate } = event;
        if (previousTabIsPrivate && !extension.privateBrowsingAllowed) {
          previousTabId = undefined;
        }
        fire.async({ tabId, previousTabId, windowId });
      },
    }),
    onAttached: this.tabEventRegistrar({
      event: "tab-attached",
      listener: (fire, event) => {
        fire.async(event.tabId, {
          newWindowId: event.newWindowId,
          newPosition: event.newPosition,
        });
      },
    }),
    onCreated: this.tabEventRegistrar({
      event: "tab-created",
      listener: (fire, event) => {
        let { tabManager } = this.extension;
        fire.async(tabManager.convert(event.nativeTab, event.currentTabSize));
      },
    }),
    onDetached: this.tabEventRegistrar({
      event: "tab-detached",
      listener: (fire, event) => {
        fire.async(event.tabId, {
          oldWindowId: event.oldWindowId,
          oldPosition: event.oldPosition,
        });
      },
    }),
    onRemoved: this.tabEventRegistrar({
      event: "tab-removed",
      listener: (fire, event) => {
        fire.async(event.tabId, {
          windowId: event.windowId,
          isWindowClosing: event.isWindowClosing,
        });
      },
    }),
    onMoved({ fire }) {
      let { tabManager } = this.extension;
      let moveListener = event => {
        let nativeTab = event.originalTarget;
        if (tabManager.canAccessTab(nativeTab)) {
          fire.async(tabTracker.getId(nativeTab), {
            windowId: windowTracker.getId(nativeTab.ownerGlobal),
            fromIndex: event.detail,
            toIndex: nativeTab._tPos,
          });
        }
      };

      windowTracker.addListener("TabMove", moveListener);
      return {
        unregister() {
          windowTracker.removeListener("TabMove", moveListener);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },

    onHighlighted({ fire, context }) {
      let { windowManager } = this.extension;
      let highlightListener = (eventName, event) => {
        // TODO see if we can avoid "context" here
        let window = windowTracker.getWindow(event.windowId, context, false);
        if (!window) {
          return;
        }
        let windowWrapper = windowManager.getWrapper(window);
        if (!windowWrapper) {
          return;
        }
        let tabIds = Array.from(
          windowWrapper.getHighlightedTabs(),
          tab => tab.id
        );
        fire.async({ tabIds: tabIds, windowId: event.windowId });
      };

      tabTracker.on("tabs-highlighted", highlightListener);
      return {
        unregister() {
          tabTracker.off("tabs-highlighted", highlightListener);
        },
        convert(_fire, _context) {
          fire = _fire;
          context = _context;
        },
      };
    },

    onUpdated({ fire, context }, params) {
      let { extension } = this;
      let { tabManager } = extension;
      let [filterProps] = params;
      let filter = { ...filterProps };
      if (filter.urls) {
        filter.urls = new MatchPatternSet(filter.urls, {
          restrictSchemes: false,
        });
      }
      let needsModified = true;
      if (filter.properties) {
        // Default is to listen for all events.
        needsModified = filter.properties.some(p => allAttrs.has(p));
        filter.properties = new Set(filter.properties);
      } else {
        filter.properties = allProperties;
      }

      function sanitize(tab, changeInfo) {
        let result = {};
        let nonempty = false;
        for (let prop in changeInfo) {
          // In practice, changeInfo contains at most one property from
          // restricted. Therefore it is not necessary to cache the value
          // of tab.hasTabPermission outside the loop.
          // Unnecessarily accessing tab.hasTabPermission can cause bugs, see
          // https://bugzilla.mozilla.org/show_bug.cgi?id=1694699#c21
          if (!restricted.has(prop) || tab.hasTabPermission) {
            nonempty = true;
            result[prop] = changeInfo[prop];
          }
        }
        return nonempty && result;
      }

      function getWindowID(windowId) {
        if (windowId === Window.WINDOW_ID_CURRENT) {
          let window = windowTracker.getTopWindow(context);
          if (!window) {
            return undefined;
          }
          return windowTracker.getId(window);
        }
        return windowId;
      }

      function matchFilters(tab, changed) {
        if (!filterProps) {
          return true;
        }
        if (filter.tabId != null && tab.id != filter.tabId) {
          return false;
        }
        if (
          filter.windowId != null &&
          tab.windowId != getWindowID(filter.windowId)
        ) {
          return false;
        }
        if (filter.urls) {
          return filter.urls.matches(tab._uri) && tab.hasTabPermission;
        }
        return true;
      }

      let fireForTab = (tab, changed, nativeTab) => {
        // Tab may be null if private and not_allowed.
        if (!tab || !matchFilters(tab, changed)) {
          return;
        }

        let changeInfo = sanitize(tab, changed);
        if (changeInfo) {
          tabTracker.maybeWaitForTabOpen(nativeTab).then(() => {
            if (!nativeTab.parentNode) {
              // If the tab is already be destroyed, do nothing.
              return;
            }
            fire.async(tab.id, changeInfo, tab.convert());
          });
        }
      };

      let listener = event => {
        // Ignore any events prior to TabOpen
        // and events that are triggered while tabs are swapped between windows.
        if (event.originalTarget.initializingTab) {
          return;
        }
        if (!extension.canAccessWindow(event.originalTarget.ownerGlobal)) {
          return;
        }
        let needed = [];
        if (event.type == "TabAttrModified") {
          let changed = event.detail.changed;
          if (
            changed.includes("image") &&
            filter.properties.has("favIconUrl")
          ) {
            needed.push("favIconUrl");
          }
          if (changed.includes("muted") && filter.properties.has("mutedInfo")) {
            needed.push("mutedInfo");
          }
          if (
            changed.includes("soundplaying") &&
            filter.properties.has("audible")
          ) {
            needed.push("audible");
          }
          if (changed.includes("label") && filter.properties.has("title")) {
            needed.push("title");
          }
          if (
            changed.includes("sharing") &&
            filter.properties.has("sharingState")
          ) {
            needed.push("sharingState");
          }
          if (
            changed.includes("attention") &&
            filter.properties.has("attention")
          ) {
            needed.push("attention");
          }
        } else if (event.type == "TabPinned") {
          needed.push("pinned");
        } else if (event.type == "TabUnpinned") {
          needed.push("pinned");
        } else if (event.type == "TabBrowserInserted") {
          // This may be an adopted tab. Bail early to avoid asking tabManager
          // about the tab before we run the adoption logic in ext-browser.js.
          if (event.detail.insertedOnTabCreation) {
            return;
          }
          needed.push("discarded");
        } else if (event.type == "TabBrowserDiscarded") {
          needed.push("discarded");
        } else if (event.type == "TabShow") {
          needed.push("hidden");
        } else if (event.type == "TabHide") {
          needed.push("hidden");
        }

        let tab = tabManager.getWrapper(event.originalTarget);

        let changeInfo = {};
        for (let prop of needed) {
          changeInfo[prop] = tab[prop];
        }

        fireForTab(tab, changeInfo, event.originalTarget);
      };

      let statusListener = ({ browser, status, url }) => {
        let { gBrowser } = browser.ownerGlobal;
        let tabElem = gBrowser.getTabForBrowser(browser);
        if (tabElem) {
          if (!extension.canAccessWindow(tabElem.ownerGlobal)) {
            return;
          }

          let changed = {};
          if (filter.properties.has("status")) {
            changed.status = status;
          }
          if (url && filter.properties.has("url")) {
            changed.url = url;
          }

          fireForTab(tabManager.wrapTab(tabElem), changed, tabElem);
        }
      };

      let isArticleChangeListener = (messageName, message) => {
        let { gBrowser } = message.target.ownerGlobal;
        let nativeTab = gBrowser.getTabForBrowser(message.target);

        if (nativeTab && extension.canAccessWindow(nativeTab.ownerGlobal)) {
          let tab = tabManager.getWrapper(nativeTab);
          fireForTab(tab, { isArticle: message.data.isArticle }, nativeTab);
        }
      };

      let listeners = new Map();
      if (filter.properties.has("status") || filter.properties.has("url")) {
        listeners.set("status", statusListener);
      }
      if (needsModified) {
        listeners.set("TabAttrModified", listener);
      }
      if (filter.properties.has("pinned")) {
        listeners.set("TabPinned", listener);
        listeners.set("TabUnpinned", listener);
      }
      if (filter.properties.has("discarded")) {
        listeners.set("TabBrowserInserted", listener);
        listeners.set("TabBrowserDiscarded", listener);
      }
      if (filter.properties.has("hidden")) {
        listeners.set("TabShow", listener);
        listeners.set("TabHide", listener);
      }

      for (let [name, listener] of listeners) {
        windowTracker.addListener(name, listener);
      }

      if (filter.properties.has("isArticle")) {
        tabTracker.on("tab-isarticle", isArticleChangeListener);
      }

      return {
        unregister() {
          for (let [name, listener] of listeners) {
            windowTracker.removeListener(name, listener);
          }

          if (filter.properties.has("isArticle")) {
            tabTracker.off("tab-isarticle", isArticleChangeListener);
          }
        },
        convert(_fire, _context) {
          fire = _fire;
          context = _context;
        },
      };
    },
  };

  getAPI(context) {
    let { extension } = context;
    let { tabManager, windowManager } = extension;
    let extensionApi = this;
    let module = "tabs";

    function getTabOrActive(tabId) {
      let tab =
        tabId !== null ? tabTracker.getTab(tabId) : tabTracker.activeTab;
      if (!tabManager.canAccessTab(tab)) {
        throw new ExtensionError(
          tabId === null
            ? "Cannot access activeTab"
            : `Invalid tab ID: ${tabId}`
        );
      }
      return tab;
    }

    function getNativeTabsFromIDArray(tabIds) {
      if (!Array.isArray(tabIds)) {
        tabIds = [tabIds];
      }
      return tabIds.map(tabId => {
        let tab = tabTracker.getTab(tabId);
        if (!tabManager.canAccessTab(tab)) {
          throw new ExtensionError(`Invalid tab ID: ${tabId}`);
        }
        return tab;
      });
    }

    async function promiseTabWhenReady(tabId) {
      let tab;
      if (tabId !== null) {
        tab = tabManager.get(tabId);
      } else {
        tab = tabManager.getWrapper(tabTracker.activeTab);
      }
      if (!tab) {
        throw new ExtensionError(
          tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}`
        );
      }

      await tabListener.awaitTabReady(tab.nativeTab);

      return tab;
    }

    function setContentTriggeringPrincipal(url, browser, options) {
      // For urls that we want to allow an extension to open in a tab, but
      // that it may not otherwise have access to, we set the triggering
      // principal to the url that is being opened.  This is used for newtab,
      // about: and moz-extension: protocols.
      options.triggeringPrincipal =
        Services.scriptSecurityManager.createContentPrincipal(
          Services.io.newURI(url),
          {
            userContextId: options.userContextId,
            privateBrowsingId: PrivateBrowsingUtils.isBrowserPrivate(browser)
              ? 1
              : 0,
          }
        );
    }

    let tabsApi = {
      tabs: {
        onActivated: new EventManager({
          context,
          module,
          event: "onActivated",
          extensionApi,
        }).api(),

        onCreated: new EventManager({
          context,
          module,
          event: "onCreated",
          extensionApi,
        }).api(),

        onHighlighted: new EventManager({
          context,
          module,
          event: "onHighlighted",
          extensionApi,
        }).api(),

        onAttached: new EventManager({
          context,
          module,
          event: "onAttached",
          extensionApi,
        }).api(),

        onDetached: new EventManager({
          context,
          module,
          event: "onDetached",
          extensionApi,
        }).api(),

        onRemoved: new EventManager({
          context,
          module,
          event: "onRemoved",
          extensionApi,
        }).api(),

        onReplaced: new EventManager({
          context,
          name: "tabs.onReplaced",
          register: fire => {
            return () => {};
          },
        }).api(),

        onMoved: new EventManager({
          context,
          module,
          event: "onMoved",
          extensionApi,
        }).api(),

        onUpdated: new EventManager({
          context,
          module,
          event: "onUpdated",
          extensionApi,
        }).api(),

        create(createProperties) {
          return new Promise((resolve, reject) => {
            let window =
              createProperties.windowId !== null
                ? windowTracker.getWindow(createProperties.windowId, context)
                : windowTracker.getTopNormalWindow(context);
            if (!window || !context.canAccessWindow(window)) {
              throw new Error(
                "Not allowed to create tabs on the target window"
              );
            }
            let { gBrowserInit } = window;
            if (!gBrowserInit || !gBrowserInit.delayedStartupFinished) {
              let obs = (finishedWindow, topic, data) => {
                if (finishedWindow != window) {
                  return;
                }
                Services.obs.removeObserver(
                  obs,
                  "browser-delayed-startup-finished"
                );
                resolve(window);
              };
              Services.obs.addObserver(obs, "browser-delayed-startup-finished");
            } else {
              resolve(window);
            }
          }).then(window => {
            let url;

            let options = { triggeringPrincipal: context.principal };
            if (createProperties.cookieStoreId) {
              // May throw if validation fails.
              options.userContextId = getUserContextIdForCookieStoreId(
                extension,
                createProperties.cookieStoreId,
                PrivateBrowsingUtils.isBrowserPrivate(window.gBrowser)
              );
            }

            if (createProperties.url !== null) {
              url = context.uri.resolve(createProperties.url);

              if (
                !url.startsWith("moz-extension://") &&
                !context.checkLoadURL(url, { dontReportErrors: true })
              ) {
                return Promise.reject({ message: `Illegal URL: ${url}` });
              }

              if (createProperties.openInReaderMode) {
                url = `about:reader?url=${encodeURIComponent(url)}`;
              }
            } else {
              url = window.BROWSER_NEW_TAB_URL;
            }
            let discardable = url && !url.startsWith("about:");
            // Handle moz-ext separately from the discardable flag to retain prior behavior.
            if (!discardable || url.startsWith("moz-extension://")) {
              setContentTriggeringPrincipal(url, window.gBrowser, options);
            }

            tabListener.initTabReady();
            const currentTab = window.gBrowser.selectedTab;
            const { frameLoader } = currentTab.linkedBrowser;
            const currentTabSize = {
              width: frameLoader.lazyWidth,
              height: frameLoader.lazyHeight,
            };

            if (createProperties.openerTabId !== null) {
              options.ownerTab = tabTracker.getTab(
                createProperties.openerTabId
              );
              options.openerBrowser = options.ownerTab.linkedBrowser;
              if (options.ownerTab.ownerGlobal !== window) {
                return Promise.reject({
                  message:
                    "Opener tab must be in the same window as the tab being created",
                });
              }
            }

            // Simple properties
            const properties = ["index", "pinned"];
            for (let prop of properties) {
              if (createProperties[prop] != null) {
                options[prop] = createProperties[prop];
              }
            }

            let active =
              createProperties.active !== null
                ? createProperties.active
                : !createProperties.discarded;
            if (createProperties.discarded) {
              if (active) {
                return Promise.reject({
                  message: `Active tabs cannot be created and discarded.`,
                });
              }
              if (createProperties.pinned) {
                return Promise.reject({
                  message: `Pinned tabs cannot be created and discarded.`,
                });
              }
              if (!discardable) {
                return Promise.reject({
                  message: `Cannot create a discarded new tab or "about" urls.`,
                });
              }
              options.createLazyBrowser = true;
              options.lazyTabTitle = createProperties.title;
            } else if (createProperties.title) {
              return Promise.reject({
                message: `Title may only be set for discarded tabs.`,
              });
            }

            let nativeTab = window.gBrowser.addTab(url, options);

            if (active) {
              window.gBrowser.selectedTab = nativeTab;
              if (!createProperties.url) {
                window.gURLBar.select();
              }
            }

            if (
              createProperties.url &&
              createProperties.url !== window.BROWSER_NEW_TAB_URL
            ) {
              // We can't wait for a location change event for about:newtab,
              // since it may be pre-rendered, in which case its initial
              // location change event has already fired.

              // Mark the tab as initializing, so that operations like
              // `executeScript` wait until the requested URL is loaded in
              // the tab before dispatching messages to the inner window
              // that contains the URL we're attempting to load.
              tabListener.initializingTabs.add(nativeTab);
            }

            if (createProperties.muted) {
              nativeTab.toggleMuteAudio(extension.id);
            }

            return tabManager.convert(nativeTab, currentTabSize);
          });
        },

        async remove(tabIds) {
          let nativeTabs = getNativeTabsFromIDArray(tabIds);

          if (nativeTabs.length === 1) {
            nativeTabs[0].ownerGlobal.gBrowser.removeTab(nativeTabs[0]);
            return;
          }

          // Or for multiple tabs, first group them by window
          let windowTabMap = new DefaultMap(() => []);
          for (let nativeTab of nativeTabs) {
            windowTabMap.get(nativeTab.ownerGlobal).push(nativeTab);
          }

          // Then make one call to removeTabs() for each window, to keep the
          // count accurate for SessionStore.getLastClosedTabCount().
          // Note: always pass options to disable animation and the warning
          // dialogue box, so that way all tabs are actually closed when the
          // browser.tabs.remove() promise resolves
          for (let [eachWindow, tabsToClose] of windowTabMap.entries()) {
            eachWindow.gBrowser.removeTabs(tabsToClose, {
              animate: false,
              suppressWarnAboutClosingWindow: true,
            });
          }
        },

        async discard(tabIds) {
          for (let nativeTab of getNativeTabsFromIDArray(tabIds)) {
            nativeTab.ownerGlobal.gBrowser.discardBrowser(nativeTab);
          }
        },

        async update(tabId, updateProperties) {
          let nativeTab = getTabOrActive(tabId);

          let tabbrowser = nativeTab.ownerGlobal.gBrowser;

          if (updateProperties.url !== null) {
            let url = context.uri.resolve(updateProperties.url);

            let options = {
              flags: updateProperties.loadReplace
                ? Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
                : Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
              triggeringPrincipal: context.principal,
            };

            if (!context.checkLoadURL(url, { dontReportErrors: true })) {
              // We allow loading top level tabs for "other" extensions.
              if (url.startsWith("moz-extension://")) {
                setContentTriggeringPrincipal(url, tabbrowser, options);
              } else {
                return Promise.reject({ message: `Illegal URL: ${url}` });
              }
            }

            let browser = nativeTab.linkedBrowser;
            if (nativeTab.linkedPanel) {
              browser.fixupAndLoadURIString(url, options);
            } else {
              // Shift to fully loaded browser and make
              // sure load handler is instantiated.
              nativeTab.addEventListener(
                "SSTabRestoring",
                () => browser.fixupAndLoadURIString(url, options),
                { once: true }
              );
              tabbrowser._insertBrowser(nativeTab);
            }
          }

          if (updateProperties.active) {
            tabbrowser.selectedTab = nativeTab;
          }
          if (updateProperties.highlighted !== null) {
            if (updateProperties.highlighted) {
              if (!nativeTab.selected && !nativeTab.multiselected) {
                tabbrowser.addToMultiSelectedTabs(nativeTab);
                // Select the highlighted tab unless active:false is provided.
                // Note that Chrome selects it even in that case.
                if (updateProperties.active !== false) {
                  tabbrowser.lockClearMultiSelectionOnce();
                  tabbrowser.selectedTab = nativeTab;
                }
              }
            } else {
              tabbrowser.removeFromMultiSelectedTabs(nativeTab);
            }
          }
          if (updateProperties.muted !== null) {
            if (nativeTab.muted != updateProperties.muted) {
              nativeTab.toggleMuteAudio(extension.id);
            }
          }
          if (updateProperties.pinned !== null) {
            if (updateProperties.pinned) {
              tabbrowser.pinTab(nativeTab);
            } else {
              tabbrowser.unpinTab(nativeTab);
            }
          }
          if (updateProperties.openerTabId !== null) {
            let opener = tabTracker.getTab(updateProperties.openerTabId);
            if (opener.ownerDocument !== nativeTab.ownerDocument) {
              return Promise.reject({
                message:
                  "Opener tab must be in the same window as the tab being updated",
              });
            }
            tabTracker.setOpener(nativeTab, opener);
          }
          if (updateProperties.successorTabId !== null) {
            let successor = null;
            if (updateProperties.successorTabId !== TAB_ID_NONE) {
              successor = tabTracker.getTab(
                updateProperties.successorTabId,
                null
              );
              if (!successor) {
                throw new ExtensionError("Invalid successorTabId");
              }
              // This also ensures "privateness" matches.
              if (successor.ownerDocument !== nativeTab.ownerDocument) {
                throw new ExtensionError(
                  "Successor tab must be in the same window as the tab being updated"
                );
              }
            }
            tabbrowser.setSuccessor(nativeTab, successor);
          }

          return tabManager.convert(nativeTab);
        },

        async reload(tabId, reloadProperties) {
          let nativeTab = getTabOrActive(tabId);

          let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
          if (reloadProperties && reloadProperties.bypassCache) {
            flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
          }
          nativeTab.linkedBrowser.reloadWithFlags(flags);
        },

        async warmup(tabId) {
          let nativeTab = tabTracker.getTab(tabId);
          if (!tabManager.canAccessTab(nativeTab)) {
            throw new ExtensionError(`Invalid tab ID: ${tabId}`);
          }
          let tabbrowser = nativeTab.ownerGlobal.gBrowser;
          tabbrowser.warmupTab(nativeTab);
        },

        async get(tabId) {
          return tabManager.get(tabId).convert();
        },

        getCurrent() {
          let tabData;
          if (context.tabId) {
            tabData = tabManager.get(context.tabId).convert();
          }
          return Promise.resolve(tabData);
        },

        async query(queryInfo) {
          return Array.from(tabManager.query(queryInfo, context), tab =>
            tab.convert()
          );
        },

        async captureTab(tabId, options) {
          let nativeTab = getTabOrActive(tabId);
          await tabListener.awaitTabReady(nativeTab);

          let browser = nativeTab.linkedBrowser;
          let window = browser.ownerGlobal;
          let zoom = window.ZoomManager.getZoomForBrowser(browser);

          let tab = tabManager.wrapTab(nativeTab);
          return tab.capture(context, zoom, options);
        },

        async captureVisibleTab(windowId, options) {
          let window =
            windowId == null
              ? windowTracker.getTopWindow(context)
              : windowTracker.getWindow(windowId, context);

          let tab = tabManager.wrapTab(window.gBrowser.selectedTab);
          await tabListener.awaitTabReady(tab.nativeTab);

          let zoom = window.ZoomManager.getZoomForBrowser(
            tab.nativeTab.linkedBrowser
          );
          return tab.capture(context, zoom, options);
        },

        async detectLanguage(tabId) {
          let tab = await promiseTabWhenReady(tabId);
          let results = await tab.queryContent("DetectLanguage", {});
          return results[0];
        },

        async executeScript(tabId, details) {
          let tab = await promiseTabWhenReady(tabId);
          return tab.executeScript(context, details);
        },

        async insertCSS(tabId, details) {
          let tab = await promiseTabWhenReady(tabId);
          return tab.insertCSS(context, details);
        },

        async removeCSS(tabId, details) {
          let tab = await promiseTabWhenReady(tabId);
          return tab.removeCSS(context, details);
        },

        async move(tabIds, moveProperties) {
          let tabsMoved = [];
          if (!Array.isArray(tabIds)) {
            tabIds = [tabIds];
          }

          let destinationWindow = null;
          if (moveProperties.windowId !== null) {
            destinationWindow = windowTracker.getWindow(
              moveProperties.windowId,
              context
            );
            // Fail on an invalid window.
            if (!destinationWindow) {
              return Promise.reject({
                message: `Invalid window ID: ${moveProperties.windowId}`,
              });
            }
          }

          /*
            Indexes are maintained on a per window basis so that a call to
              move([tabA, tabB], {index: 0})
                -> tabA to 0, tabB to 1 if tabA and tabB are in the same window
              move([tabA, tabB], {index: 0})
                -> tabA to 0, tabB to 0 if tabA and tabB are in different windows
          */
          let lastInsertionMap = new Map();

          for (let nativeTab of getNativeTabsFromIDArray(tabIds)) {
            // If the window is not specified, use the window from the tab.
            let window = destinationWindow || nativeTab.ownerGlobal;
            let isSameWindow = nativeTab.ownerGlobal == window;
            let gBrowser = window.gBrowser;

            // If we are not moving the tab to a different window, and the window
            // only has one tab, do nothing.
            if (isSameWindow && gBrowser.tabs.length === 1) {
              lastInsertionMap.set(window, 0);
              continue;
            }
            // If moving between windows, be sure privacy matches.  While gBrowser
            // prevents this, we want to silently ignore it.
            if (
              !isSameWindow &&
              PrivateBrowsingUtils.isBrowserPrivate(gBrowser) !=
                PrivateBrowsingUtils.isBrowserPrivate(
                  nativeTab.ownerGlobal.gBrowser
                )
            ) {
              continue;
            }

            let insertionPoint;
            let lastInsertion = lastInsertionMap.get(window);
            if (lastInsertion == null) {
              insertionPoint = moveProperties.index;
              let maxIndex = gBrowser.tabs.length - (isSameWindow ? 1 : 0);
              if (insertionPoint == -1) {
                // If the index is -1 it should go to the end of the tabs.
                insertionPoint = maxIndex;
              } else {
                insertionPoint = Math.min(insertionPoint, maxIndex);
              }
            } else if (isSameWindow && nativeTab._tPos <= lastInsertion) {
              // lastInsertion is the current index of the last inserted tab.
              // insertionPoint is the desired index of the current tab *after* moving it.
              // When the tab is moved, the last inserted tab will no longer be at index
              // lastInsertion, but (lastInsertion - 1). To position the tabs adjacent to
              // each other, the tab should therefore be at index (lastInsertion - 1 + 1).
              insertionPoint = lastInsertion;
            } else {
              // In this case the last inserted tab will stay at index lastInsertion,
              // so we should move the current tab to index (lastInsertion + 1).
              insertionPoint = lastInsertion + 1;
            }

            // We can only move pinned tabs to a point within, or just after,
            // the current set of pinned tabs. Unpinned tabs, likewise, can only
            // be moved to a position after the current set of pinned tabs.
            // Attempts to move a tab to an illegal position are ignored.
            let numPinned = gBrowser._numPinnedTabs;
            let ok = nativeTab.pinned
              ? insertionPoint <= numPinned
              : insertionPoint >= numPinned;
            if (!ok) {
              continue;
            }

            if (isSameWindow) {
              // If the window we are moving is the same, just move the tab.
              gBrowser.moveTabTo(nativeTab, insertionPoint);
            } else {
              // If the window we are moving the tab in is different, then move the tab
              // to the new window.
              nativeTab = gBrowser.adoptTab(nativeTab, insertionPoint, false);
            }
            lastInsertionMap.set(window, nativeTab._tPos);
            tabsMoved.push(nativeTab);
          }

          return tabsMoved.map(nativeTab => tabManager.convert(nativeTab));
        },

        duplicate(tabId, duplicateProperties) {
          const { active, index } = duplicateProperties || {};
          const inBackground = active === undefined ? false : !active;

          // Schema requires tab id.
          let nativeTab = getTabOrActive(tabId);

          let gBrowser = nativeTab.ownerGlobal.gBrowser;
          let newTab = gBrowser.duplicateTab(nativeTab, true, {
            inBackground,
            index,
          });

          tabListener.blockTabUntilRestored(newTab);
          return new Promise(resolve => {
            // Use SSTabRestoring to ensure that the tab's URL is ready before
            // resolving the promise.
            newTab.addEventListener(
              "SSTabRestoring",
              () => resolve(tabManager.convert(newTab)),
              { once: true }
            );
          });
        },

        getZoom(tabId) {
          let nativeTab = getTabOrActive(tabId);

          let { ZoomManager } = nativeTab.ownerGlobal;
          let zoom = ZoomManager.getZoomForBrowser(nativeTab.linkedBrowser);

          return Promise.resolve(zoom);
        },

        setZoom(tabId, zoom) {
          let nativeTab = getTabOrActive(tabId);

          let { FullZoom, ZoomManager } = nativeTab.ownerGlobal;

          if (zoom === 0) {
            // A value of zero means use the default zoom factor.
            return FullZoom.reset(nativeTab.linkedBrowser);
          } else if (zoom >= ZoomManager.MIN && zoom <= ZoomManager.MAX) {
            FullZoom.setZoom(zoom, nativeTab.linkedBrowser);
          } else {
            return Promise.reject({
              message: `Zoom value ${zoom} out of range (must be between ${ZoomManager.MIN} and ${ZoomManager.MAX})`,
            });
          }

          return Promise.resolve();
        },

        async getZoomSettings(tabId) {
          let nativeTab = getTabOrActive(tabId);

          let { FullZoom, ZoomUI } = nativeTab.ownerGlobal;

          return {
            mode: "automatic",
            scope: FullZoom.siteSpecific ? "per-origin" : "per-tab",
            defaultZoomFactor: await ZoomUI.getGlobalValue(),
          };
        },

        async setZoomSettings(tabId, settings) {
          let nativeTab = getTabOrActive(tabId);

          let currentSettings = await this.getZoomSettings(
            tabTracker.getId(nativeTab)
          );

          if (
            !Object.keys(settings).every(
              key => settings[key] === currentSettings[key]
            )
          ) {
            throw new ExtensionError(
              `Unsupported zoom settings: ${JSON.stringify(settings)}`
            );
          }
        },

        onZoomChange: new EventManager({
          context,
          name: "tabs.onZoomChange",
          register: fire => {
            let getZoomLevel = browser => {
              let { ZoomManager } = browser.ownerGlobal;

              return ZoomManager.getZoomForBrowser(browser);
            };

            // Stores the last known zoom level for each tab's browser.
            // WeakMap[<browser> -> number]
            let zoomLevels = new WeakMap();

            // Store the zoom level for all existing tabs.
            for (let window of windowTracker.browserWindows()) {
              if (!context.canAccessWindow(window)) {
                continue;
              }
              for (let nativeTab of window.gBrowser.tabs) {
                let browser = nativeTab.linkedBrowser;
                zoomLevels.set(browser, getZoomLevel(browser));
              }
            }

            let tabCreated = (eventName, event) => {
              let browser = event.nativeTab.linkedBrowser;
              if (!event.isPrivate || context.privateBrowsingAllowed) {
                zoomLevels.set(browser, getZoomLevel(browser));
              }
            };

            let zoomListener = async event => {
              let browser = event.originalTarget;

              // For non-remote browsers, this event is dispatched on the document
              // rather than on the <browser>.  But either way we have a node here.
              if (browser.nodeType == browser.DOCUMENT_NODE) {
                browser = browser.docShell.chromeEventHandler;
              }

              if (!context.canAccessWindow(browser.ownerGlobal)) {
                return;
              }

              let { gBrowser } = browser.ownerGlobal;
              let nativeTab = gBrowser.getTabForBrowser(browser);
              if (!nativeTab) {
                // We only care about zoom events in the top-level browser of a tab.
                return;
              }

              let oldZoomFactor = zoomLevels.get(browser);
              let newZoomFactor = getZoomLevel(browser);

              if (oldZoomFactor != newZoomFactor) {
                zoomLevels.set(browser, newZoomFactor);

                let tabId = tabTracker.getId(nativeTab);
                fire.async({
                  tabId,
                  oldZoomFactor,
                  newZoomFactor,
                  zoomSettings: await tabsApi.tabs.getZoomSettings(tabId),
                });
              }
            };

            tabTracker.on("tab-attached", tabCreated);
            tabTracker.on("tab-created", tabCreated);

            windowTracker.addListener("FullZoomChange", zoomListener);
            windowTracker.addListener("TextZoomChange", zoomListener);
            return () => {
              tabTracker.off("tab-attached", tabCreated);
              tabTracker.off("tab-created", tabCreated);

              windowTracker.removeListener("FullZoomChange", zoomListener);
              windowTracker.removeListener("TextZoomChange", zoomListener);
            };
          },
        }).api(),

        print() {
          let activeTab = getTabOrActive(null);
          let { PrintUtils } = activeTab.ownerGlobal;
          PrintUtils.startPrintWindow(activeTab.linkedBrowser.browsingContext);
        },

        // Legacy API
        printPreview() {
          return Promise.resolve(this.print());
        },

        saveAsPDF(pageSettings) {
          let activeTab = getTabOrActive(null);
          let picker = Cc["@mozilla.org/filepicker;1"].createInstance(
            Ci.nsIFilePicker
          );
          let title = strBundle.GetStringFromName(
            "saveaspdf.saveasdialog.title"
          );
          let filename;
          if (
            pageSettings.toFileName !== null &&
            pageSettings.toFileName != ""
          ) {
            filename = pageSettings.toFileName;
          } else if (activeTab.linkedBrowser.contentTitle != "") {
            filename = activeTab.linkedBrowser.contentTitle;
          } else {
            let url = new URL(activeTab.linkedBrowser.currentURI.spec);
            let path = decodeURIComponent(url.pathname);
            path = path.replace(/\/$/, "");
            filename = path.split("/").pop();
            if (filename == "") {
              filename = url.hostname;
            }
          }
          filename = DownloadPaths.sanitize(filename);

          picker.init(activeTab.ownerGlobal, title, Ci.nsIFilePicker.modeSave);
          picker.appendFilter("PDF", "*.pdf");
          picker.defaultExtension = "pdf";
          picker.defaultString = filename;

          return new Promise(resolve => {
            picker.open(function (retval) {
              if (retval == 0 || retval == 2) {
                // OK clicked (retval == 0) or replace confirmed (retval == 2)

                // Workaround: When trying to replace an existing file that is open in another application (i.e. a locked file),
                // the print progress listener is never called. This workaround ensures that a correct status is always returned.
                try {
                  let fstream = Cc[
                    "@mozilla.org/network/file-output-stream;1"
                  ].createInstance(Ci.nsIFileOutputStream);
                  fstream.init(picker.file, 0x2a, 0o666, 0); // ioflags = write|create|truncate, file permissions = rw-rw-rw-
                  fstream.close();
                } catch (e) {
                  resolve(retval == 0 ? "not_saved" : "not_replaced");
                  return;
                }

                let psService = Cc[
                  "@mozilla.org/gfx/printsettings-service;1"
                ].getService(Ci.nsIPrintSettingsService);
                let printSettings = psService.createNewPrintSettings();

                printSettings.printerName = "";
                printSettings.isInitializedFromPrinter = true;
                printSettings.isInitializedFromPrefs = true;

                printSettings.outputDestination =
                  Ci.nsIPrintSettings.kOutputDestinationFile;
                printSettings.toFileName = picker.file.path;

                printSettings.printSilent = true;

                printSettings.outputFormat =
                  Ci.nsIPrintSettings.kOutputFormatPDF;

                if (pageSettings.paperSizeUnit !== null) {
                  printSettings.paperSizeUnit = pageSettings.paperSizeUnit;
                }
                if (pageSettings.paperWidth !== null) {
                  printSettings.paperWidth = pageSettings.paperWidth;
                }
                if (pageSettings.paperHeight !== null) {
                  printSettings.paperHeight = pageSettings.paperHeight;
                }
                if (pageSettings.orientation !== null) {
                  printSettings.orientation = pageSettings.orientation;
                }
                if (pageSettings.scaling !== null) {
                  printSettings.scaling = pageSettings.scaling;
                }
                if (pageSettings.shrinkToFit !== null) {
                  printSettings.shrinkToFit = pageSettings.shrinkToFit;
                }
                if (pageSettings.showBackgroundColors !== null) {
                  printSettings.printBGColors =
                    pageSettings.showBackgroundColors;
                }
                if (pageSettings.showBackgroundImages !== null) {
                  printSettings.printBGImages =
                    pageSettings.showBackgroundImages;
                }
                if (pageSettings.edgeLeft !== null) {
                  printSettings.edgeLeft = pageSettings.edgeLeft;
                }
                if (pageSettings.edgeRight !== null) {
                  printSettings.edgeRight = pageSettings.edgeRight;
                }
                if (pageSettings.edgeTop !== null) {
                  printSettings.edgeTop = pageSettings.edgeTop;
                }
                if (pageSettings.edgeBottom !== null) {
                  printSettings.edgeBottom = pageSettings.edgeBottom;
                }
                if (pageSettings.marginLeft !== null) {
                  printSettings.marginLeft = pageSettings.marginLeft;
                }
                if (pageSettings.marginRight !== null) {
                  printSettings.marginRight = pageSettings.marginRight;
                }
                if (pageSettings.marginTop !== null) {
                  printSettings.marginTop = pageSettings.marginTop;
                }
                if (pageSettings.marginBottom !== null) {
                  printSettings.marginBottom = pageSettings.marginBottom;
                }
                if (pageSettings.headerLeft !== null) {
                  printSettings.headerStrLeft = pageSettings.headerLeft;
                }
                if (pageSettings.headerCenter !== null) {
                  printSettings.headerStrCenter = pageSettings.headerCenter;
                }
                if (pageSettings.headerRight !== null) {
                  printSettings.headerStrRight = pageSettings.headerRight;
                }
                if (pageSettings.footerLeft !== null) {
                  printSettings.footerStrLeft = pageSettings.footerLeft;
                }
                if (pageSettings.footerCenter !== null) {
                  printSettings.footerStrCenter = pageSettings.footerCenter;
                }
                if (pageSettings.footerRight !== null) {
                  printSettings.footerStrRight = pageSettings.footerRight;
                }

                activeTab.linkedBrowser.browsingContext
                  .print(printSettings)
                  .then(() => resolve(retval == 0 ? "saved" : "replaced"))
                  .catch(() =>
                    resolve(retval == 0 ? "not_saved" : "not_replaced")
                  );
              } else {
                // Cancel clicked (retval == 1)
                resolve("canceled");
              }
            });
          });
        },

        async toggleReaderMode(tabId) {
          let tab = await promiseTabWhenReady(tabId);
          if (!tab.isInReaderMode && !tab.isArticle) {
            throw new ExtensionError(
              "The specified tab cannot be placed into reader mode."
            );
          }
          let nativeTab = getTabOrActive(tabId);

          nativeTab.linkedBrowser.sendMessageToActor(
            "Reader:ToggleReaderMode",
            {},
            "AboutReader"
          );
        },

        moveInSuccession(tabIds, tabId, options) {
          const { insert, append } = options || {};
          const tabIdSet = new Set(tabIds);
          if (tabIdSet.size !== tabIds.length) {
            throw new ExtensionError(
              "IDs must not occur more than once in tabIds"
            );
          }
          if ((append || insert) && tabIdSet.has(tabId)) {
            throw new ExtensionError(
              "Value of tabId must not occur in tabIds if append or insert is true"
            );
          }

          const referenceTab = tabTracker.getTab(tabId, null);
          let referenceWindow = referenceTab && referenceTab.ownerGlobal;
          if (referenceWindow && !context.canAccessWindow(referenceWindow)) {
            throw new ExtensionError(`Invalid tab ID: ${tabId}`);
          }
          let previousTab, lastSuccessor;
          if (append) {
            previousTab = referenceTab;
            lastSuccessor =
              (insert && referenceTab && referenceTab.successor) || null;
          } else {
            lastSuccessor = referenceTab;
          }

          let firstTab;
          for (const tabId of tabIds) {
            const tab = tabTracker.getTab(tabId, null);
            if (tab === null) {
              continue;
            }
            if (!tabManager.canAccessTab(tab)) {
              throw new ExtensionError(`Invalid tab ID: ${tabId}`);
            }
            if (referenceWindow === null) {
              referenceWindow = tab.ownerGlobal;
            } else if (tab.ownerGlobal !== referenceWindow) {
              continue;
            }
            referenceWindow.gBrowser.replaceInSuccession(tab, tab.successor);
            if (append && tab === lastSuccessor) {
              lastSuccessor = tab.successor;
            }
            if (previousTab) {
              referenceWindow.gBrowser.setSuccessor(previousTab, tab);
            } else {
              firstTab = tab;
            }
            previousTab = tab;
          }

          if (previousTab) {
            if (!append && insert && lastSuccessor !== null) {
              referenceWindow.gBrowser.replaceInSuccession(
                lastSuccessor,
                firstTab
              );
            }
            referenceWindow.gBrowser.setSuccessor(previousTab, lastSuccessor);
          }
        },

        show(tabIds) {
          for (let tab of getNativeTabsFromIDArray(tabIds)) {
            if (tab.ownerGlobal) {
              tab.ownerGlobal.gBrowser.showTab(tab);
            }
          }
        },

        hide(tabIds) {
          let hidden = [];
          for (let tab of getNativeTabsFromIDArray(tabIds)) {
            if (tab.ownerGlobal && !tab.hidden) {
              tab.ownerGlobal.gBrowser.hideTab(tab, extension.id);
              if (tab.hidden) {
                hidden.push(tabTracker.getId(tab));
              }
            }
          }
          if (hidden.length) {
            let win = Services.wm.getMostRecentWindow("navigator:browser");
            tabHidePopup.open(win, extension.id);
          }
          return hidden;
        },

        highlight(highlightInfo) {
          let { windowId, tabs, populate } = highlightInfo;
          if (windowId == null) {
            windowId = Window.WINDOW_ID_CURRENT;
          }
          let window = windowTracker.getWindow(windowId, context);
          if (!context.canAccessWindow(window)) {
            throw new ExtensionError(`Invalid window ID: ${windowId}`);
          }

          if (!Array.isArray(tabs)) {
            tabs = [tabs];
          } else if (!tabs.length) {
            throw new ExtensionError("No highlighted tab.");
          }
          window.gBrowser.selectedTabs = tabs.map(tabIndex => {
            let tab = window.gBrowser.tabs[tabIndex];
            if (!tab || !tabManager.canAccessTab(tab)) {
              throw new ExtensionError("No tab at index: " + tabIndex);
            }
            return tab;
          });
          return windowManager.convert(window, { populate });
        },

        goForward(tabId) {
          let nativeTab = getTabOrActive(tabId);
          nativeTab.linkedBrowser.goForward();
        },

        goBack(tabId) {
          let nativeTab = getTabOrActive(tabId);
          nativeTab.linkedBrowser.goBack();
        },
      },
    };
    return tabsApi;
  }
};
