summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/megalist/aggregator
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:50 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:50 +0000
commitdef92d1b8e9d373e2f6f27c366d578d97d8960c6 (patch)
tree2ef34b9ad8bb9a9220e05d60352558b15f513894 /toolkit/components/satchel/megalist/aggregator
parentAdding debian version 125.0.3-1. (diff)
downloadfirefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.tar.xz
firefox-def92d1b8e9d373e2f6f27c366d578d97d8960c6.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel/megalist/aggregator')
-rw-r--r--toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs78
-rw-r--r--toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs17
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs258
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs339
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs291
-rw-r--r--toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs472
-rw-r--r--toolkit/components/satchel/megalist/aggregator/moz.build17
7 files changed, 1472 insertions, 0 deletions
diff --git a/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs
new file mode 100644
index 0000000000..e101fadd16
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/Aggregator.sys.mjs
@@ -0,0 +1,78 @@
+/* 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/. */
+
+/**
+ * Connects multiple Data Sources with multiple View Models.
+ * Aggregator owns Data Sources.
+ * Aggregator weakly refers to View Models.
+ */
+export class Aggregator {
+ #sources = [];
+ #attachedViewModels = [];
+
+ attachViewModel(viewModel) {
+ // Weak reference the View Model so we do not keep it in memory forever
+ this.#attachedViewModels.push(new WeakRef(viewModel));
+ }
+
+ detachViewModel(viewModel) {
+ for (let i = this.#attachedViewModels.length - 1; i >= 0; i--) {
+ const knownViewModel = this.#attachedViewModels[i].deref();
+ if (viewModel == knownViewModel || !knownViewModel) {
+ this.#attachedViewModels.splice(i, 1);
+ }
+ }
+ }
+
+ /**
+ * Run action on each of the alive attached view models.
+ * Remove dead consumers.
+ *
+ * @param {Function} action to perform on each alive consumer
+ */
+ forEachViewModel(action) {
+ for (let i = this.#attachedViewModels.length - 1; i >= 0; i--) {
+ const viewModel = this.#attachedViewModels[i].deref();
+ if (viewModel) {
+ action(viewModel);
+ } else {
+ this.#attachedViewModels.splice(i, 1);
+ }
+ }
+ }
+
+ *enumerateLines(searchText) {
+ for (let source of this.#sources) {
+ yield* source.enumerateLines(searchText);
+ }
+ }
+
+ /**
+ *
+ * @param {Function} createSourceFn (aggregatorApi) used to create Data Source.
+ * aggregatorApi is the way for Data Source to push data
+ * to the Aggregator.
+ */
+ addSource(createSourceFn) {
+ const api = this.#apiForDataSource();
+ const source = createSourceFn(api);
+ this.#sources.push(source);
+ }
+
+ /**
+ * Exposes interface for a datasource to communicate with Aggregator.
+ */
+ #apiForDataSource() {
+ const aggregator = this;
+ return {
+ refreshSingleLineOnScreen(line) {
+ aggregator.forEachViewModel(vm => vm.refreshSingleLineOnScreen(line));
+ },
+
+ refreshAllLinesOnScreen() {
+ aggregator.forEachViewModel(vm => vm.refreshAllLinesOnScreen());
+ },
+ };
+ }
+}
diff --git a/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs b/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs
new file mode 100644
index 0000000000..cf3a78a6a4
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/DefaultAggregator.sys.mjs
@@ -0,0 +1,17 @@
+/* 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/. */
+
+import { Aggregator } from "resource://gre/modules/megalist/aggregator/Aggregator.sys.mjs";
+import { AddressesDataSource } from "resource://gre/modules/megalist/aggregator/datasources/AddressesDataSource.sys.mjs";
+import { BankCardDataSource } from "resource://gre/modules/megalist/aggregator/datasources/BankCardDataSource.sys.mjs";
+import { LoginDataSource } from "resource://gre/modules/megalist/aggregator/datasources/LoginDataSource.sys.mjs";
+
+export class DefaultAggregator extends Aggregator {
+ constructor() {
+ super();
+ this.addSource(aggregatorApi => new AddressesDataSource(aggregatorApi));
+ this.addSource(aggregatorApi => new BankCardDataSource(aggregatorApi));
+ this.addSource(aggregatorApi => new LoginDataSource(aggregatorApi));
+ }
+}
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs
new file mode 100644
index 0000000000..f00df0b40b
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/AddressesDataSource.sys.mjs
@@ -0,0 +1,258 @@
+/* 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/. */
+
+import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs";
+import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs";
+
+async function updateAddress(address, field, value) {
+ try {
+ const newAddress = {
+ ...address,
+ [field]: value ?? "",
+ };
+
+ formAutofillStorage.INTERNAL_FIELDS.forEach(
+ name => delete newAddress[name]
+ );
+ formAutofillStorage.addresses.VALID_COMPUTED_FIELDS.forEach(
+ name => delete newAddress[name]
+ );
+
+ if (address.guid) {
+ await formAutofillStorage.addresses.update(address.guid, newAddress);
+ } else {
+ await formAutofillStorage.addresses.add(newAddress);
+ }
+ } catch (error) {
+ //todo
+ console.error("failed to modify address", error);
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Data source for Addresses.
+ *
+ */
+
+export class AddressesDataSource extends DataSourceBase {
+ #namePrototype;
+ #organizationPrototype;
+ #streetAddressPrototype;
+ #addressLevelOnePrototype;
+ #addressLevelTwoPrototype;
+ #addressLevelThreePrototype;
+ #postalCodePrototype;
+ #countryPrototype;
+ #phonePrototype;
+ #emailPrototype;
+
+ #addressesDisabledMessage;
+ #enabled;
+ #header;
+
+ constructor(...args) {
+ super(...args);
+ this.formatMessages(
+ "addresses-section-label",
+ "address-name-label",
+ "address-phone-label",
+ "address-email-label",
+ "command-copy",
+ "addresses-disabled",
+ "command-delete",
+ "command-edit",
+ "addresses-command-create"
+ ).then(
+ ([
+ headerLabel,
+ nameLabel,
+ phoneLabel,
+ emailLabel,
+ copyLabel,
+ addressesDisabled,
+ deleteLabel,
+ editLabel,
+ createLabel,
+ ]) => {
+ const copyCommand = { id: "Copy", label: copyLabel };
+ const editCommand = { id: "Edit", label: editLabel };
+ const deleteCommand = { id: "Delete", label: deleteLabel };
+ this.#addressesDisabledMessage = addressesDisabled;
+ this.#header = this.createHeaderLine(headerLabel);
+ this.#header.commands.push({ id: "Create", label: createLabel });
+
+ let self = this;
+
+ function prototypeLine(label, key, options = {}) {
+ return self.prototypeDataLine({
+ label: { value: label },
+ value: {
+ get() {
+ return this.editingValue ?? this.record[key];
+ },
+ },
+ commands: {
+ value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record[key] ?? "";
+ this.refreshOnScreen();
+ },
+ },
+ executeSave: {
+ async value(value) {
+ if (await updateAddress(this.record, key, value)) {
+ this.executeCancel();
+ }
+ },
+ },
+ ...options,
+ });
+ }
+
+ this.#namePrototype = prototypeLine(nameLabel, "name", {
+ start: { value: true },
+ });
+ this.#organizationPrototype = prototypeLine(
+ "Organization",
+ "organization"
+ );
+ this.#streetAddressPrototype = prototypeLine(
+ "Street Address",
+ "street-address"
+ );
+ this.#addressLevelThreePrototype = prototypeLine(
+ "Neighbourhood",
+ "address-level3"
+ );
+ this.#addressLevelTwoPrototype = prototypeLine(
+ "City",
+ "address-level2"
+ );
+ this.#addressLevelOnePrototype = prototypeLine(
+ "Province",
+ "address-level1"
+ );
+ this.#postalCodePrototype = prototypeLine("Postal Code", "postal-code");
+ this.#countryPrototype = prototypeLine("Country", "country");
+ this.#phonePrototype = prototypeLine(phoneLabel, "tel");
+ this.#emailPrototype = prototypeLine(emailLabel, "email", {
+ end: { value: true },
+ });
+
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ Services.prefs.addObserver(
+ "extensions.formautofill.addresses.enabled",
+ this
+ );
+ this.#reloadDataSource();
+ }
+ );
+ }
+
+ async #reloadDataSource() {
+ this.#enabled = Services.prefs.getBoolPref(
+ "extensions.formautofill.addresses.enabled"
+ );
+ if (!this.#enabled) {
+ this.#reloadEmptyDataSource();
+ return;
+ }
+
+ await formAutofillStorage.initialize();
+ const addresses = await formAutofillStorage.addresses.getAll();
+ this.beforeReloadingDataSource();
+ addresses.forEach(address => {
+ const lineId = `${address.name}:${address.tel}`;
+
+ this.addOrUpdateLine(address, lineId + "0", this.#namePrototype);
+ this.addOrUpdateLine(address, lineId + "1", this.#organizationPrototype);
+ this.addOrUpdateLine(address, lineId + "2", this.#streetAddressPrototype);
+ this.addOrUpdateLine(
+ address,
+ lineId + "3",
+ this.#addressLevelThreePrototype
+ );
+ this.addOrUpdateLine(
+ address,
+ lineId + "4",
+ this.#addressLevelTwoPrototype
+ );
+ this.addOrUpdateLine(
+ address,
+ lineId + "5",
+ this.#addressLevelOnePrototype
+ );
+ this.addOrUpdateLine(address, lineId + "6", this.#postalCodePrototype);
+ this.addOrUpdateLine(address, lineId + "7", this.#countryPrototype);
+ this.addOrUpdateLine(address, lineId + "8", this.#phonePrototype);
+ this.addOrUpdateLine(address, lineId + "9", this.#emailPrototype);
+ });
+ this.afterReloadingDataSource();
+ }
+
+ /**
+ * Enumerate all the lines provided by this data source.
+ *
+ * @param {string} searchText used to filter data
+ */
+ *enumerateLines(searchText) {
+ if (this.#enabled === undefined) {
+ // Async Fluent API makes it possible to have data source waiting
+ // for the localized strings, which can be detected by undefined in #enabled.
+ return;
+ }
+
+ yield this.#header;
+ if (this.#header.collapsed || !this.#enabled) {
+ return;
+ }
+
+ const stats = { total: 0, count: 0 };
+ searchText = searchText.toUpperCase();
+ yield* this.enumerateLinesForMatchingRecords(searchText, stats, address =>
+ [
+ "name",
+ "organization",
+ "street-address",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "tel",
+ "email",
+ ].some(key => address[key]?.toUpperCase().includes(searchText))
+ );
+
+ this.formatMessages({
+ id:
+ stats.count == stats.total
+ ? "addresses-count"
+ : "addresses-filtered-count",
+ args: stats,
+ }).then(([headerLabel]) => {
+ this.#header.value = headerLabel;
+ });
+ }
+
+ #reloadEmptyDataSource() {
+ this.lines.length = 0;
+ this.#header.value = this.#addressesDisabledMessage;
+ this.refreshAllLinesOnScreen();
+ }
+
+ observe(_subj, topic, message) {
+ if (
+ topic == "formautofill-storage-changed" ||
+ message == "extensions.formautofill.addresses.enabled"
+ ) {
+ this.#reloadDataSource();
+ }
+ }
+}
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs
new file mode 100644
index 0000000000..06266a7979
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/BankCardDataSource.sys.mjs
@@ -0,0 +1,339 @@
+/* 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/. */
+
+import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs";
+import { CreditCardRecord } from "resource://gre/modules/shared/CreditCardRecord.sys.mjs";
+import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs";
+import { OSKeyStore } from "resource://gre/modules/OSKeyStore.sys.mjs";
+
+async function decryptCard(card) {
+ if (card["cc-number-encrypted"] && !card["cc-number-decrypted"]) {
+ try {
+ card["cc-number-decrypted"] = await OSKeyStore.decrypt(
+ card["cc-number-encrypted"],
+ false
+ );
+ card["cc-number"] = card["cc-number-decrypted"];
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
+
+async function updateCard(card, field, value) {
+ try {
+ await decryptCard(card);
+ const newCard = {
+ ...card,
+ [field]: value ?? "",
+ };
+ formAutofillStorage.INTERNAL_FIELDS.forEach(name => delete newCard[name]);
+ formAutofillStorage.creditCards.VALID_COMPUTED_FIELDS.forEach(
+ name => delete newCard[name]
+ );
+ delete newCard["cc-number-decrypted"];
+ CreditCardRecord.normalizeFields(newCard);
+
+ if (card.guid) {
+ await formAutofillStorage.creditCards.update(card.guid, newCard);
+ } else {
+ await formAutofillStorage.creditCards.add(newCard);
+ }
+ } catch (error) {
+ //todo
+ console.error("failed to modify credit card", error);
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Data source for Bank Cards.
+ *
+ * Each card is represented by 3 lines: card number, expiration date and holder name.
+ *
+ * Protypes are used to reduce memory need because for different records
+ * similar lines will differ in values only.
+ */
+export class BankCardDataSource extends DataSourceBase {
+ #cardNumberPrototype;
+ #expirationPrototype;
+ #holderNamePrototype;
+ #cardsDisabledMessage;
+ #enabled;
+ #header;
+
+ constructor(...args) {
+ super(...args);
+ // Wait for Fluent to provide strings before loading data
+ this.formatMessages(
+ "payments-section-label",
+ "card-number-label",
+ "card-expiration-label",
+ "card-holder-label",
+ "command-copy",
+ "command-reveal",
+ "command-conceal",
+ "payments-disabled",
+ "command-delete",
+ "command-edit",
+ "payments-command-create"
+ ).then(
+ ([
+ headerLabel,
+ numberLabel,
+ expirationLabel,
+ holderLabel,
+ copyCommandLabel,
+ revealCommandLabel,
+ concealCommandLabel,
+ cardsDisabled,
+ deleteCommandLabel,
+ editCommandLabel,
+ cardsCreateCommandLabel,
+ ]) => {
+ const copyCommand = { id: "Copy", label: copyCommandLabel };
+ const editCommand = {
+ id: "Edit",
+ label: editCommandLabel,
+ verify: true,
+ };
+ const deleteCommand = {
+ id: "Delete",
+ label: deleteCommandLabel,
+ verify: true,
+ };
+ this.#cardsDisabledMessage = cardsDisabled;
+ this.#header = this.createHeaderLine(headerLabel);
+ this.#header.commands.push({
+ id: "Create",
+ label: cardsCreateCommandLabel,
+ });
+ this.#cardNumberPrototype = this.prototypeDataLine({
+ label: { value: numberLabel },
+ concealed: { value: true, writable: true },
+ start: { value: true },
+ value: {
+ async get() {
+ if (this.editingValue !== undefined) {
+ return this.editingValue;
+ }
+
+ if (this.concealed) {
+ return (
+ "••••••••" +
+ this.record["cc-number"].replaceAll("*", "").substr(-4)
+ );
+ }
+
+ await decryptCard(this.record);
+ return this.record["cc-number-decrypted"];
+ },
+ },
+ valueIcon: {
+ get() {
+ const typeToImage = {
+ amex: "third-party/cc-logo-amex.png",
+ cartebancaire: "third-party/cc-logo-cartebancaire.png",
+ diners: "third-party/cc-logo-diners.svg",
+ discover: "third-party/cc-logo-discover.png",
+ jcb: "third-party/cc-logo-jcb.svg",
+ mastercard: "third-party/cc-logo-mastercard.svg",
+ mir: "third-party/cc-logo-mir.svg",
+ unionpay: "third-party/cc-logo-unionpay.svg",
+ visa: "third-party/cc-logo-visa.svg",
+ };
+ return (
+ "chrome://formautofill/content/" +
+ (typeToImage[this.record["cc-type"]] ??
+ "icon-credit-card-generic.svg")
+ );
+ },
+ },
+ commands: {
+ get() {
+ const commands = [
+ { id: "Conceal", label: concealCommandLabel },
+ { ...copyCommand, verify: true },
+ editCommand,
+ "-",
+ deleteCommand,
+ ];
+ if (this.concealed) {
+ commands[0] = {
+ id: "Reveal",
+ label: revealCommandLabel,
+ verify: true,
+ };
+ }
+ return commands;
+ },
+ },
+ executeReveal: {
+ value() {
+ this.concealed = false;
+ this.refreshOnScreen();
+ },
+ },
+ executeConceal: {
+ value() {
+ this.concealed = true;
+ this.refreshOnScreen();
+ },
+ },
+ executeCopy: {
+ async value() {
+ await decryptCard(this.record);
+ this.copyToClipboard(this.record["cc-number-decrypted"]);
+ },
+ },
+ executeEdit: {
+ async value() {
+ await decryptCard(this.record);
+ this.editingValue = this.record["cc-number-decrypted"] ?? "";
+ this.refreshOnScreen();
+ },
+ },
+ executeSave: {
+ async value(value) {
+ if (updateCard(this.record, "cc-number", value)) {
+ this.executeCancel();
+ }
+ },
+ },
+ });
+ this.#expirationPrototype = this.prototypeDataLine({
+ label: { value: expirationLabel },
+ value: {
+ get() {
+ return `${this.record["cc-exp-month"]}/${this.record["cc-exp-year"]}`;
+ },
+ },
+ commands: {
+ value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ });
+ this.#holderNamePrototype = this.prototypeDataLine({
+ label: { value: holderLabel },
+ end: { value: true },
+ value: {
+ get() {
+ return this.editingValue ?? this.record["cc-name"];
+ },
+ },
+ commands: {
+ value: [copyCommand, editCommand, "-", deleteCommand],
+ },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record["cc-name"] ?? "";
+ this.refreshOnScreen();
+ },
+ },
+ executeSave: {
+ async value(value) {
+ if (updateCard(this.record, "cc-name", value)) {
+ this.executeCancel();
+ }
+ },
+ },
+ });
+
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ Services.prefs.addObserver(
+ "extensions.formautofill.creditCards.enabled",
+ this
+ );
+ this.#reloadDataSource();
+ }
+ );
+ }
+
+ /**
+ * Enumerate all the lines provided by this data source.
+ *
+ * @param {string} searchText used to filter data
+ */
+ *enumerateLines(searchText) {
+ if (this.#enabled === undefined) {
+ // Async Fluent API makes it possible to have data source waiting
+ // for the localized strings, which can be detected by undefined in #enabled.
+ return;
+ }
+
+ yield this.#header;
+ if (this.#header.collapsed || !this.#enabled) {
+ return;
+ }
+
+ const stats = { count: 0, total: 0 };
+ searchText = searchText.toUpperCase();
+ yield* this.enumerateLinesForMatchingRecords(
+ searchText,
+ stats,
+ card =>
+ (card["cc-number-decrypted"] || card["cc-number"])
+ .toUpperCase()
+ .includes(searchText) ||
+ `${card["cc-exp-month"]}/${card["cc-exp-year"]}`
+ .toUpperCase()
+ .includes(searchText) ||
+ card["cc-name"].toUpperCase().includes(searchText)
+ );
+
+ this.formatMessages({
+ id:
+ stats.count == stats.total
+ ? "payments-count"
+ : "payments-filtered-count",
+ args: stats,
+ }).then(([headerLabel]) => {
+ this.#header.value = headerLabel;
+ });
+ }
+
+ /**
+ * Sync lines array with the actual data source.
+ * This function reads all cards from the storage, adds or updates lines and
+ * removes lines for the removed cards.
+ */
+ async #reloadDataSource() {
+ this.#enabled = Services.prefs.getBoolPref(
+ "extensions.formautofill.creditCards.enabled"
+ );
+ if (!this.#enabled) {
+ this.#reloadEmptyDataSource();
+ return;
+ }
+
+ await formAutofillStorage.initialize();
+ const cards = await formAutofillStorage.creditCards.getAll();
+ this.beforeReloadingDataSource();
+ cards.forEach(card => {
+ const lineId = `${card["cc-name"]}:${card.guid}`;
+
+ this.addOrUpdateLine(card, lineId + "0", this.#cardNumberPrototype);
+ this.addOrUpdateLine(card, lineId + "1", this.#expirationPrototype);
+ this.addOrUpdateLine(card, lineId + "2", this.#holderNamePrototype);
+ });
+ this.afterReloadingDataSource();
+ }
+
+ #reloadEmptyDataSource() {
+ this.lines.length = 0;
+ //todo: user can enable credit cards by activating header line
+ this.#header.value = this.#cardsDisabledMessage;
+ this.refreshAllLinesOnScreen();
+ }
+
+ observe(_subj, topic, message) {
+ if (
+ topic == "formautofill-storage-changed" ||
+ message == "extensions.formautofill.creditCards.enabled"
+ ) {
+ this.#reloadDataSource();
+ }
+ }
+}
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs
new file mode 100644
index 0000000000..49be733aef
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/DataSourceBase.sys.mjs
@@ -0,0 +1,291 @@
+/* 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/. */
+
+import { BinarySearch } from "resource://gre/modules/BinarySearch.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "ClipboardHelper",
+ "@mozilla.org/widget/clipboardhelper;1",
+ "nsIClipboardHelper"
+);
+
+/**
+ * Create a function to format messages.
+ *
+ * @param {...any} ftlFiles to be used for formatting messages
+ * @returns {Function} a function that can be used to format messsages
+ */
+function createFormatMessages(...ftlFiles) {
+ const strings = new Localization(ftlFiles);
+
+ return async (...ids) => {
+ for (const i in ids) {
+ if (typeof ids[i] == "string") {
+ ids[i] = { id: ids[i] };
+ }
+ }
+
+ const messages = await strings.formatMessages(ids);
+ return messages.map(message => {
+ if (message.attributes) {
+ return message.attributes.reduce(
+ (result, { name, value }) => ({ ...result, [name]: value }),
+ {}
+ );
+ }
+ return message.value;
+ });
+ };
+}
+
+/**
+ * Base datasource class
+ */
+export class DataSourceBase {
+ #aggregatorApi;
+
+ constructor(aggregatorApi) {
+ this.#aggregatorApi = aggregatorApi;
+ }
+
+ // proxy consumer api functions to datasource interface
+
+ refreshSingleLineOnScreen(line) {
+ this.#aggregatorApi.refreshSingleLineOnScreen(line);
+ }
+
+ refreshAllLinesOnScreen() {
+ this.#aggregatorApi.refreshAllLinesOnScreen();
+ }
+
+ formatMessages = createFormatMessages("preview/megalist.ftl");
+
+ /**
+ * Prototype for the each line.
+ * See this link for details:
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties#props
+ */
+ #linePrototype = {
+ /**
+ * Reference to the Data Source that owns this line.
+ */
+ source: this,
+
+ /**
+ * Each line has a reference to the actual data record.
+ */
+ record: { writable: true },
+
+ /**
+ * Is line ready to be displayed?
+ * Used by the View Model.
+ *
+ * @returns {boolean} true if line can be sent to the view.
+ * false if line is not ready to be displayed. In this case
+ * data source will start pulling value from the underlying
+ * storage and will push data to screen when it's ready.
+ */
+ lineIsReady() {
+ return true;
+ },
+
+ copyToClipboard(text) {
+ lazy.ClipboardHelper.copyString(text, lazy.ClipboardHelper.Sensitive);
+ },
+
+ openLinkInTab(url) {
+ const { BrowserWindowTracker } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserWindowTracker.sys.mjs"
+ );
+ const browser = BrowserWindowTracker.getTopWindow().gBrowser;
+ browser.addWebTab(url, { inBackground: false });
+ },
+
+ /**
+ * Simple version of Copy command. Line still needs to add "Copy" command.
+ * Override if copied value != displayed value.
+ */
+ executeCopy() {
+ this.copyToClipboard(this.value);
+ },
+
+ executeOpen() {
+ this.openLinkInTab(this.href);
+ },
+
+ executeEditInProgress(value) {
+ this.editingValue = value;
+ this.refreshOnScreen();
+ },
+
+ executeCancel() {
+ delete this.editingValue;
+ this.refreshOnScreen();
+ },
+
+ get template() {
+ return "editingValue" in this ? "editingLineTemplate" : undefined;
+ },
+
+ refreshOnScreen() {
+ this.source.refreshSingleLineOnScreen(this);
+ },
+ };
+
+ /**
+ * Creates collapsible section header line.
+ *
+ * @param {string} label for the section
+ * @returns {object} section header line
+ */
+ createHeaderLine(label) {
+ const toggleCommand = { id: "Toggle", label: "" };
+ const result = {
+ label,
+ value: "",
+ collapsed: false,
+ start: true,
+ end: true,
+ source: this,
+
+ /**
+ * Use different templates depending on the collapsed state.
+ */
+ get template() {
+ return this.collapsed
+ ? "collapsedSectionTemplate"
+ : "expandedSectionTemplate";
+ },
+
+ lineIsReady: () => true,
+
+ commands: [toggleCommand],
+
+ executeToggle() {
+ this.collapsed = !this.collapsed;
+ this.source.refreshAllLinesOnScreen();
+ },
+ };
+
+ this.formatMessages("command-toggle").then(([toggleLabel]) => {
+ toggleCommand.label = toggleLabel;
+ });
+
+ return result;
+ }
+
+ /**
+ * Create a prototype to be used for data lines,
+ * provides common set of features like Copy command.
+ *
+ * @param {object} properties to customize data line
+ * @returns {object} data line prototype
+ */
+ prototypeDataLine(properties) {
+ return Object.create(this.#linePrototype, properties);
+ }
+
+ lines = [];
+ #collator = new Intl.Collator();
+ #linesToForget;
+
+ /**
+ * Code to run before reloading data source.
+ * It will start tracking which lines are no longer at the source so
+ * afterReloadingDataSource() can remove them.
+ */
+ beforeReloadingDataSource() {
+ this.#linesToForget = new Set(this.lines);
+ }
+
+ /**
+ * Code to run after reloading data source.
+ * It will forget lines that are no longer at the source and refresh screen.
+ */
+ afterReloadingDataSource() {
+ if (this.#linesToForget.size) {
+ for (let i = this.lines.length; i >= 0; i--) {
+ if (this.#linesToForget.has(this.lines[i])) {
+ this.lines.splice(i, 1);
+ }
+ }
+ }
+
+ this.#linesToForget = null;
+ this.refreshAllLinesOnScreen();
+ }
+
+ /**
+ * Add or update line associated with the record.
+ *
+ * @param {object} record with which line is associated
+ * @param {*} id sortable line id
+ * @param {*} fieldPrototype to be used when creating a line.
+ */
+ addOrUpdateLine(record, id, fieldPrototype) {
+ let [found, index] = BinarySearch.search(
+ (target, value) => this.#collator.compare(target, value.id),
+ this.lines,
+ id
+ );
+
+ if (found) {
+ this.#linesToForget.delete(this.lines[index]);
+ } else {
+ const line = Object.create(fieldPrototype, { id: { value: id } });
+ this.lines.splice(index, 0, line);
+ }
+ this.lines[index].record = record;
+ return this.lines[index];
+ }
+
+ *enumerateLinesForMatchingRecords(searchText, stats, match) {
+ stats.total = 0;
+ stats.count = 0;
+
+ if (searchText) {
+ let i = 0;
+ while (i < this.lines.length) {
+ const currentRecord = this.lines[i].record;
+ stats.total += 1;
+
+ if (match(currentRecord)) {
+ // Record matches, yield all it's lines
+ while (
+ i < this.lines.length &&
+ currentRecord == this.lines[i].record
+ ) {
+ yield this.lines[i];
+ i += 1;
+ }
+ stats.count += 1;
+ } else {
+ // Record does not match, skip until the next one
+ while (
+ i < this.lines.length &&
+ currentRecord == this.lines[i].record
+ ) {
+ i += 1;
+ }
+ }
+ }
+ } else {
+ // No search text is provided - send all lines out, count records
+ let currentRecord;
+ for (const line of this.lines) {
+ yield line;
+
+ if (line.record != currentRecord) {
+ stats.total += 1;
+ currentRecord = line.record;
+ }
+ }
+ stats.count = stats.total;
+ }
+ }
+}
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
new file mode 100644
index 0000000000..324bc4d141
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
@@ -0,0 +1,472 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs";
+import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs";
+import { LoginCSVImport } from "resource://gre/modules/LoginCSVImport.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "BREACH_ALERTS_ENABLED",
+ "signon.management.page.breach-alerts.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "VULNERABLE_PASSWORDS_ENABLED",
+ "signon.management.page.vulnerable-passwords.enabled",
+ false
+);
+
+/**
+ * Data source for Logins.
+ *
+ * Each login is represented by 3 lines: origin, username and password.
+ *
+ * Protypes are used to reduce memory need because for different records
+ * similar lines will differ in values only.
+ */
+export class LoginDataSource extends DataSourceBase {
+ #originPrototype;
+ #usernamePrototype;
+ #passwordPrototype;
+ #loginsDisabledMessage;
+ #enabled;
+ #header;
+
+ constructor(...args) {
+ super(...args);
+ // Wait for Fluent to provide strings before loading data
+ this.formatMessages(
+ "passwords-section-label",
+ "passwords-origin-label",
+ "passwords-username-label",
+ "passwords-password-label",
+ "command-open",
+ "command-copy",
+ "command-reveal",
+ "command-conceal",
+ "passwords-disabled",
+ "command-delete",
+ "command-edit",
+ "passwords-command-create",
+ "passwords-command-import",
+ "passwords-command-export",
+ "passwords-command-remove-all",
+ "passwords-command-settings",
+ "passwords-command-help",
+ "passwords-import-file-picker-title",
+ "passwords-import-file-picker-import-button",
+ "passwords-import-file-picker-csv-filter-title",
+ "passwords-import-file-picker-tsv-filter-title"
+ ).then(
+ ([
+ headerLabel,
+ originLabel,
+ usernameLabel,
+ passwordLabel,
+ openCommandLabel,
+ copyCommandLabel,
+ revealCommandLabel,
+ concealCommandLabel,
+ passwordsDisabled,
+ deleteCommandLabel,
+ editCommandLabel,
+ passwordsCreateCommandLabel,
+ passwordsImportCommandLabel,
+ passwordsExportCommandLabel,
+ passwordsRemoveAllCommandLabel,
+ passwordsSettingsCommandLabel,
+ passwordsHelpCommandLabel,
+ passwordsImportFilePickerTitle,
+ passwordsImportFilePickerImportButton,
+ passwordsImportFilePickerCsvFilterTitle,
+ passwordsImportFilePickerTsvFilterTitle,
+ ]) => {
+ const copyCommand = { id: "Copy", label: copyCommandLabel };
+ const editCommand = { id: "Edit", label: editCommandLabel };
+ const deleteCommand = { id: "Delete", label: deleteCommandLabel };
+ this.breachedSticker = { type: "warning", label: "BREACH" };
+ this.vulnerableSticker = { type: "risk", label: "🤮 Vulnerable" };
+ this.#loginsDisabledMessage = passwordsDisabled;
+ this.#header = this.createHeaderLine(headerLabel);
+ this.#header.commands.push(
+ { id: "Create", label: passwordsCreateCommandLabel },
+ { id: "Import", label: passwordsImportCommandLabel },
+ { id: "Export", label: passwordsExportCommandLabel },
+ { id: "RemoveAll", label: passwordsRemoveAllCommandLabel },
+ { id: "Settings", label: passwordsSettingsCommandLabel },
+ { id: "Help", label: passwordsHelpCommandLabel }
+ );
+ this.#header.executeImport = async () => {
+ await this.#importFromFile(
+ passwordsImportFilePickerTitle,
+ passwordsImportFilePickerImportButton,
+ passwordsImportFilePickerCsvFilterTitle,
+ passwordsImportFilePickerTsvFilterTitle
+ );
+ };
+ this.#header.executeSettings = () => {
+ this.#openPreferences();
+ };
+ this.#header.executeHelp = () => {
+ this.#getHelp();
+ };
+
+ this.#originPrototype = this.prototypeDataLine({
+ label: { value: originLabel },
+ start: { value: true },
+ value: {
+ get() {
+ return this.record.displayOrigin;
+ },
+ },
+ valueIcon: {
+ get() {
+ return `page-icon:${this.record.origin}`;
+ },
+ },
+ href: {
+ get() {
+ return this.record.origin;
+ },
+ },
+ commands: {
+ value: [
+ { id: "Open", label: openCommandLabel },
+ copyCommand,
+ "-",
+ deleteCommand,
+ ],
+ },
+ executeCopy: {
+ value() {
+ this.copyToClipboard(this.record.origin);
+ },
+ },
+ });
+ this.#usernamePrototype = this.prototypeDataLine({
+ label: { value: usernameLabel },
+ value: {
+ get() {
+ return this.editingValue ?? this.record.username;
+ },
+ },
+ commands: { value: [copyCommand, editCommand, "-", deleteCommand] },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record.username ?? "";
+ this.refreshOnScreen();
+ },
+ },
+ executeSave: {
+ value(value) {
+ try {
+ const modifiedLogin = this.record.clone();
+ modifiedLogin.username = value;
+ Services.logins.modifyLogin(this.record, modifiedLogin);
+ } catch (error) {
+ //todo
+ console.error("failed to modify login", error);
+ }
+ this.executeCancel();
+ },
+ },
+ });
+ this.#passwordPrototype = this.prototypeDataLine({
+ label: { value: passwordLabel },
+ concealed: { value: true, writable: true },
+ end: { value: true },
+ value: {
+ get() {
+ return (
+ this.editingValue ??
+ (this.concealed ? "••••••••" : this.record.password)
+ );
+ },
+ },
+ commands: {
+ get() {
+ const commands = [
+ { id: "Conceal", label: concealCommandLabel },
+ {
+ id: "Copy",
+ label: copyCommandLabel,
+ verify: true,
+ },
+ editCommand,
+ "-",
+ deleteCommand,
+ ];
+ if (this.concealed) {
+ commands[0] = {
+ id: "Reveal",
+ label: revealCommandLabel,
+ verify: true,
+ };
+ }
+ return commands;
+ },
+ },
+ executeReveal: {
+ value() {
+ this.concealed = false;
+ this.refreshOnScreen();
+ },
+ },
+ executeConceal: {
+ value() {
+ this.concealed = true;
+ this.refreshOnScreen();
+ },
+ },
+ executeCopy: {
+ value() {
+ this.copyToClipboard(this.record.password);
+ },
+ },
+ executeEdit: {
+ value() {
+ this.editingValue = this.record.password ?? "";
+ this.refreshOnScreen();
+ },
+ },
+ executeSave: {
+ value(value) {
+ try {
+ const modifiedLogin = this.record.clone();
+ modifiedLogin.password = value;
+ Services.logins.modifyLogin(this.record, modifiedLogin);
+ } catch (error) {
+ //todo
+ console.error("failed to modify login", error);
+ }
+ this.executeCancel();
+ },
+ },
+ });
+
+ Services.obs.addObserver(this, "passwordmgr-storage-changed");
+ Services.prefs.addObserver("signon.rememberSignons", this);
+ Services.prefs.addObserver(
+ "signon.management.page.breach-alerts.enabled",
+ this
+ );
+ Services.prefs.addObserver(
+ "signon.management.page.vulnerable-passwords.enabled",
+ this
+ );
+ this.#reloadDataSource();
+ }
+ );
+ }
+
+ async #importFromFile(title, buttonLabel, csvTitle, tsvTitle) {
+ const { BrowserWindowTracker } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserWindowTracker.sys.mjs"
+ );
+ const browser = BrowserWindowTracker.getTopWindow().gBrowser;
+ let { result, path } = await this.openFilePickerDialog(
+ title,
+ buttonLabel,
+ [
+ {
+ title: csvTitle,
+ extensionPattern: "*.csv",
+ },
+ {
+ title: tsvTitle,
+ extensionPattern: "*.tsv",
+ },
+ ],
+ browser.ownerGlobal
+ );
+
+ if (result != Ci.nsIFilePicker.returnCancel) {
+ let summary;
+ try {
+ summary = await LoginCSVImport.importFromCSV(path);
+ } catch (e) {
+ // TODO: Display error for import
+ }
+ if (summary) {
+ // TODO: Display successful import summary
+ }
+ }
+ }
+
+ async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) {
+ return new Promise(resolve => {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen);
+ for (const appendFilter of appendFilters) {
+ fp.appendFilter(appendFilter.title, appendFilter.extensionPattern);
+ }
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+ fp.okButtonLabel = okButtonLabel;
+ fp.open(async result => {
+ resolve({ result, path: fp.file.path });
+ });
+ });
+ }
+
+ #openPreferences() {
+ const { BrowserWindowTracker } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserWindowTracker.sys.mjs"
+ );
+ const browser = BrowserWindowTracker.getTopWindow().gBrowser;
+ browser.ownerGlobal.openPreferences("privacy-logins");
+ }
+
+ #getHelp() {
+ const { BrowserWindowTracker } = ChromeUtils.importESModule(
+ "resource:///modules/BrowserWindowTracker.sys.mjs"
+ );
+ const browser = BrowserWindowTracker.getTopWindow().gBrowser;
+ const SUPPORT_URL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "password-manager-remember-delete-edit-logins";
+ browser.ownerGlobal.openWebLinkIn(SUPPORT_URL, "tab", {
+ relatedToCurrent: true,
+ });
+ }
+
+ /**
+ * Enumerate all the lines provided by this data source.
+ *
+ * @param {string} searchText used to filter data
+ */
+ *enumerateLines(searchText) {
+ if (this.#enabled === undefined) {
+ // Async Fluent API makes it possible to have data source waiting
+ // for the localized strings, which can be detected by undefined in #enabled.
+ return;
+ }
+
+ yield this.#header;
+ if (this.#header.collapsed || !this.#enabled) {
+ return;
+ }
+
+ const stats = { count: 0, total: 0 };
+ searchText = searchText.toUpperCase();
+ yield* this.enumerateLinesForMatchingRecords(
+ searchText,
+ stats,
+ login =>
+ login.displayOrigin.toUpperCase().includes(searchText) ||
+ login.username.toUpperCase().includes(searchText) ||
+ login.password.toUpperCase().includes(searchText)
+ );
+
+ this.formatMessages({
+ id:
+ stats.count == stats.total
+ ? "passwords-count"
+ : "passwords-filtered-count",
+ args: stats,
+ }).then(([headerLabel]) => {
+ this.#header.value = headerLabel;
+ });
+ }
+
+ /**
+ * Sync lines array with the actual data source.
+ * This function reads all logins from the storage, adds or updates lines and
+ * removes lines for the removed logins.
+ */
+ async #reloadDataSource() {
+ this.#enabled = Services.prefs.getBoolPref("signon.rememberSignons");
+ if (!this.#enabled) {
+ this.#reloadEmptyDataSource();
+ return;
+ }
+
+ const logins = await LoginHelper.getAllUserFacingLogins();
+ this.beforeReloadingDataSource();
+
+ const breachesMap = lazy.BREACH_ALERTS_ENABLED
+ ? await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins)
+ : new Map();
+
+ logins.forEach(login => {
+ // Similar domains will be grouped together
+ // www. will have least effect on the sorting
+ const parts = login.displayOrigin.split(".");
+
+ // Exclude TLD domain
+ //todo support eTLD and use public suffix here https://publicsuffix.org
+ if (parts.length > 1) {
+ parts.length -= 1;
+ }
+ const domain = parts.reverse().join(".");
+ const lineId = `${domain}:${login.username}:${login.guid}`;
+
+ let originLine = this.addOrUpdateLine(
+ login,
+ lineId + "0",
+ this.#originPrototype
+ );
+ this.addOrUpdateLine(login, lineId + "1", this.#usernamePrototype);
+ let passwordLine = this.addOrUpdateLine(
+ login,
+ lineId + "2",
+ this.#passwordPrototype
+ );
+
+ let breachIndex =
+ originLine.stickers?.findIndex(s => s === this.breachedSticker) ?? -1;
+ let breach = breachesMap.get(login.guid);
+ if (breach && breachIndex < 0) {
+ originLine.stickers ??= [];
+ originLine.stickers.push(this.breachedSticker);
+ } else if (!breach && breachIndex >= 0) {
+ originLine.stickers.splice(breachIndex, 1);
+ }
+
+ const vulnerable = lazy.VULNERABLE_PASSWORDS_ENABLED
+ ? lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID([
+ login,
+ ]).size
+ : 0;
+
+ let vulnerableIndex =
+ passwordLine.stickers?.findIndex(s => s === this.vulnerableSticker) ??
+ -1;
+ if (vulnerable && vulnerableIndex < 0) {
+ passwordLine.stickers ??= [];
+ passwordLine.stickers.push(this.vulnerableSticker);
+ } else if (!vulnerable && vulnerableIndex >= 0) {
+ passwordLine.stickers.splice(vulnerableIndex, 1);
+ }
+ });
+
+ this.afterReloadingDataSource();
+ }
+
+ #reloadEmptyDataSource() {
+ this.lines.length = 0;
+ //todo: user can enable passwords by activating Passwords header line
+ this.#header.value = this.#loginsDisabledMessage;
+ this.refreshAllLinesOnScreen();
+ }
+
+ observe(_subj, topic, message) {
+ if (
+ topic == "passwordmgr-storage-changed" ||
+ message == "signon.rememberSignons" ||
+ message == "signon.management.page.breach-alerts.enabled" ||
+ message == "signon.management.page.vulnerable-passwords.enabled"
+ ) {
+ this.#reloadDataSource();
+ }
+ }
+}
diff --git a/toolkit/components/satchel/megalist/aggregator/moz.build b/toolkit/components/satchel/megalist/aggregator/moz.build
new file mode 100644
index 0000000000..f244ade794
--- /dev/null
+++ b/toolkit/components/satchel/megalist/aggregator/moz.build
@@ -0,0 +1,17 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES["megalist/aggregator"] += [
+ "Aggregator.sys.mjs",
+ "DefaultAggregator.sys.mjs",
+]
+
+EXTRA_JS_MODULES["megalist/aggregator/datasources"] += [
+ "datasources/AddressesDataSource.sys.mjs",
+ "datasources/BankCardDataSource.sys.mjs",
+ "datasources/DataSourceBase.sys.mjs",
+ "datasources/LoginDataSource.sys.mjs",
+]