tor-browser

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

emulation.sys.mjs (36889B)


      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 { RootBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/RootBiDiModule.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
     11  ContextDescriptorType:
     12    "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
     13  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
     14  Log: "chrome://remote/content/shared/Log.sys.mjs",
     15  NavigableManager: "chrome://remote/content/shared/NavigableManager.sys.mjs",
     16  pprint: "chrome://remote/content/shared/Format.sys.mjs",
     17  TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
     18  UserContextManager:
     19    "chrome://remote/content/shared/UserContextManager.sys.mjs",
     20  WindowGlobalMessageHandler:
     21    "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
     22 });
     23 
     24 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
     25  lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI)
     26 );
     27 
     28 /**
     29 * Enum of possible natural orientations supported by the
     30 * emulation.setOrientationOverride command.
     31 *
     32 * @readonly
     33 * @enum {ScreenOrientationNatural}
     34 */
     35 const ScreenOrientationNatural = {
     36  Landscape: "landscape",
     37  Portrait: "portrait",
     38 };
     39 
     40 /**
     41 * Enum of possible orientation types supported by the
     42 * emulation.setOrientationOverride command.
     43 *
     44 * @readonly
     45 * @enum {ScreenOrientationType}
     46 */
     47 const ScreenOrientationType = {
     48  PortraitPrimary: "portrait-primary",
     49  PortraitSecondary: "portrait-secondary",
     50  LandscapePrimary: "landscape-primary",
     51  LandscapeSecondary: "landscape-secondary",
     52 };
     53 
     54 // see https://www.w3.org/TR/screen-orientation/#dfn-screen-orientation-values-lists.
     55 const SCREEN_ORIENTATION_VALUES_LISTS = {
     56  [ScreenOrientationNatural.Portrait]: {
     57    [ScreenOrientationType.PortraitPrimary]: 0,
     58    [ScreenOrientationType.LandscapePrimary]: 90,
     59    [ScreenOrientationType.PortraitSecondary]: 180,
     60    [ScreenOrientationType.LandscapeSecondary]: 270,
     61  },
     62  [ScreenOrientationNatural.Landscape]: {
     63    [ScreenOrientationType.LandscapePrimary]: 0,
     64    [ScreenOrientationType.PortraitPrimary]: 90,
     65    [ScreenOrientationType.LandscapeSecondary]: 180,
     66    [ScreenOrientationType.PortraitSecondary]: 270,
     67  },
     68 };
     69 
     70 class EmulationModule extends RootBiDiModule {
     71  /**
     72   * Create a new module instance.
     73   *
     74   * @param {MessageHandler} messageHandler
     75   *     The MessageHandler instance which owns this Module instance.
     76   */
     77  constructor(messageHandler) {
     78    super(messageHandler);
     79  }
     80 
     81  destroy() {}
     82 
     83  /**
     84   * Used as an argument for emulation.setGeolocationOverride command
     85   * to represent an object which holds geolocation coordinates which
     86   * should override the return result of geolocation APIs.
     87   *
     88   * @typedef {object} GeolocationCoordinates
     89   *
     90   * @property {number} latitude
     91   * @property {number} longitude
     92   * @property {number=} accuracy
     93   *     Defaults to 1.
     94   * @property {number=} altitude
     95   *     Defaults to null.
     96   * @property {number=} altitudeAccuracy
     97   *     Defaults to null.
     98   * @property {number=} heading
     99   *     Defaults to null.
    100   * @property {number=} speed
    101   *     Defaults to null.
    102   */
    103 
    104  /**
    105   * Set the geolocation override to the list of top-level navigables
    106   * or user contexts.
    107   *
    108   * @param {object=} options
    109   * @param {Array<string>=} options.contexts
    110   *     Optional list of browsing context ids.
    111   * @param {(GeolocationCoordinates|null)} options.coordinates
    112   *     Geolocation coordinates which have to override
    113   *     the return result of geolocation APIs.
    114   *     Null value resets the override.
    115   * @param {Array<string>=} options.userContexts
    116   *     Optional list of user context ids.
    117   *
    118   * @throws {InvalidArgumentError}
    119   *     Raised if an argument is of an invalid type or value.
    120   * @throws {NoSuchFrameError}
    121   *     If the browsing context cannot be found.
    122   * @throws {NoSuchUserContextError}
    123   *     Raised if the user context id could not be found.
    124   */
    125  async setGeolocationOverride(options = {}) {
    126    let { coordinates } = options;
    127    const { contexts: contextIds = null, userContexts: userContextIds = null } =
    128      options;
    129 
    130    if (coordinates !== null) {
    131      lazy.assert.object(
    132        coordinates,
    133        lazy.pprint`Expected "coordinates" to be an object, got ${coordinates}`
    134      );
    135 
    136      const {
    137        latitude,
    138        longitude,
    139        accuracy = 1,
    140        altitude = null,
    141        altitudeAccuracy = null,
    142        heading = null,
    143        speed = null,
    144      } = coordinates;
    145 
    146      lazy.assert.numberInRange(
    147        latitude,
    148        [-90, 90],
    149        lazy.pprint`Expected "latitude" to be in the range of -90 to 90, got ${latitude}`
    150      );
    151 
    152      lazy.assert.numberInRange(
    153        longitude,
    154        [-180, 180],
    155        lazy.pprint`Expected "longitude" to be in the range of -180 to 180, got ${longitude}`
    156      );
    157 
    158      lazy.assert.positiveNumber(
    159        accuracy,
    160        lazy.pprint`Expected "accuracy" to be a positive number, got ${accuracy}`
    161      );
    162 
    163      if (altitude !== null) {
    164        lazy.assert.number(
    165          altitude,
    166          lazy.pprint`Expected "altitude" to be a number, got ${altitude}`
    167        );
    168      }
    169 
    170      if (altitudeAccuracy !== null) {
    171        lazy.assert.positiveNumber(
    172          altitudeAccuracy,
    173          lazy.pprint`Expected "altitudeAccuracy" to be a positive number, got ${altitudeAccuracy}`
    174        );
    175 
    176        if (altitude === null) {
    177          throw new lazy.error.InvalidArgumentError(
    178            `When "altitudeAccuracy" is provided it's required to provide "altitude" as well`
    179          );
    180        }
    181      }
    182 
    183      if (heading !== null) {
    184        lazy.assert.number(
    185          heading,
    186          lazy.pprint`Expected "heading" to be a number, got ${heading}`
    187        );
    188 
    189        lazy.assert.that(
    190          number => number >= 0 && number < 360,
    191          lazy.pprint`Expected "heading" to be >= 0 and < 360, got ${heading}`
    192        )(heading);
    193      }
    194 
    195      if (speed !== null) {
    196        lazy.assert.positiveNumber(
    197          speed,
    198          lazy.pprint`Expected "speed" to be a positive number, got ${speed}`
    199        );
    200      }
    201 
    202      coordinates = {
    203        ...coordinates,
    204        accuracy,
    205        // For platform API if we want to set values to null
    206        // we have to set them to NaN.
    207        altitude: altitude === null ? NaN : altitude,
    208        altitudeAccuracy: altitudeAccuracy === null ? NaN : altitudeAccuracy,
    209        heading: heading === null ? NaN : heading,
    210        speed: speed === null ? NaN : speed,
    211      };
    212    }
    213 
    214    const { navigables, userContexts } = this.#getEmulationTargets(
    215      contextIds,
    216      userContextIds
    217    );
    218 
    219    const sessionDataItems = this.#generateSessionDataUpdate({
    220      category: "geolocation-override",
    221      contextOverride: contextIds !== null,
    222      hasGlobalOverride: false,
    223      navigables,
    224      resetValue: null,
    225      userContexts,
    226      userContextOverride: userContextIds !== null,
    227      value: coordinates,
    228    });
    229 
    230    if (sessionDataItems.length) {
    231      // TODO: Bug 1953079. Saving the geolocation override in the session data works fine
    232      // with one session, but when we start supporting multiple BiDi session, we will
    233      // have to rethink this approach.
    234      await this.messageHandler.updateSessionData(sessionDataItems);
    235    }
    236 
    237    await this.#applyOverride({
    238      async: true,
    239      callback: this.#applyGeolocationOverride.bind(this),
    240      category: "geolocation-override",
    241      contextIds,
    242      navigables,
    243      resetValue: null,
    244      userContextIds,
    245      value: coordinates,
    246    });
    247  }
    248 
    249  /**
    250   * Set the locale override to the list of top-level navigables
    251   * or user contexts.
    252   *
    253   * @param {object=} options
    254   * @param {Array<string>=} options.contexts
    255   *     Optional list of browsing context ids.
    256   * @param {(string|null)} options.locale
    257   *     Locale string which have to override
    258   *     the return result of JavaScript Intl APIs.
    259   *     Null value resets the override.
    260   * @param {Array<string>=} options.userContexts
    261   *     Optional list of user context ids.
    262   *
    263   * @throws {InvalidArgumentError}
    264   *     Raised if an argument is of an invalid type or value.
    265   * @throws {NoSuchFrameError}
    266   *     If the browsing context cannot be found.
    267   * @throws {NoSuchUserContextError}
    268   *     Raised if the user context id could not be found.
    269   */
    270  async setLocaleOverride(options = {}) {
    271    const {
    272      contexts: contextIds = null,
    273      locale: localeArg,
    274      userContexts: userContextIds = null,
    275    } = options;
    276 
    277    let locale;
    278    if (localeArg === null) {
    279      // The API requires an empty string to reset the override.
    280      locale = "";
    281    } else {
    282      locale = lazy.assert.string(
    283        localeArg,
    284        lazy.pprint`Expected "locale" to be a string, got ${localeArg}`
    285      );
    286 
    287      // Validate if locale is a structurally valid language tag.
    288      try {
    289        Intl.getCanonicalLocales(localeArg);
    290      } catch (err) {
    291        if (err instanceof RangeError) {
    292          throw new lazy.error.InvalidArgumentError(
    293            `Expected "locale" to be a structurally valid language tag (e.g., "en-GB"), got ${localeArg}`
    294          );
    295        }
    296 
    297        throw err;
    298      }
    299    }
    300 
    301    const { navigables, userContexts } = this.#getEmulationTargets(
    302      contextIds,
    303      userContextIds
    304    );
    305 
    306    const sessionDataItems = this.#generateSessionDataUpdate({
    307      category: "locale-override",
    308      contextOverride: contextIds !== null,
    309      hasGlobalOverride: false,
    310      navigables,
    311      resetValue: "",
    312      userContexts,
    313      userContextOverride: userContextIds !== null,
    314      value: locale,
    315    });
    316 
    317    if (sessionDataItems.length) {
    318      // TODO: Bug 1953079. Saving the locale override in the session data works fine
    319      // with one session, but when we start supporting multiple BiDi session, we will
    320      // have to rethink this approach.
    321      await this.messageHandler.updateSessionData(sessionDataItems);
    322    }
    323 
    324    await this.#applyOverride({
    325      async: true,
    326      callback: this._setLocaleForBrowsingContext.bind(this),
    327      category: "locale-override",
    328      contextIds,
    329      navigables,
    330      userContextIds,
    331      value: locale,
    332    });
    333  }
    334 
    335  /**
    336   * Used as an argument for emulation.setScreenOrientationOverride command
    337   * to represent an object which holds screen orientation settings which
    338   * should override screen settings.
    339   *
    340   * @typedef {object} ScreenOrientation
    341   *
    342   * @property {ScreenOrientationNatural} natural
    343   * @property {ScreenOrientationType} type
    344   */
    345 
    346  /**
    347   * Set the screen orientation override to the list of
    348   * top-level navigables or user contexts.
    349   *
    350   * @param {object=} options
    351   * @param {Array<string>=} options.contexts
    352   *     Optional list of browsing context ids.
    353   * @param {(ScreenOrientation|null)} options.screenOrientation
    354   *     Screen orientation object which have to override
    355   *     screen settings.
    356   *     Null value resets the override.
    357   * @param {Array<string>=} options.userContexts
    358   *     Optional list of user context ids.
    359   *
    360   * @throws {InvalidArgumentError}
    361   *     Raised if an argument is of an invalid type or value.
    362   * @throws {NoSuchFrameError}
    363   *     If the browsing context cannot be found.
    364   * @throws {NoSuchUserContextError}
    365   *     Raised if the user context id could not be found.
    366   */
    367  async setScreenOrientationOverride(options = {}) {
    368    const {
    369      contexts: contextIds = null,
    370      screenOrientation,
    371      userContexts: userContextIds = null,
    372    } = options;
    373 
    374    let orientationOverride;
    375 
    376    if (screenOrientation !== null) {
    377      lazy.assert.object(
    378        screenOrientation,
    379        lazy.pprint`Expected "screenOrientation" to be an object or null, got ${screenOrientation}`
    380      );
    381 
    382      const { natural, type } = screenOrientation;
    383 
    384      const naturalValues = Object.keys(SCREEN_ORIENTATION_VALUES_LISTS);
    385 
    386      lazy.assert.in(
    387        natural,
    388        naturalValues,
    389        `Expected "screenOrientation.natural" to be one of ${naturalValues},` +
    390          lazy.pprint`got ${natural}`
    391      );
    392 
    393      const orientationTypes = Object.keys(
    394        SCREEN_ORIENTATION_VALUES_LISTS[natural]
    395      );
    396 
    397      lazy.assert.in(
    398        type,
    399        orientationTypes,
    400        lazy.pprint`Expected "screenOrientation.type" to be one of ${orientationTypes}` +
    401          lazy.pprint`got ${type}`
    402      );
    403 
    404      const angle = SCREEN_ORIENTATION_VALUES_LISTS[natural][type];
    405 
    406      orientationOverride = { angle, type };
    407    } else {
    408      orientationOverride = null;
    409    }
    410 
    411    const { navigables, userContexts } = this.#getEmulationTargets(
    412      contextIds,
    413      userContextIds
    414    );
    415 
    416    const sessionDataItems = this.#generateSessionDataUpdate({
    417      category: "screen-orientation-override",
    418      contextOverride: contextIds !== null,
    419      hasGlobalOverride: false,
    420      navigables,
    421      resetValue: null,
    422      userContexts,
    423      userContextOverride: userContextIds !== null,
    424      value: orientationOverride,
    425    });
    426 
    427    if (sessionDataItems.length) {
    428      // TODO: Bug 1953079. Saving the screen orientation override in the session data works fine
    429      // with one session, but when we start supporting multiple BiDi session, we will
    430      // have to rethink this approach.
    431      await this.messageHandler.updateSessionData(sessionDataItems);
    432    }
    433 
    434    this.#applyOverride({
    435      callback: this._setEmulatedScreenOrientation,
    436      category: "screen-orientation-override",
    437      contextIds,
    438      navigables,
    439      resetValue: null,
    440      userContextIds,
    441      value: orientationOverride,
    442    });
    443  }
    444 
    445  /**
    446   * Used as an argument for emulation.setScreenSettingsOverride command
    447   * to represent an object which holds screen area settings which
    448   * should override screen dimensions.
    449   *
    450   * @typedef {object} ScreenArea
    451   *
    452   * @property {number} height
    453   * @property {number} width
    454   */
    455 
    456  /**
    457   * Set the screen settings override to the list of top-level navigables
    458   * or user contexts.
    459   *
    460   * @param {object=} options
    461   * @param {Array<string>=} options.contexts
    462   *     Optional list of browsing context ids.
    463   * @param {(ScreenArea|null)} options.screenArea
    464   *     An object which has to override
    465   *     the return result of JavaScript APIs which return
    466   *     screen dimensions. Null value resets the override.
    467   * @param {Array<string>=} options.userContexts
    468   *     Optional list of user context ids.
    469   *
    470   * @throws {InvalidArgumentError}
    471   *     Raised if an argument is of an invalid type or value.
    472   * @throws {NoSuchFrameError}
    473   *     If the browsing context cannot be found.
    474   * @throws {NoSuchUserContextError}
    475   *     Raised if the user context id could not be found.
    476   */
    477  async setScreenSettingsOverride(options = {}) {
    478    const {
    479      contexts: contextIds = null,
    480      screenArea,
    481      userContexts: userContextIds = null,
    482    } = options;
    483 
    484    if (screenArea !== null) {
    485      lazy.assert.object(
    486        screenArea,
    487        lazy.pprint`Expected "screenArea" to be an object, got ${screenArea}`
    488      );
    489 
    490      const { height, width } = screenArea;
    491      lazy.assert.positiveNumber(
    492        height,
    493        lazy.pprint`Expected "screenArea.height" to be a positive number, got ${height}`
    494      );
    495      lazy.assert.positiveNumber(
    496        width,
    497        lazy.pprint`Expected "screenArea.width" to be a positive number, got ${width}`
    498      );
    499    }
    500 
    501    const { navigables, userContexts } = this.#getEmulationTargets(
    502      contextIds,
    503      userContextIds
    504    );
    505 
    506    const sessionDataItems = this.#generateSessionDataUpdate({
    507      category: "screen-settings-override",
    508      contextOverride: contextIds !== null,
    509      hasGlobalOverride: false,
    510      navigables,
    511      resetValue: null,
    512      userContexts,
    513      userContextOverride: userContextIds !== null,
    514      value: screenArea,
    515    });
    516 
    517    if (sessionDataItems.length) {
    518      // TODO: Bug 1953079. Saving the locale override in the session data works fine
    519      // with one session, but when we start supporting multiple BiDi session, we will
    520      // have to rethink this approach.
    521      await this.messageHandler.updateSessionData(sessionDataItems);
    522    }
    523 
    524    this.#applyOverride({
    525      callback: this._setScreenSettingsOverride,
    526      category: "screen-settings-override",
    527      contextIds,
    528      navigables,
    529      resetValue: null,
    530      userContextIds,
    531      value: screenArea,
    532    });
    533  }
    534 
    535  /**
    536   * Set the timezone override to the list of top-level navigables
    537   * or user contexts.
    538   *
    539   * @param {object=} options
    540   * @param {Array<string>=} options.contexts
    541   *     Optional list of browsing context ids.
    542   * @param {(string|null)} options.timezone
    543   *     Timezone string which has to override
    544   *     the return result of JavaScript Intl/Date APIs.
    545   *     It can represent timezone id or timezone offset.
    546   *     Null value resets the override.
    547   * @param {Array<string>=} options.userContexts
    548   *     Optional list of user context ids.
    549   *
    550   * @throws {InvalidArgumentError}
    551   *     Raised if an argument is of an invalid type or value.
    552   * @throws {NoSuchFrameError}
    553   *     If the browsing context cannot be found.
    554   * @throws {NoSuchUserContextError}
    555   *     Raised if the user context id could not be found.
    556   */
    557  async setTimezoneOverride(options = {}) {
    558    let { timezone } = options;
    559    const { contexts: contextIds = null, userContexts: userContextIds = null } =
    560      options;
    561 
    562    if (timezone === null) {
    563      // The API requires an empty string to reset the override.
    564      timezone = "";
    565    } else {
    566      lazy.assert.string(
    567        timezone,
    568        lazy.pprint`Expected "timezone" to be a string, got ${timezone}`
    569      );
    570 
    571      if (
    572        // Validate if the timezone is on the list of available timezones ids
    573        !Intl.supportedValuesOf("timeZone").includes(timezone) &&
    574        // or is a valid timezone offset string.
    575        !this.#isTimeZoneOffsetString(timezone)
    576      ) {
    577        throw new lazy.error.InvalidArgumentError(
    578          `Expected "timezone" to be a valid timezone ID (e.g., "Europe/Berlin") ` +
    579            `or a valid timezone offset (e.g., "+01:00"), got ${timezone}`
    580        );
    581      }
    582 
    583      if (this.#isTimeZoneOffsetString(timezone)) {
    584        // The platform API requires a timezone offset to have a "GMT" prefix.
    585        timezone = `GMT${timezone}`;
    586      }
    587    }
    588 
    589    const { navigables, userContexts } = this.#getEmulationTargets(
    590      contextIds,
    591      userContextIds
    592    );
    593 
    594    const sessionDataItems = this.#generateSessionDataUpdate({
    595      category: "timezone-override",
    596      contextOverride: contextIds !== null,
    597      hasGlobalOverride: false,
    598      navigables,
    599      resetValue: "",
    600      userContexts,
    601      userContextOverride: userContextIds !== null,
    602      value: timezone,
    603    });
    604 
    605    if (sessionDataItems.length) {
    606      // TODO: Bug 1953079. Saving the timezone override in the session data works fine
    607      // with one session, but when we start supporting multiple BiDi session, we will
    608      // have to rethink this approach.
    609      await this.messageHandler.updateSessionData(sessionDataItems);
    610    }
    611 
    612    await this.#applyOverride({
    613      async: true,
    614      callback: this._setTimezoneOverride.bind(this),
    615      category: "timezone-override",
    616      contextIds,
    617      navigables,
    618      userContextIds,
    619      value: timezone,
    620    });
    621  }
    622 
    623  /**
    624   * Set the user agent override to the list of top-level navigables
    625   * or user contexts.
    626   *
    627   * @param {object=} options
    628   * @param {Array<string>=} options.contexts
    629   *     Optional list of browsing context ids.
    630   * @param {(string|null)} options.userAgent
    631   *     User agent string which has to override
    632   *     the browser user agent.
    633   *     Null value resets the override.
    634   * @param {Array<string>=} options.userContexts
    635   *     Optional list of user context ids.
    636   *
    637   * @throws {InvalidArgumentError}
    638   *     Raised if an argument is of an invalid type or value.
    639   * @throws {NoSuchFrameError}
    640   *     If the browsing context cannot be found.
    641   * @throws {NoSuchUserContextError}
    642   *     Raised if the user context id could not be found.
    643   */
    644  async setUserAgentOverride(options = {}) {
    645    const { contexts: contextIds, userContexts: userContextIds } = options;
    646    let { userAgent } = options;
    647 
    648    if (userAgent === null) {
    649      // The API requires an empty string to reset the override.
    650      userAgent = "";
    651    } else {
    652      lazy.assert.string(
    653        userAgent,
    654        lazy.pprint`Expected "userAgent" to be a string, got ${userAgent}`
    655      );
    656 
    657      if (userAgent === "") {
    658        throw new lazy.error.UnsupportedOperationError(
    659          `Overriding "userAgent" to an empty string is not supported`
    660        );
    661      }
    662    }
    663 
    664    if (contextIds !== undefined && userContextIds !== undefined) {
    665      throw new lazy.error.InvalidArgumentError(
    666        `Providing both "contexts" and "userContexts" arguments is not supported`
    667      );
    668    }
    669 
    670    const navigables = new Set();
    671    const userContexts = new Set();
    672    if (contextIds !== undefined) {
    673      lazy.assert.isNonEmptyArray(
    674        contextIds,
    675        lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
    676      );
    677 
    678      for (const contextId of contextIds) {
    679        lazy.assert.string(
    680          contextId,
    681          lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
    682        );
    683 
    684        const context = this._getNavigable(contextId);
    685 
    686        lazy.assert.topLevel(
    687          context,
    688          `Browsing context with id ${contextId} is not top-level`
    689        );
    690 
    691        navigables.add(context);
    692      }
    693    } else if (userContextIds !== undefined) {
    694      lazy.assert.isNonEmptyArray(
    695        userContextIds,
    696        lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}`
    697      );
    698 
    699      for (const userContextId of userContextIds) {
    700        lazy.assert.string(
    701          userContextId,
    702          lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}`
    703        );
    704 
    705        const internalId =
    706          lazy.UserContextManager.getInternalIdById(userContextId);
    707 
    708        if (internalId === null) {
    709          throw new lazy.error.NoSuchUserContextError(
    710            `User context with id: ${userContextId} doesn't exist`
    711          );
    712        }
    713 
    714        userContexts.add(internalId);
    715 
    716        // Prepare the list of navigables to update.
    717        lazy.UserContextManager.getTabsForUserContext(internalId).forEach(
    718          tab => {
    719            const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
    720            navigables.add(contentBrowser.browsingContext);
    721          }
    722        );
    723      }
    724    } else {
    725      lazy.TabManager.getBrowsers().forEach(browser =>
    726        navigables.add(browser.browsingContext)
    727      );
    728    }
    729 
    730    const sessionDataItems = this.#generateSessionDataUpdate({
    731      category: "user-agent-override",
    732      contextOverride: contextIds !== undefined,
    733      hasGlobalOverride: true,
    734      navigables,
    735      resetValue: "",
    736      userContexts,
    737      userContextOverride: userContextIds !== undefined,
    738      value: userAgent,
    739    });
    740 
    741    if (sessionDataItems.length) {
    742      // TODO: Bug 1953079. Saving the user agent override in the session data works fine
    743      // with one session, but when we start supporting multiple BiDi session, we will
    744      // have to rethink this approach.
    745      await this.messageHandler.updateSessionData(sessionDataItems);
    746    }
    747 
    748    this.#applyOverride({
    749      callback: this._setUserAgentOverride,
    750      category: "user-agent-override",
    751      contextIds,
    752      navigables,
    753      userContextIds,
    754      value: userAgent,
    755    });
    756  }
    757 
    758  /**
    759   * Set the screen orientation override to the top-level browsing context.
    760   *
    761   * @param {object} options
    762   * @param {BrowsingContext} options.context
    763   *     Top-level browsing context object which is a target
    764   *     for the screen orientation override.
    765   * @param {(object|null)} options.value
    766   *     Screen orientation object which have to override
    767   *     screen settings.
    768   *     Null value resets the override.
    769   */
    770  _setEmulatedScreenOrientation(options) {
    771    const { context, value } = options;
    772    if (value) {
    773      const { angle, type } = value;
    774      context.setOrientationOverride(type, angle);
    775    } else {
    776      context.resetOrientationOverride();
    777    }
    778  }
    779 
    780  /**
    781   * Set the locale override to the top-level browsing context.
    782   *
    783   * @param {object} options
    784   * @param {BrowsingContext} options.context
    785   *     Top-level browsing context object which is a target
    786   *     for the locale override.
    787   * @param {(string|null)} options.value
    788   *     Locale string which have to override
    789   *     the return result of JavaScript Intl APIs.
    790   *     Null value resets the override.
    791   */
    792  async _setLocaleForBrowsingContext(options) {
    793    const { context, value } = options;
    794 
    795    context.languageOverride = value;
    796 
    797    await this.messageHandler.handleCommand({
    798      moduleName: "emulation",
    799      commandName: "_setLocaleOverrideToSandboxes",
    800      destination: {
    801        type: lazy.WindowGlobalMessageHandler.type,
    802        contextDescriptor: {
    803          type: lazy.ContextDescriptorType.TopBrowsingContext,
    804          id: context.browserId,
    805        },
    806      },
    807      params: {
    808        locale: value,
    809      },
    810    });
    811  }
    812 
    813  /**
    814   * Set the screen settings override to the top-level browsing context.
    815   *
    816   * @param {object} options
    817   * @param {BrowsingContext} options.context
    818   *     Top-level browsing context object which is a target
    819   *     for the locale override.
    820   * @param {(ScreenArea|null)} options.value
    821   *     An object which has to override
    822   *     the return result of JavaScript APIs which return
    823   *     screen dimensions. Null value resets the override.
    824   */
    825  _setScreenSettingsOverride(options) {
    826    const { context, value } = options;
    827 
    828    if (value === null) {
    829      context.resetScreenAreaOverride();
    830    } else {
    831      const { height, width } = value;
    832      context.setScreenAreaOverride(width, height);
    833    }
    834  }
    835 
    836  /**
    837   * Set the timezone override to the top-level browsing context.
    838   *
    839   * @param {object} options
    840   * @param {BrowsingContext} options.context
    841   *     Top-level browsing context object which is a target
    842   *     for the locale override.
    843   * @param {(string|null)} options.value
    844   *     Timezone string which has to override
    845   *     the return result of JavaScript Intl/Date APIs.
    846   *     Null value resets the override.
    847   */
    848  async _setTimezoneOverride(options) {
    849    const { context, value } = options;
    850 
    851    context.timezoneOverride = value;
    852 
    853    await this.messageHandler.handleCommand({
    854      moduleName: "emulation",
    855      commandName: "_setTimezoneOverrideToSandboxes",
    856      destination: {
    857        type: lazy.WindowGlobalMessageHandler.type,
    858        contextDescriptor: {
    859          type: lazy.ContextDescriptorType.TopBrowsingContext,
    860          id: context.browserId,
    861        },
    862      },
    863      params: {
    864        timezone: value,
    865      },
    866    });
    867  }
    868 
    869  /**
    870   * Set the user agent override to the top-level browsing context.
    871   *
    872   * @param {object} options
    873   * @param {BrowsingContext} options.context
    874   *     Top-level browsing context object which is a target
    875   *     for the locale override.
    876   * @param {string} options.value
    877   *     User agent string which has to override
    878   *     the browser user agent.
    879   */
    880  _setUserAgentOverride(options) {
    881    const { context, value } = options;
    882 
    883    try {
    884      context.customUserAgent = value;
    885    } catch (e) {
    886      const contextId = lazy.NavigableManager.getIdForBrowsingContext(context);
    887 
    888      lazy.logger.warn(
    889        `Failed to override user agent for context with id: ${contextId} (${e.message})`
    890      );
    891    }
    892  }
    893 
    894  /**
    895   * Apply the geolocation override to the top-level browsing context.
    896   *
    897   * @param {object} options
    898   * @param {BrowsingContext} options.context
    899   *     Top-level browsing context object which is a target
    900   *     for the geolocation override.
    901   * @param {(GeolocationCoordinates|null)} options.value
    902   *     Geolocation coordinates which have to override
    903   *     the return result of geolocation APIs.
    904   *     Null value resets the override.
    905   */
    906  #applyGeolocationOverride(options) {
    907    const { context, value } = options;
    908 
    909    return this._forwardToWindowGlobal(
    910      "_setGeolocationOverride",
    911      context.id,
    912      {
    913        coordinates: value,
    914      },
    915      { retryOnAbort: true }
    916    );
    917  }
    918 
    919  async #applyOverride(options) {
    920    const {
    921      async = false,
    922      callback,
    923      category,
    924      contextIds,
    925      navigables,
    926      resetValue = "",
    927      userContextIds,
    928      value,
    929    } = options;
    930 
    931    const commands = [];
    932 
    933    for (const navigable of navigables) {
    934      const overrideValue = this.#getOverrideValue(
    935        {
    936          category,
    937          context: navigable,
    938          contextIds,
    939          userContextIds,
    940          value,
    941        },
    942        resetValue
    943      );
    944 
    945      if (overrideValue === undefined) {
    946        continue;
    947      }
    948 
    949      const commandArgs = {
    950        context: navigable,
    951        value: overrideValue,
    952      };
    953 
    954      if (async) {
    955        commands.push(callback(commandArgs));
    956      } else {
    957        callback(commandArgs);
    958      }
    959    }
    960 
    961    if (async) {
    962      await Promise.all(commands);
    963    }
    964  }
    965 
    966  #generateSessionDataUpdate(options) {
    967    const {
    968      category,
    969      contextOverride,
    970      hasGlobalOverride,
    971      navigables,
    972      resetValue,
    973      userContexts,
    974      userContextOverride,
    975      value,
    976    } = options;
    977    const sessionDataItems = [];
    978    const onlyRemoveSessionDataItem = value === resetValue;
    979 
    980    if (userContextOverride) {
    981      for (const userContext of userContexts) {
    982        sessionDataItems.push(
    983          ...this.messageHandler.sessionData.generateSessionDataItemUpdate(
    984            "_configuration",
    985            category,
    986            {
    987              type: lazy.ContextDescriptorType.UserContext,
    988              id: userContext,
    989            },
    990            onlyRemoveSessionDataItem,
    991            value
    992          )
    993        );
    994      }
    995    } else if (contextOverride) {
    996      for (const navigable of navigables) {
    997        sessionDataItems.push(
    998          ...this.messageHandler.sessionData.generateSessionDataItemUpdate(
    999            "_configuration",
   1000            category,
   1001            {
   1002              type: lazy.ContextDescriptorType.TopBrowsingContext,
   1003              id: navigable.browserId,
   1004            },
   1005            onlyRemoveSessionDataItem,
   1006            value
   1007          )
   1008        );
   1009      }
   1010    } else if (hasGlobalOverride) {
   1011      sessionDataItems.push(
   1012        ...this.messageHandler.sessionData.generateSessionDataItemUpdate(
   1013          "_configuration",
   1014          category,
   1015          {
   1016            type: lazy.ContextDescriptorType.All,
   1017          },
   1018          onlyRemoveSessionDataItem,
   1019          value
   1020        )
   1021      );
   1022    }
   1023 
   1024    return sessionDataItems;
   1025  }
   1026 
   1027  /**
   1028   * Return value for #getEmulationTargets.
   1029   *
   1030   * @typedef {object} EmulationTargets
   1031   *
   1032   * @property {Set<Navigable>} navigables
   1033   * @property {Set<number>} userContexts
   1034   */
   1035 
   1036  /**
   1037   * Validates the provided browsing contexts or user contexts and resolves them
   1038   * to a set of navigables.
   1039   *
   1040   * @param {Array<string>|null} contextIds
   1041   *     Optional list of browsing context ids.
   1042   * @param {Array<string>|null} userContextIds
   1043   *     Optional list of user context ids.
   1044   *
   1045   * @returns {EmulationTargets}
   1046   */
   1047  #getEmulationTargets(contextIds, userContextIds) {
   1048    if (contextIds !== null && userContextIds !== null) {
   1049      throw new lazy.error.InvalidArgumentError(
   1050        `Providing both "contexts" and "userContexts" arguments is not supported`
   1051      );
   1052    }
   1053 
   1054    const navigables = new Set();
   1055    const userContexts = new Set();
   1056 
   1057    if (contextIds !== null) {
   1058      lazy.assert.isNonEmptyArray(
   1059        contextIds,
   1060        lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
   1061      );
   1062 
   1063      for (const contextId of contextIds) {
   1064        lazy.assert.string(
   1065          contextId,
   1066          lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
   1067        );
   1068 
   1069        const context = this._getNavigable(contextId);
   1070 
   1071        lazy.assert.topLevel(
   1072          context,
   1073          `Browsing context with id ${contextId} is not top-level`
   1074        );
   1075 
   1076        navigables.add(context);
   1077      }
   1078    } else if (userContextIds !== null) {
   1079      lazy.assert.isNonEmptyArray(
   1080        userContextIds,
   1081        lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}`
   1082      );
   1083 
   1084      for (const userContextId of userContextIds) {
   1085        lazy.assert.string(
   1086          userContextId,
   1087          lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}`
   1088        );
   1089 
   1090        const internalId =
   1091          lazy.UserContextManager.getInternalIdById(userContextId);
   1092 
   1093        if (internalId === null) {
   1094          throw new lazy.error.NoSuchUserContextError(
   1095            `User context with id: ${userContextId} doesn't exist`
   1096          );
   1097        }
   1098 
   1099        userContexts.add(internalId);
   1100 
   1101        // Prepare the list of navigables to update.
   1102        lazy.UserContextManager.getTabsForUserContext(internalId).forEach(
   1103          tab => {
   1104            const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
   1105            navigables.add(contentBrowser.browsingContext);
   1106          }
   1107        );
   1108      }
   1109    } else {
   1110      throw new lazy.error.InvalidArgumentError(
   1111        `At least one of "contexts" or "userContexts" arguments should be provided`
   1112      );
   1113    }
   1114 
   1115    return { navigables, userContexts };
   1116  }
   1117 
   1118  #getOverrideValue(params, resetValue = "") {
   1119    const { category, context, contextIds, userContextIds, value } = params;
   1120    const [overridePerContext, overridePerUserContext, overrideGlobal] =
   1121      this.#findExistingOverrideForContext(category, context);
   1122 
   1123    if (contextIds) {
   1124      if (value === resetValue) {
   1125        // In case of resetting an override for navigable,
   1126        // if there is an existing override for user context or global,
   1127        // we should apply it to browsing context.
   1128        return overridePerUserContext || overrideGlobal || resetValue;
   1129      }
   1130    } else if (userContextIds) {
   1131      // No need to do anything if there is an override
   1132      // for the browsing context.
   1133      if (overridePerContext) {
   1134        return undefined;
   1135      }
   1136 
   1137      // In case of resetting an override for user context,
   1138      // apply a global override if it exists
   1139      if (value === resetValue && overrideGlobal) {
   1140        return overrideGlobal;
   1141      }
   1142    } else if (overridePerContext || overridePerUserContext) {
   1143      // No need to do anything if there is an override
   1144      // for the browsing or user context.
   1145      return undefined;
   1146    }
   1147 
   1148    return value;
   1149  }
   1150 
   1151  /**
   1152   * Find the existing overrides for a given category and context.
   1153   *
   1154   * @param {string} category
   1155   *     The session data category.
   1156   * @param {BrowsingContext} context
   1157   *     The browsing context.
   1158   *
   1159   * @returns {Array<string>}
   1160   *     Return the list of existing values.
   1161   */
   1162  #findExistingOverrideForContext(category, context) {
   1163    let overrideGlobal, overridePerUserContext, overridePerContext;
   1164 
   1165    const sessionDataItems =
   1166      this.messageHandler.sessionData.getSessionDataForContext(
   1167        "_configuration",
   1168        category,
   1169        context
   1170      );
   1171 
   1172    sessionDataItems.forEach(item => {
   1173      switch (item.contextDescriptor.type) {
   1174        case lazy.ContextDescriptorType.All: {
   1175          overrideGlobal = item.value;
   1176          break;
   1177        }
   1178        case lazy.ContextDescriptorType.UserContext: {
   1179          overridePerUserContext = item.value;
   1180          break;
   1181        }
   1182        case lazy.ContextDescriptorType.TopBrowsingContext: {
   1183          overridePerContext = item.value;
   1184          break;
   1185        }
   1186      }
   1187    });
   1188 
   1189    return [overridePerContext, overridePerUserContext, overrideGlobal];
   1190  }
   1191 
   1192  /**
   1193   * Validate that a string has timezone offset string format
   1194   * (e.g. `+10:00` or `-05:00`).
   1195   *
   1196   * @see https://tc39.es/ecma262/#sec-time-zone-offset-strings.
   1197   *
   1198   * @param {string} string
   1199   *     The string to validate.
   1200   *
   1201   * @returns {boolean}
   1202   *     Return true if the string has timezone offset string format,
   1203   *     false otherwise.
   1204   */
   1205  #isTimeZoneOffsetString(string) {
   1206    if (string === "" || string === "Z") {
   1207      return false;
   1208    }
   1209    // Random date string is added to validate an offset string.
   1210    return ChromeUtils.isISOStyleDate(`2011-10-05T00:00${string}`);
   1211  }
   1212 }
   1213 
   1214 export const emulation = EmulationModule;