summaryrefslogtreecommitdiffstats
path: root/toolkit/components/search/SearchEngineSelector.sys.mjs
blob: 962b9cb3dff01f95f0dd40ce4bcd1062c8cb01cb (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
/* 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/. */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
});

const USER_LOCALE = "$USER_LOCALE";
const USER_REGION = "$USER_REGION";

XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
  return console.createInstance({
    prefix: "SearchEngineSelector",
    maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
  });
});

function hasAppKey(config, key) {
  return "application" in config && key in config.application;
}

function sectionExcludes(config, key, value) {
  return hasAppKey(config, key) && !config.application[key].includes(value);
}

function sectionIncludes(config, key, value) {
  return hasAppKey(config, key) && config.application[key].includes(value);
}

function isDistroExcluded(config, key, distroID) {
  // Should be excluded when:
  // - There's a distroID and that is not in the non-empty distroID list.
  // - There's no distroID and the distroID list is not empty.
  const appKey = hasAppKey(config, key);
  if (!appKey) {
    return false;
  }
  const distroList = config.application[key];
  if (distroID) {
    return distroList.length && !distroList.includes(distroID);
  }
  return !!distroList.length;
}

function belowMinVersion(config, version) {
  return (
    hasAppKey(config, "minVersion") &&
    Services.vc.compare(version, config.application.minVersion) < 0
  );
}

function aboveMaxVersion(config, version) {
  return (
    hasAppKey(config, "maxVersion") &&
    Services.vc.compare(version, config.application.maxVersion) > 0
  );
}

/**
 * SearchEngineSelector parses the JSON configuration for
 * search engines and returns the applicable engines depending
 * on their region + locale.
 */
export class SearchEngineSelector {
  /**
   * @param {Function} listener
   *   A listener for configuration update changes.
   */
  constructor(listener) {
    this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
    this._remoteConfig = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
    this._listenerAdded = false;
    this._onConfigurationUpdated = this._onConfigurationUpdated.bind(this);
    this._changeListener = listener;
  }

  /**
   * Handles getting the configuration from remote settings.
   */
  async getEngineConfiguration() {
    if (this._getConfigurationPromise) {
      return this._getConfigurationPromise;
    }

    this._configuration = await (this._getConfigurationPromise =
      this._getConfiguration());
    delete this._getConfigurationPromise;

    if (!this._configuration?.length) {
      throw Components.Exception(
        "Failed to get engine data from Remote Settings",
        Cr.NS_ERROR_UNEXPECTED
      );
    }

    if (!this._listenerAdded) {
      this._remoteConfig.on("sync", this._onConfigurationUpdated);
      this._listenerAdded = true;
    }

    return this._configuration;
  }

  /**
   * Obtains the configuration from remote settings. This includes
   * verifying the signature of the record within the database.
   *
   * If the signature in the database is invalid, the database will be wiped
   * and the stored dump will be used, until the settings next update.
   *
   * Note that this may cause a network check of the certificate, but that
   * should generally be quick.
   *
   * @param {boolean} [firstTime]
   *   Internal boolean to indicate if this is the first time check or not.
   * @returns {Array}
   *   An array of objects in the database, or an empty array if none
   *   could be obtained.
   */
  async _getConfiguration(firstTime = true) {
    let result = [];
    let failed = false;
    try {
      result = await this._remoteConfig.get({
        order: "id",
      });
    } catch (ex) {
      lazy.logConsole.error(ex);
      failed = true;
    }
    if (!result.length) {
      lazy.logConsole.error("Received empty search configuration!");
      failed = true;
    }
    // If we failed, or the result is empty, try loading from the local dump.
    if (firstTime && failed) {
      await this._remoteConfig.db.clear();
      // Now call this again.
      return this._getConfiguration(false);
    }
    return result;
  }

