summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/addonsreconciler.js
blob: a60fc8d566670e0cbc7a345f01b4c9eb4d63be9d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
/* 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";

var Cu = Components.utils;

Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://services-sync/util.js");
Cu.import("resource://gre/modules/AddonManager.jsm");

const DEFAULT_STATE_FILE = "addonsreconciler";

this.CHANGE_INSTALLED   = 1;
this.CHANGE_UNINSTALLED = 2;
this.CHANGE_ENABLED     = 3;
this.CHANGE_DISABLED    = 4;

this.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();
 *   reconciler.loadState(null, function(error) { ... });
 *
 *   // At this point, your instance should be ready to use.
 *
 * When you are finished with the instance, please call:
 *
 *   reconciler.stopListening();
 *   reconciler.saveState(...);
 *
 * There are 2 classes of listeners in the AddonManager: AddonListener and
 * InstallListener. This class is a listener for both (member functions just
 * get called directly).
 *
 * When an add-on is installed, listeners are called in the following order:
 *
 *  IL.onInstallStarted, AL.onInstalling, IL.onInstallEnded, AL.onInstalled
 *
 * For non-restartless add-ons, an application restart may occur between
 * IL.onInstallEnded and AL.onInstalled. Unfortunately, Sync likely will
 * not be loaded when AL.onInstalled is fired shortly after application
 * start, so it won't see this event. Therefore, for add-ons requiring a
 * restart, Sync treats the IL.onInstallEnded event as good enough to
 * indicate an install. For restartless add-ons, Sync assumes AL.onInstalled
 * will follow shortly after IL.onInstallEnded and thus it ignores
 * IL.onInstallEnded.
 *
 * The listeners can also see events related to the download of the add-on.
 * This class isn't interested in those. However, there are failure events,
 * IL.onDownloadFailed and IL.onDownloadCanceled which get called if a
 * download doesn't complete successfully.
 *
 * For uninstalls, we see AL.onUninstalling then AL.onUninstalled. Like
 * installs, the events could be separated by an application restart and Sync
 * may not see the onUninstalled event. Again, if we require a restart, we
 * react to onUninstalling. If not, we assume we'll get onUninstalled.
 *
 * Enabling and disabling work by sending:
 *
 *   AL.onEnabling, AL.onEnabled
 *   AL.onDisabling, AL.onDisabled
 *
 * Again, they may be separated by a restart, so we heed the requiresRestart
 * flag.
 *
 * Actions can be undone. All undoable actions notify the same
 * AL.onOperationCancelled event. We treat this event like any other.
 *
 * Restartless add-ons have interesting behavior during uninstall. These
 * 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.
 */
