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 { SelectableProfile } from "./SelectableProfile.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { DeferredTask } from "resource://gre/modules/DeferredTask.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "profilesLocalization", () => {
return new Localization(["browser/profiles.ftl"], true);
});
const PROFILES_CRYPTO_SALT_LENGTH_BYTES = 16;
const NOTIFY_TIMEOUT = 200;
const COMMAND_LINE_UPDATE = "profiles-updated";
const COMMAND_LINE_ACTIVATE = "profiles-activate";
const gSupportsBadging = "nsIMacDockSupport" in Ci || "nsIWinTaskbar" in Ci;
function loadImage(url) {
return new Promise((resolve, reject) => {
let imageTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
let imageContainer;
let observer = imageTools.createScriptedObserver({
sizeAvailable() {
resolve(imageContainer);
imageContainer = null;
},
});
imageTools.decodeImageFromChannelAsync(
url,
Services.io.newChannelFromURI(
url,
null,
Services.scriptSecurityManager.getSystemPrincipal(),
null, // aTriggeringPrincipal
Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
Ci.nsIContentPolicy.TYPE_IMAGE
),
(image, status) => {
if (!Components.isSuccessCode(status)) {
reject(new Components.Exception("Image loading failed", status));
} else {
imageContainer = image;
}
},
observer
);
});
}
/**
* The service that manages selectable profiles
*/
class SelectableProfileServiceClass {
#profileService = null;
#connection = null;
#asyncShutdownBlocker = null;
#initialized = false;
#groupToolkitProfile = null;
#storeID = null;
#currentProfile = null;
#everyWindowCallbackId = "SelectableProfileService";
#defaultAvatars = [
"book",
"briefcase",
"flower",
"heart",
"shopping",
"star",
];
#initPromise = null;
#notifyTask = null;
#observedPrefs = null;
#badge = null;
static #dirSvc = null;
// The initial preferences that will be shared amongst profiles. Only used during database
// creation, after that the set in the database is used.
static initialSharedPrefs = ["toolkit.telemetry.cachedProfileGroupID"];
// Preferences that were previously shared but should now be ignored.
static ignoredSharedPrefs = [
"browser.profiles.enabled",
"toolkit.profiles.storeID",
];
// Preferences that need to be set in newly created profiles.
static profileInitialPrefs = [
"browser.profiles.enabled",
"toolkit.profiles.storeID",
];
constructor() {
this.themeObserver = this.themeObserver.bind(this);
this.prefObserver = (subject, topic, prefName) =>
this.flushSharedPrefToDatabase(prefName);
this.#profileService = Cc[
"@mozilla.org/toolkit/profile-service;1"
].getService(Ci.nsIToolkitProfileService);
this.#asyncShutdownBlocker = () => this.uninit();
this.#observedPrefs = new Set();
}
get isEnabled() {
return (
Services.prefs.getBoolPref("browser.profiles.enabled", false) &&
!!(this.#storeID || this.#groupToolkitProfile)
);
}
/**
* 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();
}
overrideDirectoryService(dirSvc) {
if (!Cu.isInAutomation) {
return;
}
SelectableProfileServiceClass.#dirSvc = dirSvc;
}
static getDirectory(id) {
if (this.#dirSvc) {
if (id in this.#dirSvc) {
return this.#dirSvc[id];
}
}
return Services.dirsvc.get(id, Ci.nsIFile);
}
async #attemptFlushProfileService() {
try {
await this.#profileService.asyncFlush();
} catch (e) {
try {
await this.#profileService.asyncFlushCurrentProfile();
} catch (ex) {
console.error(
`Failed to flush changes to the profiles database: ${ex}`
);
}
}
}
get storeID() {
return this.#storeID;
}
get groupToolkitProfile() {
return this.#groupToolkitProfile;
}
get currentProfile() {
return this.#currentProfile;
}
get initialized() {
return this.#initialized;
}
static get PROFILE_GROUPS_DIR() {
if (this.#dirSvc && "ProfileGroups" in this.#dirSvc) {
return this.#dirSvc.ProfileGroups;
}
return PathUtils.join(this.getDirectory("UAppData").path, "Profile Groups");
}
async maybeCreateProfilesStorePath() {
if (this.storeID) {
return;
}
if (!this.#groupToolkitProfile) {
throw new Error("Cannot create a store without a group profile.");
}
await IOUtils.makeDirectory(
SelectableProfileServiceClass.PROFILE_GROUPS_DIR
);
const storageID = Services.uuid
.generateUUID()
.toString()
.replace("{", "")
.split("-")[0];
this.#groupToolkitProfile.storeID = storageID;
this.#storeID = storageID;
await this.#attemptFlushProfileService();
}
async getProfilesStorePath() {
await this.maybeCreateProfilesStorePath();
return PathUtils.join(
SelectableProfileServiceClass.PROFILE_GROUPS_DIR,
`${this.storeID}.sqlite`
);
}
/**
* At startup, store the nsToolkitProfile for the group.
* Get the groupDBPath from the nsToolkitProfile, 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;
}
this.#groupToolkitProfile =
this.#profileService.currentProfile ?? this.#profileService.groupProfile;
this.#storeID = this.#groupToolkitProfile?.storeID;
if (!this.storeID) {
this.#storeID = Services.prefs.getCharPref(
"toolkit.profiles.storeID",
""
);
}
if (!this.isEnabled) {
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.storeID) {
return;
}
// 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(
"SelectableProfileService uninit",
this.#asyncShutdownBlocker
);
} catch (ex) {
console.error(ex);
return;
}
this.#notifyTask = new DeferredTask(async () => {
// Notify ourselves.
await this.databaseChanged("local");
// Notify other instances.
await this.#notifyRunningInstances();
}, NOTIFY_TIMEOUT);
try {
await this.initConnection();
} catch (e) {
console.error(e);
// If this was an attempt to recover the storeID then reset it.
if (!this.#groupToolkitProfile?.storeID) {
Services.prefs.clearUserPref("toolkit.profiles.storeID");
}
await this.uninit();
return;
}
// This can happen if profiles.ini has been reset by a version of Firefox
// prior to 67 and the current profile is not the current default for the
// group. We can recover by attempting to find the group profile from the
// database.
if (this.#groupToolkitProfile?.storeID != this.storeID) {
await this.#restoreStoreID();
if (!this.#groupToolkitProfile) {
// If we were unable to find a matching toolkit profile then assume the
// store ID is bogus so clear it and uninit.
Services.prefs.clearUserPref("toolkit.profiles.storeID");
await this.uninit();
return;
}
}
// When we launch into the startup window, the `ProfD` is not defined so
// getting the directory 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(
SelectableProfileServiceClass.getDirectory("ProfD")
);
} catch {}
// The 'activate' event listeners use #currentProfile, so this line has
// to come after #currentProfile has been set.
this.initWindowTracker();
Services.obs.addObserver(
this.themeObserver,
"lightweight-theme-styling-update"
);
this.#initialized = true;
// this.#currentProfile is unset in the case that the database has only just been created. We
// don't need to import from the database in this case.
if (this.#currentProfile) {
// Assume that settings in the database may have changed while we weren't running.
await this.databaseChanged("startup");
}
}
async uninit() {
if (!this.#initialized) {
return;
}
lazy.AsyncShutdown.profileChangeTeardown.removeBlocker(
this.#asyncShutdownBlocker
);
lazy.EveryWindow.unregisterCallback(this.#everyWindowCallbackId);
try {
Services.obs.removeObserver(
this.themeObserver,
"lightweight-theme-styling-update"
);
} catch (e) {}
for (let prefName of this.#observedPrefs) {
Services.prefs.removeObserver(prefName, this.prefObserver);
}
this.#observedPrefs.clear();
// 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();
await this.#notifyRunningInstances();
}
await this.closeConnection();
this.#currentProfile = null;
this.#groupToolkitProfile = null;
this.#storeID = null;
this.#badge = null;
this.#initialized = false;
}
initWindowTracker() {
lazy.EveryWindow.registerCallback(
this.#everyWindowCallbackId,
window => {
if (this.#badge && "nsIWinTaskbar" in Ci) {
let iconController = Cc["@mozilla.org/windows-taskbar;1"]
.getService(Ci.nsIWinTaskbar)
.getOverlayIconController(window.docShell);
iconController.setOverlayIcon(
this.#badge.image,
this.#badge.description,
this.#badge.iconPaintContext
);
}
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");
await this.createProfilesDBTables();
}
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 #restoreStoreID() {
try {
// Finds the first nsIToolkitProfile that matches the path of a
// SelectableProfile in the database.
for (let profile of await this.getAllProfiles()) {
let groupProfile = this.#profileService.getProfileByDir(
await profile.rootDir
);
if (groupProfile && !groupProfile.storeID) {
groupProfile.storeID = this.storeID;
await this.#profileService.asyncFlush();
this.#groupToolkitProfile = groupProfile;
return;
}
}
} catch (e) {
console.error(e);
}
}
async handleEvent(event) {
switch (event.type) {
case "activate": {
this.setDefaultProfileForGroup();
break;
}
}
}
/**
* Flushes the value of a preference to the database.
*
* @param {string} prefName the name of the preference.
*/
async flushSharedPrefToDatabase(prefName) {
if (!this.#observedPrefs.has(prefName)) {
Services.prefs.addObserver(prefName, this.prefObserver);
this.#observedPrefs.add(prefName);
}
if (!Services.prefs.prefHasUserValue(prefName)) {
await this.#deleteDBPref(prefName);
return;
}
let value;
switch (Services.prefs.getPrefType(prefName)) {
case Ci.nsIPrefBranch.PREF_BOOL:
value = Services.prefs.getBoolPref(prefName);
break;
case Ci.nsIPrefBranch.PREF_INT:
value = Services.prefs.getIntPref(prefName);
break;
case Ci.nsIPrefBranch.PREF_STRING:
value = Services.prefs.getCharPref(prefName);
break;
}
await this.#setDBPref(prefName, value);
}
/**
* 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,
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);
});
}
/**
* 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 ((await this.getAllProfiles()).length) {
return;
}
this.#groupToolkitProfile.storeID = null;
this.#storeID = null;
await this.#attemptFlushProfileService();
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 = SelectableProfileServiceClass.getDirectory("XREExeF");
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);
} else {
args.push(`--${COMMAND_LINE_ACTIVATE}`);
}
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.
*/
async #notifyRunningInstances() {
let remoteService = Cc["@mozilla.org/remote;1"].getService(
Ci.nsIRemoteService
);
let profiles = await this.getAllProfiles();
for (let profile of profiles) {
// The current profile was notified above.
if (profile.id === this.#currentProfile?.id) {
continue;
}
try {
remoteService.sendCommandLine(
profile.path,
[`--${COMMAND_LINE_UPDATE}`],
false
);
} catch (e) {
// This is expected to fail if no instance is running with the profile.
}
}
}
async #updateTaskbar() {
try {
if (!gSupportsBadging) {
return;
}
let count = await this.getProfileCount();
if (count > 1 && !this.#badge) {
this.#badge = {
image: await loadImage(
Services.io.newURI(
this.#currentProfile.avatar
}.svg`
)
),
iconPaintContext: this.#currentProfile.iconPaintContext,
description: this.#currentProfile.name,
};
if ("nsIMacDockSupport" in Ci) {
Cc["@mozilla.org/widget/macdocksupport;1"]
.getService(Ci.nsIMacDockSupport)
.setBadgeImage(this.#badge.image, this.#badge.iconPaintContext);
} else if ("nsIWinTaskbar" in Ci) {
for (let win of lazy.EveryWindow.readyWindows) {
let iconController = Cc["@mozilla.org/windows-taskbar;1"]
.getService(Ci.nsIWinTaskbar)
.getOverlayIconController(win.docShell);
iconController.setOverlayIcon(
this.#badge.image,
this.#badge.description,
this.#badge.iconPaintContext
);
}
}
} else if (count <= 1 && this.#badge) {
this.#badge = null;
if ("nsIMacDockSupport" in Ci) {
Cc["@mozilla.org/widget/macdocksupport;1"]
.getService(Ci.nsIMacDockSupport)
.setBadgeImage(null);
} else if ("nsIWinTaskbar" in Ci) {
for (let win of lazy.EveryWindow.readyWindows) {
let iconController = Cc["@mozilla.org/windows-taskbar;1"]
.getService(Ci.nsIWinTaskbar)
.getOverlayIconController(win.docShell);
iconController.setOverlayIcon(null, null);
}
}
}
} catch (e) {
console.error(e);
}
}
/**
* Invoked when changes have been made to the database. Sends the observer
* notification "sps-profiles-updated" indicating that something has changed.
*
* @param {"local"|"remote"|"startup"} source The source of the notification.
* Either "local" meaning that the change was made in this process, "remote"
* meaning the change was made by a different Firefox instance or "startup"
* meaning the application has just launched and we may need to reload
* changes from the database.
*/
async databaseChanged(source) {
if (source != "local") {
await this.loadSharedPrefsFromDatabase();
}
await this.#updateTaskbar();
if (source != "startup") {
Services.obs.notifyObservers(null, "sps-profiles-updated", source);
}
}
/**
* The observer function that watches for theme changes and updates the
* current profile of a theme change.
*
* @param {object} aSubject The theme data
* @param {string} aTopic Should be "lightweight-theme-styling-update"
*/
themeObserver(aSubject, aTopic) {
if (aTopic !== "lightweight-theme-styling-update") {
return;
}
let data = aSubject.wrappedJSObject;
if (!data.theme) {
// During startup the theme might be null so just return
return;
}
let isDark = Services.appinfo.chromeColorSchemeIsDark;
let theme = isDark && !!data.darkTheme ? data.darkTheme : data.theme;
let themeFg = theme.textcolor;
let themeBg = theme.toolbarColor;
if (!themeFg || !themeBg) {
// TODO Bug 1927193: The colors defined below are from the light and
// dark theme manifest files and they are not accurate for the default
// theme. We should read the color values from the document to get the
// correct colors.
const defaultDarkText = "rgb(255,255,255)"; // dark theme "tab_text"
const defaultLightText = "rgb(21,20,26)"; // light theme "tab_text"
const defaultDarkToolbar = "rgb(43,42,51)"; // dark theme "toolbar"
const defaultLightToolbar = "#f9f9fb"; // light theme "toolbar"
themeFg = isDark ? defaultDarkText : defaultLightText;
themeBg = isDark ? defaultDarkToolbar : defaultLightToolbar;
}
this.currentProfile.theme = {
themeId: theme.id,
themeFg,
themeBg,
};
}
/**
* Fetch all prefs from the DB and write to the current instance.
*/
async loadSharedPrefsFromDatabase() {
// This stops us from observing the change during the load and means we stop observing any prefs
// no longer in the database.
for (let prefName of this.#observedPrefs) {
Services.prefs.removeObserver(prefName, this.prefObserver);
}
this.#observedPrefs.clear();
for (let { name, value, type } of await this.getAllDBPrefs()) {
if (SelectableProfileServiceClass.ignoredSharedPrefs.includes(name)) {
continue;
}
if (value === null) {
Services.prefs.clearUserPref(name);
} else {
switch (type) {
case "boolean":
Services.prefs.setBoolPref(name, value);
break;
case "string":
Services.prefs.setCharPref(name, value);
break;
case "number":
Services.prefs.setIntPref(name, value);
break;
case "null":
Services.prefs.clearUserPref(name);
break;
}
}
Services.prefs.addObserver(name, this.prefObserver);
this.#observedPrefs.add(name);
}
}
/**
* Update the default profile by setting the selectable profile's path
* as the path of the nsToolkitProfile for the group. Defaults to the current
* selectable profile.
*
* @param {SelectableProfile} aProfile The SelectableProfile to be
* set as the default.
*/
async setDefaultProfileForGroup(aProfile = this.currentProfile) {
if (!aProfile || this.#groupToolkitProfile.rootDir.path === aProfile.path) {
return;
}
this.#groupToolkitProfile.rootDir = await aProfile.rootDir;
await this.#attemptFlushProfileService();
}
/**
* 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 this.#attemptFlushProfileService();
}
// 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 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(
SelectableProfileServiceClass.getDirectory("DefProfRt").path,
profileDir
)
),
IOUtils.makeDirectory(
PathUtils.join(
SelectableProfileServiceClass.getDirectory("DefProfLRt").path,
profileDir
)
),
]);
return IOUtils.getDirectory(
PathUtils.join(
SelectableProfileServiceClass.getDirectory("DefProfRt").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",
0o600
);
const sharedPrefs = await this.getAllDBPrefs();
const LINEBREAK = AppConstants.platform === "win" ? "\r\n" : "\n";
const prefsJs = [
"// 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,
'user_pref("browser.profiles.profile-name.updated", false);',
];
for (let pref of sharedPrefs) {
prefsJs.push(
`user_pref("${pref.name}", ${
pref.type === "string" ? `"${pref.value}"` : `${pref.value}`
});`
);
}
for (let prefName of SelectableProfileServiceClass.profileInitialPrefs) {
let value;
switch (Services.prefs.getPrefType(prefName)) {
case Ci.nsIPrefBranch.PREF_STRING:
value = `"${Services.prefs.getCharPref(prefName)}"`;
break;
case Ci.nsIPrefBranch.PREF_BOOL:
value = Services.prefs.getBoolPref(prefName);
break;
case Ci.nsIPrefBranch.PREF_INT:
value = Services.prefs.getIntPref(prefName);
break;
}
prefsJs.push(`user_pref("${prefName}", ${value});`);
}
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(
SelectableProfileServiceClass.getDirectory("UAppData")
);
if (AppConstants.platform === "win") {
relativePath = relativePath.replaceAll("/", "\\");
}
return relativePath;
}
/**
* Create a Selectable Profile and add to the datastore.
*
* If path is not included, new profile directories will be created.
*
* @param {nsIFile} existingProfilePath Optional. The path of an existing profile.
*
* @returns {SelectableProfile} The newly created profile object.
*/
async #createProfile(existingProfilePath) {
let nextProfileNumber = Math.max(
0,
...(await this.getAllProfiles()).map(p => p.id)
);
let [defaultName, originalName] =
lazy.profilesLocalization.formatMessagesSync([
{ id: "default-profile-name", args: { number: nextProfileNumber } },
{ id: "original-profile-name" },
]);
let window = Services.wm.getMostRecentBrowserWindow();
let isDark = window?.matchMedia("(-moz-system-dark-theme)").matches;
let randomIndex = Math.floor(Math.random() * this.#defaultAvatars.length);
let profileData = {
// The original toolkit profile is added first and is assigned a
// different name.
name: nextProfileNumber == 0 ? originalName.value : defaultName.value,
avatar: this.#defaultAvatars[randomIndex],
themeId: "default-theme@mozilla.org",
themeFg: isDark ? "rgb(255,255,255)" : "rgb(21,20,26)",
themeBg: isDark ? "rgb(28, 27, 34)" : "rgb(240, 240, 244)",
};
let path =
existingProfilePath || (await this.createProfileDirs(profileData.name));
if (!existingProfilePath) {
await this.createProfileInitialFiles(path);
}
profileData.path = this.getRelativeProfilePath(path);
let profile = await this.insertProfile(profileData);
return profile;
}
/**
* If the user has never created a SelectableProfile before, the group
* datastore will be created and the currently running toolkit profile will
* be added to the datastore.
*/
async maybeSetupDataStore() {
if (this.#connection) {
return;
}
// 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();
// Flush our shared prefs into the database.
for (let prefName of SelectableProfileServiceClass.initialSharedPrefs) {
await this.flushSharedPrefToDatabase(prefName);
}
// If this is the first time the user has created a selectable profile,
// add the current toolkit profile to the datastore.
if (!this.#currentProfile) {
let path = this.#profileService.currentProfile.rootDir;
this.#currentProfile = await this.#createProfile(path);
}
}
/**
* Create and launch a new SelectableProfile and add it to the group datastore.
* This is an unmanaged profile from the nsToolkitProfile perspective.
*
* If the user has never created a SelectableProfile before, the group
* datastore will be lazily created and the currently running toolkit profile
* will be added to the datastore along with the newly created profile.
*
* Launches the new SelectableProfile in a new instance after creating it.
*/
async createNewProfile() {
await this.maybeSetupDataStore();
let profile = await this.#createProfile();
this.launchInstance(profile, "about:newprofile");
}
/**
* Add a profile to the profile group datastore.
*
* This function assumes the service is initialized and the datastore has
* been created.
*
* @param {object} profileData A plain object that contains a name, avatar,
* themeId, themeFg, themeBg, and relative path as string.
*
* @returns {SelectableProfile} The newly created profile object.
*/
async insertProfile(profileData) {
// Verify all fields are present.
let keys = ["avatar", "name", "path", "themeBg", "themeFg", "themeId"];
let missing = [];
keys.forEach(key => {
if (!(key in profileData)) {
missing.push(key);
}
});
if (missing.length) {
throw new Error(
"Unable to insertProfile due to missing keys: ",
missing.join(",")
);
}
await this.#connection.execute(
`INSERT INTO Profiles VALUES (NULL, :path, :name, :avatar, :themeId, :themeFg, :themeBg);`,
profileData
);
this.#notifyTask.arm();
return this.getProfileByName(profileData.name);
}
async deleteProfile(aProfile) {
if (aProfile.id == this.currentProfile.id) {
throw new Error(
"Use `deleteCurrentProfile` to delete the current profile."
);
}
// First attempt to remove the profile's directories. This will attempt to
// local the directories and so will throw an exception if the profile is
// currently in use.
await this.#profileService.removeProfileFilesByPath(
await aProfile.rootDir,
null,
0
);
// Then we can remove from the database.
await this.#connection.execute("DELETE FROM Profiles WHERE id = :id;", {
id: aProfile.id,
});
this.#notifyTask.arm();
}
/**
* Close all active instances running the current profile
*/
closeActiveProfileInstances() {}
/**
* Schedule deletion of the current SelectableProfile as a background task.
*/
async deleteCurrentProfile() {
let profiles = await this.getAllProfiles();
// Refuse to delete the last profile.
if (profiles.length <= 1) {
return;
}
// TODO: (Bug 1923980) How should we choose the new default profile?
let newDefault = profiles.find(p => p.id !== this.currentProfile.id);
await this.setDefaultProfileForGroup(newDefault);
await this.#connection.executeBeforeShutdown(
"SelectableProfileService: deleteCurrentProfile",
db =>
db.execute("DELETE FROM Profiles WHERE id = :id;", {
id: this.currentProfile.id,
})
);
if (AppConstants.MOZ_BACKGROUNDTASKS) {
// Schedule deletion of the profile directories.
const runner = Cc["@mozilla.org/backgroundtasksrunner;1"].getService(
Ci.nsIBackgroundTasksRunner
);
let rootDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
let localDir = Services.dirsvc.get("ProfLD", Ci.nsIFile);
runner.runInDetachedProcess("removeProfileFiles", [
rootDir.path,
localDir.path,
180,
]);
}
}
/**
* 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, themeId = :themeId, themeFg = :themeFg, themeBg = :themeBg
WHERE id = :id;`,
profileObj
);
if (aSelectableProfile.id == this.#currentProfile.id) {
// Force a rebuild of the taskbar icon.
this.#badge = null;
this.#currentProfile = aSelectableProfile;
}
this.#notifyTask.arm();
}
/**
* Get the complete list of profiles in the group.
*
* @returns {Array<SelectableProfile>}
* An array of profiles in the group.
*/
async getAllProfiles() {
if (!this.#connection) {
return [];
}
return (await this.#connection.executeCached("SELECT * FROM Profiles;"))
.map(row => {
return new SelectableProfile(row, this);
})
.sort((p1, p2) => p1.name.localeCompare(p2.name));
}
/**
* Get the number of profiles in the group.
*
* @returns {number}
* The number of profiles in the group.
*/
async getProfileCount() {
if (!this.#connection) {
return 0;
}
let rows = await this.#connection.executeCached(
'SELECT COUNT(*) AS "count" FROM "Profiles";'
);
return rows[0]?.getResultByName("count") ?? 0;
}
/**
* 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) {
if (!this.#connection) {
return null;
}
let row = (
await this.#connection.executeCached(
"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} aProfileName The name of the profile
* @returns {SelectableProfile}
* The specific profile.
*/
async getProfileByName(aProfileName) {
if (!this.#connection) {
return null;
}
let row = (
await this.#connection.execute(
"SELECT * FROM Profiles WHERE name = :name;",
{
name: aProfileName,
}
)
)[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) {
if (!this.#connection) {
return null;
}
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 getAllDBPrefs() {
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 from the database.
*
* @param {string} aPrefName The name of the pref to get
*
* @returns {any} Value of the pref
*/
async getDBPref(aPrefName) {
let rows = await this.#connection.execute(
"SELECT value, isBoolean FROM SharedPrefs WHERE name = :name;",
{
name: aPrefName,
}
);
if (!rows.length) {
throw new Error(`Unknown preference '${aPrefName}'`);
}
return this.getPrefValueFromRow(rows[0]);
}
/**
* Insert or update a pref value in the database, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref
* @param {any} aPrefValue The value of the pref
*/
async #setDBPref(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",
}
);
this.#notifyTask.arm();
}
// Starts tracking a new shared pref across the profiles.
async trackPref(aPrefName) {
await this.flushSharedPrefToDatabase(aPrefName);
}
/**
* Remove a shared pref from the database, then notify() other running instances.
*
* @param {string} aPrefName The name of the pref to delete
*/
async #deleteDBPref(aPrefName) {
// We mark the value as null if it already exists in the database so other profiles know what
// preference to remove.
await this.#connection.executeCached(
"UPDATE SharedPrefs SET value=NULL, isBoolean=FALSE WHERE name=:name;",
{
name: aPrefName,
}
);
this.#notifyTask.arm();
}
// 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 };
/**
* A command line handler for receiving notifications from other instances that
* the profiles database has been updated.
*/
export class CommandLineHandler {
static classID = Components.ID("{38971986-c834-4f52-bf17-5123fbc9dde5}");
static contractID = "@mozilla.org/browser/selectable-profiles-service-clh;1";
QueryInterface = ChromeUtils.generateQI([Ci.nsICommandLineHandler]);
handle(cmdLine) {
// This is only ever sent when the application is already running.
if (cmdLine.handleFlag(COMMAND_LINE_UPDATE, true)) {
if (SelectableProfileService.initialized) {
SelectableProfileService.databaseChanged("remote").catch(console.error);
}
cmdLine.preventDefault = true;
return;
}
// Sent from the profiles UI to launch a profile if it doesn't exist or bring it to the front
// if it is already running. In the case where this instance is already running we want to block
// the normal action of opening a new empty window and instead raise the application to the
// front manually.
if (
cmdLine.handleFlag(COMMAND_LINE_ACTIVATE, true) &&
cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH
) {
let win = Services.wm.getMostRecentWindow(null);
if (win) {
win.focus();
cmdLine.preventDefault = true;
}
}
}
}