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 }