summaryrefslogtreecommitdiffstats
path: root/toolkit/components/featuregates/FeatureGateImplementation.sys.mjs
blob: e54ac139cb5465f8636a5ae25946a79489d96c7e (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
/* 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/. */

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  FeatureGate: "resource://featuregates/FeatureGate.sys.mjs",
});

/** An individual feature gate that can be re-used for more advanced usage. */
export class FeatureGateImplementation {
  // Note that the following comment is *not* a jsdoc. Making it a jsdoc would
  // makes sphinx-js expose it to users. This feature shouldn't be used by
  // users, and so should not be in the docs. Sphinx-js does not respect the
  // @private marker on a constructor (https://github.com/erikrose/sphinx-js/issues/71).
  /*
   * This constructor should only be used directly in tests.
   * ``FeatureGate.fromId`` should be used instead for most cases.
   *
   * @private
   *
   * @param {object} definition Description of the feature gate.
   * @param {string} definition.id
   * @param {string} definition.title
   * @param {string} definition.description
   * @param {string} definition.descriptionLinks
   * @param {boolean} definition.restartRequired
   * @param {string} definition.type
   * @param {string} definition.preference
   * @param {string} definition.defaultValue
   * @param {object} definition.isPublic
   * @param {object} definition.bugNumbers
   */
  constructor(definition) {
    this._definition = definition;
    this._observers = new Set();
  }

  // The below are all getters instead of direct access to make it easy to provide JSDocs.

  /**
   * A short string used to refer to this feature in code.
   * @type string
   */
  get id() {
    return this._definition.id;
  }

  /**
   * A Fluent string ID that will resolve to some text to identify this feature to users.
   * @type string
   */
  get title() {
    return this._definition.title;
  }

  /**
   * A Fluent string ID that will resolve to a longer string to show to users that explains the feature.
   * @type string
   */
  get description() {
    return this._definition.description;
  }

  get descriptionLinks() {
    return this._definition.descriptionLinks;
  }

  /**
   * Whether this feature requires a browser restart to take effect after toggling.
   * @type boolean
   */
  get restartRequired() {
    return this._definition.restartRequired;
  }

  /**
   * The type of feature. Currently only booleans are supported. This may be
   * richer than JS types in the future, such as enum values.
   * @type string
   */
  get type() {
    return this._definition.type;
  }

  /**
   * The name of the preference that stores the value of this feature.
   *
   * This preference should not be read directly, but instead its values should
   * be accessed via FeatureGate#addObserver or FeatureGate#getValue. This
   * property is provided for backwards compatibility.
   *
   * @type string
   */
  get preference() {
    return this._definition.preference;
  }

  /**
   * The default value for the feature gate for this update channel.
   * @type boolean
   */
  get defaultValue() {
    return this._definition.defaultValue;
  }

  /** The default value before any targeting evaluation. */
  get defaultValueOriginalValue() {
    // This will probably be overwritten by the loader, but if not provide a default.
    return (
      this._definition.defaultValueOriginalValue || {
        default: this._definition.defaultValue,
      }
    );
  }

  /**
   * Check what the default value of this feature gate would be on another
   * browser with different facts, such as on another platform.
   *
   * @param {Map} extraFacts
   *   A `Map` of hypothetical facts to consider, such as {'windows': true} to
   *   check what the value of this feature would be on Windows.
   */
  defaultValueWith(extraFacts) {
    return lazy.FeatureGate.evaluateTargetedValue(
      this.defaultValueOriginalValue,
      extraFacts,
      { mergeFactsWithDefault: true }
    );
  }

  /**
   * If this feature should be exposed to users in an advanced settings panel
   * for this build of Firefox.
   *
   * @type boolean
   */
  get isPublic() {
    return this._definition.isPublic;
  }

  /** The isPublic before any targeting evaluation. */
  get isPublicOriginalValue() {
    // This will probably be overwritten by the loader, but if not provide a default.
    return (
      this._definition.isPublicOriginalValue || {
        default: this._definition.isPublic,
      }
    );
  }

