tor-browser

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

Navigate.sys.mjs (17725B)


      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 file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
     11  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     12 
     13  Deferred: "chrome://remote/content/shared/Sync.sys.mjs",
     14  isUncommittedInitialDocument:
     15    "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs",
     16  Log: "chrome://remote/content/shared/Log.sys.mjs",
     17  NavigationListener:
     18    "chrome://remote/content/shared/listeners/NavigationListener.sys.mjs",
     19  truncate: "chrome://remote/content/shared/Format.sys.mjs",
     20  NavigationError: "chrome://remote/content/shared/RemoteError.sys.mjs",
     21 });
     22 
     23 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
     24  lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT)
     25 );
     26 
     27 // Define a custom multiplier to apply to the unload timer on various platforms.
     28 // This multiplier should only reflect the navigation performance of the
     29 // platform and not the overall performance.
     30 ChromeUtils.defineLazyGetter(lazy, "UNLOAD_TIMEOUT_MULTIPLIER", () => {
     31  if (AppConstants.MOZ_CODE_COVERAGE) {
     32    // Navigation on ccov platforms can be extremely slow because new processes
     33    // need to be instrumented for coverage on startup.
     34    return 16;
     35  }
     36 
     37  if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) {
     38    // Use an extended timeout on slow platforms.
     39    return 8;
     40  }
     41 
     42  return 1;
     43 });
     44 
     45 export const DEFAULT_UNLOAD_TIMEOUT = 200;
     46 
     47 // Load flag for an error page from the DocShell (0x0001U << 16)
     48 const LOAD_FLAG_ERROR_PAGE = 0x10000;
     49 
     50 const STATE_START = Ci.nsIWebProgressListener.STATE_START;
     51 const STATE_STOP = Ci.nsIWebProgressListener.STATE_STOP;
     52 
     53 /**
     54 * Returns the multiplier used for the unload timer. Useful for tests which
     55 * assert the behavior of this timeout.
     56 */
     57 export function getUnloadTimeoutMultiplier() {
     58  return lazy.UNLOAD_TIMEOUT_MULTIPLIER;
     59 }
     60 
     61 // Used to keep weak references of webProgressListeners alive.
     62 const webProgressListeners = new Set();
     63 
     64 /**
     65 * Wait until the initial load of the given WebProgress is done.
     66 *
     67 * @param {WebProgress} webProgress
     68 *     The WebProgress instance to observe.
     69 * @param {object=} options
     70 * @param {boolean=} options.resolveWhenStarted
     71 *     Flag to indicate that the Promise has to be resolved when the
     72 *     page load has been started. Otherwise wait until the page has
     73 *     finished loading. Defaults to `false`.
     74 * @param {number=} options.unloadTimeout
     75 *     Time to allow before the page gets unloaded. See ProgressListener options.
     76 * @returns {Promise}
     77 *     Promise which resolves when the page load is in the expected state.
     78 *     Values as returned:
     79 *       - {nsIURI} currentURI The current URI of the page
     80 *       - {nsIURI} targetURI Target URI of the navigation
     81 */
     82 export async function waitForInitialNavigationCompleted(
     83  webProgress,
     84  options = {}
     85 ) {
     86  const { resolveWhenStarted = false, unloadTimeout } = options;
     87 
     88  const browsingContext = webProgress.browsingContext;
     89 
     90  // Start the listener right away to avoid race conditions.
     91  const listener = new ProgressListener(webProgress, {
     92    resolveWhenStarted,
     93    unloadTimeout,
     94  });
     95  const navigated = listener.start();
     96 
     97  const isUncommittedInitial =
     98    lazy.isUncommittedInitialDocument(browsingContext);
     99  const isLoadingDocument = listener.isLoadingDocument;
    100  lazy.logger.trace(
    101    lazy.truncate`[${browsingContext.id}] Wait for initial navigation: isUncommittedInitial=${isUncommittedInitial}, isLoadingDocument=${isLoadingDocument}`
    102  );
    103 
    104  // If the current document is not the initial "about:blank" and is also
    105  // no longer loading, assume the navigation is done and return.
    106  if (!isUncommittedInitial && !isLoadingDocument) {
    107    lazy.logger.trace(
    108      lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}`
    109    );
    110 
    111    // Will resolve the navigated promise.
    112    listener.stop();
    113  }
    114 
    115  try {
    116    await navigated;
    117  } catch (e) {
    118    // Ignore any error if the initial navigation failed.
    119    lazy.logger.debug(
    120      lazy.truncate`[${browsingContext.id}] Initial Navigation to ${listener.currentURI?.spec} failed: ${e}`
    121    );
    122  }
    123 
    124  const result = {
    125    currentURI: listener.currentURI,
    126    targetURI: listener.targetURI,
    127  };
    128 
    129  listener.destroy();
    130 
    131  return result;
    132 }
    133 
    134 /**
    135 * WebProgressListener to observe for page loads.
    136 */
    137 export class ProgressListener {
    138  #expectNavigation;
    139  #resolveWhenCommitted;
    140  #resolveWhenStarted;
    141  #unloadTimeout;
    142  #waitForExplicitStart;
    143  #webProgress;
    144 
    145  #deferredNavigation;
    146  #errorName;
    147  #navigationId;
    148  #navigationListener;
    149  #seenStartFlag;
    150  #targetURI;
    151  #unloadTimerId;
    152 
    153  /**
    154   * Create a new WebProgressListener instance.
    155   *
    156   * @param {WebProgress} webProgress
    157   *     The web progress to attach the listener to.
    158   * @param {object=} options
    159   * @param {boolean=} options.expectNavigation
    160   *     Flag to indicate that a navigation is guaranteed to happen.
    161   *     When set to `true`, the ProgressListener will ignore options.unloadTimeout
    162   *     and will only resolve when the expected navigation happens.
    163   *     Defaults to `false`.
    164   * @param {NavigationManager=} options.navigationManager
    165   *     The NavigationManager where navigations for the current session are
    166   *     monitored.
    167   * @param {boolean=} options.resolveWhenCommitted
    168   *     Flag to indicate that the Promise has to be resolved when the
    169   *     navigation-committed event is received. Defaults to `false`.
    170   *     Cannot be used together with resolveWhenStarted. Requires to provide
    171   *     options.navigationManager.
    172   * @param {boolean=} options.resolveWhenStarted
    173   *     Flag to indicate that the Promise has to be resolved when the
    174   *     page load has been started. Otherwise wait until the navigation was
    175   *     committed or the page has finished loading. Defaults to `false`.
    176   *     Cannot be used together with resolveWhenCommitted.
    177   * @param {string=} options.targetURI
    178   *     The target URI for the navigation.
    179   * @param {number=} options.unloadTimeout
    180   *     Time to allow before the page gets unloaded. Defaults to 200ms on
    181   *     regular platforms. A multiplier will be applied on slower platforms
    182   *     (eg. debug, ccov...).
    183   *     Ignored if options.expectNavigation is set to `true`
    184   * @param {boolean=} options.waitForExplicitStart
    185   *     Flag to indicate that the Promise can only resolve after receiving a
    186   *     STATE_START state change. In other words, if the webProgress is already
    187   *     navigating, the Promise will only resolve for the next navigation.
    188   *     Defaults to `false`.
    189   */
    190  constructor(webProgress, options = {}) {
    191    const {
    192      expectNavigation = false,
    193      navigationManager = null,
    194      resolveWhenCommitted = false,
    195      resolveWhenStarted = false,
    196      targetURI,
    197      unloadTimeout = DEFAULT_UNLOAD_TIMEOUT,
    198      waitForExplicitStart = false,
    199    } = options;
    200 
    201    this.#expectNavigation = expectNavigation;
    202    this.#resolveWhenCommitted = resolveWhenCommitted;
    203    this.#resolveWhenStarted = resolveWhenStarted;
    204    this.#unloadTimeout = unloadTimeout * lazy.UNLOAD_TIMEOUT_MULTIPLIER;
    205    this.#waitForExplicitStart = waitForExplicitStart;
    206    this.#webProgress = webProgress;
    207 
    208    this.#deferredNavigation = null;
    209    this.#errorName = null;
    210    this.#seenStartFlag = false;
    211    this.#targetURI = targetURI;
    212    this.#unloadTimerId = null;
    213 
    214    if (resolveWhenCommitted) {
    215      if (resolveWhenStarted) {
    216        throw new Error(
    217          "Cannot use both resolveWhenStarted and resolveWhenCommitted"
    218        );
    219      }
    220      if (!navigationManager) {
    221        throw new Error(
    222          "Cannot use resolveWhenCommitted without a navigationManager"
    223        );
    224      }
    225    }
    226 
    227    if (navigationManager !== null) {
    228      this.#navigationListener = new lazy.NavigationListener(navigationManager);
    229      this.#navigationListener.on(
    230        "navigation-committed",
    231        this.#onNavigationCommitted
    232      );
    233      this.#navigationListener.on(
    234        "navigation-failed",
    235        this.#onNavigationFailed
    236      );
    237      this.#navigationListener.startListening();
    238    }
    239  }
    240 
    241  destroy() {
    242    if (this.#navigationListener) {
    243      this.#navigationListener.stopListening();
    244      this.#navigationListener.off(
    245        "navigation-committed",
    246        this.#onNavigationCommitted
    247      );
    248      this.#navigationListener.off(
    249        "navigation-failed",
    250        this.#onNavigationFailed
    251      );
    252      this.#navigationListener.destroy();
    253    }
    254  }
    255 
    256  get #messagePrefix() {
    257    return `[${this.browsingContext.id}] ${this.constructor.name}`;
    258  }
    259 
    260  get browsingContext() {
    261    return this.#webProgress.browsingContext;
    262  }
    263 
    264  get currentURI() {
    265    return this.#webProgress.browsingContext.currentURI;
    266  }
    267 
    268  get documentURI() {
    269    return this.#webProgress.browsingContext.currentWindowGlobal.documentURI;
    270  }
    271 
    272  get isLoadingDocument() {
    273    return this.#webProgress.isLoadingDocument;
    274  }
    275 
    276  get isStarted() {
    277    return !!this.#deferredNavigation;
    278  }
    279 
    280  get loadType() {
    281    return this.#webProgress.loadType;
    282  }
    283 
    284  get targetURI() {
    285    return this.#targetURI;
    286  }
    287 
    288  #checkLoadingState(request, options = {}) {
    289    const { isStart = false, isStop = false, status = 0 } = options;
    290 
    291    this.#trace(
    292      `Loading state: isStart=${isStart} isStop=${isStop} status=0x${status.toString(
    293        16
    294      )}, loadType=0x${this.loadType.toString(16)}, seenStartFlag=${this.#seenStartFlag}`
    295    );
    296    if (isStart) {
    297      if (this.#seenStartFlag) {
    298        this.#trace("Skip start state because seenStartFlag is already set");
    299      } else {
    300        this.#seenStartFlag = true;
    301 
    302        this.#targetURI = this.#getTargetURI(request);
    303 
    304        this.#trace(lazy.truncate`Started loading ${this.targetURI?.spec}`);
    305 
    306        if (this.#unloadTimerId !== null) {
    307          lazy.clearTimeout(this.#unloadTimerId);
    308          this.#trace("Cleared the unload timer");
    309          this.#unloadTimerId = null;
    310        }
    311 
    312        if (this.#resolveWhenStarted) {
    313          this.#trace("Request to stop listening when navigation started");
    314          this.stop();
    315          return;
    316        }
    317      }
    318    }
    319 
    320    if (isStop) {
    321      if (!this.#seenStartFlag) {
    322        this.#trace("Skip stop state because seenStartFlag is not set");
    323      } else {
    324        // Treat NS_ERROR_PARSED_DATA_CACHED as a success code
    325        // since navigation happened and content has been loaded.
    326        if (
    327          !Components.isSuccessCode(status) &&
    328          status != Cr.NS_ERROR_PARSED_DATA_CACHED
    329        ) {
    330          const errorName = ChromeUtils.getXPCOMErrorName(status);
    331 
    332          if (this.loadType & LOAD_FLAG_ERROR_PAGE) {
    333            // Wait for the next location change notification to ensure that the
    334            // real error page was loaded.
    335            this.#trace(`Error=${errorName}, wait for redirect to error page`);
    336            this.#errorName = errorName;
    337            return;
    338          }
    339 
    340          this.stop({ error: new lazy.NavigationError(errorName, status) });
    341          return;
    342        }
    343 
    344        // If a page finished loading the navigation is done.
    345        this.stop();
    346      }
    347    }
    348  }
    349 
    350  #getErrorName(documentURI) {
    351    try {
    352      // Otherwise try to retrieve it from the document URI if it is an
    353      // error page like `about:neterror?e=contentEncodingError&u=http%3A//...`
    354      const regex = /about:.*error\?e=([^&]*)/;
    355      return documentURI.spec.match(regex)[1];
    356    } catch (e) {
    357      // Or return a generic name
    358      return "Address rejected";
    359    }
    360  }
    361 
    362  #getTargetURI(request) {
    363    try {
    364      return request.QueryInterface(Ci.nsIChannel).originalURI;
    365    } catch (e) {}
    366 
    367    return null;
    368  }
    369 
    370  #onNavigationCommitted = (eventName, data) => {
    371    const { navigationId, url } = data;
    372 
    373    if (this.#resolveWhenCommitted && this.#navigationId === navigationId) {
    374      this.#targetURI = Services.io.newURI(url);
    375      this.#trace(
    376        `Received "navigation-committed" event. Stopping the navigation.`
    377      );
    378      this.stop();
    379    }
    380  };
    381 
    382  #onNavigationFailed = (eventName, data) => {
    383    const { errorName, navigationId } = data;
    384 
    385    if (this.#navigationId === navigationId) {
    386      this.#trace(
    387        `Received "navigation-failed" event with error=${errorName}. Stopping the navigation.`
    388      );
    389      this.stop({ error: new Error(errorName) });
    390    }
    391  };
    392 
    393  #setUnloadTimer() {
    394    if (this.#expectNavigation) {
    395      this.#trace("Skip setting the unload timer");
    396    } else {
    397      this.#trace(`Setting unload timer (${this.#unloadTimeout}ms)`);
    398 
    399      this.#unloadTimerId = lazy.setTimeout(() => {
    400        this.#trace(`No navigation detected: ${this.currentURI?.spec}`);
    401        // Assume the target is the currently loaded URI.
    402        this.#targetURI = this.currentURI;
    403        this.stop();
    404      }, this.#unloadTimeout);
    405    }
    406  }
    407 
    408  #trace(message) {
    409    lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`);
    410  }
    411 
    412  onStateChange(progress, request, flag, status) {
    413    this.#checkLoadingState(request, {
    414      isStart: !!(flag & STATE_START),
    415      isStop: !!(flag & STATE_STOP),
    416      status,
    417    });
    418  }
    419 
    420  onLocationChange(progress, request, location, flag) {
    421    if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
    422      // If an error page has been loaded abort the navigation.
    423      const errorName = this.#errorName || this.#getErrorName(this.documentURI);
    424      this.#trace(
    425        lazy.truncate`Location=errorPage, error=${errorName}, url=${this.documentURI.spec}`
    426      );
    427      this.stop({ error: new Error(errorName) });
    428      return;
    429    }
    430 
    431    if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
    432      const stop = type => {
    433        this.#targetURI = location;
    434        this.#trace(`Location=${type}: ${this.#targetURI?.spec}`);
    435        this.stop();
    436      };
    437 
    438      if (location.hasRef) {
    439        // If the target URL contains a hash, handle the navigation as a
    440        // fragment navigation.
    441        stop("fragmentNavigated");
    442        return;
    443      }
    444 
    445      stop("sameDocument");
    446    }
    447  }
    448 
    449  /**
    450   * Start observing web progress changes.
    451   *
    452   * @param {string=} navigationId
    453   *     The UUID for the navigation.
    454   * @returns {Promise}
    455   *     A promise that will resolve when the navigation has been finished.
    456   */
    457  start(navigationId) {
    458    this.#navigationId = navigationId;
    459 
    460    if (this.#deferredNavigation) {
    461      throw new Error(`Progress listener already started`);
    462    }
    463 
    464    this.#trace(
    465      `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${
    466        this.#resolveWhenStarted
    467      } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${
    468        this.#waitForExplicitStart
    469      }`
    470    );
    471 
    472    if (this.#webProgress.isLoadingDocument) {
    473      this.#targetURI = this.#getTargetURI(this.#webProgress.documentRequest);
    474      this.#trace(`Document already loading ${this.#targetURI?.spec}`);
    475 
    476      if (this.#resolveWhenStarted && !this.#waitForExplicitStart) {
    477        this.#trace(
    478          "Resolve on document loading if not waiting for a load or a new navigation"
    479        );
    480        return Promise.resolve();
    481      }
    482    }
    483 
    484    this.#deferredNavigation = lazy.Deferred();
    485 
    486    // Enable all location change and network state notifications to get
    487    // informed about an upcoming load as early as possible.
    488    this.#webProgress.addProgressListener(
    489      this,
    490      Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_NETWORK
    491    );
    492 
    493    webProgressListeners.add(this);
    494 
    495    if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) {
    496      this.#checkLoadingState(this.#webProgress.documentRequest, {
    497        isStart: true,
    498      });
    499    } else {
    500      // If the document is not loading yet wait some time for the navigation
    501      // to be started.
    502      this.#setUnloadTimer();
    503    }
    504 
    505    return this.#deferredNavigation.promise;
    506  }
    507 
    508  /**
    509   * Stop observing web progress changes.
    510   *
    511   * @param {object=} options
    512   * @param {Error=} options.error
    513   *     If specified the navigation promise will be rejected with this error.
    514   */
    515  stop(options = {}) {
    516    const { error } = options;
    517 
    518    this.#trace(
    519      lazy.truncate`Stop: has error=${!!error} url=${this.currentURI.spec}`
    520    );
    521 
    522    if (!this.#deferredNavigation) {
    523      throw new Error("Progress listener not yet started");
    524    }
    525 
    526    lazy.clearTimeout(this.#unloadTimerId);
    527    this.#unloadTimerId = null;
    528 
    529    this.#webProgress.removeProgressListener(this);
    530    webProgressListeners.delete(this);
    531 
    532    if (!this.#targetURI) {
    533      // If no target URI has been set yet it should be the current URI
    534      this.#targetURI = this.browsingContext.currentURI;
    535    }
    536 
    537    if (error) {
    538      this.#deferredNavigation.reject(error);
    539    } else {
    540      this.#deferredNavigation.resolve();
    541    }
    542 
    543    this.#deferredNavigation = null;
    544  }
    545 
    546  /**
    547   * Stop the progress listener if and only if we already detected a navigation
    548   * start.
    549   *
    550   * @param {object=} options
    551   * @param {Error=} options.error
    552   *     If specified the navigation promise will be rejected with this error.
    553   */
    554  stopIfStarted(options) {
    555    this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`);
    556    if (this.#seenStartFlag) {
    557      this.stop(options);
    558    }
    559  }
    560 
    561  toString() {
    562    return `[object ${this.constructor.name}]`;
    563  }
    564 
    565  // XPCOM
    566 
    567  QueryInterface = ChromeUtils.generateQI([
    568    "nsIWebProgressListener",
    569    "nsISupportsWeakReference",
    570  ]);
    571 }