/* 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. */ import { Log } from "resource://gre/modules/Log.sys.mjs"; import { Svc, Utils } from "resource://services-sync/util.sys.mjs"; import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs"; const DEFAULT_STATE_FILE = "addonsreconciler"; export var CHANGE_INSTALLED = 1; export var CHANGE_UNINSTALLED = 2; export var CHANGE_ENABLED = 3; export var CHANGE_DISABLED = 4; /** * 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(...); * await reconciler.ensureStateLoaded(); * * // At this point, your instance should be ready to use. * * When you are finished with the instance, please call: * * reconciler.stopListening(); * await reconciler.saveState(...); * * This class uses the AddonManager AddonListener interface. * When an add-on is installed, listeners are called in the following order: * AL.onInstalling, AL.onInstalled * * For uninstalls, we see AL.onUninstalling then AL.onUninstalled. * * Enabling and disabling work by sending: * * AL.onEnabling, AL.onEnabled * AL.onDisabling, AL.onDisabled * * Actions can be undone. All undoable actions notify the same * AL.onOperationCancelled event. We treat this event like any other. * * When an add-on is uninstalled from about:addons, the user is offered an * "Undo" option, which leads to the following sequence of events as * observed by an AddonListener: * 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. */ export function AddonsReconciler(queueCaller) { this._log = Log.repository.getLogger("Sync.AddonsReconciler"); this._log.manageLevelFromPref("services.sync.log.logger.addonsreconciler"); this.queueCaller = queueCaller; Svc.Obs.add("xpcom-shutdown", this.stopListening, this); } AddonsReconciler.prototype = { /** Flag indicating whether we are listening to AddonManager events. */ _listening: false, /** * Define this as false if the reconciler should not persist state * to disk when handling events. * * This allows test code to avoid spinning to write during observer * notifications and xpcom shutdown, which appears to cause hangs on WinXP * (Bug 873861). */ _shouldPersist: true, /** Log 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() { return this._addons; }, async ensureStateLoaded() { if (!this._promiseStateLoaded) { this._promiseStateLoaded = this.loadState(); } return this._promiseStateLoaded; }, /** * 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 file * Path to load. ".json" is appended automatically. If not defined, * a default path will be consulted. */ async loadState(file = DEFAULT_STATE_FILE) { let json = await Utils.jsonLoad(file, this); this._addons = {}; this._changes = []; if (!json) { this._log.debug("No data seen in loaded file: " + file); return false; } let version = json.version; if (!version || version != 1) { this._log.error( "Could not load JSON file because version not " + "supported: " + version ); return false; } this._addons = json.addons; for (let id in this._addons) { let record = this._addons[id]; record.modified = new Date(record.modified); } for (let [time, change, id] of json.changes) { this._changes.push([new Date(time), change, id]); } return true; }, /** * Saves the current state to a file in the local profile. * * @param file * String path in profile to save to. If not defined, the default * will be used. */ async saveState(file = DEFAULT_STATE_FILE) { let state = { version: 1, addons: {}, changes: [] }; for (let [id, record] of Object.entries(this._addons)) { state.addons[id] = {}; for (let [k, v] of Object.entries(record)) { if (k == "modified") { state.addons[id][k] = v.getTime(); } else { state.addons[id][k] = v; } } } for (let [time, change, id] of this._changes) { state.changes.push([time.getTime(), change, id]); } this._log.info("Saving reconciler state to file: " + file); await Utils.jsonSave(file, this, state); }, /** * 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.includes(listener)) { 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(element => { if (element == listener) { this._log.debug("Removing change listener."); return false; } return true; }); }, /** * 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); 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 listener."); AddonManager.removeAddonListener(this); this._listening = false; }, /** * Refreshes the global state of add-ons by querying the AddonManager. */ async refreshGlobalState() { this._log.info("Refreshing global state from AddonManager."); let installs; let addons = await AddonManager.getAllAddons(); let ids = {}; for (let addon of addons) { ids[addon.id] = true; await this.rectifyStateFromAddon(addon); } // Look for locally-defined add-ons that no longer exist and update their // record. for (let [id, addon] of Object.entries(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) { installs = await AddonManager.getAllInstalls(); } let installFound = false; for (let install of 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 ); await this._addChange(new Date(), CHANGE_UNINSTALLED, addon); } } // See note for _shouldPersist. if (this._shouldPersist) { await this.saveState(); } }, /** * 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. */ async rectifyStateFromAddon(addon) { this._log.debug( `Rectifying state for addon ${addon.name} (version=${addon.version}, id=${addon.id})` ); let id = addon.id; let enabled = !addon.userDisabled; let guid = addon.syncGUID; let now = new Date(); if (!(id in this._addons)) { let record = { id, guid, enabled, installed: true, modified: now, type: addon.type, scope: addon.scope, foreignInstall: addon.foreignInstall, isSyncable: addon.isSyncable, }; this._addons[id] = record; this._log.debug( "Adding change because add-on not present locally: " + id ); await this._addChange(now, CHANGE_INSTALLED, record); return; } let record = this._addons[id]; record.isSyncable = addon.isSyncable; 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); await 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. */ async _addChange(date, change, state) { this._log.info("Change recorded for " + state.id); this._changes.push([date, change, state.id]); for (let listener of this._listeners) { try { await listener.changeListener(date, change, state); } catch (ex) { this._log.error("Exception calling change listener", 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(date) { 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(date) { this._changes = this._changes.filter(function test_age(change) { return change[0] >= date; }); }, /** * Obtains the set of all known Sync GUIDs for add-ons. */ 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. */ getAddonStateFromSyncGUID(guid) { for (let id in this.addons) { let addon = this.addons[id]; if (addon.guid == guid) { return addon; } } return null; }, /** * Handler that is invoked as part of the AddonManager listeners. */ async _handleListener(action, addon) { // 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); switch (action) { case "onEnabled": case "onDisabled": case "onInstalled": case "onInstallEnded": case "onOperationCancelled": await this.rectifyStateFromAddon(addon); break; 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 ); await this._addChange(now, CHANGE_UNINSTALLED, record); } } // See note for _shouldPersist. if (this._shouldPersist) { await this.saveState(); } } catch (ex) { this._log.warn("Exception", ex); } }, // AddonListeners onEnabled: function onEnabled(addon) { this.queueCaller.enqueueCall(() => this._handleListener("onEnabled", addon) ); }, onDisabled: function onDisabled(addon) { this.queueCaller.enqueueCall(() => this._handleListener("onDisabled", addon) ); }, onInstalled: function onInstalled(addon) { this.queueCaller.enqueueCall(() => this._handleListener("onInstalled", addon) ); }, onUninstalled: function onUninstalled(addon) { this.queueCaller.enqueueCall(() => this._handleListener("onUninstalled", addon) ); }, onOperationCancelled: function onOperationCancelled(addon) { this.queueCaller.enqueueCall(() => this._handleListener("onOperationCancelled", addon) ); }, };