  /**
   * Check if this feature is available on another browser with different
   * facts, such as on another platform.
   *
   * @param {Map} extraFacts
   *   A `Map` of hypothetical facts to consider, such as {'windows': true} to
   *   check if this feature would be available on Windows.
   */
  isPublicWith(extraFacts) {
    return lazy.FeatureGate.evaluateTargetedValue(
      this.isPublicOriginalValue,
      extraFacts,
      { mergeFactsWithDefault: true }
    );
  }

  /**
   * Bug numbers associated with this feature.
   * @type Array<number>
   */
  get bugNumbers() {
    return this._definition.bugNumbers;
  }

  /**
   * Get the current value of this feature gate. Implementors should avoid
   * storing the result to avoid missing changes to the feature's value.
   * Consider using :func:`addObserver` if it is necessary to store the value
   * of the feature.
   *
   * @async
   * @returns {Promise<boolean>} A promise for the value associated with this feature.
   */
  // Note that this is async for potential future use of a storage backend besides preferences.
  async getValue() {
    return Services.prefs.getBoolPref(this.preference, this.defaultValue);
  }

  /**
   * An alias of `getValue` for boolean typed feature gates.
   *
   * @async
   * @returns {Promise<boolean>} A promise for the value associated with this feature.
   * @throws {Error} If the feature is not a boolean.
   */
  // Note that this is async for potential future use of a storage backend besides preferences.
  async isEnabled() {
    if (this.type !== "boolean") {
      throw new Error(
        `Tried to call isEnabled when type is not boolean (it is ${this.type})`
      );
    }
    return this.getValue();
  }

  /**
   * Add an observer for changes to this feature. When the observer is added,
   * `onChange` will asynchronously be called with the current value of the
   * preference. If the feature is of type boolean and currently enabled,
   * `onEnable` will additionally be called.
   *
   * @param {object} observer Functions to be called when the feature changes.
   *        All observer functions are optional.
   * @param {Function()} [observer.onEnable] Called when the feature becomes enabled.
   * @param {Function()} [observer.onDisable] Called when the feature becomes disabled.
   * @param {Function(newValue: boolean)} [observer.onChange] Called when the
   *        feature's state changes to any value. The new value will be passed to the
   *        function.
   * @returns {Promise<boolean>} The current value of the feature.
   */
  async addObserver(observer) {
    if (this._observers.size === 0) {
      Services.prefs.addObserver(this.preference, this);
    }

    this._observers.add(observer);

    if (this.type === "boolean" && (await this.isEnabled())) {
      this._callObserverMethod(observer, "onEnable");
    }
    // onDisable should not be called, because features should be assumed
    // disabled until onEnabled is called for the first time.

    return this.getValue();
  }

  /**
   * Remove an observer of changes from this feature
   * @param observer The observer that was passed to addObserver to remove.
   */
  removeObserver(observer) {
    this._observers.delete(observer);
    if (this._observers.size === 0) {
      Services.prefs.removeObserver(this.preference, this);
    }
  }

  /**
   * Removes all observers from this instance of the feature gate.
   */
  removeAllObservers() {
    if (this._observers.size > 0) {
      this._observers.clear();
      Services.prefs.removeObserver(this.preference, this);
    }
  }

  _callObserverMethod(observer, method, ...args) {
    if (method in observer) {
      try {
        observer[method](...args);
      } catch (err) {
        console.error(err);
      }
    }
  }

  /**
   * Observes changes to the preference storing the enabled state of the
   * feature. The observer is dynamically added only when observer have been
   * added.
   * @private
   */
  async observe(aSubject, aTopic, aData) {
    if (aTopic === "nsPref:changed" && aData === this.preference) {
      const value = await this.getValue();
      for (const observer of this._observers) {
        this._callObserverMethod(observer, "onChange", value);

        if (value) {
          this._callObserverMethod(observer, "onEnable");
        } else {
          this._callObserverMethod(observer, "onDisable");
        }
      }
    } else {
      console.error(
        new Error(`Unexpected event observed: ${aSubject}, ${aTopic}, ${aData}`)
      );
    }
  }
}