services/sync/modules/addonsreconciler.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     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/. */
     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  */
    18 "use strict";
    20 const Cu = Components.utils;
    22 Cu.import("resource://gre/modules/Log.jsm");
    23 Cu.import("resource://services-sync/util.js");
    24 Cu.import("resource://gre/modules/AddonManager.jsm");
    26 const DEFAULT_STATE_FILE = "addonsreconciler";
    28 this.CHANGE_INSTALLED   = 1;
    29 this.CHANGE_UNINSTALLED = 2;
    30 this.CHANGE_ENABLED     = 3;
    31 this.CHANGE_DISABLED    = 4;
    33 this.EXPORTED_SYMBOLS = ["AddonsReconciler", "CHANGE_INSTALLED",
    34                          "CHANGE_UNINSTALLED", "CHANGE_ENABLED",
    35                          "CHANGE_DISABLED"];
    36 /**
    37  * Maintains state of add-ons.
    38  *
    39  * State is maintained in 2 data structures, an object mapping add-on IDs
    40  * to metadata and an array of changes over time. The object mapping can be
    41  * thought of as a minimal copy of data from AddonManager which is needed for
    42  * Sync. The array is effectively a log of changes over time.
    43  *
    44  * The data structures are persisted to disk by serializing to a JSON file in
    45  * the current profile. The data structures are updated by 2 mechanisms. First,
    46  * they can be refreshed from the global state of the AddonManager. This is a
    47  * sure-fire way of ensuring the reconciler is up to date. Second, the
    48  * reconciler adds itself as an AddonManager listener. When it receives change
    49  * notifications, it updates its internal state incrementally.
    50  *
    51  * The internal state is persisted to a JSON file in the profile directory.
    52  *
    53  * An instance of this is bound to an AddonsEngine instance. In reality, it
    54  * likely exists as a singleton. To AddonsEngine, it functions as a store and
    55  * an entity which emits events for tracking.
    56  *
    57  * The usage pattern for instances of this class is:
    58  *
    59  *   let reconciler = new AddonsReconciler();
    60  *   reconciler.loadState(null, function(error) { ... });
    61  *
    62  *   // At this point, your instance should be ready to use.
    63  *
    64  * When you are finished with the instance, please call:
    65  *
    66  *   reconciler.stopListening();
    67  *   reconciler.saveState(...);
    68  *
    69  * There are 2 classes of listeners in the AddonManager: AddonListener and
    70  * InstallListener. This class is a listener for both (member functions just
    71  * get called directly).
    72  *
    73  * When an add-on is installed, listeners are called in the following order:
    74  *
    75  *  IL.onInstallStarted, AL.onInstalling, IL.onInstallEnded, AL.onInstalled
    76  *
    77  * For non-restartless add-ons, an application restart may occur between
    78  * IL.onInstallEnded and AL.onInstalled. Unfortunately, Sync likely will
    79  * not be loaded when AL.onInstalled is fired shortly after application
    80  * start, so it won't see this event. Therefore, for add-ons requiring a
    81  * restart, Sync treats the IL.onInstallEnded event as good enough to
    82  * indicate an install. For restartless add-ons, Sync assumes AL.onInstalled
    83  * will follow shortly after IL.onInstallEnded and thus it ignores
    84  * IL.onInstallEnded.
    85  *
    86  * The listeners can also see events related to the download of the add-on.
    87  * This class isn't interested in those. However, there are failure events,
    88  * IL.onDownloadFailed and IL.onDownloadCanceled which get called if a
    89  * download doesn't complete successfully.
    90  *
    91  * For uninstalls, we see AL.onUninstalling then AL.onUninstalled. Like
    92  * installs, the events could be separated by an application restart and Sync
    93  * may not see the onUninstalled event. Again, if we require a restart, we
    94  * react to onUninstalling. If not, we assume we'll get onUninstalled.
    95  *
    96  * Enabling and disabling work by sending:
    97  *
    98  *   AL.onEnabling, AL.onEnabled
    99  *   AL.onDisabling, AL.onDisabled
   100  *
   101  * Again, they may be separated by a restart, so we heed the requiresRestart
   102  * flag.
   103  *
   104  * Actions can be undone. All undoable actions notify the same
   105  * AL.onOperationCancelled event. We treat this event like any other.
   106  *
   107  * Restartless add-ons have interesting behavior during uninstall. These
   108  * add-ons are first disabled then they are actually uninstalled. So, we will
   109  * see AL.onDisabling and AL.onDisabled. The onUninstalling and onUninstalled
   110  * events only come after the Addon Manager is closed or another view is
   111  * switched to. In the case of Sync performing the uninstall, the uninstall
   112  * events will occur immediately. However, we still see disabling events and
   113  * heed them like they were normal. In the end, the state is proper.
   114  */
   115 this.AddonsReconciler = function AddonsReconciler() {
   116   this._log = Log.repository.getLogger("Sync.AddonsReconciler");
   117   let level = Svc.Prefs.get("log.logger.addonsreconciler", "Debug");
   118   this._log.level = Log.Level[level];
   120   Svc.Obs.add("xpcom-shutdown", this.stopListening, this);
   121 };
   122 AddonsReconciler.prototype = {
   123   /** Flag indicating whether we are listening to AddonManager events. */
   124   _listening: false,
   126   /**
   127    * Whether state has been loaded from a file.
   128    *
   129    * State is loaded on demand if an operation requires it.
   130    */
   131   _stateLoaded: false,
   133   /**
   134    * Define this as false if the reconciler should not persist state
   135    * to disk when handling events.
   136    *
   137    * This allows test code to avoid spinning to write during observer
   138    * notifications and xpcom shutdown, which appears to cause hangs on WinXP
   139    * (Bug 873861).
   140    */
   141   _shouldPersist: true,
   143   /** Log logger instance */
   144   _log: null,
   146   /**
   147    * Container for add-on metadata.
   148    *
   149    * Keys are add-on IDs. Values are objects which describe the state of the
   150    * add-on. This is a minimal mirror of data that can be queried from
   151    * AddonManager. In some cases, we retain data longer than AddonManager.
   152    */
   153   _addons: {},
   155   /**
   156    * List of add-on changes over time.
   157    *
   158    * Each element is an array of [time, change, id].
   159    */
   160   _changes: [],
   162   /**
   163    * Objects subscribed to changes made to this instance.
   164    */
   165   _listeners: [],
   167   /**
   168    * Accessor for add-ons in this object.
   169    *
   170    * Returns an object mapping add-on IDs to objects containing metadata.
   171    */
   172   get addons() {
   173     this._ensureStateLoaded();
   174     return this._addons;
   175   },
   177   /**
   178    * Load reconciler state from a file.
   179    *
   180    * The path is relative to the weave directory in the profile. If no
   181    * path is given, the default one is used.
   182    *
   183    * If the file does not exist or there was an error parsing the file, the
   184    * state will be transparently defined as empty.
   185    *
   186    * @param path
   187    *        Path to load. ".json" is appended automatically. If not defined,
   188    *        a default path will be consulted.
   189    * @param callback
   190    *        Callback to be executed upon file load. The callback receives a
   191    *        truthy error argument signifying whether an error occurred and a
   192    *        boolean indicating whether data was loaded.
   193    */
   194   loadState: function loadState(path, callback) {
   195     let file = path || DEFAULT_STATE_FILE;
   196     Utils.jsonLoad(file, this, function(json) {
   197       this._addons = {};
   198       this._changes = [];
   200       if (!json) {
   201         this._log.debug("No data seen in loaded file: " + file);
   202         if (callback) {
   203           callback(null, false);
   204         }
   206         return;
   207       }
   209       let version = json.version;
   210       if (!version || version != 1) {
   211         this._log.error("Could not load JSON file because version not " +
   212                         "supported: " + version);
   213         if (callback) {
   214           callback(null, false);
   215         }
   217         return;
   218       }
   220       this._addons = json.addons;
   221       for each (let record in this._addons) {
   222         record.modified = new Date(record.modified);
   223       }
   225       for each (let [time, change, id] in json.changes) {
   226         this._changes.push([new Date(time), change, id]);
   227       }
   229       if (callback) {
   230         callback(null, true);
   231       }
   232     });
   233   },
   235   /**
   236    * Saves the current state to a file in the local profile.
   237    *
   238    * @param  path
   239    *         String path in profile to save to. If not defined, the default
   240    *         will be used.
   241    * @param  callback
   242    *         Function to be invoked on save completion. No parameters will be
   243    *         passed to callback.
   244    */
   245   saveState: function saveState(path, callback) {
   246     let file = path || DEFAULT_STATE_FILE;
   247     let state = {version: 1, addons: {}, changes: []};
   249     for (let [id, record] in Iterator(this._addons)) {
   250       state.addons[id] = {};
   251       for (let [k, v] in Iterator(record)) {
   252         if (k == "modified") {
   253           state.addons[id][k] = v.getTime();
   254         }
   255         else {
   256           state.addons[id][k] = v;
   257         }
   258       }
   259     }
   261     for each (let [time, change, id] in this._changes) {
   262       state.changes.push([time.getTime(), change, id]);
   263     }
   265     this._log.info("Saving reconciler state to file: " + file);
   266     Utils.jsonSave(file, this, state, callback);
   267   },
   269   /**
   270    * Registers a change listener with this instance.
   271    *
   272    * Change listeners are called every time a change is recorded. The listener
   273    * is an object with the function "changeListener" that takes 3 arguments,
   274    * the Date at which the change happened, the type of change (a CHANGE_*
   275    * constant), and the add-on state object reflecting the current state of
   276    * the add-on at the time of the change.
   277    *
   278    * @param listener
   279    *        Object containing changeListener function.
   280    */
   281   addChangeListener: function addChangeListener(listener) {
   282     if (this._listeners.indexOf(listener) == -1) {
   283       this._log.debug("Adding change listener.");
   284       this._listeners.push(listener);
   285     }
   286   },
   288   /**
   289    * Removes a previously-installed change listener from the instance.
   290    *
   291    * @param listener
   292    *        Listener instance to remove.
   293    */
   294   removeChangeListener: function removeChangeListener(listener) {
   295     this._listeners = this._listeners.filter(function(element) {
   296       if (element == listener) {
   297         this._log.debug("Removing change listener.");
   298         return false;
   299       } else {
   300         return true;
   301       }
   302     }.bind(this));
   303   },
   305   /**
   306    * Tells the instance to start listening for AddonManager changes.
   307    *
   308    * This is typically called automatically when Sync is loaded.
   309    */
   310   startListening: function startListening() {
   311     if (this._listening) {
   312       return;
   313     }
   315     this._log.info("Registering as Add-on Manager listener.");
   316     AddonManager.addAddonListener(this);
   317     AddonManager.addInstallListener(this);
   318     this._listening = true;
   319   },
   321   /**
   322    * Tells the instance to stop listening for AddonManager changes.
   323    *
   324    * The reconciler should always be listening. This should only be called when
   325    * the instance is being destroyed.
   326    *
   327    * This function will get called automatically on XPCOM shutdown. However, it
   328    * is a best practice to call it yourself.
   329    */
   330   stopListening: function stopListening() {
   331     if (!this._listening) {
   332       return;
   333     }
   335     this._log.debug("Stopping listening and removing AddonManager listeners.");
   336     AddonManager.removeInstallListener(this);
   337     AddonManager.removeAddonListener(this);
   338     this._listening = false;
   339   },
   341   /**
   342    * Refreshes the global state of add-ons by querying the AddonManager.
   343    */
   344   refreshGlobalState: function refreshGlobalState(callback) {
   345     this._log.info("Refreshing global state from AddonManager.");
   346     this._ensureStateLoaded();
   348     let installs;
   350     AddonManager.getAllAddons(function (addons) {
   351       let ids = {};
   353       for each (let addon in addons) {
   354         ids[addon.id] = true;
   355         this.rectifyStateFromAddon(addon);
   356       }
   358       // Look for locally-defined add-ons that no longer exist and update their
   359       // record.
   360       for (let [id, addon] in Iterator(this._addons)) {
   361         if (id in ids) {
   362           continue;
   363         }
   365         // If the id isn't in ids, it means that the add-on has been deleted or
   366         // the add-on is in the process of being installed. We detect the
   367         // latter by seeing if an AddonInstall is found for this add-on.
   369         if (!installs) {
   370           let cb = Async.makeSyncCallback();
   371           AddonManager.getAllInstalls(cb);
   372           installs = Async.waitForSyncCallback(cb);
   373         }
   375         let installFound = false;
   376         for each (let install in installs) {
   377           if (install.addon && install.addon.id == id &&
   378               install.state == AddonManager.STATE_INSTALLED) {
   380             installFound = true;
   381             break;
   382           }
   383         }
   385         if (installFound) {
   386           continue;
   387         }
   389         if (addon.installed) {
   390           addon.installed = false;
   391           this._log.debug("Adding change because add-on not present in " +
   392                           "Add-on Manager: " + id);
   393           this._addChange(new Date(), CHANGE_UNINSTALLED, addon);
   394         }
   395       }
   397       // See note for _shouldPersist.
   398       if (this._shouldPersist) {
   399         this.saveState(null, callback);
   400       } else {
   401         callback();
   402       }
   403     }.bind(this));
   404   },
   406   /**
   407    * Rectifies the state of an add-on from an Addon instance.
   408    *
   409    * This basically says "given an Addon instance, assume it is truth and
   410    * apply changes to the local state to reflect it."
   411    *
   412    * This function could result in change listeners being called if the local
   413    * state differs from the passed add-on's state.
   414    *
   415    * @param addon
   416    *        Addon instance being updated.
   417    */
   418   rectifyStateFromAddon: function rectifyStateFromAddon(addon) {
   419     this._log.debug("Rectifying state for addon: " + addon.id);
   420     this._ensureStateLoaded();
   422     let id = addon.id;
   423     let enabled = !addon.userDisabled;
   424     let guid = addon.syncGUID;
   425     let now = new Date();
   427     if (!(id in this._addons)) {
   428       let record = {
   429         id: id,
   430         guid: guid,
   431         enabled: enabled,
   432         installed: true,
   433         modified: now,
   434         type: addon.type,
   435         scope: addon.scope,
   436         foreignInstall: addon.foreignInstall
   437       };
   438       this._addons[id] = record;
   439       this._log.debug("Adding change because add-on not present locally: " +
   440                       id);
   441       this._addChange(now, CHANGE_INSTALLED, record);
   442       return;
   443     }
   445     let record = this._addons[id];
   447     if (!record.installed) {
   448       // It is possible the record is marked as uninstalled because an
   449       // uninstall is pending.
   450       if (!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
   451         record.installed = true;
   452         record.modified = now;
   453       }
   454     }
   456     if (record.enabled != enabled) {
   457       record.enabled = enabled;
   458       record.modified = now;
   459       let change = enabled ? CHANGE_ENABLED : CHANGE_DISABLED;
   460       this._log.debug("Adding change because enabled state changed: " + id);
   461       this._addChange(new Date(), change, record);
   462     }
   464     if (record.guid != guid) {
   465       record.guid = guid;
   466       // We don't record a change because the Sync engine rectifies this on its
   467       // own. This is tightly coupled with Sync. If this code is ever lifted
   468       // outside of Sync, this exception should likely be removed.
   469     }
   470   },
   472   /**
   473    * Record a change in add-on state.
   474    *
   475    * @param date
   476    *        Date at which the change occurred.
   477    * @param change
   478    *        The type of the change. A CHANGE_* constant.
   479    * @param state
   480    *        The new state of the add-on. From this.addons.
   481    */
   482   _addChange: function _addChange(date, change, state) {
   483     this._log.info("Change recorded for " + state.id);
   484     this._changes.push([date, change, state.id]);
   486     for each (let listener in this._listeners) {
   487       try {
   488         listener.changeListener.call(listener, date, change, state);
   489       } catch (ex) {
   490         this._log.warn("Exception calling change listener: " +
   491                        Utils.exceptionStr(ex));
   492       }
   493     }
   494   },
   496   /**
   497    * Obtain the set of changes to add-ons since the date passed.
   498    *
   499    * This will return an array of arrays. Each entry in the array has the
   500    * elements [date, change_type, id], where
   501    *
   502    *   date - Date instance representing when the change occurred.
   503    *   change_type - One of CHANGE_* constants.
   504    *   id - ID of add-on that changed.
   505    */
   506   getChangesSinceDate: function getChangesSinceDate(date) {
   507     this._ensureStateLoaded();
   509     let length = this._changes.length;
   510     for (let i = 0; i < length; i++) {
   511       if (this._changes[i][0] >= date) {
   512         return this._changes.slice(i);
   513       }
   514     }
   516     return [];
   517   },
   519   /**
   520    * Prunes all recorded changes from before the specified Date.
   521    *
   522    * @param date
   523    *        Entries older than this Date will be removed.
   524    */
   525   pruneChangesBeforeDate: function pruneChangesBeforeDate(date) {
   526     this._ensureStateLoaded();
   528     this._changes = this._changes.filter(function test_age(change) {
   529       return change[0] >= date;
   530     });
   531   },
   533   /**
   534    * Obtains the set of all known Sync GUIDs for add-ons.
   535    *
   536    * @return Object with guids as keys and values of true.
   537    */
   538   getAllSyncGUIDs: function getAllSyncGUIDs() {
   539     let result = {};
   540     for (let id in this.addons) {
   541       result[id] = true;
   542     }
   544     return result;
   545   },
   547   /**
   548    * Obtain the add-on state record for an add-on by Sync GUID.
   549    *
   550    * If the add-on could not be found, returns null.
   551    *
   552    * @param  guid
   553    *         Sync GUID of add-on to retrieve.
   554    * @return Object on success on null on failure.
   555    */
   556   getAddonStateFromSyncGUID: function getAddonStateFromSyncGUID(guid) {
   557     for each (let addon in this.addons) {
   558       if (addon.guid == guid) {
   559         return addon;
   560       }
   561     }
   563     return null;
   564   },
   566   /**
   567    * Ensures that state is loaded before continuing.
   568    *
   569    * This is called internally by anything that accesses the internal data
   570    * structures. It effectively just-in-time loads serialized state.
   571    */
   572   _ensureStateLoaded: function _ensureStateLoaded() {
   573     if (this._stateLoaded) {
   574       return;
   575     }
   577     let cb = Async.makeSpinningCallback();
   578     this.loadState(null, cb);
   579     cb.wait();
   580     this._stateLoaded = true;
   581   },
   583   /**
   584    * Handler that is invoked as part of the AddonManager listeners.
   585    */
   586   _handleListener: function _handlerListener(action, addon, requiresRestart) {
   587     // Since this is called as an observer, we explicitly trap errors and
   588     // log them to ourselves so we don't see errors reported elsewhere.
   589     try {
   590       let id = addon.id;
   591       this._log.debug("Add-on change: " + action + " to " + id);
   593       // We assume that every event for non-restartless add-ons is
   594       // followed by another event and that this follow-up event is the most
   595       // appropriate to react to. Currently we ignore onEnabling, onDisabling,
   596       // and onUninstalling for non-restartless add-ons.
   597       if (requiresRestart === false) {
   598         this._log.debug("Ignoring " + action + " for restartless add-on.");
   599         return;
   600       }
   602       switch (action) {
   603         case "onEnabling":
   604         case "onEnabled":
   605         case "onDisabling":
   606         case "onDisabled":
   607         case "onInstalled":
   608         case "onInstallEnded":
   609         case "onOperationCancelled":
   610           this.rectifyStateFromAddon(addon);
   611           break;
   613         case "onUninstalling":
   614         case "onUninstalled":
   615           let id = addon.id;
   616           let addons = this.addons;
   617           if (id in addons) {
   618             let now = new Date();
   619             let record = addons[id];
   620             record.installed = false;
   621             record.modified = now;
   622             this._log.debug("Adding change because of uninstall listener: " +
   623                             id);
   624             this._addChange(now, CHANGE_UNINSTALLED, record);
   625           }
   626       }
   628       // See note for _shouldPersist.
   629       if (this._shouldPersist) {
   630         let cb = Async.makeSpinningCallback();
   631         this.saveState(null, cb);
   632         cb.wait();
   633       }
   634     }
   635     catch (ex) {
   636       this._log.warn("Exception: " + Utils.exceptionStr(ex));
   637     }
   638   },
   640   // AddonListeners
   641   onEnabling: function onEnabling(addon, requiresRestart) {
   642     this._handleListener("onEnabling", addon, requiresRestart);
   643   },
   644   onEnabled: function onEnabled(addon) {
   645     this._handleListener("onEnabled", addon);
   646   },
   647   onDisabling: function onDisabling(addon, requiresRestart) {
   648     this._handleListener("onDisabling", addon, requiresRestart);
   649   },
   650   onDisabled: function onDisabled(addon) {
   651     this._handleListener("onDisabled", addon);
   652   },
   653   onInstalling: function onInstalling(addon, requiresRestart) {
   654     this._handleListener("onInstalling", addon, requiresRestart);
   655   },
   656   onInstalled: function onInstalled(addon) {
   657     this._handleListener("onInstalled", addon);
   658   },
   659   onUninstalling: function onUninstalling(addon, requiresRestart) {
   660     this._handleListener("onUninstalling", addon, requiresRestart);
   661   },
   662   onUninstalled: function onUninstalled(addon) {
   663     this._handleListener("onUninstalled", addon);
   664   },
   665   onOperationCancelled: function onOperationCancelled(addon) {
   666     this._handleListener("onOperationCancelled", addon);
   667   },
   669   // InstallListeners
   670   onInstallEnded: function onInstallEnded(install, addon) {
   671     this._handleListener("onInstallEnded", addon);
   672   }
   673 };

mercurial