diff options
Diffstat (limited to '')
-rw-r--r-- | remote/shared/NavigationManager.sys.mjs | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/remote/shared/NavigationManager.sys.mjs b/remote/shared/NavigationManager.sys.mjs new file mode 100644 index 0000000000..1f19ef3c0d --- /dev/null +++ b/remote/shared/NavigationManager.sys.mjs @@ -0,0 +1,414 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + registerNavigationListenerActor: + "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + truncate: "chrome://remote/content/shared/Format.sys.mjs", + unregisterNavigationListenerActor: + "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * @typedef {object} BrowsingContextDetails + * @property {string} browsingContextId - The browsing context id. + * @property {string} browserId - The id of the Browser owning the browsing + * context. + * @property {BrowsingContext=} context - The BrowsingContext itself, if + * available. + * @property {boolean} isTopBrowsingContext - Whether the browsing context is + * top level. + */ + +/** + * @typedef {object} NavigationInfo + * @property {boolean} finished - Whether the navigation is finished or not. + * @property {string} navigationId - The UUID for the navigation. + * @property {string} navigable - The UUID for the navigable. + * @property {string} url - The target url for the navigation. + */ + +/** + * The NavigationRegistry is responsible for monitoring all navigations happening + * in the browser. + * + * It relies on a JSWindowActor pair called NavigationListener{Parent|Child}, + * found under remote/shared/js-window-actors. As a simple overview, the + * NavigationListenerChild will monitor navigations in all window globals using + * content process WebProgressListener, and will forward each relevant update to + * the NavigationListenerParent + * + * The NavigationRegistry singleton holds the map of navigations, from navigable + * to NavigationInfo. It will also be called by NavigationListenerParent + * whenever a navigation event happens. + * + * This singleton is not exported outside of this class, and consumers instead + * need to use the NavigationManager class. The NavigationRegistry keeps track + * of how many NavigationListener instances are currently listening in order to + * know if the NavigationListenerActor should be registered or not. + * + * The NavigationRegistry exposes an API to retrieve the current or last + * navigation for a given navigable, and also forwards events to notify about + * navigation updates to individual NavigationManager instances. + * + * @class NavigationRegistry + */ +class NavigationRegistry extends EventEmitter { + #managers; + #navigations; + #navigationIds; + + constructor() { + super(); + + // Set of NavigationManager instances currently used. + this.#managers = new Set(); + + // Maps navigable to NavigationInfo. + this.#navigations = new WeakMap(); + + // Maps navigable id to navigation id. Only used to pre-register navigation + // ids before the actual event is detected. + this.#navigationIds = new Map(); + } + + /** + * Retrieve the last known navigation data for a given browsing context. + * + * @param {BrowsingContext} context + * The browsing context for which the navigation event was recorded. + * @returns {NavigationInfo|null} + * The last known navigation data, or null. + */ + getNavigationForBrowsingContext(context) { + if (!lazy.TabManager.isValidCanonicalBrowsingContext(context)) { + // Bail out if the provided context is not a valid CanonicalBrowsingContext + // instance. + return null; + } + + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + if (!this.#navigations.has(navigable)) { + return null; + } + + return this.#navigations.get(navigable); + } + + /** + * Start monitoring navigations in all browsing contexts. This will register + * the NavigationListener JSWindowActor and will initialize them in all + * existing browsing contexts. + */ + startMonitoring(listener) { + if (this.#managers.size == 0) { + lazy.registerNavigationListenerActor(); + } + + this.#managers.add(listener); + } + + /** + * Stop monitoring navigations. This will unregister the NavigationListener + * JSWindowActor and clear the information collected about navigations so far. + */ + stopMonitoring(listener) { + if (!this.#managers.has(listener)) { + return; + } + + this.#managers.delete(listener); + if (this.#managers.size == 0) { + lazy.unregisterNavigationListenerActor(); + // Clear the map. + this.#navigations = new WeakMap(); + } + } + + /** + * Called when a same-document navigation is recorded from the + * NavigationListener actors. + * + * This entry point is only intended to be called from + * NavigationListenerParent, to avoid setting up observers or listeners, + * which are unnecessary since NavigationManager has to be a singleton. + * + * @param {object} data + * @param {BrowsingContext} data.context + * The browsing context for which the navigation event was recorded. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The navigation created for this same-document navigation. + */ + notifyLocationChanged(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigationId = this.#getOrCreateNavigationId(navigableId); + const navigation = { finished: true, navigationId, url }; + this.#navigations.set(navigable, navigation); + + // Same document navigations are immediately done, fire a single event. + this.emit("location-changed", { navigationId, navigableId, url }); + + return navigation; + } + + /** + * Called when a navigation-started event is recorded from the + * NavigationListener actors. + * + * This entry point is only intended to be called from + * NavigationListenerParent, to avoid setting up observers or listeners, + * which are unnecessary since NavigationManager has to be a singleton. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The created navigation or the ongoing navigation, if applicable. + */ + notifyNavigationStarted(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + let navigation = this.#navigations.get(navigable); + if (navigation && !navigation.finished) { + // If we are already monitoring a navigation for this navigable, for which + // we did not receive a navigation-stopped event, this navigation + // is already tracked and we don't want to create another id & event. + lazy.logger.trace( + `[${navigableId}] Skipping already tracked navigation, navigationId: ${navigation.navigationId}` + ); + return navigation; + } + + const navigationId = this.#getOrCreateNavigationId(navigableId); + navigation = { finished: false, navigationId, url }; + this.#navigations.set(navigable, navigation); + + lazy.logger.trace( + lazy.truncate`[${navigableId}] Navigation started for url: ${url} (${navigationId})` + ); + + this.emit("navigation-started", { navigationId, navigableId, url }); + + return navigation; + } + + /** + * Called when a navigation-stopped event is recorded from the + * NavigationListener actors. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @param {string} data.url + * The URL as string for the navigation. + * @returns {NavigationInfo} + * The stopped navigation if any, or null. + */ + notifyNavigationStopped(data) { + const { contextDetails, url } = data; + + const context = this.#getContextFromContextDetails(contextDetails); + const navigable = lazy.TabManager.getNavigableForBrowsingContext(context); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigation = this.#navigations.get(navigable); + if (!navigation) { + lazy.logger.trace( + lazy.truncate`[${navigableId}] No navigation found to stop for url: ${url}` + ); + return null; + } + + if (navigation.finished) { + lazy.logger.trace( + `[${navigableId}] Navigation already marked as finished, navigationId: ${navigation.navigationId}` + ); + return navigation; + } + + lazy.logger.trace( + lazy.truncate`[${navigableId}] Navigation finished for url: ${url} (${navigation.navigationId})` + ); + + navigation.finished = true; + + this.emit("navigation-stopped", { + navigationId: navigation.navigationId, + navigableId, + url, + }); + + return navigation; + } + + /** + * Register a navigation id to be used for the next navigation for the + * provided browsing context details. + * + * @param {object} data + * @param {BrowsingContextDetails} data.contextDetails + * The details about the browsing context for this navigation. + * @returns {string} + * The UUID created the upcoming navigation. + */ + registerNavigationId(data) { + const { contextDetails } = data; + const context = this.#getContextFromContextDetails(contextDetails); + const navigableId = lazy.TabManager.getIdForBrowsingContext(context); + + const navigationId = lazy.generateUUID(); + this.#navigationIds.set(navigableId, navigationId); + + return navigationId; + } + + #getContextFromContextDetails(contextDetails) { + if (contextDetails.context) { + return contextDetails.context; + } + + return contextDetails.isTopBrowsingContext + ? BrowsingContext.getCurrentTopByBrowserId(contextDetails.browserId) + : BrowsingContext.get(contextDetails.browsingContextId); + } + + #getOrCreateNavigationId(navigableId) { + let navigationId; + if (this.#navigationIds.has(navigableId)) { + navigationId = this.#navigationIds.get(navigableId, navigationId); + this.#navigationIds.delete(navigableId); + } else { + navigationId = lazy.generateUUID(); + } + return navigationId; + } +} + +// Create a private NavigationRegistry singleton. +const navigationRegistry = new NavigationRegistry(); + +/** + * See NavigationRegistry.notifyLocationChanged. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyLocationChanged(data) { + return navigationRegistry.notifyLocationChanged(data); +} + +/** + * See NavigationRegistry.notifyNavigationStarted. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyNavigationStarted(data) { + return navigationRegistry.notifyNavigationStarted(data); +} + +/** + * See NavigationRegistry.notifyNavigationStopped. + * + * This entry point is only intended to be called from NavigationListenerParent, + * to avoid setting up observers or listeners, which are unnecessary since + * NavigationRegistry has to be a singleton. + */ +export function notifyNavigationStopped(data) { + return navigationRegistry.notifyNavigationStopped(data); +} + +export function registerNavigationId(data) { + return navigationRegistry.registerNavigationId(data); +} + +/** + * The NavigationManager exposes the NavigationRegistry data via a class which + * needs to be individually instantiated by each consumer. This allow to track + * how many consumers need navigation data at any point so that the + * NavigationRegistry can register or unregister the underlying JSWindowActors + * correctly. + * + * @fires navigation-started + * The NavigationManager emits "navigation-started" when a new navigation is + * detected, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + * @fires navigation-stopped + * The NavigationManager emits "navigation-stopped" when a known navigation + * is stopped, with the following object as payload: + * - {string} navigationId - The UUID for the navigation. + * - {string} navigableId - The UUID for the navigable. + * - {string} url - The target url for the navigation. + */ +export class NavigationManager extends EventEmitter { + #monitoring; + + constructor() { + super(); + + this.#monitoring = false; + } + + destroy() { + this.stopMonitoring(); + } + + getNavigationForBrowsingContext(context) { + return navigationRegistry.getNavigationForBrowsingContext(context); + } + + startMonitoring() { + if (this.#monitoring) { + return; + } + + this.#monitoring = true; + navigationRegistry.startMonitoring(this); + navigationRegistry.on("navigation-started", this.#onNavigationEvent); + navigationRegistry.on("location-changed", this.#onNavigationEvent); + navigationRegistry.on("navigation-stopped", this.#onNavigationEvent); + } + + stopMonitoring() { + if (!this.#monitoring) { + return; + } + + this.#monitoring = false; + navigationRegistry.stopMonitoring(this); + navigationRegistry.off("navigation-started", this.#onNavigationEvent); + navigationRegistry.off("location-changed", this.#onNavigationEvent); + navigationRegistry.off("navigation-stopped", this.#onNavigationEvent); + } + + #onNavigationEvent = (eventName, data) => { + this.emit(eventName, data); + }; +} |