tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SelectableProfileService.sys.mjs (55718B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 import { DeferredTask } from "resource://gre/modules/DeferredTask.sys.mjs";
      7 import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
      8 import { ProfilesDatastoreService } from "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs";
      9 import { SelectableProfile } from "resource:///modules/profiles/SelectableProfile.sys.mjs";
     10 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
     11 
     12 const lazy = {};
     13 
     14 // This is used to keep the icon controllers alive for as long as their windows are alive.
     15 const TASKBAR_ICON_CONTROLLERS = new WeakMap();
     16 const PROFILES_PREF_NAME = "browser.profiles.enabled";
     17 const GROUPID_PREF_NAME = "toolkit.telemetry.cachedProfileGroupID";
     18 const DEFAULT_THEME_ID = "default-theme@mozilla.org";
     19 const PROFILES_CREATED_PREF_NAME = "browser.profiles.created";
     20 const DAU_GROUPID_PREF_NAME = "datareporting.dau.cachedUsageProfileGroupID";
     21 
     22 ChromeUtils.defineESModuleGetters(lazy, {
     23  ClientID: "resource://gre/modules/ClientID.sys.mjs",
     24  CryptoUtils: "moz-src:///services/crypto/modules/utils.sys.mjs",
     25  DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
     26  EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
     27  ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
     28  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     29  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     30  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     31  TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
     32 });
     33 
     34 ChromeUtils.defineLazyGetter(lazy, "profilesLocalization", () => {
     35  return new Localization(["browser/profiles.ftl"]);
     36 });
     37 
     38 XPCOMUtils.defineLazyPreferenceGetter(
     39  lazy,
     40  "PROFILES_ENABLED",
     41  PROFILES_PREF_NAME,
     42  false,
     43  () => SelectableProfileService.updateEnabledState()
     44 );
     45 
     46 XPCOMUtils.defineLazyPreferenceGetter(
     47  lazy,
     48  "PROFILES_CREATED",
     49  PROFILES_CREATED_PREF_NAME,
     50  false
     51 );
     52 
     53 const PROFILES_CRYPTO_SALT_LENGTH_BYTES = 16;
     54 
     55 const COMMAND_LINE_UPDATE = "profiles-updated";
     56 const COMMAND_LINE_ACTIVATE = "profiles-activate";
     57 
     58 const gSupportsBadging = "nsIMacDockSupport" in Ci || "nsIWinTaskbar" in Ci;
     59 
     60 async function loadImage(profile) {
     61  let uri;
     62 
     63  if (profile.hasCustomAvatar) {
     64    const file = await IOUtils.getFile(profile.getAvatarPath(48));
     65    uri = Services.io.newFileURI(file);
     66  } else {
     67    uri = Services.io.newURI(profile.getAvatarPath(48));
     68  }
     69 
     70  const channel = Services.io.newChannelFromURI(
     71    uri,
     72    null,
     73    Services.scriptSecurityManager.getSystemPrincipal(),
     74    null,
     75    Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
     76    Ci.nsIContentPolicy.TYPE_IMAGE
     77  );
     78 
     79  return ChromeUtils.fetchDecodedImage(uri, channel);
     80 }
     81 
     82 /**
     83 * The service that manages selectable profiles
     84 */
     85 class SelectableProfileServiceClass extends EventEmitter {
     86  #profileService = null;
     87  #connection = null;
     88  #initialized = false;
     89  #storeID = null;
     90  #currentProfile = null;
     91  #everyWindowCallbackId = "SelectableProfileService";
     92  #defaultAvatars = [
     93    "book",
     94    "briefcase",
     95    "flower",
     96    "heart",
     97    "shopping",
     98    "star",
     99  ];
    100  #initPromise = null;
    101  #observedPrefs = null;
    102  #badge = null;
    103  #windowActivated = null;
    104  #isEnabled = false;
    105 
    106  // The preferences that must be permanently stored in the database and kept
    107  // consistent amongst profiles.
    108  static permanentSharedPrefs = [
    109    "app.shield.optoutstudies.enabled",
    110    "browser.crashReports.unsubmittedCheck.autoSubmit2",
    111    "browser.discovery.enabled",
    112    "browser.shell.checkDefaultBrowser",
    113    DAU_GROUPID_PREF_NAME,
    114    "datareporting.healthreport.uploadEnabled",
    115    "datareporting.policy.currentPolicyVersion",
    116    "datareporting.policy.dataSubmissionEnabled",
    117    "datareporting.policy.dataSubmissionPolicyAcceptedVersion",
    118    "datareporting.policy.dataSubmissionPolicyBypassNotification",
    119    "datareporting.policy.dataSubmissionPolicyNotifiedTime",
    120    "datareporting.policy.minimumPolicyVersion",
    121    "datareporting.policy.minimumPolicyVersion.channel-beta",
    122    "datareporting.usage.uploadEnabled",
    123    "termsofuse.acceptedDate",
    124    "termsofuse.firstAcceptedDate",
    125    "termsofuse.acceptedVersion",
    126    "termsofuse.bypassNotification",
    127    "termsofuse.currentVersion",
    128    "termsofuse.minimumVersion",
    129    GROUPID_PREF_NAME,
    130  ];
    131 
    132  // Preferences that were previously shared but should now be ignored.
    133  static ignoredSharedPrefs = [
    134    "browser.profiles.enabled",
    135    "browser.urlbar.quicksuggest.dataCollection.enabled",
    136    "toolkit.profiles.storeID",
    137  ];
    138 
    139  constructor() {
    140    super();
    141 
    142    this.onNimbusUpdate = this.onNimbusUpdate.bind(this);
    143    this.themeObserver = this.themeObserver.bind(this);
    144    this.matchMediaObserver = this.matchMediaObserver.bind(this);
    145    this.prefObserver = (subject, topic, prefName) =>
    146      this.flushSharedPrefToDatabase(prefName);
    147 
    148    this.#observedPrefs = new Set();
    149 
    150    this.#profileService = ProfilesDatastoreService.toolkitProfileService;
    151    this.#isEnabled = this.#getEnabledState();
    152 
    153    // We have to check the state again after the policy service may have disabled us.
    154    Services.obs.addObserver(
    155      () => this.updateEnabledState(),
    156      "profile-after-change"
    157    );
    158 
    159    Services.prefs.addObserver(PROFILES_CREATED_PREF_NAME, () =>
    160      Services.obs.notifyObservers(
    161        null,
    162        "sps-profile-created",
    163        lazy.PROFILES_CREATED ? "true" : "false"
    164      )
    165    );
    166  }
    167 
    168  // Migrate any early users who created profiles before the datastore service
    169  // was split out, and the PROFILES_CREATED pref replaced storeID as our check
    170  // for whether the profiles feature had been used.
    171  migrateToProfilesCreatedPref() {
    172    if (this.groupToolkitProfile?.storeID && !lazy.PROFILES_CREATED) {
    173      Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, true);
    174    }
    175  }
    176 
    177  hasCreatedSelectableProfiles() {
    178    return Services.prefs.getBoolPref(PROFILES_CREATED_PREF_NAME, false);
    179  }
    180 
    181  #getEnabledState() {
    182    if (!Services.policies.isAllowed("profileManagement")) {
    183      return false;
    184    }
    185 
    186    this.migrateToProfilesCreatedPref();
    187 
    188    // If a storeID has been assigned then profiles may have been created so force us on. Also
    189    // covers the case when the selector is shown at startup and we don't have preferences
    190    // available.
    191    if (this.groupToolkitProfile?.storeID) {
    192      return true;
    193    }
    194 
    195    return lazy.PROFILES_ENABLED && !!this.groupToolkitProfile;
    196  }
    197 
    198  updateEnabledState() {
    199    let newState = this.#getEnabledState();
    200    if (newState != this.#isEnabled) {
    201      this.#isEnabled = newState;
    202      this.emit("enableChanged", newState);
    203    }
    204  }
    205 
    206  get isEnabled() {
    207    return this.#isEnabled;
    208  }
    209 
    210  #setOverlayIcon({ win }) {
    211    if (!this.#badge || !("nsIWinTaskbar" in Ci)) {
    212      return;
    213    }
    214 
    215    let iconController = null;
    216    if (!TASKBAR_ICON_CONTROLLERS.has(win)) {
    217      iconController = Cc["@mozilla.org/windows-taskbar;1"]
    218        .getService(Ci.nsIWinTaskbar)
    219        .getOverlayIconController(win.docShell);
    220      TASKBAR_ICON_CONTROLLERS.set(win, iconController);
    221    } else {
    222      iconController = TASKBAR_ICON_CONTROLLERS.get(win);
    223    }
    224 
    225    if (this.#currentProfile.hasCustomAvatar) {
    226      iconController?.setOverlayIcon(
    227        this.#badge.image,
    228        this.#badge.description
    229      );
    230    } else {
    231      iconController?.setOverlayIcon(
    232        this.#badge.image,
    233        this.#badge.description,
    234        this.#badge.iconPaintContext
    235      );
    236    }
    237  }
    238 
    239  async #attemptFlushProfileService() {
    240    try {
    241      await this.#profileService.asyncFlush();
    242    } catch (e) {
    243      try {
    244        await this.#profileService.asyncFlushCurrentProfile();
    245      } catch (ex) {
    246        console.error(
    247          `Failed to flush changes to the profiles database: ${ex}`
    248        );
    249      }
    250    }
    251  }
    252 
    253  get storeID() {
    254    return this.#storeID;
    255  }
    256 
    257  get groupToolkitProfile() {
    258    return this.#profileService.currentProfile;
    259  }
    260 
    261  get currentProfile() {
    262    return this.#currentProfile;
    263  }
    264 
    265  get initialized() {
    266    return this.#initialized;
    267  }
    268 
    269  async initProfilesData() {
    270    if (lazy.PROFILES_CREATED) {
    271      return;
    272    }
    273 
    274    if (!this.groupToolkitProfile) {
    275      throw new Error("Cannot create a store without a toolkit profile.");
    276    }
    277 
    278    Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, true);
    279 
    280    let storeID = await ProfilesDatastoreService.storeID;
    281 
    282    this.groupToolkitProfile.storeID = storeID;
    283    this.#storeID = storeID;
    284    await this.#attemptFlushProfileService();
    285  }
    286 
    287  onNimbusUpdate() {
    288    if (lazy.NimbusFeatures.selectableProfiles.getVariable("enabled")) {
    289      Services.prefs.setBoolPref(PROFILES_PREF_NAME, true);
    290    }
    291  }
    292 
    293  /**
    294   * At startup, store the nsToolkitProfile for the group.
    295   * Get the groupDBPath from the nsToolkitProfile, and connect to it.
    296   *
    297   * @param {boolean} isInitial true if this is an init prior to creating a new profile.
    298   *
    299   * @returns {Promise}
    300   */
    301  init(isInitial = false) {
    302    if (!this.#initPromise) {
    303      this.#initPromise = this.#init(isInitial).finally(
    304        () => (this.#initPromise = null)
    305      );
    306    }
    307 
    308    return this.#initPromise;
    309  }
    310 
    311  async #init(isInitial = false) {
    312    if (this.#initialized) {
    313      return;
    314    }
    315 
    316    lazy.NimbusFeatures.selectableProfiles.onUpdate(this.onNimbusUpdate);
    317 
    318    this.#profileService = ProfilesDatastoreService.toolkitProfileService;
    319 
    320    this.#storeID = await ProfilesDatastoreService.storeID;
    321 
    322    this.updateEnabledState();
    323    if (!this.isEnabled) {
    324      return;
    325    }
    326 
    327    if (!lazy.PROFILES_CREATED) {
    328      return;
    329    }
    330 
    331    this.#connection = await ProfilesDatastoreService.getConnection();
    332    if (!this.#connection) {
    333      return;
    334    }
    335 
    336    // When we launch into the startup window, the `ProfD` is not defined so
    337    // getting the directory will throw. Leaving the `currentProfile` as null
    338    // is fine for the startup window.
    339    // The current profile will be null now that we are eagerly initing the db.
    340    try {
    341      // Get the SelectableProfile by the profile directory
    342      this.#currentProfile = await this.getProfileByPath(
    343        ProfilesDatastoreService.constructor.getDirectory("ProfD")
    344      );
    345    } catch {}
    346 
    347    // If this isn't the first init prior to creating the first new profile and
    348    // the app is started up we should have found a current profile.
    349    if (!isInitial && !Services.startup.startingUp && !this.#currentProfile) {
    350      let count = await this.getProfileCount();
    351 
    352      if (count) {
    353        // There are other profiles, re-create the current profile.
    354        this.#currentProfile = await this.#createProfile(
    355          ProfilesDatastoreService.constructor.getDirectory("ProfD")
    356        );
    357      } else {
    358        // No other profiles. Reset our state.
    359        this.groupToolkitProfile.storeID = null;
    360        await this.#attemptFlushProfileService();
    361        Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, false);
    362 
    363        this.#connection = null;
    364        this.updateEnabledState();
    365 
    366        return;
    367      }
    368    }
    369 
    370    // This can happen if profiles.ini has been reset by a version of Firefox
    371    // prior to 67 and the current profile is not the current default for the
    372    // group. We can recover by overwriting this.groupToolkitProfile.storeID
    373    // with the current storeID.
    374    if (this.groupToolkitProfile.storeID != this.storeID) {
    375      this.groupToolkitProfile.storeID = this.storeID;
    376      await this.#attemptFlushProfileService();
    377    }
    378 
    379    // On macOS when other applications request we open a url the most recent
    380    // window becomes activated first. This would cause the default profile to
    381    // change before we determine which profile to open the url in. By
    382    // introducing a small delay we can process the urls before changing the
    383    // default profile.
    384    this.#windowActivated = new DeferredTask(
    385      async () => this.setDefaultProfileForGroup(),
    386      500
    387    );
    388 
    389    // The 'activate' event listeners use #currentProfile, so this line has
    390    // to come after #currentProfile has been set.
    391    this.initWindowTracker();
    392 
    393    // We must also set the current profile as default during startup.
    394    await this.setDefaultProfileForGroup();
    395 
    396    Services.obs.addObserver(
    397      this.themeObserver,
    398      "lightweight-theme-styling-update"
    399    );
    400 
    401    let window = Services.wm.getMostRecentBrowserWindow();
    402    let prefersDarkQuery = window?.matchMedia("(prefers-color-scheme: dark)");
    403    prefersDarkQuery?.addEventListener("change", this.matchMediaObserver);
    404 
    405    Services.obs.addObserver(this, "pds-datastore-changed");
    406 
    407    this.#initialized = true;
    408 
    409    // this.#currentProfile is unset in the case that the database has only just been created. We
    410    // don't need to import from the database in this case.
    411    if (this.#currentProfile) {
    412      // Assume that settings in the database may have changed while we weren't running.
    413      await this.databaseChanged("startup");
    414 
    415      // We only need to migrate if we are in an existing profile group.
    416      await this.#maybeAddDAUGroupIDToDB();
    417    }
    418  }
    419 
    420  async uninit() {
    421    if (!this.#initialized) {
    422      return;
    423    }
    424 
    425    Services.obs.removeObserver(
    426      this.themeObserver,
    427      "lightweight-theme-styling-update"
    428    );
    429 
    430    lazy.NimbusFeatures.selectableProfiles.offUpdate(this.onNimbusUpdate);
    431 
    432    this.#currentProfile = null;
    433    this.#badge = null;
    434    this.#connection = null;
    435 
    436    this.clearPrefObservers();
    437 
    438    lazy.EveryWindow.unregisterCallback(this.#everyWindowCallbackId);
    439 
    440    Services.obs.removeObserver(this, "pds-datastore-changed");
    441 
    442    this.#initialized = false;
    443  }
    444 
    445  initWindowTracker() {
    446    lazy.EveryWindow.registerCallback(
    447      this.#everyWindowCallbackId,
    448      window => {
    449        this.#setOverlayIcon({ win: window });
    450 
    451        // Update the window title because the currentProfile, needed in the
    452        // .*-with-profile titles, didn't exist when the title was initially set.
    453        window.gBrowser.updateTitlebar();
    454 
    455        let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
    456        if (isPBM) {
    457          return;
    458        }
    459 
    460        window.addEventListener("activate", this);
    461      },
    462      window => {
    463        window.gBrowser.updateTitlebar();
    464 
    465        let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
    466        if (isPBM) {
    467          return;
    468        }
    469 
    470        window.removeEventListener("activate", this);
    471      }
    472    );
    473  }
    474 
    475  async handleEvent(event) {
    476    switch (event.type) {
    477      case "activate": {
    478        this.#windowActivated.arm();
    479        this.#setOverlayIcon({ win: event.target });
    480        break;
    481      }
    482    }
    483  }
    484 
    485  observe(subject, topic, data) {
    486    switch (topic) {
    487      case "pds-datastore-changed": {
    488        this.databaseChanged(data);
    489        break;
    490      }
    491      case "lightweight-theme-styling-update": {
    492        this.themeObserver(subject, topic);
    493        break;
    494      }
    495    }
    496  }
    497 
    498  /**
    499   * When the last selectable profile in a group is deleted,
    500   * also remove the profile group's named profile entry from profiles.ini
    501   * and set the profiles created pref to false.
    502   */
    503  async deleteProfileGroup() {
    504    if ((await this.getAllProfiles()).length) {
    505      return;
    506    }
    507 
    508    Services.prefs.setBoolPref(PROFILES_CREATED_PREF_NAME, false);
    509    this.groupToolkitProfile.storeID = null;
    510    await this.#attemptFlushProfileService();
    511  }
    512 
    513  // App session lifecycle methods and multi-process support
    514 
    515  /*
    516   * Helper that executes a new Firefox process. Mostly useful for mocking in
    517   * unit testing.
    518   */
    519  execProcess(aArgs) {
    520    let executable =
    521      ProfilesDatastoreService.constructor.getDirectory("XREExeF");
    522 
    523    if (AppConstants.platform == "macosx") {
    524      // Use the application bundle if possible.
    525      let appBundle = executable.parent.parent.parent;
    526      if (appBundle.path.endsWith(".app")) {
    527        executable = appBundle;
    528 
    529        Cc["@mozilla.org/widget/macdocksupport;1"]
    530          .getService(Ci.nsIMacDockSupport)
    531          .launchAppBundle(appBundle, aArgs, { addsToRecentItems: false });
    532        return;
    533      }
    534    }
    535 
    536    let process = Cc["@mozilla.org/process/util;1"].createInstance(
    537      Ci.nsIProcess
    538    );
    539    process.init(executable);
    540    process.runw(false, aArgs, aArgs.length);
    541  }
    542 
    543  /**
    544   * Sends a command line via the remote service. Useful for mocking from automated tests.
    545   *
    546   * @param {...any} args Arguments to pass to nsIRemoteService.sendCommandLine.
    547   */
    548  sendCommandLine(...args) {
    549    Cc["@mozilla.org/remote;1"]
    550      .getService(Ci.nsIRemoteService)
    551      .sendCommandLine(...args);
    552  }
    553 
    554  /**
    555   * Launch a new Firefox instance using the given selectable profile.
    556   *
    557   * @param {SelectableProfile} aProfile The profile to launch
    558   * @param {Array<string>} aUrls An array of urls to open in launched profile
    559   */
    560  launchInstance(aProfile, aUrls) {
    561    let args = [];
    562 
    563    if (aUrls?.length) {
    564      // See https://wiki.mozilla.org/Firefox/CommandLineOptions#-url_URL
    565      // Use '-new-tab' instead of '-url' because when opening multiple URLs,
    566      // Firefox always opens them as tabs in a new window and we want to
    567      // attempt opening these tabs in an existing window.
    568      args.push(...aUrls.flatMap(url => ["-new-tab", url]));
    569    } else {
    570      args.push(`--${COMMAND_LINE_ACTIVATE}`);
    571    }
    572 
    573    // If the other instance is already running we can just use the remoting
    574    // service directly.
    575    try {
    576      this.sendCommandLine(aProfile.path, args, true);
    577 
    578      return;
    579    } catch (e) {
    580      // This is expected to fail if no instance is running with the profile.
    581    }
    582 
    583    args.unshift("--profile", aProfile.path);
    584    if (Services.appinfo.OS === "Darwin") {
    585      args.unshift("-foreground");
    586    }
    587 
    588    this.execProcess(args);
    589  }
    590 
    591  /**
    592   * When the group DB has been updated, either changes to prefs or profiles,
    593   * ask the remoting service to notify other running instances that they should
    594   * check for updates and refresh their UI accordingly.
    595   */
    596  async #notifyRunningInstances() {
    597    let profiles = await this.getAllProfiles();
    598    for (let profile of profiles) {
    599      // The current profile was notified above.
    600      if (profile.id === this.currentProfile?.id) {
    601        continue;
    602      }
    603 
    604      try {
    605        this.sendCommandLine(profile.path, [`--${COMMAND_LINE_UPDATE}`], false);
    606      } catch (e) {
    607        // This is expected to fail if no instance is running with the profile.
    608      }
    609    }
    610  }
    611 
    612  async #updateTaskbar() {
    613    try {
    614      // We don't want the startup profile selector to badge the dock icon.
    615      if (!gSupportsBadging || Services.startup.startingUp) {
    616        return;
    617      }
    618 
    619      let count = await this.getProfileCount();
    620 
    621      if (count > 1 && !this.#badge) {
    622        this.#badge = {
    623          image: await loadImage(this.#currentProfile),
    624          iconPaintContext: this.#currentProfile.iconPaintContext,
    625          description: this.#currentProfile.name,
    626        };
    627 
    628        if ("nsIMacDockSupport" in Ci) {
    629          Cc["@mozilla.org/widget/macdocksupport;1"]
    630            .getService(Ci.nsIMacDockSupport)
    631            .setBadgeImage(this.#badge.image, this.#badge.iconPaintContext);
    632        } else if ("nsIWinTaskbar" in Ci) {
    633          for (let win of lazy.EveryWindow.readyWindows) {
    634            this.#setOverlayIcon({ win });
    635          }
    636        }
    637      } else if (count <= 1 && this.#badge) {
    638        this.#badge = null;
    639 
    640        if ("nsIMacDockSupport" in Ci) {
    641          Cc["@mozilla.org/widget/macdocksupport;1"]
    642            .getService(Ci.nsIMacDockSupport)
    643            .setBadgeImage(null);
    644        } else if ("nsIWinTaskbar" in Ci) {
    645          for (let win of lazy.EveryWindow.readyWindows) {
    646            let iconController = TASKBAR_ICON_CONTROLLERS.get(win);
    647            iconController?.setOverlayIcon(null, null);
    648          }
    649        }
    650      }
    651    } catch (e) {
    652      console.error(e);
    653    }
    654  }
    655 
    656  /**
    657   * Invoked when changes have been made to the database. Sends the observer
    658   * notification "sps-profiles-updated" indicating that something has changed.
    659   *
    660   * @param {"local"|"remote"|"startup"|"shutdown"} source The source of the
    661   *   notification. Either "local" meaning that the change was made in this
    662   *   process, "remote" meaning the change was made by a different Firefox
    663   *   instance, "startup" meaning the application has just launched and we may
    664   *   need to reload changes from the database, or "shutdown" meaning we are
    665   *   closing the connection and shutting down.
    666   */
    667  async databaseChanged(source) {
    668    if (source === "local" || source === "shutdown") {
    669      this.#notifyRunningInstances();
    670    }
    671 
    672    if (source === "shutdown") {
    673      return;
    674    }
    675 
    676    if (source != "local") {
    677      await this.loadSharedPrefsFromDatabase();
    678    }
    679 
    680    await this.#updateTaskbar();
    681 
    682    if (source != "startup") {
    683      Services.obs.notifyObservers(null, "sps-profiles-updated", source);
    684    }
    685  }
    686 
    687  /**
    688   * The default theme uses `light-dark` color function which doesn't apply
    689   * correctly to the taskbar avatar icon. We use `InspectorUtils.colorToRGBA`
    690   * to get the current rgba values for a theme. This way the color values can
    691   * be correctly applied to the taskbar avatar icon.
    692   *
    693   * @returns {object}
    694   *  themeBg {string}: the background color in rgba(r, g, b, a) format
    695   *  themeFg {string}: the foreground color in rgba(r, g, b, a) format
    696   */
    697  getColorsForDefaultTheme() {
    698    let window = Services.wm.getMostRecentBrowserWindow();
    699    // The computedStyles object is a live CSSStyleDeclaration.
    700    let computedStyles = window.getComputedStyle(
    701      window.document.documentElement
    702    );
    703 
    704    let themeFgColor = computedStyles.getPropertyValue("--toolbar-color");
    705    let themeBgColor = computedStyles.getPropertyValue("--toolbar-bgcolor");
    706 
    707    let bg = window.InspectorUtils.colorToRGBA(themeBgColor);
    708    let themeBg = `rgba(${bg.r}, ${bg.g}, ${bg.b}, ${bg.a})`;
    709 
    710    let fg = window.InspectorUtils.colorToRGBA(themeFgColor);
    711    let themeFg = `rgba(${fg.r}, ${fg.g}, ${fg.b}, ${fg.a})`;
    712 
    713    return { themeBg, themeFg };
    714  }
    715 
    716  /**
    717   * The observer function that watches for theme changes and updates the
    718   * current profile of a theme change.
    719   *
    720   * @param {object} aSubject The theme data
    721   * @param {string} aTopic Should be "lightweight-theme-styling-update"
    722   */
    723  themeObserver(aSubject, aTopic) {
    724    if (aTopic !== "lightweight-theme-styling-update") {
    725      return;
    726    }
    727 
    728    let data = aSubject.wrappedJSObject;
    729 
    730    if (!data.theme) {
    731      // During startup the theme might be null so just return
    732      return;
    733    }
    734 
    735    let window = Services.wm.getMostRecentBrowserWindow();
    736    let isDark = window.matchMedia("(-moz-system-dark-theme)").matches;
    737 
    738    let theme = isDark && !!data.darkTheme ? data.darkTheme : data.theme;
    739 
    740    let themeFg = theme.toolbar_text || theme.textcolor;
    741    let themeBg = theme.toolbarColor || theme.accentcolor;
    742 
    743    if (theme.id === DEFAULT_THEME_ID || !themeFg || !themeBg) {
    744      window.addEventListener(
    745        "windowlwthemeupdate",
    746        () => {
    747          ({ themeBg, themeFg } = this.getColorsForDefaultTheme());
    748 
    749          this.currentProfile.theme = {
    750            themeId: theme.id,
    751            themeFg,
    752            themeBg,
    753          };
    754        },
    755        {
    756          once: true,
    757        }
    758      );
    759    } else {
    760      this.currentProfile.theme = {
    761        themeId: theme.id,
    762        themeFg,
    763        themeBg,
    764      };
    765    }
    766  }
    767 
    768  /**
    769   * The observer function that watches for OS theme changes and updates the
    770   * current profile of a theme change.
    771   */
    772  matchMediaObserver() {
    773    // If the current theme isn't the default theme, we can just return because
    774    // we already got the theme colors from the theme change in `themeObserver`
    775    if (this.currentProfile.theme.themeId !== DEFAULT_THEME_ID) {
    776      return;
    777    }
    778 
    779    let { themeBg, themeFg } = this.getColorsForDefaultTheme();
    780 
    781    this.currentProfile.theme = {
    782      themeId: this.currentProfile.theme.themeId,
    783      themeFg,
    784      themeBg,
    785    };
    786  }
    787 
    788  async flushAllSharedPrefsToDatabase() {
    789    for (let prefName of SelectableProfileServiceClass.permanentSharedPrefs) {
    790      await this.flushSharedPrefToDatabase(prefName);
    791    }
    792  }
    793 
    794  /**
    795   * Flushes the value of a preference to the database.
    796   *
    797   * @param {string} prefName the name of the preference.
    798   */
    799  async flushSharedPrefToDatabase(prefName) {
    800    if (!this.#observedPrefs.has(prefName)) {
    801      Services.prefs.addObserver(prefName, this.prefObserver);
    802      this.#observedPrefs.add(prefName);
    803    }
    804 
    805    if (
    806      !SelectableProfileServiceClass.permanentSharedPrefs.includes(prefName) &&
    807      !Services.prefs.prefHasUserValue(prefName)
    808    ) {
    809      await this.#deleteDBPref(prefName);
    810      return;
    811    }
    812 
    813    let value;
    814 
    815    switch (Services.prefs.getPrefType(prefName)) {
    816      case Ci.nsIPrefBranch.PREF_BOOL:
    817        value = Services.prefs.getBoolPref(prefName);
    818        break;
    819      case Ci.nsIPrefBranch.PREF_INT:
    820        value = Services.prefs.getIntPref(prefName);
    821        break;
    822      case Ci.nsIPrefBranch.PREF_STRING:
    823        value = Services.prefs.getCharPref(prefName);
    824        break;
    825    }
    826 
    827    await this.#setDBPref(prefName, value);
    828  }
    829 
    830  clearPrefObservers() {
    831    for (let prefName of this.#observedPrefs) {
    832      Services.prefs.removeObserver(prefName, this.prefObserver);
    833    }
    834    this.#observedPrefs.clear();
    835  }
    836 
    837  /**
    838   * The "datareporting.dau.cachedUsageProfileGroupID" pref is different in
    839   * every profile before this migration was created. We now need the entire
    840   * group of profiles to share one group id. To migrate to one shared
    841   * group id, we need to get the pref into the db for existing group. This
    842   * function handles this by adding the pref to the db if it doesn't
    843   * already exist OR if our pref value is better than the value from the db.
    844   * Consolidation on one group id is also handled in `#maybeSetDAUGroupID`
    845   * where we overwrite the pref value if the db value is better.
    846   *
    847   * New profile groups will automatically start tracking this pref and keep
    848   * the UUID from the original profile. We need to migrate because the db in
    849   * existing profile groups will not contain the pref and every profile will
    850   * have a different group id.
    851   */
    852  async #maybeAddDAUGroupIDToDB() {
    853    let writeToDB = false;
    854    let prefValue = Services.prefs.getStringPref(DAU_GROUPID_PREF_NAME, "");
    855    try {
    856      let dbValue = await this.getDBPref(DAU_GROUPID_PREF_NAME);
    857 
    858      // We found a DAU group id in the db. If our pref value is smaller
    859      // alphanumerically, we will overwrite the db value.
    860      if (prefValue < dbValue) {
    861        // Pref value is smaller alphanumerically so overwrite the db.
    862        writeToDB = true;
    863      }
    864    } catch {
    865      // The pref is not in the db
    866      writeToDB = true;
    867    } finally {
    868      if (writeToDB) {
    869        // The pref is not in the db
    870        // OR
    871        // our pref value is better so overwrite the db.
    872        this.#setDBPref(DAU_GROUPID_PREF_NAME, prefValue);
    873      }
    874    }
    875  }
    876 
    877  /**
    878   * To consolidate on one group id, we compare the pref value from the db and
    879   * this profiles pref value alphanumerically to converge on the smallest
    880   * alphanumeric UUID. The `#maybeAddDAUGroupIDToDB` function handles the
    881   * initial tracking of the "datareporting.dau.cachedUsageProfileGroupID" pref
    882   * for an existing profile group. New profile groups will keep the original
    883   * profiles group id.
    884   *
    885   * @param {string} dbValue The pref value of
    886   *   "datareporting.dau.cachedUsageProfileGroupID" from the db
    887   */
    888  async #maybeSetDAUGroupID(dbValue) {
    889    if (dbValue < Services.prefs.getStringPref(DAU_GROUPID_PREF_NAME, "")) {
    890      try {
    891        // The value from the db is better so we overwrite our group id.
    892        await lazy.ClientID.setUsageProfileGroupID(dbValue); // Sets the pref for us.
    893      } catch (e) {
    894        // This may throw if the group ID is invalid. This happens in some tests.
    895        console.error(e);
    896      }
    897    }
    898  }
    899 
    900  /**
    901   * Fetch all prefs from the DB and write to the current instance.
    902   */
    903  async loadSharedPrefsFromDatabase() {
    904    // This stops us from observing the change during the load and means we stop observing any prefs
    905    // no longer in the database.
    906    this.clearPrefObservers();
    907 
    908    for (let { name, value, type } of await this.getAllDBPrefs()) {
    909      if (SelectableProfileServiceClass.ignoredSharedPrefs.includes(name)) {
    910        continue;
    911      }
    912 
    913      // If the user has disabled then re-enabled data collection in another
    914      // profile in the group, an extra step is needed to ensure each profile
    915      // uses the same profile group ID.
    916      if (
    917        name === GROUPID_PREF_NAME &&
    918        value !== lazy.TelemetryUtils.knownProfileGroupID &&
    919        value !== Services.prefs.getCharPref(GROUPID_PREF_NAME, "")
    920      ) {
    921        try {
    922          await lazy.ClientID.setProfileGroupID(value); // Sets the pref for us.
    923        } catch (e) {
    924          // This may throw if the group ID is invalid. This happens in some tests.
    925          console.error(e);
    926        }
    927        continue;
    928      }
    929 
    930      if (name === DAU_GROUPID_PREF_NAME) {
    931        await this.#maybeSetDAUGroupID(value);
    932 
    933        Services.prefs.addObserver(name, this.prefObserver);
    934        this.#observedPrefs.add(name);
    935        continue;
    936      }
    937 
    938      if (value === null) {
    939        Services.prefs.clearUserPref(name);
    940      } else {
    941        switch (type) {
    942          case "boolean":
    943            Services.prefs.setBoolPref(name, value);
    944            break;
    945          case "string":
    946            Services.prefs.setCharPref(name, value);
    947            break;
    948          case "number":
    949            Services.prefs.setIntPref(name, value);
    950            break;
    951          case "null":
    952            Services.prefs.clearUserPref(name);
    953            break;
    954        }
    955      }
    956 
    957      Services.prefs.addObserver(name, this.prefObserver);
    958      this.#observedPrefs.add(name);
    959    }
    960  }
    961 
    962  /**
    963   * Update the default profile by setting the selectable profile's path
    964   * as the path of the nsToolkitProfile for the group. Defaults to the current
    965   * selectable profile.
    966   *
    967   * @param {SelectableProfile} aProfile The SelectableProfile to be
    968   * set as the default.
    969   */
    970  async setDefaultProfileForGroup(aProfile = this.currentProfile) {
    971    if (!aProfile) {
    972      return;
    973    }
    974    this.groupToolkitProfile.rootDir = await aProfile.rootDir;
    975    Glean.profilesDefault.updated.record();
    976    await this.#attemptFlushProfileService();
    977  }
    978 
    979  /**
    980   * Update whether to show the selectable profile selector window at startup.
    981   * Set on the nsToolkitProfile instance for the group.
    982   *
    983   * @param {boolean} shouldShow Whether or not we should show the profile selector
    984   */
    985  async setShowProfileSelectorWindow(shouldShow) {
    986    this.groupToolkitProfile.showProfileSelector = shouldShow;
    987    await this.#attemptFlushProfileService();
    988  }
    989 
    990  // SelectableProfile lifecycle
    991 
    992  /**
    993   * Create the profile directory for new profile. The profile name is combined
    994   * with a salt string to ensure the directory is unique. The format of the
    995   * directory is salt + "." + profileName. (Ex. c7IZaLu7.testProfile)
    996   *
    997   * @param {string} aProfileName The name of the profile to be created
    998   * @returns {string} The path for the given profile
    999   */
   1000  async createProfileDirs(aProfileName) {
   1001    const salt = btoa(
   1002      lazy.CryptoUtils.generateRandomBytesLegacy(
   1003        PROFILES_CRYPTO_SALT_LENGTH_BYTES
   1004      )
   1005    );
   1006    // Sometimes the string from CryptoUtils.generateRandomBytesLegacy will
   1007    // contain non-word characters that we don't want to include in the profile
   1008    // directory name. So we match only word characters for the directory name.
   1009    const safeSalt = salt.match(/\w/g).join("").slice(0, 8);
   1010 
   1011    const profileDir = lazy.DownloadPaths.sanitize(
   1012      `${safeSalt}.${aProfileName}`,
   1013      {
   1014        compressWhitespaces: false,
   1015        allowDirectoryNames: true,
   1016      }
   1017    );
   1018 
   1019    // Handle errors in bug 1909919
   1020    await Promise.all([
   1021      IOUtils.makeDirectory(
   1022        PathUtils.join(
   1023          ProfilesDatastoreService.constructor.getDirectory("DefProfRt").path,
   1024          profileDir
   1025        ),
   1026        {
   1027          permissions: 0o700,
   1028        }
   1029      ),
   1030      IOUtils.makeDirectory(
   1031        PathUtils.join(
   1032          ProfilesDatastoreService.constructor.getDirectory("DefProfLRt").path,
   1033          profileDir
   1034        ),
   1035        {
   1036          permissions: 0o700,
   1037        }
   1038      ),
   1039    ]);
   1040 
   1041    return IOUtils.getDirectory(
   1042      PathUtils.join(
   1043        ProfilesDatastoreService.constructor.getDirectory("DefProfRt").path,
   1044        profileDir
   1045      )
   1046    );
   1047  }
   1048 
   1049  /**
   1050   * Create the times.json file and write the "created" timestamp and
   1051   * "firstUse" as null.
   1052   * Create the prefs.js file and write all shared prefs to the file.
   1053   *
   1054   * @param {nsIFile} profileDir The root dir of the newly created profile
   1055   */
   1056  async createProfileInitialFiles(profileDir) {
   1057    let timesJsonFilePath = await IOUtils.createUniqueFile(
   1058      profileDir.path,
   1059      "times.json",
   1060      0o700
   1061    );
   1062 
   1063    await IOUtils.writeJSON(timesJsonFilePath, {
   1064      created: Date.now(),
   1065      firstUse: null,
   1066    });
   1067 
   1068    let prefsJsFilePath = await IOUtils.createUniqueFile(
   1069      profileDir.path,
   1070      "prefs.js",
   1071      0o600
   1072    );
   1073 
   1074    const sharedPrefs = await this.getAllDBPrefs();
   1075 
   1076    const prefsJs = [];
   1077    for (let pref of sharedPrefs) {
   1078      prefsJs.push(
   1079        `user_pref("${pref.name}", ${
   1080          pref.type === "string" ? `"${pref.value}"` : `${pref.value}`
   1081        });`
   1082      );
   1083    }
   1084 
   1085    // Preferences that must be set in newly created profiles.
   1086    prefsJs.push(`user_pref("browser.profiles.profile-name.updated", false);`);
   1087    prefsJs.push(`user_pref("browser.profiles.enabled", true);`);
   1088    prefsJs.push(`user_pref("browser.profiles.created", true);`);
   1089    prefsJs.push(`user_pref("toolkit.profiles.storeID", "${this.storeID}");`);
   1090    prefsJs.push(
   1091      `user_pref("${DAU_GROUPID_PREF_NAME}", "${await this.getDBPref(DAU_GROUPID_PREF_NAME)}");`
   1092    );
   1093 
   1094    const LINEBREAK = AppConstants.platform === "win" ? "\r\n" : "\n";
   1095    await IOUtils.writeUTF8(
   1096      prefsJsFilePath,
   1097      Services.prefs.prefsJsPreamble + prefsJs.join(LINEBREAK) + LINEBREAK
   1098    );
   1099  }
   1100 
   1101  /**
   1102   * Get a relative to the Profiles directory for the given profile directory.
   1103   *
   1104   * @param {nsIFile} aProfilePath Path to profile directory.
   1105   *
   1106   * @returns {string} A relative path of the profile directory.
   1107   */
   1108  getRelativeProfilePath(aProfilePath) {
   1109    let relativePath = aProfilePath.getRelativePath(
   1110      ProfilesDatastoreService.constructor.getDirectory("UAppData")
   1111    );
   1112 
   1113    if (AppConstants.platform === "win") {
   1114      relativePath = relativePath.replaceAll("/", "\\");
   1115    }
   1116 
   1117    return relativePath;
   1118  }
   1119 
   1120  /**
   1121   * Create a Selectable Profile and add to the datastore.
   1122   *
   1123   * If path is not included, new profile directories will be created.
   1124   *
   1125   * @param {nsIFile} existingProfilePath Optional. The path of an existing profile.
   1126   *
   1127   * @returns {SelectableProfile} The newly created profile object.
   1128   */
   1129  async #createProfile(existingProfilePath) {
   1130    let nextProfileNumber = Math.max(
   1131      0,
   1132      ...(await this.getAllProfiles()).map(p => p.id)
   1133    );
   1134    let [defaultName, originalName] =
   1135      await lazy.profilesLocalization.formatMessages([
   1136        { id: "default-profile-name", args: { number: nextProfileNumber } },
   1137        { id: "original-profile-name" },
   1138      ]);
   1139 
   1140    let window = Services.wm.getMostRecentBrowserWindow();
   1141    let isDark = window?.matchMedia("(-moz-system-dark-theme)").matches;
   1142 
   1143    let randomIndex = Math.floor(Math.random() * this.#defaultAvatars.length);
   1144    let profileData = {
   1145      // The original toolkit profile is added first and is assigned a
   1146      // different name.
   1147      name: nextProfileNumber == 0 ? originalName.value : defaultName.value,
   1148      avatar: this.#defaultAvatars[randomIndex],
   1149      themeId: DEFAULT_THEME_ID,
   1150      themeFg: isDark ? "rgb(255,255,255)" : "rgb(21,20,26)",
   1151      themeBg: isDark ? "rgb(28,27,34)" : "rgb(240,240,244)",
   1152    };
   1153 
   1154    let path =
   1155      existingProfilePath || (await this.createProfileDirs(profileData.name));
   1156    if (!existingProfilePath) {
   1157      await this.createProfileInitialFiles(path);
   1158    }
   1159    profileData.path = this.getRelativeProfilePath(path);
   1160 
   1161    let profile = await this.insertProfile(profileData);
   1162    return profile;
   1163  }
   1164 
   1165  /**
   1166   * If the user has never created a SelectableProfile before, the currently
   1167   * running toolkit profile will be added to the datastore and will finish
   1168   * initing the service for profiles.
   1169   */
   1170  async maybeSetupDataStore() {
   1171    if (this.#connection) {
   1172      return;
   1173    }
   1174 
   1175    await this.initProfilesData();
   1176    await this.init(true);
   1177 
   1178    await this.flushAllSharedPrefsToDatabase();
   1179 
   1180    // If this is the first time the user has created a selectable profile,
   1181    // add the current toolkit profile to the datastore.
   1182    if (!this.#currentProfile) {
   1183      let path = this.groupToolkitProfile.rootDir;
   1184      this.#currentProfile = await this.#createProfile(path);
   1185 
   1186      // And also set the profile selector window to show at startup (bug 1933911).
   1187      await this.setShowProfileSelectorWindow(true);
   1188 
   1189      // For first-run dark mode macOS users, the original profile's dock icon
   1190      // disappears after creating and launching an additional profile for the
   1191      // first time. Here we hack around this problem.
   1192      //
   1193      // Wait a full second, which seems to be enough time for the newly-
   1194      // launched second Firefox instance's dock animation to complete. Then
   1195      // trigger redrawing the original profile's badged icon (by setting the
   1196      // avatar to its current value, a no-op change which redraws the dock
   1197      // icon as a side effect).
   1198      //
   1199      // Shorter timeouts don't work, perhaps because they trigger the update
   1200      // before the dock bouncing animation completes for the other instance?
   1201      //
   1202      // We haven't figured out the lower-level bug that's causing this, but
   1203      // hope to someday find that better solution (bug 1952338).
   1204      if (Services.appinfo.OS === "Darwin") {
   1205        lazy.setTimeout(() => {
   1206          // To avoid displeasing the linter, assign to a temporary variable.
   1207          let avatar = SelectableProfileService.currentProfile.avatar;
   1208          SelectableProfileService.currentProfile.setAvatar(avatar);
   1209        }, 1000);
   1210      }
   1211    }
   1212  }
   1213 
   1214  /**
   1215   * Add a profile to the profile group datastore.
   1216   *
   1217   * This function assumes the service is initialized and the datastore has
   1218   * been created.
   1219   *
   1220   * @param {object} profileData A plain object that contains a name, avatar,
   1221   *                 themeId, themeFg, themeBg, and relative path as string.
   1222   *
   1223   * @returns {SelectableProfile} The newly created profile object.
   1224   */
   1225  async insertProfile(profileData) {
   1226    // Verify all fields are present.
   1227    let keys = ["avatar", "name", "path", "themeBg", "themeFg", "themeId"];
   1228    let missing = [];
   1229    keys.forEach(key => {
   1230      if (!(key in profileData)) {
   1231        missing.push(key);
   1232      }
   1233    });
   1234    if (missing.length) {
   1235      throw new Error(
   1236        `Unable to insertProfile due to missing keys: ${missing.join(",")}`
   1237      );
   1238    }
   1239    const rows = await this.#connection.execute(
   1240      `INSERT INTO Profiles
   1241       VALUES (NULL, :path, :name, :avatar, :themeId, :themeFg, :themeBg)
   1242       RETURNING id;`,
   1243      profileData
   1244    );
   1245    const profileId = rows[0].getResultByName("id");
   1246    if (!profileId) {
   1247      throw new Error(`Unable to insertProfile with values: ${profileData}`);
   1248    }
   1249 
   1250    ProfilesDatastoreService.notify();
   1251 
   1252    return this.getProfile(profileId);
   1253  }
   1254 
   1255  async deleteProfile(aProfile) {
   1256    if (aProfile.id == this.currentProfile.id) {
   1257      throw new Error(
   1258        "Use `deleteCurrentProfile` to delete the current profile."
   1259      );
   1260    }
   1261 
   1262    // First attempt to remove the profile's directories. This will attempt to
   1263    // locate the directories and so will throw an exception if the profile is
   1264    // currently in use.
   1265    await this.#profileService.removeProfileFilesByPath(
   1266      await aProfile.rootDir,
   1267      null,
   1268      0
   1269    );
   1270 
   1271    // Then we can remove from the database.
   1272    await this.#connection.execute("DELETE FROM Profiles WHERE id = :id;", {
   1273      id: aProfile.id,
   1274    });
   1275 
   1276    ProfilesDatastoreService.notify();
   1277  }
   1278 
   1279  /**
   1280   * Schedule deletion of the current SelectableProfile as a background task.
   1281   */
   1282  async deleteCurrentProfile() {
   1283    let profiles = await this.getAllProfiles();
   1284 
   1285    if (profiles.length <= 1) {
   1286      await this.createNewProfile();
   1287      await this.setShowProfileSelectorWindow(false);
   1288 
   1289      profiles = await this.getAllProfiles();
   1290    }
   1291 
   1292    // TODO: (Bug 1923980) How should we choose the new default profile?
   1293    let newDefault = profiles.find(p => p.id !== this.currentProfile.id);
   1294    await this.setDefaultProfileForGroup(newDefault);
   1295 
   1296    await this.currentProfile.removeDesktopShortcut();
   1297 
   1298    await this.#connection.executeBeforeShutdown(
   1299      "SelectableProfileService: deleteCurrentProfile",
   1300      async db => {
   1301        await db.execute("DELETE FROM Profiles WHERE id = :id;", {
   1302          id: this.currentProfile.id,
   1303        });
   1304 
   1305        // TODO(bug 1969488): Make this less tightly coupled so consumers of the
   1306        // ProfilesDatastoreService can register cleanup actions to occur during
   1307        // profile deletion.
   1308        await db.execute(
   1309          "DELETE FROM NimbusEnrollments WHERE profileId = :profileId;",
   1310          {
   1311            profileId: lazy.ExperimentAPI.profileId,
   1312          }
   1313        );
   1314 
   1315        await db.execute(
   1316          "DELETE FROM NimbusSyncTimestamps WHERE profileId = :profileId;",
   1317          {
   1318            profileId: lazy.ExperimentAPI.profileId,
   1319          }
   1320        );
   1321      }
   1322    );
   1323 
   1324    if (AppConstants.MOZ_BACKGROUNDTASKS) {
   1325      // Schedule deletion of the profile directories.
   1326      const runner = Cc["@mozilla.org/backgroundtasksrunner;1"].getService(
   1327        Ci.nsIBackgroundTasksRunner
   1328      );
   1329      let rootDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
   1330      let localDir = Services.dirsvc.get("ProfLD", Ci.nsIFile);
   1331      runner.runInDetachedProcess("removeProfileFiles", [
   1332        rootDir.path,
   1333        localDir.path,
   1334        180,
   1335      ]);
   1336    }
   1337  }
   1338 
   1339  /**
   1340   * Write an updated profile to the DB.
   1341   *
   1342   * @param {SelectableProfile} aSelectableProfile The SelectableProfile to be updated
   1343   */
   1344  async updateProfile(aSelectableProfile) {
   1345    let profileObj = aSelectableProfile.toDbObject();
   1346 
   1347    await this.#connection.execute(
   1348      `UPDATE Profiles
   1349       SET path = :path, name = :name, avatar = :avatar, themeId = :themeId, themeFg = :themeFg, themeBg = :themeBg
   1350       WHERE id = :id;`,
   1351      profileObj
   1352    );
   1353 
   1354    if (aSelectableProfile.id == this.#currentProfile.id) {
   1355      // Force a rebuild of the taskbar icon.
   1356      this.#badge = null;
   1357      this.#currentProfile = aSelectableProfile;
   1358    }
   1359 
   1360    ProfilesDatastoreService.notify();
   1361  }
   1362 
   1363  /**
   1364   * Create and launch a new SelectableProfile and add it to the group datastore.
   1365   * This is an unmanaged profile from the nsToolkitProfile perspective.
   1366   *
   1367   * If the user has never created a SelectableProfile before, the currently
   1368   * running toolkit profile will be added to the datastore along with the
   1369   * newly created profile.
   1370   *
   1371   * Launches the new SelectableProfile in a new instance after creating it.
   1372   *
   1373   * @param {boolean} [launchProfile=true] Whether or not this should launch
   1374   * the newly created profile.
   1375   *
   1376   * @returns {SelectableProfile} The profile just created.
   1377   */
   1378  async createNewProfile(launchProfile = true) {
   1379    await this.maybeSetupDataStore();
   1380 
   1381    let profile = await this.#createProfile();
   1382    if (launchProfile) {
   1383      this.launchInstance(profile, ["about:newprofile"]);
   1384    }
   1385    return profile;
   1386  }
   1387 
   1388  /**
   1389   * Get the complete list of profiles in the group.
   1390   *
   1391   * @returns {Array<SelectableProfile>}
   1392   *   An array of profiles in the group.
   1393   */
   1394  async getAllProfiles() {
   1395    if (!this.#connection) {
   1396      return [];
   1397    }
   1398 
   1399    return (await this.#connection.executeCached("SELECT * FROM Profiles;"))
   1400      .map(row => {
   1401        return new SelectableProfile(row);
   1402      })
   1403      .sort((p1, p2) => p1.name.localeCompare(p2.name));
   1404  }
   1405 
   1406  /**
   1407   * Get the number of profiles in the group.
   1408   *
   1409   * @returns {number}
   1410   *   The number of profiles in the group.
   1411   */
   1412  async getProfileCount() {
   1413    if (!this.#connection) {
   1414      return 0;
   1415    }
   1416 
   1417    let rows = await this.#connection.executeCached(
   1418      'SELECT COUNT(*) AS "count" FROM "Profiles";'
   1419    );
   1420 
   1421    return rows[0]?.getResultByName("count") ?? 0;
   1422  }
   1423 
   1424  /**
   1425   * Get a specific profile by its internal ID.
   1426   *
   1427   * @param {number} aProfileID The internal id of the profile
   1428   * @returns {SelectableProfile}
   1429   *   The specific profile.
   1430   */
   1431  async getProfile(aProfileID) {
   1432    if (!this.#connection) {
   1433      return null;
   1434    }
   1435 
   1436    let row = (
   1437      await this.#connection.executeCached(
   1438        "SELECT * FROM Profiles WHERE id = :id;",
   1439        {
   1440          id: aProfileID,
   1441        }
   1442      )
   1443    )[0];
   1444 
   1445    return row ? new SelectableProfile(row) : null;
   1446  }
   1447 
   1448  /**
   1449   * Get a specific profile by its name.
   1450   *
   1451   * @param {string} aProfileName The name of the profile
   1452   * @returns {SelectableProfile}
   1453   *   The specific profile.
   1454   */
   1455  async getProfileByName(aProfileName) {
   1456    if (!this.#connection) {
   1457      return null;
   1458    }
   1459 
   1460    let row = (
   1461      await this.#connection.execute(
   1462        "SELECT * FROM Profiles WHERE name = :name;",
   1463        {
   1464          name: aProfileName,
   1465        }
   1466      )
   1467    )[0];
   1468 
   1469    return row ? new SelectableProfile(row) : null;
   1470  }
   1471 
   1472  /**
   1473   * Get a specific profile by its absolute path.
   1474   *
   1475   * @param {nsIFile} aProfilePath The path of the profile
   1476   * @returns {SelectableProfile|null}
   1477   */
   1478  async getProfileByPath(aProfilePath) {
   1479    if (!this.#connection) {
   1480      return null;
   1481    }
   1482 
   1483    let relativePath = this.getRelativeProfilePath(aProfilePath);
   1484    let row = (
   1485      await this.#connection.execute(
   1486        "SELECT * FROM Profiles WHERE path = :path;",
   1487        {
   1488          path: relativePath,
   1489        }
   1490      )
   1491    )[0];
   1492 
   1493    return row ? new SelectableProfile(row) : null;
   1494  }
   1495 
   1496  // Shared Prefs management
   1497 
   1498  getPrefValueFromRow(row) {
   1499    let value = row.getResultByName("value");
   1500    if (row.getResultByName("isBoolean")) {
   1501      return value === 1;
   1502    }
   1503 
   1504    return value;
   1505  }
   1506 
   1507  /**
   1508   * Get all shared prefs as a list.
   1509   *
   1510   * @returns {{name: string, value: *, type: string}}
   1511   */
   1512  async getAllDBPrefs() {
   1513    return (
   1514      await this.#connection.executeCached("SELECT * FROM SharedPrefs;")
   1515    ).map(row => {
   1516      let value = this.getPrefValueFromRow(row);
   1517      return {
   1518        name: row.getResultByName("name"),
   1519        value,
   1520        type: typeof value,
   1521      };
   1522    });
   1523  }
   1524 
   1525  /**
   1526   * Get the value of a specific shared pref from the database.
   1527   *
   1528   * @param {string} aPrefName The name of the pref to get
   1529   *
   1530   * @returns {any} Value of the pref
   1531   */
   1532  async getDBPref(aPrefName) {
   1533    let rows = await this.#connection.execute(
   1534      "SELECT value, isBoolean FROM SharedPrefs WHERE name = :name;",
   1535      {
   1536        name: aPrefName,
   1537      }
   1538    );
   1539 
   1540    if (!rows.length) {
   1541      throw new Error(`Unknown preference '${aPrefName}'`);
   1542    }
   1543 
   1544    return this.getPrefValueFromRow(rows[0]);
   1545  }
   1546 
   1547  async setDBPref(aPrefName, aPrefValue) {
   1548    if (!Cu.isInAutomation) {
   1549      return;
   1550    }
   1551 
   1552    await this.#setDBPref(aPrefName, aPrefValue);
   1553  }
   1554 
   1555  /**
   1556   * Insert or update a pref value in the database, then notify() other running instances.
   1557   *
   1558   * @param {string} aPrefName The name of the pref
   1559   * @param {any} aPrefValue The value of the pref
   1560   */
   1561  async #setDBPref(aPrefName, aPrefValue) {
   1562    await this.#connection.execute(
   1563      "INSERT INTO SharedPrefs(id, name, value, isBoolean) VALUES (NULL, :name, :value, :isBoolean) ON CONFLICT(name) DO UPDATE SET value=excluded.value, isBoolean=excluded.isBoolean;",
   1564      {
   1565        name: aPrefName,
   1566        value: aPrefValue,
   1567        isBoolean: typeof aPrefValue === "boolean",
   1568      }
   1569    );
   1570 
   1571    ProfilesDatastoreService.notify();
   1572  }
   1573 
   1574  // Starts tracking a new shared pref across the profiles.
   1575  async trackPref(aPrefName) {
   1576    await this.flushSharedPrefToDatabase(aPrefName);
   1577  }
   1578 
   1579  /**
   1580   * Remove a shared pref from the database, then notify() other running instances.
   1581   *
   1582   * @param {string} aPrefName The name of the pref to delete
   1583   */
   1584  async #deleteDBPref(aPrefName) {
   1585    // We mark the value as null if it already exists in the database so other profiles know what
   1586    // preference to remove.
   1587    await this.#connection.executeCached(
   1588      "UPDATE SharedPrefs SET value=NULL, isBoolean=FALSE WHERE name=:name;",
   1589      {
   1590        name: aPrefName,
   1591      }
   1592    );
   1593 
   1594    ProfilesDatastoreService.notify();
   1595  }
   1596 }
   1597 
   1598 const SelectableProfileService = new SelectableProfileServiceClass();
   1599 export { SelectableProfileService };
   1600 
   1601 /**
   1602 * A command line handler for receiving notifications from other instances that
   1603 * the profiles database has been updated.
   1604 */
   1605 export class CommandLineHandler {
   1606  static classID = Components.ID("{38971986-c834-4f52-bf17-5123fbc9dde5}");
   1607  static contractID = "@mozilla.org/browser/selectable-profiles-service-clh;1";
   1608 
   1609  QueryInterface = ChromeUtils.generateQI([Ci.nsICommandLineHandler]);
   1610 
   1611  /**
   1612   * Finds the current default profile path for the current profile group.
   1613   *
   1614   * @returns {Promise<string|null>}
   1615   */
   1616  async findDefaultProfilePath() {
   1617    try {
   1618      let profilesRoot =
   1619        ProfilesDatastoreService.constructor.getDirectory("UAppData");
   1620 
   1621      let iniPath = PathUtils.join(profilesRoot.path, "profiles.ini");
   1622 
   1623      let iniData = await IOUtils.readUTF8(iniPath);
   1624 
   1625      let iniParser = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
   1626        .getService(Ci.nsIINIParserFactory)
   1627        .createINIParser(null);
   1628      iniParser.initFromString(iniData);
   1629 
   1630      // loop is guaranteed to exit once it finds a profile section with no path.
   1631      // eslint-disable-next-line no-constant-condition
   1632      for (let i = 0; true; i++) {
   1633        let section = `Profile${i}`;
   1634 
   1635        let path;
   1636        try {
   1637          path = iniParser.getString(section, "Path");
   1638        } catch (e) {
   1639          // No path means this section doesn't exist so we've seen them all.
   1640          break;
   1641        }
   1642 
   1643        try {
   1644          let storeID = iniParser.getString(section, "StoreID");
   1645 
   1646          if (storeID != SelectableProfileService.storeID) {
   1647            continue;
   1648          }
   1649 
   1650          let isRelative = iniParser.getString(section, "IsRelative") == "1";
   1651          if (isRelative) {
   1652            let profileDir = Cc["@mozilla.org/file/local;1"].createInstance(
   1653              Ci.nsIFile
   1654            );
   1655            profileDir.setRelativeDescriptor(profilesRoot, path);
   1656            path = profileDir.path;
   1657          }
   1658 
   1659          return path;
   1660        } catch (e) {
   1661          // Ignore missing keys and just continue to the next section.
   1662          continue;
   1663        }
   1664      }
   1665    } catch (e) {
   1666      console.error(e);
   1667    }
   1668 
   1669    return null;
   1670  }
   1671 
   1672  /**
   1673   * Attempts to parse the arguments expected when opening URLs from other
   1674   * applications on macOS.
   1675   *
   1676   * @param {Array<string>} args The command line arguments.
   1677   * @returns {boolean} True if the arguments matched the expected form.
   1678   */
   1679  openUrls(args) {
   1680    // Arguments are expected to be in pairs of "-url" "<url>".
   1681    if (args.length % 2 != 0) {
   1682      return false;
   1683    }
   1684 
   1685    for (let i = 0; i < args.length; i += 2) {
   1686      if (args[i] != "-url") {
   1687        return false;
   1688      }
   1689    }
   1690 
   1691    // Now the arguments are verified to only be "-url" arguments we can pass
   1692    // them directly to the only handler for those arguments.
   1693    let workingDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
   1694    let cmdLine = Cu.createCommandLine(
   1695      args,
   1696      workingDir,
   1697      Ci.nsICommandLine.STATE_REMOTE_EXPLICIT
   1698    );
   1699 
   1700    try {
   1701      let handler = Cc["@mozilla.org/browser/final-clh;1"].createInstance(
   1702        Ci.nsICommandLineHandler
   1703      );
   1704      handler.handle(cmdLine);
   1705    } catch (e) {
   1706      console.error(e);
   1707      return false;
   1708    }
   1709 
   1710    return true;
   1711  }
   1712 
   1713  async redirectCommandLine(args) {
   1714    let defaultPath = await this.findDefaultProfilePath();
   1715 
   1716    if (defaultPath) {
   1717      if (
   1718        defaultPath == SelectableProfileService.currentProfile.path &&
   1719        this.openUrls(args)
   1720      ) {
   1721        return;
   1722      }
   1723 
   1724      // Attempt to use the remoting service to send the arguments to any
   1725      // existing instance of this profile (this even works for the current
   1726      // instance on macOS which is the only platform we call this for).
   1727      try {
   1728        SelectableProfileService.sendCommandLine(defaultPath, args, true);
   1729 
   1730        return;
   1731      } catch (e) {
   1732        // This is expected to fail if no instance is running with the profile.
   1733      }
   1734    }
   1735 
   1736    // Fall back to re-launching.
   1737    SelectableProfileService.execProcess(["-foreground", ...args]);
   1738  }
   1739 
   1740  handle(cmdLine) {
   1741    // This is only ever sent when the application is already running.
   1742    if (cmdLine.handleFlag(COMMAND_LINE_UPDATE, true)) {
   1743      if (SelectableProfileService.initialized) {
   1744        SelectableProfileService.databaseChanged("remote").catch(console.error);
   1745      }
   1746      cmdLine.preventDefault = true;
   1747      return;
   1748    }
   1749 
   1750    // Sent from the profiles UI to launch a profile if it doesn't exist or bring it to the front
   1751    // if it is already running. In the case where this instance is already running we want to block
   1752    // the normal action of opening a new empty window and instead raise the application to the
   1753    // front manually.
   1754    if (
   1755      cmdLine.handleFlag(COMMAND_LINE_ACTIVATE, true) &&
   1756      cmdLine.state != Ci.nsICommandLine.STATE_INITIAL_LAUNCH
   1757    ) {
   1758      let win = Services.wm.getMostRecentBrowserWindow();
   1759      if (win) {
   1760        win.focus();
   1761        cmdLine.preventDefault = true;
   1762        return;
   1763      }
   1764    }
   1765 
   1766    // On macOS requests to open URLs from other applications in an already running Firefox are
   1767    // passed directly to the running instance via the
   1768    // [MacApplicationDelegate::openURLs](https://searchfox.org/mozilla-central/rev/b0b003e992b199fd8e13999bd5d06d06c84a3fd2/toolkit/xre/MacApplicationDelegate.mm#323-326)
   1769    // API. This means it skips over the step in startup where we choose the correct profile to open
   1770    // the link in. Here we intercept such requests.
   1771    if (
   1772      cmdLine.state == Ci.nsICommandLine.STATE_REMOTE_EXPLICIT &&
   1773      Services.appinfo.OS === "Darwin"
   1774    ) {
   1775      // If we aren't enabled or initialized there can't be other profiles.
   1776      if (
   1777        !SelectableProfileService.isEnabled ||
   1778        !SelectableProfileService.initialized
   1779      ) {
   1780        return;
   1781      }
   1782 
   1783      if (!cmdLine.length) {
   1784        return;
   1785      }
   1786 
   1787      // We need to parse profiles.ini to determine whether this profile is the
   1788      // current default and this requires async I/O. So we're just going to
   1789      // tell other command line handlers that this command line has been handled
   1790      // as we can't wait for the async operation to complete.
   1791      let args = [];
   1792      for (let i = 0; i < cmdLine.length; i++) {
   1793        args.push(cmdLine.getArgument(i));
   1794      }
   1795 
   1796      this.redirectCommandLine(args).catch(console.error);
   1797 
   1798      cmdLine.removeArguments(0, cmdLine.length - 1);
   1799      cmdLine.preventDefault = true;
   1800    }
   1801  }
   1802 }