/** * @license * Copyright 2024 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import {EventEmitter} from '../../common/EventEmitter.js'; import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js'; import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; import type {BrowsingContext} from './BrowsingContext.js'; import type {SharedWorkerRealm} from './Realm.js'; import type {Session} from './Session.js'; import {UserContext} from './UserContext.js'; /** * @internal */ export type AddPreloadScriptOptions = Omit< Bidi.Script.AddPreloadScriptParameters, 'functionDeclaration' | 'contexts' > & { contexts?: [BrowsingContext, ...BrowsingContext[]]; }; /** * @internal */ export class Browser extends EventEmitter<{ /** Emitted before the browser closes. */ closed: { /** The reason for closing the browser. */ reason: string; }; /** Emitted after the browser disconnects. */ disconnected: { /** The reason for disconnecting the browser. */ reason: string; }; /** Emitted when a shared worker is created. */ sharedworker: { /** The realm of the shared worker. */ realm: SharedWorkerRealm; }; }> { static async from(session: Session): Promise { const browser = new Browser(session); await browser.#initialize(); return browser; } // keep-sorted start #closed = false; #reason: string | undefined; readonly #disposables = new DisposableStack(); readonly #userContexts = new Map(); readonly session: Session; // keep-sorted end private constructor(session: Session) { super(); // keep-sorted start this.session = session; // keep-sorted end this.#userContexts.set( UserContext.DEFAULT, UserContext.create(this, UserContext.DEFAULT) ); } async #initialize() { const sessionEmitter = this.#disposables.use( new EventEmitter(this.session) ); sessionEmitter.once('ended', ({reason}) => { this.dispose(reason); }); sessionEmitter.on('script.realmCreated', info => { if (info.type === 'shared-worker') { // TODO: Create a SharedWorkerRealm. } }); await this.#syncBrowsingContexts(); } async #syncBrowsingContexts() { // In case contexts are created or destroyed during `getTree`, we use this // set to detect them. const contextIds = new Set(); let contexts: Bidi.BrowsingContext.Info[]; { using sessionEmitter = new EventEmitter(this.session); sessionEmitter.on('browsingContext.contextCreated', info => { contextIds.add(info.context); }); sessionEmitter.on('browsingContext.contextDestroyed', info => { contextIds.delete(info.context); }); const {result} = await this.session.send('browsingContext.getTree', {}); contexts = result.contexts; } // Simulating events so contexts are created naturally. for (const info of contexts) { if (contextIds.has(info.context)) { this.session.emit('browsingContext.contextCreated', info); } if (info.children) { contexts.push(...info.children); } } } // keep-sorted start block=yes get closed(): boolean { return this.#closed; } get defaultUserContext(): UserContext { // SAFETY: A UserContext is always created for the default context. return this.#userContexts.get(UserContext.DEFAULT)!; } get disconnected(): boolean { return this.#reason !== undefined; } get disposed(): boolean { return this.disconnected; } get userContexts(): Iterable { return this.#userContexts.values(); } // keep-sorted end @inertIfDisposed dispose(reason?: string, closed = false): void { this.#closed = closed; this.#reason = reason; this[disposeSymbol](); } @throwIfDisposed(browser => { // SAFETY: By definition of `disposed`, `#reason` is defined. return browser.#reason!; }) async close(): Promise { try { await this.session.send('browser.close', {}); } finally { this.dispose('Browser already closed.', true); } } @throwIfDisposed(browser => { // SAFETY: By definition of `disposed`, `#reason` is defined. return browser.#reason!; }) async addPreloadScript( functionDeclaration: string, options: AddPreloadScriptOptions = {} ): Promise { const { result: {script}, } = await this.session.send('script.addPreloadScript', { functionDeclaration, ...options, contexts: options.contexts?.map(context => { return context.id; }) as [string, ...string[]], }); return script; } @throwIfDisposed(browser => { // SAFETY: By definition of `disposed`, `#reason` is defined. return browser.#reason!; }) async removePreloadScript(script: string): Promise { await this.session.send('script.removePreloadScript', { script, }); } static userContextId = 0; @throwIfDisposed(browser => { // SAFETY: By definition of `disposed`, `#reason` is defined. return browser.#reason!; }) async createUserContext(): Promise { // TODO: implement incognito context https://github.com/w3c/webdriver-bidi/issues/289. // TODO: Call `createUserContext` once available. // Generating a monotonically increasing context id. const context = `${++Browser.userContextId}`; const userContext = UserContext.create(this, context); this.#userContexts.set(userContext.id, userContext); const userContextEmitter = this.#disposables.use( new EventEmitter(userContext) ); userContextEmitter.once('closed', () => { userContextEmitter.removeAllListeners(); this.#userContexts.delete(context); }); return userContext; } [disposeSymbol](): void { this.#reason ??= 'Browser was disconnected, probably because the session ended.'; if (this.closed) { this.emit('closed', {reason: this.#reason}); } this.emit('disconnected', {reason: this.#reason}); this.#disposables.dispose(); super[disposeSymbol](); } }