summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/storage.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/storage.js')
-rw-r--r--devtools/server/actors/storage.js2542
1 files changed, 2542 insertions, 0 deletions
diff --git a/devtools/server/actors/storage.js b/devtools/server/actors/storage.js
new file mode 100644
index 000000000..572cd6b68
--- /dev/null
+++ b/devtools/server/actors/storage.js
@@ -0,0 +1,2542 @@
+/* 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 events = require("sdk/event/core");
+const protocol = require("devtools/shared/protocol");
+const {LongStringActor} = require("devtools/server/actors/string");
+const {DebuggerServer} = require("devtools/server/main");
+const Services = require("Services");
+const promise = require("promise");
+const {isWindowIncluded} = require("devtools/shared/layout/utils");
+const specs = require("devtools/shared/specs/storage");
+const { Task } = require("devtools/shared/task");
+
+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 {
+ let 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();
+
+/**
+ * An async method equivalent to setTimeout but using Promises
+ *
+ * @param {number} time
+ * The wait time in milliseconds.
+ */
+function sleep(time) {
+ let deferred = promise.defer();
+
+ setTimeout(() => {
+ deferred.resolve(null);
+ }, time);
+
+ return deferred.promise;
+}
+
+// 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 optianally 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 {string} observationTopic
+ * The topic which this actor listens to via Notification Observers.
+ */
+StorageActors.defaults = function (typeName, observationTopic) {
+ return {
+ typeName: typeName,
+
+ get conn() {
+ return this.storageActor.conn;
+ },
+
+ /**
+ * Returns a list of currently knwon hosts for the target window. This list
+ * contains unique hosts from the window + all inner windows.
+ */
+ get hosts() {
+ let hosts = new Set();
+ for (let {location} of this.storageActor.windows) {
+ hosts.add(this.getHostName(location));
+ }
+ 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 host.
+ */
+ getHostName(location) {
+ return location.hostname || location.href;
+ },
+
+ initialize(storageActor) {
+ protocol.Actor.prototype.initialize.call(this, null);
+
+ this.storageActor = storageActor;
+
+ this.populateStoresForHosts();
+ if (observationTopic) {
+ Services.obs.addObserver(this, observationTopic, false);
+ }
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
+ events.on(this.storageActor, "window-ready", this.onWindowReady);
+ events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed);
+ },
+
+ destroy() {
+ if (observationTopic) {
+ Services.obs.removeObserver(this, observationTopic, false);
+ }
+ events.off(this.storageActor, "window-ready", this.onWindowReady);
+ events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed);
+
+ this.hostVsStores.clear();
+ 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.
+ */
+ onWindowReady: Task.async(function* (window) {
+ let host = this.getHostName(window.location);
+ if (!this.hostVsStores.has(host)) {
+ yield this.populateStoresForHost(host, window);
+ let 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.
+ */
+ onWindowDestroyed(window) {
+ if (!window.location) {
+ // Nothing can be done if location object is null
+ return;
+ }
+ let host = this.getHostName(window.location);
+ if (!this.hosts.has(host)) {
+ this.hostVsStores.delete(host);
+ let data = {};
+ data[host] = [];
+ this.storageActor.update("deleted", typeName, data);
+ }
+ },
+
+ form(form, detail) {
+ if (detail === "actorid") {
+ return this.actorID;
+ }
+
+ let hosts = {};
+ for (let host of this.hosts) {
+ hosts[host] = [];
+ }
+
+ return {
+ actor: this.actorID,
+ hosts: hosts
+ };
+ },
+
+ /**
+ * Populates a map of known hosts vs a map of stores vs value.
+ */
+ populateStoresForHosts() {
+ this.hostVsStores = new Map();
+ for (let 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.
+ */
+ getStoreObjects: Task.async(function* (host, names, options = {}) {
+ let offset = options.offset || 0;
+ let size = options.size || MAX_STORE_OBJECT_COUNT;
+ if (size > MAX_STORE_OBJECT_COUNT) {
+ size = MAX_STORE_OBJECT_COUNT;
+ }
+ let sortOn = options.sortOn || "name";
+
+ let 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.
+ let win = this.storageActor.getWindowFromHost(host);
+ if (win) {
+ principal = win.document.nodePrincipal;
+ }
+ }
+
+ if (names) {
+ for (let name of names) {
+ let values = yield this.getValuesForHost(host, name, options,
+ this.hostVsStores, principal);
+
+ let {result, objectStores} = values;
+
+ if (result && typeof result.objectsSize !== "undefined") {
+ for (let {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);
+ }
+ }
+ toReturn.total = this.getObjectsSize(host, names, options);
+ if (offset > toReturn.total) {
+ // In this case, toReturn.data is an empty array.
+ toReturn.offset = toReturn.total;
+ toReturn.data = [];
+ } else {
+ toReturn.data = toReturn.data.sort((a, b) => {
+ return a[sortOn] - b[sortOn];
+ }).slice(offset, offset + size).map(a => this.toStoreObject(a));
+ }
+ } else {
+ let obj = yield this.getValuesForHost(host, undefined, undefined,
+ this.hostVsStores, principal);
+ if (obj.dbs) {
+ obj = obj.dbs;
+ }
+
+ toReturn.total = obj.length;
+ if (offset > toReturn.total) {
+ // In this case, toReturn.data is an empty array.
+ toReturn.offset = offset = toReturn.total;
+ toReturn.data = [];
+ } else {
+ toReturn.data = obj.sort((a, b) => {
+ return a[sortOn] - b[sortOn];
+ }).slice(offset, offset + size)
+ .map(object => this.toStoreObject(object));
+ }
+ }
+
+ return toReturn;
+ })
+ };
+};
+
+/**
+ * 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.
+ * - observationTopic {string}
+ * The topic 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 = {}) {
+ let actorObject = StorageActors.defaults(
+ options.typeName,
+ options.observationTopic || null
+ );
+ for (let key in overrides) {
+ actorObject[key] = overrides[key];
+ }
+
+ let actorSpec = specs.childSpecs[options.typeName];
+ let 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);
+ events.on(this.storageActor, "window-ready", this.onWindowReady);
+ events.on(this.storageActor, "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 (!DebuggerServer.isInChildProcess) {
+ this.removeCookieObservers();
+ }
+
+ events.off(this.storageActor, "window-ready", this.onWindowReady);
+ events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed);
+
+ this._pendingResponse = 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];
+ }
+ let hosts = new Set();
+ for (let host of this.hosts) {
+ for (let 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;
+ }
+ 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 {
+ name: cookie.name,
+ path: cookie.path || "",
+ host: cookie.host || "",
+
+ // because expires is in seconds
+ expires: (cookie.expires || 0) * 1000,
+
+ // because it is in micro seconds
+ creationTime: cookie.creationTime / 1000,
+
+ // - do -
+ lastAccessed: cookie.lastAccessed / 1000,
+ value: new LongStringActor(this.conn, cookie.value || ""),
+ isDomain: cookie.isDomain,
+ isSecure: cookie.isSecure,
+ isHttpOnly: cookie.isHttpOnly
+ };
+ },
+
+ populateStoresForHost(host) {
+ this.hostVsStores.set(host, new Map());
+ let doc = this.storageActor.document;
+
+ let cookies = this.getCookiesFromHost(host, doc.nodePrincipal
+ .originAttributes);
+
+ for (let cookie of cookies) {
+ if (this.isCookieAtHost(cookie, host)) {
+ this.hostVsStores.get(host).set(cookie.name, cookie);
+ }
+ }
+ },
+
+ /**
+ * 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" ||
+ !this.storageActor ||
+ !this.storageActor.windows) {
+ return null;
+ }
+
+ let hosts = this.getMatchingHosts(subject);
+ let data = {};
+
+ switch (action) {
+ case "added":
+ case "changed":
+ if (hosts.length) {
+ for (let host of hosts) {
+ this.hostVsStores.get(host).set(subject.name, subject);
+ data[host] = [subject.name];
+ }
+ this.storageActor.update(action, "cookies", data);
+ }
+ break;
+
+ case "deleted":
+ if (hosts.length) {
+ for (let host of hosts) {
+ this.hostVsStores.get(host).delete(subject.name);
+ data[host] = [subject.name];
+ }
+ this.storageActor.update("deleted", "cookies", data);
+ }
+ break;
+
+ case "batch-deleted":
+ if (hosts.length) {
+ for (let host of hosts) {
+ let stores = [];
+ for (let cookie of subject) {
+ this.hostVsStores.get(host).delete(cookie.name);
+ stores.push(cookie.name);
+ }
+ data[host] = stores;
+ }
+ this.storageActor.update("deleted", "cookies", data);
+ }
+ break;
+
+ case "cleared":
+ if (hosts.length) {
+ for (let host of hosts) {
+ data[host] = [];
+ }
+ this.storageActor.update("cleared", "cookies", data);
+ }
+ break;
+ }
+ return null;
+ },
+
+ getFields: Task.async(function* () {
+ return [
+ { name: "name", editable: 1},
+ { name: "path", editable: 1},
+ { name: "host", editable: 1},
+ { name: "expires", editable: 1},
+ { name: "lastAccessed", editable: 0},
+ { name: "value", editable: 1},
+ { name: "isDomain", editable: 0},
+ { name: "isSecure", editable: 1},
+ { name: "isHttpOnly", editable: 1}
+ ];
+ }),
+
+ /**
+ * Pass the editItem command from the content to the chrome process.
+ *
+ * @param {Object} data
+ * See editCookie() for format details.
+ */
+ editItem: Task.async(function* (data) {
+ let doc = this.storageActor.document;
+ data.originAttributes = doc.nodePrincipal
+ .originAttributes;
+ this.editCookie(data);
+ }),
+
+ removeItem: Task.async(function* (host, name) {
+ let doc = this.storageActor.document;
+ this.removeCookie(host, name, doc.nodePrincipal
+ .originAttributes);
+ }),
+
+ removeAll: Task.async(function* (host, domain) {
+ let doc = this.storageActor.document;
+ this.removeAllCookies(host, domain, doc.nodePrincipal
+ .originAttributes);
+ }),
+
+ maybeSetupChildProcess() {
+ cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this);
+
+ if (!DebuggerServer.isInChildProcess) {
+ this.getCookiesFromHost =
+ cookieHelpers.getCookiesFromHost.bind(cookieHelpers);
+ this.addCookieObservers =
+ cookieHelpers.addCookieObservers.bind(cookieHelpers);
+ this.removeCookieObservers =
+ cookieHelpers.removeCookieObservers.bind(cookieHelpers);
+ this.editCookie =
+ cookieHelpers.editCookie.bind(cookieHelpers);
+ this.removeCookie =
+ cookieHelpers.removeCookie.bind(cookieHelpers);
+ this.removeAllCookies =
+ cookieHelpers.removeAllCookies.bind(cookieHelpers);
+ return;
+ }
+
+ const { sendSyncMessage, addMessageListener } =
+ this.conn.parentMessageManager;
+
+ 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.editCookie =
+ callParentProcess.bind(null, "editCookie");
+ this.removeCookie =
+ callParentProcess.bind(null, "removeCookie");
+ this.removeAllCookies =
+ callParentProcess.bind(null, "removeAllCookies");
+
+ addMessageListener("debug:storage-cookie-request-child",
+ cookieHelpers.handleParentRequest);
+
+ function callParentProcess(methodName, ...args) {
+ let reply = 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);
+ }
+
+ let 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 = "";
+ }
+
+ let cookies = Services.cookies.getCookiesFromHost(host, originAttributes);
+ let store = [];
+
+ while (cookies.hasMoreElements()) {
+ let cookie = cookies.getNext().QueryInterface(Ci.nsICookie2);
+
+ store.push(cookie);
+ }
+
+ return store;
+ },
+
+ /**
+ * Apply the results of a cookie edit.
+ *
+ * @param {Object} data
+ * An object in the following format:
+ * {
+ * host: "http://www.mozilla.org",
+ * field: "value",
+ * key: "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"
+ * }
+ * }
+ */
+ editCookie(data) {
+ let {field, oldValue, newValue} = data;
+ let origName = field === "name" ? oldValue : data.items.name;
+ let origHost = field === "host" ? oldValue : data.items.host;
+ let origPath = field === "path" ? oldValue : data.items.path;
+ let cookie = null;
+
+ let enumerator = Services.cookies.getCookiesFromHost(origHost, data.originAttributes || {});
+ while (enumerator.hasMoreElements()) {
+ let nsiCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2);
+ if (nsiCookie.name === origName && nsiCookie.host === origHost) {
+ 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
+ };
+ break;
+ }
+ }
+
+ if (!cookie) {
+ return;
+ }
+
+ // If the date is expired set it for 1 minute in the future.
+ let now = new Date();
+ if (!cookie.isSession && (cookie.expires * 1000) <= now) {
+ let 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,
+ false, 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
+ );
+ },
+
+ _removeCookies(host, opts = {}) {
+ function hostMatches(cookieHost, matchHost) {
+ if (cookieHost == null) {
+ return matchHost == null;
+ }
+ if (cookieHost.startsWith(".")) {
+ return ("." + matchHost).endsWith(cookieHost);
+ }
+ return cookieHost == host;
+ }
+
+ let enumerator = Services.cookies.getCookiesFromHost(host, opts.originAttributes || {});
+ while (enumerator.hasMoreElements()) {
+ let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2);
+ if (hostMatches(cookie.host, host) &&
+ (!opts.name || cookie.name === opts.name) &&
+ (!opts.domain || cookie.host === opts.domain)) {
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ false,
+ cookie.originAttributes
+ );
+ }
+ }
+ },
+
+ removeCookie(host, name, originAttributes) {
+ if (name !== undefined) {
+ this._removeCookies(host, { name, originAttributes });
+ }
+ },
+
+ removeAllCookies(host, domain, originAttributes) {
+ this._removeCookies(host, { domain, originAttributes });
+ },
+
+ addCookieObservers() {
+ Services.obs.addObserver(cookieHelpers, "cookie-changed", false);
+ return null;
+ },
+
+ removeCookieObservers() {
+ Services.obs.removeObserver(cookieHelpers, "cookie-changed", false);
+ return null;
+ },
+
+ observe(subject, topic, data) {
+ if (!subject) {
+ return;
+ }
+
+ switch (topic) {
+ case "cookie-changed":
+ if (data === "batch-deleted") {
+ let cookiesNoInterface = subject.QueryInterface(Ci.nsIArray);
+ let cookies = [];
+
+ for (let i = 0; i < cookiesNoInterface.length; i++) {
+ let cookie = cookiesNoInterface.queryElementAt(i, Ci.nsICookie2);
+ cookies.push(cookie);
+ }
+ cookieHelpers.onCookieChanged(cookies, topic, data);
+
+ return;
+ }
+
+ let cookie = subject.QueryInterface(Ci.nsICookie2);
+ 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": {
+ let host = msg.data.args[0];
+ let originAttributes = msg.data.args[1];
+ let cookies = cookieHelpers.getCookiesFromHost(host, originAttributes);
+ return JSON.stringify(cookies);
+ }
+ case "addCookieObservers": {
+ return cookieHelpers.addCookieObservers();
+ }
+ case "removeCookieObservers": {
+ return cookieHelpers.removeCookieObservers();
+ }
+ case "editCookie": {
+ let rowdata = msg.data.args[0];
+ return cookieHelpers.editCookie(rowdata);
+ }
+ case "removeCookie": {
+ let host = msg.data.args[0];
+ let name = msg.data.args[1];
+ let originAttributes = msg.data.args[2];
+ return cookieHelpers.removeCookie(host, name, originAttributes);
+ }
+ case "removeAllCookies": {
+ let host = msg.data.args[0];
+ let domain = msg.data.args[1];
+ let originAttributes = msg.data.args[2];
+ return cookieHelpers.removeAllCookies(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
+ setMessageManager(mm);
+
+ 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.
+ }
+ }
+
+ function setMessageManager(newMM) {
+ if (mm) {
+ mm.removeMessageListener("debug:storage-cookie-request-parent",
+ cookieHelpers.handleChildRequest);
+ }
+ mm = newMM;
+ if (mm) {
+ mm.addMessageListener("debug:storage-cookie-request-parent",
+ cookieHelpers.handleChildRequest);
+ }
+ }
+
+ return {
+ onBrowserSwap: setMessageManager,
+ 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();
+ setMessageManager(null);
+ }
+ };
+};
+
+/**
+ * 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) {
+ let storage = this.hostVsStores.get(host);
+ return storage ? Object.keys(storage) : [];
+ },
+
+ getValuesForHost(host, name) {
+ let storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return [];
+ }
+ if (name) {
+ let value = storage ? storage.getItem(name) : null;
+ return [{ name, value }];
+ }
+ if (!storage) {
+ return [];
+ }
+ return Object.keys(storage).map(key => ({
+ name: key,
+ value: storage.getItem(key)
+ }));
+ },
+
+ getHostName(location) {
+ if (!location.host) {
+ return location.href;
+ }
+ return location.protocol + "//" + location.host;
+ },
+
+ 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 (let window of this.windows) {
+ this.populateStoresForHost(this.getHostName(window.location), window);
+ }
+ },
+
+ getFields: Task.async(function* () {
+ return [
+ { name: "name", editable: 1},
+ { name: "value", editable: 1}
+ ];
+ }),
+
+ /**
+ * Edit localStorage or sessionStorage fields.
+ *
+ * @param {Object} data
+ * See editCookie() for format details.
+ */
+ editItem: Task.async(function* ({host, field, oldValue, items}) {
+ let storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+
+ if (field === "name") {
+ storage.removeItem(oldValue);
+ }
+
+ storage.setItem(items.name, items.value);
+ }),
+
+ removeItem: Task.async(function* (host, name) {
+ let storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.removeItem(name);
+ }),
+
+ removeAll: Task.async(function* (host) {
+ let storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.clear();
+ }),
+
+ observe(subject, topic, data) {
+ if (topic != "dom-storage2-changed" || data != type) {
+ return null;
+ }
+
+ let 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";
+ }
+ let updateData = {};
+ updateData[host] = [subject.key];
+ return this.storageActor.update(action, type, updateData);
+ },
+
+ /**
+ * Given a url, correctly determine its protocol + hostname part.
+ */
+ getSchemaAndHost(url) {
+ let uri = Services.io.newURI(url, null, null);
+ 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",
+ observationTopic: "dom-storage2-changed"
+}, getObjectForLocalOrSessionStorage("localStorage"));
+
+/**
+ * The Session Storage actor and front.
+ */
+StorageActors.createActor({
+ typeName: "sessionStorage",
+ observationTopic: "dom-storage2-changed"
+}, getObjectForLocalOrSessionStorage("sessionStorage"));
+
+StorageActors.createActor({
+ typeName: "Cache"
+}, {
+ getCachesForHost: Task.async(function* (host) {
+ let uri = Services.io.newURI(host, null, null);
+ let principal =
+ Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri);
+
+ // 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.
+ let { CacheStorage } = this.storageActor.window;
+ let cache = new CacheStorage("content", principal);
+ return cache;
+ }),
+
+ preListStores: Task.async(function* () {
+ for (let host of this.hosts) {
+ yield this.populateStoresForHost(host);
+ }
+ }),
+
+ form(form, detail) {
+ if (detail === "actorid") {
+ return this.actorID;
+ }
+
+ let hosts = {};
+ for (let host of this.hosts) {
+ hosts[host] = this.getNamesForHost(host);
+ }
+
+ return {
+ actor: this.actorID,
+ hosts: hosts
+ };
+ },
+
+ 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]);
+ });
+ },
+
+ getValuesForHost: Task.async(function* (host, name) {
+ if (!name) {
+ return [];
+ }
+ // UI is weird and expect a JSON stringified array... and pass it back :/
+ name = JSON.parse(name)[0];
+
+ let cache = this.hostVsStores.get(host).get(name);
+ let requests = yield cache.keys();
+ let results = [];
+ for (let request of requests) {
+ let response = yield 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(yield this.processEntry(request, response));
+ }
+ return results;
+ }),
+
+ processEntry: Task.async(function* (request, response) {
+ return {
+ url: String(request.url),
+ status: String(response.statusText),
+ };
+ }),
+
+ getFields: Task.async(function* () {
+ return [
+ { name: "url", editable: 0 },
+ { name: "status", editable: 0 }
+ ];
+ }),
+
+ getHostName(location) {
+ if (!location.host) {
+ return location.href;
+ }
+ return location.protocol + "//" + location.host;
+ },
+
+ populateStoresForHost: Task.async(function* (host) {
+ let storeMap = new Map();
+ let caches = yield this.getCachesForHost(host);
+ try {
+ for (let name of (yield caches.keys())) {
+ storeMap.set(name, (yield 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) {
+ let uri = Services.io.newURI(url, null, null);
+ return uri.scheme + "://" + uri.hostPort;
+ },
+
+ toStoreObject(item) {
+ return item;
+ },
+
+ removeItem: Task.async(function* (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 = yield this.getCachesForHost(host);
+ yield 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) {
+ yield cache.delete(url);
+ this.onItemUpdated("deleted", host, [ cacheName, url ]);
+ }
+ }
+ }),
+
+ removeAll: Task.async(function* (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) {
+ let keys = yield cache.keys();
+ yield 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++) {
+ let index = objectStore.index(objectStore.indexNames[i]);
+
+ let 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.
+ */
+function DatabaseMetadata(origin, db) {
+ this._origin = origin;
+ this._name = db.name;
+ this._version = db.version;
+ this._objectStores = [];
+
+ if (db.objectStoreNames.length) {
+ let transaction = db.transaction(db.objectStoreNames, "readonly");
+
+ for (let i = 0; i < transaction.objectStoreNames.length; i++) {
+ let objectStore =
+ transaction.objectStore(transaction.objectStoreNames[i]);
+ this._objectStores.push([transaction.objectStoreNames[i],
+ new ObjectStoreMetadata(objectStore)]);
+ }
+ }
+}
+DatabaseMetadata.prototype = {
+ get objectStores() {
+ return this._objectStores;
+ },
+
+ toObject() {
+ return {
+ name: this._name,
+ 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);
+
+ events.on(this.storageActor, "window-ready", this.onWindowReady);
+ events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed);
+ },
+
+ destroy() {
+ this.hostVsStores.clear();
+ this.objectsSize = null;
+
+ events.off(this.storageActor, "window-ready", this.onWindowReady);
+ events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed);
+ },
+
+ /**
+ * Remove an indexedDB database from given host with a given name.
+ */
+ removeDatabase: Task.async(function* (host, name) {
+ let win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return { error: `Window for host ${host} not found` };
+ }
+
+ let principal = win.document.nodePrincipal;
+ return this.removeDB(host, principal, name);
+ }),
+
+ removeAll: Task.async(function* (host, name) {
+ let [db, store] = JSON.parse(name);
+
+ let win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return;
+ }
+
+ let principal = win.document.nodePrincipal;
+ this.clearDBStore(host, principal, db, store);
+ }),
+
+ removeItem: Task.async(function* (host, name) {
+ let [db, store, id] = JSON.parse(name);
+
+ let win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return;
+ }
+
+ let principal = win.document.nodePrincipal;
+ this.removeDBRecord(host, principal, db, store, id);
+ }),
+
+ getHostName(location) {
+ if (!location.host) {
+ return location.href;
+ }
+ return location.protocol + "//" + location.host;
+ },
+
+ /**
+ * 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) {
+ let names = [];
+
+ for (let [dbName, {objectStores}] of this.hostVsStores.get(host)) {
+ if (objectStores.size) {
+ for (let 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.
+ let name = names[0];
+ let 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.
+ let index = options.index;
+ let [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.
+ */
+ preListStores: Task.async(function* () {
+ this.hostVsStores = new Map();
+
+ for (let host of this.hosts) {
+ yield this.populateStoresForHost(host);
+ }
+ }),
+
+ populateStoresForHost: Task.async(function* (host) {
+ let storeMap = new Map();
+ let {names} = yield this.getDBNamesForHost(host);
+ let win = this.storageActor.getWindowFromHost(host);
+ if (win) {
+ let principal = win.document.nodePrincipal;
+
+ for (let name of names) {
+ let metadata = yield this.getDBMetaData(host, principal, name);
+
+ metadata = indexedDBHelpers.patchMetadataMapsAndProtos(metadata);
+ storeMap.set(name, 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 {
+ db: item.name,
+ origin: item.origin,
+ version: item.version,
+ objectStores: item.objectStores
+ };
+ }
+ // Indexed db entry
+ return {
+ name: item.name,
+ value: new LongStringActor(this.conn, JSON.stringify(item.value))
+ };
+ },
+
+ form(form, detail) {
+ if (detail === "actorid") {
+ return this.actorID;
+ }
+
+ let hosts = {};
+ for (let host of this.hosts) {
+ hosts[host] = this.getNamesForHost(host);
+ }
+
+ return {
+ actor: this.actorID,
+ hosts: hosts
+ };
+ },
+
+ 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 (!DebuggerServer.isInChildProcess) {
+ this.backToChild = (func, rv) => rv;
+ this.getDBMetaData = indexedDBHelpers.getDBMetaData;
+ this.openWithPrincipal = indexedDBHelpers.openWithPrincipal;
+ this.getDBNamesForHost = indexedDBHelpers.getDBNamesForHost;
+ this.getSanitizedHost = indexedDBHelpers.getSanitizedHost;
+ this.getNameFromDatabaseFile = indexedDBHelpers.getNameFromDatabaseFile;
+ this.getValuesForHost = indexedDBHelpers.getValuesForHost;
+ this.getObjectStoreData = indexedDBHelpers.getObjectStoreData;
+ this.removeDB = indexedDBHelpers.removeDB;
+ this.removeDBRecord = indexedDBHelpers.removeDBRecord;
+ this.clearDBStore = indexedDBHelpers.clearDBStore;
+ return;
+ }
+
+ const { sendAsyncMessage, addMessageListener } =
+ this.conn.parentMessageManager;
+
+ this.conn.setupInParent({
+ module: "devtools/server/actors/storage",
+ setupParent: "setupParentProcessForIndexedDB"
+ });
+
+ this.getDBMetaData = callParentProcessAsync.bind(null, "getDBMetaData");
+ 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");
+
+ addMessageListener("debug:storage-indexedDB-request-child", msg => {
+ switch (msg.json.method) {
+ case "backToChild": {
+ let [func, rv] = msg.json.args;
+ let deferred = unresolvedPromises.get(func);
+ if (deferred) {
+ unresolvedPromises.delete(func);
+ deferred.resolve(rv);
+ }
+ break;
+ }
+ case "onItemUpdated": {
+ let [action, host, path] = msg.json.args;
+ this.onItemUpdated(action, host, path);
+ }
+ }
+ });
+
+ let unresolvedPromises = new Map();
+ function callParentProcessAsync(methodName, ...args) {
+ let deferred = promise.defer();
+
+ unresolvedPromises.set(methodName, deferred);
+
+ sendAsyncMessage("debug:storage-indexedDB-request-parent", {
+ method: methodName,
+ args: args
+ });
+
+ return deferred.promise;
+ }
+ },
+
+ getFields: Task.async(function* (subType) {
+ switch (subType) {
+ // Detail of database
+ case "database":
+ return [
+ { name: "objectStore", editable: 0 },
+ { name: "keyPath", editable: 0 },
+ { name: "autoIncrement", editable: 0 },
+ { name: "indexes", editable: 0 },
+ ];
+
+ // Detail of object store
+ case "object store":
+ return [
+ { name: "name", editable: 0 },
+ { name: "value", editable: 0 }
+ ];
+
+ // Detail of indexedDB for one origin
+ default:
+ return [
+ { name: "db", editable: 0 },
+ { name: "origin", editable: 0 },
+ { name: "version", editable: 0 },
+ { name: "objectStores", editable: 0 },
+ ];
+ }
+ })
+});
+
+var indexedDBHelpers = {
+ backToChild(...args) {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+
+ mm.broadcastAsyncMessage("debug:storage-indexedDB-request-child", {
+ method: "backToChild",
+ args: args
+ });
+ },
+
+ onItemUpdated(action, host, path) {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+
+ 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.
+ */
+ getDBMetaData: Task.async(function* (host, principal, name) {
+ let request = this.openWithPrincipal(principal, name);
+ let success = promise.defer();
+
+ request.onsuccess = event => {
+ let db = event.target.result;
+
+ let dbData = new DatabaseMetadata(host, db);
+ db.close();
+
+ success.resolve(this.backToChild("getDBMetaData", dbData));
+ };
+ request.onerror = ({target}) => {
+ console.error(
+ `Error opening indexeddb database ${name} for host ${host}`, target.error);
+ success.resolve(this.backToChild("getDBMetaData", null));
+ };
+ return success.promise;
+ }),
+
+ /**
+ * Opens an indexed db connection for the given `principal` and
+ * database `name`.
+ */
+ openWithPrincipal(principal, name) {
+ return indexedDBForStorage.openForPrincipal(principal, name);
+ },
+
+ removeDB: Task.async(function* (host, principal, name) {
+ let result = new promise(resolve => {
+ let request = indexedDBForStorage.deleteForPrincipal(principal, name);
+
+ request.onsuccess = () => {
+ resolve({});
+ this.onItemUpdated("deleted", host, [name]);
+ };
+
+ request.onblocked = () => {
+ console.warn(`Deleting indexedDB database ${name} for host ${host} is blocked`);
+ resolve({ blocked: true });
+ };
+
+ request.onerror = () => {
+ let { 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", yield result);
+ }),
+
+ removeDBRecord: Task.async(function* (host, principal, dbName, storeName, id) {
+ let db;
+
+ try {
+ db = yield new promise((resolve, reject) => {
+ let request = this.openWithPrincipal(principal, dbName);
+ request.onsuccess = ev => resolve(ev.target.result);
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ let transaction = db.transaction(storeName, "readwrite");
+ let store = transaction.objectStore(storeName);
+
+ yield new promise((resolve, reject) => {
+ let request = store.delete(id);
+ request.onsuccess = () => resolve();
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ this.onItemUpdated("deleted", host, [dbName, storeName, id]);
+ } catch (error) {
+ let recordPath = [dbName, storeName, id].join("/");
+ console.error(`Failed to delete indexedDB record: ${recordPath}: ${error}`);
+ }
+
+ if (db) {
+ db.close();
+ }
+
+ return this.backToChild("removeDBRecord", null);
+ }),
+
+ clearDBStore: Task.async(function* (host, principal, dbName, storeName) {
+ let db;
+
+ try {
+ db = yield new promise((resolve, reject) => {
+ let request = this.openWithPrincipal(principal, dbName);
+ request.onsuccess = ev => resolve(ev.target.result);
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ let transaction = db.transaction(storeName, "readwrite");
+ let store = transaction.objectStore(storeName);
+
+ yield new promise((resolve, reject) => {
+ let request = store.clear();
+ request.onsuccess = () => resolve();
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ this.onItemUpdated("cleared", host, [dbName, storeName]);
+ } catch (error) {
+ let 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`.
+ */
+ getDBNamesForHost: Task.async(function* (host) {
+ let sanitizedHost = this.getSanitizedHost(host);
+ let directory = OS.Path.join(OS.Constants.Path.profileDir, "storage",
+ "default", sanitizedHost, "idb");
+
+ let exists = yield OS.File.exists(directory);
+ if (!exists && host.startsWith("about:")) {
+ // try for moz-safe-about directory
+ sanitizedHost = this.getSanitizedHost("moz-safe-" + host);
+ directory = OS.Path.join(OS.Constants.Path.profileDir, "storage",
+ "permanent", sanitizedHost, "idb");
+ exists = yield OS.File.exists(directory);
+ }
+ if (!exists) {
+ return this.backToChild("getDBNamesForHost", {names: []});
+ }
+
+ let names = [];
+ let dirIterator = new OS.File.DirectoryIterator(directory);
+ try {
+ yield dirIterator.forEach(file => {
+ // Skip directories.
+ if (file.isDir) {
+ return null;
+ }
+
+ // Skip any non-sqlite files.
+ if (!file.name.endsWith(".sqlite")) {
+ return null;
+ }
+
+ return this.getNameFromDatabaseFile(file.path).then(name => {
+ if (name) {
+ names.push(name);
+ }
+ return null;
+ });
+ });
+ } finally {
+ dirIterator.close();
+ }
+ return this.backToChild("getDBNamesForHost", {names: names});
+ }),
+
+ /**
+ * Removes any illegal characters from the host name to make it a valid file
+ * name.
+ */
+ getSanitizedHost(host) {
+ return host.replace(ILLEGAL_CHAR_REGEX, "+");
+ },
+
+ /**
+ * Retrieves the proper indexed db database name from the provided .sqlite
+ * file location.
+ */
+ getNameFromDatabaseFile: Task.async(function* (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 retey for some time to see if lock is removed.
+ while (!connection && retryCount++ < 25) {
+ try {
+ connection = yield Sqlite.openConnection({ path: path });
+ } catch (ex) {
+ // Continuously retrying is overkill. Waiting for 100ms before next try
+ yield sleep(100);
+ }
+ }
+
+ if (!connection) {
+ return null;
+ }
+
+ let rows = yield connection.execute("SELECT name FROM database");
+ if (rows.length != 1) {
+ return null;
+ }
+
+ let name = rows[0].getResultByName("name");
+
+ yield connection.close();
+
+ return name;
+ }),
+
+ getValuesForHost: Task.async(function* (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.
+ let 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});
+ }
+
+ let [db2, objectStore, id] = name;
+ if (!objectStore) {
+ // This means that details about all the object stores in this db are
+ // requested.
+ let objectStores = [];
+ if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) {
+ let db = hostVsStores.get(host).get(db2);
+
+ db = indexedDBHelpers.patchMetadataMapsAndProtos(db);
+
+ let objectStores2 = db.objectStores;
+
+ for (let 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
+ let result = yield this.getObjectStoreData(host, principal, db2,
+ objectStore, id, options.index, options.size);
+ return this.backToChild("getValuesForHost", {result: result});
+ }),
+
+ /**
+ * Returns all or requested entries 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} objectStore
+ * The name of the object store from the above db.
+ * @param {string} id
+ * id of the requested entry from the above object store.
+ * null if all entries from the above object store are requested.
+ * @param {string} index
+ * name of the IDBIndex to be iterated on while fetching entries.
+ * null or "name" if no index is to be iterated.
+ * @param {number} offset
+ * ofsset of the entries to be fetched.
+ * @param {number} size
+ * The intended size of the entries to be fetched.
+ */
+ getObjectStoreData(host, principal, dbName, objectStore, id, index,
+ offset, size) {
+ let request = this.openWithPrincipal(principal, dbName);
+ let success = promise.defer();
+ let data = [];
+ let db;
+
+ if (!size || size > MAX_STORE_OBJECT_COUNT) {
+ size = MAX_STORE_OBJECT_COUNT;
+ }
+
+ request.onsuccess = event => {
+ db = event.target.result;
+
+ let transaction = db.transaction(objectStore, "readonly");
+ let source = transaction.objectStore(objectStore);
+ if (index && index != "name") {
+ source = source.index(index);
+ }
+
+ source.count().onsuccess = event2 => {
+ let objectsSize = [];
+ let count = event2.target.result;
+ objectsSize.push({
+ key: host + dbName + objectStore + index,
+ count: count
+ });
+
+ if (!offset) {
+ offset = 0;
+ } else if (offset > count) {
+ db.close();
+ success.resolve([]);
+ return;
+ }
+
+ if (id) {
+ source.get(id).onsuccess = event3 => {
+ db.close();
+ success.resolve([{name: id, value: event3.target.result}]);
+ };
+ } else {
+ source.openCursor().onsuccess = event4 => {
+ let cursor = event4.target.result;
+
+ if (!cursor || data.length >= size) {
+ db.close();
+ success.resolve({
+ data: data,
+ objectsSize: objectsSize
+ });
+ return;
+ }
+ if (offset-- <= 0) {
+ data.push({name: cursor.key, value: cursor.value});
+ }
+ cursor.continue();
+ };
+ }
+ };
+ };
+ request.onerror = () => {
+ db.close();
+ success.resolve([]);
+ };
+ return success.promise;
+ },
+
+ /**
+ * 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) {
+ let md = Object.create(DatabaseMetadata.prototype);
+ Object.assign(md, metadata);
+
+ md._objectStores = new Map(metadata._objectStores);
+
+ for (let [name, store] of md._objectStores) {
+ let 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 (let [name2, value] of obj._indexes) {
+ let obj2 = Object.create(IndexMetadata.prototype);
+ Object.assign(obj2, value);
+
+ obj._indexes.set(name2, obj2);
+ }
+ }
+
+ return md;
+ },
+
+ handleChildRequest(msg) {
+ let args = msg.data.args;
+
+ switch (msg.json.method) {
+ case "getDBMetaData": {
+ let [host, principal, name] = args;
+ return indexedDBHelpers.getDBMetaData(host, principal, name);
+ }
+ case "getDBNamesForHost": {
+ let [host] = args;
+ return indexedDBHelpers.getDBNamesForHost(host);
+ }
+ case "getValuesForHost": {
+ let [host, name, options, hostVsStores, principal] = args;
+ return indexedDBHelpers.getValuesForHost(host, name, options,
+ hostVsStores, principal);
+ }
+ case "removeDB": {
+ let [host, principal, name] = args;
+ return indexedDBHelpers.removeDB(host, principal, name);
+ }
+ case "removeDBRecord": {
+ let [host, principal, db, store, id] = args;
+ return indexedDBHelpers.removeDBRecord(host, principal, db, store, id);
+ }
+ case "clearDBStore": {
+ let [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 }) {
+ // listen for director-script requests from the child process
+ setMessageManager(mm);
+
+ function setMessageManager(newMM) {
+ if (mm) {
+ mm.removeMessageListener("debug:storage-indexedDB-request-parent",
+ indexedDBHelpers.handleChildRequest);
+ }
+ mm = newMM;
+ if (mm) {
+ mm.addMessageListener("debug:storage-indexedDB-request-parent",
+ indexedDBHelpers.handleChildRequest);
+ }
+ }
+
+ return {
+ onBrowserSwap: setMessageManager,
+ onDisconnected: () => setMessageManager(null),
+ };
+};
+
+/**
+ * The main Storage Actor.
+ */
+let 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, tabActor) {
+ protocol.Actor.prototype.initialize.call(this, null);
+
+ this.conn = conn;
+ this.parentActor = tabActor;
+
+ this.childActorPool = new Map();
+ this.childWindowPool = new Set();
+
+ // Fetch all the inner iframe windows in this tab.
+ this.fetchChildWindows(this.parentActor.docShell);
+
+ // Initialize the registered store types
+ for (let [store, ActorConstructor] of storageTypePool) {
+ 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", false);
+ Services.obs.addObserver(this, "inner-window-destroyed", false);
+ this.onPageChange = this.onPageChange.bind(this);
+
+ let handler = tabActor.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", false);
+ Services.obs.removeObserver(this, "inner-window-destroyed", false);
+ 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 (let actor of this.childActorPool.values()) {
+ actor.destroy();
+ }
+ this.childActorPool.clear();
+ this.childWindowPool.clear();
+ this.childWindowPool = this.childActorPool = this.__poolMap = this.conn =
+ this.parentActor = this.boundUpdate = this.registeredPool =
+ this._pendingResponse = null;
+ },
+
+ /**
+ * 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) {
+ let docShell = item.QueryInterface(Ci.nsIDocShell)
+ .QueryInterface(Ci.nsIDocShellTreeItem);
+ if (!docShell.contentViewer) {
+ return null;
+ }
+ let 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++) {
+ let child = docShell.getChildAt(i);
+ this.fetchChildWindows(child);
+ }
+ return null;
+ },
+
+ isIncludedInTopLevelWindow(window) {
+ return isWindowIncluded(this.window, window);
+ },
+
+ getWindowFromInnerWindowID(innerID) {
+ innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data;
+ for (let win of this.childWindowPool.values()) {
+ let id = win.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils)
+ .currentInnerWindowID;
+ if (id == innerID) {
+ return win;
+ }
+ }
+ return null;
+ },
+
+ getWindowFromHost(host) {
+ for (let win of this.childWindowPool.values()) {
+ let origin = win.document
+ .nodePrincipal
+ .originNoSuffix;
+ let 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;
+ }
+
+ if (topic == "content-document-global-created" &&
+ this.isIncludedInTopLevelWindow(subject)) {
+ this.childWindowPool.add(subject);
+ events.emit(this, "window-ready", subject);
+ } else if (topic == "inner-window-destroyed") {
+ let window = this.getWindowFromInnerWindowID(subject);
+ if (window) {
+ this.childWindowPool.delete(window);
+ events.emit(this, "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;
+ }
+
+ let window = target.defaultView;
+
+ if (type == "pagehide" && this.childWindowPool.delete(window)) {
+ events.emit(this, "window-destroyed", window);
+ } else if (type == "pageshow" && persisted && window.location.href &&
+ window.location.href != "about:blank" &&
+ this.isIncludedInTopLevelWindow(window)) {
+ this.childWindowPool.add(window);
+ events.emit(this, "window-ready", window);
+ }
+ },
+
+ /**
+ * Lists the available hosts for all the registered storage types.
+ *
+ * @returns {object} An object containing with the following structure:
+ * - <storageType> : [{
+ * actor: <actorId>,
+ * host: <hostname>
+ * }]
+ */
+ listStores: Task.async(function* () {
+ let toReturn = {};
+
+ for (let [name, value] of this.childActorPool) {
+ if (value.preListStores) {
+ yield value.preListStores();
+ }
+ toReturn[name] = value;
+ }
+
+ return toReturn;
+ }),
+
+ /**
+ * 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.
+ */
+ update(action, storeType, data) {
+ if (action == "cleared") {
+ events.emit(this, "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 (let host in data) {
+ if (!this.boundUpdate[action][storeType][host]) {
+ this.boundUpdate[action][storeType][host] = [];
+ }
+ for (let 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 (let 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);
+ events.emit(this, "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 (let host in data) {
+ if (this.boundUpdate[action] && this.boundUpdate[action][storeType] &&
+ this.boundUpdate[action][storeType][host]) {
+ for (let name in data[host]) {
+ let 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;