"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CdpTarget = void 0; const chromium_bidi_js_1 = require("../../../protocol/chromium-bidi.js"); const protocol_js_1 = require("../../../protocol/protocol.js"); const Deferred_js_1 = require("../../../utils/Deferred.js"); const log_js_1 = require("../../../utils/log.js"); const BrowsingContextImpl_js_1 = require("../context/BrowsingContextImpl.js"); const LogManager_js_1 = require("../log/LogManager.js"); const NetworkStorage_js_1 = require("../network/NetworkStorage.js"); class CdpTarget { #id; userContext; #cdpClient; #browserCdpClient; #parentCdpClient; #realmStorage; #eventManager; #preloadScriptStorage; #browsingContextStorage; #networkStorage; contextConfigStorage; #unblocked = new Deferred_js_1.Deferred(); // Default user agent for the target. Required, as emulating client hints without user // agent is not possible. Cache it to avoid round trips to the browser for every target override. #defaultUserAgent; #logger; /** * Target's window id. Is filled when the CDP target is created and do not reflect * moving targets from one window to another. The actual values * will be set during `#unblock`. * */ #windowId; #deviceAccessEnabled = false; #cacheDisableState = false; #preloadEnabled = false; #fetchDomainStages = { request: false, response: false, auth: false, }; static create(targetId, cdpClient, browserCdpClient, parentCdpClient, realmStorage, eventManager, preloadScriptStorage, browsingContextStorage, networkStorage, configStorage, userContext, defaultUserAgent, logger) { const cdpTarget = new CdpTarget(targetId, cdpClient, browserCdpClient, parentCdpClient, eventManager, realmStorage, preloadScriptStorage, browsingContextStorage, configStorage, networkStorage, userContext, defaultUserAgent, logger); LogManager_js_1.LogManager.create(cdpTarget, realmStorage, eventManager, logger); cdpTarget.#setEventListeners(); // No need to await. // Deferred will be resolved when the target is unblocked. void cdpTarget.#unblock(); return cdpTarget; } constructor(targetId, cdpClient, browserCdpClient, parentCdpClient, eventManager, realmStorage, preloadScriptStorage, browsingContextStorage, configStorage, networkStorage, userContext, defaultUserAgent, logger) { this.#defaultUserAgent = defaultUserAgent; this.userContext = userContext; this.#id = targetId; this.#cdpClient = cdpClient; this.#browserCdpClient = browserCdpClient; this.#parentCdpClient = parentCdpClient; this.#eventManager = eventManager; this.#realmStorage = realmStorage; this.#preloadScriptStorage = preloadScriptStorage; this.#networkStorage = networkStorage; this.#browsingContextStorage = browsingContextStorage; this.contextConfigStorage = configStorage; this.#logger = logger; } /** Returns a deferred that resolves when the target is unblocked. */ get unblocked() { return this.#unblocked; } get id() { return this.#id; } get cdpClient() { return this.#cdpClient; } get parentCdpClient() { return this.#parentCdpClient; } get browserCdpClient() { return this.#browserCdpClient; } /** Needed for CDP escape path. */ get cdpSessionId() { // SAFETY we got the client by it's id for creating return this.#cdpClient.sessionId; } /** * Window id the target belongs to. If not known, returns 0. */ get windowId() { if (this.#windowId === undefined) { this.#logger?.(log_js_1.LogType.debugError, 'Getting windowId before it was set, returning 0'); } return this.#windowId ?? 0; } /** * Enables all the required CDP domains and unblocks the target. */ async #unblock() { const config = this.contextConfigStorage.getActiveConfig(this.topLevelId, this.userContext); const results = await Promise.allSettled([ this.#cdpClient.sendCommand('Page.enable', { enableFileChooserOpenedEvent: true, }), ...(this.#ignoreFileDialog() ? [] : [ this.#cdpClient.sendCommand('Page.setInterceptFileChooserDialog', { enabled: true, // The intercepted dialog should be canceled. cancel: true, }), ]), // There can be some existing frames in the target, if reconnecting to an // existing browser instance, e.g. via Puppeteer. Need to restore the browsing // contexts for the frames to correctly handle further events, like // `Runtime.executionContextCreated`. // It's important to schedule this task together with enabling domains commands to // prepare the tree before the events (e.g. Runtime.executionContextCreated) start // coming. // https://github.com/GoogleChromeLabs/chromium-bidi/issues/2282 this.#cdpClient .sendCommand('Page.getFrameTree') .then((frameTree) => this.#restoreFrameTreeState(frameTree.frameTree)), this.#cdpClient.sendCommand('Runtime.enable'), this.#cdpClient.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true, }), // Enabling CDP Network domain is required for navigation detection: // https://github.com/GoogleChromeLabs/chromium-bidi/issues/2856. this.#cdpClient .sendCommand('Network.enable', { // If `googDisableNetworkDurableMessages` flag is set, do not enable durable // messages. enableDurableMessages: config.disableNetworkDurableMessages !== true, maxTotalBufferSize: NetworkStorage_js_1.MAX_TOTAL_COLLECTED_SIZE, }) .then(() => this.toggleNetworkIfNeeded()), this.#cdpClient.sendCommand('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, }), this.#updateWindowId(), this.#setUserContextConfig(config), this.#initAndEvaluatePreloadScripts(), this.#cdpClient.sendCommand('Runtime.runIfWaitingForDebugger'), // Resume tab execution as well if it was paused by the debugger. this.#parentCdpClient.sendCommand('Runtime.runIfWaitingForDebugger'), this.toggleDeviceAccessIfNeeded(), this.togglePreloadIfNeeded(), ]); for (const result of results) { if (result instanceof Error) { // Ignore errors during configuring targets, just log them. this.#logger?.(log_js_1.LogType.debugError, 'Error happened when configuring a new target', result); } } this.#unblocked.resolve({ kind: 'success', value: undefined, }); } #restoreFrameTreeState(frameTree) { const frame = frameTree.frame; const maybeContext = this.#browsingContextStorage.findContext(frame.id); if (maybeContext !== undefined) { // Restoring parent of already known browsing context. This means the target is // OOPiF and the BiDi session was connected to already existing browser instance. if (maybeContext.parentId === null && frame.parentId !== null && frame.parentId !== undefined) { maybeContext.parentId = frame.parentId; } } if (maybeContext === undefined && frame.parentId !== undefined) { // Restore not yet known nested frames. The top-level frame is created when the // target is attached. const parentBrowsingContext = this.#browsingContextStorage.getContext(frame.parentId); BrowsingContextImpl_js_1.BrowsingContextImpl.create(frame.id, frame.parentId, this.userContext, parentBrowsingContext.cdpTarget, this.#eventManager, this.#browsingContextStorage, this.#realmStorage, this.contextConfigStorage, frame.url, undefined, this.#logger); } frameTree.childFrames?.map((frameTree) => this.#restoreFrameTreeState(frameTree)); } async toggleFetchIfNeeded() { const stages = this.#networkStorage.getInterceptionStages(this.topLevelId); if (this.#fetchDomainStages.request === stages.request && this.#fetchDomainStages.response === stages.response && this.#fetchDomainStages.auth === stages.auth) { return; } const patterns = []; this.#fetchDomainStages = stages; if (stages.request || stages.auth) { // CDP quirk we need request interception when we intercept auth patterns.push({ urlPattern: '*', requestStage: 'Request', }); } if (stages.response) { patterns.push({ urlPattern: '*', requestStage: 'Response', }); } if (patterns.length) { await this.#cdpClient.sendCommand('Fetch.enable', { patterns, handleAuthRequests: stages.auth, }); } else { const blockedRequest = this.#networkStorage .getRequestsByTarget(this) .filter((request) => request.interceptPhase); void Promise.allSettled(blockedRequest.map((request) => request.waitNextPhase)) .then(async () => { const blockedRequest = this.#networkStorage .getRequestsByTarget(this) .filter((request) => request.interceptPhase); if (blockedRequest.length) { return await this.toggleFetchIfNeeded(); } return await this.#cdpClient.sendCommand('Fetch.disable'); }) .catch((error) => { this.#logger?.(log_js_1.LogType.bidi, 'Disable failed', error); }); } } /** * Toggles CDP "Fetch" domain and enable/disable network cache. */ async toggleNetworkIfNeeded() { // Although the Network domain remains active, Fetch domain activation and caching // settings should be managed dynamically. try { await Promise.all([ this.toggleSetCacheDisabled(), this.toggleFetchIfNeeded(), ]); } catch (err) { this.#logger?.(log_js_1.LogType.debugError, err); if (!this.#isExpectedError(err)) { throw err; } } } async toggleSetCacheDisabled(disable) { const defaultCacheDisabled = this.#networkStorage.defaultCacheBehavior === 'bypass'; const cacheDisabled = disable ?? defaultCacheDisabled; if (this.#cacheDisableState === cacheDisabled) { return; } this.#cacheDisableState = cacheDisabled; try { await this.#cdpClient.sendCommand('Network.setCacheDisabled', { cacheDisabled, }); } catch (err) { this.#logger?.(log_js_1.LogType.debugError, err); this.#cacheDisableState = !cacheDisabled; if (!this.#isExpectedError(err)) { throw err; } } } async toggleDeviceAccessIfNeeded() { const enabled = this.isSubscribedTo(chromium_bidi_js_1.Bluetooth.EventNames.RequestDevicePromptUpdated); if (this.#deviceAccessEnabled === enabled) { return; } this.#deviceAccessEnabled = enabled; try { await this.#cdpClient.sendCommand(enabled ? 'DeviceAccess.enable' : 'DeviceAccess.disable'); } catch (err) { this.#logger?.(log_js_1.LogType.debugError, err); this.#deviceAccessEnabled = !enabled; if (!this.#isExpectedError(err)) { throw err; } } } async togglePreloadIfNeeded() { const enabled = this.isSubscribedTo(chromium_bidi_js_1.Speculation.EventNames.PrefetchStatusUpdated); if (this.#preloadEnabled === enabled) { return; } this.#preloadEnabled = enabled; try { await this.#cdpClient.sendCommand(enabled ? 'Preload.enable' : 'Preload.disable'); } catch (err) { this.#logger?.(log_js_1.LogType.debugError, err); this.#preloadEnabled = !enabled; if (!this.#isExpectedError(err)) { throw err; } } } /** * Heuristic checking if the error is due to the session being closed. If so, ignore the * error. */ #isExpectedError(err) { const error = err; return ((error.code === -32001 && error.message === 'Session with given id not found.') || this.#cdpClient.isCloseError(err)); } #setEventListeners() { this.#cdpClient.on('*', (event, params) => { // We may encounter uses for EventEmitter other than CDP events, // which we want to skip. if (typeof event !== 'string') { return; } this.#eventManager.registerEvent({ type: 'event', method: `goog:cdp.${event}`, params: { event, params, session: this.cdpSessionId, }, }, this.id); }); } async #enableFetch(stages) { const patterns = []; if (stages.request || stages.auth) { // CDP quirk we need request interception when we intercept auth patterns.push({ urlPattern: '*', requestStage: 'Request', }); } if (stages.response) { patterns.push({ urlPattern: '*', requestStage: 'Response', }); } if (patterns.length) { const oldStages = this.#fetchDomainStages; this.#fetchDomainStages = stages; try { await this.#cdpClient.sendCommand('Fetch.enable', { patterns, handleAuthRequests: stages.auth, }); } catch { this.#fetchDomainStages = oldStages; } } } async #disableFetch() { const blockedRequest = this.#networkStorage .getRequestsByTarget(this) .filter((request) => request.interceptPhase); if (blockedRequest.length === 0) { this.#fetchDomainStages = { request: false, response: false, auth: false, }; await this.#cdpClient.sendCommand('Fetch.disable'); } } async toggleNetwork() { // TODO: respect the data collectors once CDP Network domain is enabled on-demand: // const networkEnable = this.#networkStorage.getCollectorsForBrowsingContext(this.topLevelId).length > 0; const stages = this.#networkStorage.getInterceptionStages(this.topLevelId); const fetchEnable = Object.values(stages).some((value) => value); const fetchChanged = this.#fetchDomainStages.request !== stages.request || this.#fetchDomainStages.response !== stages.response || this.#fetchDomainStages.auth !== stages.auth; this.#logger?.(log_js_1.LogType.debugInfo, 'Toggle Network', `Fetch (${fetchEnable}) ${fetchChanged}`); if (fetchEnable && fetchChanged) { await this.#enableFetch(stages); } if (!fetchEnable && fetchChanged) { await this.#disableFetch(); } } /** * All the ProxyChannels from all the preload scripts of the given * BrowsingContext. */ getChannels() { return this.#preloadScriptStorage .find() .flatMap((script) => script.channels); } async #updateWindowId() { const { windowId } = await this.#browserCdpClient.sendCommand('Browser.getWindowForTarget', { targetId: this.id }); this.#windowId = windowId; } /** Loads all top-level preload scripts. */ async #initAndEvaluatePreloadScripts() { await Promise.all(this.#preloadScriptStorage .find({ // Needed for OOPIF targetId: this.topLevelId, }) .map((script) => { return script.initInTarget(this, true); })); } async setDeviceMetricsOverride(viewport, devicePixelRatio, screenOrientation, screenArea) { if (viewport === null && devicePixelRatio === null && screenOrientation === null && screenArea === null) { await this.cdpClient.sendCommand('Emulation.clearDeviceMetricsOverride'); return; } const metricsOverride = { width: viewport?.width ?? 0, height: viewport?.height ?? 0, deviceScaleFactor: devicePixelRatio ?? 0, screenOrientation: this.#toCdpScreenOrientationAngle(screenOrientation) ?? undefined, mobile: false, screenWidth: screenArea?.width, screenHeight: screenArea?.height, }; await this.cdpClient.sendCommand('Emulation.setDeviceMetricsOverride', metricsOverride); } /** * Immediately schedules all the required commands to configure user context * configuration and waits for them to finish. It's important to schedule them * in parallel, so that they are enqueued before any page's scripts. */ async #setUserContextConfig(config) { const promises = []; promises.push(this.#cdpClient .sendCommand('Page.setPrerenderingAllowed', { isAllowed: !config.prerenderingDisabled, }) .catch(() => { // Ignore CDP errors, as the command is not supported by iframe targets or // prerendered pages. Generic catch, as the error can vary between CdpClient // implementations: Tab vs Puppeteer. })); if (config.viewport !== undefined || config.devicePixelRatio !== undefined || config.screenOrientation !== undefined || config.screenArea !== undefined) { promises.push(this.setDeviceMetricsOverride(config.viewport ?? null, config.devicePixelRatio ?? null, config.screenOrientation ?? null, config.screenArea ?? null).catch(() => { // Ignore CDP errors, as the command is not supported by iframe targets. Generic // catch, as the error can vary between CdpClient implementations: Tab vs // Puppeteer. })); } if (config.geolocation !== undefined && config.geolocation !== null) { promises.push(this.setGeolocationOverride(config.geolocation)); } if (config.locale !== undefined) { promises.push(this.setLocaleOverride(config.locale)); } if (config.timezone !== undefined) { promises.push(this.setTimezoneOverride(config.timezone)); } if (config.extraHeaders !== undefined) { promises.push(this.setExtraHeaders(config.extraHeaders)); } if (config.userAgent !== undefined || config.locale !== undefined || config.clientHints !== undefined) { promises.push(this.setUserAgentAndAcceptLanguage(config.userAgent, config.locale, config.clientHints)); } if (config.scriptingEnabled !== undefined) { promises.push(this.setScriptingEnabled(config.scriptingEnabled)); } if (config.acceptInsecureCerts !== undefined) { promises.push(this.cdpClient.sendCommand('Security.setIgnoreCertificateErrors', { ignore: config.acceptInsecureCerts, })); } if (config.emulatedNetworkConditions !== undefined) { promises.push(this.setEmulatedNetworkConditions(config.emulatedNetworkConditions)); } if (config.maxTouchPoints !== undefined) { promises.push(this.setTouchOverride(config.maxTouchPoints)); } await Promise.all(promises); } get topLevelId() { return (this.#browsingContextStorage.findTopLevelContextId(this.id) ?? this.id); } isSubscribedTo(moduleOrEvent) { return this.#eventManager.subscriptionManager.isSubscribedTo(moduleOrEvent, this.topLevelId); } #ignoreFileDialog() { const config = this.contextConfigStorage.getActiveConfig(this.topLevelId, this.userContext); return ((config.userPromptHandler?.file ?? config.userPromptHandler?.default ?? "ignore" /* Session.UserPromptHandlerType.Ignore */) === "ignore" /* Session.UserPromptHandlerType.Ignore */); } async setGeolocationOverride(geolocation) { if (geolocation === null) { await this.cdpClient.sendCommand('Emulation.clearGeolocationOverride'); } else if ('type' in geolocation) { if (geolocation.type !== 'positionUnavailable') { // Unreachable. Handled by params parser. throw new protocol_js_1.UnknownErrorException(`Unknown geolocation error ${geolocation.type}`); } // Omitting latitude, longitude or accuracy emulates position unavailable. await this.cdpClient.sendCommand('Emulation.setGeolocationOverride', {}); } else if ('latitude' in geolocation) { await this.cdpClient.sendCommand('Emulation.setGeolocationOverride', { latitude: geolocation.latitude, longitude: geolocation.longitude, accuracy: geolocation.accuracy ?? 1, // `null` value is treated as "missing". altitude: geolocation.altitude ?? undefined, altitudeAccuracy: geolocation.altitudeAccuracy ?? undefined, heading: geolocation.heading ?? undefined, speed: geolocation.speed ?? undefined, }); } else { // Unreachable. Handled by params parser. throw new protocol_js_1.UnknownErrorException('Unexpected geolocation coordinates value'); } } async setTouchOverride(maxTouchPoints) { const touchEmulationParams = { enabled: maxTouchPoints !== null, }; if (maxTouchPoints !== null) { touchEmulationParams.maxTouchPoints = maxTouchPoints; } await this.cdpClient.sendCommand('Emulation.setTouchEmulationEnabled', touchEmulationParams); } #toCdpScreenOrientationAngle(orientation) { if (orientation === null) { return null; } // https://w3c.github.io/screen-orientation/#the-current-screen-orientation-type-and-angle if (orientation.natural === "portrait" /* Emulation.ScreenOrientationNatural.Portrait */) { switch (orientation.type) { case 'portrait-primary': return { angle: 0, type: 'portraitPrimary', }; case 'landscape-primary': return { angle: 90, type: 'landscapePrimary', }; case 'portrait-secondary': return { angle: 180, type: 'portraitSecondary', }; case 'landscape-secondary': return { angle: 270, type: 'landscapeSecondary', }; default: // Unreachable. throw new protocol_js_1.UnknownErrorException(`Unexpected screen orientation type ${orientation.type}`); } } if (orientation.natural === "landscape" /* Emulation.ScreenOrientationNatural.Landscape */) { switch (orientation.type) { case 'landscape-primary': return { angle: 0, type: 'landscapePrimary', }; case 'portrait-primary': return { angle: 90, type: 'portraitPrimary', }; case 'landscape-secondary': return { angle: 180, type: 'landscapeSecondary', }; case 'portrait-secondary': return { angle: 270, type: 'portraitSecondary', }; default: // Unreachable. throw new protocol_js_1.UnknownErrorException(`Unexpected screen orientation type ${orientation.type}`); } } // Unreachable. throw new protocol_js_1.UnknownErrorException(`Unexpected orientation natural ${orientation.natural}`); } async setLocaleOverride(locale) { if (locale === null) { await this.cdpClient.sendCommand('Emulation.setLocaleOverride', {}); } else { await this.cdpClient.sendCommand('Emulation.setLocaleOverride', { locale, }); } } async setScriptingEnabled(scriptingEnabled) { await this.cdpClient.sendCommand('Emulation.setScriptExecutionDisabled', { value: scriptingEnabled === false, }); } async setTimezoneOverride(timezone) { if (timezone === null) { await this.cdpClient.sendCommand('Emulation.setTimezoneOverride', { // If empty, disables the override and restores default host system timezone. timezoneId: '', }); } else { await this.cdpClient.sendCommand('Emulation.setTimezoneOverride', { timezoneId: timezone, }); } } async setExtraHeaders(headers) { await this.cdpClient.sendCommand('Network.setExtraHTTPHeaders', { headers, }); } async setUserAgentAndAcceptLanguage(userAgent, acceptLanguage, clientHints) { const userAgentMetadata = clientHints ? { brands: clientHints.brands?.map((b) => ({ brand: b.brand, version: b.version, })), fullVersionList: clientHints.fullVersionList, platform: clientHints.platform ?? '', platformVersion: clientHints.platformVersion ?? '', architecture: clientHints.architecture ?? '', model: clientHints.model ?? '', mobile: clientHints.mobile ?? false, bitness: clientHints.bitness ?? undefined, wow64: clientHints.wow64 ?? undefined, formFactors: clientHints.formFactors ?? undefined, } : undefined; await this.cdpClient.sendCommand('Emulation.setUserAgentOverride', { // `userAgent` is required if `userAgentMetadata` is provided. userAgent: userAgent || (userAgentMetadata ? this.#defaultUserAgent : ''), acceptLanguage: acceptLanguage ?? undefined, // We need to provide the platform to enable platform emulation. // Note that the value might be different from the one expected by the // legacy `navigator.platform` (e.g. `Win32` vs `Windows`). // https://github.com/w3c/webdriver-bidi/issues/1065 platform: clientHints?.platform ?? undefined, userAgentMetadata, }); } async setEmulatedNetworkConditions(networkConditions) { if (networkConditions !== null && networkConditions.type !== 'offline') { throw new protocol_js_1.UnsupportedOperationException(`Unsupported network conditions ${networkConditions.type}`); } await Promise.all([ this.cdpClient.sendCommand('Network.emulateNetworkConditionsByRule', { offline: networkConditions?.type === 'offline', matchedNetworkConditions: [ { urlPattern: '', latency: 0, downloadThroughput: -1, uploadThroughput: -1, }, ], }), this.cdpClient.sendCommand('Network.overrideNetworkState', { offline: networkConditions?.type === 'offline', // TODO: restore the original `latency` value when emulation is removed. latency: 0, downloadThroughput: -1, uploadThroughput: -1, }), ]); } } exports.CdpTarget = CdpTarget; //# sourceMappingURL=CdpTarget.js.map