diff --git a/dist/autofill-debug.js b/dist/autofill-debug.js index b830b634d..799be7996 100644 --- a/dist/autofill-debug.js +++ b/dist/autofill-debug.js @@ -8147,15 +8147,8 @@ class ExtensionInterface extends _InterfacePrototype.default { return null; } - removeAutofillUIFromPage() { - var _this$activeForm2; - - super.removeAutofillUIFromPage(); - (_this$activeForm2 = this.activeForm) === null || _this$activeForm2 === void 0 ? void 0 : _this$activeForm2.removeAllDecorations(); - } - async resetAutofillUI(callback) { - this.removeAutofillUIFromPage(); + this.removeAutofillUIFromPage('Resetting autofill.'); await this.setupAutofill(); if (callback) await callback(); this.uiController = this.createUIController(); @@ -8193,7 +8186,7 @@ class ExtensionInterface extends _InterfacePrototype.default { switch (this.getActiveTooltipType()) { case TOOLTIP_TYPES.EmailProtection: { - var _this$activeForm3; + var _this$activeForm2; this._scannerCleanup = this.scanner.init(); this.addLogoutListener(() => { @@ -8208,12 +8201,12 @@ class ExtensionInterface extends _InterfacePrototype.default { } }); - if ((_this$activeForm3 = this.activeForm) !== null && _this$activeForm3 !== void 0 && _this$activeForm3.activeInput) { - var _this$activeForm4; + if ((_this$activeForm2 = this.activeForm) !== null && _this$activeForm2 !== void 0 && _this$activeForm2.activeInput) { + var _this$activeForm3; this.attachTooltip({ form: this.activeForm, - input: (_this$activeForm4 = this.activeForm) === null || _this$activeForm4 === void 0 ? void 0 : _this$activeForm4.activeInput, + input: (_this$activeForm3 = this.activeForm) === null || _this$activeForm3 === void 0 ? void 0 : _this$activeForm3.activeInput, click: null, trigger: 'postSignup', triggerMetaData: { @@ -8449,7 +8442,7 @@ class InterfacePrototype { /** @type {boolean} */ - /** @type {(()=>void) | null} */ + /** @type {((reason, ...rest) => void) | null} */ /** * @param {GlobalConfig} config @@ -8523,12 +8516,16 @@ class InterfacePrototype { createUIController() { return new _NativeUIController.NativeUIController(); } + /** + * @param {string} reason + */ - removeAutofillUIFromPage() { + + removeAutofillUIFromPage(reason) { var _this$uiController, _this$_scannerCleanup; (_this$uiController = this.uiController) === null || _this$uiController === void 0 ? void 0 : _this$uiController.destroy(); - (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this); + (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this, reason); } get hasLocalAddresses() { @@ -8768,7 +8765,7 @@ class InterfacePrototype { postInit() { const cleanup = this.scanner.init(); this.addLogoutListener(() => { - cleanup(); + cleanup('Logged out'); if (this.globalConfig.isDDGDomain) { (0, _autofillUtils.notifyWebApp)({ @@ -10220,6 +10217,10 @@ class Form { return this.formAnalyzer.isHybrid; } + get isCCForm() { + return this.formAnalyzer.isCCForm(); + } + logFormInfo() { if (!(0, _autofillUtils.shouldLog)()) return; console.log("Form type: %c".concat(this.getFormType()), 'font-weight: bold'); @@ -10476,6 +10477,7 @@ class Form { destroy() { this.removeAllDecorations(); this.removeTooltip(); + this.forgetAllInputs(); this.mutObs.disconnect(); this.matching.clear(); this.intObs = null; @@ -10578,6 +10580,7 @@ class Form { const opts = { isLogin: this.isLogin, isHybrid: this.isHybrid, + isCCForm: this.isCCForm, hasCredentials: Boolean((_this$device$settings = this.device.settings.availableInputTypes.credentials) === null || _this$device$settings === void 0 ? void 0 : _this$device$settings.username), supportsIdentitiesAutofill: this.device.settings.featureToggles.inputType_identities }; @@ -11030,6 +11033,8 @@ class FormAnalyzer { _defineProperty(this, "matching", void 0); + _defineProperty(this, "_isCCForm", undefined); + this.form = form; this.matching = matching || new _matching.Matching(_matchingConfiguration.matchingConfiguration); /** @@ -11308,6 +11313,52 @@ class FormAnalyzer { return this; } + /** @type {undefined|boolean} */ + + + /** + * Tries to infer if it's a credit card form + * @returns {boolean} + */ + isCCForm() { + var _formEl$textContent; + + if (this._isCCForm !== undefined) return this._isCCForm; + const formEl = this.form; + const ccFieldSelector = this.matching.joinCssSelectors('cc'); + + if (!ccFieldSelector) { + this._isCCForm = false; + return this._isCCForm; + } + + const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence + + if (hasCCSelectorChild) { + this._isCCForm = true; + return this._isCCForm; + } // Read form attributes to find a signal + + + const hasCCAttribute = [...formEl.attributes].some(_ref3 => { + let { + name, + value + } = _ref3; + return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); + }); + + if (hasCCAttribute) { + this._isCCForm = true; + return this._isCCForm; + } // Match form textContent against common cc fields (includes hidden labels) + + + const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives + + this._isCCForm = Boolean(textMatches && textMatches.length > 1); + return this._isCCForm; + } } @@ -13755,7 +13806,7 @@ class Matching { this.setActiveElementStrings(input, formEl); // // For CC forms we run aggressive matches, so we want to make sure we only // // run them on actual CC forms to avoid false positives and expensive loops - if (this.isCCForm(formEl)) { + if (opts.isCCForm) { const subtype = this.subtypeFromMatchers('cc', input); if (subtype && isValidCreditCardSubtype(subtype)) { @@ -13806,6 +13857,7 @@ class Matching { * @typedef {{ * isLogin?: boolean, * isHybrid?: boolean, + * isCCForm?: boolean, * hasCredentials?: boolean, * supportsIdentitiesAutofill?: boolean * }} SetInputTypeOpts @@ -14139,39 +14191,6 @@ class Matching { this.setActiveElementStrings(input, form); return this; } - /** - * Tries to infer if it's a credit card form - * @param {HTMLElement} formEl - * @returns {boolean} - */ - - - isCCForm(formEl) { - var _formEl$textContent; - - const ccFieldSelector = this.joinCssSelectors('cc'); - - if (!ccFieldSelector) { - return false; - } - - const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence - - if (hasCCSelectorChild) return true; // Read form attributes to find a signal - - const hasCCAttribute = [...formEl.attributes].some(_ref => { - let { - name, - value - } = _ref; - return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); - }); - if (hasCCAttribute) return true; // Match form textContent against common cc fields (includes hidden labels) - - const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives - - return Boolean(textMatches && textMatches.length > 1); - } /** * @type {MatchingConfiguration} */ @@ -14340,7 +14359,8 @@ function getInputSubtype(input) { const removeExcessWhitespace = function () { let string = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; - return (string || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); + if (!string) return ''; + return string.replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); }; /** * Get text from all explicit labels @@ -14780,7 +14800,7 @@ class InContextSignup { }; if (options.shouldHideTooltip) { - this.device.removeAutofillUIFromPage(); + this.device.removeAutofillUIFromPage('Email Protection in-context signup dismissed.'); this.device.deviceApi.notify(new _deviceApiCalls.CloseAutofillParentCall(null)); } @@ -15251,7 +15271,7 @@ const { /** * @typedef {{ * forms: Map; - * init(): ()=> void; + * init(): (reason, ...rest)=> void; * enqueue(elements: (HTMLElement|Document)[]): void; * findEligibleInputs(context): Scanner; * options: ScannerOptions; @@ -15304,6 +15324,8 @@ class DefaultScanner { /** @type {boolean} A flag to indicate the whole page will be re-scanned */ + /** @type {boolean} Indicates whether we called stopScanning */ + /** * @param {import("./DeviceInterface/InterfacePrototype").default} device * @param {ScannerOptions} options @@ -15321,6 +15343,8 @@ class DefaultScanner { _defineProperty(this, "rescanAll", false); + _defineProperty(this, "stopped", false); + _defineProperty(this, "mutObs", new MutationObserver(mutationList => { /** @type {HTMLElement[]} */ if (this.rescanAll) { @@ -15365,11 +15389,13 @@ class DefaultScanner { * Call this to scan once and then watch for changes. * * Call the returned function to remove listeners. - * @returns {() => void} + * @returns {(reason: string, ...rest) => void} */ init() { + var _this = this; + if (this.device.globalConfig.isExtension) { this.device.deviceApi.notify(new _deviceApiCalls.AddDebugFlagCall({ flag: 'autofill' @@ -15385,20 +15411,12 @@ class DefaultScanner { setTimeout(() => this.scanAndObserve(), delay); } - return () => { - var _this$device$activeFo; - - const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers - - clearTimeout(this.debounceTimer); - this.mutObs.disconnect(); - this.forms.forEach(form => { - form.resetAllInputs(); - form.removeAllDecorations(); - }); - this.forms.clear(); // Bring the user back to the input they were interacting with + return function (reason) { + for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + rest[_key - 1] = arguments[_key]; + } - activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + _this.stopScanner(reason, ...rest); }; } /** @@ -15409,9 +15427,10 @@ class DefaultScanner { scanAndObserve() { var _window$performance, _window$performance$m, _window$performance2, _window$performance2$; - (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'scanner:init:start'); + (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'initial_scanner:init:start'); this.findEligibleInputs(document); - (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'scanner:init:end'); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'initial_scanner:init:end'); + (0, _autofillUtils.logPerformance)('initial_scanner'); this.mutObs.observe(document.documentElement, { childList: true, subtree: true @@ -15436,6 +15455,7 @@ class DefaultScanner { const inputs = context.querySelectorAll(_selectorsCss.FORM_INPUTS_SELECTOR); if (inputs.length > this.options.maxInputsPerPage) { + this.stopScanner('Too many input fields in the given context, stop scanning', context); return this; } @@ -15444,6 +15464,38 @@ class DefaultScanner { return this; } + /** + * Stops scanning, switches off the mutation observer and clears all forms + * @param {string} reason + * @param {...any} rest + */ + + + stopScanner(reason) { + var _this$device$activeFo; + + this.stopped = true; + + if ((0, _autofillUtils.shouldLog)()) { + for (var _len2 = arguments.length, rest = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + rest[_key2 - 1] = arguments[_key2]; + } + + console.log(reason, ...rest); + } + + const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers + + clearTimeout(this.debounceTimer); + this.changedElements.clear(); + this.mutObs.disconnect(); + this.forms.forEach(form => { + form.destroy(); + }); + this.forms.clear(); // Bring the user back to the input they were interacting with + + activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + } /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input * @returns {HTMLFormElement|HTMLElement} @@ -15452,10 +15504,13 @@ class DefaultScanner { getParentForm(input) { if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { - // Use input.form unless it encloses most of the DOM - // In that case we proceed to identify more precise wrappers - if (input.form && !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { - return input.form; + if (input.form) { + // Use input.form unless it encloses most of the DOM + // In that case we proceed to identify more precise wrappers + if (this.forms.has(input.form) || // If we've added the form we've already checked that it's not a page wrapper + !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { + return input.form; + } } } @@ -15489,28 +15544,55 @@ class DefaultScanner { addInput(input) { - const parentForm = this.getParentForm(input); // Note that el.contains returns true for el itself + if (this.stopped) return; + const parentForm = this.getParentForm(input); - const previouslyFoundParent = [...this.forms.keys()].find(form => form.contains(parentForm)); + if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { + var _this$forms$get; + + // We've met the form, add the input + (_this$forms$get = this.forms.get(parentForm)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + return; + } // Check if the forms we've seen are either disconnected, + // or are parent/child of the currently-found form + + + let previouslyFoundParent, childForm; + + for (const [formEl] of this.forms) { + // Remove disconnected forms to avoid leaks + if (!formEl.isConnected) { + this.forms.delete(formEl); + continue; + } + + if (formEl.contains(parentForm)) { + previouslyFoundParent = formEl; + break; + } + + if (parentForm.contains(formEl)) { + childForm = formEl; + break; + } + } if (previouslyFoundParent) { if (parentForm instanceof HTMLFormElement && parentForm !== previouslyFoundParent) { // If we had a prior parent but this is an explicit form, the previous was a false positive this.forms.delete(previouslyFoundParent); } else { - var _this$forms$get; + var _this$forms$get2; // If we've already met the form or a descendant, add the input - (_this$forms$get = this.forms.get(previouslyFoundParent)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + (_this$forms$get2 = this.forms.get(previouslyFoundParent)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.addInput(input); } } else { // if this form is an ancestor of an existing form, remove that before adding this - const childForm = [...this.forms.keys()].find(form => parentForm.contains(form)); - if (childForm) { - var _this$forms$get2; + var _this$forms$get3; - (_this$forms$get2 = this.forms.get(childForm)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.destroy(); + (_this$forms$get3 = this.forms.get(childForm)) === null || _this$forms$get3 === void 0 ? void 0 : _this$forms$get3.destroy(); this.forms.delete(childForm); } // Only add the form if below the limit of forms per page @@ -15518,9 +15600,7 @@ class DefaultScanner { if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new _Form.Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)); } else { - if ((0, _autofillUtils.shouldLog)()) { - console.log('The page has too many forms, stop adding them.'); - } + this.stopScanner('The page has too many forms, stop adding them.'); } } } @@ -15546,9 +15626,14 @@ class DefaultScanner { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { + var _window$performance3, _window$performance3$, _window$performance4, _window$performance4$; + + (_window$performance3 = window.performance) === null || _window$performance3 === void 0 ? void 0 : (_window$performance3$ = _window$performance3.mark) === null || _window$performance3$ === void 0 ? void 0 : _window$performance3$.call(_window$performance3, 'scanner:init:start'); this.processChangedElements(); this.changedElements.clear(); this.rescanAll = false; + (_window$performance4 = window.performance) === null || _window$performance4 === void 0 ? void 0 : (_window$performance4$ = _window$performance4.mark) === null || _window$performance4$ === void 0 ? void 0 : _window$performance4$.call(_window$performance4, 'scanner:init:end'); + (0, _autofillUtils.logPerformance)('scanner'); }, this.options.debounceTimePeriod); } /** @@ -17726,8 +17811,10 @@ exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedCon exports.isLocalNetwork = isLocalNetwork; exports.isPotentiallyViewable = void 0; exports.isValidTLD = isValidTLD; +exports.logPerformance = logPerformance; exports.setValue = exports.sendAndWaitForAnswer = exports.safeExecute = exports.removeInlineStyles = exports.notifyWebApp = void 0; exports.shouldLog = shouldLog; +exports.shouldLogPerformance = shouldLogPerformance; exports.truncateFromMiddle = truncateFromMiddle; exports.wasAutofilledByChrome = void 0; exports.whenIdle = whenIdle; @@ -18145,26 +18232,39 @@ const buttonMatchesFormType = (el, formObj) => { return true; } }; + +exports.buttonMatchesFormType = buttonMatchesFormType; +const buttonInputTypes = ['submit', 'button']; /** * Get the text of an element * @param {Element} el * @returns {string} */ - -exports.buttonMatchesFormType = buttonMatchesFormType; - const getText = el => { // for buttons, we don't care about descendants, just get the whole text as is // this is important in order to give proper attribution of the text to the button if (el instanceof HTMLButtonElement) return (0, _matching.removeExcessWhitespace)(el.textContent); - if (el instanceof HTMLInputElement && ['submit', 'button'].includes(el.type)) return el.value; - if (el instanceof HTMLInputElement && el.type === 'image') { - return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + if (el instanceof HTMLInputElement) { + if (buttonInputTypes.includes(el.type)) { + return el.value; + } + + if (el.type === 'image') { + return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + } + } + + let text = ''; + + for (const childNode of el.childNodes) { + if (childNode instanceof Text) { + text += ' ' + childNode.textContent; + } } - return (0, _matching.removeExcessWhitespace)(Array.from(el.childNodes).reduce((text, child) => child instanceof Text ? text + ' ' + child.textContent : text, '')); + return (0, _matching.removeExcessWhitespace)(text); }; /** * Check if hostname is a local address @@ -18208,7 +18308,7 @@ const wasAutofilledByChrome = input => { } }; /** - * Checks if we should log debug info to the console + * Checks if we should log form analysis debug info to the console * @returns {boolean} */ @@ -18216,15 +18316,44 @@ const wasAutofilledByChrome = input => { exports.wasAutofilledByChrome = wasAutofilledByChrome; function shouldLog() { + return readDebugSetting('ddg-autofill-debug'); +} +/** + * Checks if we should log performance info to the console + * @returns {boolean} + */ + + +function shouldLogPerformance() { + return readDebugSetting('ddg-autofill-perf'); +} +/** + * Check if a sessionStorage item is set to 'true' + * @param setting + * @returns {boolean} + */ + + +function readDebugSetting(setting) { // sessionStorage throws in invalid schemes like data: and file: try { var _window$sessionStorag; - return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem('ddg-autofill-debug')) === 'true'; + return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem(setting)) === 'true'; } catch (e) { return false; } } + +function logPerformance(markName) { + if (shouldLogPerformance()) { + var _window$performance, _window$performance2; + + const measurement = (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : _window$performance.measure("".concat(markName, ":init"), "".concat(markName, ":init:start"), "".concat(markName, ":init:end")); + console.log("".concat(markName, " took ").concat(Math.round(measurement === null || measurement === void 0 ? void 0 : measurement.duration), "ms")); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : _window$performance2.clearMarks(); + } +} /** * * @param {Function} callback diff --git a/dist/autofill.js b/dist/autofill.js index 6923dae24..0d68909e1 100644 --- a/dist/autofill.js +++ b/dist/autofill.js @@ -4471,15 +4471,8 @@ class ExtensionInterface extends _InterfacePrototype.default { return null; } - removeAutofillUIFromPage() { - var _this$activeForm2; - - super.removeAutofillUIFromPage(); - (_this$activeForm2 = this.activeForm) === null || _this$activeForm2 === void 0 ? void 0 : _this$activeForm2.removeAllDecorations(); - } - async resetAutofillUI(callback) { - this.removeAutofillUIFromPage(); + this.removeAutofillUIFromPage('Resetting autofill.'); await this.setupAutofill(); if (callback) await callback(); this.uiController = this.createUIController(); @@ -4517,7 +4510,7 @@ class ExtensionInterface extends _InterfacePrototype.default { switch (this.getActiveTooltipType()) { case TOOLTIP_TYPES.EmailProtection: { - var _this$activeForm3; + var _this$activeForm2; this._scannerCleanup = this.scanner.init(); this.addLogoutListener(() => { @@ -4532,12 +4525,12 @@ class ExtensionInterface extends _InterfacePrototype.default { } }); - if ((_this$activeForm3 = this.activeForm) !== null && _this$activeForm3 !== void 0 && _this$activeForm3.activeInput) { - var _this$activeForm4; + if ((_this$activeForm2 = this.activeForm) !== null && _this$activeForm2 !== void 0 && _this$activeForm2.activeInput) { + var _this$activeForm3; this.attachTooltip({ form: this.activeForm, - input: (_this$activeForm4 = this.activeForm) === null || _this$activeForm4 === void 0 ? void 0 : _this$activeForm4.activeInput, + input: (_this$activeForm3 = this.activeForm) === null || _this$activeForm3 === void 0 ? void 0 : _this$activeForm3.activeInput, click: null, trigger: 'postSignup', triggerMetaData: { @@ -4773,7 +4766,7 @@ class InterfacePrototype { /** @type {boolean} */ - /** @type {(()=>void) | null} */ + /** @type {((reason, ...rest) => void) | null} */ /** * @param {GlobalConfig} config @@ -4847,12 +4840,16 @@ class InterfacePrototype { createUIController() { return new _NativeUIController.NativeUIController(); } + /** + * @param {string} reason + */ - removeAutofillUIFromPage() { + + removeAutofillUIFromPage(reason) { var _this$uiController, _this$_scannerCleanup; (_this$uiController = this.uiController) === null || _this$uiController === void 0 ? void 0 : _this$uiController.destroy(); - (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this); + (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this, reason); } get hasLocalAddresses() { @@ -5092,7 +5089,7 @@ class InterfacePrototype { postInit() { const cleanup = this.scanner.init(); this.addLogoutListener(() => { - cleanup(); + cleanup('Logged out'); if (this.globalConfig.isDDGDomain) { (0, _autofillUtils.notifyWebApp)({ @@ -6544,6 +6541,10 @@ class Form { return this.formAnalyzer.isHybrid; } + get isCCForm() { + return this.formAnalyzer.isCCForm(); + } + logFormInfo() { if (!(0, _autofillUtils.shouldLog)()) return; console.log("Form type: %c".concat(this.getFormType()), 'font-weight: bold'); @@ -6800,6 +6801,7 @@ class Form { destroy() { this.removeAllDecorations(); this.removeTooltip(); + this.forgetAllInputs(); this.mutObs.disconnect(); this.matching.clear(); this.intObs = null; @@ -6902,6 +6904,7 @@ class Form { const opts = { isLogin: this.isLogin, isHybrid: this.isHybrid, + isCCForm: this.isCCForm, hasCredentials: Boolean((_this$device$settings = this.device.settings.availableInputTypes.credentials) === null || _this$device$settings === void 0 ? void 0 : _this$device$settings.username), supportsIdentitiesAutofill: this.device.settings.featureToggles.inputType_identities }; @@ -7354,6 +7357,8 @@ class FormAnalyzer { _defineProperty(this, "matching", void 0); + _defineProperty(this, "_isCCForm", undefined); + this.form = form; this.matching = matching || new _matching.Matching(_matchingConfiguration.matchingConfiguration); /** @@ -7632,6 +7637,52 @@ class FormAnalyzer { return this; } + /** @type {undefined|boolean} */ + + + /** + * Tries to infer if it's a credit card form + * @returns {boolean} + */ + isCCForm() { + var _formEl$textContent; + + if (this._isCCForm !== undefined) return this._isCCForm; + const formEl = this.form; + const ccFieldSelector = this.matching.joinCssSelectors('cc'); + + if (!ccFieldSelector) { + this._isCCForm = false; + return this._isCCForm; + } + + const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence + + if (hasCCSelectorChild) { + this._isCCForm = true; + return this._isCCForm; + } // Read form attributes to find a signal + + + const hasCCAttribute = [...formEl.attributes].some(_ref3 => { + let { + name, + value + } = _ref3; + return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); + }); + + if (hasCCAttribute) { + this._isCCForm = true; + return this._isCCForm; + } // Match form textContent against common cc fields (includes hidden labels) + + + const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives + + this._isCCForm = Boolean(textMatches && textMatches.length > 1); + return this._isCCForm; + } } @@ -10079,7 +10130,7 @@ class Matching { this.setActiveElementStrings(input, formEl); // // For CC forms we run aggressive matches, so we want to make sure we only // // run them on actual CC forms to avoid false positives and expensive loops - if (this.isCCForm(formEl)) { + if (opts.isCCForm) { const subtype = this.subtypeFromMatchers('cc', input); if (subtype && isValidCreditCardSubtype(subtype)) { @@ -10130,6 +10181,7 @@ class Matching { * @typedef {{ * isLogin?: boolean, * isHybrid?: boolean, + * isCCForm?: boolean, * hasCredentials?: boolean, * supportsIdentitiesAutofill?: boolean * }} SetInputTypeOpts @@ -10463,39 +10515,6 @@ class Matching { this.setActiveElementStrings(input, form); return this; } - /** - * Tries to infer if it's a credit card form - * @param {HTMLElement} formEl - * @returns {boolean} - */ - - - isCCForm(formEl) { - var _formEl$textContent; - - const ccFieldSelector = this.joinCssSelectors('cc'); - - if (!ccFieldSelector) { - return false; - } - - const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence - - if (hasCCSelectorChild) return true; // Read form attributes to find a signal - - const hasCCAttribute = [...formEl.attributes].some(_ref => { - let { - name, - value - } = _ref; - return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); - }); - if (hasCCAttribute) return true; // Match form textContent against common cc fields (includes hidden labels) - - const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives - - return Boolean(textMatches && textMatches.length > 1); - } /** * @type {MatchingConfiguration} */ @@ -10664,7 +10683,8 @@ function getInputSubtype(input) { const removeExcessWhitespace = function () { let string = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; - return (string || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); + if (!string) return ''; + return string.replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); }; /** * Get text from all explicit labels @@ -11104,7 +11124,7 @@ class InContextSignup { }; if (options.shouldHideTooltip) { - this.device.removeAutofillUIFromPage(); + this.device.removeAutofillUIFromPage('Email Protection in-context signup dismissed.'); this.device.deviceApi.notify(new _deviceApiCalls.CloseAutofillParentCall(null)); } @@ -11575,7 +11595,7 @@ const { /** * @typedef {{ * forms: Map; - * init(): ()=> void; + * init(): (reason, ...rest)=> void; * enqueue(elements: (HTMLElement|Document)[]): void; * findEligibleInputs(context): Scanner; * options: ScannerOptions; @@ -11628,6 +11648,8 @@ class DefaultScanner { /** @type {boolean} A flag to indicate the whole page will be re-scanned */ + /** @type {boolean} Indicates whether we called stopScanning */ + /** * @param {import("./DeviceInterface/InterfacePrototype").default} device * @param {ScannerOptions} options @@ -11645,6 +11667,8 @@ class DefaultScanner { _defineProperty(this, "rescanAll", false); + _defineProperty(this, "stopped", false); + _defineProperty(this, "mutObs", new MutationObserver(mutationList => { /** @type {HTMLElement[]} */ if (this.rescanAll) { @@ -11689,11 +11713,13 @@ class DefaultScanner { * Call this to scan once and then watch for changes. * * Call the returned function to remove listeners. - * @returns {() => void} + * @returns {(reason: string, ...rest) => void} */ init() { + var _this = this; + if (this.device.globalConfig.isExtension) { this.device.deviceApi.notify(new _deviceApiCalls.AddDebugFlagCall({ flag: 'autofill' @@ -11709,20 +11735,12 @@ class DefaultScanner { setTimeout(() => this.scanAndObserve(), delay); } - return () => { - var _this$device$activeFo; - - const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers - - clearTimeout(this.debounceTimer); - this.mutObs.disconnect(); - this.forms.forEach(form => { - form.resetAllInputs(); - form.removeAllDecorations(); - }); - this.forms.clear(); // Bring the user back to the input they were interacting with + return function (reason) { + for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + rest[_key - 1] = arguments[_key]; + } - activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + _this.stopScanner(reason, ...rest); }; } /** @@ -11733,9 +11751,10 @@ class DefaultScanner { scanAndObserve() { var _window$performance, _window$performance$m, _window$performance2, _window$performance2$; - (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'scanner:init:start'); + (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'initial_scanner:init:start'); this.findEligibleInputs(document); - (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'scanner:init:end'); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'initial_scanner:init:end'); + (0, _autofillUtils.logPerformance)('initial_scanner'); this.mutObs.observe(document.documentElement, { childList: true, subtree: true @@ -11760,6 +11779,7 @@ class DefaultScanner { const inputs = context.querySelectorAll(_selectorsCss.FORM_INPUTS_SELECTOR); if (inputs.length > this.options.maxInputsPerPage) { + this.stopScanner('Too many input fields in the given context, stop scanning', context); return this; } @@ -11768,6 +11788,38 @@ class DefaultScanner { return this; } + /** + * Stops scanning, switches off the mutation observer and clears all forms + * @param {string} reason + * @param {...any} rest + */ + + + stopScanner(reason) { + var _this$device$activeFo; + + this.stopped = true; + + if ((0, _autofillUtils.shouldLog)()) { + for (var _len2 = arguments.length, rest = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + rest[_key2 - 1] = arguments[_key2]; + } + + console.log(reason, ...rest); + } + + const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers + + clearTimeout(this.debounceTimer); + this.changedElements.clear(); + this.mutObs.disconnect(); + this.forms.forEach(form => { + form.destroy(); + }); + this.forms.clear(); // Bring the user back to the input they were interacting with + + activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + } /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input * @returns {HTMLFormElement|HTMLElement} @@ -11776,10 +11828,13 @@ class DefaultScanner { getParentForm(input) { if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { - // Use input.form unless it encloses most of the DOM - // In that case we proceed to identify more precise wrappers - if (input.form && !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { - return input.form; + if (input.form) { + // Use input.form unless it encloses most of the DOM + // In that case we proceed to identify more precise wrappers + if (this.forms.has(input.form) || // If we've added the form we've already checked that it's not a page wrapper + !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { + return input.form; + } } } @@ -11813,28 +11868,55 @@ class DefaultScanner { addInput(input) { - const parentForm = this.getParentForm(input); // Note that el.contains returns true for el itself + if (this.stopped) return; + const parentForm = this.getParentForm(input); - const previouslyFoundParent = [...this.forms.keys()].find(form => form.contains(parentForm)); + if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { + var _this$forms$get; + + // We've met the form, add the input + (_this$forms$get = this.forms.get(parentForm)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + return; + } // Check if the forms we've seen are either disconnected, + // or are parent/child of the currently-found form + + + let previouslyFoundParent, childForm; + + for (const [formEl] of this.forms) { + // Remove disconnected forms to avoid leaks + if (!formEl.isConnected) { + this.forms.delete(formEl); + continue; + } + + if (formEl.contains(parentForm)) { + previouslyFoundParent = formEl; + break; + } + + if (parentForm.contains(formEl)) { + childForm = formEl; + break; + } + } if (previouslyFoundParent) { if (parentForm instanceof HTMLFormElement && parentForm !== previouslyFoundParent) { // If we had a prior parent but this is an explicit form, the previous was a false positive this.forms.delete(previouslyFoundParent); } else { - var _this$forms$get; + var _this$forms$get2; // If we've already met the form or a descendant, add the input - (_this$forms$get = this.forms.get(previouslyFoundParent)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + (_this$forms$get2 = this.forms.get(previouslyFoundParent)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.addInput(input); } } else { // if this form is an ancestor of an existing form, remove that before adding this - const childForm = [...this.forms.keys()].find(form => parentForm.contains(form)); - if (childForm) { - var _this$forms$get2; + var _this$forms$get3; - (_this$forms$get2 = this.forms.get(childForm)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.destroy(); + (_this$forms$get3 = this.forms.get(childForm)) === null || _this$forms$get3 === void 0 ? void 0 : _this$forms$get3.destroy(); this.forms.delete(childForm); } // Only add the form if below the limit of forms per page @@ -11842,9 +11924,7 @@ class DefaultScanner { if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new _Form.Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)); } else { - if ((0, _autofillUtils.shouldLog)()) { - console.log('The page has too many forms, stop adding them.'); - } + this.stopScanner('The page has too many forms, stop adding them.'); } } } @@ -11870,9 +11950,14 @@ class DefaultScanner { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { + var _window$performance3, _window$performance3$, _window$performance4, _window$performance4$; + + (_window$performance3 = window.performance) === null || _window$performance3 === void 0 ? void 0 : (_window$performance3$ = _window$performance3.mark) === null || _window$performance3$ === void 0 ? void 0 : _window$performance3$.call(_window$performance3, 'scanner:init:start'); this.processChangedElements(); this.changedElements.clear(); this.rescanAll = false; + (_window$performance4 = window.performance) === null || _window$performance4 === void 0 ? void 0 : (_window$performance4$ = _window$performance4.mark) === null || _window$performance4$ === void 0 ? void 0 : _window$performance4$.call(_window$performance4, 'scanner:init:end'); + (0, _autofillUtils.logPerformance)('scanner'); }, this.options.debounceTimePeriod); } /** @@ -14050,8 +14135,10 @@ exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedCon exports.isLocalNetwork = isLocalNetwork; exports.isPotentiallyViewable = void 0; exports.isValidTLD = isValidTLD; +exports.logPerformance = logPerformance; exports.setValue = exports.sendAndWaitForAnswer = exports.safeExecute = exports.removeInlineStyles = exports.notifyWebApp = void 0; exports.shouldLog = shouldLog; +exports.shouldLogPerformance = shouldLogPerformance; exports.truncateFromMiddle = truncateFromMiddle; exports.wasAutofilledByChrome = void 0; exports.whenIdle = whenIdle; @@ -14469,26 +14556,39 @@ const buttonMatchesFormType = (el, formObj) => { return true; } }; + +exports.buttonMatchesFormType = buttonMatchesFormType; +const buttonInputTypes = ['submit', 'button']; /** * Get the text of an element * @param {Element} el * @returns {string} */ - -exports.buttonMatchesFormType = buttonMatchesFormType; - const getText = el => { // for buttons, we don't care about descendants, just get the whole text as is // this is important in order to give proper attribution of the text to the button if (el instanceof HTMLButtonElement) return (0, _matching.removeExcessWhitespace)(el.textContent); - if (el instanceof HTMLInputElement && ['submit', 'button'].includes(el.type)) return el.value; - if (el instanceof HTMLInputElement && el.type === 'image') { - return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + if (el instanceof HTMLInputElement) { + if (buttonInputTypes.includes(el.type)) { + return el.value; + } + + if (el.type === 'image') { + return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + } + } + + let text = ''; + + for (const childNode of el.childNodes) { + if (childNode instanceof Text) { + text += ' ' + childNode.textContent; + } } - return (0, _matching.removeExcessWhitespace)(Array.from(el.childNodes).reduce((text, child) => child instanceof Text ? text + ' ' + child.textContent : text, '')); + return (0, _matching.removeExcessWhitespace)(text); }; /** * Check if hostname is a local address @@ -14532,7 +14632,7 @@ const wasAutofilledByChrome = input => { } }; /** - * Checks if we should log debug info to the console + * Checks if we should log form analysis debug info to the console * @returns {boolean} */ @@ -14540,15 +14640,44 @@ const wasAutofilledByChrome = input => { exports.wasAutofilledByChrome = wasAutofilledByChrome; function shouldLog() { + return readDebugSetting('ddg-autofill-debug'); +} +/** + * Checks if we should log performance info to the console + * @returns {boolean} + */ + + +function shouldLogPerformance() { + return readDebugSetting('ddg-autofill-perf'); +} +/** + * Check if a sessionStorage item is set to 'true' + * @param setting + * @returns {boolean} + */ + + +function readDebugSetting(setting) { // sessionStorage throws in invalid schemes like data: and file: try { var _window$sessionStorag; - return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem('ddg-autofill-debug')) === 'true'; + return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem(setting)) === 'true'; } catch (e) { return false; } } + +function logPerformance(markName) { + if (shouldLogPerformance()) { + var _window$performance, _window$performance2; + + const measurement = (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : _window$performance.measure("".concat(markName, ":init"), "".concat(markName, ":init:start"), "".concat(markName, ":init:end")); + console.log("".concat(markName, " took ").concat(Math.round(measurement === null || measurement === void 0 ? void 0 : measurement.duration), "ms")); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : _window$performance2.clearMarks(); + } +} /** * * @param {Function} callback diff --git a/docs/runtime.windows.md b/docs/runtime.windows.md index 063780df2..68c4a02b4 100644 --- a/docs/runtime.windows.md +++ b/docs/runtime.windows.md @@ -1,6 +1,6 @@ ## Links -- [Privacy Test Pages, Form Submissions](https://privacy-test-pages.glitch.me/autofill/form-submission.html) +- [Privacy Test Pages, Form Submissions](https://privacy-test-pages.site/autofill/form-submission.html) ## `getRuntimeConfiguration()` diff --git a/jest.config.js b/jest.config.js index 9ad32a7a7..4e9a969d0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,7 +14,7 @@ module.exports = { testEnvironment: './jest-test-environment.js', // Indicates whether each individual test should be reported during the run - verbose: true, + verbose: false, // ensure snapshots are in a JSON format snapshotFormat: { diff --git a/jest.setup.js b/jest.setup.js index cb87948f1..65a97ade6 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -111,3 +111,19 @@ Object.defineProperty(window.HTMLElement.prototype, 'offsetHeight', { return this._jsdomMockOffsetHeight || 0 } }) + +// getComputedStyle is super slow on jsdom, by providing this mock we speed tests up significantly +const defaultStyle = { + display: 'block', + visibility: 'visible', + opacity: '1', + paddingRight: '10' +} +const mockGetComputedStyle = (el) => { + return { + // since we don't load stylesheets in the tests, the style prop is all the css applied, so it's a safe fallback + getPropertyValue: (prop) => el.style?.[prop] || defaultStyle[prop] + } +} +// @ts-ignore +jest.spyOn(window, 'getComputedStyle').mockImplementation(mockGetComputedStyle) diff --git a/package.json b/package.json index 04adafffa..44de19911 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "eslint .", "lint:fix": "npm run lint -- --fix", "copy-assets": "node scripts/copy-assets.js", - "open-test-extension": "npx web-ext run -t chromium -u https://privacy-test-pages.glitch.me/ -s integration-test/extension", + "open-test-extension": "npx web-ext run -t chromium -u https://privacy-test-pages.site/ -s integration-test/extension", "schema:generate": "node scripts/api-call-generator.js", "test": "npm run test:unit && npm run lint && tsc", "test:clean-tree": "npm run build && sh scripts/check-for-changes.sh", diff --git a/src/DeviceInterface/ExtensionInterface.js b/src/DeviceInterface/ExtensionInterface.js index d864935f6..c7f60a043 100644 --- a/src/DeviceInterface/ExtensionInterface.js +++ b/src/DeviceInterface/ExtensionInterface.js @@ -57,13 +57,8 @@ class ExtensionInterface extends InterfacePrototype { return null } - removeAutofillUIFromPage () { - super.removeAutofillUIFromPage() - this.activeForm?.removeAllDecorations() - } - async resetAutofillUI (callback) { - this.removeAutofillUIFromPage() + this.removeAutofillUIFromPage('Resetting autofill.') await this.setupAutofill() diff --git a/src/DeviceInterface/InterfacePrototype.js b/src/DeviceInterface/InterfacePrototype.js index 92ca56d5d..d3f30dffa 100644 --- a/src/DeviceInterface/InterfacePrototype.js +++ b/src/DeviceInterface/InterfacePrototype.js @@ -73,7 +73,7 @@ class InterfacePrototype { /** @type {boolean} */ isInitializationStarted; - /** @type {(()=>void) | null} */ + /** @type {((reason, ...rest) => void) | null} */ _scannerCleanup = null /** @@ -102,9 +102,12 @@ class InterfacePrototype { return new NativeUIController() } - removeAutofillUIFromPage () { + /** + * @param {string} reason + */ + removeAutofillUIFromPage (reason) { this.uiController?.destroy() - this._scannerCleanup?.() + this._scannerCleanup?.(reason) } get hasLocalAddresses () { @@ -332,7 +335,7 @@ class InterfacePrototype { postInit () { const cleanup = this.scanner.init() this.addLogoutListener(() => { - cleanup() + cleanup('Logged out') if (this.globalConfig.isDDGDomain) { notifyWebApp({ deviceSignedIn: {value: false} }) } diff --git a/src/Form/Form.js b/src/Form/Form.js index 0632ed086..4c9b5cb72 100644 --- a/src/Form/Form.js +++ b/src/Form/Form.js @@ -128,6 +128,9 @@ class Form { get isHybrid () { return this.formAnalyzer.isHybrid } + get isCCForm () { + return this.formAnalyzer.isCCForm() + } logFormInfo () { if (!shouldLog()) return @@ -352,6 +355,7 @@ class Form { destroy () { this.removeAllDecorations() this.removeTooltip() + this.forgetAllInputs() this.mutObs.disconnect() this.matching.clear() this.intObs = null @@ -451,6 +455,7 @@ class Form { const opts = { isLogin: this.isLogin, isHybrid: this.isHybrid, + isCCForm: this.isCCForm, hasCredentials: Boolean(this.device.settings.availableInputTypes.credentials?.username), supportsIdentitiesAutofill: this.device.settings.featureToggles.inputType_identities } diff --git a/src/Form/FormAnalyzer.js b/src/Form/FormAnalyzer.js index b87f80f38..79e36061d 100644 --- a/src/Form/FormAnalyzer.js +++ b/src/Form/FormAnalyzer.js @@ -277,6 +277,45 @@ class FormAnalyzer { } return this } + + /** @type {undefined|boolean} */ + _isCCForm = undefined + /** + * Tries to infer if it's a credit card form + * @returns {boolean} + */ + isCCForm () { + if (this._isCCForm !== undefined) return this._isCCForm + + const formEl = this.form + const ccFieldSelector = this.matching.joinCssSelectors('cc') + if (!ccFieldSelector) { + this._isCCForm = false + return this._isCCForm + } + const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector) + // If the form contains one of the specific selectors, we have high confidence + if (hasCCSelectorChild) { + this._isCCForm = true + return this._isCCForm + } + + // Read form attributes to find a signal + const hasCCAttribute = [...formEl.attributes].some(({name, value}) => + /(credit|payment).?card/i.test(`${name}=${value}`) + ) + if (hasCCAttribute) { + this._isCCForm = true + return this._isCCForm + } + + // Match form textContent against common cc fields (includes hidden labels) + const textMatches = formEl.textContent?.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig) + + // We check for more than one to minimise false positives + this._isCCForm = Boolean(textMatches && textMatches.length > 1) + return this._isCCForm + } } export default FormAnalyzer diff --git a/src/Form/input-classifiers.test.js b/src/Form/input-classifiers.test.js index 2d4c66139..edf174869 100644 --- a/src/Form/input-classifiers.test.js +++ b/src/Form/input-classifiers.test.js @@ -13,6 +13,9 @@ import {createAvailableInputTypes} from '../../integration-test/helpers/utils.js * @type {object[]} */ const testCases = JSON.parse(fs.readFileSync(path.join(__dirname, 'test-cases/index.json')).toString('utf-8')) +testCases.forEach(testCase => { + testCase.testContent = fs.readFileSync(path.resolve(__dirname, './test-cases', testCase.html), 'utf-8') +}) /** * @param {HTMLInputElement} el @@ -147,15 +150,16 @@ describe.each(testCases)('Test $html fields', (testCase) => { expectedSubmitFalsePositives = 0, expectedSubmitFalseNegatives = 0, title = '__test__', - hasExtraWrappers = true + hasExtraWrappers = true, + testContent } = testCase const testTextString = expectedFailures.length > 0 ? `should contain ${expectedFailures.length} known failure(s): ${JSON.stringify(expectedFailures)}` : `should NOT contain failures` - it(testTextString, () => { - const testContent = fs.readFileSync(path.resolve(__dirname, './test-cases', html), 'utf-8') + it.concurrent(testTextString, async () => { + document.body.innerHTML = '' let baseWrapper = document.body diff --git a/src/Form/matching.js b/src/Form/matching.js index 35b6d6600..41bc130db 100644 --- a/src/Form/matching.js +++ b/src/Form/matching.js @@ -223,7 +223,7 @@ class Matching { // // For CC forms we run aggressive matches, so we want to make sure we only // // run them on actual CC forms to avoid false positives and expensive loops - if (this.isCCForm(formEl)) { + if (opts.isCCForm) { const subtype = this.subtypeFromMatchers('cc', input) if (subtype && isValidCreditCardSubtype(subtype)) { return `creditCards.${subtype}` @@ -278,6 +278,7 @@ class Matching { * @typedef {{ * isLogin?: boolean, * isHybrid?: boolean, + * isCCForm?: boolean, * hasCredentials?: boolean, * supportsIdentitiesAutofill?: boolean * }} SetInputTypeOpts @@ -559,32 +560,6 @@ class Matching { this.setActiveElementStrings(input, form) return this } - /** - * Tries to infer if it's a credit card form - * @param {HTMLElement} formEl - * @returns {boolean} - */ - isCCForm (formEl) { - const ccFieldSelector = this.joinCssSelectors('cc') - if (!ccFieldSelector) { - return false - } - const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector) - // If the form contains one of the specific selectors, we have high confidence - if (hasCCSelectorChild) return true - - // Read form attributes to find a signal - const hasCCAttribute = [...formEl.attributes].some(({name, value}) => - /(credit|payment).?card/i.test(`${name}=${value}`) - ) - if (hasCCAttribute) return true - - // Match form textContent against common cc fields (includes hidden labels) - const textMatches = formEl.textContent?.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig) - - // We check for more than one to minimise false positives - return Boolean(textMatches && textMatches.length > 1) - } /** * @type {MatchingConfiguration} @@ -759,7 +734,9 @@ function getInputSubtype (input) { * @return {string} */ const removeExcessWhitespace = (string = '') => { - return (string || '') + if (!string) return '' + + return (string) .replace(/\n/g, ' ') .replace(/\s{2,}/g, ' ').trim() } diff --git a/src/Form/matching.test.js b/src/Form/matching.test.js index 146164ddb..9154a3b8d 100644 --- a/src/Form/matching.test.js +++ b/src/Form/matching.test.js @@ -92,9 +92,21 @@ describe('matching', () => { { html: ``, subtype: 'unknown' }, { html: ``, subtype: 'credentials.username' }, { html: ``, subtype: 'unknown' }, - { html: ``, subtype: 'creditCards.cardName' }, - { html: ``, subtype: 'creditCards.cardName' }, - { html: ``, subtype: 'creditCards.expirationMonth' }, + { + html: ``, + subtype: 'creditCards.cardName', + opts: {isCCForm: true} + }, + { + html: ``, + subtype: 'creditCards.cardName', + opts: {isCCForm: true} + }, + { + html: ``, + subtype: 'creditCards.expirationMonth', + opts: {isCCForm: true} + }, { html: ``, subtype: 'identities.addressPostalCode' }, { html: ``, subtype: 'identities.addressStreet2' }, { html: ``, subtype: 'identities.addressStreet' }, diff --git a/src/InContextSignup.js b/src/InContextSignup.js index 4c53c98d9..e776eb31f 100644 --- a/src/InContextSignup.js +++ b/src/InContextSignup.js @@ -110,7 +110,7 @@ export class InContextSignup { onIncontextSignupDismissed (options = { shouldHideTooltip: true }) { if (options.shouldHideTooltip) { - this.device.removeAutofillUIFromPage() + this.device.removeAutofillUIFromPage('Email Protection in-context signup dismissed.') this.device.deviceApi.notify(new CloseAutofillParentCall(null)) } this.permanentlyDismissedAt = new Date().getTime() diff --git a/src/Scanner.js b/src/Scanner.js index 389c1a889..23bdadf7d 100644 --- a/src/Scanner.js +++ b/src/Scanner.js @@ -2,7 +2,7 @@ import { Form } from './Form/Form.js' import { SUBMIT_BUTTON_SELECTOR, FORM_INPUTS_SELECTOR } from './Form/selectors-css.js' import { constants } from './constants.js' import { createMatching } from './Form/matching.js' -import {isFormLikelyToBeUsedAsPageWrapper, shouldLog} from './autofill-utils.js' +import {logPerformance, isFormLikelyToBeUsedAsPageWrapper, shouldLog} from './autofill-utils.js' import { AddDebugFlagCall } from './deviceApiCalls/__generated__/deviceApiCalls.js' const { @@ -14,7 +14,7 @@ const { /** * @typedef {{ * forms: Map; - * init(): ()=> void; + * init(): (reason, ...rest)=> void; * enqueue(elements: (HTMLElement|Document)[]): void; * findEligibleInputs(context): Scanner; * options: ScannerOptions; @@ -66,6 +66,8 @@ class DefaultScanner { activeInput = null; /** @type {boolean} A flag to indicate the whole page will be re-scanned */ rescanAll = false; + /** @type {boolean} Indicates whether we called stopScanning */ + stopped = false /** * @param {import("./DeviceInterface/InterfacePrototype").default} device @@ -92,7 +94,7 @@ class DefaultScanner { * Call this to scan once and then watch for changes. * * Call the returned function to remove listeners. - * @returns {() => void} + * @returns {(reason: string, ...rest) => void} */ init () { if (this.device.globalConfig.isExtension) { @@ -106,21 +108,8 @@ class DefaultScanner { // otherwise, use the delay time to defer the initial scan setTimeout(() => this.scanAndObserve(), delay) } - return () => { - const activeInput = this.device.activeForm?.activeInput - - // remove Dax, listeners, timers, and observers - clearTimeout(this.debounceTimer) - this.mutObs.disconnect() - - this.forms.forEach(form => { - form.resetAllInputs() - form.removeAllDecorations() - }) - this.forms.clear() - - // Bring the user back to the input they were interacting with - activeInput?.focus() + return (reason, ...rest) => { + this.stopScanner(reason, ...rest) } } @@ -128,9 +117,10 @@ class DefaultScanner { * Scan the page and begin observing changes */ scanAndObserve () { - window.performance?.mark?.('scanner:init:start') + window.performance?.mark?.('initial_scanner:init:start') this.findEligibleInputs(document) - window.performance?.mark?.('scanner:init:end') + window.performance?.mark?.('initial_scanner:init:end') + logPerformance('initial_scanner') this.mutObs.observe(document.documentElement, { childList: true, subtree: true }) } @@ -148,6 +138,7 @@ class DefaultScanner { } else { const inputs = context.querySelectorAll(FORM_INPUTS_SELECTOR) if (inputs.length > this.options.maxInputsPerPage) { + this.stopScanner('Too many input fields in the given context, stop scanning', context) return this } inputs.forEach((input) => this.addInput(input)) @@ -155,16 +146,49 @@ class DefaultScanner { return this } + /** + * Stops scanning, switches off the mutation observer and clears all forms + * @param {string} reason + * @param {...any} rest + */ + stopScanner (reason, ...rest) { + this.stopped = true + + if (shouldLog()) { + console.log(reason, ...rest) + } + + const activeInput = this.device.activeForm?.activeInput + + // remove Dax, listeners, timers, and observers + clearTimeout(this.debounceTimer) + this.changedElements.clear() + this.mutObs.disconnect() + + this.forms.forEach(form => { + form.destroy() + }) + this.forms.clear() + + // Bring the user back to the input they were interacting with + activeInput?.focus() + } + /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input * @returns {HTMLFormElement|HTMLElement} */ getParentForm (input) { if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { - // Use input.form unless it encloses most of the DOM - // In that case we proceed to identify more precise wrappers - if (input.form && !isFormLikelyToBeUsedAsPageWrapper(input.form)) { - return input.form + if (input.form) { + // Use input.form unless it encloses most of the DOM + // In that case we proceed to identify more precise wrappers + if ( + this.forms.has(input.form) || // If we've added the form we've already checked that it's not a page wrapper + !isFormLikelyToBeUsedAsPageWrapper(input.form) + ) { + return input.form + } } } @@ -195,10 +219,34 @@ class DefaultScanner { * @param {HTMLInputElement|HTMLSelectElement} input */ addInput (input) { + if (this.stopped) return + const parentForm = this.getParentForm(input) - // Note that el.contains returns true for el itself - const previouslyFoundParent = [...this.forms.keys()].find((form) => form.contains(parentForm)) + if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { + // We've met the form, add the input + this.forms.get(parentForm)?.addInput(input) + return + } + + // Check if the forms we've seen are either disconnected, + // or are parent/child of the currently-found form + let previouslyFoundParent, childForm + for (const [formEl] of this.forms) { + // Remove disconnected forms to avoid leaks + if (!formEl.isConnected) { + this.forms.delete(formEl) + continue + } + if (formEl.contains(parentForm)) { + previouslyFoundParent = formEl + break + } + if (parentForm.contains(formEl)) { + childForm = formEl + break + } + } if (previouslyFoundParent) { if (parentForm instanceof HTMLFormElement && parentForm !== previouslyFoundParent) { @@ -210,7 +258,6 @@ class DefaultScanner { } } else { // if this form is an ancestor of an existing form, remove that before adding this - const childForm = [...this.forms.keys()].find((form) => parentForm.contains(form)) if (childForm) { this.forms.get(childForm)?.destroy() this.forms.delete(childForm) @@ -220,9 +267,7 @@ class DefaultScanner { if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)) } else { - if (shouldLog()) { - console.log('The page has too many forms, stop adding them.') - } + this.stopScanner('The page has too many forms, stop adding them.') } } } @@ -247,9 +292,12 @@ class DefaultScanner { clearTimeout(this.debounceTimer) this.debounceTimer = setTimeout(() => { + window.performance?.mark?.('scanner:init:start') this.processChangedElements() this.changedElements.clear() this.rescanAll = false + window.performance?.mark?.('scanner:init:end') + logPerformance('scanner') }, this.options.debounceTimePeriod) } diff --git a/src/__snapshots__/Scanner.test.js.snap b/src/__snapshots__/Scanner.test.js.snap index 100c91d5e..b5e19867b 100644 --- a/src/__snapshots__/Scanner.test.js.snap +++ b/src/__snapshots__/Scanner.test.js.snap @@ -212,7 +212,6 @@ exports[`performance should stop scanning if page grows above maximum forms 1`] for="input-01" > @@ -223,7 +222,6 @@ exports[`performance should stop scanning if page grows above maximum forms 1`] for="input-02" > @@ -234,7 +232,6 @@ exports[`performance should stop scanning if page grows above maximum forms 1`] for="input-03" > @@ -245,7 +242,6 @@ exports[`performance should stop scanning if page grows above maximum forms 1`] for="input-04" > @@ -256,7 +252,6 @@ exports[`performance should stop scanning if page grows above maximum forms 1`] for="input-05" > @@ -336,7 +331,6 @@ exports[`performance should stop scanning if page grows above maximum inputs 1`] for="input-01" > @@ -347,7 +341,6 @@ exports[`performance should stop scanning if page grows above maximum inputs 1`] for="input-02" > @@ -358,7 +351,6 @@ exports[`performance should stop scanning if page grows above maximum inputs 1`] for="input-03" > @@ -369,7 +361,6 @@ exports[`performance should stop scanning if page grows above maximum inputs 1`] for="input-04" > @@ -380,7 +371,6 @@ exports[`performance should stop scanning if page grows above maximum inputs 1`] for="input-05" > diff --git a/src/autofill-utils.js b/src/autofill-utils.js index 30893f29c..9bd15c972 100644 --- a/src/autofill-utils.js +++ b/src/autofill-utils.js @@ -334,6 +334,7 @@ const buttonMatchesFormType = (el, formObj) => { } } +const buttonInputTypes = ['submit', 'button'] /** * Get the text of an element * @param {Element} el @@ -344,15 +345,24 @@ const getText = (el) => { // this is important in order to give proper attribution of the text to the button if (el instanceof HTMLButtonElement) return removeExcessWhitespace(el.textContent) - if (el instanceof HTMLInputElement && ['submit', 'button'].includes(el.type)) return el.value - if (el instanceof HTMLInputElement && el.type === 'image') { - return removeExcessWhitespace(el.alt || el.value || el.title || el.name) + if (el instanceof HTMLInputElement) { + if (buttonInputTypes.includes(el.type)) { + return el.value + } + + if (el.type === 'image') { + return removeExcessWhitespace(el.alt || el.value || el.title || el.name) + } } - return removeExcessWhitespace( - Array.from(el.childNodes).reduce((text, child) => - child instanceof Text ? text + ' ' + child.textContent : text, '') - ) + let text = '' + for (const childNode of el.childNodes) { + if (childNode instanceof Text) { + text += ' ' + childNode.textContent + } + } + + return removeExcessWhitespace(text) } /** @@ -397,18 +407,43 @@ const wasAutofilledByChrome = (input) => { } /** - * Checks if we should log debug info to the console + * Checks if we should log form analysis debug info to the console * @returns {boolean} */ function shouldLog () { + return readDebugSetting('ddg-autofill-debug') +} + +/** + * Checks if we should log performance info to the console + * @returns {boolean} + */ +function shouldLogPerformance () { + return readDebugSetting('ddg-autofill-perf') +} + +/** + * Check if a sessionStorage item is set to 'true' + * @param setting + * @returns {boolean} + */ +function readDebugSetting (setting) { // sessionStorage throws in invalid schemes like data: and file: try { - return window.sessionStorage?.getItem('ddg-autofill-debug') === 'true' + return window.sessionStorage?.getItem(setting) === 'true' } catch (e) { return false } } +function logPerformance (markName) { + if (shouldLogPerformance()) { + const measurement = window.performance?.measure(`${markName}:init`, `${markName}:init:start`, `${markName}:init:end`) + console.log(`${markName} took ${Math.round(measurement?.duration)}ms`) + window.performance?.clearMarks() + } +} + /** * * @param {Function} callback @@ -486,6 +521,8 @@ export { isValidTLD, wasAutofilledByChrome, shouldLog, + shouldLogPerformance, + logPerformance, whenIdle, truncateFromMiddle, isFormLikelyToBeUsedAsPageWrapper diff --git a/swift-package/Resources/assets/autofill-debug.js b/swift-package/Resources/assets/autofill-debug.js index b830b634d..799be7996 100644 --- a/swift-package/Resources/assets/autofill-debug.js +++ b/swift-package/Resources/assets/autofill-debug.js @@ -8147,15 +8147,8 @@ class ExtensionInterface extends _InterfacePrototype.default { return null; } - removeAutofillUIFromPage() { - var _this$activeForm2; - - super.removeAutofillUIFromPage(); - (_this$activeForm2 = this.activeForm) === null || _this$activeForm2 === void 0 ? void 0 : _this$activeForm2.removeAllDecorations(); - } - async resetAutofillUI(callback) { - this.removeAutofillUIFromPage(); + this.removeAutofillUIFromPage('Resetting autofill.'); await this.setupAutofill(); if (callback) await callback(); this.uiController = this.createUIController(); @@ -8193,7 +8186,7 @@ class ExtensionInterface extends _InterfacePrototype.default { switch (this.getActiveTooltipType()) { case TOOLTIP_TYPES.EmailProtection: { - var _this$activeForm3; + var _this$activeForm2; this._scannerCleanup = this.scanner.init(); this.addLogoutListener(() => { @@ -8208,12 +8201,12 @@ class ExtensionInterface extends _InterfacePrototype.default { } }); - if ((_this$activeForm3 = this.activeForm) !== null && _this$activeForm3 !== void 0 && _this$activeForm3.activeInput) { - var _this$activeForm4; + if ((_this$activeForm2 = this.activeForm) !== null && _this$activeForm2 !== void 0 && _this$activeForm2.activeInput) { + var _this$activeForm3; this.attachTooltip({ form: this.activeForm, - input: (_this$activeForm4 = this.activeForm) === null || _this$activeForm4 === void 0 ? void 0 : _this$activeForm4.activeInput, + input: (_this$activeForm3 = this.activeForm) === null || _this$activeForm3 === void 0 ? void 0 : _this$activeForm3.activeInput, click: null, trigger: 'postSignup', triggerMetaData: { @@ -8449,7 +8442,7 @@ class InterfacePrototype { /** @type {boolean} */ - /** @type {(()=>void) | null} */ + /** @type {((reason, ...rest) => void) | null} */ /** * @param {GlobalConfig} config @@ -8523,12 +8516,16 @@ class InterfacePrototype { createUIController() { return new _NativeUIController.NativeUIController(); } + /** + * @param {string} reason + */ - removeAutofillUIFromPage() { + + removeAutofillUIFromPage(reason) { var _this$uiController, _this$_scannerCleanup; (_this$uiController = this.uiController) === null || _this$uiController === void 0 ? void 0 : _this$uiController.destroy(); - (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this); + (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this, reason); } get hasLocalAddresses() { @@ -8768,7 +8765,7 @@ class InterfacePrototype { postInit() { const cleanup = this.scanner.init(); this.addLogoutListener(() => { - cleanup(); + cleanup('Logged out'); if (this.globalConfig.isDDGDomain) { (0, _autofillUtils.notifyWebApp)({ @@ -10220,6 +10217,10 @@ class Form { return this.formAnalyzer.isHybrid; } + get isCCForm() { + return this.formAnalyzer.isCCForm(); + } + logFormInfo() { if (!(0, _autofillUtils.shouldLog)()) return; console.log("Form type: %c".concat(this.getFormType()), 'font-weight: bold'); @@ -10476,6 +10477,7 @@ class Form { destroy() { this.removeAllDecorations(); this.removeTooltip(); + this.forgetAllInputs(); this.mutObs.disconnect(); this.matching.clear(); this.intObs = null; @@ -10578,6 +10580,7 @@ class Form { const opts = { isLogin: this.isLogin, isHybrid: this.isHybrid, + isCCForm: this.isCCForm, hasCredentials: Boolean((_this$device$settings = this.device.settings.availableInputTypes.credentials) === null || _this$device$settings === void 0 ? void 0 : _this$device$settings.username), supportsIdentitiesAutofill: this.device.settings.featureToggles.inputType_identities }; @@ -11030,6 +11033,8 @@ class FormAnalyzer { _defineProperty(this, "matching", void 0); + _defineProperty(this, "_isCCForm", undefined); + this.form = form; this.matching = matching || new _matching.Matching(_matchingConfiguration.matchingConfiguration); /** @@ -11308,6 +11313,52 @@ class FormAnalyzer { return this; } + /** @type {undefined|boolean} */ + + + /** + * Tries to infer if it's a credit card form + * @returns {boolean} + */ + isCCForm() { + var _formEl$textContent; + + if (this._isCCForm !== undefined) return this._isCCForm; + const formEl = this.form; + const ccFieldSelector = this.matching.joinCssSelectors('cc'); + + if (!ccFieldSelector) { + this._isCCForm = false; + return this._isCCForm; + } + + const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence + + if (hasCCSelectorChild) { + this._isCCForm = true; + return this._isCCForm; + } // Read form attributes to find a signal + + + const hasCCAttribute = [...formEl.attributes].some(_ref3 => { + let { + name, + value + } = _ref3; + return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); + }); + + if (hasCCAttribute) { + this._isCCForm = true; + return this._isCCForm; + } // Match form textContent against common cc fields (includes hidden labels) + + + const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives + + this._isCCForm = Boolean(textMatches && textMatches.length > 1); + return this._isCCForm; + } } @@ -13755,7 +13806,7 @@ class Matching { this.setActiveElementStrings(input, formEl); // // For CC forms we run aggressive matches, so we want to make sure we only // // run them on actual CC forms to avoid false positives and expensive loops - if (this.isCCForm(formEl)) { + if (opts.isCCForm) { const subtype = this.subtypeFromMatchers('cc', input); if (subtype && isValidCreditCardSubtype(subtype)) { @@ -13806,6 +13857,7 @@ class Matching { * @typedef {{ * isLogin?: boolean, * isHybrid?: boolean, + * isCCForm?: boolean, * hasCredentials?: boolean, * supportsIdentitiesAutofill?: boolean * }} SetInputTypeOpts @@ -14139,39 +14191,6 @@ class Matching { this.setActiveElementStrings(input, form); return this; } - /** - * Tries to infer if it's a credit card form - * @param {HTMLElement} formEl - * @returns {boolean} - */ - - - isCCForm(formEl) { - var _formEl$textContent; - - const ccFieldSelector = this.joinCssSelectors('cc'); - - if (!ccFieldSelector) { - return false; - } - - const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence - - if (hasCCSelectorChild) return true; // Read form attributes to find a signal - - const hasCCAttribute = [...formEl.attributes].some(_ref => { - let { - name, - value - } = _ref; - return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); - }); - if (hasCCAttribute) return true; // Match form textContent against common cc fields (includes hidden labels) - - const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives - - return Boolean(textMatches && textMatches.length > 1); - } /** * @type {MatchingConfiguration} */ @@ -14340,7 +14359,8 @@ function getInputSubtype(input) { const removeExcessWhitespace = function () { let string = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; - return (string || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); + if (!string) return ''; + return string.replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); }; /** * Get text from all explicit labels @@ -14780,7 +14800,7 @@ class InContextSignup { }; if (options.shouldHideTooltip) { - this.device.removeAutofillUIFromPage(); + this.device.removeAutofillUIFromPage('Email Protection in-context signup dismissed.'); this.device.deviceApi.notify(new _deviceApiCalls.CloseAutofillParentCall(null)); } @@ -15251,7 +15271,7 @@ const { /** * @typedef {{ * forms: Map; - * init(): ()=> void; + * init(): (reason, ...rest)=> void; * enqueue(elements: (HTMLElement|Document)[]): void; * findEligibleInputs(context): Scanner; * options: ScannerOptions; @@ -15304,6 +15324,8 @@ class DefaultScanner { /** @type {boolean} A flag to indicate the whole page will be re-scanned */ + /** @type {boolean} Indicates whether we called stopScanning */ + /** * @param {import("./DeviceInterface/InterfacePrototype").default} device * @param {ScannerOptions} options @@ -15321,6 +15343,8 @@ class DefaultScanner { _defineProperty(this, "rescanAll", false); + _defineProperty(this, "stopped", false); + _defineProperty(this, "mutObs", new MutationObserver(mutationList => { /** @type {HTMLElement[]} */ if (this.rescanAll) { @@ -15365,11 +15389,13 @@ class DefaultScanner { * Call this to scan once and then watch for changes. * * Call the returned function to remove listeners. - * @returns {() => void} + * @returns {(reason: string, ...rest) => void} */ init() { + var _this = this; + if (this.device.globalConfig.isExtension) { this.device.deviceApi.notify(new _deviceApiCalls.AddDebugFlagCall({ flag: 'autofill' @@ -15385,20 +15411,12 @@ class DefaultScanner { setTimeout(() => this.scanAndObserve(), delay); } - return () => { - var _this$device$activeFo; - - const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers - - clearTimeout(this.debounceTimer); - this.mutObs.disconnect(); - this.forms.forEach(form => { - form.resetAllInputs(); - form.removeAllDecorations(); - }); - this.forms.clear(); // Bring the user back to the input they were interacting with + return function (reason) { + for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + rest[_key - 1] = arguments[_key]; + } - activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + _this.stopScanner(reason, ...rest); }; } /** @@ -15409,9 +15427,10 @@ class DefaultScanner { scanAndObserve() { var _window$performance, _window$performance$m, _window$performance2, _window$performance2$; - (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'scanner:init:start'); + (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'initial_scanner:init:start'); this.findEligibleInputs(document); - (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'scanner:init:end'); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'initial_scanner:init:end'); + (0, _autofillUtils.logPerformance)('initial_scanner'); this.mutObs.observe(document.documentElement, { childList: true, subtree: true @@ -15436,6 +15455,7 @@ class DefaultScanner { const inputs = context.querySelectorAll(_selectorsCss.FORM_INPUTS_SELECTOR); if (inputs.length > this.options.maxInputsPerPage) { + this.stopScanner('Too many input fields in the given context, stop scanning', context); return this; } @@ -15444,6 +15464,38 @@ class DefaultScanner { return this; } + /** + * Stops scanning, switches off the mutation observer and clears all forms + * @param {string} reason + * @param {...any} rest + */ + + + stopScanner(reason) { + var _this$device$activeFo; + + this.stopped = true; + + if ((0, _autofillUtils.shouldLog)()) { + for (var _len2 = arguments.length, rest = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + rest[_key2 - 1] = arguments[_key2]; + } + + console.log(reason, ...rest); + } + + const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers + + clearTimeout(this.debounceTimer); + this.changedElements.clear(); + this.mutObs.disconnect(); + this.forms.forEach(form => { + form.destroy(); + }); + this.forms.clear(); // Bring the user back to the input they were interacting with + + activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + } /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input * @returns {HTMLFormElement|HTMLElement} @@ -15452,10 +15504,13 @@ class DefaultScanner { getParentForm(input) { if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { - // Use input.form unless it encloses most of the DOM - // In that case we proceed to identify more precise wrappers - if (input.form && !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { - return input.form; + if (input.form) { + // Use input.form unless it encloses most of the DOM + // In that case we proceed to identify more precise wrappers + if (this.forms.has(input.form) || // If we've added the form we've already checked that it's not a page wrapper + !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { + return input.form; + } } } @@ -15489,28 +15544,55 @@ class DefaultScanner { addInput(input) { - const parentForm = this.getParentForm(input); // Note that el.contains returns true for el itself + if (this.stopped) return; + const parentForm = this.getParentForm(input); - const previouslyFoundParent = [...this.forms.keys()].find(form => form.contains(parentForm)); + if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { + var _this$forms$get; + + // We've met the form, add the input + (_this$forms$get = this.forms.get(parentForm)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + return; + } // Check if the forms we've seen are either disconnected, + // or are parent/child of the currently-found form + + + let previouslyFoundParent, childForm; + + for (const [formEl] of this.forms) { + // Remove disconnected forms to avoid leaks + if (!formEl.isConnected) { + this.forms.delete(formEl); + continue; + } + + if (formEl.contains(parentForm)) { + previouslyFoundParent = formEl; + break; + } + + if (parentForm.contains(formEl)) { + childForm = formEl; + break; + } + } if (previouslyFoundParent) { if (parentForm instanceof HTMLFormElement && parentForm !== previouslyFoundParent) { // If we had a prior parent but this is an explicit form, the previous was a false positive this.forms.delete(previouslyFoundParent); } else { - var _this$forms$get; + var _this$forms$get2; // If we've already met the form or a descendant, add the input - (_this$forms$get = this.forms.get(previouslyFoundParent)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + (_this$forms$get2 = this.forms.get(previouslyFoundParent)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.addInput(input); } } else { // if this form is an ancestor of an existing form, remove that before adding this - const childForm = [...this.forms.keys()].find(form => parentForm.contains(form)); - if (childForm) { - var _this$forms$get2; + var _this$forms$get3; - (_this$forms$get2 = this.forms.get(childForm)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.destroy(); + (_this$forms$get3 = this.forms.get(childForm)) === null || _this$forms$get3 === void 0 ? void 0 : _this$forms$get3.destroy(); this.forms.delete(childForm); } // Only add the form if below the limit of forms per page @@ -15518,9 +15600,7 @@ class DefaultScanner { if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new _Form.Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)); } else { - if ((0, _autofillUtils.shouldLog)()) { - console.log('The page has too many forms, stop adding them.'); - } + this.stopScanner('The page has too many forms, stop adding them.'); } } } @@ -15546,9 +15626,14 @@ class DefaultScanner { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { + var _window$performance3, _window$performance3$, _window$performance4, _window$performance4$; + + (_window$performance3 = window.performance) === null || _window$performance3 === void 0 ? void 0 : (_window$performance3$ = _window$performance3.mark) === null || _window$performance3$ === void 0 ? void 0 : _window$performance3$.call(_window$performance3, 'scanner:init:start'); this.processChangedElements(); this.changedElements.clear(); this.rescanAll = false; + (_window$performance4 = window.performance) === null || _window$performance4 === void 0 ? void 0 : (_window$performance4$ = _window$performance4.mark) === null || _window$performance4$ === void 0 ? void 0 : _window$performance4$.call(_window$performance4, 'scanner:init:end'); + (0, _autofillUtils.logPerformance)('scanner'); }, this.options.debounceTimePeriod); } /** @@ -17726,8 +17811,10 @@ exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedCon exports.isLocalNetwork = isLocalNetwork; exports.isPotentiallyViewable = void 0; exports.isValidTLD = isValidTLD; +exports.logPerformance = logPerformance; exports.setValue = exports.sendAndWaitForAnswer = exports.safeExecute = exports.removeInlineStyles = exports.notifyWebApp = void 0; exports.shouldLog = shouldLog; +exports.shouldLogPerformance = shouldLogPerformance; exports.truncateFromMiddle = truncateFromMiddle; exports.wasAutofilledByChrome = void 0; exports.whenIdle = whenIdle; @@ -18145,26 +18232,39 @@ const buttonMatchesFormType = (el, formObj) => { return true; } }; + +exports.buttonMatchesFormType = buttonMatchesFormType; +const buttonInputTypes = ['submit', 'button']; /** * Get the text of an element * @param {Element} el * @returns {string} */ - -exports.buttonMatchesFormType = buttonMatchesFormType; - const getText = el => { // for buttons, we don't care about descendants, just get the whole text as is // this is important in order to give proper attribution of the text to the button if (el instanceof HTMLButtonElement) return (0, _matching.removeExcessWhitespace)(el.textContent); - if (el instanceof HTMLInputElement && ['submit', 'button'].includes(el.type)) return el.value; - if (el instanceof HTMLInputElement && el.type === 'image') { - return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + if (el instanceof HTMLInputElement) { + if (buttonInputTypes.includes(el.type)) { + return el.value; + } + + if (el.type === 'image') { + return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + } + } + + let text = ''; + + for (const childNode of el.childNodes) { + if (childNode instanceof Text) { + text += ' ' + childNode.textContent; + } } - return (0, _matching.removeExcessWhitespace)(Array.from(el.childNodes).reduce((text, child) => child instanceof Text ? text + ' ' + child.textContent : text, '')); + return (0, _matching.removeExcessWhitespace)(text); }; /** * Check if hostname is a local address @@ -18208,7 +18308,7 @@ const wasAutofilledByChrome = input => { } }; /** - * Checks if we should log debug info to the console + * Checks if we should log form analysis debug info to the console * @returns {boolean} */ @@ -18216,15 +18316,44 @@ const wasAutofilledByChrome = input => { exports.wasAutofilledByChrome = wasAutofilledByChrome; function shouldLog() { + return readDebugSetting('ddg-autofill-debug'); +} +/** + * Checks if we should log performance info to the console + * @returns {boolean} + */ + + +function shouldLogPerformance() { + return readDebugSetting('ddg-autofill-perf'); +} +/** + * Check if a sessionStorage item is set to 'true' + * @param setting + * @returns {boolean} + */ + + +function readDebugSetting(setting) { // sessionStorage throws in invalid schemes like data: and file: try { var _window$sessionStorag; - return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem('ddg-autofill-debug')) === 'true'; + return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem(setting)) === 'true'; } catch (e) { return false; } } + +function logPerformance(markName) { + if (shouldLogPerformance()) { + var _window$performance, _window$performance2; + + const measurement = (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : _window$performance.measure("".concat(markName, ":init"), "".concat(markName, ":init:start"), "".concat(markName, ":init:end")); + console.log("".concat(markName, " took ").concat(Math.round(measurement === null || measurement === void 0 ? void 0 : measurement.duration), "ms")); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : _window$performance2.clearMarks(); + } +} /** * * @param {Function} callback diff --git a/swift-package/Resources/assets/autofill.js b/swift-package/Resources/assets/autofill.js index 6923dae24..0d68909e1 100644 --- a/swift-package/Resources/assets/autofill.js +++ b/swift-package/Resources/assets/autofill.js @@ -4471,15 +4471,8 @@ class ExtensionInterface extends _InterfacePrototype.default { return null; } - removeAutofillUIFromPage() { - var _this$activeForm2; - - super.removeAutofillUIFromPage(); - (_this$activeForm2 = this.activeForm) === null || _this$activeForm2 === void 0 ? void 0 : _this$activeForm2.removeAllDecorations(); - } - async resetAutofillUI(callback) { - this.removeAutofillUIFromPage(); + this.removeAutofillUIFromPage('Resetting autofill.'); await this.setupAutofill(); if (callback) await callback(); this.uiController = this.createUIController(); @@ -4517,7 +4510,7 @@ class ExtensionInterface extends _InterfacePrototype.default { switch (this.getActiveTooltipType()) { case TOOLTIP_TYPES.EmailProtection: { - var _this$activeForm3; + var _this$activeForm2; this._scannerCleanup = this.scanner.init(); this.addLogoutListener(() => { @@ -4532,12 +4525,12 @@ class ExtensionInterface extends _InterfacePrototype.default { } }); - if ((_this$activeForm3 = this.activeForm) !== null && _this$activeForm3 !== void 0 && _this$activeForm3.activeInput) { - var _this$activeForm4; + if ((_this$activeForm2 = this.activeForm) !== null && _this$activeForm2 !== void 0 && _this$activeForm2.activeInput) { + var _this$activeForm3; this.attachTooltip({ form: this.activeForm, - input: (_this$activeForm4 = this.activeForm) === null || _this$activeForm4 === void 0 ? void 0 : _this$activeForm4.activeInput, + input: (_this$activeForm3 = this.activeForm) === null || _this$activeForm3 === void 0 ? void 0 : _this$activeForm3.activeInput, click: null, trigger: 'postSignup', triggerMetaData: { @@ -4773,7 +4766,7 @@ class InterfacePrototype { /** @type {boolean} */ - /** @type {(()=>void) | null} */ + /** @type {((reason, ...rest) => void) | null} */ /** * @param {GlobalConfig} config @@ -4847,12 +4840,16 @@ class InterfacePrototype { createUIController() { return new _NativeUIController.NativeUIController(); } + /** + * @param {string} reason + */ - removeAutofillUIFromPage() { + + removeAutofillUIFromPage(reason) { var _this$uiController, _this$_scannerCleanup; (_this$uiController = this.uiController) === null || _this$uiController === void 0 ? void 0 : _this$uiController.destroy(); - (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this); + (_this$_scannerCleanup = this._scannerCleanup) === null || _this$_scannerCleanup === void 0 ? void 0 : _this$_scannerCleanup.call(this, reason); } get hasLocalAddresses() { @@ -5092,7 +5089,7 @@ class InterfacePrototype { postInit() { const cleanup = this.scanner.init(); this.addLogoutListener(() => { - cleanup(); + cleanup('Logged out'); if (this.globalConfig.isDDGDomain) { (0, _autofillUtils.notifyWebApp)({ @@ -6544,6 +6541,10 @@ class Form { return this.formAnalyzer.isHybrid; } + get isCCForm() { + return this.formAnalyzer.isCCForm(); + } + logFormInfo() { if (!(0, _autofillUtils.shouldLog)()) return; console.log("Form type: %c".concat(this.getFormType()), 'font-weight: bold'); @@ -6800,6 +6801,7 @@ class Form { destroy() { this.removeAllDecorations(); this.removeTooltip(); + this.forgetAllInputs(); this.mutObs.disconnect(); this.matching.clear(); this.intObs = null; @@ -6902,6 +6904,7 @@ class Form { const opts = { isLogin: this.isLogin, isHybrid: this.isHybrid, + isCCForm: this.isCCForm, hasCredentials: Boolean((_this$device$settings = this.device.settings.availableInputTypes.credentials) === null || _this$device$settings === void 0 ? void 0 : _this$device$settings.username), supportsIdentitiesAutofill: this.device.settings.featureToggles.inputType_identities }; @@ -7354,6 +7357,8 @@ class FormAnalyzer { _defineProperty(this, "matching", void 0); + _defineProperty(this, "_isCCForm", undefined); + this.form = form; this.matching = matching || new _matching.Matching(_matchingConfiguration.matchingConfiguration); /** @@ -7632,6 +7637,52 @@ class FormAnalyzer { return this; } + /** @type {undefined|boolean} */ + + + /** + * Tries to infer if it's a credit card form + * @returns {boolean} + */ + isCCForm() { + var _formEl$textContent; + + if (this._isCCForm !== undefined) return this._isCCForm; + const formEl = this.form; + const ccFieldSelector = this.matching.joinCssSelectors('cc'); + + if (!ccFieldSelector) { + this._isCCForm = false; + return this._isCCForm; + } + + const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence + + if (hasCCSelectorChild) { + this._isCCForm = true; + return this._isCCForm; + } // Read form attributes to find a signal + + + const hasCCAttribute = [...formEl.attributes].some(_ref3 => { + let { + name, + value + } = _ref3; + return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); + }); + + if (hasCCAttribute) { + this._isCCForm = true; + return this._isCCForm; + } // Match form textContent against common cc fields (includes hidden labels) + + + const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives + + this._isCCForm = Boolean(textMatches && textMatches.length > 1); + return this._isCCForm; + } } @@ -10079,7 +10130,7 @@ class Matching { this.setActiveElementStrings(input, formEl); // // For CC forms we run aggressive matches, so we want to make sure we only // // run them on actual CC forms to avoid false positives and expensive loops - if (this.isCCForm(formEl)) { + if (opts.isCCForm) { const subtype = this.subtypeFromMatchers('cc', input); if (subtype && isValidCreditCardSubtype(subtype)) { @@ -10130,6 +10181,7 @@ class Matching { * @typedef {{ * isLogin?: boolean, * isHybrid?: boolean, + * isCCForm?: boolean, * hasCredentials?: boolean, * supportsIdentitiesAutofill?: boolean * }} SetInputTypeOpts @@ -10463,39 +10515,6 @@ class Matching { this.setActiveElementStrings(input, form); return this; } - /** - * Tries to infer if it's a credit card form - * @param {HTMLElement} formEl - * @returns {boolean} - */ - - - isCCForm(formEl) { - var _formEl$textContent; - - const ccFieldSelector = this.joinCssSelectors('cc'); - - if (!ccFieldSelector) { - return false; - } - - const hasCCSelectorChild = formEl.matches(ccFieldSelector) || formEl.querySelector(ccFieldSelector); // If the form contains one of the specific selectors, we have high confidence - - if (hasCCSelectorChild) return true; // Read form attributes to find a signal - - const hasCCAttribute = [...formEl.attributes].some(_ref => { - let { - name, - value - } = _ref; - return /(credit|payment).?card/i.test("".concat(name, "=").concat(value)); - }); - if (hasCCAttribute) return true; // Match form textContent against common cc fields (includes hidden labels) - - const textMatches = (_formEl$textContent = formEl.textContent) === null || _formEl$textContent === void 0 ? void 0 : _formEl$textContent.match(/(credit|payment).?card(.?number)?|ccv|security.?code|cvv|cvc|csc/ig); // We check for more than one to minimise false positives - - return Boolean(textMatches && textMatches.length > 1); - } /** * @type {MatchingConfiguration} */ @@ -10664,7 +10683,8 @@ function getInputSubtype(input) { const removeExcessWhitespace = function () { let string = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; - return (string || '').replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); + if (!string) return ''; + return string.replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim(); }; /** * Get text from all explicit labels @@ -11104,7 +11124,7 @@ class InContextSignup { }; if (options.shouldHideTooltip) { - this.device.removeAutofillUIFromPage(); + this.device.removeAutofillUIFromPage('Email Protection in-context signup dismissed.'); this.device.deviceApi.notify(new _deviceApiCalls.CloseAutofillParentCall(null)); } @@ -11575,7 +11595,7 @@ const { /** * @typedef {{ * forms: Map; - * init(): ()=> void; + * init(): (reason, ...rest)=> void; * enqueue(elements: (HTMLElement|Document)[]): void; * findEligibleInputs(context): Scanner; * options: ScannerOptions; @@ -11628,6 +11648,8 @@ class DefaultScanner { /** @type {boolean} A flag to indicate the whole page will be re-scanned */ + /** @type {boolean} Indicates whether we called stopScanning */ + /** * @param {import("./DeviceInterface/InterfacePrototype").default} device * @param {ScannerOptions} options @@ -11645,6 +11667,8 @@ class DefaultScanner { _defineProperty(this, "rescanAll", false); + _defineProperty(this, "stopped", false); + _defineProperty(this, "mutObs", new MutationObserver(mutationList => { /** @type {HTMLElement[]} */ if (this.rescanAll) { @@ -11689,11 +11713,13 @@ class DefaultScanner { * Call this to scan once and then watch for changes. * * Call the returned function to remove listeners. - * @returns {() => void} + * @returns {(reason: string, ...rest) => void} */ init() { + var _this = this; + if (this.device.globalConfig.isExtension) { this.device.deviceApi.notify(new _deviceApiCalls.AddDebugFlagCall({ flag: 'autofill' @@ -11709,20 +11735,12 @@ class DefaultScanner { setTimeout(() => this.scanAndObserve(), delay); } - return () => { - var _this$device$activeFo; - - const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers - - clearTimeout(this.debounceTimer); - this.mutObs.disconnect(); - this.forms.forEach(form => { - form.resetAllInputs(); - form.removeAllDecorations(); - }); - this.forms.clear(); // Bring the user back to the input they were interacting with + return function (reason) { + for (var _len = arguments.length, rest = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + rest[_key - 1] = arguments[_key]; + } - activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + _this.stopScanner(reason, ...rest); }; } /** @@ -11733,9 +11751,10 @@ class DefaultScanner { scanAndObserve() { var _window$performance, _window$performance$m, _window$performance2, _window$performance2$; - (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'scanner:init:start'); + (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : (_window$performance$m = _window$performance.mark) === null || _window$performance$m === void 0 ? void 0 : _window$performance$m.call(_window$performance, 'initial_scanner:init:start'); this.findEligibleInputs(document); - (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'scanner:init:end'); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : (_window$performance2$ = _window$performance2.mark) === null || _window$performance2$ === void 0 ? void 0 : _window$performance2$.call(_window$performance2, 'initial_scanner:init:end'); + (0, _autofillUtils.logPerformance)('initial_scanner'); this.mutObs.observe(document.documentElement, { childList: true, subtree: true @@ -11760,6 +11779,7 @@ class DefaultScanner { const inputs = context.querySelectorAll(_selectorsCss.FORM_INPUTS_SELECTOR); if (inputs.length > this.options.maxInputsPerPage) { + this.stopScanner('Too many input fields in the given context, stop scanning', context); return this; } @@ -11768,6 +11788,38 @@ class DefaultScanner { return this; } + /** + * Stops scanning, switches off the mutation observer and clears all forms + * @param {string} reason + * @param {...any} rest + */ + + + stopScanner(reason) { + var _this$device$activeFo; + + this.stopped = true; + + if ((0, _autofillUtils.shouldLog)()) { + for (var _len2 = arguments.length, rest = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + rest[_key2 - 1] = arguments[_key2]; + } + + console.log(reason, ...rest); + } + + const activeInput = (_this$device$activeFo = this.device.activeForm) === null || _this$device$activeFo === void 0 ? void 0 : _this$device$activeFo.activeInput; // remove Dax, listeners, timers, and observers + + clearTimeout(this.debounceTimer); + this.changedElements.clear(); + this.mutObs.disconnect(); + this.forms.forEach(form => { + form.destroy(); + }); + this.forms.clear(); // Bring the user back to the input they were interacting with + + activeInput === null || activeInput === void 0 ? void 0 : activeInput.focus(); + } /** * @param {HTMLElement|HTMLInputElement|HTMLSelectElement} input * @returns {HTMLFormElement|HTMLElement} @@ -11776,10 +11828,13 @@ class DefaultScanner { getParentForm(input) { if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { - // Use input.form unless it encloses most of the DOM - // In that case we proceed to identify more precise wrappers - if (input.form && !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { - return input.form; + if (input.form) { + // Use input.form unless it encloses most of the DOM + // In that case we proceed to identify more precise wrappers + if (this.forms.has(input.form) || // If we've added the form we've already checked that it's not a page wrapper + !(0, _autofillUtils.isFormLikelyToBeUsedAsPageWrapper)(input.form)) { + return input.form; + } } } @@ -11813,28 +11868,55 @@ class DefaultScanner { addInput(input) { - const parentForm = this.getParentForm(input); // Note that el.contains returns true for el itself + if (this.stopped) return; + const parentForm = this.getParentForm(input); - const previouslyFoundParent = [...this.forms.keys()].find(form => form.contains(parentForm)); + if (parentForm instanceof HTMLFormElement && this.forms.has(parentForm)) { + var _this$forms$get; + + // We've met the form, add the input + (_this$forms$get = this.forms.get(parentForm)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + return; + } // Check if the forms we've seen are either disconnected, + // or are parent/child of the currently-found form + + + let previouslyFoundParent, childForm; + + for (const [formEl] of this.forms) { + // Remove disconnected forms to avoid leaks + if (!formEl.isConnected) { + this.forms.delete(formEl); + continue; + } + + if (formEl.contains(parentForm)) { + previouslyFoundParent = formEl; + break; + } + + if (parentForm.contains(formEl)) { + childForm = formEl; + break; + } + } if (previouslyFoundParent) { if (parentForm instanceof HTMLFormElement && parentForm !== previouslyFoundParent) { // If we had a prior parent but this is an explicit form, the previous was a false positive this.forms.delete(previouslyFoundParent); } else { - var _this$forms$get; + var _this$forms$get2; // If we've already met the form or a descendant, add the input - (_this$forms$get = this.forms.get(previouslyFoundParent)) === null || _this$forms$get === void 0 ? void 0 : _this$forms$get.addInput(input); + (_this$forms$get2 = this.forms.get(previouslyFoundParent)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.addInput(input); } } else { // if this form is an ancestor of an existing form, remove that before adding this - const childForm = [...this.forms.keys()].find(form => parentForm.contains(form)); - if (childForm) { - var _this$forms$get2; + var _this$forms$get3; - (_this$forms$get2 = this.forms.get(childForm)) === null || _this$forms$get2 === void 0 ? void 0 : _this$forms$get2.destroy(); + (_this$forms$get3 = this.forms.get(childForm)) === null || _this$forms$get3 === void 0 ? void 0 : _this$forms$get3.destroy(); this.forms.delete(childForm); } // Only add the form if below the limit of forms per page @@ -11842,9 +11924,7 @@ class DefaultScanner { if (this.forms.size < this.options.maxFormsPerPage) { this.forms.set(parentForm, new _Form.Form(parentForm, input, this.device, this.matching, this.shouldAutoprompt)); } else { - if ((0, _autofillUtils.shouldLog)()) { - console.log('The page has too many forms, stop adding them.'); - } + this.stopScanner('The page has too many forms, stop adding them.'); } } } @@ -11870,9 +11950,14 @@ class DefaultScanner { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { + var _window$performance3, _window$performance3$, _window$performance4, _window$performance4$; + + (_window$performance3 = window.performance) === null || _window$performance3 === void 0 ? void 0 : (_window$performance3$ = _window$performance3.mark) === null || _window$performance3$ === void 0 ? void 0 : _window$performance3$.call(_window$performance3, 'scanner:init:start'); this.processChangedElements(); this.changedElements.clear(); this.rescanAll = false; + (_window$performance4 = window.performance) === null || _window$performance4 === void 0 ? void 0 : (_window$performance4$ = _window$performance4.mark) === null || _window$performance4$ === void 0 ? void 0 : _window$performance4$.call(_window$performance4, 'scanner:init:end'); + (0, _autofillUtils.logPerformance)('scanner'); }, this.options.debounceTimePeriod); } /** @@ -14050,8 +14135,10 @@ exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedCon exports.isLocalNetwork = isLocalNetwork; exports.isPotentiallyViewable = void 0; exports.isValidTLD = isValidTLD; +exports.logPerformance = logPerformance; exports.setValue = exports.sendAndWaitForAnswer = exports.safeExecute = exports.removeInlineStyles = exports.notifyWebApp = void 0; exports.shouldLog = shouldLog; +exports.shouldLogPerformance = shouldLogPerformance; exports.truncateFromMiddle = truncateFromMiddle; exports.wasAutofilledByChrome = void 0; exports.whenIdle = whenIdle; @@ -14469,26 +14556,39 @@ const buttonMatchesFormType = (el, formObj) => { return true; } }; + +exports.buttonMatchesFormType = buttonMatchesFormType; +const buttonInputTypes = ['submit', 'button']; /** * Get the text of an element * @param {Element} el * @returns {string} */ - -exports.buttonMatchesFormType = buttonMatchesFormType; - const getText = el => { // for buttons, we don't care about descendants, just get the whole text as is // this is important in order to give proper attribution of the text to the button if (el instanceof HTMLButtonElement) return (0, _matching.removeExcessWhitespace)(el.textContent); - if (el instanceof HTMLInputElement && ['submit', 'button'].includes(el.type)) return el.value; - if (el instanceof HTMLInputElement && el.type === 'image') { - return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + if (el instanceof HTMLInputElement) { + if (buttonInputTypes.includes(el.type)) { + return el.value; + } + + if (el.type === 'image') { + return (0, _matching.removeExcessWhitespace)(el.alt || el.value || el.title || el.name); + } + } + + let text = ''; + + for (const childNode of el.childNodes) { + if (childNode instanceof Text) { + text += ' ' + childNode.textContent; + } } - return (0, _matching.removeExcessWhitespace)(Array.from(el.childNodes).reduce((text, child) => child instanceof Text ? text + ' ' + child.textContent : text, '')); + return (0, _matching.removeExcessWhitespace)(text); }; /** * Check if hostname is a local address @@ -14532,7 +14632,7 @@ const wasAutofilledByChrome = input => { } }; /** - * Checks if we should log debug info to the console + * Checks if we should log form analysis debug info to the console * @returns {boolean} */ @@ -14540,15 +14640,44 @@ const wasAutofilledByChrome = input => { exports.wasAutofilledByChrome = wasAutofilledByChrome; function shouldLog() { + return readDebugSetting('ddg-autofill-debug'); +} +/** + * Checks if we should log performance info to the console + * @returns {boolean} + */ + + +function shouldLogPerformance() { + return readDebugSetting('ddg-autofill-perf'); +} +/** + * Check if a sessionStorage item is set to 'true' + * @param setting + * @returns {boolean} + */ + + +function readDebugSetting(setting) { // sessionStorage throws in invalid schemes like data: and file: try { var _window$sessionStorag; - return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem('ddg-autofill-debug')) === 'true'; + return ((_window$sessionStorag = window.sessionStorage) === null || _window$sessionStorag === void 0 ? void 0 : _window$sessionStorag.getItem(setting)) === 'true'; } catch (e) { return false; } } + +function logPerformance(markName) { + if (shouldLogPerformance()) { + var _window$performance, _window$performance2; + + const measurement = (_window$performance = window.performance) === null || _window$performance === void 0 ? void 0 : _window$performance.measure("".concat(markName, ":init"), "".concat(markName, ":init:start"), "".concat(markName, ":init:end")); + console.log("".concat(markName, " took ").concat(Math.round(measurement === null || measurement === void 0 ? void 0 : measurement.duration), "ms")); + (_window$performance2 = window.performance) === null || _window$performance2 === void 0 ? void 0 : _window$performance2.clearMarks(); + } +} /** * * @param {Function} callback