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/** 6 * nsIAutoCompleteResult and nsILoginAutoCompleteSearch implementations for saved logins. 7 */ 8 9"use strict"; 10 11const EXPORTED_SYMBOLS = ["LoginAutoComplete", "LoginAutoCompleteResult"]; 12 13const { XPCOMUtils } = ChromeUtils.import( 14 "resource://gre/modules/XPCOMUtils.jsm" 15); 16const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 17 18ChromeUtils.defineModuleGetter( 19 this, 20 "AutoCompleteChild", 21 "resource://gre/actors/AutoCompleteChild.jsm" 22); 23ChromeUtils.defineModuleGetter( 24 this, 25 "BrowserUtils", 26 "resource://gre/modules/BrowserUtils.jsm" 27); 28ChromeUtils.defineModuleGetter( 29 this, 30 "InsecurePasswordUtils", 31 "resource://gre/modules/InsecurePasswordUtils.jsm" 32); 33ChromeUtils.defineModuleGetter( 34 this, 35 "LoginFormFactory", 36 "resource://gre/modules/LoginFormFactory.jsm" 37); 38ChromeUtils.defineModuleGetter( 39 this, 40 "LoginHelper", 41 "resource://gre/modules/LoginHelper.jsm" 42); 43ChromeUtils.defineModuleGetter( 44 this, 45 "LoginManagerChild", 46 "resource://gre/modules/LoginManagerChild.jsm" 47); 48 49ChromeUtils.defineModuleGetter( 50 this, 51 "NewPasswordModel", 52 "resource://gre/modules/NewPasswordModel.jsm" 53); 54 55XPCOMUtils.defineLazyServiceGetter( 56 this, 57 "formFillController", 58 "@mozilla.org/satchel/form-fill-controller;1", 59 Ci.nsIFormFillController 60); 61XPCOMUtils.defineLazyPreferenceGetter( 62 this, 63 "SHOULD_SHOW_ORIGIN", 64 "signon.showAutoCompleteOrigins" 65); 66 67XPCOMUtils.defineLazyGetter(this, "log", () => { 68 return LoginHelper.createLogger("LoginAutoComplete"); 69}); 70XPCOMUtils.defineLazyGetter(this, "passwordMgrBundle", () => { 71 return Services.strings.createBundle( 72 "chrome://passwordmgr/locale/passwordmgr.properties" 73 ); 74}); 75XPCOMUtils.defineLazyGetter(this, "dateAndTimeFormatter", () => { 76 return new Services.intl.DateTimeFormat(undefined, { 77 dateStyle: "medium", 78 }); 79}); 80 81function loginSort(formHostPort, a, b) { 82 let maybeHostPortA = LoginHelper.maybeGetHostPortForURL(a.origin); 83 let maybeHostPortB = LoginHelper.maybeGetHostPortForURL(b.origin); 84 if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) { 85 return -1; 86 } 87 if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) { 88 return 1; 89 } 90 91 if (a.httpRealm !== b.httpRealm) { 92 // Sort HTTP auth. logins after form logins for the same origin. 93 if (b.httpRealm === null) { 94 return 1; 95 } 96 if (a.httpRealm === null) { 97 return -1; 98 } 99 } 100 101 let userA = a.username.toLowerCase(); 102 let userB = b.username.toLowerCase(); 103 104 if (userA < userB) { 105 return -1; 106 } 107 108 if (userA > userB) { 109 return 1; 110 } 111 112 return 0; 113} 114 115function findDuplicates(loginList) { 116 let seen = new Set(); 117 let duplicates = new Set(); 118 for (let login of loginList) { 119 if (seen.has(login.username)) { 120 duplicates.add(login.username); 121 } 122 seen.add(login.username); 123 } 124 return duplicates; 125} 126 127function getLocalizedString(key, formatArgs = null) { 128 if (formatArgs) { 129 return passwordMgrBundle.formatStringFromName(key, formatArgs); 130 } 131 return passwordMgrBundle.GetStringFromName(key); 132} 133 134class AutocompleteItem { 135 constructor(style) { 136 this.comment = ""; 137 this.style = style; 138 this.value = ""; 139 } 140 141 removeFromStorage() { 142 /* Do nothing by default */ 143 } 144} 145 146class InsecureLoginFormAutocompleteItem extends AutocompleteItem { 147 constructor() { 148 super("insecureWarning"); 149 150 XPCOMUtils.defineLazyGetter(this, "label", () => { 151 let learnMoreString = getLocalizedString("insecureFieldWarningLearnMore"); 152 return getLocalizedString("insecureFieldWarningDescription2", [ 153 learnMoreString, 154 ]); 155 }); 156 } 157} 158 159class LoginAutocompleteItem extends AutocompleteItem { 160 constructor( 161 login, 162 hasBeenTypePassword, 163 duplicateUsernames, 164 actor, 165 isOriginMatched 166 ) { 167 super(SHOULD_SHOW_ORIGIN ? "loginWithOrigin" : "login"); 168 this._login = login.QueryInterface(Ci.nsILoginMetaInfo); 169 this._actor = actor; 170 171 this._isDuplicateUsername = 172 login.username && duplicateUsernames.has(login.username); 173 174 XPCOMUtils.defineLazyGetter(this, "label", () => { 175 let username = login.username; 176 // If login is empty or duplicated we want to append a modification date to it. 177 if (!username || this._isDuplicateUsername) { 178 if (!username) { 179 username = getLocalizedString("noUsername"); 180 } 181 let time = dateAndTimeFormatter.format( 182 new Date(login.timePasswordChanged) 183 ); 184 username = getLocalizedString("loginHostAge", [username, time]); 185 } 186 return username; 187 }); 188 189 XPCOMUtils.defineLazyGetter(this, "value", () => { 190 return hasBeenTypePassword ? login.password : login.username; 191 }); 192 193 XPCOMUtils.defineLazyGetter(this, "comment", () => { 194 return JSON.stringify({ 195 guid: login.guid, 196 login, 197 isDuplicateUsername: this._isDuplicateUsername, 198 isOriginMatched, 199 comment: 200 isOriginMatched && login.httpRealm === null 201 ? getLocalizedString("displaySameOrigin") 202 : login.displayOrigin, 203 }); 204 }); 205 } 206 207 removeFromStorage() { 208 if (this._actor) { 209 let vanilla = LoginHelper.loginToVanillaObject(this._login); 210 this._actor.sendAsyncMessage("PasswordManager:removeLogin", { 211 login: vanilla, 212 }); 213 } else { 214 Services.logins.removeLogin(this._login); 215 } 216 } 217} 218 219class GeneratedPasswordAutocompleteItem extends AutocompleteItem { 220 constructor(generatedPassword, willAutoSaveGeneratedPassword) { 221 super("generatedPassword"); 222 XPCOMUtils.defineLazyGetter(this, "comment", () => { 223 return JSON.stringify({ 224 generatedPassword, 225 willAutoSaveGeneratedPassword, 226 }); 227 }); 228 this.value = generatedPassword; 229 230 XPCOMUtils.defineLazyGetter(this, "label", () => { 231 return getLocalizedString("useASecurelyGeneratedPassword"); 232 }); 233 } 234} 235 236class ImportableLoginsAutocompleteItem extends AutocompleteItem { 237 constructor(browserId, hostname) { 238 super("importableLogins"); 239 this.label = browserId; 240 this.comment = hostname; 241 } 242 243 removeFromStorage() { 244 Services.telemetry.recordEvent("exp_import", "event", "delete", this.label); 245 } 246} 247 248class LoginsFooterAutocompleteItem extends AutocompleteItem { 249 constructor(formHostname, telemetryEventData) { 250 super("loginsFooter"); 251 XPCOMUtils.defineLazyGetter(this, "comment", () => { 252 // The comment field of `loginsFooter` results have many additional pieces of 253 // information for telemetry purposes. After bug 1555209, this information 254 // can be passed to the parent process outside of nsIAutoCompleteResult APIs 255 // so we won't need this hack. 256 return JSON.stringify({ 257 ...telemetryEventData, 258 formHostname, 259 }); 260 }); 261 262 XPCOMUtils.defineLazyGetter(this, "label", () => { 263 return getLocalizedString("viewSavedLogins.label"); 264 }); 265 } 266} 267 268// nsIAutoCompleteResult implementation 269function LoginAutoCompleteResult( 270 aSearchString, 271 matchingLogins, 272 formOrigin, 273 { 274 generatedPassword, 275 willAutoSaveGeneratedPassword, 276 importable, 277 isSecure, 278 actor, 279 hasBeenTypePassword, 280 hostname, 281 telemetryEventData, 282 } 283) { 284 let hidingFooterOnPWFieldAutoOpened = false; 285 const importableBrowsers = 286 importable?.state === "import" && importable?.browsers; 287 function isFooterEnabled() { 288 // We need to check LoginHelper.enabled here since the insecure warning should 289 // appear even if pwmgr is disabled but the footer should never appear in that case. 290 if (!LoginHelper.showAutoCompleteFooter || !LoginHelper.enabled) { 291 return false; 292 } 293 294 // Don't show the footer on non-empty password fields as it's not providing 295 // value and only adding noise since a password was already filled. 296 if (hasBeenTypePassword && aSearchString && !generatedPassword) { 297 log.debug("Hiding footer: non-empty password field"); 298 return false; 299 } 300 301 if ( 302 !importableBrowsers && 303 !matchingLogins.length && 304 !generatedPassword && 305 hasBeenTypePassword && 306 formFillController.passwordPopupAutomaticallyOpened 307 ) { 308 hidingFooterOnPWFieldAutoOpened = true; 309 log.debug( 310 "Hiding footer: no logins and the popup was opened upon focus of the pw. field" 311 ); 312 return false; 313 } 314 315 return true; 316 } 317 318 this.searchString = aSearchString; 319 320 // Build up the array of autocomplete rows to display. 321 this._rows = []; 322 323 // Insecure field warning comes first if it applies and is enabled. 324 if (!isSecure && LoginHelper.showInsecureFieldWarning) { 325 this._rows.push(new InsecureLoginFormAutocompleteItem()); 326 } 327 328 // Saved login items 329 let formHostPort = LoginHelper.maybeGetHostPortForURL(formOrigin); 330 let logins = matchingLogins.sort(loginSort.bind(null, formHostPort)); 331 let duplicateUsernames = findDuplicates(matchingLogins); 332 333 for (let login of logins) { 334 let item = new LoginAutocompleteItem( 335 login, 336 hasBeenTypePassword, 337 duplicateUsernames, 338 actor, 339 LoginHelper.isOriginMatching(login.origin, formOrigin, { 340 schemeUpgrades: LoginHelper.schemeUpgrades, 341 }) 342 ); 343 this._rows.push(item); 344 } 345 346 // The footer comes last if it's enabled 347 if (isFooterEnabled()) { 348 if (generatedPassword) { 349 this._rows.push( 350 new GeneratedPasswordAutocompleteItem( 351 generatedPassword, 352 willAutoSaveGeneratedPassword 353 ) 354 ); 355 } 356 357 // Suggest importing logins if there are none found. 358 if (!logins.length && importableBrowsers) { 359 this._rows.push( 360 ...importableBrowsers.map( 361 browserId => new ImportableLoginsAutocompleteItem(browserId, hostname) 362 ) 363 ); 364 } 365 366 this._rows.push( 367 new LoginsFooterAutocompleteItem(hostname, telemetryEventData) 368 ); 369 } 370 371 // Determine the result code and default index. 372 if (this.matchCount > 0) { 373 this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; 374 this.defaultIndex = 0; 375 // For experiment telemetry, record how many importable logins were 376 // available when showing the popup and some extra data. 377 Services.telemetry.recordEvent( 378 "exp_import", 379 "impression", 380 "popup", 381 (importable?.browsers?.length ?? 0) + "", 382 { 383 loginsCount: logins.length + "", 384 searchLength: aSearchString.length + "", 385 } 386 ); 387 } else if (hidingFooterOnPWFieldAutoOpened) { 388 // We use a failure result so that the empty results aren't re-used for when 389 // the user tries to manually open the popup (we want the footer in that case). 390 this.searchResult = Ci.nsIAutoCompleteResult.RESULT_FAILURE; 391 this.defaultIndex = -1; 392 } 393} 394 395LoginAutoCompleteResult.prototype = { 396 QueryInterface: ChromeUtils.generateQI([ 397 Ci.nsIAutoCompleteResult, 398 Ci.nsISupportsWeakReference, 399 ]), 400 401 /** 402 * Accessed via .wrappedJSObject 403 * @private 404 */ 405 get logins() { 406 return this._rows 407 .filter(item => { 408 return item.constructor === LoginAutocompleteItem; 409 }) 410 .map(item => item._login); 411 }, 412 413 // Allow autoCompleteSearch to get at the JS object so it can 414 // modify some readonly properties for internal use. 415 get wrappedJSObject() { 416 return this; 417 }, 418 419 // Interfaces from idl... 420 searchString: null, 421 searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH, 422 defaultIndex: -1, 423 errorDescription: "", 424 get matchCount() { 425 return this._rows.length; 426 }, 427 428 getValueAt(index) { 429 if (index < 0 || index >= this.matchCount) { 430 throw new Error("Index out of range."); 431 } 432 return this._rows[index].value; 433 }, 434 435 getLabelAt(index) { 436 if (index < 0 || index >= this.matchCount) { 437 throw new Error("Index out of range."); 438 } 439 return this._rows[index].label; 440 }, 441 442 getCommentAt(index) { 443 if (index < 0 || index >= this.matchCount) { 444 throw new Error("Index out of range."); 445 } 446 return this._rows[index].comment; 447 }, 448 449 getStyleAt(index) { 450 return this._rows[index].style; 451 }, 452 453 getImageAt(index) { 454 return ""; 455 }, 456 457 getFinalCompleteValueAt(index) { 458 return this.getValueAt(index); 459 }, 460 461 removeValueAt(index) { 462 if (index < 0 || index >= this.matchCount) { 463 throw new Error("Index out of range."); 464 } 465 466 let [removedItem] = this._rows.splice(index, 1); 467 468 if (this.defaultIndex > this._rows.length) { 469 this.defaultIndex--; 470 } 471 472 removedItem.removeFromStorage(); 473 }, 474}; 475 476function LoginAutoComplete() { 477 // HTMLInputElement to number, the element's new-password heuristic confidence score 478 this._cachedNewPasswordScore = new WeakMap(); 479} 480LoginAutoComplete.prototype = { 481 classID: Components.ID("{2bdac17c-53f1-4896-a521-682ccdeef3a8}"), 482 QueryInterface: ChromeUtils.generateQI([Ci.nsILoginAutoCompleteSearch]), 483 484 _autoCompleteLookupPromise: null, 485 _cachedNewPasswordScore: null, 486 487 /** 488 * Yuck. This is called directly by satchel: 489 * nsFormFillController::StartSearch() 490 * [toolkit/components/satchel/nsFormFillController.cpp] 491 * 492 * We really ought to have a simple way for code to register an 493 * auto-complete provider, and not have satchel calling pwmgr directly. 494 * 495 * @param {string} aSearchString The value typed in the field. 496 * @param {nsIAutoCompleteResult} aPreviousResult 497 * @param {HTMLInputElement} aElement 498 * @param {nsIFormAutoCompleteObserver} aCallback 499 */ 500 startSearch(aSearchString, aPreviousResult, aElement, aCallback) { 501 let { isNullPrincipal } = aElement.nodePrincipal; 502 if (aElement.nodePrincipal.schemeIs("about")) { 503 // Don't show autocomplete results for about: pages. 504 // XXX: Don't we need to call the callback here? 505 return; 506 } 507 508 let searchStartTimeMS = Services.telemetry.msSystemNow(); 509 510 // Show the insecure login warning in the passwords field on null principal documents. 511 let isSecure = !isNullPrincipal; 512 // Avoid loading InsecurePasswordUtils.jsm in a sandboxed document (e.g. an ad. frame) if we 513 // already know it has a null principal and will therefore get the insecure autocomplete 514 // treatment. 515 // InsecurePasswordUtils doesn't handle the null principal case as not secure because we don't 516 // want the same treatment: 517 // * The web console warnings will be confusing (as they're primarily about http:) and not very 518 // useful if the developer intentionally sandboxed the document. 519 // * The site identity insecure field warning would require LoginManagerChild being loaded and 520 // listening to some of the DOM events we're ignoring in null principal documents. For memory 521 // reasons it's better to not load LMC at all for these sandboxed frames. Also, if the top- 522 // document is sandboxing a document, it probably doesn't want that sandboxed document to be 523 // able to affect the identity icon in the address bar by adding a password field. 524 let form = LoginFormFactory.createFromField(aElement); 525 if (isSecure) { 526 isSecure = InsecurePasswordUtils.isFormSecure(form); 527 } 528 let { hasBeenTypePassword } = aElement; 529 let hostname = aElement.ownerDocument.documentURIObject.host; 530 let formOrigin = LoginHelper.getLoginOrigin( 531 aElement.ownerDocument.documentURI 532 ); 533 534 let loginManagerActor = LoginManagerChild.forWindow(aElement.ownerGlobal); 535 536 let completeSearch = async autoCompleteLookupPromise => { 537 // Assign to the member synchronously before awaiting the Promise. 538 this._autoCompleteLookupPromise = autoCompleteLookupPromise; 539 540 let { 541 generatedPassword, 542 importable, 543 logins, 544 willAutoSaveGeneratedPassword, 545 } = await autoCompleteLookupPromise; 546 547 // If the search was canceled before we got our 548 // results, don't bother reporting them. 549 // N.B. This check must occur after the `await` above for it to be 550 // effective. 551 if (this._autoCompleteLookupPromise !== autoCompleteLookupPromise) { 552 log.debug("ignoring result from previous search"); 553 return; 554 } 555 556 let telemetryEventData = { 557 acFieldName: aElement.getAutocompleteInfo().fieldName, 558 hadPrevious: !!aPreviousResult, 559 typeWasPassword: aElement.hasBeenTypePassword, 560 fieldType: aElement.type, 561 searchStartTimeMS, 562 stringLength: aSearchString.length, 563 }; 564 565 this._autoCompleteLookupPromise = null; 566 let results = new LoginAutoCompleteResult( 567 aSearchString, 568 logins, 569 formOrigin, 570 { 571 generatedPassword, 572 willAutoSaveGeneratedPassword, 573 importable, 574 actor: loginManagerActor, 575 isSecure, 576 hasBeenTypePassword, 577 hostname, 578 telemetryEventData, 579 } 580 ); 581 aCallback.onSearchCompletion(results); 582 }; 583 584 if (isNullPrincipal) { 585 // Don't search login storage when the field has a null principal as we don't want to fill 586 // logins for the `location` in this case. 587 completeSearch(Promise.resolve({ logins: [] })); 588 return; 589 } 590 591 if ( 592 hasBeenTypePassword && 593 aSearchString && 594 !loginManagerActor.isPasswordGenerationForcedOn(aElement) 595 ) { 596 // Return empty result on password fields with password already filled, 597 // unless password generation was forced. 598 completeSearch(Promise.resolve({ logins: [] })); 599 return; 600 } 601 602 if (!LoginHelper.enabled) { 603 completeSearch(Promise.resolve({ logins: [] })); 604 return; 605 } 606 607 let previousResult; 608 if (aPreviousResult) { 609 previousResult = { 610 searchString: aPreviousResult.searchString, 611 logins: LoginHelper.loginsToVanillaObjects( 612 aPreviousResult.wrappedJSObject.logins 613 ), 614 }; 615 } else { 616 previousResult = null; 617 } 618 619 let acLookupPromise = this._requestAutoCompleteResultsFromParent({ 620 searchString: aSearchString, 621 previousResult, 622 inputElement: aElement, 623 form, 624 formOrigin, 625 hasBeenTypePassword, 626 }); 627 completeSearch(acLookupPromise).catch(log.error.bind(log)); 628 }, 629 630 stopSearch() { 631 this._autoCompleteLookupPromise = null; 632 }, 633 634 async _requestAutoCompleteResultsFromParent({ 635 searchString, 636 previousResult, 637 inputElement, 638 form, 639 formOrigin, 640 hasBeenTypePassword, 641 }) { 642 let actionOrigin = LoginHelper.getFormActionOrigin(form); 643 let autocompleteInfo = inputElement.getAutocompleteInfo(); 644 645 let loginManagerActor = LoginManagerChild.forWindow( 646 inputElement.ownerGlobal 647 ); 648 let forcePasswordGeneration = false; 649 let isProbablyANewPasswordField = false; 650 if (hasBeenTypePassword) { 651 forcePasswordGeneration = loginManagerActor.isPasswordGenerationForcedOn( 652 inputElement 653 ); 654 // Run the Fathom model only if the password field does not have the 655 // autocomplete="new-password" attribute. 656 isProbablyANewPasswordField = 657 autocompleteInfo.fieldName == "new-password" || 658 this._isProbablyANewPasswordField(inputElement); 659 } 660 661 let messageData = { 662 formOrigin, 663 actionOrigin, 664 searchString, 665 previousResult, 666 forcePasswordGeneration, 667 hasBeenTypePassword, 668 isSecure: InsecurePasswordUtils.isFormSecure(form), 669 isProbablyANewPasswordField, 670 }; 671 672 if (LoginHelper.showAutoCompleteFooter) { 673 gAutoCompleteListener.init(); 674 } 675 676 log.debug("LoginAutoComplete search:", { 677 forcePasswordGeneration, 678 isSecure: messageData.isSecure, 679 hasBeenTypePassword, 680 isProbablyANewPasswordField, 681 searchString: hasBeenTypePassword 682 ? "*".repeat(searchString.length) 683 : searchString, 684 }); 685 686 let result = await loginManagerActor.sendQuery( 687 "PasswordManager:autoCompleteLogins", 688 messageData 689 ); 690 691 return { 692 generatedPassword: result.generatedPassword, 693 importable: result.importable, 694 logins: LoginHelper.vanillaObjectsToLogins(result.logins), 695 willAutoSaveGeneratedPassword: result.willAutoSaveGeneratedPassword, 696 }; 697 }, 698 699 _isProbablyANewPasswordField(inputElement) { 700 const threshold = LoginHelper.generationConfidenceThreshold; 701 if (threshold == -1) { 702 // Fathom is disabled 703 return false; 704 } 705 706 let score = this._cachedNewPasswordScore.get(inputElement); 707 if (score) { 708 return score >= threshold; 709 } 710 711 const { rules, type } = NewPasswordModel; 712 const results = rules.against(inputElement); 713 score = results.get(inputElement).scoreFor(type); 714 this._cachedNewPasswordScore.set(inputElement, score); 715 return score >= threshold; 716 }, 717}; 718 719let gAutoCompleteListener = { 720 // Input element on which enter keydown event was fired. 721 keyDownEnterForInput: null, 722 723 added: false, 724 725 init() { 726 if (!this.added) { 727 AutoCompleteChild.addPopupStateListener(this); 728 this.added = true; 729 } 730 }, 731 732 popupStateChanged(messageName, data, target) { 733 switch (messageName) { 734 case "FormAutoComplete:PopupOpened": { 735 let { chromeEventHandler } = target.docShell; 736 chromeEventHandler.addEventListener("keydown", this, true); 737 break; 738 } 739 740 case "FormAutoComplete:PopupClosed": { 741 this.onPopupClosed(data, target); 742 let { chromeEventHandler } = target.docShell; 743 chromeEventHandler.removeEventListener("keydown", this, true); 744 break; 745 } 746 } 747 }, 748 749 handleEvent(event) { 750 if (event.type != "keydown") { 751 return; 752 } 753 754 let focusedElement = formFillController.focusedInput; 755 if ( 756 event.keyCode != event.DOM_VK_RETURN || 757 focusedElement != event.target 758 ) { 759 this.keyDownEnterForInput = null; 760 return; 761 } 762 this.keyDownEnterForInput = focusedElement; 763 }, 764 765 onPopupClosed({ selectedRowComment, selectedRowStyle }, window) { 766 let focusedElement = formFillController.focusedInput; 767 let eventTarget = this.keyDownEnterForInput; 768 this.keyDownEnterForInput = null; 769 if (!eventTarget || eventTarget !== focusedElement) { 770 return; 771 } 772 773 let loginManager = window.windowGlobalChild.getActor("LoginManager"); 774 switch (selectedRowStyle) { 775 case "importableLogins": 776 loginManager.sendAsyncMessage( 777 "PasswordManager:OpenMigrationWizard", 778 selectedRowComment 779 ); 780 Services.telemetry.recordEvent( 781 "exp_import", 782 "event", 783 "enter", 784 selectedRowComment 785 ); 786 break; 787 case "loginsFooter": 788 loginManager.sendAsyncMessage("PasswordManager:OpenPreferences", { 789 entryPoint: "autocomplete", 790 }); 791 Services.telemetry.recordEvent( 792 "exp_import", 793 "event", 794 "enter", 795 "loginsFooter" 796 ); 797 break; 798 } 799 }, 800}; 801