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