"use strict"; /** * Copyright 2022 Google LLC. * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.BrowsingContextImpl = void 0; const protocol_js_1 = require("../../../protocol/protocol.js"); const assert_js_1 = require("../../../utils/assert.js"); const Deferred_js_1 = require("../../../utils/Deferred.js"); const log_js_1 = require("../../../utils/log.js"); const unitConversions_js_1 = require("../../../utils/unitConversions.js"); const Realm_js_1 = require("../script/Realm.js"); class BrowsingContextImpl { static LOGGER_PREFIX = `${log_js_1.LogType.debug}:browsingContext`; /** The ID of this browsing context. */ #id; /** * The ID of the parent browsing context. * If null, this is a top-level context. */ #parentId; /** Direct children browsing contexts. */ #children = new Set(); #browsingContextStorage; #deferreds = { Page: { navigatedWithinDocument: new Deferred_js_1.Deferred(), lifecycleEvent: { DOMContentLoaded: new Deferred_js_1.Deferred(), load: new Deferred_js_1.Deferred(), }, frameStartedLoading: new Deferred_js_1.Deferred(), }, }; #url = 'about:blank'; #eventManager; #realmStorage; #loaderId; #cdpTarget; #maybeDefaultRealm; #logger; constructor(cdpTarget, realmStorage, id, parentId, eventManager, browsingContextStorage, logger) { this.#cdpTarget = cdpTarget; this.#realmStorage = realmStorage; this.#id = id; this.#parentId = parentId; this.#eventManager = eventManager; this.#browsingContextStorage = browsingContextStorage; this.#logger = logger; } static create(cdpTarget, realmStorage, id, parentId, eventManager, browsingContextStorage, logger) { const context = new BrowsingContextImpl(cdpTarget, realmStorage, id, parentId, eventManager, browsingContextStorage, logger); context.#initListeners(); browsingContextStorage.addContext(context); if (!context.isTopLevelContext()) { context.parent.addChild(context.id); } eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.ContextCreated, params: context.serializeToBidiValue(), }, context.id); return context; } static getTimestamp() { // `timestamp` from the event is MonotonicTime, not real time, so // the best Mapper can do is to set the timestamp to the epoch time // of the event arrived. // https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-MonotonicTime return new Date().getTime(); } /** * @see https://html.spec.whatwg.org/multipage/document-sequences.html#navigable */ get navigableId() { return this.#loaderId; } dispose() { this.#deleteAllChildren(); this.#realmStorage.deleteRealms({ browsingContextId: this.id, }); // Remove context from the parent. if (!this.isTopLevelContext()) { this.parent.#children.delete(this.id); } this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.ContextDestroyed, params: this.serializeToBidiValue(), }, this.id); this.#browsingContextStorage.deleteContextById(this.id); } /** Returns the ID of this context. */ get id() { return this.#id; } /** Returns the parent context ID. */ get parentId() { return this.#parentId; } /** Returns the parent context. */ get parent() { if (this.parentId === null) { return null; } return this.#browsingContextStorage.getContext(this.parentId); } /** Returns all direct children contexts. */ get directChildren() { return [...this.#children].map((id) => this.#browsingContextStorage.getContext(id)); } /** Returns all children contexts, flattened. */ get allChildren() { const children = this.directChildren; return children.concat(...children.map((child) => child.allChildren)); } /** * Returns true if this is a top-level context. * This is the case whenever the parent context ID is null. */ isTopLevelContext() { return this.#parentId === null; } get top() { // eslint-disable-next-line @typescript-eslint/no-this-alias let topContext = this; let parent = topContext.parent; while (parent) { topContext = parent; parent = topContext.parent; } return topContext; } addChild(childId) { this.#children.add(childId); } #deleteAllChildren() { this.directChildren.map((child) => child.dispose()); } get #defaultRealm() { (0, assert_js_1.assert)(this.#maybeDefaultRealm, `No default realm for browsing context ${this.#id}`); return this.#maybeDefaultRealm; } get cdpTarget() { return this.#cdpTarget; } updateCdpTarget(cdpTarget) { this.#cdpTarget = cdpTarget; this.#initListeners(); } get url() { return this.#url; } async lifecycleLoaded() { await this.#deferreds.Page.lifecycleEvent.load; } async targetUnblockedOrThrow() { const result = await this.#cdpTarget.targetUnblocked; if (result.kind === 'error') { throw result.error; } } async getOrCreateSandbox(sandbox) { if (sandbox === undefined || sandbox === '') { return this.#defaultRealm; } let maybeSandboxes = this.#realmStorage.findRealms({ browsingContextId: this.id, sandbox, }); if (maybeSandboxes.length === 0) { await this.#cdpTarget.cdpClient.sendCommand('Page.createIsolatedWorld', { frameId: this.id, worldName: sandbox, }); // `Runtime.executionContextCreated` should be emitted by the time the // previous command is done. maybeSandboxes = this.#realmStorage.findRealms({ browsingContextId: this.id, sandbox, }); (0, assert_js_1.assert)(maybeSandboxes.length !== 0); } // It's possible for more than one sandbox to be created due to provisional // frames. In this case, it's always the first one (i.e. the oldest one) // that is more relevant since the user may have set that one up already // through evaluation. return maybeSandboxes[0]; } serializeToBidiValue(maxDepth = 0, addParentField = true) { return { context: this.#id, url: this.url, children: maxDepth > 0 ? this.directChildren.map((c) => c.serializeToBidiValue(maxDepth - 1, false)) : null, ...(addParentField ? { parent: this.#parentId } : {}), }; } onTargetInfoChanged(params) { this.#url = params.targetInfo.url; } #initListeners() { this.#cdpTarget.cdpClient.on('Page.frameNavigated', (params) => { if (this.id !== params.frame.id) { return; } this.#url = params.frame.url + (params.frame.urlFragment ?? ''); // At the point the page is initialized, all the nested iframes from the // previous page are detached and realms are destroyed. // Remove children from context. this.#deleteAllChildren(); }); this.#cdpTarget.cdpClient.on('Page.navigatedWithinDocument', (params) => { if (this.id !== params.frameId) { return; } const timestamp = BrowsingContextImpl.getTimestamp(); this.#url = params.url; this.#deferreds.Page.navigatedWithinDocument.resolve(params); this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated, params: { context: this.id, navigation: null, timestamp, url: this.#url, }, }, this.id); }); this.#cdpTarget.cdpClient.on('Page.frameStartedLoading', (params) => { if (this.id !== params.frameId) { return; } this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted, params: { context: this.id, navigation: null, timestamp: BrowsingContextImpl.getTimestamp(), url: '', }, }, this.id); }); this.#cdpTarget.cdpClient.on('Page.lifecycleEvent', (params) => { if (this.id !== params.frameId) { return; } if (params.name === 'init') { this.#documentChanged(params.loaderId); return; } if (params.name === 'commit') { this.#loaderId = params.loaderId; return; } // Ignore event from not current navigation. if (params.loaderId !== this.#loaderId) { return; } const timestamp = BrowsingContextImpl.getTimestamp(); switch (params.name) { case 'DOMContentLoaded': this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.DomContentLoaded, params: { context: this.id, navigation: this.#loaderId ?? null, timestamp, url: this.#url, }, }, this.id); this.#deferreds.Page.lifecycleEvent.DOMContentLoaded.resolve(params); break; case 'load': this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.Load, params: { context: this.id, navigation: this.#loaderId ?? null, timestamp, url: this.#url, }, }, this.id); this.#deferreds.Page.lifecycleEvent.load.resolve(params); break; } }); this.#cdpTarget.cdpClient.on('Runtime.executionContextCreated', (params) => { if (params.context.auxData.frameId !== this.id) { return; } // Only this execution contexts are supported for now. if (!['default', 'isolated'].includes(params.context.auxData.type)) { return; } const realm = new Realm_js_1.Realm(this.#realmStorage, this.#browsingContextStorage, params.context.uniqueId, this.id, params.context.id, this.#getOrigin(params), // XXX: differentiate types. 'window', // Sandbox name for isolated world. params.context.auxData.type === 'isolated' ? params.context.name : undefined, this.#cdpTarget.cdpClient, this.#eventManager, this.#logger); if (params.context.auxData.isDefault) { this.#maybeDefaultRealm = realm; // Initialize ChannelProxy listeners for all the channels of all the // preload scripts related to this BrowsingContext. // TODO: extend for not default realms by the sandbox name. void Promise.all(this.#cdpTarget .getChannels() .map((channel) => channel.startListenerFromWindow(realm, this.#eventManager))); } }); this.#cdpTarget.cdpClient.on('Runtime.executionContextDestroyed', (params) => { this.#realmStorage.deleteRealms({ cdpSessionId: this.#cdpTarget.cdpSessionId, executionContextId: params.executionContextId, }); }); this.#cdpTarget.cdpClient.on('Runtime.executionContextsCleared', () => { this.#realmStorage.deleteRealms({ cdpSessionId: this.#cdpTarget.cdpSessionId, }); }); this.#cdpTarget.cdpClient.on('Page.javascriptDialogClosed', (params) => { const accepted = params.result; this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.UserPromptClosed, params: { context: this.id, accepted, userText: accepted && params.userInput ? params.userInput : undefined, }, }, this.id); }); this.#cdpTarget.cdpClient.on('Page.javascriptDialogOpening', (params) => { this.#eventManager.registerEvent({ type: 'event', method: protocol_js_1.ChromiumBidi.BrowsingContext.EventNames.UserPromptOpened, params: { context: this.id, type: params.type, message: params.message, // Don't set the value if empty string defaultValue: params.defaultPrompt || undefined, }, }, this.id); }); } #getOrigin(params) { if (params.context.auxData.type === 'isolated') { // Sandbox should have the same origin as the context itself, but in CDP // it has an empty one. return this.#defaultRealm.origin; } // https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin return ['://', ''].includes(params.context.origin) ? 'null' : params.context.origin; } #documentChanged(loaderId) { // Same document navigation. if (loaderId === undefined || this.#loaderId === loaderId) { if (this.#deferreds.Page.navigatedWithinDocument.isFinished) { this.#deferreds.Page.navigatedWithinDocument = new Deferred_js_1.Deferred(); } else { this.#logger?.(BrowsingContextImpl.LOGGER_PREFIX, 'Document changed (navigatedWithinDocument)'); } return; } this.#resetDeferredsIfFinished(); this.#loaderId = loaderId; } #resetDeferredsIfFinished() { if (this.#deferreds.Page.lifecycleEvent.DOMContentLoaded.isFinished) { this.#deferreds.Page.lifecycleEvent.DOMContentLoaded = new Deferred_js_1.Deferred(); } else { this.#logger?.(BrowsingContextImpl.LOGGER_PREFIX, 'Document changed (DOMContentLoaded)'); } if (this.#deferreds.Page.lifecycleEvent.load.isFinished) { this.#deferreds.Page.lifecycleEvent.load = new Deferred_js_1.Deferred(); } else { this.#logger?.(BrowsingContextImpl.LOGGER_PREFIX, 'Document changed (load)'); } } async navigate(url, wait) { try { new URL(url); } catch { throw new protocol_js_1.InvalidArgumentException(`Invalid URL: ${url}`); } await this.targetUnblockedOrThrow(); // TODO: handle loading errors. const cdpNavigateResult = await this.#cdpTarget.cdpClient.sendCommand('Page.navigate', { url, frameId: this.id, }); if (cdpNavigateResult.errorText) { throw new protocol_js_1.UnknownErrorException(cdpNavigateResult.errorText); } this.#documentChanged(cdpNavigateResult.loaderId); switch (wait) { case "none" /* BrowsingContext.ReadinessState.None */: break; case "interactive" /* BrowsingContext.ReadinessState.Interactive */: // No `loaderId` means same-document navigation. if (cdpNavigateResult.loaderId === undefined) { await this.#deferreds.Page.navigatedWithinDocument; } else { await this.#deferreds.Page.lifecycleEvent.DOMContentLoaded; } break; case "complete" /* BrowsingContext.ReadinessState.Complete */: // No `loaderId` means same-document navigation. if (cdpNavigateResult.loaderId === undefined) { await this.#deferreds.Page.navigatedWithinDocument; } else { await this.lifecycleLoaded(); } break; } return { navigation: cdpNavigateResult.loaderId ?? null, // Url can change due to redirect get the latest one. url: wait === "none" /* BrowsingContext.ReadinessState.None */ ? url : this.#url, }; } async reload(ignoreCache, wait) { await this.targetUnblockedOrThrow(); await this.#cdpTarget.cdpClient.sendCommand('Page.reload', { ignoreCache, }); this.#resetDeferredsIfFinished(); switch (wait) { case "none" /* BrowsingContext.ReadinessState.None */: break; case "interactive" /* BrowsingContext.ReadinessState.Interactive */: await this.#deferreds.Page.lifecycleEvent.DOMContentLoaded; break; case "complete" /* BrowsingContext.ReadinessState.Complete */: await this.lifecycleLoaded(); break; } return { navigation: wait === "none" /* BrowsingContext.ReadinessState.None */ ? null : this.navigableId ?? null, url: this.url, }; } async setViewport(viewport) { if (viewport === null) { await this.#cdpTarget.cdpClient.sendCommand('Emulation.clearDeviceMetricsOverride'); } else { try { await this.#cdpTarget.cdpClient.sendCommand('Emulation.setDeviceMetricsOverride', { width: viewport.width, height: viewport.height, deviceScaleFactor: 0, mobile: false, dontSetVisibleSize: true, }); } catch (err) { if (err.message.startsWith( // https://crsrc.org/c/content/browser/devtools/protocol/emulation_handler.cc;l=257;drc=2f6eee84cf98d4227e7c41718dd71b82f26d90ff 'Width and height values must be positive')) { throw new protocol_js_1.UnsupportedOperationException('Provided viewport dimensions are not supported'); } throw err; } } } async handleUserPrompt(params) { await this.#cdpTarget.cdpClient.sendCommand('Page.handleJavaScriptDialog', { accept: params.accept ?? true, promptText: params.userText, }); } async activate() { await this.#cdpTarget.cdpClient.sendCommand('Page.bringToFront'); } async captureScreenshot(params) { if (!this.isTopLevelContext()) { throw new protocol_js_1.UnsupportedOperationException(`Non-top-level 'context' (${params.context}) is currently not supported`); } // XXX: Focus the original tab after the screenshot is taken. // This is needed because the screenshot gets blocked until the active tab gets focus. await this.#cdpTarget.cdpClient.sendCommand('Page.bringToFront'); let rect = await this.#parseRect(params.clip); const { cssContentSize, cssLayoutViewport } = await this.#cdpTarget.cdpClient.sendCommand('Page.getLayoutMetrics'); const viewport = { x: cssContentSize.x, y: cssContentSize.y, width: cssLayoutViewport.clientWidth, height: cssLayoutViewport.clientHeight, }; rect = rect ? getIntersectionRect(rect, viewport) : viewport; if (rect.width === 0 || rect.height === 0) { throw new protocol_js_1.UnableToCaptureScreenException(`Unable to capture screenshot with zero dimensions: width=${rect.width}, height=${rect.height}`); } const result = await this.#cdpTarget.cdpClient.sendCommand('Page.captureScreenshot', { clip: { ...rect, scale: 1.0 } }); return { data: result.data, }; } async print(params) { const cdpParams = {}; if (params.background !== undefined) { cdpParams.printBackground = params.background; } if (params.margin?.bottom !== undefined) { cdpParams.marginBottom = (0, unitConversions_js_1.inchesFromCm)(params.margin.bottom); } if (params.margin?.left !== undefined) { cdpParams.marginLeft = (0, unitConversions_js_1.inchesFromCm)(params.margin.left); } if (params.margin?.right !== undefined) { cdpParams.marginRight = (0, unitConversions_js_1.inchesFromCm)(params.margin.right); } if (params.margin?.top !== undefined) { cdpParams.marginTop = (0, unitConversions_js_1.inchesFromCm)(params.margin.top); } if (params.orientation !== undefined) { cdpParams.landscape = params.orientation === 'landscape'; } if (params.page?.height !== undefined) { cdpParams.paperHeight = (0, unitConversions_js_1.inchesFromCm)(params.page.height); } if (params.page?.width !== undefined) { cdpParams.paperWidth = (0, unitConversions_js_1.inchesFromCm)(params.page.width); } if (params.pageRanges !== undefined) { for (const range of params.pageRanges) { if (typeof range === 'number') { continue; } const rangeParts = range.split('-'); if (rangeParts.length < 1 || rangeParts.length > 2) { throw new protocol_js_1.InvalidArgumentException(`Invalid page range: ${range} is not a valid integer range.`); } if (rangeParts.length === 1) { void parseInteger(rangeParts[0] ?? ''); continue; } let lowerBound; let upperBound; const [rangeLowerPart = '', rangeUpperPart = ''] = rangeParts; if (rangeLowerPart === '') { lowerBound = 1; } else { lowerBound = parseInteger(rangeLowerPart); } if (rangeUpperPart === '') { upperBound = Number.MAX_SAFE_INTEGER; } else { upperBound = parseInteger(rangeUpperPart); } if (lowerBound > upperBound) { throw new protocol_js_1.InvalidArgumentException(`Invalid page range: ${rangeLowerPart} > ${rangeUpperPart}`); } } cdpParams.pageRanges = params.pageRanges.join(','); } if (params.scale !== undefined) { cdpParams.scale = params.scale; } if (params.shrinkToFit !== undefined) { cdpParams.preferCSSPageSize = !params.shrinkToFit; } try { const result = await this.#cdpTarget.cdpClient.sendCommand('Page.printToPDF', cdpParams); return { data: result.data, }; } catch (error) { // Effectively zero dimensions. if (error.message === 'invalid print parameters: content area is empty') { throw new protocol_js_1.UnsupportedOperationException(error.message); } throw error; } } /** * See * https://w3c.github.io/webdriver-bidi/#:~:text=If%20command%20parameters%20contains%20%22clip%22%3A */ async #parseRect(clip) { if (!clip) { return; } switch (clip.type) { case 'viewport': return { x: clip.x, y: clip.y, width: clip.width, height: clip.height }; case 'element': { if (clip.scrollIntoView) { throw new protocol_js_1.UnsupportedOperationException(`'scrollIntoView' is currently not supported`); } // TODO: #1213: Use custom sandbox specifically for Chromium BiDi const sandbox = await this.getOrCreateSandbox(undefined); const result = await sandbox.callFunction(String((element) => { return element instanceof Element; }), { type: 'undefined' }, [clip.element], false, "none" /* Script.ResultOwnership.None */, {}); if (result.type === 'exception') { throw new protocol_js_1.NoSuchElementException(`Element '${clip.element.sharedId}' was not found`); } (0, assert_js_1.assert)(result.result.type === 'boolean'); if (!result.result.value) { throw new protocol_js_1.NoSuchElementException(`Node '${clip.element.sharedId}' is not an Element`); } { const result = await sandbox.callFunction(String((element) => { const rect = element.getBoundingClientRect(); return { x: rect.x, y: rect.y, height: rect.height, width: rect.width, }; }), { type: 'undefined' }, [clip.element], false, "none" /* Script.ResultOwnership.None */, {}); (0, assert_js_1.assert)(result.type === 'success'); const rect = deserializeDOMRect(result.result); if (!rect) { throw new protocol_js_1.UnableToCaptureScreenException(`Could not get bounding box for Element '${clip.element.sharedId}'`); } return rect; } } } } async close() { await this.#cdpTarget.cdpClient.sendCommand('Page.close'); } } exports.BrowsingContextImpl = BrowsingContextImpl; function deserializeDOMRect(result) { if (result.type !== 'object' || result.value === undefined) { return; } const x = result.value.find(([key]) => { return key === 'x'; })?.[1]; const y = result.value.find(([key]) => { return key === 'y'; })?.[1]; const height = result.value.find(([key]) => { return key === 'height'; })?.[1]; const width = result.value.find(([key]) => { return key === 'width'; })?.[1]; if (x?.type !== 'number' || y?.type !== 'number' || height?.type !== 'number' || width?.type !== 'number') { return; } return { x: x.value, y: y.value, width: width.value, height: height.value, }; } /** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */ function normalizeRect(box) { return { ...(box.width < 0 ? { x: box.x + box.width, width: -box.width, } : { x: box.x, width: box.width, }), ...(box.height < 0 ? { y: box.y + box.height, height: -box.height, } : { y: box.y, height: box.height, }), }; } /** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */ function getIntersectionRect(first, second) { first = normalizeRect(first); second = normalizeRect(second); const x = Math.max(first.x, second.x); const y = Math.max(first.y, second.y); return { x, y, width: Math.max(Math.min(first.x + first.width, second.x + second.width) - x, 0), height: Math.max(Math.min(first.y + first.height, second.y + second.height) - y, 0), }; } function parseInteger(value) { value = value.trim(); if (!/^[0-9]+$/.test(value)) { throw new protocol_js_1.InvalidArgumentException(`Invalid integer: ${value}`); } return parseInt(value); } //# sourceMappingURL=BrowsingContextImpl.js.map