tor-browser

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

browsingContext.sys.mjs (19013B)


      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 import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  accessibility:
     11    "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs",
     12  AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs",
     13  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
     14  ClipRectangleType:
     15    "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
     16  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
     17  EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
     18  LoadListener: "chrome://remote/content/shared/listeners/LoadListener.sys.mjs",
     19  LocatorType:
     20    "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
     21  OriginType:
     22    "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
     23  OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
     24  pprint: "chrome://remote/content/shared/Format.sys.mjs",
     25 });
     26 
     27 const DOCUMENT_FRAGMENT_NODE = 11;
     28 const DOCUMENT_NODE = 9;
     29 const ELEMENT_NODE = 1;
     30 
     31 const ORDERED_NODE_SNAPSHOT_TYPE = 7;
     32 
     33 class BrowsingContextModule extends WindowGlobalBiDiModule {
     34  #contextCreatedHandled;
     35  #loadListener;
     36  #subscribedEvents;
     37 
     38  constructor(messageHandler) {
     39    super(messageHandler);
     40 
     41    // Setup the LoadListener as early as possible.
     42    this.#loadListener = new lazy.LoadListener(this.messageHandler.window);
     43    this.#loadListener.on("DOMContentLoaded", this.#onDOMContentLoaded);
     44    this.#loadListener.on("load", this.#onLoad);
     45 
     46    // Set of event names which have active subscriptions.
     47    this.#subscribedEvents = new Set();
     48    this.contextCreatedHandled = false;
     49  }
     50 
     51  destroy() {
     52    this.#loadListener.destroy();
     53    this.#subscribedEvents = null;
     54  }
     55 
     56  /**
     57   * Collect nodes using accessibility attributes.
     58   *
     59   * @see https://w3c.github.io/webdriver-bidi/#collect-nodes-using-accessibility-attributes
     60   */
     61  async #collectNodesUsingAccessibilityAttributes(
     62    contextNodes,
     63    selector,
     64    maxReturnedNodeCount,
     65    returnedNodes
     66  ) {
     67    if (returnedNodes === null) {
     68      returnedNodes = [];
     69    }
     70 
     71    for (const contextNode of contextNodes) {
     72      let match = true;
     73 
     74      if (contextNode.nodeType === ELEMENT_NODE) {
     75        if ("role" in selector) {
     76          const role = await lazy.accessibility.getComputedRole(contextNode);
     77 
     78          if (selector.role !== role) {
     79            match = false;
     80          }
     81        }
     82 
     83        if ("name" in selector) {
     84          const name = await lazy.accessibility.getAccessibleName(contextNode);
     85          if (selector.name !== name) {
     86            match = false;
     87          }
     88        }
     89      } else {
     90        match = false;
     91      }
     92 
     93      if (match) {
     94        if (
     95          maxReturnedNodeCount !== null &&
     96          returnedNodes.length === maxReturnedNodeCount
     97        ) {
     98          break;
     99        }
    100        returnedNodes.push(contextNode);
    101      }
    102 
    103      const childNodes = [...contextNode.children];
    104 
    105      await this.#collectNodesUsingAccessibilityAttributes(
    106        childNodes,
    107        selector,
    108        maxReturnedNodeCount,
    109        returnedNodes
    110      );
    111    }
    112 
    113    return returnedNodes;
    114  }
    115 
    116  #getNavigationInfo(data) {
    117    // Note: the navigation id is collected in the parent-process and will be
    118    // added via event interception by the windowglobal-in-root module.
    119    return {
    120      context: this.messageHandler.context,
    121      timestamp: Date.now(),
    122      url: data.target.URL,
    123    };
    124  }
    125 
    126  #getOriginRectangle(origin) {
    127    const win = this.messageHandler.window;
    128 
    129    if (origin === lazy.OriginType.viewport) {
    130      const viewport = win.visualViewport;
    131      // Until it's clarified in the scope of the issue:
    132      // https://github.com/w3c/webdriver-bidi/issues/592
    133      // if we should take into account scrollbar dimensions, when calculating
    134      // the viewport size, we match the behavior of WebDriver Classic,
    135      // meaning we include scrollbar dimensions.
    136      return new DOMRect(
    137        viewport.pageLeft,
    138        viewport.pageTop,
    139        win.innerWidth,
    140        win.innerHeight
    141      );
    142    }
    143 
    144    const documentElement = win.document.documentElement;
    145    return new DOMRect(
    146      0,
    147      0,
    148      documentElement.scrollWidth,
    149      documentElement.scrollHeight
    150    );
    151  }
    152 
    153  /**
    154   * Locate the container element of a provided context id.
    155   *
    156   * @see https://w3c.github.io/webdriver-bidi/#locate-the-container-element
    157   */
    158 
    159  #locateContainer(contextId) {
    160    const returnedNodes = [];
    161    const context = BrowsingContext.get(contextId);
    162    const container = context.embedderElement;
    163    if (container) {
    164      returnedNodes.push(container);
    165    }
    166 
    167    return returnedNodes;
    168  }
    169 
    170  /**
    171   * Locate nodes using accessibility attributes.
    172   *
    173   * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-accessibility-attributes
    174   */
    175  async #locateNodesUsingAccessibilityAttributes(
    176    contextNodes,
    177    selector,
    178    maxReturnedNodeCount
    179  ) {
    180    if (!("role" in selector) && !("name" in selector)) {
    181      throw new lazy.error.InvalidSelectorError(
    182        "Locating nodes by accessibility attributes requires `role` or `name` arguments"
    183      );
    184    }
    185 
    186    return this.#collectNodesUsingAccessibilityAttributes(
    187      contextNodes,
    188      selector,
    189      maxReturnedNodeCount,
    190      null
    191    );
    192  }
    193 
    194  /**
    195   * Locate nodes using css selector.
    196   *
    197   * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-css
    198   */
    199  #locateNodesUsingCss(contextNodes, selector, maxReturnedNodeCount) {
    200    const returnedNodes = [];
    201 
    202    for (const contextNode of contextNodes) {
    203      let elements;
    204      try {
    205        elements = contextNode.querySelectorAll(selector);
    206      } catch (e) {
    207        throw new lazy.error.InvalidSelectorError(
    208          `${e.message}: "${selector}"`
    209        );
    210      }
    211 
    212      if (maxReturnedNodeCount === null) {
    213        returnedNodes.push(...elements);
    214      } else {
    215        for (const element of elements) {
    216          returnedNodes.push(element);
    217 
    218          if (returnedNodes.length === maxReturnedNodeCount) {
    219            return returnedNodes;
    220          }
    221        }
    222      }
    223    }
    224 
    225    return returnedNodes;
    226  }
    227 
    228  /**
    229   * Locate nodes using XPath.
    230   *
    231   * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-xpath
    232   */
    233  #locateNodesUsingXPath(contextNodes, selector, maxReturnedNodeCount) {
    234    const returnedNodes = [];
    235 
    236    for (const contextNode of contextNodes) {
    237      let evaluationResult;
    238      try {
    239        evaluationResult = this.messageHandler.window.document.evaluate(
    240          selector,
    241          contextNode,
    242          null,
    243          ORDERED_NODE_SNAPSHOT_TYPE,
    244          null
    245        );
    246      } catch (e) {
    247        const errorMessage = `${e.message}: "${selector}"`;
    248        if (DOMException.isInstance(e) && e.name === "SyntaxError") {
    249          throw new lazy.error.InvalidSelectorError(errorMessage);
    250        }
    251 
    252        throw new lazy.error.UnknownError(errorMessage);
    253      }
    254 
    255      for (let index = 0; index < evaluationResult.snapshotLength; index++) {
    256        const node = evaluationResult.snapshotItem(index);
    257        returnedNodes.push(node);
    258 
    259        if (
    260          maxReturnedNodeCount !== null &&
    261          returnedNodes.length === maxReturnedNodeCount
    262        ) {
    263          return returnedNodes;
    264        }
    265      }
    266    }
    267 
    268    return returnedNodes;
    269  }
    270 
    271  /**
    272   * Normalize rectangle. This ensures that the resulting rect has
    273   * positive width and height dimensions.
    274   *
    275   * @see https://w3c.github.io/webdriver-bidi/#normalise-rect
    276   *
    277   * @param {DOMRect} rect
    278   *     An object which describes the size and position of a rectangle.
    279   *
    280   * @returns {DOMRect} Normalized rectangle.
    281   */
    282  #normalizeRect(rect) {
    283    let { x, y, width, height } = rect;
    284 
    285    if (width < 0) {
    286      x += width;
    287      width = -width;
    288    }
    289 
    290    if (height < 0) {
    291      y += height;
    292      height = -height;
    293    }
    294 
    295    return new DOMRect(x, y, width, height);
    296  }
    297 
    298  #onDOMContentLoaded = (eventName, data) => {
    299    if (this.#subscribedEvents.has("browsingContext._documentInteractive")) {
    300      this.messageHandler.emitEvent("browsingContext._documentInteractive", {
    301        baseURL: data.target.baseURI,
    302        contextId: this.messageHandler.contextId,
    303        documentURL: data.target.URL,
    304        innerWindowId: this.messageHandler.innerWindowId,
    305        readyState: data.target.readyState,
    306      });
    307    }
    308 
    309    if (this.#subscribedEvents.has("browsingContext.domContentLoaded")) {
    310      this.emitEvent(
    311        "browsingContext.domContentLoaded",
    312        this.#getNavigationInfo(data)
    313      );
    314    }
    315  };
    316 
    317  #onLoad = (eventName, data) => {
    318    if (this.#subscribedEvents.has("browsingContext.load")) {
    319      this.emitEvent("browsingContext.load", this.#getNavigationInfo(data));
    320    }
    321  };
    322 
    323  /**
    324   * Create a new rectangle which will be an intersection of
    325   * rectangles specified as arguments.
    326   *
    327   * @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection
    328   *
    329   * @param {DOMRect} rect1
    330   *     An object which describes the size and position of a rectangle.
    331   * @param {DOMRect} rect2
    332   *     An object which describes the size and position of a rectangle.
    333   *
    334   * @returns {DOMRect} Rectangle, representing an intersection of <var>rect1</var> and <var>rect2</var>.
    335   */
    336  #rectangleIntersection(rect1, rect2) {
    337    rect1 = this.#normalizeRect(rect1);
    338    rect2 = this.#normalizeRect(rect2);
    339 
    340    const x_min = Math.max(rect1.x, rect2.x);
    341    const x_max = Math.min(rect1.x + rect1.width, rect2.x + rect2.width);
    342 
    343    const y_min = Math.max(rect1.y, rect2.y);
    344    const y_max = Math.min(rect1.y + rect1.height, rect2.y + rect2.height);
    345 
    346    const width = Math.max(x_max - x_min, 0);
    347    const height = Math.max(y_max - y_min, 0);
    348 
    349    return new DOMRect(x_min, y_min, width, height);
    350  }
    351 
    352  #startListening() {
    353    if (this.#subscribedEvents.size == 0) {
    354      this.#loadListener.startListening();
    355    }
    356  }
    357 
    358  #stopListening() {
    359    if (this.#subscribedEvents.size == 0) {
    360      this.#loadListener.stopListening();
    361    }
    362  }
    363 
    364  #subscribeEvent(event) {
    365    switch (event) {
    366      case "browsingContext._documentInteractive":
    367        this.#startListening();
    368        this.#subscribedEvents.add("browsingContext._documentInteractive");
    369        break;
    370      case "browsingContext.domContentLoaded":
    371        this.#startListening();
    372        this.#subscribedEvents.add("browsingContext.domContentLoaded");
    373        break;
    374      case "browsingContext.load":
    375        this.#startListening();
    376        this.#subscribedEvents.add("browsingContext.load");
    377        break;
    378    }
    379  }
    380 
    381  #unsubscribeEvent(event) {
    382    switch (event) {
    383      case "browsingContext._documentInteractive":
    384        this.#subscribedEvents.delete("browsingContext._documentInteractive");
    385        break;
    386      case "browsingContext.domContentLoaded":
    387        this.#subscribedEvents.delete("browsingContext.domContentLoaded");
    388        break;
    389      case "browsingContext.load":
    390        this.#subscribedEvents.delete("browsingContext.load");
    391        break;
    392    }
    393 
    394    this.#stopListening();
    395  }
    396 
    397  /**
    398   * Internal commands
    399   */
    400 
    401  _applySessionData(params) {
    402    // TODO: Bug 1775231. Move this logic to a shared module or an abstract
    403    // class.
    404    const { category } = params;
    405    if (category === "event") {
    406      const filteredSessionData = params.sessionData.filter(item =>
    407        this.messageHandler.matchesContext(item.contextDescriptor)
    408      );
    409      for (const event of this.#subscribedEvents.values()) {
    410        const hasSessionItem = filteredSessionData.some(
    411          item => item.value === event
    412        );
    413        // If there are no session items for this context, we should unsubscribe from the event.
    414        if (!hasSessionItem) {
    415          this.#unsubscribeEvent(event);
    416        }
    417      }
    418 
    419      // Subscribe to all events, which have an item in SessionData.
    420      for (const { value } of filteredSessionData) {
    421        /**
    422         * We only want to emit backfill events when subscribing to the contextCreated
    423         * event. We also do not want to emit a backfill when creating the context
    424         * initially as this is already emitted elsewhere, so backfilling it would
    425         * cause a duplicate event to be emitted.
    426         */
    427        if (value === "browsingContext.contextCreated") {
    428          /**
    429           * We check for contextCreatedHandled so we do not replay any contextCreated events we
    430           * have seen before. This can happen when navigating within a browser session as
    431           * navigation will cause _applySessionData to be called with params.initial = false.
    432           */
    433          if (!params.initial && !this.#contextCreatedHandled) {
    434            this.emitEvent("browsingContext.contextCreated", {
    435              context: this.messageHandler.context,
    436            });
    437          }
    438 
    439          this.#contextCreatedHandled = true;
    440        }
    441 
    442        this.#subscribeEvent(value);
    443      }
    444    }
    445  }
    446 
    447  /**
    448   * Waits until the viewport has reached the new dimensions.
    449   *
    450   * @param {object} options
    451   * @param {number} options.height
    452   *     Expected height the viewport will resize to.
    453   * @param {number} options.width
    454   *     Expected width the viewport will resize to.
    455   *
    456   * @returns {Promise}
    457   *     Promise that resolves when the viewport has been resized.
    458   */
    459  async _awaitViewportDimensions(options) {
    460    const { height, width } = options;
    461 
    462    const win = this.messageHandler.window;
    463    let resized;
    464 
    465    // Updates for background tabs are throttled, and we also have to make
    466    // sure that the new browser dimensions have been received by the content
    467    // process. As such wait for the next animation frame.
    468    await lazy.AnimationFramePromise(win);
    469 
    470    const checkBrowserSize = () => {
    471      if (win.innerWidth === width && win.innerHeight === height) {
    472        resized();
    473      }
    474    };
    475 
    476    return new Promise(resolve => {
    477      resized = resolve;
    478 
    479      win.addEventListener("resize", checkBrowserSize);
    480 
    481      // Trigger a layout flush in case none happened yet.
    482      checkBrowserSize();
    483    }).finally(() => {
    484      win.removeEventListener("resize", checkBrowserSize);
    485    });
    486  }
    487 
    488  /**
    489   * Waits until the visibility state of the document has the expected value.
    490   *
    491   * @param {object} options
    492   * @param {number=} options.timeout
    493   *     Timeout in ms. Optional, if not provided, the command will only resolve
    494   *     when the expected state is met.
    495   * @param {number} options.value
    496   *     Expected value of the visibility state.
    497   *
    498   * @returns {Promise}
    499   *     Promise that resolves when the visibility state has the expected value,
    500   *     or the timeout has been reached.
    501   */
    502  async _awaitVisibilityState(options) {
    503    const { timeout = null, value } = options;
    504    const win = this.messageHandler.window;
    505 
    506    if (win.document.visibilityState === value) {
    507      // If the document visibilityState already has the expected value, resolve
    508      // immediately.
    509      return;
    510    }
    511 
    512    try {
    513      // Otherwise, wait for the next visibilitychange event.
    514      await new lazy.EventPromise(win, "visibilitychange", { timeout });
    515    } catch (e) {
    516      if (e instanceof lazy.error.TimeoutError) {
    517        // Swallow the exception thrown by the EventPromise if we simply
    518        // reached the timeout. Here the timeout is meant as an escape hatch,
    519        // but we should still resolve
    520        return;
    521      }
    522      throw e;
    523    }
    524  }
    525 
    526  _getBaseURL() {
    527    return this.messageHandler.window.document.baseURI;
    528  }
    529 
    530  _getScreenshotRect(params = {}) {
    531    const { clip, origin } = params;
    532 
    533    const originRect = this.#getOriginRectangle(origin);
    534    let clipRect = originRect;
    535 
    536    if (clip !== null) {
    537      switch (clip.type) {
    538        case lazy.ClipRectangleType.Box: {
    539          clipRect = new DOMRect(
    540            clip.x + originRect.x,
    541            clip.y + originRect.y,
    542            clip.width,
    543            clip.height
    544          );
    545          break;
    546        }
    547 
    548        case lazy.ClipRectangleType.Element: {
    549          const realm = this.messageHandler.getRealm();
    550          const element = this.deserialize(clip.element, realm);
    551          const viewportRect = this.#getOriginRectangle(
    552            lazy.OriginType.viewport
    553          );
    554          const elementRect = element.getBoundingClientRect();
    555 
    556          clipRect = new DOMRect(
    557            elementRect.x + viewportRect.x,
    558            elementRect.y + viewportRect.y,
    559            elementRect.width,
    560            elementRect.height
    561          );
    562          break;
    563        }
    564      }
    565    }
    566 
    567    return this.#rectangleIntersection(originRect, clipRect);
    568  }
    569 
    570  async _locateNodes(params = {}) {
    571    const { locator, maxNodeCount, serializationOptions, startNodes } = params;
    572 
    573    const realm = this.messageHandler.getRealm();
    574 
    575    const contextNodes = [];
    576    if (startNodes === null) {
    577      contextNodes.push(this.messageHandler.window.document.documentElement);
    578    } else {
    579      for (const serializedStartNode of startNodes) {
    580        const startNode = this.deserialize(serializedStartNode, realm);
    581        lazy.assert.that(
    582          startNode =>
    583            Node.isInstance(startNode) &&
    584            [DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, ELEMENT_NODE].includes(
    585              startNode.nodeType
    586            ),
    587          lazy.pprint`Expected an item of "startNodes" to be an Element, got ${startNode}`
    588        )(startNode);
    589 
    590        contextNodes.push(startNode);
    591      }
    592    }
    593 
    594    let returnedNodes;
    595    switch (locator.type) {
    596      case lazy.LocatorType.accessibility: {
    597        returnedNodes = await this.#locateNodesUsingAccessibilityAttributes(
    598          contextNodes,
    599          locator.value,
    600          maxNodeCount
    601        );
    602        break;
    603      }
    604      case lazy.LocatorType.context: {
    605        returnedNodes = this.#locateContainer(locator.value.context);
    606        break;
    607      }
    608      case lazy.LocatorType.css: {
    609        returnedNodes = this.#locateNodesUsingCss(
    610          contextNodes,
    611          locator.value,
    612          maxNodeCount
    613        );
    614        break;
    615      }
    616      case lazy.LocatorType.xpath: {
    617        returnedNodes = this.#locateNodesUsingXPath(
    618          contextNodes,
    619          locator.value,
    620          maxNodeCount
    621        );
    622        break;
    623      }
    624    }
    625 
    626    const serializedNodes = [];
    627    const seenNodeIds = new Map();
    628    for (const returnedNode of returnedNodes) {
    629      serializedNodes.push(
    630        this.serialize(
    631          returnedNode,
    632          serializationOptions,
    633          lazy.OwnershipModel.None,
    634          realm,
    635          { seenNodeIds }
    636        )
    637      );
    638    }
    639 
    640    return {
    641      serializedNodes,
    642      _extraData: { seenNodeIds },
    643    };
    644  }
    645 }
    646 
    647 export const browsingContext = BrowsingContextModule;