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

const { Cc, Ci, Cu, CC } = require("chrome");
const protocol = require("devtools/shared/protocol");
const { LongStringActor } = require("devtools/server/actors/string");
const { DevToolsServer } = require("devtools/server/devtools-server");
const Services = require("Services");
const { isWindowIncluded } = require("devtools/shared/layout/utils");
const specs = require("devtools/shared/specs/storage");
const { parseItemValue } = require("devtools/shared/storage/utils");
loader.lazyGetter(this, "ExtensionProcessScript", () => {
  return require("resource://gre/modules/ExtensionProcessScript.jsm")
    .ExtensionProcessScript;
});
loader.lazyGetter(this, "ExtensionStorageIDB", () => {
  return require("resource://gre/modules/ExtensionStorageIDB.jsm")
    .ExtensionStorageIDB;
});
loader.lazyRequireGetter(
  this,
  "getAddonIdForWindowGlobal",
  "devtools/server/actors/watcher/browsing-context-helpers.jsm",
  true
);

const EXTENSION_STORAGE_ENABLED_PREF =
  "devtools.storage.extensionStorage.enabled";

const DEFAULT_VALUE = "value";

loader.lazyRequireGetter(
  this,
  "naturalSortCaseInsensitive",
  "devtools/shared/natural-sort",
  true
);

// "Lax", "Strict" and "None" are special values of the SameSite property
// that should not be translated.
const COOKIE_SAMESITE = {
  LAX: "Lax",
  STRICT: "Strict",
  NONE: "None",
};

const SAFE_HOSTS_PREFIXES_REGEX = /^(about\+|https?\+|file\+|moz-extension\+)/;

// GUID to be used as a separator in compound keys. This must match the same
// constant in devtools/client/storage/ui.js,
// devtools/client/storage/test/head.js and
// devtools/server/tests/browser/head.js
const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";

loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm");
loader.lazyImporter(this, "Sqlite", "resource://gre/modules/Sqlite.jsm");

// We give this a funny name to avoid confusion with the global
// indexedDB.
loader.lazyGetter(this, "indexedDBForStorage", () => {
  // On xpcshell, we can't instantiate indexedDB without crashing
  try {
    const sandbox = Cu.Sandbox(
      CC("@mozilla.org/systemprincipal;1", "nsIPrincipal")(),
      { wantGlobalProperties: ["indexedDB"] }
    );
    return sandbox.indexedDB;
  } catch (e) {
    return {};
  }
});

// Maximum number of cookies/local storage key-value-pairs that can be sent
// over the wire to the client in one request.
const MAX_STORE_OBJECT_COUNT = 50;
// Delay for the batch job that sends the accumulated update packets to the
// client (ms).
const BATCH_DELAY = 200;

// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that
// precision.
const MAX_COOKIE_EXPIRY = Math.pow(2, 62);

// A RegExp for characters that cannot appear in a file/directory name. This is
// used to sanitize the host name for indexed db to lookup whether the file is
// present in <profileDir>/storage/default/ location
var illegalFileNameCharacters = [
  "[",
  // Control characters \001 to \036
  "\\x00-\\x24",
  // Special characters
  '/:*?\\"<>|\\\\',
  "]",
].join("");
var ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g");

// Holder for all the registered storage actors.
var storageTypePool = new Map();
exports.storageTypePool = storageTypePool;

/**
 * An async method equivalent to setTimeout but using Promises
 *
 * @param {number} time
 *        The wait time in milliseconds.
 */
function sleep(time) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(null);
    }, time);
  });
}

// Helper methods to create a storage actor.
var StorageActors = {};

/**
 * Creates a default object with the common methods required by all storage
 * actors.
 *
 * This default object is missing a couple of required methods that should be
 * implemented seperately for each actor. They are namely:
 *   - observe : Method which gets triggered on the notificaiton of the watched
 *               topic.
 *   - getNamesForHost : Given a host, get list of all known store names.
 *   - getValuesForHost : Given a host (and optionally a name) get all known
 *                        store objects.
 *   - toStoreObject : Given a store object, convert it to the required format
 *                     so that it can be transferred over wire.
 *   - populateStoresForHost : Given a host, populate the map of all store
 *                             objects for it
 *   - getFields: Given a subType(optional), get an array of objects containing
 *                column field info. The info includes,
 *                "name" is name of colume key.
 *                "editable" is 1 means editable field; 0 means uneditable.
 *
 * @param {string} typeName
 *        The typeName of the actor.
 * @param {array} observationTopics
 *        An array of topics which this actor listens to via Notification Observers.
 */
StorageActors.defaults = function(typeName, observationTopics) {
  return {
    typeName: typeName,

    get conn() {
      return this.storageActor.conn;
    },

    /**
     * Returns a list of currently known hosts for the target window. This list
     * contains unique hosts from the window + all inner windows. If
     * this._internalHosts is defined then these will also be added to the list.
     */
    get hosts() {
      const hosts = new Set();
      for (const { location } of this.storageActor.windows) {
        const host = this.getHostName(location);

        if (host) {
          hosts.add(host);
        }
      }
      if (this._internalHosts) {
        for (const host of this._internalHosts) {
          hosts.add(host);
        }
      }
      return hosts;
    },

    /**
     * Returns all the windows present on the page. Includes main window + inner
     * iframe windows.
     */
    get windows() {
      return this.storageActor.windows;
    },

    /**
     * Converts the window.location object into a URL (e.g. http://domain.com).
     */
    getHostName(location) {
      if (!location) {
        // Debugging a legacy Firefox extension... no hostname available and no
        // storage possible.
        return null;
      }

      if (this.storageActor.getHostName) {
        return this.storageActor.getHostName(location);
      }

      switch (location.protocol) {
        case "about:":
          return `${location.protocol}${location.pathname}`;
        case "chrome:":
          // chrome: URLs do not support storage of any type.
          return null;
        case "data:":
          // data: URLs do not support storage of any type.
          return null;
        case "file:":
          return `${location.protocol}//${location.pathname}`;
        case "javascript:":
          return location.href;
        case "moz-extension:":
          return location.origin;
        case "resource:":
          return `${location.origin}${location.pathname}`;
        default:
          // http: or unknown protocol.
          return `${location.protocol}//${location.host}`;
      }
    },

    initialize(storageActor) {
      protocol.Actor.prototype.initialize.call(this, null);

      this.storageActor = storageActor;

      this.populateStoresForHosts();
      if (observationTopics) {
        observationTopics.forEach(observationTopic => {
          Services.obs.addObserver(this, observationTopic);
        });
      }
      this.onWindowReady = this.onWindowReady.bind(this);
      this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
      this.storageActor.on("window-ready", this.onWindowReady);
      this.storageActor.on("window-destroyed", this.onWindowDestroyed);
    },

    destroy() {
      if (!this.storageActor) {
        return;
      }

      if (observationTopics) {
        observationTopics.forEach(observationTopic => {
          Services.obs.removeObserver(this, observationTopic);
        });
      }
      this.storageActor.off("window-ready", this.onWindowReady);
      this.storageActor.off("window-destroyed", this.onWindowDestroyed);

      this.hostVsStores.clear();

      protocol.Actor.prototype.destroy.call(this);

      this.storageActor = null;
    },

    getNamesForHost(host) {
      return [...this.hostVsStores.get(host).keys()];
    },

    getValuesForHost(host, name) {
      if (name) {
        return [this.hostVsStores.get(host).get(name)];
      }
      return [...this.hostVsStores.get(host).values()];
    },

    getObjectsSize(host, names) {
      return names.length;
    },

    /**
     * When a new window is added to the page. This generally means that a new
     * iframe is created, or the current window is completely reloaded.
     *
     * @param {window} window
     *        The window which was added.
     */
    async onWindowReady(window) {
      if (!this.hostVsStores) {
        return;
      }
      const host = this.getHostName(window.location);
      if (host && !this.hostVsStores.has(host)) {
        await this.populateStoresForHost(host, window);
        if (!this.storageActor) {
          // The actor might be destroyed during populateStoresForHost.
          return;
        }

        const data = {};
        data[host] = this.getNamesForHost(host);
        this.storageActor.update("added", typeName, data);
      }
    },

    /**
     * When a window is removed from the page. This generally means that an
     * iframe was removed, or the current window reload is triggered.
     *
     * @param {window} window
     *        The window which was removed.
     * @param {Object} options
     * @param {Boolean} options.dontCheckHost
     *        If set to true, the function won't check if the host still is in this.hosts.
     *        This is helpful in the case of the StorageActorMock, as the `hosts` getter
     *        uses its `windows` getter, and at this point in time the window which is
     *        going to be destroyed still exists.
     */
    onWindowDestroyed(window, { dontCheckHost } = {}) {
      if (!this.hostVsStores) {
        return;
      }
      if (!window.location) {
        // Nothing can be done if location object is null
        return;
      }
      const host = this.getHostName(window.location);
      if (host && (!this.hosts.has(host) || dontCheckHost)) {
        this.hostVsStores.delete(host);
        const data = {};
        data[host] = [];
        this.storageActor.update("deleted", typeName, data);
      }
    },

    form() {
      const hosts = {};
      for (const host of this.hosts) {
        hosts[host] = [];
      }

      return {
        actor: this.actorID,
        hosts: hosts,
        traits: this._getTraits(),
      };
    },

    // Share getTraits for child classes overriding form()
    _getTraits() {
      return {
        // The supportsXXX traits are not related to backward compatibility
        // Different storage actor types implement different APIs, the traits
        // help the client to know what is supported or not.
        supportsAddItem: typeof this.addItem === "function",
        // Note: supportsRemoveItem and supportsRemoveAll are always defined
        // for all actors. See Bug 1655001.
        supportsRemoveItem: typeof this.removeItem === "function",
        supportsRemoveAll: typeof this.removeAll === "function",
        supportsRemoveAllSessionCookies:
          typeof this.removeAllSessionCookies === "function",
      };
    },

    /**
     * Populates a map of known hosts vs a map of stores vs value.
     */
    populateStoresForHosts() {
      this.hostVsStores = new Map();
      for (const host of this.hosts) {
        this.populateStoresForHost(host);
      }
    },

    /**
     * Returns a list of requested store objects. Maximum values returned are
     * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose
     * starting index and total size can be controlled via the options object
     *
     * @param {string} host
     *        The host name for which the store values are required.
     * @param {array:string} names
     *        Array containing the names of required store objects. Empty if all
     *        items are required.
     * @param {object} options
     *        Additional options for the request containing following
     *        properties:
     *         - offset {number} : The begin index of the returned array amongst
     *                  the total values
     *         - size {number} : The number of values required.
     *         - sortOn {string} : The values should be sorted on this property.
     *         - index {string} : In case of indexed db, the IDBIndex to be used
     *                 for fetching the values.
     *
     * @return {object} An object containing following properties:
     *          - offset - The actual offset of the returned array. This might
     *                     be different from the requested offset if that was
     *                     invalid
     *          - total - The total number of entries possible.
     *          - data - The requested values.
     */
    async getStoreObjects(host, names, options = {}) {
      const offset = options.offset || 0;
      let size = options.size || MAX_STORE_OBJECT_COUNT;
      if (size > MAX_STORE_OBJECT_COUNT) {
        size = MAX_STORE_OBJECT_COUNT;
      }
      const sortOn = options.sortOn || "name";

      const toReturn = {
        offset: offset,
        total: 0,
        data: [],
      };

      let principal = null;
      if (this.typeName === "indexedDB") {
        // We only acquire principal when the type of the storage is indexedDB
        // because the principal only matters the indexedDB.
        const win = this.storageActor.getWindowFromHost(host);
        principal = this.getPrincipal(win);
      }

      if (names) {
        for (const name of names) {
          const values = await this.getValuesForHost(
            host,
            name,
            options,
            this.hostVsStores,
            principal
          );

          const { result, objectStores } = values;

          if (result && typeof result.objectsSize !== "undefined") {
            for (const { key, count } of result.objectsSize) {
              this.objectsSize[key] = count;
            }
          }

          if (result) {
            toReturn.data.push(...result.data);
          } else if (objectStores) {
            toReturn.data.push(...objectStores);
          } else {
            toReturn.data.push(...values);
          }
        }

        if (this.typeName === "Cache") {
          // Cache storage contains several items per name but misses a custom
          // `getObjectsSize` implementation, as implemented for IndexedDB.
          // See Bug 1745242.
          toReturn.total = toReturn.data.length;
        } else {
          toReturn.total = this.getObjectsSize(host, names, options);
        }
      } else {
        let obj = await this.getValuesForHost(
          host,
          undefined,
          undefined,
          this.hostVsStores,
          principal
        );
        if (obj.dbs) {
          obj = obj.dbs;
        }

        toReturn.total = obj.length;
        toReturn.data = obj;
      }

      if (offset > toReturn.total) {
        // In this case, toReturn.data is an empty array.
        toReturn.offset = toReturn.total;
        toReturn.data = [];
      } else {
        // We need to use natural sort before slicing.
        const sorted = toReturn.data.sort((a, b) => {
          return naturalSortCaseInsensitive(a[sortOn], b[sortOn]);
        });
        let sliced;
        if (this.typeName === "indexedDB") {
          // indexedDB's getValuesForHost never returns *all* values available but only
          // a slice, starting at the expected offset. Therefore the result is already
          // sliced as expected.
          sliced = sorted;
        } else {
          sliced = sorted.slice(offset, offset + size);
        }
        toReturn.data = sliced.map(a => this.toStoreObject(a));
      }

      return toReturn;
    },

    getPrincipal(win) {
      if (win) {
        return win.document.effectiveStoragePrincipal;
      }
      // We are running in the browser toolbox and viewing system DBs so we
      // need to use system principal.
      return Cc["@mozilla.org/systemprincipal;1"].createInstance(
        Ci.nsIPrincipal
      );
    },
  };
};

