forked from mirrors/gecko-dev
		
	 80327d3561
			
		
	
	
		80327d3561
		
	
	
	
	
		
			
			Differential Revision: https://phabricator.services.mozilla.com/D3729 --HG-- extra : rebase_source : e187b8e9a6b6db7ebc762adda5e489b25c7a7e43 extra : histedit_source : 868cb99d09954a51d6be321fcb516475ef70eb33
		
			
				
	
	
		
			530 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			530 lines
		
	
	
	
		
			16 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/. */
 | |
| 
 | |
| /* eslint semi: error */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| var EXPORTED_SYMBOLS = ["SavantShieldStudy"];
 | |
| 
 | |
| ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 | |
| ChromeUtils.import("resource://gre/modules/Services.jsm");
 | |
| 
 | |
| XPCOMUtils.defineLazyModuleGetters(this, {
 | |
|   AddonManager: "resource://gre/modules/AddonManager.jsm",
 | |
|   PlacesUtils: "resource://gre/modules/PlacesUtils.jsm"
 | |
| });
 | |
| 
 | |
| // See LOG_LEVELS in Console.jsm. Examples: "all", "info", "warn", & "error".
 | |
| const PREF_LOG_LEVEL = "shield.savant.loglevel";
 | |
| 
 | |
| // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
 | |
| XPCOMUtils.defineLazyGetter(this, "log", () => {
 | |
|   let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
 | |
|   let consoleOptions = {
 | |
|     maxLogLevelPref: PREF_LOG_LEVEL,
 | |
|     prefix: "SavantShieldStudy",
 | |
|   };
 | |
|   return new ConsoleAPI(consoleOptions);
 | |
| });
 | |
| 
 | |
