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

/*
 * Target actor for a WebExtension add-on.
 *
 * This actor extends ParentProcessTargetActor.
 *
 * See devtools/docs/backend/actor-hierarchy.md for more details.
 */

const { extend } = require("devtools/shared/extend");
const { Ci, Cu, Cc } = require("chrome");
const Services = require("Services");

const {
  ParentProcessTargetActor,
  parentProcessTargetPrototype,
} = require("devtools/server/actors/targets/parent-process");
const makeDebugger = require("devtools/server/actors/utils/make-debugger");
const {
  webExtensionTargetSpec,
} = require("devtools/shared/specs/targets/webextension");
const DevToolsUtils = require("devtools/shared/DevToolsUtils");

const Targets = require("devtools/server/actors/targets/index");
const TargetActorMixin = require("devtools/server/actors/targets/target-actor-mixin");

loader.lazyRequireGetter(
  this,
  "unwrapDebuggerObjectGlobal",
  "devtools/server/actors/thread",
  true
);
const FALLBACK_DOC_URL =
  "chrome://devtools/content/shared/webextension-fallback.html";

/**
 * Protocol.js expects only the prototype object, and does not maintain the prototype
 * chain when it constructs the ActorClass. For this reason we are using `extend` to
 * maintain the properties of BrowsingContextTargetActor.prototype
 */
const webExtensionTargetPrototype = extend({}, parentProcessTargetPrototype);

/**
 * Creates a target actor for debugging all the contexts associated to a target
 * WebExtensions add-on running in a child extension process. Most of the implementation
 * is inherited from ParentProcessTargetActor (which inherits most of its implementation
 * from BrowsingContextTargetActor).
 *
 * WebExtensionTargetActor is created by a WebExtensionActor counterpart, when its
 * parent actor's `connect` method has been called (on the listAddons RDP package),
 * it runs in the same process that the extension is running into (which can be the main
 * process if the extension is running in non-oop mode, or the child extension process
 * if the extension is running in oop-mode).
 *
 * A WebExtensionTargetActor contains all target-scoped actors, like a regular
 * ParentProcessTargetActor or BrowsingContextTargetActor.
 *
 * History lecture:
 * - The add-on actors used to not inherit BrowsingContextTargetActor because of the
 *   different way the add-on APIs where exposed to the add-on itself, and for this reason
 *   the Addon Debugger has only a sub-set of the feature available in the Tab or in the
 *   Browser Toolbox.
 * - In a WebExtensions add-on all the provided contexts (background, popups etc.),
 *   besides the Content Scripts which run in the content process, hooked to an existent
 *   tab, by creating a new WebExtensionActor which inherits from
 *   ParentProcessTargetActor, we can provide a full features Addon Toolbox (which is
 *   basically like a BrowserToolbox which filters the visible sources and frames to the
 *   one that are related to the target add-on).
 * - When the WebExtensions OOP mode has been introduced, this actor has been refactored
 *   and moved from the main process to the new child extension process.
 *
 * @param {DevToolsServerConnection} conn
 *        The connection to the client.
 * @param {nsIMessageSender} chromeGlobal.
 *        The chromeGlobal where this actor has been injected by the
 *        frame-connector.js connectToFrame method.
 * @param {Object} options
 *        - addonId: {String} the addonId of the target WebExtension.
 *        - chromeGlobal: {nsIMessageSender} The chromeGlobal where this actor
 *          has been injected by the frame-connector.js connectToFrame method.
 *        - isTopLevelTarget: {Boolean} flag to indicate if this is the top
 *          level target of the DevTools session
 *        - prefix: {String} the custom RDP prefix to use.
 */
webExtensionTargetPrototype.initialize = function(
  conn,
  { addonId, chromeGlobal, isTopLevelTarget, prefix }
) {
  this.addonId = addonId;
  this.chromeGlobal = chromeGlobal;

  // Try to discovery an existent extension page to attach (which will provide the initial
  // URL shown in the window tittle when the addon debugger is opened).
  const extensionWindow = this._searchForExtensionWindow();

  parentProcessTargetPrototype.initialize.call(this, conn, {
    isTopLevelTarget,
    window: extensionWindow,
  });
  this._chromeGlobal = chromeGlobal;
  this._prefix = prefix;

  // Redefine the messageManager getter to return the chromeGlobal
  // as the messageManager for this actor (which is the browser XUL
  // element used by the parent actor running in the main process to
  // connect to the extension process).
  Object.defineProperty(this, "messageManager", {
    enumerable: true,
    configurable: true,
    get: () => {
      return this._chromeGlobal;
    },
  });

  // Bind the _allowSource helper to this, it is used in the
  // BrowsingContextTargetActor to lazily create the SourcesManager instance.
  this._allowSource = this._allowSource.bind(this);
  this._onParentExit = this._onParentExit.bind(this);

  this._chromeGlobal.addMessageListener(
    "debug:webext_parent_exit",
    this._onParentExit
  );

  // Set the consoleAPIListener filtering options
  // (retrieved and used in the related webconsole child actor).
  this.consoleAPIListenerOptions = {
    addonId: this.addonId,
  };

  this.aps = Cc["@mozilla.org/addons/policy-service;1"].getService(
    Ci.nsIAddonPolicyService
  );

  // This creates a Debugger instance for debugging all the add-on globals.
  this.makeDebugger = makeDebugger.bind(null, {
    findDebuggees: dbg => {
      return dbg.findAllGlobals().filter(this._shouldAddNewGlobalAsDebuggee);
    },
    shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee.bind(this),
  });
};

