tor-browser

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

SyncedTabs.sys.mjs (15541B)


      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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs",
     11  Weave: "resource://services-sync/main.sys.mjs",
     12  getRemoteCommandStore: "resource://services-sync/TabsStore.sys.mjs",
     13  RemoteCommand: "resource://services-sync/TabsStore.sys.mjs",
     14  FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
     15 });
     16 
     17 // The Sync XPCOM service
     18 ChromeUtils.defineLazyGetter(lazy, "weaveXPCService", function () {
     19  return Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
     20    .wrappedJSObject;
     21 });
     22 
     23 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
     24  return ChromeUtils.importESModule(
     25    "resource://gre/modules/FxAccounts.sys.mjs"
     26  ).getFxAccountsSingleton();
     27 });
     28 
     29 // from MDN...
     30 function escapeRegExp(string) {
     31  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
     32 }
     33 
     34 // A topic we fire whenever we have new tabs available. This might be due
     35 // to a request made by this module to refresh the tab list, or as the result
     36 // of a regularly scheduled sync. The intent is that consumers just listen
     37 // for this notification and update their UI in response.
     38 const TOPIC_TABS_CHANGED = "services.sync.tabs.changed";
     39 
     40 // A topic we fire whenever we have queued a new remote tabs command.
     41 const TOPIC_TABS_COMMAND_QUEUED = "services.sync.tabs.command-queued";
     42 
     43 // The interval, in seconds, before which we consider the existing list
     44 // of tabs "fresh enough" and don't force a new sync.
     45 const TABS_FRESH_ENOUGH_INTERVAL_SECONDS = 30;
     46 
     47 ChromeUtils.defineLazyGetter(lazy, "log", () => {
     48  const { Log } = ChromeUtils.importESModule(
     49    "resource://gre/modules/Log.sys.mjs"
     50  );
     51  let log = Log.repository.getLogger("Sync.RemoteTabs");
     52  log.manageLevelFromPref("services.sync.log.logger.tabs");
     53  return log;
     54 });
     55 
     56 // We allow some test preferences to simulate many and inactive tabs.
     57 XPCOMUtils.defineLazyPreferenceGetter(
     58  lazy,
     59  "NUM_FAKE_INACTIVE_TABS",
     60  "services.sync.syncedTabs.numFakeInactiveTabs",
     61  0
     62 );
     63 
     64 XPCOMUtils.defineLazyPreferenceGetter(
     65  lazy,
     66  "NUM_FAKE_ACTIVE_TABS",
     67  "services.sync.syncedTabs.numFakeActiveTabs",
     68  0
     69 );
     70 
     71 // A private singleton that does the work.
     72 let SyncedTabsInternal = {
     73  /* Make a "tab" record. Returns a promise */
     74  async _makeTab(client, tab, url, showRemoteIcons) {
     75    let icon;
     76    if (showRemoteIcons) {
     77      icon = tab.icon;
     78    }
     79    if (!icon) {
     80      // By not specifying a size the favicon service will pick the default,
     81      // that is usually set through setDefaultIconURIPreferredSize by the
     82      // first browser window. Commonly it's 16px at current dpi.
     83      icon = "page-icon:" + url;
     84    }
     85    return {
     86      type: "tab",
     87      title: tab.title || url,
     88      url,
     89      icon,
     90      client: client.id,
     91      lastUsed: tab.lastUsed,
     92      inactive: tab.inactive,
     93    };
     94  },
     95 
     96  /* Make a "client" record. Returns a promise for consistency with _makeTab */
     97  async _makeClient(client) {
     98    return {
     99      id: client.id,
    100      type: "client",
    101      name: lazy.Weave.Service.clientsEngine.getClientName(client.id),
    102      clientType: lazy.Weave.Service.clientsEngine.getClientType(client.id),
    103      lastModified: client.lastModified * 1000, // sec to ms
    104      tabs: [],
    105    };
    106  },
    107 
    108  _tabMatchesFilter(tab, filter) {
    109    let reFilter = new RegExp(escapeRegExp(filter), "i");
    110    return reFilter.test(tab.url) || reFilter.test(tab.title);
    111  },
    112 
    113  // A wrapper for grabbing the fxaDeviceId, to make it easier for stubbing
    114  // for tests
    115  _getClientFxaDeviceId(clientId) {
    116    return lazy.Weave.Service.clientsEngine.getClientFxaDeviceId(clientId);
    117  },
    118 
    119  _createRecentTabsList(
    120    clients,
    121    maxCount,
    122    extraParams = { removeAllDupes: true, removeDeviceDupes: false }
    123  ) {
    124    let tabs = [];
    125 
    126    for (let client of clients) {
    127      if (extraParams.removeDeviceDupes) {
    128        client.tabs = this._filterRecentTabsDupes(client.tabs);
    129      }
    130 
    131      // We have the client obj but we need the FxA device obj so we use the clients
    132      // engine to get us the FxA device
    133      let device =
    134        lazy.fxAccounts.device.recentDeviceList &&
    135        lazy.fxAccounts.device.recentDeviceList.find(
    136          d => d.id === this._getClientFxaDeviceId(client.id)
    137        );
    138 
    139      for (let tab of client.tabs) {
    140        tab.device = client.name;
    141        tab.deviceType = client.clientType;
    142        // Surface broadcasted commmands for things like close remote tab
    143        tab.fxaDeviceId = device.id;
    144        tab.availableCommands = device.availableCommands;
    145      }
    146      tabs = [...tabs, ...client.tabs.reverse()];
    147    }
    148    if (extraParams.removeAllDupes) {
    149      tabs = this._filterRecentTabsDupes(tabs);
    150    }
    151    tabs = tabs.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, maxCount);
    152    return tabs;
    153  },
    154 
    155  // Filter out any tabs with duplicate URLs preserving
    156  // the duplicate with the most recent lastUsed value
    157  _filterRecentTabsDupes(tabs) {
    158    const tabMap = new Map();
    159    for (const tab of tabs) {
    160      const existingTab = tabMap.get(tab.url);
    161      if (!existingTab || tab.lastUsed > existingTab.lastUsed) {
    162        tabMap.set(tab.url, tab);
    163      }
    164    }
    165    return Array.from(tabMap.values());
    166  },
    167 
    168  async getTabClients(filter) {
    169    lazy.log.info("Generating tab list with filter", filter);
    170    let result = [];
    171 
    172    // If Sync isn't ready, don't try and get anything.
    173    if (!lazy.weaveXPCService.ready) {
    174      lazy.log.debug("Sync isn't yet ready, so returning an empty tab list");
    175      return result;
    176    }
    177 
    178    // A boolean that controls whether we should show the icon from the remote tab.
    179    const showRemoteIcons = Services.prefs.getBoolPref(
    180      "services.sync.syncedTabs.showRemoteIcons",
    181      true
    182    );
    183 
    184    let engine = lazy.Weave.Service.engineManager.get("tabs");
    185 
    186    let ntabs = 0;
    187    let clientTabList = await engine.getAllClients();
    188    for (let client of clientTabList) {
    189      if (!lazy.Weave.Service.clientsEngine.remoteClientExists(client.id)) {
    190        continue;
    191      }
    192      let clientRepr = await this._makeClient(client);
    193      lazy.log.debug("Processing client", clientRepr);
    194 
    195      let tabs = Array.from(client.tabs); // avoid modifying in-place.
    196      // For QA, UX, etc, we allow "fake tabs" to be added to each device.
    197      for (let i = 0; i < lazy.NUM_FAKE_INACTIVE_TABS; i++) {
    198        tabs.push({
    199          icon: null,
    200          lastUsed: 1000,
    201          title: `Fake inactive tab ${i}`,
    202          urlHistory: [`https://example.com/inactive/${i}`],
    203          inactive: true,
    204        });
    205      }
    206      for (let i = 0; i < lazy.NUM_FAKE_ACTIVE_TABS; i++) {
    207        tabs.push({
    208          icon: null,
    209          lastUsed: Date.now() - 1000 + i,
    210          title: `Fake tab ${i}`,
    211          urlHistory: [`https://example.com/${i}`],
    212        });
    213      }
    214 
    215      for (let tab of tabs) {
    216        let url = tab.urlHistory[0];
    217        lazy.log.trace("remote tab", url);
    218 
    219        if (!url) {
    220          continue;
    221        }
    222        let tabRepr = await this._makeTab(client, tab, url, showRemoteIcons);
    223        if (filter && !this._tabMatchesFilter(tabRepr, filter)) {
    224          continue;
    225        }
    226        clientRepr.tabs.push(tabRepr);
    227      }
    228 
    229      // Filter out duplicate tabs based on URL
    230      clientRepr.tabs = this._filterRecentTabsDupes(clientRepr.tabs);
    231 
    232      // We return all clients, even those without tabs - the consumer should
    233      // filter it if they care.
    234      ntabs += clientRepr.tabs.length;
    235      result.push(clientRepr);
    236    }
    237    lazy.log.info(
    238      `Final tab list has ${result.length} clients with ${ntabs} tabs.`
    239    );
    240    return result;
    241  },
    242 
    243  async syncTabs(force) {
    244    if (!force) {
    245      // Don't bother refetching tabs if we already did so recently
    246      let lastFetch = Services.prefs.getIntPref(
    247        "services.sync.lastTabFetch",
    248        0
    249      );
    250      let now = Math.floor(Date.now() / 1000);
    251      if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL_SECONDS) {
    252        lazy.log.info("_refetchTabs was done recently, do not doing it again");
    253        return false;
    254      }
    255    }
    256 
    257    // If Sync isn't configured don't try and sync, else we will get reports
    258    // of a login failure.
    259    if (lazy.Weave.Status.checkSetup() === lazy.CLIENT_NOT_CONFIGURED) {
    260      lazy.log.info(
    261        "Sync client is not configured, so not attempting a tab sync"
    262      );
    263      return false;
    264    }
    265    // If the primary pass is locked, we should not try to sync
    266    if (lazy.Weave.Utils.mpLocked()) {
    267      lazy.log.info(
    268        "Can't sync tabs due to the primary password being locked",
    269        lazy.Weave.Status.login
    270      );
    271      return false;
    272    }
    273    // Ask Sync to just do the tabs engine if it can.
    274    try {
    275      lazy.log.info("Doing a tab sync.");
    276      await lazy.Weave.Service.sync({ why: "tabs", engines: ["tabs"] });
    277      return true;
    278    } catch (ex) {
    279      lazy.log.error("Sync failed", ex);
    280      throw ex;
    281    }
    282  },
    283 
    284  observe(subject, topic, data) {
    285    lazy.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
    286    switch (topic) {
    287      case "weave:engine:sync:finish":
    288        if (data != "tabs") {
    289          return;
    290        }
    291        // The tabs engine just finished syncing
    292        // Set our lastTabFetch pref here so it tracks both explicit sync calls
    293        // and normally scheduled ones.
    294        Services.prefs.setIntPref(
    295          "services.sync.lastTabFetch",
    296          Math.floor(Date.now() / 1000)
    297        );
    298        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
    299        break;
    300      case "weave:service:start-over":
    301        // start-over needs to notify so consumers find no tabs.
    302        Services.prefs.clearUserPref("services.sync.lastTabFetch");
    303        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
    304        break;
    305      case "nsPref:changed":
    306        Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED);
    307        break;
    308      default:
    309        break;
    310    }
    311  },
    312 
    313  // Returns true if Sync is configured to Sync tabs, false otherwise
    314  get isConfiguredToSyncTabs() {
    315    if (!lazy.weaveXPCService.ready) {
    316      lazy.log.debug("Sync isn't yet ready; assuming tab engine is enabled");
    317      return true;
    318    }
    319 
    320    let engine = lazy.Weave.Service.engineManager.get("tabs");
    321    return engine && engine.enabled;
    322  },
    323 
    324  get hasSyncedThisSession() {
    325    let engine = lazy.Weave.Service.engineManager.get("tabs");
    326    return engine && engine.hasSyncedThisSession;
    327  },
    328 };
    329 
    330 Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish");
    331 Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over");
    332 // Observe the pref the indicates the state of the tabs engine has changed.
    333 // This will force consumers to re-evaluate the state of sync and update
    334 // accordingly.
    335 Services.prefs.addObserver("services.sync.engine.tabs", SyncedTabsInternal);
    336 
    337 // The public interface.
    338 export var SyncedTabs = {
    339  // A mock-point for tests.
    340  _internal: SyncedTabsInternal,
    341 
    342  // We make the topic for the observer notification public.
    343  TOPIC_TABS_CHANGED,
    344 
    345  // Expose the interval used to determine if synced tabs data needs a new sync
    346  TABS_FRESH_ENOUGH_INTERVAL_SECONDS,
    347 
    348  // Returns true if Sync is configured to Sync tabs, false otherwise
    349  get isConfiguredToSyncTabs() {
    350    return this._internal.isConfiguredToSyncTabs;
    351  },
    352 
    353  // Returns true if a tab sync has completed once this session. If this
    354  // returns false, then getting back no clients/tabs possibly just means we
    355  // are waiting for that first sync to complete.
    356  get hasSyncedThisSession() {
    357    return this._internal.hasSyncedThisSession;
    358  },
    359 
    360  // Return a promise that resolves with an array of client records, each with
    361  // a .tabs array. Note that part of the contract for this module is that the
    362  // returned objects are not shared between invocations, so callers are free
    363  // to mutate the returned objects (eg, sort, truncate) however they see fit.
    364  getTabClients(query) {
    365    return this._internal.getTabClients(query);
    366  },
    367 
    368  // Starts a background request to start syncing tabs. Returns a promise that
    369  // resolves when the sync is complete, but there's no resolved value -
    370  // callers should be listening for TOPIC_TABS_CHANGED.
    371  // If |force| is true we always sync. If false, we only sync if the most
    372  // recent sync wasn't "recently".
    373  syncTabs(force) {
    374    return this._internal.syncTabs(force);
    375  },
    376 
    377  createRecentTabsList(clients, maxCount, extraParams) {
    378    return this._internal._createRecentTabsList(clients, maxCount, extraParams);
    379  },
    380 
    381  sortTabClientsByLastUsed(clients) {
    382    // First sort the list of tabs for each client. Note that
    383    // this module promises that the objects it returns are never
    384    // shared, so we are free to mutate those objects directly.
    385    for (let client of clients) {
    386      let tabs = client.tabs;
    387      tabs.sort((a, b) => b.lastUsed - a.lastUsed);
    388    }
    389    // Now sort the clients - the clients are sorted in the order of the
    390    // most recent tab for that client (ie, it is important the tabs for
    391    // each client are already sorted.)
    392    clients.sort((a, b) => {
    393      if (!a.tabs.length) {
    394        return 1; // b comes first.
    395      }
    396      if (!b.tabs.length) {
    397        return -1; // a comes first.
    398      }
    399      return b.tabs[0].lastUsed - a.tabs[0].lastUsed;
    400    });
    401  },
    402 
    403  recordSyncedTabsTelemetry(object, tabEvent, extraOptions) {
    404    if (
    405      !["fxa_avatar_menu", "fxa_app_menu", "synced_tabs_sidebar"].includes(
    406        object
    407      )
    408    ) {
    409      return;
    410    }
    411    object = object
    412      .split("_")
    413      .map(word => word[0].toUpperCase() + word.slice(1))
    414      .join("");
    415    Glean.syncedTabs[tabEvent + object].record(extraOptions);
    416  },
    417 
    418  // Get list of synced tabs across all devices/clients
    419  // truncated by value of maxCount param, sorted by
    420  // lastUsed value, and filtered for duplicate URLs
    421  async getRecentTabs(maxCount, extraParams) {
    422    let clients = await this.getTabClients();
    423    return this._internal._createRecentTabsList(clients, maxCount, extraParams);
    424  },
    425 };
    426 
    427 // Remote tab management public interface.
    428 export var SyncedTabsManagement = {
    429  // A mock-point for tests.
    430  async _getStore() {
    431    return await lazy.getRemoteCommandStore();
    432  },
    433 
    434  /// Enqueue a tab to close on a remote device.
    435  async enqueueTabToClose(deviceId, url) {
    436    let store = await this._getStore();
    437    let command = new lazy.RemoteCommand.CloseTab({ url });
    438    if (!store.addRemoteCommand(deviceId, command)) {
    439      lazy.log.warn(
    440        "Could not queue a remote tab close - it was already queued"
    441      );
    442    } else {
    443      lazy.log.info("Queued remote tab close command.");
    444    }
    445    // fxAccounts commands infrastructure is lazily initialized, at which point
    446    // it registers observers etc - make sure it's initialized;
    447    lazy.FxAccounts.commands;
    448    Services.obs.notifyObservers(null, TOPIC_TABS_COMMAND_QUEUED);
    449  },
    450 
    451  /// Remove a tab from the queue of commands for a remote device.
    452  async removePendingTabToClose(deviceId, url) {
    453    let store = await this._getStore();
    454    let command = new lazy.RemoteCommand.CloseTab({ url });
    455    if (!store.removeRemoteCommand(deviceId, command)) {
    456      lazy.log.warn("Could not remove a remote tab close - it was not queued");
    457    } else {
    458      lazy.log.info("Removed queued remote tab close command.");
    459    }
    460  },
    461 };