  /**
   * Handles updating of the configuration. Note that the search service is
   * only updated after a period where the user is observed to be idle.
   *
   * @param {object} options
   *   The options object
   * @param {object} options.data
   *   The data to update
   * @param {Array} options.data.current
   *   The new configuration object
   */
  _onConfigurationUpdated({ data: { current } }) {
    this._configuration = current;
    lazy.logConsole.debug("Search configuration updated remotely");
    if (this._changeListener) {
      this._changeListener();
    }
  }

  /**
   * @param {object} options
   *   The options object
   * @param {string} options.locale
   *   Users locale.
   * @param {string} options.region
   *   Users region.
   * @param {string} [options.channel]
   *   The update channel the application is running on.
   * @param {string} [options.distroID]
   *   The distribution ID of the application.
   * @param {string} [options.experiment]
   *   Any associated experiment id.
   * @param {string} [options.name]
   *   The name of the application.
   * @param {string} [options.version]
   *   The version of the application.
   * @returns {object}
   *   An object with "engines" field, a sorted list of engines and
   *   optionally "privateDefault" which is an object containing the engine
   *   details for the engine which should be the default in Private Browsing mode.
   */
  async fetchEngineConfiguration({
    locale,
    region,
    channel = "default",
    distroID,
    experiment,
    name = Services.appinfo.name ?? "",
    version = Services.appinfo.version ?? "",
  }) {
    if (!this._configuration) {
      await this.getEngineConfiguration();
    }
    lazy.logConsole.debug(
      `fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${name}:${version}`
    );
    let engines = [];
    const lcName = name.toLowerCase();
    const lcVersion = version.toLowerCase();
    const lcLocale = locale.toLowerCase();
    const lcRegion = region.toLowerCase();
    for (let config of this._configuration) {
      const appliesTo = config.appliesTo || [];
      const applies = appliesTo.filter(section => {
        if ("experiment" in section) {
          if (experiment != section.experiment) {
            return false;
          }
          if (section.override) {
            return true;
          }
        }

        let shouldInclude = () => {
          let included =
            "included" in section &&
            this._isInSection(lcRegion, lcLocale, section.included);
          let excluded =
            "excluded" in section &&
            this._isInSection(lcRegion, lcLocale, section.excluded);
          return included && !excluded;
        };

        const distroExcluded =
          (distroID &&
            sectionIncludes(section, "excludedDistributions", distroID)) ||
          isDistroExcluded(section, "distributions", distroID);

        if (distroID && !distroExcluded && section.override) {
          if ("included" in section || "excluded" in section) {
            return shouldInclude();
          }
          return true;
        }

        if (
          sectionExcludes(section, "channel", channel) ||
          sectionExcludes(section, "name", lcName) ||
          distroExcluded ||
          belowMinVersion(section, lcVersion) ||
          aboveMaxVersion(section, lcVersion)
        ) {
          return false;
        }
        return shouldInclude();
      });

      let baseConfig = this._copyObject({}, config);

      // Don't include any engines if every section is an override
      // entry, these are only supposed to override otherwise
      // included engine configurations.
      let allOverrides = applies.every(e => "override" in e && e.override);
      // Loop through all the appliedTo sections that apply to
      // this configuration.
      if (applies.length && !allOverrides) {
        for (let section of applies) {
          this._copyObject(baseConfig, section);
        }

        if (
          "webExtension" in baseConfig &&
          "locales" in baseConfig.webExtension
        ) {
          for (const webExtensionLocale of baseConfig.webExtension.locales) {
            const engine = { ...baseConfig };
            engine.webExtension = { ...baseConfig.webExtension };
            delete engine.webExtension.locales;
            engine.webExtension.locale = webExtensionLocale
              .replace(USER_LOCALE, locale)
              .replace(USER_REGION, lcRegion);
            engines.push(engine);
          }
        } else {
          const engine = { ...baseConfig };
          (engine.webExtension = engine.webExtension || {}).locale =
            lazy.SearchUtils.DEFAULT_TAG;
          engines.push(engine);
        }
      }
    }

    let defaultEngine;
    let privateEngine;

    function shouldPrefer(setting, hasCurrentDefault, currentDefaultSetting) {
      if (
        setting == "yes" &&
        (!hasCurrentDefault || currentDefaultSetting == "yes-if-no-other")
      ) {
        return true;
      }
      return setting == "yes-if-no-other" && !hasCurrentDefault;
    }

    for (const engine of engines) {
      engine.telemetryId = engine.telemetryId
        ?.replace(USER_LOCALE, locale)
        .replace(USER_REGION, lcRegion);
      if (
        "default" in engine &&
        shouldPrefer(
          engine.default,
          !!defaultEngine,
          defaultEngine && defaultEngine.default
        )
      ) {
        defaultEngine = engine;
      }
      if (
        "defaultPrivate" in engine &&
        shouldPrefer(
          engine.defaultPrivate,
          !!privateEngine,
          privateEngine && privateEngine.defaultPrivate
        )
      ) {
        privateEngine = engine;
      }
    }

    engines.sort(this._sort.bind(this, defaultEngine, privateEngine));

    let result = { engines };

    if (privateEngine) {
      result.privateDefault = privateEngine;
    }

    if (lazy.SearchUtils.loggingEnabled) {
      lazy.logConsole.debug(
        "fetchEngineConfiguration: " +
          result.engines.map(e => e.webExtension.id)
      );
    }
    return result;
  }

