forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			791 lines
		
	
	
	
		
			25 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			791 lines
		
	
	
	
		
			25 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* 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/. */
 | |
| 
 | |
| /**
 | |
|  * Control panel for the Ion project, formerly known as Pioneer.
 | |
|  * This lives in `about:ion` and provides a UI for users to un/enroll in the
 | |
|  * overall program, and to un/enroll from individual studies.
 | |
|  *
 | |
|  * NOTE - prefs and Telemetry both still mention Pioneer for backwards-compatibility,
 | |
|  *        this may change in the future.
 | |
|  */
 | |
| 
 | |
| const { AddonManager } = ChromeUtils.importESModule(
 | |
|   "resource://gre/modules/AddonManager.sys.mjs"
 | |
| );
 | |
| 
 | |
| const { RemoteSettings } = ChromeUtils.importESModule(
 | |
|   "resource://services-settings/remote-settings.sys.mjs"
 | |
| );
 | |
| 
 | |
| const { TelemetryController } = ChromeUtils.importESModule(
 | |
|   "resource://gre/modules/TelemetryController.sys.mjs"
 | |
| );
 | |
| 
 | |
| let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
 | |
|   Ci.nsIParserUtils
 | |
| );
 | |
| 
 | |
| const PREF_ION_ID = "toolkit.telemetry.pioneerId";
 | |
| const PREF_ION_NEW_STUDIES_AVAILABLE =
 | |
|   "toolkit.telemetry.pioneer-new-studies-available";
 | |
| const PREF_ION_COMPLETED_STUDIES =
 | |
|   "toolkit.telemetry.pioneer-completed-studies";
 | |
| 
 | |
| /**
 | |
|  * Remote Settings keys for general content, and available studies.
 | |
|  */
 | |
| const CONTENT_COLLECTION_KEY = "pioneer-content-v2";
 | |
| const STUDY_ADDON_COLLECTION_KEY = "pioneer-study-addons-v2";
 | |
| 
 | |
| const STUDY_LEAVE_REASONS = {
 | |
|   USER_ABANDONED: "user-abandoned",
 | |
|   STUDY_ENDED: "study-ended",
 | |
| };
 | |
| 
 | |
| const PREF_TEST_CACHED_CONTENT = "toolkit.pioneer.testCachedContent";
 | |
| const PREF_TEST_CACHED_ADDONS = "toolkit.pioneer.testCachedAddons";
 | |
| const PREF_TEST_ADDONS = "toolkit.pioneer.testAddons";
 | |
| 
 | |
| /**
 | |
|  * Use the in-tree HTML Sanitizer to ensure that HTML from remote-settings is safe to use.
 | |
|  * Note that RS does use content-signing, we're doing this extra step as an in-depth security measure.
 | |
|  *
 | |
|  * @param {string} htmlString - unsanitized HTML (content-signed by remote-settings)
 | |
|  * @returns {DocumentFragment} - sanitized DocumentFragment
 | |
|  */
 | |
| function sanitizeHtml(htmlString) {
 | |
|   const content = document.createElement("div");
 | |
|   const contentFragment = parserUtils.parseFragment(
 | |
|     htmlString,
 | |
|     Ci.nsIParserUtils.SanitizerDropForms |
 | |
|       Ci.nsIParserUtils.SanitizerAllowStyle |
 | |
|       Ci.nsIParserUtils.SanitizerLogRemovals,
 | |
|     false,
 | |
|     Services.io.newURI("about:ion"),
 | |
|     content
 | |
|   );
 | |
| 
 | |
|   return contentFragment;
 | |
| }
 | |
| 
 | |
