forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			762 lines
		
	
	
	
		
			22 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			762 lines
		
	
	
	
		
			22 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/. */
 | |
| 
 | |
| import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
 | |
| 
 | |
| const lazy = {};
 | |
| 
 | |
| ChromeUtils.defineESModuleGetters(lazy, {
 | |
|   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
 | |
|   InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs",
 | |
|   PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
 | |
|   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
 | |
|   clearTimeout: "resource://gre/modules/Timer.sys.mjs",
 | |
|   setTimeout: "resource://gre/modules/Timer.sys.mjs",
 | |
| });
 | |
| 
 | |
| ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
 | |
|   return console.createInstance({
 | |
|     prefix: "InteractionsManager",
 | |
|     maxLogLevel: Services.prefs.getBoolPref(
 | |
|       "browser.places.interactions.log",
 | |
|       false
 | |
|     )
 | |
|       ? "Debug"
 | |
|       : "Warn",
 | |
|   });
 | |
| });
 | |
| 
 | |
| XPCOMUtils.defineLazyServiceGetters(lazy, {
 | |
|   idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"],
 | |
| });
 | |
| 
 | |
| XPCOMUtils.defineLazyPreferenceGetter(
 | |
|   lazy,
 | |
|   "pageViewIdleTime",
 | |
|   "browser.places.interactions.pageViewIdleTime",
 | |
|   60
 | |
| );
 | |
| 
 | |
| XPCOMUtils.defineLazyPreferenceGetter(
 | |
|   lazy,
 | |
|   "saveInterval",
 | |
|   "browser.places.interactions.saveInterval",
 | |
|   10000
 | |
| );
 | |
| 
 | |
| XPCOMUtils.defineLazyPreferenceGetter(
 | |
|   lazy,
 | |
|   "isHistoryEnabled",
 | |
|   "places.history.enabled",
 | |
|   false
 | |
| );
 | |
| 
 | |
| const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
 | |
| 
 | |
| /**
 | |
|  * Returns a monotonically increasing timestamp, that is critical to distinguish
 | |
|  * database entries by creation time.
 | |
|  */
 | |
| let gLastTime = 0;
 | |
