tor-browser

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

addons.sys.mjs (25410B)


      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 file defines the add-on sync functionality.
      7 *
      8 * There are currently a number of known limitations:
      9 *  - We only sync XPI extensions and themes available from addons.mozilla.org.
     10 *    We hope to expand support for other add-ons eventually.
     11 *  - We only attempt syncing of add-ons between applications of the same type.
     12 *    This means add-ons will not synchronize between Firefox desktop and
     13 *    Firefox mobile, for example. This is because of significant add-on
     14 *    incompatibility between application types.
     15 *
     16 * Add-on records exist for each known {add-on, app-id} pair in the Sync client
     17 * set. Each record has a randomly chosen GUID. The records then contain
     18 * basic metadata about the add-on.
     19 *
     20 * We currently synchronize:
     21 *
     22 *  - Installations
     23 *  - Uninstallations
     24 *  - User enabling and disabling
     25 *
     26 * Synchronization is influenced by the following preferences:
     27 *
     28 *  - services.sync.addons.ignoreUserEnabledChanges
     29 *  - services.sync.addons.trustedSourceHostnames
     30 *
     31 *  and also influenced by whether addons have repository caching enabled and
     32 *  whether they allow installation of addons from insecure options (both of
     33 *  which are themselves influenced by the "extensions." pref branch)
     34 *
     35 * See the documentation in all.js for the behavior of these prefs.
     36 */
     37 
     38 import { AddonUtils } from "resource://services-sync/addonutils.sys.mjs";
     39 import { AddonsReconciler } from "resource://services-sync/addonsreconciler.sys.mjs";
     40 import {
     41  Store,
     42  SyncEngine,
     43  LegacyTracker,
     44 } from "resource://services-sync/engines.sys.mjs";
     45 import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
     46 import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
     47 
     48 import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
     49 import { CollectionValidator } from "resource://services-sync/collection_validator.sys.mjs";
     50 
     51 const lazy = {};
     52 
     53 ChromeUtils.defineESModuleGetters(lazy, {
     54  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
     55  AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
     56 });
     57 
     58 // 7 days in milliseconds.
     59 const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000;
     60 
     61 /**
     62 * AddonRecord represents the state of an add-on in an application.
     63 *
     64 * Each add-on has its own record for each application ID it is installed
     65 * on.
     66 *
     67 * The ID of add-on records is a randomly-generated GUID. It is random instead
     68 * of deterministic so the URIs of the records cannot be guessed and so
     69 * compromised server credentials won't result in disclosure of the specific
     70 * add-ons present in a Sync account.
     71 *
     72 * The record contains the following fields:
     73 *
     74 *  addonID
     75 *    ID of the add-on. This correlates to the "id" property on an Addon type.
     76 *
     77 *  applicationID
     78 *    The application ID this record is associated with.
     79 *
     80 *  enabled
     81 *    Boolean stating whether add-on is enabled or disabled by the user.
     82 *
     83 *  source
     84 *    String indicating where an add-on is from. Currently, we only support
     85 *    the value "amo" which indicates that the add-on came from the official
     86 *    add-ons repository, addons.mozilla.org. In the future, we may support
     87 *    installing add-ons from other sources. This provides a future-compatible
     88 *    mechanism for clients to only apply records they know how to handle.
     89 */
     90 function AddonRecord(collection, id) {
     91  CryptoWrapper.call(this, collection, id);
     92 }
     93 AddonRecord.prototype = {
     94  _logName: "Record.Addon",
     95 };
     96 Object.setPrototypeOf(AddonRecord.prototype, CryptoWrapper.prototype);
     97 
     98 Utils.deferGetSet(AddonRecord, "cleartext", [
     99  "addonID",
    100  "applicationID",
    101  "enabled",
    102  "source",
    103 ]);
    104 
    105 /**
    106 * The AddonsEngine handles synchronization of add-ons between clients.
    107 *
    108 * The engine maintains an instance of an AddonsReconciler, which is the entity
    109 * maintaining state for add-ons. It provides the history and tracking APIs
    110 * that AddonManager doesn't.
    111 *
    112 * The engine instance overrides a handful of functions on the base class. The
    113 * rationale for each is documented by that function.
    114 */
    115 export function AddonsEngine(service) {
    116  SyncEngine.call(this, "Addons", service);
    117 
    118  this._reconciler = new AddonsReconciler(this._tracker.asyncObserver);
    119 }
    120 
    121 AddonsEngine.prototype = {
    122  _storeObj: AddonsStore,
    123  _trackerObj: AddonsTracker,
    124  _recordObj: AddonRecord,
    125  version: 1,
    126 
    127  syncPriority: 5,
    128 
    129  _reconciler: null,
    130 
    131  async initialize() {
    132    await SyncEngine.prototype.initialize.call(this);
    133    await this._reconciler.ensureStateLoaded();
    134  },
    135 
    136  /**
    137   * Override parent method to find add-ons by their public ID, not Sync GUID.
    138   */
    139  async _findDupe(item) {
    140    let id = item.addonID;
    141 
    142    // The reconciler should have been updated at the top of the sync, so we
    143    // can assume it is up to date when this function is called.
    144    let addons = this._reconciler.addons;
    145    if (!(id in addons)) {
    146      return null;
    147    }
    148 
    149    let addon = addons[id];
    150    if (addon.guid != item.id) {
    151      return addon.guid;
    152    }
    153 
    154    return null;
    155  },
    156 
    157  /**
    158   * Override getChangedIDs to pull in tracker changes plus changes from the
    159   * reconciler log.
    160   */
    161  async getChangedIDs() {
    162    let changes = {};
    163    const changedIDs = await this._tracker.getChangedIDs();
    164    for (let [id, modified] of Object.entries(changedIDs)) {
    165      changes[id] = modified;
    166    }
    167 
    168    let lastSync = await this.getLastSync();
    169    let lastSyncDate = new Date(lastSync * 1000);
    170 
    171    // The reconciler should have been refreshed at the beginning of a sync and
    172    // we assume this function is only called from within a sync.
    173    let reconcilerChanges = this._reconciler.getChangesSinceDate(lastSyncDate);
    174    let addons = this._reconciler.addons;
    175    for (let change of reconcilerChanges) {
    176      let changeTime = change[0];
    177      let id = change[2];
    178 
    179      if (!(id in addons)) {
    180        continue;
    181      }
    182 
    183      // Keep newest modified time.
    184      if (id in changes && changeTime < changes[id]) {
    185        continue;
    186      }
    187 
    188      if (!(await this.isAddonSyncable(addons[id]))) {
    189        continue;
    190      }
    191 
    192      this._log.debug("Adding changed add-on from changes log: " + id);
    193      let addon = addons[id];
    194      changes[addon.guid] = changeTime.getTime() / 1000;
    195    }
    196 
    197    return changes;
    198  },
    199 
    200  /**
    201   * Override start of sync function to refresh reconciler.
    202   *
    203   * Many functions in this class assume the reconciler is refreshed at the
    204   * top of a sync. If this ever changes, those functions should be revisited.
    205   *
    206   * Technically speaking, we don't need to refresh the reconciler on every
    207   * sync since it is installed as an AddonManager listener. However, add-ons
    208   * are complicated and we force a full refresh, just in case the listeners
    209   * missed something.
    210   */
    211  async _syncStartup() {
    212    // We refresh state before calling parent because syncStartup in the parent
    213    // looks for changed IDs, which is dependent on add-on state being up to
    214    // date.
    215    await this._refreshReconcilerState();
    216    return SyncEngine.prototype._syncStartup.call(this);
    217  },
    218 
    219  /**
    220   * Override end of sync to perform a little housekeeping on the reconciler.
    221   *
    222   * We prune changes to prevent the reconciler state from growing without
    223   * bound. Even if it grows unbounded, there would have to be many add-on
    224   * changes (thousands) for it to slow things down significantly. This is
    225   * highly unlikely to occur. Still, we exercise defense just in case.
    226   */
    227  async _syncCleanup() {
    228    let lastSync = await this.getLastSync();
    229    let ms = 1000 * lastSync - PRUNE_ADDON_CHANGES_THRESHOLD;
    230    this._reconciler.pruneChangesBeforeDate(new Date(ms));
    231    return SyncEngine.prototype._syncCleanup.call(this);
    232  },
    233 
    234  /**
    235   * Helper function to ensure reconciler is up to date.
    236   *
    237   * This will load the reconciler's state from the file
    238   * system (if needed) and refresh the state of the reconciler.
    239   */
    240  async _refreshReconcilerState() {
    241    this._log.debug("Refreshing reconciler state");
    242    return this._reconciler.refreshGlobalState();
    243  },
    244 
    245  // Returns a promise
    246  isAddonSyncable(addon, ignoreRepoCheck) {
    247    return this._store.isAddonSyncable(addon, ignoreRepoCheck);
    248  },
    249 };
    250 Object.setPrototypeOf(AddonsEngine.prototype, SyncEngine.prototype);
    251 
    252 /**
    253 * This is the primary interface between Sync and the Addons Manager.
    254 *
    255 * In addition to the core store APIs, we provide convenience functions to wrap
    256 * Add-on Manager APIs with Sync-specific semantics.
    257 */
    258 function AddonsStore(name, engine) {
    259  Store.call(this, name, engine);
    260 }
    261 AddonsStore.prototype = {
    262  // Define the add-on types (.type) that we support.
    263  _syncableTypes: ["extension", "theme"],
    264 
    265  _extensionsPrefs: Services.prefs.getBranch("extensions."),
    266 
    267  get reconciler() {
    268    return this.engine._reconciler;
    269  },
    270 
    271  /**
    272   * Override applyIncoming to filter out records we can't handle.
    273   */
    274  async applyIncoming(record) {
    275    // The fields we look at aren't present when the record is deleted.
    276    if (!record.deleted) {
    277      // Ignore records not belonging to our application ID because that is the
    278      // current policy.
    279      if (record.applicationID != Services.appinfo.ID) {
    280        this._log.info(
    281          "Ignoring incoming record from other App ID: " + record.id
    282        );
    283        return;
    284      }
    285 
    286      // Ignore records that aren't from the official add-on repository, as that
    287      // is our current policy.
    288      if (record.source != "amo") {
    289        this._log.info(
    290          "Ignoring unknown add-on source (" +
    291            record.source +
    292            ")" +
    293            " for " +
    294            record.id
    295        );
    296        return;
    297      }
    298    }
    299 
    300    // Ignore incoming records for which an existing non-syncable addon
    301    // exists. Note that we do not insist that the addon manager already have
    302    // metadata for this addon - it's possible our reconciler previously saw the
    303    // addon but the addon-manager cache no longer has it - which is fine for a
    304    // new incoming addon.
    305    // (Note that most other cases where the addon-manager cache is invalid
    306    // doesn't get this treatment because that cache self-repairs after some
    307    // time - but it only re-populates addons which are currently installed.)
    308    let existingMeta = this.reconciler.addons[record.addonID];
    309    if (
    310      existingMeta &&
    311      !(await this.isAddonSyncable(existingMeta, /* ignoreRepoCheck */ true))
    312    ) {
    313      this._log.info(
    314        "Ignoring incoming record for an existing but non-syncable addon",
    315        record.addonID
    316      );
    317      return;
    318    }
    319 
    320    await Store.prototype.applyIncoming.call(this, record);
    321  },
    322 
    323  /**
    324   * Provides core Store API to create/install an add-on from a record.
    325   */
    326  async create(record) {
    327    // This will throw if there was an error. This will get caught by the sync
    328    // engine and the record will try to be applied later.
    329    const results = await AddonUtils.installAddons([
    330      {
    331        id: record.addonID,
    332        syncGUID: record.id,
    333        enabled: record.enabled,
    334        requireSecureURI: this._extensionsPrefs.getBoolPref(
    335          "install.requireSecureOrigin",
    336          true
    337        ),
    338      },
    339    ]);
    340 
    341    if (results.skipped.includes(record.addonID)) {
    342      this._log.info("Add-on skipped: " + record.addonID);
    343      // Just early-return for skipped addons - we don't want to arrange to
    344      // try again next time because the condition that caused up to skip
    345      // will remain true for this addon forever.
    346      return;
    347    }
    348 
    349    let addon;
    350    for (let a of results.addons) {
    351      if (a.id == record.addonID) {
    352        addon = a;
    353        break;
    354      }
    355    }
    356 
    357    // This should never happen, but is present as a fail-safe.
    358    if (!addon) {
    359      throw new Error("Add-on not found after install: " + record.addonID);
    360    }
    361 
    362    this._log.info("Add-on installed: " + record.addonID);
    363  },
    364 
    365  /**
    366   * Provides core Store API to remove/uninstall an add-on from a record.
    367   */
    368  async remove(record) {
    369    // If this is called, the payload is empty, so we have to find by GUID.
    370    let addon = await this.getAddonByGUID(record.id);
    371    if (!addon) {
    372      // We don't throw because if the add-on could not be found then we assume
    373      // it has already been uninstalled and there is nothing for this function
    374      // to do.
    375      return;
    376    }
    377 
    378    this._log.info("Uninstalling add-on: " + addon.id);
    379    await AddonUtils.uninstallAddon(addon);
    380  },
    381 
    382  /**
    383   * Provides core Store API to update an add-on from a record.
    384   */
    385  async update(record) {
    386    let addon = await this.getAddonByID(record.addonID);
    387 
    388    // update() is called if !this.itemExists. And, since itemExists consults
    389    // the reconciler only, we need to take care of some corner cases.
    390    //
    391    // First, the reconciler could know about an add-on that was uninstalled
    392    // and no longer present in the add-ons manager.
    393    if (!addon) {
    394      await this.create(record);
    395      return;
    396    }
    397 
    398    // It's also possible that the add-on is non-restartless and has pending
    399    // install/uninstall activity.
    400    //
    401    // We wouldn't get here if the incoming record was for a deletion. So,
    402    // check for pending uninstall and cancel if necessary.
    403    if (addon.pendingOperations & lazy.AddonManager.PENDING_UNINSTALL) {
    404      addon.cancelUninstall();
    405 
    406      // We continue with processing because there could be state or ID change.
    407    }
    408 
    409    await this.updateUserDisabled(addon, !record.enabled);
    410  },
    411 
    412  /**
    413   * Provide core Store API to determine if a record exists.
    414   */
    415  async itemExists(guid) {
    416    let addon = this.reconciler.getAddonStateFromSyncGUID(guid);
    417 
    418    return !!addon;
    419  },
    420 
    421  /**
    422   * Create an add-on record from its GUID.
    423   *
    424   * @param guid
    425   *        Add-on GUID (from extensions DB)
    426   * @param collection
    427   *        Collection to add record to.
    428   *
    429   * @return AddonRecord instance
    430   */
    431  async createRecord(guid, collection) {
    432    let record = new AddonRecord(collection, guid);
    433    record.applicationID = Services.appinfo.ID;
    434 
    435    let addon = this.reconciler.getAddonStateFromSyncGUID(guid);
    436 
    437    // If we don't know about this GUID or if it has been uninstalled, we mark
    438    // the record as deleted.
    439    if (!addon || !addon.installed) {
    440      record.deleted = true;
    441      return record;
    442    }
    443 
    444    record.modified = addon.modified.getTime() / 1000;
    445 
    446    record.addonID = addon.id;
    447    record.enabled = addon.enabled;
    448 
    449    // This needs to be dynamic when add-ons don't come from AddonRepository.
    450    record.source = "amo";
    451 
    452    return record;
    453  },
    454 
    455  /**
    456   * Changes the id of an add-on.
    457   *
    458   * This implements a core API of the store.
    459   */
    460  async changeItemID(oldID, newID) {
    461    // We always update the GUID in the reconciler because it will be
    462    // referenced later in the sync process.
    463    let state = this.reconciler.getAddonStateFromSyncGUID(oldID);
    464    if (state) {
    465      state.guid = newID;
    466      await this.reconciler.saveState();
    467    }
    468 
    469    let addon = await this.getAddonByGUID(oldID);
    470    if (!addon) {
    471      this._log.debug(
    472        "Cannot change item ID (" +
    473          oldID +
    474          ") in Add-on " +
    475          "Manager because old add-on not present: " +
    476          oldID
    477      );
    478      return;
    479    }
    480 
    481    addon.syncGUID = newID;
    482  },
    483 
    484  /**
    485   * Obtain the set of all syncable add-on Sync GUIDs.
    486   *
    487   * This implements a core Store API.
    488   */
    489  async getAllIDs() {
    490    let ids = {};
    491 
    492    let addons = this.reconciler.addons;
    493    for (let id in addons) {
    494      let addon = addons[id];
    495      if (await this.isAddonSyncable(addon)) {
    496        ids[addon.guid] = true;
    497      }
    498    }
    499 
    500    return ids;
    501  },
    502 
    503  /**
    504   * Wipe engine data.
    505   *
    506   * This uninstalls all syncable addons from the application. In case of
    507   * error, it logs the error and keeps trying with other add-ons.
    508   */
    509  async wipe() {
    510    this._log.info("Processing wipe.");
    511 
    512    await this.engine._refreshReconcilerState();
    513 
    514    // We only wipe syncable add-ons. Wipe is a Sync feature not a security
    515    // feature.
    516    let ids = await this.getAllIDs();
    517    for (let guid in ids) {
    518      let addon = await this.getAddonByGUID(guid);
    519      if (!addon) {
    520        this._log.debug(
    521          "Ignoring add-on because it couldn't be obtained: " + guid
    522        );
    523        continue;
    524      }
    525 
    526      this._log.info("Uninstalling add-on as part of wipe: " + addon.id);
    527      await Utils.catch.call(this, () => addon.uninstall())();
    528    }
    529  },
    530 
    531  /***************************************************************************
    532   * Functions below are unique to this store and not part of the Store API  *
    533   ***************************************************************************/
    534 
    535  /**
    536   * Obtain an add-on from its public ID.
    537   *
    538   * @param id
    539   *        Add-on ID
    540   * @return Addon or undefined if not found
    541   */
    542  async getAddonByID(id) {
    543    return lazy.AddonManager.getAddonByID(id);
    544  },
    545 
    546  /**
    547   * Obtain an add-on from its Sync GUID.
    548   *
    549   * @param  guid
    550   *         Add-on Sync GUID
    551   * @return DBAddonInternal or null
    552   */
    553  async getAddonByGUID(guid) {
    554    return lazy.AddonManager.getAddonBySyncGUID(guid);
    555  },
    556 
    557  /**
    558   * Determines whether an add-on is suitable for Sync.
    559   *
    560   * @param  addon
    561   *         Addon instance
    562   * @param ignoreRepoCheck
    563   *         Should we skip checking the Addons repository (primarially useful
    564   *         for testing and validation).
    565   * @return Boolean indicating whether it is appropriate for Sync
    566   */
    567  async isAddonSyncable(addon, ignoreRepoCheck = false) {
    568    // Currently, we limit syncable add-ons to those that are:
    569    //   1) In a well-defined set of types
    570    //   2) Installed in the current profile
    571    //   3) Not installed by a foreign entity (i.e. installed by the app)
    572    //      since they act like global extensions.
    573    //   4) Is not a hotfix.
    574    //   5) The addons XPIProvider doesn't veto it (i.e not being installed in
    575    //      the profile directory, or any other reasons it says the addon can't
    576    //      be synced)
    577    //   6) Are installed from AMO
    578 
    579    // We could represent the test as a complex boolean expression. We go the
    580    // verbose route so the failure reason is logged.
    581    if (!addon) {
    582      this._log.debug("Null object passed to isAddonSyncable.");
    583      return false;
    584    }
    585 
    586    if (!this._syncableTypes.includes(addon.type)) {
    587      this._log.debug(
    588        addon.id + " not syncable: type not in allowed list: " + addon.type
    589      );
    590      return false;
    591    }
    592 
    593    if (!(addon.scope & lazy.AddonManager.SCOPE_PROFILE)) {
    594      this._log.debug(addon.id + " not syncable: not installed in profile.");
    595      return false;
    596    }
    597 
    598    // If the addon manager says it's not syncable, we skip it.
    599    if (!addon.isSyncable) {
    600      this._log.debug(addon.id + " not syncable: vetoed by the addon manager.");
    601      return false;
    602    }
    603 
    604    // This may be too aggressive. If an add-on is downloaded from AMO and
    605    // manually placed in the profile directory, foreignInstall will be set.
    606    // Arguably, that add-on should be syncable.
    607    // TODO Address the edge case and come up with more robust heuristics.
    608    if (addon.foreignInstall) {
    609      this._log.debug(addon.id + " not syncable: is foreign install.");
    610      return false;
    611    }
    612 
    613    // If the AddonRepository's cache isn't enabled (which it typically isn't
    614    // in tests), getCachedAddonByID always returns null - so skip the check
    615    // in that case. We also provide a way to specifically opt-out of the check
    616    // even if the cache is enabled, which is used by the validators.
    617    if (ignoreRepoCheck || !lazy.AddonRepository.cacheEnabled) {
    618      return true;
    619    }
    620 
    621    let result = await new Promise(res => {
    622      lazy.AddonRepository.getCachedAddonByID(addon.id, res);
    623    });
    624 
    625    if (!result) {
    626      this._log.debug(
    627        addon.id + " not syncable: add-on not found in add-on repository."
    628      );
    629      return false;
    630    }
    631 
    632    return this.isSourceURITrusted(result.sourceURI);
    633  },
    634 
    635  /**
    636   * Determine whether an add-on's sourceURI field is trusted and the add-on
    637   * can be installed.
    638   *
    639   * This function should only ever be called from isAddonSyncable(). It is
    640   * exposed as a separate function to make testing easier.
    641   *
    642   * @param  uri
    643   *         nsIURI instance to validate
    644   * @return bool
    645   */
    646  isSourceURITrusted: function isSourceURITrusted(uri) {
    647    // For security reasons, we currently limit synced add-ons to those
    648    // installed from trusted hostname(s). We additionally require TLS with
    649    // the add-ons site to help prevent forgeries.
    650    let trustedHostnames = Svc.PrefBranch.getStringPref(
    651      "addons.trustedSourceHostnames",
    652      ""
    653    ).split(",");
    654 
    655    if (!uri) {
    656      this._log.debug("Undefined argument to isSourceURITrusted().");
    657      return false;
    658    }
    659 
    660    // Scheme is validated before the hostname because uri.host may not be
    661    // populated for certain schemes. It appears to always be populated for
    662    // https, so we avoid the potential NS_ERROR_FAILURE on field access.
    663    if (uri.scheme != "https") {
    664      this._log.debug("Source URI not HTTPS: " + uri.spec);
    665      return false;
    666    }
    667 
    668    if (!trustedHostnames.includes(uri.host)) {
    669      this._log.debug("Source hostname not trusted: " + uri.host);
    670      return false;
    671    }
    672 
    673    return true;
    674  },
    675 
    676  /**
    677   * Update the userDisabled flag on an add-on.
    678   *
    679   * This will enable or disable an add-on. It has no return value and does
    680   * not catch or handle exceptions thrown by the addon manager. If no action
    681   * is needed it will return immediately.
    682   *
    683   * @param addon
    684   *        Addon instance to manipulate.
    685   * @param value
    686   *        Boolean to which to set userDisabled on the passed Addon.
    687   */
    688  async updateUserDisabled(addon, value) {
    689    if (addon.userDisabled == value) {
    690      return;
    691    }
    692 
    693    // A pref allows changes to the enabled flag to be ignored.
    694    if (Svc.PrefBranch.getBoolPref("addons.ignoreUserEnabledChanges", false)) {
    695      this._log.info(
    696        "Ignoring enabled state change due to preference: " + addon.id
    697      );
    698      return;
    699    }
    700 
    701    AddonUtils.updateUserDisabled(addon, value);
    702    // updating this flag doesn't send a notification for appDisabled addons,
    703    // meaning the reconciler will not update its state and may resync the
    704    // addon - so explicitly rectify the state (bug 1366994)
    705    if (addon.appDisabled) {
    706      await this.reconciler.rectifyStateFromAddon(addon);
    707    }
    708  },
    709 };
    710 
    711 Object.setPrototypeOf(AddonsStore.prototype, Store.prototype);
    712 
    713 /**
    714 * The add-ons tracker keeps track of real-time changes to add-ons.
    715 *
    716 * It hooks up to the reconciler and receives notifications directly from it.
    717 */
    718 function AddonsTracker(name, engine) {
    719  LegacyTracker.call(this, name, engine);
    720 }
    721 AddonsTracker.prototype = {
    722  get reconciler() {
    723    return this.engine._reconciler;
    724  },
    725 
    726  get store() {
    727    return this.engine._store;
    728  },
    729 
    730  /**
    731   * This callback is executed whenever the AddonsReconciler sends out a change
    732   * notification. See AddonsReconciler.addChangeListener().
    733   */
    734  async changeListener(date, change, addon) {
    735    this._log.debug("changeListener invoked: " + change + " " + addon.id);
    736    // Ignore changes that occur during sync.
    737    if (this.ignoreAll) {
    738      return;
    739    }
    740 
    741    if (!(await this.store.isAddonSyncable(addon))) {
    742      this._log.debug(
    743        "Ignoring change because add-on isn't syncable: " + addon.id
    744      );
    745      return;
    746    }
    747 
    748    const added = await this.addChangedID(addon.guid, date.getTime() / 1000);
    749    if (added) {
    750      this.score += SCORE_INCREMENT_XLARGE;
    751    }
    752  },
    753 
    754  onStart() {
    755    this.reconciler.startListening();
    756    this.reconciler.addChangeListener(this);
    757  },
    758 
    759  onStop() {
    760    this.reconciler.removeChangeListener(this);
    761    this.reconciler.stopListening();
    762  },
    763 };
    764 
    765 Object.setPrototypeOf(AddonsTracker.prototype, LegacyTracker.prototype);
    766 
    767 export class AddonValidator extends CollectionValidator {
    768  constructor(engine = null) {
    769    super("addons", "id", ["addonID", "enabled", "applicationID", "source"]);
    770    this.engine = engine;
    771  }
    772 
    773  async getClientItems() {
    774    return lazy.AddonManager.getAllAddons();
    775  }
    776 
    777  normalizeClientItem(item) {
    778    let enabled = !item.userDisabled;
    779    if (item.pendingOperations & lazy.AddonManager.PENDING_ENABLE) {
    780      enabled = true;
    781    } else if (item.pendingOperations & lazy.AddonManager.PENDING_DISABLE) {
    782      enabled = false;
    783    }
    784    return {
    785      enabled,
    786      id: item.syncGUID,
    787      addonID: item.id,
    788      applicationID: Services.appinfo.ID,
    789      source: "amo", // check item.foreignInstall?
    790      original: item,
    791    };
    792  }
    793 
    794  async normalizeServerItem(item) {
    795    let guid = await this.engine._findDupe(item);
    796    if (guid) {
    797      item.id = guid;
    798    }
    799    return item;
    800  }
    801 
    802  clientUnderstands(item) {
    803    return item.applicationID === Services.appinfo.ID;
    804  }
    805 
    806  async syncedByClient(item) {
    807    return (
    808      !item.original.hidden &&
    809      !item.original.isSystem &&
    810      !(
    811        item.original.pendingOperations & lazy.AddonManager.PENDING_UNINSTALL
    812      ) &&
    813      // No need to await the returned promise explicitely:
    814      // |expr1 && expr2| evaluates to expr2 if expr1 is true.
    815      this.engine.isAddonSyncable(item.original, true)
    816    );
    817  }
    818 }