tor-browser

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

SyncedTabsController.sys.mjs (13907B)


      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 lazy = {};
      6 ChromeUtils.defineESModuleGetters(lazy, {
      7  ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
      8  SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
      9  SyncedTabsManagement: "resource://services-sync/SyncedTabs.sys.mjs",
     10  COMMAND_CLOSETAB: "resource://gre/modules/FxAccountsCommon.sys.mjs",
     11 });
     12 
     13 import { SyncedTabsErrorHandler } from "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs";
     14 import { TabsSetupFlowManager } from "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs";
     15 import { searchTabList } from "chrome://browser/content/firefoxview/search-helpers.mjs";
     16 
     17 const SYNCED_TABS_CHANGED = "services.sync.tabs.changed";
     18 const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
     19 
     20 /**
     21 * The controller for synced tabs components.
     22 *
     23 * @implements {ReactiveController}
     24 */
     25 export class SyncedTabsController {
     26  /**
     27   * @type {boolean}
     28   */
     29  contextMenu;
     30  currentSetupStateIndex = -1;
     31  currentSyncedTabs = [];
     32  devices = [];
     33  /**
     34   * The current error state as determined by `SyncedTabsErrorHandler`.
     35   *
     36   * @type {number}
     37   */
     38  errorState = null;
     39  /**
     40   * Component associated with this controller.
     41   *
     42   * @type {ReactiveControllerHost}
     43   */
     44  host;
     45  /**
     46   * @type {Function}
     47   */
     48  pairDeviceCallback;
     49  searchQuery = "";
     50  /**
     51   * @type {Function}
     52   */
     53  signupCallback;
     54 
     55  /**
     56   * Construct a new SyncedTabsController.
     57   *
     58   * @param {ReactiveControllerHost} host
     59   * @param {object} options
     60   * @param {boolean} [options.contextMenu]
     61   *   Whether synced tab items have a secondary context menu.
     62   * @param {Function} [options.pairDeviceCallback]
     63   *   The function to call when the pair device window is opened.
     64   * @param {Function} [options.signupCallback]
     65   *   The function to call when the signup window is opened.
     66   */
     67  constructor(host, { contextMenu, pairDeviceCallback, signupCallback } = {}) {
     68    this.contextMenu = contextMenu;
     69    this.pairDeviceCallback = pairDeviceCallback;
     70    this.signupCallback = signupCallback;
     71    this.observe = this.observe.bind(this);
     72    this.host = host;
     73    this.host.addController(this);
     74    // Track tabs requested close per device but not-yet-sent,
     75    // it'll be in the form of {fxaDeviceId: Set(urls)}
     76    this._pendingCloseTabs = new Map();
     77    // The last closed URL, for undo purposes
     78    this.lastClosedURL = null;
     79  }
     80 
     81  hostConnected() {
     82    this.host.addEventListener("click", this);
     83  }
     84 
     85  hostDisconnected() {
     86    this.host.removeEventListener("click", this);
     87  }
     88 
     89  get isSyncedTabsLoaded() {
     90    return this.currentSetupStateIndex === 4;
     91  }
     92 
     93  addSyncObservers() {
     94    Services.obs.addObserver(this.observe, SYNCED_TABS_CHANGED);
     95    Services.obs.addObserver(this.observe, TOPIC_SETUPSTATE_CHANGED);
     96  }
     97 
     98  removeSyncObservers() {
     99    Services.obs.removeObserver(this.observe, SYNCED_TABS_CHANGED);
    100    Services.obs.removeObserver(this.observe, TOPIC_SETUPSTATE_CHANGED);
    101  }
    102 
    103  handleEvent(event) {
    104    if (event.type == "click" && event.target.dataset.action) {
    105      const { ErrorType } = SyncedTabsErrorHandler;
    106      switch (event.target.dataset.action) {
    107        case `${ErrorType.SYNC_ERROR}`:
    108        case `${ErrorType.NETWORK_OFFLINE}`:
    109        case `${ErrorType.PASSWORD_LOCKED}`: {
    110          TabsSetupFlowManager.tryToClearError();
    111          break;
    112        }
    113        case `${ErrorType.SIGNED_OUT}`:
    114        case "sign-in": {
    115          TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal);
    116          this.signupCallback?.();
    117          break;
    118        }
    119        case "add-device": {
    120          TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal);
    121          this.pairDeviceCallback?.();
    122          break;
    123        }
    124        case "sync-tabs-disabled": {
    125          TabsSetupFlowManager.syncOpenTabs(event.target);
    126          break;
    127        }
    128        case `${ErrorType.SYNC_DISCONNECTED}`: {
    129          const win = event.target.ownerGlobal;
    130          const { switchToTabHavingURI } =
    131            win.docShell.chromeEventHandler.ownerGlobal;
    132          switchToTabHavingURI(
    133            "about:preferences?action=choose-what-to-sync#sync",
    134            true,
    135            {}
    136          );
    137          break;
    138        }
    139      }
    140    } else if (event.type == "click" && event.composedTarget.href) {
    141      const { switchToTabHavingURI } =
    142        event.view.browsingContext.topChromeWindow;
    143      switchToTabHavingURI(event.composedTarget.href, true, {
    144        ignoreFragment: "whenComparingAndReplace",
    145      });
    146    }
    147  }
    148 
    149  async observe(_, topic, errorState) {
    150    if (topic == TOPIC_SETUPSTATE_CHANGED) {
    151      await this.updateStates(errorState);
    152    }
    153    if (topic == SYNCED_TABS_CHANGED) {
    154      // Usually this means we performed a sync, so clear the
    155      // "in-queue" things as those most likely got flushed
    156      this._pendingCloseTabs = new Map();
    157      this.lastClosedURL = null;
    158      await this.getSyncedTabData();
    159    }
    160  }
    161 
    162  async updateStates(errorState) {
    163    let stateIndex = TabsSetupFlowManager.uiStateIndex;
    164    errorState = errorState || SyncedTabsErrorHandler.getErrorType();
    165 
    166    if (stateIndex == 4 && this.currentSetupStateIndex !== stateIndex) {
    167      // trigger an initial request for the synced tabs list
    168      await this.getSyncedTabData();
    169    }
    170 
    171    this.currentSetupStateIndex = stateIndex;
    172    this.errorState = errorState;
    173    this.host.requestUpdate();
    174  }
    175 
    176  actionMappings = {
    177    "sign-in": {
    178      header: "firefoxview-syncedtabs-signin-header-2",
    179      description: "firefoxview-syncedtabs-signin-description-2",
    180      buttonLabel: "firefoxview-syncedtabs-signin-primarybutton-2",
    181    },
    182    "add-device": {
    183      header: "firefoxview-syncedtabs-adddevice-header-2",
    184      description: "firefoxview-syncedtabs-adddevice-description-2",
    185      buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton",
    186      descriptionLink: {
    187        name: "url",
    188        url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync",
    189      },
    190    },
    191    "sync-tabs-disabled": {
    192      header: "firefoxview-syncedtabs-synctabs-header",
    193      description: "firefoxview-syncedtabs-synctabs-description",
    194      buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton",
    195    },
    196    loading: {
    197      header: "firefoxview-syncedtabs-loading-header",
    198      description: "firefoxview-syncedtabs-loading-description",
    199    },
    200  };
    201 
    202  #getMessageCardForState({ error = false, action, errorState }) {
    203    errorState = errorState || this.errorState;
    204    let header, description, descriptionLink, buttonLabel, mainImageUrl;
    205    let descriptionArray;
    206    if (error) {
    207      let link;
    208      ({ header, description, link, buttonLabel } =
    209        SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState));
    210      action = `${errorState}`;
    211      mainImageUrl =
    212        "chrome://browser/content/firefoxview/synced-tabs-error.svg";
    213      descriptionArray = [description];
    214      if (errorState == "password-locked") {
    215        descriptionLink = {};
    216        // This is ugly, but we need to special case this link so we can
    217        // coexist with the old view.
    218        descriptionArray.push("firefoxview-syncedtab-password-locked-link");
    219        descriptionLink.name = "syncedtab-password-locked-link";
    220        descriptionLink.url = link.href;
    221      }
    222    } else {
    223      header = this.actionMappings[action].header;
    224      description = this.actionMappings[action].description;
    225      buttonLabel = this.actionMappings[action].buttonLabel;
    226      descriptionLink = this.actionMappings[action].descriptionLink;
    227      mainImageUrl =
    228        "chrome://browser/content/firefoxview/synced-tabs-empty.svg";
    229      descriptionArray = [description];
    230    }
    231    return {
    232      action,
    233      buttonLabel,
    234      descriptionArray,
    235      descriptionLink,
    236      error,
    237      header,
    238      mainImageUrl,
    239    };
    240  }
    241 
    242  getRenderInfo() {
    243    let renderInfo = {};
    244    for (let tab of this.currentSyncedTabs) {
    245      if (!(tab.client in renderInfo)) {
    246        renderInfo[tab.client] = {
    247          name: tab.device,
    248          deviceType: tab.deviceType,
    249          canClose: !!tab.availableCommands[lazy.COMMAND_CLOSETAB],
    250          tabs: [],
    251        };
    252      }
    253      renderInfo[tab.client].tabs.push(tab);
    254    }
    255 
    256    // Add devices without tabs
    257    for (let device of this.devices) {
    258      if (!(device.id in renderInfo)) {
    259        renderInfo[device.id] = {
    260          name: device.name,
    261          deviceType: device.clientType,
    262          tabs: [],
    263        };
    264      }
    265    }
    266 
    267    for (let id in renderInfo) {
    268      renderInfo[id].tabItems = this.searchQuery
    269        ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id]))
    270        : this.getTabItems(renderInfo[id]);
    271    }
    272    return renderInfo;
    273  }
    274 
    275  getMessageCard() {
    276    switch (this.currentSetupStateIndex) {
    277      case 0 /* error-state */:
    278        if (this.errorState) {
    279          return this.#getMessageCardForState({ error: true });
    280        }
    281        return this.#getMessageCardForState({ action: "loading" });
    282      case 1 /* not-signed-in */:
    283        if (Services.prefs.prefHasUserValue("services.sync.lastversion")) {
    284          // If this pref is set, the user has signed out of sync.
    285          // This path is also taken if we are disconnected from sync. See bug 1784055
    286          return this.#getMessageCardForState({
    287            error: true,
    288            errorState: "signed-out",
    289          });
    290        }
    291        return this.#getMessageCardForState({ action: "sign-in" });
    292      case 2 /* connect-secondary-device*/:
    293        return this.#getMessageCardForState({ action: "add-device" });
    294      case 3 /* disabled-tab-sync */:
    295        return this.#getMessageCardForState({ action: "sync-tabs-disabled" });
    296      case 4 /* synced-tabs-loaded*/:
    297        // There seems to be an edge case where sync says everything worked
    298        // fine but we have no devices.
    299        if (!this.devices.length) {
    300          return this.#getMessageCardForState({ action: "add-device" });
    301        }
    302    }
    303    return null;
    304  }
    305 
    306  /**
    307   * Turn renderInfo into a list of tabs for syncedtabs-tab-list
    308   *
    309   * @param {object} renderInfo
    310   * @param {Array<object>} [renderInfo.tabs]
    311   *   tabs to display to the user
    312   * @param {string} [renderInfo.name]
    313   *   The name of the device for use when the user hovers over
    314   *   the close button for context
    315   * @param {boolean} [renderInfo.canClose]
    316   *   Whether the list should support remotely closing tabs
    317   */
    318  getTabItems({ tabs, name, canClose }) {
    319    return tabs
    320      ?.map(tab => {
    321        let tabItem = {
    322          icon: tab.icon,
    323          title: tab.title,
    324          time: tab.lastUsed * 1000,
    325          url: tab.url,
    326          fxaDeviceId: tab.fxaDeviceId,
    327          primaryL10nId: "firefoxview-tabs-list-tab-button",
    328          primaryL10nArgs: JSON.stringify({ targetURI: tab.url }),
    329          secondaryL10nId: this.contextMenu
    330            ? "fxviewtabrow-options-menu-button"
    331            : undefined,
    332          secondaryL10nArgs: this.contextMenu
    333            ? JSON.stringify({ tabTitle: tab.title })
    334            : undefined,
    335        };
    336        // We don't want to show the option to close remotely if the
    337        // device doesn't support it
    338        if (!canClose) {
    339          return tabItem;
    340        }
    341 
    342        // If this item has been requested to be closed, show
    343        // the undo instead until removed from the list
    344        if (tabItem.url === this.lastClosedURL) {
    345          tabItem.tertiaryL10nId = "text-action-undo";
    346          tabItem.tertiaryActionClass = "undo-button";
    347          tabItem.tertiaryL10nArgs = null;
    348          tabItem.closeRequested = true;
    349        } else {
    350          // Otherwise default to showing the close/dismiss button
    351          tabItem.tertiaryL10nId = "synced-tabs-context-close-tab-title";
    352          tabItem.tertiaryL10nArgs = JSON.stringify({ deviceName: name });
    353          tabItem.tertiaryActionClass = "dismiss-button";
    354          tabItem.closeRequested = false;
    355        }
    356        return tabItem;
    357      })
    358      .filter(
    359        item =>
    360          !this.isURLQueuedToClose(item.fxaDeviceId, item.url) ||
    361          item.url === this.lastClosedURL
    362      );
    363  }
    364 
    365  updateTabsList(syncedTabs) {
    366    if (!syncedTabs.length) {
    367      this.currentSyncedTabs = syncedTabs;
    368    }
    369 
    370    const tabsToRender = syncedTabs;
    371 
    372    // Return early if new tabs are the same as previous ones
    373    if (lazy.ObjectUtils.deepEqual(tabsToRender, this.currentSyncedTabs)) {
    374      return;
    375    }
    376 
    377    this.currentSyncedTabs = tabsToRender;
    378    this.host.requestUpdate();
    379  }
    380 
    381  async getSyncedTabData() {
    382    this.devices = await lazy.SyncedTabs.getTabClients();
    383    let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 5000, {
    384      removeAllDupes: false,
    385      removeDeviceDupes: true,
    386    });
    387 
    388    this.updateTabsList(tabs);
    389  }
    390 
    391  // Wrappers and helpful methods for SyncedTabManagement
    392  // so FxView and Sidebar don't need to import
    393  requestCloseRemoteTab(fxaDeviceId, url) {
    394    if (!this._pendingCloseTabs.has(fxaDeviceId)) {
    395      this._pendingCloseTabs.set(fxaDeviceId, new Set());
    396    }
    397    this._pendingCloseTabs.get(fxaDeviceId).add(url);
    398    this.lastClosedURL = url;
    399    lazy.SyncedTabsManagement.enqueueTabToClose(fxaDeviceId, url);
    400  }
    401 
    402  removePendingTabToClose(fxaDeviceId, url) {
    403    const urls = this._pendingCloseTabs.get(fxaDeviceId);
    404    if (urls) {
    405      urls.delete(url);
    406      if (!urls.size) {
    407        this._pendingCloseTabs.delete(fxaDeviceId);
    408      }
    409    }
    410    this.lastClosedURL = null;
    411    lazy.SyncedTabsManagement.removePendingTabToClose(fxaDeviceId, url);
    412  }
    413 
    414  isURLQueuedToClose(fxaDeviceId, url) {
    415    const urls = this._pendingCloseTabs.get(fxaDeviceId);
    416    return urls && urls.has(url);
    417  }
    418 }