// NOTE: This is needed to catch in the webextension webconsole all the
// errors raised by the WebExtension internals that are not currently
// associated with any window.
webExtensionTargetPrototype.isRootActor = true;

/**
 * Called when the actor is removed from the connection.
 */
webExtensionTargetPrototype.destroy = function() {
  if (this._chromeGlobal) {
    const chromeGlobal = this._chromeGlobal;
    this._chromeGlobal = null;

    chromeGlobal.removeMessageListener(
      "debug:webext_parent_exit",
      this._onParentExit
    );

    chromeGlobal.sendAsyncMessage("debug:webext_child_exit", {
      actor: this.actorID,
    });
  }

  this.addon = null;
  this.addonId = null;

  return ParentProcessTargetActor.prototype.destroy.apply(this);
};

// Private helpers.

webExtensionTargetPrototype._searchFallbackWindow = function() {
  if (this.fallbackWindow) {
    // Skip if there is already an existent fallback window.
    return this.fallbackWindow;
  }

  // Set and initialize the fallbackWindow (which initially is a empty
  // about:blank browser), this window is related to a XUL browser element
  // specifically created for the devtools server and it is never used
  // or navigated anywhere else.
  this.fallbackWindow = this.chromeGlobal.content;
  this.fallbackWindow.document.location.href = FALLBACK_DOC_URL;

  return this.fallbackWindow;
};

webExtensionTargetPrototype._destroyFallbackWindow = function() {
  if (this.fallbackWindow) {
    this.fallbackWindow = null;
  }
};

// Discovery an extension page to use as a default target window.
// NOTE: This currently fail to discovery an extension page running in a
// windowless browser when running in non-oop mode, and the background page
// is set later using _onNewExtensionWindow.
webExtensionTargetPrototype._searchForExtensionWindow = function() {
  for (const window of Services.ww.getWindowEnumerator(null)) {
    if (window.document.nodePrincipal.addonId == this.addonId) {
      return window;
    }
  }

  return this._searchFallbackWindow();
};

// Customized ParentProcessTargetActor/BrowsingContextTargetActor hooks.

webExtensionTargetPrototype._onDocShellDestroy = function(docShell) {
  // Stop watching this docshell (the unwatch() method will check if we
  // started watching it before).
  this._unwatchDocShell(docShell);

  // Let the _onDocShellDestroy notify that the docShell has been destroyed.
  const webProgress = docShell
    .QueryInterface(Ci.nsIInterfaceRequestor)
    .getInterface(Ci.nsIWebProgress);
  this._notifyDocShellDestroy(webProgress);

  // If the destroyed docShell was the current docShell and the actor is
  // currently attached, switch to the fallback window
  if (this.attached && docShell == this.docShell) {
    this._changeTopLevelDocument(this._searchForExtensionWindow());
  }
};

webExtensionTargetPrototype._onNewExtensionWindow = function(window) {
  if (!this.window || this.window === this.fallbackWindow) {
    this._changeTopLevelDocument(window);
  }
};

webExtensionTargetPrototype._attach = function() {
  // NOTE: we need to be sure that `this.window` can return a window before calling the
  // ParentProcessTargetActor.onAttach, or the BrowsingContextTargetActor will not be
  // subscribed to the child doc shell updates.

  if (
    !this.window ||
    this.window.document.nodePrincipal.addonId !== this.addonId
  ) {
    // Discovery an existent extension page (or fallback window) to attach.
    this._setWindow(this._searchForExtensionWindow());
  }

  // Call ParentProcessTargetActor's _attach to listen for any new/destroyed chrome
  // docshell.
  ParentProcessTargetActor.prototype._attach.apply(this);
};

webExtensionTargetPrototype._detach = function() {
  // Call ParentProcessTargetActor's _detach to unsubscribe new/destroyed chrome docshell
  // listeners.
  ParentProcessTargetActor.prototype._detach.apply(this);

  // Stop watching for new extension windows.
  this._destroyFallbackWindow();
};

/**
 * Return the json details related to a docShell.
 */
