tor-browser

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

SessionStateAggregator.js (19521B)


      1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
      2 /* This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      5 
      6 "use strict";
      7 
      8 const { GeckoViewChildModule } = ChromeUtils.importESModule(
      9  "resource://gre/modules/GeckoViewChildModule.sys.mjs"
     10 );
     11 const { XPCOMUtils } = ChromeUtils.importESModule(
     12  "resource://gre/modules/XPCOMUtils.sys.mjs"
     13 );
     14 
     15 ChromeUtils.defineESModuleGetters(this, {
     16  SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
     17  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
     18  setTimeoutWithTarget: "resource://gre/modules/Timer.sys.mjs",
     19 });
     20 
     21 const NO_INDEX = Number.MAX_SAFE_INTEGER;
     22 const LAST_INDEX = Number.MAX_SAFE_INTEGER - 1;
     23 const DEFAULT_INTERVAL_MS = 1500;
     24 
     25 // This pref controls whether or not we send updates to the parent on a timeout
     26 // or not, and should only be used for tests or debugging.
     27 const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates";
     28 
     29 const PREF_INTERVAL = "browser.sessionstore.interval";
     30 
     31 class Handler {
     32  constructor(store) {
     33    this.store = store;
     34  }
     35 
     36  get mm() {
     37    return this.store.mm;
     38  }
     39 
     40  get eventDispatcher() {
     41    return this.store.eventDispatcher;
     42  }
     43 
     44  get messageQueue() {
     45    return this.store.messageQueue;
     46  }
     47 
     48  get stateChangeNotifier() {
     49    return this.store.stateChangeNotifier;
     50  }
     51 }
     52 
     53 /**
     54 * Listens for state change notifcations from webProgress and notifies each
     55 * registered observer for either the start of a page load, or its completion.
     56 */
     57 class StateChangeNotifier extends Handler {
     58  constructor(store) {
     59    super(store);
     60 
     61    this._observers = new Set();
     62    const ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor);
     63    const webProgress = ifreq.getInterface(Ci.nsIWebProgress);
     64    webProgress.addProgressListener(
     65      this,
     66      Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
     67    );
     68  }
     69 
     70  /**
     71   * Adds a given observer |obs| to the set of observers that will be notified
     72   * when when a new document starts or finishes loading.
     73   *
     74   * @param obs (object)
     75   */
     76  addObserver(obs) {
     77    this._observers.add(obs);
     78  }
     79 
     80  /**
     81   * Notifies all observers that implement the given |method|.
     82   *
     83   * @param method (string)
     84   */
     85  notifyObservers(method) {
     86    for (const obs of this._observers) {
     87      if (typeof obs[method] == "function") {
     88        obs[method]();
     89      }
     90    }
     91  }
     92 
     93  /**
     94   * @see nsIWebProgressListener.onStateChange
     95   */
     96  onStateChange(webProgress, request, stateFlags) {
     97    // Ignore state changes for subframes because we're only interested in the
     98    // top-document starting or stopping its load.
     99    if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) {
    100      return;
    101    }
    102 
    103    // onStateChange will be fired when loading the initial about:blank URI for
    104    // a browser, which we don't actually care about. This is particularly for
    105    // the case of unrestored background tabs, where the content has not yet
    106    // been restored: we don't want to accidentally send any updates to the
    107    // parent when the about:blank placeholder page has loaded.
    108    if (!this.mm.docShell.hasLoadedNonBlankURI) {
    109      return;
    110    }
    111 
    112    if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
    113      this.notifyObservers("onPageLoadStarted");
    114    } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
    115      this.notifyObservers("onPageLoadCompleted");
    116    }
    117  }
    118 }
    119 StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([
    120  "nsIWebProgressListener",
    121  "nsISupportsWeakReference",
    122 ]);
    123 
    124 /**
    125 * Listens for changes to the session history. Whenever the user navigates
    126 * we will collect URLs and everything belonging to session history.
    127 *
    128 * Causes a SessionStore:update message to be sent that contains the current
    129 * session history.
    130 *
    131 * Example:
    132 *   {entries: [{url: "about:mozilla", ...}, ...], index: 1}
    133 */
    134 class SessionHistoryListener extends Handler {
    135  constructor(store) {
    136    super(store);
    137 
    138    this._fromIdx = NO_INDEX;
    139 
    140    // The state change observer is needed to handle initial subframe loads.
    141    // It will redundantly invalidate with the SHistoryListener in some cases
    142    // but these invalidations are very cheap.
    143    this.stateChangeNotifier.addObserver(this);
    144 
    145    // By adding the SHistoryListener immediately, we will unfortunately be
    146    // notified of every history entry as the tab is restored. We don't bother
    147    // waiting to add the listener later because these notifications are cheap.
    148    // We will likely only collect once since we are batching collection on
    149    // a delay.
    150    this.mm.docShell
    151      .QueryInterface(Ci.nsIWebNavigation)
    152      .sessionHistory.legacySHistory.addSHistoryListener(this);
    153 
    154    // Listen for page title changes.
    155    this.mm.addEventListener("DOMTitleChanged", this);
    156  }
    157 
    158  uninit() {
    159    const sessionHistory = this.mm.docShell.QueryInterface(
    160      Ci.nsIWebNavigation
    161    ).sessionHistory;
    162    if (sessionHistory) {
    163      sessionHistory.legacySHistory.removeSHistoryListener(this);
    164    }
    165  }
    166 
    167  collect() {
    168    // We want to send down a historychange even for full collects in case our
    169    // session history is a partial session history, in which case we don't have
    170    // enough information for a full update. collectFrom(-1) tells the collect
    171    // function to collect all data avaliable in this process.
    172    if (this.mm.docShell) {
    173      this.collectFrom(-1);
    174    }
    175  }
    176 
    177  // History can grow relatively big with the nested elements, so if we don't have to, we
    178  // don't want to send the entire history all the time. For a simple optimization
    179  // we keep track of the smallest index from after any change has occured and we just send
    180  // the elements from that index. If something more complicated happens we just clear it
    181  // and send the entire history. We always send the additional info like the current selected
    182  // index (so for going back and forth between history entries we set the index to LAST_INDEX
    183  // if nothing else changed send an empty array and the additonal info like the selected index)
    184  collectFrom(idx) {
    185    if (this._fromIdx <= idx) {
    186      // If we already know that we need to update history fromn index N we can ignore any changes
    187      // tha happened with an element with index larger than N.
    188      // Note: initially we use NO_INDEX which is MAX_SAFE_INTEGER which means we don't ignore anything
    189      // here, and in case of navigation in the history back and forth we use LAST_INDEX which ignores
    190      // only the subsequent navigations, but not any new elements added.
    191      return;
    192    }
    193 
    194    this._fromIdx = idx;
    195    this.messageQueue.push("historychange", () => {
    196      if (this._fromIdx === NO_INDEX) {
    197        return null;
    198      }
    199 
    200      const history = SessionHistory.collect(this.mm.docShell, this._fromIdx);
    201      this._fromIdx = NO_INDEX;
    202      return history;
    203    });
    204  }
    205 
    206  handleEvent() {
    207    this.collect();
    208  }
    209 
    210  onPageLoadCompleted() {
    211    this.collect();
    212  }
    213 
    214  onPageLoadStarted() {
    215    this.collect();
    216  }
    217 
    218  OnHistoryNewEntry() {
    219    // We ought to collect the previously current entry as well, see bug 1350567.
    220    // TODO: Reenable partial history collection for performance
    221    // this.collectFrom(oldIndex);
    222    this.collect();
    223  }
    224 
    225  OnHistoryGotoIndex() {
    226    // We ought to collect the previously current entry as well, see bug 1350567.
    227    // TODO: Reenable partial history collection for performance
    228    // this.collectFrom(LAST_INDEX);
    229    this.collect();
    230  }
    231 
    232  OnHistoryPurge() {
    233    this.collect();
    234  }
    235 
    236  OnHistoryReload() {
    237    this.collect();
    238    return true;
    239  }
    240 
    241  OnHistoryReplaceEntry() {
    242    this.collect();
    243  }
    244 }
    245 SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([
    246  "nsISHistoryListener",
    247  "nsISupportsWeakReference",
    248 ]);
    249 
    250 /**
    251 * Listens for scroll position changes. Whenever the user scrolls the top-most
    252 * frame we update the scroll position and will restore it when requested.
    253 *
    254 * Causes a SessionStore:update message to be sent that contains the current
    255 * scroll positions as a tree of strings. If no frame of the whole frame tree
    256 * is scrolled this will return null so that we don't tack a property onto
    257 * the tabData object in the parent process.
    258 *
    259 * Example:
    260 *   {scroll: "100,100", zoom: {resolution: "1.5", displaySize:
    261 *   {height: "1600", width: "1000"}}, children:
    262 *   [null, null, {scroll: "200,200"}]}
    263 */
    264 class ScrollPositionListener extends Handler {
    265  constructor(store) {
    266    super(store);
    267 
    268    SessionStoreUtils.addDynamicFrameFilteredListener(
    269      this.mm,
    270      "mozvisualscroll",
    271      this,
    272      /* capture */ false,
    273      /* system group */ true
    274    );
    275 
    276    SessionStoreUtils.addDynamicFrameFilteredListener(
    277      this.mm,
    278      "mozvisualresize",
    279      this,
    280      /* capture */ false,
    281      /* system group */ true
    282    );
    283 
    284    this.stateChangeNotifier.addObserver(this);
    285  }
    286 
    287  handleEvent() {
    288    this.messageQueue.push("scroll", () => this.collect());
    289  }
    290 
    291  onPageLoadCompleted() {
    292    this.messageQueue.push("scroll", () => this.collect());
    293  }
    294 
    295  onPageLoadStarted() {
    296    this.messageQueue.push("scroll", () => null);
    297  }
    298 
    299  collect() {
    300    // TODO: Keep an eye on bug 1525259; we may not have to manually store zoom
    301    // Save the current document resolution.
    302    let zoom = 1;
    303    const scrolldata =
    304      SessionStoreUtils.collectScrollPosition(this.mm.content) || {};
    305    const domWindowUtils = this.mm.content.windowUtils;
    306    zoom = domWindowUtils.getResolution();
    307    scrolldata.zoom = {};
    308    scrolldata.zoom.resolution = zoom;
    309 
    310    // Save some data that'll help in adjusting the zoom level
    311    // when restoring in a different screen orientation.
    312    const displaySize = {};
    313    const width = {},
    314      height = {};
    315    domWindowUtils.getDocumentViewerSize(width, height);
    316 
    317    displaySize.width = width.value;
    318    displaySize.height = height.value;
    319 
    320    scrolldata.zoom.displaySize = displaySize;
    321 
    322    return scrolldata;
    323  }
    324 }
    325 
    326 /**
    327 * Listens for changes to input elements. Whenever the value of an input
    328 * element changes we will re-collect data for the current frame tree and send
    329 * a message to the parent process.
    330 *
    331 * Causes a SessionStore:update message to be sent that contains the form data
    332 * for all reachable frames.
    333 *
    334 * Example:
    335 *   {
    336 *     formdata: {url: "http://mozilla.org/", id: {input_id: "input value"}},
    337 *     children: [
    338 *       null,
    339 *       {url: "http://sub.mozilla.org/", id: {input_id: "input value 2"}}
    340 *     ]
    341 *   }
    342 */
    343 class FormDataListener extends Handler {
    344  constructor(store) {
    345    super(store);
    346 
    347    SessionStoreUtils.addDynamicFrameFilteredListener(
    348      this.mm,
    349      "input",
    350      this,
    351      true
    352    );
    353    this.stateChangeNotifier.addObserver(this);
    354  }
    355 
    356  handleEvent() {
    357    this.messageQueue.push("formdata", () => this.collect());
    358  }
    359 
    360  onPageLoadStarted() {
    361    this.messageQueue.push("formdata", () => null);
    362  }
    363 
    364  collect() {
    365    return SessionStoreUtils.collectFormData(this.mm.content);
    366  }
    367 }
    368 
    369 /**
    370 * A message queue that takes collected data and will take care of sending it
    371 * to the chrome process. It allows flushing using synchronous messages and
    372 * takes care of any race conditions that might occur because of that. Changes
    373 * will be batched if they're pushed in quick succession to avoid a message
    374 * flood.
    375 */
    376 class MessageQueue extends Handler {
    377  constructor(store) {
    378    super(store);
    379 
    380    /**
    381     * A map (string -> lazy fn) holding lazy closures of all queued data
    382     * collection routines. These functions will return data collected from the
    383     * docShell.
    384     */
    385    this._data = new Map();
    386 
    387    /**
    388     * The delay (in ms) used to delay sending changes after data has been
    389     * invalidated.
    390     */
    391    this.BATCH_DELAY_MS = 1000;
    392 
    393    /**
    394     * The minimum idle period (in ms) we need for sending data to chrome process.
    395     */
    396    this.NEEDED_IDLE_PERIOD_MS = 5;
    397 
    398    /**
    399     * Timeout for waiting an idle period to send data. We will set this from
    400     * the pref "browser.sessionstore.interval".
    401     */
    402    this._timeoutWaitIdlePeriodMs = null;
    403 
    404    /**
    405     * The current timeout ID, null if there is no queue data. We use timeouts
    406     * to damp a flood of data changes and send lots of changes as one batch.
    407     */
    408    this._timeout = null;
    409 
    410    /**
    411     * Whether or not sending batched messages on a timer is disabled. This should
    412     * only be used for debugging or testing. If you need to access this value,
    413     * you should probably use the timeoutDisabled getter.
    414     */
    415    this._timeoutDisabled = false;
    416 
    417    /**
    418     * True if there is already a send pending idle dispatch, set to prevent
    419     * scheduling more than one. If false there may or may not be one scheduled.
    420     */
    421    this._idleScheduled = false;
    422 
    423    this.timeoutDisabled = Services.prefs.getBoolPref(
    424      TIMEOUT_DISABLED_PREF,
    425      false
    426    );
    427    this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
    428      PREF_INTERVAL,
    429      DEFAULT_INTERVAL_MS
    430    );
    431 
    432    Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this);
    433    Services.prefs.addObserver(PREF_INTERVAL, this);
    434  }
    435 
    436  /**
    437   * True if batched messages are not being fired on a timer. This should only
    438   * ever be true when debugging or during tests.
    439   */
    440  get timeoutDisabled() {
    441    return this._timeoutDisabled;
    442  }
    443 
    444  /**
    445   * Disables sending batched messages on a timer. Also cancels any pending
    446   * timers.
    447   */
    448  set timeoutDisabled(val) {
    449    this._timeoutDisabled = val;
    450 
    451    if (val && this._timeout) {
    452      clearTimeout(this._timeout);
    453      this._timeout = null;
    454    }
    455  }
    456 
    457  uninit() {
    458    this.cleanupTimers();
    459  }
    460 
    461  /**
    462   * Cleanup pending idle callback and timer.
    463   */
    464  cleanupTimers() {
    465    this._idleScheduled = false;
    466    if (this._timeout) {
    467      clearTimeout(this._timeout);
    468      this._timeout = null;
    469    }
    470  }
    471 
    472  observe(subject, topic, data) {
    473    if (topic == "nsPref:changed") {
    474      switch (data) {
    475        case TIMEOUT_DISABLED_PREF:
    476          this.timeoutDisabled = Services.prefs.getBoolPref(
    477            TIMEOUT_DISABLED_PREF,
    478            false
    479          );
    480          break;
    481        case PREF_INTERVAL:
    482          this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(
    483            PREF_INTERVAL,
    484            DEFAULT_INTERVAL_MS
    485          );
    486          break;
    487        default:
    488          debug`Received unknown message: ${data}`;
    489          break;
    490      }
    491    }
    492  }
    493 
    494  /**
    495   * Pushes a given |value| onto the queue. The given |key| represents the type
    496   * of data that is stored and can override data that has been queued before
    497   * but has not been sent to the parent process, yet.
    498   *
    499   * @param key (string)
    500   *        A unique identifier specific to the type of data this is passed.
    501   * @param fn (function)
    502   *        A function that returns the value that will be sent to the parent
    503   *        process.
    504   */
    505  push(key, fn) {
    506    this._data.set(key, fn);
    507 
    508    if (!this._timeout && !this._timeoutDisabled) {
    509      // Wait a little before sending the message to batch multiple changes.
    510      this._timeout = setTimeoutWithTarget(
    511        () => this.sendWhenIdle(),
    512        this.BATCH_DELAY_MS,
    513        this.mm.tabEventTarget
    514      );
    515    }
    516  }
    517 
    518  /**
    519   * Sends queued data when the remaining idle time is enough or waiting too
    520   * long; otherwise, request an idle time again. If the |deadline| is not
    521   * given, this function is going to schedule the first request.
    522   *
    523   * @param deadline (object)
    524   *        An IdleDeadline object passed by idleDispatch().
    525   */
    526  sendWhenIdle(deadline) {
    527    if (!this.mm.content) {
    528      // The frameloader is being torn down. Nothing more to do.
    529      return;
    530    }
    531 
    532    if (deadline) {
    533      if (
    534        deadline.didTimeout ||
    535        deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
    536      ) {
    537        this.send();
    538        return;
    539      }
    540    } else if (this._idleScheduled) {
    541      // Bail out if there's a pending run.
    542      return;
    543    }
    544    ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
    545      timeout: this._timeoutWaitIdlePeriodMs,
    546    });
    547    this._idleScheduled = true;
    548  }
    549 
    550  /**
    551   * Sends queued data to the chrome process.
    552   *
    553   * @param options (object)
    554   *        {isFinal: true} to signal this is the final message sent on unload
    555   */
    556  send(options = {}) {
    557    // Looks like we have been called off a timeout after the tab has been
    558    // closed. The docShell is gone now and we can just return here as there
    559    // is nothing to do.
    560    if (!this.mm.docShell) {
    561      return;
    562    }
    563 
    564    this.cleanupTimers();
    565 
    566    const data = {};
    567    for (const [key, func] of this._data) {
    568      const value = func();
    569 
    570      if (value || (key != "storagechange" && key != "historychange")) {
    571        data[key] = value;
    572      }
    573    }
    574 
    575    this._data.clear();
    576 
    577    try {
    578      // Send all data to the parent process.
    579      this.eventDispatcher.sendRequest({
    580        type: "GeckoView:StateUpdated",
    581        data,
    582        isFinal: options.isFinal || false,
    583        epoch: this.store.epoch,
    584      });
    585    } catch (ex) {
    586      if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
    587        warn`Failed to save session state`;
    588      }
    589    }
    590  }
    591 }
    592 
    593 class SessionStateAggregator extends GeckoViewChildModule {
    594  constructor(aModuleName, aMessageManager) {
    595    super(aModuleName, aMessageManager);
    596 
    597    this.mm = aMessageManager;
    598    this.messageQueue = new MessageQueue(this);
    599    this.stateChangeNotifier = new StateChangeNotifier(this);
    600 
    601    this.handlers = [
    602      new SessionHistoryListener(this),
    603      this.stateChangeNotifier,
    604      this.messageQueue,
    605    ];
    606 
    607    if (!Services.appinfo.sessionStorePlatformCollection) {
    608      this.handlers.push(
    609        new FormDataListener(this),
    610        new ScrollPositionListener(this)
    611      );
    612    }
    613 
    614    this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
    615  }
    616 
    617  receiveMessage(aMsg) {
    618    debug`receiveMessage: ${aMsg.name}`;
    619 
    620    switch (aMsg.name) {
    621      case "GeckoView:FlushSessionState":
    622        this.flush();
    623        break;
    624    }
    625  }
    626 
    627  flush() {
    628    // Flush the message queue, send the latest updates.
    629    this.messageQueue.send();
    630  }
    631 
    632  onUnload() {
    633    // Upon frameLoader destruction, send a final update message to
    634    // the parent and flush all data currently held in the child.
    635    this.messageQueue.send({ isFinal: true });
    636 
    637    for (const handler of this.handlers) {
    638      if (handler.uninit) {
    639        handler.uninit();
    640      }
    641    }
    642 
    643    // We don't need to take care of any StateChangeNotifier observers as they
    644    // will die with the content script.
    645  }
    646 }
    647 
    648 // TODO: Bug 1648158 Move SessionAggregator to the parent process
    649 class DummySessionStateAggregator extends GeckoViewChildModule {
    650  constructor(aModuleName, aMessageManager) {
    651    super(aModuleName, aMessageManager);
    652    this.messageManager.addMessageListener("GeckoView:FlushSessionState", this);
    653  }
    654 
    655  receiveMessage(aMsg) {
    656    debug`receiveMessage: ${aMsg.name}`;
    657 
    658    switch (aMsg.name) {
    659      case "GeckoView:FlushSessionState":
    660        // Do nothing
    661        break;
    662    }
    663  }
    664 }
    665 
    666 const { debug, warn } = SessionStateAggregator.initLogging(
    667  "SessionStateAggregator"
    668 );
    669 
    670 const module = Services.appinfo.sessionHistoryInParent
    671  ? // If history is handled in the parent we don't need a session aggregator
    672    // TODO: Bug 1648158 remove this and do everything in the parent
    673    DummySessionStateAggregator.create(this)
    674  : SessionStateAggregator.create(this);