mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-10-31 22:25:30 +00:00
0469a02b25
--HG-- extra : rebase_source : 98337b6a8c07d05e8c961a452dd05a7d75c3c60b
656 lines
21 KiB
JavaScript
656 lines
21 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
/**
|
|
* This file contains middleware to reconcile state of AddonManager for
|
|
* purposes of tracking events for Sync. The content in this file exists
|
|
* because AddonManager does not have a getChangesSinceX() API and adding
|
|
* that functionality properly was deemed too time-consuming at the time
|
|
* add-on sync was originally written. If/when AddonManager adds this API,
|
|
* this file can go away and the add-ons engine can be rewritten to use it.
|
|
*
|
|
* It was decided to have this tracking functionality exist in a separate
|
|
* standalone file so it could be more easily understood, tested, and
|
|
* hopefully ported.
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
const Cu = Components.utils;
|
|
|
|
Cu.import("resource://services-common/log4moz.js");
|
|
Cu.import("resource://services-sync/util.js");
|
|
Cu.import("resource://gre/modules/AddonManager.jsm");
|
|
|
|
const DEFAULT_STATE_FILE = "addonsreconciler";
|
|
|
|
this.CHANGE_INSTALLED = 1;
|
|
this.CHANGE_UNINSTALLED = 2;
|
|
this.CHANGE_ENABLED = 3;
|
|
this.CHANGE_DISABLED = 4;
|
|
|
|
this.EXPORTED_SYMBOLS = ["AddonsReconciler", "CHANGE_INSTALLED",
|
|
"CHANGE_UNINSTALLED", "CHANGE_ENABLED",
|
|
"CHANGE_DISABLED"];
|
|
/**
|
|
* Maintains state of add-ons.
|
|
*
|
|
* State is maintained in 2 data structures, an object mapping add-on IDs
|
|
* to metadata and an array of changes over time. The object mapping can be
|
|
* thought of as a minimal copy of data from AddonManager which is needed for
|
|
* Sync. The array is effectively a log of changes over time.
|
|
*
|
|
* The data structures are persisted to disk by serializing to a JSON file in
|
|
* the current profile. The data structures are updated by 2 mechanisms. First,
|
|
* they can be refreshed from the global state of the AddonManager. This is a
|
|
* sure-fire way of ensuring the reconciler is up to date. Second, the
|
|
* reconciler adds itself as an AddonManager listener. When it receives change
|
|
* notifications, it updates its internal state incrementally.
|
|
*
|
|
* The internal state is persisted to a JSON file in the profile directory.
|
|
*
|
|
* An instance of this is bound to an AddonsEngine instance. In reality, it
|
|
* likely exists as a singleton. To AddonsEngine, it functions as a store and
|
|
* an entity which emits events for tracking.
|
|
*
|
|
* The usage pattern for instances of this class is:
|
|
*
|
|
* let reconciler = new AddonsReconciler();
|
|
* reconciler.loadState(null, function(error) { ... });
|
|
*
|
|
* // At this point, your instance should be ready to use.
|
|
*
|
|
* When you are finished with the instance, please call:
|
|
*
|
|
* reconciler.stopListening();
|
|
* reconciler.saveStateFile(...);
|
|
*
|
|
*
|
|
* There are 2 classes of listeners in the AddonManager: AddonListener and
|
|
* InstallListener. This class is a listener for both (member functions just
|
|
* get called directly).
|
|
*
|
|
* When an add-on is installed, listeners are called in the following order:
|
|
*
|
|
* IL.onInstallStarted, AL.onInstalling, IL.onInstallEnded, AL.onInstalled
|
|
*
|
|
* For non-restartless add-ons, an application restart may occur between
|
|
* IL.onInstallEnded and AL.onInstalled. Unfortunately, Sync likely will
|
|
* not be loaded when AL.onInstalled is fired shortly after application
|
|
* start, so it won't see this event. Therefore, for add-ons requiring a
|
|
* restart, Sync treats the IL.onInstallEnded event as good enough to
|
|
* indicate an install. For restartless add-ons, Sync assumes AL.onInstalled
|
|
* will follow shortly after IL.onInstallEnded and thus it ignores
|
|
* IL.onInstallEnded.
|
|
*
|
|
* The listeners can also see events related to the download of the add-on.
|
|
* This class isn't interested in those. However, there are failure events,
|
|
* IL.onDownloadFailed and IL.onDownloadCanceled which get called if a
|
|
* download doesn't complete successfully.
|
|
*
|
|
* For uninstalls, we see AL.onUninstalling then AL.onUninstalled. Like
|
|
* installs, the events could be separated by an application restart and Sync
|
|
* may not see the onUninstalled event. Again, if we require a restart, we
|
|
* react to onUninstalling. If not, we assume we'll get onUninstalled.
|
|
*
|
|
* Enabling and disabling work by sending:
|
|
*
|
|
* AL.onEnabling, AL.onEnabled
|
|
* AL.onDisabling, AL.onDisabled
|
|
*
|
|
* Again, they may be separated by a restart, so we heed the requiresRestart
|
|
* flag.
|
|
*
|
|
* Actions can be undone. All undoable actions notify the same
|
|
* AL.onOperationCancelled event. We treat this event like any other.
|
|
*
|
|
* Restartless add-ons have interesting behavior during uninstall. These
|
|
* add-ons are first disabled then they are actually uninstalled. So, we will
|
|
* see AL.onDisabling and AL.onDisabled. The onUninstalling and onUninstalled
|
|
* events only come after the Addon Manager is closed or another view is
|
|
* switched to. In the case of Sync performing the uninstall, the uninstall
|
|
* events will occur immediately. However, we still see disabling events and
|
|
* heed them like they were normal. In the end, the state is proper.
|
|
*/
|
|
this.AddonsReconciler = function AddonsReconciler() {
|
|
this._log = Log4Moz.repository.getLogger("Sync.AddonsReconciler");
|
|
let level = Svc.Prefs.get("log.logger.addonsreconciler", "Debug");
|
|
this._log.level = Log4Moz.Level[level];
|
|
|
|
Svc.Obs.add("xpcom-shutdown", this.stopListening, this);
|
|
};
|
|
AddonsReconciler.prototype = {
|
|
/** Flag indicating whether we are listening to AddonManager events. */
|
|
_listening: false,
|
|
|
|
/** Whether state has been loaded from a file.
|
|
*
|
|
* State is loaded on demand if an operation requires it.
|
|
*/
|
|
_stateLoaded: false,
|
|
|
|
/** log4moz logger instance */
|
|
_log: null,
|
|
|
|
/**
|
|
* Container for add-on metadata.
|
|
*
|
|
* Keys are add-on IDs. Values are objects which describe the state of the
|
|
* add-on. This is a minimal mirror of data that can be queried from
|
|
* AddonManager. In some cases, we retain data longer than AddonManager.
|
|
*/
|
|
_addons: {},
|
|
|
|
/**
|
|
* List of add-on changes over time.
|
|
*
|
|
* Each element is an array of [time, change, id].
|
|
*/
|
|
_changes: [],
|
|
|
|
/**
|
|
* Objects subscribed to changes made to this instance.
|
|
*/
|
|
_listeners: [],
|
|
|
|
/**
|
|
* Accessor for add-ons in this object.
|
|
*
|
|
* Returns an object mapping add-on IDs to objects containing metadata.
|
|
*/
|
|
get addons() {
|
|
this._ensureStateLoaded();
|
|
return this._addons;
|
|
},
|
|
|
|
/**
|
|
* Load reconciler state from a file.
|
|
*
|
|
* The path is relative to the weave directory in the profile. If no
|
|
* path is given, the default one is used.
|
|
*
|
|
* If the file does not exist or there was an error parsing the file, the
|
|
* state will be transparently defined as empty.
|
|
*
|
|
* @param path
|
|
* Path to load. ".json" is appended automatically. If not defined,
|
|
* a default path will be consulted.
|
|
* @param callback
|
|
* Callback to be executed upon file load. The callback receives a
|
|
* truthy error argument signifying whether an error occurred and a
|
|
* boolean indicating whether data was loaded.
|
|
*/
|
|
loadState: function loadState(path, callback) {
|
|
let file = path || DEFAULT_STATE_FILE;
|
|
Utils.jsonLoad(file, this, function(json) {
|
|
this._addons = {};
|
|
this._changes = [];
|
|
|
|
if (!json) {
|
|
this._log.debug("No data seen in loaded file: " + file);
|
|
if (callback) {
|
|
callback(null, false);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
let version = json.version;
|
|
if (!version || version != 1) {
|
|
this._log.error("Could not load JSON file because version not " +
|
|
"supported: " + version);
|
|
if (callback) {
|
|
callback(null, false);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this._addons = json.addons;
|
|
for each (let record in this._addons) {
|
|
record.modified = new Date(record.modified);
|
|
}
|
|
|
|
for each (let [time, change, id] in json.changes) {
|
|
this._changes.push([new Date(time), change, id]);
|
|
}
|
|
|
|
if (callback) {
|
|
callback(null, true);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Saves the current state to a file in the local profile.
|
|
*
|
|
* @param path
|
|
* String path in profile to save to. If not defined, the default
|
|
* will be used.
|
|
* @param callback
|
|
* Function to be invoked on save completion. No parameters will be
|
|
* passed to callback.
|
|
*/
|
|
saveState: function saveState(path, callback) {
|
|
let file = path || DEFAULT_STATE_FILE;
|
|
let state = {version: 1, addons: {}, changes: []};
|
|
|
|
for (let [id, record] in Iterator(this._addons)) {
|
|
state.addons[id] = {};
|
|
for (let [k, v] in Iterator(record)) {
|
|
if (k == "modified") {
|
|
state.addons[id][k] = v.getTime();
|
|
}
|
|
else {
|
|
state.addons[id][k] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
for each (let [time, change, id] in this._changes) {
|
|
state.changes.push([time.getTime(), change, id]);
|
|
}
|
|
|
|
this._log.info("Saving reconciler state to file: " + file);
|
|
Utils.jsonSave(file, this, state, callback);
|
|
},
|
|
|
|
/**
|
|
* Registers a change listener with this instance.
|
|
*
|
|
* Change listeners are called every time a change is recorded. The listener
|
|
* is an object with the function "changeListener" that takes 3 arguments,
|
|
* the Date at which the change happened, the type of change (a CHANGE_*
|
|
* constant), and the add-on state object reflecting the current state of
|
|
* the add-on at the time of the change.
|
|
*
|
|
* @param listener
|
|
* Object containing changeListener function.
|
|
*/
|
|
addChangeListener: function addChangeListener(listener) {
|
|
if (this._listeners.indexOf(listener) == -1) {
|
|
this._log.debug("Adding change listener.");
|
|
this._listeners.push(listener);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Removes a previously-installed change listener from the instance.
|
|
*
|
|
* @param listener
|
|
* Listener instance to remove.
|
|
*/
|
|
removeChangeListener: function removeChangeListener(listener) {
|
|
this._listeners = this._listeners.filter(function(element) {
|
|
if (element == listener) {
|
|
this._log.debug("Removing change listener.");
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
}.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Tells the instance to start listening for AddonManager changes.
|
|
*
|
|
* This is typically called automatically when Sync is loaded.
|
|
*/
|
|
startListening: function startListening() {
|
|
if (this._listening) {
|
|
return;
|
|
}
|
|
|
|
this._log.info("Registering as Add-on Manager listener.");
|
|
AddonManager.addAddonListener(this);
|
|
AddonManager.addInstallListener(this);
|
|
this._listening = true;
|
|
},
|
|
|
|
/**
|
|
* Tells the instance to stop listening for AddonManager changes.
|
|
*
|
|
* The reconciler should always be listening. This should only be called when
|
|
* the instance is being destroyed.
|
|
*
|
|
* This function will get called automatically on XPCOM shutdown. However, it
|
|
* is a best practice to call it yourself.
|
|
*/
|
|
stopListening: function stopListening() {
|
|
if (!this._listening) {
|
|
return;
|
|
}
|
|
|
|
this._log.debug("Stopping listening and removing AddonManager listeners.");
|
|
AddonManager.removeInstallListener(this);
|
|
AddonManager.removeAddonListener(this);
|
|
this._listening = false;
|
|
},
|
|
|
|
/**
|
|
* Refreshes the global state of add-ons by querying the AddonManager.
|
|
*/
|
|
refreshGlobalState: function refreshGlobalState(callback) {
|
|
this._log.info("Refreshing global state from AddonManager.");
|
|
this._ensureStateLoaded();
|
|
|
|
let installs;
|
|
|
|
AddonManager.getAllAddons(function (addons) {
|
|
let ids = {};
|
|
|
|
for each (let addon in addons) {
|
|
ids[addon.id] = true;
|
|
this.rectifyStateFromAddon(addon);
|
|
}
|
|
|
|
// Look for locally-defined add-ons that no longer exist and update their
|
|
// record.
|
|
for (let [id, addon] in Iterator(this._addons)) {
|
|
if (id in ids) {
|
|
continue;
|
|
}
|
|
|
|
// If the id isn't in ids, it means that the add-on has been deleted or
|
|
// the add-on is in the process of being installed. We detect the
|
|
// latter by seeing if an AddonInstall is found for this add-on.
|
|
|
|
if (!installs) {
|
|
let cb = Async.makeSyncCallback();
|
|
AddonManager.getAllInstalls(cb);
|
|
installs = Async.waitForSyncCallback(cb);
|
|
}
|
|
|
|
let installFound = false;
|
|
for each (let install in installs) {
|
|
if (install.addon && install.addon.id == id &&
|
|
install.state == AddonManager.STATE_INSTALLED) {
|
|
|
|
installFound = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (installFound) {
|
|
continue;
|
|
}
|
|
|
|
if (addon.installed) {
|
|
addon.installed = false;
|
|
this._log.debug("Adding change because add-on not present in " +
|
|
"Add-on Manager: " + id);
|
|
this._addChange(new Date(), CHANGE_UNINSTALLED, addon);
|
|
}
|
|
}
|
|
|
|
this.saveState(null, callback);
|
|
}.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Rectifies the state of an add-on from an Addon instance.
|
|
*
|
|
* This basically says "given an Addon instance, assume it is truth and
|
|
* apply changes to the local state to reflect it."
|
|
*
|
|
* This function could result in change listeners being called if the local
|
|
* state differs from the passed add-on's state.
|
|
*
|
|
* @param addon
|
|
* Addon instance being updated.
|
|
*/
|
|
rectifyStateFromAddon: function rectifyStateFromAddon(addon) {
|
|
this._log.debug("Rectifying state for addon: " + addon.id);
|
|
this._ensureStateLoaded();
|
|
|
|
let id = addon.id;
|
|
let enabled = !addon.userDisabled;
|
|
let guid = addon.syncGUID;
|
|
let now = new Date();
|
|
|
|
if (!(id in this._addons)) {
|
|
let record = {
|
|
id: id,
|
|
guid: guid,
|
|
enabled: enabled,
|
|
installed: true,
|
|
modified: now,
|
|
type: addon.type,
|
|
scope: addon.scope,
|
|
foreignInstall: addon.foreignInstall
|
|
};
|
|
this._addons[id] = record;
|
|
this._log.debug("Adding change because add-on not present locally: " +
|
|
id);
|
|
this._addChange(now, CHANGE_INSTALLED, record);
|
|
return;
|
|
}
|
|
|
|
let record = this._addons[id];
|
|
|
|
if (!record.installed) {
|
|
// It is possible the record is marked as uninstalled because an
|
|
// uninstall is pending.
|
|
if (!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
|
|
record.installed = true;
|
|
record.modified = now;
|
|
}
|
|
}
|
|
|
|
if (record.enabled != enabled) {
|
|
record.enabled = enabled;
|
|
record.modified = now;
|
|
let change = enabled ? CHANGE_ENABLED : CHANGE_DISABLED;
|
|
this._log.debug("Adding change because enabled state changed: " + id);
|
|
this._addChange(new Date(), change, record);
|
|
}
|
|
|
|
if (record.guid != guid) {
|
|
record.guid = guid;
|
|
// We don't record a change because the Sync engine rectifies this on its
|
|
// own. This is tightly coupled with Sync. If this code is ever lifted
|
|
// outside of Sync, this exception should likely be removed.
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Record a change in add-on state.
|
|
*
|
|
* @param date
|
|
* Date at which the change occurred.
|
|
* @param change
|
|
* The type of the change. A CHANGE_* constant.
|
|
* @param state
|
|
* The new state of the add-on. From this.addons.
|
|
*/
|
|
_addChange: function _addChange(date, change, state) {
|
|
this._log.info("Change recorded for " + state.id);
|
|
this._changes.push([date, change, state.id]);
|
|
|
|
for each (let listener in this._listeners) {
|
|
try {
|
|
listener.changeListener.call(listener, date, change, state);
|
|
} catch (ex) {
|
|
this._log.warn("Exception calling change listener: " +
|
|
Utils.exceptionStr(ex));
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Obtain the set of changes to add-ons since the date passed.
|
|
*
|
|
* This will return an array of arrays. Each entry in the array has the
|
|
* elements [date, change_type, id], where
|
|
*
|
|
* date - Date instance representing when the change occurred.
|
|
* change_type - One of CHANGE_* constants.
|
|
* id - ID of add-on that changed.
|
|
*/
|
|
getChangesSinceDate: function getChangesSinceDate(date) {
|
|
this._ensureStateLoaded();
|
|
|
|
let length = this._changes.length;
|
|
for (let i = 0; i < length; i++) {
|
|
if (this._changes[i][0] >= date) {
|
|
return this._changes.slice(i);
|
|
}
|
|
}
|
|
|
|
return [];
|
|
},
|
|
|
|
/**
|
|
* Prunes all recorded changes from before the specified Date.
|
|
*
|
|
* @param date
|
|
* Entries older than this Date will be removed.
|
|
*/
|
|
pruneChangesBeforeDate: function pruneChangesBeforeDate(date) {
|
|
this._ensureStateLoaded();
|
|
|
|
this._changes = this._changes.filter(function test_age(change) {
|
|
return change[0] >= date;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Obtains the set of all known Sync GUIDs for add-ons.
|
|
*
|
|
* @return Object with guids as keys and values of true.
|
|
*/
|
|
getAllSyncGUIDs: function getAllSyncGUIDs() {
|
|
let result = {};
|
|
for (let id in this.addons) {
|
|
result[id] = true;
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* Obtain the add-on state record for an add-on by Sync GUID.
|
|
*
|
|
* If the add-on could not be found, returns null.
|
|
*
|
|
* @param guid
|
|
* Sync GUID of add-on to retrieve.
|
|
* @return Object on success on null on failure.
|
|
*/
|
|
getAddonStateFromSyncGUID: function getAddonStateFromSyncGUID(guid) {
|
|
for each (let addon in this.addons) {
|
|
if (addon.guid == guid) {
|
|
return addon;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Ensures that state is loaded before continuing.
|
|
*
|
|
* This is called internally by anything that accesses the internal data
|
|
* structures. It effectively just-in-time loads serialized state.
|
|
*/
|
|
_ensureStateLoaded: function _ensureStateLoaded() {
|
|
if (this._stateLoaded) {
|
|
return;
|
|
}
|
|
|
|
let cb = Async.makeSpinningCallback();
|
|
this.loadState(null, cb);
|
|
cb.wait();
|
|
this._stateLoaded = true;
|
|
},
|
|
|
|
/**
|
|
* Handler that is invoked as part of the AddonManager listeners.
|
|
*/
|
|
_handleListener: function _handlerListener(action, addon, requiresRestart) {
|
|
// Since this is called as an observer, we explicitly trap errors and
|
|
// log them to ourselves so we don't see errors reported elsewhere.
|
|
try {
|
|
let id = addon.id;
|
|
this._log.debug("Add-on change: " + action + " to " + id);
|
|
|
|
// We assume that every event for non-restartless add-ons is
|
|
// followed by another event and that this follow-up event is the most
|
|
// appropriate to react to. Currently we ignore onEnabling, onDisabling,
|
|
// and onUninstalling for non-restartless add-ons.
|
|
if (requiresRestart === false) {
|
|
this._log.debug("Ignoring " + action + " for restartless add-on.");
|
|
return;
|
|
}
|
|
|
|
switch (action) {
|
|
case "onEnabling":
|
|
case "onEnabled":
|
|
case "onDisabling":
|
|
case "onDisabled":
|
|
case "onInstalled":
|
|
case "onInstallEnded":
|
|
case "onOperationCancelled":
|
|
this.rectifyStateFromAddon(addon);
|
|
break;
|
|
|
|
case "onUninstalling":
|
|
case "onUninstalled":
|
|
let id = addon.id;
|
|
let addons = this.addons;
|
|
if (id in addons) {
|
|
let now = new Date();
|
|
let record = addons[id];
|
|
record.installed = false;
|
|
record.modified = now;
|
|
this._log.debug("Adding change because of uninstall listener: " +
|
|
id);
|
|
this._addChange(now, CHANGE_UNINSTALLED, record);
|
|
}
|
|
}
|
|
|
|
let cb = Async.makeSpinningCallback();
|
|
this.saveState(null, cb);
|
|
cb.wait();
|
|
}
|
|
catch (ex) {
|
|
this._log.warn("Exception: " + Utils.exceptionStr(ex));
|
|
}
|
|
},
|
|
|
|
// AddonListeners
|
|
onEnabling: function onEnabling(addon, requiresRestart) {
|
|
this._handleListener("onEnabling", addon, requiresRestart);
|
|
},
|
|
onEnabled: function onEnabled(addon) {
|
|
this._handleListener("onEnabled", addon);
|
|
},
|
|
onDisabling: function onDisabling(addon, requiresRestart) {
|
|
this._handleListener("onDisabling", addon, requiresRestart);
|
|
},
|
|
onDisabled: function onDisabled(addon) {
|
|
this._handleListener("onDisabled", addon);
|
|
},
|
|
onInstalling: function onInstalling(addon, requiresRestart) {
|
|
this._handleListener("onInstalling", addon, requiresRestart);
|
|
},
|
|
onInstalled: function onInstalled(addon) {
|
|
this._handleListener("onInstalled", addon);
|
|
},
|
|
onUninstalling: function onUninstalling(addon, requiresRestart) {
|
|
this._handleListener("onUninstalling", addon, requiresRestart);
|
|
},
|
|
onUninstalled: function onUninstalled(addon) {
|
|
this._handleListener("onUninstalled", addon);
|
|
},
|
|
onOperationCancelled: function onOperationCancelled(addon) {
|
|
this._handleListener("onOperationCancelled", addon);
|
|
},
|
|
|
|
// InstallListeners
|
|
onInstallEnded: function onInstallEnded(install, addon) {
|
|
this._handleListener("onInstallEnded", addon);
|
|
}
|
|
};
|