| function monotonicNow() {
 | |
|   let time = Date.now();
 | |
|   if (time == gLastTime) {
 | |
|     time++;
 | |
|   }
 | |
|   return (gLastTime = time);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * @typedef {object} DocumentInfo
 | |
|  *   DocumentInfo is used to pass document information from the child process
 | |
|  *   to _Interactions.
 | |
|  * @property {boolean} isActive
 | |
|  *   Set to true if the document is active, i.e. visible.
 | |
|  * @property {string} url
 | |
|  *   The url of the page that was interacted with.
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * @typedef {object} InteractionInfo
 | |
|  *   InteractionInfo is used to store information associated with interactions.
 | |
|  * @property {number} totalViewTime
 | |
|  *   Time in milliseconds that the page has been actively viewed for.
 | |
|  * @property {string} url
 | |
|  *   The url of the page that was interacted with.
 | |
|  * @property {Interactions.DOCUMENT_TYPE} documentType
 | |
|  *   The type of the document.
 | |
|  * @property {number} typingTime
 | |
|  *   Time in milliseconds that the user typed on the page
 | |
|  * @property {number} keypresses
 | |
|  *   The number of keypresses made on the page
 | |
|  * @property {number} scrollingTime
 | |
|  *   Time in milliseconds that the user spent scrolling the page
 | |
|  * @property {number} scrollingDistance
 | |
|  *   The distance, in pixels, that the user scrolled the page
 | |
|  * @property {number} created_at
 | |
|  *   Creation time as the number of milliseconds since the epoch.
 | |
|  * @property {number} updated_at
 | |
|  *   Last updated time as the number of milliseconds since the epoch.
 | |
|  * @property {string} referrer
 | |
|  *   The referrer to the url of the page that was interacted with (may be empty)
 | |
|  *
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * The Interactions object sets up listeners and other approriate tools for
 | |
|  * obtaining interaction information and passing it to the InteractionsManager.
 | |
|  */
 | |
| class _Interactions {
 | |
|   DOCUMENT_TYPE = {
 | |
|     // Used when the document type is unknown.
 | |
|     GENERIC: 0,
 | |
|     // Used for pages serving media, e.g. videos.
 | |
|     MEDIA: 1,
 | |
|   };
 | |
| 
 | |
|   /**
 | |
|    * This is used to store potential interactions. It maps the browser
 | |
|    * to the current interaction information.
 | |
|    * The current interaction is updated to the database when it transitions
 | |
|    * to non-active, which occurs before a browser tab is closed, hence this
 | |
|    * can be a weak map.
 | |
|    *
 | |
|    * @type {WeakMap<browser, InteractionInfo>}
 | |
|    */
 | |
|   #interactions = new WeakMap();
 | |
| 
 | |
|   /**
 | |
|    * Tracks the currently active window so that we can avoid recording
 | |
|    * interactions in non-active windows.
 | |
|    *
 | |
|    * @type {DOMWindow}
 | |
|    */
 | |
|   #activeWindow = undefined;
 | |
| 
 | |
|   /**
 | |
|    * Tracks if the user is idle.
 | |
|    *
 | |
|    * @type {boolean}
 | |
|    */
 | |
|   #userIsIdle = false;
 | |
| 
 | |
|   /**
 | |
|    * This stores the page view start time of the current page view.
 | |
|    * For any single page view, this may be moved multiple times as the
 | |
|    * associated interaction is updated for the current total page view time.
 | |
|    *
 | |
|    * @type {number}
 | |
|    */
 | |
|   _pageViewStartTime = Cu.now();
 | |
| 
 | |
|   /**
 | |
|    * Stores interactions in the database, see the {@link InteractionsStore}
 | |
|    * class. This is created lazily, see the `store` getter.
 | |
|    *
 | |
|    * @type {InteractionsStore | undefined}
 | |
|    */
 | |
|   #store = undefined;
 | |
| 
 | |
|   /**
 | |
|    * Whether the component has been initialized.
 | |
|    */
 | |
|   #initialized = false;
 | |
| 
 | |
|   /**
 | |
|    * Initializes, sets up actors and observers.
 | |
|    */
 | |
|   init() {
 | |
|     if (
 | |
|       !Services.prefs.getBoolPref("browser.places.interactions.enabled", false)
 | |
|     ) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     ChromeUtils.registerWindowActor("Interactions", {
 | |
|       parent: {
 | |
|         esModuleURI: "resource:///actors/InteractionsParent.sys.mjs",
 | |
|       },
 | |
|       child: {
 | |
|         esModuleURI: "resource:///actors/InteractionsChild.sys.mjs",
 | |
|         events: {
 | |
|           DOMContentLoaded: {},
 | |
|           pagehide: { mozSystemGroup: true },
 | |
|         },
 | |
|       },
 | |
|       messageManagerGroups: ["browsers"],
 | |
|     });
 | |
| 
 | |
|     this.#activeWindow = Services.wm.getMostRecentBrowserWindow();
 | |
| 
 | |
|     for (let win of lazy.BrowserWindowTracker.orderedWindows) {
 | |
|       if (!win.closed) {
 | |
|         this.#registerWindow(win);
 | |
|       }
 | |
|     }
 | |
|     Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
 | |
|     lazy.idleService.addIdleObserver(this, lazy.pageViewIdleTime);
 | |
|     this.#initialized = true;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Uninitializes, removes any observers that need cleaning up manually.
 | |
|    */
 | |
|   uninit() {
 | |
|     if (this.#initialized) {
 | |
|       lazy.idleService.removeIdleObserver(this, lazy.pageViewIdleTime);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Resets any stored user or interaction state.
 | |
|    * Used by tests.
 | |
|    */
 | |
|   async reset() {
 | |
|     lazy.logConsole.debug("Database reset");
 | |
|     this.#interactions = new WeakMap();
 | |
|     this.#userIsIdle = false;
 | |
|     this._pageViewStartTime = Cu.now();
 | |
|     ChromeUtils.consumeInteractionData();
 | |
|     await _Interactions.interactionUpdatePromise;
 | |
|     await this.store.reset();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Retrieve the underlying InteractionsStore object. This exists for testing
 | |
|    * purposes and should not be abused by production code (for example it'd be
 | |
|    * a bad idea to force flushes).
 | |
|    *
 | |
|    * @returns {InteractionsStore}
 | |
|    */
 | |
|   get store() {
 | |
|     if (!this.#store) {
 | |
|       this.#store = new InteractionsStore();
 | |
|     }
 | |
|     return this.#store;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Registers the start of a new interaction.
 | |
|    *
 | |
|    * @param {Browser} browser
 | |
|    *   The browser object associated with the interaction.
 | |
|    * @param {DocumentInfo} docInfo
 | |
|    *   The document information of the page associated with the interaction.
 | |
|    */
 | |
|   registerNewInteraction(browser, docInfo) {
 | |
|     if (
 | |
|       !browser ||
 | |
|       !lazy.isHistoryEnabled ||
 | |
|       !browser.browsingContext.useGlobalHistory
 | |
|     ) {
 | |
|       return;
 | |
|     }
 | |
|     let interaction = this.#interactions.get(browser);
 | |
|     if (interaction && interaction.url != docInfo.url) {
 | |
|       this.registerEndOfInteraction(browser);
 | |
|     }
 | |
| 
 | |
|     if (lazy.InteractionsBlocklist.isUrlBlocklisted(docInfo.url)) {
 | |
|       lazy.logConsole.debug(
 | |
|         "Ignoring a page as the URL is blocklisted",
 | |
|         docInfo
 | |
|       );
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     lazy.logConsole.debug("Tracking a new interaction", docInfo);
 | |
|     let now = monotonicNow();
 | |
|     interaction = {
 | |
|       url: docInfo.url,
 | |
|       referrer: docInfo.referrer,
 | |
|       totalViewTime: 0,
 | |
|       typingTime: 0,
 | |
|       keypresses: 0,
 | |
|       scrollingTime: 0,
 | |
|       scrollingDistance: 0,
 | |
|       created_at: now,
 | |
|       updated_at: now,
 | |
|     };
 | |
|     this.#interactions.set(browser, interaction);
 | |
| 
 | |
|     // Only reset the time if this is being loaded in the active tab of the
 | |
|     // active window.
 | |
|     if (docInfo.isActive && browser.ownerGlobal == this.#activeWindow) {
 | |
|       this._pageViewStartTime = Cu.now();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Registers the end of an interaction, e.g. if the user navigates away
 | |
|    * from the page. This will store the final interaction details and clear
 | |
|    * the current interaction.
 | |
|    *
 | |
|    * @param {Browser} browser
 | |
|    *   The browser object associated with the interaction.
 | |
|    */
 | |
|   registerEndOfInteraction(browser) {
 | |
|     // Not having a browser passed to us probably means the tab has gone away
 | |
|     // before we received the notification - due to the tab being a background
 | |
|     // tab. Since that will be a non-active tab, it is acceptable that we don't
 | |
|     // update the interaction. When switching away from active tabs, a TabSelect
 | |
|     // notification is generated which we handle elsewhere.
 | |
|     if (
 | |
|       !browser ||
 | |
|       !lazy.isHistoryEnabled ||
 | |
|       !browser.browsingContext.useGlobalHistory
 | |
|     ) {
 | |
|       return;
 | |
|     }
 | |
|     lazy.logConsole.debug("Saw the end of an interaction");
 | |
| 
 | |
|     this.#updateInteraction(browser);
 | |
|     this.#interactions.delete(browser);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Updates the current interaction
 | |
|    *
 | |
|    * @param {Browser} [browser]
 | |
|    *   The browser object that has triggered the update, if known. This is
 | |
|    *   used to check if the browser is in the active window, and as an
 | |
|    *   optimization to avoid obtaining the browser object.
 | |
|    */
 | |
|   #updateInteraction(browser = undefined) {
 | |
|     _Interactions.#updateInteraction_async(
 | |
|       browser,
 | |
|       this.#activeWindow,
 | |
|       this.#userIsIdle,
 | |
|       this.#interactions,
 | |
|       this._pageViewStartTime,
 | |
|       this.store
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Stores the promise created in updateInteraction_async so that we can await its fulfillment
 | |
|    * when sychronization is needed.
 | |
|    */
 | |
|   static interactionUpdatePromise = Promise.resolve();
 | |
| 
 | |
|   /**
 | |
|    * Returns the interactions update promise to be used when sychronization is needed from tests.
 | |
|    *
 | |
|    * @returns {Promise<void>}
 | |
|    */
 | |
|   get interactionUpdatePromise() {
 | |
|     return _Interactions.interactionUpdatePromise;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions.
 | |
|    *
 | |
|    * @param {Browser} browser
 | |
|    *   The browser object that has triggered the update, if known.
 | |
|    * @param {DOMWindow} activeWindow
 | |
|    *   The active window.
 | |
|    * @param {boolean} userIsIdle
 | |
|    *   Whether the user is idle.
 | |
|    * @param {WeakMap<Browser, InteractionInfo>} interactions
 | |
|    *   A map of interactions for each browser instance
 | |
|    * @param {number} pageViewStartTime
 | |
|    *   The time the page was loaded.
 | |
|    * @param {InteractionsStore} store
 | |
|    *   The interactions store.
 | |
|    */
 | |
|   static async #updateInteraction_async(
 | |
|     browser,
 | |
|     activeWindow,
 | |
|     userIsIdle,
 | |
|     interactions,
 | |
|     pageViewStartTime,
 | |
|     store
 | |
|   ) {
 | |
|     if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) {
 | |
|       lazy.logConsole.debug(
 | |
|         "Not updating interaction as there is no active window"
 | |
|       );
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // We do not update the interaction when the user is idle, since we will
 | |
|     // have already updated it when idle was signalled.
 | |
|     // Sometimes an interaction may be signalled before idle is cleared, however
 | |
|     // worst case we'd only loose approx 2 seconds of interaction detail.
 | |
|     if (userIsIdle) {
 | |
|       lazy.logConsole.debug("Not updating interaction as the user is idle");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!browser) {
 | |
|       browser = activeWindow.gBrowser.selectedTab.linkedBrowser;
 | |
|     }
 | |
| 
 | |
|     let interaction = interactions.get(browser);
 | |
|     if (!interaction) {
 | |
|       lazy.logConsole.debug("No interaction to update");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     interaction.totalViewTime += Cu.now() - pageViewStartTime;
 | |
|     Interactions._pageViewStartTime = Cu.now();
 | |
| 
 | |
|     const interactionData = ChromeUtils.consumeInteractionData();
 | |
|     const typing = interactionData.Typing;
 | |
|     if (typing) {
 | |
|       interaction.typingTime += typing.interactionTimeInMilliseconds;
 | |
|       interaction.keypresses += typing.interactionCount;
 | |
|     }
 | |
| 
 | |
|     // Collect the scrolling data and add the interaction to the store on completion
 | |
|     _Interactions.interactionUpdatePromise =
 | |
|       _Interactions.interactionUpdatePromise
 | |
|         .then(async () => ChromeUtils.collectScrollingData())
 | |
|         .then(
 | |
|           result => {
 | |
|             interaction.scrollingTime += result.interactionTimeInMilliseconds;
 | |
|             interaction.scrollingDistance += result.scrollingDistanceInPixels;
 | |
|           },
 | |
|           reason => {
 | |
|             console.error(reason);
 | |
|           }
 | |
|         )
 | |
|         .then(() => {
 | |
|           interaction.updated_at = monotonicNow();
 | |
| 
 | |
|           lazy.logConsole.debug("Add to store: ", interaction);
 | |
|           store.add(interaction);
 | |
|         });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles a window becoming active.
 | |
|    *
 | |
|    * @param {DOMWindow} win
 | |
|    *   The window that has become active.
 | |
|    */
 | |
|   #onActivateWindow(win) {
 | |
|     lazy.logConsole.debug("Window activated");
 | |
| 
 | |
|     if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.#activeWindow = win;
 | |
|     this._pageViewStartTime = Cu.now();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles a window going inactive.
 | |
|    *
 | |
|    * @param {DOMWindow} win
 | |
|    *   The window that is going inactive.
 | |
|    */
 | |
|   #onDeactivateWindow(win) {
 | |
|     lazy.logConsole.debug("Window deactivate");
 | |
| 
 | |
|     this.#updateInteraction();
 | |
|     this.#activeWindow = undefined;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles the TabSelect notification. Updates the current interaction and
 | |
|    * then switches it to the interaction for the new tab. The new interaction
 | |
|    * may be null if it doesn't exist.
 | |
|    *
 | |
|    * @param {Browser} previousBrowser
 | |
|    *   The instance of the browser that the user switched away from.
 | |
|    */
 | |
|   #onTabSelect(previousBrowser) {
 | |
|     lazy.logConsole.debug("Tab switched");
 | |
| 
 | |
|     this.#updateInteraction(previousBrowser);
 | |
|     this._pageViewStartTime = Cu.now();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles various events and forwards them to appropriate functions.
 | |
|    *
 | |
|    * @param {DOMEvent} event
 | |
|    *   The event that will be handled
 | |
|    */
 | |
|   handleEvent(event) {
 | |
|     switch (event.type) {
 | |
|       case "TabSelect":
 | |
|         this.#onTabSelect(event.detail.previousTab.linkedBrowser);
 | |
|         break;
 | |
|       case "activate":
 | |
|         this.#onActivateWindow(event.target);
 | |
|         break;
 | |
|       case "deactivate":
 | |
|         this.#onDeactivateWindow(event.target);
 | |
|         break;
 | |
|       case "unload":
 | |
|         this.#unregisterWindow(event.target);
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles notifications from the observer service.
 | |
|    *
 | |
|    * @param {nsISupports} subject
 | |
|    *   The subject of the notification.
 | |
|    * @param {string} topic
 | |
|    *   The topic of the notification.
 | |
|    * @param {string} data
 | |
|    *   The data attached to the notification.
 | |
|    */
 | |
|   observe(subject, topic, data) {
 | |
|     switch (topic) {
 | |
|       case DOMWINDOW_OPENED_TOPIC:
 | |
|         this.#onWindowOpen(subject);
 | |
|         break;
 | |
|       case "idle":
 | |
|         lazy.logConsole.debug("User went idle");
 | |
|         // We save the state of the current interaction when we are notified
 | |
|         // that the user is idle.
 | |
|         this.#updateInteraction();
 | |
|         this.#userIsIdle = true;
 | |
|         break;
 | |
|       case "active":
 | |
|         lazy.logConsole.debug("User became active");
 | |
|         this.#userIsIdle = false;
 | |
|         this._pageViewStartTime = Cu.now();
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles registration of listeners in a new window.
 | |
|    *
 | |
|    * @param {DOMWindow} win
 | |
|    *   The window to register in.
 | |
|    */
 | |
|   #registerWindow(win) {
 | |
|     if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     win.addEventListener("TabSelect", this, true);
 | |
|     win.addEventListener("deactivate", this, true);
 | |
|     win.addEventListener("activate", this, true);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles removing of listeners from a window.
 | |
|    *
 | |
|    * @param {DOMWindow} win
 | |
|    *   The window to remove listeners from.
 | |
|    */
 | |
|   #unregisterWindow(win) {
 | |
|     win.removeEventListener("TabSelect", this, true);
 | |
|     win.removeEventListener("deactivate", this, true);
 | |
|     win.removeEventListener("activate", this, true);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Handles a new window being opened, waits for load and checks that
 | |
|    * it is a browser window, then adds listeners.
 | |
|    *
 | |
|    * @param {DOMWindow} win
 | |
|    *   The window being opened.
 | |
|    */
 | |
|   #onWindowOpen(win) {
 | |
|     win.addEventListener(
 | |
|       "load",
 | |
|       () => {
 | |
|         if (
 | |
|           win.document.documentElement.getAttribute("windowtype") !=
 | |
|           "navigator:browser"
 | |
|         ) {
 | |
|           return;
 | |
|         }
 | |
|         this.#registerWindow(win);
 | |
|       },
 | |
|       { once: true }
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   QueryInterface = ChromeUtils.generateQI([
 | |
|     "nsIObserver",
 | |
|     "nsISupportsWeakReference",
 | |
|   ]);
 | |
| }
 | |
| 
 | |
| export const Interactions = new _Interactions();
 | |
| 
 | |
| /**
 | |
|  * Store interactions data in the Places database.
 | |
|  * To improve performance the writes are buffered every `saveInterval`
 | |
|  * milliseconds. Even if this means we could be trying to write interaction for
 | |
|  * pages that in the meanwhile have been removed, that's not a problem because
 | |
|  * we won't be able to insert entries having a NULL place_id, they will just be
 | |
|  * ignored.
 | |
|  * Use .add(interaction) to request storing of an interaction.
 | |
|  * Use .pendingPromise to await for any pending writes to have happened.
 | |
|  */
 | |
| class InteractionsStore {
 | |
|   /**
 | |
|    * Timer to run database updates on.
 | |
|    */
 | |
|   #timer = undefined;
 | |
|   /**
 | |
|    * Tracks interactions replicating the unique index in the underlying schema.
 | |
|    * Interactions are keyed by url and then created_at.
 | |
|    *
 | |
|    * @type {Map<string, Map<number, InteractionInfo>>}
 | |
|    */
 | |
|   #interactions = new Map();
 | |
|   /**
 | |
|    * Used to unblock the queue of promises when the timer is cleared.
 | |
|    */
 | |
|   #timerResolve = undefined;
 | |
| 
 | |
|   constructor() {
 | |
|     // Block async shutdown to ensure the last write goes through.
 | |
|     this.progress = {};
 | |
|     lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker(
 | |
|       "Interactions.jsm:: store",
 | |
|       async () => this.flush(),
 | |
|       { fetchState: () => this.progress }
 | |
|     );
 | |
| 
 | |
|     // Can be used to wait for the last pending write to have happened.
 | |
|     this.pendingPromise = Promise.resolve();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Synchronizes the pending interactions with the storage device.
 | |
|    *
 | |
|    * @returns {Promise} resolved when the pending data is on disk.
 | |
|    */
 | |
|   async flush() {
 | |
|     if (this.#timer) {
 | |
|       lazy.clearTimeout(this.#timer);
 | |
|       this.#timerResolve();
 | |
|       await this.#updateDatabase();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Completely clears the store and any pending writes.
 | |
|    * This exists for testing purposes.
 | |
|    */
 | |
|   async reset() {
 | |
|     await lazy.PlacesUtils.withConnectionWrapper(
 | |
|       "Interactions.jsm::reset",
 | |
|       async db => {
 | |
|         await db.executeCached(`DELETE FROM moz_places_metadata`);
 | |
|       }
 | |
|     );
 | |
|     if (this.#timer) {
 | |
|       lazy.clearTimeout(this.#timer);
 | |
|       this.#timer = undefined;
 | |
|       this.#timerResolve();
 | |
|       this.#interactions.clear();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Registers an interaction to be stored persistently. At the end of the call
 | |
|    * the interaction has not yet been added to the store, tests can await
 | |
|    * flushStore() for that.
 | |
|    *
 | |
|    * @param {InteractionInfo} interaction
 | |
|    *   The document information to write.
 | |
|    */
 | |
|   add(interaction) {
 | |
|     lazy.logConsole.debug("Preparing interaction for storage", interaction);
 | |
| 
 | |
|     let interactionsForUrl = this.#interactions.get(interaction.url);
 | |
|     if (!interactionsForUrl) {
 | |
|       interactionsForUrl = new Map();
 | |
|       this.#interactions.set(interaction.url, interactionsForUrl);
 | |
|     }
 | |
|     interactionsForUrl.set(interaction.created_at, interaction);
 | |
| 
 | |
|     if (!this.#timer) {
 | |
|       let promise = new Promise(resolve => {
 | |
|         this.#timerResolve = resolve;
 | |
|         this.#timer = lazy.setTimeout(() => {
 | |
|           this.#updateDatabase().catch(console.error).then(resolve);
 | |
|         }, lazy.saveInterval);
 | |
|       });
 | |
|       this.pendingPromise = this.pendingPromise.then(() => promise);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async #updateDatabase() {
 | |
|     this.#timer = undefined;
 | |
| 
 | |
|     // Reset the buffer.
 | |
|     let interactions = this.#interactions;
 | |
|     if (!interactions.size) {
 | |
|       return;
 | |
|     }
 | |
|     // Don't clear() this, since that would also clear interactions.
 | |
|     this.#interactions = new Map();
 | |
| 
 | |
|     let params = {};
 | |
|     let SQLInsertFragments = [];
 | |
|     let i = 0;
 | |
|     for (let interactionsForUrl of interactions.values()) {
 | |
|       for (let interaction of interactionsForUrl.values()) {
 | |
|         params[`url${i}`] = interaction.url;
 | |
|         params[`referrer${i}`] = interaction.referrer;
 | |
|         params[`created_at${i}`] = interaction.created_at;
 | |
|         params[`updated_at${i}`] = interaction.updated_at;
 | |
|         params[`document_type${i}`] =
 | |
|           interaction.documentType ?? Interactions.DOCUMENT_TYPE.GENERIC;
 | |
|         params[`total_view_time${i}`] =
 | |
|           Math.round(interaction.totalViewTime) || 0;
 | |
|         params[`typing_time${i}`] = Math.round(interaction.typingTime) || 0;
 | |
|         params[`key_presses${i}`] = interaction.keypresses || 0;
 | |
|         params[`scrolling_time${i}`] =
 | |
|           Math.round(interaction.scrollingTime) || 0;
 | |
|         params[`scrolling_distance${i}`] =
 | |
|           Math.round(interaction.scrollingDistance) || 0;
 | |
|         SQLInsertFragments.push(`(
 | |
|           (SELECT id FROM moz_places_metadata
 | |
|             WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i})
 | |
|               AND created_at = :created_at${i}),
 | |
|           (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}),
 | |
|           (SELECT id FROM moz_places WHERE url_hash = hash(:referrer${i}) AND url = :referrer${i} AND :referrer${i} != :url${i}),
 | |
|           :created_at${i},
 | |
|           :updated_at${i},
 | |
|           :document_type${i},
 | |
|           :total_view_time${i},
 | |
|           :typing_time${i},
 | |
|           :key_presses${i},
 | |
|           :scrolling_time${i},
 | |
|           :scrolling_distance${i}
 | |
|         )`);
 | |
|         i++;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     lazy.logConsole.debug(`Storing ${i} entries in the database`);
 | |
| 
 | |
|     this.progress.pendingUpdates = i;
 | |
|     await lazy.PlacesUtils.withConnectionWrapper(
 | |
|       "Interactions.jsm::updateDatabase",
 | |
|       async db => {
 | |
|         await db.executeCached(
 | |
|           `
 | |
|           WITH inserts (id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance) AS (
 | |
|             VALUES ${SQLInsertFragments.join(", ")}
 | |
|           )
 | |
|           INSERT OR REPLACE INTO moz_places_metadata (
 | |
|             id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance
 | |
|           ) SELECT * FROM inserts WHERE place_id NOT NULL;
 | |
|         `,
 | |
|           params
 | |
|         );
 | |
|       }
 | |
|     );
 | |
|     this.progress.pendingUpdates = 0;
 | |
| 
 | |
|     Services.obs.notifyObservers(null, "places-metadata-updated");
 | |
|   }
 | |
| }
 | 