/**
 * Creates an actor and its corresponding front and registers it to the Storage
 * Actor.
 *
 * @See StorageActors.defaults()
 *
 * @param {object} options
 *        Options required by StorageActors.defaults method which are :
 *         - typeName {string}
 *                    The typeName of the actor.
 *         - observationTopics {array}
 *                            The topics which this actor listens to via
 *                            Notification Observers.
 * @param {object} overrides
 *        All the methods which you want to be different from the ones in
 *        StorageActors.defaults method plus the required ones described there.
 */
StorageActors.createActor = function(options = {}, overrides = {}) {
  const actorObject = StorageActors.defaults(
    options.typeName,
    options.observationTopics || null
  );
  for (const key in overrides) {
    actorObject[key] = overrides[key];
  }

  const actorSpec = specs.childSpecs[options.typeName];
  const actor = protocol.ActorClassWithSpec(actorSpec, actorObject);
  storageTypePool.set(actorObject.typeName, actor);
};

/**
 * The Cookies actor and front.
 */
StorageActors.createActor(
  {
    typeName: "cookies",
  },
  {
    initialize(storageActor) {
      protocol.Actor.prototype.initialize.call(this, null);

      this.storageActor = storageActor;

      this.maybeSetupChildProcess();
      this.populateStoresForHosts();
      this.addCookieObservers();

      this.onWindowReady = this.onWindowReady.bind(this);
      this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
      this.storageActor.on("window-ready", this.onWindowReady);
      this.storageActor.on("window-destroyed", this.onWindowDestroyed);
    },

    destroy() {
      this.hostVsStores.clear();

      // We need to remove the cookie listeners early in E10S mode so we need to
      // use a conditional here to ensure that we only attempt to remove them in
      // single process mode.
      if (!DevToolsServer.isInChildProcess) {
        this.removeCookieObservers();
      }

      this.storageActor.off("window-ready", this.onWindowReady);
      this.storageActor.off("window-destroyed", this.onWindowDestroyed);

      this._pendingResponse = null;

      protocol.Actor.prototype.destroy.call(this);

      this.storageActor = null;
    },

    /**
     * Given a cookie object, figure out all the matching hosts from the page that
     * the cookie belong to.
     */
    getMatchingHosts(cookies) {
      if (!cookies.length) {
        cookies = [cookies];
      }
      const hosts = new Set();
      for (const host of this.hosts) {
        for (const cookie of cookies) {
          if (this.isCookieAtHost(cookie, host)) {
            hosts.add(host);
          }
        }
      }
      return [...hosts];
    },

    /**
     * Given a cookie object and a host, figure out if the cookie is valid for
     * that host.
     */
    isCookieAtHost(cookie, host) {
      if (cookie.host == null) {
        return host == null;
      }

      host = trimHttpHttpsPort(host);

      if (cookie.host.startsWith(".")) {
        return ("." + host).endsWith(cookie.host);
      }
      if (cookie.host === "") {
        return host.startsWith("file://" + cookie.path);
      }

      return cookie.host == host;
    },

    toStoreObject(cookie) {
      if (!cookie) {
        return null;
      }

      return {
        uniqueKey:
          `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
          `${SEPARATOR_GUID}${cookie.path}`,
        name: cookie.name,
        host: cookie.host || "",
        path: cookie.path || "",

        // because expires is in seconds
        expires: (cookie.expires || 0) * 1000,

        // because creationTime is in micro seconds
        creationTime: cookie.creationTime / 1000,

        size: cookie.name.length + (cookie.value || "").length,

        // - do -
        lastAccessed: cookie.lastAccessed / 1000,
        value: new LongStringActor(this.conn, cookie.value || ""),
        hostOnly: !cookie.isDomain,
        isSecure: cookie.isSecure,
        isHttpOnly: cookie.isHttpOnly,
        sameSite: this.getSameSiteStringFromCookie(cookie),
      };
    },

    getSameSiteStringFromCookie(cookie) {
      switch (cookie.sameSite) {
        case cookie.SAMESITE_LAX:
          return COOKIE_SAMESITE.LAX;
        case cookie.SAMESITE_STRICT:
          return COOKIE_SAMESITE.STRICT;
      }
      // cookie.SAMESITE_NONE
      return COOKIE_SAMESITE.NONE;
    },

    populateStoresForHost(host) {
      this.hostVsStores.set(host, new Map());

      const originAttributes = this.getOriginAttributesFromHost(host);
      const cookies = this.getCookiesFromHost(host, originAttributes);

      for (const cookie of cookies) {
        if (this.isCookieAtHost(cookie, host)) {
          const uniqueKey =
            `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
            `${SEPARATOR_GUID}${cookie.path}`;

          this.hostVsStores.get(host).set(uniqueKey, cookie);
        }
      }
    },

    getOriginAttributesFromHost(host) {
      const win = this.storageActor.getWindowFromHost(host);
      let originAttributes;
      if (win) {
        originAttributes =
          win.document.effectiveStoragePrincipal.originAttributes;
      } else {
        // If we can't find the window by host, fallback to the top window
        // origin attributes.
        originAttributes = this.storageActor.document?.effectiveStoragePrincipal
          .originAttributes;
      }

      return originAttributes;
    },

    /**
     * Notification observer for "cookie-change".
     *
     * @param subject
     *        {Cookie|[Array]} A JSON parsed object containing either a single
     *        cookie representation or an array. Array is only in case of
     *        a "batch-deleted" action.
     * @param {string} topic
     *        The topic of the notification.
     * @param {string} action
     *        Additional data associated with the notification. Its the type of
     *        cookie change in the "cookie-change" topic.
     */
    onCookieChanged(subject, topic, action) {
      if (
        (topic !== "cookie-changed" && topic !== "private-cookie-changed") ||
        !this.storageActor ||
        !this.storageActor.windows
      ) {
        return null;
      }

      const hosts = this.getMatchingHosts(subject);
      const data = {};

      switch (action) {
        case "added":
        case "changed":
          if (hosts.length) {
            for (const host of hosts) {
              const uniqueKey =
                `${subject.name}${SEPARATOR_GUID}${subject.host}` +
                `${SEPARATOR_GUID}${subject.path}`;

              this.hostVsStores.get(host).set(uniqueKey, subject);
              data[host] = [uniqueKey];
            }
            this.storageActor.update(action, "cookies", data);
          }
          break;

        case "deleted":
          if (hosts.length) {
            for (const host of hosts) {
              const uniqueKey =
                `${subject.name}${SEPARATOR_GUID}${subject.host}` +
                `${SEPARATOR_GUID}${subject.path}`;

              this.hostVsStores.get(host).delete(uniqueKey);
              data[host] = [uniqueKey];
            }
            this.storageActor.update("deleted", "cookies", data);
          }
          break;

        case "batch-deleted":
          if (hosts.length) {
            for (const host of hosts) {
              const stores = [];
              for (const cookie of subject) {
                const uniqueKey =
                  `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
                  `${SEPARATOR_GUID}${cookie.path}`;

                this.hostVsStores.get(host).delete(uniqueKey);
                stores.push(uniqueKey);
              }
              data[host] = stores;
            }
            this.storageActor.update("deleted", "cookies", data);
          }
          break;

        case "cleared":
          if (hosts.length) {
            for (const host of hosts) {
              data[host] = [];
            }
            this.storageActor.update("cleared", "cookies", data);
          }
          break;
      }
      return null;
    },

    async getFields() {
      return [
        { name: "uniqueKey", editable: false, private: true },
        { name: "name", editable: true, hidden: false },
        { name: "value", editable: true, hidden: false },
        { name: "host", editable: true, hidden: false },
        { name: "path", editable: true, hidden: false },
        { name: "expires", editable: true, hidden: false },
        { name: "size", editable: false, hidden: false },
        { name: "isHttpOnly", editable: true, hidden: false },
        { name: "isSecure", editable: true, hidden: false },
        { name: "sameSite", editable: false, hidden: false },
        { name: "lastAccessed", editable: false, hidden: false },
        { name: "creationTime", editable: false, hidden: true },
        { name: "hostOnly", editable: false, hidden: true },
      ];
    },

    /**
     * Pass the editItem command from the content to the chrome process.
     *
     * @param {Object} data
     *        See editCookie() for format details.
     */
    async editItem(data) {
      data.originAttributes = this.getOriginAttributesFromHost(data.host);
      this.editCookie(data);
    },

    async addItem(guid, host) {
      const window = this.storageActor.getWindowFromHost(host);
      const principal = window.document.effectiveStoragePrincipal;
      this.addCookie(guid, principal);
    },

    async removeItem(host, name) {
      const originAttributes = this.getOriginAttributesFromHost(host);
      this.removeCookie(host, name, originAttributes);
    },

    async removeAll(host, domain) {
      const originAttributes = this.getOriginAttributesFromHost(host);
      this.removeAllCookies(host, domain, originAttributes);
    },

    async removeAllSessionCookies(host, domain) {
      const originAttributes = this.getOriginAttributesFromHost(host);
      this.removeAllSessionCookies(host, domain, originAttributes);
    },

    maybeSetupChildProcess() {
      cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this);

      if (!DevToolsServer.isInChildProcess) {
        this.getCookiesFromHost = cookieHelpers.getCookiesFromHost.bind(
          cookieHelpers
        );
        this.addCookieObservers = cookieHelpers.addCookieObservers.bind(
          cookieHelpers
        );
        this.removeCookieObservers = cookieHelpers.removeCookieObservers.bind(
          cookieHelpers
        );
        this.addCookie = cookieHelpers.addCookie.bind(cookieHelpers);
        this.editCookie = cookieHelpers.editCookie.bind(cookieHelpers);
        this.removeCookie = cookieHelpers.removeCookie.bind(cookieHelpers);
        this.removeAllCookies = cookieHelpers.removeAllCookies.bind(
          cookieHelpers
        );
        this.removeAllSessionCookies = cookieHelpers.removeAllSessionCookies.bind(
          cookieHelpers
        );
        return;
      }

      const mm = this.conn.parentMessageManager;

      // eslint-disable-next-line no-restricted-properties
      this.conn.setupInParent({
        module: "devtools/server/actors/storage",
        setupParent: "setupParentProcessForCookies",
      });

      this.getCookiesFromHost = callParentProcess.bind(
        null,
        "getCookiesFromHost"
      );
      this.addCookieObservers = callParentProcess.bind(
        null,
        "addCookieObservers"
      );
      this.removeCookieObservers = callParentProcess.bind(
        null,
        "removeCookieObservers"
      );
      this.addCookie = callParentProcess.bind(null, "addCookie");
      this.editCookie = callParentProcess.bind(null, "editCookie");
      this.removeCookie = callParentProcess.bind(null, "removeCookie");
      this.removeAllCookies = callParentProcess.bind(null, "removeAllCookies");
      this.removeAllSessionCookies = callParentProcess.bind(
        null,
        "removeAllSessionCookies"
      );

      mm.addMessageListener(
        "debug:storage-cookie-request-child",
        cookieHelpers.handleParentRequest
      );

      function callParentProcess(methodName, ...args) {
        const reply = mm.sendSyncMessage(
          "debug:storage-cookie-request-parent",
          {
            method: methodName,
            args: args,
          }
        );

        if (reply.length === 0) {
          console.error("ERR_DIRECTOR_CHILD_NO_REPLY from " + methodName);
        } else if (reply.length > 1) {
          console.error(
            "ERR_DIRECTOR_CHILD_MULTIPLE_REPLIES from " + methodName
          );
        }

        const result = reply[0];

        if (methodName === "getCookiesFromHost") {
          return JSON.parse(result);
        }

        return result;
      }
    },
  }
);

var cookieHelpers = {
  getCookiesFromHost(host, originAttributes) {
    // Local files have no host.
    if (host.startsWith("file:///")) {
      host = "";
    }

    host = trimHttpHttpsPort(host);

    return Services.cookies.getCookiesFromHost(host, originAttributes);
  },

  addCookie(guid, principal) {
    // Set expiry time for cookie 1 day into the future
    // NOTE: Services.cookies.add expects the time in seconds.
    const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
    const time = Math.floor(Date.now() / 1000);
    const expiry = time + ONE_DAY_IN_SECONDS;

    // principal throws an error when we try to access principal.host if it
    // does not exist (which happens at about: pages).
    // We check for asciiHost instead, which is always present, and has a
    // value of "" when the host is not available.
    const domain = principal.asciiHost ? principal.host : principal.baseDomain;
    const path = principal.filePath.startsWith("/") ? principal.filePath : "/";

    Services.cookies.add(
      domain,
      path,
      guid, // name
      DEFAULT_VALUE, // value
      false, // isSecure
      false, // isHttpOnly,
      false, // isSession,
      expiry, // expires,
      principal.originAttributes, // originAttributes
      Ci.nsICookie.SAMESITE_LAX, // sameSite
      principal.scheme === "https" // schemeMap
        ? Ci.nsICookie.SCHEME_HTTPS
        : Ci.nsICookie.SCHEME_HTTP
    );
  },

  /**
   * Apply the results of a cookie edit.
   *
   * @param {Object} data
   *        An object in the following format:
   *        {
   *          host: "http://www.mozilla.org",
   *          field: "value",
   *          editCookie: "name",
   *          oldValue: "%7BHello%7D",
   *          newValue: "%7BHelloo%7D",
   *          items: {
   *            name: "optimizelyBuckets",
   *            path: "/",
   *            host: ".mozilla.org",
   *            expires: "Mon, 02 Jun 2025 12:37:37 GMT",
   *            creationTime: "Tue, 18 Nov 2014 16:21:18 GMT",
   *            lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT",
   *            value: "%7BHelloo%7D",
   *            isDomain: "true",
   *            isSecure: "false",
   *            isHttpOnly: "false"
   *          }
   *        }
   */
  // eslint-disable-next-line complexity
  editCookie(data) {
    let { field, oldValue, newValue } = data;
    const origName = field === "name" ? oldValue : data.items.name;
    const origHost = field === "host" ? oldValue : data.items.host;
    const origPath = field === "path" ? oldValue : data.items.path;
    let cookie = null;

    const cookies = Services.cookies.getCookiesFromHost(
      origHost,
      data.originAttributes || {}
    );
    for (const nsiCookie of cookies) {
      if (
        nsiCookie.name === origName &&
        nsiCookie.host === origHost &&
        nsiCookie.path === origPath
      ) {
        cookie = {
          host: nsiCookie.host,
          path: nsiCookie.path,
          name: nsiCookie.name,
          value: nsiCookie.value,
          isSecure: nsiCookie.isSecure,
          isHttpOnly: nsiCookie.isHttpOnly,
          isSession: nsiCookie.isSession,
          expires: nsiCookie.expires,
          originAttributes: nsiCookie.originAttributes,
          schemeMap: nsiCookie.schemeMap,
        };
        break;
      }
    }

    if (!cookie) {
      return;
    }

    // If the date is expired set it for 10 seconds in the future.
    const now = new Date();
    if (!cookie.isSession && cookie.expires * 1000 <= now) {
      const tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000;

      cookie.expires = tenSecondsFromNow;
    }

    switch (field) {
      case "isSecure":
      case "isHttpOnly":
      case "isSession":
        newValue = newValue === "true";
        break;

      case "expires":
        newValue = Date.parse(newValue) / 1000;

        if (isNaN(newValue)) {
          newValue = MAX_COOKIE_EXPIRY;
        }
        break;

      case "host":
      case "name":
      case "path":
        // Remove the edited cookie.
        Services.cookies.remove(
          origHost,
          origName,
          origPath,
          cookie.originAttributes
        );
        break;
    }

    // Apply changes.
    cookie[field] = newValue;

    // cookie.isSession is not always set correctly on session cookies so we
    // need to trust cookie.expires instead.
    cookie.isSession = !cookie.expires;

    // Add the edited cookie.
    Services.cookies.add(
      cookie.host,
      cookie.path,
      cookie.name,
      cookie.value,
      cookie.isSecure,
      cookie.isHttpOnly,
      cookie.isSession,
      cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires,
      cookie.originAttributes,
      cookie.sameSite,
      cookie.schemeMap
    );
  },

  _removeCookies(host, opts = {}) {
    // We use a uniqueId to emulate compound keys for cookies. We need to
    // extract the cookie name to remove the correct cookie.
    if (opts.name) {
      const split = opts.name.split(SEPARATOR_GUID);

      opts.name = split[0];
      opts.path = split[2];
    }

    host = trimHttpHttpsPort(host);

    function hostMatches(cookieHost, matchHost) {
      if (cookieHost == null) {
        return matchHost == null;
      }
      if (cookieHost.startsWith(".")) {
        return ("." + matchHost).endsWith(cookieHost);
      }
      return cookieHost == host;
    }

    const cookies = Services.cookies.getCookiesFromHost(
      host,
      opts.originAttributes || {}
    );
    for (const cookie of cookies) {
      if (
        hostMatches(cookie.host, host) &&
        (!opts.name || cookie.name === opts.name) &&
        (!opts.domain || cookie.host === opts.domain) &&
        (!opts.path || cookie.path === opts.path) &&
        (!opts.session || (!cookie.expires && !cookie.maxAge))
      ) {
        Services.cookies.remove(
          cookie.host,
          cookie.name,
          cookie.path,
          cookie.originAttributes
        );
      }
    }
  },

  removeCookie(host, name, originAttributes) {
    if (name !== undefined) {
      this._removeCookies(host, { name, originAttributes });
    }
  },

  removeAllCookies(host, domain, originAttributes) {
    this._removeCookies(host, { domain, originAttributes });
  },

  removeAllSessionCookies(host, domain, originAttributes) {
    this._removeCookies(host, { domain, originAttributes, session: true });
  },

  addCookieObservers() {
    Services.obs.addObserver(cookieHelpers, "cookie-changed");
    Services.obs.addObserver(cookieHelpers, "private-cookie-changed");
    return null;
  },

  removeCookieObservers() {
    Services.obs.removeObserver(cookieHelpers, "cookie-changed");
    Services.obs.removeObserver(cookieHelpers, "private-cookie-changed");
    return null;
  },

  observe(subject, topic, data) {
    if (!subject) {
      return;
    }

    switch (topic) {
      case "cookie-changed":
      case "private-cookie-changed":
        if (data === "batch-deleted") {
          const cookiesNoInterface = subject.QueryInterface(Ci.nsIArray);
          const cookies = [];

          for (let i = 0; i < cookiesNoInterface.length; i++) {
            const cookie = cookiesNoInterface.queryElementAt(i, Ci.nsICookie);
            cookies.push(cookie);
          }
          cookieHelpers.onCookieChanged(cookies, topic, data);

          return;
        }

        const cookie = subject.QueryInterface(Ci.nsICookie);
        cookieHelpers.onCookieChanged(cookie, topic, data);
        break;
    }
  },

  handleParentRequest(msg) {
    switch (msg.json.method) {
      case "onCookieChanged":
        let [cookie, topic, data] = msg.data.args;
        cookie = JSON.parse(cookie);
        cookieHelpers.onCookieChanged(cookie, topic, data);
        break;
    }
  },

  handleChildRequest(msg) {
    switch (msg.json.method) {
      case "getCookiesFromHost": {
        const host = msg.data.args[0];
        const originAttributes = msg.data.args[1];
        const cookies = cookieHelpers.getCookiesFromHost(
          host,
          originAttributes
        );
        return JSON.stringify(cookies);
      }
      case "addCookieObservers": {
        return cookieHelpers.addCookieObservers();
      }
      case "removeCookieObservers": {
        return cookieHelpers.removeCookieObservers();
      }
      case "addCookie": {
        const guid = msg.data.args[0];
        const principal = msg.data.args[1];
        return cookieHelpers.addCookie(guid, principal);
      }
      case "editCookie": {
        const rowdata = msg.data.args[0];
        return cookieHelpers.editCookie(rowdata);
      }
      case "createNewCookie": {
        const host = msg.data.args[0];
        const guid = msg.data.args[1];
        const originAttributes = msg.data.args[2];
        return cookieHelpers.createNewCookie(host, guid, originAttributes);
      }
      case "removeCookie": {
        const host = msg.data.args[0];
        const name = msg.data.args[1];
        const originAttributes = msg.data.args[2];
        return cookieHelpers.removeCookie(host, name, originAttributes);
      }
      case "removeAllCookies": {
        const host = msg.data.args[0];
        const domain = msg.data.args[1];
        const originAttributes = msg.data.args[2];
        return cookieHelpers.removeAllCookies(host, domain, originAttributes);
      }
      case "removeAllSessionCookies": {
        const host = msg.data.args[0];
        const domain = msg.data.args[1];
        const originAttributes = msg.data.args[2];
        return cookieHelpers.removeAllSessionCookies(
          host,
          domain,
          originAttributes
        );
      }
      default:
        console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method);
        throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD");
    }
  },
};

/**
 * E10S parent/child setup helpers
 */

exports.setupParentProcessForCookies = function({ mm, prefix }) {
  cookieHelpers.onCookieChanged = callChildProcess.bind(
    null,
    "onCookieChanged"
  );

  // listen for director-script requests from the child process
  mm.addMessageListener(
    "debug:storage-cookie-request-parent",
    cookieHelpers.handleChildRequest
  );

  function callChildProcess(methodName, ...args) {
    if (methodName === "onCookieChanged") {
      args[0] = JSON.stringify(args[0]);
    }

    try {
      mm.sendAsyncMessage("debug:storage-cookie-request-child", {
        method: methodName,
        args: args,
      });
    } catch (e) {
      // We may receive a NS_ERROR_NOT_INITIALIZED if the target window has
      // been closed. This can legitimately happen in between test runs.
    }
  }

  return {
    onDisconnected: () => {
      // Although "disconnected-from-child" implies that the child is already
      // disconnected this is not the case. The disconnection takes place after
      // this method has finished. This gives us chance to clean up items within
      // the parent process e.g. observers.
      cookieHelpers.removeCookieObservers();
      mm.removeMessageListener(
        "debug:storage-cookie-request-parent",
        cookieHelpers.handleChildRequest
      );
    },
  };
};

/**
 * Helper method to create the overriden object required in
 * StorageActors.createActor for Local Storage and Session Storage.
 * This method exists as both Local Storage and Session Storage have almost
 * identical actors.
 */
function getObjectForLocalOrSessionStorage(type) {
  return {
    getNamesForHost(host) {
      const storage = this.hostVsStores.get(host);
      return storage ? Object.keys(storage) : [];
    },

    getValuesForHost(host, name) {
      const storage = this.hostVsStores.get(host);
      if (!storage) {
        return [];
      }
      if (name) {
        const value = storage ? storage.getItem(name) : null;
        return [{ name, value }];
      }
      if (!storage) {
        return [];
      }

      // local and session storage cannot be iterated over using Object.keys()
      // because it skips keys that are duplicated on the prototype
      // e.g. "key", "getKeys" so we need to gather the real keys using the
      // storage.key() function.
      const storageArray = [];
      for (let i = 0; i < storage.length; i++) {
        const key = storage.key(i);
        storageArray.push({
          name: key,
          value: storage.getItem(key),
        });
      }
      return storageArray;
    },

    populateStoresForHost(host, window) {
      try {
        this.hostVsStores.set(host, window[type]);
      } catch (ex) {
        console.warn(`Failed to enumerate ${type} for host ${host}: ${ex}`);
      }
    },

    populateStoresForHosts() {
      this.hostVsStores = new Map();
      for (const window of this.windows) {
        const host = this.getHostName(window.location);
        if (host) {
          this.populateStoresForHost(host, window);
        }
      }
    },

    async getFields() {
      return [
        { name: "name", editable: true },
        { name: "value", editable: true },
      ];
    },

    async addItem(guid, host) {
      const storage = this.hostVsStores.get(host);
      if (!storage) {
        return;
      }
      storage.setItem(guid, DEFAULT_VALUE);
    },

    /**
     * Edit localStorage or sessionStorage fields.
     *
     * @param {Object} data
     *        See editCookie() for format details.
     */
    async editItem({ host, field, oldValue, items }) {
      const storage = this.hostVsStores.get(host);
      if (!storage) {
        return;
      }

      if (field === "name") {
        storage.removeItem(oldValue);
      }

      storage.setItem(items.name, items.value);
    },

    async removeItem(host, name) {
      const storage = this.hostVsStores.get(host);
      if (!storage) {
        return;
      }
      storage.removeItem(name);
    },

    async removeAll(host) {
      const storage = this.hostVsStores.get(host);
      if (!storage) {
        return;
      }
      storage.clear();
    },

    observe(subject, topic, data) {
      if (
        (topic != "dom-storage2-changed" &&
          topic != "dom-private-storage2-changed") ||
        data != type
      ) {
        return null;
      }

      const host = this.getSchemaAndHost(subject.url);

      if (!this.hostVsStores.has(host)) {
        return null;
      }

      let action = "changed";
      if (subject.key == null) {
        return this.storageActor.update("cleared", type, [host]);
      } else if (subject.oldValue == null) {
        action = "added";
      } else if (subject.newValue == null) {
        action = "deleted";
      }
      const updateData = {};
      updateData[host] = [subject.key];
      return this.storageActor.update(action, type, updateData);
    },

    /**
     * Given a url, correctly determine its protocol + hostname part.
     */
    getSchemaAndHost(url) {
      const uri = Services.io.newURI(url);
      if (!uri.host) {
        return uri.spec;
      }
      return uri.scheme + "://" + uri.hostPort;
    },

    toStoreObject(item) {
      if (!item) {
        return null;
      }

      return {
        name: item.name,
        value: new LongStringActor(this.conn, item.value || ""),
      };
    },
  };
}

/**
 * The Local Storage actor and front.
 */
StorageActors.createActor(
  {
    typeName: "localStorage",
    observationTopics: ["dom-storage2-changed", "dom-private-storage2-changed"],
  },
  getObjectForLocalOrSessionStorage("localStorage")
);

/**
 * The Session Storage actor and front.
 */
StorageActors.createActor(
  {
    typeName: "sessionStorage",
    observationTopics: ["dom-storage2-changed", "dom-private-storage2-changed"],
  },
  getObjectForLocalOrSessionStorage("sessionStorage")
);

const extensionStorageHelpers = {
  unresolvedPromises: new Map(),
  // Map of addonId => onStorageChange listeners in the parent process. Each addon toolbox targets
  // a single addon, and multiple addon toolboxes could be open at the same time.
  onChangedParentListeners: new Map(),
  // Set of onStorageChange listeners in the extension child process. Each addon toolbox will create
  // a separate extensionStorage actor targeting that addon. The addonId is passed into the listener,
  // so that changes propagate only if the storage actor has a matching addonId.
  onChangedChildListeners: new Set(),
  /**
   * Editing is supported only for serializable types. Examples of unserializable
   * types include Map, Set and ArrayBuffer.
   */
  isEditable(value) {
    // Bug 1542038: the managed storage area is never editable
    for (const { test } of Object.values(this.supportedTypes)) {
      if (test(value)) {
        return true;
      }
    }
    return false;
  },
  isPrimitive(value) {
    const primitiveValueTypes = ["string", "number", "boolean"];
    return primitiveValueTypes.includes(typeof value) || value === null;
  },
  isObjectLiteral(value) {
    return (
      value &&
      typeof value === "object" &&
      Cu.getClassName(value, true) === "Object"
    );
  },
  // Nested arrays or object literals are only editable 2 levels deep
  isArrayOrObjectLiteralEditable(obj) {
    const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj);
    if (
      topLevelValuesArr.some(
        value =>
          !this.isPrimitive(value) &&
          !Array.isArray(value) &&
          !this.isObjectLiteral(value)
      )
    ) {
      // At least one value is too complex to parse
      return false;
    }
    const arrayOrObjects = topLevelValuesArr.filter(
      value => Array.isArray(value) || this.isObjectLiteral(value)
    );
    if (arrayOrObjects.length === 0) {
      // All top level values are primitives
      return true;
    }

    // One or more top level values was an array or object literal.
    // All of these top level values must themselves have only primitive values
    // for the object to be editable
    for (const nestedObj of arrayOrObjects) {
      const secondLevelValuesArr = Array.isArray(nestedObj)
        ? nestedObj
        : Object.values(nestedObj);
      if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) {
        return false;
      }
    }
    return true;
  },
  typesFromString: {
    // Helper methods to parse string values in editItem
    jsonifiable: {
      test(str) {
        try {
          JSON.parse(str);
        } catch (e) {
          return false;
        }
        return true;
      },
      parse(str) {
        return JSON.parse(str);
      },
    },
  },
  supportedTypes: {
    // Helper methods to determine the value type of an item in isEditable
    array: {
      test(value) {
        if (Array.isArray(value)) {
          return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
        }
        return false;
      },
    },
    boolean: {
      test(value) {
        return typeof value === "boolean";
      },
    },
    null: {
      test(value) {
        return value === null;
      },
    },
    number: {
      test(value) {
        return typeof value === "number";
      },
    },
    object: {
      test(value) {
        if (extensionStorageHelpers.isObjectLiteral(value)) {
          return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
        }
        return false;
      },
    },
    string: {
      test(value) {
        return typeof value === "string";
      },
    },
  },

  // Sets the parent process message manager
  setPpmm(ppmm) {
    this.ppmm = ppmm;
  },

  // A promise in the main process has resolved, and we need to pass the return value(s)
  // back to the child process
  backToChild(...args) {
    Services.mm.broadcastAsyncMessage(
      "debug:storage-extensionStorage-request-child",
      {
        method: "backToChild",
        args: args,
      }
    );
  },

  // Send a message from the main process to a listener in the child process that the
  // extension has modified storage local data
  fireStorageOnChanged({ addonId, changes }) {
    Services.mm.broadcastAsyncMessage(
      "debug:storage-extensionStorage-request-child",
      {
        addonId,
        changes,
        method: "storageOnChanged",
      }
    );
  },

  // Subscribe a listener for event notifications from the WE storage API when
  // storage local data has been changed by the extension, and keep track of the
  // listener to remove it when the debugger is being disconnected.
  subscribeOnChangedListenerInParent(addonId) {
    if (!this.onChangedParentListeners.has(addonId)) {
      const onChangedListener = changes => {
        this.fireStorageOnChanged({ addonId, changes });
      };
      ExtensionStorageIDB.addOnChangedListener(addonId, onChangedListener);
      this.onChangedParentListeners.set(addonId, onChangedListener);
    }
  },

  // The main process does not require an extension context to select the backend
  // Bug 1542038, 1542039: Each storage area will need its own implementation, as
  // they use different storage backends.
  async setupStorageInParent(addonId) {
    const { extension } = WebExtensionPolicy.getByID(addonId);
    const parentResult = await ExtensionStorageIDB.selectBackend({ extension });
    const result = {
      ...parentResult,
      // Received as a StructuredCloneHolder, so we need to deserialize
      storagePrincipal: parentResult.storagePrincipal.deserialize(this, true),
    };

    this.subscribeOnChangedListenerInParent(addonId);
    return this.backToChild("setupStorageInParent", result);
  },

  onDisconnected() {
    for (const [addonId, listener] of this.onChangedParentListeners) {
      ExtensionStorageIDB.removeOnChangedListener(addonId, listener);
    }
    this.onChangedParentListeners.clear();
  },

  // Runs in the main process. This determines what code to execute based on the message
  // received from the child process.
  async handleChildRequest(msg) {
    switch (msg.json.method) {
      case "setupStorageInParent":
        const addonId = msg.data.args[0];
        const result = await extensionStorageHelpers.setupStorageInParent(
          addonId
        );
        return result;
      default:
        console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method);
        throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD");
    }
  },

  // Runs in the child process. This determines what code to execute based on the message
  // received from the parent process.
  handleParentRequest(msg) {
    switch (msg.json.method) {
      case "backToChild": {
        const [func, rv] = msg.json.args;
        const resolve = this.unresolvedPromises.get(func);
        if (resolve) {
          this.unresolvedPromises.delete(func);
          resolve(rv);
        }
        break;
      }
      case "storageOnChanged": {
        const { addonId, changes } = msg.data;
        for (const listener of this.onChangedChildListeners) {
          try {
            listener({ addonId, changes });
          } catch (err) {
            console.error(err);
            // Ignore errors raised from listeners.
          }
        }
        break;
      }
      default:
        console.error("ERR_DIRECTOR_CLIENT_UNKNOWN_METHOD", msg.json.method);
        throw new Error("ERR_DIRECTOR_CLIENT_UNKNOWN_METHOD");
    }
  },

  callParentProcessAsync(methodName, ...args) {
    const promise = new Promise(resolve => {
      this.unresolvedPromises.set(methodName, resolve);
    });

    this.ppmm.sendAsyncMessage(
      "debug:storage-extensionStorage-request-parent",
      {
        method: methodName,
        args: args,
      }
    );

    return promise;
  },
};

/**
 * E10S parent/child setup helpers
 * Add a message listener in the parent process to receive messages from the child
 * process.
 */
exports.setupParentProcessForExtensionStorage = function({ mm, prefix }) {
  // listen for director-script requests from the child process
  mm.addMessageListener(
    "debug:storage-extensionStorage-request-parent",
    extensionStorageHelpers.handleChildRequest
  );

  return {
    onDisconnected: () => {
      // Although "disconnected-from-child" implies that the child is already
      // disconnected this is not the case. The disconnection takes place after
      // this method has finished. This gives us chance to clean up items within
      // the parent process e.g. observers.
      mm.removeMessageListener(
        "debug:storage-extensionStorage-request-parent",
        extensionStorageHelpers.handleChildRequest
      );
      extensionStorageHelpers.onDisconnected();
    },
  };
};

/**
 * The Extension Storage actor.
 */
if (Services.prefs.getBoolPref(EXTENSION_STORAGE_ENABLED_PREF, false)) {
  StorageActors.createActor(
    {
      typeName: "extensionStorage",
    },
    {
      initialize(storageActor) {
        protocol.Actor.prototype.initialize.call(this, null);

        this.storageActor = storageActor;

        this.addonId = this.storageActor.parentActor.addonId;

        // Retrieve the base moz-extension url for the extension
        // (and also remove the final '/' from it).
        this.extensionHostURL = this.getExtensionPolicy()
          .getURL()
          .slice(0, -1);

        // Map<host, ExtensionStorageIDB db connection>
        // Bug 1542038, 1542039: Each storage area will need its own
        // dbConnectionForHost, as they each have different storage backends.
        // Anywhere dbConnectionForHost is used, we need to know the storage
        // area to access the correct database.
        this.dbConnectionForHost = new Map();

        // Bug 1542038, 1542039: Each storage area will need its own
        // this.hostVsStores or this actor will need to deviate from how
        // this.hostVsStores is defined in the framework to associate each
        // storage item with a storage area. Any methods that use it will also
        // need to be updated (e.g. getNamesForHost).
        this.hostVsStores = new Map();

        this.onStorageChange = this.onStorageChange.bind(this);

        this.setupChildProcess();

        this.onWindowReady = this.onWindowReady.bind(this);
        this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
        this.storageActor.on("window-ready", this.onWindowReady);
        this.storageActor.on("window-destroyed", this.onWindowDestroyed);
      },

      getExtensionPolicy() {
        return WebExtensionPolicy.getByID(this.addonId);
      },

      destroy() {
        extensionStorageHelpers.onChangedChildListeners.delete(
          this.onStorageChange
        );

        this.storageActor.off("window-ready", this.onWindowReady);
        this.storageActor.off("window-destroyed", this.onWindowDestroyed);

        this.hostVsStores.clear();
        protocol.Actor.prototype.destroy.call(this);

        this.storageActor = null;
      },

      setupChildProcess() {
        const ppmm = this.conn.parentMessageManager;
        extensionStorageHelpers.setPpmm(ppmm);

        // eslint-disable-next-line no-restricted-properties
        this.conn.setupInParent({
          module: "devtools/server/actors/storage",
          setupParent: "setupParentProcessForExtensionStorage",
        });

        extensionStorageHelpers.onChangedChildListeners.add(
          this.onStorageChange
        );
        this.setupStorageInParent = extensionStorageHelpers.callParentProcessAsync.bind(
          extensionStorageHelpers,
          "setupStorageInParent"
        );

        // Add a message listener in the child process to receive messages from the parent
        // process
        ppmm.addMessageListener(
          "debug:storage-extensionStorage-request-child",
          extensionStorageHelpers.handleParentRequest.bind(
            extensionStorageHelpers
          )
        );
      },

      /**
       * This fires when the extension changes storage data while the storage
       * inspector is open. Ensures this.hostVsStores stays up-to-date and
       * passes the changes on to update the client.
       */
      onStorageChange({ addonId, changes }) {
        if (addonId !== this.addonId) {
          return;
        }

        const host = this.extensionHostURL;
        const storeMap = this.hostVsStores.get(host);

        function isStructuredCloneHolder(value) {
          return (
            value &&
            typeof value === "object" &&
            Cu.getClassName(value, true) === "StructuredCloneHolder"
          );
        }

        for (const key in changes) {
          const storageChange = changes[key];
          let { newValue, oldValue } = storageChange;
          if (isStructuredCloneHolder(newValue)) {
            newValue = newValue.deserialize(this);
          }
          if (isStructuredCloneHolder(oldValue)) {
            oldValue = oldValue.deserialize(this);
          }

          let action;
          if (typeof newValue === "undefined") {
            action = "deleted";
            storeMap.delete(key);
          } else if (typeof oldValue === "undefined") {
            action = "added";
            storeMap.set(key, newValue);
          } else {
            action = "changed";
            storeMap.set(key, newValue);
          }

          this.storageActor.update(action, this.typeName, { [host]: [key] });
        }
      },

      /**
       * Purpose of this method is same as populateStoresForHosts but this is async.
       * This exact same operation cannot be performed in populateStoresForHosts
       * method, as that method is called in initialize method of the actor, which
       * cannot be asynchronous.
       */
      async preListStores() {
        // Ensure the actor's target is an extension and it is enabled
        if (!this.addonId || !WebExtensionPolicy.getByID(this.addonId)) {
          return;
        }

        await this.populateStoresForHost(this.extensionHostURL);
      },

      /**
       * This method is overriden and left blank as for extensionStorage, this operation
       * cannot be performed synchronously. Thus, the preListStores method exists to
       * do the same task asynchronously.
       */
      populateStoresForHosts() {},

      /**
       * This method asynchronously reads the storage data for the target extension
       * and caches this data into this.hostVsStores.
       * @param {String} host - the hostname for the extension
       */
      async populateStoresForHost(host) {
        if (host !== this.extensionHostURL) {
          return;
        }

        const extension = ExtensionProcessScript.getExtensionChild(
          this.addonId
        );
        if (!extension || !extension.hasPermission("storage")) {
          return;
        }

        // Make sure storeMap is defined and set in this.hostVsStores before subscribing
        // a storage onChanged listener in the parent process
        const storeMap = new Map();
        this.hostVsStores.set(host, storeMap);

        const storagePrincipal = await this.getStoragePrincipal(extension.id);

        if (!storagePrincipal) {
          // This could happen if the extension fails to be migrated to the
          // IndexedDB backend
          return;
        }

        const db = await ExtensionStorageIDB.open(storagePrincipal);
        this.dbConnectionForHost.set(host, db);
        const data = await db.get();

        for (const [key, value] of Object.entries(data)) {
          storeMap.set(key, value);
        }

        if (this.storageActor.parentActor.fallbackWindow) {
          // Show the storage actor in the add-on storage inspector even when there
          // is no extension page currently open
          // This strategy may need to change depending on the outcome of Bug 1597900
          const storageData = {};
          storageData[host] = this.getNamesForHost(host);
          this.storageActor.update("added", this.typeName, storageData);
        }
      },

      async getStoragePrincipal(addonId) {
        const {
          backendEnabled,
          storagePrincipal,
        } = await this.setupStorageInParent(addonId);

        if (!backendEnabled) {
          // IDB backend disabled; give up.
          return null;
        }
        return storagePrincipal;
      },

      getValuesForHost(host, name) {
        const result = [];

        if (!this.hostVsStores.has(host)) {
          return result;
        }

        if (name) {
          return [{ name, value: this.hostVsStores.get(host).get(name) }];
        }

        for (const [key, value] of Array.from(
          this.hostVsStores.get(host).entries()
        )) {
          result.push({ name: key, value });
        }
        return result;
      },

      /**
       * Converts a storage item to an "extensionobject" as defined in
       * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor,
       * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined`
       * `item.value`).
       * @param {Object} item - The storage item to convert
       * @param {String} item.name - The storage item key
       * @param {*} item.value - The storage item value
       * @return {extensionobject}
       */
      toStoreObject(item) {
        if (!item) {
          return null;
        }

        let { name, value } = item;
        let isValueEditable = extensionStorageHelpers.isEditable(value);

        // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings,
        // and doesn't modify `undefined`.
        switch (typeof value) {
          case "bigint":
            value = `${value.toString()}n`;
            break;
          case "string":
            break;
          case "undefined":
            value = "undefined";
            break;
          default:
            value = JSON.stringify(value);
            if (
              // can't use `instanceof` across frame boundaries
              Object.prototype.toString.call(item.value) === "[object Date]"
            ) {
              value = JSON.parse(value);
            }
        }

        // FIXME: Bug 1318029 - Due to a bug that is thrown whenever a
        // LongStringActor string reaches DevToolsServer.LONG_STRING_LENGTH we need
        // to trim the value. When the bug is fixed we should stop trimming the
        // string here.
        const maxLength = DevToolsServer.LONG_STRING_LENGTH - 1;
        if (value.length > maxLength) {
          value = value.substr(0, maxLength);
          isValueEditable = false;
        }

        return {
          name,
          value: new LongStringActor(this.conn, value),
          area: "local", // Bug 1542038, 1542039: set the correct storage area
          isValueEditable,
        };
      },

      getFields() {
        return [
          { name: "name", editable: false },
          { name: "value", editable: true },
          { name: "area", editable: false },
          { name: "isValueEditable", editable: false, private: true },
        ];
      },

      onItemUpdated(action, host, names) {
        this.storageActor.update(action, this.typeName, {
          [host]: names,
        });
      },

      async editItem({ host, field, items, oldValue }) {
        const db = this.dbConnectionForHost.get(host);
        if (!db) {
          return;
        }

        const { name, value } = items;

        let parsedValue = parseItemValue(value);
        if (parsedValue === value) {
          const { typesFromString } = extensionStorageHelpers;
          for (const { test, parse } of Object.values(typesFromString)) {
            if (test(value)) {
              parsedValue = parse(value);
              break;
            }
          }
        }
        const changes = await db.set({ [name]: parsedValue });
        this.fireOnChangedExtensionEvent(host, changes);

        this.onItemUpdated("changed", host, [name]);
      },

      async removeItem(host, name) {
        const db = this.dbConnectionForHost.get(host);
        if (!db) {
          return;
        }

        const changes = await db.remove(name);
        this.fireOnChangedExtensionEvent(host, changes);

        this.onItemUpdated("deleted", host, [name]);
      },

      async removeAll(host) {
        const db = this.dbConnectionForHost.get(host);
        if (!db) {
          return;
        }

        const changes = await db.clear();
        this.fireOnChangedExtensionEvent(host, changes);

        this.onItemUpdated("cleared", host, []);
      },

      /**
       * Let the extension know that storage data has been changed by the user from
       * the storage inspector.
       */
      fireOnChangedExtensionEvent(host, changes) {
        // Bug 1542038, 1542039: Which message to send depends on the storage area
        const uuid = new URL(host).host;
        Services.cpmm.sendAsyncMessage(
          `Extension:StorageLocalOnChanged:${uuid}`,
          changes
        );
      },
    }
  );
}

StorageActors.createActor(
  {
    typeName: "Cache",
  },
  {
    async getCachesForHost(host) {
      const win = this.storageActor.getWindowFromHost(host);
      if (!win) {
        return null;
      }

      const principal = win.document.effectiveStoragePrincipal;

      // The first argument tells if you want to get |content| cache or |chrome|
      // cache.
      // The |content| cache is the cache explicitely named by the web content
      // (service worker or web page).
      // The |chrome| cache is the cache implicitely cached by the platform,
      // hosting the source file of the service worker.
      const { CacheStorage } = win;

      if (!CacheStorage) {
        return null;
      }

      const cache = new CacheStorage("content", principal);
      return cache;
    },

    async preListStores() {
      for (const host of this.hosts) {
        await this.populateStoresForHost(host);
      }
    },

    form() {
      const hosts = {};
      for (const host of this.hosts) {
        hosts[host] = this.getNamesForHost(host);
      }

      return {
        actor: this.actorID,
        hosts: hosts,
        traits: this._getTraits(),
      };
    },

    getNamesForHost(host) {
      // UI code expect each name to be a JSON string of an array :/
      return [...this.hostVsStores.get(host).keys()].map(a => {
        return JSON.stringify([a]);
      });
    },

    async getValuesForHost(host, name) {
      if (!name) {
        // if we get here, we most likely clicked on the refresh button
        // which called getStoreObjects, itself calling this method,
        // all that, without having selected any particular cache name.
        //
        // Try to detect if a new cache has been added and notify the client
        // asynchronously, via a RDP event.
        const previousCaches = [...this.hostVsStores.get(host).keys()];
        await this.preListStores();
        const updatedCaches = [...this.hostVsStores.get(host).keys()];
        const newCaches = updatedCaches.filter(
          cacheName => !previousCaches.includes(cacheName)
        );
        newCaches.forEach(cacheName =>
          this.onItemUpdated("added", host, [cacheName])
        );
        const removedCaches = previousCaches.filter(
          cacheName => !updatedCaches.includes(cacheName)
        );
        removedCaches.forEach(cacheName =>
          this.onItemUpdated("deleted", host, [cacheName])
        );
        return [];
      }
      // UI is weird and expect a JSON stringified array... and pass it back :/
      name = JSON.parse(name)[0];

      const cache = this.hostVsStores.get(host).get(name);
      const requests = await cache.keys();
      const results = [];
      for (const request of requests) {
        let response = await cache.match(request);
        // Unwrap the response to get access to all its properties if the
        // response happen to be 'opaque', when it is a Cross Origin Request.
        response = response.cloneUnfiltered();
        results.push(await this.processEntry(request, response));
      }
      return results;
    },

    async processEntry(request, response) {
      return {
        url: String(request.url),
        status: String(response.statusText),
      };
    },

    async getFields() {
      return [
        { name: "url", editable: false },
        { name: "status", editable: false },
      ];
    },

    async populateStoresForHost(host) {
      const storeMap = new Map();
      const caches = await this.getCachesForHost(host);
      try {
        for (const name of await caches.keys()) {
          storeMap.set(name, await caches.open(name));
        }
      } catch (ex) {
        console.warn(
          `Failed to enumerate CacheStorage for host ${host}: ${ex}`
        );
      }
      this.hostVsStores.set(host, storeMap);
    },

    /**
     * This method is overriden and left blank as for Cache Storage, this
     * operation cannot be performed synchronously. Thus, the preListStores
     * method exists to do the same task asynchronously.
     */
    populateStoresForHosts() {
      this.hostVsStores = new Map();
    },

    /**
     * Given a url, correctly determine its protocol + hostname part.
     */
    getSchemaAndHost(url) {
      const uri = Services.io.newURI(url);
      return uri.scheme + "://" + uri.hostPort;
    },

    toStoreObject(item) {
      return item;
    },

    async removeItem(host, name) {
      const cacheMap = this.hostVsStores.get(host);
      if (!cacheMap) {
        return;
      }

      const parsedName = JSON.parse(name);

      if (parsedName.length == 1) {
        // Delete the whole Cache object
        const [cacheName] = parsedName;
        cacheMap.delete(cacheName);
        const cacheStorage = await this.getCachesForHost(host);
        await cacheStorage.delete(cacheName);
        this.onItemUpdated("deleted", host, [cacheName]);
      } else if (parsedName.length == 2) {
        // Delete one cached request
        const [cacheName, url] = parsedName;
        const cache = cacheMap.get(cacheName);
        if (cache) {
          await cache.delete(url);
          this.onItemUpdated("deleted", host, [cacheName, url]);
        }
      }
    },

    async removeAll(host, name) {
      const cacheMap = this.hostVsStores.get(host);
      if (!cacheMap) {
        return;
      }

      const parsedName = JSON.parse(name);

      // Only a Cache object is a valid object to clear
      if (parsedName.length == 1) {
        const [cacheName] = parsedName;
        const cache = cacheMap.get(cacheName);
        if (cache) {
          const keys = await cache.keys();
          await Promise.all(keys.map(key => cache.delete(key)));
          this.onItemUpdated("cleared", host, [cacheName]);
        }
      }
    },

    /**
     * CacheStorage API doesn't support any notifications, we must fake them
     */
    onItemUpdated(action, host, path) {
      this.storageActor.update(action, "Cache", {
        [host]: [JSON.stringify(path)],
      });
    },
  }
);

/**
 * Code related to the Indexed DB actor and front
 */

// Metadata holder objects for various components of Indexed DB

/**
 * Meta data object for a particular index in an object store
 *
 * @param {IDBIndex} index
 *        The particular index from the object store.
 */
function IndexMetadata(index) {
  this._name = index.name;
  this._keyPath = index.keyPath;
  this._unique = index.unique;
  this._multiEntry = index.multiEntry;
}
IndexMetadata.prototype = {
  toObject() {
    return {
      name: this._name,
      keyPath: this._keyPath,
      unique: this._unique,
      multiEntry: this._multiEntry,
    };
  },
};

/**
 * Meta data object for a particular object store in a db
 *
 * @param {IDBObjectStore} objectStore
 *        The particular object store from the db.
 */
function ObjectStoreMetadata(objectStore) {
  this._name = objectStore.name;
  this._keyPath = objectStore.keyPath;
  this._autoIncrement = objectStore.autoIncrement;
  this._indexes = [];

  for (let i = 0; i < objectStore.indexNames.length; i++) {
    const index = objectStore.index(objectStore.indexNames[i]);

    const newIndex = {
      keypath: index.keyPath,
      multiEntry: index.multiEntry,
      name: index.name,
      objectStore: {
        autoIncrement: index.objectStore.autoIncrement,
        indexNames: [...index.objectStore.indexNames],
        keyPath: index.objectStore.keyPath,
        name: index.objectStore.name,
      },
    };

    this._indexes.push([newIndex, new IndexMetadata(index)]);
  }
}
ObjectStoreMetadata.prototype = {
  toObject() {
    return {
      name: this._name,
      keyPath: this._keyPath,
      autoIncrement: this._autoIncrement,
      indexes: JSON.stringify(
        [...this._indexes.values()].map(index => index.toObject())
      ),
    };
  },
};

/**
 * Meta data object for a particular indexed db in a host.
 *
 * @param {string} origin
 *        The host associated with this indexed db.
 * @param {IDBDatabase} db
 *        The particular indexed db.
 * @param {String} storage
 *        Storage type, either "temporary", "default" or "persistent".
 */
function DatabaseMetadata(origin, db, storage) {
  this._origin = origin;
  this._name = db.name;
  this._version = db.version;
  this._objectStores = [];
  this.storage = storage;

  if (db.objectStoreNames.length) {
    const transaction = db.transaction(db.objectStoreNames, "readonly");

    for (let i = 0; i < transaction.objectStoreNames.length; i++) {
      const objectStore = transaction.objectStore(
        transaction.objectStoreNames[i]
      );
      this._objectStores.push([
        transaction.objectStoreNames[i],
        new ObjectStoreMetadata(objectStore),
      ]);
    }
  }
}
DatabaseMetadata.prototype = {
  get objectStores() {
    return this._objectStores;
  },

  toObject() {
    return {
      uniqueKey: `${this._name}${SEPARATOR_GUID}${this.storage}`,
      name: this._name,
      storage: this.storage,
      origin: this._origin,
      version: this._version,
      objectStores: this._objectStores.size,
    };
  },
};

StorageActors.createActor(
  {
    typeName: "indexedDB",
  },
  {
    initialize(storageActor) {
      protocol.Actor.prototype.initialize.call(this, null);

      this.storageActor = storageActor;

      this.maybeSetupChildProcess();

      this.objectsSize = {};
      this.storageActor = storageActor;
      this.onWindowReady = this.onWindowReady.bind(this);
      this.onWindowDestroyed = this.onWindowDestroyed.bind(this);

      this.storageActor.on("window-ready", this.onWindowReady);
      this.storageActor.on("window-destroyed", this.onWindowDestroyed);
    },

    destroy() {
      this.hostVsStores.clear();
      this.objectsSize = null;

      this.storageActor.off("window-ready", this.onWindowReady);
      this.storageActor.off("window-destroyed", this.onWindowDestroyed);

      protocol.Actor.prototype.destroy.call(this);

      this.storageActor = null;
    },

    /**
     * Returns a list of currently known hosts for the target window. This list
     * contains unique hosts from the window, all inner windows and all permanent
     * indexedDB hosts defined inside the browser.
     */
    async getHosts() {
      // Add internal hosts to this._internalHosts, which will be picked up by
      // the this.hosts getter. Because this.hosts is a property on the default
      // storage actor and inherited by all storage actors we have to do it this
      // way.
      // Only look up internal hosts if we are in the browser toolbox
      const isBrowserToolbox = this.storageActor.parentActor.isRootActor;

      this._internalHosts = isBrowserToolbox
        ? await this.getInternalHosts()
        : [];

      return this.hosts;
    },

    /**
     * Remove an indexedDB database from given host with a given name.
     */
    async removeDatabase(host, name) {
      const win = this.storageActor.getWindowFromHost(host);
      if (!win) {
        return { error: `Window for host ${host} not found` };
      }

      const principal = win.document.effectiveStoragePrincipal;
      return this.removeDB(host, principal, name);
    },

    async removeAll(host, name) {
      const [db, store] = JSON.parse(name);

      const win = this.storageActor.getWindowFromHost(host);
      if (!win) {
        return;
      }

      const principal = win.document.effectiveStoragePrincipal;
      this.clearDBStore(host, principal, db, store);
    },

    async removeItem(host, name) {
      const [db, store, id] = JSON.parse(name);

      const win = this.storageActor.getWindowFromHost(host);
      if (!win) {
        return;
      }

      const principal = win.document.effectiveStoragePrincipal;
      this.removeDBRecord(host, principal, db, store, id);
    },

    /**
     * This method is overriden and left blank as for indexedDB, this operation
     * cannot be performed synchronously. Thus, the preListStores method exists to
     * do the same task asynchronously.
     */
    populateStoresForHosts() {},

    getNamesForHost(host) {
      const storesForHost = this.hostVsStores.get(host);
      if (!storesForHost) {
        return [];
      }

      const names = [];

      for (const [dbName, { objectStores }] of storesForHost) {
        if (objectStores.size) {
          for (const objectStore of objectStores.keys()) {
            names.push(JSON.stringify([dbName, objectStore]));
          }
        } else {
          names.push(JSON.stringify([dbName]));
        }
      }

      return names;
    },

    /**
     * Returns the total number of entries for various types of requests to
     * getStoreObjects for Indexed DB actor.
     *
     * @param {string} host
     *        The host for the request.
     * @param {array:string} names
     *        Array of stringified name objects for indexed db actor.
     *        The request type depends on the length of any parsed entry from this
     *        array. 0 length refers to request for the whole host. 1 length
     *        refers to request for a particular db in the host. 2 length refers
     *        to a particular object store in a db in a host. 3 length refers to
     *        particular items of an object store in a db in a host.
     * @param {object} options
     *        An options object containing following properties:
     *         - index {string} The IDBIndex for the object store in the db.
     */
    getObjectsSize(host, names, options) {
      // In Indexed DB, we are interested in only the first name, as the pattern
      // should follow in all entries.
      const name = names[0];
      const parsedName = JSON.parse(name);

      if (parsedName.length == 3) {
        // This is the case where specific entries from an object store were
        // requested
        return names.length;
      } else if (parsedName.length == 2) {
        // This is the case where all entries from an object store are requested.
        const index = options.index;
        const [db, objectStore] = parsedName;
        if (this.objectsSize[host + db + objectStore + index]) {
          return this.objectsSize[host + db + objectStore + index];
        }
      } else if (parsedName.length == 1) {
        // This is the case where details of all object stores in a db are
        // requested.
        if (
          this.hostVsStores.has(host) &&
          this.hostVsStores.get(host).has(parsedName[0])
        ) {
          return this.hostVsStores.get(host).get(parsedName[0]).objectStores
            .size;
        }
      } else if (!parsedName || !parsedName.length) {
        // This is the case were details of all dbs in a host are requested.
        if (this.hostVsStores.has(host)) {
          return this.hostVsStores.get(host).size;
        }
      }
      return 0;
    },

    /**
     * Purpose of this method is same as populateStoresForHosts but this is async.
     * This exact same operation cannot be performed in populateStoresForHosts
     * method, as that method is called in initialize method of the actor, which
     * cannot be asynchronous.
     */
    async preListStores() {
      this.hostVsStores = new Map();
      for (const host of await this.getHosts()) {
        await this.populateStoresForHost(host);
      }
    },

    async populateStoresForHost(host) {
      const storeMap = new Map();

      const win = this.storageActor.getWindowFromHost(host);
      const principal = this.getPrincipal(win);

      const { names } = await this.getDBNamesForHost(host, principal);

      for (const { name, storage } of names) {
        let metadata = await this.getDBMetaData(host, principal, name, storage);

        metadata = indexedDBHelpers.patchMetadataMapsAndProtos(metadata);

        storeMap.set(`${name} (${storage})`, metadata);
      }

      this.hostVsStores.set(host, storeMap);
    },

    /**
     * Returns the over-the-wire implementation of the indexed db entity.
     */
    toStoreObject(item) {
      if (!item) {
        return null;
      }

      if ("indexes" in item) {
        // Object store meta data
        return {
          objectStore: item.name,
          keyPath: item.keyPath,
          autoIncrement: item.autoIncrement,
          indexes: item.indexes,
        };
      }
      if ("objectStores" in item) {
        // DB meta data
        return {
          uniqueKey: `${item.name} (${item.storage})`,
          db: item.name,
          storage: item.storage,
          origin: item.origin,
          version: item.version,
          objectStores: item.objectStores,
        };
      }

      let value = JSON.stringify(item.value);

      // FIXME: Bug 1318029 - Due to a bug that is thrown whenever a
      // LongStringActor string reaches DevToolsServer.LONG_STRING_LENGTH we need
      // to trim the value. When the bug is fixed we should stop trimming the
      // string here.
      const maxLength = DevToolsServer.LONG_STRING_LENGTH - 1;
      if (value.length > maxLength) {
        value = value.substr(0, maxLength);
      }

      // Indexed db entry
      return {
        name: item.name,
        value: new LongStringActor(this.conn, value),
      };
    },

    form() {
      const hosts = {};
      for (const host of this.hosts) {
        hosts[host] = this.getNamesForHost(host);
      }

      return {
        actor: this.actorID,
        hosts: hosts,
        traits: this._getTraits(),
      };
    },

    onItemUpdated(action, host, path) {
      // Database was removed, remove it from stores map
      if (action === "deleted" && path.length === 1) {
        if (this.hostVsStores.has(host)) {
          this.hostVsStores.get(host).delete(path[0]);
        }
      }

      this.storageActor.update(action, "indexedDB", {
        [host]: [JSON.stringify(path)],
      });
    },

    maybeSetupChildProcess() {
      if (!DevToolsServer.isInChildProcess) {
        this.backToChild = (func, rv) => rv;
        this.clearDBStore = indexedDBHelpers.clearDBStore;
        this.findIDBPathsForHost = indexedDBHelpers.findIDBPathsForHost;
        this.findSqlitePathsForHost = indexedDBHelpers.findSqlitePathsForHost;
        this.findStorageTypePaths = indexedDBHelpers.findStorageTypePaths;
        this.getDBMetaData = indexedDBHelpers.getDBMetaData;
        this.getDBNamesForHost = indexedDBHelpers.getDBNamesForHost;
        this.getNameFromDatabaseFile = indexedDBHelpers.getNameFromDatabaseFile;
        this.getObjectStoreData = indexedDBHelpers.getObjectStoreData;
        this.getSanitizedHost = indexedDBHelpers.getSanitizedHost;
        this.getValuesForHost = indexedDBHelpers.getValuesForHost;
        this.openWithPrincipal = indexedDBHelpers.openWithPrincipal;
        this.removeDB = indexedDBHelpers.removeDB;
        this.removeDBRecord = indexedDBHelpers.removeDBRecord;
        this.splitNameAndStorage = indexedDBHelpers.splitNameAndStorage;
        this.getInternalHosts = indexedDBHelpers.getInternalHosts;
        return;
      }

      const mm = this.conn.parentMessageManager;

      // eslint-disable-next-line no-restricted-properties
      this.conn.setupInParent({
        module: "devtools/server/actors/storage",
        setupParent: "setupParentProcessForIndexedDB",
      });

      this.getDBMetaData = callParentProcessAsync.bind(null, "getDBMetaData");
      this.splitNameAndStorage = callParentProcessAsync.bind(
        null,
        "splitNameAndStorage"
      );
      this.getInternalHosts = callParentProcessAsync.bind(
        null,
        "getInternalHosts"
      );
      this.getDBNamesForHost = callParentProcessAsync.bind(
        null,
        "getDBNamesForHost"
      );
      this.getValuesForHost = callParentProcessAsync.bind(
        null,
        "getValuesForHost"
      );
      this.removeDB = callParentProcessAsync.bind(null, "removeDB");
      this.removeDBRecord = callParentProcessAsync.bind(null, "removeDBRecord");
      this.clearDBStore = callParentProcessAsync.bind(null, "clearDBStore");

      mm.addMessageListener("debug:storage-indexedDB-request-child", msg => {
        switch (msg.json.method) {
          case "backToChild": {
            const [func, rv] = msg.json.args;
            const resolve = unresolvedPromises.get(func);
            if (resolve) {
              unresolvedPromises.delete(func);
              resolve(rv);
            }
            break;
          }
          case "onItemUpdated": {
            const [action, host, path] = msg.json.args;
            this.onItemUpdated(action, host, path);
          }
        }
      });

      const unresolvedPromises = new Map();
      function callParentProcessAsync(methodName, ...args) {
        const promise = new Promise(resolve => {
          unresolvedPromises.set(methodName, resolve);
        });

        mm.sendAsyncMessage("debug:storage-indexedDB-request-parent", {
          method: methodName,
          args: args,
        });

        return promise;
      }
    },

    async getFields(subType) {
      switch (subType) {
        // Detail of database
        case "database":
          return [
            { name: "objectStore", editable: false },
            { name: "keyPath", editable: false },
            { name: "autoIncrement", editable: false },
            { name: "indexes", editable: false },
          ];

        // Detail of object store
        case "object store":
          return [
            { name: "name", editable: false },
            { name: "value", editable: false },
          ];

        // Detail of indexedDB for one origin
        default:
          return [
            { name: "uniqueKey", editable: false, private: true },
            { name: "db", editable: false },
            { name: "storage", editable: false },
            { name: "origin", editable: false },
            { name: "version", editable: false },
            { name: "objectStores", editable: false },
          ];
      }
    },
  }
);

var indexedDBHelpers = {
  backToChild(...args) {
    Services.mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", {
      method: "backToChild",
      args: args,
    });
  },

  onItemUpdated(action, host, path) {
    Services.mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", {
      method: "onItemUpdated",
      args: [action, host, path],
    });
  },

  /**
   * Fetches and stores all the metadata information for the given database
   * `name` for the given `host` with its `principal`. The stored metadata
   * information is of `DatabaseMetadata` type.
   */
  async getDBMetaData(host, principal, name, storage) {
    const request = this.openWithPrincipal(principal, name, storage);
    return new Promise(resolve => {
      request.onsuccess = event => {
        const db = event.target.result;
        const dbData = new DatabaseMetadata(host, db, storage);
        db.close();

        resolve(this.backToChild("getDBMetaData", dbData));
      };
      request.onerror = ({ target }) => {
        console.error(
          `Error opening indexeddb database ${name} for host ${host}`,
          target.error
        );
        resolve(this.backToChild("getDBMetaData", null));
      };
    });
  },

  splitNameAndStorage: function(name) {
    const lastOpenBracketIndex = name.lastIndexOf("(");
    const lastCloseBracketIndex = name.lastIndexOf(")");
    const delta = lastCloseBracketIndex - lastOpenBracketIndex - 1;

    const storage = name.substr(lastOpenBracketIndex + 1, delta);

    name = name.substr(0, lastOpenBracketIndex - 1);

    return { storage, name };
  },

  /**
   * Get all "internal" hosts. Internal hosts are database namespaces used by
   * the browser.
   */
  async getInternalHosts() {
    const profileDir = OS.Constants.Path.profileDir;
    const storagePath = OS.Path.join(profileDir, "storage", "permanent");
    const iterator = new OS.File.DirectoryIterator(storagePath);
    const hosts = [];

    await iterator.forEach(entry => {
      if (entry.isDir && !SAFE_HOSTS_PREFIXES_REGEX.test(entry.name)) {
        hosts.push(entry.name);
      }
    });
    iterator.close();

    return this.backToChild("getInternalHosts", hosts);
  },

  /**
   * Opens an indexed db connection for the given `principal` and
   * database `name`.
   */
  openWithPrincipal: function(principal, name, storage) {
    return indexedDBForStorage.openForPrincipal(principal, name, {
      storage: storage,
    });
  },

  async removeDB(host, principal, dbName) {
    const result = new Promise(resolve => {
      const { name, storage } = this.splitNameAndStorage(dbName);
      const request = indexedDBForStorage.deleteForPrincipal(principal, name, {
        storage: storage,
      });

      request.onsuccess = () => {
        resolve({});
        this.onItemUpdated("deleted", host, [dbName]);
      };

      request.onblocked = () => {
        console.warn(
          `Deleting indexedDB database ${name} for host ${host} is blocked`
        );
        resolve({ blocked: true });
      };

      request.onerror = () => {
        const { error } = request;
        console.warn(
          `Error deleting indexedDB database ${name} for host ${host}: ${error}`
        );
        resolve({ error: error.message });
      };

      // If the database is blocked repeatedly, the onblocked event will not
      // be fired again. To avoid waiting forever, report as blocked if nothing
      // else happens after 3 seconds.
      setTimeout(() => resolve({ blocked: true }), 3000);
    });

    return this.backToChild("removeDB", await result);
  },

  async removeDBRecord(host, principal, dbName, storeName, id) {
    let db;
    const { name, storage } = this.splitNameAndStorage(dbName);

    try {
      db = await new Promise((resolve, reject) => {
        const request = this.openWithPrincipal(principal, name, storage);
        request.onsuccess = ev => resolve(ev.target.result);
        request.onerror = ev => reject(ev.target.error);
      });

      const transaction = db.transaction(storeName, "readwrite");
      const store = transaction.objectStore(storeName);

      await new Promise((resolve, reject) => {
        const request = store.delete(id);
        request.onsuccess = () => resolve();
        request.onerror = ev => reject(ev.target.error);
      });

      this.onItemUpdated("deleted", host, [dbName, storeName, id]);
    } catch (error) {
      const recordPath = [dbName, storeName, id].join("/");
      console.error(
        `Failed to delete indexedDB record: ${recordPath}: ${error}`
      );
    }

    if (db) {
      db.close();
    }

    return this.backToChild("removeDBRecord", null);
  },

  async clearDBStore(host, principal, dbName, storeName) {
    let db;
    const { name, storage } = this.splitNameAndStorage(dbName);

    try {
      db = await new Promise((resolve, reject) => {
        const request = this.openWithPrincipal(principal, name, storage);
        request.onsuccess = ev => resolve(ev.target.result);
        request.onerror = ev => reject(ev.target.error);
      });

      const transaction = db.transaction(storeName, "readwrite");
      const store = transaction.objectStore(storeName);

      await new Promise((resolve, reject) => {
        const request = store.clear();
        request.onsuccess = () => resolve();
        request.onerror = ev => reject(ev.target.error);
      });

      this.onItemUpdated("cleared", host, [dbName, storeName]);
    } catch (error) {
      const storePath = [dbName, storeName].join("/");
      console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`);
    }

    if (db) {
      db.close();
    }

    return this.backToChild("clearDBStore", null);
  },

  /**
   * Fetches all the databases and their metadata for the given `host`.
   */
  async getDBNamesForHost(host, principal) {
    const sanitizedHost = this.getSanitizedHost(host) + principal.originSuffix;
    const profileDir = OS.Constants.Path.profileDir;
    const files = [];
    const names = [];
    const storagePath = OS.Path.join(profileDir, "storage");

    // We expect sqlite DB paths to look something like this:
    // - PathToProfileDir/storage/default/http+++www.example.com/
    //   idb/1556056096MeysDaabta.sqlite
    // - PathToProfileDir/storage/permanent/http+++www.example.com/
    //   idb/1556056096MeysDaabta.sqlite
    // - PathToProfileDir/storage/temporary/http+++www.example.com/
    //   idb/1556056096MeysDaabta.sqlite
    // The subdirectory inside the storage folder is determined by the storage
    // type:
    // - default:   { storage: "default" } or not specified.
    // - permanent: { storage: "persistent" }.
    // - temporary: { storage: "temporary" }.
    const sqliteFiles = await this.findSqlitePathsForHost(
      storagePath,
      sanitizedHost
    );

    for (const file of sqliteFiles) {
      const splitPath = OS.Path.split(file).components;
      const idbIndex = splitPath.indexOf("idb");
      const storage = splitPath[idbIndex - 2];
      const relative = file.substr(profileDir.length + 1);

      files.push({
        file: relative,
        storage: storage === "permanent" ? "persistent" : storage,
      });
    }

    if (files.length > 0) {
      for (const { file, storage } of files) {
        const name = await this.getNameFromDatabaseFile(file);
        if (name) {
          names.push({
            name,
            storage,
          });
        }
      }
    }

    return this.backToChild("getDBNamesForHost", { names });
  },

  /**
   * Find all SQLite files that hold IndexedDB data for a host, such as:
   *   storage/temporary/http+++www.example.com/idb/1556056096MeysDaabta.sqlite
   */
  async findSqlitePathsForHost(storagePath, sanitizedHost) {
    const sqlitePaths = [];
    const idbPaths = await this.findIDBPathsForHost(storagePath, sanitizedHost);
    for (const idbPath of idbPaths) {
      const iterator = new OS.File.DirectoryIterator(idbPath);
      await iterator.forEach(entry => {
        if (!entry.isDir && entry.path.endsWith(".sqlite")) {
          sqlitePaths.push(entry.path);
        }
      });
      iterator.close();
    }
    return sqlitePaths;
  },

  /**
   * Find all paths that hold IndexedDB data for a host, such as:
   *   storage/temporary/http+++www.example.com/idb
   */
  async findIDBPathsForHost(storagePath, sanitizedHost) {
    const idbPaths = [];
    const typePaths = await this.findStorageTypePaths(storagePath);
    for (const typePath of typePaths) {
      const idbPath = OS.Path.join(typePath, sanitizedHost, "idb");
      if (await OS.File.exists(idbPath)) {
        idbPaths.push(idbPath);
      }
    }
    return idbPaths;
  },

  /**
   * Find all the storage types, such as "default", "permanent", or "temporary".
   * These names have changed over time, so it seems simpler to look through all
   * types that currently exist in the profile.
   */
  async findStorageTypePaths(storagePath) {
    const iterator = new OS.File.DirectoryIterator(storagePath);
    const typePaths = [];
    await iterator.forEach(entry => {
      if (entry.isDir) {
        typePaths.push(entry.path);
      }
    });
    iterator.close();
    return typePaths;
  },

  /**
   * Removes any illegal characters from the host name to make it a valid file
   * name.
   */
  getSanitizedHost(host) {
    if (host.startsWith("about:")) {
      host = "moz-safe-" + host;
    }
    return host.replace(ILLEGAL_CHAR_REGEX, "+");
  },

  /**
   * Retrieves the proper indexed db database name from the provided .sqlite
   * file location.
   */
  async getNameFromDatabaseFile(path) {
    let connection = null;
    let retryCount = 0;

    // Content pages might be having an open transaction for the same indexed db
    // which this sqlite file belongs to. In that case, sqlite.openConnection
    // will throw. Thus we retry for some time to see if lock is removed.
    while (!connection && retryCount++ < 25) {
      try {
        connection = await Sqlite.openConnection({ path: path });
      } catch (ex) {
        // Continuously retrying is overkill. Waiting for 100ms before next try
        await sleep(100);
      }
    }

    if (!connection) {
      return null;
    }

    const rows = await connection.execute("SELECT name FROM database");
    if (rows.length != 1) {
      return null;
    }

    const name = rows[0].getResultByName("name");

    await connection.close();

    return name;
  },

  async getValuesForHost(
    host,
    name = "null",
    options,
    hostVsStores,
    principal
  ) {
    name = JSON.parse(name);
    if (!name || !name.length) {
      // This means that details about the db in this particular host are
      // requested.
      const dbs = [];
      if (hostVsStores.has(host)) {
        for (let [, db] of hostVsStores.get(host)) {
          db = indexedDBHelpers.patchMetadataMapsAndProtos(db);
          dbs.push(db.toObject());
        }
      }
      return this.backToChild("getValuesForHost", { dbs: dbs });
    }

    const [db2, objectStore, id] = name;
    if (!objectStore) {
      // This means that details about all the object stores in this db are
      // requested.
      const objectStores = [];
      if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) {
        let db = hostVsStores.get(host).get(db2);

        db = indexedDBHelpers.patchMetadataMapsAndProtos(db);

        const objectStores2 = db.objectStores;

        for (const objectStore2 of objectStores2) {
          objectStores.push(objectStore2[1].toObject());
        }
      }
      return this.backToChild("getValuesForHost", {
        objectStores: objectStores,
      });
    }
    // Get either all entries from the object store, or a particular id
    const storage = hostVsStores.get(host).get(db2).storage;
    const result = await this.getObjectStoreData(
      host,
      principal,
      db2,
      storage,
      {
        objectStore: objectStore,
        id: id,
        index: options.index,
        offset: options.offset,
        size: options.size,
      }
    );
    return this.backToChild("getValuesForHost", { result: result });
  },

  /**
   * Returns requested entries (or at most MAX_STORE_OBJECT_COUNT) from a particular
   * objectStore from the db in the given host.
   *
   * @param {string} host
   *        The given host.
   * @param {nsIPrincipal} principal
   *        The principal of the given document.
   * @param {string} dbName
   *        The name of the indexed db from the above host.
   * @param {String} storage
   *        Storage type, either "temporary", "default" or "persistent".
   * @param {Object} requestOptions
   *        An object in the following format:
   *        {
   *          objectStore: The name of the object store from the above db,
   *          id:          Id of the requested entry from the above object
   *                       store. null if all entries from the above object
   *                       store are requested,
   *          index:       Name of the IDBIndex to be iterated on while fetching
   *                       entries. null or "name" if no index is to be
   *                       iterated,
   *          offset:      offset of the entries to be fetched,
   *          size:        The intended size of the entries to be fetched
   *        }
   */
  getObjectStoreData(host, principal, dbName, storage, requestOptions) {
    const { name } = this.splitNameAndStorage(dbName);
    const request = this.openWithPrincipal(principal, name, storage);

    return new Promise((resolve, reject) => {
      let { objectStore, id, index, offset, size } = requestOptions;
      const data = [];
      let db;

      if (!size || size > MAX_STORE_OBJECT_COUNT) {
        size = MAX_STORE_OBJECT_COUNT;
      }

      request.onsuccess = event => {
        db = event.target.result;

        const transaction = db.transaction(objectStore, "readonly");
        let source = transaction.objectStore(objectStore);
        if (index && index != "name") {
          source = source.index(index);
        }

        source.count().onsuccess = event2 => {
          const objectsSize = [];
          const count = event2.target.result;
          objectsSize.push({
            key: host + dbName + objectStore + index,
            count: count,
          });

          if (!offset) {
            offset = 0;
          } else if (offset > count) {
            db.close();
            resolve([]);
            return;
          }

          if (id) {
            source.get(id).onsuccess = event3 => {
              db.close();
              resolve([{ name: id, value: event3.target.result }]);
            };
          } else {
            source.openCursor().onsuccess = event4 => {
              const cursor = event4.target.result;

              if (!cursor || data.length >= size) {
                db.close();
                resolve({
                  data: data,
                  objectsSize: objectsSize,
                });
                return;
              }
              if (offset-- <= 0) {
                data.push({ name: cursor.key, value: cursor.value });
              }
              cursor.continue();
            };
          }
        };
      };

      request.onerror = () => {
        db.close();
        resolve([]);
      };
    });
  },

  /**
   * When indexedDB metadata is parsed to and from JSON then the object's
   * prototype is dropped and any Maps are changed to arrays of arrays. This
   * method is used to repair the prototypes and fix any broken Maps.
   */
  patchMetadataMapsAndProtos(metadata) {
    const md = Object.create(DatabaseMetadata.prototype);
    Object.assign(md, metadata);

    md._objectStores = new Map(metadata._objectStores);

    for (const [name, store] of md._objectStores) {
      const obj = Object.create(ObjectStoreMetadata.prototype);
      Object.assign(obj, store);

      md._objectStores.set(name, obj);

      if (typeof store._indexes.length !== "undefined") {
        obj._indexes = new Map(store._indexes);
      }

      for (const [name2, value] of obj._indexes) {
        const obj2 = Object.create(IndexMetadata.prototype);
        Object.assign(obj2, value);

        obj._indexes.set(name2, obj2);
      }
    }

    return md;
  },

  handleChildRequest(msg) {
    const args = msg.data.args;

    switch (msg.json.method) {
      case "getDBMetaData": {
        const [host, principal, name, storage] = args;
        return indexedDBHelpers.getDBMetaData(host, principal, name, storage);
      }
      case "getInternalHosts": {
        return indexedDBHelpers.getInternalHosts();
      }
      case "splitNameAndStorage": {
        const [name] = args;
        return indexedDBHelpers.splitNameAndStorage(name);
      }
      case "getDBNamesForHost": {
        const [host, principal] = args;
        return indexedDBHelpers.getDBNamesForHost(host, principal);
      }
      case "getValuesForHost": {
        const [host, name, options, hostVsStores, principal] = args;
        return indexedDBHelpers.getValuesForHost(
          host,
          name,
          options,
          hostVsStores,
          principal
        );
      }
      case "removeDB": {
        const [host, principal, dbName] = args;
        return indexedDBHelpers.removeDB(host, principal, dbName);
      }
      case "removeDBRecord": {
        const [host, principal, db, store, id] = args;
        return indexedDBHelpers.removeDBRecord(host, principal, db, store, id);
      }
      case "clearDBStore": {
        const [host, principal, db, store] = args;
        return indexedDBHelpers.clearDBStore(host, principal, db, store);
      }
      default:
        console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method);
        throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD");
    }
  },
};

/**
 * E10S parent/child setup helpers
 */

exports.setupParentProcessForIndexedDB = function({ mm, prefix }) {
  mm.addMessageListener(
    "debug:storage-indexedDB-request-parent",
    indexedDBHelpers.handleChildRequest
  );

  return {
    onDisconnected: () => {
      mm.removeMessageListener(
        "debug:storage-indexedDB-request-parent",
        indexedDBHelpers.handleChildRequest
      );
    },
  };
};

/**
 * General helpers
 */
function trimHttpHttpsPort(url) {
  const match = url.match(/(.+):\d+$/);

  if (match) {
    url = match[1];
  }
  if (url.startsWith("http://")) {
    return url.substr(7);
  }
  if (url.startsWith("https://")) {
    return url.substr(8);
  }
  return url;
}

/**
 * The main Storage Actor.
 *
 * This class is meant to be dropped once we implement all storage
 * types via a Watcher class. (bug 1644192)
 * listStores will have been replaced by the ResourceCommand API
 * which will distribute all storage type specific actors.
 */
const StorageActor = protocol.ActorClassWithSpec(specs.storageSpec, {
  typeName: "storage",

  get window() {
    return this.parentActor.window;
  },

  get document() {
    return this.parentActor.window.document;
  },

  get windows() {
    return this.childWindowPool;
  },

  initialize(conn, targetActor) {
    protocol.Actor.prototype.initialize.call(this, conn);

    this.parentActor = targetActor;

    this.childActorPool = new Map();
    this.childWindowPool = new Set();

    // Fetch all the inner iframe windows in this tab.
    this.fetchChildWindows(this.parentActor.docShell);

    // Skip initializing storage actors already instanced as Resources
    // by the watcher. This happens when the target is a tab.
    const isAddonTarget = !!this.parentActor.addonId;
    const isWatcherEnabled = !isAddonTarget && !this.parentActor.isRootActor;
    const shallUseLegacyActors = Services.prefs.getBoolPref(
      "devtools.storage.test.forceLegacyActors",
      false
    );
    const resourcesInWatcher = {
      Cache: isWatcherEnabled,
      cookies: isWatcherEnabled,
      indexedDB: isWatcherEnabled,
      localStorage: isWatcherEnabled,
      sessionStorage: isWatcherEnabled,
    };

    // Initialize the registered store types
    for (const [store, ActorConstructor] of storageTypePool) {
      // Only create the extensionStorage actor when the debugging target
      // is an extension.
      if (store === "extensionStorage" && !isAddonTarget) {
        continue;
      }
      // Skip resource actors
      if (resourcesInWatcher[store] && !shallUseLegacyActors) {
        continue;
      }

      this.childActorPool.set(store, new ActorConstructor(this));
    }

    // Notifications that help us keep track of newly added windows and windows
    // that got removed
    Services.obs.addObserver(this, "content-document-global-created");
    Services.obs.addObserver(this, "inner-window-destroyed");
    this.onPageChange = this.onPageChange.bind(this);

    const handler = targetActor.chromeEventHandler;
    handler.addEventListener("pageshow", this.onPageChange, true);
    handler.addEventListener("pagehide", this.onPageChange, true);

    this.destroyed = false;
    this.boundUpdate = {};
  },

  destroy() {
    clearTimeout(this.batchTimer);
    this.batchTimer = null;
    // Remove observers
    Services.obs.removeObserver(this, "content-document-global-created");
    Services.obs.removeObserver(this, "inner-window-destroyed");
    this.destroyed = true;
    if (this.parentActor.browser) {
      this.parentActor.browser.removeEventListener(
        "pageshow",
        this.onPageChange,
        true
      );
      this.parentActor.browser.removeEventListener(
        "pagehide",
        this.onPageChange,
        true
      );
    }
    // Destroy the registered store types
    for (const actor of this.childActorPool.values()) {
      actor.destroy();
    }
    this.childActorPool.clear();
    this.childWindowPool.clear();

    this.childActorPool = null;
    this.childWindowPool = null;
    this.parentActor = null;
    this.boundUpdate = null;
    this._pendingResponse = null;

    protocol.Actor.prototype.destroy.call(this);
  },

  /**
   * Given a docshell, recursively find out all the child windows from it.
   *
   * @param {nsIDocShell} item
   *        The docshell from which all inner windows need to be extracted.
   */
  fetchChildWindows(item) {
    const docShell = item
      .QueryInterface(Ci.nsIDocShell)
      .QueryInterface(Ci.nsIDocShellTreeItem);
    if (!docShell.contentViewer) {
      return null;
    }
    const window = docShell.contentViewer.DOMDocument.defaultView;
    if (window.location.href == "about:blank") {
      // Skip out about:blank windows as Gecko creates them multiple times while
      // creating any global.
      return null;
    }
    this.childWindowPool.add(window);
    for (let i = 0; i < docShell.childCount; i++) {
      const child = docShell.getChildAt(i);
      this.fetchChildWindows(child);
    }
    return null;
  },

  isIncludedInTargetExtension(subject) {
    const addonId = getAddonIdForWindowGlobal(subject.windowGlobalChild);
    return addonId && addonId === this.parentActor.addonId;
  },

  isIncludedInTopLevelWindow(window) {
    return isWindowIncluded(this.window, window);
  },

  getWindowFromInnerWindowID(innerID) {
    innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data;
    for (const win of this.childWindowPool.values()) {
      const id = win.windowGlobalChild.innerWindowId;
      if (id == innerID) {
        return win;
      }
    }
    return null;
  },

  getWindowFromHost(host) {
    for (const win of this.childWindowPool.values()) {
      const origin = win.document.nodePrincipal.originNoSuffix;
      const url = win.document.URL;
      if (origin === host || url === host) {
        return win;
      }
    }
    return null;
  },

  /**
   * Event handler for any docshell update. This lets us figure out whenever
   * any new window is added, or an existing window is removed.
   */
  observe(subject, topic) {
    if (
      subject.location &&
      (!subject.location.href || subject.location.href == "about:blank")
    ) {
      return null;
    }

    // We don't want to try to find a top level window for an extension page, as
    // in many cases (e.g. background page), it is not loaded in a tab, and
    // 'isIncludedInTopLevelWindow' throws an error
    if (
      topic == "content-document-global-created" &&
      (this.isIncludedInTargetExtension(subject) ||
        this.isIncludedInTopLevelWindow(subject))
    ) {
      this.childWindowPool.add(subject);
      this.emit("window-ready", subject);
    } else if (topic == "inner-window-destroyed") {
      const window = this.getWindowFromInnerWindowID(subject);
      if (window) {
        this.childWindowPool.delete(window);
        this.emit("window-destroyed", window);
      }
    }
    return null;
  },

  /**
   * Called on "pageshow" or "pagehide" event on the chromeEventHandler of
   * current tab.
   *
   * @param {event} The event object passed to the handler. We are using these
   *        three properties from the event:
   *         - target {document} The document corresponding to the event.
   *         - type {string} Name of the event - "pageshow" or "pagehide".
   *         - persisted {boolean} true if there was no
   *                     "content-document-global-created" notification along
   *                     this event.
   */
  onPageChange({ target, type, persisted }) {
    if (this.destroyed) {
      return;
    }

    const window = target.defaultView;

    if (type == "pagehide" && this.childWindowPool.delete(window)) {
      this.emit("window-destroyed", window);
    } else if (
      type == "pageshow" &&
      persisted &&
      window.location.href &&
      window.location.href != "about:blank" &&
      this.isIncludedInTopLevelWindow(window)
    ) {
      this.childWindowPool.add(window);
      this.emit("window-ready", window);
    }
  },

  /**
   * Lists the available hosts for all the registered storage types.
   *
   * @returns {object} An object containing with the following structure:
   *  - <storageType> : StorageActor's form specific to the storage type, which looks like this:
   *                    {
   *                      actor: <actorId>,
   *                      host: <hostname>
   *                    }
   */
  async listStores() {
    // Avoid trying to compute the list of storage actors more than once.
    // `preListStores` is subject to issues if called more than once.
    if (this._cachedStores) {
      return this._cachedStores;
    }
    this._cachedStores = (async () => {
      const toReturn = {};

      for (const [name, value] of this.childActorPool) {
        if (value.preListStores) {
          await value.preListStores();
        }
        toReturn[name] = value;
      }

      return toReturn;
    })();
    return this._cachedStores;
  },

  /**
   * This method is called by the registered storage types so as to tell the
   * Storage Actor that there are some changes in the stores. Storage Actor then
   * notifies the client front about these changes at regular (BATCH_DELAY)
   * interval.
   *
   * @param {string} action
   *        The type of change. One of "added", "changed" or "deleted"
   * @param {string} storeType
   *        The storage actor in which this change has occurred.
   * @param {object} data
   *        The update object. This object is of the following format:
   *         - {
   *             <host1>: [<store_names1>, <store_name2>...],
   *             <host2>: [<store_names34>...],
   *           }
   *           Where host1, host2 are the host in which this change happened and
   *           [<store_namesX] is an array of the names of the changed store objects.
   *           Pass an empty array if the host itself was affected: either completely
   *           removed or cleared.
   */
  // eslint-disable-next-line complexity
  update(action, storeType, data) {
    if (action == "cleared") {
      this.emit("stores-cleared", { [storeType]: data });
      return null;
    }

    if (this.batchTimer) {
      clearTimeout(this.batchTimer);
    }
    if (!this.boundUpdate[action]) {
      this.boundUpdate[action] = {};
    }
    if (!this.boundUpdate[action][storeType]) {
      this.boundUpdate[action][storeType] = {};
    }
    for (const host in data) {
      if (!this.boundUpdate[action][storeType][host]) {
        this.boundUpdate[action][storeType][host] = [];
      }
      for (const name of data[host]) {
        if (!this.boundUpdate[action][storeType][host].includes(name)) {
          this.boundUpdate[action][storeType][host].push(name);
        }
      }
    }
    if (action == "added") {
      // If the same store name was previously deleted or changed, but now is
      // added somehow, dont send the deleted or changed update.
      this.removeNamesFromUpdateList("deleted", storeType, data);
      this.removeNamesFromUpdateList("changed", storeType, data);
    } else if (
      action == "changed" &&
      this.boundUpdate.added &&
      this.boundUpdate.added[storeType]
    ) {
      // If something got added and changed at the same time, then remove those
      // items from changed instead.
      this.removeNamesFromUpdateList(
        "changed",
        storeType,
        this.boundUpdate.added[storeType]
      );
    } else if (action == "deleted") {
      // If any item got delete, or a host got delete, no point in sending
      // added or changed update
      this.removeNamesFromUpdateList("added", storeType, data);
      this.removeNamesFromUpdateList("changed", storeType, data);

      for (const host in data) {
        if (
          data[host].length == 0 &&
          this.boundUpdate.added &&
          this.boundUpdate.added[storeType] &&
          this.boundUpdate.added[storeType][host]
        ) {
          delete this.boundUpdate.added[storeType][host];
        }
        if (
          data[host].length == 0 &&
          this.boundUpdate.changed &&
          this.boundUpdate.changed[storeType] &&
          this.boundUpdate.changed[storeType][host]
        ) {
          delete this.boundUpdate.changed[storeType][host];
        }
      }
    }

    this.batchTimer = setTimeout(() => {
      clearTimeout(this.batchTimer);
      this.emit("stores-update", this.boundUpdate);
      this.boundUpdate = {};
    }, BATCH_DELAY);

    return null;
  },

  /**
   * This method removes data from the this.boundUpdate object in the same
   * manner like this.update() adds data to it.
   *
   * @param {string} action
   *        The type of change. One of "added", "changed" or "deleted"
   * @param {string} storeType
   *        The storage actor for which you want to remove the updates data.
   * @param {object} data
   *        The update object. This object is of the following format:
   *         - {
   *             <host1>: [<store_names1>, <store_name2>...],
   *             <host2>: [<store_names34>...],
   *           }
   *           Where host1, host2 are the hosts which you want to remove and
   *           [<store_namesX] is an array of the names of the store objects.
   */
  removeNamesFromUpdateList(action, storeType, data) {
    for (const host in data) {
      if (
        this.boundUpdate[action] &&
        this.boundUpdate[action][storeType] &&
        this.boundUpdate[action][storeType][host]
      ) {
        for (const name in data[host]) {
          const index = this.boundUpdate[action][storeType][host].indexOf(name);
          if (index > -1) {
            this.boundUpdate[action][storeType][host].splice(index, 1);
          }
        }
        if (!this.boundUpdate[action][storeType][host].length) {
          delete this.boundUpdate[action][storeType][host];
        }
      }
    }
    return null;
  },
});

exports.StorageActor = StorageActor;
