gecko-dev/toolkit/components/search/SearchSettings.sys.mjs

604 строки
18 KiB
JavaScript

/* 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, {
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
});
XPCOMUtils.defineLazyModuleGetters(lazy, {
ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
});
XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
return console.createInstance({
prefix: "SearchSettings",
maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
});
});
const SETTINGS_FILENAME = "search.json.mozlz4";
/**
* This class manages the saves search settings.
*
* Global settings can be saved and obtained from this class via the
* `*Attribute` methods.
*/
export class SearchSettings {
constructor(searchService) {
this.#searchService = searchService;
}
QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]);
// Delay for batching invalidation of the JSON settings (ms)
static SETTINGS_INVALIDATION_DELAY = 1000;
/**
* A reference to the pending DeferredTask, if there is one.
*/
_batchTask = null;
/**
* A reference to the search service so that we can save the engines list.
*/
#searchService = null;
/*
* The user's settings file read from disk so we can persist metadata for
* engines that are default or hidden, the user's locale and region, hashes
* for the loadPath, and hashes for default and private default engines.
* This is the JSON we read from disk and save to disk when there's an update
* to the settings.
*
* Structure of settings:
* Object { version: <number>,
* engines: [...],
* metaData: {...},
* }
*
* Settings metaData is the active metadata for setting and getting attributes.
* When a new metadata attribute is set, we save it to #settings.metaData and
* write #settings to disk.
*
* #settings.metaData attributes:
* @property {string} current
* The current user-set default engine. The associated hash is called
* 'hash'.
* @property {string} private
* The current user-set private engine. The associated hash is called
* 'privateHash'.
* The current and prviate objects have associated hash fields to validate
* the value is set by the application.
* @property {string} appDefaultEngine
* @property {string} channel
* Configuration is restricted to the specified channel. ESR is an example
* of a channel.
* @property {string} distroID
* Specifies which distribution the default engine is included in.
* @property {string} experiment
* Specifies if the application is running on an experiment.
* @property {string} locale
* @property {string} region
* @property {boolean} useSavedOrder
* True if the user's order information stored in settings is used.
*
*/
#settings = null;
/**
* @type {object} A deep copy of #settings.
* #cachedSettings is updated when we read the settings from disk and when
* we write settings to disk. #cachedSettings is compared with #settings
* before we do a write to disk. If there's no change to the settings
* attributes, then we don't write the settings to disk.
*/
#cachedSettings = {};
addObservers() {
Services.obs.addObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
Services.obs.addObserver(this, lazy.SearchUtils.TOPIC_SEARCH_SERVICE);
}
/**
* Cleans up, removing observers.
*/
removeObservers() {
Services.obs.removeObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
Services.obs.removeObserver(this, lazy.SearchUtils.TOPIC_SEARCH_SERVICE);
}
/**
* Reads the settings file.
*
* @param {string} origin
* If this parameter is "test", then the settings will not be written. As
* some tests manipulate the settings directly, we allow turning off writing to
* avoid writing stale settings data.
* @returns {object}
* Returns the settings file data.
*/
async get(origin = "") {
let json;
await this._ensurePendingWritesCompleted(origin);
try {
let settingsFilePath = PathUtils.join(
PathUtils.profileDir,
SETTINGS_FILENAME
);
json = await IOUtils.readJSON(settingsFilePath, { decompress: true });
if (!json.engines || !json.engines.length) {
throw new Error("no engine in the file");
}
} catch (ex) {
lazy.logConsole.debug("get: No settings file exists, new profile?", ex);
json = {};
}
this.#settings = json;
this.#cachedSettings = structuredClone(json);
if (!this.#settings.metaData) {
this.#settings.metaData = {};
}
// Versions of gecko older than 82 stored the order flag as a preference.
// This was changed in version 6 of the settings file.
if (
this.#settings.version < 6 ||
!("useSavedOrder" in this.#settings.metaData)
) {
const prefName = lazy.SearchUtils.BROWSER_SEARCH_PREF + "useDBForOrder";
let useSavedOrder = Services.prefs.getBoolPref(prefName, false);
this.setMetaDataAttribute("useSavedOrder", useSavedOrder);
// Clear the old pref so it isn't lying around.
Services.prefs.clearUserPref(prefName);
}
// Added in Firefox 110.
if (this.#settings.version < 8 && Array.isArray(this.#settings.engines)) {
this.#migrateTelemetryLoadPaths();
}
return structuredClone(json);
}
/**
* Queues writing the settings until after SETTINGS_INVALIDATION_DELAY. If there
* is a currently queued task then it will be restarted.
*/
_delayedWrite() {
if (this._batchTask) {
this._batchTask.disarm();
} else {
let task = async () => {
if (
!this.#searchService.isInitialized ||
this.#searchService._reloadingEngines
) {
// Re-arm the task as we don't want to save potentially incomplete
// information during the middle of (re-)initializing.
this._batchTask.arm();
return;
}
lazy.logConsole.debug("batchTask: Invalidating engine settings");
await this._write();
};
this._batchTask = new lazy.DeferredTask(
task,
SearchSettings.SETTINGS_INVALIDATION_DELAY
);
}
this._batchTask.arm();
}
/**
* Ensures any pending writes of the settings are completed.
*
* @param {string} origin
* If this parameter is "test", then the settings will not be written. As
* some tests manipulate the settings directly, we allow turning off writing to
* avoid writing stale settings data.
*/
async _ensurePendingWritesCompleted(origin = "") {
// Before we read the settings file, first make sure all pending tasks are clear.
if (!this._batchTask) {
return;
}
lazy.logConsole.debug("finalizing batch task");
let task = this._batchTask;
this._batchTask = null;
// Tests manipulate the settings directly, so let's not double-write with
// stale settings data here.
if (origin == "test") {
task.disarm();
} else {
await task.finalize();
}
}
/**
* Writes the settings to disk (no delay).
*/
async _write() {
if (this._batchTask) {
this._batchTask.disarm();
}
let settings = {};
// Allows us to force a settings refresh should the settings format change.
settings.version = lazy.SearchUtils.SETTINGS_VERSION;
settings.engines = [...this.#searchService._engines.values()].map(engine =>
JSON.parse(JSON.stringify(engine))
);
settings.metaData = this.#settings.metaData;
// Persist metadata for AppProvided engines even if they aren't currently
// active, this means if they become active again their settings
// will be restored.
if (this.#settings?.engines) {
for (let engine of this.#settings.engines) {
let included = settings.engines.some(e => e._name == engine._name);
if (engine._isAppProvided && !included) {
settings.engines.push(engine);
}
}
}
// Update the local copy.
this.#settings = settings;
try {
if (!settings.engines.length) {
throw new Error("cannot write without any engine.");
}
if (this.isCurrentAndCachedSettingsEqual()) {
lazy.logConsole.debug(
"_write: Settings unchanged. Did not write to disk."
);
Services.obs.notifyObservers(
null,
lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
"write-prevented-when-settings-unchanged"
);
Services.obs.notifyObservers(
null,
lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
"write-settings-to-disk-complete"
);
return;
}
// At this point, the settings and cached settings are different. We
// write settings to disk and update #cachedSettings.
this.#cachedSettings = structuredClone(this.#settings);
lazy.logConsole.debug("_write: Writing to settings file.");
let path = PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME);
await IOUtils.writeJSON(path, settings, {
compress: true,
tmpPath: path + ".tmp",
});
lazy.logConsole.debug("_write: settings file written to disk.");
Services.obs.notifyObservers(
null,
lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
"write-settings-to-disk-complete"
);
} catch (ex) {
lazy.logConsole.error("_write: Could not write to settings file:", ex);
}
}
/**
* Sets an attribute without verification.
*
* @param {string} name
* The name of the attribute to set.
* @param {*} val
* The value to set.
*/
setMetaDataAttribute(name, val) {
this.#settings.metaData[name] = val;
this._delayedWrite();
}
/**
* Sets a verified attribute. This will save an additional hash
* value, that can be verified when reading back.
*
* @param {string} name
* The name of the attribute to set.
* @param {*} val
* The value to set.
*/
setVerifiedMetaDataAttribute(name, val) {
this.#settings.metaData[name] = val;
this.#settings.metaData[this.getHashName(name)] =
lazy.SearchUtils.getVerificationHash(val);
this._delayedWrite();
}
/**
* Gets an attribute without verification.
*
* @param {string} name
* The name of the attribute to get.
* @returns {*}
* The value of the attribute, or undefined if not known.
*/
getMetaDataAttribute(name) {
return this.#settings.metaData[name] ?? undefined;
}
/**
* Gets a copy of the settings metadata.
*
* @returns {*}
* A copy of the settings metadata object.
*
*/
getSettingsMetaData() {
return { ...this.#settings.metaData };
}
/**
* Gets a verified attribute.
*
* @param {string} name
* The name of the attribute to get.
* @returns {*}
* The value of the attribute, or undefined if not known or an empty strings
* if it does not match the verification hash.
*/
getVerifiedMetaDataAttribute(name) {
let val = this.getMetaDataAttribute(name);
if (
val &&
this.getMetaDataAttribute(this.getHashName(name)) !=
lazy.SearchUtils.getVerificationHash(val)
) {
lazy.logConsole.warn("getVerifiedGlobalAttr, invalid hash for", name);
return undefined;
}
return val;
}
/**
* Sets an attribute in #settings.engines._metaData
*
* @param {string} engineName
* The name of the engine.
* @param {string} property
* The name of the attribute to set.
* @param {*} value
* The value to set.
*/
setEngineMetaDataAttribute(engineName, property, value) {
let engines = [...this.#searchService._engines.values()];
let engine = engines.find(engine => engine._name == engineName);
if (engine) {
engine._metaData[property] = value;
this._delayedWrite();
}
}
/**
* Gets an attribute from #settings.engines._metaData
*
* @param {string} engineName
* The name of the engine.
* @param {string} property
* The name of the attribute to get.
* @returns {*}
* The value of the attribute, or undefined if not known.
*/
getEngineMetaDataAttribute(engineName, property) {
let engine = this.#settings.engines.find(
engine => engine._name == engineName
);
return engine._metaData[property] ?? undefined;
}
/**
* Returns the name for the hash for a particular attribute. This is
* necessary because the default engine ID property is named `current`
* with its hash as `hash`. All other hashes are in the `<name>Hash` format.
*
* @param {string} name
* The name of the attribute to get the hash name for.
* @returns {string}
* The hash name to use.
*/
getHashName(name) {
// The "current" check remains here because we need to retrieve the
// "current" hash name for the migration of engine ids. After the migration,
// the "current" property is no longer used because we now store
// "defaultEngineId" instead.
if (name == "current") {
return "hash";
}
return name + "Hash";
}
/**
* Handles shutdown; writing the settings if necessary.
*
* @param {object} state
* The shutdownState object that is used to help analyzing the shutdown
* state in case of a crash or shutdown timeout.
*/
async shutdown(state) {
if (!this._batchTask) {
return;
}
state.step = "Finalizing batched task";
try {
await this._batchTask.finalize();
state.step = "Batched task finalized";
} catch (ex) {
state.step = "Batched task failed to finalize";
state.latestError.message = "" + ex;
if (ex && typeof ex == "object") {
state.latestError.stack = ex.stack || undefined;
}
}
}
// nsIObserver
observe(engine, topic, verb) {
switch (topic) {
case lazy.SearchUtils.TOPIC_ENGINE_MODIFIED:
switch (verb) {
case lazy.SearchUtils.MODIFIED_TYPE.ADDED:
case lazy.SearchUtils.MODIFIED_TYPE.CHANGED:
case lazy.SearchUtils.MODIFIED_TYPE.REMOVED:
this._delayedWrite();
break;
}
break;
case lazy.SearchUtils.TOPIC_SEARCH_SERVICE:
switch (verb) {
case "init-complete":
case "engines-reloaded":
this._delayedWrite();
break;
}
break;
}
}
/**
* Compares the #settings and #cachedSettings objects.
*
* @returns {boolean}
* True if the objects have the same property and values.
*/
isCurrentAndCachedSettingsEqual() {
return lazy.ObjectUtils.deepEqual(this.#settings, this.#cachedSettings);
}
/**
* This function writes to settings versions 6 and below. It does two
* updates:
* 1) Store engine ids.
* 2) Store "defaultEngineId" and "privateDefaultEngineId" to replace
* "current" and "private" because we are no longer referencing the
* "current" and "private" attributes with engine names as their values.
*
* @param {object} clonedSettings
* The SearchService holds a deep copy of the settings file object. This
* clonedSettings is passed in as an argument from SearchService.
*/
migrateEngineIds(clonedSettings) {
if (clonedSettings.version <= 6) {
lazy.logConsole.debug("migrateEngineIds: start");
for (let engineSettings of clonedSettings.engines) {
let engine = this.#getEngineByName(engineSettings._name);
if (engine) {
// Store the engine id
engineSettings.id = engine.id;
}
}
let currentDefaultEngine = this.#getEngineByName(
clonedSettings.metaData.current
);
let privateDefaultEngine = this.#getEngineByName(
clonedSettings.metaData.private
);
// As per SearchService._getEngineDefault, we relax the verification hash
// check for application provided engines to reduce the annoyance for
// users who backup/sync their profile in custom ways.
if (
currentDefaultEngine &&
(currentDefaultEngine.isAppProvided ||
lazy.SearchUtils.getVerificationHash(
clonedSettings.metaData.current
) == clonedSettings.metaData[this.getHashName("current")])
) {
// Store the defaultEngineId
this.setVerifiedMetaDataAttribute(
"defaultEngineId",
currentDefaultEngine.id
);
} else {
this.setVerifiedMetaDataAttribute("defaultEngineId", "");
}
if (
privateDefaultEngine &&
(privateDefaultEngine.isAppProvided ||
lazy.SearchUtils.getVerificationHash(
clonedSettings.metaData.private
) == clonedSettings.metaData[this.getHashName("private")])
) {
// Store the privateDefaultEngineId
this.setVerifiedMetaDataAttribute(
"privateDefaultEngineId",
privateDefaultEngine.id
);
} else {
this.setVerifiedMetaDataAttribute("privateDefaultEngineId", "");
}
lazy.logConsole.debug("migrateEngineIds: done");
}
}
/**
* Migrates telemetry load paths for versions of settings prior to v8.
*/
#migrateTelemetryLoadPaths() {
for (let engine of this.#settings.engines) {
if (!engine._loadPath) {
continue;
}
if (engine._loadPath.includes("set-via-policy")) {
engine._loadPath = "[policy]";
} else if (engine._loadPath.includes("set-via-user")) {
engine._loadPath = "[user]";
} else if (engine._loadPath.startsWith("[other]addEngineWithDetails:")) {
engine._loadPath = engine._loadPath.replace(
"[other]addEngineWithDetails:",
"[addon]"
);
}
}
}
/**
* Returns the engine associated with the name without SearchService
* initialization checks.
*
* @param {string} engineName
* The name of the engine.
* @returns {SearchEngine}
* The associated engine if found, null otherwise.
*/
#getEngineByName(engineName) {
for (let engine of this.#searchService._engines.values()) {
if (engine.name == engineName) {
return engine;
}
}
return null;
}
}