tor-browser

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

parent-process-storage.js (21305B)


      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 const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule(
      9  "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
     10  { global: "contextual" }
     11 );
     12 
     13 // ms of delay to throttle updates
     14 const BATCH_DELAY = 200;
     15 
     16 // Filters "stores-update" response to only include events for
     17 // the storage type we desire
     18 function getFilteredStorageEvents(updates, storageType) {
     19  const filteredUpdate = Object.create(null);
     20 
     21  // updateType will be "added", "changed", or "deleted"
     22  for (const updateType in updates) {
     23    if (updates[updateType][storageType]) {
     24      if (!filteredUpdate[updateType]) {
     25        filteredUpdate[updateType] = {};
     26      }
     27      filteredUpdate[updateType][storageType] =
     28        updates[updateType][storageType];
     29    }
     30  }
     31 
     32  return Object.keys(filteredUpdate).length ? filteredUpdate : null;
     33 }
     34 
     35 class ParentProcessStorage {
     36  constructor(ActorConstructor, storageKey, storageType) {
     37    this.ActorConstructor = ActorConstructor;
     38    this.storageKey = storageKey;
     39    this.storageType = storageType;
     40 
     41    this.onStoresUpdate = this.onStoresUpdate.bind(this);
     42    this.onStoresCleared = this.onStoresCleared.bind(this);
     43 
     44    this.observe = this.observe.bind(this);
     45    // Notifications that help us keep track of newly added windows and windows
     46    // that got removed
     47    Services.obs.addObserver(this, "window-global-created");
     48    Services.obs.addObserver(this, "window-global-destroyed");
     49 
     50    // bfcacheInParent is only enabled when fission is enabled
     51    // and when Session History In Parent is enabled. (all three modes should now enabled all together)
     52    loader.lazyGetter(
     53      this,
     54      "isBfcacheInParentEnabled",
     55      () =>
     56        Services.appinfo.sessionHistoryInParent &&
     57        Services.prefs.getBoolPref("fission.bfcacheInParent", false)
     58    );
     59  }
     60 
     61  async watch(watcherActor, { onAvailable }) {
     62    this.watcherActor = watcherActor;
     63    this.onAvailable = onAvailable;
     64 
     65    // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled,
     66    // we're not getting a the window-global-created events.
     67    // In such case, the watcher emits specific events that we can use instead.
     68    this._offPageShow = watcherActor.on(
     69      "bf-cache-navigation-pageshow",
     70      ({ windowGlobal }) => this._onNewWindowGlobal(windowGlobal, true)
     71    );
     72 
     73    if (watcherActor.sessionContext.type == "browser-element") {
     74      const { browsingContext, innerWindowID: innerWindowId } =
     75        watcherActor.browserElement;
     76      await this._spawnActor(browsingContext.id, innerWindowId);
     77    } else if (watcherActor.sessionContext.type == "webextension") {
     78      // As the top level actor may change over time for the web extension,
     79      // we don't have a good browsingContextID/innerWindowId to reference.
     80      // Passing a `browsingContext` set to -1 will be interpreted by the frontend as a resource
     81      // bound to the current top level target and will be automatically assigned to it.
     82      await this._spawnActor(-1, null);
     83    } else if (watcherActor.sessionContext.type == "all") {
     84      // Note that there should be only one such target in the browser toolbox.
     85      // The Parent Process Target Actor.
     86      for (const targetActor of this.watcherActor.getTargetActorsInParentProcess()) {
     87        const { browsingContextID, innerWindowId } = targetActor.form();
     88        await this._spawnActor(browsingContextID, innerWindowId);
     89      }
     90    } else {
     91      throw new Error(
     92        "Unsupported session context type=" + watcherActor.sessionContext.type
     93      );
     94    }
     95  }
     96 
     97  onStoresUpdate(response) {
     98    response = getFilteredStorageEvents(response, this.storageKey);
     99    if (!response) {
    100      return;
    101    }
    102    this.actor.emit("single-store-update", {
    103      changed: response.changed,
    104      added: response.added,
    105      deleted: response.deleted,
    106    });
    107  }
    108 
    109  onStoresCleared(response) {
    110    const cleared = response[this.storageKey];
    111 
    112    if (!cleared) {
    113      return;
    114    }
    115 
    116    this.actor.emit("single-store-cleared", {
    117      clearedHostsOrPaths: cleared,
    118    });
    119  }
    120 
    121  destroy() {
    122    // Remove observers
    123    Services.obs.removeObserver(this, "window-global-created");
    124    Services.obs.removeObserver(this, "window-global-destroyed");
    125    this._offPageShow();
    126    this._cleanActor();
    127  }
    128 
    129  async _spawnActor(browsingContextID, innerWindowId) {
    130    const storageActor = new StorageActorMock(this.watcherActor);
    131    this.storageActor = storageActor;
    132    this.actor = new this.ActorConstructor(storageActor);
    133 
    134    // Some storage types require to prelist their stores
    135    try {
    136      await this.actor.populateStoresForHosts();
    137    } catch (e) {
    138      // It can happen that the actor gets destroyed while populateStoresForHosts is being
    139      // executed.
    140      if (this.actor) {
    141        throw e;
    142      }
    143    }
    144 
    145    // If the actor was destroyed, we don't need to go further.
    146    if (!this.actor) {
    147      return;
    148    }
    149 
    150    // We have to manage the actor manually, because ResourceCommand doesn't
    151    // use the protocol.js specification.
    152    // resources-available-array is typed as "json"
    153    // So that we have to manually handle stuff that would normally be
    154    // automagically done by procotol.js
    155    // 1) Manage the actor in order to have an actorID on it
    156    this.watcherActor.manage(this.actor);
    157    // 2) Convert to JSON "form"
    158    const storage = this.actor.form();
    159 
    160    // All resources should have a resourceId and resourceKey
    161    // attributes, so available/updated/destroyed callbacks work properly.
    162    storage.resourceId = `${this.storageKey}-${innerWindowId}`;
    163    storage.resourceKey = this.storageKey;
    164    // NOTE: the resource command needs this attribute
    165    storage.browsingContextID = browsingContextID;
    166 
    167    this.onAvailable([storage]);
    168 
    169    // Maps global events from `storageActor` shared for all storage-types,
    170    // down to storage-type's specific actor `storage`.
    171    storageActor.on("stores-update", this.onStoresUpdate);
    172 
    173    // When a store gets cleared
    174    storageActor.on("stores-cleared", this.onStoresCleared);
    175  }
    176 
    177  _cleanActor() {
    178    this.actor?.destroy();
    179    this.actor = null;
    180    if (this.storageActor) {
    181      this.storageActor.off("stores-update", this.onStoresUpdate);
    182      this.storageActor.off("stores-cleared", this.onStoresCleared);
    183      this.storageActor.destroy();
    184      this.storageActor = null;
    185    }
    186  }
    187 
    188  /**
    189   * Event handler for any docshell update. This lets us figure out whenever
    190   * any new window is added, or an existing window is removed.
    191   */
    192  observe(subject, topic) {
    193    if (topic === "window-global-created") {
    194      this._onNewWindowGlobal(subject);
    195    }
    196  }
    197 
    198  /**
    199   * Handle WindowGlobal received via:
    200   * - <window-global-created> (to cover regular navigations, with brand new documents)
    201   * - <bf-cache-navigation-pageshow> (to cover history navications)
    202   *
    203   * @param {WindowGlobal} windowGlobal
    204   * @param {boolean} isBfCacheNavigation
    205   */
    206  async _onNewWindowGlobal(windowGlobal, isBfCacheNavigation) {
    207    // We instantiate only one instance of parent process storage actors per toolbox
    208    // when debugging addons as they don't really have any top level target
    209    // which cause to switch to a brand new context and require to hook on that new context.
    210    if (this.watcherActor.sessionContext.type == "webextension") {
    211      return;
    212    }
    213 
    214    // Only process WindowGlobals which are related to the debugged scope.
    215    if (
    216      !isWindowGlobalPartOfContext(
    217        windowGlobal,
    218        this.watcherActor.sessionContext,
    219        { acceptNoWindowGlobal: true }
    220      )
    221    ) {
    222      return;
    223    }
    224 
    225    // Ignore about:blank
    226    if (windowGlobal.documentURI.displaySpec === "about:blank") {
    227      return;
    228    }
    229 
    230    // Only process top BrowsingContext (ignore same-process iframe ones)
    231    const isTopContext =
    232      windowGlobal.browsingContext.top == windowGlobal.browsingContext;
    233    if (!isTopContext) {
    234      return;
    235    }
    236 
    237    // We only want to spawn a new StorageActor if a new target is being created, i.e.
    238    // - target switching is enabled and we're notified about a new top-level window global,
    239    //   via window-global-created
    240    // - target switching is enabled OR bfCacheInParent is enabled, and a bfcache navigation
    241    //   is performed (See handling of "pageshow" event in DevToolsFrameChild)
    242    const isNewTargetBeingCreated =
    243      this.watcherActor.sessionContext.isServerTargetSwitchingEnabled ||
    244      (isBfCacheNavigation && this.isBfcacheInParentEnabled);
    245 
    246    if (!isNewTargetBeingCreated) {
    247      return;
    248    }
    249 
    250    // When server side target switching is enabled, we replace the StorageActor
    251    // with a new one.
    252    // On the frontend, the navigation will destroy the previous target, which
    253    // will destroy the previous storage front, so we must notify about a new one.
    254 
    255    // When we are target switching we keep the storage watcher, so we need
    256    // to send a new resource to the client.
    257    // However, we must ensure that we do this when the new target is
    258    // already available, so we check innerWindowId to do it.
    259    await new Promise(resolve => {
    260      const listener = targetActorForm => {
    261        if (targetActorForm.innerWindowId != windowGlobal.innerWindowId) {
    262          return;
    263        }
    264        this.watcherActor.off("target-available-form", listener);
    265        resolve();
    266      };
    267      this.watcherActor.on("target-available-form", listener);
    268    });
    269 
    270    this._cleanActor();
    271    this._spawnActor(
    272      windowGlobal.browsingContext.id,
    273      windowGlobal.innerWindowId
    274    );
    275  }
    276 }
    277 
    278 module.exports = ParentProcessStorage;
    279 
    280 class StorageActorMock extends EventEmitter {
    281  constructor(watcherActor) {
    282    super();
    283 
    284    this.conn = watcherActor.conn;
    285    this.watcherActor = watcherActor;
    286 
    287    this.boundUpdate = {};
    288 
    289    // Notifications that help us keep track of newly added windows and windows
    290    // that got removed
    291    this.observe = this.observe.bind(this);
    292    Services.obs.addObserver(this, "window-global-created");
    293    Services.obs.addObserver(this, "window-global-destroyed");
    294 
    295    // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled,
    296    // we're not getting a the window-global-created/window-global-destroyed events.
    297    // In such case, the watcher emits specific events that we can use as equivalent to
    298    // window-global-created/window-global-destroyed.
    299    // We only need to react to those events here if target switching is not enabled; when
    300    // it is enabled, ParentProcessStorage will spawn a whole new actor which will allow
    301    // the client to get the information it needs.
    302    if (!this.watcherActor.sessionContext.isServerTargetSwitchingEnabled) {
    303      this._offPageShow = watcherActor.on(
    304        "bf-cache-navigation-pageshow",
    305        ({ windowGlobal }) => {
    306          // if a new target is created in the content process as a result of the bfcache
    307          // navigation, we don't need to emit window-ready as a new StorageActorMock will
    308          // be created by ParentProcessStorage.
    309          // When server targets are disabled, this only happens when bfcache in parent is enabled.
    310          if (this.isBfcacheInParentEnabled) {
    311            return;
    312          }
    313          const windowMock = { location: windowGlobal.documentURI };
    314          this.emit("window-ready", windowMock);
    315        }
    316      );
    317 
    318      this._offPageHide = watcherActor.on(
    319        "bf-cache-navigation-pagehide",
    320        ({ windowGlobal }) => {
    321          const windowMock = { location: windowGlobal.documentURI };
    322          // The listener of this events usually check that there are no other windows
    323          // with the same host before notifying the client that it can remove it from
    324          // the UI. The windows are retrieved from the `windows` getter, and in this case
    325          // we still have a reference to the window we're navigating away from.
    326          // We pass a `dontCheckHost` parameter alongside the window-destroyed event to
    327          // always notify the client.
    328          this.emit("window-destroyed", windowMock, { dontCheckHost: true });
    329        }
    330      );
    331    }
    332  }
    333 
    334  destroy() {
    335    // clear update throttle timeout
    336    clearTimeout(this.batchTimer);
    337    this.batchTimer = null;
    338    // Remove observers
    339    Services.obs.removeObserver(this, "window-global-created");
    340    Services.obs.removeObserver(this, "window-global-destroyed");
    341    if (this._offPageShow) {
    342      this._offPageShow();
    343    }
    344    if (this._offPageHide) {
    345      this._offPageHide();
    346    }
    347  }
    348 
    349  get windows() {
    350    return (
    351      this.watcherActor
    352        .getAllBrowsingContexts()
    353        .map(x => {
    354          const uri = x.currentWindowGlobal.documentURI;
    355          return { location: uri };
    356        })
    357        // NOTE: we are removing about:blank because we might get them for iframes
    358        // whose src attribute has not been set yet.
    359        .filter(x => x.location.displaySpec !== "about:blank")
    360    );
    361  }
    362 
    363  // NOTE: this uri argument is not a real window.Location, but the
    364  // `currentWindowGlobal.documentURI` object passed from `windows` getter.
    365  getHostName(uri) {
    366    switch (uri.scheme) {
    367      case "about":
    368      case "file":
    369      case "javascript":
    370      case "resource":
    371        return uri.displaySpec;
    372      case "moz-extension":
    373      case "http":
    374      case "https":
    375        return uri.prePath;
    376      default:
    377        // chrome: and data: do not support storage
    378        return null;
    379    }
    380  }
    381 
    382  getWindowFromHost(host) {
    383    const hostBrowsingContext = this.watcherActor
    384      .getAllBrowsingContexts()
    385      .find(x => {
    386        const hostName = this.getHostName(x.currentWindowGlobal.documentURI);
    387        return hostName === host;
    388      });
    389    // In case of WebExtension or BrowserToolbox, we may pass privileged hosts
    390    // which don't relate to any particular window.
    391    // Like "indexeddb+++fx-devtools" or "chrome".
    392    // (callsites of this method are used to handle null returned values)
    393    if (!hostBrowsingContext) {
    394      return null;
    395    }
    396 
    397    const principal =
    398      hostBrowsingContext.currentWindowGlobal.documentStoragePrincipal;
    399 
    400    return {
    401      document: { effectiveStoragePrincipal: principal },
    402    };
    403  }
    404 
    405  /**
    406   * Get the browsing contexts matching the given host.
    407   *
    408   * @param {string} host: The host for which we want the browsing contexts
    409   * @returns Array<BrowsingContext>
    410   */
    411  getBrowsingContextsFromHost(host) {
    412    return this.watcherActor
    413      .getAllBrowsingContexts()
    414      .filter(
    415        bc => this.getHostName(bc.currentWindowGlobal.documentURI) === host
    416      );
    417  }
    418 
    419  get parentActor() {
    420    return {
    421      isRootActor: this.watcherActor.sessionContext.type == "all",
    422      addonId: this.watcherActor.sessionContext.addonId,
    423    };
    424  }
    425 
    426  /**
    427   * Event handler for any docshell update. This lets us figure out whenever
    428   * any new window is added, or an existing window is removed.
    429   */
    430  async observe(windowGlobal, topic) {
    431    // Only process WindowGlobals which are related to the debugged scope.
    432    if (
    433      !isWindowGlobalPartOfContext(
    434        windowGlobal,
    435        this.watcherActor.sessionContext,
    436        { acceptNoWindowGlobal: true }
    437      )
    438    ) {
    439      return;
    440    }
    441 
    442    // Ignore about:blank
    443    if (windowGlobal.documentURI.displaySpec === "about:blank") {
    444      return;
    445    }
    446 
    447    // Only notify about remote iframe windows when JSWindowActor based targets are enabled
    448    // We will create a new StorageActor for the top level tab documents when server side target
    449    // switching is enabled
    450    const isTopContext =
    451      windowGlobal.browsingContext.top == windowGlobal.browsingContext;
    452    if (
    453      isTopContext &&
    454      this.watcherActor.sessionContext.isServerTargetSwitchingEnabled
    455    ) {
    456      return;
    457    }
    458 
    459    // emit window-wready and window-destroyed events when needed
    460    const windowMock = { location: windowGlobal.documentURI };
    461    if (topic === "window-global-created") {
    462      this.emit("window-ready", windowMock);
    463    } else if (topic === "window-global-destroyed") {
    464      this.emit("window-destroyed", windowMock);
    465    }
    466  }
    467 
    468  /**
    469   * This method is called by the registered storage types so as to tell the
    470   * Storage Actor that there are some changes in the stores. Storage Actor then
    471   * notifies the client front about these changes at regular (BATCH_DELAY)
    472   * interval.
    473   *
    474   * @param {string} action
    475   *        The type of change. One of "added", "changed" or "deleted"
    476   * @param {string} storeType
    477   *        The storage actor in which this change has occurred.
    478   * @param {object} data
    479   *        The update object. This object is of the following format:
    480   *         - {
    481   *             <host1>: [<store_names1>, <store_name2>...],
    482   *             <host2>: [<store_names34>...],
    483   *           }
    484   *           Where host1, host2 are the host in which this change happened and
    485   *           [<store_namesX] is an array of the names of the changed store objects.
    486   *           Pass an empty array if the host itself was affected: either completely
    487   *           removed or cleared.
    488   */
    489  // eslint-disable-next-line complexity
    490  update(action, storeType, data) {
    491    if (action == "cleared") {
    492      this.emit("stores-cleared", { [storeType]: data });
    493      return null;
    494    }
    495 
    496    if (this.batchTimer) {
    497      clearTimeout(this.batchTimer);
    498    }
    499    if (!this.boundUpdate[action]) {
    500      this.boundUpdate[action] = {};
    501    }
    502    if (!this.boundUpdate[action][storeType]) {
    503      this.boundUpdate[action][storeType] = {};
    504    }
    505    for (const host in data) {
    506      if (!this.boundUpdate[action][storeType][host]) {
    507        this.boundUpdate[action][storeType][host] = [];
    508      }
    509      for (const name of data[host]) {
    510        if (!this.boundUpdate[action][storeType][host].includes(name)) {
    511          this.boundUpdate[action][storeType][host].push(name);
    512        }
    513      }
    514    }
    515    if (action == "added") {
    516      // If the same store name was previously deleted or changed, but now is
    517      // added somehow, dont send the deleted or changed update.
    518      this.removeNamesFromUpdateList("deleted", storeType, data);
    519      this.removeNamesFromUpdateList("changed", storeType, data);
    520    } else if (
    521      action == "changed" &&
    522      this.boundUpdate.added &&
    523      this.boundUpdate.added[storeType]
    524    ) {
    525      // If something got added and changed at the same time, then remove those
    526      // items from changed instead.
    527      this.removeNamesFromUpdateList(
    528        "changed",
    529        storeType,
    530        this.boundUpdate.added[storeType]
    531      );
    532    } else if (action == "deleted") {
    533      // If any item got delete, or a host got delete, no point in sending
    534      // added or changed update
    535      this.removeNamesFromUpdateList("added", storeType, data);
    536      this.removeNamesFromUpdateList("changed", storeType, data);
    537 
    538      for (const host in data) {
    539        if (
    540          !data[host].length &&
    541          this.boundUpdate.added &&
    542          this.boundUpdate.added[storeType] &&
    543          this.boundUpdate.added[storeType][host]
    544        ) {
    545          delete this.boundUpdate.added[storeType][host];
    546        }
    547        if (
    548          !data[host].length &&
    549          this.boundUpdate.changed &&
    550          this.boundUpdate.changed[storeType] &&
    551          this.boundUpdate.changed[storeType][host]
    552        ) {
    553          delete this.boundUpdate.changed[storeType][host];
    554        }
    555      }
    556    }
    557 
    558    this.batchTimer = setTimeout(() => {
    559      clearTimeout(this.batchTimer);
    560      this.emit("stores-update", this.boundUpdate);
    561      this.boundUpdate = {};
    562    }, BATCH_DELAY);
    563 
    564    return null;
    565  }
    566 
    567  /**
    568   * This method removes data from the this.boundUpdate object in the same
    569   * manner like this.update() adds data to it.
    570   *
    571   * @param {string} action
    572   *        The type of change. One of "added", "changed" or "deleted"
    573   * @param {string} storeType
    574   *        The storage actor for which you want to remove the updates data.
    575   * @param {object} data
    576   *        The update object. This object is of the following format:
    577   *         - {
    578   *             <host1>: [<store_names1>, <store_name2>...],
    579   *             <host2>: [<store_names34>...],
    580   *           }
    581   *           Where host1, host2 are the hosts which you want to remove and
    582   *           [<store_namesX] is an array of the names of the store objects.
    583   */
    584  removeNamesFromUpdateList(action, storeType, data) {
    585    for (const host in data) {
    586      if (
    587        this.boundUpdate[action] &&
    588        this.boundUpdate[action][storeType] &&
    589        this.boundUpdate[action][storeType][host]
    590      ) {
    591        for (const name of data[host]) {
    592          const index = this.boundUpdate[action][storeType][host].indexOf(name);
    593          if (index > -1) {
    594            this.boundUpdate[action][storeType][host].splice(index, 1);
    595          }
    596        }
    597        if (!this.boundUpdate[action][storeType][host].length) {
    598          delete this.boundUpdate[action][storeType][host];
    599        }
    600      }
    601    }
    602    return null;
    603  }
    604 }