Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6 * This file contains middleware to reconcile state of AddonManager for
7 * purposes of tracking events for Sync. The content in this file exists
8 * because AddonManager does not have a getChangesSinceX() API and adding
9 * that functionality properly was deemed too time-consuming at the time
10 * add-on sync was originally written. If/when AddonManager adds this API,
11 * this file can go away and the add-ons engine can be rewritten to use it.
12 *
13 * It was decided to have this tracking functionality exist in a separate
14 * standalone file so it could be more easily understood, tested, and
15 * hopefully ported.
16 */
18 "use strict";
20 const Cu = Components.utils;
22 Cu.import("resource://gre/modules/Log.jsm");
23 Cu.import("resource://services-sync/util.js");
24 Cu.import("resource://gre/modules/AddonManager.jsm");
26 const DEFAULT_STATE_FILE = "addonsreconciler";
28 this.CHANGE_INSTALLED = 1;
29 this.CHANGE_UNINSTALLED = 2;
30 this.CHANGE_ENABLED = 3;
31 this.CHANGE_DISABLED = 4;
33 this.EXPORTED_SYMBOLS = ["AddonsReconciler", "CHANGE_INSTALLED",
34 "CHANGE_UNINSTALLED", "CHANGE_ENABLED",
35 "CHANGE_DISABLED"];
36 /**
37 * Maintains state of add-ons.
38 *
39 * State is maintained in 2 data structures, an object mapping add-on IDs
40 * to metadata and an array of changes over time. The object mapping can be
41 * thought of as a minimal copy of data from AddonManager which is needed for
42 * Sync. The array is effectively a log of changes over time.
43 *
44 * The data structures are persisted to disk by serializing to a JSON file in
45 * the current profile. The data structures are updated by 2 mechanisms. First,
46 * they can be refreshed from the global state of the AddonManager. This is a
47 * sure-fire way of ensuring the reconciler is up to date. Second, the
48 * reconciler adds itself as an AddonManager listener. When it receives change
49 * notifications, it updates its internal state incrementally.
50 *
51 * The internal state is persisted to a JSON file in the profile directory.
52 *
53 * An instance of this is bound to an AddonsEngine instance. In reality, it
54 * likely exists as a singleton. To AddonsEngine, it functions as a store and
55 * an entity which emits events for tracking.
56 *
57 * The usage pattern for instances of this class is:
58 *
59 * let reconciler = new AddonsReconciler();
60 * reconciler.loadState(null, function(error) { ... });
61 *
62 * // At this point, your instance should be ready to use.
63 *
64 * When you are finished with the instance, please call:
65 *
66 * reconciler.stopListening();
67 * reconciler.saveState(...);
68 *
69 * There are 2 classes of listeners in the AddonManager: AddonListener and
70 * InstallListener. This class is a listener for both (member functions just
71 * get called directly).
72 *
73 * When an add-on is installed, listeners are called in the following order:
74 *
75 * IL.onInstallStarted, AL.onInstalling, IL.onInstallEnded, AL.onInstalled
76 *
77 * For non-restartless add-ons, an application restart may occur between
78 * IL.onInstallEnded and AL.onInstalled. Unfortunately, Sync likely will
79 * not be loaded when AL.onInstalled is fired shortly after application
80 * start, so it won't see this event. Therefore, for add-ons requiring a
81 * restart, Sync treats the IL.onInstallEnded event as good enough to
82 * indicate an install. For restartless add-ons, Sync assumes AL.onInstalled
83 * will follow shortly after IL.onInstallEnded and thus it ignores
84 * IL.onInstallEnded.
85 *
86 * The listeners can also see events related to the download of the add-on.
87 * This class isn't interested in those. However, there are failure events,
88 * IL.onDownloadFailed and IL.onDownloadCanceled which get called if a
89 * download doesn't complete successfully.
90 *
91 * For uninstalls, we see AL.onUninstalling then AL.onUninstalled. Like
92 * installs, the events could be separated by an application restart and Sync
93 * may not see the onUninstalled event. Again, if we require a restart, we
94 * react to onUninstalling. If not, we assume we'll get onUninstalled.
95 *
96 * Enabling and disabling work by sending:
97 *
98 * AL.onEnabling, AL.onEnabled
99 * AL.onDisabling, AL.onDisabled
100 *
101 * Again, they may be separated by a restart, so we heed the requiresRestart
102 * flag.
103 *
104 * Actions can be undone. All undoable actions notify the same
105 * AL.onOperationCancelled event. We treat this event like any other.
106 *
107 * Restartless add-ons have interesting behavior during uninstall. These
108 * add-ons are first disabled then they are actually uninstalled. So, we will
109 * see AL.onDisabling and AL.onDisabled. The onUninstalling and onUninstalled
110 * events only come after the Addon Manager is closed or another view is
111 * switched to. In the case of Sync performing the uninstall, the uninstall
112 * events will occur immediately. However, we still see disabling events and
113 * heed them like they were normal. In the end, the state is proper.
114 */
115 this.AddonsReconciler = function AddonsReconciler() {
116 this._log = Log.repository.getLogger("Sync.AddonsReconciler");
117 let level = Svc.Prefs.get("log.logger.addonsreconciler", "Debug");
118 this._log.level = Log.Level[level];
120 Svc.Obs.add("xpcom-shutdown", this.stopListening, this);
121 };
122 AddonsReconciler.prototype = {
123 /** Flag indicating whether we are listening to AddonManager events. */
124 _listening: false,
126 /**
127 * Whether state has been loaded from a file.
128 *
129 * State is loaded on demand if an operation requires it.
130 */
131 _stateLoaded: false,
133 /**
134 * Define this as false if the reconciler should not persist state
135 * to disk when handling events.
136 *
137 * This allows test code to avoid spinning to write during observer
138 * notifications and xpcom shutdown, which appears to cause hangs on WinXP
139 * (Bug 873861).
140 */
141 _shouldPersist: true,
143 /** Log logger instance */
144 _log: null,
146 /**
147 * Container for add-on metadata.
148 *
149 * Keys are add-on IDs. Values are objects which describe the state of the
150 * add-on. This is a minimal mirror of data that can be queried from
151 * AddonManager. In some cases, we retain data longer than AddonManager.
152 */
153 _addons: {},
155 /**
156 * List of add-on changes over time.
157 *
158 * Each element is an array of [time, change, id].
159 */
160 _changes: [],
162 /**
163 * Objects subscribed to changes made to this instance.
164 */
165 _listeners: [],
167 /**
168 * Accessor for add-ons in this object.
169 *
170 * Returns an object mapping add-on IDs to objects containing metadata.
171 */
172 get addons() {
173 this._ensureStateLoaded();
174 return this._addons;
175 },
177 /**
178 * Load reconciler state from a file.
179 *
180 * The path is relative to the weave directory in the profile. If no
181 * path is given, the default one is used.
182 *
183 * If the file does not exist or there was an error parsing the file, the
184 * state will be transparently defined as empty.
185 *
186 * @param path
187 * Path to load. ".json" is appended automatically. If not defined,
188 * a default path will be consulted.
189 * @param callback
190 * Callback to be executed upon file load. The callback receives a
191 * truthy error argument signifying whether an error occurred and a
192 * boolean indicating whether data was loaded.
193 */
194 loadState: function loadState(path, callback) {
195 let file = path || DEFAULT_STATE_FILE;
196 Utils.jsonLoad(file, this, function(json) {
197 this._addons = {};
198 this._changes = [];
200 if (!json) {
201 this._log.debug("No data seen in loaded file: " + file);
202 if (callback) {
203 callback(null, false);
204 }
206 return;
207 }
209 let version = json.version;
210 if (!version || version != 1) {
211 this._log.error("Could not load JSON file because version not " +
212 "supported: " + version);
213 if (callback) {
214 callback(null, false);
215 }
217 return;
218 }
220 this._addons = json.addons;
221 for each (let record in this._addons) {
222 record.modified = new Date(record.modified);
223 }
225 for each (let [time, change, id] in json.changes) {
226 this._changes.push([new Date(time), change, id]);
227 }
229 if (callback) {
230 callback(null, true);
231 }
232 });
233 },
235 /**
236 * Saves the current state to a file in the local profile.
237 *
238 * @param path
239 * String path in profile to save to. If not defined, the default
240 * will be used.
241 * @param callback
242 * Function to be invoked on save completion. No parameters will be
243 * passed to callback.
244 */
245 saveState: function saveState(path, callback) {
246 let file = path || DEFAULT_STATE_FILE;
247 let state = {version: 1, addons: {}, changes: []};
249 for (let [id, record] in Iterator(this._addons)) {
250 state.addons[id] = {};
251 for (let [k, v] in Iterator(record)) {
252 if (k == "modified") {
253 state.addons[id][k] = v.getTime();
254 }
255 else {
256 state.addons[id][k] = v;
257 }
258 }
259 }
261 for each (let [time, change, id] in this._changes) {
262 state.changes.push([time.getTime(), change, id]);
263 }
265 this._log.info("Saving reconciler state to file: " + file);
266 Utils.jsonSave(file, this, state, callback);
267 },
269 /**
270 * Registers a change listener with this instance.
271 *
272 * Change listeners are called every time a change is recorded. The listener
273 * is an object with the function "changeListener" that takes 3 arguments,
274 * the Date at which the change happened, the type of change (a CHANGE_*
275 * constant), and the add-on state object reflecting the current state of
276 * the add-on at the time of the change.
277 *
278 * @param listener
279 * Object containing changeListener function.
280 */
281 addChangeListener: function addChangeListener(listener) {
282 if (this._listeners.indexOf(listener) == -1) {
283 this._log.debug("Adding change listener.");
284 this._listeners.push(listener);
285 }
286 },
288 /**
289 * Removes a previously-installed change listener from the instance.
290 *
291 * @param listener
292 * Listener instance to remove.
293 */
294 removeChangeListener: function removeChangeListener(listener) {
295 this._listeners = this._listeners.filter(function(element) {
296 if (element == listener) {
297 this._log.debug("Removing change listener.");
298 return false;
299 } else {
300 return true;
301 }
302 }.bind(this));
303 },
305 /**
306 * Tells the instance to start listening for AddonManager changes.
307 *
308 * This is typically called automatically when Sync is loaded.
309 */
310 startListening: function startListening() {
311 if (this._listening) {
312 return;
313 }
315 this._log.info("Registering as Add-on Manager listener.");
316 AddonManager.addAddonListener(this);
317 AddonManager.addInstallListener(this);
318 this._listening = true;
319 },
321 /**
322 * Tells the instance to stop listening for AddonManager changes.
323 *
324 * The reconciler should always be listening. This should only be called when
325 * the instance is being destroyed.
326 *
327 * This function will get called automatically on XPCOM shutdown. However, it
328 * is a best practice to call it yourself.
329 */
330 stopListening: function stopListening() {
331 if (!this._listening) {
332 return;
333 }
335 this._log.debug("Stopping listening and removing AddonManager listeners.");
336 AddonManager.removeInstallListener(this);
337 AddonManager.removeAddonListener(this);
338 this._listening = false;
339 },
341 /**
342 * Refreshes the global state of add-ons by querying the AddonManager.
343 */
344 refreshGlobalState: function refreshGlobalState(callback) {
345 this._log.info("Refreshing global state from AddonManager.");
346 this._ensureStateLoaded();
348 let installs;
350 AddonManager.getAllAddons(function (addons) {
351 let ids = {};
353 for each (let addon in addons) {
354 ids[addon.id] = true;
355 this.rectifyStateFromAddon(addon);
356 }
358 // Look for locally-defined add-ons that no longer exist and update their
359 // record.
360 for (let [id, addon] in Iterator(this._addons)) {
361 if (id in ids) {
362 continue;
363 }
365 // If the id isn't in ids, it means that the add-on has been deleted or
366 // the add-on is in the process of being installed. We detect the
367 // latter by seeing if an AddonInstall is found for this add-on.
369 if (!installs) {
370 let cb = Async.makeSyncCallback();
371 AddonManager.getAllInstalls(cb);
372 installs = Async.waitForSyncCallback(cb);
373 }
375 let installFound = false;
376 for each (let install in installs) {
377 if (install.addon && install.addon.id == id &&
378 install.state == AddonManager.STATE_INSTALLED) {
380 installFound = true;
381 break;
382 }
383 }
385 if (installFound) {
386 continue;
387 }
389 if (addon.installed) {
390 addon.installed = false;
391 this._log.debug("Adding change because add-on not present in " +
392 "Add-on Manager: " + id);
393 this._addChange(new Date(), CHANGE_UNINSTALLED, addon);
394 }
395 }
397 // See note for _shouldPersist.
398 if (this._shouldPersist) {
399 this.saveState(null, callback);
400 } else {
401 callback();
402 }
403 }.bind(this));
404 },
406 /**
407 * Rectifies the state of an add-on from an Addon instance.
408 *
409 * This basically says "given an Addon instance, assume it is truth and
410 * apply changes to the local state to reflect it."
411 *
412 * This function could result in change listeners being called if the local
413 * state differs from the passed add-on's state.
414 *
415 * @param addon
416 * Addon instance being updated.
417 */
418 rectifyStateFromAddon: function rectifyStateFromAddon(addon) {
419 this._log.debug("Rectifying state for addon: " + addon.id);
420 this._ensureStateLoaded();
422 let id = addon.id;
423 let enabled = !addon.userDisabled;
424 let guid = addon.syncGUID;
425 let now = new Date();
427 if (!(id in this._addons)) {
428 let record = {
429 id: id,
430 guid: guid,
431 enabled: enabled,
432 installed: true,
433 modified: now,
434 type: addon.type,
435 scope: addon.scope,
436 foreignInstall: addon.foreignInstall
437 };
438 this._addons[id] = record;
439 this._log.debug("Adding change because add-on not present locally: " +
440 id);
441 this._addChange(now, CHANGE_INSTALLED, record);
442 return;
443 }
445 let record = this._addons[id];
447 if (!record.installed) {
448 // It is possible the record is marked as uninstalled because an
449 // uninstall is pending.
450 if (!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
451 record.installed = true;
452 record.modified = now;
453 }
454 }
456 if (record.enabled != enabled) {
457 record.enabled = enabled;
458 record.modified = now;
459 let change = enabled ? CHANGE_ENABLED : CHANGE_DISABLED;
460 this._log.debug("Adding change because enabled state changed: " + id);
461 this._addChange(new Date(), change, record);
462 }
464 if (record.guid != guid) {
465 record.guid = guid;
466 // We don't record a change because the Sync engine rectifies this on its
467 // own. This is tightly coupled with Sync. If this code is ever lifted
468 // outside of Sync, this exception should likely be removed.
469 }
470 },
472 /**
473 * Record a change in add-on state.
474 *
475 * @param date
476 * Date at which the change occurred.
477 * @param change
478 * The type of the change. A CHANGE_* constant.
479 * @param state
480 * The new state of the add-on. From this.addons.
481 */
482 _addChange: function _addChange(date, change, state) {
483 this._log.info("Change recorded for " + state.id);
484 this._changes.push([date, change, state.id]);
486 for each (let listener in this._listeners) {
487 try {
488 listener.changeListener.call(listener, date, change, state);
489 } catch (ex) {
490 this._log.warn("Exception calling change listener: " +
491 Utils.exceptionStr(ex));
492 }
493 }
494 },
496 /**
497 * Obtain the set of changes to add-ons since the date passed.
498 *
499 * This will return an array of arrays. Each entry in the array has the
500 * elements [date, change_type, id], where
501 *
502 * date - Date instance representing when the change occurred.
503 * change_type - One of CHANGE_* constants.
504 * id - ID of add-on that changed.
505 */
506 getChangesSinceDate: function getChangesSinceDate(date) {
507 this._ensureStateLoaded();
509 let length = this._changes.length;
510 for (let i = 0; i < length; i++) {
511 if (this._changes[i][0] >= date) {
512 return this._changes.slice(i);
513 }
514 }
516 return [];
517 },
519 /**
520 * Prunes all recorded changes from before the specified Date.
521 *
522 * @param date
523 * Entries older than this Date will be removed.
524 */
525 pruneChangesBeforeDate: function pruneChangesBeforeDate(date) {
526 this._ensureStateLoaded();
528 this._changes = this._changes.filter(function test_age(change) {
529 return change[0] >= date;
530 });
531 },
533 /**
534 * Obtains the set of all known Sync GUIDs for add-ons.
535 *
536 * @return Object with guids as keys and values of true.
537 */
538 getAllSyncGUIDs: function getAllSyncGUIDs() {
539 let result = {};
540 for (let id in this.addons) {
541 result[id] = true;
542 }
544 return result;
545 },
547 /**
548 * Obtain the add-on state record for an add-on by Sync GUID.
549 *
550 * If the add-on could not be found, returns null.
551 *
552 * @param guid
553 * Sync GUID of add-on to retrieve.
554 * @return Object on success on null on failure.
555 */
556 getAddonStateFromSyncGUID: function getAddonStateFromSyncGUID(guid) {
557 for each (let addon in this.addons) {
558 if (addon.guid == guid) {
559 return addon;
560 }
561 }
563 return null;
564 },
566 /**
567 * Ensures that state is loaded before continuing.
568 *
569 * This is called internally by anything that accesses the internal data
570 * structures. It effectively just-in-time loads serialized state.
571 */
572 _ensureStateLoaded: function _ensureStateLoaded() {
573 if (this._stateLoaded) {
574 return;
575 }
577 let cb = Async.makeSpinningCallback();
578 this.loadState(null, cb);
579 cb.wait();
580 this._stateLoaded = true;
581 },
583 /**
584 * Handler that is invoked as part of the AddonManager listeners.
585 */
586 _handleListener: function _handlerListener(action, addon, requiresRestart) {
587 // Since this is called as an observer, we explicitly trap errors and
588 // log them to ourselves so we don't see errors reported elsewhere.
589 try {
590 let id = addon.id;
591 this._log.debug("Add-on change: " + action + " to " + id);
593 // We assume that every event for non-restartless add-ons is
594 // followed by another event and that this follow-up event is the most
595 // appropriate to react to. Currently we ignore onEnabling, onDisabling,
596 // and onUninstalling for non-restartless add-ons.
597 if (requiresRestart === false) {
598 this._log.debug("Ignoring " + action + " for restartless add-on.");
599 return;
600 }
602 switch (action) {
603 case "onEnabling":
604 case "onEnabled":
605 case "onDisabling":
606 case "onDisabled":
607 case "onInstalled":
608 case "onInstallEnded":
609 case "onOperationCancelled":
610 this.rectifyStateFromAddon(addon);
611 break;
613 case "onUninstalling":
614 case "onUninstalled":
615 let id = addon.id;
616 let addons = this.addons;
617 if (id in addons) {
618 let now = new Date();
619 let record = addons[id];
620 record.installed = false;
621 record.modified = now;
622 this._log.debug("Adding change because of uninstall listener: " +
623 id);
624 this._addChange(now, CHANGE_UNINSTALLED, record);
625 }
626 }
628 // See note for _shouldPersist.
629 if (this._shouldPersist) {
630 let cb = Async.makeSpinningCallback();
631 this.saveState(null, cb);
632 cb.wait();
633 }
634 }
635 catch (ex) {
636 this._log.warn("Exception: " + Utils.exceptionStr(ex));
637 }
638 },
640 // AddonListeners
641 onEnabling: function onEnabling(addon, requiresRestart) {
642 this._handleListener("onEnabling", addon, requiresRestart);
643 },
644 onEnabled: function onEnabled(addon) {
645 this._handleListener("onEnabled", addon);
646 },
647 onDisabling: function onDisabling(addon, requiresRestart) {
648 this._handleListener("onDisabling", addon, requiresRestart);
649 },
650 onDisabled: function onDisabled(addon) {
651 this._handleListener("onDisabled", addon);
652 },
653 onInstalling: function onInstalling(addon, requiresRestart) {
654 this._handleListener("onInstalling", addon, requiresRestart);
655 },
656 onInstalled: function onInstalled(addon) {
657 this._handleListener("onInstalled", addon);
658 },
659 onUninstalling: function onUninstalling(addon, requiresRestart) {
660 this._handleListener("onUninstalling", addon, requiresRestart);
661 },
662 onUninstalled: function onUninstalled(addon) {
663 this._handleListener("onUninstalled", addon);
664 },
665 onOperationCancelled: function onOperationCancelled(addon) {
666 this._handleListener("onOperationCancelled", addon);
667 },
669 // InstallListeners
670 onInstallEnded: function onInstallEnded(install, addon) {
671 this._handleListener("onInstallEnded", addon);
672 }
673 };