diff options
Diffstat (limited to '')
-rw-r--r-- | remote/cdp/observers/ChannelEventSink.sys.mjs | 100 | ||||
-rw-r--r-- | remote/cdp/observers/ContextObserver.sys.mjs | 178 | ||||
-rw-r--r-- | remote/cdp/observers/NetworkObserver.sys.mjs | 630 | ||||
-rw-r--r-- | remote/cdp/observers/TargetObserver.sys.mjs | 142 |
4 files changed, 1050 insertions, 0 deletions
diff --git a/remote/cdp/observers/ChannelEventSink.sys.mjs b/remote/cdp/observers/ChannelEventSink.sys.mjs new file mode 100644 index 0000000000..48e7c6ee64 --- /dev/null +++ b/remote/cdp/observers/ChannelEventSink.sys.mjs @@ -0,0 +1,100 @@ +/* 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 { ComponentUtils } from "resource://gre/modules/ComponentUtils.sys.mjs"; + +const Cm = Components.manager; + +/** + * This is a nsIChannelEventSink implementation that monitors channel redirects. + * This has been forked from: + * https://searchfox.org/mozilla-central/source/devtools/server/actors/network-monitor/channel-event-sink.js + * The rest of this module is also more or less forking: + * https://searchfox.org/mozilla-central/source/devtools/server/actors/network-monitor/network-observer.js + * TODO(try to re-unify /remote/ with /devtools code) + */ +const SINK_CLASS_DESCRIPTION = "NetworkMonitor Channel Event Sink"; +const SINK_CLASS_ID = Components.ID("{c2b4c83e-607a-405a-beab-0ef5dbfb7617}"); +const SINK_CONTRACT_ID = "@mozilla.org/network/monitor/channeleventsink;1"; +const SINK_CATEGORY_NAME = "net-channel-event-sinks"; + +function ChannelEventSink() { + this.wrappedJSObject = this; + this.collectors = new Set(); +} + +ChannelEventSink.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIChannelEventSink"]), + + registerCollector(collector) { + this.collectors.add(collector); + }, + + unregisterCollector(collector) { + this.collectors.delete(collector); + + if (this.collectors.size == 0) { + ChannelEventSinkFactory.unregister(); + } + }, + + // eslint-disable-next-line no-shadow + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + for (const collector of this.collectors) { + try { + collector._onChannelRedirect(oldChannel, newChannel, flags); + } catch (ex) { + console.error( + "StackTraceCollector.onChannelRedirect threw an exception", + ex + ); + } + } + callback.onRedirectVerifyCallback(Cr.NS_OK); + }, +}; + +export const ChannelEventSinkFactory = + ComponentUtils.generateSingletonFactory(ChannelEventSink); + +ChannelEventSinkFactory.register = function () { + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + if (registrar.isCIDRegistered(SINK_CLASS_ID)) { + return; + } + + registrar.registerFactory( + SINK_CLASS_ID, + SINK_CLASS_DESCRIPTION, + SINK_CONTRACT_ID, + ChannelEventSinkFactory + ); + + Services.catMan.addCategoryEntry( + SINK_CATEGORY_NAME, + SINK_CONTRACT_ID, + SINK_CONTRACT_ID, + false, + true + ); +}; + +ChannelEventSinkFactory.unregister = function () { + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(SINK_CLASS_ID, ChannelEventSinkFactory); + + Services.catMan.deleteCategoryEntry( + SINK_CATEGORY_NAME, + SINK_CONTRACT_ID, + false + ); +}; + +ChannelEventSinkFactory.getService = function () { + // Make sure the ChannelEventSink service is registered before accessing it + ChannelEventSinkFactory.register(); + + return Cc[SINK_CONTRACT_ID].getService(Ci.nsIChannelEventSink) + .wrappedJSObject; +}; diff --git a/remote/cdp/observers/ContextObserver.sys.mjs b/remote/cdp/observers/ContextObserver.sys.mjs new file mode 100644 index 0000000000..d0d6fd1f93 --- /dev/null +++ b/remote/cdp/observers/ContextObserver.sys.mjs @@ -0,0 +1,178 @@ +/* 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/. */ + +/** + * Helper class to coordinate Runtime and Page events. + * Events have to be sent in the following order: + * - Runtime.executionContextDestroyed + * - Page.frameNavigated + * - Runtime.executionContextCreated + * + * This class also handles the special case of Pages going from/to the BF cache. + * When you navigate to a new URL, the previous document may be stored in the BF Cache. + * All its asynchronous operations are frozen (XHR, timeouts, ...) and a `pagehide` event + * is fired for this document. We then navigate to the new URL. + * If the user navigates back to the previous page, the page is resurected from the + * cache. A `pageshow` event is fired and its asynchronous operations are resumed. + * + * When a page is in the BF Cache, we should consider it as frozen and shouldn't try + * to execute any javascript. So that the ExecutionContext should be considered as + * being destroyed and the document navigated. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + executeSoon: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +export class ContextObserver { + constructor(chromeEventHandler) { + this.chromeEventHandler = chromeEventHandler; + lazy.EventEmitter.decorate(this); + + this._fissionEnabled = Services.appinfo.fissionAutostart; + + this.chromeEventHandler.addEventListener("DOMWindowCreated", this, { + mozSystemGroup: true, + }); + + // Listen for pageshow and pagehide to track pages going in/out to/from the BF Cache + this.chromeEventHandler.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + + Services.obs.addObserver(this, "document-element-inserted"); + Services.obs.addObserver(this, "inner-window-destroyed"); + + // With Fission disabled the `DOMWindowCreated` event is fired too late. + // Use the `webnavigation-create` notification instead. + if (!this._fissionEnabled) { + Services.obs.addObserver(this, "webnavigation-create"); + } + Services.obs.addObserver(this, "webnavigation-destroy"); + } + + destructor() { + this.chromeEventHandler.removeEventListener("DOMWindowCreated", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.chromeEventHandler.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + + Services.obs.removeObserver(this, "document-element-inserted"); + Services.obs.removeObserver(this, "inner-window-destroyed"); + + if (!this._fissionEnabled) { + Services.obs.removeObserver(this, "webnavigation-create"); + } + Services.obs.removeObserver(this, "webnavigation-destroy"); + } + + handleEvent({ type, target, persisted }) { + const window = target.defaultView; + const frameId = window.browsingContext.id; + const id = window.windowGlobalChild.innerWindowId; + + switch (type) { + case "DOMWindowCreated": + // Do not pass `id` here as that's the new document ID instead of the old one + // that is destroyed. Instead, pass the frameId and let the listener figure out + // what ExecutionContext(s) to destroy. + this.emit("context-destroyed", { frameId }); + + // With Fission enabled the frame is attached early enough so that + // expected network requests and responses are handles afterward. + // Otherwise send the event when `webnavigation-create` is received. + if (this._fissionEnabled) { + this.emit("frame-attached", { frameId, window }); + } + + break; + + case "pageshow": + // `persisted` is true when this is about a page being resurected from BF Cache + if (!persisted) { + return; + } + // XXX(ochameau) we might have to emit FrameNavigate here to properly handle BF Cache + // scenario in Page domain events + this.emit("context-created", { windowId: id, window }); + this.emit("script-loaded", { windowId: id, window }); + break; + + case "pagehide": + // `persisted` is true when this is about a page being frozen into BF Cache + if (!persisted) { + return; + } + this.emit("context-destroyed", { windowId: id }); + break; + } + } + + observe(subject, topic, data) { + switch (topic) { + case "document-element-inserted": + const window = subject.defaultView; + + // Ignore events without a window and those from other tabs + if ( + !window || + window.docShell.chromeEventHandler !== this.chromeEventHandler + ) { + return; + } + + // Send when the document gets attached to the window, and its location + // is available. + this.emit("frame-navigated", { + frameId: window.browsingContext.id, + window, + }); + + const id = window.windowGlobalChild.innerWindowId; + this.emit("context-created", { windowId: id, window }); + // Delay script-loaded to allow context cleanup to happen first + lazy.executeSoon(() => { + this.emit("script-loaded", { windowId: id, window }); + }); + break; + case "inner-window-destroyed": + const windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + this.emit("context-destroyed", { windowId }); + break; + case "webnavigation-create": + subject.QueryInterface(Ci.nsIDocShell); + this.onDocShellCreated(subject); + break; + case "webnavigation-destroy": + subject.QueryInterface(Ci.nsIDocShell); + this.onDocShellDestroyed(subject); + break; + } + } + + onDocShellCreated(docShell) { + this.emit("frame-attached", { + frameId: docShell.browsingContext.id, + window: docShell.browsingContext.window, + }); + } + + onDocShellDestroyed(docShell) { + this.emit("frame-detached", { + frameId: docShell.browsingContext.id, + }); + } +} diff --git a/remote/cdp/observers/NetworkObserver.sys.mjs b/remote/cdp/observers/NetworkObserver.sys.mjs new file mode 100644 index 0000000000..ffd8028ba7 --- /dev/null +++ b/remote/cdp/observers/NetworkObserver.sys.mjs @@ -0,0 +1,630 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + + ChannelEventSinkFactory: + "chrome://remote/content/cdp/observers/ChannelEventSink.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gActivityDistributor", + "@mozilla.org/network/http-activity-distributor;1", + "nsIHttpActivityDistributor" +); + +const CC = Components.Constructor; + +ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () => { + return CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "BinaryOutputStream", () => { + return CC( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "StorageStream", () => { + return CC("@mozilla.org/storagestream;1", "nsIStorageStream", "init"); +}); + +// Cap response storage with 100Mb per tracked tab. +const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024; + +export class NetworkObserver { + constructor() { + lazy.EventEmitter.decorate(this); + this._browserSessionCount = new Map(); + lazy.gActivityDistributor.addObserver(this); + lazy.ChannelEventSinkFactory.getService().registerCollector(this); + + this._redirectMap = new Map(); + + // Request interception state. + this._browserSuspendedChannels = new Map(); + this._extraHTTPHeaders = new Map(); + this._browserResponseStorages = new Map(); + + this._onRequest = this._onRequest.bind(this); + this._onExamineResponse = this._onResponse.bind( + this, + false /* fromCache */ + ); + this._onCachedResponse = this._onResponse.bind(this, true /* fromCache */); + } + + dispose() { + lazy.gActivityDistributor.removeObserver(this); + lazy.ChannelEventSinkFactory.getService().unregisterCollector(this); + + Services.obs.removeObserver(this._onRequest, "http-on-modify-request"); + Services.obs.removeObserver( + this._onExamineResponse, + "http-on-examine-response" + ); + Services.obs.removeObserver( + this._onCachedResponse, + "http-on-examine-cached-response" + ); + Services.obs.removeObserver( + this._onCachedResponse, + "http-on-examine-merged-response" + ); + } + + setExtraHTTPHeaders(browser, headers) { + if (!headers) { + this._extraHTTPHeaders.delete(browser); + } else { + this._extraHTTPHeaders.set(browser, headers); + } + } + + enableRequestInterception(browser) { + if (!this._browserSuspendedChannels.has(browser)) { + this._browserSuspendedChannels.set(browser, new Map()); + } + } + + disableRequestInterception(browser) { + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (!suspendedChannels) { + return; + } + this._browserSuspendedChannels.delete(browser); + for (const channel of suspendedChannels.values()) { + channel.resume(); + } + } + + resumeSuspendedRequest(browser, requestId, headers) { + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (!suspendedChannels) { + throw new Error(`Request interception is not enabled`); + } + const httpChannel = suspendedChannels.get(requestId); + if (!httpChannel) { + throw new Error(`Cannot find request "${requestId}"`); + } + if (headers) { + // 1. Clear all previous headers. + for (const header of requestHeaders(httpChannel)) { + httpChannel.setRequestHeader(header.name, "", false /* merge */); + } + // 2. Set new headers. + for (const header of headers) { + httpChannel.setRequestHeader( + header.name, + header.value, + false /* merge */ + ); + } + } + suspendedChannels.delete(requestId); + httpChannel.resume(); + } + + getResponseBody(browser, requestId) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) { + throw new Error("Responses are not tracked for the given browser"); + } + return responseStorage.getBase64EncodedResponse(requestId); + } + + abortSuspendedRequest(browser, aRequestId) { + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (!suspendedChannels) { + throw new Error(`Request interception is not enabled`); + } + const httpChannel = suspendedChannels.get(aRequestId); + if (!httpChannel) { + throw new Error(`Cannot find request "${aRequestId}"`); + } + suspendedChannels.delete(aRequestId); + httpChannel.cancel(Cr.NS_ERROR_FAILURE); + httpChannel.resume(); + this.emit("requestfailed", httpChannel, { + requestId: requestId(httpChannel), + errorCode: getNetworkErrorStatusText(httpChannel.status), + }); + } + + _onChannelRedirect(oldChannel, newChannel) { + // We can be called with any nsIChannel, but are interested only in HTTP channels + try { + oldChannel.QueryInterface(Ci.nsIHttpChannel); + newChannel.QueryInterface(Ci.nsIHttpChannel); + } catch (ex) { + return; + } + + const httpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel); + const loadContext = getLoadContext(httpChannel); + if ( + !loadContext || + !this._browserSessionCount.has(loadContext.topFrameElement) + ) { + return; + } + this._redirectMap.set(newChannel, oldChannel); + } + + _onRequest(channel, topic) { + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const loadContext = getLoadContext(httpChannel); + const browser = loadContext?.topFrameElement; + if (!loadContext || !this._browserSessionCount.has(browser)) { + return; + } + + const extraHeaders = this._extraHTTPHeaders.get(browser); + if (extraHeaders) { + for (const header of extraHeaders) { + httpChannel.setRequestHeader( + header.name, + header.value, + false /* merge */ + ); + } + } + const causeType = httpChannel.loadInfo + ? httpChannel.loadInfo.externalContentPolicyType + : Ci.nsIContentPolicy.TYPE_OTHER; + + const suspendedChannels = this._browserSuspendedChannels.get(browser); + if (suspendedChannels) { + httpChannel.suspend(); + suspendedChannels.set(requestId(httpChannel), httpChannel); + } + + const oldChannel = this._redirectMap.get(httpChannel); + this._redirectMap.delete(httpChannel); + + // Install response body hooks. + new ResponseBodyListener(this, browser, httpChannel); + + this.emit("request", httpChannel, { + url: httpChannel.URI.spec, + suspended: suspendedChannels ? true : undefined, + requestId: requestId(httpChannel), + redirectedFrom: oldChannel ? requestId(oldChannel) : undefined, + postData: readRequestPostData(httpChannel), + headers: requestHeaders(httpChannel), + method: httpChannel.requestMethod, + isNavigationRequest: httpChannel.isMainDocumentChannel, + cause: causeType, + causeString: causeTypeToString(causeType), + frameId: this.frameId(httpChannel), + // clients expect loaderId == requestId for document navigation + loaderId: [ + Ci.nsIContentPolicy.TYPE_DOCUMENT, + Ci.nsIContentPolicy.TYPE_SUBDOCUMENT, + ].includes(causeType) + ? requestId(httpChannel) + : undefined, + }); + } + + _onResponse(fromCache, httpChannel, topic) { + const loadContext = getLoadContext(httpChannel); + if ( + !loadContext || + !this._browserSessionCount.has(loadContext.topFrameElement) + ) { + return; + } + httpChannel.QueryInterface(Ci.nsIHttpChannelInternal); + const causeType = httpChannel.loadInfo + ? httpChannel.loadInfo.externalContentPolicyType + : Ci.nsIContentPolicy.TYPE_OTHER; + let remoteIPAddress; + let remotePort; + try { + remoteIPAddress = httpChannel.remoteAddress; + remotePort = httpChannel.remotePort; + } catch (e) { + // remoteAddress is not defined for cached requests. + } + + this.emit("response", httpChannel, { + requestId: requestId(httpChannel), + securityDetails: getSecurityDetails(httpChannel), + fromCache, + headers: responseHeaders(httpChannel), + requestHeaders: requestHeaders(httpChannel), + remoteIPAddress, + remotePort, + status: httpChannel.responseStatus, + statusText: httpChannel.responseStatusText, + cause: causeType, + causeString: causeTypeToString(causeType), + frameId: this.frameId(httpChannel), + // clients expect loaderId == requestId for document navigation + loaderId: [ + Ci.nsIContentPolicy.TYPE_DOCUMENT, + Ci.nsIContentPolicy.TYPE_SUBDOCUMENT, + ].includes(causeType) + ? requestId(httpChannel) + : undefined, + }); + } + + _onResponseFinished(browser, httpChannel, body) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) { + return; + } + responseStorage.addResponseBody(httpChannel, body); + this.emit("requestfinished", httpChannel, { + requestId: requestId(httpChannel), + errorCode: getNetworkErrorStatusText(httpChannel.status), + }); + } + + isActive(browser) { + return !!this._browserSessionCount.get(browser); + } + + startTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser) || 0; + this._browserSessionCount.set(browser, value + 1); + if (value === 0) { + Services.obs.addObserver(this._onRequest, "http-on-modify-request"); + Services.obs.addObserver( + this._onExamineResponse, + "http-on-examine-response" + ); + Services.obs.addObserver( + this._onCachedResponse, + "http-on-examine-cached-response" + ); + Services.obs.addObserver( + this._onCachedResponse, + "http-on-examine-merged-response" + ); + this._browserResponseStorages.set( + browser, + new ResponseStorage( + MAX_RESPONSE_STORAGE_SIZE, + MAX_RESPONSE_STORAGE_SIZE / 10 + ) + ); + } + return () => this.stopTrackingBrowserNetwork(browser); + } + + stopTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser); + if (value) { + this._browserSessionCount.set(browser, value - 1); + } else { + this._browserSessionCount.delete(browser); + this._browserResponseStorages.delete(browser); + this.dispose(); + } + } + + /** + * Returns the frameId of the current httpChannel. + */ + frameId(httpChannel) { + const loadInfo = httpChannel.loadInfo; + return loadInfo.frameBrowsingContext?.id || loadInfo.browsingContext.id; + } +} + +const protocolVersionNames = { + [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: "TLS 1", + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: "TLS 1.1", + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: "TLS 1.2", + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: "TLS 1.3", +}; + +function getSecurityDetails(httpChannel) { + const securityInfo = httpChannel.securityInfo; + if (!securityInfo) { + return null; + } + if (!securityInfo.serverCert) { + return null; + } + return { + protocol: protocolVersionNames[securityInfo.protocolVersion] || "<unknown>", + subjectName: securityInfo.serverCert.commonName, + issuer: securityInfo.serverCert.issuerCommonName, + // Convert to seconds. + validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000, + validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000, + }; +} + +function readRequestPostData(httpChannel) { + if (!(httpChannel instanceof Ci.nsIUploadChannel)) { + return undefined; + } + const iStream = httpChannel.uploadStream; + if (!iStream) { + return undefined; + } + const isSeekableStream = iStream instanceof Ci.nsISeekableStream; + + let prevOffset; + if (isSeekableStream) { + prevOffset = iStream.tell(); + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + } + + // Read data from the stream. + let text; + try { + text = lazy.NetUtil.readInputStreamToString(iStream, iStream.available()); + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + text = converter.ConvertToUnicode(text); + } catch (err) { + text = undefined; + } + + // Seek locks the file, so seek to the beginning only if necko hasn"t + // read it yet, since necko doesn"t seek to 0 before reading (at lest + // not till 459384 is fixed). + if (isSeekableStream && prevOffset == 0) { + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + } + return text; +} + +function getLoadContext(httpChannel) { + let loadContext = null; + try { + if (httpChannel.notificationCallbacks) { + loadContext = httpChannel.notificationCallbacks.getInterface( + Ci.nsILoadContext + ); + } + } catch (e) {} + try { + if (!loadContext && httpChannel.loadGroup) { + loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface( + Ci.nsILoadContext + ); + } + } catch (e) {} + return loadContext; +} + +function requestId(httpChannel) { + return String(httpChannel.channelId); +} + +function requestHeaders(httpChannel) { + const headers = []; + httpChannel.visitRequestHeaders({ + visitHeader: (name, value) => headers.push({ name, value }), + }); + return headers; +} + +function responseHeaders(httpChannel) { + const headers = []; + httpChannel.visitResponseHeaders({ + visitHeader: (name, value) => headers.push({ name, value }), + }); + return headers; +} + +function causeTypeToString(causeType) { + for (let key in Ci.nsIContentPolicy) { + if (Ci.nsIContentPolicy[key] === causeType) { + return key; + } + } + return "TYPE_OTHER"; +} + +class ResponseStorage { + constructor(maxTotalSize, maxResponseSize) { + this._totalSize = 0; + this._maxResponseSize = maxResponseSize; + this._maxTotalSize = maxTotalSize; + this._responses = new Map(); + } + + addResponseBody(httpChannel, body) { + if (body.length > this._maxResponseSize) { + this._responses.set(requestId, { + evicted: true, + body: "", + }); + return; + } + let encodings = []; + if ( + httpChannel instanceof Ci.nsIEncodedChannel && + httpChannel.contentEncodings && + !httpChannel.applyConversion + ) { + const encodingHeader = httpChannel.getResponseHeader("Content-Encoding"); + encodings = encodingHeader.split(/\s*\t*,\s*\t*/); + } + this._responses.set(requestId(httpChannel), { body, encodings }); + this._totalSize += body.length; + if (this._totalSize > this._maxTotalSize) { + for (let [, response] of this._responses) { + this._totalSize -= response.body.length; + response.body = ""; + response.evicted = true; + if (this._totalSize < this._maxTotalSize) { + break; + } + } + } + } + + getBase64EncodedResponse(requestId) { + const response = this._responses.get(requestId); + if (!response) { + throw new Error(`Request "${requestId}" is not found`); + } + if (response.evicted) { + return { base64body: "", evicted: true }; + } + let result = response.body; + if (response.encodings && response.encodings.length) { + for (const encoding of response.encodings) { + result = lazy.CommonUtils.convertString( + result, + encoding, + "uncompressed" + ); + } + } + return { base64body: btoa(result) }; + } +} + +class ResponseBodyListener { + constructor(networkObserver, browser, httpChannel) { + this._networkObserver = networkObserver; + this._browser = browser; + this._httpChannel = httpChannel; + this._chunks = []; + this.QueryInterface = ChromeUtils.generateQI(["nsIStreamListener"]); + httpChannel.QueryInterface(Ci.nsITraceableChannel); + this.originalListener = httpChannel.setNewListener(this); + } + + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + const iStream = new lazy.BinaryInputStream(aInputStream); + const sStream = new lazy.StorageStream(8192, aCount, null); + const oStream = new lazy.BinaryOutputStream(sStream.getOutputStream(0)); + + // Copy received data as they come. + const data = iStream.readBytes(aCount); + this._chunks.push(data); + + oStream.writeBytes(data, aCount); + this.originalListener.onDataAvailable( + aRequest, + sStream.newInputStream(0), + aOffset, + aCount + ); + } + + onStartRequest(aRequest) { + this.originalListener.onStartRequest(aRequest); + } + + onStopRequest(aRequest, aStatusCode) { + this.originalListener.onStopRequest(aRequest, aStatusCode); + const body = this._chunks.join(""); + delete this._chunks; + this._networkObserver._onResponseFinished( + this._browser, + this._httpChannel, + body + ); + } +} + +function getNetworkErrorStatusText(status) { + if (!status) { + return null; + } + for (const key of Object.keys(Cr)) { + if (Cr[key] === status) { + return key; + } + } + // Security module. The following is taken from + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL + if ((status & 0xff0000) === 0x5a0000) { + // NSS_SEC errors (happen below the base value because of negative vals) + if ( + (status & 0xffff) < + Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) + ) { + // The bases are actually negative, so in our positive numeric space, we + // need to subtract the base off our value. + const nssErr = + Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff); + switch (nssErr) { + case 11: + return "SEC_ERROR_EXPIRED_CERTIFICATE"; + case 12: + return "SEC_ERROR_REVOKED_CERTIFICATE"; + case 13: + return "SEC_ERROR_UNKNOWN_ISSUER"; + case 20: + return "SEC_ERROR_UNTRUSTED_ISSUER"; + case 21: + return "SEC_ERROR_UNTRUSTED_CERT"; + case 36: + return "SEC_ERROR_CA_CERT_INVALID"; + case 90: + return "SEC_ERROR_INADEQUATE_KEY_USAGE"; + case 176: + return "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED"; + default: + return "SEC_ERROR_UNKNOWN"; + } + } + const sslErr = + Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff); + switch (sslErr) { + case 3: + return "SSL_ERROR_NO_CERTIFICATE"; + case 4: + return "SSL_ERROR_BAD_CERTIFICATE"; + case 8: + return "SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE"; + case 9: + return "SSL_ERROR_UNSUPPORTED_VERSION"; + case 12: + return "SSL_ERROR_BAD_CERT_DOMAIN"; + default: + return "SSL_ERROR_UNKNOWN"; + } + } + return "<unknown error>"; +} diff --git a/remote/cdp/observers/TargetObserver.sys.mjs b/remote/cdp/observers/TargetObserver.sys.mjs new file mode 100644 index 0000000000..dfd9e2d9dc --- /dev/null +++ b/remote/cdp/observers/TargetObserver.sys.mjs @@ -0,0 +1,142 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + EventPromise: "chrome://remote/content/shared/Sync.sys.mjs", +}); + +// TODO(ato): +// +// The DOM team is working on pulling browsing context related behaviour, +// such as window and tab handling, out of product code and into the platform. +// This will have implication for the remote agent, +// and as the platform gains support for product-independent events +// we can likely get rid of this entire module. + +/** + * Observe Firefox tabs as they open and close. + * + * "open" fires when a tab opens. + * "close" fires when a tab closes. + */ +export class TabObserver { + /** + * @param {boolean?} [false] registerExisting + * Events will be fired for ChromeWIndows and their respective tabs + * at the time when the observer is started. + */ + constructor({ registerExisting = false } = {}) { + lazy.EventEmitter.decorate(this); + + this.registerExisting = registerExisting; + + this.onTabOpen = this.onTabOpen.bind(this); + this.onTabClose = this.onTabClose.bind(this); + } + + async start() { + Services.wm.addListener(this); + + if (this.registerExisting) { + // Start listening for events on already open windows + for (const win of Services.wm.getEnumerator("navigator:browser")) { + this._registerDOMWindow(win); + } + } + } + + stop() { + Services.wm.removeListener(this); + + // Stop listening for events on still opened windows + for (const win of Services.wm.getEnumerator("navigator:browser")) { + this._unregisterDOMWindow(win); + } + } + + // Event emitters + + onTabOpen({ target }) { + this.emit("open", target); + } + + onTabClose({ target }) { + this.emit("close", target); + } + + // Internal methods + + _registerDOMWindow(win) { + for (const tab of win.gBrowser.tabs) { + // a missing linkedBrowser means the tab is still initialising, + // and a TabOpen event will fire once it is ready + if (!tab.linkedBrowser) { + continue; + } + + this.onTabOpen({ target: tab }); + } + + win.gBrowser.tabContainer.addEventListener("TabOpen", this.onTabOpen); + win.gBrowser.tabContainer.addEventListener("TabClose", this.onTabClose); + } + + _unregisterDOMWindow(win) { + for (const tab of win.gBrowser.tabs) { + // a missing linkedBrowser means the tab is still initialising + if (!tab.linkedBrowser) { + continue; + } + + // Emulate custom "TabClose" events because that event is not + // fired for each of the tabs when the window closes. + this.onTabClose({ target: tab }); + } + + win.gBrowser.tabContainer.removeEventListener("TabOpen", this.onTabOpen); + win.gBrowser.tabContainer.removeEventListener("TabClose", this.onTabClose); + } + + // nsIWindowMediatorListener + + async onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + + await new lazy.EventPromise(win, "load"); + + // Return early if it's not a browser window + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + + this._registerDOMWindow(win); + } + + onCloseWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + + // Return early if it's not a browser window + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + + this._unregisterDOMWindow(win); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIWindowMediatorListener"]); + } +} |