tor-browser

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

tabs.sys.mjs (21467B)


      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 const STORAGE_VERSION = 1; // This needs to be kept in-sync with the rust storage version
      6 
      7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      8 import { SyncEngine, Tracker } from "resource://services-sync/engines.sys.mjs";
      9 import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
     10 import { Log } from "resource://gre/modules/Log.sys.mjs";
     11 import {
     12  SCORE_INCREMENT_SMALL,
     13  STATUS_OK,
     14  URI_LENGTH_MAX,
     15 } from "resource://services-sync/constants.sys.mjs";
     16 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
     17 import { Async } from "resource://services-common/async.sys.mjs";
     18 import {
     19  SyncRecord,
     20  SyncTelemetry,
     21 } from "resource://services-sync/telemetry.sys.mjs";
     22 import { BridgedEngine } from "resource://services-sync/bridged_engine.sys.mjs";
     23 
     24 const FAR_FUTURE = 4102405200000; // 2100/01/01
     25 
     26 const lazy = {};
     27 
     28 ChromeUtils.defineESModuleGetters(lazy, {
     29  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     30  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     31  ReaderMode: "moz-src:///toolkit/components/reader/ReaderMode.sys.mjs",
     32  getTabsStore: "resource://services-sync/TabsStore.sys.mjs",
     33  RemoteTabRecord:
     34    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustTabs.sys.mjs",
     35  setupLoggerForTarget: "resource://gre/modules/AppServicesTracing.sys.mjs",
     36 });
     37 
     38 XPCOMUtils.defineLazyPreferenceGetter(
     39  lazy,
     40  "TABS_FILTERED_SCHEMES",
     41  "services.sync.engine.tabs.filteredSchemes",
     42  "",
     43  null,
     44  val => {
     45    return new Set(val.split("|"));
     46  }
     47 );
     48 
     49 XPCOMUtils.defineLazyPreferenceGetter(
     50  lazy,
     51  "SYNC_AFTER_DELAY_MS",
     52  "services.sync.syncedTabs.syncDelayAfterTabChange",
     53  0
     54 );
     55 
     56 // A "bridged engine" to our tabs component.
     57 export function TabEngine(service) {
     58  BridgedEngine.call(this, "Tabs", service);
     59 }
     60 
     61 TabEngine.prototype = {
     62  _trackerObj: TabTracker,
     63  syncPriority: 3,
     64 
     65  async prepareTheBridge(isQuickWrite) {
     66    let clientsEngine = this.service.clientsEngine;
     67    // Tell the bridged engine about clients.
     68    // This is the same shape as ClientData in app-services.
     69    // schema: https://github.com/mozilla/application-services/blob/a1168751231ed4e88c44d85f6dccc09c3b412bd2/components/sync15/src/client_types.rs#L14
     70    let clientData = {
     71      local_client_id: clientsEngine.localID,
     72      recent_clients: {},
     73    };
     74 
     75    // We shouldn't upload tabs past what the server will accept
     76    let tabs = await this.getTabsWithinPayloadSize();
     77    await this._rustStore.setLocalTabs(
     78      tabs.map(tab => {
     79        // rust wants lastUsed in MS but the provider gives it in seconds
     80        tab.lastUsed = tab.lastUsed * 1000;
     81        return new lazy.RemoteTabRecord(tab);
     82      })
     83    );
     84 
     85    for (let remoteClient of clientsEngine.remoteClients) {
     86      let id = remoteClient.id;
     87      if (!id) {
     88        throw new Error("Remote client somehow did not have an id");
     89      }
     90      let client = {
     91        fxa_device_id: remoteClient.fxaDeviceId,
     92        // device_name and device_type are soft-deprecated - every client
     93        // prefers what's in the FxA record. But fill them correctly anyway.
     94        device_name: clientsEngine.getClientName(id) ?? "",
     95        device_type: clientsEngine.getClientType(id),
     96      };
     97      clientData.recent_clients[id] = client;
     98    }
     99 
    100    // put ourself in there too so we record the correct device info in our sync record.
    101    clientData.recent_clients[clientsEngine.localID] = {
    102      fxa_device_id: await clientsEngine.fxAccounts.device.getLocalId(),
    103      device_name: clientsEngine.localName,
    104      device_type: clientsEngine.localType,
    105    };
    106 
    107    // Quick write needs to adjust the lastSync so we can POST to the server
    108    // see quickWrite() for details
    109    if (isQuickWrite) {
    110      await this.setLastSync(FAR_FUTURE);
    111      await this._bridge.prepareForSync(JSON.stringify(clientData));
    112      return;
    113    }
    114 
    115    // Just incase we crashed while the lastSync timestamp was FAR_FUTURE, we
    116    // reset it to zero
    117    if ((await this.getLastSync()) === FAR_FUTURE) {
    118      await this._bridge.setLastSync(0);
    119    }
    120    await this._bridge.prepareForSync(JSON.stringify(clientData));
    121  },
    122 
    123  async _syncStartup() {
    124    await super._syncStartup();
    125    await this.prepareTheBridge();
    126  },
    127 
    128  async initialize() {
    129    await SyncEngine.prototype.initialize.call(this);
    130 
    131    lazy.setupLoggerForTarget("tabs", "Sync.Engine.Tabs");
    132    // highlights problems with simple logs - we get everyone's sql-support
    133    lazy.setupLoggerForTarget("sql_support", "Sync.Engine.Tabs");
    134    this._rustStore = await lazy.getTabsStore();
    135    this._bridge = await this._rustStore.bridgedEngine();
    136 
    137    // Uniffi doesn't currently only support async methods, so we'll need to hardcode
    138    // these values for now (which is fine for now as these hardly ever change)
    139    this._bridge.storageVersion = STORAGE_VERSION;
    140    this._bridge.allowSkippedRecord = true;
    141 
    142    this._log.info("Got a bridged engine!");
    143    this._tracker.modified = true;
    144  },
    145 
    146  async getChangedIDs() {
    147    // No need for a proper timestamp (no conflict resolution needed).
    148    let changedIDs = {};
    149    if (this._tracker.modified) {
    150      changedIDs[this.service.clientsEngine.localID] = 0;
    151    }
    152    return changedIDs;
    153  },
    154 
    155  // API for use by Sync UI code to give user choices of tabs to open.
    156  async getAllClients() {
    157    let remoteTabs = await this._rustStore.getAll();
    158    let remoteClientTabs = [];
    159    for (let remoteClient of this.service.clientsEngine.remoteClients) {
    160      // We get the some client info from the rust tabs engine and some from
    161      // the clients engine.
    162      let rustClient = remoteTabs.find(
    163        x => x.clientId === remoteClient.fxaDeviceId
    164      );
    165      if (!rustClient) {
    166        continue;
    167      }
    168      let client = {
    169        // rust gives us ms but js uses seconds, so fix them up.
    170        tabs: rustClient.remoteTabs.map(tab => {
    171          tab.lastUsed = tab.lastUsed / 1000;
    172          return tab;
    173        }),
    174        lastModified: rustClient.lastModified / 1000,
    175        ...remoteClient,
    176      };
    177      remoteClientTabs.push(client);
    178    }
    179    return remoteClientTabs;
    180  },
    181 
    182  async removeClientData() {
    183    let url = this.engineURL + "/" + this.service.clientsEngine.localID;
    184    await this.service.resource(url).delete();
    185  },
    186 
    187  async trackRemainingChanges() {
    188    if (this._modified.count() > 0) {
    189      this._tracker.modified = true;
    190    }
    191  },
    192 
    193  async getTabsWithinPayloadSize() {
    194    const maxPayloadSize = this.service.getMaxRecordPayloadSize();
    195    // See bug 535326 comment 8 for an explanation of the estimation
    196    const maxSerializedSize = (maxPayloadSize / 4) * 3 - 1500;
    197    return TabProvider.getAllTabsWithEstimatedMax(true, maxSerializedSize);
    198  },
    199 
    200  // Support for "quick writes"
    201  _engineLock: Utils.lock,
    202  _engineLocked: false,
    203 
    204  // Tabs has a special lock to help support its "quick write"
    205  get locked() {
    206    return this._engineLocked;
    207  },
    208  lock() {
    209    if (this._engineLocked) {
    210      return false;
    211    }
    212    this._engineLocked = true;
    213    return true;
    214  },
    215  unlock() {
    216    this._engineLocked = false;
    217  },
    218 
    219  // Quickly do a POST of our current tabs if possible.
    220  // This does things that would be dangerous for other engines - eg, posting
    221  // without checking what's on the server could cause data-loss for other
    222  // engines, but because each device exclusively owns exactly 1 tabs record
    223  // with a known ID, it's safe here.
    224  // Returns true if we successfully synced, false otherwise (either on error
    225  // or because we declined to sync for any reason.) The return value is
    226  // primarily for tests.
    227  async quickWrite() {
    228    if (!this.enabled) {
    229      // this should be very rare, and only if tabs are disabled after the
    230      // timer is created.
    231      this._log.info("Can't do a quick-sync as tabs is disabled");
    232      return false;
    233    }
    234    // This quick-sync doesn't drive the login state correctly, so just
    235    // decline to sync if out status is bad
    236    if (this.service.status.checkSetup() != STATUS_OK) {
    237      this._log.info(
    238        "Can't do a quick-sync due to the service status",
    239        this.service.status.toString()
    240      );
    241      return false;
    242    }
    243    if (!this.service.serverConfiguration) {
    244      this._log.info("Can't do a quick sync before the first full sync");
    245      return false;
    246    }
    247    try {
    248      return await this._engineLock("tabs.js: quickWrite", async () => {
    249        // We want to restore the lastSync timestamp when complete so next sync
    250        // takes tabs written by other devices since our last real sync.
    251        // And for this POST we don't want the protections offered by
    252        // X-If-Unmodified-Since - we want the POST to work even if the remote
    253        // has moved on and we will catch back up next full sync.
    254        const origLastSync = await this.getLastSync();
    255        try {
    256          return this._doQuickWrite();
    257        } finally {
    258          // set the lastSync to it's original value for regular sync
    259          await this.setLastSync(origLastSync);
    260        }
    261      })();
    262    } catch (ex) {
    263      if (!Utils.isLockException(ex)) {
    264        throw ex;
    265      }
    266      this._log.info(
    267        "Can't do a quick-write as another tab sync is in progress"
    268      );
    269      return false;
    270    }
    271  },
    272 
    273  // The guts of the quick-write sync, after we've taken the lock, checked
    274  // the service status etc.
    275  async _doQuickWrite() {
    276    // We need to track telemetry for these syncs too!
    277    const name = "tabs";
    278    let telemetryRecord = new SyncRecord(
    279      SyncTelemetry.allowedEngines,
    280      "quick-write"
    281    );
    282    telemetryRecord.onEngineStart(name);
    283    try {
    284      Async.checkAppReady();
    285      // We need to prep the bridge before we try to POST since it grabs
    286      // the most recent local client id and properly sets a lastSync
    287      // which is needed for a proper POST request
    288      await this.prepareTheBridge(true);
    289      this._tracker.clearChangedIDs();
    290      this._tracker.resetScore();
    291 
    292      Async.checkAppReady();
    293      // now just the "upload" part of a sync,
    294      // which for a rust engine is  not obvious.
    295      // We need to do is ask the rust engine for the changes. Although
    296      // this is kinda abusing the bridged-engine interface, we know the tabs
    297      // implementation of it works ok
    298      let outgoing = await this._bridge.apply();
    299      // We know we always have exactly 1 record.
    300      let mine = outgoing[0];
    301      this._log.trace("outgoing bso", mine);
    302      // `this._recordObj` is a `BridgedRecord`, which isn't exported.
    303      let record = this._recordObj.fromOutgoingBso(this.name, JSON.parse(mine));
    304      let changeset = {};
    305      changeset[record.id] = { synced: false, record };
    306      this._modified.replace(changeset);
    307 
    308      Async.checkAppReady();
    309      await this._uploadOutgoing();
    310      telemetryRecord.onEngineStop(name, null);
    311      return true;
    312    } catch (ex) {
    313      this._log.warn("quicksync sync failed", ex);
    314      telemetryRecord.onEngineStop(name, ex);
    315      return false;
    316    } finally {
    317      // The top-level sync is never considered to fail here, just the engine
    318      telemetryRecord.finished(null);
    319      SyncTelemetry.takeTelemetryRecord(telemetryRecord);
    320    }
    321  },
    322 
    323  async _sync() {
    324    try {
    325      await this._engineLock("tabs.js: fullSync", async () => {
    326        await super._sync();
    327      })();
    328    } catch (ex) {
    329      if (!Utils.isLockException(ex)) {
    330        throw ex;
    331      }
    332      this._log.info(
    333        "Can't do full tabs sync as a quick-write is currently running"
    334      );
    335    }
    336  },
    337 };
    338 Object.setPrototypeOf(TabEngine.prototype, BridgedEngine.prototype);
    339 
    340 export const TabProvider = {
    341  getWindowEnumerator() {
    342    return Services.wm.getEnumerator("navigator:browser");
    343  },
    344 
    345  shouldSkipWindow(win) {
    346    return win.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(win);
    347  },
    348 
    349  getAllBrowserTabs() {
    350    let tabs = [];
    351    for (let win of this.getWindowEnumerator()) {
    352      if (this.shouldSkipWindow(win)) {
    353        continue;
    354      }
    355      // Get all the tabs from the browser
    356      for (let tab of win.gBrowser.tabs) {
    357        tabs.push(tab);
    358      }
    359    }
    360 
    361    return tabs.sort(function (a, b) {
    362      return b.lastAccessed - a.lastAccessed;
    363    });
    364  },
    365 
    366  // This function creates tabs records up to a specified amount of bytes
    367  // It is an "estimation" since we don't accurately calculate how much the
    368  // favicon and JSON overhead is and give a rough estimate (for optimization purposes)
    369  async getAllTabsWithEstimatedMax(filter, bytesMax) {
    370    let log = Log.repository.getLogger(`Sync.Engine.Tabs.Provider`);
    371    let tabRecords = [];
    372    let iconPromises = [];
    373    let runningByteLength = 0;
    374    let encoder = new TextEncoder();
    375 
    376    // Fetch all the tabs the user has open
    377    let winTabs = this.getAllBrowserTabs();
    378 
    379    for (let tab of winTabs) {
    380      // We don't want to process any more tabs than we can sync
    381      if (runningByteLength >= bytesMax) {
    382        log.warn(
    383          `Can't fit all tabs in sync payload: have ${winTabs.length},
    384              but can only fit ${tabRecords.length}.`
    385        );
    386        break;
    387      }
    388 
    389      // Note that we used to sync "tab history" (ie, the "back button") state,
    390      // but in practice this hasn't been used - only the current URI is of
    391      // interest to clients.
    392      // We stopped recording this in bug 1783991.
    393      if (!tab?.linkedBrowser) {
    394        continue;
    395      }
    396      let acceptable = !filter
    397        ? url => url
    398        : url =>
    399            url &&
    400            !lazy.TABS_FILTERED_SCHEMES.has(Services.io.extractScheme(url));
    401 
    402      let url = tab.linkedBrowser.currentURI?.spec;
    403      // Special case for reader mode.
    404      if (url && url.startsWith("about:reader?")) {
    405        url = lazy.ReaderMode.getOriginalUrl(url);
    406      }
    407      // We ignore the tab completely if the current entry url is
    408      // not acceptable (we need something accurate to open).
    409      if (!acceptable(url)) {
    410        continue;
    411      }
    412 
    413      if (url.length > URI_LENGTH_MAX) {
    414        log.trace("Skipping over-long URL.");
    415        continue;
    416      }
    417 
    418      let thisTab = new lazy.RemoteTabRecord({
    419        title: tab.linkedBrowser.contentTitle || "",
    420        urlHistory: [url],
    421        icon: "",
    422        lastUsed: Math.floor((tab.lastAccessed || 0) / 1000),
    423      });
    424      tabRecords.push(thisTab);
    425 
    426      // we don't want to wait for each favicon to resolve to get the bytes
    427      // so we estimate a conservative 100 chars for the favicon and json overhead
    428      // Rust will further optimize and trim if we happened to be wildly off
    429      runningByteLength +=
    430        encoder.encode(thisTab.title + thisTab.lastUsed + url).byteLength + 100;
    431 
    432      // Use the favicon service for the icon url - we can wait for the promises at the end.
    433      let iconPromise = lazy.PlacesUtils.favicons
    434        .getFaviconForPage(lazy.PlacesUtils.toURI(url))
    435        .then(favicon => {
    436          thisTab.icon = favicon.uri.spec;
    437        })
    438        .catch(() => {
    439          log.trace(
    440            `Failed to fetch favicon for ${url}`,
    441            thisTab.urlHistory[0]
    442          );
    443        });
    444      iconPromises.push(iconPromise);
    445    }
    446 
    447    await Promise.allSettled(iconPromises);
    448    return tabRecords;
    449  },
    450 };
    451 
    452 function TabTracker(name, engine) {
    453  Tracker.call(this, name, engine);
    454 
    455  // Make sure "this" pointer is always set correctly for event listeners.
    456  this.onTab = Utils.bind2(this, this.onTab);
    457  this._unregisterListeners = Utils.bind2(this, this._unregisterListeners);
    458 }
    459 TabTracker.prototype = {
    460  QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
    461 
    462  clearChangedIDs() {
    463    this.modified = false;
    464  },
    465 
    466  // We do not track TabSelect because that almost always triggers
    467  // the web progress listeners (onLocationChange), which we already track
    468  _topics: ["TabOpen", "TabClose"],
    469 
    470  _registerListenersForWindow(window) {
    471    this._log.trace("Registering tab listeners in window");
    472    for (let topic of this._topics) {
    473      window.addEventListener(topic, this.onTab);
    474    }
    475    window.addEventListener("unload", this._unregisterListeners);
    476    // If it's got a tab browser we can listen for things like navigation.
    477    if (window.gBrowser) {
    478      window.gBrowser.addProgressListener(this);
    479    }
    480  },
    481 
    482  _unregisterListeners(event) {
    483    this._unregisterListenersForWindow(event.target);
    484  },
    485 
    486  _unregisterListenersForWindow(window) {
    487    this._log.trace("Removing tab listeners in window");
    488    window.removeEventListener("unload", this._unregisterListeners);
    489    for (let topic of this._topics) {
    490      window.removeEventListener(topic, this.onTab);
    491    }
    492    if (window.gBrowser) {
    493      window.gBrowser.removeProgressListener(this);
    494    }
    495  },
    496 
    497  onStart() {
    498    Svc.Obs.add("domwindowopened", this.asyncObserver);
    499    for (let win of Services.wm.getEnumerator("navigator:browser")) {
    500      this._registerListenersForWindow(win);
    501    }
    502  },
    503 
    504  onStop() {
    505    Svc.Obs.remove("domwindowopened", this.asyncObserver);
    506    for (let win of Services.wm.getEnumerator("navigator:browser")) {
    507      this._unregisterListenersForWindow(win);
    508    }
    509  },
    510 
    511  async observe(subject, topic) {
    512    switch (topic) {
    513      case "domwindowopened": {
    514        let onLoad = () => {
    515          subject.removeEventListener("load", onLoad);
    516          // Only register after the window is done loading to avoid unloads.
    517          this._registerListenersForWindow(subject);
    518        };
    519 
    520        // Add tab listeners now that a window has opened.
    521        subject.addEventListener("load", onLoad);
    522        break;
    523      }
    524    }
    525  },
    526 
    527  onTab(event) {
    528    if (event.originalTarget.linkedBrowser) {
    529      let browser = event.originalTarget.linkedBrowser;
    530      if (
    531        lazy.PrivateBrowsingUtils.isBrowserPrivate(browser) &&
    532        !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
    533      ) {
    534        this._log.trace("Ignoring tab event from private browsing.");
    535        return;
    536      }
    537    }
    538    this._log.trace("onTab event: " + event.type);
    539 
    540    switch (event.type) {
    541      case "TabOpen":
    542        /* We do not have a reliable way of checking the URI on the TabOpen
    543         * so we will rely on the other methods (onLocationChange, getAllTabsWithEstimatedMax)
    544         * to filter these when going through sync
    545         */
    546        this.callScheduleSync(SCORE_INCREMENT_SMALL);
    547        break;
    548      case "TabClose": {
    549        // If event target has `linkedBrowser`, the event target can be assumed <tab> element.
    550        // Else, event target is assumed <browser> element, use the target as it is.
    551        const tab = event.target.linkedBrowser || event.target;
    552 
    553        // TabClose means the tab has already loaded and we can check the URI
    554        // and ignore if it's a scheme we don't care about
    555        if (lazy.TABS_FILTERED_SCHEMES.has(tab.currentURI.scheme)) {
    556          return;
    557        }
    558        this.callScheduleSync(SCORE_INCREMENT_SMALL);
    559        break;
    560      }
    561    }
    562  },
    563 
    564  // web progress listeners.
    565  onLocationChange(webProgress, request, locationURI, flags) {
    566    // We only care about top-level location changes. We do want location changes in the
    567    // same document because if a page uses the `pushState()` API, they *appear* as though
    568    // they are in the same document even if the URL changes. It also doesn't hurt to accurately
    569    // reflect the fragment changing - so we allow LOCATION_CHANGE_SAME_DOCUMENT
    570    if (
    571      flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD ||
    572      !webProgress.isTopLevel ||
    573      !locationURI
    574    ) {
    575      return;
    576    }
    577 
    578    // We can't filter out tabs that we don't sync here, because we might be
    579    // navigating from a tab that we *did* sync to one we do not, and that
    580    // tab we *did* sync should no longer be synced.
    581    this.callScheduleSync();
    582  },
    583 
    584  callScheduleSync(scoreIncrement) {
    585    this.modified = true;
    586    let { scheduler } = this.engine.service;
    587    let delayInMs = lazy.SYNC_AFTER_DELAY_MS;
    588 
    589    // Schedule a sync once we detect a tab change
    590    // to ensure the server always has the most up to date tabs
    591    if (
    592      delayInMs > 0 &&
    593      scheduler.numClients > 1 // Only schedule quick syncs for multi client users
    594    ) {
    595      if (this.tabsQuickWriteTimer) {
    596        this._log.debug(
    597          "Detected a tab change, but a quick-write is already scheduled"
    598        );
    599        return;
    600      }
    601      this._log.debug(
    602        "Detected a tab change: scheduling a quick-write in " + delayInMs + "ms"
    603      );
    604      CommonUtils.namedTimer(
    605        () => {
    606          this._log.trace("tab quick-sync timer fired.");
    607          this.engine
    608            .quickWrite()
    609            .then(() => {
    610              this._log.trace("tab quick-sync done.");
    611            })
    612            .catch(ex => {
    613              this._log.error("tab quick-sync failed.", ex);
    614            });
    615        },
    616        delayInMs,
    617        this,
    618        "tabsQuickWriteTimer"
    619      );
    620    } else if (scoreIncrement) {
    621      this._log.debug(
    622        "Detected a tab change, but conditions aren't met for a quick write - bumping score"
    623      );
    624      this.score += scoreIncrement;
    625    } else {
    626      this._log.debug(
    627        "Detected a tab change, but conditions aren't met for a quick write or a score bump"
    628      );
    629    }
    630  },
    631 };
    632 Object.setPrototypeOf(TabTracker.prototype, Tracker.prototype);