| function showEnrollmentStatus() {
 | |
|   const ionId = Services.prefs.getStringPref(PREF_ION_ID, null);
 | |
| 
 | |
|   const enrollmentButton = document.getElementById("enrollment-button");
 | |
| 
 | |
|   document.l10n.setAttributes(
 | |
|     enrollmentButton,
 | |
|     `ion-${ionId ? "un" : ""}enrollment-button`
 | |
|   );
 | |
|   enrollmentButton.classList.toggle("primary", !ionId);
 | |
| 
 | |
|   // collapse content above the fold if enrolled, otherwise open it.
 | |
|   for (const section of ["details", "data"]) {
 | |
|     const details = document.getElementById(section);
 | |
|     if (ionId) {
 | |
|       details.removeAttribute("open");
 | |
|     } else {
 | |
|       details.setAttribute("open", true);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| function toggleContentBasedOnLocale() {
 | |
|   const requestedLocale = Services.locale.requestedLocale;
 | |
|   if (requestedLocale !== "en-US") {
 | |
|     const localeNotificationBar = document.getElementById(
 | |
|       "locale-notification"
 | |
|     );
 | |
|     localeNotificationBar.style.display = "block";
 | |
| 
 | |
|     const reportContent = document.getElementById("report-content");
 | |
|     reportContent.style.display = "none";
 | |
|   }
 | |
| }
 | |
| 
 | |
| async function toggleEnrolled(studyAddonId, cachedAddons) {
 | |
|   let addon;
 | |
|   let install;
 | |
| 
 | |
|   const cachedAddon = cachedAddons.find(a => a.addon_id == studyAddonId);
 | |
| 
 | |
|   if (Cu.isInAutomation) {
 | |
|     install = {
 | |
|       install: async () => {
 | |
|         let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]");
 | |
|         testAddons = JSON.parse(testAddons);
 | |
| 
 | |
|         testAddons.push(studyAddonId);
 | |
|         Services.prefs.setStringPref(
 | |
|           PREF_TEST_ADDONS,
 | |
|           JSON.stringify(testAddons)
 | |
|         );
 | |
|       },
 | |
|     };
 | |
| 
 | |
|     let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]");
 | |
|     testAddons = JSON.parse(testAddons);
 | |
| 
 | |
|     for (const testAddon of testAddons) {
 | |
|       if (testAddon == studyAddonId) {
 | |
|         addon = {};
 | |
|         addon.uninstall = () => {
 | |
|           Services.prefs.setStringPref(
 | |
|             PREF_TEST_ADDONS,
 | |
|             JSON.stringify(testAddons.filter(a => a.id != testAddon.id))
 | |
|           );
 | |
|         };
 | |
|       }
 | |
|     }
 | |
|   } else {
 | |
|     addon = await AddonManager.getAddonByID(studyAddonId);
 | |
|     install = await AddonManager.getInstallForURL(cachedAddon.sourceURI.spec);
 | |
|   }
 | |
| 
 | |
|   const completedStudies = Services.prefs.getStringPref(
 | |
|     PREF_ION_COMPLETED_STUDIES,
 | |
|     "{}"
 | |
|   );
 | |
| 
 | |
|   const study = document.querySelector(`.card[id="${cachedAddon.addon_id}"`);
 | |
|   const joinBtn = study.querySelector(".join-button");
 | |
| 
 | |
|   if (addon) {
 | |
|     joinBtn.disabled = true;
 | |
|     await addon.uninstall();
 | |
|     await sendDeletionPing(studyAddonId);
 | |
| 
 | |
|     document.l10n.setAttributes(joinBtn, "ion-join-study");
 | |
|     joinBtn.disabled = false;
 | |
| 
 | |
|     // Record that the user abandoned this study, since it may not be re-join-able.
 | |
|     if (completedStudies) {
 | |
|       const studies = JSON.parse(completedStudies);
 | |
|       studies[studyAddonId] = STUDY_LEAVE_REASONS.USER_ABANDONED;
 | |
|       Services.prefs.setStringPref(
 | |
|         PREF_ION_COMPLETED_STUDIES,
 | |
|         JSON.stringify(studies)
 | |
|       );
 | |
|     }
 | |
|   } else {
 | |
|     // Check if this study is re-join-able before enrollment.
 | |
|     const studies = JSON.parse(completedStudies);
 | |
|     if (studyAddonId in studies) {
 | |
|       if (
 | |
|         "canRejoin" in cachedAddons[studyAddonId] &&
 | |
|         cachedAddons[studyAddonId].canRejoin === false
 | |
|       ) {
 | |
|         console.error(
 | |
|           `Cannot rejoin ended study ${studyAddonId}, reason: ${studies[studyAddonId]}`
 | |
|         );
 | |
|         return;
 | |
|       }
 | |
|     }
 | |
|     joinBtn.disabled = true;
 | |
|     await install.install();
 | |
|     document.l10n.setAttributes(joinBtn, "ion-leave-study");
 | |
|     joinBtn.disabled = false;
 | |
| 
 | |
|     // Send an enrollment ping for this study. Note that this could be sent again
 | |
|     // if we are re-joining.
 | |
|     await sendEnrollmentPing(studyAddonId);
 | |
|   }
 | |
| 
 | |
|   await updateStudy(cachedAddon.addon_id);
 | |
| }
 | |
| 
 | |
| async function showAvailableStudies(cachedAddons) {
 | |
|   const ionId = Services.prefs.getStringPref(PREF_ION_ID, null);
 | |
|   const defaultAddons = cachedAddons.filter(a => a.isDefault);
 | |
|   if (ionId) {
 | |
|     for (const defaultAddon of defaultAddons) {
 | |
|       let addon;
 | |
|       let install;
 | |
|       if (Cu.isInAutomation) {
 | |
|         install = {
 | |
|           install: async () => {
 | |
|             if (
 | |
|               defaultAddon.addon_id == "ion-v2-bad-default-example@mozilla.org"
 | |
|             ) {
 | |
|               throw new Error("Bad test default add-on");
 | |
|             }
 | |
|           },
 | |
|         };
 | |
|       } else {
 | |
|         addon = await AddonManager.getAddonByID(defaultAddon.addon_id);
 | |
|         install = await AddonManager.getInstallForURL(
 | |
|           defaultAddon.sourceURI.spec
 | |
|         );
 | |
|       }
 | |
| 
 | |
|       if (!addon) {
 | |
|         // Any default add-ons are required, try to reinstall.
 | |
|         await install.install();
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const studyAddons = cachedAddons.filter(a => !a.isDefault);
 | |
|   for (const cachedAddon of studyAddons) {
 | |
|     if (!cachedAddon) {
 | |
|       console.error(
 | |
|         `about:ion - Study addon ID not found in cache: ${studyAddonId}`
 | |
|       );
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const studyAddonId = cachedAddon.addon_id;
 | |
| 
 | |
|     const study = document.createElement("div");
 | |
|     study.setAttribute("id", studyAddonId);
 | |
|     study.setAttribute("class", "card card-no-hover");
 | |
| 
 | |
|     if (cachedAddon.icons && 32 in cachedAddon.icons) {
 | |
|       const iconName = document.createElement("img");
 | |
|       iconName.setAttribute("class", "card-icon");
 | |
|       iconName.setAttribute("src", cachedAddon.icons[32]);
 | |
|       study.appendChild(iconName);
 | |
|     }
 | |
| 
 | |
|     const studyBody = document.createElement("div");
 | |
|     studyBody.classList.add("card-body");
 | |
|     study.appendChild(studyBody);
 | |
| 
 | |
|     const studyName = document.createElement("h3");
 | |
|     studyName.setAttribute("class", "card-name");
 | |
|     studyName.textContent = cachedAddon.name;
 | |
|     studyBody.appendChild(studyName);
 | |
| 
 | |
|     const studyAuthor = document.createElement("span");
 | |
|     studyAuthor.setAttribute("class", "card-author");
 | |
|     studyAuthor.textContent = cachedAddon.authors.name;
 | |
|     studyBody.appendChild(studyAuthor);
 | |
| 
 | |
|     const actions = document.createElement("div");
 | |
|     actions.classList.add("card-actions");
 | |
|     study.appendChild(actions);
 | |
| 
 | |
|     const joinBtn = document.createElement("button");
 | |
|     joinBtn.setAttribute("id", `${studyAddonId}-join-button`);
 | |
|     joinBtn.classList.add("primary");
 | |
|     joinBtn.classList.add("join-button");
 | |
|     document.l10n.setAttributes(joinBtn, "ion-join-study");
 | |
| 
 | |
|     joinBtn.addEventListener("click", async () => {
 | |
|       let addon;
 | |
|       if (Cu.isInAutomation) {
 | |
|         const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]");
 | |
|         for (const testAddon of JSON.parse(testAddons)) {
 | |
|           if (testAddon == studyAddonId) {
 | |
|             addon = {};
 | |
|             addon.uninstall = () => {
 | |
|               Services.prefs.setStringPref(PREF_TEST_ADDONS, "[]");
 | |
|             };
 | |
|           }
 | |
|         }
 | |
|       } else {
 | |
|         addon = await AddonManager.getAddonByID(studyAddonId);
 | |
|       }
 | |
|       let joinOrLeave = addon ? "leave" : "join";
 | |
|       let dialog = document.getElementById(
 | |
|         `${joinOrLeave}-study-consent-dialog`
 | |
|       );
 | |
|       dialog.setAttribute("addon-id", cachedAddon.addon_id);
 | |
|       const consentText = dialog.querySelector(
 | |
|         `[id=${joinOrLeave}-study-consent]`
 | |
|       );
 | |
| 
 | |
|       // Clears out any existing children with a single #text node
 | |
|       consentText.textContent = "";
 | |
| 
 | |
|       const contentFragment = sanitizeHtml(
 | |
|         cachedAddon[`${joinOrLeave}StudyConsent`]
 | |
|       );
 | |
|       consentText.appendChild(contentFragment);
 | |
| 
 | |
|       dialog.showModal();
 | |
|       dialog.scrollTop = 0;
 | |
| 
 | |
|       const openEvent = new Event("open");
 | |
|       dialog.dispatchEvent(openEvent);
 | |
|     });
 | |
|     actions.appendChild(joinBtn);
 | |
| 
 | |
|     const studyDesc = document.createElement("div");
 | |
|     studyDesc.setAttribute("class", "card-description");
 | |
| 
 | |
|     const contentFragment = sanitizeHtml(cachedAddon.description);
 | |
|     studyDesc.appendChild(contentFragment);
 | |
| 
 | |
|     study.appendChild(studyDesc);
 | |
| 
 | |
|     const studyDataCollected = document.createElement("div");
 | |
|     studyDataCollected.setAttribute("class", "card-data-collected");
 | |
|     study.appendChild(studyDataCollected);
 | |
| 
 | |
|     const dataCollectionDetailsHeader = document.createElement("p");
 | |
|     dataCollectionDetailsHeader.textContent = "This study will collect:";
 | |
|     studyDataCollected.appendChild(dataCollectionDetailsHeader);
 | |
| 
 | |
|     const dataCollectionDetails = document.createElement("ul");
 | |
|     for (const dataCollectionDetail of cachedAddon.dataCollectionDetails) {
 | |
|       const detailsBullet = document.createElement("li");
 | |
|       detailsBullet.textContent = dataCollectionDetail;
 | |
|       dataCollectionDetails.append(detailsBullet);
 | |
|     }
 | |
|     studyDataCollected.appendChild(dataCollectionDetails);
 | |
| 
 | |
|     const availableStudies = document.getElementById("available-studies");
 | |
|     availableStudies.appendChild(study);
 | |
| 
 | |
|     await updateStudy(studyAddonId);
 | |
|   }
 | |
| 
 | |
|   const availableStudies = document.getElementById("header-available-studies");
 | |
|   document.l10n.setAttributes(availableStudies, "ion-current-studies");
 | |
| }
 | |
| 
 | |
| async function updateStudy(studyAddonId) {
 | |
|   let addon;
 | |
|   if (Cu.isInAutomation) {
 | |
|     const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]");
 | |
|     for (const testAddon of JSON.parse(testAddons)) {
 | |
|       if (testAddon == studyAddonId) {
 | |
|         addon = {
 | |
|           uninstall() {},
 | |
|         };
 | |
|       }
 | |
|     }
 | |
|   } else {
 | |
|     addon = await AddonManager.getAddonByID(studyAddonId);
 | |
|   }
 | |
| 
 | |
|   const study = document.querySelector(`.card[id="${studyAddonId}"`);
 | |
| 
 | |
|   const joinBtn = study.querySelector(".join-button");
 | |
| 
 | |
|   const ionId = Services.prefs.getStringPref(PREF_ION_ID, null);
 | |
| 
 | |
|   const completedStudies = Services.prefs.getStringPref(
 | |
|     PREF_ION_COMPLETED_STUDIES,
 | |
|     "{}"
 | |
|   );
 | |
| 
 | |
|   const studies = JSON.parse(completedStudies);
 | |
|   if (studyAddonId in studies) {
 | |
|     study.style.opacity = 0.5;
 | |
|     joinBtn.disabled = true;
 | |
|     document.l10n.setAttributes(joinBtn, "ion-ended-study");
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   if (ionId) {
 | |
|     study.style.opacity = 1;
 | |
|     joinBtn.disabled = false;
 | |
| 
 | |
|     if (addon) {
 | |
|       document.l10n.setAttributes(joinBtn, "ion-leave-study");
 | |
|     } else {
 | |
|       document.l10n.setAttributes(joinBtn, "ion-join-study");
 | |
|     }
 | |
|   } else {
 | |
|     document.l10n.setAttributes(joinBtn, "ion-study-prompt");
 | |
|     study.style.opacity = 0.5;
 | |
|     joinBtn.disabled = true;
 | |
|   }
 | |
| }
 | |
| 
 | |
| // equivalent to what we use for Telemetry IDs
 | |
| // https://searchfox.org/mozilla-central/rev/9193635dca8cfdcb68f114306194ffc860456044/toolkit/components/telemetry/app/TelemetryUtils.jsm#222
 | |
| function generateUUID() {
 | |
|   let str = Services.uuid.generateUUID().toString();
 | |
|   return str.substring(1, str.length - 1);
 | |
| }
 | |
| 
 | |
| async function setup(cachedAddons) {
 | |
|   document
 | |
|     .getElementById("enrollment-button")
 | |
|     .addEventListener("click", async () => {
 | |
|       const ionId = Services.prefs.getStringPref(PREF_ION_ID, null);
 | |
| 
 | |
|       if (ionId) {
 | |
|         let dialog = document.getElementById("leave-ion-consent-dialog");
 | |
|         dialog.showModal();
 | |
|         dialog.scrollTop = 0;
 | |
|       } else {
 | |
|         let dialog = document.getElementById("join-ion-consent-dialog");
 | |
|         dialog.showModal();
 | |
|         dialog.scrollTop = 0;
 | |
|       }
 | |
|     });
 | |
| 
 | |
|   document
 | |
|     .getElementById("join-ion-cancel-dialog-button")
 | |
|     .addEventListener("click", () =>
 | |
|       document.getElementById("join-ion-consent-dialog").close()
 | |
|     );
 | |
|   document
 | |
|     .getElementById("leave-ion-cancel-dialog-button")
 | |
|     .addEventListener("click", () =>
 | |
|       document.getElementById("leave-ion-consent-dialog").close()
 | |
|     );
 | |
|   document
 | |
|     .getElementById("join-study-cancel-dialog-button")
 | |
|     .addEventListener("click", () =>
 | |
|       document.getElementById("join-study-consent-dialog").close()
 | |
|     );
 | |
|   document
 | |
|     .getElementById("leave-study-cancel-dialog-button")
 | |
|     .addEventListener("click", () =>
 | |
|       document.getElementById("leave-study-consent-dialog").close()
 | |
|     );
 | |
| 
 | |
|   document
 | |
|     .getElementById("join-ion-accept-dialog-button")
 | |
|     .addEventListener("click", async event => {
 | |
|       const ionId = Services.prefs.getStringPref(PREF_ION_ID, null);
 | |
| 
 | |
|       if (!ionId) {
 | |
|         let uuid = generateUUID();
 | |
|         Services.prefs.setStringPref(PREF_ION_ID, uuid);
 | |
|         for (const cachedAddon of cachedAddons) {
 | |
|           if (cachedAddon.isDefault) {
 | |
|             let install;
 | |
|             if (Cu.isInAutomation) {
 | |
|               install = {
 | |
|                 install: async () => {
 | |
|                   if (
 | |
|                     cachedAddon.addon_id ==
 | |
|                     "ion-v2-bad-default-example@mozilla.org"
 | |
|                   ) {
 | |
|                     throw new Error("Bad test default add-on");
 | |
|                   }
 | |
|                 },
 | |
|               };
 | |
|             } else {
 | |
|               install = await AddonManager.getInstallForURL(
 | |
|                 cachedAddon.sourceURI.spec
 | |
|               );
 | |
|             }
 | |
| 
 | |
|             try {
 | |
|               await install.install();
 | |
|             } catch (ex) {
 | |
|               // No need to throw here, we'll try again before letting users enroll in any studies.
 | |
|               console.error(
 | |
|                 `Could not install default add-on ${cachedAddon.addon_id}`
 | |
|               );
 | |
|               const availableStudies =
 | |
|                 document.getElementById("available-studies");
 | |
|               document.l10n.setAttributes(
 | |
|                 availableStudies,
 | |
|                 "ion-no-current-studies"
 | |
|               );
 | |
|             }
 | |
|           }
 | |
|           const study = document.getElementById(cachedAddon.addon_id);
 | |
|           if (study) {
 | |
|             await updateStudy(cachedAddon.addon_id);
 | |
|           }
 | |
|         }
 | |
|         document.querySelector("dialog").close();
 | |
|       }
 | |
|       // A this point we should have a valid ion id, so we should be able to send
 | |
|       // the enrollment ping.
 | |
|       await sendEnrollmentPing();
 | |
| 
 | |
|       showEnrollmentStatus();
 | |
|     });
 | |
| 
 | |
|   document
 | |
|     .getElementById("leave-ion-accept-dialog-button")
 | |
|     .addEventListener("click", async event => {
 | |
|       const completedStudies = Services.prefs.getStringPref(
 | |
|         PREF_ION_COMPLETED_STUDIES,
 | |
|         "{}"
 | |
|       );
 | |
|       const studies = JSON.parse(completedStudies);
 | |
| 
 | |
|       // Send a deletion ping for all completed studies the user has been a part of.
 | |
|       for (const studyAddonId in studies) {
 | |
|         await sendDeletionPing(studyAddonId);
 | |
|       }
 | |
| 
 | |
|       Services.prefs.clearUserPref(PREF_ION_COMPLETED_STUDIES);
 | |
| 
 | |
|       for (const cachedAddon of cachedAddons) {
 | |
|         // Record any studies that have been marked as concluded on the server, in case they re-enroll.
 | |
|         if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) {
 | |
|           studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED;
 | |
| 
 | |
|           Services.prefs.setStringPref(
 | |
|             PREF_ION_COMPLETED_STUDIES,
 | |
|             JSON.stringify(studies)
 | |
|           );
 | |
|         }
 | |
| 
 | |
|         let addon;
 | |
|         if (Cu.isInAutomation) {
 | |
|           addon = {};
 | |
|           addon.id = cachedAddon.addon_id;
 | |
|           addon.uninstall = () => {
 | |
|             let testAddons = Services.prefs.getStringPref(
 | |
|               PREF_TEST_ADDONS,
 | |
|               "[]"
 | |
|             );
 | |
|             testAddons = JSON.parse(testAddons);
 | |
| 
 | |
|             Services.prefs.setStringPref(
 | |
|               PREF_TEST_ADDONS,
 | |
|               JSON.stringify(
 | |
|                 testAddons.filter(a => a.id != cachedAddon.addon_id)
 | |
|               )
 | |
|             );
 | |
|           };
 | |
|         } else {
 | |
|           addon = await AddonManager.getAddonByID(cachedAddon.addon_id);
 | |
|         }
 | |
|         if (addon) {
 | |
|           await sendDeletionPing(addon.id);
 | |
|           await addon.uninstall();
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       Services.prefs.clearUserPref(PREF_ION_ID);
 | |
|       for (const cachedAddon of cachedAddons) {
 | |
|         const study = document.getElementById(cachedAddon.addon_id);
 | |
|         if (study) {
 | |
|           await updateStudy(cachedAddon.addon_id);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       document.getElementById("leave-ion-consent-dialog").close();
 | |
|       showEnrollmentStatus();
 | |
|     });
 | |
| 
 | |
|   document
 | |
|     .getElementById("join-study-accept-dialog-button")
 | |
|     .addEventListener("click", async event => {
 | |
|       const dialog = document.getElementById("join-study-consent-dialog");
 | |
|       const studyAddonId = dialog.getAttribute("addon-id");
 | |
|       toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close());
 | |
|     });
 | |
| 
 | |
|   document
 | |
|     .getElementById("leave-study-accept-dialog-button")
 | |
|     .addEventListener("click", async event => {
 | |
|       const dialog = document.getElementById("leave-study-consent-dialog");
 | |
|       const studyAddonId = dialog.getAttribute("addon-id");
 | |
|       await toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close());
 | |
|     });
 | |
| 
 | |
|   const onAddonEvent = async addon => {
 | |
|     for (const cachedAddon of cachedAddons) {
 | |
|       if (cachedAddon.addon_id == addon.id) {
 | |
|         await updateStudy(addon.id);
 | |
|       }
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const addonsListener = {
 | |
|     onEnabled: onAddonEvent,
 | |
|     onDisabled: onAddonEvent,
 | |
|     onInstalled: onAddonEvent,
 | |
|     onUninstalled: onAddonEvent,
 | |
|   };
 | |
|   AddonManager.addAddonListener(addonsListener);
 | |
| 
 | |
|   window.addEventListener("unload", event => {
 | |
|     AddonManager.removeAddonListener(addonsListener);
 | |
|   });
 | |
| }
 | |
| 
 | |
| function removeBadge() {
 | |
|   Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, false);
 | |
| 
 | |
|   for (let win of Services.wm.getEnumerator("navigator:browser")) {
 | |
|     const badge = win.document
 | |
|       .getElementById("ion-button")
 | |
|       .querySelector(".toolbarbutton-badge");
 | |
|     badge.classList.remove("feature-callout");
 | |
|   }
 | |
| }
 | |
| 
 | |
| // Updates Ion HTML page contents from RemoteSettings.
 | |
| function updateContents(contents) {
 | |
|   for (const section of [
 | |
|     "title",
 | |
|     "summary",
 | |
|     "details",
 | |
|     "data",
 | |
|     "joinIonConsent",
 | |
|     "leaveIonConsent",
 | |
|   ]) {
 | |
|     if (contents && section in contents) {
 | |
|       // Generate a corresponding dom-id style ID for a camel-case domId style JS attribute.
 | |
|       // Dynamically set the tag type based on which section is getting updated.
 | |
|       const domId = section
 | |
|         .split(/(?=[A-Z])/)
 | |
|         .join("-")
 | |
|         .toLowerCase();
 | |
|       // Clears out any existing children with a single #text node.
 | |
|       document.getElementById(domId).textContent = "";
 | |
| 
 | |
|       const contentFragment = sanitizeHtml(contents[section]);
 | |
|       document.getElementById(domId).appendChild(contentFragment);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| document.addEventListener("DOMContentLoaded", async domEvent => {
 | |
|   toggleContentBasedOnLocale();
 | |
| 
 | |
|   showEnrollmentStatus();
 | |
| 
 | |
|   document.addEventListener("focus", removeBadge);
 | |
|   removeBadge();
 | |
| 
 | |
|   const privacyPolicyLinks = document.querySelectorAll(
 | |
|     ".privacy-policy,.privacy-notice"
 | |
|   );
 | |
|   for (const privacyPolicyLink of privacyPolicyLinks) {
 | |
|     const privacyPolicyFormattedLink = Services.urlFormatter.formatURL(
 | |
|       privacyPolicyLink.href
 | |
|     );
 | |
|     privacyPolicyLink.href = privacyPolicyFormattedLink;
 | |
|   }
 | |
| 
 | |
|   let cachedContent;
 | |
|   let cachedAddons;
 | |
|   if (Cu.isInAutomation) {
 | |
|     let testCachedAddons = Services.prefs.getStringPref(
 | |
|       PREF_TEST_CACHED_ADDONS,
 | |
|       null
 | |
|     );
 | |
|     if (testCachedAddons) {
 | |
|       cachedAddons = JSON.parse(testCachedAddons);
 | |
|     }
 | |
| 
 | |
|     let testCachedContent = Services.prefs.getStringPref(
 | |
|       PREF_TEST_CACHED_CONTENT,
 | |
|       null
 | |
|     );
 | |
|     if (testCachedContent) {
 | |
|       cachedContent = JSON.parse(testCachedContent);
 | |
|     }
 | |
|   } else {
 | |
|     cachedContent = await RemoteSettings(CONTENT_COLLECTION_KEY).get();
 | |
|     cachedAddons = await RemoteSettings(STUDY_ADDON_COLLECTION_KEY).get();
 | |
|   }
 | |
| 
 | |
|   // Replace existing contents immediately on page load.
 | |
|   for (const contents of cachedContent) {
 | |
|     updateContents(contents);
 | |
|   }
 | |
| 
 | |
|   for (const cachedAddon of cachedAddons) {
 | |
|     // Record any studies that have been marked as concluded on the server.
 | |
|     if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) {
 | |
|       const completedStudies = Services.prefs.getStringPref(
 | |
|         PREF_ION_COMPLETED_STUDIES,
 | |
|         "{}"
 | |
|       );
 | |
|       const studies = JSON.parse(completedStudies);
 | |
|       studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED;
 | |
| 
 | |
|       Services.prefs.setStringPref(
 | |
|         PREF_ION_COMPLETED_STUDIES,
 | |
|         JSON.stringify(studies)
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   await setup(cachedAddons);
 | |
| 
 | |
|   try {
 | |
|     await showAvailableStudies(cachedAddons);
 | |
|   } catch (ex) {
 | |
|     // No need to throw here, we'll try again before letting users enroll in any studies.
 | |
|     console.error(`Could not show available studies`, ex);
 | |
|   }
 | |
| });
 | |
| 
 | |
| async function sendDeletionPing(studyAddonId) {
 | |
|   const type = "pioneer-study";
 | |
| 
 | |
|   const options = {
 | |
|     studyName: studyAddonId,
 | |
|     addPioneerId: true,
 | |
|     useEncryption: true,
 | |
|     // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry
 | |
|     // pipeline requires that pings are shaped this way so they are routed to the correct environment.
 | |
|     //
 | |
|     // At the moment, the public key used here isn't important but we do need to use *something*.
 | |
|     encryptionKeyId: "discarded",
 | |
|     publicKey: {
 | |
|       crv: "P-256",
 | |
|       kty: "EC",
 | |
|       x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk",
 | |
|       y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE",
 | |
|     },
 | |
|     schemaName: "deletion-request",
 | |
|     schemaVersion: 1,
 | |
|     // The schema namespace needs to be the study addon id, as we
 | |
|     // want to route the ping to the specific study table.
 | |
|     schemaNamespace: studyAddonId,
 | |
|   };
 | |
| 
 | |
|   const payload = {
 | |
|     encryptedData: "",
 | |
|   };
 | |
| 
 | |
|   await TelemetryController.submitExternalPing(type, payload, options);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sends a Pioneer enrollment ping.
 | |
|  *
 | |
|  * The `creationDate` provided by the telemetry APIs will be used as the timestamp for
 | |
|  * considering the user enrolled in pioneer and/or the study.
 | |
|  *
 | |
|  * @param {string} [studyAddonId] - optional study id. It's sent in the ping, if present,
 | |
|  * to signal that user enroled in the study.
 | |
|  */
 | |
| async function sendEnrollmentPing(studyAddonId) {
 | |
|   let options = {
 | |
|     studyName: "pioneer-meta",
 | |
|     addPioneerId: true,
 | |
|     useEncryption: true,
 | |
|     // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry
 | |
|     // pipeline requires that pings are shaped this way so they are routed to the correct environment.
 | |
|     //
 | |
|     // At the moment, the public key used here isn't important but we do need to use *something*.
 | |
|     encryptionKeyId: "discarded",
 | |
|     publicKey: {
 | |
|       crv: "P-256",
 | |
|       kty: "EC",
 | |
|       x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk",
 | |
|       y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE",
 | |
|     },
 | |
|     schemaName: "pioneer-enrollment",
 | |
|     schemaVersion: 1,
 | |
|     // Note that the schema namespace directly informs how data is segregated after ingestion.
 | |
|     // If this is an enrollment ping for the pioneer program (in contrast to the enrollment to
 | |
|     // a specific study), use a meta namespace.
 | |
|     schemaNamespace: "pioneer-meta",
 | |
|   };
 | |
| 
 | |
|   // If we were provided with a study id, then this is an enrollment to a study.
 | |
|   // Send the id alongside with the data and change the schema namespace to simplify
 | |
|   // the work on the ingestion pipeline.
 | |
|   if (typeof studyAddonId != "undefined") {
 | |
|     options.studyName = studyAddonId;
 | |
|     // The schema namespace needs to be the study addon id, as we
 | |
|     // want to route the ping to the specific study table.
 | |
|     options.schemaNamespace = studyAddonId;
 | |
|   }
 | |
| 
 | |
|   await TelemetryController.submitExternalPing("pioneer-study", {}, options);
 | |
| }
 | 
