GeckoViewAutoFillChild.sys.mjs (11813B)
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 { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; 6 import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; 7 8 const lazy = {}; 9 10 ChromeUtils.defineESModuleGetters(lazy, { 11 FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", 12 LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", 13 LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", 14 }); 15 16 export class GeckoViewAutoFillChild extends GeckoViewActorChild { 17 constructor() { 18 super(); 19 20 this._autofillElements = undefined; 21 this._autofillInfos = undefined; 22 } 23 24 // eslint-disable-next-line complexity 25 handleEvent(aEvent) { 26 debug`handleEvent: ${aEvent.type}`; 27 switch (aEvent.type) { 28 case "DOMFormHasPassword": { 29 this.addElement( 30 lazy.FormLikeFactory.createFromForm(aEvent.composedTarget) 31 ); 32 break; 33 } 34 case "DOMInputPasswordAdded": { 35 const input = aEvent.composedTarget; 36 if (!input.form) { 37 this.addElement(lazy.FormLikeFactory.createFromField(input)); 38 } 39 break; 40 } 41 case "focusin": { 42 const element = aEvent.composedTarget; 43 if (!this.contentWindow.HTMLInputElement.isInstance(element)) { 44 break; 45 } 46 GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => { 47 if (Cu.isDeadWrapper(element)) { 48 // Focus element is removed or document is navigated to new page. 49 return; 50 } 51 const focusedElement = 52 Services.focus.focusedElement || 53 element.ownerDocument?.activeElement; 54 if (element == focusedElement) { 55 this.onFocus(focusedElement); 56 } 57 }); 58 break; 59 } 60 case "focusout": { 61 if ( 62 this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget) 63 ) { 64 this.onFocus(null); 65 } 66 break; 67 } 68 case "pagehide": { 69 if (aEvent.target === this.document) { 70 this.clearElements(this.browsingContext); 71 } 72 break; 73 } 74 case "pageshow": { 75 if (aEvent.target === this.document) { 76 this.scanDocument(this.document); 77 } 78 break; 79 } 80 case "PasswordManager:ShowDoorhanger": { 81 const { form: formLike } = aEvent.detail; 82 this.commitAutofill(formLike); 83 break; 84 } 85 } 86 } 87 88 /** 89 * Process an auto-fillable form and send the relevant details of the form 90 * to Java. Multiple calls within a short time period for the same form are 91 * coalesced, so that, e.g., if multiple inputs are added to a form in 92 * succession, we will only perform one processing pass. Note that for inputs 93 * without forms, FormLikeFactory treats the document as the "form", but 94 * there is no difference in how we process them. 95 * 96 * @param aFormLike A FormLike object produced by FormLikeFactory. 97 */ 98 async addElement(aFormLike) { 99 debug`Adding auto-fill ${aFormLike.rootElement.tagName}`; 100 101 const window = aFormLike.rootElement.ownerGlobal; 102 // Get password field to get better form data via LoginManagerChild. 103 let passwordField; 104 for (const field of aFormLike.elements) { 105 if ( 106 ChromeUtils.getClassName(field) === "HTMLInputElement" && 107 field.type == "password" 108 ) { 109 passwordField = field; 110 break; 111 } 112 } 113 114 const loginManagerChild = lazy.LoginManagerChild.forWindow(window); 115 const docState = loginManagerChild.stateForDocument( 116 passwordField.ownerDocument 117 ); 118 const [usernameField] = docState.getUserNameAndPasswordFields( 119 passwordField || aFormLike.elements[0] 120 ); 121 122 const focusedElement = aFormLike.rootElement.ownerDocument.activeElement; 123 let sendFocusEvent = aFormLike.rootElement === focusedElement; 124 125 const rootInfo = this._getInfo( 126 aFormLike.rootElement, 127 null, 128 undefined, 129 null 130 ); 131 132 rootInfo.rootUuid = rootInfo.uuid; 133 rootInfo.children = aFormLike.elements 134 .filter( 135 element => 136 element.type != "hidden" && 137 (!usernameField || 138 element.type != "text" || 139 element == usernameField || 140 (element.getAutocompleteInfo() && 141 element.getAutocompleteInfo().fieldName == "email")) 142 ) 143 .map(element => { 144 sendFocusEvent |= element === focusedElement; 145 return this._getInfo( 146 element, 147 rootInfo.uuid, 148 rootInfo.uuid, 149 usernameField 150 ); 151 }); 152 153 try { 154 // We don't await here so that we can send a focus event immediately 155 // after this as the app might not know which element is focused. 156 const responsePromise = this.sendQuery("Add", { 157 node: rootInfo, 158 }); 159 160 if (sendFocusEvent) { 161 // We might have missed sending a focus event for the active element. 162 this.onFocus(aFormLike.ownerDocument.activeElement); 163 } 164 165 const responses = await responsePromise; 166 // `responses` is an object with global IDs as keys. 167 debug`Performing auto-fill ${Object.keys(responses)}`; 168 169 const AUTOFILL_STATE = "autofill"; 170 171 for (const uuid in responses) { 172 const entry = 173 this._autofillElements && this._autofillElements.get(uuid); 174 const element = entry && entry.get(); 175 const value = responses[uuid] || ""; 176 177 if ( 178 window.HTMLInputElement.isInstance(element) && 179 !element.disabled && 180 element.parentElement 181 ) { 182 element.setUserInput(value); 183 if (element.value === value) { 184 // Add highlighting for autofilled fields. 185 element.autofillState = AUTOFILL_STATE; 186 187 // Remove highlighting when the field is changed. 188 element.addEventListener( 189 "input", 190 _ => (element.autofillState = ""), 191 { mozSystemGroup: true, once: true } 192 ); 193 } 194 } else if (element) { 195 warn`Don't know how to auto-fill ${element.tagName}`; 196 } 197 } 198 } catch (error) { 199 warn`Cannot perform autofill ${error}`; 200 } 201 } 202 203 _getInfo(aElement, aParent, aRoot, aUsernameField) { 204 if (!this._autofillInfos) { 205 this._autofillInfos = new WeakMap(); 206 this._autofillElements = new Map(); 207 } 208 209 let info = this._autofillInfos.get(aElement); 210 if (info) { 211 return info; 212 } 213 214 const window = aElement.ownerGlobal; 215 const bounds = aElement.getBoundingClientRect(); 216 const isInputElement = window.HTMLInputElement.isInstance(aElement); 217 218 info = { 219 isInputElement, 220 uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces 221 parentUuid: aParent, 222 rootUuid: aRoot, 223 tag: aElement.tagName, 224 type: isInputElement ? aElement.type : null, 225 value: isInputElement ? aElement.value : null, 226 editable: 227 isInputElement && 228 [ 229 "color", 230 "date", 231 "datetime-local", 232 "email", 233 "month", 234 "number", 235 "password", 236 "range", 237 "search", 238 "tel", 239 "text", 240 "time", 241 "url", 242 "week", 243 ].includes(aElement.type), 244 disabled: isInputElement ? aElement.disabled : null, 245 attributes: Object.assign( 246 {}, 247 ...Array.from(aElement.attributes) 248 .filter(attr => attr.localName !== "value") 249 .map(attr => ({ [attr.localName]: attr.value })) 250 ), 251 origin: aElement.ownerDocument.location.origin, 252 autofillhint: "", 253 bounds: { 254 left: bounds.left, 255 top: bounds.top, 256 right: bounds.right, 257 bottom: bounds.bottom, 258 }, 259 }; 260 261 if (aElement === aUsernameField) { 262 info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME 263 } else if (isInputElement) { 264 // Using autocomplete attribute if it is email. 265 const autocompleteInfo = aElement.getAutocompleteInfo(); 266 if (autocompleteInfo) { 267 const autocompleteAttr = autocompleteInfo.fieldName; 268 if (autocompleteAttr == "email") { 269 info.type = "email"; 270 } 271 } 272 } 273 274 this._autofillInfos.set(aElement, info); 275 this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement)); 276 return info; 277 } 278 279 _updateInfoValues(aElements) { 280 if (!this._autofillInfos) { 281 return []; 282 } 283 284 const updated = []; 285 for (const element of aElements) { 286 const info = this._autofillInfos.get(element); 287 288 if (!info?.isInputElement || info.value === element.value) { 289 continue; 290 } 291 debug`Updating value ${info.value} to ${element.value}`; 292 293 info.value = element.value; 294 this._autofillInfos.set(element, info); 295 updated.push(info); 296 } 297 return updated; 298 } 299 300 /** 301 * Called when an auto-fillable field is focused or blurred. 302 * 303 * @param aTarget Focused element, or null if an element has lost focus. 304 */ 305 onFocus(aTarget) { 306 debug`Auto-fill focus on ${aTarget && aTarget.tagName}`; 307 308 const info = aTarget && this._autofillInfos?.get(aTarget); 309 if (info) { 310 const bounds = aTarget.getBoundingClientRect(); 311 const screenRect = lazy.LayoutUtils.rectToScreenRect( 312 aTarget.ownerGlobal, 313 bounds 314 ); 315 info.screenRect = { 316 left: screenRect.left, 317 top: screenRect.top, 318 right: screenRect.right, 319 bottom: screenRect.bottom, 320 }; 321 } 322 323 if (!aTarget || info) { 324 this.sendAsyncMessage("Focus", { 325 node: info, 326 }); 327 } 328 } 329 330 commitAutofill(aFormLike) { 331 if (!aFormLike) { 332 throw new Error("null-form on autofill commit"); 333 } 334 335 debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`; 336 337 const updatedNodeInfos = this._updateInfoValues([ 338 aFormLike.rootElement, 339 ...aFormLike.elements, 340 ]); 341 342 for (const updatedInfo of updatedNodeInfos) { 343 debug`Updating node ${updatedInfo}`; 344 this.sendAsyncMessage("Update", { 345 node: updatedInfo, 346 }); 347 } 348 349 const info = this._getInfo(aFormLike.rootElement); 350 if (info) { 351 debug`Committing node ${info}`; 352 this.sendAsyncMessage("Commit", { 353 node: info, 354 }); 355 } 356 } 357 358 /** 359 * Clear all tracked auto-fill forms and notify Java. 360 */ 361 clearElements(browsingContext) { 362 this._autofillInfos = undefined; 363 this._autofillElements = undefined; 364 365 if (browsingContext === browsingContext.top) { 366 this.sendAsyncMessage("Clear"); 367 } 368 } 369 370 /** 371 * Scan for auto-fillable forms and add them if necessary. Called when a page 372 * is navigated to through history, in which case we don't get our typical 373 * "input added" notifications. 374 * 375 * @param aDoc Document to scan. 376 */ 377 scanDocument(aDoc) { 378 // Add forms first; only check forms with password inputs. 379 const inputs = aDoc.querySelectorAll("input[type=password]"); 380 let inputAdded = false; 381 for (let i = 0; i < inputs.length; i++) { 382 if (inputs[i].form) { 383 // Let addElement coalesce multiple calls for the same form. 384 this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form)); 385 } else if (!inputAdded) { 386 // Treat inputs without forms as one unit, and process them only once. 387 inputAdded = true; 388 this.addElement(lazy.FormLikeFactory.createFromField(inputs[i])); 389 } 390 } 391 } 392 } 393 394 const { debug, warn } = GeckoViewAutoFillChild.initLogging("GeckoViewAutoFill");