tor-browser

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

WindowManager.sys.mjs (18396B)


      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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
     11 
     12  AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs",
     13  AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
     14  BiMap: "chrome://remote/content/shared/BiMap.sys.mjs",
     15  BrowsingContextListener:
     16    "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
     17  ChromeWindowListener:
     18    "chrome://remote/content/shared/listeners/ChromeWindowListener.sys.mjs",
     19  DebounceCallback: "chrome://remote/content/marionette/sync.sys.mjs",
     20  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
     21  EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
     22  Log: "chrome://remote/content/shared/Log.sys.mjs",
     23  TimedPromise: "chrome://remote/content/shared/Sync.sys.mjs",
     24  UserContextManager:
     25    "chrome://remote/content/shared/UserContextManager.sys.mjs",
     26  waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
     27 });
     28 
     29 ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
     30 
     31 // Timeout used to abort fullscreen, maximize, and minimize
     32 // commands if no window manager is present.
     33 const TIMEOUT_NO_WINDOW_MANAGER = 5000;
     34 
     35 /**
     36 * Provides helpers to interact with Window objects.
     37 *
     38 * @class WindowManager
     39 */
     40 class WindowManager {
     41  #chromeWindowListener;
     42  #clientWindowIds;
     43  #contextListener;
     44  #contextToWindowMap;
     45  #tracking;
     46 
     47  constructor() {
     48    /**
     49     * Keep track of the client window for any registered contexts. When the
     50     * contextDestroyed event is fired, the context is already destroyed so
     51     * we cannot query for the client window at that time.
     52     */
     53    this.#clientWindowIds = new lazy.BiMap();
     54 
     55    // For content browsing contexts, the embedder element may already be
     56    // gone by the time when it is getting discarded. To ensure we can still
     57    // retrieve the corresponding chrome window, we maintain a mapping from
     58    // each top-level content browsing context to its chrome window.
     59    this.#contextToWindowMap = new WeakMap();
     60    this.#contextListener = new lazy.BrowsingContextListener();
     61 
     62    this.#tracking = false;
     63 
     64    this.#chromeWindowListener = new lazy.ChromeWindowListener();
     65  }
     66 
     67  destroy() {
     68    this.stopTracking();
     69  }
     70 
     71  startTracking() {
     72    if (this.#tracking) {
     73      return;
     74    }
     75 
     76    this.#chromeWindowListener.on("closed", this.#onChromeWindowClosed);
     77    this.#chromeWindowListener.on("opened", this.#onChromeWindowOpened);
     78    this.#chromeWindowListener.startListening();
     79 
     80    this.#contextListener.on("attached", this.#onContextAttached);
     81    this.#contextListener.startListening();
     82 
     83    // Pre-fill the internal window id mapping.
     84    this.windows.forEach(window => this.getIdForWindow(window));
     85 
     86    this.#tracking = true;
     87  }
     88 
     89  stopTracking() {
     90    if (!this.#tracking) {
     91      return;
     92    }
     93 
     94    this.#chromeWindowListener.stopListening();
     95    this.#chromeWindowListener.off("closed", this.#onChromeWindowClosed);
     96    this.#chromeWindowListener.off("opened", this.#onChromeWindowOpened);
     97 
     98    this.#contextListener.stopListening();
     99    this.#contextListener.off("attached", this.#onContextAttached);
    100 
    101    this.#clientWindowIds = new lazy.BiMap();
    102    this.#contextToWindowMap = new WeakMap();
    103 
    104    this.#tracking = false;
    105  }
    106 
    107  /**
    108   * Retrieve all the open windows.
    109   *
    110   * @returns {Array<Window>}
    111   *     All the open windows. Will return an empty list if no window is open.
    112   */
    113  get windows() {
    114    const windows = [];
    115 
    116    for (const win of Services.wm.getEnumerator(null)) {
    117      if (win.closed) {
    118        continue;
    119      }
    120      windows.push(win);
    121    }
    122 
    123    return windows;
    124  }
    125 
    126  /**
    127   * Retrieves an id for the given chrome window. The id is a dynamically
    128   * generated uuid by the WindowManager and associated with the
    129   * top-level browsing context of that chrome window.
    130   *
    131   * @param {ChromeWindow} win
    132   *     The chrome window for which we want to retrieve the id.
    133   *
    134   * @returns {string|null}
    135   *     The unique id for this chrome window or `null` if not a valid window.
    136   */
    137  getIdForWindow(win) {
    138    if (win) {
    139      return this.#clientWindowIds.getOrInsert(win);
    140    }
    141 
    142    return null;
    143  }
    144 
    145  /**
    146   * Retrieve the Chrome Window corresponding to the provided window id.
    147   *
    148   * @param {string} id
    149   *     A unique id for the chrome window.
    150   *
    151   * @returns {ChromeWindow|undefined}
    152   *     The chrome window found for this id, `null` if none
    153   *     was found.
    154   */
    155  getWindowById(id) {
    156    return this.#clientWindowIds.getObject(id);
    157  }
    158 
    159  /**
    160   * Close the specified window.
    161   *
    162   * @param {window} win
    163   *     The window to close.
    164   * @returns {Promise}
    165   *     A promise which is resolved when the current window has been closed.
    166   */
    167  async closeWindow(win) {
    168    const destroyed = lazy.waitForObserverTopic("xul-window-destroyed", {
    169      checkFn: () => win && win.closed,
    170    });
    171 
    172    win.close();
    173 
    174    return destroyed;
    175  }
    176 
    177  /**
    178   * Adjusts the window geometry.
    179   *
    180   *@param {window} win
    181   *     The browser window to adjust.
    182   * @param {number} x
    183   *     The x-coordinate of the window.
    184   * @param {number} y
    185   *     The y-coordinate of the window.
    186   * @param {number} width
    187   *     The width of the window.
    188   * @param {number} height
    189   *     The height of the window.
    190   *
    191   * @returns {Promise}
    192   *     A promise that resolves when the window geometry has been adjusted.
    193   *
    194   * @throws {TimeoutError}
    195   *     Raised if the operating system fails to honor the requested move or resize.
    196   */
    197  async adjustWindowGeometry(win, x, y, width, height) {
    198    // we find a matching position on e.g. resize, then resolve, then a geometry
    199    // change comes in, then the window pos listener runs, we might try to
    200    // incorrectly reset the position without this check.
    201    let foundMatch = false;
    202 
    203    function geometryMatches() {
    204      lazy.logger.trace(
    205        `Checking window geometry ${win.outerWidth}x${win.outerHeight} @ (${win.screenX}, ${win.screenY})`
    206      );
    207 
    208      if (foundMatch) {
    209        lazy.logger.trace(`Already found a previous match for this request`);
    210        return true;
    211      }
    212 
    213      let sizeMatches = true;
    214      let posMatches = true;
    215 
    216      if (
    217        width !== null &&
    218        height !== null &&
    219        (win.outerWidth !== width || win.outerHeight !== height)
    220      ) {
    221        sizeMatches = false;
    222      }
    223 
    224      // Wayland doesn't support getting the window position.
    225      if (
    226        x !== null &&
    227        y !== null &&
    228        (win.screenX !== x || win.screenY !== y)
    229      ) {
    230        if (lazy.AppInfo.isWayland) {
    231          lazy.logger.info(
    232            `Wayland doesn't support setting the window position`
    233          );
    234        } else {
    235          posMatches = false;
    236        }
    237      }
    238 
    239      if (sizeMatches && posMatches) {
    240        lazy.logger.trace(`Requested window geometry matches`);
    241        foundMatch = true;
    242        return true;
    243      }
    244 
    245      return false;
    246    }
    247 
    248    if (!geometryMatches()) {
    249      // There might be more than one resize or MozUpdateWindowPos event due
    250      // to previous geometry changes, such as from restoreWindow(), so
    251      // wait longer if window geometry does not match.
    252      const options = {
    253        checkFn: geometryMatches,
    254        timeout: 500,
    255      };
    256      const promises = [];
    257 
    258      if (width !== null && height !== null) {
    259        promises.push(new lazy.EventPromise(win, "resize", options));
    260        win.resizeTo(width, height);
    261      }
    262 
    263      // Wayland doesn't support setting the window position.
    264      if (!lazy.AppInfo.isWayland && x !== null && y !== null) {
    265        promises.push(
    266          new lazy.EventPromise(win.windowRoot, "MozUpdateWindowPos", options)
    267        );
    268        win.moveTo(x, y);
    269      }
    270 
    271      try {
    272        await Promise.race(promises);
    273      } catch (e) {
    274        if (e instanceof lazy.error.TimeoutError) {
    275          // The operating system might not honor the move or resize, in which
    276          // case assume that geometry will have been adjusted "as close as
    277          // possible" to that requested.  There may be no event received if the
    278          // geometry is already as close as possible.
    279        } else {
    280          throw e;
    281        }
    282      }
    283    }
    284  }
    285 
    286  /**
    287   * Focus the specified window.
    288   *
    289   * @param {window} win
    290   *     The window to focus.
    291   * @returns {Promise}
    292   *     A promise which is resolved when the window has been focused.
    293   */
    294  async focusWindow(win) {
    295    if (Services.focus.activeWindow != win) {
    296      let activated = new lazy.EventPromise(win, "activate");
    297      let focused = new lazy.EventPromise(win, "focus", { capture: true });
    298 
    299      win.focus();
    300 
    301      await Promise.all([activated, focused]);
    302    }
    303  }
    304 
    305  /**
    306   * Returns the chrome window for a specific browsing context.
    307   *
    308   * @param {BrowsingContext} context
    309   *    The browsing context for which we want to retrieve the window.
    310   *
    311   * @returns {ChromeWindow|null}
    312   *    The chrome window associated with the browsing context.
    313   *    Otherwise `null` is returned.
    314   */
    315  getChromeWindowForBrowsingContext(context) {
    316    if (!context.isContent) {
    317      // Chrome browsing contexts always have a chrome window set.
    318      return context.topChromeWindow;
    319    }
    320 
    321    if (this.#contextToWindowMap.has(context.top)) {
    322      return this.#contextToWindowMap.get(context.top);
    323    }
    324 
    325    return this.#setChromeWindowForBrowsingContext(context);
    326  }
    327 
    328  /**
    329   * Open a new browser window.
    330   *
    331   * @param {object=} options
    332   * @param {boolean=} options.focus
    333   *     If true, the opened window will receive the focus. Defaults to false.
    334   * @param {boolean=} options.isPrivate
    335   *     If true, the opened window will be a private window. Defaults to false.
    336   * @param {ChromeWindow=} options.openerWindow
    337   *     Use this window as the opener of the new window. Defaults to the
    338   *     topmost window.
    339   * @param {string=} options.userContextId
    340   *     The id of the user context which should own the initial tab of the new
    341   *     window.
    342   *
    343   * @returns {Promise<ChromeWindow>}
    344   *     A promise resolving to the newly created chrome window.
    345   *
    346   * @throws {UnsupportedOperationError}
    347   *     When opening a new browser window is not supported.
    348   */
    349  async openBrowserWindow(options = {}) {
    350    let {
    351      focus = false,
    352      isPrivate = false,
    353      openerWindow = null,
    354      userContextId = null,
    355    } = options;
    356 
    357    switch (lazy.AppInfo.name) {
    358      case "Firefox": {
    359        if (openerWindow === null) {
    360          // If no opener was provided, fallback to the topmost window.
    361          openerWindow = Services.wm.getMostRecentBrowserWindow();
    362        }
    363 
    364        if (!openerWindow) {
    365          throw new lazy.error.UnsupportedOperationError(
    366            `openWindow() could not find a valid opener window`
    367          );
    368        }
    369 
    370        // Open new browser window, and wait until it is fully loaded.
    371        // Also wait for the window to be focused and activated to prevent a
    372        // race condition when promptly focusing to the original window again.
    373        const browser = await new Promise(resolveOnContentBrowserCreated =>
    374          lazy.URILoadingHelper.openTrustedLinkIn(
    375            openerWindow,
    376            "about:blank",
    377            "window",
    378            {
    379              private: isPrivate,
    380              resolveOnContentBrowserCreated,
    381              userContextId:
    382                lazy.UserContextManager.getInternalIdById(userContextId),
    383            }
    384          )
    385        );
    386 
    387        // TODO: Both for WebDriver BiDi and classic, opening a new window
    388        // should not run the focus steps. When focus is false we should avoid
    389        // focusing the new window completely. See Bug 1766329
    390 
    391        if (focus) {
    392          // Focus the currently selected tab.
    393          browser.focus();
    394        } else {
    395          // If the new window shouldn't get focused, set the
    396          // focus back to the opening window.
    397          await this.focusWindow(openerWindow);
    398        }
    399 
    400        const chromeWindow = browser.ownerGlobal;
    401        await this.waitForChromeWindowLoaded(chromeWindow);
    402 
    403        return chromeWindow;
    404      }
    405 
    406      default:
    407        throw new lazy.error.UnsupportedOperationError(
    408          `openWindow() not supported in ${lazy.AppInfo.name}`
    409        );
    410    }
    411  }
    412 
    413  supportsWindows() {
    414    return !lazy.AppInfo.isAndroid;
    415  }
    416 
    417  /**
    418   * Minimize the specified window.
    419   *
    420   * @param {window} win
    421   *     The window to minimize.
    422   *
    423   * @returns {Promise}
    424   *     A promise resolved when the window is minimized, or times out if no window manager is present.
    425   */
    426  async minimizeWindow(win) {
    427    if (WindowState.from(win.windowState) != WindowState.Minimized) {
    428      await waitForWindowState(win, () => win.minimize());
    429    }
    430  }
    431 
    432  /**
    433   * Maximize the specified window.
    434   *
    435   * @param {window} win
    436   *     The window to maximize.
    437   *
    438   * @returns {Promise}
    439   *     A promise resolved when the window is maximized, or times out if no window manager is present.
    440   */
    441  async maximizeWindow(win) {
    442    if (WindowState.from(win.windowState) != WindowState.Maximized) {
    443      await waitForWindowState(win, () => win.maximize());
    444    }
    445  }
    446 
    447  /**
    448   * Restores the specified window to its normal state.
    449   *
    450   * @param {window} win
    451   *     The window to restore.
    452   *
    453   * @returns {Promise}
    454   *     A promise resolved when the window is restored, or times out if no window manager is present.
    455   */
    456  async restoreWindow(win) {
    457    if (WindowState.from(win.windowState) !== WindowState.Normal) {
    458      await waitForWindowState(win, () => win.restore());
    459    }
    460  }
    461 
    462  /**
    463   * Sets the fullscreen state of the specified window.
    464   *
    465   * @param {window} win
    466   *     The target window.
    467   * @param {boolean} enable
    468   *     Whether to enter fullscreen (true) or exit fullscreen (false).
    469   *
    470   * @returns {Promise}
    471   *     A promise resolved when the window enters or exits fullscreen mode.
    472   */
    473  async setFullscreen(win, enable) {
    474    const isFullscreen =
    475      WindowState.from(win.windowState) === WindowState.Fullscreen;
    476    if (enable !== isFullscreen) {
    477      await waitForWindowState(win, () => (win.fullScreen = enable));
    478    }
    479  }
    480 
    481  /**
    482   * Wait until the browser window is initialized and loaded.
    483   *
    484   * @param {ChromeWindow} window
    485   *     The chrome window to check for completed loading.
    486   *
    487   * @returns {Promise}
    488   *     A promise that resolves when the chrome window finished loading.
    489   */
    490  async waitForChromeWindowLoaded(window) {
    491    const loaded =
    492      window.document.readyState === "complete" &&
    493      !window.document.isUncommittedInitialDocument;
    494 
    495    if (!loaded) {
    496      lazy.logger.trace(
    497        `Chrome window not loaded yet. Waiting for "load" event`
    498      );
    499      await new lazy.EventPromise(window, "load");
    500    }
    501 
    502    // Only Firefox stores the delayed startup finished status, allowing
    503    // it to be checked at any time. On Android, this is unnecessary
    504    // because there is only a single window, and we already wait for
    505    // that window during startup.
    506    if (
    507      lazy.AppInfo.isFirefox &&
    508      window.document.documentURI === AppConstants.BROWSER_CHROME_URL &&
    509      !(window.gBrowserInit && window.gBrowserInit.delayedStartupFinished)
    510    ) {
    511      lazy.logger.trace(
    512        `Browser window not initialized yet. Waiting for startup finished`
    513      );
    514 
    515      // If it's a browser window wait for it to be fully initialized.
    516      await lazy.waitForObserverTopic("browser-delayed-startup-finished", {
    517        checkFn: subject => subject === window,
    518      });
    519    }
    520  }
    521 
    522  #setChromeWindowForBrowsingContext(context) {
    523    const chromeWindow = context.top.embedderElement?.ownerGlobal;
    524    if (chromeWindow) {
    525      return this.#contextToWindowMap.getOrInsert(context.top, chromeWindow);
    526    }
    527 
    528    return null;
    529  }
    530 
    531  /* Event handlers */
    532 
    533  #onContextAttached = (_, data = {}) => {
    534    const { browsingContext } = data;
    535 
    536    if (!browsingContext.isContent) {
    537      return;
    538    }
    539 
    540    this.#setChromeWindowForBrowsingContext(browsingContext);
    541  };
    542 
    543  #onChromeWindowClosed = (_, data = {}) => {
    544    const { window } = data;
    545 
    546    this.#clientWindowIds.deleteByObject(window);
    547  };
    548 
    549  #onChromeWindowOpened = (_, data = {}) => {
    550    const { window } = data;
    551 
    552    this.getIdForWindow(window);
    553  };
    554 }
    555 
    556 // Expose a shared singleton.
    557 export const windowManager = new WindowManager();
    558 
    559 /**
    560 * Representation of the {@link ChromeWindow} window state.
    561 *
    562 * @enum {string}
    563 */
    564 export const WindowState = {
    565  Maximized: "maximized",
    566  Minimized: "minimized",
    567  Normal: "normal",
    568  Fullscreen: "fullscreen",
    569 
    570  /**
    571   * Converts {@link Window.windowState} to WindowState.
    572   *
    573   * @param {number} windowState
    574   *     Attribute from {@link Window.windowState}.
    575   *
    576   * @returns {WindowState}
    577   *     JSON representation.
    578   *
    579   * @throws {TypeError}
    580   *     If <var>windowState</var> was unknown.
    581   */
    582  from(windowState) {
    583    switch (windowState) {
    584      case 1:
    585        return WindowState.Maximized;
    586 
    587      case 2:
    588        return WindowState.Minimized;
    589 
    590      case 3:
    591        return WindowState.Normal;
    592 
    593      case 4:
    594        return WindowState.Fullscreen;
    595 
    596      default:
    597        throw new TypeError(`Unknown window state: ${windowState}`);
    598    }
    599  },
    600 };
    601 
    602 /**
    603 * Waits for the window to reach a specific state after invoking a callback.
    604 *
    605 * @param {window} win
    606 *     The target window.
    607 * @param {Function} callback
    608 *     The function to invoke to change the window state.
    609 *
    610 * @returns {Promise}
    611 *     A promise resolved when the window reaches the target state, or times out if no window manager is present.
    612 */
    613 async function waitForWindowState(win, callback) {
    614  let cb;
    615  // Use a timed promise to abort if no window manager is present
    616  await new lazy.TimedPromise(
    617    resolve => {
    618      cb = new lazy.DebounceCallback(resolve);
    619      win.addEventListener("sizemodechange", cb);
    620      callback();
    621    },
    622    { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
    623  );
    624  win.removeEventListener("sizemodechange", cb);
    625  await new lazy.AnimationFramePromise(win);
    626 }