  _sort(defaultEngine, privateEngine, a, b) {
    return (
      this._sortIndex(b, defaultEngine, privateEngine) -
      this._sortIndex(a, defaultEngine, privateEngine)
    );
  }

  /**
   * Create an index order to ensure default (and backup default)
   * engines are ordered correctly.
   *
   * @param {object} obj
   *   Object representing the engine configation.
   * @param {object} defaultEngine
   *   The default engine, for comparison to obj.
   * @param {object} privateEngine
   *   The private engine, for comparison to obj.
   * @returns {integer}
   *  Number indicating how this engine should be sorted.
   */
  _sortIndex(obj, defaultEngine, privateEngine) {
    if (obj == defaultEngine) {
      return Number.MAX_SAFE_INTEGER;
    }
    if (obj == privateEngine) {
      return Number.MAX_SAFE_INTEGER - 1;
    }
    return obj.orderHint || 0;
  }

  /**
   * Is the engine marked to be the default search engine.
   *
   * @param {object} obj - Object representing the engine configation.
   * @returns {boolean} - Whether the engine should be default.
   */
  _isDefault(obj) {
    return "default" in obj && obj.default === "yes";
  }

  /**
   * Object.assign but ignore some keys
   *
   * @param {object} target - Object to copy to.
   * @param {object} source - Object top copy from.
   * @returns {object} - The source object.
   */
  _copyObject(target, source) {
    for (let key in source) {
      if (["included", "excluded", "appliesTo"].includes(key)) {
        continue;
      }
      if (key == "webExtension") {
        if (key in target) {
          this._copyObject(target[key], source[key]);
        } else {
          target[key] = { ...source[key] };
        }
      } else {
        target[key] = source[key];
      }
    }
    return target;
  }

  /**
   * Determines wether the section of the config applies to a user
   * given what region + locale they are using.
   *
   * @param {string} region - The region the user is in.
   * @param {string} locale - The language the user has configured.
   * @param {object} config - Section of configuration.
   * @returns {boolean} - Does the section apply for the region + locale.
   */
  _isInSection(region, locale, config) {
    if (!config) {
      return false;
    }
    if (config.everywhere) {
      return true;
    }
    let locales = config.locales || {};
    let inLocales =
      "matches" in locales &&
      !!locales.matches.find(e => e.toLowerCase() == locale);
    let inRegions =
      "regions" in config &&
      !!config.regions.find(e => e.toLowerCase() == region);
    if (
      locales.startsWith &&
      locales.startsWith.some(key => locale.startsWith(key))
    ) {
      inLocales = true;
    }
    if (config.locales && config.regions) {
      return inLocales && inRegions;
    }
    return inLocales || inRegions;
  }
}