/* ***** BEGIN LICENSE BLOCK ***** * Version: MPL 1.1/GPL 2.0/LGPL 2.1 * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * http://www.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is Firefox Sync. * * The Initial Developer of the Original Code is the Mozilla Foundation. * Portions created by the Initial Developer are Copyright (C) 2011 * the Initial Developer. All Rights Reserved. * * Contributor(s): * Gregory Szorc * * Alternatively, the contents of this file may be used under the terms of * either the GNU General Public License Version 2 or later (the "GPL"), or * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), * in which case the provisions of the GPL or the LGPL are applicable instead * of those above. If you wish to allow use of your version of this file only * under the terms of either the GPL or the LGPL, and not to allow others to * use your version of this file under the terms of the MPL, indicate your * decision by deleting the provisions above and replace them with the notice * and other provisions required by the GPL or the LGPL. If you do not delete * the provisions above, a recipient may use your version of this file under * the terms of any one of the MPL, the GPL or the LGPL. * * ***** END LICENSE BLOCK ***** */ /* * This file defines the add-on sync functionality. * * There are currently a number of known limitations: * - We only sync XPI extensions and themes available from addons.mozilla.org. * We hope to expand support for other add-ons eventually. * - We only attempt syncing of add-ons between applications of the same type. * This means add-ons will not synchronize between Firefox desktop and * Firefox mobile, for example. This is because of significant add-on * incompatibility between application types. * * Add-on records exist for each known {add-on, app-id} pair in the Sync client * set. Each record has a randomly chosen GUID. The records then contain * basic metadata about the add-on. * * We currently synchronize: * * - Installations * - Uninstallations * - User enabling and disabling * * Synchronization is influenced by the following preferences: * * - services.sync.addons.ignoreRepositoryChecking * - services.sync.addons.ignoreUserEnabledChanges * - services.sync.addons.trustedSourceHostnames * * See the documentation in services-sync.js for the behavior of these prefs. */ "use strict"; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://services-sync/addonsreconciler.js"); Cu.import("resource://services-sync/engines.js"); Cu.import("resource://services-sync/record.js"); Cu.import("resource://services-sync/util.js"); Cu.import("resource://services-sync/constants.js"); Cu.import("resource://services-sync/async.js"); Cu.import("resource://services-sync/ext/Preferences.js"); Cu.import("resource://gre/modules/AddonManager.jsm"); Cu.import("resource://gre/modules/AddonRepository.jsm"); const EXPORTED_SYMBOLS = ["AddonsEngine"]; // 7 days in milliseconds. const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000; /** * AddonRecord represents the state of an add-on in an application. * * Each add-on has its own record for each application ID it is installed * on. * * The ID of add-on records is a randomly-generated GUID. It is random instead * of deterministic so the URIs of the records cannot be guessed and so * compromised server credentials won't result in disclosure of the specific * add-ons present in a Sync account. * * The record contains the following fields: * * addonID * ID of the add-on. This correlates to the "id" property on an Addon type. * * applicationID * The application ID this record is associated with. * * enabled * Boolean stating whether add-on is enabled or disabled by the user. * * source * String indicating where an add-on is from. Currently, we only support * the value "amo" which indicates that the add-on came from the official * add-ons repository, addons.mozilla.org. In the future, we may support * installing add-ons from other sources. This provides a future-compatible * mechanism for clients to only apply records they know how to handle. */ function AddonRecord(collection, id) { CryptoWrapper.call(this, collection, id); } AddonRecord.prototype = { __proto__: CryptoWrapper.prototype, _logName: "Record.Addon" }; Utils.deferGetSet(AddonRecord, "cleartext", ["addonID", "applicationID", "enabled", "source"]); /** * The AddonsEngine handles synchronization of add-ons between clients. * * The engine maintains an instance of an AddonsReconciler, which is the entity * maintaining state for add-ons. It provides the history and tracking APIs * that AddonManager doesn't. * * The engine instance overrides a handful of functions on the base class. The * rationale for each is documented by that function. */ function AddonsEngine() { SyncEngine.call(this, "Addons"); this._reconciler = new AddonsReconciler(); } AddonsEngine.prototype = { __proto__: SyncEngine.prototype, _storeObj: AddonsStore, _trackerObj: AddonsTracker, _recordObj: AddonRecord, version: 1, _reconciler: null, /** * Override parent method to find add-ons by their public ID, not Sync GUID. */ _findDupe: function _findDupe(item) { let id = item.addonID; // The reconciler should have been updated at the top of the sync, so we // can assume it is up to date when this function is called. let addons = this._reconciler.addons; if (!(id in addons)) { return null; } let addon = addons[id]; if (addon.guid != item.id) { return addon.guid; } return null; }, /** * Override getChangedIDs to pull in tracker changes plus changes from the * reconciler log. */ getChangedIDs: function getChangedIDs() { let changes = {}; for (let [id, modified] in Iterator(this._tracker.changedIDs)) { changes[id] = modified; } let lastSyncDate = new Date(this.lastSync * 1000); // The reconciler should have been refreshed at the beginning of a sync and // we assume this function is only called from within a sync. let reconcilerChanges = this._reconciler.getChangesSinceDate(lastSyncDate); let addons = this._reconciler.addons; for each (let change in reconcilerChanges) { let changeTime = change[0]; let id = change[2]; if (!(id in addons)) { continue; } // Keep newest modified time. if (id in changes && changeTime < changes[id]) { continue; } if (!this._store.isAddonSyncable(addons[id])) { continue; } this._log.debug("Adding changed add-on from changes log: " + id); let addon = addons[id]; changes[addon.guid] = changeTime.getTime() / 1000; } return changes; }, /** * Override start of sync function to refresh reconciler. * * Many functions in this class assume the reconciler is refreshed at the * top of a sync. If this ever changes, those functions should be revisited. * * Technically speaking, we don't need to refresh the reconciler on every * sync since it is installed as an AddonManager listener. However, add-ons * are complicated and we force a full refresh, just in case the listeners * missed something. */ _syncStartup: function _syncStartup() { // We refresh state before calling parent because syncStartup in the parent // looks for changed IDs, which is dependent on add-on state being up to // date. this._refreshReconcilerState(); SyncEngine.prototype._syncStartup.call(this); }, /** * Override end of sync to perform a little housekeeping on the reconciler. * * We prune changes to prevent the reconciler state from growing without * bound. Even if it grows unbounded, there would have to be many add-on * changes (thousands) for it to slow things down significantly. This is * highly unlikely to occur. Still, we exercise defense just in case. */ _syncCleanup: function _syncCleanup() { let ms = 1000 * this.lastSync - PRUNE_ADDON_CHANGES_THRESHOLD; this._reconciler.pruneChangesBeforeDate(new Date(ms)); SyncEngine.prototype._syncCleanup.call(this); }, /** * Helper function to ensure reconciler is up to date. * * This will synchronously load the reconciler's state from the file * system (if needed) and refresh the state of the reconciler. */ _refreshReconcilerState: function _refreshReconcilerState() { this._log.debug("Refreshing reconciler state"); let cb = Async.makeSpinningCallback(); this._reconciler.refreshGlobalState(cb); cb.wait(); } }; /** * This is the primary interface between Sync and the Addons Manager. * * In addition to the core store APIs, we provide convenience functions to wrap * Add-on Manager APIs with Sync-specific semantics. */ function AddonsStore(name) { Store.call(this, name); } AddonsStore.prototype = { __proto__: Store.prototype, // Define the add-on types (.type) that we support. _syncableTypes: ["extension", "theme"], get reconciler() { return this.engine._reconciler; }, get engine() { // Ideally we'd link to a specific object, but the API doesn't provide an // easy way to faciliate this. When the async API lands, this hackiness can // go away. return Engines.get("addons"); }, /** * Override applyIncoming to filter out records we can't handle. */ applyIncoming: function applyIncoming(record) { // The fields we look at aren't present when the record is deleted. if (!record.deleted) { // Ignore records not belonging to our application ID because that is the // current policy. if (record.applicationID != Services.appinfo.ID) { this._log.info("Ignoring incoming record from other App ID: " + record.id); return; } // Ignore records that aren't from the official add-on repository, as that // is our current policy. if (record.source != "amo") { this._log.info("Ignoring unknown add-on source (" + record.source + ")" + " for " + record.id); return; } } Store.prototype.applyIncoming.call(this, record); }, /** * Provides core Store API to create/install an add-on from a record. */ create: function create(record) { // Ideally, we'd set syncGUID and userDisabled on install. For now, we // make the changes post-installation. // TODO Set syncGUID and userDisabled in one step, during install. let cb = Async.makeSpinningCallback(); this.installAddonsFromIDs([record.addonID], cb); // This will throw if there was an error. This will get caught by the sync // engine and the record will try to be applied later. let results = cb.wait(); let addon; for each (let a in results.addons) { if (a.id == record.addonID) { addon = a; break; } } // This should never happen, but is present as a fail-safe. if (!addon) { throw new Error("Add-on not found after install: " + record.addonID); } this._log.info("Add-on installed: " + record.addonID); this._log.info("Setting add-on Sync GUID to remote: " + record.id); addon.syncGUID = record.id; cb = Async.makeSpinningCallback(); this.updateUserDisabled(addon, !record.enabled, cb); cb.wait(); }, /** * Provides core Store API to remove/uninstall an add-on from a record. */ remove: function remove(record) { // If this is called, the payload is empty, so we have to find by GUID. let addon = this.getAddonByGUID(record.id); if (!addon) { // We don't throw because if the add-on could not be found then we assume // it has already been uninstalled and there is nothing for this function // to do. return; } this._log.info("Uninstalling add-on: " + addon.id); let cb = Async.makeSpinningCallback(); this.uninstallAddon(addon, cb); cb.wait(); }, /** * Provides core Store API to update an add-on from a record. */ update: function update(record) { let addon = this.getAddonByID(record.addonID); // This should only happen if there is a race condition between an add-on // being uninstalled locally and Sync calling this function after it // determines an add-on exists. if (!addon) { this._log.warn("Requested to update record but add-on not found: " + record.addonID); return; } let cb = Async.makeSpinningCallback(); this.updateUserDisabled(addon, !record.enabled, cb); cb.wait(); }, /** * Provide core Store API to determine if a record exists. */ itemExists: function itemExists(guid) { let addon = this.reconciler.getAddonStateFromSyncGUID(guid); return !!addon; }, /** * Create an add-on record from its GUID. * * @param guid * Add-on GUID (from extensions DB) * @param collection * Collection to add record to. * * @return AddonRecord instance */ createRecord: function createRecord(guid, collection) { let record = new AddonRecord(collection, guid); record.applicationID = Services.appinfo.ID; let addon = this.reconciler.getAddonStateFromSyncGUID(guid); // If we don't know about this GUID or if it has been uninstalled, we mark // the record as deleted. if (!addon || !addon.installed) { record.deleted = true; return record; } record.modified = addon.modified.getTime() / 1000; record.addonID = addon.id; record.enabled = addon.enabled; // This needs to be dynamic when add-ons don't come from AddonRepository. record.source = "amo"; return record; }, /** * Changes the id of an add-on. * * This implements a core API of the store. */ changeItemID: function changeItemID(oldID, newID) { // We always update the GUID in the reconciler because it will be // referenced later in the sync process. let state = this.reconciler.getAddonStateFromSyncGUID(oldID); if (state) { state.guid = newID; let cb = Async.makeSpinningCallback(); this.reconciler.saveState(null, cb); cb.wait(); } let addon = this.getAddonByGUID(oldID); if (!addon) { this._log.debug("Cannot change item ID (" + oldID + ") in Add-on " + "Manager because old add-on not present: " + oldID); return; } addon.syncGUID = newID; }, /** * Obtain the set of all syncable add-on Sync GUIDs. * * This implements a core Store API. */ getAllIDs: function getAllIDs() { let ids = {}; let addons = this.reconciler.addons; for each (let addon in addons) { if (this.isAddonSyncable(addon)) { ids[addon.guid] = true; } } return ids; }, /** * Wipe engine data. * * This uninstalls all syncable addons from the application. In case of * error, it logs the error and keeps trying with other add-ons. */ wipe: function wipe() { this._log.info("Processing wipe."); this.engine._refreshReconcilerState(); // We only wipe syncable add-ons. Wipe is a Sync feature not a security // feature. for (let guid in this.getAllIDs()) { let addon = this.getAddonByGUID(guid); if (!addon) { this._log.debug("Ignoring add-on because it couldn't be obtained: " + guid); continue; } this._log.info("Uninstalling add-on as part of wipe: " + addon.id); Utils.catch(addon.uninstall)(); } }, /*************************************************************************** * Functions below are unique to this store and not part of the Store API * ***************************************************************************/ /** * Synchronously obtain an add-on from its public ID. * * @param id * Add-on ID * @return Addon or undefined if not found */ getAddonByID: function getAddonByID(id) { let cb = Async.makeSyncCallback(); AddonManager.getAddonByID(id, cb); return Async.waitForSyncCallback(cb); }, /** * Synchronously obtain an add-on from its Sync GUID. * * @param guid * Add-on Sync GUID * @return DBAddonInternal or null */ getAddonByGUID: function getAddonByGUID(guid) { let cb = Async.makeSyncCallback(); AddonManager.getAddonBySyncGUID(guid, cb); return Async.waitForSyncCallback(cb); }, /** * Determines whether an add-on is suitable for Sync. * * @param addon * Addon instance * @return Boolean indicating whether it is appropriate for Sync */ isAddonSyncable: function isAddonSyncable(addon) { // Currently, we limit syncable add-ons to those that are: // 1) In a well-defined set of types // 2) Installed in the current profile // 3) Not installed by a foreign entity (i.e. installed by the app) // since they act like global extensions. // 4) Are installed from AMO // We could represent the test as a complex boolean expression. We go the // verbose route so the failure reason is logged. if (!addon) { this._log.debug("Null object passed to isAddonSyncable."); return false; } if (this._syncableTypes.indexOf(addon.type) == -1) { this._log.debug(addon.id + " not syncable: type not in whitelist: " + addon.type); return false; } if (!(addon.scope & AddonManager.SCOPE_PROFILE)) { this._log.debug(addon.id + " not syncable: not installed in profile."); return false; } // This may be too aggressive. If an add-on is downloaded from AMO and // manually placed in the profile directory, foreignInstall will be set. // Arguably, that add-on should be syncable. // TODO Address the edge case and come up with more robust heuristics. if (addon.foreignInstall) { this._log.debug(addon.id + " not syncable: is foreign install."); return false; } // We provide a back door to skip the repository checking of an add-on. // This is utilized by the tests to make testing easier. Users could enable // this, but it would sacrifice security. if (Svc.Prefs.get("addons.ignoreRepositoryChecking", false)) { return true; } let cb = Async.makeSyncCallback(); AddonRepository.getCachedAddonByID(addon.id, cb); let result = Async.waitForSyncCallback(cb); if (!result) { this._log.debug(addon.id + " not syncable: add-on not found in add-on " + "repository."); return false; } return this.isSourceURITrusted(result.sourceURI); }, /** * Determine whether an add-on's sourceURI field is trusted and the add-on * can be installed. * * This function should only ever be called from isAddonSyncable(). It is * exposed as a separate function to make testing easier. * * @param uri * nsIURI instance to validate * @return bool */ isSourceURITrusted: function isSourceURITrusted(uri) { // For security reasons, we currently limit synced add-ons to those // installed from trusted hostname(s). We additionally require TLS with // the add-ons site to help prevent forgeries. let trustedHostnames = Svc.Prefs.get("addons.trustedSourceHostnames", "") .split(","); if (!uri) { this._log.debug("Undefined argument to isSourceURITrusted()."); return false; } // Scheme is validated before the hostname because uri.host may not be // populated for certain schemes. It appears to always be populated for // https, so we avoid the potential NS_ERROR_FAILURE on field access. if (uri.scheme != "https") { this._log.debug("Source URI not HTTPS: " + uri.spec); return false; } if (trustedHostnames.indexOf(uri.host) == -1) { this._log.debug("Source hostname not trusted: " + uri.host); return false; } return true; }, /** * Obtain an AddonInstall object from an AddonSearchResult instance. * * The callback will be invoked with the result of the operation. The * callback receives 2 arguments, error and result. Error will be falsy * on success or some kind of error value otherwise. The result argument * will be an AddonInstall on success or null on failure. It is possible * for the error to be falsy but result to be null. This could happen if * an install was not found. * * @param addon * AddonSearchResult to obtain install from. * @param cb * Function to be called with result of operation. */ getInstallFromSearchResult: function getInstallFromSearchResult(addon, cb) { // If we have an install, use it. if (addon.install) { cb(null, addon.install); return; } this._log.debug("Manually obtaining install for " + addon.id); // Verify that the source URI uses TLS. We don't allow installs from // insecure sources for security reasons. The Addon Manager ensures that // cert validation, etc is performed. if (!Svc.Prefs.get("addons.ignoreRepositoryChecking", false)) { let scheme = addon.sourceURI.scheme; if (scheme != "https") { cb(new Error("Insecure source URI scheme: " + scheme), addon.install); } } AddonManager.getInstallForURL( addon.sourceURI.spec, function handleInstall(install) { cb(null, install); }, "application/x-xpinstall", undefined, addon.name, addon.iconURL, addon.version ); }, /** * Installs an add-on from an AddonSearchResult instance. * * When complete it calls a callback with 2 arguments, error and result. * * If error is falsy, result is an object. If error is truthy, result is * null. * * The result object has the following keys: * * id ID of add-on that was installed. * install AddonInstall that was installed. * addon Addon that was installed. * * @param addon * AddonSearchResult to install add-on from. * @param cb * Function to be invoked with result of operation. */ installAddonFromSearchResult: function installAddonFromSearchResult(addon, cb) { this._log.info("Trying to install add-on from search result: " + addon.id); this.getInstallFromSearchResult(addon, function(error, install) { if (error) { cb(error, null); return; } if (!install) { cb(new Error("AddonInstall not available: " + addon.id), null); return; } try { this._log.info("Installing " + addon.id); let listener = { onInstallEnded: function(install, addon) { install.removeListener(listener); cb(null, {id: addon.id, install: install, addon: addon}); }, onInstallFailed: function(install) { install.removeListener(listener); cb(new Error("Install failed: " + install.error), null); }, onDownloadFailed: function(install) { install.removeListener(listener); cb(new Error("Download failed: " + install.error), null); } }; install.addListener(listener); install.install(); } catch (ex) { this._log.error("Error installing add-on: " + Utils.exceptionstr(ex)); cb(ex, null); } }.bind(this)); }, /** * Uninstalls the Addon instance and invoke a callback when it is done. * * @param addon * Addon instance to uninstall. * @param callback * Function to be invoked when uninstall has finished. It receives a * truthy value signifying error and the add-on which was uninstalled. */ uninstallAddon: function uninstallAddon(addon, callback) { let listener = { onUninstalling: function(uninstalling, needsRestart) { if (addon.id != uninstalling.id) { return; } // We assume restartless add-ons will send the onUninstalled event // soon. if (!needsRestart) { return; } // For non-restartless add-ons, we issue the callback on uninstalling // because we will likely never see the uninstalled event. AddonManager.removeAddonListener(listener); callback(null, addon); }, onUninstalled: function(uninstalled) { if (addon.id != uninstalled.id) { return; } AddonManager.removeAddonListener(listener); callback(null, addon); } }; AddonManager.addAddonListener(listener); addon.uninstall(); }, /** * Update the userDisabled flag on an add-on. * * This will enable or disable an add-on and call the supplied callback when * the action is complete. If no action is needed, the callback gets called * immediately. * * @param addon * Addon instance to manipulate. * @param value * Boolean to which to set userDisabled on the passed Addon. * @param callback * Function to be called when action is complete. Will receive 2 * arguments, a truthy value that signifies error, and the Addon * instance passed to this function. */ updateUserDisabled: function updateUserDisabled(addon, value, callback) { if (addon.userDisabled == value) { callback(null, addon); return; } // A pref allows changes to the enabled flag to be ignored. if (Svc.Prefs.get("addons.ignoreUserEnabledChanges", false)) { this._log.info("Ignoring enabled state change due to preference: " + addon.id); callback(null, addon); return; } let listener = { onEnabling: function onEnabling(wrapper, needsRestart) { this._log.debug("onEnabling: " + wrapper.id); if (wrapper.id != addon.id) { return; } // We ignore the restartless case because we'll get onEnabled shortly. if (!needsRestart) { return; } AddonManager.removeAddonListener(listener); callback(null, wrapper); }.bind(this), onEnabled: function onEnabled(wrapper) { this._log.debug("onEnabled: " + wrapper.id); if (wrapper.id != addon.id) { return; } AddonManager.removeAddonListener(listener); callback(null, wrapper); }.bind(this), onDisabling: function onDisabling(wrapper, needsRestart) { this._log.debug("onDisabling: " + wrapper.id); if (wrapper.id != addon.id) { return; } if (!needsRestart) { return; } AddonManager.removeAddonListener(listener); callback(null, wrapper); }.bind(this), onDisabled: function onDisabled(wrapper) { this._log.debug("onDisabled: " + wrapper.id); if (wrapper.id != addon.id) { return; } AddonManager.removeAddonListener(listener); callback(null, wrapper); }.bind(this), onOperationCancelled: function onOperationCancelled(wrapper) { this._log.debug("onOperationCancelled: " + wrapper.id); if (wrapper.id != addon.id) { return; } AddonManager.removeAddonListener(listener); callback(new Error("Operation cancelled"), wrapper); }.bind(this) }; // The add-on listeners are only fired if the add-on is active. If not, the // change is silently updated and made active when/if the add-on is active. if (!addon.appDisabled) { AddonManager.addAddonListener(listener); } this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value); addon.userDisabled = !!value; if (!addon.appDisabled) { callback(null, addon); return; } // Else the listener will handle invoking the callback. }, /** * Installs multiple add-ons specified by their IDs. * * The callback will be called when activity on all add-ons is complete. The * callback receives 2 arguments, error and result. * * If error is truthy, it contains a string describing the overall error. * * The 2nd argument to the callback is always an object with details on the * overall execution state. It contains the following keys: * * installedIDs Array of add-on IDs that were installed. * installs Array of AddonInstall instances that were installed. * addons Array of Addon instances that were installed. * errors Array of errors encountered. Only has elements if error is * truthy. * * @param ids * Array of add-on string IDs to install. * @param cb * Function to be called when all actions are complete. */ installAddonsFromIDs: function installAddonsFromIDs(ids, cb) { if (!cb) { throw new Error("Invalid argument: cb is not defined."); } AddonRepository.getAddonsByIDs(ids, { searchSucceeded: function searchSucceeded(addons, addonsLength, total) { this._log.info("Found " + addonsLength + "/" + ids.length + " add-ons during repository search."); let ourResult = { installedIDs: [], installs: [], addons: [], errors: [] }; if (!addonsLength) { cb(null, ourResult); return; } let finishedCount = 0; let installCallback = function installCallback(error, result) { finishedCount++; if (error) { ourResult.errors.push(error); } else { ourResult.installedIDs.push(result.id); ourResult.installs.push(result.install); ourResult.addons.push(result.addon); } if (finishedCount >= addonsLength) { if (ourResult.errors.length > 0) { cb(new Error("1 or more add-ons failed to install"), ourResult); } else { cb(null, ourResult); } } }.bind(this); // Start all the installs asynchronously. They will report back to us // as they finish, eventually triggering the global callback. for (let i = 0; i < addonsLength; i++) { this.installAddonFromSearchResult(addons[i], installCallback); } }.bind(this), searchFailed: function searchFailed() { cb(new Error("AddonRepository search failed"), null); }.bind(this) }); } }; /** * The add-ons tracker keeps track of real-time changes to add-ons. * * It hooks up to the reconciler and receives notifications directly from it. */ function AddonsTracker(name) { Tracker.call(this, name); Svc.Obs.add("weave:engine:start-tracking", this); Svc.Obs.add("weave:engine:stop-tracking", this); } AddonsTracker.prototype = { __proto__: Tracker.prototype, get reconciler() { return Engines.get("addons")._reconciler; }, get store() { return Engines.get("addons")._store; }, /** * This callback is executed whenever the AddonsReconciler sends out a change * notification. See AddonsReconciler.addChangeListener(). */ changeListener: function changeHandler(date, change, addon) { this._log.debug("changeListener invoked: " + change + " " + addon.id); // Ignore changes that occur during sync. if (this.ignoreAll) { return; } if (!this.store.isAddonSyncable(addon)) { this._log.debug("Ignoring change because add-on isn't syncable: " + addon.id); return; } this.addChangedID(addon.guid, date.getTime() / 1000); this.score += SCORE_INCREMENT_XLARGE; }, observe: function(subject, topic, data) { switch (topic) { case "weave:engine:start-tracking": this.reconciler.addChangeListener(this); break; case "weave:engine:stop-tracking": this.reconciler.removeChangeListener(this); break; } } };