tor-browser

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

OpenTabs.sys.mjs (13566B)


      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 /**
      6 * This module provides the means to monitor and query for tab collections against open
      7 * browser windows and allow listeners to be notified of changes to those collections.
      8 */
      9 
     10 const lazy = {};
     11 
     12 ChromeUtils.defineESModuleGetters(lazy, {
     13  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
     14  EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
     15  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     16 });
     17 
     18 const TAB_ATTRS_TO_WATCH = Object.freeze([
     19  "attention",
     20  "image",
     21  "label",
     22  "muted",
     23  "soundplaying",
     24  "titlechanged",
     25 ]);
     26 const TAB_CHANGE_EVENTS = Object.freeze([
     27  "TabAttrModified",
     28  "TabClose",
     29  "TabMove",
     30  "TabOpen",
     31  "TabPinned",
     32  "TabUnpinned",
     33  "SplitViewCreated",
     34  "SplitViewRemoved",
     35 ]);
     36 const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([
     37  "activate",
     38  "sizemodechange",
     39  "TabAttrModified",
     40  "TabClose",
     41  "TabOpen",
     42  "TabPinned",
     43  "TabUnpinned",
     44  "TabSelect",
     45  "TabAttrModified",
     46 ]);
     47 
     48 // Debounce tab/tab recency changes and dispatch max once per frame at 60fps
     49 const CHANGES_DEBOUNCE_MS = 1000 / 60;
     50 
     51 /**
     52 * A sort function used to order tabs by most-recently seen and active.
     53 */
     54 export function lastSeenActiveSort(a, b) {
     55  let dt = b.lastSeenActive - a.lastSeenActive;
     56  if (dt) {
     57    return dt;
     58  }
     59  // try to break a deadlock by sorting the selected tab higher
     60  if (!(a.selected || b.selected)) {
     61    return 0;
     62  }
     63  return a.selected ? -1 : 1;
     64 }
     65 
     66 /**
     67 * Provides a object capable of monitoring and accessing tab collections for either
     68 * private or non-private browser windows. As the class extends EventTarget, consumers
     69 * should add event listeners for the change events.
     70 *
     71 * @param {boolean} options.usePrivateWindows
     72              Constrain to only windows that match this privateness. Defaults to false.
     73 * @param {Window | null} options.exclusiveWindow
     74 *            Constrain to only a specific window.
     75 */
     76 class OpenTabsTarget extends EventTarget {
     77  #changedWindowsByType = {
     78    TabChange: new Set(),
     79    TabRecencyChange: new Set(),
     80  };
     81  #sourceEventsByType = {
     82    TabChange: new Set(),
     83    TabRecencyChange: new Set(),
     84  };
     85  #dispatchChangesTask;
     86  #started = false;
     87  #watchedWindows = new Set();
     88 
     89  #exclusiveWindowWeakRef = null;
     90  usePrivateWindows = false;
     91 
     92  constructor(options = {}) {
     93    super();
     94    this.usePrivateWindows = !!options.usePrivateWindows;
     95 
     96    if (options.exclusiveWindow) {
     97      this.exclusiveWindow = options.exclusiveWindow;
     98      this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`;
     99    } else {
    100      this.everyWindowCallbackId = `opentabs-${
    101        this.usePrivateWindows ? "private" : "non-private"
    102      }`;
    103    }
    104  }
    105 
    106  get exclusiveWindow() {
    107    return this.#exclusiveWindowWeakRef?.get();
    108  }
    109  set exclusiveWindow(newValue) {
    110    if (newValue) {
    111      this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue);
    112    } else {
    113      this.#exclusiveWindowWeakRef = null;
    114    }
    115  }
    116 
    117  includeWindowFilter(win) {
    118    if (this.#exclusiveWindowWeakRef) {
    119      return win == this.exclusiveWindow;
    120    }
    121    return (
    122      win.gBrowser &&
    123      !win.closed &&
    124      this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
    125    );
    126  }
    127 
    128  get currentWindows() {
    129    return lazy.EveryWindow.readyWindows.filter(win =>
    130      this.includeWindowFilter(win)
    131    );
    132  }
    133 
    134  /**
    135   * A promise that resolves to all matched windows once their delayedStartupPromise resolves
    136   */
    137  get readyWindowsPromise() {
    138    let windowList = Array.from(
    139      Services.wm.getEnumerator("navigator:browser")
    140    ).filter(win => {
    141      // avoid waiting for windows we definitely don't care about
    142      if (this.#exclusiveWindowWeakRef) {
    143        return this.exclusiveWindow == win;
    144      }
    145      return (
    146        this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
    147      );
    148    });
    149    return Promise.allSettled(
    150      windowList.map(win => win.delayedStartupPromise)
    151    ).then(() => {
    152      // re-filter the list as properties might have changed in the interim
    153      return windowList.filter(() => this.includeWindowFilter);
    154    });
    155  }
    156 
    157  haveListenersForEvent(eventType) {
    158    switch (eventType) {
    159      case "TabChange":
    160        return Services.els.hasListenersFor(this, "TabChange");
    161      case "TabRecencyChange":
    162        return Services.els.hasListenersFor(this, "TabRecencyChange");
    163      default:
    164        return false;
    165    }
    166  }
    167 
    168  get haveAnyListeners() {
    169    return (
    170      this.haveListenersForEvent("TabChange") ||
    171      this.haveListenersForEvent("TabRecencyChange")
    172    );
    173  }
    174 
    175  /**
    176   * @param {string} type
    177   *        Either "TabChange" or "TabRecencyChange"
    178   * @param {object | Function} listener
    179   * @param {object} [options]
    180   */
    181  addEventListener(type, listener, options) {
    182    let hadListeners = this.haveAnyListeners;
    183    super.addEventListener(type, listener, options);
    184 
    185    // if this is the first listener, start up all the window & tab monitoring
    186    if (!hadListeners && this.haveAnyListeners) {
    187      this.start();
    188    }
    189  }
    190 
    191  /**
    192   * @param {string} type
    193   *        Either "TabChange" or "TabRecencyChange"
    194   * @param {object | Function} listener
    195   */
    196  removeEventListener(type, listener) {
    197    let hadListeners = this.haveAnyListeners;
    198    super.removeEventListener(type, listener);
    199 
    200    // if this was the last listener, we can stop all the window & tab monitoring
    201    if (hadListeners && !this.haveAnyListeners) {
    202      this.stop();
    203    }
    204  }
    205 
    206  /**
    207   * Begin watching for tab-related events from all browser windows matching the instance's private property
    208   */
    209  start() {
    210    if (this.#started) {
    211      return;
    212    }
    213    // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves.
    214    lazy.EveryWindow.registerCallback(
    215      this.everyWindowCallbackId,
    216      win => this.#watchWindow(win),
    217      win => this.#unwatchWindow(win)
    218    );
    219    this.#started = true;
    220  }
    221 
    222  /**
    223   * Stop watching for tab-related events from all browser windows and clean up.
    224   */
    225  stop() {
    226    if (this.#started) {
    227      lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId);
    228      this.#started = false;
    229    }
    230    for (let changedWindows of Object.values(this.#changedWindowsByType)) {
    231      changedWindows.clear();
    232    }
    233    for (let sourceEvents of Object.values(this.#sourceEventsByType)) {
    234      sourceEvents.clear();
    235    }
    236    this.#watchedWindows.clear();
    237    this.#dispatchChangesTask?.disarm();
    238  }
    239 
    240  /**
    241   * Add listeners for tab-related events from the given window. The consumer's
    242   * listeners will always be notified at least once for newly-watched window.
    243   */
    244  #watchWindow(win) {
    245    if (!this.includeWindowFilter(win)) {
    246      return;
    247    }
    248    this.#watchedWindows.add(win);
    249    const { tabContainer } = win.gBrowser;
    250    tabContainer.addEventListener("TabAttrModified", this);
    251    tabContainer.addEventListener("TabClose", this);
    252    tabContainer.addEventListener("TabMove", this);
    253    tabContainer.addEventListener("TabOpen", this);
    254    tabContainer.addEventListener("TabPinned", this);
    255    tabContainer.addEventListener("TabUnpinned", this);
    256    tabContainer.addEventListener("TabSelect", this);
    257    tabContainer.addEventListener("SplitViewCreated", this);
    258    tabContainer.addEventListener("SplitViewRemoved", this);
    259    win.addEventListener("activate", this);
    260    win.addEventListener("sizemodechange", this);
    261 
    262    this.#scheduleEventDispatch("TabChange", {
    263      sourceWindowId: win.windowGlobalChild.innerWindowId,
    264      sourceEvent: "watchWindow",
    265    });
    266    this.#scheduleEventDispatch("TabRecencyChange", {
    267      sourceWindowId: win.windowGlobalChild.innerWindowId,
    268      sourceEvent: "watchWindow",
    269    });
    270  }
    271 
    272  /**
    273   * Remove all listeners for tab-related events from the given window.
    274   * Consumers will always be notified at least once for unwatched window.
    275   */
    276  #unwatchWindow(win) {
    277    // We check the window is in our watchedWindows collection rather than currentWindows
    278    // as the unwatched window may not match the criteria we used to watch it anymore,
    279    // and we need to unhook our event listeners regardless.
    280    if (this.#watchedWindows.has(win)) {
    281      this.#watchedWindows.delete(win);
    282 
    283      const { tabContainer } = win.gBrowser;
    284      tabContainer.removeEventListener("TabAttrModified", this);
    285      tabContainer.removeEventListener("TabClose", this);
    286      tabContainer.removeEventListener("TabMove", this);
    287      tabContainer.removeEventListener("TabOpen", this);
    288      tabContainer.removeEventListener("TabPinned", this);
    289      tabContainer.removeEventListener("TabSelect", this);
    290      tabContainer.removeEventListener("TabUnpinned", this);
    291      tabContainer.removeEventListener("SplitViewCreated", this);
    292      tabContainer.removeEventListener("SplitViewRemoved", this);
    293      win.removeEventListener("activate", this);
    294      win.removeEventListener("sizemodechange", this);
    295 
    296      this.#scheduleEventDispatch("TabChange", {
    297        sourceWindowId: win.windowGlobalChild.innerWindowId,
    298        sourceEvent: "unwatchWindow",
    299      });
    300      this.#scheduleEventDispatch("TabRecencyChange", {
    301        sourceWindowId: win.windowGlobalChild.innerWindowId,
    302        sourceEvent: "unwatchWindow",
    303      });
    304    }
    305  }
    306 
    307  /**
    308   * Flag the need to notify all our consumers of a change to open tabs.
    309   * Repeated calls within approx 16ms will be consolidated
    310   * into one event dispatch.
    311   */
    312  #scheduleEventDispatch(eventType, { sourceWindowId, sourceEvent } = {}) {
    313    if (!this.haveListenersForEvent(eventType)) {
    314      return;
    315    }
    316 
    317    this.#sourceEventsByType[eventType].add(sourceEvent);
    318    this.#changedWindowsByType[eventType].add(sourceWindowId);
    319    // Queue up an event dispatch - we use a deferred task to make this less noisy by
    320    // consolidating multiple change events into one.
    321    if (!this.#dispatchChangesTask) {
    322      this.#dispatchChangesTask = new lazy.DeferredTask(() => {
    323        this.#dispatchChanges();
    324      }, CHANGES_DEBOUNCE_MS);
    325    }
    326    this.#dispatchChangesTask.arm();
    327  }
    328 
    329  #dispatchChanges() {
    330    this.#dispatchChangesTask?.disarm();
    331    for (let [eventType, changedWindowIds] of Object.entries(
    332      this.#changedWindowsByType
    333    )) {
    334      let sourceEvents = this.#sourceEventsByType[eventType];
    335      if (this.haveListenersForEvent(eventType) && changedWindowIds.size) {
    336        let changeEvent = new CustomEvent(eventType, {
    337          detail: {
    338            windowIds: [...changedWindowIds],
    339            sourceEvents: [...sourceEvents],
    340          },
    341        });
    342        this.dispatchEvent(changeEvent);
    343        changedWindowIds.clear();
    344      }
    345      sourceEvents?.clear();
    346    }
    347  }
    348 
    349  /**
    350   * @param {Window} win
    351   * @param {boolean} sortByRecency
    352   * @returns {Array<Tab>}
    353   *    The list of visible tabs for the browser window
    354   */
    355  getTabsForWindow(win, sortByRecency = false) {
    356    if (this.currentWindows.includes(win)) {
    357      const tabs = win.gBrowser.openTabs.filter(tab => !tab.hidden);
    358      return sortByRecency ? tabs.toSorted(lastSeenActiveSort) : tabs;
    359    }
    360    return [];
    361  }
    362 
    363  /**
    364   * Get an aggregated list of tabs from all the same-privateness browser windows.
    365   *
    366   * @returns {MozTabbrowserTab[]}
    367   */
    368  getAllTabs() {
    369    return this.currentWindows.flatMap(win => this.getTabsForWindow(win));
    370  }
    371 
    372  /**
    373   * @returns {Array<Tab>}
    374   *    A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows.
    375   */
    376  getRecentTabs() {
    377    return this.getAllTabs().sort(lastSeenActiveSort);
    378  }
    379 
    380  handleEvent({ detail, target, type }) {
    381    const win = target.ownerGlobal;
    382    // NOTE: we already filtered on privateness by not listening for those events
    383    // from private/not-private windows
    384    if (
    385      type == "TabAttrModified" &&
    386      !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr))
    387    ) {
    388      return;
    389    }
    390 
    391    if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) {
    392      this.#scheduleEventDispatch("TabRecencyChange", {
    393        sourceWindowId: win.windowGlobalChild.innerWindowId,
    394        sourceEvent: type,
    395      });
    396    }
    397    if (TAB_CHANGE_EVENTS.includes(type)) {
    398      this.#scheduleEventDispatch("TabChange", {
    399        sourceWindowId: win.windowGlobalChild.innerWindowId,
    400        sourceEvent: type,
    401      });
    402    }
    403  }
    404 }
    405 
    406 const gExclusiveWindows = new (class {
    407  perWindowInstances = new WeakMap();
    408  constructor() {
    409    Services.obs.addObserver(this, "domwindowclosed");
    410  }
    411  observe(subject) {
    412    let win = subject;
    413    let winTarget = this.perWindowInstances.get(win);
    414    if (winTarget) {
    415      winTarget.stop();
    416      this.perWindowInstances.delete(win);
    417    }
    418  }
    419 })();
    420 
    421 /**
    422 * Get an OpenTabsTarget instance constrained to a specific window.
    423 *
    424 * @param {Window} exclusiveWindow
    425 * @returns {OpenTabsTarget}
    426 */
    427 const getTabsTargetForWindow = function (exclusiveWindow) {
    428  let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow);
    429  if (instance) {
    430    return instance;
    431  }
    432  instance = new OpenTabsTarget({
    433    exclusiveWindow,
    434  });
    435  gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance);
    436  return instance;
    437 };
    438 
    439 const NonPrivateTabs = new OpenTabsTarget({
    440  usePrivateWindows: false,
    441 });
    442 
    443 const PrivateTabs = new OpenTabsTarget({
    444  usePrivateWindows: true,
    445 });
    446 
    447 export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow };