tor-browser

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

addonsreconciler.sys.mjs (17498B)


      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 contains middleware to reconcile state of AddonManager for
      7 * purposes of tracking events for Sync. The content in this file exists
      8 * because AddonManager does not have a getChangesSinceX() API and adding
      9 * that functionality properly was deemed too time-consuming at the time
     10 * add-on sync was originally written. If/when AddonManager adds this API,
     11 * this file can go away and the add-ons engine can be rewritten to use it.
     12 *
     13 * It was decided to have this tracking functionality exist in a separate
     14 * standalone file so it could be more easily understood, tested, and
     15 * hopefully ported.
     16 */
     17 
     18 import { Log } from "resource://gre/modules/Log.sys.mjs";
     19 
     20 import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
     21 
     22 import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs";
     23 
     24 const DEFAULT_STATE_FILE = "addonsreconciler";
     25 
     26 export var CHANGE_INSTALLED = 1;
     27 export var CHANGE_UNINSTALLED = 2;
     28 export var CHANGE_ENABLED = 3;
     29 export var CHANGE_DISABLED = 4;
     30 
     31 /**
     32 * Maintains state of add-ons.
     33 *
     34 * State is maintained in 2 data structures, an object mapping add-on IDs
     35 * to metadata and an array of changes over time. The object mapping can be
     36 * thought of as a minimal copy of data from AddonManager which is needed for
     37 * Sync. The array is effectively a log of changes over time.
     38 *
     39 * The data structures are persisted to disk by serializing to a JSON file in
     40 * the current profile. The data structures are updated by 2 mechanisms. First,
     41 * they can be refreshed from the global state of the AddonManager. This is a
     42 * sure-fire way of ensuring the reconciler is up to date. Second, the
     43 * reconciler adds itself as an AddonManager listener. When it receives change
     44 * notifications, it updates its internal state incrementally.
     45 *
     46 * The internal state is persisted to a JSON file in the profile directory.
     47 *
     48 * An instance of this is bound to an AddonsEngine instance. In reality, it
     49 * likely exists as a singleton. To AddonsEngine, it functions as a store and
     50 * an entity which emits events for tracking.
     51 *
     52 * The usage pattern for instances of this class is:
     53 *
     54 *   let reconciler = new AddonsReconciler(...);
     55 *   await reconciler.ensureStateLoaded();
     56 *
     57 *   // At this point, your instance should be ready to use.
     58 *
     59 * When you are finished with the instance, please call:
     60 *
     61 *   reconciler.stopListening();
     62 *   await reconciler.saveState(...);
     63 *
     64 * This class uses the AddonManager AddonListener interface.
     65 * When an add-on is installed, listeners are called in the following order:
     66 *  AL.onInstalling, AL.onInstalled
     67 *
     68 * For uninstalls, we see AL.onUninstalling then AL.onUninstalled.
     69 *
     70 * Enabling and disabling work by sending:
     71 *
     72 *   AL.onEnabling, AL.onEnabled
     73 *   AL.onDisabling, AL.onDisabled
     74 *
     75 * Actions can be undone. All undoable actions notify the same
     76 * AL.onOperationCancelled event. We treat this event like any other.
     77 *
     78 * When an add-on is uninstalled from about:addons, the user is offered an
     79 * "Undo" option, which leads to the following sequence of events as
     80 * observed by an AddonListener:
     81 * Add-ons are first disabled then they are actually uninstalled. So, we will
     82 * see AL.onDisabling and AL.onDisabled. The onUninstalling and onUninstalled
     83 * events only come after the Addon Manager is closed or another view is
     84 * switched to. In the case of Sync performing the uninstall, the uninstall
     85 * events will occur immediately. However, we still see disabling events and
     86 * heed them like they were normal. In the end, the state is proper.
     87 */
     88 export function AddonsReconciler(queueCaller) {
     89  this._log = Log.repository.getLogger("Sync.AddonsReconciler");
     90  this._log.manageLevelFromPref("services.sync.log.logger.addonsreconciler");
     91  this.queueCaller = queueCaller;
     92 
     93  Svc.Obs.add("xpcom-shutdown", this.stopListening, this);
     94 }
     95 
     96 AddonsReconciler.prototype = {
     97  /** Flag indicating whether we are listening to AddonManager events. */
     98  _listening: false,
     99 
    100  /**
    101   * Define this as false if the reconciler should not persist state
    102   * to disk when handling events.
    103   *
    104   * This allows test code to avoid spinning to write during observer
    105   * notifications and xpcom shutdown, which appears to cause hangs on WinXP
    106   * (Bug 873861).
    107   */
    108  _shouldPersist: true,
    109 
    110  /** Log logger instance */
    111  _log: null,
    112 
    113  /**
    114   * Container for add-on metadata.
    115   *
    116   * Keys are add-on IDs. Values are objects which describe the state of the
    117   * add-on. This is a minimal mirror of data that can be queried from
    118   * AddonManager. In some cases, we retain data longer than AddonManager.
    119   */
    120  _addons: {},
    121 
    122  /**
    123   * List of add-on changes over time.
    124   *
    125   * Each element is an array of [time, change, id].
    126   */
    127  _changes: [],
    128 
    129  /**
    130   * Objects subscribed to changes made to this instance.
    131   */
    132  _listeners: [],
    133 
    134  /**
    135   * Accessor for add-ons in this object.
    136   *
    137   * Returns an object mapping add-on IDs to objects containing metadata.
    138   */
    139  get addons() {
    140    return this._addons;
    141  },
    142 
    143  async ensureStateLoaded() {
    144    if (!this._promiseStateLoaded) {
    145      this._promiseStateLoaded = this.loadState();
    146    }
    147    return this._promiseStateLoaded;
    148  },
    149 
    150  /**
    151   * Load reconciler state from a file.
    152   *
    153   * The path is relative to the weave directory in the profile. If no
    154   * path is given, the default one is used.
    155   *
    156   * If the file does not exist or there was an error parsing the file, the
    157   * state will be transparently defined as empty.
    158   *
    159   * @param file
    160   *        Path to load. ".json" is appended automatically. If not defined,
    161   *        a default path will be consulted.
    162   */
    163  async loadState(file = DEFAULT_STATE_FILE) {
    164    let json = await Utils.jsonLoad(file, this);
    165    this._addons = {};
    166    this._changes = [];
    167 
    168    if (!json) {
    169      this._log.debug("No data seen in loaded file: " + file);
    170      return false;
    171    }
    172 
    173    let version = json.version;
    174    if (!version || version != 1) {
    175      this._log.error(
    176        "Could not load JSON file because version not " +
    177          "supported: " +
    178          version
    179      );
    180      return false;
    181    }
    182 
    183    this._addons = json.addons;
    184    for (let id in this._addons) {
    185      let record = this._addons[id];
    186      record.modified = new Date(record.modified);
    187    }
    188 
    189    for (let [time, change, id] of json.changes) {
    190      this._changes.push([new Date(time), change, id]);
    191    }
    192 
    193    return true;
    194  },
    195 
    196  /**
    197   * Saves the current state to a file in the local profile.
    198   *
    199   * @param  file
    200   *         String path in profile to save to. If not defined, the default
    201   *         will be used.
    202   */
    203  async saveState(file = DEFAULT_STATE_FILE) {
    204    let state = { version: 1, addons: {}, changes: [] };
    205 
    206    for (let [id, record] of Object.entries(this._addons)) {
    207      state.addons[id] = {};
    208      for (let [k, v] of Object.entries(record)) {
    209        if (k == "modified") {
    210          state.addons[id][k] = v.getTime();
    211        } else {
    212          state.addons[id][k] = v;
    213        }
    214      }
    215    }
    216 
    217    for (let [time, change, id] of this._changes) {
    218      state.changes.push([time.getTime(), change, id]);
    219    }
    220 
    221    this._log.info("Saving reconciler state to file: " + file);
    222    await Utils.jsonSave(file, this, state);
    223  },
    224 
    225  /**
    226   * Registers a change listener with this instance.
    227   *
    228   * Change listeners are called every time a change is recorded. The listener
    229   * is an object with the function "changeListener" that takes 3 arguments,
    230   * the Date at which the change happened, the type of change (a CHANGE_*
    231   * constant), and the add-on state object reflecting the current state of
    232   * the add-on at the time of the change.
    233   *
    234   * @param listener
    235   *        Object containing changeListener function.
    236   */
    237  addChangeListener: function addChangeListener(listener) {
    238    if (!this._listeners.includes(listener)) {
    239      this._log.debug("Adding change listener.");
    240      this._listeners.push(listener);
    241    }
    242  },
    243 
    244  /**
    245   * Removes a previously-installed change listener from the instance.
    246   *
    247   * @param listener
    248   *        Listener instance to remove.
    249   */
    250  removeChangeListener: function removeChangeListener(listener) {
    251    this._listeners = this._listeners.filter(element => {
    252      if (element == listener) {
    253        this._log.debug("Removing change listener.");
    254        return false;
    255      }
    256      return true;
    257    });
    258  },
    259 
    260  /**
    261   * Tells the instance to start listening for AddonManager changes.
    262   *
    263   * This is typically called automatically when Sync is loaded.
    264   */
    265  startListening: function startListening() {
    266    if (this._listening) {
    267      return;
    268    }
    269 
    270    this._log.info("Registering as Add-on Manager listener.");
    271    AddonManager.addAddonListener(this);
    272    this._listening = true;
    273  },
    274 
    275  /**
    276   * Tells the instance to stop listening for AddonManager changes.
    277   *
    278   * The reconciler should always be listening. This should only be called when
    279   * the instance is being destroyed.
    280   *
    281   * This function will get called automatically on XPCOM shutdown. However, it
    282   * is a best practice to call it yourself.
    283   */
    284  stopListening: function stopListening() {
    285    if (!this._listening) {
    286      return;
    287    }
    288 
    289    this._log.debug("Stopping listening and removing AddonManager listener.");
    290    AddonManager.removeAddonListener(this);
    291    this._listening = false;
    292  },
    293 
    294  /**
    295   * Refreshes the global state of add-ons by querying the AddonManager.
    296   */
    297  async refreshGlobalState() {
    298    this._log.info("Refreshing global state from AddonManager.");
    299 
    300    let installs;
    301    let addons = await AddonManager.getAllAddons();
    302 
    303    let ids = {};
    304 
    305    for (let addon of addons) {
    306      ids[addon.id] = true;
    307      await this.rectifyStateFromAddon(addon);
    308    }
    309 
    310    // Look for locally-defined add-ons that no longer exist and update their
    311    // record.
    312    for (let [id, addon] of Object.entries(this._addons)) {
    313      if (id in ids) {
    314        continue;
    315      }
    316 
    317      // If the id isn't in ids, it means that the add-on has been deleted or
    318      // the add-on is in the process of being installed. We detect the
    319      // latter by seeing if an AddonInstall is found for this add-on.
    320 
    321      if (!installs) {
    322        installs = await AddonManager.getAllInstalls();
    323      }
    324 
    325      let installFound = false;
    326      for (let install of installs) {
    327        if (
    328          install.addon &&
    329          install.addon.id == id &&
    330          install.state == AddonManager.STATE_INSTALLED
    331        ) {
    332          installFound = true;
    333          break;
    334        }
    335      }
    336 
    337      if (installFound) {
    338        continue;
    339      }
    340 
    341      if (addon.installed) {
    342        addon.installed = false;
    343        this._log.debug(
    344          "Adding change because add-on not present in " +
    345            "Add-on Manager: " +
    346            id
    347        );
    348        await this._addChange(new Date(), CHANGE_UNINSTALLED, addon);
    349      }
    350    }
    351 
    352    // See note for _shouldPersist.
    353    if (this._shouldPersist) {
    354      await this.saveState();
    355    }
    356  },
    357 
    358  /**
    359   * Rectifies the state of an add-on from an Addon instance.
    360   *
    361   * This basically says "given an Addon instance, assume it is truth and
    362   * apply changes to the local state to reflect it."
    363   *
    364   * This function could result in change listeners being called if the local
    365   * state differs from the passed add-on's state.
    366   *
    367   * @param addon
    368   *        Addon instance being updated.
    369   */
    370  async rectifyStateFromAddon(addon) {
    371    this._log.debug(
    372      `Rectifying state for addon ${addon.name} (version=${addon.version}, id=${addon.id})`
    373    );
    374 
    375    let id = addon.id;
    376    let enabled = !addon.userDisabled;
    377    let guid = addon.syncGUID;
    378    let now = new Date();
    379 
    380    if (!(id in this._addons)) {
    381      let record = {
    382        id,
    383        guid,
    384        enabled,
    385        installed: true,
    386        modified: now,
    387        type: addon.type,
    388        scope: addon.scope,
    389        foreignInstall: addon.foreignInstall,
    390        isSyncable: addon.isSyncable,
    391      };
    392      this._addons[id] = record;
    393      this._log.debug(
    394        "Adding change because add-on not present locally: " + id
    395      );
    396      await this._addChange(now, CHANGE_INSTALLED, record);
    397      return;
    398    }
    399 
    400    let record = this._addons[id];
    401    record.isSyncable = addon.isSyncable;
    402 
    403    if (!record.installed) {
    404      // It is possible the record is marked as uninstalled because an
    405      // uninstall is pending.
    406      if (!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
    407        record.installed = true;
    408        record.modified = now;
    409      }
    410    }
    411 
    412    if (record.enabled != enabled) {
    413      record.enabled = enabled;
    414      record.modified = now;
    415      let change = enabled ? CHANGE_ENABLED : CHANGE_DISABLED;
    416      this._log.debug("Adding change because enabled state changed: " + id);
    417      await this._addChange(new Date(), change, record);
    418    }
    419 
    420    if (record.guid != guid) {
    421      record.guid = guid;
    422      // We don't record a change because the Sync engine rectifies this on its
    423      // own. This is tightly coupled with Sync. If this code is ever lifted
    424      // outside of Sync, this exception should likely be removed.
    425    }
    426  },
    427 
    428  /**
    429   * Record a change in add-on state.
    430   *
    431   * @param date
    432   *        Date at which the change occurred.
    433   * @param change
    434   *        The type of the change. A CHANGE_* constant.
    435   * @param state
    436   *        The new state of the add-on. From this.addons.
    437   */
    438  async _addChange(date, change, state) {
    439    this._log.info("Change recorded for " + state.id);
    440    this._changes.push([date, change, state.id]);
    441 
    442    for (let listener of this._listeners) {
    443      try {
    444        await listener.changeListener(date, change, state);
    445      } catch (ex) {
    446        this._log.error("Exception calling change listener", ex);
    447      }
    448    }
    449  },
    450 
    451  /**
    452   * Obtain the set of changes to add-ons since the date passed.
    453   *
    454   * This will return an array of arrays. Each entry in the array has the
    455   * elements [date, change_type, id], where
    456   *
    457   *   date - Date instance representing when the change occurred.
    458   *   change_type - One of CHANGE_* constants.
    459   *   id - ID of add-on that changed.
    460   */
    461  getChangesSinceDate(date) {
    462    let length = this._changes.length;
    463    for (let i = 0; i < length; i++) {
    464      if (this._changes[i][0] >= date) {
    465        return this._changes.slice(i);
    466      }
    467    }
    468 
    469    return [];
    470  },
    471 
    472  /**
    473   * Prunes all recorded changes from before the specified Date.
    474   *
    475   * @param date
    476   *        Entries older than this Date will be removed.
    477   */
    478  pruneChangesBeforeDate(date) {
    479    this._changes = this._changes.filter(function test_age(change) {
    480      return change[0] >= date;
    481    });
    482  },
    483 
    484  /**
    485   * Obtains the set of all known Sync GUIDs for add-ons.
    486   */
    487  getAllSyncGUIDs() {
    488    let result = {};
    489    for (let id in this.addons) {
    490      result[id] = true;
    491    }
    492 
    493    return result;
    494  },
    495 
    496  /**
    497   * Obtain the add-on state record for an add-on by Sync GUID.
    498   *
    499   * If the add-on could not be found, returns null.
    500   *
    501   * @param  guid
    502   *         Sync GUID of add-on to retrieve.
    503   */
    504  getAddonStateFromSyncGUID(guid) {
    505    for (let id in this.addons) {
    506      let addon = this.addons[id];
    507      if (addon.guid == guid) {
    508        return addon;
    509      }
    510    }
    511 
    512    return null;
    513  },
    514 
    515  /**
    516   * Handler that is invoked as part of the AddonManager listeners.
    517   */
    518  async _handleListener(action, addon) {
    519    // Since this is called as an observer, we explicitly trap errors and
    520    // log them to ourselves so we don't see errors reported elsewhere.
    521    try {
    522      let id = addon.id;
    523      this._log.debug("Add-on change: " + action + " to " + id);
    524 
    525      switch (action) {
    526        case "onEnabled":
    527        case "onDisabled":
    528        case "onInstalled":
    529        case "onInstallEnded":
    530        case "onOperationCancelled":
    531          await this.rectifyStateFromAddon(addon);
    532          break;
    533 
    534        case "onUninstalled": {
    535          let id = addon.id;
    536          let addons = this.addons;
    537          if (id in addons) {
    538            let now = new Date();
    539            let record = addons[id];
    540            record.installed = false;
    541            record.modified = now;
    542            this._log.debug(
    543              "Adding change because of uninstall listener: " + id
    544            );
    545            await this._addChange(now, CHANGE_UNINSTALLED, record);
    546          }
    547        }
    548      }
    549 
    550      // See note for _shouldPersist.
    551      if (this._shouldPersist) {
    552        await this.saveState();
    553      }
    554    } catch (ex) {
    555      this._log.warn("Exception", ex);
    556    }
    557  },
    558 
    559  // AddonListeners
    560  onEnabled: function onEnabled(addon) {
    561    this.queueCaller.enqueueCall(() =>
    562      this._handleListener("onEnabled", addon)
    563    );
    564  },
    565  onDisabled: function onDisabled(addon) {
    566    this.queueCaller.enqueueCall(() =>
    567      this._handleListener("onDisabled", addon)
    568    );
    569  },
    570  onInstalled: function onInstalled(addon) {
    571    this.queueCaller.enqueueCall(() =>
    572      this._handleListener("onInstalled", addon)
    573    );
    574  },
    575  onUninstalled: function onUninstalled(addon) {
    576    this.queueCaller.enqueueCall(() =>
    577      this._handleListener("onUninstalled", addon)
    578    );
    579  },
    580  onOperationCancelled: function onOperationCancelled(addon) {
    581    this.queueCaller.enqueueCall(() =>
    582      this._handleListener("onOperationCancelled", addon)
    583    );
    584  },
    585 };