1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5var EXPORTED_SYMBOLS = ["SearchSettings"];
6
7const { XPCOMUtils } = ChromeUtils.import(
8  "resource://gre/modules/XPCOMUtils.jsm"
9);
10
11XPCOMUtils.defineLazyModuleGetters(this, {
12  DeferredTask: "resource://gre/modules/DeferredTask.jsm",
13  SearchUtils: "resource://gre/modules/SearchUtils.jsm",
14  Services: "resource://gre/modules/Services.jsm",
15});
16
17XPCOMUtils.defineLazyGetter(this, "logConsole", () => {
18  return console.createInstance({
19    prefix: "SearchSettings",
20    maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn",
21  });
22});
23
24const SETTINGS_FILENAME = "search.json.mozlz4";
25
26/**
27 * This class manages the saves search settings.
28 *
29 * Global settings can be saved and obtained from this class via the
30 * `*Attribute` methods.
31 */
32class SearchSettings {
33  constructor(searchService) {
34    this._searchService = searchService;
35  }
36
37  QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]);
38
39  // Delay for batching invalidation of the JSON settings (ms)
40  static SETTINGS_INVALIDATION_DELAY = 1000;
41
42  /**
43   * A reference to the pending DeferredTask, if there is one.
44   */
45  _batchTask = null;
46
47  /**
48   * The current metadata stored in the settings. This stores:
49   *   - current
50   *       The current user-set default engine. The associated hash is called
51   *       'hash'.
52   *   - private
53   *       The current user-set private engine. The associated hash is called
54   *       'privateHash'.
55   *
56   * All of the above have associated hash fields to validate the value is set
57   * by the application.
58   */
59  _metaData = {};
60
61  /**
62   * A reference to the search service so that we can save the engines list.
63   */
64  _searchService = null;
65
66  /*
67   * A copy of the settings so we can persist metadata for engines that
68   * are not currently active.
69   */
70  _currentSettings = null;
71
72  addObservers() {
73    Services.obs.addObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED);
74    Services.obs.addObserver(this, SearchUtils.TOPIC_SEARCH_SERVICE);
75  }
76
77  /**
78   * Cleans up, removing observers.
79   */
80  removeObservers() {
81    Services.obs.removeObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED);
82    Services.obs.removeObserver(this, SearchUtils.TOPIC_SEARCH_SERVICE);
83  }
84
85  /**
86   * Reads the settings file.
87   *
88   * @param {string} origin
89   *   If this parameter is "test", then the settings will not be written. As
90   *   some tests manipulate the settings directly, we allow turning off writing to
91   *   avoid writing stale settings data.
92   * @returns {object}
93   *   Returns the settings file data.
94   */
95  async get(origin = "") {
96    let json;
97    await this._ensurePendingWritesCompleted(origin);
98    try {
99      let settingsFilePath = PathUtils.join(
100        await PathUtils.getProfileDir(),
101        SETTINGS_FILENAME
102      );
103      json = await IOUtils.readJSON(settingsFilePath, { decompress: true });
104      if (!json.engines || !json.engines.length) {
105        throw new Error("no engine in the file");
106      }
107    } catch (ex) {
108      logConsole.warn("get: No settings file exists, new profile?", ex);
109      json = {};
110    }
111    if (json.metaData) {
112      this._metaData = json.metaData;
113    }
114    // Versions of gecko older than 82 stored the order flag as a preference.
115    // This was changed in version 6 of the settings file.
116    if (json.version < 6 || !("useSavedOrder" in this._metaData)) {
117      const prefName = SearchUtils.BROWSER_SEARCH_PREF + "useDBForOrder";
118      let useSavedOrder = Services.prefs.getBoolPref(prefName, false);
119
120      this.setAttribute("useSavedOrder", useSavedOrder);
121
122      // Clear the old pref so it isn't lying around.
123      Services.prefs.clearUserPref(prefName);
124    }
125
126    this._currentSettings = json;
127    return json;
128  }
129
130  /**
131   * Queues writing the settings until after SETTINGS_INVALIDATION_DELAY. If there
132   * is a currently queued task then it will be restarted.
133   */
134  _delayedWrite() {
135    if (this._batchTask) {
136      this._batchTask.disarm();
137    } else {
138      let task = async () => {
139        if (
140          !this._searchService.isInitialized ||
141          this._searchService._reloadingEngines
142        ) {
143          // Re-arm the task as we don't want to save potentially incomplete
144          // information during the middle of (re-)initializing.
145          this._batchTask.arm();
146          return;
147        }
148        logConsole.debug("batchTask: Invalidating engine settings");
149        await this._write();
150      };
151      this._batchTask = new DeferredTask(
152        task,
153        SearchSettings.SETTINGS_INVALIDATION_DELAY
154      );
155    }
156    this._batchTask.arm();
157  }
158
159  /**
160   * Ensures any pending writes of the settings are completed.
161   *
162   * @param {string} origin
163   *   If this parameter is "test", then the settings will not be written. As
164   *   some tests manipulate the settings directly, we allow turning off writing to
165   *   avoid writing stale settings data.
166   */
167  async _ensurePendingWritesCompleted(origin = "") {
168    // Before we read the settings file, first make sure all pending tasks are clear.
169    if (!this._batchTask) {
170      return;
171    }
172    logConsole.debug("finalizing batch task");
173    let task = this._batchTask;
174    this._batchTask = null;
175    // Tests manipulate the settings directly, so let's not double-write with
176    // stale settings data here.
177    if (origin == "test") {
178      task.disarm();
179    } else {
180      await task.finalize();
181    }
182  }
183
184  /**
185   * Writes the settings to disk (no delay).
186   */
187  async _write() {
188    if (this._batchTask) {
189      this._batchTask.disarm();
190    }
191
192    let settings = {};
193
194    // Allows us to force a settings refresh should the settings format change.
195    settings.version = SearchUtils.SETTINGS_VERSION;
196    settings.engines = [...this._searchService._engines.values()];
197    settings.metaData = this._metaData;
198
199    // Persist metadata for AppProvided engines even if they aren't currently
200    // active, this means if they become active again their settings
201    // will be restored.
202    if (this._currentSettings?.engines) {
203      for (let engine of this._currentSettings.engines) {
204        let included = settings.engines.some(e => e._name == engine._name);
205        if (engine._isAppProvided && !included) {
206          settings.engines.push(engine);
207        }
208      }
209    }
210
211    // Update the local copy.
212    this._currentSettings = settings;
213
214    try {
215      if (!settings.engines.length) {
216        throw new Error("cannot write without any engine.");
217      }
218
219      logConsole.debug("_write: Writing to settings file.");
220      let path = PathUtils.join(
221        await PathUtils.getProfileDir(),
222        SETTINGS_FILENAME
223      );
224      await IOUtils.writeJSON(path, settings, { compress: true });
225      logConsole.debug("_write: settings file written to disk.");
226      Services.obs.notifyObservers(
227        null,
228        SearchUtils.TOPIC_SEARCH_SERVICE,
229        "write-settings-to-disk-complete"
230      );
231    } catch (ex) {
232      logConsole.error("_write: Could not write to settings file:", ex);
233    }
234  }
235
236  /**
237   * Sets an attribute without verification.
238   *
239   * @param {string} name
240   *   The name of the attribute to set.
241   * @param {*} val
242   *   The value to set.
243   */
244  setAttribute(name, val) {
245    this._metaData[name] = val;
246    this._delayedWrite();
247  }
248
249  /**
250   * Sets a verified attribute. This will save an additional hash
251   * value, that can be verified when reading back.
252   *
253   * @param {string} name
254   *   The name of the attribute to set.
255   * @param {*} val
256   *   The value to set.
257   */
258  setVerifiedAttribute(name, val) {
259    this._metaData[name] = val;
260    this._metaData[this.getHashName(name)] = SearchUtils.getVerificationHash(
261      val
262    );
263    this._delayedWrite();
264  }
265
266  /**
267   * Gets an attribute without verification.
268   *
269   * @param {string} name
270   *   The name of the attribute to get.
271   * @returns {*}
272   *   The value of the attribute, or undefined if not known.
273   */
274  getAttribute(name) {
275    return this._metaData[name] ?? undefined;
276  }
277
278  /**
279   * Gets a verified attribute.
280   *
281   * @param {string} name
282   *   The name of the attribute to get.
283   * @returns {*}
284   *   The value of the attribute, or undefined if not known or an empty strings
285   *   if it does not match the verification hash.
286   */
287  getVerifiedAttribute(name) {
288    let val = this.getAttribute(name);
289    if (
290      val &&
291      this.getAttribute(this.getHashName(name)) !=
292        SearchUtils.getVerificationHash(val)
293    ) {
294      logConsole.warn("getVerifiedGlobalAttr, invalid hash for", name);
295      return undefined;
296    }
297    return val;
298  }
299
300  /**
301   * Returns the name for the hash for a particular attribute. This is
302   * necessary because the normal default engine is named `current` with
303   * its hash as `hash`. All other hashes are in the `<name>Hash` format.
304   *
305   * @param {string} name
306   *   The name of the attribute to get the hash name for.
307   * @returns {string}
308   *   The hash name to use.
309   */
310  getHashName(name) {
311    if (name == "current") {
312      return "hash";
313    }
314    return name + "Hash";
315  }
316
317  /**
318   * Handles shutdown; writing the settings if necessary.
319   *
320   * @param {object} state
321   *   The shutdownState object that is used to help analyzing the shutdown
322   *   state in case of a crash or shutdown timeout.
323   */
324  async shutdown(state) {
325    if (!this._batchTask) {
326      return;
327    }
328    state.step = "Finalizing batched task";
329    try {
330      await this._batchTask.finalize();
331      state.step = "Batched task finalized";
332    } catch (ex) {
333      state.step = "Batched task failed to finalize";
334
335      state.latestError.message = "" + ex;
336      if (ex && typeof ex == "object") {
337        state.latestError.stack = ex.stack || undefined;
338      }
339    }
340  }
341
342  // nsIObserver
343  observe(engine, topic, verb) {
344    switch (topic) {
345      case SearchUtils.TOPIC_ENGINE_MODIFIED:
346        switch (verb) {
347          case SearchUtils.MODIFIED_TYPE.ADDED:
348          case SearchUtils.MODIFIED_TYPE.CHANGED:
349          case SearchUtils.MODIFIED_TYPE.REMOVED:
350            this._delayedWrite();
351            break;
352        }
353        break;
354      case SearchUtils.TOPIC_SEARCH_SERVICE:
355        switch (verb) {
356          case "init-complete":
357          case "engines-reloaded":
358            this._delayedWrite();
359            break;
360        }
361        break;
362    }
363  }
364}
365