/* 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, { 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", 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( `chrome://browser/content/profiles/assets/48_${ 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} * 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; } } } }