summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs
blob: 69108ef63b3e0455f5ab5f209fbf7918a6c4cef2 (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
677
678
679
680
681
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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/. */

/**
 * @file
 * This module is used for storing changes to settings that are
 * requested by extensions, and for finding out what the current value
 * of a setting should be, based on the precedence chain.
 *
 * When multiple extensions request to make a change to a particular
 * setting, the most recently installed extension will be given
 * precedence.
 *
 * This precedence chain of settings is stored in JSON format,
 * without indentation, using UTF-8 encoding.
 * With indentation applied, the file would look like this:
 *
 * {
 *   type: { // The type of settings being stored in this object, i.e., prefs.
 *     key: { // The unique key for the setting.
 *       initialValue, // The initial value of the setting.
 *       precedenceList: [
 *         {
 *           id, // The id of the extension requesting the setting.
 *           installDate, // The install date of the extension, stored as a number.
 *           value, // The value of the setting requested by the extension.
 *           enabled // Whether the setting is currently enabled.
 *         }
 *       ],
 *     },
 *     key: {
 *       // ...
 *     }
 *   }
 * }
 *
 */

import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
});

// Defined for readability of precedence and selection code.  keyInfo.selected will be
// one of these defines, or the id of an extension if an extension has been explicitly
// selected.
const SETTING_USER_SET = null;
const SETTING_PRECEDENCE_ORDER = undefined;

const JSON_FILE_NAME = "extension-settings.json";
const JSON_FILE_VERSION = 3;
const STORE_PATH = PathUtils.join(
  Services.dirsvc.get("ProfD", Ci.nsIFile).path,
  JSON_FILE_NAME
);

let _initializePromise;
let _store = {};

// Processes the JSON data when read from disk to convert string dates into numbers.
function dataPostProcessor(json) {
  if (json.version !== JSON_FILE_VERSION) {
    for (let storeType in json) {
      for (let setting in json[storeType]) {
        for (let extData of json[storeType][setting].precedenceList) {
          if (setting == "overrideContentColorScheme" && extData.value > 2) {
            extData.value = 2;
          }
          if (typeof extData.installDate != "number") {
            extData.installDate = new Date(extData.installDate).valueOf();
          }
        }
      }
    }
    json.version = JSON_FILE_VERSION;
  }
  return json;
}

// Loads the data from the JSON file into memory.
function initialize() {
  if (!_initializePromise) {
    _store = new lazy.JSONFile({
      path: STORE_PATH,
      dataPostProcessor,
    });
    _initializePromise = _store.load();
  }
  return _initializePromise;
}

// Test-only method to force reloading of the JSON file.
async function reloadFile(saveChanges) {
  if (!saveChanges) {
    // Disarm the saver so that the current changes are dropped.
    _store._saver.disarm();
  }
  await _store.finalize();
  _initializePromise = null;
  return initialize();
}

// Checks that the store is ready and that the requested type exists.
function ensureType(type) {
  if (!_store.dataReady) {
    throw new Error(
      "The ExtensionSettingsStore was accessed before the initialize promise resolved."
    );
  }

  // Ensure a property exists for the given type.
  if (!_store.data[type]) {
    _store.data[type] = {};
  }
}

/**
 * Return an object with properties for key, value|initialValue, id|null, or
 * null if no setting has been stored for that key.
 *
 * If no id is passed then return the highest priority item for the key.
 *
 * @param {string} type
 *        The type of setting to be retrieved.
 * @param {string} key
 *        A string that uniquely identifies the setting.
 * @param {string} id
 *        The id of the extension for which the item is being retrieved.
 *        If no id is passed, then the highest priority item for the key
 *        is returned.
 *
 * @returns {object | null}
 *          Either an object with properties for key and value, or
 *          null if no key is found.
 */
