tor-browser

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

ext-tabs.js (16866B)


      1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
      2 /* vim: set sts=2 sw=2 et tw=80: */
      3 /* This Source Code Form is subject to the terms of the Mozilla Public
      4 * License, v. 2.0. If a copy of the MPL was not distributed with this
      5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      6 
      7 "use strict";
      8 
      9 ChromeUtils.defineESModuleGetters(this, {
     10  GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs",
     11  mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs",
     12 });
     13 
     14 const getBrowserWindow = window => {
     15  return window.browsingContext.topChromeWindow;
     16 };
     17 
     18 const tabListener = {
     19  tabReadyInitialized: false,
     20  tabReadyPromises: new WeakMap(),
     21  initializingTabs: new WeakSet(),
     22 
     23  initTabReady() {
     24    if (!this.tabReadyInitialized) {
     25      windowTracker.addListener("progress", this);
     26 
     27      this.tabReadyInitialized = true;
     28    }
     29  },
     30 
     31  onLocationChange(browser, webProgress, request) {
     32    if (webProgress.isTopLevel) {
     33      const { tab } = browser.ownerGlobal;
     34 
     35      // Ignore initial about:blank
     36      if (!request && this.initializingTabs.has(tab)) {
     37        return;
     38      }
     39 
     40      // Now we are certain that the first page in the tab was loaded.
     41      this.initializingTabs.delete(tab);
     42 
     43      // browser.innerWindowID is now set, resolve the promises if any.
     44      const deferred = this.tabReadyPromises.get(tab);
     45      if (deferred) {
     46        deferred.resolve(tab);
     47        this.tabReadyPromises.delete(tab);
     48      }
     49    }
     50  },
     51 
     52  /**
     53   * Returns a promise that resolves when the tab is ready.
     54   * Tabs created via the `tabs.create` method are "ready" once the location
     55   * changes to the requested URL. Other tabs are assumed to be ready once their
     56   * inner window ID is known.
     57   *
     58   * @param {NativeTab} nativeTab The native tab object.
     59   * @returns {Promise} Resolves with the given tab once ready.
     60   */
     61  awaitTabReady(nativeTab) {
     62    let deferred = this.tabReadyPromises.get(nativeTab);
     63    if (!deferred) {
     64      deferred = Promise.withResolvers();
     65      if (
     66        !this.initializingTabs.has(nativeTab) &&
     67        (nativeTab.browser.innerWindowID ||
     68          nativeTab.browser.currentURI.spec === "about:blank")
     69      ) {
     70        deferred.resolve(nativeTab);
     71      } else {
     72        this.initTabReady();
     73        this.tabReadyPromises.set(nativeTab, deferred);
     74      }
     75    }
     76    return deferred.promise;
     77  },
     78 };
     79 
     80 this.tabs = class extends ExtensionAPIPersistent {
     81  tabEventRegistrar({ event, listener }) {
     82    const { extension } = this;
     83    const { tabManager } = extension;
     84    return ({ fire }) => {
     85      const listener2 = (eventName, eventData, ...args) => {
     86        if (!tabManager.canAccessTab(eventData.nativeTab)) {
     87          return;
     88        }
     89 
     90        listener(fire, eventData, ...args);
     91      };
     92 
     93      tabTracker.on(event, listener2);
     94      return {
     95        unregister() {
     96          tabTracker.off(event, listener2);
     97        },
     98        convert(_fire) {
     99          fire = _fire;
    100        },
    101      };
    102    };
    103  }
    104 
    105  PERSISTENT_EVENTS = {
    106    onActivated({ fire, context }) {
    107      const listener = (eventName, event) => {
    108        const { windowId, tabId, isPrivate } = event;
    109        if (isPrivate && !context.privateBrowsingAllowed) {
    110          return;
    111        }
    112        // In GeckoView each window has only one tab, so previousTabId is omitted.
    113        fire.async({ windowId, tabId });
    114      };
    115 
    116      mobileWindowTracker.on("tab-activated", listener);
    117      return {
    118        unregister() {
    119          mobileWindowTracker.off("tab-activated", listener);
    120        },
    121        convert(_fire, _context) {
    122          fire = _fire;
    123          context = _context;
    124        },
    125      };
    126    },
    127    onCreated: this.tabEventRegistrar({
    128      event: "tab-created",
    129      listener: (fire, event) => {
    130        const { tabManager } = this.extension;
    131        fire.async(tabManager.convert(event.nativeTab));
    132      },
    133    }),
    134    onRemoved: this.tabEventRegistrar({
    135      event: "tab-removed",
    136      listener: (fire, event) => {
    137        fire.async(event.tabId, {
    138          windowId: event.windowId,
    139          isWindowClosing: event.isWindowClosing,
    140        });
    141      },
    142    }),
    143    onUpdated({ fire }) {
    144      const { tabManager } = this.extension;
    145      const restricted = ["url", "favIconUrl", "title"];
    146 
    147      function sanitize(tab, changeInfo) {
    148        const result = {};
    149        let nonempty = false;
    150        for (const prop in changeInfo) {
    151          // In practice, changeInfo contains at most one property from
    152          // restricted. Therefore it is not necessary to cache the value
    153          // of tab.hasTabPermission outside the loop.
    154          if (!restricted.includes(prop) || tab.hasTabPermission) {
    155            nonempty = true;
    156            result[prop] = changeInfo[prop];
    157          }
    158        }
    159        return [nonempty, result];
    160      }
    161 
    162      const fireForTab = (tab, changed) => {
    163        const [needed, changeInfo] = sanitize(tab, changed);
    164        if (needed) {
    165          fire.async(tab.id, changeInfo, tab.convert());
    166        }
    167      };
    168 
    169      const listener = event => {
    170        const needed = [];
    171        let nativeTab;
    172        switch (event.type) {
    173          case "pagetitlechanged": {
    174            const window = getBrowserWindow(event.target.ownerGlobal);
    175            nativeTab = window.tab;
    176 
    177            needed.push("title");
    178            break;
    179          }
    180 
    181          case "DOMAudioPlaybackStarted":
    182          case "DOMAudioPlaybackStopped": {
    183            const window = event.target.ownerGlobal;
    184            nativeTab = window.tab;
    185            needed.push("audible");
    186            break;
    187          }
    188        }
    189 
    190        if (!nativeTab) {
    191          return;
    192        }
    193 
    194        const tab = tabManager.getWrapper(nativeTab);
    195        const changeInfo = {};
    196        for (const prop of needed) {
    197          changeInfo[prop] = tab[prop];
    198        }
    199 
    200        fireForTab(tab, changeInfo);
    201      };
    202 
    203      const statusListener = ({ browser, status, url }) => {
    204        const { tab } = browser.ownerGlobal;
    205        if (tab) {
    206          const changed = { status };
    207          if (url) {
    208            changed.url = url;
    209          }
    210 
    211          fireForTab(tabManager.wrapTab(tab), changed);
    212        }
    213      };
    214 
    215      windowTracker.addListener("status", statusListener);
    216      windowTracker.addListener("pagetitlechanged", listener);
    217 
    218      return {
    219        unregister() {
    220          windowTracker.removeListener("status", statusListener);
    221          windowTracker.removeListener("pagetitlechanged", listener);
    222        },
    223        convert(_fire) {
    224          fire = _fire;
    225        },
    226      };
    227    },
    228  };
    229 
    230  getAPI(context) {
    231    const { extension } = context;
    232    const { tabManager } = extension;
    233    const extensionApi = this;
    234    const module = "tabs";
    235 
    236    function getTabOrActive(tabId) {
    237      if (tabId !== null) {
    238        return tabTracker.getTab(tabId);
    239      }
    240      return tabTracker.activeTab;
    241    }
    242 
    243    async function promiseTabWhenReady(tabId) {
    244      let tab;
    245      if (tabId !== null) {
    246        tab = tabManager.get(tabId);
    247      } else {
    248        tab = tabManager.getWrapper(tabTracker.activeTab);
    249      }
    250      if (!tab) {
    251        throw new ExtensionError(
    252          tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}`
    253        );
    254      }
    255 
    256      await tabListener.awaitTabReady(tab.nativeTab);
    257 
    258      return tab;
    259    }
    260 
    261    function loadURIInTab(nativeTab, url) {
    262      const { browser } = nativeTab;
    263 
    264      let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
    265      let { principal } = context;
    266      const isAboutUrl = url.startsWith("about:");
    267      if (
    268        isAboutUrl ||
    269        (ExtensionUtils.isExtensionUrl(url) &&
    270          !context.checkLoadURL(url, { dontReportErrors: true }))
    271      ) {
    272        // Falling back to content here as about: requires it, however is safe.
    273        principal =
    274          Services.scriptSecurityManager.getLoadContextContentPrincipal(
    275            Services.io.newURI(url),
    276            browser.loadContext
    277          );
    278      }
    279      if (isAboutUrl) {
    280        // Make sure things like about:blank and other about: URIs never
    281        // inherit, and instead always get a NullPrincipal.
    282        loadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
    283      }
    284 
    285      browser.fixupAndLoadURIString(url, {
    286        loadFlags,
    287        triggeringPrincipal: principal,
    288      });
    289    }
    290 
    291    return {
    292      tabs: {
    293        onActivated: new EventManager({
    294          context,
    295          module,
    296          event: "onActivated",
    297          extensionApi,
    298        }).api(),
    299 
    300        onCreated: new EventManager({
    301          context,
    302          module,
    303          event: "onCreated",
    304          extensionApi,
    305        }).api(),
    306 
    307        /**
    308         * Since multiple tabs currently can't be highlighted, onHighlighted
    309         * essentially acts an alias for tabs.onActivated but returns
    310         * the tabId in an array to match the API.
    311         *
    312         * @see  https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted
    313         */
    314        onHighlighted: makeGlobalEvent(
    315          context,
    316          "tabs.onHighlighted",
    317          "Tab:Selected",
    318          (fire, data) => {
    319            const tab = tabManager.get(data.id);
    320 
    321            fire.async({ tabIds: [tab.id], windowId: tab.windowId });
    322          }
    323        ),
    324 
    325        // Some events below are not be persisted because they are not implemented.
    326        // They do not have an "extensionApi" property with an entry in
    327        // PERSISTENT_EVENTS, but instead an empty "register" method.
    328        onAttached: new EventManager({
    329          context,
    330          name: "tabs.onAttached",
    331          register: () => {
    332            return () => {};
    333          },
    334        }).api(),
    335 
    336        onDetached: new EventManager({
    337          context,
    338          name: "tabs.onDetached",
    339          register: () => {
    340            return () => {};
    341          },
    342        }).api(),
    343 
    344        onRemoved: new EventManager({
    345          context,
    346          module,
    347          event: "onRemoved",
    348          extensionApi,
    349        }).api(),
    350 
    351        onReplaced: new EventManager({
    352          context,
    353          name: "tabs.onReplaced",
    354          register: () => {
    355            return () => {};
    356          },
    357        }).api(),
    358 
    359        onMoved: new EventManager({
    360          context,
    361          name: "tabs.onMoved",
    362          register: () => {
    363            return () => {};
    364          },
    365        }).api(),
    366 
    367        onUpdated: new EventManager({
    368          context,
    369          module,
    370          event: "onUpdated",
    371          extensionApi,
    372        }).api(),
    373 
    374        async create({
    375          active,
    376          cookieStoreId,
    377          discarded,
    378          index,
    379          openInReaderMode,
    380          pinned,
    381          url,
    382        } = {}) {
    383          if (active === null) {
    384            active = true;
    385          }
    386 
    387          tabListener.initTabReady();
    388 
    389          if (url !== null) {
    390            url = context.uri.resolve(url);
    391 
    392            if (
    393              !ExtensionUtils.isExtensionUrl(url) &&
    394              !context.checkLoadURL(url, { dontReportErrors: true })
    395            ) {
    396              return Promise.reject({ message: `Illegal URL: ${url}` });
    397            }
    398          }
    399 
    400          if (cookieStoreId) {
    401            cookieStoreId = getUserContextIdForCookieStoreId(
    402              extension,
    403              cookieStoreId,
    404              false // TODO bug 1372178: support creation of private browsing tabs
    405            );
    406          }
    407          cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined;
    408 
    409          const nativeTab = await GeckoViewTabBridge.createNewTab({
    410            extensionId: context.extension.id,
    411            createProperties: {
    412              active,
    413              cookieStoreId,
    414              discarded,
    415              index,
    416              openInReaderMode,
    417              pinned,
    418              url,
    419            },
    420          });
    421 
    422          // The initial about:blank loads synchronously, so no listener is needed
    423          if (url !== null && !url.startsWith("about:blank")) {
    424            tabListener.initializingTabs.add(nativeTab);
    425          } else {
    426            url = "about:blank";
    427          }
    428 
    429          loadURIInTab(nativeTab, url);
    430 
    431          if (active) {
    432            const newWindow = nativeTab.browser.ownerGlobal;
    433            mobileWindowTracker.setTabActive(newWindow, true);
    434          }
    435 
    436          return tabManager.convert(nativeTab);
    437        },
    438 
    439        async remove(tabs) {
    440          if (!Array.isArray(tabs)) {
    441            tabs = [tabs];
    442          }
    443 
    444          await Promise.all(
    445            tabs.map(async tabId => {
    446              const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId);
    447              const window = windowTracker.getWindow(windowId, context, false);
    448              if (!window) {
    449                throw new ExtensionError(`Invalid tab ID ${tabId}`);
    450              }
    451              await GeckoViewTabBridge.closeTab({
    452                window,
    453                extensionId: context.extension.id,
    454              });
    455            })
    456          );
    457        },
    458 
    459        async update(
    460          tabId,
    461          { active, autoDiscardable, highlighted, muted, pinned, url } = {}
    462        ) {
    463          const nativeTab = getTabOrActive(tabId);
    464          const window = nativeTab.browser.ownerGlobal;
    465 
    466          if (url !== null) {
    467            url = context.uri.resolve(url);
    468 
    469            if (
    470              !ExtensionUtils.isExtensionUrl(url) &&
    471              !context.checkLoadURL(url, { dontReportErrors: true })
    472            ) {
    473              return Promise.reject({ message: `Illegal URL: ${url}` });
    474            }
    475          }
    476 
    477          await GeckoViewTabBridge.updateTab({
    478            window,
    479            extensionId: context.extension.id,
    480            updateProperties: {
    481              active,
    482              autoDiscardable,
    483              highlighted,
    484              muted,
    485              pinned,
    486              url,
    487            },
    488          });
    489 
    490          if (url !== null) {
    491            loadURIInTab(nativeTab, url);
    492          }
    493 
    494          // FIXME: openerTabId, successorTabId
    495          if (active) {
    496            mobileWindowTracker.setTabActive(window, true);
    497          }
    498 
    499          return tabManager.convert(nativeTab);
    500        },
    501 
    502        async reload(tabId, reloadProperties) {
    503          const nativeTab = getTabOrActive(tabId);
    504 
    505          let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
    506          if (reloadProperties && reloadProperties.bypassCache) {
    507            flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
    508          }
    509          nativeTab.browser.reloadWithFlags(flags);
    510        },
    511 
    512        async get(tabId) {
    513          return tabManager.get(tabId).convert();
    514        },
    515 
    516        async getCurrent() {
    517          if (context.tabId) {
    518            return tabManager.get(context.tabId).convert();
    519          }
    520        },
    521 
    522        async query(queryInfo) {
    523          return Array.from(tabManager.query(queryInfo, context), tab =>
    524            tab.convert()
    525          );
    526        },
    527 
    528        async captureTab(tabId, options) {
    529          const nativeTab = getTabOrActive(tabId);
    530          await tabListener.awaitTabReady(nativeTab);
    531 
    532          const { browser } = nativeTab;
    533          const tab = tabManager.wrapTab(nativeTab);
    534          return tab.capture(context, browser.fullZoom, options);
    535        },
    536 
    537        async captureVisibleTab(windowId, options) {
    538          const window =
    539            windowId == null
    540              ? windowTracker.topWindow
    541              : windowTracker.getWindow(windowId, context);
    542 
    543          const tab = tabManager.getWrapper(window.tab);
    544          if (
    545            !extension.hasPermission("<all_urls>") &&
    546            !tab.hasActiveTabPermission
    547          ) {
    548            throw new ExtensionError("Missing activeTab permission");
    549          }
    550          await tabListener.awaitTabReady(tab.nativeTab);
    551          const zoom = window.browsingContext.fullZoom;
    552 
    553          return tab.capture(context, zoom, options);
    554        },
    555 
    556        async detectLanguage(tabId) {
    557          const tab = await promiseTabWhenReady(tabId);
    558          const results = await tab.queryContent("DetectLanguage", {});
    559          return results[0];
    560        },
    561 
    562        async executeScript(tabId, details) {
    563          const tab = await promiseTabWhenReady(tabId);
    564 
    565          return tab.executeScript(context, details);
    566        },
    567 
    568        async insertCSS(tabId, details) {
    569          const tab = await promiseTabWhenReady(tabId);
    570 
    571          return tab.insertCSS(context, details);
    572        },
    573 
    574        async removeCSS(tabId, details) {
    575          const tab = await promiseTabWhenReady(tabId);
    576 
    577          return tab.removeCSS(context, details);
    578        },
    579 
    580        goForward(tabId) {
    581          const { browser } = getTabOrActive(tabId);
    582          browser.goForward(false);
    583        },
    584 
    585        goBack(tabId) {
    586          const { browser } = getTabOrActive(tabId);
    587          browser.goBack(false);
    588        },
    589      },
    590    };
    591  }
    592 };