tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

content-process-storage.js (14711B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
      8 
      9 const lazy = {};
     10 ChromeUtils.defineESModuleGetters(
     11  lazy,
     12  {
     13    getAddonIdForWindowGlobal:
     14      "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
     15  },
     16  { global: "contextual" }
     17 );
     18 
     19 // ms of delay to throttle updates
     20 const BATCH_DELAY = 200;
     21 
     22 // Filters "stores-update" response to only include events for
     23 // the storage type we desire
     24 function getFilteredStorageEvents(updates, storageType) {
     25  const filteredUpdate = Object.create(null);
     26 
     27  // updateType will be "added", "changed", or "deleted"
     28  for (const updateType in updates) {
     29    if (updates[updateType][storageType]) {
     30      if (!filteredUpdate[updateType]) {
     31        filteredUpdate[updateType] = {};
     32      }
     33      filteredUpdate[updateType][storageType] =
     34        updates[updateType][storageType];
     35    }
     36  }
     37 
     38  return Object.keys(filteredUpdate).length ? filteredUpdate : null;
     39 }
     40 
     41 class ContentProcessStorage {
     42  constructor(ActorConstructor, storageKey) {
     43    this.ActorConstructor = ActorConstructor;
     44    this.storageKey = storageKey;
     45 
     46    this.onStoresUpdate = this.onStoresUpdate.bind(this);
     47    this.onStoresCleared = this.onStoresCleared.bind(this);
     48  }
     49 
     50  async watch(targetActor, { onAvailable }) {
     51    const storageActor = new StorageActorMock(targetActor);
     52    this.storageActor = storageActor;
     53    this.actor = new this.ActorConstructor(storageActor);
     54 
     55    // Some storage types require to prelist their stores
     56    await this.actor.populateStoresForHosts();
     57 
     58    // We have to manage the actor manually, because ResourceCommand doesn't
     59    // use the protocol.js specification.
     60    // resources-available-array is typed as "json"
     61    // So that we have to manually handle stuff that would normally be
     62    // automagically done by procotol.js
     63    // 1) Manage the actor in order to have an actorID on it
     64    targetActor.manage(this.actor);
     65    // 2) Convert to JSON "form"
     66    const form = this.actor.form();
     67 
     68    // NOTE: this is hoisted, so the `update` method above may use it.
     69    const storage = form;
     70 
     71    // All resources should have a resourceId and resourceKey
     72    // attributes, so available/updated/destroyed callbacks work properly.
     73    storage.resourceId = this.storageKey;
     74    storage.resourceKey = this.storageKey;
     75 
     76    onAvailable([storage]);
     77 
     78    // Maps global events from `storageActor` shared for all storage-types,
     79    // down to storage-type's specific actor `storage`.
     80    storageActor.on("stores-update", this.onStoresUpdate);
     81 
     82    // When a store gets cleared
     83    storageActor.on("stores-cleared", this.onStoresCleared);
     84  }
     85 
     86  onStoresUpdate(response) {
     87    response = getFilteredStorageEvents(response, this.storageKey);
     88    if (!response) {
     89      return;
     90    }
     91    this.actor.emit("single-store-update", {
     92      changed: response.changed,
     93      added: response.added,
     94      deleted: response.deleted,
     95    });
     96  }
     97 
     98  onStoresCleared(response) {
     99    const cleared = response[this.storageKey];
    100 
    101    if (!cleared) {
    102      return;
    103    }
    104 
    105    this.actor.emit("single-store-cleared", {
    106      clearedHostsOrPaths: cleared,
    107    });
    108  }
    109 
    110  destroy() {
    111    this.actor?.destroy();
    112    this.actor = null;
    113    if (this.storageActor) {
    114      this.storageActor.on("stores-update", this.onStoresUpdate);
    115      this.storageActor.on("stores-cleared", this.onStoresCleared);
    116      this.storageActor.destroy();
    117      this.storageActor = null;
    118    }
    119  }
    120 }
    121 
    122 module.exports = ContentProcessStorage;
    123 
    124 // This class mocks what used to be implement in devtools/server/actors/storage.js: StorageActor
    125 // But without being a protocol.js actor, nor implement any RDP method/event.
    126 // An instance of this class is passed to each storage type actor and named `storageActor`.
    127 // Once we implement all storage type in watcher classes, we can get rid of the original
    128 // StorageActor in devtools/server/actors/storage.js
    129 class StorageActorMock extends EventEmitter {
    130  constructor(targetActor) {
    131    super();
    132    // Storage classes fetch conn from storageActor
    133    this.conn = targetActor.conn;
    134    this.targetActor = targetActor;
    135 
    136    this.childWindowPool = new Set();
    137 
    138    // Fetch all the inner iframe windows in this tab.
    139    this.fetchChildWindows(this.targetActor.docShell);
    140 
    141    // Notifications that help us keep track of newly added windows and windows
    142    // that got removed
    143    Services.obs.addObserver(this, "content-document-global-created");
    144    Services.obs.addObserver(this, "inner-window-destroyed");
    145    this.onPageChange = this.onPageChange.bind(this);
    146 
    147    const handler = targetActor.chromeEventHandler;
    148    handler.addEventListener("pageshow", this.onPageChange, true);
    149    handler.addEventListener("pagehide", this.onPageChange, true);
    150 
    151    this.destroyed = false;
    152    this.boundUpdate = {};
    153  }
    154 
    155  destroy() {
    156    clearTimeout(this.batchTimer);
    157    this.batchTimer = null;
    158    // Remove observers
    159    Services.obs.removeObserver(this, "content-document-global-created");
    160    Services.obs.removeObserver(this, "inner-window-destroyed");
    161    this.destroyed = true;
    162    if (this.targetActor.browser) {
    163      this.targetActor.browser.removeEventListener(
    164        "pageshow",
    165        this.onPageChange,
    166        true
    167      );
    168      this.targetActor.browser.removeEventListener(
    169        "pagehide",
    170        this.onPageChange,
    171        true
    172      );
    173    }
    174    this.childWindowPool.clear();
    175 
    176    this.childWindowPool = null;
    177    this.targetActor = null;
    178    this.boundUpdate = null;
    179  }
    180 
    181  get window() {
    182    return this.targetActor.window;
    183  }
    184 
    185  get document() {
    186    return this.targetActor.window.document;
    187  }
    188 
    189  get windows() {
    190    return this.childWindowPool;
    191  }
    192 
    193  /**
    194   * Given a docshell, recursively find out all the child windows from it.
    195   *
    196   * @param {nsIDocShell} item
    197   *        The docshell from which all inner windows need to be extracted.
    198   */
    199  fetchChildWindows(item) {
    200    const docShell = item
    201      .QueryInterface(Ci.nsIDocShell)
    202      .QueryInterface(Ci.nsIDocShellTreeItem);
    203    if (!docShell.docViewer) {
    204      return null;
    205    }
    206    const window = docShell.docViewer.DOMDocument.defaultView;
    207    if (window.location.href == "about:blank") {
    208      // Skip out about:blank windows as Gecko creates them multiple times while
    209      // creating any global.
    210      return null;
    211    }
    212    if (!this.isIncludedInTopLevelWindow(window)) {
    213      return null;
    214    }
    215    this.childWindowPool.add(window);
    216    for (let i = 0; i < docShell.childCount; i++) {
    217      const child = docShell.getChildAt(i);
    218      this.fetchChildWindows(child);
    219    }
    220    return null;
    221  }
    222 
    223  isIncludedInTargetExtension(subject) {
    224    const addonId = lazy.getAddonIdForWindowGlobal(subject.windowGlobalChild);
    225    return addonId && addonId === this.targetActor.addonId;
    226  }
    227 
    228  isIncludedInTopLevelWindow(window) {
    229    return this.targetActor.windows.includes(window);
    230  }
    231 
    232  getWindowFromInnerWindowID(innerID) {
    233    innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data;
    234    for (const win of this.childWindowPool.values()) {
    235      const id = win.windowGlobalChild.innerWindowId;
    236      if (id == innerID) {
    237        return win;
    238      }
    239    }
    240    return null;
    241  }
    242 
    243  getWindowFromHost(host) {
    244    for (const win of this.childWindowPool.values()) {
    245      const origin = win.document.nodePrincipal.originNoSuffix;
    246      const url = win.document.URL;
    247      if (origin === host || url === host) {
    248        return win;
    249      }
    250    }
    251    return null;
    252  }
    253 
    254  /**
    255   * Event handler for any docshell update. This lets us figure out whenever
    256   * any new window is added, or an existing window is removed.
    257   */
    258  observe(subject, topic) {
    259    if (
    260      subject.location &&
    261      (!subject.location.href || subject.location.href == "about:blank")
    262    ) {
    263      return null;
    264    }
    265 
    266    // We don't want to try to find a top level window for an extension page, as
    267    // in many cases (e.g. background page), it is not loaded in a tab, and
    268    // 'isIncludedInTopLevelWindow' throws an error
    269    if (
    270      topic == "content-document-global-created" &&
    271      (this.isIncludedInTargetExtension(subject) ||
    272        this.isIncludedInTopLevelWindow(subject))
    273    ) {
    274      this.childWindowPool.add(subject);
    275      this.emit("window-ready", subject);
    276    } else if (topic == "inner-window-destroyed") {
    277      const window = this.getWindowFromInnerWindowID(subject);
    278      if (window) {
    279        this.childWindowPool.delete(window);
    280        this.emit("window-destroyed", window);
    281      }
    282    }
    283    return null;
    284  }
    285 
    286  /**
    287   * Called on "pageshow" or "pagehide" event on the chromeEventHandler of
    288   * current tab.
    289   *
    290   * @param {event} The event object passed to the handler. We are using these
    291   *        three properties from the event:
    292   *         - target {document} The document corresponding to the event.
    293   *         - type {string} Name of the event - "pageshow" or "pagehide".
    294   *         - persisted {boolean} true if there was no
    295   *                     "content-document-global-created" notification along
    296   *                     this event.
    297   */
    298  onPageChange({ target, type, persisted }) {
    299    if (this.destroyed) {
    300      return;
    301    }
    302 
    303    const window = target.defaultView;
    304 
    305    if (type == "pagehide" && this.childWindowPool.delete(window)) {
    306      this.emit("window-destroyed", window);
    307    } else if (
    308      type == "pageshow" &&
    309      persisted &&
    310      window.location.href &&
    311      window.location.href != "about:blank" &&
    312      this.isIncludedInTopLevelWindow(window)
    313    ) {
    314      this.childWindowPool.add(window);
    315      this.emit("window-ready", window);
    316    }
    317  }
    318 
    319  /**
    320   * This method is called by the registered storage types so as to tell the
    321   * Storage Actor that there are some changes in the stores. Storage Actor then
    322   * notifies the client front about these changes at regular (BATCH_DELAY)
    323   * interval.
    324   *
    325   * @param {string} action
    326   *        The type of change. One of "added", "changed" or "deleted"
    327   * @param {string} storeType
    328   *        The storage actor in which this change has occurred.
    329   * @param {object} data
    330   *        The update object. This object is of the following format:
    331   *         - {
    332   *             <host1>: [<store_names1>, <store_name2>...],
    333   *             <host2>: [<store_names34>...],
    334   *           }
    335   *           Where host1, host2 are the host in which this change happened and
    336   *           [<store_namesX] is an array of the names of the changed store objects.
    337   *           Pass an empty array if the host itself was affected: either completely
    338   *           removed or cleared.
    339   */
    340  // eslint-disable-next-line complexity
    341  update(action, storeType, data) {
    342    if (action == "cleared") {
    343      this.emit("stores-cleared", { [storeType]: data });
    344      return null;
    345    }
    346 
    347    if (this.batchTimer) {
    348      clearTimeout(this.batchTimer);
    349    }
    350    if (!this.boundUpdate[action]) {
    351      this.boundUpdate[action] = {};
    352    }
    353    if (!this.boundUpdate[action][storeType]) {
    354      this.boundUpdate[action][storeType] = {};
    355    }
    356    for (const host in data) {
    357      if (!this.boundUpdate[action][storeType][host]) {
    358        this.boundUpdate[action][storeType][host] = [];
    359      }
    360      for (const name of data[host]) {
    361        if (!this.boundUpdate[action][storeType][host].includes(name)) {
    362          this.boundUpdate[action][storeType][host].push(name);
    363        }
    364      }
    365    }
    366    if (action == "added") {
    367      // If the same store name was previously deleted or changed, but now is
    368      // added somehow, dont send the deleted or changed update.
    369      this.removeNamesFromUpdateList("deleted", storeType, data);
    370      this.removeNamesFromUpdateList("changed", storeType, data);
    371    } else if (
    372      action == "changed" &&
    373      this.boundUpdate.added &&
    374      this.boundUpdate.added[storeType]
    375    ) {
    376      // If something got added and changed at the same time, then remove those
    377      // items from changed instead.
    378      this.removeNamesFromUpdateList(
    379        "changed",
    380        storeType,
    381        this.boundUpdate.added[storeType]
    382      );
    383    } else if (action == "deleted") {
    384      // If any item got delete, or a host got delete, no point in sending
    385      // added or changed update
    386      this.removeNamesFromUpdateList("added", storeType, data);
    387      this.removeNamesFromUpdateList("changed", storeType, data);
    388 
    389      for (const host in data) {
    390        if (
    391          !data[host].length &&
    392          this.boundUpdate.added &&
    393          this.boundUpdate.added[storeType] &&
    394          this.boundUpdate.added[storeType][host]
    395        ) {
    396          delete this.boundUpdate.added[storeType][host];
    397        }
    398        if (
    399          !data[host].length &&
    400          this.boundUpdate.changed &&
    401          this.boundUpdate.changed[storeType] &&
    402          this.boundUpdate.changed[storeType][host]
    403        ) {
    404          delete this.boundUpdate.changed[storeType][host];
    405        }
    406      }
    407    }
    408 
    409    this.batchTimer = setTimeout(() => {
    410      clearTimeout(this.batchTimer);
    411      this.emit("stores-update", this.boundUpdate);
    412      this.boundUpdate = {};
    413    }, BATCH_DELAY);
    414 
    415    return null;
    416  }
    417 
    418  /**
    419   * This method removes data from the this.boundUpdate object in the same
    420   * manner like this.update() adds data to it.
    421   *
    422   * @param {string} action
    423   *        The type of change. One of "added", "changed" or "deleted"
    424   * @param {string} storeType
    425   *        The storage actor for which you want to remove the updates data.
    426   * @param {object} data
    427   *        The update object. This object is of the following format:
    428   *         - {
    429   *             <host1>: [<store_names1>, <store_name2>...],
    430   *             <host2>: [<store_names34>...],
    431   *           }
    432   *           Where host1, host2 are the hosts which you want to remove and
    433   *           [<store_namesX] is an array of the names of the store objects.
    434   */
    435  removeNamesFromUpdateList(action, storeType, data) {
    436    for (const host in data) {
    437      if (
    438        this.boundUpdate[action] &&
    439        this.boundUpdate[action][storeType] &&
    440        this.boundUpdate[action][storeType][host]
    441      ) {
    442        for (const name in data[host]) {
    443          const index = this.boundUpdate[action][storeType][host].indexOf(name);
    444          if (index > -1) {
    445            this.boundUpdate[action][storeType][host].splice(index, 1);
    446          }
    447        }
    448        if (!this.boundUpdate[action][storeType][host].length) {
    449          delete this.boundUpdate[action][storeType][host];
    450        }
    451      }
    452    }
    453    return null;
    454  }
    455 }