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, {
225        compress: true,
226        tmpPath: path + ".tmp",
227      });
228      logConsole.debug("_write: settings file written to disk.");
229      Services.obs.notifyObservers(
230        null,
231        SearchUtils.TOPIC_SEARCH_SERVICE,
232        "write-settings-to-disk-complete"
233      );
234    } catch (ex) {
235      logConsole.error("_write: Could not write to settings file:", ex);
236    }
237  }
238
239  /**
240   * Sets an attribute without verification.
241   *
242   * @param {string} name
243   *   The name of the attribute to set.
244   * @param {*} val
245   *   The value to set.
246   */
247  setAttribute(name, val) {
248    this._metaData[name] = val;
249    this._delayedWrite();
250  }
251
252  /**
253   * Sets a verified attribute. This will save an additional hash
254   * value, that can be verified when reading back.
255   *
256   * @param {string} name
257   *   The name of the attribute to set.
258   * @param {*} val
259   *   The value to set.
260   */
261  setVerifiedAttribute(name, val) {
262    this._metaData[name] = val;
263    this._metaData[this.getHashName(name)] = SearchUtils.getVerificationHash(
264      val
265    );
266    this._delayedWrite();
267  }
268
269  /**
270   * Gets an attribute without verification.
271   *
272   * @param {string} name
273   *   The name of the attribute to get.
274   * @returns {*}
275   *   The value of the attribute, or undefined if not known.
276   */
277  getAttribute(name) {
278    return this._metaData[name] ?? undefined;
279  }
280
281  /**
282   * Gets a verified attribute.
283   *
284   * @param {string} name
285   *   The name of the attribute to get.
286   * @returns {*}
287   *   The value of the attribute, or undefined if not known or an empty strings
288   *   if it does not match the verification hash.
289   */
290  getVerifiedAttribute(name) {
291    let val = this.getAttribute(name);
292    if (
293      val &&
294      this.getAttribute(this.getHashName(name)) !=
295        SearchUtils.getVerificationHash(val)
296    ) {
297      logConsole.warn("getVerifiedGlobalAttr, invalid hash for", name);
298      return undefined;
299    }
300    return val;
301  }
302
303  /**
304   * Returns the name for the hash for a particular attribute. This is
305   * necessary because the normal default engine is named `current` with
306   * its hash as `hash`. All other hashes are in the `<name>Hash` format.
307   *
308   * @param {string} name
309   *   The name of the attribute to get the hash name for.
310   * @returns {string}
311   *   The hash name to use.
312   */
313  getHashName(name) {
314    if (name == "current") {
315      return "hash";
316    }
317    return name + "Hash";
318  }
319
320  /**
321   * Handles shutdown; writing the settings if necessary.
322   *
323   * @param {object} state
324   *   The shutdownState object that is used to help analyzing the shutdown
325   *   state in case of a crash or shutdown timeout.
326   */
327  async shutdown(state) {
328    if (!this._batchTask) {
329      return;
330    }
331    state.step = "Finalizing batched task";
332    try {
333      await this._batchTask.finalize();
334      state.step = "Batched task finalized";
335    } catch (ex) {
336      state.step = "Batched task failed to finalize";
337
338      state.latestError.message = "" + ex;
339      if (ex && typeof ex == "object") {
340        state.latestError.stack = ex.stack || undefined;
341      }
342    }
343  }
344
345  // nsIObserver
346  observe(engine, topic, verb) {
347    switch (topic) {
348      case SearchUtils.TOPIC_ENGINE_MODIFIED:
349        switch (verb) {
350          case SearchUtils.MODIFIED_TYPE.ADDED:
351          case SearchUtils.MODIFIED_TYPE.CHANGED:
352          case SearchUtils.MODIFIED_TYPE.REMOVED:
353            this._delayedWrite();
354            break;
355        }
356        break;
357      case SearchUtils.TOPIC_SEARCH_SERVICE:
358        switch (verb) {
359          case "init-complete":
360          case "engines-reloaded":
361            this._delayedWrite();
362            break;
363        }
364        break;
365    }
366  }
367}
368