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"use strict"; 6 7var EXPORTED_SYMBOLS = ["AutoCompleteParent"]; 8 9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 10 11const { XPCOMUtils } = ChromeUtils.import( 12 "resource://gre/modules/XPCOMUtils.jsm" 13); 14 15XPCOMUtils.defineLazyModuleGetters(this, { 16 GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", 17}); 18 19XPCOMUtils.defineLazyPreferenceGetter( 20 this, 21 "DELEGATE_AUTOCOMPLETE", 22 "toolkit.autocomplete.delegate", 23 false 24); 25 26ChromeUtils.defineModuleGetter( 27 this, 28 "setTimeout", 29 "resource://gre/modules/Timer.jsm" 30); 31 32const PREF_SECURITY_DELAY = "security.notification_enable_delay"; 33 34// Stores the browser and actor that has the active popup, used by formfill 35let currentBrowserWeakRef = null; 36let currentActor = null; 37 38let autoCompleteListeners = new Set(); 39 40function compareContext(message) { 41 if ( 42 !currentActor || 43 (currentActor.browsingContext != message.data.browsingContext && 44 currentActor.browsingContext.top != message.data.browsingContext) 45 ) { 46 return false; 47 } 48 49 return true; 50} 51 52// These are two synchronous messages sent by the child. 53// The browsingContext within the message data is either the one that has 54// the active autocomplete popup or the top-level of the one that has 55// the active autocomplete popup. 56Services.ppmm.addMessageListener( 57 "FormAutoComplete:GetSelectedIndex", 58 message => { 59 if (compareContext(message)) { 60 let actor = currentActor; 61 if (actor && actor.openedPopup) { 62 return actor.openedPopup.selectedIndex; 63 } 64 } 65 66 return -1; 67 } 68); 69 70Services.ppmm.addMessageListener("FormAutoComplete:SelectBy", message => { 71 if (compareContext(message)) { 72 let actor = currentActor; 73 if (actor && actor.openedPopup) { 74 actor.openedPopup.selectBy(message.data.reverse, message.data.page); 75 } 76 } 77}); 78 79// AutoCompleteResultView is an abstraction around a list of results. 80// It implements enough of nsIAutoCompleteController and 81// nsIAutoCompleteInput to make the richlistbox popup work. Since only 82// one autocomplete popup should be open at a time, this is a singleton. 83var AutoCompleteResultView = { 84 // nsISupports 85 QueryInterface: ChromeUtils.generateQI([ 86 "nsIAutoCompleteController", 87 "nsIAutoCompleteInput", 88 ]), 89 90 // Private variables 91 results: [], 92 93 // The AutoCompleteParent currently showing results or null otherwise. 94 currentActor: null, 95 96 // nsIAutoCompleteController 97 get matchCount() { 98 return this.results.length; 99 }, 100 101 getValueAt(index) { 102 return this.results[index].value; 103 }, 104 105 getFinalCompleteValueAt(index) { 106 return this.results[index].value; 107 }, 108 109 getLabelAt(index) { 110 // Backwardly-used by richlist autocomplete - see getCommentAt. 111 // The label is used for secondary information. 112 return this.results[index].comment; 113 }, 114 115 getCommentAt(index) { 116 // The richlist autocomplete popup uses comment for its main 117 // display of an item, which is why we're returning the label 118 // here instead. 119 return this.results[index].label; 120 }, 121 122 getStyleAt(index) { 123 return this.results[index].style; 124 }, 125 126 getImageAt(index) { 127 return this.results[index].image; 128 }, 129 130 handleEnter(aIsPopupSelection) { 131 if (this.currentActor) { 132 this.currentActor.handleEnter(aIsPopupSelection); 133 } 134 }, 135 136 stopSearch() {}, 137 138 searchString: "", 139 140 // nsIAutoCompleteInput 141 get controller() { 142 return this; 143 }, 144 145 get popup() { 146 return null; 147 }, 148 149 _focus() { 150 if (this.currentActor) { 151 this.currentActor.requestFocus(); 152 } 153 }, 154 155 // Internal JS-only API 156 clearResults() { 157 this.currentActor = null; 158 this.results = []; 159 }, 160 161 setResults(actor, results) { 162 this.currentActor = actor; 163 this.results = results; 164 }, 165}; 166 167class AutoCompleteParent extends JSWindowActorParent { 168 didDestroy() { 169 if (this.openedPopup) { 170 this.openedPopup.closePopup(); 171 } 172 } 173 174 static getCurrentActor() { 175 return currentActor; 176 } 177 178 static getCurrentBrowser() { 179 return currentBrowserWeakRef ? currentBrowserWeakRef.get() : null; 180 } 181 182 static addPopupStateListener(listener) { 183 autoCompleteListeners.add(listener); 184 } 185 186 static removePopupStateListener(listener) { 187 autoCompleteListeners.delete(listener); 188 } 189 190 handleEvent(evt) { 191 switch (evt.type) { 192 case "popupshowing": { 193 this.sendAsyncMessage("FormAutoComplete:PopupOpened", {}); 194 break; 195 } 196 197 case "popuphidden": { 198 let selectedIndex = this.openedPopup.selectedIndex; 199 let selectedRowComment = 200 selectedIndex != -1 201 ? AutoCompleteResultView.getCommentAt(selectedIndex) 202 : ""; 203 let selectedRowStyle = 204 selectedIndex != -1 205 ? AutoCompleteResultView.getStyleAt(selectedIndex) 206 : ""; 207 this.sendAsyncMessage("FormAutoComplete:PopupClosed", { 208 selectedRowComment, 209 selectedRowStyle, 210 }); 211 AutoCompleteResultView.clearResults(); 212 // adjustHeight clears the height from the popup so that 213 // we don't have a big shrink effect if we closed with a 214 // large list, and then open on a small one. 215 this.openedPopup.adjustHeight(); 216 this.openedPopup = null; 217 currentBrowserWeakRef = null; 218 currentActor = null; 219 evt.target.removeEventListener("popuphidden", this); 220 evt.target.removeEventListener("popupshowing", this); 221 break; 222 } 223 } 224 } 225 226 showPopupWithResults({ rect, dir, results }) { 227 if (!results.length || this.openedPopup) { 228 // We shouldn't ever be showing an empty popup, and if we 229 // already have a popup open, the old one needs to close before 230 // we consider opening a new one. 231 return; 232 } 233 234 let browser = this.browsingContext.top.embedderElement; 235 let window = browser.ownerGlobal; 236 // Also check window top in case this is a sidebar. 237 if ( 238 Services.focus.activeWindow !== window.top && 239 Services.focus.focusedWindow.top !== window.top 240 ) { 241 // We were sent a message from a window or tab that went into the 242 // background, so we'll ignore it for now. 243 return; 244 } 245 246 // Non-empty result styles 247 let resultStyles = new Set(results.map(r => r.style).filter(r => !!r)); 248 currentBrowserWeakRef = Cu.getWeakReference(browser); 249 currentActor = this; 250 this.openedPopup = browser.autoCompletePopup; 251 // the layout varies according to different result type 252 this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" ")); 253 this.openedPopup.hidden = false; 254 // don't allow the popup to become overly narrow 255 this.openedPopup.style.setProperty( 256 "--panel-width", 257 Math.max(100, rect.width) + "px" 258 ); 259 this.openedPopup.style.direction = dir; 260 261 AutoCompleteResultView.setResults(this, results); 262 this.openedPopup.view = AutoCompleteResultView; 263 this.openedPopup.selectedIndex = -1; 264 265 // Reset fields that were set from the last time the search popup was open 266 this.openedPopup.mInput = AutoCompleteResultView; 267 // Temporarily increase the maxRows as we don't want to show 268 // the scrollbar in login or form autofill popups. 269 if ( 270 resultStyles.size && 271 (resultStyles.has("autofill-profile") || resultStyles.has("loginsFooter")) 272 ) { 273 this.openedPopup._normalMaxRows = this.openedPopup.maxRows; 274 this.openedPopup.mInput.maxRows = 10; 275 } 276 this.openedPopup.addEventListener("popuphidden", this); 277 this.openedPopup.addEventListener("popupshowing", this); 278 this.openedPopup.openPopupAtScreenRect( 279 "after_start", 280 rect.left, 281 rect.top, 282 rect.width, 283 rect.height, 284 false, 285 false 286 ); 287 this.openedPopup.invalidate(); 288 this._maybeRecordTelemetryEvents(results); 289 290 // This is a temporary solution. We should replace it with 291 // proper meta information about the popup once such field 292 // becomes available. 293 let isCreditCard = results.some(result => 294 result?.comment?.includes("cc-number") 295 ); 296 297 if (isCreditCard) { 298 this.delayPopupInput(); 299 } 300 } 301 302 /** 303 * @param {object[]} results - Non-empty array of autocomplete results. 304 */ 305 _maybeRecordTelemetryEvents(results) { 306 let actor = this.browsingContext.currentWindowGlobal.getActor( 307 "LoginManager" 308 ); 309 actor.maybeRecordPasswordGenerationShownTelemetryEvent(results); 310 311 // Assume the result with the start time (loginsFooter) is last. 312 let lastResult = results[results.length - 1]; 313 if (lastResult.style != "loginsFooter") { 314 return; 315 } 316 317 // The comment field of `loginsFooter` results have many additional pieces of 318 // information for telemetry purposes. After bug 1555209, this information 319 // can be passed to the parent process outside of nsIAutoCompleteResult APIs 320 // so we won't need this hack. 321 let rawExtraData = JSON.parse(lastResult.comment); 322 if (!rawExtraData.searchStartTimeMS) { 323 throw new Error("Invalid autocomplete search start time"); 324 } 325 326 if (rawExtraData.stringLength > 1) { 327 // To reduce event volume, only record for lengths 0 and 1. 328 return; 329 } 330 331 let duration = 332 Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS; 333 delete rawExtraData.searchStartTimeMS; 334 335 delete rawExtraData.formHostname; 336 337 // Add counts by result style to rawExtraData. 338 results.reduce((accumulated, r) => { 339 // Ignore learn more as it is only added after importable logins. 340 if (r.style === "importableLearnMore") { 341 return accumulated; 342 } 343 344 // Keys can be a maximum of 15 characters and values must be strings. 345 // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys 346 // is limited to 10. 347 let truncatedStyle = r.style.substring( 348 0, 349 r.style === "loginWithOrigin" ? 5 : 15 350 ); 351 accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1; 352 return accumulated; 353 }, rawExtraData); 354 355 // Convert extra values to strings since recordEvent requires that. 356 let extraStrings = Object.fromEntries( 357 Object.entries(rawExtraData).map(([key, val]) => { 358 let stringVal = ""; 359 if (typeof val == "boolean") { 360 stringVal += val ? "1" : "0"; 361 } else { 362 stringVal += val; 363 } 364 return [key, stringVal]; 365 }) 366 ); 367 368 Services.telemetry.recordEvent( 369 "form_autocomplete", 370 "show", 371 "logins", 372 // Convert to a string 373 duration + "", 374 extraStrings 375 ); 376 } 377 378 invalidate(results) { 379 if (!this.openedPopup) { 380 return; 381 } 382 383 if (!results.length) { 384 this.closePopup(); 385 } else { 386 AutoCompleteResultView.setResults(this, results); 387 this.openedPopup.invalidate(); 388 this._maybeRecordTelemetryEvents(results); 389 } 390 } 391 392 closePopup() { 393 if (this.openedPopup) { 394 // Note that hidePopup() closes the popup immediately, 395 // so popuphiding or popuphidden events will be fired 396 // and handled during this call. 397 this.openedPopup.hidePopup(); 398 } 399 } 400 401 receiveMessage(message) { 402 let browser = this.browsingContext.top.embedderElement; 403 404 if (!browser || (!DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup)) { 405 // If there is no browser or popup, just make sure that the popup has been closed. 406 if (this.openedPopup) { 407 this.openedPopup.closePopup(); 408 } 409 410 // Returning false to pacify ESLint, but this return value is 411 // ignored by the messaging infrastructure. 412 return false; 413 } 414 415 switch (message.name) { 416 case "FormAutoComplete:SetSelectedIndex": { 417 let { index } = message.data; 418 if (this.openedPopup) { 419 this.openedPopup.selectedIndex = index; 420 } 421 break; 422 } 423 424 case "FormAutoComplete:MaybeOpenPopup": { 425 let { 426 results, 427 rect, 428 dir, 429 inputElementIdentifier, 430 formOrigin, 431 } = message.data; 432 if (DELEGATE_AUTOCOMPLETE) { 433 GeckoViewAutocomplete.delegateSelection({ 434 browsingContext: this.browsingContext, 435 options: results, 436 inputElementIdentifier, 437 formOrigin, 438 }); 439 } else { 440 this.showPopupWithResults({ results, rect, dir }); 441 this.notifyListeners(); 442 } 443 break; 444 } 445 446 case "FormAutoComplete:Invalidate": { 447 let { results } = message.data; 448 this.invalidate(results); 449 break; 450 } 451 452 case "FormAutoComplete:ClosePopup": { 453 this.closePopup(); 454 break; 455 } 456 457 case "FormAutoComplete:Disconnect": { 458 // The controller stopped controlling the current input, so clear 459 // any cached data. This is necessary cause otherwise we'd clear data 460 // only when starting a new search, but the next input could not support 461 // autocomplete and it would end up inheriting the existing data. 462 AutoCompleteResultView.clearResults(); 463 break; 464 } 465 } 466 // Returning false to pacify ESLint, but this return value is 467 // ignored by the messaging infrastructure. 468 return false; 469 } 470 471 // Imposes a brief period during which the popup will not respond to 472 // a click, so as to reduce the chances of a successful clickjacking 473 // attempt 474 delayPopupInput() { 475 if (!this.openedPopup) { 476 return; 477 } 478 const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY); 479 480 // Mochitests set this to 0, and many will fail on integration 481 // if we make the popup items inactive, even briefly. 482 if (!popupDelay) { 483 return; 484 } 485 486 const items = Array.from( 487 this.openedPopup.getElementsByTagName("richlistitem") 488 ); 489 items.forEach(item => (item.disabled = true)); 490 491 setTimeout( 492 () => items.forEach(item => (item.disabled = false)), 493 popupDelay 494 ); 495 } 496 497 notifyListeners() { 498 let window = this.browsingContext.top.embedderElement.ownerGlobal; 499 for (let listener of autoCompleteListeners) { 500 try { 501 listener(window); 502 } catch (ex) { 503 Cu.reportError(ex); 504 } 505 } 506 } 507 508 /** 509 * Despite its name, this handleEnter is only called when the user clicks on 510 * one of the items in the popup since the popup is rendered in the parent process. 511 * The real controller's handleEnter is called directly in the content process 512 * for other methods of completing a selection (e.g. using the tab or enter 513 * keys) since the field with focus is in that process. 514 * @param {boolean} aIsPopupSelection 515 */ 516 handleEnter(aIsPopupSelection) { 517 if (this.openedPopup) { 518 this.sendAsyncMessage("FormAutoComplete:HandleEnter", { 519 selectedIndex: this.openedPopup.selectedIndex, 520 isPopupSelection: aIsPopupSelection, 521 }); 522 } 523 } 524 525 stopSearch() {} 526 527 /** 528 * Sends a message to the browser that is requesting the input 529 * that the open popup should be focused. 530 */ 531 requestFocus() { 532 // Bug 1582722 - See the response in AutoCompleteChild.jsm for why this disabled. 533 /* 534 if (this.openedPopup) { 535 this.sendAsyncMessage("FormAutoComplete:Focus"); 536 } 537 */ 538 } 539} 540