diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..53dd768 Binary files /dev/null and b/.DS_Store differ diff --git a/additions/juggler/ChannelEventSink.sys.mjs b/additions/juggler/ChannelEventSink.sys.mjs new file mode 100644 index 0000000..ab03e9f --- /dev/null +++ b/additions/juggler/ChannelEventSink.sys.mjs @@ -0,0 +1,99 @@ +/* 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(); + } + }, + + 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/additions/juggler/Helper.js b/additions/juggler/Helper.js index f5a64d6..875dd5a 100644 --- a/additions/juggler/Helper.js +++ b/additions/juggler/Helper.js @@ -4,9 +4,9 @@ const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); -class Helper { +export class Helper { decorateAsEventEmitter(objectToDecorate) { - const { EventEmitter } = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); + const { EventEmitter } = ChromeUtils.importESModule('resource://gre/modules/EventEmitter.sys.mjs'); const emitter = new EventEmitter(); objectToDecorate.on = emitter.on.bind(emitter); objectToDecorate.addEventListener = emitter.on.bind(emitter); @@ -172,7 +172,7 @@ class Helper { const helper = new Helper(); -class EventWatcher { +export class EventWatcher { constructor(receiver, eventNames, pendingEventWatchers = new Set()) { this._pendingEventWatchers = pendingEventWatchers; this._pendingEventWatchers.add(this); @@ -233,7 +233,3 @@ class EventWatcher { } } -var EXPORTED_SYMBOLS = [ "Helper", "EventWatcher" ]; -this.Helper = Helper; -this.EventWatcher = EventWatcher; - diff --git a/additions/juggler/JugglerFrameParent.jsm b/additions/juggler/JugglerFrameParent.jsm index e621fab..9d41842 100644 --- a/additions/juggler/JugglerFrameParent.jsm +++ b/additions/juggler/JugglerFrameParent.jsm @@ -1,13 +1,11 @@ "use strict"; -const { TargetRegistry } = ChromeUtils.import('chrome://juggler/content/TargetRegistry.js'); -const { Helper } = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const { TargetRegistry } = ChromeUtils.importESModule('chrome://juggler/content/TargetRegistry.js'); +const { Helper } = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); const helper = new Helper(); -var EXPORTED_SYMBOLS = ['JugglerFrameParent']; - -class JugglerFrameParent extends JSWindowActorParent { +export class JugglerFrameParent extends JSWindowActorParent { constructor() { super(); } @@ -18,7 +16,7 @@ class JugglerFrameParent extends JSWindowActorParent { // Actors are registered per the WindowGlobalParent / WindowGlobalChild pair. We are only // interested in those WindowGlobalParent actors that are matching current browsingContext // window global. - // See https://github.com/mozilla/gecko-dev/blob/cd2121e7d83af1b421c95e8c923db70e692dab5f/testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs#L15 + // See https://github.com/mozilla-firefox/firefox/blob/35e22180b0b61413dd8eccf6c00b1c6fac073eee/testing/mochitest/BrowserTestUtils/BrowserTestUtilsParent.sys.mjs#L15 if (!this.manager?.isCurrentGlobal) return; diff --git a/additions/juggler/NetworkObserver.js b/additions/juggler/NetworkObserver.js index ef269b0..2719cc1 100644 --- a/additions/juggler/NetworkObserver.js +++ b/additions/juggler/NetworkObserver.js @@ -4,9 +4,9 @@ "use strict"; -const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); -const { ChannelEventSinkFactory } = ChromeUtils.import("chrome://remote/content/cdp/observers/ChannelEventSink.jsm"); +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys.mjs'); +const { ChannelEventSinkFactory } = ChromeUtils.importESModule("chrome://juggler/content/ChannelEventSink.sys.mjs"); const Cc = Components.classes; @@ -28,7 +28,7 @@ const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024; const pageNetworkSymbol = Symbol('PageNetwork'); -class PageNetwork { +export class PageNetwork { static forPageTarget(target) { if (!target) return undefined; @@ -143,9 +143,10 @@ class NetworkRequest { const target = this._networkObserver._targetRegistry.targetForBrowserId(browsingContext.browserId); this._pageNetwork = PageNetwork.forPageTarget(target); } - this._expectingInterception = false; + this._shouldYieldInterceptionToServiceWorker = false; this._expectingResumedRequest = undefined; // { method, headers, postData } this._overriddenHeadersForRedirect = redirectedFrom?._overriddenHeadersForRedirect; + this._sentOnRequest = false; this._sentOnResponse = false; this._fulfilled = false; @@ -204,11 +205,13 @@ class NetworkRequest { this._interceptedChannel.synthesizeHeader(header.name, header.value); if (header.name.toLowerCase() === 'set-cookie') { Services.cookies.QueryInterface(Ci.nsICookieService); - Services.cookies.setCookieStringFromHttp(this.httpChannel.URI, header.value, this.httpChannel); + for (const cookieString of header.value.split('\n')) + Services.cookies.setCookieStringFromHttp(this.httpChannel.URI, cookieString, this.httpChannel); } } const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); - synthesized.data = base64body ? atob(base64body) : ''; + if (base64body) + synthesized.setByteStringData(atob(base64body)); this._interceptedChannel.startSynthesizedResponse(synthesized, null, null, '', false); this._interceptedChannel.finishSynthesizedResponse(); this._interceptedChannel = undefined; @@ -316,9 +319,8 @@ class NetworkRequest { const interceptController = this._fallThroughInterceptController(); if (interceptController && interceptController.shouldPrepareForIntercept(aURI, channel)) { // We assume that interceptController is a service worker if there is one, - // and yield interception to it. We are not going to intercept ourselves, - // so we send onRequest now. - this._sendOnRequest(false); + // and yield interception to it. + this._shouldYieldInterceptionToServiceWorker = true; return true; } @@ -327,12 +329,6 @@ class NetworkRequest { return false; } - // We do not want to intercept any redirects, because we are not able - // to intercept subresource redirects, and it's unreliable for main requests. - // We do not sendOnRequest here, because redirects do that in constructor. - if (this.redirectedFromId) - return false; - const shouldIntercept = this._shouldIntercept(); if (!shouldIntercept) { // We are not intercepting - ready to issue onRequest. @@ -340,21 +336,24 @@ class NetworkRequest { return false; } - this._expectingInterception = true; return true; } // nsINetworkInterceptController channelIntercepted(intercepted) { - if (!this._expectingInterception) { - // We are not intercepting, fall-through. - const interceptController = this._fallThroughInterceptController(); - if (interceptController) - interceptController.channelIntercepted(intercepted); + // Yield to a service worker if determined so in shouldPrepareForIntercept(). + const serviceWorker = this._shouldYieldInterceptionToServiceWorker ? this._fallThroughInterceptController() : undefined; + // Clear the flag to avoid an infinite loop. After service worker, we should intercept ourselves. + this._shouldYieldInterceptionToServiceWorker = false; + + if (serviceWorker) { + const interceptedChannel = intercepted.QueryInterface(Ci.nsIInterceptedChannel); + // If service worker will not actually intercept the request, we want to be called again. + interceptedChannel.interceptAfterServiceWorkerResets(); + serviceWorker.channelIntercepted(intercepted); return; } - this._expectingInterception = false; this._interceptedChannel = intercepted.QueryInterface(Ci.nsIInterceptedChannel); const pageNetwork = this._pageNetwork; @@ -408,6 +407,7 @@ class NetworkRequest { // See https://github.com/microsoft/playwright/issues/9418#issuecomment-944836244 if (aRequest !== this.httpChannel) return; + this._sendOnRequest(false); try { this._originalListener.onStartRequest(aRequest); } catch (e) { @@ -445,6 +445,10 @@ class NetworkRequest { } _shouldIntercept() { + // We do not want to intercept any redirects, because we are not able + // to intercept subresource redirects, and it's unreliable for main requests. + if (this.redirectedFromId) + return false; const pageNetwork = this._pageNetwork; if (!pageNetwork) return false; @@ -465,8 +469,15 @@ class NetworkRequest { } _sendOnRequest(isIntercepted) { - // Note: we call _sendOnRequest either after we intercepted the request, - // or at the first moment we know that we are not going to intercept. + if (this._sentOnRequest) { + // We can come here twice because: + // - Redirects call _sendOnRequest in the constructor and from inside interception. + // - All other requests might call _sendOnRequest from onStartRequest and from inside interception. + // - All requests call _sendOnRequest from _sendOnResponse to avoid responses without requests. + return; + } + this._sentOnRequest = true; + const pageNetwork = this._pageNetwork; if (!pageNetwork) return; @@ -489,11 +500,21 @@ class NetworkRequest { } _sendOnResponse(fromCache, opt_statusCode, opt_statusText) { + // For internal redirects, and perhaps something else that we lack test coverage for, + // we can arrive here before onStartRequest has fired. Make sure we + // notify about the request first. + this._sendOnRequest(false); + if (this._sentOnResponse) { - // We can come here twice because of internal redirects, e.g. service workers. + // We can come here twice because of an internal redirect, for example: + // - request was intercepted by a service worker; + // - HSTS redirect; + // - CORS preflight; + // - who knows what else? return; } this._sentOnResponse = true; + const pageNetwork = this._pageNetwork; if (!pageNetwork) return; @@ -512,6 +533,8 @@ class NetworkRequest { }; const { status, statusText, headers } = responseHead(this.httpChannel, opt_statusCode, opt_statusText); + if (redirectStatus.includes(status) && this._overriddenHeadersForRedirect) + this._overriddenHeadersForRedirect = filterHeadersForRedirect(this._overriddenHeadersForRedirect, this.httpChannel.requestMethod, status); let remoteIPAddress = undefined; let remotePort = undefined; try { @@ -574,7 +597,7 @@ class NetworkRequest { } } -class NetworkObserver { +export class NetworkObserver { static instance() { return NetworkObserver._instance || null; } @@ -774,6 +797,10 @@ function clearRequestHeaders(httpChannel) { // We cannot remove the "host" header. if (header.name.toLowerCase() === 'host') continue; + // Keep the "cookie" header. If there is an override, it will be set anyway. + // Otherwise, we may delete a cookie that was set for a redirect. + if (header.name.toLowerCase() === 'cookie') + continue; httpChannel.setRequestHeader(header.name, '', false /* merge */); } } @@ -783,6 +810,18 @@ function overrideRequestHeaders(httpChannel, headers) { appendExtraHTTPHeaders(httpChannel, headers); } +const redirectStatus = [301, 302, 303, 307, 308]; + +function filterHeadersForRedirect(headers, requestMethod, status) { + // HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch) + if ((status === 301 || status === 302) && requestMethod === 'POST' || + status === 303 && !['GET', 'HEAD'].includes(requestMethod)) { + const requestBodyHeaders = ['content-encoding', 'content-language', 'content-length', 'content-location', 'content-type']; + return headers.filter(header => !requestBodyHeaders.includes(header.name.toLowerCase())); + } + return headers; +} + function causeTypeToString(causeType) { for (let key in Ci.nsIContentPolicy) { if (Ci.nsIContentPolicy[key] === causeType) @@ -870,7 +909,7 @@ function setPostData(httpChannel, postData, headers) { return; const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); const body = atob(postData); - synthesized.setByteStringData(body, body.length); + synthesized.setByteStringData(body); const overriddenHeader = (lowerCaseName) => { if (headers) { @@ -902,7 +941,7 @@ function convertString(s, source, dest) { const is = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( Ci.nsIStringInputStream ); - is.setByteStringData(s, s.length); + is.setByteStringData(s); const listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance( Ci.nsIStreamLoader ); @@ -962,6 +1001,3 @@ PageNetwork.Events = { RequestFailed: Symbol('PageNetwork.Events.RequestFailed'), }; -var EXPORTED_SYMBOLS = ['NetworkObserver', 'PageNetwork']; -this.NetworkObserver = NetworkObserver; -this.PageNetwork = PageNetwork; \ No newline at end of file diff --git a/additions/juggler/SimpleChannel.js b/additions/juggler/SimpleChannel.js index a4e1b19..cc55545 100644 --- a/additions/juggler/SimpleChannel.js +++ b/additions/juggler/SimpleChannel.js @@ -79,7 +79,7 @@ class SimpleChannel { _setTimeout(cb, timeout) { // Lazy load on first call. - this._setTimeout = ChromeUtils.import('resource://gre/modules/Timer.jsm').setTimeout; + this._setTimeout = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs').setTimeout; this._setTimeout(cb, timeout); } @@ -251,6 +251,3 @@ class SimpleChannel { } } } - -var EXPORTED_SYMBOLS = ['SimpleChannel']; -this.SimpleChannel = SimpleChannel; diff --git a/additions/juggler/TargetRegistry.js b/additions/juggler/TargetRegistry.js index 4a73580..55afdad 100644 --- a/additions/juggler/TargetRegistry.js +++ b/additions/juggler/TargetRegistry.js @@ -1,19 +1,18 @@ /* 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/. */ + * 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 {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); -const {Preferences} = ChromeUtils.import("resource://gre/modules/Preferences.jsm"); -const {ContextualIdentityService} = ChromeUtils.import("resource://gre/modules/ContextualIdentityService.jsm"); -const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); -const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const {Preferences} = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs"); +const {ContextualIdentityService} = ChromeUtils.importESModule("resource://gre/modules/ContextualIdentityService.sys.mjs"); +const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys.mjs'); +const {AppConstants} = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); const Cr = Components.results; const helper = new Helper(); -const IDENTITY_NAME = 'Camoufox '; +const IDENTITY_NAME = 'JUGGLER '; const HUNDRED_YEARS = 60 * 60 * 24 * 365 * 100; const ALL_PERMISSIONS = [ @@ -22,6 +21,9 @@ const ALL_PERMISSIONS = [ ]; let globalTabAndWindowActivationChain = Promise.resolve(); +// This is a workaround for https://github.com/microsoft/playwright/issues/34586 +let didCreateFirstPage = false; +let globalNewPageChain = Promise.resolve(); class DownloadInterceptor { constructor(registry) { @@ -102,7 +104,7 @@ class DownloadInterceptor { const screencastService = Cc['@mozilla.org/juggler/screencast;1'].getService(Ci.nsIScreencastService); -class TargetRegistry { +export class TargetRegistry { static instance() { return TargetRegistry._instance || null; } @@ -308,6 +310,16 @@ class TargetRegistry { } async newPage({browserContextId}) { + // When creating the very first page, we cannot create multiple in parallel. + // See https://github.com/microsoft/playwright/issues/34586. + if (didCreateFirstPage) + return this._newPageInternal({browserContextId}); + const result = globalNewPageChain.then(() => this._newPageInternal({browserContextId})); + globalNewPageChain = result.catch(error => { /* swallow errors to keep chain running */ }); + return result; + } + + async _newPageInternal({browserContextId}) { const browserContext = this.browserContextForId(browserContextId); const features = "chrome,dialog=no,all"; // See _callWithURIToLoad in browser.js for the structure of window.arguments @@ -356,6 +368,7 @@ class TargetRegistry { if (await target.hasFailedToOverrideTimezone()) throw new Error('Failed to override timezone'); } + didCreateFirstPage = true; return target.id(); } @@ -372,7 +385,7 @@ class TargetRegistry { } } -class PageTarget { +export class PageTarget { constructor(registry, win, tab, browserContext, opener) { helper.decorateAsEventEmitter(this); @@ -384,16 +397,7 @@ class PageTarget { this._linkedBrowser = tab.linkedBrowser; this._browserContext = browserContext; this._viewportSize = undefined; - // Set the viewport size to Camoufox's default value. - if ( - ChromeUtils.camouGetInt("window.innerWidth") - || ChromeUtils.camouGetInt("window.innerHeight") - ) { - this._viewportSize = { - width: ChromeUtils.camouGetInt("window.innerWidth") || 1280, - height: ChromeUtils.camouGetInt("window.innerHeight") || 720, - }; - } + this._zoom = 1; this._initialDPPX = this._linkedBrowser.browsingContext.overrideDPPX; this._url = 'about:blank'; this._openerId = opener ? opener.id() : undefined; @@ -506,9 +510,11 @@ class PageTarget { this.updateUserAgent(browsingContext); this.updatePlatform(browsingContext); this.updateDPPXOverride(browsingContext); + this.updateZoom(browsingContext); this.updateEmulatedMedia(browsingContext); this.updateColorSchemeOverride(browsingContext); this.updateReducedMotionOverride(browsingContext); + this.updateContrastOverride(browsingContext); this.updateForcedColorsOverride(browsingContext); this.updateForceOffline(browsingContext); this.updateCacheDisabled(browsingContext); @@ -544,7 +550,16 @@ class PageTarget { } updateDPPXOverride(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).overrideDPPX = this._browserContext.deviceScaleFactor || this._initialDPPX; + browsingContext ||= this._linkedBrowser.browsingContext; + const dppx = this._zoom * (this._browserContext.deviceScaleFactor || this._initialDPPX); + browsingContext.overrideDPPX = dppx; + } + + async updateZoom(browsingContext = undefined) { + browsingContext ||= this._linkedBrowser.browsingContext; + // Update dpr first, and then UI zoom. + this.updateDPPXOverride(browsingContext); + browsingContext.fullZoom = this._zoom; } _updateModalDialogs() { @@ -579,19 +594,7 @@ class PageTarget { // The "default size" (1) is only respected when the page is opened. // Otherwise, explicitly set page viewport prevales over browser context // default viewport. - - // Camoufox is already handling viewport size, so we don't need to set it here. - if ( - ChromeUtils.camouGetInt("window.outerWidth") || - ChromeUtils.camouGetInt("window.outerHeight") || - ChromeUtils.camouGetInt("window.innerWidth") || - ChromeUtils.camouGetInt("window.innerHeight") - ) { - return; - } - const viewportSize = this._viewportSize || this._browserContext.defaultViewportSize; - if (viewportSize) { const {width, height} = viewportSize; this._linkedBrowser.style.setProperty('width', width + 'px'); @@ -606,7 +609,7 @@ class PageTarget { const toolbarTop = stackRect.y; this._window.resizeBy(width - this._window.innerWidth, height + toolbarTop - this._window.innerHeight); - await this._channel.connect('').send('awaitViewportDimensions', { width, height }); + await this._channel.connect('').send('awaitViewportDimensions', { width: width / this._zoom, height: height / this._zoom }); } else { this._linkedBrowser.style.removeProperty('width'); this._linkedBrowser.style.removeProperty('height'); @@ -618,8 +621,8 @@ class PageTarget { const actualSize = this._linkedBrowser.getBoundingClientRect(); await this._channel.connect('').send('awaitViewportDimensions', { - width: actualSize.width, - height: actualSize.height, + width: actualSize.width / this._zoom, + height: actualSize.height / this._zoom, }); } } @@ -639,7 +642,7 @@ class PageTarget { } updateColorSchemeOverride(browsingContext = undefined) { - (browsingContext || this._linkedBrowser.browsingContext).prefersColorSchemeOverride = this.colorScheme || 'dark'; + (browsingContext || this._linkedBrowser.browsingContext).prefersColorSchemeOverride = this.colorScheme || this._browserContext.colorScheme || 'none'; } setReducedMotion(reducedMotion) { @@ -651,6 +654,15 @@ class PageTarget { (browsingContext || this._linkedBrowser.browsingContext).prefersReducedMotionOverride = this.reducedMotion || this._browserContext.reducedMotion || 'none'; } + setContrast(contrast) { + this.contrast = fromProtocolContrast(contrast); + this.updateContrastOverride(); + } + + updateContrastOverride(browsingContext = undefined) { + (browsingContext || this._linkedBrowser.browsingContext).prefersContrastOverride = this.contrast || this._browserContext.contrast || 'none'; + } + setForcedColors(forcedColors) { this.forcedColors = fromProtocolForcedColors(forcedColors); this.updateForcedColorsOverride(); @@ -672,6 +684,14 @@ class PageTarget { await this.updateViewportSize(); } + async setZoom(zoom) { + // This is default range from the ZoomManager. + if (zoom < 0.3 || zoom > 5) + throw new Error('Invalid zoom value, must be between 0.3 and 5'); + this._zoom = zoom; + await this.updateZoom(); + } + close(runBeforeUnload = false) { this._gBrowser.removeTab(this._tab, { skipPermitUnload: !runBeforeUnload, @@ -878,6 +898,14 @@ function fromProtocolReducedMotion(reducedMotion) { throw new Error('Unknown reduced motion: ' + reducedMotion); } +function fromProtocolContrast(contrast) { + if (contrast === 'more' || contrast === 'less' || contrast === 'custom' || contrast === 'no-preference') + return contrast; + if (contrast === null) + return undefined; + throw new Error('Unknown contrast: ' + contrast); +} + function fromProtocolForcedColors(forcedColors) { if (forcedColors === 'active' || forcedColors === 'none') return forcedColors; @@ -918,6 +946,7 @@ class BrowserContext { this.colorScheme = 'none'; this.forcedColors = 'none'; this.reducedMotion = 'none'; + this.contrast = 'none'; this.videoRecordingOptions = undefined; this.crossProcessCookie = { initScripts: [], @@ -944,6 +973,12 @@ class BrowserContext { page.updateReducedMotionOverride(); } + setContrast(contrast) { + this.contrast = fromProtocolContrast(contrast); + for (const page of this.pages) + page.updateContrastOverride(); + } + setForcedColors(forcedColors) { this.forcedColors = fromProtocolForcedColors(forcedColors); for (const page of this.pages) @@ -988,9 +1023,9 @@ class BrowserContext { if (ignoreHTTPSErrors) { Preferences.set("network.stricttransportsecurity.preloadlist", false); Preferences.set("security.cert_pinning.enforcement_level", 0); - certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(true, this.userContextId); + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyDataForUserContext(this.userContextId, true); } else { - certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(false, this.userContextId); + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyDataForUserContext(this.userContextId, false); } } @@ -1025,15 +1060,6 @@ class BrowserContext { } async setDefaultViewport(viewport) { - // Camoufox: only override the set viewport if a new one was passed - if ( - ChromeUtils.camouGetInt("window.innerWidth") - || ChromeUtils.camouGetInt("window.innerHeight") - ) { - if (viewport.viewportSize?.width == 1280 && viewport.viewportSize?.height == 720) { - return; - } - } this.defaultViewportSize = viewport ? viewport.viewportSize : undefined; this.deviceScaleFactor = viewport ? viewport.deviceScaleFactor : undefined; await Promise.all(Array.from(this.pages).map(page => page.updateViewportSize())); @@ -1098,7 +1124,8 @@ class BrowserContext { setCookies(cookies) { const protocolToSameSite = { - [undefined]: Ci.nsICookie.SAMESITE_NONE, + [undefined]: Ci.nsICookie.SAMESITE_UNSET, + 'None': Ci.nsICookie.SAMESITE_UNSET, 'Lax': Ci.nsICookie.SAMESITE_LAX, 'Strict': Ci.nsICookie.SAMESITE_STRICT, }; @@ -1126,7 +1153,7 @@ class BrowserContext { secure, cookie.httpOnly || false, cookie.expires === undefined || cookie.expires === -1 /* isSession */, - cookie.expires === undefined ? Date.now() + HUNDRED_YEARS : cookie.expires, + cookie.expires === undefined ? Date.now() + HUNDRED_YEARS : cookie.expires * 1000, { userContextId: this.userContextId || undefined } /* originAttributes */, protocolToSameSite[cookie.sameSite], Ci.nsICookie.SCHEME_UNSET @@ -1141,6 +1168,7 @@ class BrowserContext { getCookies() { const result = []; const sameSiteToProtocol = { + [Ci.nsICookie.SAMESITE_UNSET]: 'None', [Ci.nsICookie.SAMESITE_NONE]: 'None', [Ci.nsICookie.SAMESITE_LAX]: 'Lax', [Ci.nsICookie.SAMESITE_STRICT]: 'Strict', @@ -1155,7 +1183,7 @@ class BrowserContext { value: cookie.value, domain: cookie.host, path: cookie.path, - expires: cookie.isSession ? -1 : cookie.expiry, + expires: cookie.isSession ? -1 : cookie.expiry / 1000, size: cookie.name.length + cookie.value.length, httpOnly: cookie.isHttpOnly, secure: cookie.isSecure, @@ -1267,7 +1295,3 @@ TargetRegistry.Events = { DownloadFinished: Symbol('TargetRegistry.Events.DownloadFinished'), ScreencastStopped: Symbol('TargetRegistry.ScreencastStopped'), }; - -var EXPORTED_SYMBOLS = ['TargetRegistry', 'PageTarget']; -this.TargetRegistry = TargetRegistry; -this.PageTarget = PageTarget; \ No newline at end of file diff --git a/additions/juggler/components/Juggler.js b/additions/juggler/components/Juggler.js index 953758d..7595958 100644 --- a/additions/juggler/components/Juggler.js +++ b/additions/juggler/components/Juggler.js @@ -158,3 +158,4 @@ const jugglerInstance = new Juggler(); export var JugglerFactory = function() { return jugglerInstance; }; + diff --git a/additions/juggler/components/components.conf b/additions/juggler/components/components.conf index 9ac4454..955b38a 100644 --- a/additions/juggler/components/components.conf +++ b/additions/juggler/components/components.conf @@ -15,4 +15,4 @@ Classes = [ "constructor": "JugglerFactory", }, ] - \ No newline at end of file + diff --git a/additions/juggler/content/FrameTree.js b/additions/juggler/content/FrameTree.js index e4a7db1..09a95b3 100644 --- a/additions/juggler/content/FrameTree.js +++ b/additions/juggler/content/FrameTree.js @@ -7,13 +7,11 @@ const Ci = Components.interfaces; const Cr = Components.results; const Cu = Components.utils; -const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); -const {Runtime} = ChromeUtils.import('chrome://juggler/content/content/Runtime.js'); +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); const helper = new Helper(); -class FrameTree { +export class FrameTree { constructor(rootBrowsingContext) { helper.decorateAsEventEmitter(this); @@ -411,11 +409,7 @@ class Frame { this._parentFrame = parentFrame; parentFrame._children.add(this); } - - this.allowMW = ChromeUtils.camouGetBool('allowMainWorld', false); - this.forceScopeAccess = ChromeUtils.camouGetBool('forceScopeAccess', false); - this.masterSandbox = undefined; this._lastCommittedNavigationId = null; this._pendingNavigationId = null; @@ -509,46 +503,18 @@ class Frame { }; } - // Camoufox: Add a "God mode" master sandbox with it's own compartment - getMasterSandbox() { - if (!this.masterSandbox) { - this.masterSandbox = Cu.Sandbox( - Services.scriptSecurityManager.getSystemPrincipal(), - { - sandboxPrototype: this.domWindow(), - wantComponents: false, - wantExportHelpers: false, - wantXrays: true, - freshCompartment: true, - } - ); - } - return this.masterSandbox; - } - - _createIsolatedContext(name, useMaster=false) { - let sandbox; - // Camoufox: Use the master sandbox (with system principle scope access) - if (useMaster && this.forceScopeAccess) { - sandbox = this.getMasterSandbox(); - } else { - // Standard access (run in domWindow principal) - sandbox = Cu.Sandbox([this.domWindow()], { - sandboxPrototype: this.domWindow(), - wantComponents: false, - wantExportHelpers: false, - wantXrays: true, - }); - } + _createIsolatedContext(name) { + const principal = [this.domWindow()]; // extended principal + const sandbox = Cu.Sandbox(principal, { + sandboxPrototype: this.domWindow(), + wantComponents: false, + wantExportHelpers: false, + wantXrays: true, + }); const world = this._runtime.createExecutionContext(this.domWindow(), sandbox, { frameId: this.id(), name, }); - // Camoufox: Create a main world for the isolated context - if (this.allowMW) { - const mainWorld = this._runtime.createMW(this.domWindow(), this.domWindow()); - world.mainEquivalent = mainWorld; - } this._worldNameToContext.set(name, world); return world; } @@ -582,11 +548,15 @@ class Frame { webSocketService.removeListener(this._webSocketListenerInnerWindowId, this._webSocketListener); this._webSocketListenerInnerWindowId = this.domWindow().windowGlobalChild.innerWindowId; webSocketService.addListener(this._webSocketListenerInnerWindowId, this._webSocketListener); + for (const context of this._worldNameToContext.values()) this._runtime.destroyExecutionContext(context); this._worldNameToContext.clear(); - // Camoufox: Scope the initial execution context to prevent leaks - this._createIsolatedContext('', true); + + this._worldNameToContext.set('', this._runtime.createExecutionContext(this.domWindow(), this.domWindow(), { + frameId: this._frameId, + name: '', + })); for (const [name, world] of this._frameTree._isolatedWorlds) { if (name) this._createIsolatedContext(name); @@ -718,6 +688,3 @@ function channelId(channel) { } -var EXPORTED_SYMBOLS = ['FrameTree']; -this.FrameTree = FrameTree; - diff --git a/additions/juggler/content/JugglerFrameChild.jsm b/additions/juggler/content/JugglerFrameChild.jsm index 05d9147..af060c5 100644 --- a/additions/juggler/content/JugglerFrameChild.jsm +++ b/additions/juggler/content/JugglerFrameChild.jsm @@ -1,7 +1,7 @@ "use strict"; -const { Helper } = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const { initialize } = ChromeUtils.import('chrome://juggler/content/content/main.js'); +const { Helper } = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const { initialize } = ChromeUtils.importESModule('chrome://juggler/content/content/main.js'); const Ci = Components.interfaces; const helper = new Helper(); @@ -10,7 +10,7 @@ let sameProcessInstanceNumber = 0; const topBrowingContextToAgents = new Map(); -class JugglerFrameChild extends JSWindowActorChild { +export class JugglerFrameChild extends JSWindowActorChild { constructor() { super(); @@ -83,4 +83,3 @@ class JugglerFrameChild extends JSWindowActorChild { receiveMessage() { } } -var EXPORTED_SYMBOLS = ['JugglerFrameChild']; diff --git a/additions/juggler/content/PageAgent.js b/additions/juggler/content/PageAgent.js index 226bea6..2ff5e6b 100644 --- a/additions/juggler/content/PageAgent.js +++ b/additions/juggler/content/PageAgent.js @@ -8,9 +8,9 @@ const Ci = Components.interfaces; const Cr = Components.results; const Cu = Components.utils; -const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); -const {setTimeout} = ChromeUtils.import('resource://gre/modules/Timer.jsm'); +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys.mjs'); +const {setTimeout} = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs'); const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService( Ci.nsIDragService @@ -51,7 +51,7 @@ class WorkerData { } } -class PageAgent { +export class PageAgent { constructor(browserChannel, frameTree) { this._browserChannel = browserChannel; this._browserPage = browserChannel.connect('page'); @@ -549,7 +549,7 @@ class PageAgent { false /*aIgnoreRootScrollFrame*/, 0.0 /*pressure*/, 0 /*inputSource*/, - false /*isDOMEventSynthesized*/, + true /*isDOMEventSynthesized*/, false /*isWidgetEventSynthesized*/, 0 /*buttons*/, win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, @@ -575,7 +575,7 @@ class PageAgent { // We crash by using js-ctypes and dereferencing // a bad pointer. The crash should happen immediately // upon loading this frame script. - const { ctypes } = ChromeUtils.import('resource://gre/modules/ctypes.jsm'); + const { ctypes } = ChromeUtils.importESModule('resource://gre/modules/ctypes.sys.mjs'); ChromeUtils.privateNoteIntentionalCrash(); const zero = new ctypes.intptr_t(8); const badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); @@ -709,6 +709,3 @@ class PageAgent { } } -var EXPORTED_SYMBOLS = ['PageAgent']; -this.PageAgent = PageAgent; - diff --git a/additions/juggler/content/Runtime.js b/additions/juggler/content/Runtime.js index a3e2ce2..ad8720a 100644 --- a/additions/juggler/content/Runtime.js +++ b/additions/juggler/content/Runtime.js @@ -8,8 +8,8 @@ if (!this.Debugger) { // Worker has a Debugger defined already. - const {addDebuggerToGlobal} = ChromeUtils.import("resource://gre/modules/jsdebugger.jsm", {}); - addDebuggerToGlobal(Components.utils.getGlobalForObject(this)); + const {addDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs"); + addDebuggerToGlobal(Components.utils.getGlobalForObject(globalThis)); } let lastId = 0; @@ -99,32 +99,6 @@ class Runtime { const executionContext = this.findExecutionContext(executionContextId); if (!executionContext) throw new Error('Failed to find execution context with id = ' + executionContextId); - - // Hijack the utilityScript.evaluate function to evaluate in the main world - if ( - ChromeUtils.camouGetBool('allowMainWorld', false) && - functionDeclaration.includes('utilityScript.evaluate') && - args.length >= 4 && - args[3].value && - typeof args[3].value === 'string' && - args[3].value.startsWith('mw:')) { - ChromeUtils.camouDebug(`Evaluating in main world: ${args[3].value}`); - const mainWorldScript = args[3].value.substring(3); - - // Get the main world execution context - const mainContext = executionContext.mainEquivalent; - if (!mainContext) { - throw new Error(`Main world injection is not enabled.`); - } - // Extract arguments for the main world function - const functionArgs = args[5]?.value?.a || []; - const exceptionDetails = {}; - const result = mainContext.executeInGlobal(mainWorldScript, functionArgs, exceptionDetails); - if (!result) - return {exceptionDetails}; - return {result}; - } - const exceptionDetails = {}; let result = await executionContext.evaluateFunction(functionDeclaration, args, exceptionDetails); if (!result) @@ -190,7 +164,7 @@ class Runtime { emitEvent(this.events.onRuntimeError, { executionContext, message: message.errorMessage, - stack: message.stack?.toString() || '', + stack: message.stack.toString(), }); } }, @@ -308,11 +282,6 @@ class Runtime { return context; } - createMW(domWindow, contextGlobal) { - const context = new MainWorldContext(this, domWindow, contextGlobal); - return context; - } - findExecutionContext(executionContextId) { const executionContext = this._executionContexts.get(executionContextId); if (!executionContext) @@ -337,68 +306,6 @@ class Runtime { } } -class MainWorldContext { - constructor(runtime, domWindow, contextGlobal) { - this._runtime = runtime; - this._domWindow = domWindow; - this._contextGlobal = contextGlobal; - this._debuggee = runtime._debugger.addDebuggee(contextGlobal); - } - - _getResult(completionValue, exceptionDetails = {}) { - if (!completionValue) { - exceptionDetails.text = "Evaluation terminated"; - return {success: false, obj: null}; - } - - if (completionValue.throw) { - const result = this._debuggee.executeInGlobalWithBindings(` - (function(error) { - try { - if (error instanceof Error) { - return error.toString(); - } - return String(error); - } catch(e) { - return "Unknown error occurred"; - } - })(e) - `, { e: completionValue.throw }); - - exceptionDetails.text = result.return || "Unknown error"; - return {success: false, obj: null}; - } - - return {success: true, obj: completionValue.return}; - } - - executeInGlobal(script, args = [], exceptionDetails = {}) { - try { - const wrappedScript = ` - (() => { - let _s = (${script}); - let _r = typeof _s === 'function' - ? _s(${args.map(arg => JSON.stringify(arg)).join(', ')}) - : _s; - return JSON.stringify({value: _r}); - })() - `; - - const result = this._debuggee.executeInGlobal(wrappedScript); - - let {success, obj} = this._getResult(result, exceptionDetails); - if (!success) { - return {exceptionDetails}; - } - return JSON.parse(obj); - } catch (e) { - exceptionDetails.text = e.message; - exceptionDetails.stack = e.stack; - return {exceptionDetails}; - } - } -} - class ExecutionContext { constructor(runtime, domWindow, contextGlobal, auxData) { this._runtime = runtime; @@ -431,8 +338,6 @@ class ExecutionContext { return hasSymbol ? undefined : result; }).bind(null, JSON.stringify.bind(JSON))`).return; - - this.mainEquivalent = undefined; } id() { @@ -691,5 +596,5 @@ function emitEvent(event, ...args) { listener.call(null, ...args); } -var EXPORTED_SYMBOLS = ['Runtime']; -this.Runtime = Runtime; +// Export Runtime to global. +globalThis.Runtime = Runtime; diff --git a/additions/juggler/content/main.js b/additions/juggler/content/main.js index 7eaa704..61e4106 100644 --- a/additions/juggler/content/main.js +++ b/additions/juggler/content/main.js @@ -2,20 +2,26 @@ * 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 {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {FrameTree} = ChromeUtils.import('chrome://juggler/content/content/FrameTree.js'); -const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); -const {PageAgent} = ChromeUtils.import('chrome://juggler/content/content/PageAgent.js'); +// Load SimpleChannel and Runtime in content process's global. +// NOTE: since these have to exist in both Worker and main threads, and we do +// not know a way to load ES Modules in worker threads, we have to use the loadSubScript +// utility instead. +Services.scriptloader.loadSubScript('chrome://juggler/content/SimpleChannel.js'); +Services.scriptloader.loadSubScript('chrome://juggler/content/content/Runtime.js'); + +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const {FrameTree} = ChromeUtils.importESModule('chrome://juggler/content/content/FrameTree.js'); +const {PageAgent} = ChromeUtils.importESModule('chrome://juggler/content/content/PageAgent.js'); const helper = new Helper(); -function initialize(browsingContext, docShell) { +export function initialize(browsingContext, docShell) { const data = { channel: undefined, pageAgent: undefined, frameTree: undefined, failedToOverrideTimezone: false }; const applySetting = { geolocation: (geolocation) => { if (geolocation) { - docShell.setGeolocationOverride({ + browsingContext.setGeolocationServiceOverride({ coords: { latitude: geolocation.latitude, longitude: geolocation.longitude, @@ -25,14 +31,12 @@ function initialize(browsingContext, docShell) { heading: NaN, speed: NaN, }, - address: null, - timestamp: Date.now() + timestamp: Date.now() + 24 * 60 * 60 * 1000, // Make sure it does not expire for a day. }); } else { - docShell.setGeolocationOverride(null); + browsingContext.setGeolocationServiceOverride(); } }, - bypassCSP: (bypassCSP) => { docShell.bypassCSPEnabled = bypassCSP; }, @@ -95,16 +99,20 @@ function initialize(browsingContext, docShell) { }, async awaitViewportDimensions({width, height}) { - const win = docShell.domWindow; - if (win.innerWidth === width && win.innerHeight === height) - return; await new Promise(resolve => { - const listener = helper.addEventListener(win, 'resize', () => { - if (win.innerWidth === width && win.innerHeight === height) { - helper.removeListeners([listener]); + const listeners = []; + const check = () => { + helper.removeListeners(listeners); + if (docShell.domWindow.innerWidth === width && docShell.domWindow.innerHeight === height) { resolve(); + return; } - }); + // Note: "domWindow" listeners are often removed upon navigation, as specced. + // To survive viewport changes across navigations, re-install listeners upon commit. + listeners.push(helper.addEventListener(docShell.domWindow, 'resize', check)); + listeners.push(helper.addEventListener(data.frameTree, 'navigationcommitted', check)); + }; + check(); }); }, @@ -114,6 +122,3 @@ function initialize(browsingContext, docShell) { return data; } - -var EXPORTED_SYMBOLS = ['initialize']; -this.initialize = initialize; diff --git a/additions/juggler/jar.mn b/additions/juggler/jar.mn index 7f3ecf5..b21b33b 100644 --- a/additions/juggler/jar.mn +++ b/additions/juggler/jar.mn @@ -9,6 +9,7 @@ juggler.jar: content/Helper.js (Helper.js) content/NetworkObserver.js (NetworkObserver.js) + content/ChannelEventSink.sys.mjs (ChannelEventSink.sys.mjs) content/TargetRegistry.js (TargetRegistry.js) content/SimpleChannel.js (SimpleChannel.js) content/JugglerFrameParent.jsm (JugglerFrameParent.jsm) diff --git a/additions/juggler/protocol/BrowserHandler.js b/additions/juggler/protocol/BrowserHandler.js index 81aeee4..970d8f8 100644 --- a/additions/juggler/protocol/BrowserHandler.js +++ b/additions/juggler/protocol/BrowserHandler.js @@ -4,15 +4,16 @@ "use strict"; -const {AddonManager} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm"); -const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); -const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {PageHandler} = ChromeUtils.import("chrome://juggler/content/protocol/PageHandler.js"); -const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); +const {AddonManager} = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); +const {XPIProvider} = ChromeUtils.importESModule("resource://gre/modules/addons/XPIProvider.sys.mjs"); +const {TargetRegistry} = ChromeUtils.importESModule("chrome://juggler/content/TargetRegistry.js"); +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const {PageHandler} = ChromeUtils.importESModule("chrome://juggler/content/protocol/PageHandler.js"); +const {AppConstants} = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); const helper = new Helper(); -class BrowserHandler { +export class BrowserHandler { constructor(session, dispatcher, targetRegistry, startCompletePromise, onclose) { this._session = session; this._dispatcher = dispatcher; @@ -147,6 +148,10 @@ class BrowserHandler { ]); } await this._startCompletePromise; + await Promise.all([ + ...XPIProvider.startupPromises, + ...XPIProvider.enabledAddonsStartupPromises, + ]); this._onclose(); Services.startup.quit(Ci.nsIAppStartup.eForceQuit); } @@ -166,7 +171,9 @@ class BrowserHandler { ['Browser.clearCache']() { // Clearing only the context cache does not work: https://bugzilla.mozilla.org/show_bug.cgi?id=1819147 Services.cache2.clear(); - ChromeUtils.clearStyleSheetCache(); + ChromeUtils.clearResourceCache({ + types: ["stylesheet"], + }); } ['Browser.setHTTPCredentials']({browserContextId, credentials}) { @@ -220,7 +227,7 @@ class BrowserHandler { } async ['Browser.setContrast']({browserContextId, contrast}) { - return; // TODO: Implement + await this._targetRegistry.browserContextForId(browserContextId).setContrast(nullToUndefined(contrast)); } async ['Browser.setVideoRecordingOptions']({browserContextId, options}) { @@ -313,6 +320,3 @@ async function waitForWindowClosed(browserWindow) { function nullToUndefined(value) { return value === null ? undefined : value; } - -var EXPORTED_SYMBOLS = ['BrowserHandler']; -this.BrowserHandler = BrowserHandler; diff --git a/additions/juggler/protocol/Dispatcher.js b/additions/juggler/protocol/Dispatcher.js index cf24b89..7a65d0a 100644 --- a/additions/juggler/protocol/Dispatcher.js +++ b/additions/juggler/protocol/Dispatcher.js @@ -2,14 +2,13 @@ * 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 {protocol, checkScheme} = ChromeUtils.import("chrome://juggler/content/protocol/Protocol.js"); -const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {protocol} = ChromeUtils.importESModule("chrome://juggler/content/protocol/Protocol.js"); +const {checkScheme} = ChromeUtils.importESModule("chrome://juggler/content/protocol/PrimitiveTypes.js"); +const {Helper} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); const helper = new Helper(); -// Camoufox: Exclude redundant internal events from logs. -const EXCLUDED_DBG = ['Page.navigationStarted', 'Page.frameAttached', 'Runtime.executionContextCreated', 'Runtime.console', 'Page.navigationAborted', 'Page.eventFired']; -class Dispatcher { +export class Dispatcher { /** * @param {Connection} connection */ @@ -46,11 +45,6 @@ class Dispatcher { async _dispatch(event) { const data = JSON.parse(event.data); - - if (ChromeUtils.isCamouDebug()) - ChromeUtils.camouDebug(`[${new Date().toLocaleString()}]` - + `\nReceived message: ${safeJsonStringify(data)}`); - const id = data.id; const sessionId = data.sessionId; delete data.sessionId; @@ -93,13 +87,6 @@ class Dispatcher { _emitEvent(sessionId, eventName, params) { const [domain, eName] = eventName.split('.'); - - // Camoufox: Log internal events - if (ChromeUtils.isCamouDebug() && !EXCLUDED_DBG.includes(eventName) && domain !== 'Network') { - ChromeUtils.camouDebug(`[${new Date().toLocaleString()}]` - + `\nInternal event: ${eventName}\nParams: ${JSON.stringify(params, null, 2)}`); - } - const scheme = protocol.domains[domain] ? protocol.domains[domain].events[eName] : null; if (!scheme) throw new Error(`ERROR: event '${eventName}' is not supported`); @@ -146,48 +133,3 @@ class ProtocolSession { return await this._handler[method](params); } } - -this.EXPORTED_SYMBOLS = ['Dispatcher']; -this.Dispatcher = Dispatcher; - - -function formatDate(date) { - const pad = (num) => String(num).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; -} - -function truncateObject(obj, maxDepth = 8, maxLength = 100) { - if (maxDepth < 0) return '[Max Depth Reached]'; - - if (typeof obj !== 'object' || obj === null) { - return typeof obj === 'string' ? truncateString(obj, maxLength) : obj; - } - - if (Array.isArray(obj)) { - return obj.slice(0, 10).map(item => truncateObject(item, maxDepth - 1, maxLength)); - } - - const truncated = {}; - for (const [key, value] of Object.entries(obj)) { - if (Object.keys(truncated).length >= 10) { - truncated['...'] = '[Truncated]'; - break; - } - truncated[key] = truncateObject(value, maxDepth - 1, maxLength); - } - return truncated; -} - -function truncateString(str, maxLength) { - if (str.length <= maxLength) return str; - ChromeUtils.camouDebug(`String length: ${str.length}`); - return str.substr(0, maxLength) + '... [truncated]'; -} - -function safeJsonStringify(data) { - try { - return JSON.stringify(truncateObject(data), null, 2); - } catch (error) { - return `[Unable to stringify: ${error.message}]`; - } -} \ No newline at end of file diff --git a/additions/juggler/protocol/PageHandler.js b/additions/juggler/protocol/PageHandler.js index 213ede0..1dab7d8 100644 --- a/additions/juggler/protocol/PageHandler.js +++ b/additions/juggler/protocol/PageHandler.js @@ -1,14 +1,14 @@ /* 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/. */ + * 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 {Helper, EventWatcher} = ChromeUtils.import('chrome://juggler/content/Helper.js'); -const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); -const {NetworkObserver, PageNetwork} = ChromeUtils.import('chrome://juggler/content/NetworkObserver.js'); -const {PageTarget} = ChromeUtils.import('chrome://juggler/content/TargetRegistry.js'); -const {setTimeout} = ChromeUtils.import('resource://gre/modules/Timer.jsm'); +const {Helper, EventWatcher} = ChromeUtils.importESModule('chrome://juggler/content/Helper.js'); +const {NetUtil} = ChromeUtils.importESModule('resource://gre/modules/NetUtil.sys.mjs'); +const {NetworkObserver, PageNetwork} = ChromeUtils.importESModule('chrome://juggler/content/NetworkObserver.js'); +const {PageTarget} = ChromeUtils.importESModule('chrome://juggler/content/TargetRegistry.js'); +const {setTimeout} = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs'); const Cc = Components.classes; const Ci = Components.interfaces; @@ -65,7 +65,7 @@ class WorkerHandler { } } -class PageHandler { +export class PageHandler { constructor(target, session, contentChannel) { this._session = session; this._contentChannel = contentChannel; @@ -80,17 +80,7 @@ class PageHandler { } this._isDragging = false; - - // Camoufox: set a random default cursor position - let random_val = (max_val) => Math.floor(Math.random() * max_val); - - // Try to fetch the viewport size - this._defaultCursorPos = { - x: random_val(this._pageTarget._viewportSize?.width || 1280), - y: random_val(this._pageTarget._viewportSize?.height || 720), - }; - this._lastMousePosition = { ...this._defaultCursorPos }; - this._lastTrackedPos = { ...this._defaultCursorPos }; + this._lastMousePosition = { x: 0, y: 0 }; this._reportedFrameIds = new Set(); this._networkEventsForUnreportedFrameIds = new Map(); @@ -250,6 +240,10 @@ class PageHandler { await this._pageTarget.setViewportSize(viewportSize === null ? undefined : viewportSize); } + async ['Page.setZoom']({zoom}) { + await this._pageTarget.setZoom(zoom); + } + async ['Runtime.evaluate'](options) { return await this._contentPage.send('evaluate', options); } @@ -308,10 +302,11 @@ class PageHandler { return await this._contentPage.send('setFileInputFiles', options); } - async ['Page.setEmulatedMedia']({colorScheme, type, reducedMotion, forcedColors}) { + async ['Page.setEmulatedMedia']({colorScheme, type, reducedMotion, forcedColors, contrast}) { this._pageTarget.setColorScheme(colorScheme || null); this._pageTarget.setReducedMotion(reducedMotion || null); this._pageTarget.setForcedColors(forcedColors || null); + this._pageTarget.setContrast(contrast || null); this._pageTarget.setEmulatedMedia(type); } @@ -432,14 +427,6 @@ class PageHandler { }); unsubscribe(); - if (ChromeUtils.camouGetBool('memorysaver', false)) { - ChromeUtils.camouDebug('Clearing all memory...'); - Services.obs.notifyObservers(null, "child-gc-request"); - Cu.forceGC(); - Services.obs.notifyObservers(null, "child-cc-request"); - Cu.forceCC(); - } - return { navigationId: sameDocumentNavigation ? null : navigationId, }; @@ -518,43 +505,28 @@ class PageHandler { await helper.awaitTopic('apz-repaints-flushed'); const watcher = new EventWatcher(this._pageEventSink, types, this._pendingEventWatchers); - const sendMouseEvent = async (eventType, eventX, eventY) => { + const promises = []; + for (const type of types) { + // This dispatches to the renderer synchronously. const jugglerEventId = win.windowUtils.jugglerSendMouseEvent( - eventType, - eventX + boundingBox.left, - eventY + boundingBox.top, + type, + x + boundingBox.left, + y + boundingBox.top, button, clickCount, modifiers, false /* aIgnoreRootScrollFrame */, 0.0 /* pressure */, 0 /* inputSource */, - false /* isDOMEventSynthesized */, + true /* isDOMEventSynthesized */, false /* isWidgetEventSynthesized */, buttons, win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, false /* disablePointerEvent */ ); - await watcher.ensureEvent(eventType, eventObject => eventObject.jugglerEventId === jugglerEventId); - }; - for (const type of types) { - if (type === 'mousemove' && ChromeUtils.camouGetBool('humanize', false)) { - let trajectory = ChromeUtils.camouGetMouseTrajectory(this._lastTrackedPos.x, this._lastTrackedPos.y, x, y); - for (let i = 2; i < trajectory.length - 2; i += 2) { - let currentX = trajectory[i]; - let currentY = trajectory[i + 1]; - // Skip movement that is out of bounds - if (currentX < 0 || currentY < 0 || currentX > boundingBox.width || currentY > boundingBox.height) { - continue; - } - await sendMouseEvent(type, currentX, currentY); - await new Promise(resolve => setTimeout(resolve, 10)); - } - } else { - // Call the function for the current event - await sendMouseEvent(type, x, y); - } + promises.push(watcher.ensureEvent(type, eventObject => eventObject.jugglerEventId === jugglerEventId)); } + await Promise.all(promises); await watcher.dispose(); }; @@ -575,15 +547,15 @@ class PageHandler { // NOTE: since this won't go inside the renderer, there's no need to wait for ACK. win.windowUtils.sendMouseEvent( 'mousemove', - this._defaultCursorPos.x, - this._defaultCursorPos.y, + 0 /* x */, + 0 /* y */, button, clickCount, modifiers, false /* aIgnoreRootScrollFrame */, 0.0 /* pressure */, 0 /* inputSource */, - false /* isDOMEventSynthesized */, + true /* isDOMEventSynthesized */, false /* isWidgetEventSynthesized */, buttons, win.windowUtils.DEFAULT_MOUSE_POINTER_ID /* pointerIdentifier */, @@ -612,7 +584,6 @@ class PageHandler { const watcher = new EventWatcher(this._pageEventSink, ['dragstart', 'juggler-drag-finalized'], this._pendingEventWatchers); await sendEvents(['mousemove']); - this._lastTrackedPos = { x, y }; // The order of events after 'mousemove' is sent: // 1. [dragstart] - might or might NOT be emitted @@ -720,6 +691,3 @@ class PageHandler { return await worker.sendMessage(JSON.parse(message)); } } - -var EXPORTED_SYMBOLS = ['PageHandler']; -this.PageHandler = PageHandler; diff --git a/additions/juggler/protocol/PrimitiveTypes.js b/additions/juggler/protocol/PrimitiveTypes.js index 5799038..ca6d1ec 100644 --- a/additions/juggler/protocol/PrimitiveTypes.js +++ b/additions/juggler/protocol/PrimitiveTypes.js @@ -2,7 +2,7 @@ * 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 t = {}; +export const t = {}; t.String = function(x, details = {}, path = ['']) { if (typeof x === 'string' || typeof x === 'String') @@ -96,7 +96,7 @@ function beauty(path, obj) { return `property "${path.join('.')}" - ${JSON.stringify(obj, null, 2)}`; } -function checkScheme(scheme, x, details = {}, path = ['']) { +export function checkScheme(scheme, x, details = {}, path = ['']) { if (!scheme) throw new Error(`ILLDEFINED SCHEME: ${path.join('.')}`); if (typeof scheme === 'object') { @@ -142,6 +142,3 @@ test(t.Either(t.String, t.Number), {}); */ -this.t = t; -this.checkScheme = checkScheme; -this.EXPORTED_SYMBOLS = ['t', 'checkScheme']; diff --git a/additions/juggler/protocol/Protocol.js b/additions/juggler/protocol/Protocol.js index 08ca173..4cb44d6 100644 --- a/additions/juggler/protocol/Protocol.js +++ b/additions/juggler/protocol/Protocol.js @@ -2,7 +2,7 @@ * 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 {t, checkScheme} = ChromeUtils.import('chrome://juggler/content/protocol/PrimitiveTypes.js'); +const {t} = ChromeUtils.importESModule('chrome://juggler/content/protocol/PrimitiveTypes.js'); // Protocol-specific types. const browserTypes = {}; @@ -800,6 +800,11 @@ const Page = { viewportSize: t.Nullable(pageTypes.Size), }, }, + 'setZoom': { + params: { + zoom: t.Number, + }, + }, 'bringToFront': { params: { }, @@ -810,6 +815,7 @@ const Page = { colorScheme: t.Optional(t.Enum(['dark', 'light', 'no-preference'])), reducedMotion: t.Optional(t.Enum(['reduce', 'no-preference'])), forcedColors: t.Optional(t.Enum(['active', 'none'])), + contrast: t.Optional(t.Enum(['less', 'more', 'custom', 'no-preference'])), }, }, 'setCacheDisabled': { @@ -1012,8 +1018,6 @@ const Accessibility = { } } -this.protocol = { +export const protocol = { domains: {Browser, Heap, Page, Runtime, Network, Accessibility}, }; -this.checkScheme = checkScheme; -this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; diff --git a/additions/juggler/screencast/nsScreencastService.cpp b/additions/juggler/screencast/nsScreencastService.cpp index 062a851..fa9d1a9 100644 --- a/additions/juggler/screencast/nsScreencastService.cpp +++ b/additions/juggler/screencast/nsScreencastService.cpp @@ -4,6 +4,7 @@ #include "nsScreencastService.h" +#include "gfxPlatform.h" #include "ScreencastEncoder.h" #include "HeadlessWidget.h" #include "HeadlessWindowCapturer.h" @@ -343,10 +344,17 @@ nsresult nsScreencastService::StartVideoRecording(nsIScreencastServiceClient* aC return NS_ERROR_FAILURE; gfx::IntMargin margin; - auto bounds = widget->GetScreenBounds().ToUnknownRect(); + // Screen bounds is the widget location on screen. + auto screenBounds = widget->GetScreenBounds().ToUnknownRect(); + // Client bounds is the content location, in terms of parent widget. + // To use it, we need to translate it to screen coordinates first. auto clientBounds = widget->GetClientBounds().ToUnknownRect(); + for (auto parent = widget->GetParent(); parent != nullptr; parent = parent->GetParent()) { + auto pb = parent->GetClientBounds().ToUnknownRect(); + clientBounds.MoveBy(pb.X(), pb.Y()); + } // Crop the image to exclude frame (if any). - margin = bounds - clientBounds; + margin = screenBounds - clientBounds; // Crop the image to exclude controls. margin.top += offsetTop; diff --git a/docs/playwright-maintenance.md b/docs/playwright-maintenance.md new file mode 100644 index 0000000..a4aeb92 --- /dev/null +++ b/docs/playwright-maintenance.md @@ -0,0 +1,165 @@ +# Playwright Maintenance Guide + +This document describes how to maintain Playwright integration in Camoufox. + +## Overview + +Camoufox integrates Playwright's browser automation capabilities through patches and additional files. These need to be kept in sync with upstream Playwright development. + +## Patch Files + +Location: `patches/playwright/` + +| File | Purpose | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0-playwright.patch` | Playwright's upstream patches. Must be kept up to date with [bootstrap.diff](https://github.com/microsoft/playwright/blob/main/browser_patches/firefox/patches/bootstrap.diff) | +| `1-leak-fixes.patch` | Undos certain patches from `0-playwright.patch` to fix memory leaks | + +## Addition Files + +Location: `additions/juggler/` + +The `juggler` directory contains Playwright's Juggler protocol implementation. These files must be kept in sync with: + +**Upstream Source:** https://github.com/microsoft/playwright/tree/main/browser_patches/firefox/juggler + +### Key Files + +- **`components/Juggler.js`** - Main Juggler component (legacy JSM format) +- **`components/Juggler.sys.mjs`** - ESM wrapper for Firefox 146+ compatibility +- **`components/components.conf`** - XPCOM component registration + +### Firefox 146 ESM Migration + +Firefox 146 removed JSM (JavaScript Module) support in favor of ESM (ES Modules). To maintain compatibility: + +1. **`components.conf`** uses `esModule` field instead of deprecated `jsm` field: + ```python + { + "esModule": "chrome://juggler/content/components/Juggler.sys.mjs", + "constructor": "JugglerFactory", + } + ``` + +2. **`Juggler.sys.mjs`** acts as an ESM wrapper that imports the legacy JSM file: + ```javascript + const { JugglerFactory } = ChromeUtils.import( + "chrome://juggler/content/components/Juggler.js" + ); + export { JugglerFactory }; + ``` + +This maintains backward compatibility while satisfying Firefox 146's static component generator requirements. + +## Updating Playwright Integration + +### 1. Update Upstream Patches + +Compare the current `patches/playwright/0-playwright.patch` with Playwright's [bootstrap.diff](https://github.com/microsoft/playwright/blob/main/browser_patches/firefox/patches/bootstrap.diff). + +If changes are needed: +```bash +# Download latest bootstrap.diff +curl -o patches/playwright/0-playwright.patch \ + https://raw.githubusercontent.com/microsoft/playwright/main/browser_patches/firefox/patches/bootstrap.diff + +# Test the build +make clean && make dir && make build +``` + +### 2. Update Juggler Files + +Sync `additions/juggler/` with upstream: + +```bash +# Clone Playwright repository +git clone https://github.com/microsoft/playwright.git /tmp/playwright + +# Compare directories +diff -r additions/juggler/ /tmp/playwright/browser_patches/firefox/juggler/ + +# Copy updated files (example) +cp -r /tmp/playwright/browser_patches/firefox/juggler/* additions/juggler/ + +# IMPORTANT: Preserve Firefox 146 ESM compatibility +# - Keep additions/juggler/components/Juggler.sys.mjs +# - Keep additions/juggler/components/components.conf with esModule field +``` + +### 3. Verify ESM Wrapper Compatibility + +After updating from upstream, ensure the ESM wrapper remains functional: + +1. Check that `Juggler.js` still exports `JugglerFactory`: + ```javascript + var EXPORTED_SYMBOLS = ["Juggler", "JugglerFactory"]; + var JugglerFactory = function() { /* ... */ }; + ``` + +2. If upstream changed the export name, update `Juggler.sys.mjs` accordingly. + +3. Verify `components.conf` matches the format above (not upstream's format). + +### 4. Test Build + +```bash +# Clean build to verify component registration +cd camoufox-146.0.1-beta.25 +make clean +cd .. +make dir +cd camoufox-146.0.1-beta.25 +./mach build +``` + +**Expected:** No linker errors about `mozCreateComponent`. + +**Common Error:** If you see: +``` +ld64.lld: error: undefined symbol: already_AddRefed mozCreateComponent() +``` + +This means `components.conf` is not using ESM format. Fix by ensuring it has: +- `"esModule"` field (not `"jsm"`) +- `"constructor": "JugglerFactory"` field (not `"type"`) + +## Troubleshooting + +### Component Registration Errors + +**Error:** `Externally-constructed components may not specify 'constructor' or 'legacy_constructor' properties` + +**Cause:** Using `"jsm"` field which is unsupported in Firefox 146. + +**Fix:** Use `"esModule"` field instead. + +--- + +**Error:** `Externally-constructed components must specify a type other than nsISupports` + +**Cause:** Using external component without proper type specification. + +**Fix:** Convert to ESM component with constructor. + +--- + +**Error:** `JavaScript components must specify a constructor` + +**Cause:** ESM component missing constructor field. + +**Fix:** Add `"constructor": "JugglerFactory"` to components.conf. + +### Build Failures + +If the build fails after updating Juggler files: + +1. Check that all JSM imports in `Juggler.js` are still valid +2. Verify the ESM wrapper exports match what's imported +3. Ensure no file paths changed in upstream +4. Check for Firefox API changes that might require patches + +## References + +- [Playwright Firefox Patches](https://github.com/microsoft/playwright/tree/main/browser_patches/firefox) +- [Firefox 146 Component Registration](https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/basics.html) +- [Firefox ESM Migration Guide](https://firefox-source-docs.mozilla.org/dom/script_loader/index.html)