Source code

Revision control

Copy as Markdown

Other Tools

/* 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 { DeferredTask } from "resource://gre/modules/DeferredTask.sys.mjs";
const NOTIFY_TIMEOUT = 200;
const STOREID_PREF_NAME = "toolkit.profiles.storeID";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
});
/**
* An API that allows access to the cross-profile SQLite database shared
* between a group of profiles. This database always exists, even if the
* user does not have the multiple profiles feature enabled.
*/
class ProfilesDatastoreServiceClass {
#connection = null;
#asyncShutdownBlocker = null;
#initialized = false;
#storeID = null;
#initPromise = null;
#notifyTask = null;
#profileService = null;
static #dirSvc = null;
/**
* Gets a connection to the database.
*
* Use this connection to query existing tables, but never to create or
* modify schemas. Any schema changes should be added as a new migration,
* see `createTables()`.
*
* Rethrows errors thrown by `Sqlite.openConnection()`; see details in
* `Sqlite.sys.mjs`. TODO: document and handle errors (bug 1960963).
*/
async getConnection() {
await this.init();
return this.#connection;
}
/**
* Create or update tables in this shared cross-profile database.
*
* Includes simple forward-only migration support which applies any new
* migrations based on the schema version.
*
* Notes for migration authors:
*
* Since a mix of Firefox versions may access the database at any time, all
* schema changes must be backwards-compatible.
*
* Please keep your schemas as simple as possible, to reduce the odds of
* corruption affecting all users of the database.
*/
async createTables() {
// TODO: (Bug 1902320) Handle exceptions on connection opening
let currentVersion = await this.#connection.getSchemaVersion();
if (currentVersion == 3) {
return;
}
if (currentVersion < 1) {
// Brand new database or created prior to migration support.
await this.#connection.executeTransaction(async () => {
const createProfilesTable = `
CREATE TABLE IF NOT EXISTS "Profiles" (
id INTEGER NOT NULL,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar TEXT NOT NULL,
themeId TEXT NOT NULL,
themeFg TEXT NOT NULL,
themeBg TEXT NOT NULL,
PRIMARY KEY(id)
);`;
await this.#connection.execute(createProfilesTable);
const createSharedPrefsTable = `
CREATE TABLE IF NOT EXISTS "SharedPrefs" (
id INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE,
value BLOB,
isBoolean INTEGER,
PRIMARY KEY(id)
);`;
await this.#connection.execute(createSharedPrefsTable);
});
await this.#connection.setSchemaVersion(1);
}
if (currentVersion < 2) {
await this.#connection.executeTransaction(async () => {
const createEnrollmentsTable = `
CREATE TABLE IF NOT EXISTS "NimbusEnrollments" (
id INTEGER NOT NULL,
profileId INTEGER NOT NULL,
slug TEXT NOT NULL,
branchSlug TEXT NOT NULL,
recipe JSONB,
active BOOLEAN NOT NULL,
unenrollReason TEXT,
lastSeen TEXT NOT NULL,
setPrefs JSONB,
prefFlips JSONB,
source TEXT NOT NULL,
PRIMARY KEY(id),
UNIQUE (profileId, slug) ON CONFLICT FAIL
);
`;
await this.#connection.execute(createEnrollmentsTable);
});
await this.#connection.setSchemaVersion(2);
}
if (currentVersion < 3) {
await this.#connection.execute("DELETE FROM NimbusEnrollments;");
await this.#connection.setSchemaVersion(3);
}
}
/**
* Trigger an async cross-instance notification that the data in the
* datastore has been changed.
*
* Two different nsIObserver events may be fired, depending on whether the
* change originated in the current Firefox instance or in another instance.
*
* Changes to the datastore made by the current instance will trigger the
* "pds-datastore-changed" nsIObserver event; see `#datastoreChanged` for
* details. These events are fired whether or not the multiple profiles
* feature is enabled.
*
* If the multiple profiles feature is enabled, then changes to the datastore
* made either by the current instance or another instance in the profile
* group will trigger the "sps-profiles-updated" nsIObserver event. See
* `SelectableProfileService.databaseChanged` for details.
*/
notify() {
this.#notifyTask.arm();
}
/**
* Notify datastore observers that the data has changed by firing
* the "pds-datastore-changed" nsIObserver signal.
*
*@param {"local"|"shutdown"} source The source of the
* notification. Either "local" meaning that the change was made in this
* process and "shutdown" meaning we are closing the connection and
* shutting down.
*/
#datastoreChanged(source) {
Services.obs.notifyObservers(null, "pds-datastore-changed", source);
}
get storeID() {
return new Promise(resolve => {
this.init().then(() => {
resolve(this.#storeID);
});
});
}
get initialized() {
return this.#initialized;
}
static get PROFILE_GROUPS_DIR() {
if (this.#dirSvc && "ProfileGroups" in this.#dirSvc) {
return this.#dirSvc.ProfileGroups;
}
return PathUtils.join(
ProfilesDatastoreServiceClass.getDirectory("UAppData").path,
"Profile Groups"
);
}
overrideDirectoryService(dirSvc) {
if (!Cu.isInAutomation) {
return;
}
ProfilesDatastoreServiceClass.#dirSvc = dirSvc;
}
static getDirectory(id) {
if (this.#dirSvc) {
if (id in this.#dirSvc) {
return this.#dirSvc[id].clone();
}
}
return Services.dirsvc.get(id, Ci.nsIFile);
}
/**
* For use in testing only, override the profile service with a mock version
* and reset state accordingly.
*
* @param {Ci.nsIToolkitProfileService} profileService The mock profile service
*/
async resetProfileService(profileService) {
if (!Cu.isInAutomation) {
return;
}
await this.uninit();
this.#profileService =
profileService ??
Cc["@mozilla.org/toolkit/profile-service;1"].getService(
Ci.nsIToolkitProfileService
);
await this.init();
}
get toolkitProfileService() {
return this.#profileService;
}
constructor() {
this.#asyncShutdownBlocker = () => this.uninit();
this.#profileService = Cc[
"@mozilla.org/toolkit/profile-service;1"
].getService(Ci.nsIToolkitProfileService);
}
/**
* At startup, get the groupDBPath from the prefs, and connect to it.
*
* @returns {Promise}
*/
init() {
if (!this.#initPromise) {
this.#initPromise = this.#init().finally(
() => (this.#initPromise = null)
);
}
return this.#initPromise;
}
async #init() {
if (this.#initialized) {
return;
}
// We read the store ID from prefs but in early startup (for example in the profile selector)
// this is not available so we get it from the current profile.
this.#storeID = Services.startup.startingUp
? this.#profileService.currentProfile?.storeID
: Services.prefs.getStringPref(STOREID_PREF_NAME, "");
// This could fail if we're adding it during shutdown. In this case,
// don't throw but don't continue initialization.
try {
lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
"ProfilesDatastoreService uninit",
this.#asyncShutdownBlocker
);
} catch (ex) {
console.error(ex);
return;
}
this.#notifyTask = new DeferredTask(async () => {
this.#datastoreChanged("local");
}, NOTIFY_TIMEOUT);
try {
await this.#initConnection();
} catch (e) {
console.error(e);
await this.uninit();
return;
}
this.#initialized = true;
}
async uninit() {
lazy.AsyncShutdown.profileChangeTeardown.removeBlocker(
this.#asyncShutdownBlocker
);
// During shutdown we don't need to notify ourselves, just other instances
// so rather than finalizing the task just disarm it and do the notification
// manually.
if (this.#notifyTask.isArmed) {
this.#notifyTask.disarm();
this.#datastoreChanged("shutdown");
}
await this.closeConnection();
this.#storeID = null;
this.#initialized = false;
}
async #initConnection() {
if (this.#connection) {
return;
}
let path = await this.getProfilesStorePath();
// TODO: (Bug 1902320) Handle exceptions on connection opening
// This could fail if the store is corrupted.
this.#connection = await lazy.Sqlite.openConnection({
path,
openNotExclusive: true,
});
await this.#connection.execute("PRAGMA journal_mode = WAL");
await this.#connection.execute("PRAGMA wal_autocheckpoint = 16");
await this.createTables();
}
async closeConnection() {
if (!this.#connection) {
return;
}
// An error could occur while closing the connection. We suppress the
// error since it is not a critical part of the browser.
try {
await this.#connection.close();
} catch (ex) {}
this.#connection = null;
}
async maybeCreateProfilesStorePath() {
if (this.#storeID) {
return;
}
await IOUtils.makeDirectory(
ProfilesDatastoreServiceClass.PROFILE_GROUPS_DIR
);
const storageID = Services.uuid
.generateUUID()
.toString()
.replace("{", "")
.split("-")[0];
this.#storeID = storageID;
Services.prefs.setStringPref(STOREID_PREF_NAME, storageID);
}
async getProfilesStorePath() {
await this.maybeCreateProfilesStorePath();
// If we are not running in a named nsIToolkitProfile, the datastore path
// should be in the profile directory. This is true in a local build or a
// CI test build, for example.
if (!this.#profileService.currentProfile) {
return PathUtils.join(
ProfilesDatastoreServiceClass.getDirectory("ProfD").path,
`${this.#storeID}.sqlite`
);
}
return PathUtils.join(
ProfilesDatastoreServiceClass.PROFILE_GROUPS_DIR,
`${this.#storeID}.sqlite`
);
}
}
const ProfilesDatastoreService = new ProfilesDatastoreServiceClass();
export { ProfilesDatastoreService };