webExtensionTargetPrototype._docShellToWindow = function(docShell) {
  const baseWindowDetails = ParentProcessTargetActor.prototype._docShellToWindow.call(
    this,
    docShell
  );

  const webProgress = docShell
    .QueryInterface(Ci.nsIInterfaceRequestor)
    .getInterface(Ci.nsIWebProgress);
  const window = webProgress.DOMWindow;

  // Collect the addonID from the document origin attributes and its sameType top level
  // frame.
  const addonID = window.document.nodePrincipal.addonId;
  const sameTypeRootAddonID =
    docShell.sameTypeRootTreeItem.domWindow.document.nodePrincipal.addonId;

  return Object.assign(baseWindowDetails, {
    addonID,
    sameTypeRootAddonID,
  });
};

/**
 * Return an array of the json details related to an array/iterator of docShells.
 */
webExtensionTargetPrototype._docShellsToWindows = function(docshells) {
  return ParentProcessTargetActor.prototype._docShellsToWindows
    .call(this, docshells)
    .filter(windowDetails => {
      // Filter the docShells based on the addon id of the window or
      // its sameType top level frame.
      return (
        windowDetails.addonID === this.addonId ||
        windowDetails.sameTypeRootAddonID === this.addonId
      );
    });
};

webExtensionTargetPrototype.isExtensionWindow = function(window) {
  return window.document.nodePrincipal.addonId == this.addonId;
};

webExtensionTargetPrototype.isExtensionWindowDescendent = function(window) {
  // Check if the source is coming from a descendant docShell of an extension window.
  const rootWin = window.docShell.sameTypeRootTreeItem.domWindow;
  return this.isExtensionWindow(rootWin);
};

/**
 * Return true if the given source is associated with this addon and should be
 * added to the visible sources (retrieved and used by the webbrowser actor module).
 */
webExtensionTargetPrototype._allowSource = function(source) {
  // Use the source.element to detect the allowed source, if any.
  if (source.element) {
    try {
      const domEl = unwrapDebuggerObjectGlobal(source.element);
      return (
        this.isExtensionWindow(domEl.ownerGlobal) ||
        this.isExtensionWindowDescendent(domEl.ownerGlobal)
      );
    } catch (e) {
      // If the source's window is dead then the above will throw.
      DevToolsUtils.reportException("WebExtensionTarget.allowSource", e);
      return false;
    }
  }

  // Fallback to check the uri if there is no source.element associated to the source.

  // Retrieve the first component of source.url in the form "url1 -> url2 -> ...".
  const url = source.url.split(" -> ").pop();

  // Filter out the code introduced by evaluating code in the webconsole.
  if (url === "debugger eval code" || url === "debugger eager eval code") {
    return false;
  }

  let uri;

  // Try to decode the url.
  try {
    uri = Services.io.newURI(url);
  } catch (err) {
    Cu.reportError(`Unexpected invalid url: ${url}`);
    return false;
  }

  // Filter out resource and chrome sources (which are related to the loaded internals).
  if (["resource", "chrome", "file"].includes(uri.scheme)) {
    return false;
  }

  try {
    const addonID = this.aps.extensionURIToAddonId(uri);

    return addonID == this.addonId;
  } catch (err) {
    // extensionURIToAddonId raises an exception on non-extension URLs.
    return false;
  }
};

/**
 * Return true if the given global is associated with this addon and should be
 * added as a debuggee, false otherwise.
 */
webExtensionTargetPrototype._shouldAddNewGlobalAsDebuggee = function(
  newGlobal
) {
  const global = unwrapDebuggerObjectGlobal(newGlobal);

  if (global instanceof Ci.nsIDOMWindow) {
    try {
      global.document;
    } catch (e) {
      // The global might be a sandbox with a window object in its proto chain. If the
      // window navigated away since the sandbox was created, it can throw a security
      // exception during this property check as the sandbox no longer has access to
      // its own proto.
      return false;
    }

    // Change top level document as a simulated frame switching.
    if (global.document.ownerGlobal && this.isExtensionWindow(global)) {
      this._onNewExtensionWindow(global.document.ownerGlobal);
    }

    return (
      global.document.ownerGlobal &&
      this.isExtensionWindowDescendent(global.document.ownerGlobal)
    );
  }

  try {
    // This will fail for non-Sandbox objects, hence the try-catch block.
    const metadata = Cu.getSandboxMetadata(global);
    if (metadata) {
      return metadata.addonID === this.addonId;
    }
  } catch (e) {
    // Unable to retrieve the sandbox metadata.
  }

  return false;
};

// Handlers for the messages received from the parent actor.

webExtensionTargetPrototype._onParentExit = function(msg) {
  if (msg.json.actor !== this.actorID) {
    return;
  }

  this.destroy();
};

exports.WebExtensionTargetActor = TargetActorMixin(
  Targets.TYPES.FRAME,
  webExtensionTargetSpec,
  webExtensionTargetPrototype
);
