summaryrefslogtreecommitdiffstats
path: root/remote/observers
diff options
context:
space:
mode:
Diffstat (limited to 'remote/observers')
-rw-r--r--remote/observers/ChannelEventSink.jsm109
-rw-r--r--remote/observers/ContextObserver.jsm143
-rw-r--r--remote/observers/NetworkObserver.jsm635
-rw-r--r--remote/observers/TargetObserver.jsm144
4 files changed, 1031 insertions, 0 deletions
diff --git a/remote/observers/ChannelEventSink.jsm b/remote/observers/ChannelEventSink.jsm
new file mode 100644
index 0000000000..bf86316a06
--- /dev/null
+++ b/remote/observers/ChannelEventSink.jsm
@@ -0,0 +1,109 @@
+/* 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/. */
+
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { ComponentUtils } = ChromeUtils.import(
+ "resource://gre/modules/ComponentUtils.jsm"
+);
+
+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);
+ },
+};
+
+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;
+};
+
+var EXPORTED_SYMBOLS = ["ChannelEventSinkFactory"];
+this.ChannelEventSinkFactory = ChannelEventSinkFactory;
diff --git a/remote/observers/ContextObserver.jsm b/remote/observers/ContextObserver.jsm
new file mode 100644
index 0000000000..e98f94ebbb
--- /dev/null
+++ b/remote/observers/ContextObserver.jsm
@@ -0,0 +1,143 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * 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.
+ */
+
+var EXPORTED_SYMBOLS = ["ContextObserver"];
+
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { executeSoon } = ChromeUtils.import("chrome://remote/content/Sync.jsm");
+
+class ContextObserver {
+ constructor(chromeEventHandler) {
+ this.chromeEventHandler = chromeEventHandler;
+ EventEmitter.decorate(this);
+
+ 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, "inner-window-destroyed");
+
+ 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, "inner-window-destroyed");
+
+ 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 });
+ this.emit("frame-navigated", { frameId, window });
+ this.emit("context-created", { windowId: id, window });
+ // Delay script-loaded to allow context cleanup to happen first
+ executeSoon(() => {
+ this.emit("script-loaded", { windowId: id, 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 "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("docshell-created", {
+ id: docShell.browsingContext.id,
+ });
+ }
+
+ onDocShellDestroyed(docShell) {
+ this.emit("docshell-destroyed", {
+ id: docShell.browsingContext.id,
+ });
+ }
+}
diff --git a/remote/observers/NetworkObserver.jsm b/remote/observers/NetworkObserver.jsm
new file mode 100644
index 0000000000..fa1954180b
--- /dev/null
+++ b/remote/observers/NetworkObserver.jsm
@@ -0,0 +1,635 @@
+/* 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/. */
+
+"use strict";
+
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { CommonUtils } = ChromeUtils.import(
+ "resource://services-common/utils.js"
+);
+const { ChannelEventSinkFactory } = ChromeUtils.import(
+ "chrome://remote/content/observers/ChannelEventSink.jsm"
+);
+
+const CC = Components.Constructor;
+
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+const BinaryOutputStream = CC(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+);
+const StorageStream = CC(
+ "@mozilla.org/storagestream;1",
+ "nsIStorageStream",
+ "init"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gActivityDistributor",
+ "@mozilla.org/network/http-activity-distributor;1",
+ "nsIHttpActivityDistributor"
+);
+
+// Cap response storage with 100Mb per tracked tab.
+const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024;
+
+class NetworkObserver {
+ constructor() {
+ EventEmitter.decorate(this);
+ this._browserSessionCount = new Map();
+ gActivityDistributor.addObserver(this);
+ 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() {
+ gActivityDistributor.removeObserver(this);
+ 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) {
+ try {
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ return;
+ }
+
+ 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 = undefined;
+ let remotePort = undefined;
+ 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;
+ }
+ securityInfo.QueryInterface(Ci.nsITransportSecurityInfo);
+ 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 = undefined;
+ try {
+ text = 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 = 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 BinaryInputStream(aInputStream);
+ const sStream = new StorageStream(8192, aCount, null);
+ const oStream = new 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>";
+}
+
+var EXPORTED_SYMBOLS = ["NetworkObserver"];
+this.NetworkObserver = NetworkObserver;
diff --git a/remote/observers/TargetObserver.jsm b/remote/observers/TargetObserver.jsm
new file mode 100644
index 0000000000..9e33772b95
--- /dev/null
+++ b/remote/observers/TargetObserver.jsm
@@ -0,0 +1,144 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["TabObserver"];
+
+const { EventPromise } = ChromeUtils.import("chrome://remote/content/Sync.jsm");
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// 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.
+ */
+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 } = {}) {
+ 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 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"]);
+ }
+}