| class SavantShieldStudyClass {
 | |
|   constructor() {
 | |
|     this.STUDY_PREF = "shield.savant.enabled";
 | |
|     this.STUDY_TELEMETRY_CATEGORY = "savant";
 | |
|     this.ALWAYS_PRIVATE_BROWSING_PREF = "browser.privatebrowsing.autostart";
 | |
|     this.STUDY_DURATION_OVERRIDE_PREF = "shield.savant.duration_override";
 | |
|     this.STUDY_EXPIRATION_DATE_PREF = "shield.savant.expiration_date";
 | |
|     // ms = 'x' weeks * 7 days/week * 24 hours/day * 60 minutes/hour
 | |
|     // * 60 seconds/minute * 1000 milliseconds/second
 | |
|     this.DEFAULT_STUDY_DURATION_MS = 4 * 7 * 24 * 60 * 60 * 1000;
 | |
|     // If on startupStudy(), user is ineligible or study has expired,
 | |
|     // no probe listeners from this module have been added yet
 | |
|     this.shouldRemoveListeners = true;
 | |
|   }
 | |
| 
 | |
|   init() {
 | |
|     this.telemetryEvents = new TelemetryEvents(this.STUDY_TELEMETRY_CATEGORY);
 | |
|     this.addonListener = new AddonListener(this.STUDY_TELEMETRY_CATEGORY);
 | |
|     this.bookmarkObserver = new BookmarkObserver(this.STUDY_TELEMETRY_CATEGORY);
 | |
|     this.menuListener = new MenuListener(this.STUDY_TELEMETRY_CATEGORY);
 | |
| 
 | |
|     // check the pref in case Normandy flipped it on before we could add the pref listener
 | |
|     this.shouldCollect = Services.prefs.getBoolPref(this.STUDY_PREF);
 | |
|     if (this.shouldCollect) {
 | |
|       this.startupStudy();
 | |
|     }
 | |
|     Services.prefs.addObserver(this.STUDY_PREF, this);
 | |
|   }
 | |
| 
 | |
|   observe(subject, topic, data) {
 | |
|     if (topic === "nsPref:changed" && data === this.STUDY_PREF) {
 | |
|       // toggle state of the pref
 | |
|       this.shouldCollect = !this.shouldCollect;
 | |
|       if (this.shouldCollect) {
 | |
|         this.startupStudy();
 | |
|       } else {
 | |
|         // The pref has been turned off
 | |
|         this.endStudy("study_disable");
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   startupStudy() {
 | |
|     // enable before any possible calls to endStudy, since it sends an 'end_study' event
 | |
|     this.telemetryEvents.enableCollection();
 | |
| 
 | |
|     if (!this.isEligible()) {
 | |
|       this.shouldRemoveListeners = false;
 | |
|       this.endStudy("ineligible");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.initStudyDuration();
 | |
| 
 | |
|     if (this.isStudyExpired()) {
 | |
|       log.debug("Study expired in between this and the previous session.");
 | |
|       this.shouldRemoveListeners = false;
 | |
|       this.endStudy("expired");
 | |
|     }
 | |
| 
 | |
|     this.addonListener.init();
 | |
|     this.bookmarkObserver.init();
 | |
|     this.menuListener.init();
 | |
|   }
 | |
| 
 | |
|   isEligible() {
 | |
|     const isAlwaysPrivateBrowsing = Services.prefs.getBoolPref(this.ALWAYS_PRIVATE_BROWSING_PREF);
 | |
|     if (isAlwaysPrivateBrowsing) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   initStudyDuration() {
 | |
|     if (Services.prefs.getStringPref(this.STUDY_EXPIRATION_DATE_PREF, "")) {
 | |
|       return;
 | |
|     }
 | |
|     Services.prefs.setStringPref(
 | |
|       this.STUDY_EXPIRATION_DATE_PREF,
 | |
|       this.getExpirationDateString()
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   getDurationFromPref() {
 | |
|     return Services.prefs.getIntPref(this.STUDY_DURATION_OVERRIDE_PREF, 0);
 | |
|   }
 | |
| 
 | |
|   getExpirationDateString() {
 | |
|     const now = Date.now();
 | |
|     const studyDurationInMs =
 | |
|     this.getDurationFromPref()
 | |
|       || this.DEFAULT_STUDY_DURATION_MS;
 | |
|     const expirationDateInt = now + studyDurationInMs;
 | |
|     return new Date(expirationDateInt).toISOString();
 | |
|   }
 | |
| 
 | |
|   isStudyExpired() {
 | |
|     const expirationDateInt =
 | |
|       Date.parse(Services.prefs.getStringPref(
 | |
|         this.STUDY_EXPIRATION_DATE_PREF,
 | |
|         this.getExpirationDateString()
 | |
|       ));
 | |
| 
 | |
|     if (isNaN(expirationDateInt)) {
 | |
|       log.error(
 | |
|         `The value for the preference ${this.STUDY_EXPIRATION_DATE_PREF} is invalid.`
 | |
|       );
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     if (Date.now() > expirationDateInt) {
 | |
|       return true;
 | |
|     }
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   endStudy(reason) {
 | |
|     log.debug(`Ending the study due to reason: ${ reason }`);
 | |
|     const isStudyEnding = true;
 | |
|     Services.telemetry.recordEvent(this.STUDY_TELEMETRY_CATEGORY, "end_study", reason, null,
 | |
|                                   { subcategory: "shield" });
 | |
|     this.telemetryEvents.disableCollection();
 | |
|     this.uninit(isStudyEnding);
 | |
|     // These prefs needs to persist between restarts, so only reset on endStudy
 | |
|     Services.prefs.clearUserPref(this.STUDY_PREF);
 | |
|     Services.prefs.clearUserPref(this.STUDY_EXPIRATION_DATE_PREF);
 | |
|   }
 | |
| 
 | |
|   // Called on every Firefox shutdown and endStudy
 | |
|   uninit(isStudyEnding = false) {
 | |
|     // if just shutting down, check for expiration, so the endStudy event can
 | |
|     // be sent along with this session's main ping.
 | |
|     if (!isStudyEnding && this.isStudyExpired()) {
 | |
|       log.debug("Study expired during this session.");
 | |
|       this.endStudy("expired");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.addonListener.uninit();
 | |
|     this.bookmarkObserver.uninit();
 | |
|     this.menuListener.uninit();
 | |
| 
 | |
|     Services.prefs.removeObserver(this.ALWAYS_PRIVATE_BROWSING_PREF, this);
 | |
|     Services.prefs.removeObserver(this.STUDY_PREF, this);
 | |
|     Services.prefs.removeObserver(this.STUDY_DURATION_OVERRIDE_PREF, this);
 | |
|     Services.prefs.clearUserPref(PREF_LOG_LEVEL);
 | |
|     Services.prefs.clearUserPref(this.STUDY_DURATION_OVERRIDE_PREF);
 | |
|   }
 | |
| }
 | |
| 
 | |
| // References:
 | |
| // - https://hg.mozilla.org/mozilla-central/file/tip/toolkit/components/normandy/lib/TelemetryEvents.jsm
 | |
| // - https://hg.mozilla.org/mozilla-central/file/tip/toolkit/components/normandy/lib/PreferenceExperiments.jsm#l357
 | |
| class TelemetryEvents {
 | |
|   constructor(studyCategory) {
 | |
|     this.STUDY_TELEMETRY_CATEGORY = studyCategory;
 | |
|   }
 | |
| 
 | |
|   enableCollection() {
 | |
|     log.debug("Study has been enabled; turning ON data collection.");
 | |
|     Services.telemetry.setEventRecordingEnabled(this.STUDY_TELEMETRY_CATEGORY, true);
 | |
|   }
 | |
| 
 | |
|   disableCollection() {
 | |
|     log.debug("Study has been disabled; turning OFF data collection.");
 | |
|     Services.telemetry.setEventRecordingEnabled(this.STUDY_TELEMETRY_CATEGORY, false);
 | |
|   }
 | |
| }
 | |
| 
 | |
| class AddonListener {
 | |
|   constructor(studyCategory) {
 | |
|     this.STUDY_TELEMETRY_CATEGORY = studyCategory;
 | |
|     this.METHOD = "addon";
 | |
|     this.EXTRA_SUBCATEGORY = "customize";
 | |
|   }
 | |
| 
 | |
|   init() {
 | |
|     this.listener = {
 | |
|       onInstalling: (addon, needsRestart) => {
 | |
|         const addon_id = addon.id;
 | |
|         this.recordEvent("install_start", addon_id);
 | |
|       },
 | |
| 
 | |
|       onInstalled: (addon) => {
 | |
|         const addon_id = addon.id;
 | |
|         this.recordEvent("install_finish", addon_id);
 | |
|       },
 | |
| 
 | |
|       onEnabled: (addon) => {
 | |
|         const addon_id = addon.id;
 | |
|         this.recordEvent("enable", addon_id);
 | |
|       },
 | |
| 
 | |
|       onDisabled: (addon) => {
 | |
|         const addon_id = addon.id;
 | |
|         this.recordEvent("disable", addon_id);
 | |
|       },
 | |
| 
 | |
|       onUninstalling: (addon, needsRestart) => {
 | |
|         const addon_id = addon.id;
 | |
|         this.recordEvent("remove_start", addon_id);
 | |
|       },
 | |
| 
 | |
|       onUninstalled: (addon) => {
 | |
|         const addon_id = addon.id;
 | |
|         this.recordEvent("remove_finish", addon_id);
 | |
|       }
 | |
|     };
 | |
|     this.addListeners();
 | |
|   }
 | |
| 
 | |
|   addListeners() {
 | |
|     AddonManager.addAddonListener(this.listener);
 | |
|   }
 | |
| 
 | |
|   recordEvent(event, addon_id) {
 | |
|     log.debug(`Addon ID: ${addon_id}; event: ${ event }`);
 | |
|     Services.telemetry.recordEvent(this.STUDY_TELEMETRY_CATEGORY,
 | |
|                                   this.METHOD,
 | |
|                                   event,
 | |
|                                   addon_id,
 | |
|                                   { subcategory: this.EXTRA_SUBCATEGORY });
 | |
|   }
 | |
| 
 | |
|   removeListeners() {
 | |
|     AddonManager.removeAddonListener(this.listener);
 | |
|   }
 | |
| 
 | |
|   uninit() {
 | |
|     if (SavantShieldStudy.shouldRemoveListeners) {
 | |
|       this.removeListeners();
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| class BookmarkObserver {
 | |
|   constructor(studyCategory) {
 | |
|     this.STUDY_TELEMETRY_CATEGORY = studyCategory;
 | |
|     // there are two probes: bookmark and follow_bookmark
 | |
|     this.METHOD_1 = "bookmark";
 | |
|     this.EXTRA_SUBCATEGORY_1 = "feature";
 | |
|     this.METHOD_2 = "follow_bookmark";
 | |
|     this.EXTRA_SUBCATEGORY_2 = "navigation";
 | |
|     this.TYPE_BOOKMARK = Ci.nsINavBookmarksService.TYPE_BOOKMARK;
 | |
|     // Ignore "fake" bookmarks created for bookmark tags
 | |
|     this.skipTags = true;
 | |
|   }
 | |
| 
 | |
|   init() {
 | |
|     this.addObservers();
 | |
|   }
 | |
| 
 | |
|   addObservers() {
 | |
|     PlacesUtils.bookmarks.addObserver(this);
 | |
|   }
 | |
| 
 | |
|   onItemAdded(itemID, parentID, index, itemType, uri, title, dateAdded, guid, parentGUID, source) {
 | |
|     this.handleItemAddRemove(itemType, uri, source, "save");
 | |
|   }
 | |
| 
 | |
|   onItemRemoved(itemID, parentID, index, itemType, uri, guid, parentGUID, source) {
 | |
|     this.handleItemAddRemove(itemType, uri, source, "remove");
 | |
|   }
 | |
| 
 | |
|   handleItemAddRemove(itemType, uri, source, event) {
 | |
|     /*
 | |
|     * "place:query" uris are used to create containers like Most Visited or
 | |
|     * Recently Bookmarked. These are added as default bookmarks.
 | |
|     */
 | |
|     if (itemType === this.TYPE_BOOKMARK && !uri.schemeIs("place")
 | |
|       && source === PlacesUtils.bookmarks.SOURCE_DEFAULT) {
 | |
|       const isBookmarkProbe = true;
 | |
|       this.recordEvent(event, isBookmarkProbe);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // This observer is only fired for TYPE_BOOKMARK items.
 | |
|   onItemVisited(itemID, visitID, time, transitionType, uri, parentID, guid, parentGUID) {
 | |
|     const isBookmarkProbe = false;
 | |
|     this.recordEvent("open", isBookmarkProbe);
 | |
|   }
 | |
| 
 | |
|   recordEvent(event, isBookmarkProbe) {
 | |
|     const method = isBookmarkProbe ? this.METHOD_1 : this.METHOD_2;
 | |
|     const subcategory = isBookmarkProbe ? this.EXTRA_SUBCATEGORY_1 : this.EXTRA_SUBCATEGORY_2;
 | |
|     Services.telemetry.recordEvent(this.STUDY_TELEMETRY_CATEGORY, method, event, null,
 | |
|                                   {
 | |
|                                     subcategory
 | |
|                                   });
 | |
|   }
 | |
| 
 | |
|   removeObservers() {
 | |
|     PlacesUtils.bookmarks.removeObserver(this);
 | |
|   }
 | |
| 
 | |
|   uninit() {
 | |
|     if (SavantShieldStudy.shouldRemoveListeners) {
 | |
|       this.removeObservers();
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MenuListener {
 | |
|   constructor(studyCategory) {
 | |
|     this.STUDY_TELEMETRY_CATEGORY = studyCategory;
 | |
|     this.NAVIGATOR_TOOLBOX_ID = "navigator-toolbox";
 | |
|     this.OVERFLOW_PANEL_ID = "widget-overflow";
 | |
|     this.LIBRARY_PANELVIEW_ID = "appMenu-libraryView";
 | |
|     this.HAMBURGER_PANEL_ID = "appMenu-popup";
 | |
|     this.DOTDOTDOT_PANEL_ID = "pageActionPanel";
 | |
|     this.windowWatcher = new WindowWatcher();
 | |
|   }
 | |
| 
 | |
|   init() {
 | |
|     this.windowWatcher.init(this.loadIntoWindow.bind(this),
 | |
|     this.unloadFromWindow.bind(this),
 | |
|     this.onWindowError.bind(this));
 | |
|   }
 | |
| 
 | |
|  loadIntoWindow(win) {
 | |
|     this.addListeners(win);
 | |
|   }
 | |
| 
 | |
|   unloadFromWindow(win) {
 | |
|     this.removeListeners(win);
 | |
|   }
 | |
| 
 | |
|   onWindowError(msg) {
 | |
|     log.error(msg);
 | |
|   }
 | |
| 
 | |
|   addListeners(win) {
 | |
|     const doc = win.document;
 | |
|     const navToolbox = doc.getElementById(this.NAVIGATOR_TOOLBOX_ID);
 | |
|     const overflowPanel = doc.getElementById(this.OVERFLOW_PANEL_ID);
 | |
|     const hamburgerPanel = doc.getElementById(this.HAMBURGER_PANEL_ID);
 | |
|     const dotdotdotPanel = doc.getElementById(this.DOTDOTDOT_PANEL_ID);
 | |
| 
 | |
|     /*
 | |
|     * the library menu "ViewShowing" event bubbles up on the navToolbox in its
 | |
|     * default location. A separate listener is needed if it is moved to the
 | |
|     * overflow panel via Hamburger > Customize
 | |
|     */
 | |
|     navToolbox.addEventListener("ViewShowing", this);
 | |
|     overflowPanel.addEventListener("ViewShowing", this);
 | |
|     hamburgerPanel.addEventListener("popupshown", this);
 | |
|     dotdotdotPanel.addEventListener("popupshown", this);
 | |
|   }
 | |
| 
 | |
|   handleEvent(evt) {
 | |
|     switch (evt.type) {
 | |
|       case "ViewShowing":
 | |
|         if (evt.target.id === this.LIBRARY_PANELVIEW_ID) {
 | |
|           log.debug("Library panel opened.");
 | |
|           this.recordEvent("library_menu");
 | |
|         }
 | |
|         break;
 | |
|       case "popupshown":
 | |
|         switch (evt.target.id) {
 | |
|           case this.HAMBURGER_PANEL_ID:
 | |
|             log.debug("Hamburger panel opened.");
 | |
|             this.recordEvent("hamburger_menu");
 | |
|             break;
 | |
|           case this.DOTDOTDOT_PANEL_ID:
 | |
|             log.debug("Dotdotdot panel opened.");
 | |
|             this.recordEvent("dotdotdot_menu");
 | |
|             break;
 | |
|           default:
 | |
|             break;
 | |
|         }
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   recordEvent(method) {
 | |
|     Services.telemetry.recordEvent(this.STUDY_TELEMETRY_CATEGORY, method, "open", null,
 | |
|                                   { subcategory: "menu" });
 | |
|   }
 | |
| 
 | |
|   removeListeners(win) {
 | |
|     const doc = win.document;
 | |
|     const navToolbox = doc.getElementById(this.NAVIGATOR_TOOLBOX_ID);
 | |
|     const overflowPanel = doc.getElementById(this.OVERFLOW_PANEL_ID);
 | |
|     const hamburgerPanel = doc.getElementById(this.HAMBURGER_PANEL_ID);
 | |
|     const dotdotdotPanel = doc.getElementById(this.DOTDOTDOT_PANEL_ID);
 | |
| 
 | |
|     try {
 | |
|       navToolbox.removeEventListener("ViewShowing", this);
 | |
|       overflowPanel.removeEventListener("ViewShowing", this);
 | |
|       hamburgerPanel.removeEventListener("popupshown", this);
 | |
|       dotdotdotPanel.removeEventListener("popupshown", this);
 | |
|     } catch (err) {
 | |
|       // Firefox is shutting down; elements have already been removed.
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   uninit() {
 | |
|     if (SavantShieldStudy.shouldRemoveListeners) {
 | |
|       this.windowWatcher.uninit();
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| /*
 | |
| * The WindowWatcher is used to add/remove listeners from MenuListener
 | |
| * to/from all windows.
 | |
| */
 | |
| class WindowWatcher {
 | |
|   constructor() {
 | |
|     this._isActive = false;
 | |
|     this._loadCallback = null;
 | |
|     this._unloadCallback = null;
 | |
|     this._errorCallback = null;
 | |
|   }
 | |
| 
 | |
|   // It is expected that loadCallback, unloadCallback, and errorCallback are bound
 | |
|   // to a `this` value.
 | |
|   init(loadCallback, unloadCallback, errorCallback) {
 | |
|     if (this._isActive) {
 | |
|       errorCallback("Called init, but WindowWatcher was already running");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this._isActive = true;
 | |
|     this._loadCallback = loadCallback;
 | |
|     this._unloadCallback = unloadCallback;
 | |
|     this._errorCallback = errorCallback;
 | |
| 
 | |
|     // Add loadCallback to existing windows
 | |
|     for (const win of Services.wm.getEnumerator("navigator:browser")) {
 | |
|       try {
 | |
|         this._loadCallback(win);
 | |
|       } catch (ex) {
 | |
|         this._errorCallback(`WindowWatcher code loading callback failed: ${ ex }`);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Add loadCallback to future windows
 | |
|     // This will call the observe method on WindowWatcher
 | |
|     Services.ww.registerNotification(this);
 | |
|   }
 | |
| 
 | |
|   uninit() {
 | |
|     if (!this._isActive) {
 | |
|       this._errorCallback("Called uninit, but WindowWatcher was already uninited");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     for (const win of Services.wm.getEnumerator("navigator:browser")) {
 | |
|       try {
 | |
|         this._unloadCallback(win);
 | |
|       } catch (ex) {
 | |
|         this._errorCallback(`WindowWatcher code unloading callback failed: ${ ex }`);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     Services.ww.unregisterNotification(this);
 | |
| 
 | |
|     this._loadCallback = null;
 | |
|     this._unloadCallback = null;
 | |
|     this._errorCallback = null;
 | |
|     this._isActive = false;
 | |
|   }
 | |
| 
 | |
|   observe(win, topic) {
 | |
|     switch (topic) {
 | |
|       case "domwindowopened":
 | |
|         this._onWindowOpened(win);
 | |
|         break;
 | |
|       case "domwindowclosed":
 | |
|         this._onWindowClosed(win);
 | |
|         break;
 | |
|       default:
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   _onWindowOpened(win) {
 | |
|     win.addEventListener("load", this, { once: true });
 | |
|   }
 | |
| 
 | |
|   // only one event type expected: "load"
 | |
|   handleEvent(evt) {
 | |
|     const win = evt.target.ownerGlobal;
 | |
| 
 | |
|     // make sure we only add window listeners to a DOMWindow (browser.xul)
 | |
|     const winType = win.document.documentElement.getAttribute("windowtype");
 | |
|     if (winType === "navigator:browser") {
 | |
|       this._loadCallback(win);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   _onWindowClosed(win) {
 | |
|     this._unloadCallback(win);
 | |
|   }
 | |
| }
 | |
| 
 | |
| const SavantShieldStudy = new SavantShieldStudyClass();
 |