function getItem(type, key, id) {
  ensureType(type);

  let keyInfo = _store.data[type][key];
  if (!keyInfo) {
    return null;
  }

  // If no id was provided, the selected entry will have precedence.
  if (!id && keyInfo.selected) {
    id = keyInfo.selected;
  }
  if (id) {
    // Return the item that corresponds to the extension with id of id.
    let item = keyInfo.precedenceList.find(item => item.id === id);
    return item ? { key, value: item.value, id } : null;
  }

  // Find the highest precedence, enabled setting, if it has not been
  // user set.
  if (keyInfo.selected === SETTING_PRECEDENCE_ORDER) {
    for (let item of keyInfo.precedenceList) {
      if (item.enabled) {
        return { key, value: item.value, id: item.id };
      }
    }
  }

  // Nothing found in the precedenceList or the setting is user-set,
  // return the initialValue.
  return { key, initialValue: keyInfo.initialValue };
}

/**
 * Return an array of objects with properties for key, value, id, and enabled
 * or an empty array if no settings have been stored for that key.
 *
 * @param {string} type
 *        The type of setting to be retrieved.
 * @param {string} key
 *        A string that uniquely identifies the setting.
 *
 * @returns {Array} an array of objects with properties for key, value, id, and enabled
 */
function getAllItems(type, key) {
  ensureType(type);

  let keyInfo = _store.data[type][key];
  if (!keyInfo) {
    return [];
  }

  let items = keyInfo.precedenceList;
  return items
    ? items.map(item => ({
        key,
        value: item.value,
        id: item.id,
        enabled: item.enabled,
      }))
    : [];
}

// Comparator used when sorting the precedence list.
function precedenceComparator(a, b) {
  if (a.enabled && !b.enabled) {
    return -1;
  }
  if (b.enabled && !a.enabled) {
    return 1;
  }
  return b.installDate - a.installDate;
}

/**
 * Helper method that alters a setting, either by changing its enabled status
 * or by removing it.
 *
 * @param {string|null} id
 *        The id of the extension for which a setting is being altered, may also
 *        be SETTING_USER_SET (null).
 * @param {string} type
 *        The type of setting to be altered.
 * @param {string} key
 *        A string that uniquely identifies the setting.
 * @param {string} action
 *        The action to perform on the setting.
 *        Will be one of remove|enable|disable.
 *
 * @returns {object | null}
 *          Either an object with properties for key and value, which
 *          corresponds to the current top precedent setting, or null if
 *          the current top precedent setting has not changed.
 */
function alterSetting(id, type, key, action) {
  let returnItem = null;
  ensureType(type);

  let keyInfo = _store.data[type][key];
  if (!keyInfo) {
    if (action === "remove") {
      return null;
    }
    throw new Error(
      `Cannot alter the setting for ${type}:${key} as it does not exist.`
    );
  }

  let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);

  if (foundIndex === -1 && (action !== "select" || id !== SETTING_USER_SET)) {
    if (action === "remove") {
      return null;
    }
    throw new Error(
      `Cannot alter the setting for ${type}:${key} as ${id} does not exist.`
    );
  }

  let selected = keyInfo.selected;
  switch (action) {
    case "select":
      if (foundIndex >= 0 && !keyInfo.precedenceList[foundIndex].enabled) {
        throw new Error(
          `Cannot select the setting for ${type}:${key} as ${id} is disabled.`
        );
      }
      keyInfo.selected = id;
      keyInfo.selectedDate = Date.now();
      break;

    case "remove":
      // Removing a user-set setting reverts to precedence order.
      if (id === keyInfo.selected) {
        keyInfo.selected = SETTING_PRECEDENCE_ORDER;
        delete keyInfo.selectedDate;
      }
      keyInfo.precedenceList.splice(foundIndex, 1);
      break;

    case "enable":
      keyInfo.precedenceList[foundIndex].enabled = true;
      keyInfo.precedenceList.sort(precedenceComparator);
      // Enabling a setting does not change a user-set setting, so we
      // save and bail early.
      if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) {
        _store.saveSoon();
        return null;
      }
      foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
      break;

    case "disable":
      // Disabling a user-set setting reverts to precedence order.
      if (keyInfo.selected === id) {
        keyInfo.selected = SETTING_PRECEDENCE_ORDER;
        delete keyInfo.selectedDate;
      }
      keyInfo.precedenceList[foundIndex].enabled = false;
      keyInfo.precedenceList.sort(precedenceComparator);
      break;

    default:
      throw new Error(`${action} is not a valid action for alterSetting.`);
  }

  if (selected !== keyInfo.selected || foundIndex === 0) {
    returnItem = getItem(type, key);
  }

  if (action === "remove" && keyInfo.precedenceList.length === 0) {
    delete _store.data[type][key];
  }

  _store.saveSoon();
  ExtensionParent.apiManager.emit("extension-setting-changed", {
    action,
    id,
    type,
    key,
    item: returnItem,
  });
  return returnItem;
}

