/* 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 { LoginHelper, OptInFeature, ParentAutocompleteOption, } from "resource://gre/modules/LoginHelper.sys.mjs"; import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs"; const lazy = {}; // Static configuration const gConfig = (function () { const baseUrl = Services.prefs.getStringPref( "signon.firefoxRelay.base_url", undefined ); return { scope: ["profile", "https://identity.mozilla.com/apps/relay"], addressesUrl: baseUrl + `relayaddresses/`, acceptTermsUrl: baseUrl + `terms-accepted-user/`, profilesUrl: baseUrl + `profiles/`, learnMoreURL: Services.urlFormatter.formatURLPref( "signon.firefoxRelay.learn_more_url" ), manageURL: Services.urlFormatter.formatURLPref( "signon.firefoxRelay.manage_url" ), relayFeaturePref: "signon.firefoxRelay.feature", showToAllBrowsersPref: "signon.firefoxRelay.showToAllBrowsers", termsOfServiceUrl: Services.urlFormatter.formatURLPref( "signon.firefoxRelay.terms_of_service_url" ), privacyPolicyUrl: Services.urlFormatter.formatURLPref( "signon.firefoxRelay.privacy_policy_url" ), allowListForFirstOfferPref: "signon.firefoxRelay.allowListForFirstOffer", allowListRemoteSettingsCollectionPref: "signon.firefoxRelay.allowListRemoteSettingsCollection", }; })(); export const autocompleteUXTreatments = { control: { image: "chrome://browser/content/logos/relay.svg", messageIds: [ "firefox-relay-opt-in-title-1", "firefox-relay-opt-in-subtitle-1", ], }, "basic-info": { image: "chrome://browser/content/asrouter/assets/glyph-mail-16.svg", messageIds: [ "firefox-relay-opt-in-title-a", "firefox-relay-opt-in-subtitle-a", ], }, "with-domain": { image: "chrome://browser/content/asrouter/assets/glyph-mail-16.svg", messageIds: [ "firefox-relay-opt-in-title-b", "firefox-relay-opt-in-subtitle-b", ], }, "with-domain-and-value-prop": { image: "chrome://browser/content/asrouter/assets/glyph-mail-16.svg", messageIds: [ "firefox-relay-opt-in-title-b", "firefox-relay-opt-in-subtitle-b", ], }, }; ChromeUtils.defineLazyGetter(lazy, "log", () => LoginHelper.createLogger("FirefoxRelay") ); ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ).getFxAccountsSingleton() ); ChromeUtils.defineLazyGetter(lazy, "fxAccountsCommon", () => ChromeUtils.importESModule("resource://gre/modules/FxAccountsCommon.sys.mjs") ); ChromeUtils.defineLazyGetter(lazy, "strings", function () { return new Localization([ "branding/brand.ftl", "browser/firefoxRelay.ftl", "toolkit/branding/brandings.ftl", ]); }); ChromeUtils.defineESModuleGetters(lazy, { NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.sys.mjs", }); if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { throw new Error("FirefoxRelay.sys.mjs should only run in the parent process"); } // Using 418 to avoid conflict with other standard http error code const AUTH_TOKEN_ERROR_CODE = 418; let gFlowId; let gAllowListCollection; async function getRelayTokenAsync() { try { return await lazy.fxAccounts.getOAuthToken({ scope: gConfig.scope }); } catch (e) { console.error(`There was an error getting the user's token: ${e.message}`); return undefined; } } async function hasFirefoxAccountAsync() { if (!lazy.fxAccounts.constructor.config.isProductionConfig()) { return false; } return lazy.fxAccounts.hasLocalSession(); } async function fetchWithReauth( browser, createRequest, canGetFreshOAuthToken = true ) { const relayToken = await getRelayTokenAsync(); if (!relayToken) { if (browser) { await showErrorAsync(browser, "firefox-relay-must-login-to-account"); } return undefined; } const headers = new Headers({ Authorization: `Bearer ${relayToken}`, Accept: "application/json", "Accept-Language": Services.locale.requestedLocales, "Content-Type": "application/json", }); const request = createRequest(headers); const response = await fetch(request); if (canGetFreshOAuthToken && response.status == 401) { await lazy.fxAccounts.removeCachedOAuthToken({ token: relayToken }); return fetchWithReauth(browser, createRequest, false); } return response; } async function getReusableMasksAsync(browser, _origin) { const response = await fetchWithReauth( browser, headers => new Request(gConfig.addressesUrl, { method: "GET", headers, }) ); if (!response) { // fetchWithReauth only returns undefined if login / obtaining a token failed. // Otherwise, it will return a response object. return [undefined, AUTH_TOKEN_ERROR_CODE]; } if (response.ok) { return [await response.json(), response.status]; } lazy.log.error( `failed to find reusable Relay masks: ${response.status}:${response.statusText}` ); await showErrorAsync(browser, "firefox-relay-get-reusable-masks-failed", { status: response.status, }); return [undefined, response.status]; } /** * Show localized notification. * * @param {*} browser * @param {*} messageId from browser/firefoxRelay.ftl * @param {object} messageArgs */ async function showErrorAsync(browser, messageId, messageArgs) { const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; const [message] = await lazy.strings.formatValues([ { id: messageId, args: messageArgs }, ]); PopupNotifications.show( browser, "relay-integration-error", message, "password-notification-icon", null, null, { autofocus: true, removeOnDismissal: true, hideClose: true, popupIconURL: "chrome://browser/content/logos/relay.svg", learnMoreURL: gConfig.learnMoreURL, } ); } function customizeNotificationHeader(notification, treatment = "control") { if (!notification) { return; } const document = notification.owner.panel.ownerDocument; const description = document.querySelector( `description[popupid=${notification.id}]` ); const notificationHeaderId = treatment === "control" ? `firefox-relay-header` : `firefox-relay-header-${treatment}`; const headerTemplate = document.getElementById(notificationHeaderId); description.replaceChildren(headerTemplate.firstChild.cloneNode(true)); } async function formatMessages(...ids) { for (let i in ids) { if (typeof ids[i] == "string") { ids[i] = { id: ids[i] }; } } const messages = await lazy.strings.formatMessages(ids); return messages.map(message => { if (message.attributes) { return message.attributes.reduce( (result, { name, value }) => ({ ...result, [name]: value }), {} ); } return message.value; }); } function getPostpone(postponeStrings, feature) { return { label: postponeStrings.label, accessKey: postponeStrings.accesskey, dismiss: true, callback() { lazy.log.info( "user decided not to decide about Firefox Relay integration" ); feature.markAsOffered(); Glean.relayIntegration.postponedOptInPanel.record({ value: gFlowId }); }, }; } function getDisableIntegration(disableStrings, feature) { return { label: disableStrings.label, accessKey: disableStrings.accesskey, dismiss: true, callback() { lazy.log.info("user opted out from Firefox Relay integration"); feature.markAsDisabled(); Glean.relayIntegration.disabledOptInPanel.record({ value: gFlowId }); }, }; } async function showReusableMasksAsync(browser, origin, error) { const [reusableMasks, status] = await getReusableMasksAsync(browser, origin); if (!reusableMasks) { Glean.relayIntegration.shownReusePanel.record({ value: gFlowId, error_code: status, }); return null; } let fillUsername; const fillUsernamePromise = new Promise(resolve => (fillUsername = resolve)); const [getUnlimitedMasksStrings] = await formatMessages( "firefox-relay-get-unlimited-masks" ); const getUnlimitedMasks = { label: getUnlimitedMasksStrings.label, accessKey: getUnlimitedMasksStrings.accesskey, dismiss: true, async callback() { Glean.relayIntegration.getUnlimitedMasksReusePanel.record({ value: gFlowId, }); browser.ownerGlobal.openWebLinkIn(gConfig.manageURL, "tab"); }, }; let notification; function getReusableMasksList() { return notification?.owner.panel.getElementsByClassName( "reusable-relay-masks" )[0]; } function notificationShown() { if (!notification) { return; } customizeNotificationHeader(notification); notification.owner.panel.getElementsByClassName( "error-message" )[0].textContent = error.detail || ""; // rebuild "reuse mask" buttons list const list = getReusableMasksList(); list.innerHTML = ""; const document = list.ownerDocument; const fragment = document.createDocumentFragment(); reusableMasks .filter(mask => mask.enabled) .forEach(mask => { const button = document.createElement("button"); const maskFullAddress = document.createElement("span"); maskFullAddress.textContent = mask.full_address; button.appendChild(maskFullAddress); const maskDescription = document.createElement("span"); maskDescription.textContent = mask.description || mask.generated_for || mask.used_on; button.appendChild(maskDescription); button.addEventListener( "click", () => { notification.remove(); lazy.log.info("Reusing Relay mask"); fillUsername(mask.full_address); showConfirmation( browser, "confirmation-hint-firefox-relay-mask-reused" ); Glean.relayIntegration.reuseMaskReusePanel.record({ value: gFlowId, }); }, { once: true } ); fragment.appendChild(button); }); list.appendChild(fragment); } function notificationRemoved() { const list = getReusableMasksList(); list.innerHTML = ""; } function onNotificationEvent(event) { switch (event) { case "removed": notificationRemoved(); break; case "shown": notificationShown(); Glean.relayIntegration.shownReusePanel.record({ value: gFlowId, error_code: 0, }); break; } } const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; notification = PopupNotifications.show( browser, "relay-integration-reuse-masks", "", // content is provided after popup shown "password-notification-icon", getUnlimitedMasks, [], { autofocus: true, removeOnDismissal: true, hideClose: true, eventCallback: onNotificationEvent, } ); return fillUsernamePromise; } async function generateUsernameAsync(browser, origin) { const body = JSON.stringify({ enabled: true, description: origin.substr(0, 64), generated_for: origin.substr(0, 255), used_on: origin, }); const response = await fetchWithReauth( browser, headers => new Request(gConfig.addressesUrl, { method: "POST", headers, body, }) ); if (!response) { Glean.relayIntegration.shownFillUsername.record({ value: gFlowId, error_code: AUTH_TOKEN_ERROR_CODE, }); return undefined; } if (response.ok) { lazy.log.info(`generated Relay mask`); const result = await response.json(); showConfirmation(browser, "confirmation-hint-firefox-relay-mask-created"); return result.full_address; } if (response.status == 403) { const error = await response.json(); if (error?.error_code == "free_tier_limit") { Glean.relayIntegration.shownFillUsername.record({ value: gFlowId, error_code: error.error_code, }); return showReusableMasksAsync(browser, origin, error); } } lazy.log.error( `failed to generate Relay mask: ${response.status}:${response.statusText}` ); await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { status: response.status, }); Glean.relayIntegration.shownReusePanel.record({ value: gFlowId, error_code: response.status, }); return undefined; } function isSignup(scenarioName) { return scenarioName == "SignUpFormScenario"; } async function onAllowList(origin) { const allowListForFirstOffer = Services.prefs.getBoolPref( gConfig.allowListForFirstOfferPref, true ); if (!allowListForFirstOffer) { return true; } if (!origin) { return false; } if (!gAllowListCollection) { const allowListRemoteSettingsCollection = Services.prefs.getStringPref( gConfig.allowListRemoteSettingsCollectionPref, "fxrelay-allowlist" ); try { gAllowListCollection = await lazy .RemoteSettings(allowListRemoteSettingsCollection) .get(); lazy.RemoteSettings(allowListRemoteSettingsCollection).on("sync", () => { gAllowListCollection = null; }); } catch (ex) { if (ex instanceof lazy.RemoteSettingsClient.UnknownCollectionError) { lazy.log.warn( "Could not get Remote Settings collection.", gConfig.allowListRemoteSettingsCollection, ex ); } throw ex; } } const originHost = new URL(origin).host; return gAllowListCollection.some( allowListRecord => allowListRecord.domain == originHost ); } class RelayOffered { async *autocompleteItemsAsync(origin, scenarioName, hasInput) { const hasFxA = await hasFirefoxAccountAsync(); const showRelayOnAllowlistSiteToAllUsers = Services.prefs.getBoolPref(gConfig.showToAllBrowsersPref, false) && (await onAllowList(origin)); if ( !hasInput && isSignup(scenarioName) && !Services.prefs.prefIsLocked(gConfig.relayFeaturePref) && (hasFxA || showRelayOnAllowlistSiteToAllUsers) ) { const nimbusRelayAutocompleteFeature = lazy.NimbusFeatures["email-autocomplete-relay"]; const treatment = nimbusRelayAutocompleteFeature.getVariable("firstOfferVersion"); if (!hasFxA && treatment == "disabled") { return; } nimbusRelayAutocompleteFeature.recordExposureEvent({ once: true }); const [title, subtitle] = await formatMessages( ...autocompleteUXTreatments[treatment].messageIds ); yield new ParentAutocompleteOption( autocompleteUXTreatments[treatment].image, title, subtitle, "PasswordManager:offerRelayIntegration", { telemetry: { flowId: gFlowId, scenarioName, }, } ); Glean.relayIntegration.shownOfferRelay.record({ value: gFlowId, scenario: scenarioName, }); } } async notifyServerTermsAcceptedAsync(browser) { const response = await fetchWithReauth( browser, headers => new Request(gConfig.acceptTermsUrl, { method: "POST", headers, }) ); if (!response?.ok) { lazy.log.error( `failed to notify server that terms are accepted : ${response?.status}:${response?.statusText}` ); let error; try { error = await response?.json(); } catch {} await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { status: error?.detail || response.status, }); return false; } return true; } async offerRelayIntegration(feature, browser, origin) { const fxaUser = await lazy.fxAccounts.getSignedInUser(); if (!fxaUser) { return this.offerRelayIntegrationToSignedOutUser( feature, browser, origin ); } return this.offerRelayIntegrationToFxAUser( feature, browser, origin, fxaUser ); } async offerRelayIntegrationToSignedOutUser(feature, browser, origin) { const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; let fillUsername; const fillUsernamePromise = new Promise( resolve => (fillUsername = resolve) ); const nimbusRelayAutocompleteFeature = lazy.NimbusFeatures["email-autocomplete-relay"]; const treatment = nimbusRelayAutocompleteFeature.getVariable("firstOfferVersion"); const enableButtonId = treatment === "control" ? "firefox-relay-and-fxa-opt-in-confirmation-enable-button-sign-up" : `firefox-relay-and-fxa-opt-in-confirmation-enable-button-${treatment}`; const [enableStrings, disableStrings, postponeStrings] = await formatMessages( enableButtonId, "firefox-relay-and-fxa-opt-in-confirmation-disable", "firefox-relay-and-fxa-opt-in-confirmation-postpone" ); const enableIntegration = { label: enableStrings.label, accessKey: enableStrings.accesskey, dismiss: true, callback: async () => { lazy.log.info( "user opted in to Mozilla account and Firefox Relay integration" ); // Capture the flowId here since async operations might take some time to resolve // and by then gFlowId might have another value const flowId = gFlowId; Glean.relayIntegration.enabledOptInPanel.record({ value: flowId }); // Capture the selected tab panel ID so we can come back to it after the // user finishes FXA sign-in const tabPanelId = browser.ownerGlobal.gBrowser.selectedTab.linkedPanel; // TODO: add some visual treatment to the tab and/or the form field to // indicate to the user that they need to complete sign-in to receive a // mask // Add an observer for ONVERIFIED_NOTIFICATION // to detect if a new FxA user verifies their email during sign-up, // and add an observer for ONLOGIN_NOTIFICATION // to detect if an existing FxA user logs in. const notificationsToObserve = [ lazy.fxAccountsCommon.ONVERIFIED_NOTIFICATION, lazy.fxAccountsCommon.ONLOGIN_NOTIFICATION, ]; const obs = async (_subject, topic) => { // When a user first signs up for FxA, Firefox receives an // ONLOGIN_NOTIFICATION *before* the user verifies their email // address. We can't forward any Relay emails until they verify their // email address, so we shouldn't call notifyServerTermsAcceptedAsync. // So, ignore login notifications for unverified users. if (topic == lazy.fxAccountsCommon.ONLOGIN_NOTIFICATION) { const fxaUser = await lazy.fxAccounts.getSignedInUser(); if (!fxaUser || !fxaUser.verified) { return; } } // Remove the observers to prevent them from running again for (const observedNotification of notificationsToObserve) { Services.obs.removeObserver(obs, observedNotification); } // Go back to the tab with the form that started the FXA sign-in flow const tabToFocus = Array.from(browser.ownerGlobal.gBrowser.tabs).find( tab => tab.linkedPanel === tabPanelId ); if (!tabToFocus) { // If the tab has been closed, return // TODO: figure out the real UX here? return; } // TODO: Update the visual treatment to the form field to indicate to // the user that we are hiding their email address. browser.ownerGlobal.gBrowser.selectedTab = tabToFocus; // Create the relay user, mark feature enabled, fill in the username // field with a mask // FIXME: If the Relay server user record is corrupted (See MPP-3512), // notifyServerTermsAcceptedAsync receives a 500 error from Relay // server. But we can't use fxAccounts.listAttachedOAuthClients to // detect if the user already has Desktop Relay, because Desktop // Relay does not show up as an OAuth client if (await this.notifyServerTermsAcceptedAsync(browser)) { feature.markAsEnabled(); fillUsername(await generateUsernameAsync(browser, origin)); } }; for (const notificationToObserve of notificationsToObserve) { Services.obs.addObserver(obs, notificationToObserve); } // Open tab to sign up for FxA and Relay const fxaUrl = await lazy.fxAccounts.constructor.config.promiseConnectAccountURI( "relay_integration", { service: "relay", entrypoint_experiment: "first_offer_version", entrypoint_variation: treatment, utm_source: "relay-integration", utm_medium: "firefox-desktop", utm_campaign: "first_offer_version", utm_content: treatment, } ); browser.ownerGlobal.openWebLinkIn(fxaUrl, "tab"); }, }; const postpone = getPostpone(postponeStrings, feature); const disableIntegration = getDisableIntegration(disableStrings, feature); let notification; feature.markAsOffered(); const popupNotificationId = treatment === "control" ? "fxa-and-relay-integration-offer" : `fxa-and-relay-integration-offer-${treatment}`; const learnMoreURL = treatment === "control" ? gConfig.learnMoreURL : undefined; notification = PopupNotifications.show( browser, popupNotificationId, "", // content is provided after popup shown "password-notification-icon", enableIntegration, [postpone, disableIntegration], { autofocus: true, removeOnDismissal: true, hideClose: true, learnMoreURL, eventCallback: event => { switch (event) { case "shown": { const document = notification.owner.panel.ownerDocument; customizeNotificationHeader(notification, treatment); document.querySelector( '[data-l10n-name="firefox-relay-learn-more-url"]' ).href = gConfig.learnMoreURL; const baseDomain = Services.eTLD.getBaseDomain( Services.io.newURI(origin) ); document.querySelector( '[data-l10n-name="firefox-fxa-and-relay-offer-domain"]' ).textContent = baseDomain; const tosLink = document.querySelector( ".firefox-fxa-and-relay-offer-tos-url" ); if (tosLink) { tosLink.href = gConfig.termsOfServiceUrl; } const privacyPolicyLink = document.querySelector( ".firefox-fxa-and-relay-offer-privacy-url" ); if (privacyPolicyLink) { privacyPolicyLink.href = gConfig.privacyPolicyUrl; } Glean.relayIntegration.shownOptInPanel.record({ value: gFlowId }); break; } } }, } ); return fillUsernamePromise; } async offerRelayIntegrationToFxAUser(feature, browser, origin, fxaUser) { const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; let fillUsername; const fillUsernamePromise = new Promise( resolve => (fillUsername = resolve) ); const [enableStrings, disableStrings, postponeStrings] = await formatMessages( "firefox-relay-opt-in-confirmation-enable-button", "firefox-relay-opt-in-confirmation-disable", "firefox-relay-opt-in-confirmation-postpone" ); const enableIntegration = { label: enableStrings.label, accessKey: enableStrings.accesskey, dismiss: true, callback: async () => { lazy.log.info("user opted in to Firefox Relay integration"); // Capture the flowId here since async operations might take some time to resolve // and by then gFlowId might have another value const flowId = gFlowId; Glean.relayIntegration.enabledOptInPanel.record({ value: flowId }); if (await this.notifyServerTermsAcceptedAsync(browser)) { feature.markAsEnabled(); fillUsername(await generateUsernameAsync(browser, origin)); } }, }; const postpone = getPostpone(postponeStrings, feature); const disableIntegration = getDisableIntegration(disableStrings, feature); let notification; feature.markAsOffered(); notification = PopupNotifications.show( browser, "relay-integration-offer", "", // content is provided after popup shown "password-notification-icon", enableIntegration, [postpone, disableIntegration], { autofocus: true, removeOnDismissal: true, hideClose: true, learnMoreURL: gConfig.learnMoreURL, eventCallback: event => { switch (event) { case "shown": { const document = notification.owner.panel.ownerDocument; customizeNotificationHeader(notification); document.getElementById("firefox-relay-offer-tos-url").href = gConfig.termsOfServiceUrl; document.getElementById("firefox-relay-offer-privacy-url").href = gConfig.privacyPolicyUrl; document.l10n.setAttributes( document .querySelector( `popupnotification[id=${notification.id}-notification] popupnotificationcontent` ) .querySelector( "[id=firefox-relay-offer-what-relay-provides]" ), "firefox-relay-offer-what-relay-provides", { useremail: fxaUser.email, } ); Glean.relayIntegration.shownOptInPanel.record({ value: gFlowId }); break; } } }, } ); getRelayTokenAsync(); return fillUsernamePromise; } } class RelayEnabled { async *autocompleteItemsAsync(origin, scenarioName, hasInput) { if ( !hasInput && isSignup(scenarioName) && ((await hasFirefoxAccountAsync()) || Services.prefs.getBoolPref(gConfig.showToAllBrowsersPref, false)) ) { const [title] = await formatMessages("firefox-relay-use-mask-title"); yield new ParentAutocompleteOption( "chrome://browser/content/logos/relay.svg", title, "", // when the user has opted-in, there is no subtitle content "PasswordManager:generateRelayUsername", { telemetry: { flowId: gFlowId, }, } ); Glean.relayIntegration.shownFillUsername.record({ value: gFlowId, error_code: 0, }); } } async generateUsername(browser, origin) { return generateUsernameAsync(browser, origin); } } class RelayDisabled {} class RelayFeature extends OptInFeature { constructor() { super(RelayOffered, RelayEnabled, RelayDisabled, gConfig.relayFeaturePref); // Update the config when the signon.firefoxRelay.base_url pref is changed. // This is added mainly for tests. Services.prefs.addObserver( "signon.firefoxRelay.base_url", this.updateConfig ); } get learnMoreUrl() { return gConfig.learnMoreURL; } updateConfig() { const newBaseUrl = Services.prefs.getStringPref( "signon.firefoxRelay.base_url" ); gConfig.addressesUrl = newBaseUrl + `relayaddresses/`; gConfig.profilesUrl = newBaseUrl + `profiles/`; gConfig.acceptTermsUrl = newBaseUrl + `terms-accepted-user/`; } async autocompleteItemsAsync({ origin, scenarioName, hasInput }) { const result = []; // Generate a flowID to unique identify a series of user action. FlowId // allows us to link users' interaction on different UI component (Ex. autocomplete, notification) // We can use flowID to build the Funnel Diagram // This value need to always be regenerated in the entry point of an user // action so we overwrite the previous one. gFlowId = TelemetryUtils.generateUUID(); if (this.implementation.autocompleteItemsAsync) { for await (const item of this.implementation.autocompleteItemsAsync( origin, scenarioName, hasInput )) { result.push(item); } } return result; } async generateUsername(browser, origin) { return this.implementation.generateUsername?.(browser, origin); } async offerRelayIntegration(browser, origin) { return this.implementation.offerRelayIntegration?.(this, browser, origin); } } export const FirefoxRelay = new RelayFeature();