Files
tubestation/browser/components/profiles/SelectableProfileService.sys.mjs
Dave Townsend 4d68ba5791 Bug 1915216: Add a method to asynchronously write the important data about the current profile to the INI file on disk. r=glandium,jhirsch,pehrsons,backup-reviewers,mconley
This adds an asynchronous method to lock the startup files using the same
lock that we use during normal startup.

The profile service then uses this lock to gate access to the profiles.ini
files adding a method to async flush the entire database or in the case
that the on-disk database has changed a way to mergwe in some properties
about the current profile into the on-disk version.

Differential Revision: https://phabricator.services.mozilla.com/D222662
2024-10-17 15:09:41 +00:00

927 lines
25 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/. */
// TDOD: Remove eslint-disable lines once methods are updated. See bug 1896727
/* eslint-disable no-unused-vars */
/* eslint-disable no-unused-private-class-members */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { SelectableProfile } from "./SelectableProfile.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
CryptoUtils: "resource://services-crypto/utils.sys.mjs",
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "profilesLocalization", () => {
return new Localization(["preview/profiles.ftl"], true);
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"ProfileService",
"@mozilla.org/toolkit/profile-service;1",
"nsIToolkitProfileService"
);
const PROFILES_CRYPTO_SALT_LENGTH_BYTES = 16;
async function attemptFlush() {
try {
await lazy.ProfileService.asyncFlush();
} catch (e) {
await lazy.ProfileService.asyncFlushCurrentProfile();
}
}
/**
* The service that manages selectable profiles
*/
class SelectableProfileServiceClass {
#connection = null;
#asyncShutdownBlocker = null;
#initialized = false;
#groupToolkitProfile = null;
#currentProfile = null;
#everyWindowCallbackId = "SelectableProfileService";
constructor() {
if (Cu.isInAutomation) {
this.#groupToolkitProfile = {
storeID: "12345678",
rootDir: Services.dirsvc.get("ProfD", Ci.nsIFile),
};
} else {
this.#groupToolkitProfile =
lazy.ProfileService.currentProfile ?? lazy.ProfileService.groupProfile;
}
}
// Do not use. Only for testing.
set groupToolkitProfile(mockToolkitProfile) {
if (Cu.isInAutomation) {
this.#groupToolkitProfile = mockToolkitProfile;
}
}
get groupToolkitProfile() {
return this.#groupToolkitProfile;
}
get toolkitProfileRootDir() {
return this.#groupToolkitProfile.rootDir;
}
get currentProfile() {
return this.#currentProfile;
}
get initialized() {
return this.#initialized;
}
static get PROFILE_GROUPS_DIR() {
return PathUtils.join(
Services.dirsvc.get("UAppData", Ci.nsIFile).path,
"Profile Groups"
);
}
async maybeCreateProfilesStorePath() {
if (this.#groupToolkitProfile.storeID) {
return;
}
await IOUtils.makeDirectory(
SelectableProfileServiceClass.PROFILE_GROUPS_DIR
);
const storageID = Services.uuid
.generateUUID()
.toString()
.replace("{", "")
.split("-")[0];
this.#groupToolkitProfile.storeID = storageID;
await attemptFlush();
}
async getProfilesStorePath() {
await this.maybeCreateProfilesStorePath();
return PathUtils.join(
SelectableProfileServiceClass.PROFILE_GROUPS_DIR,
`${this.#groupToolkitProfile.storeID}.sqlite`
);
}
/**
* At startup, store the nsToolkitProfile for the group.
* Get the groupDBPath from the nsToolkitProfile, and connect to it.
*/
async init() {
if (this.#initialized || !this.groupToolkitProfile) {
return;
}
// If the storeID doesn't exist, we don't want to create the db until we
// need to so we early return.
if (!this.groupToolkitProfile.storeID) {
return;
}
await this.initConnection();
// When we launch into the startup window, the `ProfD` is not defined so
// Services.dirsvc.get("ProfD", Ci.nsIFile) will throw. Leaving the
// `currentProfile` as null is fine for the startup window.
try {
// Get the SelectableProfile by the profile directory
this.#currentProfile = await this.getProfileByPath(
Services.dirsvc.get("ProfD", Ci.nsIFile)
);
} catch {}
this.setSharedPrefs();
// The 'activate' event listeners use #currentProfile, so this line has
// to come after #currentProfile has been set.
this.initWindowTracker();
this.#initialized = true;
}
async uninit() {
if (!this.#initialized) {
return;
}
lazy.EveryWindow.unregisterCallback(this.#everyWindowCallbackId);
await this.closeConnection();
this.#currentProfile = null;
this.#initialized = false;
}
initWindowTracker() {
lazy.EveryWindow.registerCallback(
this.#everyWindowCallbackId,
window => {
let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
if (isPBM) {
return;
}
window.addEventListener("activate", this);
},
window => {
let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
if (isPBM) {
return;
}
window.removeEventListener("activate", this);
}
);
}
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");
this.#asyncShutdownBlocker = async () => {
await this.#connection.close();
this.#connection = null;
};
// This could fail if we're adding it during shutdown. In this case,
// don't throw but close the connection.
try {
lazy.Sqlite.shutdown.addBlocker(
"Profiles:ProfilesSqlite closing",
this.#asyncShutdownBlocker
);
} catch (ex) {
await this.closeConnection();
return;
}
await this.createProfilesDBTables();
}
async closeConnection() {
if (this.#asyncShutdownBlocker) {
lazy.Sqlite.shutdown.removeBlocker(this.#asyncShutdownBlocker);
this.#asyncShutdownBlocker = null;
}
if (this.#connection) {
// 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 handleEvent(event) {
switch (event.type) {
case "activate": {
this.setDefaultProfileForGroup();
break;
}
}
}
/**
* Set the shared prefs that will be needed when creating a
* new selectable profile.
*/
setSharedPrefs() {
this.setPref(
"toolkit.profiles.storeID",
Services.prefs.getStringPref("toolkit.profiles.storeID", "")
);
this.setPref(
"browser.profiles.enabled",
Services.prefs.getBoolPref("browser.profiles.enabled", true)
);
this.setPref(
"toolkit.telemetry.cachedProfileGroupID",
Services.prefs.getStringPref("toolkit.telemetry.cachedProfileGroupID", "")
);
}
/**
* Create tables for Selectable Profiles if they don't already exist
*/
async createProfilesDBTables() {
// TODO: (Bug 1902320) Handle exceptions on connection opening
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,
themeL10nId 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);
});
}
/**
* Create the SQLite DB for the profile group.
* Init shared prefs for the group and add to DB.
* Create the Group DB path to aNamedProfile entry in profiles.ini.
* Import aNamedProfile into DB.
*/
createProfileGroup() {}
/**
* When the last selectable profile in a group is deleted,
* also remove the profile group's named profile entry from profiles.ini
* and vacuum the group DB.
*/
async deleteProfileGroup() {
if (this.getAllProfiles().length) {
return;
}
this.#groupToolkitProfile.storeID = null;
await attemptFlush();
await this.vacuumAndCloseGroupDB();
}
// App session lifecycle methods and multi-process support
/*
* Helper that returns an inited Firefox executable process (nsIProcess).
* Mostly useful for mocking in unit testing.
*/
getExecutableProcess() {
let process = Cc["@mozilla.org/process/util;1"].createInstance(
Ci.nsIProcess
);
let executable = Services.dirsvc.get("XREExeF", Ci.nsIFile);
process.init(executable);
return process;
}
/**
* Launch a new Firefox instance using the given selectable profile.
*
* @param {SelectableProfile} aProfile The profile to launch
* @param {string} url A url to open in launched profile
*/
launchInstance(aProfile, url) {
let process = this.getExecutableProcess();
let args = ["--profile", aProfile.path];
if (Services.appinfo.OS === "Darwin") {
args.unshift("-foreground");
}
if (url) {
args.push("-url", url);
}
process.runw(false, args, args.length);
}
/**
* When the group DB has been updated, either changes to prefs or profiles,
* ask the remoting service to notify other running instances that they should
* check for updates and refresh their UI accordingly.
*/
notify() {}
/**
* Invoked when the remoting service has notified this instance that another
* instance has updated the database. Triggers refreshProfiles() and refreshPrefs().
*/
observe() {}
/**
* Init or update the current SelectableProfiles from the DB.
*/
refreshProfiles() {}
/**
* Fetch all prefs from the DB and write to the current instance.
*/
refreshPrefs() {}
/**
* Update the default profile by setting the current selectable profile path
* as the path of the nsToolkitProfile for the group.
*/
async setDefaultProfileForGroup() {
if (this.#groupToolkitProfile.rootDir.path === this.currentProfile.path) {
return;
}
this.#groupToolkitProfile.rootDir = await this.currentProfile.rootDir;
await attemptFlush();
}
/**
* Update whether to show the selectable profile selector window at startup.
* Set on the nsToolkitProfile instance for the group.
*
* @param {boolean} shouldShow Whether or not we should show the profile selector
*/
async showProfileSelectorWindow(shouldShow) {
if (shouldShow === this.groupToolkitProfile.showProfileSelector) {
return;
}
this.groupToolkitProfile.showProfileSelector = shouldShow;
await attemptFlush();
}
/**
* Update the path to the group DB. Set on the nsToolkitProfile instance
* for the group.
*
* @param {string} aPath The path to the group DB
*/
setGroupDBPath(aPath) {}
// SelectableProfile lifecycle
/**
* Create the profile directory for new profile. The profile name is combined
* with a salt string to ensure the directory is unique. The format of the
* directory is salt + "." + profileName. (Ex. c7IZaLu7.testProfile)
*
* @param {string} aProfileName The name of the profile to be created
* @returns {string} The relative path for the given profile
*/
async createProfileDirs(aProfileName) {
const salt = btoa(
lazy.CryptoUtils.generateRandomBytesLegacy(
PROFILES_CRYPTO_SALT_LENGTH_BYTES
)
);
// Sometimes the string from CryptoUtils.generateRandomBytesLegacy will
// contain non-word characters that we don't want to include in the profile
// directory name. So we match only word characters for the directory name.
const safeSalt = salt.match(/\w/g).join("").slice(0, 8);
const profileDir = `${safeSalt}.${aProfileName}`;
// Handle errors in bug 1909919
await Promise.all([
IOUtils.makeDirectory(
PathUtils.join(
Services.dirsvc.get("DefProfRt", Ci.nsIFile).path,
profileDir
)
),
IOUtils.makeDirectory(
PathUtils.join(
Services.dirsvc.get("DefProfLRt", Ci.nsIFile).path,
profileDir
)
),
]);
return IOUtils.getDirectory(
PathUtils.join(
Services.dirsvc.get("DefProfRt", Ci.nsIFile).path,
profileDir
)
);
}
/**
* Create the times.json file and write the "created" timestamp and
* "firstUse" as null.
* Create the prefs.js file and write all shared prefs to the file.
*
* @param {nsIFile} profileDir The root dir of the newly created profile
*/
async createProfileInitialFiles(profileDir) {
let timesJsonFilePath = await IOUtils.createUniqueFile(
profileDir.path,
"times.json",
0o700
);
await IOUtils.writeJSON(timesJsonFilePath, {
created: Date.now(),
firstUse: null,
});
let prefsJsFilePath = await IOUtils.createUniqueFile(
profileDir.path,
"prefs.js",
0o700
);
const sharedPrefs = await this.getAllPrefs();
const LINEBREAK = AppConstants.platform === "win" ? "\r\n" : "\n";
const prefsJsHeader = [
"// Mozilla User Preferences",
LINEBREAK,
"// DO NOT EDIT THIS FILE.",
"//",
"// If you make changes to this file while the application is running,",
"// the changes will be overwritten when the application exits.",
"//",
"// To change a preference value, you can either:",
"// - modify it via the UI (e.g. via about:config in the browser); or",
"// - set it within a user.js file in your profile.",
LINEBREAK,
];
const prefsJsContent = sharedPrefs.map(
pref =>
`user_pref("${pref.name}", ${
pref.type === "string" ? `"${pref.value}"` : `${pref.value}`
});`
);
const prefsJs = prefsJsHeader.concat(prefsJsContent);
await IOUtils.writeUTF8(prefsJsFilePath, prefsJs.join(LINEBREAK));
}
/**
* Get a relative to the Profiles directory for the given profile directory.
*
* @param {nsIFile} aProfilePath Path to profile directory.
*
* @returns {string} A relative path of the profile directory.
*/
getRelativeProfilePath(aProfilePath) {
let relativePath = aProfilePath.getRelativePath(
Services.dirsvc.get("UAppData", Ci.nsIFile)
);
if (AppConstants.platform === "win") {
relativePath = relativePath.replace("/", "\\");
}
return relativePath;
}
async createNewProfile() {
// Create the profiles db and set the storeID on the toolkit profile if it
// doesn't exist so we can init the service
await this.maybeCreateProfilesStorePath();
await this.init();
let nextProfileNumber =
1 + Math.max(0, ...(await this.getAllProfiles()).map(p => p.id));
let [defaultName] = lazy.profilesLocalization.formatMessagesSync([
{ id: "default-profile-name", args: { number: nextProfileNumber } },
]);
let newProfile = {
name: defaultName.value,
avatar: "default",
themeL10nId: "default",
themeFg: "var(--text-color)",
themeBg: "var(--background-color-box)",
};
let profile = await this.createProfile(newProfile);
this.launchInstance(profile);
}
/**
* Create an empty SelectableProfile and add it to the group DB.
* This is an unmanaged profile from the nsToolkitProfile perspective.
*
* @param {object} profile An object that contains a path, name, themeL10nId,
* themeFg, and themeBg for creating a new profile.
* @returns {SelectableProfile}
* The newly created profile object.
*/
async createProfile(profile) {
let profilePath = await this.createProfileDirs(profile.name);
await this.createProfileInitialFiles(profilePath);
let relativePath = this.getRelativeProfilePath(profilePath);
profile.path = relativePath;
await this.#connection.execute(
`INSERT INTO Profiles VALUES (NULL, :path, :name, :avatar, :themeL10nId, :themeFg, :themeBg);`,
profile
);
return this.getProfileByPath(profilePath);
}
/**
* Remove the profile directories.
*
* @param {SelectableProfile} aSelectableProfile The SelectableProfile of the
* directories to be removed.
*/
async removeProfileDirs(aSelectableProfile) {
let profileDir = (await aSelectableProfile.rootDir).leafName;
// Handle errors in bug 1909919
await Promise.all([
IOUtils.remove(
PathUtils.join(
Services.dirsvc.get("DefProfRt", Ci.nsIFile).path,
profileDir
),
{
recursive: true,
}
),
IOUtils.remove(
PathUtils.join(
Services.dirsvc.get("DefProfLRt", Ci.nsIFile).path,
profileDir
),
{
recursive: true,
}
),
]);
}
/**
* Delete a SelectableProfile from the group DB.
* If it was the last profile in the group, also call deleteProfileGroup().
*
* @param {SelectableProfile} aSelectableProfile The SelectableProfile to be deleted
* @param {boolean} removeFiles True if the profile directory should be removed
*/
async deleteProfile(aSelectableProfile, removeFiles) {
await this.#connection.execute("DELETE FROM Profiles WHERE id = :id;", {
id: aSelectableProfile.id,
});
if (removeFiles) {
await this.removeProfileDirs(aSelectableProfile);
}
}
/**
* Close all active instances running the current profile
*/
closeActiveProfileInstances() {}
/**
* Schedule deletion of the current SelectableProfile as a background task, then exit.
*/
async deleteCurrentProfile() {
this.closeActiveProfileInstances();
await this.#connection.executeBeforeShutdown(
"SelectableProfileService: deleteCurrentProfile",
db =>
db.execute("DELETE FROM Profiles WHERE id = :id;", {
id: this.currentProfile.id,
})
);
}
/**
* Write an updated profile to the DB.
*
* @param {SelectableProfile} aSelectableProfile The SelectableProfile to be updated
*/
async updateProfile(aSelectableProfile) {
let profileObj = aSelectableProfile.toObject();
await this.#connection.execute(
`UPDATE Profiles
SET path = :path, name = :name, avatar = :avatar, themeL10nId = :themeL10nId, themeFg = :themeFg, themeBg = :themeBg
WHERE id = :id;`,
profileObj
);
}
/**
* Get the complete list of profiles in the group.
*
* @returns {Array<SelectableProfile>}
* An array of profiles in the group.
*/
async getAllProfiles() {
return (
await this.#connection.executeCached("SELECT * FROM Profiles;")
).map(row => {
return new SelectableProfile(row, this);
});
}
/**
* Get a specific profile by its internal ID.
*
* @param {number} aProfileID The internal id of the profile
* @returns {SelectableProfile}
* The specific profile.
*/
async getProfile(aProfileID) {
let row = (
await this.#connection.execute("SELECT * FROM Profiles WHERE id = :id;", {
id: aProfileID,
})
)[0];
return row ? new SelectableProfile(row, this) : null;
}
/**
* Get a specific profile by its name.
*
* @param {string} aProfileNanme The name of the profile
* @returns {SelectableProfile}
* The specific profile.
*/
async getProfileByName(aProfileNanme) {
let row = (
await this.#connection.execute(
"SELECT * FROM Profiles WHERE name = :name;",
{
name: aProfileNanme,
}
)
)[0];
return row ? new SelectableProfile(row, this) : null;
}
/**
* Get a specific profile by its absolute path.
*
* @param {nsIFile} aProfilePath The path of the profile
* @returns {SelectableProfile|null}
*/
async getProfileByPath(aProfilePath) {
let relativePath = this.getRelativeProfilePath(aProfilePath);
let row = (
await this.#connection.execute(
"SELECT * FROM Profiles WHERE path = :path;",
{
path: relativePath,
}
)
)[0];
return row ? new SelectableProfile(row, this) : null;
}
// Shared Prefs management
getPrefValueFromRow(row) {
let value = row.getResultByName("value");
if (row.getResultByName("isBoolean")) {
return value === 1;
}
return value;
}
/**
* Get all shared prefs as a list.
*
* @returns {{name: string, value: *, type: string}}
*/
async getAllPrefs() {
return (
await this.#connection.executeCached("SELECT * FROM SharedPrefs;")
).map(row => {
let value = this.getPrefValueFromRow(row);
return {
name: row.getResultByName("name"),
value,
type: typeof value,
};
});
}
/**
* Get the value of a specific shared pref.
*
* @param {string} aPrefName The name of the pref to get
*
* @returns {any} Value of the pref
*/
async getPref(aPrefName) {
let row = (
await this.#connection.execute(
"SELECT value, isBoolean FROM SharedPrefs WHERE name = :name;",
{
name: aPrefName,
}
)
)[0];
return this.getPrefValueFromRow(row);
}
/**
* Get the value of a specific shared pref.
*
* @param {string} aPrefName The name of the pref to get
*
* @returns {boolean} Value of the pref
*/
async getBoolPref(aPrefName) {
let prefValue = await this.getPref(aPrefName);
if (typeof prefValue !== "boolean") {
return null;
}
return prefValue;
}
/**
* Get the value of a specific shared pref.
*
* @param {string} aPrefName The name of the pref to get
*
* @returns {number} Value of the pref
*/
async getIntPref(aPrefName) {
let prefValue = await this.getPref(aPrefName);
if (typeof prefValue !== "number") {
return null;
}
return prefValue;
}
/**
* Get the value of a specific shared pref.
*
* @param {string} aPrefName The name of the pref to get
*
* @returns {string} Value of the pref
*/
async getStringPref(aPrefName) {
let prefValue = await this.getPref(aPrefName);
if (typeof prefValue !== "string") {
return null;
}
return prefValue;
}
/**
* Insert or update a pref value, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref
* @param {any} aPrefValue The value of the pref
*/
async setPref(aPrefName, aPrefValue) {
await this.#connection.execute(
"INSERT INTO SharedPrefs(id, name, value, isBoolean) VALUES (NULL, :name, :value, :isBoolean) ON CONFLICT(name) DO UPDATE SET value=excluded.value, isBoolean=excluded.isBoolean;",
{
name: aPrefName,
value: aPrefValue,
isBoolean: typeof aPrefValue === "boolean",
}
);
}
/**
* Insert or update a pref value, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref
* @param {boolean} aPrefValue The value of the pref
*/
async setBoolPref(aPrefName, aPrefValue) {
if (typeof aPrefValue !== "boolean") {
throw new Error("aPrefValue must be of type boolean");
}
await this.setPref(aPrefName, aPrefValue);
}
/**
* Insert or update a pref value, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref
* @param {number} aPrefValue The value of the pref
*/
async setIntPref(aPrefName, aPrefValue) {
if (typeof aPrefValue !== "number") {
throw new Error("aPrefValue must be of type number");
}
await this.setPref(aPrefName, aPrefValue);
}
/**
* Insert or update a pref value, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref
* @param {string} aPrefValue The value of the pref
*/
async setStringPref(aPrefName, aPrefValue) {
if (typeof aPrefValue !== "string") {
throw new Error("aPrefValue must be of type string");
}
await this.setPref(aPrefName, aPrefValue);
}
/**
* Remove a shared pref, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref to delete
*/
async deletePref(aPrefName) {
await this.#connection.execute(
"DELETE FROM SharedPrefs WHERE name = :name;",
{
name: aPrefName,
}
);
}
// DB lifecycle
/**
* Create the SQLite DB for the profile group at groupDBPath.
* Init shared prefs for the group and add to DB.
*/
createGroupDB() {}
/**
* Vacuum the SQLite DB.
*/
async vacuumAndCloseGroupDB() {
await this.#connection.execute("VACUUM;");
await this.closeConnection();
}
}
const SelectableProfileService = new SelectableProfileServiceClass();
export { SelectableProfileService };