// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as fillConstants from '//components/autofill/ios/form_util/resources/fill_constants.js';
import {findChildText} from '//components/autofill/ios/form_util/resources/fill_element_inference_util.js';
import {gCrWeb} from '//ios/web/public/js_messaging/resources/gcrweb.js';
import {trim} from '//ios/web/public/js_messaging/resources/utils.js';
declare global {
// Defines an additional property, `angular`, on the Window object.
// The code below assumes that this property exists within the object.
interface Window {
angular: any;
}
// Extends the Document object to add the ability to access its
// properties via the [] notation and defines a property that is
// assumed to exist within the object.
interface Document {
[key: symbol]: number;
__gCrWebURLNormalizer: HTMLAnchorElement;
}
}
// Extends the Element to add the ability to access its properties
// via the [] notation.
declare interface IndexableElement extends Element {
[key: symbol]: number;
}
declare interface AutofillFormFieldData {
name: string;
value: string;
renderer_id: string;
form_control_type: string;
autocomplete_attribute: string;
max_length: number;
is_autofilled: boolean;
is_user_edited: boolean;
is_checkable: boolean;
is_focusable: boolean;
should_autocomplete: boolean;
role: number;
placeholder_attribute: string;
aria_label: string;
aria_description: string;
option_texts: string[];
option_values: string[];
label?: string;
identifier?: string;
name_attribute?: string;
id_attribute?: string;
}
declare interface AutofillFormData {
name: string;
renderer_id: string;
origin: string;
action: string;
fields: AutofillFormFieldData[];
frame_id: string;
child_frames?: FrameTokenWithPredecessor[];
name_attribute?: string;
id_attribute?: string;
}
declare interface FrameTokenWithPredecessor {
token: string;
predecessor: number;
}
/**
* Maps elements using their unique ID
*/
const elementMap = new Map();
/**
* Stores the next available ID for forms and fields. By convention, 0 means
* null, so we start at 1 and increment from there.
*/
document[gCrWeb.fill.ID_SYMBOL] = 1;
/**
* Acquires the specified DOM `attribute` from the DOM `element` and returns
* its lower-case value, or null if not present.
*
* @param element A DOM element.
* @param attribute An attribute name.
* @return Lowercase value of DOM element or null if not present.
*/
function getLowerCaseAttribute(
element: Element | null, attribute: string): string | null {
if (!element) {
return null;
}
const value = element.getAttribute(attribute);
if (value) {
return value.toLowerCase();
}
return null;
}
/**
* Returns true if an element can be autocompleted.
*
* This method aims to provide the same logic as method
* bool autoComplete() const
* in chromium/src/third_party/WebKit/Source/WebKit/chromium/src/
* WebFormElement.cpp.
*
* @param element An element to check if it can be autocompleted.
* @return true if element can be autocompleted.
*/
function autoComplete(element: fillConstants.FormControlElement|null): boolean {
if (!element) {
return false;
}
if (getLowerCaseAttribute(element, 'autocomplete') === 'off') {
return false;
}
if (getLowerCaseAttribute(element.form, 'autocomplete') == 'off') {
return false;
}
return true;
}
/**
* Returns true if an element should suggest autocomplete dropdown.
*
* @param element An element to check if it can be autocompleted.
* @return true if autocomplete dropdown should be suggested.
*/
gCrWeb.fill.shouldAutocomplete = function(
element: fillConstants.FormControlElement|null): boolean {
if (!autoComplete(element)) {
return false;
}
if (getLowerCaseAttribute(element!, 'autocomplete') === 'one-time-code') {
return false;
}
if (getLowerCaseAttribute(
element!.form, 'autocomplete') === 'one-time-code') {
return false;
}
return true;
};
/**
* Sets the value of a data-bound input using AngularJS.
*
* The method first set the value using the val() method. Then, if input is
* bound to a model value, it sets the model value.
* Documentation of relevant modules of AngularJS can be found at
* https://docs.angularjs.org/guide/databinding
* https://docs.angularjs.org/api/auto/service/$injector
* https://docs.angularjs.org/api/ng/service/$parse
*
* @param value The value the input element will be set.
* @param input The input element of which the value is set.
*/
function setInputElementAngularValue(
value: string, input: Element | null): void {
if (!input || !window['angular']) {
return;
}
const angularElement =
window['angular'].element && window['angular'].element(input);
if (!angularElement) {
return;
}
angularElement.val(value);
const angularModel = angularElement.data && angularElement.data('ngModel');
const angularScope = angularElement.scope();
if (!angularModel || !angularScope) {
return;
}
angularElement.injector().invoke([
'$parse',
function(parse: Function) {
const setter = parse(angularModel);
setter.assign(angularScope, value);
},
]);
}
/**
* Sets the value of an input, dispatches the events on the changed element and
* call `callback` if it is defined.
*
* It is based on the logic in
*
* void setValue(const WebString&, bool sendChangeEvent = false)
*
* in chromium/src/third_party/WebKit/Source/WebKit/chromium/src/
* WebInputElement.cpp, which calls
* void setValue(const String& value, TextFieldEventBehavior eventBehavior)
* or
* void setChecked(bool nowChecked, TextFieldEventBehavior eventBehavior)
* in chromium/src/third_party/WebKit/Source/core/html/HTMLInputElement.cpp.
*
* @param value The value the input element will be set.
* @param input The input element of which the value is set.
* @param callback Callback function called after the input
* element's value is changed.
* @return Whether the value has been set successfully.
*/
gCrWeb.fill.setInputElementValue = function(
value: string, input: HTMLInputElement|null,
callback: Function|undefined = undefined): boolean {
if (!input) {
return false;
}
const activeElement = document.activeElement;
if (input !== activeElement) {
createAndDispatchHTMLEvent(activeElement, 'blur', true, false);
createAndDispatchHTMLEvent(input, 'focus', true, false);
}
const filled = setInputElementValue(value, input);
if (callback) {
callback();
}
if (input !== activeElement) {
createAndDispatchHTMLEvent(input, 'blur', true, false);
createAndDispatchHTMLEvent(activeElement, 'focus', true, false);
}
return filled;
};
declare interface PropertyDescriptor {
get(): string;
set?(): void;
configurable: boolean;
}
/**
* Internal function to set the element value.
*
* @param value The value the input element will be set.
* @param input The input element of which the value is set.
* @return Whether the value has been set successfully.
*/
function setInputElementValue(value: string, input: HTMLInputElement): boolean {
const propertyName = (input.type === 'checkbox' || input.type === 'radio') ?
'checked' :
'value';
if (input.type !== 'select-one' && input.type !== 'checkbox' &&
input.type !== 'radio') {
// In HTMLInputElement.cpp there is a check on canSetValue(value), which
// returns false only for file input. As file input is not relevant for
// autofill and this method is only used for autofill for now, there is no
// such check in this implementation.
value = sanitizeValueForInputElement(value, input);
}
// Return early if the value hasn't changed.
if (input[propertyName] === value) {
return false;
}
// When the user inputs a value in an HTMLInput field, the property setter is
// not called. The different frameworks often call it explicitly when
// receiving the input event.
// This is probably due to the sync between the HTML object and the DOM
// object.
// The sequence of event is: User input -> input event -> setter.
// When the property is set programmatically (input.value = 'foo'), the setter
// is called immediately (then probably called again on the input event)
// JS input -> setter.
// The only way to emulate the user behavior is to override the property
// The getter will return the new value to emulate the fact the the HTML
// value was updated without calling the setter.
// The setter simply forwards the set to the older property descriptor.
// Once the setter has been called, just forward get and set calls.
const oldPropertyDescriptor =
Object.getOwnPropertyDescriptor(input, propertyName);
const overrideProperty =
oldPropertyDescriptor && oldPropertyDescriptor.configurable;
let setterCalled = false;
if (overrideProperty) {
const newProperty: PropertyDescriptor = {
get() {
if (setterCalled && oldPropertyDescriptor.get) {
return oldPropertyDescriptor.get.call(input);
}
// Simulate the fact that the HTML value has been set but not yet the
// property.
return value + '';
},
configurable: true,
};
if (oldPropertyDescriptor.set) {
newProperty.set = function() {
setterCalled = true;
oldPropertyDescriptor.set!.call(input, value);
};
}
Object.defineProperty(input, propertyName, newProperty);
} else {
setterCalled = true;
(input[propertyName] as boolean|string) = value;
}
if (window['angular']) {
// The page uses the AngularJS framework. Update the angular value before
// sending events.
setInputElementAngularValue(value, input);
}
notifyElementValueChanged(input);
if (overrideProperty) {
Object.defineProperty(input, propertyName, oldPropertyDescriptor);
if (!setterCalled) {
// The setter was never called. This may be intentional (the framework
// ignored the input event) or not (the event did not conform to what
// framework expected). The whole function will likely fail, but try to
// set the value directly as a last try.
(input[propertyName] as boolean|string) = value;
}
}
return true;
}
/**
* Returns a sanitized value of proposedValue for a given input element type.
* The logic is based on
*
* String sanitizeValue(const String&) const
*
* in chromium/src/third_party/WebKit/Source/core/html/InputType.h
*
* @param proposedValue The proposed value.
* @param element The element for which the proposedValue is to be
* sanitized.
* @return The sanitized value.
*/
function sanitizeValueForInputElement(
proposedValue: string|null, element: Element): string {
if (!proposedValue) {
return '';
}
// Method HTMLInputElement::sanitizeValue() calls InputType::sanitizeValue()
// (chromium/src/third_party/WebKit/Source/core/html/InputType.cpp) for
// non-null proposedValue. InputType::sanitizeValue() returns the original
// proposedValue by default and it is overridden in classes
// BaseDateAndTimeInputType, ColorInputType, RangeInputType and
// TextFieldInputType (all are in
// chromium/src/third_party/WebKit/Source/core/html/). Currently only
// TextFieldInputType is relevant and sanitizeValue() for other types of
// input elements has not been implemented.
if (gCrWeb.common.isTextField(element)) {
return sanitizeValueForTextFieldInputType(
proposedValue, element as HTMLInputElement);
}
return proposedValue;
}
/**
* Returns a sanitized value for a text field.
*
* The logic is based on `String sanitizeValue(const String&)`
* in chromium/src/third_party/WebKit/Source/core/html/TextFieldInputType.h
* Note this method is overridden in EmailInputType and NumberInputType.
*
* @param proposedValue The proposed value.
* @param element The element for which the proposedValue is to be
* sanitized.
* @return The sanitized value.
*/
function sanitizeValueForTextFieldInputType(
proposedValue: string, element: HTMLInputElement): string {
const textFieldElementType = element.type;
if (textFieldElementType === 'email') {
return sanitizeValueForEmailInputType(proposedValue, element);
} else if (textFieldElementType === 'number') {
return sanitizeValueForNumberInputType(proposedValue);
}
const valueWithLineBreakRemoved = proposedValue.replace(/(\r\n|\n|\r)/gm, '');
// TODO(chenyu): Should we also implement numCharactersInGraphemeClusters()
// in chromium/src/third_party/WebKit/Source/core/platform/text/
// TextBreakIterator.cpp and call it here when computing newLength?
// Different from the implementation in TextFieldInputType.h, where a limit
// on the text length is considered due to
// https://bugs.webkit.org/show_bug.cgi?id=14536, no such limit is
// considered here for now.
let newLength = valueWithLineBreakRemoved.length;
// This logic is from method String limitLength() in TextFieldInputType.h
for (let i = 0; i < newLength; ++i) {
const current = valueWithLineBreakRemoved[i]!;
if (current < ' ' && current !== '\t') {
newLength = i;
break;
}
}
return valueWithLineBreakRemoved.substring(0, newLength);
}
/**
* Returns the sanitized value for an email input.
*
* The logic is based on
*
* String EmailInputType::sanitizeValue(const String& proposedValue) const
*
* in chromium/src/third_party/WebKit/Source/core/html/EmailInputType.cpp
*
* @param proposedValue The proposed value.
* @param element The element for which the proposedValue is to be
* sanitized.
* @return The sanitized value.
*/
function sanitizeValueForEmailInputType(
proposedValue: string, element: HTMLInputElement): string {
const valueWithLineBreakRemoved = proposedValue.replace(/(\r\n|\n\r)/gm, '');
if (!element.multiple) {
return trim(proposedValue);
}
const addresses = valueWithLineBreakRemoved.split(',');
for (let i = 0; i < addresses.length; ++i) {
addresses[i] = trim(addresses[i]!);
}
return addresses.join(',');
}
/**
* Returns the sanitized value of a proposed value for a number input.
*
* The logic is based on
*
* String NumberInputType::sanitizeValue(const String& proposedValue)
* const
*
* in chromium/src/third_party/WebKit/Source/core/html/NumberInputType.cpp
*
* Note in this implementation method Number() is used in the place of method
* parseToDoubleForNumberType() called in NumberInputType.cpp.
*
* @param proposedValue The proposed value.
* @return The sanitized value.
*/
function sanitizeValueForNumberInputType(proposedValue: string): string {
const sanitizedValue = Number(proposedValue);
if (isNaN(sanitizedValue)) {
return '';
}
return sanitizedValue.toString();
}
/**
* Creates and sends notification that element has changed.
*
* Send events that 'mimic' the user typing in a field.
* 'input' event is often use in case of a text field, and 'change'event is
* more often used in case of selects.
*
* @param {Element} element The element that changed.
*/
function notifyElementValueChanged(element: Element): void {
createAndDispatchHTMLEvent(element, 'keydown', true, false);
createAndDispatchHTMLEvent(element, 'keypress', true, false);
createAndDispatchHTMLEvent(element, 'input', true, false);
createAndDispatchHTMLEvent(element, 'keyup', true, false);
createAndDispatchHTMLEvent(element, 'change', true, false);
}
/**
* Creates and dispatches an HTML event.
*
* @param {Element} element The element for which an event is created.
* @param {string} type The type of the event.
* @param {boolean} bubbles A boolean indicating whether the event should
* bubble up through the event chain or not.
* @param {boolean} cancelable A boolean indicating whether the event can be
* canceled.
*/
function createAndDispatchHTMLEvent(
element: Element | null, type: string, bubbles: boolean,
cancelable: boolean): void {
const event =
new Event(type, {bubbles: bubbles, cancelable: cancelable});
element?.dispatchEvent(event);
}
/**
* Converts a relative URL into an absolute URL.
*/
function absoluteURL(doc: Document, relativeURL: string): string {
// In the case of data: URL-based pages, relativeURL === absoluteURL.
if (doc.location.protocol === 'data:') {
return doc.location.href;
}
let urlNormalizer = doc['__gCrWebURLNormalizer'];
if (!urlNormalizer) {
urlNormalizer = doc.createElement('a');
doc['__gCrWebURLNormalizer'] = urlNormalizer;
}
// Use the magical quality of the <a> element. It automatically converts
// relative URLs into absolute ones.
urlNormalizer.href = relativeURL;
return urlNormalizer.href;
}
/**
* Returns a canonical action for `formElement`. It works the same as upstream
* function GetCanonicalActionForForm.
* @return Canonical action.
*/
gCrWeb.fill.getCanonicalActionForForm = function(
formElement: HTMLFormElement): string {
const rawAction = formElement.getAttribute('action') || '';
const absoluteUrl = absoluteURL(formElement.ownerDocument, rawAction);
return gCrWeb.common.removeQueryAndReferenceFromURL(absoluteUrl);
};
declare interface OptionFieldStrings {
option_values: string[] & {toJSON?: string|null};
option_texts: string[]&{toJSON?: string | null};
}
/**
* Fills `field` data with the values of the <option> elements present in
* `selectElement`.
*
* It is based on the logic in
* void GetOptionStringsFromElement(const WebSelectElement& select_element,
* std::vector<string16>* option_values,
* std::vector<string16>* option_texts)
* in chromium/src/components/autofill/content/renderer/form_autofill_util.cc.
*
* @param selectElement A select element from which option data are
* extracted.
* @param field A field that will contain the extracted option
* information.
*/
gCrWeb.fill.getOptionStringsFromElement = function(
selectElement: HTMLSelectElement, field: OptionFieldStrings): void {
field.option_values = [];
// Protect against custom implementation of Array.toJSON in host pages.
field.option_values.toJSON = null;
field.option_texts = [];
field.option_texts.toJSON = null;
const options = selectElement.options;
for (let i = 0; i < options.length; ++i) {
const option = options[i]!;
field.option_values.push(
option.value.substring(0, fillConstants.MAX_STRING_LENGTH));
field.option_texts.push(
option.text.substring(0, fillConstants.MAX_STRING_LENGTH));
}
};
/**
* Returns the value in a way similar to the C++ version of node.value,
* used in src/components/autofill/content/renderer/form_autofill_util.h.
* Newlines and tabs are stripped.
*
* Note: this method tries to match the behavior of Blink for the select
* element. On Blink, a select element with a first option that is disabled and
* not explicitly selected will automatically select the second element.
* On WebKit, the disabled element is enabled until user interacts with it.
* As the result of this method will be used by code written for Blink, match
* the behavior on it.
*
* @param element An element to examine.
* @return The value for `element`.
*/
gCrWeb.fill.value = function(
element: fillConstants.FormControlElement|HTMLOptionElement): string {
let value = element.value;
if (gCrWeb.fill.isSelectElement(element)) {
const selectElement = element as HTMLSelectElement;
if (selectElement.options.length > 0 && selectElement.selectedIndex === 0 &&
selectElement.options[0]!.disabled &&
!selectElement.options[0]!.hasAttribute('selected')) {
for (const option of selectElement.options) {
if (!option.disabled || option.hasAttribute('selected')) {
value = option.value;
break;
}
}
}
}
return (value || '').replace(/[\n\t]/gm, '');
};
/**
* Returns the coalesced child text of the elements who's ids are found in
* the `attribute` of `element`.
*
* For example, given this document...
*
* <div id="billing">Billing</div>
* <div>
* <div id="name">Name</div>
* <input id="field1" type="text" aria-labelledby="billing name"/>
* </div>
* <div>
* <div id="address">Address</div>
* <input id="field2" type="text" aria-labelledby="billing address"/>
* </div>
*
* The coalesced text by the id_list found in the aria-labelledby attribute
* of the field1 input element would be "Billing Name" and for field2 it would
* be "Billing Address".
*/
function coalesceTextByIdList(
element: Element|null, attribute: string): string {
if (!element) {
return '';
}
const ids = element.getAttribute(attribute);
if (!ids) {
return '';
}
return ids.trim()
.split(/\s+/)
.map(function(i) {
return document.getElementById(i);
})
.filter(function(e) {
return e !== null;
})
.map(function(n) {
return findChildText(n!);
})
.filter(function(s) {
return s.length > 0;
})
.join(' ')
.trim();
}
/**
* Returns the coalesced text referenced by the aria-labelledby attribute
* or the value of the aria-label attribute, with priority given to the
* aria-labelledby text.
*/
gCrWeb.fill.getAriaLabel = function(element: Element): string {
let label = coalesceTextByIdList(element, 'aria-labelledby');
if (!label) {
label = element.getAttribute('aria-label') || '';
}
return label.trim();
};
/**
* Returns the coalesced text referenced by the aria-describedby attribute.
*/
gCrWeb.fill.getAriaDescription = function(element: Element): string {
return coalesceTextByIdList(element, 'aria-describedby');
};
/**
* Searches an element's ancestors to see if the element is inside a <form> or
* <fieldset>.
*
* It is based on the logic in
* bool (const WebElement& element)
* in chromium/src/components/autofill/content/renderer/form_cache.cc
*
* @param element An element to examine.
* @return Whether the element is inside a <form> or <fieldset>.
*/
gCrWeb.fill.isElementInsideFormOrFieldSet = function(
element: fillConstants.FormControlElement): boolean {
let parentNode = element.parentNode;
while (parentNode) {
if ((parentNode.nodeType === Node.ELEMENT_NODE) &&
(gCrWeb.fill.hasTagName(parentNode, 'form') ||
gCrWeb.fill.hasTagName(parentNode, 'fieldset'))) {
return true;
}
parentNode = parentNode.parentNode;
}
return false;
};
/**
* @param element Form or form input element.
*/
function setUniqueIDIfNeeded(element: IndexableElement): void {
try {
const uniqueID = gCrWeb.fill.ID_SYMBOL;
if (typeof element[uniqueID] === 'undefined') {
element[uniqueID] = document[uniqueID]!++;
// TODO(crbug.com/40856841): WeakRef starts in 14.5, remove checks once 14
// is deprecated.
elementMap.set(
element[uniqueID], window.WeakRef ? new WeakRef(element) : element);
}
} catch (e) {
}
}
/**
* @param element Form or form input element.
* @return Unique stable ID converted to string..
*/
gCrWeb.fill.getUniqueID = function(element: any): string {
setUniqueIDIfNeeded(element);
try {
const uniqueID = gCrWeb.fill.ID_SYMBOL;
if (typeof element[uniqueID]! !== 'undefined' &&
!isNaN(element[uniqueID]!)) {
return element[uniqueID].toString();
} else {
return fillConstants.RENDERER_ID_NOT_SET;
}
} catch (e) {
return fillConstants.RENDERER_ID_NOT_SET;
}
};
/**
* @param id Unique ID.
* @return element Form or form input element.
*/
gCrWeb.fill.getElementByUniqueID = function(id: number): Element | null {
try {
// TODO(crbug.com/40856841): WeakRef starts in 14.5, remove checks once 14 is
// deprecated.
return window.WeakRef ? elementMap.get(id).deref() : elementMap.get(id);
} catch (e) {
return null;
}
};
export {AutofillFormFieldData, AutofillFormData, FrameTokenWithPredecessor};