Bug 1953884 made the storeID pref always set and used the presence of storeID in profiles.ini to determine whether profiles had previously been created. However it missed the startup code in nsToolkitProfileService which always sets the storeID in profiles.ini if there is one set in prefs. This means that users who had never used the profiles feature had the profiles created pref set to true but no entry for the current profile was added to the database. This causes bustage when we attempt to display various parts of the UI. This adds a check to a point in startup when we know we should have found an entry for the current profile in the database. If one doesn't exist we either create one if there are other profiles in the database or we reset the prefs and profiles.ini to what they should look like if no profiles have ever been created. This should also resolve bug 1943559. Differential Revision: https://phabricator.services.mozilla.com/D246775
1538 lines
46 KiB
JavaScript
1538 lines
46 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
|
|
import { DeferredTask } from "resource://gre/modules/DeferredTask.sys.mjs";
|
|
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
|
|
import { ProfilesDatastoreService } from "resource:///modules/profiles/ProfilesDatastoreService.sys.mjs";
|
|
import { SelectableProfile } from "resource:///modules/profiles/SelectableProfile.sys.mjs";
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
// This is used to keep the icon controllers alive for as long as their windows are alive.
|
|
const TASKBAR_ICON_CONTROLLERS = new WeakMap();
|
|
const PROFILES_PREF_NAME = "browser.profiles.enabled";
|
|
const GROUPID_PREF_NAME = "toolkit.telemetry.cachedProfileGroupID";
|
|
const DEFAULT_THEME_ID = "default-theme@mozilla.org";
|
|
const PROFILES_CREATED_PREF_NAME = "browser.profiles.created";
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ClientID: "resource://gre/modules/ClientID.sys.mjs",
|
|
CryptoUtils: "resource://services-crypto/utils.sys.mjs",
|
|
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
|
|
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
|
|
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
|
|
setTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "profilesLocalization", () => {
|
|
return new Localization(["browser/profiles.ftl"], true);
|
|
});
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"PROFILES_ENABLED",
|
|
PROFILES_PREF_NAME,
|
|
false,
|
|
() => SelectableProfileService.updateEnabledState()
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"PROFILES_CREATED",
|
|
PROFILES_CREATED_PREF_NAME,
|
|
false
|
|
);
|
|
|
|
const PROFILES_CRYPTO_SALT_LENGTH_BYTES = 16;
|
|
|
|
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 extends EventEmitter {
|
|
#profileService = null;
|
|
#connection = null;
|
|
#initialized = false;
|
|
#groupToolkitProfile = null;
|
|
#storeID = null;
|
|
#currentProfile = null;
|
|
#everyWindowCallbackId = "SelectableProfileService";
|
|
#defaultAvatars = [
|
|
"book",
|
|
"briefcase",
|
|
"flower",
|
|
"heart",
|
|
"shopping",
|
|
"star",
|
|
];
|
|
#initPromise = null;
|
|
#observedPrefs = null;
|
|
#badge = null;
|
|
#windowActivated = null;
|
|
#isEnabled = false;
|
|
|
|
// The preferences that must be permanently stored in the database and kept
|
|
// consistent amongst profiles.
|
|
static permanentSharedPrefs = [
|
|
"app.shield.optoutstudies.enabled",
|
|
"browser.crashReports.unsubmittedCheck.autoSubmit2",
|
|
"browser.discovery.enabled",
|
|
"browser.urlbar.quicksuggest.dataCollection.enabled",
|
|
"datareporting.healthreport.uploadEnabled",
|
|
"datareporting.policy.currentPolicyVersion",
|
|
"datareporting.policy.dataSubmissionEnabled",
|
|
"datareporting.policy.dataSubmissionPolicyAcceptedVersion",
|
|
"datareporting.policy.dataSubmissionPolicyBypassNotification",
|
|
"datareporting.policy.dataSubmissionPolicyNotifiedTime",
|
|
"datareporting.policy.minimumPolicyVersion",
|
|
"datareporting.policy.minimumPolicyVersion.channel-beta",
|
|
"datareporting.usage.uploadEnabled",
|
|
GROUPID_PREF_NAME,
|
|
];
|
|
|
|
// Preferences that were previously shared but should now be ignored.
|
|
static ignoredSharedPrefs = [
|
|
"browser.profiles.enabled",
|
|
"toolkit.profiles.storeID",
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.onNimbusUpdate = this.onNimbusUpdate.bind(this);
|
|
this.themeObserver = this.themeObserver.bind(this);
|
|
this.matchMediaObserver = this.matchMediaObserver.bind(this);
|
|
this.prefObserver = (subject, topic, prefName) =>
|
|
this.flushSharedPrefToDatabase(prefName);
|
|
|
|
this.#observedPrefs = new Set();
|
|
|
|
this.#isEnabled = this.#getEnabledState();
|
|
|
|
// We have to check the state again after the policy service may have disabled us.
|
|
Services.obs.addObserver(
|
|
() => this.updateEnabledState(),
|
|
"profile-after-change"
|
|
);
|
|
}
|
|
|
|
// Migrate any early users who created profiles before the datastore service
|
|
// was split out, and the PROFILES_CREATED pref replaced storeID as our check
|
|
// for whether the profiles feature had been used.
|
|
migrateToProfilesCreatedPref() {
|
|
if (this.groupToolkitProfile?.storeID && !lazy.PROFILES_CREATED) {
|
|
Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, true);
|
|
}
|
|
}
|
|
|
|
#getEnabledState() {
|
|
if (!Services.policies.isAllowed("profileManagement")) {
|
|
return false;
|
|
}
|
|
|
|
this.migrateToProfilesCreatedPref();
|
|
|
|
// If a storeID has been assigned then profiles may have been created so force us on. Also
|
|
// covers the case when the selector is shown at startup and we don't have preferences
|
|
// available.
|
|
if (this.groupToolkitProfile?.storeID) {
|
|
return true;
|
|
}
|
|
|
|
return lazy.PROFILES_ENABLED && !!this.#groupToolkitProfile;
|
|
}
|
|
|
|
updateEnabledState() {
|
|
let newState = this.#getEnabledState();
|
|
if (newState != this.#isEnabled) {
|
|
this.#isEnabled = newState;
|
|
this.emit("enableChanged", newState);
|
|
}
|
|
}
|
|
|
|
get isEnabled() {
|
|
return this.#isEnabled;
|
|
}
|
|
|
|
async #attemptFlushProfileService() {
|
|
try {
|
|
await this.#profileService.asyncFlush();
|
|
} catch (e) {
|
|
try {
|
|
await this.#profileService.asyncFlushGroupProfile();
|
|
} 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;
|
|
}
|
|
|
|
async initProfilesData() {
|
|
if (lazy.PROFILES_CREATED) {
|
|
return;
|
|
}
|
|
|
|
if (!this.#groupToolkitProfile) {
|
|
throw new Error("Cannot create a store without a group profile.");
|
|
}
|
|
|
|
Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, true);
|
|
|
|
let storeID = await ProfilesDatastoreService.storeID;
|
|
|
|
this.#groupToolkitProfile.storeID = storeID;
|
|
this.#storeID = storeID;
|
|
await this.#attemptFlushProfileService();
|
|
}
|
|
|
|
onNimbusUpdate() {
|
|
if (lazy.NimbusFeatures.selectableProfiles.getVariable("enabled")) {
|
|
Services.prefs.setBoolPref(PROFILES_PREF_NAME, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* At startup, store the nsToolkitProfile for the group.
|
|
* Get the groupDBPath from the nsToolkitProfile, and connect to it.
|
|
*
|
|
* @param {boolean} isInitial true if this is an init prior to creating a new profile.
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
init(isInitial = false) {
|
|
if (!this.#initPromise) {
|
|
this.#initPromise = this.#init(isInitial).finally(
|
|
() => (this.#initPromise = null)
|
|
);
|
|
}
|
|
|
|
return this.#initPromise;
|
|
}
|
|
|
|
async #init(isInitial = false) {
|
|
if (this.#initialized) {
|
|
return;
|
|
}
|
|
|
|
lazy.NimbusFeatures.selectableProfiles.onUpdate(this.onNimbusUpdate);
|
|
|
|
this.#profileService = ProfilesDatastoreService.toolkitProfileService;
|
|
|
|
this.#groupToolkitProfile =
|
|
this.#profileService.currentProfile ?? this.#profileService.groupProfile;
|
|
this.#storeID = await ProfilesDatastoreService.storeID;
|
|
|
|
this.updateEnabledState();
|
|
if (!this.isEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (!lazy.PROFILES_CREATED) {
|
|
return;
|
|
}
|
|
|
|
this.#connection = await ProfilesDatastoreService.getConnection();
|
|
if (!this.#connection) {
|
|
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.
|
|
// The current profile will be null now that we are eagerly initing the db.
|
|
try {
|
|
// Get the SelectableProfile by the profile directory
|
|
this.#currentProfile = await this.getProfileByPath(
|
|
ProfilesDatastoreService.constructor.getDirectory("ProfD")
|
|
);
|
|
} catch {}
|
|
|
|
// If this isn't the first init prior to creating the first new profile and
|
|
// the app is started up we should have found a current profile.
|
|
if (!isInitial && !Services.startup.startingUp && !this.#currentProfile) {
|
|
let count = await this.getProfileCount();
|
|
|
|
if (count) {
|
|
// There are other profiles, re-create the current profile.
|
|
this.#currentProfile = await this.#createProfile(
|
|
ProfilesDatastoreService.constructor.getDirectory("ProfD")
|
|
);
|
|
} else {
|
|
// No other profiles. Reset our state.
|
|
this.#groupToolkitProfile.storeID = null;
|
|
this.#attemptFlushProfileService();
|
|
Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, false);
|
|
|
|
this.#connection = null;
|
|
this.updateEnabledState();
|
|
|
|
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 overwriting this.#groupToolkitProfile.storeID
|
|
// with the current storeID.
|
|
if (this.#groupToolkitProfile.storeID != this.storeID) {
|
|
this.#groupToolkitProfile.storeID = this.storeID;
|
|
this.#attemptFlushProfileService();
|
|
}
|
|
|
|
// On macOS when other applications request we open a url the most recent
|
|
// window becomes activated first. This would cause the default profile to
|
|
// change before we determine which profile to open the url in. By
|
|
// introducing a small delay we can process the urls before changing the
|
|
// default profile.
|
|
this.#windowActivated = new DeferredTask(
|
|
async () => this.setDefaultProfileForGroup(),
|
|
500
|
|
);
|
|
|
|
// The 'activate' event listeners use #currentProfile, so this line has
|
|
// to come after #currentProfile has been set.
|
|
this.initWindowTracker();
|
|
|
|
// We must also set the current profile as default during startup.
|
|
this.setDefaultProfileForGroup();
|
|
|
|
Services.obs.addObserver(
|
|
this.themeObserver,
|
|
"lightweight-theme-styling-update"
|
|
);
|
|
|
|
let window = Services.wm.getMostRecentBrowserWindow();
|
|
let prefersDarkQuery = window?.matchMedia("(prefers-color-scheme: dark)");
|
|
prefersDarkQuery?.addEventListener("change", this.matchMediaObserver);
|
|
|
|
Services.obs.addObserver(this, "pds-datastore-changed");
|
|
|
|
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;
|
|
}
|
|
|
|
try {
|
|
Services.obs.removeObserver(this, "lightweight-theme-styling-update");
|
|
} catch (e) {}
|
|
|
|
lazy.NimbusFeatures.selectableProfiles.offUpdate(this.onNimbusUpdate);
|
|
|
|
this.#currentProfile = null;
|
|
this.#groupToolkitProfile = null;
|
|
this.#badge = null;
|
|
this.#connection = null;
|
|
|
|
lazy.EveryWindow.unregisterCallback(this.#everyWindowCallbackId);
|
|
|
|
Services.obs.removeObserver(this, "pds-datastore-changed");
|
|
|
|
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);
|
|
TASKBAR_ICON_CONTROLLERS.set(window, iconController);
|
|
|
|
iconController.setOverlayIcon(
|
|
this.#badge.image,
|
|
this.#badge.description,
|
|
this.#badge.iconPaintContext
|
|
);
|
|
}
|
|
|
|
// Update the window title because the currentProfile, needed in the
|
|
// .*-with-profile titles, didn't exist when the title was initially set.
|
|
window.gBrowser.updateTitlebar();
|
|
|
|
let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
|
|
if (isPBM) {
|
|
return;
|
|
}
|
|
|
|
window.addEventListener("activate", this);
|
|
},
|
|
window => {
|
|
window.gBrowser.updateTitlebar();
|
|
|
|
let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
|
|
if (isPBM) {
|
|
return;
|
|
}
|
|
|
|
window.removeEventListener("activate", this);
|
|
}
|
|
);
|
|
}
|
|
|
|
async handleEvent(event) {
|
|
switch (event.type) {
|
|
case "activate": {
|
|
this.#windowActivated.arm();
|
|
if ("nsIWinTaskbar" in Ci && this.#badge) {
|
|
let iconController = TASKBAR_ICON_CONTROLLERS.get(event.target);
|
|
|
|
iconController?.setOverlayIcon(
|
|
this.#badge.image,
|
|
this.#badge.description,
|
|
this.#badge.iconPaintContext
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
observe(subject, topic, data) {
|
|
switch (topic) {
|
|
case "pds-datastore-changed": {
|
|
this.databaseChanged(data);
|
|
break;
|
|
}
|
|
case "lightweight-theme-styling-update": {
|
|
this.themeObserver(subject, topic);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When the last selectable profile in a group is deleted,
|
|
* also remove the profile group's named profile entry from profiles.ini
|
|
* and set the profiles created pref to false.
|
|
*/
|
|
async deleteProfileGroup() {
|
|
if ((await this.getAllProfiles()).length) {
|
|
return;
|
|
}
|
|
|
|
Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, false);
|
|
this.#groupToolkitProfile.storeID = null;
|
|
await this.#attemptFlushProfileService();
|
|
}
|
|
|
|
// App session lifecycle methods and multi-process support
|
|
|
|
/*
|
|
* Helper that executes a new Firefox process. Mostly useful for mocking in
|
|
* unit testing.
|
|
*/
|
|
execProcess(aArgs) {
|
|
let executable =
|
|
ProfilesDatastoreService.constructor.getDirectory("XREExeF");
|
|
|
|
if (AppConstants.platform == "macosx") {
|
|
// Use the application bundle if possible.
|
|
let appBundle = executable.parent.parent.parent;
|
|
if (appBundle.path.endsWith(".app")) {
|
|
executable = appBundle;
|
|
|
|
Cc["@mozilla.org/widget/macdocksupport;1"]
|
|
.getService(Ci.nsIMacDockSupport)
|
|
.launchAppBundle(appBundle, aArgs, { addsToRecentItems: false });
|
|
return;
|
|
}
|
|
}
|
|
|
|
let process = Cc["@mozilla.org/process/util;1"].createInstance(
|
|
Ci.nsIProcess
|
|
);
|
|
process.init(executable);
|
|
process.runw(false, aArgs, aArgs.length);
|
|
}
|
|
|
|
/**
|
|
* Launch a new Firefox instance using the given selectable profile.
|
|
*
|
|
* @param {SelectableProfile} aProfile The profile to launch
|
|
* @param {string} aUrl A url to open in launched profile
|
|
*/
|
|
launchInstance(aProfile, aUrl) {
|
|
let args = ["--profile", aProfile.path];
|
|
if (Services.appinfo.OS === "Darwin") {
|
|
args.unshift("-foreground");
|
|
}
|
|
|
|
if (aUrl) {
|
|
args.push("-url", aUrl);
|
|
} else {
|
|
args.push(`--${COMMAND_LINE_ACTIVATE}`);
|
|
}
|
|
|
|
this.execProcess(args);
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// We don't want the startup profile selector to badge the dock icon.
|
|
if (!gSupportsBadging || Services.startup.startingUp) {
|
|
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);
|
|
TASKBAR_ICON_CONTROLLERS.set(win, iconController);
|
|
|
|
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 = TASKBAR_ICON_CONTROLLERS.get(win);
|
|
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"|"shutdown"} 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, "startup" meaning the application has just launched and we may
|
|
* need to reload changes from the database, or "shutdown" meaning we are
|
|
* closing the connection and shutting down.
|
|
*/
|
|
async databaseChanged(source) {
|
|
if (source === "local" || source === "shutdown") {
|
|
this.#notifyRunningInstances();
|
|
}
|
|
|
|
if (source === "shutdown") {
|
|
return;
|
|
}
|
|
|
|
if (source != "local") {
|
|
await this.loadSharedPrefsFromDatabase();
|
|
}
|
|
|
|
await this.#updateTaskbar();
|
|
|
|
if (source != "startup") {
|
|
Services.obs.notifyObservers(null, "sps-profiles-updated", source);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The default theme uses `light-dark` color function which doesn't apply
|
|
* correctly to the taskbar avatar icon. We use `InspectorUtils.colorToRGBA`
|
|
* to get the current rgba values for a theme. This way the color values can
|
|
* be correctly applied to the taskbar avatar icon.
|
|
*
|
|
* @returns {object}
|
|
* themeBg {string}: the background color in rgba(r, g, b, a) format
|
|
* themeFg {string}: the foreground color in rgba(r, g, b, a) format
|
|
*/
|
|
getColorsForDefaultTheme() {
|
|
let window = Services.wm.getMostRecentBrowserWindow();
|
|
// The computedStyles object is a live CSSStyleDeclaration.
|
|
let computedStyles = window.getComputedStyle(
|
|
window.document.documentElement
|
|
);
|
|
|
|
let themeFgColor = computedStyles.getPropertyValue("--toolbar-color");
|
|
let themeBgColor = computedStyles.getPropertyValue("--toolbar-bgcolor");
|
|
|
|
let bg = InspectorUtils.colorToRGBA(themeBgColor, window.document);
|
|
let themeBg = `rgba(${bg.r}, ${bg.r}, ${bg.b}, ${bg.a})`;
|
|
|
|
let fg = InspectorUtils.colorToRGBA(themeFgColor, window.document);
|
|
let themeFg = `rgba(${fg.r}, ${fg.g}, ${fg.b}, ${fg.a})`;
|
|
|
|
return { themeBg, themeFg };
|
|
}
|
|
|
|
/**
|
|
* 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 window = Services.wm.getMostRecentBrowserWindow();
|
|
let isDark = window.matchMedia("(-moz-system-dark-theme)").matches;
|
|
|
|
let theme = isDark && !!data.darkTheme ? data.darkTheme : data.theme;
|
|
|
|
let themeFg = theme.toolbar_text;
|
|
let themeBg = theme.toolbarColor;
|
|
|
|
if (theme.id === DEFAULT_THEME_ID || !themeFg || !themeBg) {
|
|
window.addEventListener(
|
|
"windowlwthemeupdate",
|
|
() => {
|
|
({ themeBg, themeFg } = this.getColorsForDefaultTheme());
|
|
|
|
this.currentProfile.theme = {
|
|
themeId: theme.id,
|
|
themeFg,
|
|
themeBg,
|
|
};
|
|
},
|
|
{
|
|
once: true,
|
|
}
|
|
);
|
|
} else {
|
|
this.currentProfile.theme = {
|
|
themeId: theme.id,
|
|
themeFg,
|
|
themeBg,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The observer function that watches for OS theme changes and updates the
|
|
* current profile of a theme change.
|
|
*/
|
|
matchMediaObserver() {
|
|
// If the current theme isn't the default theme, we can just return because
|
|
// we already got the theme colors from the theme change in `themeObserver`
|
|
if (this.currentProfile.theme.themeId !== DEFAULT_THEME_ID) {
|
|
return;
|
|
}
|
|
|
|
let { themeBg, themeFg } = this.getColorsForDefaultTheme();
|
|
|
|
this.currentProfile.theme = {
|
|
themeId: this.currentProfile.theme.themeId,
|
|
themeFg,
|
|
themeBg,
|
|
};
|
|
}
|
|
|
|
async flushAllSharedPrefsToDatabase() {
|
|
for (let prefName of SelectableProfileServiceClass.permanentSharedPrefs) {
|
|
await this.flushSharedPrefToDatabase(prefName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
!SelectableProfileServiceClass.permanentSharedPrefs.includes(prefName) &&
|
|
!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);
|
|
}
|
|
|
|
/**
|
|
* 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 the user has disabled then re-enabled data collection in another
|
|
// profile in the group, an extra step is needed to ensure each profile
|
|
// uses the same profile group ID.
|
|
if (
|
|
name === GROUPID_PREF_NAME &&
|
|
value !== lazy.TelemetryUtils.knownProfileGroupID &&
|
|
value !== Services.prefs.getCharPref(GROUPID_PREF_NAME)
|
|
) {
|
|
await lazy.ClientID.setProfileGroupID(value); // Sets the pref for us.
|
|
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) {
|
|
return;
|
|
}
|
|
this.#groupToolkitProfile.rootDir = await aProfile.rootDir;
|
|
Glean.profilesDefault.updated.record();
|
|
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 setShowProfileSelectorWindow(shouldShow) {
|
|
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(
|
|
ProfilesDatastoreService.constructor.getDirectory("DefProfRt").path,
|
|
profileDir
|
|
),
|
|
{
|
|
permissions: 0o700,
|
|
}
|
|
),
|
|
IOUtils.makeDirectory(
|
|
PathUtils.join(
|
|
ProfilesDatastoreService.constructor.getDirectory("DefProfLRt").path,
|
|
profileDir
|
|
),
|
|
{
|
|
permissions: 0o700,
|
|
}
|
|
),
|
|
]);
|
|
|
|
return IOUtils.getDirectory(
|
|
PathUtils.join(
|
|
ProfilesDatastoreService.constructor.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}`
|
|
});`
|
|
);
|
|
}
|
|
|
|
// Preferences that must be set in newly created profiles.
|
|
prefsJs.push(`user_pref("browser.profiles.enabled", true);`);
|
|
prefsJs.push(`user_pref("browser.profiles.created", true);`);
|
|
prefsJs.push(`user_pref("toolkit.profiles.storeID", "${this.storeID}");`);
|
|
|
|
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(
|
|
ProfilesDatastoreService.constructor.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_ID,
|
|
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 currently
|
|
* running toolkit profile will be added to the datastore and will finish
|
|
* initing the service for profiles.
|
|
*/
|
|
async maybeSetupDataStore() {
|
|
if (this.#connection) {
|
|
return;
|
|
}
|
|
|
|
await this.initProfilesData();
|
|
await this.init(true);
|
|
|
|
await this.flushAllSharedPrefsToDatabase();
|
|
|
|
// 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);
|
|
|
|
// And also set the profile selector window to show at startup (bug 1933911).
|
|
this.setShowProfileSelectorWindow(true);
|
|
|
|
// For first-run dark mode macOS users, the original profile's dock icon
|
|
// disappears after creating and launching an additional profile for the
|
|
// first time. Here we hack around this problem.
|
|
//
|
|
// Wait a full second, which seems to be enough time for the newly-
|
|
// launched second Firefox instance's dock animation to complete. Then
|
|
// trigger redrawing the original profile's badged icon (by setting the
|
|
// avatar to its current value, a no-op change which redraws the dock
|
|
// icon as a side effect).
|
|
//
|
|
// Shorter timeouts don't work, perhaps because they trigger the update
|
|
// before the dock bouncing animation completes for the other instance?
|
|
//
|
|
// We haven't figured out the lower-level bug that's causing this, but
|
|
// hope to someday find that better solution (bug 1952338).
|
|
if (Services.appinfo.OS === "Darwin") {
|
|
lazy.setTimeout(() => {
|
|
// To avoid displeasing the linter, assign to a temporary variable.
|
|
let avatar = SelectableProfileService.currentProfile.avatar;
|
|
SelectableProfileService.currentProfile.avatar = avatar;
|
|
}, 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
);
|
|
|
|
ProfilesDatastoreService.notify();
|
|
|
|
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,
|
|
});
|
|
|
|
ProfilesDatastoreService.notify();
|
|
}
|
|
|
|
/**
|
|
* Schedule deletion of the current SelectableProfile as a background task.
|
|
*/
|
|
async deleteCurrentProfile() {
|
|
let profiles = await this.getAllProfiles();
|
|
|
|
if (profiles.length <= 1) {
|
|
await this.createNewProfile();
|
|
await this.setShowProfileSelectorWindow(false);
|
|
|
|
profiles = await this.getAllProfiles();
|
|
}
|
|
|
|
// 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();
|
|
delete profileObj.avatarL10nId;
|
|
|
|
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;
|
|
}
|
|
|
|
ProfilesDatastoreService.notify();
|
|
}
|
|
|
|
/**
|
|
* 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 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.
|
|
*
|
|
* @param {boolean} [launchProfile=true] Whether or not this should launch
|
|
* the newly created profile.
|
|
*
|
|
* @returns {SelectableProfile} The profile just created.
|
|
*/
|
|
async createNewProfile(launchProfile = true) {
|
|
await this.maybeSetupDataStore();
|
|
|
|
let profile = await this.#createProfile();
|
|
if (launchProfile) {
|
|
this.launchInstance(profile, "about:newprofile");
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
})
|
|
.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) : 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) : 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) : 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",
|
|
}
|
|
);
|
|
|
|
ProfilesDatastoreService.notify();
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
);
|
|
|
|
ProfilesDatastoreService.notify();
|
|
}
|
|
}
|
|
|
|
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;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// On macOS requests to open URLs from other applications in an already running Firefox are
|
|
// passed directly to the running instance via the
|
|
// [MacApplicationDelegate::openURLs](https://searchfox.org/mozilla-central/rev/b0b003e992b199fd8e13999bd5d06d06c84a3fd2/toolkit/xre/MacApplicationDelegate.mm#323-326)
|
|
// API. This means it skips over the step in startup where we choose the correct profile to open
|
|
// the link in. Here we intercept such requests.
|
|
if (
|
|
cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_EXPLICIT &&
|
|
Services.appinfo.OS === "Darwin"
|
|
) {
|
|
// If we aren't enabled or initialized there can't be other profiles.
|
|
if (
|
|
!SelectableProfileService.isEnabled ||
|
|
!SelectableProfileService.initialized
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!cmdLine.length) {
|
|
return;
|
|
}
|
|
|
|
// Ideally here we would be able to find which profile we should load the link in, to do so we
|
|
// would need to load `profiles.ini` as we can't rely on the current in-memory state in
|
|
// `nsIToolkitProfileService`. But we have to handle the command line synchronously. So we
|
|
// just assume that the current profile may not be correct and re-launch Firefox with the
|
|
// command line and let the startup code figure out which profile to use. If necessary it will
|
|
// remote the command line back to this instance.
|
|
|
|
let args = ["-foreground"];
|
|
for (let i = 0; i < cmdLine.length; i++) {
|
|
args.push(cmdLine.getArgument(i));
|
|
}
|
|
|
|
cmdLine.removeArguments(0, cmdLine.length - 1);
|
|
cmdLine.preventDefault = true;
|
|
|
|
SelectableProfileService.execProcess(args);
|
|
}
|
|
}
|
|
}
|