Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* nsIAutoCompleteResult implementations for saved logins.
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import {
GenericAutocompleteItem,
sendFillRequestToParent,
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"formFillController",
"@mozilla.org/satchel/form-fill-controller;1",
Ci.nsIFormFillController
);
ChromeUtils.defineLazyGetter(lazy, "log", () => {
return lazy.LoginHelper.createLogger("LoginAutoComplete");
});
ChromeUtils.defineLazyGetter(lazy, "passwordMgrBundle", () => {
return Services.strings.createBundle(
);
});
ChromeUtils.defineLazyGetter(lazy, "dateAndTimeFormatter", () => {
return new Services.intl.DateTimeFormat(undefined, {
dateStyle: "medium",
});
});
function loginSort(formHostPort, a, b) {
let maybeHostPortA = lazy.LoginHelper.maybeGetHostPortForURL(a.origin);
let maybeHostPortB = lazy.LoginHelper.maybeGetHostPortForURL(b.origin);
if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) {
return -1;
}
if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) {
return 1;
}
if (a.httpRealm !== b.httpRealm) {
// Sort HTTP auth. logins after form logins for the same origin.
if (b.httpRealm === null) {
return 1;
}
if (a.httpRealm === null) {
return -1;
}
}
let userA = a.username.toLowerCase();
let userB = b.username.toLowerCase();
if (userA < userB) {
return -1;
}
if (userA > userB) {
return 1;
}
return 0;
}
function findDuplicates(loginList) {
let seen = new Set();
let duplicates = new Set();
for (let login of loginList) {
if (seen.has(login.username)) {
duplicates.add(login.username);
}
seen.add(login.username);
}
return duplicates;
}
function getLocalizedString(key, ...formatArgs) {
if (formatArgs.length) {
return lazy.passwordMgrBundle.formatStringFromName(key, formatArgs);
}
return lazy.passwordMgrBundle.GetStringFromName(key);
}
class AutocompleteItem {
constructor(style) {
this.comment = "";
this.style = style;
this.value = "";
}
removeFromStorage() {
/* Do nothing by default */
}
}
class InsecureLoginFormAutocompleteItem extends AutocompleteItem {
constructor() {
super("insecureWarning");
this.label = getLocalizedString(
"insecureFieldWarningDescription2",
getLocalizedString("insecureFieldWarningLearnMore")
);
}
}
class LoginAutocompleteItem extends AutocompleteItem {
login;
#actor;
constructor(
login,
hasBeenTypePassword,
duplicateUsernames,
actor,
isOriginMatched
) {
super("loginWithOrigin");
this.login = login.QueryInterface(Ci.nsILoginMetaInfo);
this.#actor = actor;
const isDuplicateUsername =
login.username && duplicateUsernames.has(login.username);
let username = login.username
? login.username
: getLocalizedString("noUsername");
// If login is empty or duplicated we want to append a modification date to it.
if (!login.username || isDuplicateUsername) {
const time = lazy.dateAndTimeFormatter.format(
new Date(login.timePasswordChanged)
);
username = getLocalizedString("loginHostAge", username, time);
}
this.label = username;
this.value = hasBeenTypePassword ? login.password : login.username;
this.comment = JSON.stringify({
guid: login.guid,
login, // We have to keep login here to satisfy Android
isDuplicateUsername,
isOriginMatched,
comment:
isOriginMatched && login.httpRealm === null
? getLocalizedString("displaySameOrigin")
: login.displayOrigin,
});
this.image = `page-icon:${login.origin}`;
}
removeFromStorage() {
if (this.#actor) {
let vanilla = lazy.LoginHelper.loginToVanillaObject(this.login);
this.#actor.sendAsyncMessage("PasswordManager:removeLogin", {
login: vanilla,
});
} else {
Services.logins.removeLogin(this.login);
}
}
}
class GeneratedPasswordAutocompleteItem extends AutocompleteItem {
constructor(generatedPassword, willAutoSaveGeneratedPassword) {
super("generatedPassword");
this.label = getLocalizedString("useASecurelyGeneratedPassword");
this.value = generatedPassword;
this.comment = JSON.stringify({
generatedPassword,
willAutoSaveGeneratedPassword,
});
}
}
class ImportableLearnMoreAutocompleteItem extends AutocompleteItem {
constructor() {
super("importableLearnMore");
this.comment = JSON.stringify({
fillMessageName: "PasswordManager:OpenImportableLearnMore",
});
}
}
class ImportableLoginsAutocompleteItem extends AutocompleteItem {
#actor;
constructor(browserId, hostname, actor) {
super("importableLogins");
this.label = browserId;
this.comment = JSON.stringify({
hostname,
fillMessageName: "PasswordManager:HandleImportable",
fillMessageData: {
browserId,
},
});
this.#actor = actor;
// This is sent for every item (re)shown, but the parent will debounce to
// reduce the count by 1 total.
this.#actor.sendAsyncMessage(
"PasswordManager:decreaseSuggestImportCount",
1
);
}
removeFromStorage() {
this.#actor.sendAsyncMessage(
"PasswordManager:decreaseSuggestImportCount",
100
);
}
}
class LoginsFooterAutocompleteItem extends AutocompleteItem {
constructor(formHostname, telemetryEventData) {
super("loginsFooter");
this.label = getLocalizedString("managePasswords.label");
// The comment field of `loginsFooter` results have many additional pieces of
// information for telemetry purposes. After bug 1555209, this information
// can be passed to the parent process outside of nsIAutoCompleteResult APIs
// so we won't need this hack.
this.comment = JSON.stringify({
telemetryEventData,
formHostname,
fillMessageName: "PasswordManager:OpenPreferences",
fillMessageData: {
entryPoint: "autocomplete",
},
});
}
}
// nsIAutoCompleteResult implementation
export class LoginAutoCompleteResult {
#rows = [];
constructor(
aSearchString,
matchingLogins,
autocompleteItems,
formOrigin,
{
generatedPassword,
willAutoSaveGeneratedPassword,
importable,
isSecure,
actor,
hasBeenTypePassword,
hostname,
telemetryEventData,
}
) {
let hidingFooterOnPWFieldAutoOpened = false;
const importableBrowsers =
importable?.state === "import" && importable?.browsers;
function isFooterEnabled() {
// We need to check LoginHelper.enabled here since the insecure warning should
// appear even if pwmgr is disabled but the footer should never appear in that case.
if (
!lazy.LoginHelper.showAutoCompleteFooter ||
!lazy.LoginHelper.enabled
) {
return false;
}
// Don't show the footer on non-empty password fields as it's not providing
// value and only adding noise since a password was already filled.
if (hasBeenTypePassword && aSearchString && !generatedPassword) {
lazy.log.debug("Hiding footer: non-empty password field");
return false;
}
if (
!autocompleteItems?.length &&
!importableBrowsers &&
!matchingLogins.length &&
!generatedPassword &&
hasBeenTypePassword &&
lazy.formFillController.passwordPopupAutomaticallyOpened
) {
hidingFooterOnPWFieldAutoOpened = true;
lazy.log.debug(
"Hiding footer: no logins and the popup was opened upon focus of the pw. field"
);
return false;
}
return true;
}
this.searchString = aSearchString;
// Insecure field warning comes first.
if (!isSecure) {
this.#rows.push(new InsecureLoginFormAutocompleteItem());
}
// Saved login items
let formHostPort = lazy.LoginHelper.maybeGetHostPortForURL(formOrigin);
let logins = matchingLogins.sort(loginSort.bind(null, formHostPort));
let duplicateUsernames = findDuplicates(matchingLogins);
for (let login of logins) {
let item = new LoginAutocompleteItem(
login,
hasBeenTypePassword,
duplicateUsernames,
actor,
lazy.LoginHelper.isOriginMatching(login.origin, formOrigin, {
schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
})
);
this.#rows.push(item);
}
// The footer comes last if it's enabled
if (isFooterEnabled()) {
// TODO: This would be removed once autofill is triggered from the parent.
gAutoCompleteListener.init();
if (autocompleteItems) {
this.#rows.push(
...autocompleteItems.map(
item =>
new GenericAutocompleteItem(
item.image,
item.title,
item.subtitle,
item.fillMessageName,
item.fillMessageData
)
)
);
}
if (generatedPassword) {
this.#rows.push(
new GeneratedPasswordAutocompleteItem(
generatedPassword,
willAutoSaveGeneratedPassword
)
);
}
// Suggest importing logins if there are none found.
if (!logins.length && importableBrowsers) {
this.#rows.push(
...importableBrowsers.map(
browserId =>
new ImportableLoginsAutocompleteItem(browserId, hostname, actor)
)
);
this.#rows.push(new ImportableLearnMoreAutocompleteItem());
}
// If we have anything in autocomplete, then add "Manage Passwords"
this.#rows.push(
new LoginsFooterAutocompleteItem(hostname, telemetryEventData)
);
}
// Determine the result code and default index.
if (this.matchCount > 0) {
this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
this.defaultIndex = 0;
} else if (hidingFooterOnPWFieldAutoOpened) {
// We use a failure result so that the empty results aren't re-used for when
// the user tries to manually open the popup (we want the footer in that case).
this.searchResult = Ci.nsIAutoCompleteResult.RESULT_FAILURE;
this.defaultIndex = -1;
}
}
QueryInterface = ChromeUtils.generateQI([
"nsIAutoCompleteResult",
"nsISupportsWeakReference",
]);
/**
* Accessed via .wrappedJSObject
* @private
*/
get logins() {
return this.#rows
.filter(item => item instanceof LoginAutocompleteItem)
.map(item => item.login);
}
// Allow autoCompleteSearch to get at the JS object so it can
// modify some readonly properties for internal use.
get wrappedJSObject() {
return this;
}
// Interfaces from idl...
searchString = null;
searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
defaultIndex = -1;
errorDescription = "";
get matchCount() {
return this.#rows.length;
}
#throwOnBadIndex(index) {
if (index < 0 || index >= this.matchCount) {
throw new Error("Index out of range.");
}
}
getValueAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].value;
}
getLabelAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].label;
}
getCommentAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].comment;
}
getStyleAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].style;
}
getImageAt(index) {
this.#throwOnBadIndex(index);
return this.#rows[index].image ?? "";
}
getFinalCompleteValueAt(index) {
return this.getValueAt(index);
}
isRemovableAt(index) {
this.#throwOnBadIndex(index);
return true;
}
removeValueAt(index) {
this.#throwOnBadIndex(index);
let [removedItem] = this.#rows.splice(index, 1);
if (this.defaultIndex > this.#rows.length) {
this.defaultIndex--;
}
removedItem.removeFromStorage();
}
}
let gAutoCompleteListener = {
added: false,
init() {
if (!this.added) {
Services.obs.addObserver(this, "autocomplete-will-enter-text");
this.added = true;
}
},
async observe(subject, topic, data) {
switch (topic) {
case "autocomplete-will-enter-text": {
if (subject && subject == lazy.formFillController.controller.input) {
await sendFillRequestToParent("LoginManager", subject, data);
}
break;
}
}
},
};