1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7const EXPORTED_SYMBOLS = [
8  "GeckoViewAutocomplete",
9  "LoginEntry",
10  "CreditCard",
11  "Address",
12  "SelectOption",
13];
14
15const { XPCOMUtils } = ChromeUtils.import(
16  "resource://gre/modules/XPCOMUtils.jsm"
17);
18
19const { GeckoViewUtils } = ChromeUtils.import(
20  "resource://gre/modules/GeckoViewUtils.jsm"
21);
22
23XPCOMUtils.defineLazyModuleGetters(this, {
24  EventDispatcher: "resource://gre/modules/Messaging.jsm",
25  GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.jsm",
26});
27
28XPCOMUtils.defineLazyGetter(this, "LoginInfo", () =>
29  Components.Constructor(
30    "@mozilla.org/login-manager/loginInfo;1",
31    "nsILoginInfo",
32    "init"
33  )
34);
35
36class LoginEntry {
37  constructor({
38    origin,
39    formActionOrigin,
40    httpRealm,
41    username,
42    password,
43    guid,
44    timeCreated,
45    timeLastUsed,
46    timePasswordChanged,
47    timesUsed,
48  }) {
49    this.origin = origin ?? null;
50    this.formActionOrigin = formActionOrigin ?? null;
51    this.httpRealm = httpRealm ?? null;
52    this.username = username ?? null;
53    this.password = password ?? null;
54
55    // Metadata.
56    this.guid = guid ?? null;
57    // TODO: Not supported by GV.
58    this.timeCreated = timeCreated ?? null;
59    this.timeLastUsed = timeLastUsed ?? null;
60    this.timePasswordChanged = timePasswordChanged ?? null;
61    this.timesUsed = timesUsed ?? null;
62  }
63
64  toLoginInfo() {
65    const info = new LoginInfo(
66      this.origin,
67      this.formActionOrigin,
68      this.httpRealm,
69      this.username,
70      this.password
71    );
72
73    // Metadata.
74    info.QueryInterface(Ci.nsILoginMetaInfo);
75    info.guid = this.guid;
76    info.timeCreated = this.timeCreated;
77    info.timeLastUsed = this.timeLastUsed;
78    info.timePasswordChanged = this.timePasswordChanged;
79    info.timesUsed = this.timesUsed;
80
81    return info;
82  }
83
84  static parse(aObj) {
85    const entry = new LoginEntry({});
86    Object.assign(entry, aObj);
87
88    return entry;
89  }
90
91  static fromLoginInfo(aInfo) {
92    const entry = new LoginEntry({});
93    entry.origin = aInfo.origin;
94    entry.formActionOrigin = aInfo.formActionOrigin;
95    entry.httpRealm = aInfo.httpRealm;
96    entry.username = aInfo.username;
97    entry.password = aInfo.password;
98
99    // Metadata.
100    aInfo.QueryInterface(Ci.nsILoginMetaInfo);
101    entry.guid = aInfo.guid;
102    entry.timeCreated = aInfo.timeCreated;
103    entry.timeLastUsed = aInfo.timeLastUsed;
104    entry.timePasswordChanged = aInfo.timePasswordChanged;
105    entry.timesUsed = aInfo.timesUsed;
106
107    return entry;
108  }
109}
110
111class Address {
112  constructor({
113    name,
114    givenName,
115    additionalName,
116    familyName,
117    organization,
118    streetAddress,
119    addressLevel1,
120    addressLevel2,
121    addressLevel3,
122    postalCode,
123    country,
124    tel,
125    email,
126    guid,
127    timeCreated,
128    timeLastUsed,
129    timeLastModified,
130    timesUsed,
131    version,
132  }) {
133    this.name = name ?? null;
134    this.givenName = givenName ?? null;
135    this.additionalName = additionalName ?? null;
136    this.familyName = familyName ?? null;
137    this.organization = organization ?? null;
138    this.streetAddress = streetAddress ?? null;
139    this.addressLevel1 = addressLevel1 ?? null;
140    this.addressLevel2 = addressLevel2 ?? null;
141    this.addressLevel3 = addressLevel3 ?? null;
142    this.postalCode = postalCode ?? null;
143    this.country = country ?? null;
144    this.tel = tel ?? null;
145    this.email = email ?? null;
146
147    // Metadata.
148    this.guid = guid ?? null;
149    // TODO: Not supported by GV.
150    this.timeCreated = timeCreated ?? null;
151    this.timeLastUsed = timeLastUsed ?? null;
152    this.timeLastModified = timeLastModified ?? null;
153    this.timesUsed = timesUsed ?? null;
154    this.version = version ?? null;
155  }
156
157  isValid() {
158    return (
159      (this.name ?? this.givenName ?? this.familyName) !== null &&
160      this.streetAddress !== null &&
161      this.postalCode !== null
162    );
163  }
164
165  static fromGecko(aObj) {
166    return new Address({
167      version: aObj.version,
168      name: aObj.name,
169      givenName: aObj["given-name"],
170      additionalName: aObj["additional-name"],
171      familyName: aObj["family-name"],
172      organization: aObj.organization,
173      streetAddress: aObj["street-address"],
174      addressLevel1: aObj["address-level1"],
175      addressLevel2: aObj["address-level2"],
176      addressLevel3: aObj["address-level3"],
177      postalCode: aObj["postal-code"],
178      country: aObj.country,
179      tel: aObj.tel,
180      email: aObj.email,
181      guid: aObj.guid,
182      timeCreated: aObj.timeCreated,
183      timeLastUsed: aObj.timeLastUsed,
184      timeLastModified: aObj.timeLastModified,
185      timesUsed: aObj.timesUsed,
186    });
187  }
188
189  static parse(aObj) {
190    const entry = new Address({});
191    Object.assign(entry, aObj);
192
193    return entry;
194  }
195
196  toGecko() {
197    return {
198      version: this.version,
199      name: this.name,
200      "given-name": this.givenName,
201      "additional-name": this.additionalName,
202      "family-name": this.familyName,
203      organization: this.organization,
204      "street-address": this.streetAddress,
205      "address-level1": this.addressLevel1,
206      "address-level2": this.addressLevel2,
207      "address-level3": this.addressLevel3,
208      "postal-code": this.postalCode,
209      country: this.country,
210      tel: this.tel,
211      email: this.email,
212      guid: this.guid,
213    };
214  }
215}
216
217class CreditCard {
218  constructor({
219    name,
220    number,
221    expMonth,
222    expYear,
223    type,
224    guid,
225    timeCreated,
226    timeLastUsed,
227    timeLastModified,
228    timesUsed,
229    version,
230  }) {
231    this.name = name ?? null;
232    this.number = number ?? null;
233    this.expMonth = expMonth ?? null;
234    this.expYear = expYear ?? null;
235    this.type = type ?? null;
236
237    // Metadata.
238    this.guid = guid ?? null;
239    // TODO: Not supported by GV.
240    this.timeCreated = timeCreated ?? null;
241    this.timeLastUsed = timeLastUsed ?? null;
242    this.timeLastModified = timeLastModified ?? null;
243    this.timesUsed = timesUsed ?? null;
244    this.version = version ?? null;
245  }
246
247  isValid() {
248    return (
249      this.name !== null &&
250      this.number !== null &&
251      this.expMonth !== null &&
252      this.expYear !== null
253    );
254  }
255
256  static fromGecko(aObj) {
257    return new CreditCard({
258      version: aObj.version,
259      name: aObj["cc-name"],
260      number: aObj["cc-number"],
261      expMonth: aObj["cc-exp-month"],
262      expYear: aObj["cc-exp-year"],
263      type: aObj["cc-type"],
264      guid: aObj.guid,
265      timeCreated: aObj.timeCreated,
266      timeLastUsed: aObj.timeLastUsed,
267      timeLastModified: aObj.timeLastModified,
268      timesUsed: aObj.timesUsed,
269    });
270  }
271
272  static parse(aObj) {
273    const entry = new CreditCard({});
274    Object.assign(entry, aObj);
275
276    return entry;
277  }
278
279  toGecko() {
280    return {
281      version: this.version,
282      "cc-name": this.name,
283      "cc-number": this.number,
284      "cc-exp-month": this.expMonth,
285      "cc-exp-year": this.expYear,
286      "cc-type": this.type,
287      guid: this.guid,
288    };
289  }
290}
291
292class SelectOption {
293  // Sync with Autocomplete.SelectOption.Hint in Autocomplete.java.
294  static Hint = {
295    NONE: 0,
296    GENERATED: 1 << 0,
297    INSECURE_FORM: 1 << 1,
298    DUPLICATE_USERNAME: 1 << 2,
299    MATCHING_ORIGIN: 1 << 3,
300  };
301
302  constructor({ value, hint }) {
303    this.value = value ?? null;
304    this.hint = hint ?? SelectOption.Hint.NONE;
305  }
306}
307
308// Sync with Autocomplete.UsedField in Autocomplete.java.
309const UsedField = { PASSWORD: 1 };
310
311const GeckoViewAutocomplete = {
312  /**
313   * Delegates login entry fetching for the given domain to the attached
314   * LoginStorage GeckoView delegate.
315   *
316   * @param aDomain
317   *        The domain string to fetch login entries for.
318   * @return {Promise}
319   *         Resolves with an array of login objects or null.
320   *         Rejected if no delegate is attached.
321   *         Login object string properties:
322   *         { guid, origin, formActionOrigin, httpRealm, username, password }
323   */
324  fetchLogins(aDomain) {
325    debug`fetchLogins for ${aDomain}`;
326
327    return EventDispatcher.instance.sendRequestForResult({
328      type: "GeckoView:Autocomplete:Fetch:Login",
329      domain: aDomain,
330    });
331  },
332
333  /**
334   * Delegates credit card entry fetching to the attached LoginStorage
335   * GeckoView delegate.
336   *
337   * @return {Promise}
338   *         Resolves with an array of credit card objects or null.
339   *         Rejected if no delegate is attached.
340   *         Login object string properties:
341   *         { guid, name, number, expMonth, expYear, type }
342   */
343  fetchCreditCards() {
344    debug`fetchCreditCards`;
345
346    return EventDispatcher.instance.sendRequestForResult({
347      type: "GeckoView:Autocomplete:Fetch:CreditCard",
348    });
349  },
350
351  /**
352   * Delegates address entry fetching to the attached LoginStorage
353   * GeckoView delegate.
354   *
355   * @return {Promise}
356   *         Resolves with an array of address objects or null.
357   *         Rejected if no delegate is attached.
358   *         Login object string properties:
359   *         { guid, name, givenName, additionalName, familyName,
360   *           organization, streetAddress, addressLevel1, addressLevel2,
361   *           addressLevel3, postalCode, country, tel, email }
362   */
363  fetchAddresses() {
364    debug`fetchAddresses`;
365
366    return EventDispatcher.instance.sendRequestForResult({
367      type: "GeckoView:Autocomplete:Fetch:Address",
368    });
369  },
370
371  /**
372   * Delegates credit card entry saving to the attached LoginStorage GeckoView delegate.
373   * Call this when a new or modified credit card entry has been submitted.
374   *
375   * @param aCreditCard The {CreditCard} to be saved.
376   */
377  onCreditCardSave(aCreditCard) {
378    debug`onLoginSave ${aCreditCard}`;
379
380    EventDispatcher.instance.sendRequest({
381      type: "GeckoView:Autocomplete:Save:CreditCard",
382      creditCard: aCreditCard,
383    });
384  },
385
386  /**
387   * Delegates address entry saving to the attached LoginStorage GeckoView delegate.
388   * Call this when a new or modified address entry has been submitted.
389   *
390   * @param aAddress The {Address} to be saved.
391   */
392  onAddressSave(aAddress) {
393    debug`onLoginSave ${aAddress}`;
394
395    EventDispatcher.instance.sendRequest({
396      type: "GeckoView:Autocomplete:Save:Address",
397      address: aAddress,
398    });
399  },
400
401  /**
402   * Delegates login entry saving to the attached LoginStorage GeckoView delegate.
403   * Call this when a new login entry or a new password for an existing login
404   * entry has been submitted.
405   *
406   * @param aLogin The {LoginEntry} to be saved.
407   */
408  onLoginSave(aLogin) {
409    debug`onLoginSave ${aLogin}`;
410
411    EventDispatcher.instance.sendRequest({
412      type: "GeckoView:Autocomplete:Save:Login",
413      login: aLogin,
414    });
415  },
416
417  /**
418   * Delegates login entry password usage to the attached LoginStorage GeckoView
419   * delegate.
420   * Call this when the password of an existing login entry, as returned by
421   * fetchLogins, has been used for autofill.
422   *
423   * @param aLogin The {LoginEntry} whose password was used.
424   */
425  onLoginPasswordUsed(aLogin) {
426    debug`onLoginUsed ${aLogin}`;
427
428    EventDispatcher.instance.sendRequest({
429      type: "GeckoView:Autocomplete:Used:Login",
430      usedFields: UsedField.PASSWORD,
431      login: aLogin,
432    });
433  },
434
435  _numActiveSelections: 0,
436
437  /**
438   * Delegates login entry selection.
439   * Call this when there are multiple login entry option for a form to delegate
440   * the selection.
441   *
442   * @param aBrowser The browser instance the triggered the selection.
443   * @param aOptions The list of {SelectOption} depicting viable options.
444   */
445  onLoginSelect(aBrowser, aOptions) {
446    debug`onLoginSelect ${aOptions}`;
447
448    return new Promise((resolve, reject) => {
449      if (!aBrowser || !aOptions) {
450        debug`onLoginSelect Rejecting - no browser or options provided`;
451        reject();
452        return;
453      }
454
455      const prompt = new GeckoViewPrompter(aBrowser.ownerGlobal);
456      prompt.asyncShowPrompt(
457        {
458          type: "Autocomplete:Select:Login",
459          options: aOptions,
460        },
461        result => {
462          if (!result || !result.selection) {
463            reject();
464            return;
465          }
466
467          const option = new SelectOption({
468            value: LoginEntry.parse(result.selection.value),
469            hint: result.selection.hint,
470          });
471          resolve(option);
472        }
473      );
474    });
475  },
476
477  /**
478   * Delegates credit card entry selection.
479   * Call this when there are multiple credit card entry option for a form to delegate
480   * the selection.
481   *
482   * @param aBrowser The browser instance the triggered the selection.
483   * @param aOptions The list of {SelectOption} depicting viable options.
484   */
485  onCreditCardSelect(aBrowser, aOptions) {
486    debug`onCreditCardSelect ${aOptions}`;
487
488    return new Promise((resolve, reject) => {
489      if (!aBrowser || !aOptions) {
490        debug`onCreditCardSelect Rejecting - no browser or options provided`;
491        reject();
492        return;
493      }
494
495      const prompt = new GeckoViewPrompter(aBrowser.ownerGlobal);
496      prompt.asyncShowPrompt(
497        {
498          type: "Autocomplete:Select:CreditCard",
499          options: aOptions,
500        },
501        result => {
502          if (!result || !result.selection) {
503            reject();
504            return;
505          }
506
507          const option = new SelectOption({
508            value: CreditCard.parse(result.selection.value),
509            hint: result.selection.hint,
510          });
511          resolve(option);
512        }
513      );
514    });
515  },
516
517  /**
518   * Delegates address entry selection.
519   * Call this when there are multiple address entry option for a form to delegate
520   * the selection.
521   *
522   * @param aBrowser The browser instance the triggered the selection.
523   * @param aOptions The list of {SelectOption} depicting viable options.
524   */
525  onAddressSelect(aBrowser, aOptions) {
526    debug`onAddressSelect ${aOptions}`;
527
528    return new Promise((resolve, reject) => {
529      if (!aBrowser || !aOptions) {
530        debug`onAddressSelect Rejecting - no browser or options provided`;
531        reject();
532        return;
533      }
534
535      const prompt = new GeckoViewPrompter(aBrowser.ownerGlobal);
536      prompt.asyncShowPrompt(
537        {
538          type: "Autocomplete:Select:Address",
539          options: aOptions,
540        },
541        result => {
542          if (!result || !result.selection) {
543            reject();
544            return;
545          }
546
547          const option = new SelectOption({
548            value: Address.parse(result.selection.value),
549            hint: result.selection.hint,
550          });
551          resolve(option);
552        }
553      );
554    });
555  },
556
557  async delegateSelection({
558    browsingContext,
559    options,
560    inputElementIdentifier,
561    formOrigin,
562  }) {
563    debug`delegateSelection ${options}`;
564
565    if (!options.length) {
566      return;
567    }
568
569    let insecureHint = SelectOption.Hint.NONE;
570    let loginStyle = null;
571
572    // TODO: Replace this string with more robust mechanics.
573    let selectionType = null;
574    const selectOptions = [];
575
576    for (const option of options) {
577      switch (option.style) {
578        case "insecureWarning": {
579          // We depend on the insecure warning to be the first option.
580          insecureHint = SelectOption.Hint.INSECURE_FORM;
581          break;
582        }
583        case "generatedPassword": {
584          selectionType = "login";
585          const comment = JSON.parse(option.comment);
586          selectOptions.push(
587            new SelectOption({
588              value: new LoginEntry({
589                password: comment.generatedPassword,
590              }),
591              hint: SelectOption.Hint.GENERATED | insecureHint,
592            })
593          );
594          break;
595        }
596        case "login":
597        // Fallthrough.
598        case "loginWithOrigin": {
599          selectionType = "login";
600          loginStyle = option.style;
601          const comment = JSON.parse(option.comment);
602
603          let hint = SelectOption.Hint.NONE | insecureHint;
604          if (comment.isDuplicateUsername) {
605            hint |= SelectOption.Hint.DUPLICATE_USERNAME;
606          }
607          if (comment.isOriginMatched) {
608            hint |= SelectOption.Hint.MATCHING_ORIGIN;
609          }
610
611          selectOptions.push(
612            new SelectOption({
613              value: LoginEntry.parse(comment.login),
614              hint,
615            })
616          );
617          break;
618        }
619        case "autofill-profile": {
620          const comment = JSON.parse(option.comment);
621          debug`delegateSelection ${comment}`;
622          const creditCard = CreditCard.fromGecko(comment);
623          const address = Address.fromGecko(comment);
624          if (creditCard.isValid()) {
625            selectionType = "creditCard";
626            selectOptions.push(
627              new SelectOption({
628                value: creditCard,
629                hint: insecureHint,
630              })
631            );
632          } else if (address.isValid()) {
633            selectionType = "address";
634            selectOptions.push(
635              new SelectOption({
636                value: address,
637                hint: insecureHint,
638              })
639            );
640          }
641          break;
642        }
643        default:
644          debug`delegateSelection - ignoring unknown option style ${option.style}`;
645      }
646    }
647
648    if (selectOptions.length < 1) {
649      debug`Abort delegateSelection - no valid options provided`;
650      return;
651    }
652
653    if (this._numActiveSelections > 0) {
654      debug`Abort delegateSelection - there is already one delegation active`;
655      return;
656    }
657
658    ++this._numActiveSelections;
659
660    let selectedOption = null;
661    const browser = browsingContext.top.embedderElement;
662    if (selectionType === "login") {
663      selectedOption = await this.onLoginSelect(browser, selectOptions).catch(
664        _ => {
665          debug`No GV delegate attached`;
666        }
667      );
668    } else if (selectionType === "creditCard") {
669      selectedOption = await this.onCreditCardSelect(
670        browser,
671        selectOptions
672      ).catch(_ => {
673        debug`No GV delegate attached`;
674      });
675    } else if (selectionType === "address") {
676      selectedOption = await this.onAddressSelect(browser, selectOptions).catch(
677        _ => {
678          debug`No GV delegate attached`;
679        }
680      );
681    }
682
683    --this._numActiveSelections;
684
685    debug`delegateSelection selected option: ${selectedOption}`;
686
687    if (selectionType === "login") {
688      const selectedLogin = selectedOption?.value?.toLoginInfo();
689
690      if (!selectedLogin) {
691        debug`Abort delegateSelection - no login entry selected`;
692        return;
693      }
694
695      debug`delegateSelection - filling form`;
696
697      const actor = browsingContext.currentWindowGlobal.getActor(
698        "LoginManager"
699      );
700
701      await actor.fillForm({
702        browser,
703        inputElementIdentifier,
704        loginFormOrigin: formOrigin,
705        login: selectedLogin,
706        style:
707          selectedOption.hint & SelectOption.Hint.GENERATED
708            ? "generatedPassword"
709            : loginStyle,
710      });
711    } else if (selectionType === "creditCard") {
712      const selectedCreditCard = selectedOption?.value?.toGecko();
713      const actor = browsingContext.currentWindowGlobal.getActor(
714        "FormAutofill"
715      );
716
717      actor.sendAsyncMessage("FormAutofill:FillForm", selectedCreditCard);
718    } else if (selectionType === "address") {
719      const selectedAddress = selectedOption?.value?.toGecko();
720      const actor = browsingContext.currentWindowGlobal.getActor(
721        "FormAutofill"
722      );
723
724      actor.sendAsyncMessage("FormAutofill:FillForm", selectedAddress);
725    }
726
727    debug`delegateSelection - form filled`;
728  },
729};
730
731const { debug } = GeckoViewUtils.initLogging("GeckoViewAutocomplete");
732