diff --git a/browser/components/profiles/SelectableProfileService.sys.mjs b/browser/components/profiles/SelectableProfileService.sys.mjs index 206cb47b4099..db9df55faf32 100644 --- a/browser/components/profiles/SelectableProfileService.sys.mjs +++ b/browser/components/profiles/SelectableProfileService.sys.mjs @@ -91,6 +91,7 @@ class SelectableProfileServiceClass { #asyncShutdownBlocker = null; #initialized = false; #groupToolkitProfile = null; + #storeID = null; #currentProfile = null; #everyWindowCallbackId = "SelectableProfileService"; #defaultAvatars = [ @@ -101,6 +102,7 @@ class SelectableProfileServiceClass { "shopping", "star", ]; + #initPromise = null; static #dirSvc = null; constructor() { @@ -111,6 +113,23 @@ class SelectableProfileServiceClass { 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.#storeID) { + // 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. + this.#initPromise = this.restoreStoreID() + .catch(console.error) + .finally(() => (this.#initPromise = null)); + } + } } /** @@ -167,6 +186,10 @@ class SelectableProfileServiceClass { } } + get storeID() { + return this.#storeID; + } + get groupToolkitProfile() { return this.#groupToolkitProfile; } @@ -188,7 +211,7 @@ class SelectableProfileServiceClass { } async maybeCreateProfilesStorePath() { - if (!this.#groupToolkitProfile || this.#groupToolkitProfile.storeID) { + if (this.storeID) { return; } @@ -202,6 +225,7 @@ class SelectableProfileServiceClass { .replace("{", "") .split("-")[0]; this.#groupToolkitProfile.storeID = storageID; + this.#storeID = storageID; await this.#attemptFlushProfileService(); } @@ -210,22 +234,34 @@ class SelectableProfileServiceClass { return PathUtils.join( SelectableProfileServiceClass.PROFILE_GROUPS_DIR, - `${this.#groupToolkitProfile.storeID}.sqlite` + `${this.storeID}.sqlite` ); } /** * At startup, store the nsToolkitProfile for the group. * Get the groupDBPath from the nsToolkitProfile, and connect to it. + * + * @returns {Promise} */ - async init() { + init() { + if (!this.#initPromise) { + this.#initPromise = this.#init().finally( + () => (this.#initPromise = null) + ); + } + + return this.#initPromise; + } + + async #init() { if (this.#initialized || !this.groupToolkitProfile) { return; } // If the storeID doesn't exist, we don't want to create the db until we // need to so we early return. - if (!this.groupToolkitProfile.storeID) { + if (!this.storeID) { return; } @@ -347,6 +383,33 @@ class SelectableProfileServiceClass { } } + async restoreStoreID() { + try { + await this.#init(); + + for (let profile of await this.getAllProfiles()) { + let groupProfile = this.#profileService.getProfileByDir( + await profile.rootDir + ); + + if (groupProfile) { + this.#groupToolkitProfile = groupProfile; + this.#groupToolkitProfile.storeID = this.storeID; + await this.#profileService.asyncFlush(); + return; + } + } + } catch (e) { + console.error(e); + } + + // If we were unable to find a matching toolkit profile then assume the + // store ID is bogus so clear it and uninit. + this.#storeID = null; + await this.uninit(); + Services.prefs.clearUserPref("toolkit.profiles.storeID"); + } + async handleEvent(event) { switch (event.type) { case "activate": { @@ -422,11 +485,12 @@ class SelectableProfileServiceClass { * and vacuum the group DB. */ async deleteProfileGroup() { - if (this.getAllProfiles().length) { + if ((await this.getAllProfiles()).length) { return; } this.#groupToolkitProfile.storeID = null; + this.#storeID = null; await this.#attemptFlushProfileService(); await this.vacuumAndCloseGroupDB(); } diff --git a/browser/components/profiles/tests/unit/head.js b/browser/components/profiles/tests/unit/head.js index e80b5e0e7fc1..af30a8ffe3fc 100644 --- a/browser/components/profiles/tests/unit/head.js +++ b/browser/components/profiles/tests/unit/head.js @@ -42,6 +42,18 @@ async function initSelectableProfileService() { await SelectableProfileService.maybeSetupDataStore(); } +function getRelativeProfilePath(path) { + let relativePath = path.getRelativePath( + Services.dirsvc.get("UAppData", Ci.nsIFile) + ); + + if (AppConstants.platform === "win") { + relativePath = relativePath.replace("/", "\\"); + } + + return relativePath; +} + async function createTestProfile(profileData = {}) { const SelectableProfileService = getSelectableProfileService(); diff --git a/browser/components/profiles/tests/unit/test_fail_recover_storeID.js b/browser/components/profiles/tests/unit/test_fail_recover_storeID.js new file mode 100644 index 000000000000..c3bc07fc0835 --- /dev/null +++ b/browser/components/profiles/tests/unit/test_fail_recover_storeID.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task( + { + skip_if: () => !AppConstants.MOZ_SELECTABLE_PROFILES, + }, + async function test_recover_storeID() { + startProfileService(); + Services.prefs.setCharPref("toolkit.profiles.storeID", "foobar"); + + const SelectableProfileService = getSelectableProfileService(); + await SelectableProfileService.init(); + Assert.ok( + !SelectableProfileService.initialized, + "Didn't initialize the service" + ); + + let profile = SelectableProfileService.currentProfile; + Assert.ok(!profile, "Should not have a current profile"); + Assert.equal( + getProfileService().currentProfile.storeID, + null, + "Should not have updated the store ID on the profile" + ); + + Assert.ok( + !Services.prefs.prefHasUserValue("toolkit.profiles.storeID"), + "Should have cleared the storeID pref" + ); + } +); diff --git a/browser/components/profiles/tests/unit/test_recover_storeID.js b/browser/components/profiles/tests/unit/test_recover_storeID.js new file mode 100644 index 000000000000..04e24839d5db --- /dev/null +++ b/browser/components/profiles/tests/unit/test_recover_storeID.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Sqlite } = ChromeUtils.importESModule( + "resource://gre/modules/Sqlite.sys.mjs" +); + +add_task( + { + skip_if: () => !AppConstants.MOZ_SELECTABLE_PROFILES, + }, + async function test_recover_storeID() { + startProfileService(); + Services.prefs.setCharPref("toolkit.profiles.storeID", "foobar"); + + // The database needs to exist already + let groupsPath = PathUtils.join( + Services.dirsvc.get("UAppData", Ci.nsIFile).path, + "Profile Groups" + ); + + await IOUtils.makeDirectory(groupsPath); + let dbFile = PathUtils.join(groupsPath, "foobar.sqlite"); + let db = await Sqlite.openConnection({ + path: dbFile, + openNotExclusive: true, + }); + + let path = getRelativeProfilePath( + getProfileService().currentProfile.rootDir + ); + + // Slightly annoying we have to replicate this... + await db.execute(`CREATE TABLE IF NOT EXISTS "Profiles" ( + id INTEGER NOT NULL, + path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + avatar TEXT NOT NULL, + themeL10nId TEXT NOT NULL, + themeFg TEXT NOT NULL, + themeBg TEXT NOT NULL, + PRIMARY KEY(id) + );`); + await db.execute( + `INSERT INTO Profiles VALUES (NULL, :path, :name, :avatar, :themeL10nId, :themeFg, :themeBg);`, + { + path, + name: "Fake Profile", + avatar: "book", + themeL10nId: "default", + themeFg: "", + themeBg: "", + } + ); + + await db.close(); + + const SelectableProfileService = getSelectableProfileService(); + await SelectableProfileService.init(); + Assert.ok( + SelectableProfileService.initialized, + "Did initialize the service" + ); + + let profile = SelectableProfileService.currentProfile; + Assert.ok(profile, "Should have a current profile"); + Assert.equal(profile.name, "Fake Profile"); + Assert.equal( + getProfileService().currentProfile.storeID, + "foobar", + "Should have updated the store ID on the profile" + ); + } +); diff --git a/browser/components/profiles/tests/unit/xpcshell.toml b/browser/components/profiles/tests/unit/xpcshell.toml index 90b0367f91cb..8b775be4cfbb 100644 --- a/browser/components/profiles/tests/unit/xpcshell.toml +++ b/browser/components/profiles/tests/unit/xpcshell.toml @@ -2,6 +2,10 @@ head = "../../../../../toolkit/profile/test/xpcshell/head.js head.js" firefox-appdir = "browser" +["test_fail_recover_storeID.js"] + +["test_recover_storeID.js"] + ["test_selectable_profile_launch.js"] ["test_selectable_profile_service_exists.js"] diff --git a/toolkit/profile/nsIToolkitProfileService.idl b/toolkit/profile/nsIToolkitProfileService.idl index a12127586db2..eee76e558168 100644 --- a/toolkit/profile/nsIToolkitProfileService.idl +++ b/toolkit/profile/nsIToolkitProfileService.idl @@ -103,6 +103,14 @@ interface nsIToolkitProfileService : nsISupports */ nsIToolkitProfile getProfileByName(in AUTF8String aName); + /** + * Get a profile by directory. Finds a profile with the matching root directory + * + * @param aRootDir The root directory to match against. + * @param aLocalDir And optional local directory to also match against. + */ + nsIToolkitProfile getProfileByDir(in nsIFile aRootDir, [optional] in nsIFile aLocalDir); + /** * Create a new profile. * diff --git a/toolkit/profile/nsToolkitProfileService.cpp b/toolkit/profile/nsToolkitProfileService.cpp index 4915a2fc886c..2c62804ad7b0 100644 --- a/toolkit/profile/nsToolkitProfileService.cpp +++ b/toolkit/profile/nsToolkitProfileService.cpp @@ -80,6 +80,7 @@ using namespace mozilla; #define PROFILE_DB_VERSION "2" #define INSTALL_PREFIX "Install" #define INSTALL_PREFIX_LENGTH 7 +#define STORE_ID_PREF "toolkit.profiles.storeID" struct KeyValue { KeyValue(const char* aKey, const char* aValue) : key(aKey), value(aValue) {} @@ -357,32 +358,35 @@ nsToolkitProfile::SetStoreID(const nsACString& aStoreID) { mSection.get(), "StoreID", PromiseFlatCString(aStoreID).get()); NS_ENSURE_SUCCESS(rv, rv); - rv = prefs->SetCharPref("toolkit.profiles.storeID", aStoreID); + rv = nsToolkitProfileService::gService->mProfileDB.SetString( + mSection.get(), "ShowSelector", mShowProfileSelector ? "1" : "0"); NS_ENSURE_SUCCESS(rv, rv); - nsToolkitProfileService::gService->mGroupProfile = this; - } else { - rv = nsToolkitProfileService::gService->mProfileDB.DeleteString( - mSection.get(), "StoreID"); + if (nsToolkitProfileService::gService->mCurrent == this) { + rv = prefs->SetCharPref(STORE_ID_PREF, aStoreID); + NS_ENSURE_SUCCESS(rv, rv); - // If the string was not present in the ini file, just ignore the error. - if (rv == NS_ERROR_FAILURE) { - rv = NS_OK; + nsToolkitProfileService::gService->mGroupProfile = this; } + } else { + // If the string was not present in the ini file, just ignore the error. + nsToolkitProfileService::gService->mProfileDB.DeleteString(mSection.get(), + "StoreID"); // We need a StoreID to show the profile selector, so if StoreID has been // removed, then remove ShowSelector also. mShowProfileSelector = false; - rv = nsToolkitProfileService::gService->mProfileDB.DeleteString( - mSection.get(), "ShowSelector"); - if (rv == NS_ERROR_FAILURE) { - rv = NS_OK; + + // If the string was not present in the ini file, just ignore the error. + nsToolkitProfileService::gService->mProfileDB.DeleteString(mSection.get(), + "ShowSelector"); + + if (nsToolkitProfileService::gService->mCurrent == this) { + rv = prefs->ClearUserPref(STORE_ID_PREF); + NS_ENSURE_SUCCESS(rv, rv); + + nsToolkitProfileService::gService->mGroupProfile = nullptr; } - - rv = prefs->ClearUserPref("toolkit.profiles.storeID"); - NS_ENSURE_SUCCESS(rv, rv); - - nsToolkitProfileService::gService->mGroupProfile = nullptr; } mStoreID = aStoreID; @@ -670,13 +674,15 @@ void nsToolkitProfileService::CompleteStartup() { glean::startup::profile_database_version.Set(mStartupFileVersion); glean::startup::profile_count.Set(static_cast(mProfiles.length())); + nsresult rv; + bool needsFlush = false; + // If we started into an unmanaged profile in a profile group, set the group // profile to be the managed profile belonging to the group. - nsresult rv; nsCOMPtr prefs = do_GetService(NS_PREFSERVICE_CONTRACTID); if (!mCurrent) { nsCString storeID; - rv = prefs->GetCharPref("toolkit.profiles.storeID", storeID); + rv = prefs->GetCharPref(STORE_ID_PREF, storeID); if (NS_SUCCEEDED(rv) && !storeID.IsEmpty()) { mGroupProfile = GetProfileByStoreID(storeID); } @@ -685,32 +691,44 @@ void nsToolkitProfileService::CompleteStartup() { // profile for some group. if (!mCurrent->mStoreID.IsVoid()) { mGroupProfile = mCurrent; - rv = prefs->SetCharPref("toolkit.profiles.storeID", mCurrent->mStoreID); + rv = prefs->SetCharPref(STORE_ID_PREF, mCurrent->mStoreID); NS_ENSURE_SUCCESS_VOID(rv); + } else { + // Otherwise if the current profile has a store ID set in prefs but not in + // the database then restore it. This can happen if a version of Firefox + // prior to 67 has overwritten the database. + nsCString storeID; + rv = prefs->GetCharPref(STORE_ID_PREF, storeID); + if (NS_SUCCEEDED(rv) && !storeID.IsEmpty()) { + rv = mCurrent->SetStoreID(storeID); + if (NS_SUCCEEDED(rv)) { + needsFlush = true; + } + } } } if (mMaybeLockProfile) { nsCOMPtr shell = do_GetService(NS_TOOLKITSHELLSERVICE_CONTRACTID); - if (!shell) { - return; + if (shell) { + bool isDefaultApp; + rv = shell->IsDefaultApplication(&isDefaultApp); + if (NS_SUCCEEDED(rv) && isDefaultApp) { + mProfileDB.SetString(mInstallSection.get(), "Locked", "1"); + + needsFlush = true; + } } + } - bool isDefaultApp; - nsresult rv = shell->IsDefaultApplication(&isDefaultApp); - NS_ENSURE_SUCCESS_VOID(rv); - - if (isDefaultApp) { - mProfileDB.SetString(mInstallSection.get(), "Locked", "1"); - - // There is a very small chance that this could fail if something else - // overwrote the profiles database since we started up, probably less than - // a second ago. There isn't really a sane response here, all the other - // profile changes are already flushed so whether we fail to flush here or - // force quit the app makes no difference. - NS_ENSURE_SUCCESS_VOID(Flush()); - } + if (needsFlush) { + // There is a very small chance that this could fail if something else + // overwrote the profiles database since we started up, probably less than + // a second ago. There isn't really a sane response here, all the other + // profile changes are already flushed so whether we fail to flush here or + // force quit the app makes no difference. + NS_ENSURE_SUCCESS_VOID(Flush()); } } @@ -2077,6 +2095,16 @@ void nsToolkitProfileService::GetProfileByDir(nsIFile* aRootDir, } } +NS_IMETHODIMP +nsToolkitProfileService::GetProfileByDir(nsIFile* aRootDir, nsIFile* aLocalDir, + nsIToolkitProfile** aResult) { + RefPtr result; + GetProfileByDir(aRootDir, aLocalDir, getter_AddRefs(result)); + result.forget(aResult); + + return NS_OK; +} + nsresult NS_LockProfilePath(nsIFile* aPath, nsIFile* aTempPath, nsIProfileUnlocker** aUnlocker, nsIProfileLock** aResult) { diff --git a/toolkit/profile/test/xpcshell/test_restore_storeID.js b/toolkit/profile/test/xpcshell/test_restore_storeID.js new file mode 100644 index 000000000000..966da9f73b8d --- /dev/null +++ b/toolkit/profile/test/xpcshell/test_restore_storeID.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests that the StoreID is restored if available in prefs. + */ +add_task( + { + skip_if: () => !AppConstants.MOZ_SELECTABLE_PROFILES, + }, + async () => { + let hash = xreDirProvider.getInstallHash(); + let defaultProfile = makeRandomProfileDir("default"); + let profilesIni = { + profiles: [ + { + name: "default", + path: defaultProfile.leafName, + default: true, + }, + ], + installs: { + [hash]: { + default: defaultProfile.leafName, + }, + }, + }; + writeProfilesIni(profilesIni); + + Services.prefs.setCharPref("toolkit.profiles.storeID", "bishbashbosh"); + + let service = getProfileService(); + let { profile } = selectStartupProfile(); + + Assert.equal(profile.rootDir.path, defaultProfile.path); + Assert.equal(service.currentProfile, profile); + Assert.equal(service.groupProfile, profile); + Assert.equal(profile.storeID, "bishbashbosh"); + + checkProfileService(); + } +); diff --git a/toolkit/profile/test/xpcshell/xpcshell.toml b/toolkit/profile/test/xpcshell/xpcshell.toml index a3a01e47e6f7..36b4017665b2 100644 --- a/toolkit/profile/test/xpcshell/xpcshell.toml +++ b/toolkit/profile/test/xpcshell/xpcshell.toml @@ -40,6 +40,8 @@ skip-if = ["os == 'android'"] ["test_remove_default.js"] +["test_restore_storeID.js"] + ["test_select_backgroundtasks_ephemeral.js"] ["test_select_backgroundtasks_not_ephemeral_create.js"]