export var ExtensionSettingsStore = {
  SETTING_USER_SET,

  /**
   * Loads the JSON file for the SettingsStore into memory.
   * The promise this returns must be resolved before asking the SettingsStore
   * to perform any other operations.
   *
   * @returns {Promise}
   *          A promise that resolves when the Store is ready to be accessed.
   */
  initialize() {
    return initialize();
  },

  /**
   * Adds a setting to the store, returning the new setting if it changes.
   *
   * @param {string} id
   *        The id of the extension for which a setting is being added.
   * @param {string} type
   *        The type of setting to be stored.
   * @param {string} key
   *        A string that uniquely identifies the setting.
   * @param {string} value
   *        The value to be stored in the setting.
   * @param {Function} initialValueCallback
   *        A function to be called to determine the initial value for the
   *        setting. This will be passed the value in the callbackArgument
   *        argument. If omitted the initial value will be undefined.
   * @param {any} callbackArgument
   *        The value to be passed into the initialValueCallback. It defaults to
   *        the value of the key argument.
   * @param {Function} settingDataUpdate
   *        A function to be called to modify the initial value if necessary.
   *
   * @returns {object | null} Either an object with properties for key and
   *                          value, which corresponds to the item that was
   *                          just added, or null if the item that was just
   *                          added does not need to be set because it is not
   *                          selected or at the top of the precedence list.
   */
  async addSetting(
    id,
    type,
    key,
    value,
    initialValueCallback = () => undefined,
    callbackArgument = key,
    settingDataUpdate = val => val
  ) {
    if (typeof initialValueCallback != "function") {
      throw new Error("initialValueCallback must be a function.");
    }

    ensureType(type);

    if (!_store.data[type][key]) {
      // The setting for this key does not exist. Set the initial value.
      let initialValue = await initialValueCallback(callbackArgument);
      _store.data[type][key] = {
        initialValue,
        precedenceList: [],
      };
    }
    let keyInfo = _store.data[type][key];

    // Allow settings to upgrade the initial value if necessary.
    keyInfo.initialValue = settingDataUpdate(keyInfo.initialValue);

    // Check for this item in the precedenceList.
    let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);
    let newInstall = false;
    if (foundIndex === -1) {
      // No item for this extension, so add a new one.
      let addon = await lazy.AddonManager.getAddonByID(id);
      keyInfo.precedenceList.push({
        id,
        installDate: addon.installDate.valueOf(),
        value,
        enabled: true,
      });
      newInstall = addon.installDate.valueOf() > keyInfo.selectedDate;
    } else {
      // Item already exists or this extension, so update it.
      let item = keyInfo.precedenceList[foundIndex];
      item.value = value;
      // Ensure the item is enabled.
      item.enabled = true;
    }

    // Sort the list.
    keyInfo.precedenceList.sort(precedenceComparator);
    foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id);

    // If our new setting is top of precedence, then reset the selected entry.
    if (foundIndex === 0 && newInstall) {
      keyInfo.selected = SETTING_PRECEDENCE_ORDER;
      delete keyInfo.selectedDate;
    }

    _store.saveSoon();

    // Check whether this is currently selected item if one is
    // selected, otherwise the top item has precedence.
    if (
      keyInfo.selected !== SETTING_USER_SET &&
      (keyInfo.selected === id || foundIndex === 0)
    ) {
      return { id, key, value };
    }
    return null;
  },

  /**
   * Removes a setting from the store, returning the new setting if it changes.
   *
   * @param {string} id
   *        The id of the extension for which a setting is being removed.
   * @param {string} type
   *        The type of setting to be removed.
   * @param {string} key
   *        A string that uniquely identifies the setting.
   *
   * @returns {object | null}
   *          Either an object with properties for key and value if the setting changes, or null.
   */
  removeSetting(id, type, key) {
    return alterSetting(id, type, key, "remove");
  },

  /**
   * Enables a setting in the store, returning the new setting if it changes.
   *
   * @param {string} id
   *        The id of the extension for which a setting is being enabled.
   * @param {string} type
   *        The type of setting to be enabled.
   * @param {string} key
   *        A string that uniquely identifies the setting.
   *
   * @returns {object | null}
   *          Either an object with properties for key and value if the setting changes, or null.
   */
  enable(id, type, key) {
    return alterSetting(id, type, key, "enable");
  },

  /**
   * Disables a setting in the store, returning the new setting if it changes.
   *
   * @param {string} id
   *        The id of the extension for which a setting is being disabled.
   * @param {string} type
   *        The type of setting to be disabled.
   * @param {string} key
   *        A string that uniquely identifies the setting.
   *
   * @returns {object | null}
   *          Either an object with properties for key and value if the setting changes, or null.
   */
  disable(id, type, key) {
    return alterSetting(id, type, key, "disable");
  },

  /**
   * Specifically select an extension, or no extension, that will be in control of
   * this setting.
   *
   * To select a specific extension that controls this setting, pass the extension id.
   *
   * To select as user-set  pass SETTING_USER_SET as the id.  In this case, no extension
   * will have control of the setting.
   *
   * Once a specific selection is made, precedence order will not be used again unless the selected
   * extension is disabled, removed, or a new extension takes control of the setting.
   *
   * @param {string | null} id
   *        The id of the extension being selected or SETTING_USER_SET (null).
   * @param {string} type
   *        The type of setting to be selected.
   * @param {string} key
   *        A string that uniquely identifies the setting.
   *
   * @returns {object | null}
   *          Either an object with properties for key and value if the setting changes, or null.
   */
  select(id, type, key) {
    return alterSetting(id, type, key, "select");
  },

  /**
   * Retrieves all settings from the store for a given extension.
   *
   * @param {string} id
   *        The id of the extension for which a settings are being retrieved.
   * @param {string} type
   *        The type of setting to be returned.
   *
   * @returns {Array}
   *          A list of settings which have been stored for the extension.
   */
  getAllForExtension(id, type) {
    ensureType(type);

    let keysObj = _store.data[type];
    let items = [];
    for (let key in keysObj) {
      if (keysObj[key].precedenceList.find(item => item.id == id)) {
        items.push(key);
      }
    }
    return items;
  },

  /**
   * Retrieves a setting from the store, either for a specific extension,
   * or current top precedent setting for the key.
   *
   * @param {string} type The type of setting to be returned.
   * @param {string} key A string that uniquely identifies the setting.
   * @param {string} id
   *        The id of the extension for which the setting is being retrieved.
   *        Defaults to undefined, in which case the top setting is returned.
   *
   * @returns {object} An object with properties for key, value and id.
   */
  getSetting(type, key, id) {
    return getItem(type, key, id);
  },

  /**
   * Retrieves an array of objects representing extensions attempting to control the specified setting
   * or an empty array if no settings have been stored for that key.
   *
   * @param {string} type
   *        The type of setting to be retrieved.
   * @param {string} key
   *        A string that uniquely identifies the setting.
   *
   * @returns {Array} an array of objects with properties for key, value, id, and enabled
   */
  getAllSettings(type, key) {
    return getAllItems(type, key);
  },

  /**
   * Returns whether an extension currently has a stored setting for a given
   * key.
   *
   * @param {string} id The id of the extension which is being checked.
   * @param {string} type The type of setting to be checked.
   * @param {string} key A string that uniquely identifies the setting.
   *
   * @returns {boolean} Whether the extension currently has a stored setting.
   */
  hasSetting(id, type, key) {
    return this.getAllForExtension(id, type).includes(key);
  },

  /**
   * Return the levelOfControl for a key / extension combo.
   * levelOfControl is required by Google's ChromeSetting prototype which
   * in turn is used by the privacy API among others.
   *
   * It informs a caller of the state of a setting with respect to the current
   * extension, and can be one of the following values:
   *
   * controlled_by_other_extensions: controlled by extensions with higher precedence
   * controllable_by_this_extension: can be controlled by this extension
   * controlled_by_this_extension: controlled by this extension
   *
   * @param {string} id
   *        The id of the extension for which levelOfControl is being requested.
   * @param {string} type
   *        The type of setting to be returned. For example `pref`.
   * @param {string} key
   *        A string that uniquely identifies the setting, for example, a
   *        preference name.
   *
   * @returns {string}
   *          The level of control of the extension over the key.
   */
  async getLevelOfControl(id, type, key) {
    ensureType(type);

    let keyInfo = _store.data[type][key];
    if (!keyInfo || !keyInfo.precedenceList.length) {
      return "controllable_by_this_extension";
    }

    if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) {
      if (id === keyInfo.selected) {
        return "controlled_by_this_extension";
      }
      // When user set, the setting is never "controllable" unless the installDate
      // is later than the user date.
      let addon = await lazy.AddonManager.getAddonByID(id);
      return !addon || keyInfo.selectedDate > addon.installDate.valueOf()
        ? "not_controllable"
        : "controllable_by_this_extension";
    }

    let enabledItems = keyInfo.precedenceList.filter(item => item.enabled);
    if (!enabledItems.length) {
      return "controllable_by_this_extension";
    }

    let topItem = enabledItems[0];
    if (topItem.id == id) {
      return "controlled_by_this_extension";
    }

    let addon = await lazy.AddonManager.getAddonByID(id);
    return !addon || topItem.installDate > addon.installDate.valueOf()
      ? "controlled_by_other_extensions"
      : "controllable_by_this_extension";
  },

  /**
   * Test-only method to force reloading of the JSON file.
   *
   * Note that this method simply clears the local variable that stores the
   * file, so the next time the file is accessed it will be reloaded.
   *
   * @param   {boolean} saveChanges
   *          When false, discard any changes that have been made since the last
   *          time the store was saved.
   * @returns {Promise}
   *          A promise that resolves once the settings store has been cleared.
   */
  _reloadFile(saveChanges = true) {
    return reloadFile(saveChanges);
  },
};

// eslint-disable-next-line mozilla/balanced-listeners
ExtensionParent.apiManager.on("uninstall-complete", async (type, { id }) => {
  // Catch any settings that were not properly removed during "uninstall".
  await ExtensionSettingsStore.initialize();
  for (let type in _store.data) {
    // prefs settings must be handled by ExtensionPreferencesManager.
    if (type === "prefs") {
      continue;
    }
    let items = ExtensionSettingsStore.getAllForExtension(id, type);
    for (let key of items) {
      ExtensionSettingsStore.removeSetting(id, type, key);
      Services.console.logStringMessage(
        `Post-Uninstall removal of addon settings for ${id}, type: ${type} key: ${key}`
      );
    }
  }
});