/* 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 { RootBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/RootBiDiModule.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", ContextDescriptorType: "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", getWebDriverSessionById: "chrome://remote/content/shared/webdriver/Session.sys.mjs", pprint: "chrome://remote/content/shared/Format.sys.mjs", RootMessageHandler: "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", UserContextManager: "chrome://remote/content/shared/UserContextManager.sys.mjs", }); class SessionModule extends RootBiDiModule { #knownSubscriptionIds; #subscriptions; /** * An object that holds information about the subscription, * if the topLevelTraversableIds and the userContextIds * are both empty, the subscription is considered global. * * @typedef Subscription * * @property {Set} eventNames * A set of event names related to this subscription. * @property {string} subscriptionId * A unique subscription identifier. * @property {Set} topLevelTraversableIds * A set of top level traversable ids related to this subscription. * @property {Set} userContextIds * A set of user context ids related to this subscription. */ constructor(messageHandler) { super(messageHandler); // Set of subscription ids. this.#knownSubscriptionIds = new Set(); // List of subscription objects type Subscription. this.#subscriptions = []; } destroy() { this.#knownSubscriptionIds = null; this.#subscriptions = null; } /** * Commands */ /** * End the current session. * * Session clean up will happen later in WebDriverBiDiConnection class. */ async end() { const session = lazy.getWebDriverSessionById(this.messageHandler.sessionId); if (session.http) { throw new lazy.error.UnsupportedOperationError( "Ending a session started with WebDriver classic is not supported." + ' Use the WebDriver classic "Delete Session" command instead.' ); } } /** * An object that holds a unique subscription identifier. * * @typedef SubscribeResult * * @property {string} subscription * A unique subscription identifier. */ /** * Enable certain events either globally, or for a list of browsing contexts. * * @param {object=} params * @param {Array} params.events * List of events to subscribe to. * @param {Array=} params.contexts * Optional list of top-level browsing context ids * to subscribe the events for. * @param {Array=} params.userContexts * Optional list of user context ids * to subscribe the events for. * * @returns {SubscribeResult} * A unique subscription identifier. * @throws {InvalidArgumentError} * If events or contexts are not valid types. */ async subscribe(params = {}) { const { events, contexts: contextIds = null, userContexts = null } = params; // Check input types until we run schema validation. this.#assertNonEmptyArrayWithStrings(events, "events"); if (contextIds !== null) { this.#assertNonEmptyArrayWithStrings(contextIds, "contexts"); } if (userContexts !== null) { this.#assertNonEmptyArrayWithStrings(userContexts, "userContexts"); } const eventNames = new Set(); events.forEach(name => { this.#obtainEvents(name).forEach(event => eventNames.add(event)); }); const inputUserContextIds = new Set(userContexts); const inputContextIds = new Set(contextIds); if (inputUserContextIds.size > 0 && inputContextIds.size > 0) { throw new lazy.error.InvalidArgumentError( `Providing both "userContexts" and "contexts" arguments is not supported` ); } let subscriptionNavigables = new Set(); const topLevelTraversableContextIds = new Set(); const userContextIds = new Set(); if (inputContextIds.size !== 0) { const navigables = this.#getValidNavigablesByIds(inputContextIds); subscriptionNavigables = this.#getTopLevelTraversables(navigables); for (const navigable of subscriptionNavigables) { topLevelTraversableContextIds.add( lazy.TabManager.getIdForBrowsingContext(navigable) ); } } else if (inputUserContextIds.size !== 0) { for (const userContextId of inputUserContextIds) { const internalId = lazy.UserContextManager.getInternalIdById(userContextId); if (internalId === null) { throw new lazy.error.NoSuchUserContextError( `User context with id: ${userContextId} doesn't exist` ); } lazy.UserContextManager.getTabsForUserContext(internalId).forEach( item => subscriptionNavigables.add(item) ); userContextIds.add(internalId); } } else { for (const tab of lazy.TabManager.tabs) { subscriptionNavigables.add(tab); } } const subscription = { eventNames, subscriptionId: lazy.generateUUID(), topLevelTraversableIds: topLevelTraversableContextIds, userContextIds, }; const subscribeStepEvents = new Map(); for (const eventName of eventNames) { const existingNavigables = this.#getEnabledTopLevelTraversables(eventName); subscribeStepEvents.set( eventName, subscriptionNavigables.difference(existingNavigables) ); } this.#subscriptions.push(subscription); this.#knownSubscriptionIds.add(subscription.subscriptionId); // TODO: Bug 1801284. Add subscribe priority sorting of subscribeStepEvents (step 4 to 6, and 8). const includeGlobal = this.#isSubscriptionGlobal(subscription); const listeners = this.#getListenersToSubscribe( eventNames, includeGlobal, subscribeStepEvents, userContextIds ); // Subscribe to the relevant engine-internal events. await this.messageHandler.eventsDispatcher.update(listeners); return { subscription: subscription.subscriptionId }; } /** * Disable certain events either globally, for a list of browsing contexts * or for a list of subscription ids. * * @param {object=} params * @param {Array=} params.events * List of events to unsubscribe from. * @param {Array=} params.contexts * Optional list of top-level browsing context ids * to unsubscribe the events from. * @param {Array=} params.subscriptions * List of subscription identifiers to unsubscribe from. * * @throws {InvalidArgumentError} * If events or contexts are not valid types. */ async unsubscribe(params = {}) { const { events = null, contexts = null, subscriptions = null } = params; const listeners = subscriptions === null ? this.#unsubscribeByAttributes(events, contexts) : this.#unsubscribeById(subscriptions); // Unsubscribe from the relevant engine-internal events. await this.messageHandler.eventsDispatcher.update(listeners); } #assertModuleSupportsEvent(moduleName, event) { const rootModuleClass = this.#getRootModuleClass(moduleName); if (!rootModuleClass?.supportsEvent(event)) { throw new lazy.error.InvalidArgumentError( `${event} is not a valid event name` ); } } #assertNonEmptyArrayWithStrings(array, variableName) { lazy.assert.isNonEmptyArray( array, `Expected "${variableName}" to be a non-empty array, ` + lazy.pprint`got ${array}` ); array.forEach(item => { lazy.assert.string( item, `Expected elements of "${variableName}" to be a string, ` + lazy.pprint`got ${item}` ); }); } #createListener( enable, { eventName, traversableId = null, userContextId = null } ) { let contextDescriptor; if (traversableId === null && userContextId === null) { contextDescriptor = { type: lazy.ContextDescriptorType.All, }; } else if (userContextId !== null) { contextDescriptor = { type: lazy.ContextDescriptorType.UserContext, id: userContextId, }; } else { const traversable = lazy.TabManager.getBrowsingContextById(traversableId); if (traversable === null) { return null; } contextDescriptor = { type: lazy.ContextDescriptorType.TopBrowsingContext, id: traversable.browserId, }; } return { event: eventName, contextDescriptor, callback: this.#onMessageHandlerEvent, enable, }; } #createListenerToSubscribe(params) { return this.#createListener(true, params); } #createListenerToUnsubscribe(params) { return this.#createListener(false, params); } /** * Get a set of top-level traversables for which an event is enabled. * * @see https://w3c.github.io/webdriver-bidi/#set-of-top-level-traversables-for-which-an-event-is-enabled * * @param {string} eventName * The name of the event. * * @returns {Array} * The list of top-level traversables. */ #getEnabledTopLevelTraversables(eventName) { let result = new Set(); for (const subscription of this.#getSubscriptionsForEvent(eventName)) { const { topLevelTraversableIds } = subscription; if (this.#isSubscriptionGlobal(subscription)) { for (const traversable of lazy.TabManager.tabs) { result.add(traversable); } break; } result = this.#getNavigablesByIds(topLevelTraversableIds); } return result; } #getListenersToSubscribe( eventNames, includeGlobal, subscribeStepEvents, userContextIds ) { const listeners = []; for (const eventName of eventNames) { if (includeGlobal) { // Since we're going to subscribe to all top-level // traversable ids to not have duplicate subscriptions, // we have to unsubscribe from already subscribed. const alreadyEnabledTraversableIds = this.#obtainEventEnabledTraversableIds(eventName); for (const traversableId of alreadyEnabledTraversableIds) { listeners.push( this.#createListenerToUnsubscribe({ eventName, traversableId, }) ); } // Also unsubscribe from already subscribed user contexts. const alreadyEnabledUserContextIds = this.#obtainEventEnabledUserContextIds(eventName); for (const userContextId of alreadyEnabledUserContextIds) { listeners.push( this.#createListenerToUnsubscribe({ eventName, userContextId, }) ); } listeners.push(this.#createListenerToSubscribe({ eventName })); } else if (userContextIds.size !== 0) { for (const userContextId of userContextIds) { // Do nothing if the event has already a global subscription. if (this.#hasGlobalEventSubscription(eventName)) { continue; } // Since we're going to subscribe to all top-level // traversable ids which belongs to the certain user context // to not have duplicate subscriptions, // we have to unsubscribe from already subscribed. const alreadyEnabledTraversableIds = this.#obtainEventEnabledTraversableIds(eventName, userContextId); for (const traversableId of alreadyEnabledTraversableIds) { listeners.push( this.#createListenerToUnsubscribe({ eventName, traversableId, }) ); } listeners.push( this.#createListenerToSubscribe({ eventName, userContextId }) ); } } else { for (const navigable of subscribeStepEvents.get(eventName)) { // Do nothing if the event has already a global subscription // or subscription to the associated user context. if ( this.#hasGlobalEventSubscription(eventName) || this.#hasSubscriptionByAssociatedUserContext(eventName, navigable) ) { continue; } const traversableId = lazy.TabManager.getIdForBrowsingContext(navigable); listeners.push( this.#createListenerToSubscribe({ eventName, traversableId, }) ); } } } return listeners; } #getListenersToUnsubscribe(subscription) { const { eventNames, topLevelTraversableIds, userContextIds } = subscription; const listeners = []; for (const eventName of eventNames) { // Do nothing if there is a global subscription. if (this.#hasGlobalEventSubscription(eventName)) { continue; } if (this.#isSubscriptionGlobal(subscription)) { listeners.push( ...this.#getListenersToUnsubscribeFromGlobalSubscription(eventName) ); } else if (userContextIds.size !== 0) { for (const userContextId of userContextIds) { listeners.push( ...this.#getListenersToUnsubscribeFromUserContext( eventName, userContextId ) ); } } else { for (const traversableId of topLevelTraversableIds) { listeners.push( this.#getListenersToUnsubscribeFromTraversable( eventName, traversableId ) ); } } } return listeners; } #getListenersToUnsubscribeFromGlobalSubscription(eventName) { // Unsubscribe from the global subscription. const listeners = [this.#createListenerToUnsubscribe({ eventName })]; // Subscribe again to user contexts which have a subscription and // to traversables which have individual subscriptions, // but are not associated with subscribed user contexts. for (const item of this.#getSubscriptionsForEvent(eventName)) { for (const userContextId of item.userContextIds) { listeners.push( this.#createListenerToSubscribe({ eventName, userContextId, }) ); } for (const traversableId of item.topLevelTraversableIds) { const traversable = lazy.TabManager.getBrowsingContextById(traversableId); // Do nothing if traversable doesn't exist anymore or // there is already a subscription to the associated user context. if ( traversable === null || this.#hasSubscriptionByAssociatedUserContext(eventName, traversable) ) { continue; } listeners.push( this.#createListenerToSubscribe({ eventName, traversableId, }) ); } } return listeners; } #getListenersToUnsubscribeFromTraversable(eventName, traversableId) { // Do nothing if traversable is already closed or still has another subscription. const traversable = lazy.TabManager.getBrowsingContextById(traversableId); if ( traversable === null || this.#hasSubscriptionByAssociatedUserContext(eventName, traversable) || this.#hasSubscriptionByTraversableId(eventName, traversableId) ) { return null; } return this.#createListenerToUnsubscribe({ eventName, traversableId, }); } #getListenersToUnsubscribeFromUserContext(eventName, userContextId) { // Do nothing if there is another subscription for this user context. if (this.#hasSubscriptionByUserContextId(eventName, userContextId)) { return []; } // Unsubscribe from the user context. const listeners = [ this.#createListenerToUnsubscribe({ eventName, userContextId }), ]; // Resubscribe to traversables which are associated with this user context and // have individual subscriptions. const alreadyEnabledTraversableIds = this.#obtainEventEnabledTraversableIds( eventName, userContextId ); for (const traversableId of alreadyEnabledTraversableIds) { listeners.push( this.#createListenerToSubscribe({ eventName, traversableId, }) ); } return listeners; } /** * Retrieves a navigable based on its id. * * @see https://w3c.github.io/webdriver-bidi/#get-a-navigable * * @param {number} navigableId * Id of the navigable. * * @returns {BrowsingContext=} * The navigable or null if navigableId is null. * @throws {NoSuchFrameError} * If the navigable cannot be found. */ #getNavigable(navigableId) { if (navigableId === null) { return null; } const navigable = lazy.TabManager.getBrowsingContextById(navigableId); if (!navigable) { throw new lazy.error.NoSuchFrameError( `Browsing context with id ${navigableId} not found` ); } return navigable; } /** * Get a list of navigables by provided ids. * * @see https://w3c.github.io/webdriver-bidi/#get-navigables-by-ids * * @param {Set} navigableIds * The set of the navigable ids. * * @returns {Set} * The set of navigables. */ #getNavigablesByIds(navigableIds) { const result = new Set(); for (const navigableId of navigableIds) { const navigable = lazy.TabManager.getBrowsingContextById(navigableId); if (navigable !== null) { result.add(navigable); } } return result; } #getRootModuleClass(moduleName) { // Modules which support event subscriptions should have a root module // defining supported events. const rootDestination = { type: lazy.RootMessageHandler.type }; const moduleClasses = this.messageHandler.getAllModuleClasses( moduleName, rootDestination ); if (!moduleClasses.length) { throw new lazy.error.InvalidArgumentError( `Module ${moduleName} does not exist` ); } return moduleClasses[0]; } #getSubscriptionsForEvent(eventName) { return this.#subscriptions.filter(({ eventNames }) => eventNames.has(eventName) ); } #getTopLevelTraversableContextIds(contextIds) { const topLevelTraversableContextIds = new Set(); const inputContextIds = new Set(contextIds); if (inputContextIds.size !== 0) { const navigables = this.#getValidNavigablesByIds(inputContextIds); const topLevelTraversable = this.#getTopLevelTraversables(navigables); for (const navigable of topLevelTraversable) { topLevelTraversableContextIds.add( lazy.TabManager.getIdForBrowsingContext(navigable) ); } } return topLevelTraversableContextIds; } /** * Get a list of top-level traversables for provided navigables. * * @see https://w3c.github.io/webdriver-bidi/#get-top-level-traversables * * @param {Array} navigables * The list of the navigables. * * @returns {Set} * The set of top-level traversables. */ #getTopLevelTraversables(navigables) { const result = new Set(); for (const { top } of navigables) { result.add(top); } return result; } /** * Get a list of valid navigables by provided ids. * * @see https://w3c.github.io/webdriver-bidi/#get-valid-navigables-by-ids * * @param {Set} navigableIds * The set of the navigable ids. * * @returns {Set} * The set of navigables. * @throws {NoSuchFrameError} * If the navigable cannot be found. */ #getValidNavigablesByIds(navigableIds) { const result = new Set(); for (const navigableId of navigableIds) { result.add(this.#getNavigable(navigableId)); } return result; } #hasGlobalEventSubscription(eventName) { for (const subscription of this.#getSubscriptionsForEvent(eventName)) { if (this.#isSubscriptionGlobal(subscription)) { return true; } } return false; } // Check if for a given event name and traversable there is // a subscription for a user context associated with this traversable. #hasSubscriptionByAssociatedUserContext(eventName, traversable) { if (traversable === null) { return false; } return this.#hasSubscriptionByUserContextId( eventName, traversable.originAttributes.userContextId ); } #hasSubscriptionByTraversableId(eventName, traversableId) { for (const subscription of this.#getSubscriptionsForEvent(eventName)) { const { topLevelTraversableIds } = subscription; for (const topLevelTraversableId of topLevelTraversableIds) { if (topLevelTraversableId === traversableId) { return true; } } } return false; } #hasSubscriptionByUserContextId(eventName, userContextId) { for (const subscription of this.#getSubscriptionsForEvent(eventName)) { const { userContextIds } = subscription; if (userContextIds.has(userContextId)) { return true; } } return false; } /** * Identify if a provided subscription is global. * * @see https://w3c.github.io/webdriver-bidi/#subscription-global * * @param {Subscription} subscription * A subscription object. * * @returns {boolean} * Return true if the subscription is global, false otherwise. */ #isSubscriptionGlobal(subscription) { return ( subscription.topLevelTraversableIds.size === 0 && subscription.userContextIds.size === 0 ); } /** * Obtain a list of event enabled traversable ids. * * @param {string} eventName * The name of the event. * @param {string=} userContextId * The user context id. * * @returns {Set} * The set of traversable ids. */ #obtainEventEnabledTraversableIds(eventName, userContextId = null) { let traversableIds = new Set(); for (const { topLevelTraversableIds } of this.#getSubscriptionsForEvent( eventName )) { if (topLevelTraversableIds.size === 0) { continue; } if (userContextId === null) { traversableIds = traversableIds.union(topLevelTraversableIds); continue; } for (const traversableId of topLevelTraversableIds) { const traversable = lazy.TabManager.getBrowsingContextById(traversableId); if (traversable === null) { continue; } if (traversable.originAttributes.userContextId === userContextId) { traversableIds.add(traversableId); } } } return traversableIds; } #obtainEventEnabledUserContextIds(eventName) { let enabledUserContextIds = new Set(); for (const { userContextIds } of this.#getSubscriptionsForEvent( eventName )) { enabledUserContextIds = enabledUserContextIds.union(userContextIds); } return enabledUserContextIds; } /** * Obtain a set of events based on the given event name. * * Could contain a period for a specific event, * or just the module name for all events. * * @param {string} event * Name of the event to process. * * @returns {Set} * A Set with the expanded events in the form of `.`. * * @throws {InvalidArgumentError} * If event does not reference a valid event. */ #obtainEvents(event) { const events = new Set(); // Check if a period is present that splits the event name into the module, // and the actual event. Hereby only care about the first found instance. const index = event.indexOf("."); if (index >= 0) { const [moduleName] = event.split("."); this.#assertModuleSupportsEvent(moduleName, event); events.add(event); } else { // Interpret the name as module, and register all its available events const rootModuleClass = this.#getRootModuleClass(event); const supportedEvents = rootModuleClass?.supportedEvents; for (const eventName of supportedEvents) { events.add(eventName); } } return events; } #onMessageHandlerEvent = (name, event) => { this.messageHandler.emitProtocolEvent(name, event); }; #unsubscribeByAttributes(events, contextIds) { const listeners = []; // Check input types until we run schema validation. this.#assertNonEmptyArrayWithStrings(events, "events"); if (contextIds !== null) { this.#assertNonEmptyArrayWithStrings(contextIds, "contexts"); } const eventNames = new Set(); events.forEach(name => { this.#obtainEvents(name).forEach(event => eventNames.add(event)); }); const topLevelTraversableContextIds = this.#getTopLevelTraversableContextIds(contextIds); const newSubscriptions = []; const matchedEvents = new Set(); const matchedContexts = new Set(); for (const subscription of this.#subscriptions) { // Keep subscription if it doesn't contain any target events. if (subscription.eventNames.intersection(eventNames).size === 0) { newSubscriptions.push(subscription); continue; } // Unsubscribe globally. if (topLevelTraversableContextIds.size === 0) { // Keep subscription if verified subscription is not global. if (!this.#isSubscriptionGlobal(subscription)) { newSubscriptions.push(subscription); continue; } // Delete event names from the subscription. const subscriptionEventNames = new Set(subscription.eventNames); for (const eventName of eventNames) { if (subscriptionEventNames.has(eventName)) { matchedEvents.add(eventName); subscriptionEventNames.delete(eventName); listeners.push(this.#createListenerToUnsubscribe({ eventName })); } } // If the subscription still contains some event, // save a new partial subscription. if (subscriptionEventNames.size !== 0) { const clonedSubscription = { subscriptionId: subscription.subscriptionId, eventNames: new Set(subscriptionEventNames), topLevelTraversableIds: new Set(), userContextIds: new Set(subscription.userContextIds), }; newSubscriptions.push(clonedSubscription); } } // Keep the subscription if it's global but we want to unsubscribe only from some contexts. else if (this.#isSubscriptionGlobal(subscription)) { newSubscriptions.push(subscription); } else { // Map with an event name as a key and the set of subscribed traversable ids as a value. const eventMap = new Map(); // Populate the map. for (const eventName of subscription.eventNames) { eventMap.set(eventName, new Set(subscription.topLevelTraversableIds)); } for (const eventName of eventNames) { // Skip if there is no subscription related to this event. if (!eventMap.has(eventName)) { continue; } for (const topLevelTraversableId of topLevelTraversableContextIds) { // Skip if there is no subscription related to this event and this traversable id. if (!eventMap.get(eventName).has(topLevelTraversableId)) { continue; } matchedContexts.add(topLevelTraversableId); matchedEvents.add(eventName); eventMap.get(eventName).delete(topLevelTraversableId); listeners.push( this.#createListenerToUnsubscribe({ eventName, traversableId: topLevelTraversableId, }) ); } if (eventMap.get(eventName).size === 0) { eventMap.delete(eventName); } } // Build new partial subscriptions based on the remaining data in eventMap. for (const [ eventName, remainingTopLevelTraversableIds, ] of eventMap.entries()) { const partialSubscription = { subscriptionId: subscription.subscriptionId, eventNames: new Set([eventName]), topLevelTraversableIds: remainingTopLevelTraversableIds, userContextIds: new Set(subscription.userContextIds), }; newSubscriptions.push(partialSubscription); const traversableIdsToUnsubscribe = subscription.topLevelTraversableIds.difference( remainingTopLevelTraversableIds ); for (const traversableId of traversableIdsToUnsubscribe) { listeners.push( this.#createListenerToUnsubscribe({ eventName, traversableId }) ); } } } } if (matchedEvents.symmetricDifference(eventNames).size > 0) { throw new lazy.error.InvalidArgumentError( `Failed to unsubscribe from events: ${Array.from(eventNames).join(", ")}` ); } if ( topLevelTraversableContextIds.size > 0 && matchedContexts.symmetricDifference(topLevelTraversableContextIds).size > 0 ) { throw new lazy.error.InvalidArgumentError( `Failed to unsubscribe from events: ${Array.from(eventNames).join(", ")} for context ids: ${Array.from(topLevelTraversableContextIds).join(", ")}` ); } this.#subscriptions = newSubscriptions; return listeners; } #unsubscribeById(subscriptionIds) { this.#assertNonEmptyArrayWithStrings(subscriptionIds, "subscriptions"); const subscriptions = new Set(subscriptionIds); const unknownSubscriptionIds = subscriptions.difference( this.#knownSubscriptionIds ); if (unknownSubscriptionIds.size !== 0) { throw new lazy.error.InvalidArgumentError( `Failed to unsubscribe from subscriptions with ids: ${Array.from(subscriptionIds).join(", ")} ` + `(unknown ids: ${Array.from(unknownSubscriptionIds).join(", ")})` ); } const listeners = []; const subscriptionIdsToRemove = new Set(); const subscriptionsToRemove = new Set(); for (const subscription of this.#subscriptions) { const { subscriptionId } = subscription; if (!subscriptions.has(subscriptionId)) { continue; } subscriptionIdsToRemove.add(subscriptionId); subscriptionsToRemove.add(subscription); } this.#knownSubscriptionIds = this.#knownSubscriptionIds.difference(subscriptions); this.#subscriptions = this.#subscriptions.filter( ({ subscriptionId }) => !subscriptionIdsToRemove.has(subscriptionId) ); for (const subscription of subscriptionsToRemove) { listeners.push(...this.#getListenersToUnsubscribe(subscription)); } return listeners; } } // To export the class as lower-case export const session = SessionModule;