summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/director-manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/director-manager.js')
-rw-r--r--devtools/server/actors/director-manager.js615
1 files changed, 615 insertions, 0 deletions
diff --git a/devtools/server/actors/director-manager.js b/devtools/server/actors/director-manager.js
new file mode 100644
index 000000000..027a456db
--- /dev/null
+++ b/devtools/server/actors/director-manager.js
@@ -0,0 +1,615 @@
+/* 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 events = require("sdk/event/core");
+const protocol = require("devtools/shared/protocol");
+
+const { Cu, Ci } = require("chrome");
+
+const { on, once, off, emit } = events;
+const { method, Arg, Option, RetVal, types } = protocol;
+
+const { sandbox, evaluate } = require("sdk/loader/sandbox");
+const { Class } = require("sdk/core/heritage");
+
+const { PlainTextConsole } = require("sdk/console/plain-text");
+
+const { DirectorRegistry } = require("./director-registry");
+
+const {
+ messagePortSpec,
+ directorManagerSpec,
+ directorScriptSpec,
+} = require("devtools/shared/specs/director-manager");
+
+/**
+ * Error Messages
+ */
+
+const ERR_MESSAGEPORT_FINALIZED = "message port finalized";
+
+const ERR_DIRECTOR_UNKNOWN_SCRIPTID = "unkown director-script id";
+const ERR_DIRECTOR_UNINSTALLED_SCRIPTID = "uninstalled director-script id";
+
+/**
+ * A MessagePort Actor allowing communication through messageport events
+ * over the remote debugging protocol.
+ */
+var MessagePortActor = exports.MessagePortActor = protocol.ActorClassWithSpec(messagePortSpec, {
+ /**
+ * Create a MessagePort actor.
+ *
+ * @param DebuggerServerConnection conn
+ * The server connection.
+ * @param MessagePort port
+ * The wrapped MessagePort.
+ */
+ initialize: function (conn, port) {
+ protocol.Actor.prototype.initialize.call(this, conn);
+
+ // NOTE: can't get a weak reference because we need to subscribe events
+ // using port.onmessage or addEventListener
+ this.port = port;
+ },
+
+ destroy: function (conn) {
+ protocol.Actor.prototype.destroy.call(this, conn);
+ this.finalize();
+ },
+
+ /**
+ * Sends a message on the wrapped message port.
+ *
+ * @param Object msg
+ * The JSON serializable message event payload
+ */
+ postMessage: function (msg) {
+ if (!this.port) {
+ console.error(ERR_MESSAGEPORT_FINALIZED);
+ return;
+ }
+
+ this.port.postMessage(msg);
+ },
+
+ /**
+ * Starts to receive and send queued messages on this message port.
+ */
+ start: function () {
+ if (!this.port) {
+ console.error(ERR_MESSAGEPORT_FINALIZED);
+ return;
+ }
+
+ // NOTE: set port.onmessage to a function is an implicit start
+ // and starts to send queued messages.
+ // On the client side we should set MessagePortClient.onmessage
+ // to a setter which register an handler to the message event
+ // and call the actor start method to start receiving messages
+ // from the MessagePort's queue.
+ this.port.onmessage = (evt) => {
+ var ports;
+
+ // TODO: test these wrapped ports
+ if (Array.isArray(evt.ports)) {
+ ports = evt.ports.map((port) => {
+ let actor = new MessagePortActor(this.conn, port);
+ this.manage(actor);
+ return actor;
+ });
+ }
+
+ emit(this, "message", {
+ isTrusted: evt.isTrusted,
+ data: evt.data,
+ origin: evt.origin,
+ lastEventId: evt.lastEventId,
+ source: this,
+ ports: ports
+ });
+ };
+ },
+
+ /**
+ * Starts to receive and send queued messages on this message port, or
+ * raise an exception if the port is null
+ */
+ close: function () {
+ if (!this.port) {
+ console.error(ERR_MESSAGEPORT_FINALIZED);
+ return;
+ }
+
+ try {
+ this.port.onmessage = null;
+ this.port.close();
+ } catch (e) {
+ // The port might be a dead object
+ console.error(e);
+ }
+ },
+
+ finalize: function () {
+ this.close();
+ this.port = null;
+ },
+});
+
+/**
+ * The Director Script Actor manage javascript code running in a non-privileged sandbox with the same
+ * privileges of the target global (browser tab or a firefox os app).
+ *
+ * After retrieving an instance of this actor (from the tab director actor), you'll need to set it up
+ * by calling setup().
+ *
+ * After the setup, this actor will automatically attach/detach the content script (and optionally a
+ * directly connect the debugger client and the content script using a MessageChannel) on tab
+ * navigation.
+ */
+var DirectorScriptActor = exports.DirectorScriptActor = protocol.ActorClassWithSpec(directorScriptSpec, {
+ /**
+ * Creates the director script actor
+ *
+ * @param DebuggerServerConnection conn
+ * The server connection.
+ * @param Actor tabActor
+ * The tab (or root) actor.
+ * @param String scriptId
+ * The director-script id.
+ * @param String scriptCode
+ * The director-script javascript source.
+ * @param Object scriptOptions
+ * The director-script options object.
+ */
+ initialize: function (conn, tabActor, { scriptId, scriptCode, scriptOptions }) {
+ protocol.Actor.prototype.initialize.call(this, conn, tabActor);
+
+ this.tabActor = tabActor;
+
+ this._scriptId = scriptId;
+ this._scriptCode = scriptCode;
+ this._scriptOptions = scriptOptions;
+ this._setupCalled = false;
+
+ this._onGlobalCreated = this._onGlobalCreated.bind(this);
+ this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this);
+ },
+ destroy: function (conn) {
+ protocol.Actor.prototype.destroy.call(this, conn);
+
+ this.finalize();
+ },
+
+ /**
+ * Starts listening to the tab global created, in order to create the director-script sandbox
+ * using the configured scriptCode, attached/detached automatically to the tab
+ * window on tab navigation.
+ *
+ * @param Boolean reload
+ * attach the page immediately or reload it first.
+ * @param Boolean skipAttach
+ * skip the attach
+ */
+ setup: function ({ reload, skipAttach }) {
+ if (this._setupCalled) {
+ // do nothing
+ return;
+ }
+
+ this._setupCalled = true;
+
+ on(this.tabActor, "window-ready", this._onGlobalCreated);
+ on(this.tabActor, "window-destroyed", this._onGlobalDestroyed);
+
+ // optional skip attach (needed by director-manager for director scripts bulk activation)
+ if (skipAttach) {
+ return;
+ }
+
+ if (reload) {
+ this.window.location.reload();
+ } else {
+ // fake a global created event to attach without reload
+ this._onGlobalCreated({ id: getWindowID(this.window), window: this.window, isTopLevel: true });
+ }
+ },
+
+ /**
+ * Get the attached MessagePort actor if any
+ */
+ getMessagePort: function () {
+ return this._messagePortActor;
+ },
+
+ /**
+ * Stop listening for document global changes, destroy the content worker and puts
+ * this actor to hibernation.
+ */
+ finalize: function () {
+ if (!this._setupCalled) {
+ return;
+ }
+
+ off(this.tabActor, "window-ready", this._onGlobalCreated);
+ off(this.tabActor, "window-destroyed", this._onGlobalDestroyed);
+
+ this._onGlobalDestroyed({ id: this._lastAttachedWinId });
+
+ this._setupCalled = false;
+ },
+
+ // local helpers
+ get window() {
+ return this.tabActor.window;
+ },
+
+ /* event handlers */
+ _onGlobalCreated: function ({ id, window, isTopLevel }) {
+ if (!isTopLevel) {
+ // filter iframes
+ return;
+ }
+
+ try {
+ if (this._lastAttachedWinId) {
+ // if we have received a global created without a previous global destroyed,
+ // it's time to cleanup the previous state
+ this._onGlobalDestroyed(this._lastAttachedWinId);
+ }
+
+ // TODO: check if we want to share a single sandbox per global
+ // for multiple debugger clients
+
+ // create & attach the new sandbox
+ this._scriptSandbox = new DirectorScriptSandbox({
+ scriptId: this._scriptId,
+ scriptCode: this._scriptCode,
+ scriptOptions: this._scriptOptions
+ });
+
+ // attach the global window
+ this._lastAttachedWinId = id;
+ var port = this._scriptSandbox.attach(window, id);
+ this._onDirectorScriptAttach(window, port);
+ } catch (e) {
+ this._onDirectorScriptError(e);
+ }
+ },
+ _onGlobalDestroyed: function ({ id }) {
+ if (id !== this._lastAttachedWinId) {
+ // filter destroyed globals
+ return;
+ }
+
+ // unmanage and cleanup the messageport actor
+ if (this._messagePortActor) {
+ this.unmanage(this._messagePortActor);
+ this._messagePortActor = null;
+ }
+
+ // NOTE: destroy here the old worker
+ if (this._scriptSandbox) {
+ this._scriptSandbox.destroy(this._onDirectorScriptError.bind(this));
+
+ // send a detach event to the debugger client
+ emit(this, "detach", {
+ directorScriptId: this._scriptId,
+ innerId: this._lastAttachedWinId
+ });
+
+ this._lastAttachedWinId = null;
+ this._scriptSandbox = null;
+ }
+ },
+ _onDirectorScriptError: function (error) {
+ // route the content script error to the debugger client
+ if (error) {
+ // prevents silent director-script-errors
+ console.error("director-script-error", error);
+ // route errors to debugger server clients
+ emit(this, "error", {
+ directorScriptId: this._scriptId,
+ message: error.toString(),
+ stack: error.stack,
+ fileName: error.fileName,
+ lineNumber: error.lineNumber,
+ columnNumber: error.columnNumber
+ });
+ }
+ },
+ _onDirectorScriptAttach: function (window, port) {
+ let portActor = new MessagePortActor(this.conn, port);
+ this.manage(portActor);
+ this._messagePortActor = portActor;
+
+ emit(this, "attach", {
+ directorScriptId: this._scriptId,
+ url: (window && window.location) ? window.location.toString() : "",
+ innerId: this._lastAttachedWinId,
+ port: this._messagePortActor
+ });
+ }
+});
+
+/**
+ * The DirectorManager Actor is a tab actor which manages enabling/disabling director scripts.
+ */
+const DirectorManagerActor = exports.DirectorManagerActor = protocol.ActorClassWithSpec(directorManagerSpec, {
+ /* init & destroy methods */
+ initialize: function (conn, tabActor) {
+ protocol.Actor.prototype.initialize.call(this, conn);
+ this.tabActor = tabActor;
+ this._directorScriptActorsMap = new Map();
+ },
+ destroy: function (conn) {
+ protocol.Actor.prototype.destroy.call(this, conn);
+ this.finalize();
+ },
+
+ /**
+ * Retrieves the list of installed director-scripts.
+ */
+ list: function () {
+ let enabledScriptIds = [...this._directorScriptActorsMap.keys()];
+
+ return {
+ installed: DirectorRegistry.list(),
+ enabled: enabledScriptIds
+ };
+ },
+
+ /**
+ * Bulk enabling director-scripts.
+ *
+ * @param Array[String] selectedIds
+ * The list of director-script ids to be enabled,
+ * ["*"] will activate all the installed director-scripts
+ * @param Boolean reload
+ * optionally reload the target window
+ */
+ enableByScriptIds: function (selectedIds, { reload }) {
+ if (selectedIds && selectedIds.length === 0) {
+ // filtered all director scripts ids
+ return;
+ }
+
+ for (let scriptId of DirectorRegistry.list()) {
+ // filter director script ids
+ if (selectedIds.indexOf("*") < 0 &&
+ selectedIds.indexOf(scriptId) < 0) {
+ continue;
+ }
+
+ let actor = this.getByScriptId(scriptId);
+
+ // skip attach if reload is true (activated director scripts
+ // will be automatically attached on the final reload)
+ actor.setup({ reload: false, skipAttach: reload });
+ }
+
+ if (reload) {
+ this.tabActor.window.location.reload();
+ }
+ },
+
+ /**
+ * Bulk disabling director-scripts.
+ *
+ * @param Array[String] selectedIds
+ * The list of director-script ids to be disable,
+ * ["*"] will de-activate all the enable director-scripts
+ * @param Boolean reload
+ * optionally reload the target window
+ */
+ disableByScriptIds: function (selectedIds, { reload }) {
+ if (selectedIds && selectedIds.length === 0) {
+ // filtered all director scripts ids
+ return;
+ }
+
+ for (let scriptId of this._directorScriptActorsMap.keys()) {
+ // filter director script ids
+ if (selectedIds.indexOf("*") < 0 &&
+ selectedIds.indexOf(scriptId) < 0) {
+ continue;
+ }
+
+ let actor = this._directorScriptActorsMap.get(scriptId);
+ this._directorScriptActorsMap.delete(scriptId);
+
+ // finalize the actor (which will produce director-script-detach event)
+ actor.finalize();
+ // unsubscribe event handlers on the disabled actor
+ off(actor);
+
+ this.unmanage(actor);
+ }
+
+ if (reload) {
+ this.tabActor.window.location.reload();
+ }
+ },
+
+ /**
+ * Retrieves the actor instance of an installed director-script
+ * (and create the actor instance if it doesn't exists yet).
+ */
+ getByScriptId: function (scriptId) {
+ var id = scriptId;
+ // raise an unknown director-script id exception
+ if (!DirectorRegistry.checkInstalled(id)) {
+ console.error(ERR_DIRECTOR_UNKNOWN_SCRIPTID, id);
+ throw Error(ERR_DIRECTOR_UNKNOWN_SCRIPTID);
+ }
+
+ // get a previous created actor instance
+ let actor = this._directorScriptActorsMap.get(id);
+
+ // create a new actor instance
+ if (!actor) {
+ let directorScriptDefinition = DirectorRegistry.get(id);
+
+ // test lazy director-script (e.g. uninstalled in the parent process)
+ if (!directorScriptDefinition) {
+
+ console.error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID, id);
+ throw Error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID);
+ }
+
+ actor = new DirectorScriptActor(this.conn, this.tabActor, directorScriptDefinition);
+ this._directorScriptActorsMap.set(id, actor);
+
+ on(actor, "error", emit.bind(null, this, "director-script-error"));
+ on(actor, "attach", emit.bind(null, this, "director-script-attach"));
+ on(actor, "detach", emit.bind(null, this, "director-script-detach"));
+
+ this.manage(actor);
+ }
+
+ return actor;
+ },
+
+ finalize: function () {
+ this.disableByScriptIds(["*"], false);
+ }
+});
+
+/* private helpers */
+
+/**
+ * DirectorScriptSandbox is a private utility class, which attach a non-priviliged sandbox
+ * to a target window.
+ */
+const DirectorScriptSandbox = Class({
+ initialize: function ({scriptId, scriptCode, scriptOptions}) {
+ this._scriptId = scriptId;
+ this._scriptCode = scriptCode;
+ this._scriptOptions = scriptOptions;
+ },
+
+ attach: function (window, innerId) {
+ this._innerId = innerId,
+ this._window = window;
+ this._proto = Cu.createObjectIn(this._window);
+
+ var id = this._scriptId;
+ var uri = this._scriptCode;
+
+ this._sandbox = sandbox(window, {
+ sandboxName: uri,
+ sandboxPrototype: this._proto,
+ sameZoneAs: window,
+ wantXrays: true,
+ wantComponents: false,
+ wantExportHelpers: false,
+ metadata: {
+ URI: uri,
+ addonID: id,
+ SDKDirectorScript: true,
+ "inner-window-id": innerId
+ }
+ });
+
+ // create a CommonJS module object which match the interface from addon-sdk
+ // (addon-sdk/sources/lib/toolkit/loader.js#L678-L686)
+ var module = Cu.cloneInto(Object.create(null, {
+ id: { enumerable: true, value: id },
+ uri: { enumerable: true, value: uri },
+ exports: { enumerable: true, value: Cu.createObjectIn(this._sandbox) }
+ }), this._sandbox);
+
+ // create a console API object
+ let directorScriptConsole = new PlainTextConsole(null, this._innerId);
+
+ // inject CommonJS module globals into the sandbox prototype
+ Object.defineProperties(this._proto, {
+ module: { enumerable: true, value: module },
+ exports: { enumerable: true, value: module.exports },
+ console: {
+ enumerable: true,
+ value: Cu.cloneInto(directorScriptConsole, this._sandbox, { cloneFunctions: true })
+ }
+ });
+
+ Object.defineProperties(this._sandbox, {
+ require: {
+ enumerable: true,
+ value: Cu.cloneInto(function () {
+ throw Error("NOT IMPLEMENTED");
+ }, this._sandbox, { cloneFunctions: true })
+ }
+ });
+
+ // TODO: if the debugger target is local, the debugger client could pass
+ // to the director actor the resource url instead of the entire javascript source code.
+
+ // evaluate the director script source in the sandbox
+ evaluate(this._sandbox, this._scriptCode, "javascript:" + this._scriptCode);
+
+ // prepare the messageport connected to the debugger client
+ let { port1, port2 } = new this._window.MessageChannel();
+
+ // prepare the unload callbacks queue
+ var sandboxOnUnloadQueue = this._sandboxOnUnloadQueue = [];
+
+ // create the attach options
+ var attachOptions = this._attachOptions = Cu.createObjectIn(this._sandbox);
+ Object.defineProperties(attachOptions, {
+ port: { enumerable: true, value: port1 },
+ window: { enumerable: true, value: window },
+ scriptOptions: { enumerable: true, value: Cu.cloneInto(this._scriptOptions, this._sandbox) },
+ onUnload: {
+ enumerable: true,
+ value: Cu.cloneInto(function (cb) {
+ // collect unload callbacks
+ if (typeof cb == "function") {
+ sandboxOnUnloadQueue.push(cb);
+ }
+ }, this._sandbox, { cloneFunctions: true })
+ }
+ });
+
+ // select the attach method
+ var exports = this._proto.module.exports;
+ if (this._scriptOptions && "attachMethod" in this._scriptOptions) {
+ this._sandboxOnAttach = exports[this._scriptOptions.attachMethod];
+ } else {
+ this._sandboxOnAttach = exports;
+ }
+
+ if (typeof this._sandboxOnAttach !== "function") {
+ throw Error("the configured attachMethod '" +
+ (this._scriptOptions.attachMethod || "module.exports") +
+ "' is not exported by the directorScript");
+ }
+
+ // call the attach method
+ this._sandboxOnAttach.call(this._sandbox, attachOptions);
+
+ return port2;
+ },
+ destroy: function (onError) {
+ // evaluate queue unload methods if any
+ while (this._sandboxOnUnloadQueue && this._sandboxOnUnloadQueue.length > 0) {
+ let cb = this._sandboxOnUnloadQueue.pop();
+
+ try {
+ cb();
+ } catch (e) {
+ console.error("Exception on DirectorScript Sandbox destroy", e);
+ onError(e);
+ }
+ }
+
+ Cu.nukeSandbox(this._sandbox);
+ }
+});
+
+function getWindowID(window) {
+ return window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils)
+ .currentInnerWindowID;
+}