this.AddonsReconciler = function AddonsReconciler() {
  this._log = Log.repository.getLogger("Sync.AddonsReconciler");
  let level = Svc.Prefs.get("log.logger.addonsreconciler", "Debug");
  this._log.level = Log.Level[level];

  Svc.Obs.add("xpcom-shutdown", this.stopListening, this);
};
AddonsReconciler.prototype = {
  /** Flag indicating whether we are listening to AddonManager events. */
  _listening: false,

  /**
   * Whether state has been loaded from a file.
   *
   * State is loaded on demand if an operation requires it.
   */
  _stateLoaded: 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() {
    this._ensureStateLoaded();
    return this._addons;
  },

  /**
   * 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 path
   *        Path to load. ".json" is appended automatically. If not defined,
   *        a default path will be consulted.
   * @param callback
   *        Callback to be executed upon file load. The callback receives a
   *        truthy error argument signifying whether an error occurred and a
   *        boolean indicating whether data was loaded.
   */
  loadState: function loadState(path, callback) {
    let file = path || DEFAULT_STATE_FILE;
    Utils.jsonLoad(file, this, function(json) {
      this._addons = {};
      this._changes = [];

      if (!json) {
        this._log.debug("No data seen in loaded file: " + file);
        if (callback) {
          callback(null, false);
        }

        return;
      }

      let version = json.version;
      if (!version || version != 1) {
        this._log.error("Could not load JSON file because version not " +
                        "supported: " + version);
        if (callback) {
          callback(null, false);
        }

        return;
      }

      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]);
      }

      if (callback) {
        callback(null, true);
      }
    });
  },

  /**
   * Saves the current state to a file in the local profile.
   *
   * @param  path
   *         String path in profile to save to. If not defined, the default
   *         will be used.
   * @param  callback
   *         Function to be invoked on save completion. No parameters will be
   *         passed to callback.
   */
  saveState: function saveState(path, callback) {
    let file = path || 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);
    Utils.jsonSave(file, this, state, callback);
  },

  /**
   * 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.indexOf(listener) == -1) {
      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(function(element) {
      if (element == listener) {
        this._log.debug("Removing change listener.");
        return false;
      } else {
        return true;
      }
    }.bind(this));
  },

  /**
   * 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);
    AddonManager.addInstallListener(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 listeners.");
    AddonManager.removeInstallListener(this);
    AddonManager.removeAddonListener(this);
    this._listening = false;
  },

  /**
   * Refreshes the global state of add-ons by querying the AddonManager.
   */
  refreshGlobalState: function refreshGlobalState(callback) {
    this._log.info("Refreshing global state from AddonManager.");
    this._ensureStateLoaded();

    let installs;

    AddonManager.getAllAddons(function (addons) {
      let ids = {};

      for (let addon of addons) {
        ids[addon.id] = true;
        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) {
          let cb = Async.makeSyncCallback();
          AddonManager.getAllInstalls(cb);
          installs = Async.waitForSyncCallback(cb);
        }

        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);
          this._addChange(new Date(), CHANGE_UNINSTALLED, addon);
        }
      }

      // See note for _shouldPersist.
      if (this._shouldPersist) {
        this.saveState(null, callback);
      } else {
        callback();
      }
    }.bind(this));
  },

  /**
   * 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.
   */
  rectifyStateFromAddon: function rectifyStateFromAddon(addon) {
    this._log.debug(`Rectifying state for addon ${addon.name} (version=${addon.version}, id=${addon.id})`);
    this._ensureStateLoaded();

    let id = addon.id;
    let enabled = !addon.userDisabled;
    let guid = addon.syncGUID;
    let now = new Date();

    if (!(id in this._addons)) {
      let record = {
        id: id,
        guid: guid,
        enabled: 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);
      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);
      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.
   */
  _addChange: function _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 {
        listener.changeListener.call(listener, date, change, state);
      } catch (ex) {
        this._log.warn("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: function getChangesSinceDate(date) {
    this._ensureStateLoaded();

    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: function pruneChangesBeforeDate(date) {
    this._ensureStateLoaded();

    this._changes = this._changes.filter(function test_age(change) {
      return change[0] >= date;
    });
  },

  /**
   * Obtains the set of all known Sync GUIDs for add-ons.
   *
   * @return Object with guids as keys and values of true.
   */
  getAllSyncGUIDs: function 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.
   * @return Object on success on null on failure.
   */
  getAddonStateFromSyncGUID: function getAddonStateFromSyncGUID(guid) {
    for (let id in this.addons) {
      let addon = this.addons[id];
      if (addon.guid == guid) {
        return addon;
      }
    }

    return null;
  },

  /**
   * Ensures that state is loaded before continuing.
   *
   * This is called internally by anything that accesses the internal data
   * structures. It effectively just-in-time loads serialized state.
   */
  _ensureStateLoaded: function _ensureStateLoaded() {
    if (this._stateLoaded) {
      return;
    }

    let cb = Async.makeSpinningCallback();
    this.loadState(null, cb);
    cb.wait();
    this._stateLoaded = true;
  },

  /**
   * Handler that is invoked as part of the AddonManager listeners.
   */
  _handleListener: function _handlerListener(action, addon, requiresRestart) {
    // 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);

      // We assume that every event for non-restartless add-ons is
      // followed by another event and that this follow-up event is the most
      // appropriate to react to. Currently we ignore onEnabling, onDisabling,
      // and onUninstalling for non-restartless add-ons.
      if (requiresRestart === false) {
        this._log.debug("Ignoring " + action + " for restartless add-on.");
        return;
      }

      switch (action) {
        case "onEnabling":
        case "onEnabled":
        case "onDisabling":
        case "onDisabled":
        case "onInstalled":
        case "onInstallEnded":
        case "onOperationCancelled":
          this.rectifyStateFromAddon(addon);
          break;

        case "onUninstalling":
        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);
            this._addChange(now, CHANGE_UNINSTALLED, record);
          }
      }

      // See note for _shouldPersist.
      if (this._shouldPersist) {
        let cb = Async.makeSpinningCallback();
        this.saveState(null, cb);
        cb.wait();
      }
    }
    catch (ex) {
      this._log.warn("Exception", ex);
    }
  },

  // AddonListeners
  onEnabling: function onEnabling(addon, requiresRestart) {
    this._handleListener("onEnabling", addon, requiresRestart);
  },
  onEnabled: function onEnabled(addon) {
    this._handleListener("onEnabled", addon);
  },
  onDisabling: function onDisabling(addon, requiresRestart) {
    this._handleListener("onDisabling", addon, requiresRestart);
  },
  onDisabled: function onDisabled(addon) {
    this._handleListener("onDisabled", addon);
  },
  onInstalling: function onInstalling(addon, requiresRestart) {
    this._handleListener("onInstalling", addon, requiresRestart);
  },
  onInstalled: function onInstalled(addon) {
    this._handleListener("onInstalled", addon);
  },
  onUninstalling: function onUninstalling(addon, requiresRestart) {
    this._handleListener("onUninstalling", addon, requiresRestart);
  },
  onUninstalled: function onUninstalled(addon) {
    this._handleListener("onUninstalled", addon);
  },
  onOperationCancelled: function onOperationCancelled(addon) {
    this._handleListener("onOperationCancelled", addon);
  },

  // InstallListeners
  onInstallEnded: function onInstallEnded(install, addon) {
    this._handleListener("onInstallEnded", addon);
  }
};