зеркало из https://github.com/mozilla/gecko-dev.git
593 строки
17 KiB
JavaScript
593 строки
17 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 { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm");
|
|
const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js");
|
|
const { AddonManager } = ChromeUtils.import(
|
|
"resource://gre/modules/AddonManager.jsm"
|
|
);
|
|
|
|
const DEFAULT_STATE_FILE = "addonsreconciler";
|
|
|
|
var CHANGE_INSTALLED = 1;
|
|
var CHANGE_UNINSTALLED = 2;
|
|
var CHANGE_ENABLED = 3;
|
|
var CHANGE_DISABLED = 4;
|
|
|
|
var 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(...);
|
|
* 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.
|
|
*/
|
|
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)
|
|
);
|
|
},
|
|
};
|