summaryrefslogtreecommitdiffstats
path: root/services/sync/modules/addonsreconciler.sys.mjs
blob: 902f57348e48ec38175cf315463d768990454e77 (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
/* 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)
    );
  },
};